mirror of https://github.com/pixelfed/pixelfed
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
237 lines
7.7 KiB
PHP
237 lines
7.7 KiB
PHP
<?php
|
|
|
|
use App\Models\Follower;
|
|
use App\Models\Profile;
|
|
use App\Models\Status;
|
|
use App\Models\User;
|
|
use App\Services\HomeTimelineService;
|
|
use App\Services\PublicTimelineService;
|
|
use App\Services\StatusService;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
|
use Illuminate\Support\Facades\Redis;
|
|
use Laravel\Passport\Passport;
|
|
|
|
uses(LazilyRefreshDatabase::class);
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Timeline scroll pagination with created_at ranking
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| Feeds are ranked by created_at, not status id. A remote post can hold a low
|
|
| id but a recent date, so it sorts far from its id position. The next/prev
|
|
| Link cursors must follow display order; deriving them from the min/max id
|
|
| points the cursor at the wrong row and makes infinite scroll re-serve posts
|
|
| or stall. Each walk below follows the Link header like the web client and
|
|
| asserts every post appears exactly once.
|
|
|
|
|
*/
|
|
|
|
function parseNextMaxId(?string $link): ?string
|
|
{
|
|
if (! $link) {
|
|
return null;
|
|
}
|
|
if (preg_match('/max_id=(\d+)>; rel="next"/', $link, $m)) {
|
|
return $m[1];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Create a status whose created_at is set explicitly so date order and id
|
|
* order can be made to disagree.
|
|
*/
|
|
function statusAt(int $authorPid, Carbon $createdAt, bool $remote = false): Status
|
|
{
|
|
$status = Status::factory()->create([
|
|
'profile_id' => $authorPid,
|
|
'type' => 'photo',
|
|
'scope' => 'public',
|
|
'visibility' => 'public',
|
|
'in_reply_to_id' => null,
|
|
'reblog_of_id' => null,
|
|
'local' => ! $remote,
|
|
'uri' => $remote ? 'https://remote.example/p/'.bin2hex(random_bytes(6)) : null,
|
|
'created_at' => $createdAt,
|
|
]);
|
|
|
|
StatusService::get($status->id, false);
|
|
StatusService::getMastodon($status->id, false);
|
|
|
|
return $status;
|
|
}
|
|
|
|
it('paginates the home feed without duplicates when id order differs from date order', function () {
|
|
$viewer = User::factory()->create();
|
|
$viewer->refresh();
|
|
$pid = $viewer->profile_id;
|
|
|
|
$local = User::factory()->create();
|
|
$local->refresh();
|
|
Follower::create(['profile_id' => $pid, 'following_id' => $local->profile_id]);
|
|
|
|
$remoteAuthor = Profile::factory()->create(['user_id' => null, 'domain' => 'remote.example']);
|
|
Follower::create(['profile_id' => $pid, 'following_id' => $remoteAuthor->id]);
|
|
|
|
// Interleave local and remote posts so id order and date order diverge:
|
|
// the remote posts are created last (highest ids) but back-dated so they
|
|
// sort into the middle of the feed by created_at.
|
|
$all = [];
|
|
for ($i = 0; $i < 8; $i++) {
|
|
$all[] = statusAt($local->profile_id, now()->subMinutes(10 * ($i + 1)));
|
|
}
|
|
// Remote posts get the newest ids but dates spread across the local range.
|
|
for ($i = 0; $i < 4; $i++) {
|
|
$all[] = statusAt($remoteAuthor->id, now()->subMinutes(15 + 20 * $i), true);
|
|
}
|
|
|
|
Redis::del(HomeTimelineService::CACHE_KEY.$pid);
|
|
HomeTimelineService::warmCache($pid, true, 400);
|
|
|
|
$cached = collect(Redis::zrevrange(HomeTimelineService::CACHE_KEY.$pid, 0, -1))
|
|
->map(fn ($v) => (string) $v);
|
|
|
|
expect($cached->count())->toBe(count($all));
|
|
|
|
Passport::actingAs($viewer, ['read']);
|
|
|
|
$limit = 3;
|
|
$base = '/api/v1/timelines/home?limit='.$limit.'&_pe=1';
|
|
$seen = [];
|
|
$maxId = null;
|
|
|
|
for ($page = 0; $page < 20; $page++) {
|
|
$url = $maxId ? $base.'&max_id='.$maxId : $base;
|
|
$resp = $this->getJson($url)->assertOk();
|
|
$ids = collect($resp->json())->pluck('id')->map(fn ($id) => (string) $id)->all();
|
|
|
|
if (count($ids) === 0) {
|
|
break;
|
|
}
|
|
|
|
foreach ($ids as $id) {
|
|
// A cursor that drifts off display order re-serves earlier posts.
|
|
expect($seen)->not->toContain($id);
|
|
$seen[] = $id;
|
|
}
|
|
|
|
$next = parseNextMaxId($resp->headers->get('Link'));
|
|
if (! $next || $next === $maxId) {
|
|
break;
|
|
}
|
|
$maxId = $next;
|
|
}
|
|
|
|
// Every cached post was served exactly once across the scroll.
|
|
expect(collect($seen)->unique()->count())->toBe($cached->count());
|
|
expect(collect($seen)->sort()->values()->all())
|
|
->toBe($cached->sort()->values()->all());
|
|
|
|
Redis::del(HomeTimelineService::CACHE_KEY.$pid);
|
|
});
|
|
|
|
it('paginates the local feed without duplicates when id order differs from date order', function () {
|
|
$viewer = User::factory()->create();
|
|
$viewer->refresh();
|
|
|
|
$author = User::factory()->create();
|
|
$author->refresh();
|
|
|
|
$all = [];
|
|
for ($i = 0; $i < 8; $i++) {
|
|
$all[] = statusAt($author->profile_id, now()->subMinutes(10 * ($i + 1)));
|
|
}
|
|
|
|
Redis::del(PublicTimelineService::CACHE_KEY);
|
|
PublicTimelineService::warmCache(true, 400);
|
|
|
|
$cached = collect(Redis::zrevrange(PublicTimelineService::CACHE_KEY, 0, -1))
|
|
->map(fn ($v) => (string) $v);
|
|
|
|
expect($cached->count())->toBeGreaterThanOrEqual(count($all));
|
|
|
|
Passport::actingAs($viewer, ['read']);
|
|
|
|
$limit = 3;
|
|
$base = '/api/v1/timelines/public?limit='.$limit.'&_pe=1&local=1';
|
|
$seen = [];
|
|
$maxId = null;
|
|
|
|
for ($page = 0; $page < 30; $page++) {
|
|
$url = $maxId ? $base.'&max_id='.$maxId : $base;
|
|
$resp = $this->getJson($url)->assertOk();
|
|
$ids = collect($resp->json())->pluck('id')->map(fn ($id) => (string) $id)->all();
|
|
|
|
if (count($ids) === 0) {
|
|
break;
|
|
}
|
|
|
|
foreach ($ids as $id) {
|
|
expect($seen)->not->toContain($id);
|
|
$seen[] = $id;
|
|
}
|
|
|
|
$next = parseNextMaxId($resp->headers->get('Link'));
|
|
if (! $next || $next === $maxId) {
|
|
break;
|
|
}
|
|
$maxId = $next;
|
|
}
|
|
|
|
// Each seeded local post was served exactly once.
|
|
$seededIds = collect($all)->pluck('id')->map(fn ($id) => (string) $id)->sort()->values();
|
|
$seenSeeded = collect($seen)->filter(fn ($id) => $seededIds->contains($id))->sort()->values();
|
|
expect($seenSeeded->all())->toBe($seededIds->all());
|
|
|
|
Redis::del(PublicTimelineService::CACHE_KEY);
|
|
});
|
|
|
|
it('advances the home feed when the client rounds the snowflake cursor', function () {
|
|
$viewer = User::factory()->create();
|
|
$viewer->refresh();
|
|
$pid = $viewer->profile_id;
|
|
|
|
$author = User::factory()->create();
|
|
$author->refresh();
|
|
Follower::create(['profile_id' => $pid, 'following_id' => $author->profile_id]);
|
|
|
|
$all = [];
|
|
for ($i = 0; $i < 12; $i++) {
|
|
$all[] = statusAt($author->profile_id, now()->subMinutes(10 * ($i + 1)));
|
|
}
|
|
|
|
Redis::del(HomeTimelineService::CACHE_KEY.$pid);
|
|
HomeTimelineService::warmCache($pid, true, 400);
|
|
|
|
$ordered = collect(Redis::zrevrange(HomeTimelineService::CACHE_KEY.$pid, 0, -1))
|
|
->map(fn ($v) => (string) $v)
|
|
->values();
|
|
|
|
// Take a real cursor from mid-feed and round it the way a JS Number would,
|
|
// past Number.MAX_SAFE_INTEGER, exactly as the web client sends it back.
|
|
$realCursor = $ordered->get(3);
|
|
$roundedCursor = (string) (int) (float) $realCursor;
|
|
|
|
expect($roundedCursor)->not->toBe($realCursor);
|
|
|
|
Passport::actingAs($viewer, ['read']);
|
|
|
|
$res = $this->getJson('/api/v1/timelines/home?limit=4&max_id='.$roundedCursor.'&_pe=1')
|
|
->assertOk()
|
|
->json();
|
|
|
|
$ids = collect($res)->pluck('id')->map(fn ($id) => (string) $id)->values();
|
|
|
|
// The page must continue below the cursor, not restart at the newest post.
|
|
$cursorRank = $ordered->search($realCursor);
|
|
$expected = $ordered->slice($cursorRank + 1, 4)->values();
|
|
|
|
expect($ids->all())->toBe($expected->all());
|
|
expect($ids)->not->toContain($ordered->first());
|
|
|
|
Redis::del(HomeTimelineService::CACHE_KEY.$pid);
|
|
});
|