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
@@ -5,7 +5,7 @@ type OperationStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLE
type Operation = {
id: string;
profileName: string;
type: 'RESET' | 'DEPLOY' | 'START' | 'STOP';
type: 'RESET' | 'DEPLOY' | 'START' | 'STOP' | 'CANCEL_GAME';
status: OperationStatus;
sourceMode?: 'BRANCH' | 'COMMIT';
sourceRef?: string;
@@ -389,6 +389,22 @@ const installFixture = async (page: Page, state: FixtureState) => {
state.operations = [operation];
return response(operation);
}
if (name === 'admin.operations.requestGameCancellation') {
const operation: Operation = {
id: '88888888-8888-4888-8888-888888888888',
profileName: 'che:default',
type: 'CANCEL_GAME',
status: 'QUEUED',
sourceMode: 'COMMIT',
sourceRef: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
payload: {},
requestedBy: 'admin',
createdAt: '2026-08-18T01:00:00.000Z',
updatedAt: '2026-08-18T01:00:00.000Z',
};
state.operations = [operation];
return response(operation);
}
if (name === 'admin.releases.requestGatewayDeploy' || name === 'admin.releases.requestGatewayRollback') {
const releaseOperation = {
id: '77777777-7777-4777-8777-777777777777',
@@ -700,6 +716,93 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
});
test('submits a separately authorized destructive game cancellation on desktop and mobile', async ({
page,
}, testInfo) => {
const state: FixtureState = {
operations: [],
gatewayOperations: [],
runtimeRunning: true,
requestBodies: [],
profileLogProgress: true,
capabilities: [{ permission: 'admin.games.cancel', scope: 'PROFILE', scopes: ['che:default'] }],
};
await installFixture(page, state);
const confirmations: string[] = [];
page.on('dialog', async (dialog) => {
confirmations.push(dialog.message());
await dialog.accept();
});
await page.goto('admin/servers/che%3Adefault/cancel');
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3Adefault\/cancel$/);
await expect(page.getByRole('heading', { name: 'che:default 게임 취소' })).toBeVisible();
await expect(page.getByRole('link', { name: '게임 취소', exact: true })).toHaveAttribute('aria-current', 'page');
await expect(page.getByRole('link', { name: '시나리오 초기화', exact: true })).toHaveCount(0);
await expect(page.getByTestId('request-game-cancellation')).toBeDisabled();
await page.getByTestId('cancellation-history-mode').selectOption('DELETE');
await page.getByTestId('cancellation-general-mode').selectOption('RETAIN');
await page.getByTestId('cancellation-retention-percent').fill('35');
await page.getByTestId('cancellation-reason').fill('잘못된 시나리오로 개장함');
await page.getByTestId('cancellation-confirmation').fill('che:default');
const cancelButton = page.getByTestId('request-game-cancellation');
await expect(cancelButton).toBeEnabled();
await cancelButton.hover();
const desktopMetrics = await page.getByTestId('game-cancellation-form').evaluate((form) => {
const rect = form.getBoundingClientRect();
const style = getComputedStyle(form);
const controls = Array.from(form.querySelectorAll('select, input:not([type="range"]), textarea, button')).map(
(control) => {
const controlRect = control.getBoundingClientRect();
return { width: controlRect.width, height: controlRect.height };
}
);
return {
x: rect.x,
width: rect.width,
borderColor: style.borderColor,
backgroundColor: style.backgroundColor,
minimumControlHeight: Math.min(...controls.map((control) => control.height)),
};
});
expect(desktopMetrics.width).toBeGreaterThan(800);
expect(desktopMetrics.minimumControlHeight).toBeGreaterThanOrEqual(21);
await page.screenshot({ path: testInfo.outputPath('game-cancellation-desktop.png'), fullPage: true });
await cancelButton.click();
await expect(page.getByText('게임 취소 작업을 등록했습니다.').first()).toBeVisible();
await expect(page.getByTestId('operations-table')).toContainText('CANCEL_GAME');
expect(confirmations).toHaveLength(1);
expect(confirmations[0]).toContain('기수 행 물리 삭제');
expect(confirmations[0]).toContain('장수 기록 보존');
expect(confirmations[0]).toContain('유산 획득분 35% 보전');
const request = state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestGameCancellation');
expect(JSON.stringify(request?.body)).toContain('"historyMode":"DELETE"');
expect(JSON.stringify(request?.body)).toContain('"generalMode":"RETAIN"');
expect(JSON.stringify(request?.body)).toContain('"earnedPointRetentionPercent":35');
expect(JSON.stringify(request?.body)).toContain('잘못된 시나리오로 개장함');
await page.setViewportSize({ width: 390, height: 844 });
const mobileMetrics = await page.getByTestId('game-cancellation-form').evaluate((form) => {
const rect = form.getBoundingClientRect();
return {
x: rect.x,
width: rect.width,
viewportWidth: document.documentElement.clientWidth,
documentScrollWidth: document.documentElement.scrollWidth,
};
});
expect(mobileMetrics.x).toBeGreaterThanOrEqual(0);
expect(mobileMetrics.x + mobileMetrics.width).toBeLessThanOrEqual(mobileMetrics.viewportWidth);
expect(mobileMetrics.documentScrollWidth).toBeLessThanOrEqual(mobileMetrics.viewportWidth);
await writeFile(
testInfo.outputPath('game-cancellation-metrics.json'),
JSON.stringify({ desktopMetrics, mobileMetrics }, null, 2)
);
await page.screenshot({ path: testInfo.outputPath('game-cancellation-mobile.png'), fullPage: true });
});
for (const viewportSize of [
{ name: 'desktop', width: 1280, height: 720 },
{ name: 'mobile', width: 390, height: 844 },