Files
core2026/app/game-api/test/generalAccessTracking.integration.test.ts

468 lines
16 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { TRPCError } from '@trpc/server';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { z } from 'zod';
import type { GameApiContext } from '../src/context.js';
import { appRouter } from '../src/router.js';
import { upsertGeneralAccess } from '../src/services/generalAccess.js';
import { accessAuthedInputProcedure, router } from '../src/trpc.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const generalId = 9_980_071;
const secondGeneralId = generalId + 1;
const rollbackGeneralId = generalId + 2;
const zeroWeightGeneralId = generalId + 3;
const endpointGeneralId = generalId + 4;
const endpointUserId = `access-endpoint-user-${endpointGeneralId}`;
const scenarioCode = `traffic-period-${generalId}`;
const endpointRequestPrefix = `access-endpoint-${endpointGeneralId}`;
const yearbookProfile = `access-profile-${endpointGeneralId}`;
const endpointAuth = (roles = ['user']): GameSessionTokenPayload => ({
version: 1,
profile: `${yearbookProfile}:default`,
issuedAt: '2026-07-26T00:00:00.000Z',
expiresAt: '2026-07-27T00:00:00.000Z',
sessionId: `access-session-${endpointGeneralId}`,
user: {
id: endpointUserId,
username: endpointUserId,
displayName: endpointUserId,
roles,
},
sanctions: {},
});
const endpointBoundaryRouter = router({
world: router({
getGeneralDirectory: accessAuthedInputProcedure(z.object({ accepted: z.literal(true) })).query(() => ({
ok: true,
})),
}),
general: router({
setMySetting: accessAuthedInputProcedure(z.object({ accepted: z.literal(true) })).mutation(() => ({
ok: true,
})),
}),
board: router({
writeArticle: accessAuthedInputProcedure(z.object({ accepted: z.literal(true) })).mutation(() => {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'business rejected' });
}),
}),
});
integration('general access tracking persistence', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
let worldStateId: number;
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.generalAccessLog.deleteMany({
where: {
generalId: {
in: [generalId, secondGeneralId, rollbackGeneralId, zeroWeightGeneralId, endpointGeneralId],
},
},
});
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: endpointRequestPrefix } } });
await db.worldState.deleteMany({ where: { scenarioCode } });
const world = await db.worldState.create({
data: {
scenarioCode,
currentYear: 185,
currentMonth: 3,
tickSeconds: 600,
config: {},
meta: {},
},
});
worldStateId = world.id;
await db.general.deleteMany({ where: { id: endpointGeneralId } });
await db.general.create({
data: {
id: endpointGeneralId,
userId: endpointUserId,
name: '접속경계',
turnTime: new Date('2026-07-26T03:00:00.000Z'),
},
});
await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } });
await db.yearbookHistory.create({
data: {
profileName: yearbookProfile,
sourceId: 1,
year: 184,
month: 12,
map: {
year: 184,
month: 12,
startYear: 180,
cityList: [],
nationList: [],
},
nations: [],
globalHistory: ['기록'],
globalAction: ['행동'],
},
});
});
afterAll(async () => {
await db.generalAccessLog.deleteMany({
where: {
generalId: {
in: [generalId, secondGeneralId, rollbackGeneralId, zeroWeightGeneralId, endpointGeneralId],
},
},
});
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: endpointRequestPrefix } } });
await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } });
await db.general.deleteMany({ where: { id: endpointGeneralId } });
await db.worldState.deleteMany({ where: { id: worldStateId } });
await closeDb?.();
});
it('atomically increments one game-month bucket and opens a new bucket at the next month', async () => {
const firstWindow = {
worldStateId,
year: 185,
month: 3,
tickSeconds: 600,
generalId,
userId: 'access-user-a',
now: new Date('2026-07-26T03:05:00.000Z'),
periodStartedAt: new Date('2026-07-26T03:00:00.000Z'),
scoreStartedAt: new Date('2026-07-26T03:00:00.000Z'),
};
await upsertGeneralAccess(db, { ...firstWindow, weight: 2 });
await Promise.all(
Array.from({ length: 20 }, (_, index) =>
upsertGeneralAccess(db, {
...firstWindow,
now: new Date(firstWindow.now.getTime() + index + 1),
weight: 1,
})
)
);
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({
userId: 'access-user-a',
refresh: 22,
refreshTotal: 22,
refreshScore: 22,
refreshScoreTotal: 22,
});
const firstPeriod = await db.trafficPeriod.findUniqueOrThrow({
where: {
worldStateId_year_month: {
worldStateId,
year: 185,
month: 3,
},
},
include: { generals: { orderBy: { generalId: 'asc' } } },
});
expect(firstPeriod).toMatchObject({
refresh: 22,
online: 1,
generals: [
{
generalId,
userId: 'access-user-a',
refresh: 22,
},
],
});
await upsertGeneralAccess(db, {
worldStateId,
year: 185,
month: 4,
tickSeconds: 600,
generalId,
userId: 'access-user-b',
now: new Date('2026-07-26T03:15:00.000Z'),
periodStartedAt: new Date('2026-07-26T03:10:00.000Z'),
scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'),
weight: 1,
});
expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({
userId: 'access-user-b',
refresh: 1,
refreshTotal: 23,
refreshScore: 1,
refreshScoreTotal: 23,
});
await upsertGeneralAccess(db, {
worldStateId,
year: 185,
month: 4,
tickSeconds: 600,
generalId: secondGeneralId,
userId: 'access-user-c',
now: new Date('2026-07-26T03:15:01.000Z'),
periodStartedAt: new Date('2026-07-26T03:10:00.000Z'),
scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'),
weight: 1,
});
await expect(
db.trafficPeriod.findUniqueOrThrow({
where: {
worldStateId_year_month: {
worldStateId,
year: 185,
month: 4,
},
},
include: { generals: { orderBy: { generalId: 'asc' } } },
})
).resolves.toMatchObject({
refresh: 2,
online: 2,
generals: [
{
generalId,
userId: 'access-user-b',
refresh: 1,
},
{
generalId: secondGeneralId,
userId: 'access-user-c',
refresh: 1,
},
],
});
await upsertGeneralAccess(db, {
worldStateId,
year: 185,
month: 6,
tickSeconds: 600,
generalId,
userId: 'access-user-b',
now: new Date('2026-07-26T03:35:00.000Z'),
periodStartedAt: new Date('2026-07-26T03:30:00.000Z'),
scoreStartedAt: new Date('2026-07-26T03:30:00.000Z'),
weight: 1,
});
await expect(
db.trafficPeriod.findUniqueOrThrow({
where: {
worldStateId_year_month: {
worldStateId,
year: 185,
month: 5,
},
},
})
).resolves.toMatchObject({
startedAt: new Date('2026-07-26T03:20:00.000Z'),
lastRefresh: new Date('2026-07-26T03:30:00.000Z'),
refresh: 0,
online: 0,
});
});
it('rolls back the period and member rows when the legacy access update fails', async () => {
await db.generalAccessLog.create({
data: {
generalId: rollbackGeneralId,
userId: 'rollback-user',
lastRefresh: new Date('2026-07-26T03:19:00.000Z'),
refresh: 1,
refreshTotal: 2_147_483_647,
refreshScore: 1,
refreshScoreTotal: 1,
},
});
await expect(
upsertGeneralAccess(db, {
worldStateId,
year: 186,
month: 1,
tickSeconds: 600,
generalId: rollbackGeneralId,
userId: 'rollback-user',
now: new Date('2026-07-26T03:20:00.000Z'),
periodStartedAt: new Date('2026-07-26T03:20:00.000Z'),
scoreStartedAt: new Date('2026-07-26T03:20:00.000Z'),
weight: 1,
})
).rejects.toThrow();
await expect(
db.trafficPeriod.findUnique({
where: {
worldStateId_year_month: {
worldStateId,
year: 186,
month: 1,
},
},
})
).resolves.toBeNull();
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: rollbackGeneralId } })
).resolves.toMatchObject({
refreshTotal: 2_147_483_647,
});
});
it('records weight zero membership and last refresh without changing counters', async () => {
const now = new Date('2026-07-26T03:40:00.000Z');
await upsertGeneralAccess(db, {
worldStateId,
year: 186,
month: 2,
tickSeconds: 600,
generalId: zeroWeightGeneralId,
userId: 'zero-weight-user',
now,
periodStartedAt: now,
scoreStartedAt: now,
weight: 0,
});
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: zeroWeightGeneralId } })
).resolves.toMatchObject({
userId: 'zero-weight-user',
lastRefresh: now,
refresh: 0,
refreshTotal: 0,
refreshScore: 0,
refreshScoreTotal: 0,
});
await expect(
db.trafficPeriod.findUniqueOrThrow({
where: {
worldStateId_year_month: {
worldStateId,
year: 186,
month: 2,
},
},
include: { generals: true },
})
).resolves.toMatchObject({
refresh: 0,
online: 1,
generals: [
{
generalId: zeroWeightGeneralId,
userId: 'zero-weight-user',
refresh: 0,
lastRefresh: now,
},
],
});
});
it('runs parser, access, business event, zero-weight, admin, and yearbook cache boundaries on PostgreSQL', async () => {
const context = {
auth: endpointAuth(),
db,
generalAccessTracking: true,
requestId: endpointRequestPrefix,
profile: {
id: `${yearbookProfile}:default`,
name: yearbookProfile,
scenario: 'default',
},
} as unknown as GameApiContext;
const boundaryCaller = endpointBoundaryRouter.createCaller(context);
await expect(boundaryCaller.world.getGeneralDirectory({ accepted: false as true })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull();
await expect(boundaryCaller.world.getGeneralDirectory({ accepted: true })).resolves.toEqual({ ok: true });
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({
refresh: 2,
refreshTotal: 2,
});
await expect(boundaryCaller.general.setMySetting({ accepted: true })).resolves.toEqual({ ok: true });
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({
refresh: 2,
refreshTotal: 2,
});
await expect(boundaryCaller.board.writeArticle({ accepted: true })).rejects.toMatchObject({
code: 'BAD_REQUEST',
message: 'business rejected',
});
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({
refresh: 3,
refreshTotal: 3,
});
const adminCaller = endpointBoundaryRouter.createCaller({
...context,
auth: endpointAuth(['admin']),
});
await expect(adminCaller.world.getGeneralDirectory({ accepted: true })).resolves.toEqual({ ok: true });
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({
refresh: 3,
refreshTotal: 3,
});
const yearbookCaller = appRouter.createCaller({
...context,
requestId: `${endpointRequestPrefix}-yearbook`,
});
const first = await yearbookCaller.yearbook.getHistory({ year: 184, month: 12 });
expect(first.notModified).toBe(false);
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({
refresh: 4,
refreshTotal: 4,
});
const cached = await yearbookCaller.yearbook.getHistory({
year: 184,
month: 12,
hash: first.hash,
});
expect(cached).toEqual({ notModified: true, hash: first.hash });
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({
refresh: 4,
refreshTotal: 4,
});
await yearbookCaller.yearbook.getHistory({
year: 184,
month: 12,
hash: 'stale-hash',
});
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({
refresh: 5,
refreshTotal: 5,
});
});
});