From b07b1fb5d9fe7cbb7f066ea03bd480a742ba1d04 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Sat, 3 Oct 2026 22:29:25 +0800 Subject: [PATCH] fix(ops): validate alert rule durations as whole minutes --- .../ops/components/OpsAlertRulesCard.vue | 4 +- .../OpsAlertRulesCard.duration.spec.ts | 50 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 frontend/src/views/admin/ops/components/__tests__/OpsAlertRulesCard.duration.spec.ts diff --git a/frontend/src/views/admin/ops/components/OpsAlertRulesCard.vue b/frontend/src/views/admin/ops/components/OpsAlertRulesCard.vue index 91a7d1c462..6df834fe2a 100644 --- a/frontend/src/views/admin/ops/components/OpsAlertRulesCard.vue +++ b/frontend/src/views/admin/ops/components/OpsAlertRulesCard.vue @@ -327,10 +327,10 @@ const editorValidation = computed(() => { if (!(typeof r.window_minutes === 'number' && Number.isFinite(r.window_minutes) && [1, 5, 60].includes(r.window_minutes))) { errors.push(t('admin.ops.alertRules.validation.windowRange')) } - if (!(typeof r.sustained_minutes === 'number' && Number.isFinite(r.sustained_minutes) && r.sustained_minutes >= 1 && r.sustained_minutes <= 1440)) { + if (!(typeof r.sustained_minutes === 'number' && Number.isInteger(r.sustained_minutes) && r.sustained_minutes >= 1 && r.sustained_minutes <= 1440)) { errors.push(t('admin.ops.alertRules.validation.sustainedRange')) } - if (!(typeof r.cooldown_minutes === 'number' && Number.isFinite(r.cooldown_minutes) && r.cooldown_minutes >= 0 && r.cooldown_minutes <= 1440)) { + if (!(typeof r.cooldown_minutes === 'number' && Number.isInteger(r.cooldown_minutes) && r.cooldown_minutes >= 0 && r.cooldown_minutes <= 1440)) { errors.push(t('admin.ops.alertRules.validation.cooldownRange')) } return { valid: errors.length === 0, errors } diff --git a/frontend/src/views/admin/ops/components/__tests__/OpsAlertRulesCard.duration.spec.ts b/frontend/src/views/admin/ops/components/__tests__/OpsAlertRulesCard.duration.spec.ts new file mode 100644 index 0000000000..ea3517627f --- /dev/null +++ b/frontend/src/views/admin/ops/components/__tests__/OpsAlertRulesCard.duration.spec.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, flushPromises, shallowMount } from '@vue/test-utils' + +vi.mock('vue-i18n', async (importOriginal) => ({ + ...await importOriginal(), + useI18n: () => ({ t: (key: string) => key }), +})) +enableAutoUnmount(afterEach) + + +import OpsAlertRulesCard from '../OpsAlertRulesCard.vue' +import { ref } from 'vue' +const { createAlertRule } = vi.hoisted(() => ({ createAlertRule: vi.fn() })) +vi.mock('@vueuse/core', () => ({ useMediaQuery: () => ref(true) })) +vi.mock('@/api', () => ({ adminAPI: { groups: { getAll: vi.fn().mockResolvedValue([]) } } })) +vi.mock('@/api/admin/ops', () => ({ opsAPI: { listAlertRules: vi.fn().mockResolvedValue([]), createAlertRule } })) +vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showError: vi.fn(), showSuccess: vi.fn() }) })) + +async function openRule() { + const wrapper = shallowMount(OpsAlertRulesCard, { + global: { stubs: { BaseDialog: { props: ['show'], template: '
' } } }, + }) + await flushPromises() + await wrapper.findAll('button').find(b => b.text().includes('admin.ops.alertRules.create'))!.trigger('click') + await wrapper.find('input[type="text"]').setValue('Error rate') + return wrapper +} +describe('alert rule integer durations', () => { + beforeEach(() => { vi.clearAllMocks() }) + it.each(['1.5', '0.5'])('rejects fractional duration %s before submitting', async value => { + const wrapper = await openRule() + const input = wrapper.find(value === '1.5' ? 'input[min="1"][max="1440"]' : 'input[min="0"][max="1440"]') + await input.setValue(value) + const save = wrapper.findAll('button').find(b => b.text() === 'common.save')! + expect(wrapper.text()).toContain(value === '1.5' ? 'admin.ops.alertRules.validation.sustainedRange' : 'admin.ops.alertRules.validation.cooldownRange') + await save.trigger('click') + expect(createAlertRule).not.toHaveBeenCalled() + }) + it('allows whole-minute boundaries and a fractional metric threshold', async () => { + const wrapper = await openRule() + await wrapper.get('input[min="1"][max="1440"]').setValue('1440') + await wrapper.get('input[min="0"][max="1440"]').setValue('0') + await wrapper.get('input[type="number"]:not([min])').setValue('1.25') + const save = wrapper.findAll('button').find(b => b.text() === 'common.save')! + expect(save.attributes('disabled')).toBeUndefined() + await save.trigger('click') + await flushPromises() + expect(createAlertRule).toHaveBeenCalledWith(expect.objectContaining({ sustained_minutes: 1440, cooldown_minutes: 0, threshold: 1.25 })) + }) +})