인사부 도시 관직 자원과 기본 처리 및 수뇌 알림 추가
This commit is contained in:
@@ -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())));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user