diff --git a/apps/web/src/lib/contrail/continuity.test-utils.ts b/apps/web/src/lib/contrail/continuity.test-utils.ts new file mode 100644 index 0000000..7e7608a --- /dev/null +++ b/apps/web/src/lib/contrail/continuity.test-utils.ts @@ -0,0 +1,41 @@ +// Shared assertions for the page-1 ↔ load-more continuity tests that sit beside +// each paginated route's +page.server.ts. +import { expect } from 'vitest'; + +/** + * A fixed instant for those tests to run at, so page 1 and the resumer compute + * the same `now()` time bound and it can be compared by value. Install per file: + * + * beforeEach(() => vi.useFakeTimers({ toFake: ['Date'], now: FROZEN_NOW })); + * afterEach(() => vi.useRealTimers()); + * + * Only `Date` is faked, so async control flow is untouched. + */ +export const FROZEN_NOW = new Date('2026-06-01T12:00:00.000Z'); + +/** + * Assert that a route's page-1 `load()` and the load-more resumer continuing it + * issued the SAME query. + * + * A keyset cursor is query-shape-specific, so page 2 is only adjacent to page 1 + * when every other param agrees — sort, order, limit, filters, scope and the + * time bounds. Hence the full param bags are compared rather than a chosen + * subset: a param added to one side only is drift, whether or not this helper + * knows its name. The cursor is the one param that legitimately differs, so it + * is checked separately — page 1 runs fresh, page 2 threads page 1's raw keyset + * through unchanged. + * + * Requires a frozen clock (see FROZEN_NOW). + */ +export function expectSameQuery( + page1: Record, + page2: Record, + rawKeyset: string +): void { + const { cursor: page1Cursor, ...page1Query } = page1; + const { cursor: page2Cursor, ...page2Query } = page2; + + expect(page2Query).toEqual(page1Query); + expect(page1Cursor).toBeUndefined(); + expect(page2Cursor).toBe(rawKeyset); +} diff --git a/apps/web/src/lib/contrail/events-load-more.test.ts b/apps/web/src/lib/contrail/events-load-more.test.ts index 025278c..d1534ca 100644 --- a/apps/web/src/lib/contrail/events-load-more.test.ts +++ b/apps/web/src/lib/contrail/events-load-more.test.ts @@ -26,7 +26,8 @@ vi.mock('$lib/search/server/query', () => ({ runEventSearchPage: vi.fn() })); -import { runLoadMoreEvents } from './events-load-more'; +import * as v from 'valibot'; +import { listEventsInput, runLoadMoreEvents } from './events-load-more'; import { encodeCursor, decodeCursor, type CursorEnvelope } from './cursor'; import { listAuthoredEventsFromContrail, @@ -231,10 +232,14 @@ describe('security: a tampered/forged envelope cannot surface non-discoverable e noReads(); }); - it('q tampered to an unknown string / missing / empty => empty, and listRecords never runs', async () => { + it('q tampered to an unknown string / prototype key / missing / empty => empty, and listRecords never runs', async () => { mockRecords.mockResolvedValue(page([unlisted])); for (const forged of [ forge({ v: 1, q: 'listRecords', raw: 'x' }), + // A prototype-chain name must never dispatch (decodeCursor rejects it first, + // and the Object.hasOwn dispatch guard is belt-and-suspenders behind that). + forge({ v: 1, q: 'constructor', raw: 'x' }), + forge({ v: 1, q: '__proto__', raw: 'x' }), forge({ v: 1, raw: 'x' }), forge({ v: 1, q: '', raw: 'x' }) ]) { @@ -270,7 +275,9 @@ describe('security: a tampered/forged envelope cannot surface non-discoverable e mockAuthored.mockResolvedValue(page([{ uri: 'at://authored' }], null)); // A different actor on 'hosting' is exactly the same as browsing that public // profile — permitted, and it still scopes to that actor. - const result = await call(token({ v: 1, q: 'hosting', args: { actor: 'did:plc:bob' }, raw: 'k' })); + const result = await call( + token({ v: 1, q: 'hosting', args: { actor: 'did:plc:bob' }, raw: 'k' }) + ); expect(mockAuthored).toHaveBeenCalledTimes(1); expect(mockAuthored.mock.calls[0][1]).toMatchObject({ actor: 'did:plc:bob' }); expect(result.cursor).toBeNull(); @@ -293,7 +300,9 @@ describe('registry required-arg guards end cleanly (never throw, never fall thro }); it('topic with an unknown slug => empty', async () => { - const result = await call(token({ v: 1, q: 'topic', args: { slug: 'no-such-topic' }, raw: 'k' })); + const result = await call( + token({ v: 1, q: 'topic', args: { slug: 'no-such-topic' }, raw: 'k' }) + ); expect(mockDiscoverable).not.toHaveBeenCalled(); expect(result).toEqual({ events: [], handles: {}, cursor: null }); }); @@ -352,3 +361,36 @@ describe('legacy / undecodable cursors end pagination cleanly with no read', () expect(mockRunSearch).not.toHaveBeenCalled(); }); }); + +// The schema is the trust boundary for the load-more POST body — a legacy client +// mid-deploy, or a hostile one, can put anything in it. `v.object` STRIPS unknown +// keys, so only `cursor` and `q` ever reach runLoadMoreEvents and every filter +// value stays server-authoritative instead of client-echoed. +describe('listEventsInput accepts a client bag without trusting it', () => { + it('strips a query-reconstruction bag down to the two fields that are read', () => { + const parsed = v.parse(listEventsInput, { + cursor: 'envelope-token', + q: 'jazz', + // What a pre-envelope client echoed, and what a hostile one would widen. + pipeline: 'listRecords', + sort: 'indexedAt', + order: 'desc', + limit: 500, + startsAtMin: '1970-01-01T00:00:00.000Z', + actor: 'did:plc:someone-else' + }); + expect(parsed).toEqual({ cursor: 'envelope-token', q: 'jazz' }); + }); + + it('accepts a cursor-only body and an empty one', () => { + expect(v.parse(listEventsInput, { cursor: 'envelope-token' })).toEqual({ + cursor: 'envelope-token' + }); + expect(v.parse(listEventsInput, {})).toEqual({}); + }); + + it('rejects a mistyped cursor/q rather than coercing it', () => { + expect(() => v.parse(listEventsInput, { cursor: 123 })).toThrow(); + expect(() => v.parse(listEventsInput, { q: ['jazz'] })).toThrow(); + }); +}); diff --git a/apps/web/src/lib/past-events.test.ts b/apps/web/src/lib/past-events.test.ts new file mode 100644 index 0000000..438e523 --- /dev/null +++ b/apps/web/src/lib/past-events.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { hasEnded } from './past-events'; + +// hasEnded is the shared "is this event over?" predicate the past-events route +// load and its load-more resumer both apply. These pin the two edges that the +// startsAt/endsAt fallback and the strict comparison decide, so neither side can +// quietly re-interpret "past". +const ASOF = '2026-06-01T12:00:00.000Z'; + +describe('hasEnded', () => { + it('is over once an event with no endsAt has started', () => { + expect(hasEnded({ startsAt: '2026-01-01T00:00:00Z', endsAt: undefined }, ASOF)).toBe(true); + }); + + it('is not over when an event with no endsAt has not started yet', () => { + expect(hasEnded({ startsAt: '2026-12-01T00:00:00Z', endsAt: undefined }, ASOF)).toBe(false); + }); + + it('uses endsAt, not startsAt: a started-but-still-running event is not over', () => { + expect( + hasEnded({ startsAt: '2026-01-01T00:00:00Z', endsAt: '2026-12-01T00:00:00Z' }, ASOF) + ).toBe(false); + }); + + it('is over when endsAt is in the past', () => { + expect( + hasEnded({ startsAt: '2026-01-01T00:00:00Z', endsAt: '2026-03-01T00:00:00Z' }, ASOF) + ).toBe(true); + }); + + it('is NOT over at the exact instant it ends — strict <, so the past/upcoming partition never overlaps', () => { + expect(hasEnded({ startsAt: '2026-01-01T00:00:00Z', endsAt: ASOF }, ASOF)).toBe(false); + }); +}); diff --git a/apps/web/src/routes/(app)/events/page.server.test.ts b/apps/web/src/routes/(app)/events/page.server.test.ts new file mode 100644 index 0000000..3ac8aa8 --- /dev/null +++ b/apps/web/src/routes/(app)/events/page.server.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Page 1 of /events comes from this route's load(); page 2 comes from the +// load-more registry's 'events' resumer. They are separate code paths that must +// issue the same discoverable query, so these drive both for real and compare +// what each one emitted. +vi.mock('$lib/contrail', () => ({ + getServerClient: vi.fn(() => ({})), + flattenEventRecords: vi.fn((records: unknown[]) => records), + listDiscoverableEventsFromContrail: vi.fn(), + listAuthoredEventsFromContrail: vi.fn() +})); +// events-load-more.ts imports the search module at load time; the events route +// never hits it, so a null-backend stub keeps the import deterministic. +vi.mock('$lib/search/server/query', () => ({ + searchBackendFromEnv: vi.fn(() => null), + runEventSearchPage: vi.fn() +})); + +import { load } from './+page.server'; +import { runLoadMoreEvents } from '$lib/contrail/events-load-more'; +import { listDiscoverableEventsFromContrail } from '$lib/contrail'; +import { decodeCursor, encodeCursor } from '$lib/contrail/cursor'; +import { FROZEN_NOW, expectSameQuery } from '$lib/contrail/continuity.test-utils'; + +const mockDiscoverable = vi.mocked(listDiscoverableEventsFromContrail); + +const env = { DB: {} } as unknown as App.Platform['env']; + +function event(opts?: { filter?: string; cursor?: string }) { + const url = new URL('https://atmo.test/events'); + if (opts?.filter) url.searchParams.set('filter', opts.filter); + if (opts?.cursor) url.searchParams.set('cursor', opts.cursor); + return { url, platform: { env: { DB: {} } } } as unknown as Parameters[0]; +} + +const page = (cursor: string | null) => + ({ + records: [{ uri: 'at://did:plc:a/community.lexicon.calendar.event/1' }], + profiles: [{ did: 'did:plc:a', handle: 'alice' }], + cursor + }) as unknown as Awaited>; + +type LoadResult = { events: unknown[]; handles: Record; cursor: string | null }; +const runLoad = async (opts?: { filter?: string; cursor?: string }) => + (await load(event(opts))) as LoadResult; + +beforeEach(() => vi.useFakeTimers({ toFake: ['Date'], now: FROZEN_NOW })); +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe('events page load ↔ resumer continuity', () => { + it('page-1 load and the events resumer issue the same query (popular)', async () => { + mockDiscoverable.mockResolvedValue(page('keyset-p1')); + const result = await runLoad(); // no filter => popular + const p1 = mockDiscoverable.mock.calls[0][1]; + + // The emitted envelope names the same server-side query + scope, carrying the + // fresh keyset for the resumer to thread back in. + expect(decodeCursor(result.cursor)).toEqual({ + v: 1, + q: 'events', + args: { popular: true }, + raw: 'keyset-p1' + }); + + await runLoadMoreEvents(env, { cursor: result.cursor! }); + const p2 = mockDiscoverable.mock.calls[1][1]; + + expectSameQuery(p1, p2, 'keyset-p1'); + }); + + it('page-1 load and the resumer both drop rsvpsCountMin for the all/non-popular filter', async () => { + mockDiscoverable.mockResolvedValue(page('keyset-all')); + const result = await runLoad({ filter: 'all' }); + const p1 = mockDiscoverable.mock.calls[0][1]; + expect(p1).not.toHaveProperty('rsvpsCountMin'); + expect(decodeCursor(result.cursor)).toEqual({ + v: 1, + q: 'events', + args: { popular: false }, + raw: 'keyset-all' + }); + + await runLoadMoreEvents(env, { cursor: result.cursor! }); + const p2 = mockDiscoverable.mock.calls[1][1]; + expect(p2).not.toHaveProperty('rsvpsCountMin'); + + expectSameQuery(p1, p2, 'keyset-all'); + }); +}); + +describe('events deep-link ?cursor= guard', () => { + it('resumes an events envelope minted for the SAME popular filter', async () => { + mockDiscoverable.mockResolvedValue(page(null)); + const inbound = encodeCursor({ v: 1, q: 'events', args: { popular: true }, raw: 'p2keyset' }); + await runLoad({ cursor: inbound }); // default load is popular + expect(mockDiscoverable.mock.calls[0][1].cursor).toBe('p2keyset'); + }); + + it('ignores an events envelope minted for a DIFFERENT filter (same q, diff args => fresh page 1)', async () => { + mockDiscoverable.mockResolvedValue(page(null)); + const otherArgs = encodeCursor({ v: 1, q: 'events', args: { popular: false }, raw: 'nope' }); + await runLoad({ cursor: otherArgs }); // default popular:true vs envelope popular:false + expect(mockDiscoverable.mock.calls[0][1].cursor).toBeUndefined(); + }); + + it('ignores a foreign-query envelope (fresh page 1)', async () => { + mockDiscoverable.mockResolvedValue(page(null)); + const foreign = encodeCursor({ + v: 1, + q: 'hosting', + args: { actor: 'did:plc:alice' }, + raw: 'nope' + }); + await runLoad({ cursor: foreign }); + expect(mockDiscoverable.mock.calls[0][1].cursor).toBeUndefined(); + }); +}); diff --git a/apps/web/src/routes/(app)/p/[actor]/hosting/page.server.test.ts b/apps/web/src/routes/(app)/p/[actor]/hosting/page.server.test.ts new file mode 100644 index 0000000..eec5d2e --- /dev/null +++ b/apps/web/src/routes/(app)/p/[actor]/hosting/page.server.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Page 1 of /p/[actor]/hosting comes from this route's load(); page 2 comes from +// the load-more registry's 'hosting' resumer. They are separate code paths that +// must issue the same authored + upcoming query, scoped to the same actor, so +// these drive both for real and compare what each one emitted. +vi.mock('$lib/actor', () => ({ + getActor: vi.fn(async () => 'did:plc:alice') +})); +vi.mock('$lib/contrail', () => ({ + getServerClient: vi.fn(() => ({})), + flattenEventRecords: vi.fn((records: unknown[]) => records), + getProfileFromContrail: vi.fn(async () => ({})), + listAuthoredEventsFromContrail: vi.fn(), + listDiscoverableEventsFromContrail: vi.fn() +})); +vi.mock('$lib/search/server/query', () => ({ + searchBackendFromEnv: vi.fn(() => null), + runEventSearchPage: vi.fn() +})); + +import { load } from './+page.server'; +import { runLoadMoreEvents } from '$lib/contrail/events-load-more'; +import { listAuthoredEventsFromContrail } from '$lib/contrail'; +import { decodeCursor, encodeCursor } from '$lib/contrail/cursor'; +import { FROZEN_NOW, expectSameQuery } from '$lib/contrail/continuity.test-utils'; + +const mockAuthored = vi.mocked(listAuthoredEventsFromContrail); + +const ACTOR = 'did:plc:alice'; +const env = { DB: {} } as unknown as App.Platform['env']; + +function event(actor: string, cursor?: string) { + const url = new URL(`https://atmo.test/p/${actor}/hosting`); + if (cursor) url.searchParams.set('cursor', cursor); + return { + params: { actor }, + url, + platform: { env: { DB: {} } } + } as unknown as Parameters[0]; +} + +const page = (cursor: string | null) => + ({ + records: [{ uri: 'at://did:plc:alice/community.lexicon.calendar.event/1' }], + profiles: [{ did: ACTOR, handle: 'alice' }], + cursor + }) as unknown as Awaited>; + +type LoadResult = { events: unknown[]; cursor: string | null }; +const runLoad = async (actor: string, cursor?: string) => + (await load(event(actor, cursor))) as LoadResult; + +beforeEach(() => vi.useFakeTimers({ toFake: ['Date'], now: FROZEN_NOW })); +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe('hosting page load ↔ resumer continuity', () => { + it('page-1 load and the hosting resumer issue the same query', async () => { + mockAuthored.mockResolvedValue(page('keyset-p1')); + const result = await runLoad(ACTOR); + const p1 = mockAuthored.mock.calls[0][1]; + + expect(decodeCursor(result.cursor)).toEqual({ + v: 1, + q: 'hosting', + args: { actor: ACTOR }, + raw: 'keyset-p1' + }); + + await runLoadMoreEvents(env, { cursor: result.cursor! }); + const p2 = mockAuthored.mock.calls[1][1]; + + expectSameQuery(p1, p2, 'keyset-p1'); + }); +}); + +describe('hosting deep-link ?cursor= guard', () => { + it('resumes a hosting envelope minted for THIS actor', async () => { + mockAuthored.mockResolvedValue(page(null)); + const inbound = encodeCursor({ v: 1, q: 'hosting', args: { actor: ACTOR }, raw: 'p2keyset' }); + await runLoad(ACTOR, inbound); + expect(mockAuthored.mock.calls[0][1].cursor).toBe('p2keyset'); + }); + + it('ignores a hosting envelope minted for a DIFFERENT actor (same q, diff args => fresh page 1)', async () => { + mockAuthored.mockResolvedValue(page(null)); + const otherActor = encodeCursor({ + v: 1, + q: 'hosting', + args: { actor: 'did:plc:bob' }, + raw: 'nope' + }); + await runLoad(ACTOR, otherActor); + expect(mockAuthored.mock.calls[0][1].cursor).toBeUndefined(); + }); + + it('ignores a foreign-query (past-events) envelope even for the same actor (fresh page 1)', async () => { + mockAuthored.mockResolvedValue(page(null)); + const foreign = encodeCursor({ v: 1, q: 'past-events', args: { actor: ACTOR }, raw: 'nope' }); + await runLoad(ACTOR, foreign); + expect(mockAuthored.mock.calls[0][1].cursor).toBeUndefined(); + }); +}); diff --git a/apps/web/src/routes/(app)/p/[actor]/past-events/page.server.test.ts b/apps/web/src/routes/(app)/p/[actor]/past-events/page.server.test.ts new file mode 100644 index 0000000..5d32006 --- /dev/null +++ b/apps/web/src/routes/(app)/p/[actor]/past-events/page.server.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Page 1 of /p/[actor]/past-events comes from this route's load(); page 2 comes +// from the load-more registry's 'past-events' resumer. They are separate code +// paths that must issue the same authored + past query — desc order under a +// startsAtMax upper bound, not startsAtMin — so these drive both for real and +// compare what each one emitted. +vi.mock('$lib/actor', () => ({ + getActor: vi.fn(async () => 'did:plc:alice') +})); +vi.mock('$lib/contrail', () => ({ + getServerClient: vi.fn(() => ({})), + flattenEventRecords: vi.fn((records: unknown[]) => records), + getProfileFromContrail: vi.fn(async () => ({})), + listAuthoredEventsFromContrail: vi.fn(), + listDiscoverableEventsFromContrail: vi.fn() +})); +vi.mock('$lib/search/server/query', () => ({ + searchBackendFromEnv: vi.fn(() => null), + runEventSearchPage: vi.fn() +})); + +import { load } from './+page.server'; +import { runLoadMoreEvents } from '$lib/contrail/events-load-more'; +import { listAuthoredEventsFromContrail } from '$lib/contrail'; +import { decodeCursor, encodeCursor } from '$lib/contrail/cursor'; +import { FROZEN_NOW, expectSameQuery } from '$lib/contrail/continuity.test-utils'; + +const mockAuthored = vi.mocked(listAuthoredEventsFromContrail); + +const ACTOR = 'did:plc:alice'; +const env = { DB: {} } as unknown as App.Platform['env']; + +function event(actor: string, cursor?: string) { + const url = new URL(`https://atmo.test/p/${actor}/past-events`); + if (cursor) url.searchParams.set('cursor', cursor); + return { + params: { actor }, + url, + platform: { env: { DB: {} } } + } as unknown as Parameters[0]; +} + +// Genuinely over before the frozen clock. +const ENDED = { + uri: 'at://did:plc:alice/community.lexicon.calendar.event/1', + startsAt: '2000-01-01T00:00:00Z' +}; +// Began before the frozen clock but has NOT finished. The startsAtMax bound +// admits it, so both sides have to exclude it themselves. +const ONGOING = { + uri: 'at://did:plc:alice/community.lexicon.calendar.event/ongoing', + startsAt: '2000-01-01T00:00:00Z', + endsAt: '2030-01-01T00:00:00Z' +}; + +const page = (cursor: string | null, records: unknown[] = [ENDED]) => + ({ + records, + profiles: [{ did: ACTOR, handle: 'alice' }], + cursor + }) as unknown as Awaited>; + +type LoadResult = { events: unknown[]; cursor: string | null }; +const runLoad = async (actor: string, cursor?: string) => + (await load(event(actor, cursor))) as LoadResult; + +beforeEach(() => vi.useFakeTimers({ toFake: ['Date'], now: FROZEN_NOW })); +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe('past-events page load ↔ resumer continuity', () => { + it('page-1 load and the past-events resumer issue the same query (desc, startsAtMax)', async () => { + mockAuthored.mockResolvedValue(page('keyset-p1')); + const result = await runLoad(ACTOR); + const p1 = mockAuthored.mock.calls[0][1]; + // The past query is bounded ABOVE by now, not below — the one route where + // swapping the bound would still look plausible. + expect(typeof p1.startsAtMax).toBe('string'); + expect(p1.startsAtMin).toBeUndefined(); + expect(p1.order).toBe('desc'); + + expect(decodeCursor(result.cursor)).toEqual({ + v: 1, + q: 'past-events', + args: { actor: ACTOR }, + raw: 'keyset-p1' + }); + + await runLoadMoreEvents(env, { cursor: result.cursor! }); + const p2 = mockAuthored.mock.calls[1][1]; + + expectSameQuery(p1, p2, 'keyset-p1'); + }); + + // Matching query bags are not enough here: this route also narrows the + // RESULT after the query returns. If only page 1 did that, an event that is + // still running would be filtered off page 1 and then resurface on page 2 of + // "past events". + it('page-1 load and the resumer both exclude an event that is still running', async () => { + mockAuthored.mockResolvedValue(page('keyset-p1', [ONGOING, ENDED])); + + const result = await runLoad(ACTOR); + expect(result.events).toEqual([ENDED]); + + const page2 = await runLoadMoreEvents(env, { cursor: result.cursor! }); + expect(page2.events).toEqual([ENDED]); + }); +}); + +describe('past-events deep-link ?cursor= guard', () => { + it('resumes a past-events envelope minted for THIS actor', async () => { + mockAuthored.mockResolvedValue(page(null)); + const inbound = encodeCursor({ + v: 1, + q: 'past-events', + args: { actor: ACTOR }, + raw: 'p2keyset' + }); + await runLoad(ACTOR, inbound); + expect(mockAuthored.mock.calls[0][1].cursor).toBe('p2keyset'); + }); + + it('ignores a past-events envelope minted for a DIFFERENT actor (same q, diff args => fresh page 1)', async () => { + mockAuthored.mockResolvedValue(page(null)); + const otherActor = encodeCursor({ + v: 1, + q: 'past-events', + args: { actor: 'did:plc:bob' }, + raw: 'nope' + }); + await runLoad(ACTOR, otherActor); + expect(mockAuthored.mock.calls[0][1].cursor).toBeUndefined(); + }); + + it('ignores a foreign-query (hosting) envelope even for the same actor (fresh page 1)', async () => { + mockAuthored.mockResolvedValue(page(null)); + const foreign = encodeCursor({ v: 1, q: 'hosting', args: { actor: ACTOR }, raw: 'nope' }); + await runLoad(ACTOR, foreign); + expect(mockAuthored.mock.calls[0][1].cursor).toBeUndefined(); + }); +}); diff --git a/apps/web/src/routes/(app)/search/page.server.test.ts b/apps/web/src/routes/(app)/search/page.server.test.ts index 4062a03..2a10ecc 100644 --- a/apps/web/src/routes/(app)/search/page.server.test.ts +++ b/apps/web/src/routes/(app)/search/page.server.test.ts @@ -1,14 +1,11 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -// The search load decides between two backends whose cursors are NOT +// The search load picks between two backends whose cursors are NOT // interchangeable: Meilisearch (offset cursor) and the D1 LIKE fallback (opaque -// keyset). Both now hand back a self-describing continuation ENVELOPE: the Meili -// path a 'search-meili' envelope, the D1 fallback a 'search-d1' envelope. The D1 -// fallback used to return cursor:null because load-more had no safe way to -// re-run the discoverable+startsAtMin query — the envelope closes that gap (and -// with it the earlier "search results stop after the first batch" limitation), -// so these tests now pin a REAL cursor on the D1 path, keyed to a query the -// load-more registry re-runs identically. +// keyset). Each hands back a self-describing continuation envelope naming its +// own query, so load-more resumes on the backend that produced the page. These +// pin the page-1 behaviour, the deep-link guard, and — for both backends — that +// page 1 and its resumer issue the same query. vi.mock('$lib/contrail', () => ({ getServerClient: vi.fn(() => ({})), flattenEventRecords: vi.fn((records: unknown[]) => records), @@ -20,14 +17,18 @@ vi.mock('$lib/search/server/query', () => ({ })); import { load } from './+page.server'; +import { runLoadMoreEvents } from '$lib/contrail/events-load-more'; import { listDiscoverableEventsFromContrail } from '$lib/contrail'; import { runEventSearchPage, searchBackendFromEnv } from '$lib/search/server/query'; import { decodeCursor, encodeCursor } from '$lib/contrail/cursor'; +import { FROZEN_NOW, expectSameQuery } from '$lib/contrail/continuity.test-utils'; const mockSearchBackendFromEnv = vi.mocked(searchBackendFromEnv); const mockRunEventSearchPage = vi.mocked(runEventSearchPage); const mockListDiscoverable = vi.mocked(listDiscoverableEventsFromContrail); +const env = { DB: {} } as unknown as App.Platform['env']; + type LoadResult = { events: unknown[]; handles: Record; @@ -46,7 +47,11 @@ function event(q: string, cursor?: string) { return { url, platform: { env: {} } } as unknown as Parameters[0]; } -afterEach(() => vi.clearAllMocks()); +beforeEach(() => vi.useFakeTimers({ toFake: ['Date'], now: FROZEN_NOW })); +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); describe('search page load', () => { it('returns early for an empty query without touching any backend', async () => { @@ -153,3 +158,60 @@ describe('search page load', () => { expect(mockListDiscoverable.mock.calls[0][1].cursor).toBeUndefined(); }); }); + +// Unlike the other routes the search term rides ?q=/input, NOT the envelope, so +// the resumer must be HANDED the term to reproduce page 1. These pin that once +// it is, the page-1 load and the resumer issue the same query — on whichever of +// the two backends served page 1. +describe('search page load ↔ resumer continuity', () => { + it('page-1 D1 fallback and the search-d1 resumer issue the same query', async () => { + mockSearchBackendFromEnv.mockReturnValue(null); + mockListDiscoverable.mockResolvedValue({ + records: [{ uri: 'at://did:plc:a/community.lexicon.calendar.event/1' }], + profiles: [{ did: 'did:plc:a', handle: 'alice' }], + cursor: 'keyset-p1' + } as unknown as Awaited>); + + const result = await runLoad('kite'); + const p1 = mockListDiscoverable.mock.calls[0][1]; + expect(decodeCursor(result.cursor)).toEqual({ v: 1, q: 'search-d1', raw: 'keyset-p1' }); + + // The term is absent from the envelope; the client re-supplies it as `q`. + await runLoadMoreEvents(env, { cursor: result.cursor!, q: 'kite' }); + const p2 = mockListDiscoverable.mock.calls[1][1]; + + expectSameQuery(p1, p2, 'keyset-p1'); + }); + + it('page-1 Meili and the search-meili resumer issue the same query, threading the raw offset', async () => { + mockSearchBackendFromEnv.mockReturnValue({ url: 'https://meili.test', apiKey: 'k' }); + mockRunEventSearchPage.mockResolvedValue({ + events: [{ uri: 'at://did:plc:a/community.lexicon.calendar.event/1' }], + handles: { 'did:plc:a': 'alice' }, + cursor: 'meili:20', + distances: {} + } as unknown as Awaited>); + + const result = await runLoad('kite'); + expect(decodeCursor(result.cursor)).toEqual({ v: 1, q: 'search-meili', raw: 'meili:20' }); + + await runLoadMoreEvents(env, { cursor: result.cursor!, q: 'kite' }); + + // Compare the whole options object both sides passed, not just `q` — an + // option added to the page-1 call alone (a filter, a page size, a locale) + // would search a different result set on page 2. + const p1 = mockRunEventSearchPage.mock.calls[0][2]; + const p2 = mockRunEventSearchPage.mock.calls[1][2]; + expectSameQuery(p1, p2, 'meili:20'); + + // The backend is part of the query's identity too — it carries the index + // being searched, so an offset from one index is meaningless against + // another. This is outside the options bag, so it needs its own pin. + expect(mockRunEventSearchPage.mock.calls[1][0]).toEqual( + mockRunEventSearchPage.mock.calls[0][0] + ); + + expect(p1.q).toBe('kite'); + expect(mockListDiscoverable).not.toHaveBeenCalled(); // never the D1 path + }); +}); diff --git a/apps/web/src/routes/(app)/topics/[slug]/page.server.test.ts b/apps/web/src/routes/(app)/topics/[slug]/page.server.test.ts index f3c8b9e..1ccc20a 100644 --- a/apps/web/src/routes/(app)/topics/[slug]/page.server.test.ts +++ b/apps/web/src/routes/(app)/topics/[slug]/page.server.test.ts @@ -1,23 +1,34 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -// The topic page used to return cursor:null (first-batch-only) because load-more -// had no safe way to re-run its discoverable + OR-search + startsAtMin query. -// The envelope closes that gap: the load now emits a self-describing 'topic' -// envelope carrying the slug, and the load-more registry re-derives the SAME -// OR-search from that slug server-side. These pin the page-1 side of that -// continuity plus the deep-link query-match rule. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Page 1 of /topics/[slug] comes from this route's load(); page 2 comes from the +// load-more registry's 'topic' resumer. Both derive the OR-search from the slug +// server-side, and both must issue the same discoverable query, so these drive +// both for real and compare what each one emitted — plus the deep-link guard. vi.mock('$lib/contrail', () => ({ getServerClient: vi.fn(() => ({})), flattenEventRecords: vi.fn((records: unknown[]) => records), - listDiscoverableEventsFromContrail: vi.fn() + listDiscoverableEventsFromContrail: vi.fn(), + listAuthoredEventsFromContrail: vi.fn() +})); +// events-load-more.ts imports the search module at load time; the topic query +// never hits it, so a null-backend stub keeps the import deterministic. $lib/topics +// is intentionally left REAL so orQueryFromSlug derives the same OR-search on both +// the page-1 and resumer sides. +vi.mock('$lib/search/server/query', () => ({ + searchBackendFromEnv: vi.fn(() => null), + runEventSearchPage: vi.fn() })); import { load } from './+page.server'; +import { runLoadMoreEvents } from '$lib/contrail/events-load-more'; import { listDiscoverableEventsFromContrail } from '$lib/contrail'; import { decodeCursor, encodeCursor } from '$lib/contrail/cursor'; +import { FROZEN_NOW, expectSameQuery } from '$lib/contrail/continuity.test-utils'; const mockListDiscoverable = vi.mocked(listDiscoverableEventsFromContrail); +const env = { DB: {} } as unknown as App.Platform['env']; + type LoadResult = { topic: { slug: string }; events: unknown[]; @@ -35,7 +46,11 @@ function event(slug: string, cursor?: string) { const run = async (slug: string, cursor?: string) => (await load(event(slug, cursor))) as unknown as LoadResult; -afterEach(() => vi.clearAllMocks()); +beforeEach(() => vi.useFakeTimers({ toFake: ['Date'], now: FROZEN_NOW })); +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); describe('topic page load', () => { it("builds a 'topic' envelope carrying the slug, deriving the OR-search server-side", async () => { @@ -80,7 +95,12 @@ describe('topic page load', () => { cursor: null } as unknown as Awaited>); - const inbound = encodeCursor({ v: 1, q: 'topic', args: { slug: 'technology' }, raw: 'p2keyset' }); + const inbound = encodeCursor({ + v: 1, + q: 'topic', + args: { slug: 'technology' }, + raw: 'p2keyset' + }); await run('technology', inbound); expect(mockListDiscoverable.mock.calls[0][1]).toMatchObject({ cursor: 'p2keyset' }); @@ -99,7 +119,7 @@ describe('topic page load', () => { expect(mockListDiscoverable.mock.calls[0][1].cursor).toBeUndefined(); }); - it("deep-link: ignores a topic envelope minted for a DIFFERENT slug (fresh page 1)", async () => { + it('deep-link: ignores a topic envelope minted for a DIFFERENT slug (fresh page 1)', async () => { mockListDiscoverable.mockResolvedValue({ records: [], profiles: [], @@ -119,3 +139,32 @@ describe('topic page load', () => { expect(mockListDiscoverable).not.toHaveBeenCalled(); }); }); + +describe('topic page load ↔ resumer continuity', () => { + it('page-1 load and the topic resumer issue the same query (same OR-search)', async () => { + mockListDiscoverable.mockResolvedValue({ + records: [{ uri: 'at://did:plc:a/community.lexicon.calendar.event/1' }], + profiles: [{ did: 'did:plc:a', handle: 'alice' }], + cursor: 'keyset-p1' + } as unknown as Awaited>); + + const result = await run('technology'); + const p1 = mockListDiscoverable.mock.calls[0][1]; + // Page 1 derives the OR-search server-side from the slug. + expect(p1.search).toBe('tech OR technology'); + expect(decodeCursor(result.cursor)).toEqual({ + v: 1, + q: 'topic', + args: { slug: 'technology' }, + raw: 'keyset-p1' + }); + + await runLoadMoreEvents(env, { cursor: result.cursor! }); + const p2 = mockListDiscoverable.mock.calls[1][1]; + + // The comparison covers `search`, so it also pins that the resumer re-derived + // the SAME 'tech OR technology' from the slug rather than trusting a client + // value or drifting to another spelling of the query. + expectSameQuery(p1, p2, 'keyset-p1'); + }); +});