feat: 오픈 게임 취소와 유산 정산 경로 추가

별도 최고위험 권한으로 실행되는 원자적 취소 작업을 추가한다. 버려진 게임과 장수 기록의 보존·삭제 옵션, 오픈 원금 전액 환급 및 획득 포인트 보전율, 취소 상태와 관리자·과거기록 UI를 함께 반영한다.
This commit is contained in:
2026-08-18 13:31:49 +00:00
parent a7b11811de
commit 383d173790
36 changed files with 1967 additions and 83 deletions
+101 -4
View File
@@ -31,6 +31,7 @@ const buildCaller = async (
initialProfileStatus?: GatewayProfileRecord['status'];
profileScenario?: string | null;
profileMeta?: GatewayProfileRecord['meta'];
releaseCommitSha?: string;
initialOperation?: GatewayOperationRecord;
profileLogVisibilityAfterPolls?: number;
releaseLogVisibilityAfterPolls?: number;
@@ -158,7 +159,7 @@ const buildCaller = async (
const releases: GatewayReleaseRepository = {
getState: async () => ({
id: 'gateway',
activeCommitSha: '1111111111111111111111111111111111111111',
activeCommitSha: options.releaseCommitSha ?? '1111111111111111111111111111111111111111',
activeWorkspace: '/srv/sammo/current',
previousCommitSha: '2222222222222222222222222222222222222222',
previousWorkspace: '/srv/sammo/previous',
@@ -476,6 +477,99 @@ describe('gateway notice API', () => {
});
describe('admin operation API', () => {
it('queues game cancellation only with its dedicated scoped capability', async () => {
const harness = await buildCaller(
async (input) => ({
id: '77777777-7777-4777-8777-777777777777',
profileName: input.profileName,
type: input.type,
status: 'QUEUED',
sourceMode: input.sourceMode,
sourceRef: input.sourceRef,
payload: input.payload ?? {},
reason: input.reason,
requestedBy: input.requestedBy,
createdAt: '2026-08-18T00:00:00.000Z',
updatedAt: '2026-08-18T00:00:00.000Z',
}),
{
adminRoles: ['admin.games.cancel:che:2'],
firstUserIsAdmin: false,
initialProfileStatus: 'RUNNING',
releaseCommitSha: 'HEAD',
}
);
await harness.caller.admin.operations.requestGameCancellation({
profileName: 'che:2',
historyMode: 'RETAIN_ABANDONED',
generalMode: 'DELETE',
earnedPointRetentionPercent: 35,
reason: '잘못 연 게임 취소',
});
expect(harness.createdInputs[0]).toMatchObject({
profileName: 'che:2',
type: 'CANCEL_GAME',
sourceMode: 'COMMIT',
sourceRef: expect.stringMatching(/^[0-9a-f]{40}$/u),
payload: {
historyMode: 'RETAIN_ABANDONED',
generalMode: 'DELETE',
earnedPointRetentionPercent: 35,
},
reason: '잘못 연 게임 취소',
});
});
it('does not treat scenario reset permission as game cancellation permission', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{
adminRoles: ['admin.scenarios.reset:che:2'],
firstUserIsAdmin: false,
initialProfileStatus: 'RUNNING',
releaseCommitSha: 'HEAD',
}
);
await expect(
harness.caller.admin.operations.requestGameCancellation({
profileName: 'che:2',
historyMode: 'DELETE',
generalMode: 'DELETE',
earnedPointRetentionPercent: 0,
reason: '잘못 연 게임 취소',
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('rejects cancellation after the profile is already terminal', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{
adminRoles: ['admin.games.cancel:che:2'],
firstUserIsAdmin: false,
initialProfileStatus: 'COMPLETED',
releaseCommitSha: 'HEAD',
}
);
await expect(
harness.caller.admin.operations.requestGameCancellation({
profileName: 'che:2',
historyMode: 'RETAIN_ABANDONED',
generalMode: 'RETAIN',
earnedPointRetentionPercent: 0,
reason: '완료 게임 취소 시도',
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
it('queues a start operation with the authenticated requester', async () => {
const operation = {
id: '11111111-1111-4111-8111-111111111111',
@@ -504,9 +598,12 @@ describe('admin operation API', () => {
});
it('reports an active-operation uniqueness conflict', async () => {
const harness = await buildCaller(async () => {
throw { code: 'P2002' };
});
const harness = await buildCaller(
async () => {
throw { code: 'P2002' };
},
{ initialProfileStatus: 'RUNNING' }
);
await expect(
harness.caller.admin.operations.requestRuntime({
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { GatewayOrchestrator } from '../src/orchestrator/gatewayOrchestrator.js';
import { GatewayOrchestrator, type GatewayOrchestratorOptions } from '../src/orchestrator/gatewayOrchestrator.js';
import type { ProcessDefinition, ProcessManager } from '../src/orchestrator/processManager.js';
import type {
GatewayOperationRecord,
@@ -45,8 +45,13 @@ const createHarness = (
processesPresent = true,
missingOnDelete = false,
workspaceManager?: GitWorkspaceManager,
startGate?: Promise<void>
startGate?: Promise<void>,
options: {
profile?: GatewayProfileRecord;
cancelGame?: GatewayOrchestratorOptions['cancelGame'];
} = {}
) => {
const harnessProfile = options.profile ?? profile;
let nextOperation: GatewayOperationRecord | null = operation;
const statuses: string[] = [];
const completions: GatewayOperationStatus[] = [];
@@ -57,23 +62,23 @@ const createHarness = (
const logs: Array<{ phase: string; message: string; level: string }> = [];
const repository: GatewayProfileRepository = {
listProfiles: async () => [profile],
getProfile: async () => profile,
upsertProfile: async () => profile,
updateCurrentScenario: async () => profile,
listProfiles: async () => [harnessProfile],
getProfile: async () => harnessProfile,
upsertProfile: async () => harnessProfile,
updateCurrentScenario: async () => harnessProfile,
updateStatus: async (_profileName, status) => {
statuses.push(status);
return { ...profile, status };
return { ...harnessProfile, status };
},
updateBuildStatus: async () => profile,
updateMeta: async () => profile,
updateBuildStatus: async () => harnessProfile,
updateMeta: async () => harnessProfile,
listReservedToStart: async () => [],
findQueuedBuild: async () => null,
updateLastError: async () => {},
updateWorkspaceUsage: async () => {},
clearWorkspaceUsage: async () => {},
listOperations: async () => [],
listActiveOperationProfileNames: async () => [profile.profileName],
listActiveOperationProfileNames: async () => [harnessProfile.profileName],
getOperation: async () => operation,
listOperationLogs: async () => [],
appendOperationLog: async (operationId, input) => {
@@ -99,6 +104,10 @@ const createHarness = (
requeueOperation: async () => ({ ...operation, status: 'QUEUED' }),
cancelOperation: async () => false,
retryOperation: async () => null,
updateProfileForOperation: async (_id, _ownerId, _profileName, patch) => {
if (patch.status) statuses.push(patch.status);
return { ...harnessProfile, status: patch.status ?? harnessProfile.status };
},
};
const processManager: ProcessManager = {
list: async () =>
@@ -152,17 +161,102 @@ const createHarness = (
redisKeyPrefix: 'sammo:test',
gameTokenSecret: 'test-secret',
gatewayInternalApiUrl: 'http://127.0.0.1:13000',
baseEnv: { DATABASE_URL: 'postgresql://test:test@127.0.0.1:15432/test' },
},
reconcileIntervalMs: 60_000,
scheduleIntervalMs: 60_000,
buildIntervalMs: 60_000,
adminActionIntervalMs: 60_000,
cancelGame: options.cancelGame,
});
return { orchestrator, statuses, completions, completionFields, started, stopped, deleted, logs };
};
describe('GatewayOrchestrator first-class operations', () => {
it('stops runtime, settles once, and seals a cancelled profile', async () => {
const operation: GatewayOperationRecord = {
id: '88888888-8888-4888-8888-888888888888',
profileName: profile.profileName,
type: 'CANCEL_GAME',
status: 'RUNNING',
sourceMode: 'COMMIT',
sourceRef: profile.buildCommitSha,
resolvedCommitSha: profile.buildCommitSha,
payload: {
historyMode: 'RETAIN_ABANDONED',
generalMode: 'RETAIN',
earnedPointRetentionPercent: 40,
},
reason: '잘못 연 게임 취소',
requestedBy: 'admin',
createdAt: '2026-08-18T00:00:00.000Z',
startedAt: '2026-08-18T00:00:00.000Z',
updatedAt: '2026-08-18T00:00:00.000Z',
};
const workspaceManager = {
resolveCommit: async () => profile.buildCommitSha!,
prepare: async () => ({ root: process.cwd(), needsInstall: false }),
remove: async () => {},
} as unknown as GitWorkspaceManager;
const settlementRequests: Array<Record<string, unknown>> = [];
const cancellationProfile = { ...profile, status: 'RUNNING' as const };
const cancelGame: NonNullable<GatewayOrchestratorOptions['cancelGame']> = async (request) => {
settlementRequests.push(request as unknown as Record<string, unknown>);
return {
cancellationId: operation.id,
serverId: 'che_260818_fixture',
originalSeason: 7,
participantCount: 2,
preservedGeneralCount: 2,
historyMode: 'RETAIN_ABANDONED',
generalMode: 'RETAIN',
earnedPointRetentionPercent: 40,
alreadyApplied: false,
settlements: {},
};
};
const harness = createHarness(operation, false, false, true, false, workspaceManager, undefined, {
profile: cancellationProfile,
cancelGame,
});
await harness.orchestrator.runOperationsNow();
expect(settlementRequests).toHaveLength(1);
expect(settlementRequests[0]).toMatchObject({
cancellationId: operation.id,
cancelledBy: 'admin',
reason: '잘못 연 게임 취소',
historyMode: 'RETAIN_ABANDONED',
generalMode: 'RETAIN',
earnedPointRetentionPercent: 40,
});
expect(harness.statuses).toEqual(['STOPPED', 'CANCELLED']);
expect(harness.stopped).toHaveLength(6);
expect(harness.completions).toEqual(['SUCCEEDED']);
expect(harness.logs).toEqual(
expect.arrayContaining([
expect.objectContaining({ phase: 'settlement' }),
expect.objectContaining({ phase: 'publish', message: expect.stringContaining('CANCELLED') }),
])
);
});
it('does not let a stale START operation reopen a cancelled profile', async () => {
const cancelledProfile = { ...profile, status: 'CANCELLED' as const };
const operation = buildOperation('START');
const harness = createHarness(operation, false, false, true, false, undefined, undefined, {
profile: cancelledProfile,
});
await harness.orchestrator.runOperationsNow();
expect(harness.started).toEqual([]);
expect(harness.completions).toEqual(['FAILED']);
expect(harness.completionFields[0]?.error).toContain('CANCELLED');
});
it('does not reconcile a profile while a durable operation is active', async () => {
const harness = createHarness(buildOperation('START'));
+1 -1
View File
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260818000000_add_legacy_import_checkpoints',
gatewaySchemaHead: '20260818001000_add_game_cancellation_operation',
gameSchemaHead: '20260818010000_add_legacy_battle_result_logs',
});
});