서버가 권위 환경과 반복 seed를 준비하고 공용 logic 프로세서를 브라우저와 기존 서버 fallback이 함께 사용하도록 변경한다. production Chromium에서 고정 seed와 1000회 Node 결과 동등성을 검증한다.
393 lines
17 KiB
TypeScript
393 lines
17 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { TRPCError } from '@trpc/server';
|
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
|
import { z } from 'zod';
|
|
import type { GameApiContext } from '../src/context.js';
|
|
import type { DatabaseClient } from '../src/context.js';
|
|
|
|
import { accessAuthedInputProcedure, accessLimitAuthedProcedure, router } from '../src/trpc.js';
|
|
import {
|
|
accessPageWeights,
|
|
generalAccessEndpointWeights,
|
|
getGeneralAccessState,
|
|
recordGeneralAccess,
|
|
recordGeneralAccessWeight,
|
|
resolveGeneralAccessEndpointWeight,
|
|
resolveAccessWindows,
|
|
resolveGeneralScoreStartedAt,
|
|
} from '../src/services/generalAccess.js';
|
|
|
|
const profile = { id: 'che', name: 'che:default', scenario: 'default' };
|
|
const profileStatusSource = { get: vi.fn(async () => 'RUNNING' as const) };
|
|
const accessContext = (db: DatabaseClient, token: GameSessionTokenPayload | null = auth()) => ({
|
|
auth: token,
|
|
db,
|
|
profile,
|
|
profileStatusSource,
|
|
generalAccessTracking: true as const,
|
|
});
|
|
|
|
const auth = (roles = ['user']): GameSessionTokenPayload => ({
|
|
version: 1,
|
|
profile: 'che:default',
|
|
issuedAt: '2026-07-26T00:00:00.000Z',
|
|
expiresAt: '2026-07-27T00:00:00.000Z',
|
|
sessionId: 'access-session',
|
|
user: {
|
|
id: 'user-7',
|
|
username: 'user7',
|
|
displayName: '사용자7',
|
|
roles,
|
|
},
|
|
sanctions: {},
|
|
});
|
|
|
|
const buildDb = (
|
|
meta: Record<string, unknown> = {},
|
|
access: { lastRefresh: Date | null; refreshScore: number } | null = null
|
|
) => {
|
|
const executeRaw = vi.fn(async (_query: unknown) => 1);
|
|
const queryRaw = vi.fn(async (query: unknown) => {
|
|
const sql = (query as { sql?: string }).sql ?? '';
|
|
if (sql.includes('read_model_revision')) {
|
|
return [{ domain: 'access.general', entityId: 7, revision: 1n, outboxId: 1n }];
|
|
}
|
|
return [{ id: 41 }];
|
|
});
|
|
const transaction = vi.fn(
|
|
async (
|
|
callback: (client: { $executeRaw: typeof executeRaw; $queryRaw: typeof queryRaw }) => Promise<unknown>
|
|
) => callback({ $executeRaw: executeRaw, $queryRaw: queryRaw })
|
|
);
|
|
const findGeneral = vi.fn(async () => ({
|
|
id: 7,
|
|
userId: 'user-7',
|
|
turnTime: new Date('2026-07-26T03:10:00.000Z'),
|
|
}));
|
|
const findWorld = vi.fn(async () => ({
|
|
id: 3,
|
|
currentYear: 185,
|
|
currentMonth: 4,
|
|
tickSeconds: 600,
|
|
meta: {
|
|
opentime: '2026-07-25T00:00:00.000Z',
|
|
lastTurnTime: '2026-07-26T03:00:00.000Z',
|
|
...meta,
|
|
},
|
|
}));
|
|
const db = {
|
|
$transaction: transaction,
|
|
general: { findFirst: findGeneral },
|
|
generalAccessLog: { findUnique: vi.fn(async () => access) },
|
|
worldState: { findFirst: findWorld },
|
|
} as unknown as DatabaseClient;
|
|
return { db, executeRaw, queryRaw, transaction, findGeneral, findWorld };
|
|
};
|
|
|
|
describe('general access tracking', () => {
|
|
it('owns every migrated Ref call at one explicit server endpoint weight', () => {
|
|
expect(generalAccessEndpointWeights).toEqual({
|
|
'world.getGeneralDirectory': 2,
|
|
'public.getNpcList': 2,
|
|
'ranking.getBestGeneral': 1,
|
|
'ranking.getHallOfFame': 1,
|
|
'tournament.getSnapshot': 1,
|
|
'nation.getSecretGeneralList': 1,
|
|
'nation.getPersonnelInfo': 1,
|
|
'nation.getGeneralList': 1,
|
|
'general.ensureDieOnPrestartStatus': 1,
|
|
'nation.getStratFinan': 1,
|
|
'board.getArticles': 1,
|
|
'diplomacy.getLetters': 2,
|
|
'battle.getGeneralDetail': 1,
|
|
'betting.getList': 1,
|
|
'general.getFrontStatus': 1,
|
|
'yearbook.getHistory': 1,
|
|
'world.getGlobalInfo': 1,
|
|
'nation.getBattleCenter': 1,
|
|
'nation.getChiefCenter': 1,
|
|
'troop.getList': 1,
|
|
'board.writeArticle': 1,
|
|
'board.writeComment': 1,
|
|
'diplomacy.sendLetter': 1,
|
|
'diplomacy.respondLetter': 1,
|
|
'diplomacy.rollbackLetter': 1,
|
|
'diplomacy.destroyLetter': 1,
|
|
'general.buildNationCandidate': 1,
|
|
'general.dieOnPrestart': 1,
|
|
'general.instantRetreat': 1,
|
|
'messages.send': 1,
|
|
'turns.getCommandTable': 1,
|
|
'general.setMySetting': 0,
|
|
'npc.setNationPolicy': 0,
|
|
'npc.setNationPriority': 0,
|
|
'npc.setGeneralPriority': 0,
|
|
'battle.prepareSimulation': 0,
|
|
'battle.simulate': 0,
|
|
});
|
|
});
|
|
|
|
it('counts only current-profile yearbook reads like Ref Global.GetHistory', () => {
|
|
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', {}, 'che')).toBe(1);
|
|
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', { serverID: 'che' }, 'che')).toBe(1);
|
|
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', { serverID: 'hwe' }, 'che')).toBeNull();
|
|
expect(resolveGeneralAccessEndpointWeight('unknown.path', {}, 'che')).toBeUndefined();
|
|
});
|
|
|
|
it('keeps the legacy route weight while endpoint-owned directories use the server map', () => {
|
|
expect(accessPageWeights['nation-list']).toBe(2);
|
|
expect(generalAccessEndpointWeights['world.getGeneralDirectory']).toBe(2);
|
|
});
|
|
|
|
it('uses the latest processed game turn as the traffic period and score window', () => {
|
|
expect(
|
|
resolveAccessWindows(new Date('2026-07-26T03:14:15.000Z'), 600, {
|
|
lastTurnTime: '2026-07-26T03:10:00.000Z',
|
|
})
|
|
).toEqual({
|
|
periodStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
|
scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'),
|
|
});
|
|
expect(resolveGeneralScoreStartedAt(600, new Date('2026-07-26T03:20:00.000Z'))).toEqual(
|
|
new Date('2026-07-26T03:10:00.000Z')
|
|
);
|
|
});
|
|
|
|
it('uses the session user actor and the legacy page weight in one atomic upsert', async () => {
|
|
const { db, executeRaw, queryRaw, transaction, findGeneral } = buildDb();
|
|
const now = new Date('2026-07-26T03:05:00.000Z');
|
|
|
|
await expect(recordGeneralAccess(accessContext(db), 'nation-list', now)).resolves.toBe(true);
|
|
expect(findGeneral).toHaveBeenCalledWith({
|
|
where: { userId: 'user-7' },
|
|
orderBy: { id: 'asc' },
|
|
select: { id: true, userId: true, turnTime: true },
|
|
});
|
|
expect(transaction).toHaveBeenCalledTimes(1);
|
|
expect(queryRaw).toHaveBeenCalledTimes(2);
|
|
expect(executeRaw).toHaveBeenCalledTimes(2);
|
|
|
|
const periodStatement = queryRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
|
|
expect(periodStatement.sql).toContain('INSERT INTO traffic_period');
|
|
expect(periodStatement.sql).toContain('generate_series');
|
|
expect(periodStatement.sql).toContain('ON CONFLICT (world_state_id, year, month) DO UPDATE');
|
|
expect(periodStatement.values).toContain(3);
|
|
expect(periodStatement.values).toContain(185);
|
|
expect(periodStatement.values).toContain(4);
|
|
expect(periodStatement.values).toContain(600);
|
|
expect(periodStatement.values).toContain(2);
|
|
expect(periodStatement.values).toContain(now);
|
|
expect(periodStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z'));
|
|
|
|
const memberStatement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
|
|
expect(memberStatement.sql).toContain('INSERT INTO traffic_period_general');
|
|
expect(memberStatement.sql).toContain('ON CONFLICT (period_id, general_id) DO NOTHING');
|
|
expect(memberStatement.sql).toContain('SET online = traffic_period.online');
|
|
|
|
const accessStatement = executeRaw.mock.calls[1]![0] as { sql: string; values: unknown[] };
|
|
expect(accessStatement.sql).toContain('ON CONFLICT (general_id) DO UPDATE');
|
|
expect(accessStatement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh');
|
|
expect(accessStatement.values).toContain(7);
|
|
expect(accessStatement.values).toContain('user-7');
|
|
expect(accessStatement.values).toContain(2);
|
|
expect(accessStatement.values).toContain(now);
|
|
expect(accessStatement.values).toContainEqual(new Date('2026-07-26T03:00:00.000Z'));
|
|
|
|
const journalStatement = queryRaw.mock.calls[1]![0] as { sql: string; values: unknown[] };
|
|
expect(journalStatement.sql).toContain('INSERT INTO "read_model_outbox"');
|
|
expect(journalStatement.values).toContain('access.general');
|
|
expect(journalStatement.values).toContain(7);
|
|
});
|
|
|
|
it('accepts legacy weight zero to refresh timestamps without incrementing counters', async () => {
|
|
const { db, executeRaw, queryRaw, transaction } = buildDb();
|
|
const now = new Date('2026-07-26T03:06:00.000Z');
|
|
|
|
await expect(recordGeneralAccessWeight(accessContext(db), 0, now)).resolves.toBe(true);
|
|
expect(transaction).toHaveBeenCalledTimes(1);
|
|
expect((queryRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
|
|
expect((executeRaw.mock.calls[0]![0] as { values: unknown[] }).values).toContain(0);
|
|
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(0);
|
|
expect((executeRaw.mock.calls[1]![0] as { values: unknown[] }).values).toContain(now);
|
|
});
|
|
|
|
it('blocks above the strict limit and lazily clears a score from before the own turn', async () => {
|
|
const blocked = buildDb(
|
|
{ refreshLimit: 120 },
|
|
{ lastRefresh: new Date('2026-07-26T03:05:00.000Z'), refreshScore: 121 }
|
|
);
|
|
await expect(getGeneralAccessState(accessContext(blocked.db))).resolves.toMatchObject({
|
|
refreshScore: 121,
|
|
refreshLimit: 120,
|
|
level: 2,
|
|
nextAccessAt: new Date('2026-07-26T03:10:00.000Z'),
|
|
});
|
|
|
|
const stale = buildDb(
|
|
{ refreshLimit: 120 },
|
|
{ lastRefresh: new Date('2026-07-26T02:59:59.999Z'), refreshScore: 999 }
|
|
);
|
|
await expect(getGeneralAccessState(accessContext(stale.db))).resolves.toMatchObject({
|
|
refreshScore: 0,
|
|
level: 0,
|
|
});
|
|
|
|
const resolver = vi.fn(() => ({ ok: true }));
|
|
const limitedRouter = router({ read: accessLimitAuthedProcedure.query(resolver) });
|
|
await expect(
|
|
limitedRouter.createCaller(accessContext(blocked.db) as unknown as GameApiContext).read()
|
|
).rejects.toMatchObject({
|
|
code: 'TOO_MANY_REQUESTS',
|
|
message: expect.stringContaining('자신의 턴이 되면 다시 접속 가능합니다.'),
|
|
});
|
|
expect(resolver).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects weights that cannot come from a server-owned Ref call boundary', async () => {
|
|
const fixture = buildDb();
|
|
await expect(recordGeneralAccessWeight(accessContext(fixture.db), -1)).rejects.toBeInstanceOf(RangeError);
|
|
await expect(recordGeneralAccessWeight(accessContext(fixture.db), 0.5)).rejects.toBeInstanceOf(RangeError);
|
|
expect(fixture.findGeneral).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('parses input, commits access, and only then opens the failing business event boundary', async () => {
|
|
const events: string[] = [];
|
|
let transactionCount = 0;
|
|
const transactionClient = {
|
|
$queryRaw: vi.fn(async (query: unknown) => {
|
|
const sql = (query as { sql?: string }).sql ?? '';
|
|
return sql.includes('read_model_revision')
|
|
? [{ domain: 'access.general', entityId: 7, revision: 1n, outboxId: 1n }]
|
|
: [{ id: 41 }];
|
|
}),
|
|
$executeRaw: vi.fn(async () => 1),
|
|
inputEvent: {
|
|
update: vi.fn(async () => ({})),
|
|
},
|
|
};
|
|
const db = {
|
|
general: {
|
|
findFirst: vi.fn(async () => ({
|
|
id: 7,
|
|
userId: 'user-7',
|
|
turnTime: new Date('2026-07-26T03:10:00.000Z'),
|
|
})),
|
|
},
|
|
generalAccessLog: {
|
|
findUnique: vi.fn(async () => ({
|
|
lastRefresh: new Date('2026-07-26T03:05:00.000Z'),
|
|
refreshScore: 1,
|
|
})),
|
|
},
|
|
worldState: {
|
|
findFirst: vi.fn(async () => ({
|
|
id: 3,
|
|
currentYear: 185,
|
|
currentMonth: 4,
|
|
tickSeconds: 600,
|
|
meta: {
|
|
opentime: '2026-07-25T00:00:00.000Z',
|
|
lastTurnTime: '2026-07-26T03:00:00.000Z',
|
|
},
|
|
})),
|
|
},
|
|
inputEvent: {
|
|
create: vi.fn(async () => {
|
|
events.push('input-event-create');
|
|
return {};
|
|
}),
|
|
update: vi.fn(async () => {
|
|
events.push('input-event-failed');
|
|
return {};
|
|
}),
|
|
updateMany: vi.fn(async () => ({ count: 0 })),
|
|
},
|
|
$transaction: vi.fn(async (callback: (client: typeof transactionClient) => Promise<unknown>) => {
|
|
transactionCount += 1;
|
|
events.push(transactionCount === 1 ? 'access-transaction' : 'business-transaction');
|
|
return callback(transactionClient);
|
|
}),
|
|
};
|
|
const trackedRouter = router({
|
|
board: router({
|
|
writeArticle: accessAuthedInputProcedure(
|
|
z.object({
|
|
value: z.string().transform((value) => {
|
|
events.push('input-parse');
|
|
return value;
|
|
}),
|
|
})
|
|
).mutation(() => {
|
|
events.push('resolver');
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'business rejected' });
|
|
}),
|
|
}),
|
|
});
|
|
const context = {
|
|
auth: auth(),
|
|
db,
|
|
generalAccessTracking: true,
|
|
requestId: 'access-boundary-test',
|
|
profile: { id: 'che:default', name: 'che' },
|
|
profileStatusSource,
|
|
} as unknown as GameApiContext;
|
|
|
|
await expect(trackedRouter.createCaller(context).board.writeArticle({ value: 'ok' })).rejects.toMatchObject({
|
|
message: 'business rejected',
|
|
});
|
|
expect(events).toEqual([
|
|
'input-parse',
|
|
'access-transaction',
|
|
'input-event-create',
|
|
'business-transaction',
|
|
'resolver',
|
|
'input-event-failed',
|
|
]);
|
|
|
|
events.length = 0;
|
|
transactionCount = 0;
|
|
await expect(
|
|
trackedRouter.createCaller(context).board.writeArticle({ value: 7 } as never)
|
|
).rejects.toMatchObject({
|
|
code: 'BAD_REQUEST',
|
|
});
|
|
expect(events).toEqual([]);
|
|
});
|
|
|
|
it('does not write for anonymous/admin users, a future opening, or a finished world', async () => {
|
|
const anonymous = buildDb();
|
|
await expect(recordGeneralAccess(accessContext(anonymous.db, null), 'traffic')).resolves.toBe(false);
|
|
expect(anonymous.findGeneral).not.toHaveBeenCalled();
|
|
|
|
const admin = buildDb();
|
|
await expect(recordGeneralAccess(accessContext(admin.db, auth(['admin'])), 'traffic')).resolves.toBe(false);
|
|
expect(admin.findGeneral).not.toHaveBeenCalled();
|
|
|
|
const future = buildDb({ opentime: '2026-07-27T00:00:00.000Z' });
|
|
await expect(
|
|
recordGeneralAccess(accessContext(future.db), 'traffic', new Date('2026-07-26T03:05:00.000Z'))
|
|
).resolves.toBe(false);
|
|
expect(future.transaction).not.toHaveBeenCalled();
|
|
|
|
const united = buildDb({ isUnited: 2 });
|
|
await expect(recordGeneralAccess(accessContext(united.db), 'traffic')).resolves.toBe(false);
|
|
expect(united.transaction).not.toHaveBeenCalled();
|
|
|
|
const paused = buildDb();
|
|
const pausedContext = {
|
|
...accessContext(paused.db),
|
|
profileStatusSource: { get: vi.fn(async () => 'PAUSED' as const) },
|
|
};
|
|
await expect(recordGeneralAccess(pausedContext, 'traffic')).resolves.toBe(false);
|
|
expect(paused.findGeneral).not.toHaveBeenCalled();
|
|
|
|
const unavailable = buildDb();
|
|
const unavailableContext = {
|
|
...accessContext(unavailable.db),
|
|
profileStatusSource: { get: vi.fn(async () => Promise.reject(new Error('gateway unavailable'))) },
|
|
};
|
|
await expect(recordGeneralAccess(unavailableContext, 'traffic')).resolves.toBe(false);
|
|
expect(unavailable.findGeneral).not.toHaveBeenCalled();
|
|
});
|
|
});
|