@@ -11,6 +11,7 @@ import {
1111import createOrGetConnection from '../src/db' ;
1212import { ArticlePost } from '../src/entity/posts/ArticlePost' ;
1313import { FreeformPost } from '../src/entity/posts/FreeformPost' ;
14+ import { BriefPost } from '../src/entity/posts/BriefPost' ;
1415import { ChannelDigest } from '../src/entity/ChannelDigest' ;
1516import { ChannelHighlightDefinition } from '../src/entity/ChannelHighlightDefinition' ;
1617import { HighlightsCanonical } from '../src/entity/HighlightsCanonical' ;
@@ -22,7 +23,14 @@ import { User } from '../src/entity/user/User';
2223import { usersFixture } from './fixture/user' ;
2324import { Feed } from '../src/entity/Feed' ;
2425import { 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
2735let con : DataSource ;
2836let 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
464807describe ( 'query channelDigestConfigurations' , ( ) => {
0 commit comments