Files
core2026/app/game-api/test/generalAccessTracking.test.ts
T
Hide_D 48d94e5ff2 feat: Ref 호환 접속 제한과 벌점 초기화 구현
실행 중인 프로필에서만 접속 벌점을 누적하고 제한 임계값과 대상 경로를 Ref 순서에 맞춘다. 자기 턴 명령 성공 시 순간 점수를 같은 flush에서 초기화하며 월간 누적 감쇠는 유지한다. 제한 중 메인 자동 갱신과 실시간 구독을 중지하고 수동 갱신 성공 시 복구한다.
2026-08-15 18:49:41 +00:00

376 lines
16 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) => [{ 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.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(1);
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'));
});
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 () => [{ 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();
});
});