Skip to content

Commit c5e8379

Browse files
authored
feat: update to daily features (#3962)
1 parent 289422c commit c5e8379

9 files changed

Lines changed: 719 additions & 36 deletions

File tree

__tests__/highlights.ts

Lines changed: 344 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import createOrGetConnection from '../src/db';
1212
import { ArticlePost } from '../src/entity/posts/ArticlePost';
1313
import { FreeformPost } from '../src/entity/posts/FreeformPost';
14+
import { BriefPost } from '../src/entity/posts/BriefPost';
1415
import { ChannelDigest } from '../src/entity/ChannelDigest';
1516
import { ChannelHighlightDefinition } from '../src/entity/ChannelHighlightDefinition';
1617
import { HighlightsCanonical } from '../src/entity/HighlightsCanonical';
@@ -22,7 +23,14 @@ import { User } from '../src/entity/user/User';
2223
import { usersFixture } from './fixture/user';
2324
import { Feed } from '../src/entity/Feed';
2425
import { ContentPreferenceSource } from '../src/entity/contentPreference/ContentPreferenceSource';
25-
import { ContentPreferenceStatus } from '../src/entity/contentPreference/types';
26+
import { ContentPreferenceKeyword } from '../src/entity/contentPreference/ContentPreferenceKeyword';
27+
import {
28+
ContentPreferenceStatus,
29+
ContentPreferenceType,
30+
} from '../src/entity/contentPreference/types';
31+
import { Keyword } from '../src/entity/Keyword';
32+
import { PostKeyword } from '../src/entity/PostKeyword';
33+
import { UserAction, UserActionType } from '../src/entity/user/UserAction';
2634

2735
let con: DataSource;
2836
let state: GraphQLTestingState;
@@ -379,6 +387,26 @@ describe('query dailyHeadlines', () => {
379387
const hoursAgo = (hours: number): Date =>
380388
new Date(Date.now() - hours * 60 * 60 * 1000);
381389

390+
const saveBriefPost = (
391+
id: string,
392+
title: string,
393+
createdAt: Date,
394+
authorId = '1',
395+
) =>
396+
con.getRepository(BriefPost).save({
397+
id,
398+
shortId: id,
399+
sourceId: 'briefing',
400+
authorId,
401+
type: PostType.Brief,
402+
title,
403+
visible: true,
404+
visibleAt: createdAt,
405+
createdAt,
406+
metadataChangedAt: createdAt,
407+
private: false,
408+
});
409+
382410
beforeEach(async () => {
383411
await saveFixtures(con, User, usersFixture);
384412
await saveFixtures(con, Feed, [{ id: '1', userId: '1' }]);
@@ -459,6 +487,321 @@ describe('query dailyHeadlines', () => {
459487
expect(res.errors).toBeFalsy();
460488
expect(res.data.dailyHeadlines.edges).toEqual([]);
461489
});
490+
491+
it('should lead with the latest brief even when a digest post is newer', async () => {
492+
loggedUser = '1';
493+
494+
await saveDigestSource('briefing');
495+
await saveDigestSource('backend_digest');
496+
await saveChannelDigest('backend', 'backend_digest', 'backend');
497+
await followDigest('backend_digest');
498+
await saveDigestPost('bd-new', 'backend_digest', 'Backend', hoursAgo(1));
499+
await saveBriefPost('brief-old', 'Your briefing', hoursAgo(10));
500+
501+
const res = await client.query(DAILY_HEADLINES_QUERY);
502+
503+
expect(res.errors).toBeFalsy();
504+
expect(res.data.dailyHeadlines.edges.map(({ node }) => node.id)).toEqual([
505+
'brief-old',
506+
'bd-new',
507+
]);
508+
});
509+
510+
it('should return the brief even when the user follows no channels', async () => {
511+
loggedUser = '1';
512+
513+
await saveDigestSource('briefing');
514+
await saveBriefPost('brief-1', 'Your briefing', hoursAgo(1));
515+
516+
const res = await client.query(DAILY_HEADLINES_QUERY);
517+
518+
expect(res.errors).toBeFalsy();
519+
expect(res.data.dailyHeadlines.edges.map(({ node }) => node.id)).toEqual([
520+
'brief-1',
521+
]);
522+
});
523+
524+
it('should not return briefs authored by other users', async () => {
525+
loggedUser = '1';
526+
527+
await saveDigestSource('briefing');
528+
await saveBriefPost('brief-other', 'Other briefing', hoursAgo(1), '2');
529+
530+
const res = await client.query(DAILY_HEADLINES_QUERY);
531+
532+
expect(res.errors).toBeFalsy();
533+
expect(res.data.dailyHeadlines.edges).toEqual([]);
534+
});
535+
536+
it('should exclude a brief older than 24 hours', async () => {
537+
loggedUser = '1';
538+
539+
await saveDigestSource('briefing');
540+
await saveBriefPost('brief-stale', 'Your briefing', hoursAgo(30));
541+
542+
const res = await client.query(DAILY_HEADLINES_QUERY);
543+
544+
expect(res.errors).toBeFalsy();
545+
expect(res.data.dailyHeadlines.edges).toEqual([]);
546+
});
547+
548+
const DAILY_HEADLINES_BACKFILL_QUERY = `
549+
query DailyHeadlinesBackfill($first: Int, $after: String) {
550+
dailyHeadlines(first: $first, after: $after) {
551+
pageInfo {
552+
hasNextPage
553+
endCursor
554+
}
555+
edges {
556+
node {
557+
id
558+
}
559+
}
560+
}
561+
}
562+
`;
563+
564+
describe('backfill', () => {
565+
const saveDigestSource = (id: string) =>
566+
con.getRepository(Source).save({
567+
id,
568+
name: id,
569+
image: `https://example.com/${id}.png`,
570+
handle: id,
571+
type: SourceType.Machine,
572+
active: true,
573+
private: false,
574+
});
575+
576+
const saveChannelDigest = (
577+
key: string,
578+
sourceId: string,
579+
channel: string,
580+
) =>
581+
con.getRepository(ChannelDigest).save({
582+
key,
583+
sourceId,
584+
channel,
585+
targetAudience: 'devs',
586+
frequency: 'daily',
587+
enabled: true,
588+
});
589+
590+
const saveDigestPost = (id: string, sourceId: string, createdAt: Date) =>
591+
con.getRepository(FreeformPost).save({
592+
id,
593+
shortId: id,
594+
sourceId,
595+
type: PostType.Freeform,
596+
title: id,
597+
content: id,
598+
contentHtml: `<p>${id}</p>`,
599+
visible: true,
600+
visibleAt: createdAt,
601+
createdAt,
602+
metadataChangedAt: createdAt,
603+
showOnFeed: true,
604+
private: false,
605+
});
606+
607+
const followKeyword = (value: string) =>
608+
con.getRepository(ContentPreferenceKeyword).save({
609+
userId: '1',
610+
feedId: '1',
611+
referenceId: value,
612+
keywordId: value,
613+
status: ContentPreferenceStatus.Follow,
614+
type: ContentPreferenceType.Keyword,
615+
});
616+
617+
const saveChannelPosts = async (
618+
prefix: string,
619+
channels: string[],
620+
keyword: string,
621+
count: number,
622+
) => {
623+
for (let i = 0; i < count; i += 1) {
624+
const id = `${prefix}-${i}`;
625+
await con.getRepository(ArticlePost).save({
626+
id,
627+
shortId: id,
628+
title: id,
629+
url: `https://example.com/${id}`,
630+
sourceId: 'a',
631+
visible: true,
632+
private: false,
633+
deleted: false,
634+
type: PostType.Article,
635+
createdAt: new Date(),
636+
metadataChangedAt: new Date(),
637+
contentMeta: { channels },
638+
});
639+
await con.getRepository(PostKeyword).save({ postId: id, keyword });
640+
}
641+
};
642+
643+
const hoursAgo = (hours: number): Date =>
644+
new Date(Date.now() - hours * 60 * 60 * 1000);
645+
646+
const refreshKeywordChannel = () =>
647+
con.query('REFRESH MATERIALIZED VIEW keyword_channel');
648+
649+
beforeEach(async () => {
650+
await con.getRepository(ContentPreferenceKeyword).clear();
651+
await con.getRepository(PostKeyword).clear();
652+
await con.getRepository(UserAction).delete({ userId: '1' });
653+
await saveFixtures(con, User, usersFixture);
654+
await saveFixtures(con, Feed, [{ id: '1', userId: '1' }]);
655+
await saveFixtures(con, Source, [
656+
{
657+
id: 'a',
658+
name: 'A',
659+
image: 'https://example.com/a.png',
660+
handle: 'a',
661+
type: SourceType.Machine,
662+
active: true,
663+
private: false,
664+
},
665+
]);
666+
await saveFixtures(con, Keyword, [
667+
{ value: 'nodejs' },
668+
{ value: 'docker' },
669+
]);
670+
});
671+
672+
it('should require authentication', () =>
673+
testQueryErrorCode(
674+
client,
675+
{ query: DAILY_HEADLINES_BACKFILL_QUERY },
676+
'UNAUTHENTICATED',
677+
));
678+
679+
it('should seed channel digest follows from onboarding tags and return their digest posts', async () => {
680+
loggedUser = '1';
681+
682+
await saveDigestSource('backend_digest');
683+
await saveChannelDigest('backend', 'backend_digest', 'backend');
684+
await followKeyword('nodejs');
685+
await saveChannelPosts('mh', ['backend'], 'nodejs', 3);
686+
await saveDigestPost('bd-new', 'backend_digest', hoursAgo(2));
687+
await refreshKeywordChannel();
688+
689+
const res = await client.query(DAILY_HEADLINES_BACKFILL_QUERY);
690+
691+
expect(res.errors).toBeFalsy();
692+
const follows = await con
693+
.getRepository(ContentPreferenceSource)
694+
.find({ where: { userId: '1', feedId: '1' } });
695+
expect(follows).toHaveLength(1);
696+
expect(follows[0]).toMatchObject({
697+
referenceId: 'backend_digest',
698+
sourceId: 'backend_digest',
699+
status: ContentPreferenceStatus.Follow,
700+
});
701+
expect(res.data.dailyHeadlines.edges.map(({ node }) => node.id)).toEqual([
702+
'bd-new',
703+
]);
704+
const action = await con.getRepository(UserAction).findOneBy({
705+
userId: '1',
706+
type: UserActionType.DailyHeadlinesBackfilled,
707+
});
708+
expect(action).not.toBeNull();
709+
});
710+
711+
it('should mark backfilled without seeding when no channel clears the minimum post count', async () => {
712+
loggedUser = '1';
713+
714+
await saveDigestSource('backend_digest');
715+
await saveChannelDigest('backend', 'backend_digest', 'backend');
716+
await followKeyword('nodejs');
717+
await saveChannelPosts('mh', ['backend'], 'nodejs', 2);
718+
await refreshKeywordChannel();
719+
720+
const res = await client.query(DAILY_HEADLINES_BACKFILL_QUERY);
721+
722+
expect(res.errors).toBeFalsy();
723+
const follows = await con
724+
.getRepository(ContentPreferenceSource)
725+
.findBy({ userId: '1' });
726+
expect(follows).toHaveLength(0);
727+
const action = await con.getRepository(UserAction).findOneBy({
728+
userId: '1',
729+
type: UserActionType.DailyHeadlinesBackfilled,
730+
});
731+
expect(action).not.toBeNull();
732+
});
733+
734+
it('should not seed when the user was already backfilled', async () => {
735+
loggedUser = '1';
736+
737+
await saveDigestSource('backend_digest');
738+
await saveChannelDigest('backend', 'backend_digest', 'backend');
739+
await followKeyword('nodejs');
740+
await saveChannelPosts('mh', ['backend'], 'nodejs', 3);
741+
await con.getRepository(UserAction).save({
742+
userId: '1',
743+
type: UserActionType.DailyHeadlinesBackfilled,
744+
});
745+
746+
const res = await client.query(DAILY_HEADLINES_BACKFILL_QUERY);
747+
748+
expect(res.errors).toBeFalsy();
749+
const follows = await con
750+
.getRepository(ContentPreferenceSource)
751+
.findBy({ userId: '1', status: ContentPreferenceStatus.Follow });
752+
expect(follows).toHaveLength(0);
753+
});
754+
755+
it('should not seed when the user already has a channel digest preference', async () => {
756+
loggedUser = '1';
757+
758+
await saveDigestSource('backend_digest');
759+
await saveChannelDigest('backend', 'backend_digest', 'backend');
760+
await followKeyword('nodejs');
761+
await saveChannelPosts('mh', ['backend'], 'nodejs', 3);
762+
await con.getRepository(ContentPreferenceSource).save({
763+
userId: '1',
764+
feedId: '1',
765+
referenceId: 'backend_digest',
766+
sourceId: 'backend_digest',
767+
status: ContentPreferenceStatus.Blocked,
768+
});
769+
770+
const res = await client.query(DAILY_HEADLINES_BACKFILL_QUERY);
771+
772+
expect(res.errors).toBeFalsy();
773+
const follows = await con
774+
.getRepository(ContentPreferenceSource)
775+
.findBy({ userId: '1', status: ContentPreferenceStatus.Follow });
776+
expect(follows).toHaveLength(0);
777+
const action = await con.getRepository(UserAction).findOneBy({
778+
userId: '1',
779+
type: UserActionType.DailyHeadlinesBackfilled,
780+
});
781+
expect(action).not.toBeNull();
782+
});
783+
784+
it('should mark backfilled without seeding when the user has no onboarding tags', async () => {
785+
loggedUser = '1';
786+
787+
await saveDigestSource('backend_digest');
788+
await saveChannelDigest('backend', 'backend_digest', 'backend');
789+
await saveChannelPosts('mh', ['backend'], 'nodejs', 3);
790+
791+
const res = await client.query(DAILY_HEADLINES_BACKFILL_QUERY);
792+
793+
expect(res.errors).toBeFalsy();
794+
const follows = await con
795+
.getRepository(ContentPreferenceSource)
796+
.findBy({ userId: '1' });
797+
expect(follows).toHaveLength(0);
798+
const action = await con.getRepository(UserAction).findOneBy({
799+
userId: '1',
800+
type: UserActionType.DailyHeadlinesBackfilled,
801+
});
802+
expect(action).not.toBeNull();
803+
});
804+
});
462805
});
463806

464807
describe('query channelDigestConfigurations', () => {

__tests__/setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ jest.mock('../src/remoteConfig', () => ({
2929
vordrIps: ['192.0.2.0/24', '198.51.100.0/24', '203.0.113.0/24'],
3030
ignoredWorkEmailDomains: ['igored.com', 'ignored.org'],
3131
rateLimitReputationThreshold: 1,
32+
headlineChannelMinPosts: 3,
3233
pricingIds: { pricingGift: 'yearly' },
3334
fees: {
3435
transfer: 5,

0 commit comments

Comments
 (0)