468 lines
16 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|