관리자 승인과 사용자 OAuth 증명을 분리하고 기존 Kakao stable ID를 영구 폐기한다. 로그인 ID와 닉네임 변경은 현재 장수에 revision 기반으로 투영하며 과거 기록은 당시 이름으로 보존한다.
275 lines
10 KiB
TypeScript
275 lines
10 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { GamePrisma } from '@sammo-ts/infra';
|
|
import type { TurnSchedule } from '@sammo-ts/logic';
|
|
|
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
|
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
|
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
|
|
|
const revision = '2026-07-31T09:00:00.000Z';
|
|
const requestId = `general:adjustIcon:user-1:${revision}`;
|
|
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
|
|
|
|
const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
|
id: 1,
|
|
userId: 'user-1',
|
|
name: '아이콘장수',
|
|
nationId: 0,
|
|
cityId: 0,
|
|
troopId: 0,
|
|
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
|
turnTime: new Date('2026-07-31T09:00:00.000Z'),
|
|
recentWarTime: null,
|
|
role: {
|
|
items: { horse: null, weapon: null, book: null, item: null },
|
|
personality: null,
|
|
specialDomestic: null,
|
|
specialWar: null,
|
|
},
|
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
|
meta: { killturn: 24 },
|
|
officerLevel: 0,
|
|
experience: 0,
|
|
dedication: 0,
|
|
injury: 0,
|
|
gold: 0,
|
|
rice: 0,
|
|
crew: 0,
|
|
crewTypeId: 0,
|
|
train: 0,
|
|
atmos: 0,
|
|
age: 20,
|
|
npcState: 0,
|
|
picture: 'old.jpg',
|
|
imageServer: 0,
|
|
...overrides,
|
|
});
|
|
|
|
const buildWorld = (generals: TurnGeneral[]) => {
|
|
const state: TurnWorldState = {
|
|
id: 1,
|
|
currentYear: 190,
|
|
currentMonth: 1,
|
|
tickSeconds: 600,
|
|
lastTurnTime: new Date('2026-07-31T09:00:00.000Z'),
|
|
meta: {},
|
|
};
|
|
const snapshot: TurnWorldSnapshot = {
|
|
generals,
|
|
cities: [],
|
|
nations: [],
|
|
troops: [],
|
|
diplomacy: [],
|
|
events: [],
|
|
initialEvents: [],
|
|
scenarioConfig: {
|
|
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 },
|
|
iconPath: '',
|
|
map: {},
|
|
const: {},
|
|
environment: { mapName: 'test', unitSet: 'test' },
|
|
},
|
|
scenarioMeta: {
|
|
title: 'test',
|
|
startYear: 190,
|
|
life: null,
|
|
fiction: null,
|
|
history: [],
|
|
ignoreDefaultEvents: false,
|
|
},
|
|
map: {
|
|
id: 'test',
|
|
name: 'test',
|
|
cities: [],
|
|
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
|
},
|
|
};
|
|
return new InMemoryTurnWorld(state, snapshot, { schedule });
|
|
};
|
|
|
|
const buildCommandDb = (actorUserId = 'user-1', acceptedAt = new Date('2026-07-31T09:00:00.000Z')) =>
|
|
({
|
|
inputEvent: {
|
|
findUnique: vi.fn(async () => ({
|
|
createdAt: acceptedAt,
|
|
actorUserId,
|
|
target: 'ENGINE',
|
|
eventType: 'adjustGeneralIcon',
|
|
})),
|
|
},
|
|
}) as unknown as GamePrisma.TransactionClient;
|
|
|
|
const buildIdentityCommandDb = (actorUserId = 'user-1') =>
|
|
({
|
|
inputEvent: {
|
|
findUnique: vi.fn(async () => ({
|
|
createdAt: new Date(revision),
|
|
actorUserId,
|
|
target: 'ENGINE',
|
|
eventType: 'adjustGeneralIdentity',
|
|
})),
|
|
},
|
|
}) as unknown as GamePrisma.TransactionClient;
|
|
|
|
const command = (
|
|
overrides: Partial<{
|
|
requestId: string;
|
|
userId: string;
|
|
picture: string;
|
|
imageServer: number;
|
|
iconRevision: string;
|
|
enforceCooldown: boolean;
|
|
}> = {}
|
|
) => ({
|
|
type: 'adjustGeneralIcon' as const,
|
|
requestId,
|
|
userId: 'user-1',
|
|
picture: 'new.png',
|
|
imageServer: 1,
|
|
iconRevision: revision,
|
|
...overrides,
|
|
});
|
|
|
|
describe('adjustGeneralIcon ENGINE command', () => {
|
|
it('updates only the authenticated human general and preserves existing meta', async () => {
|
|
const human = buildGeneral();
|
|
const possessedNpc = buildGeneral({ id: 2, npcState: 1 });
|
|
const foreign = buildGeneral({ id: 3, userId: 'user-2' });
|
|
const world = buildWorld([human, possessedNpc, foreign]);
|
|
const handler = createTurnDaemonCommandHandler({ world });
|
|
|
|
await expect(handler.handle(command(), { db: buildCommandDb() })).resolves.toEqual({
|
|
type: 'adjustGeneralIcon',
|
|
ok: true,
|
|
generalId: human.id,
|
|
updated: true,
|
|
});
|
|
expect(world.getGeneralById(human.id)).toMatchObject({
|
|
picture: 'new.png',
|
|
imageServer: 1,
|
|
meta: {
|
|
killturn: 24,
|
|
accountIconUpdatedAt: revision,
|
|
},
|
|
});
|
|
expect(world.getGeneralById(possessedNpc.id)).toMatchObject({ picture: 'old.jpg', imageServer: 0 });
|
|
expect(world.getGeneralById(foreign.id)).toMatchObject({ picture: 'old.jpg', imageServer: 0 });
|
|
});
|
|
|
|
it('treats no human general as a successful no-op', async () => {
|
|
const world = buildWorld([buildGeneral({ id: 2, npcState: 1 }), buildGeneral({ id: 3, userId: 'user-2' })]);
|
|
const handler = createTurnDaemonCommandHandler({ world });
|
|
|
|
await expect(handler.handle(command(), { db: buildCommandDb() })).resolves.toEqual({
|
|
type: 'adjustGeneralIcon',
|
|
ok: true,
|
|
generalId: null,
|
|
updated: false,
|
|
});
|
|
});
|
|
|
|
it('keeps an identical revision idempotent and rejects rollback or tuple conflicts', async () => {
|
|
const world = buildWorld([
|
|
buildGeneral({
|
|
picture: 'new.png',
|
|
imageServer: 1,
|
|
meta: { killturn: 24, accountIconUpdatedAt: revision },
|
|
}),
|
|
]);
|
|
const handler = createTurnDaemonCommandHandler({ world });
|
|
|
|
await expect(handler.handle(command(), { db: buildCommandDb() })).resolves.toMatchObject({
|
|
ok: true,
|
|
updated: false,
|
|
});
|
|
await expect(
|
|
handler.handle(command({ iconRevision: '2026-07-31T08:59:59.000Z' }), {
|
|
db: buildCommandDb(),
|
|
})
|
|
).resolves.toMatchObject({ ok: false, code: 'CONFLICT' });
|
|
await expect(
|
|
handler.handle(command({ picture: 'conflict.png' }), { db: buildCommandDb() })
|
|
).resolves.toMatchObject({ ok: false, code: 'CONFLICT' });
|
|
expect(world.getGeneralById(1)).toMatchObject({ picture: 'new.png', imageServer: 1 });
|
|
});
|
|
|
|
it('fails closed for a corrupt watermark or mismatched input-event actor', async () => {
|
|
const corruptWorld = buildWorld([buildGeneral({ meta: { killturn: 24, accountIconUpdatedAt: 'not-a-date' } })]);
|
|
const corruptHandler = createTurnDaemonCommandHandler({ world: corruptWorld });
|
|
await expect(corruptHandler.handle(command(), { db: buildCommandDb() })).resolves.toMatchObject({
|
|
ok: false,
|
|
code: 'PRECONDITION_FAILED',
|
|
});
|
|
|
|
const actorWorld = buildWorld([buildGeneral()]);
|
|
const actorHandler = createTurnDaemonCommandHandler({ world: actorWorld });
|
|
await expect(actorHandler.handle(command(), { db: buildCommandDb('user-2') })).rejects.toThrow(
|
|
'actor does not match'
|
|
);
|
|
expect(actorWorld.getGeneralById(1)).toMatchObject({ picture: 'old.jpg', imageServer: 0 });
|
|
});
|
|
|
|
it('allows human in-game changes only after a rolling 24-hour window', async () => {
|
|
const changedAt = '2026-07-31T08:00:00.000Z';
|
|
const world = buildWorld([buildGeneral({ meta: { killturn: 24, generalIconChangedAt: changedAt } })]);
|
|
const handler = createTurnDaemonCommandHandler({ world });
|
|
const manual = command({ enforceCooldown: true });
|
|
|
|
await expect(handler.handle(manual, { db: buildCommandDb() })).resolves.toMatchObject({
|
|
ok: false,
|
|
code: 'TOO_MANY_REQUESTS',
|
|
availableAt: '2026-08-01T08:00:00.000Z',
|
|
});
|
|
expect(world.getGeneralById(1)).toMatchObject({ picture: 'old.jpg' });
|
|
|
|
await expect(
|
|
handler.handle(manual, { db: buildCommandDb('user-1', new Date('2026-08-01T08:00:00.000Z')) })
|
|
).resolves.toMatchObject({ ok: true, updated: true });
|
|
expect(world.getGeneralById(1)).toMatchObject({
|
|
picture: 'new.png',
|
|
meta: { generalIconChangedAt: '2026-08-01T08:00:00.000Z' },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('adjustGeneralIdentity ENGINE command', () => {
|
|
it('updates only the current human general owner-name projection', async () => {
|
|
const world = buildWorld([
|
|
buildGeneral({ meta: { killturn: 24, ownerName: '이전닉네임' } }),
|
|
buildGeneral({ id: 2, npcState: 1, meta: { killturn: 24, ownerName: '이전닉네임' } }),
|
|
]);
|
|
const handler = createTurnDaemonCommandHandler({ world });
|
|
const identityCommand = {
|
|
type: 'adjustGeneralIdentity' as const,
|
|
requestId: `general:adjustIdentity:user-1:${revision}`,
|
|
userId: 'user-1',
|
|
displayName: '새닉네임',
|
|
identityRevision: revision,
|
|
};
|
|
|
|
await expect(handler.handle(identityCommand, { db: buildIdentityCommandDb() })).resolves.toEqual({
|
|
type: 'adjustGeneralIdentity',
|
|
ok: true,
|
|
generalId: 1,
|
|
updated: true,
|
|
});
|
|
expect(world.getGeneralById(1)?.meta).toMatchObject({
|
|
killturn: 24,
|
|
ownerDisplayName: '새닉네임',
|
|
ownerName: '새닉네임',
|
|
owner_name: '새닉네임',
|
|
ownerIdentityRevision: revision,
|
|
});
|
|
expect(world.getGeneralById(2)?.meta).toMatchObject({ ownerName: '이전닉네임' });
|
|
|
|
await expect(handler.handle(identityCommand, { db: buildIdentityCommandDb() })).resolves.toMatchObject({
|
|
ok: true,
|
|
updated: false,
|
|
});
|
|
await expect(
|
|
handler.handle({ ...identityCommand, displayName: '충돌닉네임' }, { db: buildIdentityCommandDb() })
|
|
).resolves.toMatchObject({ ok: false, code: 'CONFLICT' });
|
|
});
|
|
});
|