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) | 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, }); }); });