Files
core2026/app/game-api/test/dashboardSourceRevision.test.ts
T
Hide_D 5bc1e3473f perf: 접속 점수를 Redis 배치로 지연 반영
대시보드 벌점을 read-model source와 outbox에서 제외하고 수동 projection의 가점을 Redis에 합산한다. 5초 worker가 월별 traffic과 접속 점수를 멱등 PostgreSQL batch로 반영하며 메인 제한 gate는 Redis TTL marker만 조회한다.
2026-08-17 14:50:34 +00:00

137 lines
6.2 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import type { DatabaseClient } from '../src/context.js';
import {
readDashboardSourceRevisionState,
readDashboardSourceRevisionStateForUser,
} from '../src/services/dashboardSourceRevision.js';
const row = (overrides: Record<string, unknown> = {}) => ({
generalId: 7,
cityId: 3,
nationId: 2,
coverageVersion: 1,
globalRevision: 10n,
generalRevision: 11n,
cityRevision: 12n,
nationRevision: 13n,
worldRevision: 14n,
...overrides,
});
const read = async (value: unknown, authSource?: Parameters<typeof readDashboardSourceRevisionState>[2]) => {
const queryRaw = vi.fn(async (_query: unknown) => value);
const state = await readDashboardSourceRevisionState(
{ $queryRaw: queryRaw } as Pick<DatabaseClient, '$queryRaw'>,
7,
authSource
);
return { queryRaw, state };
};
describe('dashboard source revision', () => {
it('uses zero for missing revision rows and returns opaque 22-character hashes', async () => {
const { queryRaw, state } = await read([
row({
globalRevision: 0n,
generalRevision: 0n,
cityRevision: 0n,
nationRevision: 0n,
worldRevision: 0n,
}),
]);
expect(state?.coverageVersion).toBe(1);
expect(Object.values(state?.sourceRevisions ?? {})).toEqual([
expect.stringMatching(/^[A-Za-z0-9_-]{22}$/u),
expect.stringMatching(/^[A-Za-z0-9_-]{22}$/u),
expect.stringMatching(/^[A-Za-z0-9_-]{22}$/u),
]);
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string };
expect(statement.sql.match(/COALESCE\([^)]*\."revision", 0\)/gu)).toHaveLength(5);
});
it('hashes exactly the documented context, command, and board dependency vectors', async () => {
const initial = (await read([row()])).state;
const globalChanged = (await read([row({ globalRevision: 99n })])).state;
const cityChanged = (await read([row({ cityRevision: 99n })])).state;
const worldChanged = (await read([row({ worldRevision: 99n })])).state;
const nationChanged = (await read([row({ nationRevision: 99n })])).state;
if (!initial || !globalChanged || !cityChanged || !worldChanged || !nationChanged) {
throw new Error('source revision state missing');
}
expect(globalChanged.sourceRevisions.context).not.toBe(initial.sourceRevisions.context);
expect(globalChanged.sourceRevisions.commandTable).not.toBe(initial.sourceRevisions.commandTable);
expect(globalChanged.sourceRevisions.boardAccess).toBe(initial.sourceRevisions.boardAccess);
expect(cityChanged.sourceRevisions.context).not.toBe(initial.sourceRevisions.context);
expect(cityChanged.sourceRevisions.commandTable).not.toBe(initial.sourceRevisions.commandTable);
expect(cityChanged.sourceRevisions.boardAccess).toBe(initial.sourceRevisions.boardAccess);
expect(worldChanged.sourceRevisions.context).not.toBe(initial.sourceRevisions.context);
expect(worldChanged.sourceRevisions.commandTable).not.toBe(initial.sourceRevisions.commandTable);
expect(worldChanged.sourceRevisions.boardAccess).toBe(initial.sourceRevisions.boardAccess);
expect(nationChanged.sourceRevisions.context).not.toBe(initial.sourceRevisions.context);
expect(nationChanged.sourceRevisions.commandTable).not.toBe(initial.sourceRevisions.commandTable);
expect(nationChanged.sourceRevisions.boardAccess).not.toBe(initial.sourceRevisions.boardAccess);
});
it('resolves the authenticated actor and revision vector in one user-owned statement', async () => {
const queryRaw = vi.fn(async (_query: unknown) => [row()]);
await expect(
readDashboardSourceRevisionStateForUser(
{ $queryRaw: queryRaw } as Pick<DatabaseClient, '$queryRaw'>,
'viewer-7'
)
).resolves.toMatchObject({ identity: { generalId: 7 } });
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
expect(statement.sql).toContain('actor."user_id"');
expect(statement.values).toContain('viewer-7');
});
it('includes only the authenticated icon projection in the context source', async () => {
const initial = (
await read([row()], {
canUseGeneralPicture: true,
icons: [
{
id: 'icon-1',
picture: 'icon-a.png',
imageServer: 1,
createdAt: '2026-08-16T00:00:00.000Z',
},
],
})
).state;
const changed = (
await read([row()], {
canUseGeneralPicture: false,
icons: [
{
id: 'icon-1',
picture: 'icon-a.png',
imageServer: 1,
createdAt: '2026-08-16T00:00:00.000Z',
},
],
})
).state;
if (!initial || !changed) throw new Error('source revision state missing');
expect(changed.sourceRevisions.context).not.toBe(initial.sourceRevisions.context);
expect(changed.sourceRevisions.commandTable).toBe(initial.sourceRevisions.commandTable);
expect(changed.sourceRevisions.boardAccess).toBe(initial.sourceRevisions.boardAccess);
});
it('rejects missing meta/actor rows, malformed values, and query failures', async () => {
await expect(read([])).resolves.toMatchObject({ state: null });
await expect(read([row({ coverageVersion: -1 })])).resolves.toMatchObject({ state: null });
await expect(read([row({ generalRevision: 'not-a-revision' })])).resolves.toMatchObject({ state: null });
await expect(read([row({ generalRevision: true })])).resolves.toMatchObject({ state: null });
const db = {
$queryRaw: vi.fn(async () => Promise.reject(new Error('query failed'))),
} as unknown as Pick<DatabaseClient, '$queryRaw'>;
await expect(readDashboardSourceRevisionState(db, 7)).resolves.toBeNull();
});
});