feat: port pre-start general deletion lifecycle

This commit is contained in:
2026-07-31 05:44:36 +00:00
parent f1929be3fe
commit 71ec02d091
24 changed files with 1257 additions and 160 deletions
@@ -74,15 +74,15 @@ const auth: GameSessionTokenPayload = {
};
const createContext = (options: {
me?: GeneralRow;
me?: GeneralRow | null;
targets?: GeneralRow[];
nationMeta?: Record<string, unknown>;
requestCommand?: ReturnType<typeof vi.fn>;
}) => {
const me = options.me ?? buildGeneral();
const targets = options.targets ?? [me];
const me = options.me === undefined ? buildGeneral() : options.me;
const targets = options.targets ?? (me ? [me] : []);
const requestCommand =
options.requestCommand ?? vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: me.id }));
options.requestCommand ?? vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: me?.id ?? 0 }));
const generalFindUnique = vi.fn(
async ({ where }: { where: { id: number } }) => targets.find((general) => general.id === where.id) ?? null
);
@@ -90,7 +90,7 @@ const createContext = (options: {
general: {
findFirst: vi.fn(async () => me),
findUnique: generalFindUnique,
findMany: vi.fn(async () => targets.filter((general) => general.nationId === me.nationId)),
findMany: vi.fn(async () => targets.filter((general) => general.nationId === (me?.nationId ?? 0))),
update: vi.fn(),
},
city: { findUnique: vi.fn(async () => null) },
@@ -232,6 +232,7 @@ describe('in-game my information ownership', () => {
});
it.each([
['dieOnPrestart', 'dieOnPrestart'],
['buildNationCandidate', 'buildNationCandidate'],
['instantRetreat', 'instantRetreat'],
] as const)('dispatches %s only for the session-owned general', async (procedure, commandType) => {
@@ -253,6 +254,32 @@ describe('in-game my information ownership', () => {
});
});
it('gets the server-owned pre-start deletion status without accepting a general id', async () => {
const requestCommand = vi.fn(async () => ({
type: 'ensureDieOnPrestartStatus' as const,
generalId: 7,
show: true,
available: false,
availableAt: '2026-01-01T00:20:00.000Z',
}));
const fixture = createContext({ requestCommand });
await expect(appRouter.createCaller(fixture.context).general.ensureDieOnPrestartStatus()).resolves.toEqual({
show: true,
available: false,
availableAt: '2026-01-01T00:20:00.000Z',
});
expect(requestCommand).toHaveBeenCalledWith({
type: 'ensureDieOnPrestartStatus',
userId: 'user-7',
generalId: 7,
});
expect(fixture.db.general.findFirst).toHaveBeenCalledWith({
where: { userId: 'user-7', npcState: 0 },
select: { id: true },
});
});
it('returns the daemon compatibility failure without performing an API-side mutation', async () => {
const requestCommand = vi.fn(async () => ({
type: 'instantRetreat' as const,
@@ -287,6 +314,38 @@ describe('in-game my information ownership', () => {
generalId: 7,
});
});
it('keeps the die-on-prestart request identity when its destructive result times out', async () => {
const requestCommand = vi.fn(async () => null);
const fixture = createContext({ requestCommand });
const caller = appRouter.createCaller(fixture.context);
const clientRequestId = '33333333-3333-4333-8333-333333333333';
await expect(caller.general.dieOnPrestart({ clientRequestId })).rejects.toMatchObject({
code: 'TIMEOUT',
message: expect.stringContaining('같은 요청으로 다시 시도'),
});
expect(requestCommand).toHaveBeenCalledWith({
type: 'dieOnPrestart',
requestId: `general:dieOnPrestart:user-7:${clientRequestId}`,
userId: 'user-7',
generalId: 7,
});
});
it('rejects deletion with the legacy no-general message before dispatching a daemon command', async () => {
const requestCommand = vi.fn();
const fixture = createContext({ me: null, requestCommand });
await expect(appRouter.createCaller(fixture.context).general.dieOnPrestart()).rejects.toMatchObject({
code: 'NOT_FOUND',
message: '장수가 없습니다',
});
expect(fixture.db.general.findFirst).toHaveBeenCalledWith({
where: { userId: 'user-7', npcState: 0 },
});
expect(requestCommand).not.toHaveBeenCalled();
});
});
describe('battle-center general and user permissions', () => {