메인 front status 조회는 접속 제한 gate만 적용하고 접속 점수는 기록하지 않는다. 실제 PostgreSQL 회귀 검증으로 access row가 생성되지 않는 경계를 고정한다.
392 lines
17 KiB
TypeScript
392 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,
|
|
'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('general.getFrontStatus', {}, 'che')).toBeUndefined();
|
|
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();
|
|
});
|
|
});
|