대시보드 벌점을 read-model source와 outbox에서 제외하고 수동 projection의 가점을 Redis에 합산한다. 5초 worker가 월별 traffic과 접속 점수를 멱등 PostgreSQL batch로 반영하며 메인 제한 gate는 Redis TTL marker만 조회한다.
137 lines
6.2 KiB
TypeScript
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();
|
|
});
|
|
});
|