fix(gateway): 이벤트 기수 0 저장을 허용

숫자 입력에서 Vue가 반환한 number 값을 안전하게 정규화해 다음 시즌 번호 0이 메타 저장 요청까지 전달되도록 수정한다. API 경계와 실제 Chromium payload 회귀 테스트를 추가한다.
This commit is contained in:
2026-08-19 11:00:31 +00:00
parent b43d7601e7
commit 617802c11b
4 changed files with 63 additions and 3 deletions
@@ -789,6 +789,30 @@ describe('admin operation API', () => {
).rejects.toBeDefined(); ).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 () => { it('does not let a scenario-only operator combine a Git update with reset', async () => {
const harness = await buildCaller( const harness = await buildCaller(
async () => { async () => {
@@ -1040,6 +1040,33 @@ test('edits server reset defaults through profile metadata settings', async ({ p
expect(request).toContain('"npcMode":2'); 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) => { test('shows a dismissible error toast when profile metadata persistence fails', async ({ page }, testInfo) => {
const state: FixtureState = { const state: FixtureState = {
operations: [], operations: [],
+6 -2
View File
@@ -407,7 +407,7 @@ const profileEdits = ref<
color: string; color: string;
inGameNotice: string; inGameNotice: string;
profileImageUrl: string; profileImageUrl: string;
nextSeasonIdx: string; nextSeasonIdx: string | number;
localAccountAccessGraceDays: string; localAccountAccessGraceDays: string;
localAccountGeneralCreationGraceDays: string; localAccountGeneralCreationGraceDays: string;
resetDefaults: ProfileResetDefaults; resetDefaults: ProfileResetDefaults;
@@ -770,7 +770,10 @@ const updateProfileMeta = async (profileName: string) => {
if (!edit) { if (!edit) {
return; 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); const nextSeasonIdx = nextSeasonRaw === '' ? null : Number(nextSeasonRaw);
if (nextSeasonIdx !== null && (!Number.isFinite(nextSeasonIdx) || nextSeasonIdx < 0)) { if (nextSeasonIdx !== null && (!Number.isFinite(nextSeasonIdx) || nextSeasonIdx < 0)) {
profileActionStatus.value = { profileActionStatus.value = {
@@ -2229,6 +2232,7 @@ onMounted(() => {
type="number" type="number"
min="0" min="0"
step="1" 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" class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="예: 12" placeholder="예: 12"
/> />
+6 -1
View File
@@ -76,6 +76,11 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
메타가 없거나 유효하지 않으면 기존 시스템 기본값을 사용합니다. 시나리오와 메타가 없거나 유효하지 않으면 기존 시스템 기본값을 사용합니다. 시나리오와
예약·가오픈·정식 오픈 시각은 매 실행마다 선택하므로 서버 기본값에 포함하지 예약·가오픈·정식 오픈 시각은 매 실행마다 선택하므로 서버 기본값에 포함하지
않습니다. 않습니다.
- 서버 상태의 `다음 시즌 번호`는 위 리셋 옵션과 별도의
`GatewayProfile.meta.nextSeasonIdx`이며 0 이상의 정수를 허용합니다. 이벤트
기수는 0을 저장한 뒤 RESET하고, 이벤트 종료 뒤 정상 기수 번호를 다시 저장한
다음 RESET합니다. 빈 값은 강제 번호를 해제하여 기존 게임의 season 또는 신규
기본값 1을 사용한다는 뜻입니다.
- 같은 화면의 `실행 중 게임 옵션`은 리셋 기본값과 별개로 현재 기수 DB의 턴 - 같은 화면의 `실행 중 게임 옵션`은 리셋 기본값과 별개로 현재 기수 DB의 턴
간격, 장수 생성 제한, 유저 자동턴 제한·동작을 읽어 표시합니다. 세 값은 간격, 장수 생성 제한, 유저 자동턴 제한·동작을 읽어 표시합니다. 세 값은
`admin.profiles.runtime:<name>` 권한과 3자 이상의 사유가 있을 때 하나의 `admin.profiles.runtime:<name>` 권한과 3자 이상의 사유가 있을 때 하나의
@@ -96,7 +101,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
| `admin.profiles.settings:<name>` | 표시 정보·리셋 기본 옵션·Kakao 미인증 접근/장수 생성 유예 | | `admin.profiles.settings:<name>` | 표시 정보·리셋 기본 옵션·Kakao 미인증 접근/장수 생성 유예 |
| `admin.profiles.deploy:<name>` | DB를 유지하는 Git 버전 업데이트, 초기화와 새 버전 결합 | | `admin.profiles.deploy:<name>` | DB를 유지하는 Git 버전 업데이트, 초기화와 새 버전 결합 |
| `admin.scenarios.reset:<name>` | 현재 배포 버전으로 시나리오 초기화 | | `admin.scenarios.reset:<name>` | 현재 배포 버전으로 시나리오 초기화 |
| `admin.games.cancel:<name>` | 진행 게임 취소, 기록 옵션과 유산 포인트 보전율 확정 | | `admin.games.cancel:<name>` | 진행 게임 취소, 기록 옵션과 유산 포인트 보전율 확정 |
| `admin.reset.schedule:<name>` | 허용된 시나리오 초기화를 미래 시각에 예약 | | `admin.reset.schedule:<name>` | 허용된 시나리오 초기화를 미래 시각에 예약 |
| `admin.releases.manage` | profile과 분리된 Gateway control plane 배포·rollback | | `admin.releases.manage` | profile과 분리된 Gateway control plane 배포·rollback |