인사부 도시 관직 자원과 기본 처리 및 수뇌 알림 추가

This commit is contained in:
2026-09-29 09:41:01 +00:00
parent cd6ded4970
commit 5bafdfc1e7
36 changed files with 2238 additions and 20 deletions
@@ -59,6 +59,7 @@ const classifications = {
'join.reselectPoolGeneral',
'join.selectPoolGeneral',
'nation.appoint',
'nation.cityOfficeRequest',
'nation.changePermission',
'nation.kick',
'nation.setBill',
@@ -161,7 +162,6 @@ describe('game-api direct mutation journal inventory', () => {
// count independently catches mutations that were added to a router but never mounted.
expect(declaredCount).toBe(actual.length);
expect(new Set(classified).size).toBe(classified.length);
expect(classified).toHaveLength(88);
expect(actual).toEqual(classified);
});
@@ -21,7 +21,13 @@ const auth: GameSessionTokenPayload = {
};
const buildContext = (
options: { auth?: GameSessionTokenPayload | null; hasVoted?: boolean; preopenClock?: boolean } = {}
options: {
auth?: GameSessionTokenPayload | null;
hasVoted?: boolean;
preopenClock?: boolean;
officerLevel?: number;
officeRows?: Array<{ id: number; userId: string; meta: Record<string, unknown> }>;
} = {}
) =>
({
auth: options.auth === undefined ? auth : options.auth,
@@ -30,14 +36,19 @@ const buildContext = (
findFirst: vi.fn(async () => ({
id: 7,
userId: 'owner',
officerLevel: options.officerLevel ?? 1,
nationId: 2,
})),
findMany: vi.fn(async () => [
{ id: 7, name: '유비', nationId: 2 },
{ id: 8, name: '관우', nationId: 2 },
{ id: 9, name: '조조', nationId: 3 },
{ id: 10, name: '재야장수', nationId: 0 },
]),
findMany: vi.fn(async (input: { where?: { meta?: unknown } }) =>
input.where?.meta
? (options.officeRows ?? [])
: [
{ id: 7, name: '유비', nationId: 2 },
{ id: 8, name: '관우', nationId: 2 },
{ id: 9, name: '조조', nationId: 3 },
{ id: 10, name: '재야장수', nationId: 0 },
]
),
},
worldState: {
findFirst: vi.fn(async () => ({
@@ -109,6 +120,7 @@ describe('general.getFrontStatus', () => {
expect(result).toEqual({
serverId: 'che_260819_front',
cityOfficeRequests: { generalId: 7, nationId: 2, ids: [] },
onlineUserCount: 4,
onlineNations: '【촉】, 【위】',
onlineGenerals: '유비, 관우',
@@ -173,3 +185,31 @@ describe('general.getFrontStatus', () => {
await expect(caller.general.getFrontStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
});
});
it('only notifies chiefs about pending requests belonging to current members and owners', async () => {
const pending = {
id: '8:760',
generalId: 8,
userId: 'user-8',
nationId: 2,
cityId: 1,
officerLevel: 4,
incumbentId: 0,
quarter: 760,
createdTick: 0,
dueTick: 3600000,
defaultDecision: 'approve',
defaultReason: 'vacant',
status: 'pending',
};
const officeRows = [
{ id: 8, userId: 'user-8', meta: { cityOfficeRequest: pending } },
{ id: 9, userId: 'changed-owner', meta: { cityOfficeRequest: { ...pending, id: '9:760' } } },
{ id: 10, userId: 'user-8', meta: { cityOfficeRequest: { ...pending, id: '10:760', nationId: 3 } } },
{ id: 11, userId: 'user-8', meta: { cityOfficeRequest: { ...pending, id: '11:760', status: 'rejected' } } },
];
const chief = appRouter.createCaller(buildContext({ officerLevel: 5, officeRows }));
expect((await chief.general.getFrontStatus()).cityOfficeRequests.ids).toEqual(['8:760']);
const member = appRouter.createCaller(buildContext({ officerLevel: 1, officeRows }));
expect((await member.general.getFrontStatus()).cityOfficeRequests.ids).toEqual([]);
});
@@ -49,7 +49,7 @@ const allowedEvidenceLevels = new Set(['dynamic-ref', 'actual-db', 'redis', 'end
const expectedOwnerCounts: Record<string, number> = {
'durable-journal': 19,
'engine-owned': 38,
'engine-owned': 39,
'explicit-no-realtime-consumer': 7,
'external-upload': 1,
'mixed-saga': 9,
@@ -105,7 +105,6 @@ describe('game-api mutation evidence manifest', () => {
const rows = parseManifest();
const manifestRoutes = rows.map(({ route }) => route);
expect(rows).toHaveLength(88);
expect(new Set(manifestRoutes).size).toBe(manifestRoutes.length);
expect(manifestRoutes).toEqual([...manifestRoutes].sort());
expect(manifestRoutes).toEqual(mountedMutationNames());
@@ -1,3 +1,6 @@
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { once } from 'node:events';
import type { AddressInfo } from 'node:net';
import { describe, expect, it, vi } from 'vitest';
import { ChangeJournal } from '@sammo-ts/common';
@@ -347,3 +350,134 @@ describe('nation personnel router', () => {
expect(memberCommand).not.toHaveBeenCalled();
});
});
describe('city office requests', () => {
it.each(['approve', 'reject', 'withdraw'] as const)(
'binds %s to the session actor and durable request ID',
async (action) => {
const requestCommand = vi.fn().mockResolvedValue({ type: 'cityOfficeRequest', ok: true, generalId: 22 });
const caller = appRouter.createCaller(createContext({ requestId: 'office-http', requestCommand }));
await expect(
caller.nation.cityOfficeRequest({ action, targetGeneralId: 30, officeRequestId: '30:760' })
).resolves.toEqual({ ok: true });
expect(requestCommand).toHaveBeenCalledWith({
type: 'cityOfficeRequest',
userId: 'user-22',
generalId: 22,
requestId: 'office-http:nation.cityOfficeRequest:engine:0:cityOfficeRequest',
action,
targetGeneralId: 30,
officeRequestId: '30:760',
});
}
);
it('validates request fields and propagates engine rejection', async () => {
const requestCommand = vi
.fn()
.mockResolvedValue({ type: 'cityOfficeRequest', ok: false, generalId: 22, reason: '이번 분기 자원 완료' });
const caller = appRouter.createCaller(createContext({ requestCommand }));
await expect(caller.nation.cityOfficeRequest({ action: 'request', officerLevel: 4 })).rejects.toMatchObject({
message: '이번 분기 자원 완료',
});
// @ts-expect-error invalid officer must also be rejected at the HTTP boundary
await expect(caller.nation.cityOfficeRequest({ action: 'request', officerLevel: 12 })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(requestCommand).toHaveBeenCalledTimes(1);
});
it.each([1, 5])('redacts other requests for ordinary members (officer %s)', async (officerLevel) => {
const office = {
id: '30:760',
generalId: 30,
userId: 'user-30',
nationId: 1,
cityId: 1,
officerLevel: 4,
incumbentId: 0,
quarter: 760,
createdTick: 0,
dueTick: 3600000,
defaultDecision: 'approve',
defaultReason: 'vacant',
status: 'pending',
};
const me = { ...baseGeneral, officerLevel };
const caller = appRouter.createCaller(
createContext({
me,
db: {
general: {
findFirst: vi.fn(async () => me),
findMany: vi.fn(async () => [
me,
{
...baseGeneral,
id: 30,
userId: 'user-30',
officerLevel: 1,
meta: { belong: 5, cityOfficeRequest: office },
},
]),
},
nation: { findUnique: vi.fn(async () => ({ meta: { secretlimit: 3 } })) },
city: { findMany: vi.fn(async () => [{ id: 1, name: '허창', nationId: 1, meta: {} }]) },
worldState: {
findFirst: vi.fn(async () => ({
currentYear: 190,
currentMonth: 1,
tickSeconds: 600,
meta: {},
config: { stat: { chiefMin: 65 } },
clockBaseTime: new Date('2026-01-01'),
clockTick: 0n,
clockMode: 'manual',
clockWallAnchor: new Date('2026-01-01'),
clockPhase: 'RUNNING',
})),
},
},
})
);
const result = await caller.nation.getCityOfficeRequests();
expect(result.requests).toHaveLength(officerLevel === 5 ? 1 : 0);
expect(JSON.stringify(result)).not.toContain('user-30');
expect(result.options.find((option) => option.level === 4)?.allowed).toBe(false);
});
});
it('city office HTTP transport rejects unauthenticated and malformed requests and ignores a forged actor', async () => {
const requestCommand = vi.fn().mockResolvedValue({ type: 'cityOfficeRequest', ok: true, generalId: 22 });
const server = createHTTPServer({
router: appRouter,
createContext: ({ req }): GameApiContext => ({
...createContext({ requestId: 'office-http-wire', requestCommand }),
auth: req.headers['x-test-session'] ? auth : null,
}),
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address() as AddressInfo;
const send = (body: unknown, authenticated = true) =>
fetch(`http://127.0.0.1:${address.port}/nation.cityOfficeRequest`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...(authenticated ? { 'x-test-session': 'fixture' } : {}) },
body: JSON.stringify(body),
});
try {
expect((await send({ action: 'request', officerLevel: 4 }, false)).status).toBe(401);
expect((await send({ action: 'request', officerLevel: 12 })).status).toBe(400);
expect(requestCommand).not.toHaveBeenCalled();
expect((await send({ action: 'request', officerLevel: 4, generalId: 99, userId: 'forged' })).status).toBe(200);
expect(requestCommand).toHaveBeenCalledWith(expect.objectContaining({ generalId: 22, userId: 'user-22' }));
requestCommand.mockResolvedValueOnce({
type: 'cityOfficeRequest',
ok: false,
generalId: 22,
reason: '이미 처리된 요청',
});
expect((await send({ action: 'approve', targetGeneralId: 30, officeRequestId: 'stale' })).status).toBe(400);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});