Files
core2026/app/game-api/test/dashboardRouter.test.ts
T
Hide_D 1a77bb3df1 feat: dashboard source coverage를 안전하게 활성화
엔진의 부대·예약턴·집계 변화와 mailbox writer를 저널에 연결하고 dashboard.global 및 인증 아이콘 source를 dependency vector에 포함한다. post-deploy coverage v1 활성화는 shared head seed와 CAS를 한 transaction으로 수행한다.
2026-08-16 18:51:17 +00:00

306 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { applyReadModelDelta } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { GameApiContext } from '../src/context.js';
import { dashboardRouter } from '../src/router/dashboard/index.js';
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'hwe:default',
issuedAt: '2026-08-11T00:00:00.000Z',
expiresAt: '2026-08-12T00:00:00.000Z',
sessionId: 'dashboard-delta-session',
user: {
id: 'viewer-1',
username: 'dashboard-viewer',
displayName: '대시보드 사용자',
roles: [],
},
sanctions: {},
};
const buildContext = (authenticated: boolean, generalAccessTracking = false) => {
let generalName = '초기 장수';
const redisValues = new Map<string, string>();
const findGeneral = vi.fn(async () => ({
id: 7,
name: generalName,
npcState: 0,
nationId: 0,
cityId: 0,
troopId: 0,
picture: null,
imageServer: 0,
leadership: 70,
strength: 60,
intel: 50,
officerLevel: 0,
gold: 1_000,
rice: 2_000,
crew: 300,
train: 80,
atmos: 90,
injury: 0,
experience: 100,
dedication: 200,
age: 20,
turnTime: new Date('2026-08-11T00:10:00.000Z'),
crewTypeId: 0,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
weaponCode: 'None',
horseCode: 'None',
bookCode: 'None',
itemCode: 'None',
meta: {},
penalty: {},
}));
const findCity = vi.fn(async () => null);
const findNation = vi.fn(async () => null);
const findWorld = vi.fn(async () => ({
currentYear: 185,
currentMonth: 1,
tickSeconds: 600,
config: { const: {} },
meta: { lastTurnTime: '2026-08-11T00:00:00.000Z' },
}));
const context = {
auth: authenticated ? auth : null,
profile: { id: 'hwe', scenario: 'default', name: 'hwe:default' },
generalAccessTracking,
redis: {
get: async (key: string) => redisValues.get(key) ?? null,
set: async (key: string, value: string) => {
redisValues.set(key, value);
return 'OK';
},
},
db: {
general: {
findFirst: findGeneral,
},
city: { findUnique: findCity },
nation: { findUnique: findNation },
generalAccessLog: { findUnique: async () => null },
worldState: { findFirst: findWorld },
},
} as unknown as GameApiContext;
return {
context,
rename: (name: string) => {
generalName = name;
},
findGeneral,
findCity,
findNation,
findWorld,
};
};
const installSourceRevisionState = (
context: GameApiContext,
initial: Partial<{
coverageVersion: number;
generalRevision: bigint;
cityRevision: bigint;
nationRevision: bigint;
worldRevision: bigint;
accessRevision: bigint;
}> = {}
) => {
let row = {
generalId: 7,
cityId: 0,
nationId: 0,
coverageVersion: 1,
globalRevision: 1n,
generalRevision: 1n,
cityRevision: 0n,
nationRevision: 0n,
worldRevision: 1n,
accessRevision: 1n,
...initial,
};
const queryRaw = vi.fn(async () => [row]);
Object.assign(context.db, { $queryRaw: queryRaw });
context.realtimeAccessGeneralId = 7;
return {
queryRaw,
update: (next: Partial<typeof row>) => {
row = { ...row, ...next };
},
};
};
const contextOnly = {
include: { context: true, commandTable: false, boardAccess: false },
};
describe('dashboardRouter.getContextBundleDelta', () => {
it('returns a snapshot, unchanged revision, and applicable patch for the authenticated viewer', async () => {
const fixture = buildContext(true);
const caller = dashboardRouter.createCaller(fixture.context);
const initial = await caller.getContextBundleDelta({ ...contextOnly, forceSnapshot: true });
expect(initial.context?.kind).toBe('snapshot');
if (!initial.context || initial.context.kind !== 'snapshot') throw new Error('initial snapshot missing');
if (!initial.context.data) throw new Error('initial general context missing');
const initialData = initial.context.data;
const initialRevision = initial.context.revision;
const unchanged = await caller.getContextBundleDelta({
...contextOnly,
known: { context: initialRevision },
});
expect(unchanged.context).toEqual({ kind: 'unchanged', revision: initialRevision });
fixture.rename('갱신된 장수');
const changed = await caller.getContextBundleDelta({
...contextOnly,
known: { context: initialRevision },
});
expect(changed.context?.kind).toBe('patch');
if (!changed.context) throw new Error('context delta missing');
const applied = applyReadModelDelta(initialData, initialRevision, changed.context).data;
if (!applied) throw new Error('patched general context missing');
expect(applied.general.name).toBe('갱신된 장수');
expect(Buffer.byteLength(JSON.stringify(changed))).toBeLessThan(1_000);
});
it('rejects anonymous requests before reading dashboard data', async () => {
const fixture = buildContext(false);
await expect(
dashboardRouter.createCaller(fixture.context).getContextBundleDelta(contextOnly)
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
});
it('uses an all-false bundle as an access-only gate without projecting dashboard context', async () => {
const fixture = buildContext(true, true);
const queryRaw = vi.fn(async (_query: unknown) => []);
Object.assign(fixture.context.db, { $queryRaw: queryRaw });
await expect(
dashboardRouter.createCaller(fixture.context).getContextBundleDelta({
include: { context: false, commandTable: false, boardAccess: false },
})
).resolves.toEqual({ context: undefined, commandTable: undefined, boardAccess: undefined });
expect(fixture.findGeneral).toHaveBeenCalledTimes(1);
expect(fixture.findGeneral).toHaveBeenCalledWith({
where: { userId: auth.user.id },
orderBy: { id: 'asc' },
select: { id: true, turnTime: true },
});
expect(queryRaw).not.toHaveBeenCalled();
});
it('returns revision-first unchanged without running the projection loader', async () => {
const fixture = buildContext(true);
const source = installSourceRevisionState(fixture.context);
const caller = dashboardRouter.createCaller(fixture.context);
const initial = await caller.getContextBundleDelta({ ...contextOnly, forceSnapshot: true });
if (!initial.context || initial.context.kind !== 'snapshot' || !initial.context.sourceRevision) {
throw new Error('initial source revision missing');
}
fixture.findGeneral.mockClear();
fixture.findCity.mockClear();
fixture.findNation.mockClear();
fixture.findWorld.mockClear();
const unchanged = await caller.getContextBundleDelta({
...contextOnly,
known: { context: initial.context.revision },
knownSource: { context: initial.context.sourceRevision },
});
expect(unchanged.context).toEqual({
kind: 'unchanged',
revision: initial.context.revision,
sourceRevision: initial.context.sourceRevision,
});
expect(source.queryRaw).toHaveBeenCalledTimes(2);
expect(fixture.findGeneral).not.toHaveBeenCalled();
expect(fixture.findCity).not.toHaveBeenCalled();
expect(fixture.findNation).not.toHaveBeenCalled();
expect(fixture.findWorld).not.toHaveBeenCalled();
});
it('falls back to full computation while coverage is zero', async () => {
const fixture = buildContext(true);
installSourceRevisionState(fixture.context, { coverageVersion: 0 });
const caller = dashboardRouter.createCaller(fixture.context);
const initial = await caller.getContextBundleDelta({ ...contextOnly, forceSnapshot: true });
if (!initial.context || initial.context.kind !== 'snapshot' || !initial.context.sourceRevision) {
throw new Error('initial source revision missing');
}
fixture.findGeneral.mockClear();
const unchanged = await caller.getContextBundleDelta({
...contextOnly,
known: { context: initial.context.revision },
knownSource: { context: initial.context.sourceRevision },
});
expect(unchanged.context).toMatchObject({ kind: 'unchanged', revision: initial.context.revision });
expect(fixture.findGeneral).toHaveBeenCalledTimes(1);
});
it('advances source revision when source changes but canonical content does not', async () => {
const fixture = buildContext(true);
const source = installSourceRevisionState(fixture.context);
const caller = dashboardRouter.createCaller(fixture.context);
const initial = await caller.getContextBundleDelta({ ...contextOnly, forceSnapshot: true });
if (!initial.context || initial.context.kind !== 'snapshot' || !initial.context.sourceRevision) {
throw new Error('initial source revision missing');
}
source.update({ generalRevision: 2n });
fixture.findGeneral.mockClear();
const unchanged = await caller.getContextBundleDelta({
...contextOnly,
known: { context: initial.context.revision },
knownSource: { context: initial.context.sourceRevision },
});
expect(unchanged.context).toMatchObject({ kind: 'unchanged', revision: initial.context.revision });
expect(unchanged.context?.sourceRevision).not.toBe(initial.context.sourceRevision);
expect(fixture.findGeneral).toHaveBeenCalledTimes(1);
});
it('keeps old content-only clients on the existing full-computation path', async () => {
const fixture = buildContext(true);
installSourceRevisionState(fixture.context);
const caller = dashboardRouter.createCaller(fixture.context);
const initial = await caller.getContextBundleDelta({ ...contextOnly, forceSnapshot: true });
if (!initial.context || initial.context.kind !== 'snapshot') throw new Error('initial snapshot missing');
fixture.findGeneral.mockClear();
const unchanged = await caller.getContextBundleDelta({
...contextOnly,
known: { context: initial.context.revision },
});
expect(unchanged.context).toMatchObject({ kind: 'unchanged', revision: initial.context.revision });
expect(fixture.findGeneral).toHaveBeenCalledTimes(1);
});
it('falls back to full computation when the revision-head query fails', async () => {
const fixture = buildContext(true);
Object.assign(fixture.context.db, {
$queryRaw: vi.fn(async () => Promise.reject(new Error('revision table unavailable'))),
});
fixture.context.realtimeAccessGeneralId = 7;
const result = await dashboardRouter.createCaller(fixture.context).getContextBundleDelta({
...contextOnly,
known: { context: 'A'.repeat(22) },
knownSource: { context: 'B'.repeat(22) },
});
expect(result.context?.kind).toBe('snapshot');
expect(fixture.findGeneral).toHaveBeenCalledTimes(1);
});
});