diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 1e429481..7cef041a 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -789,6 +789,30 @@ describe('admin operation API', () => { ).rejects.toBeDefined(); }); + it('stores event season zero as the next season number', async () => { + const harness = await buildCaller( + async () => { + throw new Error('not used'); + }, + { adminRoles: ['admin.profiles.settings:che:2'], firstUserIsAdmin: false } + ); + + await harness.caller.admin.profiles.updateMeta({ + profileName: 'che:2', + patch: { nextSeasonIdx: 0 }, + reason: 'prepare event season', + }); + expect(harness.updatedMetas.at(-1)).toMatchObject({ nextSeasonIdx: 0 }); + + await expect( + harness.caller.admin.profiles.updateMeta({ + profileName: 'che:2', + patch: { nextSeasonIdx: -1 }, + reason: 'reject negative season', + }) + ).rejects.toBeDefined(); + }); + it('does not let a scenario-only operator combine a Git update with reset', async () => { const harness = await buildCaller( async () => { diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index 911b5c2b..efd9920d 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -1040,6 +1040,33 @@ test('edits server reset defaults through profile metadata settings', async ({ p expect(request).toContain('"npcMode":2'); }); +test('stores event season zero from server metadata settings', async ({ page }) => { + const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] }; + await installFixture(page, state); + + await page.goto('admin/servers/che%3Adefault'); + const nextSeasonInput = page.getByTestId('next-season-idx'); + await nextSeasonInput.fill('0'); + await expect(nextSeasonInput).toHaveValue('0'); + expect( + await nextSeasonInput.evaluate((element: HTMLInputElement) => ({ + valid: element.validity.valid, + valueAsNumber: element.valueAsNumber, + })) + ).toEqual({ valid: true, valueAsNumber: 0 }); + await page.getByPlaceholder('변경 사유 (필수)').fill('prepare event season'); + await page.getByRole('button', { name: '메타 저장' }).click(); + + await expect + .poll(() => state.requestBodies.find((entry) => entry.operation === 'admin.profiles.updateMeta')) + .toBeTruthy(); + const request = JSON.stringify( + state.requestBodies.find((entry) => entry.operation === 'admin.profiles.updateMeta')?.body + ); + expect(request).toContain('"nextSeasonIdx":0'); + await expect(page.getByTestId('action-toast').filter({ hasText: '메타 저장 완료' })).toBeVisible(); +}); + test('shows a dismissible error toast when profile metadata persistence fails', async ({ page }, testInfo) => { const state: FixtureState = { operations: [], diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 28bb3f68..a78367ef 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -407,7 +407,7 @@ const profileEdits = ref< color: string; inGameNotice: string; profileImageUrl: string; - nextSeasonIdx: string; + nextSeasonIdx: string | number; localAccountAccessGraceDays: string; localAccountGeneralCreationGraceDays: string; resetDefaults: ProfileResetDefaults; @@ -770,7 +770,10 @@ const updateProfileMeta = async (profileName: string) => { if (!edit) { return; } - const nextSeasonRaw = edit.nextSeasonIdx.trim(); + // Vue casts non-empty values from type="number" inputs to numbers even when + // the buffer was initialized with a string. Normalize both runtime shapes so + // event season 0 reaches the metadata mutation instead of throwing on trim(). + const nextSeasonRaw = String(edit.nextSeasonIdx).trim(); const nextSeasonIdx = nextSeasonRaw === '' ? null : Number(nextSeasonRaw); if (nextSeasonIdx !== null && (!Number.isFinite(nextSeasonIdx) || nextSeasonIdx < 0)) { profileActionStatus.value = { @@ -2229,6 +2232,7 @@ onMounted(() => { type="number" min="0" step="1" + data-testid="next-season-idx" class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white" placeholder="예: 12" /> diff --git a/docs/admin-console.md b/docs/admin-console.md index eeb26f28..9d78b406 100644 --- a/docs/admin-console.md +++ b/docs/admin-console.md @@ -76,6 +76,11 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로 메타가 없거나 유효하지 않으면 기존 시스템 기본값을 사용합니다. 시나리오와 예약·가오픈·정식 오픈 시각은 매 실행마다 선택하므로 서버 기본값에 포함하지 않습니다. +- 서버 상태의 `다음 시즌 번호`는 위 리셋 옵션과 별도의 + `GatewayProfile.meta.nextSeasonIdx`이며 0 이상의 정수를 허용합니다. 이벤트 + 기수는 0을 저장한 뒤 RESET하고, 이벤트 종료 뒤 정상 기수 번호를 다시 저장한 + 다음 RESET합니다. 빈 값은 강제 번호를 해제하여 기존 게임의 season 또는 신규 + 기본값 1을 사용한다는 뜻입니다. - 같은 화면의 `실행 중 게임 옵션`은 리셋 기본값과 별개로 현재 기수 DB의 턴 간격, 장수 생성 제한, 유저 자동턴 제한·동작을 읽어 표시합니다. 세 값은 `admin.profiles.runtime:` 권한과 3자 이상의 사유가 있을 때 하나의 @@ -96,7 +101,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로 | `admin.profiles.settings:` | 표시 정보·리셋 기본 옵션·Kakao 미인증 접근/장수 생성 유예 | | `admin.profiles.deploy:` | DB를 유지하는 Git 버전 업데이트, 초기화와 새 버전 결합 | | `admin.scenarios.reset:` | 현재 배포 버전으로 시나리오 초기화 | -| `admin.games.cancel:` | 진행 게임 취소, 기록 옵션과 유산 포인트 보전율 확정 | +| `admin.games.cancel:` | 진행 게임 취소, 기록 옵션과 유산 포인트 보전율 확정 | | `admin.reset.schedule:` | 허용된 시나리오 초기화를 미래 시각에 예약 | | `admin.releases.manage` | profile과 분리된 Gateway control plane 배포·rollback |