Skip to content

Commit a1630c6

Browse files
Test/timeline (#93)
* test(timeline): add for you unit tests * test(timeline): add controller unit tests * ci(ci-check): solve npm i errors
1 parent 6e105c9 commit a1630c6

File tree

10 files changed

+1123
-730
lines changed

10 files changed

+1123
-730
lines changed
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { getRepositoryToken } from '@nestjs/typeorm';
3+
import { Repository, SelectQueryBuilder } from 'typeorm';
4+
import { TweetsRepository } from 'src/tweets/tweets.repository';
5+
import { UserPostsView } from 'src/tweets/entities/user-posts-view.entity';
6+
import { PaginationService } from 'src/shared/services/pagination/pagination.service';
7+
import { ScoredCandidateDTO } from 'src/timeline/dto/scored-candidates.dto';
8+
import { InterestsCandidateSource } from './interests-source';
9+
10+
describe('InterestsCandidateSource', () => {
11+
let service: InterestsCandidateSource;
12+
let user_posts_view_repository: Repository<UserPostsView>;
13+
let tweets_repository: TweetsRepository;
14+
let pagination_service: PaginationService;
15+
let query_builder: jest.Mocked<SelectQueryBuilder<UserPostsView>>;
16+
17+
const mock_user_id = 'user-123';
18+
const mock_cursor = 'cursor-abc';
19+
const mock_limit = 20;
20+
21+
const mock_tweet_data = [
22+
{
23+
tweet_id: 'tweet-1',
24+
profile_user_id: 'profile-1',
25+
tweet_author_id: 'author-1',
26+
repost_id: null,
27+
post_type: 'original',
28+
type: 'tweet',
29+
content: 'Test tweet content',
30+
post_date: new Date('2024-01-01'),
31+
images: [],
32+
videos: [],
33+
num_likes: 10,
34+
num_reposts: 5,
35+
num_views: 100,
36+
num_quotes: 2,
37+
num_replies: 3,
38+
created_at: new Date('2024-01-01'),
39+
updated_at: new Date('2024-01-01'),
40+
user: {
41+
id: 'author-1',
42+
username: 'testuser',
43+
name: 'Test User',
44+
avatar_url: 'http://example.com/avatar.jpg',
45+
cover_url: 'http://example.com/cover.jpg',
46+
verified: true,
47+
bio: 'Test bio',
48+
followers: 1000,
49+
following: 500,
50+
},
51+
},
52+
];
53+
54+
beforeEach(async () => {
55+
// Create mock query builder
56+
query_builder = {
57+
select: jest.fn().mockReturnThis(),
58+
innerJoin: jest.fn().mockReturnThis(),
59+
where: jest.fn().mockReturnThis(),
60+
andWhere: jest.fn().mockReturnThis(),
61+
orderBy: jest.fn().mockReturnThis(),
62+
limit: jest.fn().mockReturnThis(),
63+
setParameter: jest.fn().mockReturnThis(),
64+
getRawMany: jest.fn().mockResolvedValue(mock_tweet_data),
65+
} as any;
66+
67+
const module: TestingModule = await Test.createTestingModule({
68+
providers: [
69+
InterestsCandidateSource,
70+
{
71+
provide: TweetsRepository,
72+
useValue: {
73+
attachUserInteractionBooleanFlags: jest.fn().mockReturnValue(query_builder),
74+
attachRepostInfo: jest.fn().mockReturnValue(query_builder),
75+
attachUserFollowFlags: jest.fn().mockImplementation((tweets) => tweets),
76+
},
77+
},
78+
{
79+
provide: getRepositoryToken(UserPostsView),
80+
useValue: {
81+
createQueryBuilder: jest.fn().mockReturnValue(query_builder),
82+
},
83+
},
84+
{
85+
provide: PaginationService,
86+
useValue: {
87+
applyCursorPagination: jest.fn().mockReturnValue(query_builder),
88+
generateNextCursor: jest.fn().mockReturnValue('next-cursor'),
89+
},
90+
},
91+
],
92+
}).compile();
93+
94+
service = module.get<InterestsCandidateSource>(InterestsCandidateSource);
95+
user_posts_view_repository = module.get<Repository<UserPostsView>>(
96+
getRepositoryToken(UserPostsView)
97+
);
98+
tweets_repository = module.get<TweetsRepository>(TweetsRepository);
99+
pagination_service = module.get<PaginationService>(PaginationService);
100+
});
101+
102+
afterEach(() => {
103+
jest.clearAllMocks();
104+
});
105+
106+
it('should be defined', () => {
107+
expect(service).toBeDefined();
108+
});
109+
110+
describe('getCandidates', () => {
111+
it('should return candidates with interest-based tweets', async () => {
112+
const result = await service.getCandidates(mock_user_id, mock_cursor, mock_limit);
113+
114+
expect(result).toHaveProperty('data');
115+
expect(result).toHaveProperty('pagination');
116+
expect(result.data).toBeInstanceOf(Array);
117+
expect(result.data.length).toBe(mock_tweet_data.length);
118+
expect(result.pagination.next_cursor).toBe('next-cursor');
119+
expect(result.pagination.has_more).toBe(false);
120+
});
121+
122+
it('should build query with correct joins and filters', async () => {
123+
await service.getCandidates(mock_user_id, mock_cursor, mock_limit);
124+
125+
expect(user_posts_view_repository.createQueryBuilder).toHaveBeenCalledWith('tweet');
126+
expect(query_builder.select).toHaveBeenCalled();
127+
expect(query_builder.innerJoin).toHaveBeenCalledWith(
128+
'tweet_categories',
129+
'tc',
130+
'tc.tweet_id = tweet.tweet_id'
131+
);
132+
expect(query_builder.innerJoin).toHaveBeenCalledWith(
133+
'user_interests',
134+
'ui',
135+
'ui.category_id = tc.category_id AND ui.user_id = :user_id',
136+
{ user_id: mock_user_id }
137+
);
138+
});
139+
140+
it('should filter out muted and blocked users', async () => {
141+
await service.getCandidates(mock_user_id, mock_cursor, mock_limit);
142+
143+
expect(query_builder.where).toHaveBeenCalledWith(
144+
'tweet.tweet_author_id NOT IN (SELECT muted_id FROM user_mutes WHERE muter_id = :user_id)',
145+
{ user_id: mock_user_id }
146+
);
147+
expect(query_builder.andWhere).toHaveBeenCalledWith(
148+
'tweet.tweet_author_id NOT IN (SELECT blocked_id FROM user_blocks WHERE blocker_id = :user_id)',
149+
{ user_id: mock_user_id }
150+
);
151+
});
152+
153+
it('should apply default ordering by post_date DESC', async () => {
154+
await service.getCandidates(mock_user_id, mock_cursor, mock_limit);
155+
156+
expect(query_builder.orderBy).toHaveBeenCalledWith('tweet.post_date', 'DESC');
157+
});
158+
159+
it('should apply limit correctly', async () => {
160+
await service.getCandidates(mock_user_id, mock_cursor, mock_limit);
161+
162+
expect(query_builder.limit).toHaveBeenCalledWith(mock_limit);
163+
});
164+
165+
it('should attach user interaction flags', async () => {
166+
await service.getCandidates(mock_user_id, mock_cursor, mock_limit);
167+
168+
expect(tweets_repository.attachUserInteractionBooleanFlags).toHaveBeenCalledWith(
169+
query_builder,
170+
mock_user_id,
171+
'tweet.tweet_author_id',
172+
'tweet.tweet_id'
173+
);
174+
});
175+
176+
it('should attach repost info', async () => {
177+
await service.getCandidates(mock_user_id, mock_cursor, mock_limit);
178+
179+
expect(tweets_repository.attachRepostInfo).toHaveBeenCalledWith(query_builder);
180+
});
181+
182+
it('should apply cursor pagination', async () => {
183+
await service.getCandidates(mock_user_id, mock_cursor, mock_limit);
184+
185+
expect(pagination_service.applyCursorPagination).toHaveBeenCalledWith(
186+
query_builder,
187+
mock_cursor,
188+
'tweet',
189+
'post_date',
190+
'tweet_id'
191+
);
192+
});
193+
194+
it('should fall back to random tweets when no interest-based tweets found', async () => {
195+
query_builder.getRawMany
196+
.mockResolvedValueOnce([])
197+
.mockResolvedValueOnce(mock_tweet_data);
198+
199+
const result = await service.getCandidates(mock_user_id, mock_cursor, mock_limit);
200+
201+
expect(query_builder.orderBy).toHaveBeenCalledWith('RANDOM()');
202+
expect(result.data.length).toBe(mock_tweet_data.length);
203+
});
204+
205+
it('should not include innerJoin for interests in fallback query', async () => {
206+
query_builder.getRawMany
207+
.mockResolvedValueOnce([])
208+
.mockResolvedValueOnce(mock_tweet_data);
209+
210+
await service.getCandidates(mock_user_id, mock_cursor, mock_limit);
211+
212+
const inner_join_calls = (query_builder.innerJoin as jest.Mock).mock.calls;
213+
const fallback_calls = inner_join_calls.slice(2); // After first query
214+
215+
expect(fallback_calls.length).toBe(0);
216+
});
217+
218+
it('should attach user follow flags to results', async () => {
219+
await service.getCandidates(mock_user_id, mock_cursor, mock_limit);
220+
221+
expect(tweets_repository.attachUserFollowFlags).toHaveBeenCalledWith(mock_tweet_data);
222+
});
223+
224+
it('should generate next cursor from results', async () => {
225+
await service.getCandidates(mock_user_id, mock_cursor, mock_limit);
226+
227+
expect(pagination_service.generateNextCursor).toHaveBeenCalledWith(
228+
mock_tweet_data,
229+
'post_date',
230+
'tweet_id'
231+
);
232+
});
233+
234+
it('should set has_more to true when results equal limit', async () => {
235+
const full_results = Array(20).fill(mock_tweet_data[0]);
236+
query_builder.getRawMany.mockResolvedValue(full_results);
237+
238+
const result = await service.getCandidates(mock_user_id, mock_cursor, 20);
239+
240+
expect(result.pagination.has_more).toBe(true);
241+
});
242+
243+
it('should set has_more to false when results less than limit', async () => {
244+
const result = await service.getCandidates(mock_user_id, mock_cursor, mock_limit);
245+
246+
expect(result.pagination.has_more).toBe(false);
247+
});
248+
249+
it('should use default limit of 20 when not provided', async () => {
250+
await service.getCandidates(mock_user_id, mock_cursor);
251+
252+
expect(query_builder.limit).toHaveBeenCalledWith(20);
253+
});
254+
255+
it('should work without cursor parameter', async () => {
256+
const result = await service.getCandidates(mock_user_id);
257+
258+
expect(result).toHaveProperty('data');
259+
expect(result).toHaveProperty('pagination');
260+
expect(pagination_service.applyCursorPagination).toHaveBeenCalledWith(
261+
query_builder,
262+
undefined,
263+
'tweet',
264+
'post_date',
265+
'tweet_id'
266+
);
267+
});
268+
269+
it('should return empty data array when no tweets found in both queries', async () => {
270+
query_builder.getRawMany.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
271+
272+
const result = await service.getCandidates(mock_user_id, mock_cursor, mock_limit);
273+
274+
expect(result.data).toEqual([]);
275+
expect(result.pagination.has_more).toBe(false);
276+
});
277+
278+
it('should log the number of tweets found', async () => {
279+
const console_spy = jest.spyOn(console, 'log').mockImplementation();
280+
281+
await service.getCandidates(mock_user_id, mock_cursor, mock_limit);
282+
283+
expect(console_spy).toHaveBeenCalledWith(mock_tweet_data.length);
284+
285+
console_spy.mockRestore();
286+
});
287+
});
288+
});
Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +0,0 @@
1-
import { Injectable } from '@nestjs/common';
2-
import { TweetsRepository } from 'src/tweets/tweets.repository';
3-
import { DataSource } from 'typeorm';
4-
5-
@Injectable()
6-
export class InNetworkCandidateSource {
7-
constructor(private tweets_repository: TweetsRepository) {}
8-
9-
async getCandidates(user_id: string, limit: number = 100) {
10-
const result = await this.tweets_repository.getFollowingTweets(
11-
user_id,
12-
undefined,
13-
limit,
14-
48
15-
);
16-
17-
//TODO: Response from all candidates
18-
19-
return result.data.map((tweet) => ({
20-
...tweet,
21-
source: 'in_network',
22-
}));
23-
}
24-
}

0 commit comments

Comments
 (0)