@@ -384,3 +384,134 @@ describe.sequential("/api/v1/timelines/home", () => {
384384 expect ( json [ 0 ] . content ) . toContain ( quotedPostUrl ) ;
385385 } ) ;
386386} ) ;
387+
388+ describe . sequential ( "/api/v1/timelines/public (pagination)" , ( ) => {
389+ let owner : Awaited < ReturnType < typeof createAccount > > ;
390+ let client : Awaited < ReturnType < typeof createOAuthApplication > > ;
391+ let accessToken : Awaited < ReturnType < typeof getAccessToken > > ;
392+ // postIds[0] is the oldest; postIds[24] is the newest.
393+ let postIds : Uuid [ ] ;
394+
395+ beforeEach ( async ( ) => {
396+ await cleanDatabase ( ) ;
397+
398+ owner = await createAccount ( ) ;
399+ client = await createOAuthApplication ( { scopes : [ "read:statuses" ] } ) ;
400+ accessToken = await getAccessToken ( client , owner , [ "read:statuses" ] ) ;
401+
402+ postIds = [ ] ;
403+ for ( let i = 0 ; i < 25 ; i ++ ) {
404+ const id = uuidv7 ( ) ;
405+ postIds . push ( id ) ;
406+ await db . insert ( posts ) . values ( {
407+ id,
408+ iri : `https://hollo.test/@hollo/${ id } ` ,
409+ type : "Note" ,
410+ accountId : owner . id ,
411+ visibility : "public" ,
412+ content : `Post ${ i } ` ,
413+ contentHtml : `<p>Post ${ i } </p>` ,
414+ published : new Date ( ) ,
415+ } ) ;
416+ }
417+ } ) ;
418+
419+ async function fetchTimeline ( qs : string ) : Promise < Response > {
420+ return await app . request ( `/api/v1/timelines/public${ qs } ` , {
421+ method : "GET" ,
422+ headers : {
423+ authorization : bearerAuthorization ( accessToken ) ,
424+ } ,
425+ } ) ;
426+ }
427+
428+ it ( "returns the newest posts with bidirectional Link headers" , async ( ) => {
429+ expect . assertions ( 6 ) ;
430+
431+ const response = await fetchTimeline ( "?limit=10" ) ;
432+ expect ( response . status ) . toBe ( 200 ) ;
433+
434+ const json = ( await response . json ( ) ) as { id : string } [ ] ;
435+ expect ( json ) . toHaveLength ( 10 ) ;
436+ expect ( json [ 0 ] . id ) . toBe ( postIds [ 24 ] ) ;
437+ expect ( json [ 9 ] . id ) . toBe ( postIds [ 15 ] ) ;
438+
439+ const link = response . headers . get ( "Link" ) ?? "" ;
440+ expect ( link ) . toContain ( `max_id=${ postIds [ 15 ] } >; rel="next"` ) ;
441+ expect ( link ) . toContain ( `min_id=${ postIds [ 24 ] } >; rel="prev"` ) ;
442+ } ) ;
443+
444+ it ( "walks up a large gap with min_id (Mastodon gap-loading)" , async ( ) => {
445+ expect . assertions ( 4 ) ;
446+
447+ // Cursor sits 19 posts below the top. With limit=5, gap-loading must
448+ // return the 5 posts *immediately* above the cursor — postIds[6..10] —
449+ // ordered newest-first. Naïve `since_id`-style logic would instead
450+ // return postIds[24..20] and the gap would never close.
451+ const response = await fetchTimeline ( `?limit=5&min_id=${ postIds [ 5 ] } ` ) ;
452+ expect ( response . status ) . toBe ( 200 ) ;
453+
454+ const json = ( await response . json ( ) ) as { id : string } [ ] ;
455+ expect ( json . map ( ( p ) => p . id ) ) . toEqual ( [
456+ postIds [ 10 ] ,
457+ postIds [ 9 ] ,
458+ postIds [ 8 ] ,
459+ postIds [ 7 ] ,
460+ postIds [ 6 ] ,
461+ ] ) ;
462+
463+ // The rel="prev" cursor must point at the newest returned post so a
464+ // follow-up request continues walking up the gap.
465+ const link = response . headers . get ( "Link" ) ?? "" ;
466+ expect ( link ) . toContain ( `min_id=${ postIds [ 10 ] } >; rel="prev"` ) ;
467+ expect ( link ) . toContain ( `max_id=${ postIds [ 6 ] } >; rel="next"` ) ;
468+ } ) ;
469+
470+ it ( "returns the newest posts above the cursor when only since_id is set" , async ( ) => {
471+ expect . assertions ( 2 ) ;
472+
473+ const response = await fetchTimeline ( `?limit=5&since_id=${ postIds [ 5 ] } ` ) ;
474+ expect ( response . status ) . toBe ( 200 ) ;
475+
476+ const json = ( await response . json ( ) ) as { id : string } [ ] ;
477+ expect ( json . map ( ( p ) => p . id ) ) . toEqual ( [
478+ postIds [ 24 ] ,
479+ postIds [ 23 ] ,
480+ postIds [ 22 ] ,
481+ postIds [ 21 ] ,
482+ postIds [ 20 ] ,
483+ ] ) ;
484+ } ) ;
485+
486+ it ( "lets min_id win over since_id when both are supplied" , async ( ) => {
487+ expect . assertions ( 1 ) ;
488+
489+ const response = await fetchTimeline (
490+ `?limit=5&min_id=${ postIds [ 5 ] } &since_id=${ postIds [ 20 ] } ` ,
491+ ) ;
492+ const json = ( await response . json ( ) ) as { id : string } [ ] ;
493+ expect ( json . map ( ( p ) => p . id ) ) . toEqual ( [
494+ postIds [ 10 ] ,
495+ postIds [ 9 ] ,
496+ postIds [ 8 ] ,
497+ postIds [ 7 ] ,
498+ postIds [ 6 ] ,
499+ ] ) ;
500+ } ) ;
501+
502+ it ( "drops conflicting cursors when generating Link headers" , async ( ) => {
503+ expect . assertions ( 2 ) ;
504+
505+ // Passing every cursor at once should not propagate into the next/prev
506+ // links — each link must contain exactly one of max_id/min_id and no
507+ // stale since_id.
508+ const response = await fetchTimeline (
509+ `?limit=5&max_id=${ postIds [ 24 ] } &min_id=${ postIds [ 0 ] } &since_id=${ postIds [ 10 ] } ` ,
510+ ) ;
511+ const link = response . headers . get ( "Link" ) ?? "" ;
512+ expect ( link ) . not . toContain ( "since_id=" ) ;
513+ // rel="next" carries max_id only; rel="prev" carries min_id only.
514+ const matches = link . match ( / ( m a x _ i d | m i n _ i d | s i n c e _ i d ) = / g) ?? [ ] ;
515+ expect ( matches ) . toHaveLength ( 2 ) ;
516+ } ) ;
517+ } ) ;
0 commit comments