Skip to content

Commit 06541f2

Browse files
authored
feat: daily gql data (#3947)
1 parent af7b7c8 commit 06541f2

15 files changed

Lines changed: 1287 additions & 8 deletions

__tests__/feeds.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import {
3535
} from '../src/entity';
3636
import { PollOption } from '../src/entity/polls/PollOption';
3737
import { SourceMemberRoles } from '../src/roles';
38+
import { snotraClient } from '../src/integrations/snotra/clients';
39+
import { PersonaliseState } from '../src/integrations/snotra/types';
3840
import { Category } from '../src/entity/Category';
3941
import { Persona } from '../src/entity/Persona';
4042
import { FastifyInstance } from 'fastify';
@@ -1456,6 +1458,101 @@ describe('query feedV2', () => {
14561458
});
14571459
});
14581460

1461+
describe('query dailyFeed', () => {
1462+
const variables = { first: 10 };
1463+
1464+
const QUERY = `
1465+
query DailyFeed($first: Int, $after: String, $ranking: Ranking, $version: Int, $unreadOnly: Boolean, $supportedTypes: [String!]) {
1466+
dailyFeed(first: $first, after: $after, ranking: $ranking, version: $version, unreadOnly: $unreadOnly, supportedTypes: $supportedTypes) {
1467+
${feedFields()}
1468+
}
1469+
}
1470+
`;
1471+
1472+
afterEach(() => jest.restoreAllMocks());
1473+
1474+
it('should not authorize when not logged-in', () =>
1475+
testQueryErrorCode(client, { query: QUERY, variables }, 'UNAUTHENTICATED'));
1476+
1477+
it('should fetch the daily feed with the personalised config for personalised users', async () => {
1478+
loggedUser = '1';
1479+
await saveFeedFixtures();
1480+
1481+
jest.spyOn(snotraClient, 'getUserProfile').mockResolvedValue({
1482+
personalise: { state: PersonaliseState.Personalised },
1483+
});
1484+
nock('http://localhost:6000')
1485+
.post('/api/daily-posts', (body) => {
1486+
expect(body.feed_config_name).toEqual('daily_v1');
1487+
return true;
1488+
})
1489+
.reply(200, {
1490+
data: [{ post_id: 'p1' }, { post_id: 'p4' }],
1491+
cursor: 'next-cursor',
1492+
});
1493+
1494+
const res = await client.query(QUERY, { variables });
1495+
1496+
expect(res.errors).toBeFalsy();
1497+
expect(res.data.dailyFeed.pageInfo.endCursor).toEqual('next-cursor');
1498+
expect(res.data.dailyFeed.edges.map((edge) => edge.node.id)).toEqual([
1499+
'p1',
1500+
'p4',
1501+
]);
1502+
});
1503+
1504+
it('should fetch the daily feed with the cold start config for non-personalised users', async () => {
1505+
loggedUser = '1';
1506+
await saveFeedFixtures();
1507+
1508+
jest.spyOn(snotraClient, 'getUserProfile').mockResolvedValue({
1509+
personalise: { state: PersonaliseState.NonPersonalised },
1510+
});
1511+
nock('http://localhost:6000')
1512+
.post('/api/daily-posts', (body) => {
1513+
expect(body.feed_config_name).toEqual('daily_cs_v1');
1514+
return true;
1515+
})
1516+
.reply(200, {
1517+
data: [{ post_id: 'p1' }],
1518+
cursor: 'b',
1519+
});
1520+
1521+
const res = await client.query(QUERY, { variables });
1522+
1523+
expect(res.errors).toBeFalsy();
1524+
expect(res.data.dailyFeed.edges.map((edge) => edge.node.id)).toEqual([
1525+
'p1',
1526+
]);
1527+
});
1528+
1529+
it('should serve repeat requests from the redis cache without re-hitting the feed service', async () => {
1530+
loggedUser = '1';
1531+
await saveFeedFixtures();
1532+
1533+
jest.spyOn(snotraClient, 'getUserProfile').mockResolvedValue({
1534+
personalise: { state: PersonaliseState.Personalised },
1535+
});
1536+
// Only one feed-service mock: a second call would have no nock match and fail.
1537+
nock('http://localhost:6000')
1538+
.post('/api/daily-posts')
1539+
.reply(200, {
1540+
data: [{ post_id: 'p1' }, { post_id: 'p4' }],
1541+
cursor: 'next-cursor',
1542+
});
1543+
1544+
const first = await client.query(QUERY, { variables });
1545+
const second = await client.query(QUERY, { variables });
1546+
1547+
expect(first.errors).toBeFalsy();
1548+
expect(second.errors).toBeFalsy();
1549+
expect(second.data.dailyFeed.edges.map((edge) => edge.node.id)).toEqual([
1550+
'p1',
1551+
'p4',
1552+
]);
1553+
});
1554+
});
1555+
14591556
describe('query feedByTags', () => {
14601557
const QUERY = `
14611558
query FeedByTags($tags: [String!]!, $ranking: Ranking, $first: Int, $version: Int) {
@@ -1758,6 +1855,19 @@ describe('query sourceFeed', () => {
17581855
expect(res.data.sourceFeed.edges).toMatchSnapshot();
17591856
});
17601857

1858+
it('should resolve updatedAt with a time cursor without a SQL parameter collision', async () => {
1859+
additionalProps = 'updatedAt';
1860+
const after = base64(`time:${new Date().getTime()}`);
1861+
const res = await client.query(
1862+
QUERY('b', Ranking.TIME, new Date(), 5, after),
1863+
);
1864+
1865+
expect(res.errors).toBeFalsy();
1866+
res.data.sourceFeed.edges.forEach(({ node }) =>
1867+
expect(node.updatedAt).toEqual(expect.any(String)),
1868+
);
1869+
});
1870+
17611871
it('should display return the right posts after the first page being a mix of pinned and unpinned posts', async () => {
17621872
await con.getRepository(User).save({ id: '1', name: 'Lee' });
17631873
const createdAt1 = new Date('2020-09-21T01:15:51.247Z');

__tests__/highlights.ts

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
initializeGraphQLTesting,
77
MockContext,
88
saveFixtures,
9+
testQueryErrorCode,
910
} from './helpers';
1011
import createOrGetConnection from '../src/db';
1112
import { ArticlePost } from '../src/entity/posts/ArticlePost';
@@ -16,14 +17,24 @@ import { Source, SourceType } from '../src/entity/Source';
1617
import { HighlightSignificance } from '../src/common/channelHighlight/significance';
1718
import { PostType } from '../src/entity/posts/Post';
1819
import { sourcesFixture } from './fixture/source';
20+
import { User } from '../src/entity/user/User';
21+
import { usersFixture } from './fixture/user';
22+
import { NotificationPreferenceSource } from '../src/entity/notifications/NotificationPreferenceSource';
23+
import {
24+
NotificationPreferenceStatus,
25+
NotificationType,
26+
} from '../src/notifications/common';
1927

2028
let con: DataSource;
2129
let state: GraphQLTestingState;
2230
let client: GraphQLTestClient;
31+
let loggedUser: string | null = null;
2332

2433
beforeAll(async () => {
2534
con = await createOrGetConnection();
26-
state = await initializeGraphQLTesting(() => new MockContext(con));
35+
state = await initializeGraphQLTesting(
36+
() => new MockContext(con, loggedUser),
37+
);
2738
client = state.client;
2839
});
2940

@@ -87,6 +98,8 @@ const createTestPosts = async () => {
8798

8899
beforeEach(async () => {
89100
jest.resetAllMocks();
101+
loggedUser = null;
102+
await con.getRepository(NotificationPreferenceSource).clear();
90103
await con.getRepository(ChannelDigest).clear();
91104
await con.getRepository(ChannelHighlightDefinition).clear();
92105
await con.getRepository(HighlightsCanonical).clear();
@@ -290,6 +303,177 @@ const CHANNEL_DIGEST_CONFIGURATIONS_QUERY = `
290303
}
291304
`;
292305

306+
const DAILY_HIGHLIGHTS_QUERY = `
307+
query DailyHighlights($first: Int, $after: String) {
308+
dailyHighlights(first: $first, after: $after) {
309+
pageInfo {
310+
hasNextPage
311+
endCursor
312+
}
313+
edges {
314+
cursor
315+
node {
316+
id
317+
channel
318+
highlightedAt
319+
headline
320+
significance
321+
post {
322+
id
323+
title
324+
}
325+
}
326+
}
327+
}
328+
}
329+
`;
330+
331+
describe('query dailyHighlights', () => {
332+
const saveDigestSource = (id: string) =>
333+
con.getRepository(Source).save({
334+
id,
335+
name: id,
336+
image: `https://example.com/${id}.png`,
337+
handle: id,
338+
type: SourceType.Machine,
339+
active: true,
340+
private: false,
341+
});
342+
343+
const subscribeToDigest = (sourceId: string) =>
344+
con.getRepository(NotificationPreferenceSource).save({
345+
userId: '1',
346+
referenceId: sourceId,
347+
sourceId,
348+
notificationType: NotificationType.SourcePostAdded,
349+
status: NotificationPreferenceStatus.Subscribed,
350+
});
351+
352+
beforeEach(async () => {
353+
await saveFixtures(con, User, usersFixture);
354+
await createTestPosts();
355+
});
356+
357+
it('should require authentication', () =>
358+
testQueryErrorCode(
359+
client,
360+
{ query: DAILY_HIGHLIGHTS_QUERY },
361+
'UNAUTHENTICATED',
362+
));
363+
364+
it('should return the top highlight of the day per subscribed channel', async () => {
365+
loggedUser = '1';
366+
367+
await saveDigestSource('backend_digest');
368+
await saveDigestSource('career_digest');
369+
await con.getRepository(ChannelDigest).save([
370+
{
371+
key: 'backend',
372+
sourceId: 'backend_digest',
373+
channel: 'backend',
374+
targetAudience: 'backend devs',
375+
frequency: 'daily',
376+
enabled: true,
377+
},
378+
{
379+
key: 'career',
380+
sourceId: 'career_digest',
381+
channel: 'career',
382+
targetAudience: 'everyone',
383+
frequency: 'daily',
384+
enabled: true,
385+
},
386+
]);
387+
await subscribeToDigest('backend_digest');
388+
await subscribeToDigest('career_digest');
389+
390+
const today = new Date();
391+
today.setUTCHours(12, 0, 0, 0);
392+
const earlierToday = new Date(today.getTime() - 60 * 60 * 1000);
393+
const yesterday = new Date(today.getTime() - 2 * 24 * 60 * 60 * 1000);
394+
395+
await saveCanonicalHighlights([
396+
{
397+
id: '11111111-1111-1111-1111-111111111111',
398+
postId: 'h1',
399+
channel: 'backend',
400+
highlightedAt: today,
401+
headline: 'Backend major',
402+
significance: HighlightSignificance.Major,
403+
},
404+
{
405+
id: '22222222-2222-2222-2222-222222222222',
406+
postId: 'h2',
407+
channel: 'backend',
408+
highlightedAt: earlierToday,
409+
headline: 'Backend breaking',
410+
significance: HighlightSignificance.Breaking,
411+
},
412+
{
413+
id: '33333333-3333-3333-3333-333333333333',
414+
postId: 'h3',
415+
channel: 'career',
416+
highlightedAt: today,
417+
headline: 'Career notable',
418+
significance: HighlightSignificance.Notable,
419+
},
420+
{
421+
id: '44444444-4444-4444-4444-444444444444',
422+
postId: 'h4',
423+
channel: 'backend',
424+
highlightedAt: yesterday,
425+
headline: 'Backend stale breaking',
426+
significance: HighlightSignificance.Breaking,
427+
},
428+
]);
429+
430+
const res = await client.query(DAILY_HIGHLIGHTS_QUERY);
431+
432+
expect(res.errors).toBeFalsy();
433+
// backend → breaking (beats today's major); career → notable; stale excluded.
434+
// ordered by highlightedAt desc: career @12:00 then backend @11:00.
435+
expect(
436+
res.data.dailyHighlights.edges.map(({ node }) => ({
437+
channel: node.channel,
438+
headline: node.headline,
439+
postId: node.post.id,
440+
})),
441+
).toEqual([
442+
{ channel: 'career', headline: 'Career notable', postId: 'h3' },
443+
{ channel: 'backend', headline: 'Backend breaking', postId: 'h2' },
444+
]);
445+
});
446+
447+
it('should return empty when the user has no subscriptions', async () => {
448+
loggedUser = '1';
449+
450+
await saveDigestSource('backend_digest');
451+
await con.getRepository(ChannelDigest).save({
452+
key: 'backend',
453+
sourceId: 'backend_digest',
454+
channel: 'backend',
455+
targetAudience: 'backend devs',
456+
frequency: 'daily',
457+
enabled: true,
458+
});
459+
await saveCanonicalHighlights([
460+
{
461+
id: '55555555-5555-5555-5555-555555555555',
462+
postId: 'h1',
463+
channel: 'backend',
464+
highlightedAt: new Date(),
465+
headline: 'Backend today',
466+
significance: HighlightSignificance.Major,
467+
},
468+
]);
469+
470+
const res = await client.query(DAILY_HIGHLIGHTS_QUERY);
471+
472+
expect(res.errors).toBeFalsy();
473+
expect(res.data.dailyHighlights.edges).toEqual([]);
474+
});
475+
});
476+
293477
describe('query channelDigestConfigurations', () => {
294478
const saveDigestSource = (id: string, name: string) =>
295479
con.getRepository(Source).save({

bin/import.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { readFileSync, readdirSync } from 'fs';
22
import createOrGetConnection from '../src/db';
3-
import type { DataSource } from 'typeorm';
3+
import type { DataSource, EntityManager } from 'typeorm';
44
import z from 'zod';
55
import { zodToParseArgs } from './common';
66
import { seedEntityNames } from './seedEntities';
@@ -47,7 +47,7 @@ const getConflictPaths = (entityName: string): string[] | undefined => {
4747
};
4848

4949
const importEntity = async (
50-
con: DataSource,
50+
con: DataSource | EntityManager,
5151
name: string,
5252
options: {
5353
conflictPaths?: string[];
@@ -115,9 +115,13 @@ const start = async (): Promise<void> => {
115115
const con = await createOrGetConnection();
116116

117117
if (params.entity) {
118-
console.log('importing specific entity for: ', params.entity);
119-
return await importEntity(con, params.entity, {
120-
conflictPaths: getConflictPaths(params.entity),
118+
const entityToImport = params.entity;
119+
120+
console.log('importing specific entity for: ', entityToImport);
121+
return con.transaction(async (manager) => {
122+
await importEntity(manager, entityToImport, {
123+
conflictPaths: getConflictPaths(entityToImport),
124+
});
121125
});
122126
}
123127

bin/seedEntities.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export const seedEntityNames = [
2121
'PostKeyword',
2222
'UserTopReader',
2323
'MarketingCta',
24+
'ChannelHighlightDefinition',
25+
'HighlightsCanonical',
2426
'SourceMember',
2527
'Product',
2628
'Organization',

0 commit comments

Comments
 (0)