feat: 외교 문서 변경을 입력 원장과 함께 감사 이력으로 저장

This commit is contained in:
2026-09-16 05:46:11 +00:00
parent 8faab62866
commit b9d9b63b14
15 changed files with 576 additions and 45 deletions
+3
View File
@@ -1,4 +1,5 @@
import { z } from 'zod';
import type { ApiInputExecutionContext } from './inputEventBoundary.js';
import type { ChangeJournal } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { DatabaseClient as InfraDatabaseClient, RedisConnector, GamePrisma } from '@sammo-ts/infra';
@@ -99,6 +100,8 @@ export type InputJsonValue = GamePrisma.InputJsonValue;
export type DatabaseClient = InfraDatabaseClient;
export interface GameApiContext {
/** 현재 API 업무 transaction이 잠근 입력 원장의 인증된 식별자다. */
auditInput?: ApiInputExecutionContext;
requestId?: string;
generalAccessTracking?: boolean;
/** Validated server-issued proof for one realtime refresh burst. */
+18 -2
View File
@@ -18,6 +18,7 @@ export interface ApiInputPayloadIdentity {
}
interface LockedInputEvent {
sequence: bigint;
target: 'API' | 'ENGINE';
eventType: string;
payload: GamePrisma.JsonValue;
@@ -27,6 +28,14 @@ interface LockedInputEvent {
attempts: number;
}
export interface ApiInputExecutionContext {
requestId: string;
sequence: bigint;
actorUserId: string | null;
eventType: string;
attempt: number;
}
type InputEventOutcome<T> =
{ kind: 'executed'; value: T } | { kind: 'replayed'; value: T } | { kind: 'failed'; error: unknown };
@@ -115,6 +124,7 @@ const lockInputEvent = async (db: DatabaseClient, requestId: string): Promise<Lo
const rows = await db.$queryRaw<LockedInputEvent[]>(
GamePrisma.sql`
SELECT
sequence,
target,
event_type AS "eventType",
payload,
@@ -235,7 +245,7 @@ export const executeInputEvent = async <T>(options: {
payload: unknown;
actorUserId?: string | null;
acquireClockFence?: boolean;
execute(db: DatabaseClient): Promise<T>;
execute(db: DatabaseClient, context?: ApiInputExecutionContext): Promise<T>;
}): Promise<T> => {
const { db, requestId, eventType, payload, execute } = options;
const actorUserId = options.actorUserId ?? null;
@@ -275,7 +285,13 @@ export const executeInputEvent = async <T>(options: {
await savepointDb.$executeRawUnsafe(`SAVEPOINT ${BUSINESS_SAVEPOINT}`);
businessStarted = true;
try {
const value = await execute(transaction);
const value = await execute(transaction, {
requestId,
sequence: row.sequence,
actorUserId,
eventType,
attempt: row.attempts + 1,
});
const durableResult = canonicalJsonValue(value);
await transaction.$executeRaw(GamePrisma.sql`
UPDATE input_event
+42 -30
View File
@@ -15,6 +15,7 @@ import {
import type { GameApiContext, GeneralRow, NationRow } from '../../context.js';
import { insertMessage } from '../../messages/store.js';
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js';
import { createDiplomacyDocumentAudit, projectAuditDocumentState } from '../../services/playAuditDiplomacy.js';
import { readDatabaseWallTime } from '../../services/wallClock.js';
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
@@ -216,6 +217,7 @@ export const diplomacyRouter = router({
assertNationAccess(me);
const permission = await resolvePermissionLevel(ctx, me.nationId);
const audit = createDiplomacyDocumentAudit(ctx, me, permission);
if (permission < 4) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
@@ -262,20 +264,21 @@ export const diplomacyRouter = router({
}
if (prevLetter.state === 'PROPOSED') {
const before = projectAuditDocumentState(prevLetter);
const aux = asRecord(prevLetter.aux);
aux.reason = {
who: me.id,
action: 'new_letter',
reason: 'new_letter',
};
await ctx.db.diplomacyLetter.update({
const updated = await ctx.db.diplomacyLetter.update({
where: { id: prevId },
data: { state: 'REPLACED', aux: aux as GamePrisma.InputJsonValue },
});
audit.record(updated, before, 'LETTER_REPLACED');
}
destNationId =
prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
destNationId = prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId;
}
const nations = await ctx.db.nation.findMany({
@@ -320,6 +323,7 @@ export const diplomacyRouter = router({
},
});
audit.record(created, null, 'LETTER_PROPOSED');
const letterIdText = String(created.id);
const josaYi = JosaUtil.pick(letterIdText, '이');
const text = prevId
@@ -333,6 +337,7 @@ export const diplomacyRouter = router({
time: created.date,
});
await audit.flush();
return { id: created.id };
}),
respondLetter: accessAuthedInputProcedure(
@@ -347,6 +352,7 @@ export const diplomacyRouter = router({
assertNationAccess(me);
const permission = await resolvePermissionLevel(ctx, me.nationId);
const audit = createDiplomacyDocumentAudit(ctx, me, permission);
if (permission < 4) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
@@ -362,14 +368,11 @@ export const diplomacyRouter = router({
throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' });
}
const { srcNation, destNation } = await loadLetterNations(
ctx,
letter.srcNationId,
letter.destNationId
);
const { srcNation, destNation } = await loadLetterNations(ctx, letter.srcNationId, letter.destNationId);
const messageSrc = buildActorTarget(me, destNation);
const messageDest = buildNationTarget(srcNation);
const messageTime = await readDatabaseWallTime(ctx.db);
const before = projectAuditDocumentState(letter);
const aux = asRecord(letter.aux);
let messageText: string;
if (input.agree) {
@@ -379,7 +382,7 @@ export const diplomacyRouter = router({
dest.generalIcon = messageSrc.icon;
aux.dest = dest;
await ctx.db.diplomacyLetter.update({
const updated = await ctx.db.diplomacyLetter.update({
where: { id: letter.id },
data: {
state: 'ACTIVATED',
@@ -388,16 +391,20 @@ export const diplomacyRouter = router({
},
});
audit.record(updated, before, 'LETTER_ACCEPTED');
let prevId = letter.prevId;
while (prevId) {
const prevLetter = await ctx.db.diplomacyLetter.findFirst({ where: { id: prevId } });
if (!prevLetter) {
break;
}
await ctx.db.diplomacyLetter.update({
const updated = await ctx.db.diplomacyLetter.update({
where: { id: prevId },
data: { state: 'REPLACED' },
});
if (prevLetter.state !== 'REPLACED') {
audit.record(updated, projectAuditDocumentState(prevLetter), 'LETTER_REPLACED');
}
prevId = prevLetter.prevId;
}
messageText = `외교 서신( #${letter.id})이 승인되었습니다.`;
@@ -407,10 +414,11 @@ export const diplomacyRouter = router({
action: 'disagree',
reason: input.reason ?? '',
};
await ctx.db.diplomacyLetter.update({
const updated = await ctx.db.diplomacyLetter.update({
where: { id: letter.id },
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
});
audit.record(updated, before, 'LETTER_REJECTED');
messageText = `외교 서신(#${letter.id})이 거부되었습니다.`;
if (input.reason && input.reason !== '0') {
messageText += ` 이유 : ${input.reason}`;
@@ -426,14 +434,16 @@ export const diplomacyRouter = router({
includeNational: true,
});
await audit.flush();
return { ok: true };
}),
rollbackLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => {
rollbackLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() })).mutation(
async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
const permission = await resolvePermissionLevel(ctx, me.nationId);
const audit = createDiplomacyDocumentAudit(ctx, me, permission);
if (permission < 4) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
@@ -449,14 +459,11 @@ export const diplomacyRouter = router({
throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' });
}
const { srcNation, destNation } = await loadLetterNations(
ctx,
letter.srcNationId,
letter.destNationId
);
const { srcNation, destNation } = await loadLetterNations(ctx, letter.srcNationId, letter.destNationId);
const messageSrc = buildActorTarget(me, srcNation);
const messageDest = buildNationTarget(destNation);
const messageTime = await readDatabaseWallTime(ctx.db);
const before = projectAuditDocumentState(letter);
const aux = asRecord(letter.aux);
aux.reason = {
who: me.id,
@@ -464,11 +471,12 @@ export const diplomacyRouter = router({
reason: '회수',
};
await ctx.db.diplomacyLetter.update({
const updated = await ctx.db.diplomacyLetter.update({
where: { id: letter.id },
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
});
audit.record(updated, before, 'LETTER_WITHDRAWN');
await sendDocumentNotice({
ctx,
src: messageSrc,
@@ -477,14 +485,17 @@ export const diplomacyRouter = router({
time: messageTime,
});
await audit.flush();
return { ok: true };
}),
destroyLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() }))
.mutation(async ({ ctx, input }) => {
}
),
destroyLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() })).mutation(
async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
const permission = await resolvePermissionLevel(ctx, me.nationId);
const audit = createDiplomacyDocumentAudit(ctx, me, permission);
if (permission < 4) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
@@ -500,6 +511,7 @@ export const diplomacyRouter = router({
throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' });
}
const before = projectAuditDocumentState(letter);
const aux = asRecord(letter.aux);
const stateOpt = typeof aux.state_opt === 'string' ? aux.state_opt : null;
const myStateOpt = letter.srcNationId === me.nationId ? 'try_destroy_src' : 'try_destroy_dest';
@@ -508,11 +520,7 @@ export const diplomacyRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 파기 신청을 했습니다.' });
}
const { srcNation, destNation } = await loadLetterNations(
ctx,
letter.srcNationId,
letter.destNationId
);
const { srcNation, destNation } = await loadLetterNations(ctx, letter.srcNationId, letter.destNationId);
const actorNation = letter.srcNationId === me.nationId ? srcNation : destNation;
const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation;
const messageSrc = buildActorTarget(me, actorNation);
@@ -522,18 +530,20 @@ export const diplomacyRouter = router({
let messageText: string;
if (stateOpt && stateOpt !== myStateOpt) {
await ctx.db.diplomacyLetter.update({
const updated = await ctx.db.diplomacyLetter.update({
where: { id: letter.id },
data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue },
});
audit.record(updated, before, 'LETTER_DESTROYED');
resultState = 'CANCELLED';
messageText = `외교 서신(#${letter.id})을 파기했습니다.`;
} else {
aux.state_opt = myStateOpt;
await ctx.db.diplomacyLetter.update({
const updated = await ctx.db.diplomacyLetter.update({
where: { id: letter.id },
data: { aux: aux as GamePrisma.InputJsonValue },
});
audit.record(updated, before, 'LETTER_DESTROY_REQUESTED');
resultState = 'ACTIVATED';
messageText = `외교 서신(#${letter.id})을 파기 요청합니다.`;
}
@@ -545,6 +555,8 @@ export const diplomacyRouter = router({
text: messageText,
time: messageTime,
});
await audit.flush();
return { state: resultState };
}),
}
),
});
@@ -0,0 +1,156 @@
import { asRecord, GameClock, inferClockPhase, parseGameClockPhase, readTurnRecovery } from '@sammo-ts/common';
import {
GamePrisma,
hashAuditDiplomacyDocument,
persistAuditDiplomacyEvents,
readTurnRuntimeReady,
type AuditDiplomacyEventDraft,
} from '@sammo-ts/infra';
import type { GameApiContext, GeneralRow } from '../context.js';
type Letter = GamePrisma.DiplomacyLetterGetPayload<Record<string, never>>;
type DocumentAction =
| 'LETTER_PROPOSED'
| 'LETTER_REPLACED'
| 'LETTER_ACCEPTED'
| 'LETTER_REJECTED'
| 'LETTER_WITHDRAWN'
| 'LETTER_DESTROY_REQUESTED'
| 'LETTER_DESTROYED';
export const projectAuditDocumentState = (letter: Letter): Record<string, unknown> => {
const aux = asRecord(letter.aux);
const src = asRecord(aux.src);
const dest = asRecord(aux.dest);
const reason = asRecord(aux.reason);
return {
state: letter.state,
srcSignerId: letter.srcSignerId,
destSignerId: letter.destSignerId,
srcNationName: typeof src.nationName === 'string' ? src.nationName : null,
destNationName: typeof dest.nationName === 'string' ? dest.nationName : null,
srcSignerName: typeof src.generalName === 'string' ? src.generalName : null,
destSignerName: typeof dest.generalName === 'string' ? dest.generalName : null,
stateOption: typeof aux.state_opt === 'string' ? aux.state_opt : null,
reason: typeof reason.reason === 'string' ? reason.reason : null,
reasonAction: typeof reason.action === 'string' ? reason.action : null,
reasonActorId: typeof reason.who === 'number' ? reason.who : null,
};
};
interface AuditCoordinateRow {
serverId: string | null;
year: number;
month: number;
wallNow: Date;
clockBaseTime: Date | null;
clockTick: bigint | null;
clockMode: string;
clockWallAnchor: Date | null;
clockPhase: string;
clockRevision: bigint;
clockRecoveryStartTick: bigint | null;
clockRecoveryEndTick: bigint | null;
clockRecoveryStartWallAt: Date | null;
tickSeconds: number;
}
/** API 입력 transaction의 기존 clock fence 안에서 작은 시계/기수 투영만 읽는다. */
const readCoordinate = async (ctx: GameApiContext) => {
const [row] = await ctx.db.$queryRaw<AuditCoordinateRow[]>(GamePrisma.sql`
SELECT meta->>'serverId' AS "serverId", current_year AS year, current_month AS month,
CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS "wallNow",
clock_base_time AS "clockBaseTime", clock_tick AS "clockTick", clock_mode AS "clockMode",
clock_wall_anchor AS "clockWallAnchor", clock_phase AS "clockPhase", clock_revision AS "clockRevision",
clock_recovery_start_tick AS "clockRecoveryStartTick", clock_recovery_end_tick AS "clockRecoveryEndTick",
clock_recovery_start_wall_at AS "clockRecoveryStartWallAt", tick_seconds AS "tickSeconds"
FROM world_state ORDER BY id LIMIT 1
`);
if (!row?.serverId?.trim()) return null;
let tick: bigint | null = null;
if (row.clockBaseTime && row.clockTick !== null && row.clockWallAnchor) {
const clockTick = Number(row.clockTick);
const revision = Number(row.clockRevision);
if (!Number.isSafeInteger(clockTick) || !Number.isSafeInteger(revision))
throw new Error('Play audit diplomacy clock outside safe integer range');
const mode = row.clockMode === 'manual' ? 'manual' : 'realtime';
const phase = row.clockPhase ? parseGameClockPhase(row.clockPhase) : inferClockPhase(mode);
const clock = new GameClock({
baseTime: row.clockBaseTime,
tick: clockTick,
mode,
wallAnchor: row.clockWallAnchor,
recovery: readTurnRecovery(row),
turnSeconds: row.tickSeconds,
phase,
revision,
});
const ready =
phase !== 'RUNNING' || mode !== 'realtime' || (await readTurnRuntimeReady(ctx.db, row.clockRevision));
tick = BigInt(ready ? clock.nowTick(row.wallNow) : clock.tick);
}
return {
serverId: row.serverId,
year: row.year,
month: row.month,
tick,
clockRevision: row.clockRevision,
wallAt: row.wallNow,
};
};
export const createDiplomacyDocumentAudit = (ctx: GameApiContext, actor: GeneralRow, permission: number) => {
const changes: {
letter: Letter;
before: Record<string, unknown> | null;
after: Record<string, unknown>;
eventType: DocumentAction;
}[] = [];
return {
record: (letter: Letter, before: Record<string, unknown> | null, eventType: DocumentAction) => {
if (!ctx.auditInput) return;
changes.push({ letter, before, after: projectAuditDocumentState(letter), eventType });
},
flush: async (): Promise<void> => {
// 무transaction legacy unit fixture에는 입력 원장 identity를 꾸며 넣지 않는다.
const input = ctx.auditInput;
if (!input || !changes.length) return;
if (input.actorUserId !== actor.userId || input.actorUserId !== ctx.auth?.user.id)
throw new Error('Play audit diplomacy actor mismatch');
const coordinate = await readCoordinate(ctx);
if (!coordinate) return;
const events: AuditDiplomacyEventDraft[] = changes.map((change, index) => ({
schemaVersion: 1,
serverId: coordinate.serverId,
srcNationId: change.letter.srcNationId,
destNationId: change.letter.destNationId,
category: 'DOCUMENT',
source: 'API',
eventType: change.eventType,
documentId: change.letter.id,
documentHash: hashAuditDiplomacyDocument(change.letter),
previousDocumentId: change.letter.prevId,
year: coordinate.year,
month: coordinate.month,
tick: coordinate.tick,
clockRevision: coordinate.clockRevision,
executionId: `api:${input.requestId}`,
ordinal: index + 1,
requestId: input.requestId,
inputSequence: input.sequence,
actor: {
userId: actor.userId,
generalId: actor.id,
name: actor.name,
nationId: actor.nationId,
officerLevel: actor.officerLevel,
npcState: actor.npcState,
permission,
},
before: change.before,
after: change.after,
}));
await persistAuditDiplomacyEvents(ctx.db, events);
},
};
};
+2 -1
View File
@@ -81,11 +81,12 @@ const createInputEventMiddleware = (acquireClockFence: boolean) =>
payload,
actorUserId: ctx.auth?.user.id,
acquireClockFence,
execute: async (transaction) => {
execute: async (transaction, auditInput) => {
const result = await next({
ctx: {
...ctx,
db: transaction,
auditInput,
changeJournal,
turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId),
},
@@ -1,6 +1,6 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { JosaUtil, MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
import { JosaUtil } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { createGamePostgresConnector, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra';
import {
@@ -15,7 +15,7 @@ import { InMemoryFlushStore } from '../src/auth/flushStore.js';
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
import type { GameApiContext } from '../src/context.js';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import { fetchMessagesFromMailbox, invalidateMessages } from '../src/messages/store.js';
import { fetchMessagesFromMailbox, tombstoneMessages } from '../src/messages/store.js';
import { appRouter } from '../src/router.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
@@ -82,6 +82,7 @@ integration('diplomacy document message persistence', () => {
};
const cleanupRouteState = async (): Promise<void> => {
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: requestPrefix } });
await db.message.deleteMany({ where: { mailbox: { in: [...fixtureMailboxes] } } });
await db.diplomacyLetter.deleteMany({
where: {
@@ -231,8 +232,9 @@ integration('diplomacy document message persistence', () => {
type,
src: srcMailbox,
dest: destMailbox,
time: logicalGameTime,
timeTick: logicalGameTick,
time: expect.any(Date),
timeTick: null,
occurredGameTick: null,
});
expect(payload).toMatchObject({
src: options.src,
@@ -295,7 +297,7 @@ integration('diplomacy document message persistence', () => {
clockMode: 'manual',
clockWallAnchor: new Date('2026-08-24T00:00:00.000Z'),
config: {},
meta: {},
meta: { serverId: requestPrefix },
},
});
await db.nation.createMany({
@@ -382,7 +384,7 @@ integration('diplomacy document message persistence', () => {
await expectInputEvent(chainedRequestId, 'sendLetter', fixtureUserId);
});
it('keeps permanent messages readable without a clock and does not resurrect them after invalidation', async () => {
it('keeps permanent messages readable without a clock and preserves tombstoned content after clock recovery', async () => {
const created = await appRouter
.createCaller(buildContext('legacy-clock-fallback', fixtureAuth))
.diplomacy.sendLetter({
@@ -395,7 +397,7 @@ integration('diplomacy document message persistence', () => {
where: { mailbox: receiverMailbox, type: 'diplomacy' },
orderBy: { id: 'desc' },
});
expect(receiver.validUntilTick).toBe(BigInt(MAX_SAFE_GAME_TICK));
expect(receiver.validUntilTick).toBeNull();
await db.worldState.update({
where: { id: fixtureWorldStateId },
@@ -416,10 +418,10 @@ integration('diplomacy document message persistence', () => {
})
);
await invalidateMessages(db, [receiver.id]);
await tombstoneMessages(db, [receiver.id]);
await expect(
db.message.findUniqueOrThrow({ where: { id: receiver.id }, select: { validUntilTick: true } })
).resolves.toEqual({ validUntilTick: 0n });
db.message.findUniqueOrThrow({ where: { id: receiver.id }, select: { tombstonedAtWall: true } })
).resolves.toEqual({ tombstonedAtWall: expect.any(Date) });
await expect(
fetchMessagesFromMailbox({
db,
@@ -428,7 +430,7 @@ integration('diplomacy document message persistence', () => {
limit: 15,
fromSeq: 0,
})
).resolves.not.toContainEqual(expect.objectContaining({ id: receiver.id }));
).resolves.toContainEqual(expect.objectContaining({ id: receiver.id, text: '삭제된 메시지입니다.' }));
} finally {
await db.worldState.update({
where: { id: fixtureWorldStateId },
@@ -448,7 +450,7 @@ integration('diplomacy document message persistence', () => {
limit: 15,
fromSeq: 0,
})
).resolves.not.toContainEqual(expect.objectContaining({ id: receiver.id }));
).resolves.toContainEqual(expect.objectContaining({ id: receiver.id, text: '삭제된 메시지입니다.' }));
});
it('stores diplomacy and national copies for both approval and rejection responses', async () => {
@@ -561,6 +563,86 @@ integration('diplomacy document message persistence', () => {
await expectInputEvent(completeRequestId, 'destroyLetter', foreignUserId);
});
it('records ordered document transitions once, bound to durable input and immutable content', async () => {
const input = { destNationId: foreignNationId, brief: '감사 문서', detail: '불변 본문' };
const sender = appRouter.createCaller(buildContext('audit-send', fixtureAuth));
const created = await sender.diplomacy.sendLetter(input);
await expect(sender.diplomacy.sendLetter(input)).resolves.toEqual(created);
await appRouter.createCaller(buildContext('audit-accept', foreignAuth)).diplomacy.respondLetter({
letterId: created.id,
agree: true,
});
await appRouter
.createCaller(buildContext('audit-destroy-src', fixtureAuth))
.diplomacy.destroyLetter({ letterId: created.id });
await appRouter
.createCaller(buildContext('audit-destroy-dest', foreignAuth))
.diplomacy.destroyLetter({ letterId: created.id });
const events = await db.playAuditDiplomacyEvent.findMany({
where: { serverId: requestPrefix },
orderBy: { sequence: 'asc' },
});
expect(events.map((event) => event.eventType)).toEqual([
'LETTER_PROPOSED',
'LETTER_ACCEPTED',
'LETTER_DESTROY_REQUESTED',
'LETTER_DESTROYED',
]);
expect(events[0]).toMatchObject({
year: 208,
month: 4,
tick: logicalGameTick,
documentId: created.id,
before: null,
actor: { userId: fixtureUserId, generalId: fixtureGeneralId },
});
expect(events[1]).toMatchObject({
before: { state: 'PROPOSED', destSignerId: null },
after: { state: 'ACTIVATED', destSignerId: foreignGeneralId },
});
expect(events[2]).toMatchObject({ before: { stateOption: null }, after: { stateOption: 'try_destroy_src' } });
expect(events[3]).toMatchObject({ before: { state: 'ACTIVATED' }, after: { state: 'CANCELLED' } });
expect(new Set(events.map((event) => event.documentHash)).size).toBe(1);
for (const event of events) {
const journal = await db.inputEvent.findUniqueOrThrow({ where: { requestId: event.requestId! } });
expect(event.inputSequence).toBe(journal.sequence);
expect(JSON.stringify(event.after)).not.toContain('불변 본문');
}
await expect(
db.diplomacyLetter.update({ where: { id: created.id }, data: { textDetail: '변조' } })
).rejects.toThrow('Diplomacy document content is immutable');
expect((await db.diplomacyLetter.findUniqueOrThrow({ where: { id: created.id } })).textDetail).toBe(
'불변 본문'
);
});
it('rolls back the document and notices if the audit insert fails', async () => {
await db.$executeRawUnsafe(`CREATE FUNCTION reject_audit_document_fixture() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN RAISE EXCEPTION 'injected audit insert failure'; END; $$`);
await db.$executeRawUnsafe(`CREATE TRIGGER reject_audit_document_fixture BEFORE INSERT ON play_audit_diplomacy_event
FOR EACH ROW EXECUTE FUNCTION reject_audit_document_fixture()`);
try {
await expect(
appRouter.createCaller(buildContext('audit-failure', fixtureAuth)).diplomacy.sendLetter({
destNationId: foreignNationId,
brief: '감사 실패',
detail: '롤백',
})
).rejects.toThrow('injected audit insert failure');
expect(await db.diplomacyLetter.count({ where: { textBrief: '감사 실패' } })).toBe(0);
expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(0);
expect(await db.playAuditDiplomacyEvent.count({ where: { serverId: requestPrefix } })).toBe(0);
expect(
await db.inputEvent.findUniqueOrThrow({
where: { requestId: `${requestPrefix}:audit-failure:diplomacy.sendLetter` },
})
).toMatchObject({ status: 'FAILED', attempts: 1 });
} finally {
await db.$executeRawUnsafe('DROP TRIGGER reject_audit_document_fixture ON play_audit_diplomacy_event');
await db.$executeRawUnsafe('DROP FUNCTION reject_audit_document_fixture()');
}
});
it('rolls letter and message writes back together while retaining the failed API input event', async () => {
const failure = new Error('injected diplomacy message transaction rollback');
const requestId = 'send-rollback';
@@ -576,6 +658,7 @@ integration('diplomacy document message persistence', () => {
await expect(db.diplomacyLetter.findFirst({ where: { textBrief: 'rollback 외교문서' } })).resolves.toBeNull();
await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0);
await expect(db.playAuditDiplomacyEvent.count({ where: { serverId: requestPrefix } })).resolves.toBe(0);
await expect(
db.readModelRevision.count({
where: {