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();
});
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 () => {
@@ -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: [],
+6 -2
View File
@@ -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"
/>