300 lines
13 KiB
TypeScript
300 lines
13 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, router } from '../src/trpc.js';
|
|
import {
|
|
accessPageWeights,
|
|
generalAccessEndpointWeights,
|
|
recordGeneralAccess,
|
|
recordGeneralAccessWeight,
|
|
resolveGeneralAccessEndpointWeight,
|
|
resolveAccessWindows,
|
|
} from '../src/services/generalAccess.js';
|
|
|
|
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> = {}) => {
|
|
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' }));
|
|
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 },
|
|
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,
|
|
'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'),
|
|
});
|
|
});
|
|
|
|
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({ auth: auth(), db }, 'nation-list', now)).resolves.toBe(true);
|
|
expect(findGeneral).toHaveBeenCalledWith({
|
|
where: { userId: 'user-7' },
|
|
orderBy: { id: 'asc' },
|
|
select: { id: true, userId: 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({ auth: auth(), 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('rejects weights that cannot come from a server-owned Ref call boundary', async () => {
|
|
const fixture = buildDb();
|
|
await expect(recordGeneralAccessWeight({ auth: auth(), db: fixture.db }, -1)).rejects.toBeInstanceOf(
|
|
RangeError
|
|
);
|
|
await expect(recordGeneralAccessWeight({ auth: auth(), db: 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',
|
|
})),
|
|
},
|
|
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' },
|
|
} 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({ auth: null, db: anonymous.db }, 'traffic')).resolves.toBe(false);
|
|
expect(anonymous.findGeneral).not.toHaveBeenCalled();
|
|
|
|
const admin = buildDb();
|
|
await expect(recordGeneralAccess({ auth: auth(['admin']), db: admin.db }, 'traffic')).resolves.toBe(false);
|
|
expect(admin.findGeneral).not.toHaveBeenCalled();
|
|
|
|
const future = buildDb({ opentime: '2026-07-27T00:00:00.000Z' });
|
|
await expect(
|
|
recordGeneralAccess({ auth: auth(), db: 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({ auth: auth(), db: united.db }, 'traffic')).resolves.toBe(false);
|
|
expect(united.transaction).not.toHaveBeenCalled();
|
|
});
|
|
});
|