235 lines
9.6 KiB
TypeScript
235 lines
9.6 KiB
TypeScript
/**
|
|
* Unit test — GET /v1/profiles batch Cache-Control (#2).
|
|
*
|
|
* A batch response that OMITS a requested account carries a negative
|
|
* ("no profile") result, which is usually just indexer lag right after a
|
|
* profile broadcast or signup. Caching that for 90s pins it in the browser's
|
|
* HTTP cache, so the fresh profile stays invisible ACROSS PAGE REFRESHES (the
|
|
* client's in-memory cache is cleared by a reload; the browser's disk cache is
|
|
* not) — the display name falls back to "@account" and the avatar to the
|
|
* identicon, and refreshing changes nothing.
|
|
*
|
|
* So: complete batches stay cacheable; partial batches must be `no-store`.
|
|
* The db is stubbed — the SQL itself is covered by the integration test.
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import { Hono } from 'hono';
|
|
|
|
import { profilesRoute } from '$api/profiles';
|
|
import type { Database } from '$db/pool';
|
|
|
|
interface Row {
|
|
account: string;
|
|
// v1.5.5: nullable — the batch is anchored on `accounts`, so an account
|
|
// with a posting key but no profile op resolves with these NULL.
|
|
display_name: string | null;
|
|
json_metadata: unknown;
|
|
source_block_num: string | null;
|
|
updated_at: Date | null;
|
|
/** v1.5.5: does a `profiles` row exist? The completeness check keys off
|
|
* THIS, not row presence — see `profileLessRow` below. */
|
|
has_profile: boolean;
|
|
}
|
|
|
|
/** A row for an account that HAS a profile. */
|
|
function row(account: string): Row {
|
|
return {
|
|
account,
|
|
display_name: `${account} display`,
|
|
json_metadata: {},
|
|
source_block_num: '100',
|
|
updated_at: new Date('2026-07-08T00:00:00.000Z'),
|
|
has_profile: true
|
|
};
|
|
}
|
|
|
|
/**
|
|
* v1.5.5 — a row for an account that EXISTS on-chain but has never set a
|
|
* profile. Impossible before the batch was re-anchored on `accounts`: such an
|
|
* account simply returned nothing, which is why its posting key never reached
|
|
* the profile's review cards (t155, Ken: no truncated key under @kentest2).
|
|
*
|
|
* It is the whole reason the completeness check can no longer count rows. This
|
|
* row IS returned, but it is NOT a profile — pinning a batch containing one
|
|
* would hide that user's profile for the full 90s the moment they create it.
|
|
*/
|
|
function profileLessRow(account: string): Row {
|
|
return {
|
|
account,
|
|
display_name: null,
|
|
json_metadata: null,
|
|
source_block_num: null,
|
|
updated_at: null,
|
|
has_profile: false
|
|
};
|
|
}
|
|
|
|
/** Mount the route with a db stub that returns exactly `rows`. */
|
|
function mount(rows: Row[]): Hono {
|
|
const db = {
|
|
query: async () => ({ rows, rowCount: rows.length })
|
|
} as unknown as Database;
|
|
const app = new Hono();
|
|
app.route('/v1/profiles', profilesRoute(db));
|
|
return app;
|
|
}
|
|
|
|
describe('GET /v1/profiles — batch Cache-Control', () => {
|
|
it('caches a COMPLETE batch (every requested account resolved)', async () => {
|
|
const app = mount([row('alice'), row('bob')]);
|
|
const res = await app.request('/v1/profiles?accounts=alice,bob');
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
'public, max-age=90, stale-while-revalidate=60'
|
|
);
|
|
const body = (await res.json()) as { profiles: Record<string, unknown> };
|
|
expect(Object.keys(body.profiles).sort()).toEqual(['alice', 'bob']);
|
|
});
|
|
|
|
it('does NOT cache a batch containing a PROFILE-LESS account (v1.5.5)', async () => {
|
|
// The regression this exists for. v1.5.5 re-anchored the batch on
|
|
// `accounts` so a key-only account resolves (t155: the truncated posting
|
|
// key was missing under @kentest2, because the query started at
|
|
// `profiles` and returned NOTHING for anyone who never set one).
|
|
//
|
|
// That silently broke the negative-caching rule: bob now comes back as a
|
|
// ROW, so a `rows.length === accounts.length` completeness test calls
|
|
// this batch complete and pins it for 90 seconds — and bob stays
|
|
// invisible for a minute and a half after creating his first profile.
|
|
// Row presence stopped meaning "has a profile"; only has_profile does.
|
|
const app = mount([row('alice'), profileLessRow('bob')]);
|
|
const res = await app.request('/v1/profiles?accounts=alice,bob');
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('Cache-Control')).toBe('no-store');
|
|
});
|
|
|
|
it('still RETURNS the profile-less account, so its posting key is usable (v1.5.5)', async () => {
|
|
// Not cacheable is not the same as not useful: bob must still come back,
|
|
// key and all — that IS the t155 fix. His profile fields are simply null.
|
|
const app = mount([row('alice'), profileLessRow('bob')]);
|
|
const res = await app.request('/v1/profiles?accounts=alice,bob');
|
|
const body = (await res.json()) as {
|
|
profiles: Record<string, { display_name: string | null; updated_at: string | null }>;
|
|
};
|
|
expect(Object.keys(body.profiles).sort()).toEqual(['alice', 'bob']);
|
|
// And the null-safety half: rowToProfile used to call
|
|
// updated_at.toISOString() / parseInt(source_block_num) unconditionally,
|
|
// which THREW the moment a profile-less row could reach it.
|
|
expect(body.profiles.bob?.display_name).toBeNull();
|
|
expect(body.profiles.bob?.updated_at).toBeNull();
|
|
});
|
|
|
|
it('does NOT cache a PARTIAL batch (an account is missing → negative result)', async () => {
|
|
// bob has no profile row yet — e.g. he broadcast it one block ago.
|
|
const app = mount([row('alice')]);
|
|
const res = await app.request('/v1/profiles?accounts=alice,bob');
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('Cache-Control')).toBe('no-store');
|
|
const body = (await res.json()) as { profiles: Record<string, unknown> };
|
|
expect(Object.keys(body.profiles)).toEqual(['alice']);
|
|
});
|
|
|
|
it('does NOT cache a batch where NO account has a profile', async () => {
|
|
const app = mount([]);
|
|
const res = await app.request('/v1/profiles?accounts=alice,bob');
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('Cache-Control')).toBe('no-store');
|
|
});
|
|
|
|
it('a single-account batch that resolves stays cacheable', async () => {
|
|
const app = mount([row('alice')]);
|
|
const res = await app.request('/v1/profiles?accounts=alice');
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
'public, max-age=90, stale-while-revalidate=60'
|
|
);
|
|
});
|
|
|
|
it('duplicate account names are deduped before the completeness check', async () => {
|
|
// 'alice,alice' dedupes to one requested account; one row ⇒ complete.
|
|
const app = mount([row('alice')]);
|
|
const res = await app.request('/v1/profiles?accounts=alice,alice');
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
'public, max-age=90, stale-while-revalidate=60'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('GET /v1/profiles/:account — single lookup', () => {
|
|
it('does NOT let a 404 be heuristically cached', async () => {
|
|
const app = mount([]);
|
|
const res = await app.request('/v1/profiles/bob');
|
|
expect(res.status).toBe(404);
|
|
expect(res.headers.get('Cache-Control')).toBe('no-store');
|
|
});
|
|
|
|
it('returns the profile when it exists', async () => {
|
|
const app = mount([row('alice')]);
|
|
const res = await app.request('/v1/profiles/alice');
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { account: string };
|
|
expect(body.account).toBe('alice');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Server-side warm-positive cache (t.txt avatar latency). Ken: profiles "STILL
|
|
* taking up to 7 seconds … the server itself can cache the user avatars and
|
|
* display name text". The endpoint now serves warm POSITIVES from an in-memory
|
|
* cache and DB-queries only the misses, so a hot avatar/name returns without
|
|
* touching Postgres (and stops queueing behind the poller's block writes).
|
|
* Negatives are never cached — a fresh profile must not be hidden.
|
|
*/
|
|
describe('GET /v1/profiles — server-side warm-positive cache', () => {
|
|
/** Mount with a db stub that RECORDS the accounts of every query. */
|
|
function mountCounting(rowsFor: (accounts: string[]) => Row[]): {
|
|
app: Hono;
|
|
calls: string[][];
|
|
} {
|
|
const calls: string[][] = [];
|
|
const db = {
|
|
query: async (_sql: string, params: unknown[]) => {
|
|
const accounts = (Array.isArray(params) ? (params[0] as string[]) : []) ?? [];
|
|
calls.push(accounts);
|
|
const rows = rowsFor(accounts);
|
|
return { rows, rowCount: rows.length };
|
|
}
|
|
} as unknown as Database;
|
|
const app = new Hono();
|
|
app.route('/v1/profiles', profilesRoute(db));
|
|
return { app, calls };
|
|
}
|
|
|
|
it('serves a warm positive from cache WITHOUT re-querying the DB', async () => {
|
|
const { app, calls } = mountCounting((accts) => accts.map(row)); // all positive
|
|
await app.request('/v1/profiles?accounts=alice,bob');
|
|
await app.request('/v1/profiles?accounts=alice,bob');
|
|
// Second request is fully served from cache — no second DB query.
|
|
expect(calls.length).toBe(1);
|
|
});
|
|
|
|
it('never caches a profile-less account — re-queries only it next time', async () => {
|
|
const { app, calls } = mountCounting((accts) =>
|
|
accts.map((a) => (a === 'bob' ? profileLessRow(a) : row(a)))
|
|
);
|
|
await app.request('/v1/profiles?accounts=alice,bob');
|
|
await app.request('/v1/profiles?accounts=alice,bob');
|
|
// alice cached after call 1; bob (a negative) is never cached, so call 2
|
|
// must re-query — and for bob ONLY, since alice came from cache.
|
|
expect(calls.length).toBe(2);
|
|
expect(calls[1]).toEqual(['bob']);
|
|
});
|
|
|
|
it('a fully cache-served batch is still marked COMPLETE (cacheable)', async () => {
|
|
const { app } = mountCounting((accts) => accts.map(row));
|
|
await app.request('/v1/profiles?accounts=alice,bob'); // warms the cache
|
|
const res = await app.request('/v1/profiles?accounts=alice,bob'); // all from cache
|
|
expect(res.status).toBe(200);
|
|
// Completeness must count the cache-served positives, not just queried rows.
|
|
expect(res.headers.get('Cache-Control')).toBe(
|
|
'public, max-age=90, stale-while-revalidate=60'
|
|
);
|
|
const body = (await res.json()) as { profiles: Record<string, unknown> };
|
|
expect(Object.keys(body.profiles).sort()).toEqual(['alice', 'bob']);
|
|
});
|
|
});
|