diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index b4b050799..bc7aacf6a 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -1563,7 +1563,10 @@ class ApiV1Controller extends Controller return $status['like_id']; })->filter(); - $max = $ids->min() - 1; + // Exclusive `<` upper bound on likes.id (see query above), so the + // next cursor is exactly the smallest like_id on this page — no -1, + // which would skip the row at (min - 1). + $max = $ids->min(); $min = $ids->max(); $baseUrl = config('app.url').'/api/v1/favourites?limit='.$limit.'&'; diff --git a/tests/Feature/Api/FavouritesPaginationTest.php b/tests/Feature/Api/FavouritesPaginationTest.php new file mode 100644 index 000000000..c096a40a2 --- /dev/null +++ b/tests/Feature/Api/FavouritesPaginationTest.php @@ -0,0 +1,88 @@ +create([ + 'profile_id' => $author->profile_id, + 'type' => 'photo', + 'scope' => 'public', + ]); + + return Like::create([ + 'profile_id' => $liker->profile_id, + 'status_id' => $status->id, + ]); +} + +/** + * Parse the max_id value out of a rel="next" Link header. + */ +function nextMaxId(?string $linkHeader): ?int +{ + if (! $linkHeader) { + return null; + } + + if (preg_match('/max_id=(\d+)>; rel="next"/', $linkHeader, $m)) { + return (int) $m[1]; + } + + return null; +} + +it('traverses both pages returning every favourite with no skipped rows', function () { + $author = User::factory()->create(); + $author->refresh(); + $user = User::factory()->create(); + $user->refresh(); + + // Five favourites; capture their like_ids (autoincrement, ascending). + $likeIds = collect(range(1, 5)) + ->map(fn () => favourite($author, $user)->id) + ->sort() + ->values() + ->all(); + + Passport::actingAs($user, ['read']); + + // Page 1: newest 3 by like_id desc. + $page1 = $this->getJson('/api/v1/favourites?limit=3')->assertOk(); + $page1Ids = collect($page1->json())->pluck('like_id')->all(); + + expect($page1Ids)->toHaveCount(3); + + $nextMaxId = nextMaxId($page1->headers->get('Link')); + expect($nextMaxId)->not->toBeNull(); + + // Page 2: follow rel="next". + $page2 = $this->getJson("/api/v1/favourites?limit=3&max_id={$nextMaxId}")->assertOk(); + $page2Ids = collect($page2->json())->pluck('like_id')->all(); + + // The union of both pages must cover every favourite with no gap. + $seen = collect($page1Ids)->merge($page2Ids)->unique()->sort()->values()->all(); + + expect($seen)->toBe($likeIds); +});