From eaebff9e57fcbe531374bcc6fbd96fb22a93f509 Mon Sep 17 00:00:00 2001 From: ktx-mihir Date: Fri, 17 Jul 2026 18:33:17 +0530 Subject: [PATCH] fix: alerts module migration with zod (#13227) - alert module migration with zod --------- Co-authored-by: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com> --- .../pages/alertsPages/alertCreationWizard.js | 37 +- .../pages/alertsPages/alertsPage.js | 18 +- .../anomalyPages/anomalyFormValidationPage.js | 31 +- .../Alerts/alerts-form-validation.spec.js | 8 +- .../anomaly-detection-form-validation.spec.js | 38 +- .../components/alerts/AddAlert.schema.spec.ts | 220 + web/src/components/alerts/AddAlert.schema.ts | 287 ++ web/src/components/alerts/AddAlert.spec.ts | 3706 ++++------------- web/src/components/alerts/AddAlert.vue | 156 +- .../alerts/AddDestination.schema.ts | 209 + .../components/alerts/AddDestination.spec.ts | 580 ++- web/src/components/alerts/AddDestination.vue | 984 +++-- .../components/alerts/AddTemplate.schema.ts | 77 + web/src/components/alerts/AddTemplate.spec.ts | 459 +- web/src/components/alerts/AddTemplate.vue | 349 +- web/src/components/alerts/FieldsInput.spec.ts | 182 +- web/src/components/alerts/FieldsInput.vue | 166 +- .../components/alerts/FilterCondition.spec.ts | 235 +- web/src/components/alerts/FilterCondition.vue | 204 +- web/src/components/alerts/FilterGroup.spec.ts | 343 +- web/src/components/alerts/FilterGroup.vue | 113 +- .../alerts/ImportDestination.spec.ts | 35 +- .../components/alerts/ImportDestination.vue | 118 +- .../components/alerts/OFormTagInput.types.ts | 15 + web/src/components/alerts/OFormTagInput.vue | 55 + ...rganizationDeduplicationSettings.schema.ts | 78 + .../OrganizationDeduplicationSettings.spec.ts | 122 +- .../OrganizationDeduplicationSettings.vue | 193 +- .../PrebuiltDestinationForm.schema.spec.ts | 165 + .../alerts/PrebuiltDestinationForm.schema.ts | 139 + .../alerts/PrebuiltDestinationForm.spec.ts | 407 +- .../alerts/PrebuiltDestinationForm.vue | 293 +- .../alerts/QueryEditorDialog.spec.ts | 65 +- .../components/alerts/QueryEditorDialog.vue | 57 +- .../alerts/SemanticGroupItem.schema.ts | 53 + .../alerts/SemanticGroupItem.spec.ts | 99 +- .../components/alerts/SemanticGroupItem.vue | 251 +- .../components/alerts/VariablesInput.spec.ts | 175 +- web/src/components/alerts/VariablesInput.vue | 154 +- .../components/alerts/steps/Advanced.spec.ts | 817 +--- web/src/components/alerts/steps/Advanced.vue | 224 +- .../alerts/steps/AlertSettings.schema.ts | 142 + .../alerts/steps/AlertSettings.spec.ts | 1250 ++---- .../components/alerts/steps/AlertSettings.vue | 963 +---- .../alerts/steps/CompareWithPast.spec.ts | 107 + .../alerts/steps/CompareWithPast.vue | 103 +- .../alerts/steps/Deduplication.spec.ts | 678 +-- .../components/alerts/steps/Deduplication.vue | 131 +- .../alerts/steps/QueryConfig.schema.spec.ts | 708 ++++ .../alerts/steps/QueryConfig.schema.ts | 531 +++ .../alerts/steps/QueryConfig.spec.ts | 1260 ++++-- .../components/alerts/steps/QueryConfig.vue | 1566 ++++--- .../steps/AnomalyAlerting.vue | 5 +- .../steps/AnomalyDetectionConfig.schema.ts | 210 + .../steps/AnomalyDetectionConfig.spec.ts | 495 ++- .../steps/AnomalyDetectionConfig.vue | 693 +-- web/src/composables/useAlertForm.ts | 1122 +++-- .../usePrebuiltDestinations.spec.ts | 23 +- .../composables/usePrebuiltDestinations.ts | 29 +- web/src/lib/forms/Input/OFormInput.types.ts | 12 +- web/src/lib/forms/Input/OFormInput.vue | 14 +- web/src/lib/forms/Input/OInput.vue | 8 +- web/src/lib/forms/Range/OFormRange.spec.ts | 37 + web/src/lib/forms/Range/OFormRange.vue | 11 + web/src/lib/forms/Select/OFormSelect.spec.ts | 52 +- web/src/locales/languages/de.json | 19 + web/src/locales/languages/en.json | 322 +- web/src/locales/languages/es.json | 19 + web/src/locales/languages/fr.json | 19 + web/src/locales/languages/it.json | 19 + web/src/locales/languages/ja.json | 19 + web/src/locales/languages/ko.json | 19 + web/src/locales/languages/nl.json | 19 + web/src/locales/languages/pt.json | 19 + web/src/locales/languages/tr.json | 19 + web/src/locales/languages/zh.json | 19 + web/src/locales/languages/zh_TW.json | 19 + .../utils/alerts/alertDataTransforms.spec.ts | 64 + web/src/utils/alerts/alertDataTransforms.ts | 15 + web/src/utils/alerts/alertPayload.spec.ts | 129 + web/src/utils/alerts/alertPayload.ts | 66 +- web/src/utils/alerts/alertValidation.ts | 150 +- web/src/utils/prebuilt-templates/discord.ts | 9 +- .../utils/prebuilt-templates/email.spec.ts | 2 +- web/src/utils/prebuilt-templates/email.ts | 7 +- .../utils/prebuilt-templates/index.spec.ts | 4 +- web/src/utils/prebuilt-templates/msteams.ts | 6 +- web/src/utils/prebuilt-templates/opsgenie.ts | 10 +- web/src/utils/prebuilt-templates/pagerduty.ts | 8 +- .../utils/prebuilt-templates/servicenow.ts | 23 +- web/src/utils/prebuilt-templates/slack.ts | 9 +- .../prebuilt-templates/templates.spec.ts | 2 +- .../utils/prebuilt-templates/types.spec.ts | 21 +- web/src/utils/prebuilt-templates/types.ts | 26 +- web/src/utils/validateAlerts.spec.ts | 11 +- 95 files changed, 13432 insertions(+), 9728 deletions(-) create mode 100644 web/src/components/alerts/AddAlert.schema.spec.ts create mode 100644 web/src/components/alerts/AddAlert.schema.ts create mode 100644 web/src/components/alerts/AddDestination.schema.ts create mode 100644 web/src/components/alerts/AddTemplate.schema.ts create mode 100644 web/src/components/alerts/OFormTagInput.types.ts create mode 100644 web/src/components/alerts/OFormTagInput.vue create mode 100644 web/src/components/alerts/OrganizationDeduplicationSettings.schema.ts create mode 100644 web/src/components/alerts/PrebuiltDestinationForm.schema.spec.ts create mode 100644 web/src/components/alerts/PrebuiltDestinationForm.schema.ts create mode 100644 web/src/components/alerts/SemanticGroupItem.schema.ts create mode 100644 web/src/components/alerts/steps/AlertSettings.schema.ts create mode 100644 web/src/components/alerts/steps/QueryConfig.schema.spec.ts create mode 100644 web/src/components/alerts/steps/QueryConfig.schema.ts create mode 100644 web/src/components/anomaly_detection/steps/AnomalyDetectionConfig.schema.ts diff --git a/tests/ui-testing/pages/alertsPages/alertCreationWizard.js b/tests/ui-testing/pages/alertsPages/alertCreationWizard.js index 27afa01728..342196af46 100644 --- a/tests/ui-testing/pages/alertsPages/alertCreationWizard.js +++ b/tests/ui-testing/pages/alertsPages/alertCreationWizard.js @@ -227,7 +227,7 @@ export class AlertCreationWizard { await expect(this.page.locator(this.locators.operatorSelect).first()).toBeVisible({ timeout: 5000 }); await this.page.locator(this.locators.operatorSelect).first().click(); - await this.page.getByText('Contains', { exact: true }).click(); + await this.page.getByRole('option', { name: 'Contains', exact: true }).click(); await this.page.locator(this.locators.conditionValueInput).first().locator('input').fill(value); @@ -517,13 +517,16 @@ export class AlertCreationWizard { await thresholdInput.fill('1'); testLogger.info('Set threshold value: 1'); - // Period - const periodInput = this.page.locator('.period-input-container input[type="number"]'); - await this.page.waitForTimeout(500); - if (await periodInput.isVisible({ timeout: 3000 })) { - await periodInput.fill('15'); - testLogger.info('Set period: 15 minutes'); - } + // Period — migrated to an OFormInput (data-test="alert-settings-period-input"). + // The native input carries the auto-derived `-field` suffix; the old + // `.period-input-container` wrapper no longer exists. Fill it hard (not a + // soft isVisible-guard) so the alert saves with period=15 — otherwise it + // silently falls back to the default (10) and the "15 Mins" list cell never + // renders. + const periodInput = this.page.locator('[data-test="alert-settings-period-input-field"]').first(); + await periodInput.waitFor({ state: 'visible', timeout: 5000 }); + await periodInput.fill('15'); + testLogger.info('Set period: 15 minutes'); // Destination selection using v3 data-test locator const destinationDropdown = this.page.locator('[data-test="alert-destinations-select"]'); @@ -653,8 +656,8 @@ export class AlertCreationWizard { await expect(operatorSelect1).toBeVisible({ timeout: 5000 }); await operatorSelect1.click(); await this.page.waitForTimeout(500); - await expect(this.page.getByText('Contains', { exact: true })).toBeVisible({ timeout: 5000 }); - await this.page.getByText('Contains', { exact: true }).click(); + await expect(this.page.getByRole('option', { name: 'Contains', exact: true })).toBeVisible({ timeout: 5000 }); + await this.page.getByRole('option', { name: 'Contains', exact: true }).click(); await this.page.waitForTimeout(500); // Use 'test' as value - matches the log field from ingested validation data @@ -868,7 +871,7 @@ export class AlertCreationWizard { await thresholdInput.fill('1'); testLogger.info('Set threshold value: 1'); - const periodInput = this.page.locator('.period-input-container input[type="number"]'); + const periodInput = this.page.locator('[data-test="alert-settings-period-input-field"]').first(); await this.page.waitForTimeout(500); if (await periodInput.isVisible({ timeout: 3000 })) { await periodInput.fill('15'); @@ -1023,8 +1026,8 @@ export class AlertCreationWizard { const operatorSelect1 = this.page.locator(this.locators.operatorSelect).first(); await operatorSelect1.click(); await this.page.waitForTimeout(500); - await expect(this.page.getByText('Contains', { exact: true })).toBeVisible({ timeout: 5000 }); - await this.page.getByText('Contains', { exact: true }).click(); + await expect(this.page.getByRole('option', { name: 'Contains', exact: true })).toBeVisible({ timeout: 5000 }); + await this.page.getByRole('option', { name: 'Contains', exact: true }).click(); await this.page.waitForTimeout(500); const valueInput1 = this.page.locator(this.locators.conditionValueInput).first().locator('input'); @@ -1386,7 +1389,7 @@ export class AlertCreationWizard { testLogger.info('Set threshold value: 1'); // Set shortest period (1 minute) for faster testing - const periodInput = this.page.locator('.period-input-container input[type="number"]'); + const periodInput = this.page.locator('[data-test="alert-settings-period-input-field"]').first(); await this.page.waitForTimeout(500); if (await periodInput.isVisible({ timeout: 5000 })) { await periodInput.fill('1'); @@ -1547,7 +1550,7 @@ export class AlertCreationWizard { // Select operator await this.page.locator(this.locators.operatorSelect).first().click(); - await this.page.getByText('Contains', { exact: true }).click(); + await this.page.getByRole('option', { name: 'Contains', exact: true }).click(); await this.page.waitForTimeout(300); // Fill condition value @@ -1640,7 +1643,7 @@ export class AlertCreationWizard { // trigger condition section exists below the Having groups section. // Period - const periodInput = this.page.locator('.period-input-container input[type="number"]'); + const periodInput = this.page.locator('[data-test="alert-settings-period-input-field"]').first(); if (await periodInput.isVisible({ timeout: 3000 })) { await periodInput.fill('15'); } @@ -1853,7 +1856,7 @@ export class AlertCreationWizard { // No separate threshold section — just period and destination // Set period - const periodInput = this.page.locator('.period-input-container input[type="number"]'); + const periodInput = this.page.locator('[data-test="alert-settings-period-input-field"]').first(); await this.page.waitForTimeout(500); if (await periodInput.isVisible({ timeout: 3000 })) { await periodInput.fill('15'); diff --git a/tests/ui-testing/pages/alertsPages/alertsPage.js b/tests/ui-testing/pages/alertsPages/alertsPage.js index 83c0b59c4e..17c1c28e24 100644 --- a/tests/ui-testing/pages/alertsPages/alertsPage.js +++ b/tests/ui-testing/pages/alertsPages/alertsPage.js @@ -1118,16 +1118,14 @@ export class AlertsPage { await this.page.locator(this.locators.alertSubmitButton).click(); await this.page.waitForTimeout(500); - // v3 shows a toast notification when name is empty - // OToast renders the message in 3 elements (sr-only ARIA span, sr-only title div, - // visible message div) — scope to the visible `o-toast-message` data-test to avoid - // strict-mode violation per AGENT_RULES §2. - await expect( - this.page - .locator('[data-test="o-toast-message"]') - .filter({ hasText: 'Alert name is required.' }) - .first() - ).toBeVisible({ timeout: 10000 }); + // Post-migration the alert form validates via useOForm (revalidateLogic): + // an empty name surfaces an INLINE schema error on the name OFormInput + // (data-test="add-alert-name-input" → auto-derived `-error` element), not a + // toast. Message is `alerts.nameRequired` = "Alert name is required." — the + // same copy the old toast used; only the delivery (toast → inline) changed. + const nameError = this.page.locator('[data-test="add-alert-name-input-error"]').first(); + await expect(nameError).toBeVisible({ timeout: 10000 }); + await expect(nameError).toContainText('Alert name is required'); testLogger.info('Invalid alert name validation working'); // Close with robust handling for dialog backdrops diff --git a/tests/ui-testing/pages/anomalyPages/anomalyFormValidationPage.js b/tests/ui-testing/pages/anomalyPages/anomalyFormValidationPage.js index b3ea620ace..4ea9821768 100644 --- a/tests/ui-testing/pages/anomalyPages/anomalyFormValidationPage.js +++ b/tests/ui-testing/pages/anomalyPages/anomalyFormValidationPage.js @@ -23,11 +23,17 @@ class AnomalyFormValidationPage { this.streamTypeSelect = '[data-test="add-alert-stream-type-select-dropdown"]'; // OSelect: stream name dropdown this.streamNameSelect = '[data-test="add-alert-stream-name-select-dropdown"]'; - // Next / Save / Cancel buttons shared across the wizard - this.nextBtn = '[data-test="add-alert-next-btn"]'; + // Save / Cancel buttons shared across the wizard. There is no Next button: + // the V3 layout is a single pane navigated by tabs, not a stepper. this.saveBtn = '[data-test="add-alert-submit-btn"]'; this.cancelBtn = '[data-test="add-alert-cancel-btn"]'; + // ── Validation feedback ──────────────────────────────────────────── + // OInput derives its error node's data-test from the consumer's own + // data-test, so the topbar OFormInput yields -error. + this.anomalyNameError = '[data-test="add-anomaly-name-input-error"]'; + this.toastMessage = '[data-test="o-toast-message"]'; + // ── Step 2 — AnomalyDetectionConfig fields ───────────────────────── this.queryTabsToggle = '[data-test="anomaly-query-tabs"]'; this.detectionFunction = '[data-test="anomaly-detection-function"]'; @@ -72,11 +78,6 @@ class AnomalyFormValidationPage { testLogger.info('Add Anomaly wizard opened'); } - async clickNext() { - testLogger.info('Clicking Next in anomaly wizard'); - await this.page.locator(this.nextBtn).click(); - } - async clickSave() { testLogger.info('Clicking Save in anomaly wizard'); await this.page.locator(this.saveBtn).click(); @@ -91,8 +92,22 @@ class AnomalyFormValidationPage { getStreamTypeSelectLocator() { return this.page.locator(this.streamTypeSelect); } getStreamNameSelectLocator() { return this.page.locator(this.streamNameSelect); } - getNextBtnLocator() { return this.page.locator(this.nextBtn); } getSaveBtnLocator() { return this.page.locator(this.saveBtn); } + getAnomalyNameErrorLocator() { return this.page.locator(this.anomalyNameError); } + + /** + * Toast message node, narrowed to the one we care about. + * + * Toasts STACK (OToastProvider v-for's over every open record) and error + * toasts live 30s, so an unrelated earlier error is very likely still on + * screen. Matching the bare selector would resolve to several nodes and trip + * Playwright strict mode, so always filter by the expected text. + * + * @param {string|RegExp} hasText text the target toast must contain + */ + getToastMessageLocator(hasText) { + return this.page.locator(this.toastMessage).filter({ hasText }); + } getCancelBtnLocator() { return this.page.locator(this.cancelBtn); } getDetectionWindowErrorLocator() { return this.page.locator(this.detectionWindowError); } getCustomSqlTimestampErrorLocator() { return this.page.locator(this.customSqlTimestampError); } diff --git a/tests/ui-testing/playwright-tests/Alerts/alerts-form-validation.spec.js b/tests/ui-testing/playwright-tests/Alerts/alerts-form-validation.spec.js index 9c54300d75..ff39470d26 100644 --- a/tests/ui-testing/playwright-tests/Alerts/alerts-form-validation.spec.js +++ b/tests/ui-testing/playwright-tests/Alerts/alerts-form-validation.spec.js @@ -430,10 +430,10 @@ test.describe('Alerts Form Validation', { tag: ['@alerts-form-validation', '@P0' await fvPage.addFilterCondition(); await fvPage.getFieldsInputRowLocator(0).waitFor({ state: 'visible', timeout: 10000 }); - // Trigger value field blur without entering a value - const valueField = fvPage.getFilterConditionValueFieldLocator().first(); - await valueField.click(); - await page.keyboard.press('Tab'); + // Post-migration the alert form validates on SUBMIT (useOForm/revalidateLogic), + // not on blur. Submit with an empty condition value so the schema-driven + // "Field is required!" error surfaces inline on the value OFormInput (R3). + await page.locator('[data-test="add-alert-submit-btn"]').click(); // Value error should become visible const valueError = fvPage.getFilterConditionValueErrorLocator().first(); diff --git a/tests/ui-testing/playwright-tests/Alerts/anomaly-detection-form-validation.spec.js b/tests/ui-testing/playwright-tests/Alerts/anomaly-detection-form-validation.spec.js index 31ebad7f91..d14a4fde93 100644 --- a/tests/ui-testing/playwright-tests/Alerts/anomaly-detection-form-validation.spec.js +++ b/tests/ui-testing/playwright-tests/Alerts/anomaly-detection-form-validation.spec.js @@ -3,7 +3,7 @@ // Anomaly Detection domain — form validation E2E tests // // Covers: -// 1. Add Anomaly wizard — Next button disabled until stream is selected +// 1. Add Anomaly wizard — blank name blocks save and highlights the field // 2. Query mode toggle — switching between filter and custom SQL modes // 3. Detection window error — save button disabled or window error shown for invalid window @@ -26,31 +26,35 @@ test.describe('Anomaly Detection form validation', { tag: ['@anomaly-form-valida testLogger.info('Navigated to Anomaly Detection tab'); }); - // ── TC001: Next button disabled when stream type is not selected ────────── + // ── TC001: blank name blocks save and highlights the name field ─────────── + // + // The wizard does NOT gate save by disabling the button: OForm keeps submit + // enabled and validates on click, so the offending field gets painted rather + // than the user being left guessing why a gray button won't respond. The + // blank-name rule lives in AddAlert.schema.ts's anomaly branch. - test('should disable Next button when stream is not selected in step 1', { + test('should block save and highlight the name when it is blank', { tag: ['@anomaly-form-validation', '@P0', '@smoke'] }, async ({ page }) => { - testLogger.info('TC001: Verify Next button disabled when no stream selected'); + testLogger.info('TC001: Verify blank anomaly name blocks save and paints the field'); await pm.anomalyFormValidation.openAddAnomalyWizard(); - // Stream type and name selects are visible; nothing is selected yet + // Wizard is open on step 1; name is blank and nothing is selected yet await expect(pm.anomalyFormValidation.getStreamTypeSelectLocator()).toBeVisible(); - // Next button should be disabled until a valid stream is selected - const nextBtn = pm.anomalyFormValidation.getNextBtnLocator(); - const nextVisible = await nextBtn.isVisible().catch(() => false); + // Save stays enabled — validation runs on submit, not via a disabled button + const saveBtn = pm.anomalyFormValidation.getSaveBtnLocator(); + await expect(saveBtn).toBeEnabled(); - if (nextVisible) { - await expect(nextBtn).toBeDisabled(); - testLogger.info('Next button correctly disabled when no stream selected'); - } else { - // Some environments render a single-step form — check save is disabled - const saveBtn = pm.anomalyFormValidation.getSaveBtnLocator(); - await expect(saveBtn).toBeDisabled(); - testLogger.info('Save button correctly disabled when no stream selected'); - } + await pm.anomalyFormValidation.clickSave(); + + // Blank name must paint the topbar field and toast, and must not save + await expect(pm.anomalyFormValidation.getAnomalyNameErrorLocator()) + .toHaveText('Anomaly name is required.'); + await expect(pm.anomalyFormValidation.getToastMessageLocator(/fix the highlighted fields/i)) + .toBeVisible(); + testLogger.info('Blank name correctly blocked save and highlighted the field'); await pm.anomalyFormValidation.clickCancel(); }); diff --git a/web/src/components/alerts/AddAlert.schema.spec.ts b/web/src/components/alerts/AddAlert.schema.spec.ts new file mode 100644 index 0000000000..e0fff16424 --- /dev/null +++ b/web/src/components/alerts/AddAlert.schema.spec.ts @@ -0,0 +1,220 @@ +// Copyright 2026 OpenObserve Inc. +// +// Unit tests for the composed AddAlert orchestrator schema. These exercise the +// schema in isolation (no component mount) to prove the topbar rules + the +// reused QueryConfig / AlertSettings rules fire at the right nested paths and in +// the right is_real_time branch (Rule ④ validation parity). + +import { describe, it, expect } from "vitest"; +import i18n from "@/locales"; +import { makeAddAlertSchema, defaultAddAlertMeta } from "./AddAlert.schema"; + +// Messages are i18n-driven now — resolve through the real locale so assertions +// verify the exact English (parity) AND that the keys exist. +const t = (key: string, named?: Record): string => + (i18n.global.t as any)(key, named); +const addAlertSchema = makeAddAlertSchema(t); +const ALERT_NAME_REQUIRED_MESSAGE = t("alerts.nameRequired"); +const ALERT_NAME_SPECIAL_CHARS_MESSAGE = t("alerts.nameNoSpecialChars"); +const STREAM_TYPE_REQUIRED_MESSAGE = t("alerts.validation.streamTypeRequired"); +const STREAM_NAME_REQUIRED_MESSAGE = t("alerts.validation.streamNameRequired"); + +const validScheduled = (over: Record = {}) => ({ + name: "my_alert", + stream_type: "logs", + stream_name: "default", + is_real_time: "false", + destinations: ["dest1"], + creates_incident: false, + trigger_condition: { + period: 10, + operator: ">=", + frequency: 10, + cron: "", + threshold: 3, + silence: 10, + frequency_type: "minutes", + timezone: "UTC", + }, + query_condition: { + conditions: { + filterType: "group", + logicalOperator: "AND", + groupId: "", + conditions: [], + }, + sql: "", + promql: "", + type: "custom", + aggregation: null, + promql_condition: null, + vrl_function: null, + multi_time_range: [], + }, + logGroupBy: [], + // DISPLAY value for the "Check every" input (minutes mode → same number as + // the stored trigger_condition.frequency minutes). The frequency rules key off + // this path — it is the one bound to the visible input. + _ui: { checkEvery: 10 }, + _meta: defaultAddAlertMeta(), + ...over, +}); + +/** Collect issue messages keyed by dot-path for easy assertions. */ +const issuesByPath = (value: any): Record => { + const res = addAlertSchema.safeParse(value); + const map: Record = {}; + if (!res.success) { + for (const issue of res.error.issues) { + const key = issue.path.join("."); + (map[key] ||= []).push(issue.message); + } + } + return map; +}; + +describe("addAlertSchema (composed orchestrator schema)", () => { + it("passes a complete valid scheduled alert", () => { + expect(addAlertSchema.safeParse(validScheduled()).success).toBe(true); + }); + + // ── Topbar (name §4 restore + stream required) ────────────────────────────── + it("blocks an empty name", () => { + const m = issuesByPath(validScheduled({ name: "" })); + expect(m["name"]).toContain(ALERT_NAME_REQUIRED_MESSAGE); + }); + + it("blocks a name with unsupported characters (§4 restore)", () => { + const m = issuesByPath(validScheduled({ name: "bad name" })); + expect(m["name"]).toContain(ALERT_NAME_SPECIAL_CHARS_MESSAGE); + // colon / hash / quotes / % / & are all rejected + expect(issuesByPath(validScheduled({ name: "a:b" }))["name"]).toBeTruthy(); + expect(issuesByPath(validScheduled({ name: "a#b" }))["name"]).toBeTruthy(); + expect(issuesByPath(validScheduled({ name: "a%b" }))["name"]).toBeTruthy(); + }); + + it("blocks an empty stream_type and stream_name", () => { + const m = issuesByPath( + validScheduled({ stream_type: "", stream_name: "" }), + ); + expect(m["stream_type"]).toContain(STREAM_TYPE_REQUIRED_MESSAGE); + expect(m["stream_name"]).toContain(STREAM_NAME_REQUIRED_MESSAGE); + }); + + // ── AlertSettings (reused) ────────────────────────────────────────────────── + it("requires at least one destination", () => { + const m = issuesByPath(validScheduled({ destinations: [] })); + expect(m["destinations"]?.length).toBeGreaterThan(0); + }); + + it("requires period ≥ 1 for scheduled alerts", () => { + const base = validScheduled(); + base.trigger_condition.period = 0; + const m = issuesByPath(base); + expect(m["trigger_condition.period"]?.length).toBeGreaterThan(0); + }); + + it("does NOT enforce period ≥ 1 for realtime alerts", () => { + const base = validScheduled({ + is_real_time: "true", + _meta: defaultAddAlertMeta({ isRealTime: "true" }), + }); + base.trigger_condition.period = 0; + const m = issuesByPath(base); + expect(m["trigger_condition.period"]).toBeUndefined(); + }); + + // ── QueryConfig (reused) ──────────────────────────────────────────────────── + it("requires threshold ≥ 1 for scheduled alerts", () => { + const base = validScheduled(); + base.trigger_condition.threshold = 0; + const m = issuesByPath(base); + expect(m["trigger_condition.threshold"]?.length).toBeGreaterThan(0); + }); + + it("restores promql operator + value requiredness in promql mode (§4)", () => { + const base = validScheduled({ + _meta: defaultAddAlertMeta({ tab: "promql" }), + }); + base.query_condition.type = "promql"; + base.query_condition.promql_condition = { operator: "", value: "" }; + const m = issuesByPath(base); + expect( + m["query_condition.promql_condition.operator"]?.length, + ).toBeGreaterThan(0); + expect( + m["query_condition.promql_condition.value"]?.length, + ).toBeGreaterThan(0); + }); + + it("promql value is zero-safe (0 passes)", () => { + const base = validScheduled({ + _meta: defaultAddAlertMeta({ tab: "promql" }), + }); + base.query_condition.type = "promql"; + base.query_condition.promql_condition = { operator: ">=", value: 0 }; + const m = issuesByPath(base); + expect(m["query_condition.promql_condition.value"]).toBeUndefined(); + }); + + it("blocks a partially-filled condition row (custom mode)", () => { + const base = validScheduled(); + base.query_condition.conditions = { + filterType: "group", + logicalOperator: "AND", + groupId: "", + conditions: [ + { + filterType: "condition", + column: "", + operator: "=", + value: "", + logicalOperator: "AND", + }, + ], + }; + const m = issuesByPath(base); + expect( + m["query_condition.conditions.conditions.0.column"]?.length, + ).toBeGreaterThan(0); + }); + + // ── Anomaly branch: near-pass-through — `name` ONLY ───────────────────────── + // The detection-config fields live on AnomalyDetectionConfig's own form, so + // this schema still ignores stream/query/settings in anomaly mode. `name` is + // the exception: it is the one topbar field this form holds, and its blank + // rule is re-homed here from saveAnomalyDetection's toast so the field can + // actually highlight. + it("does NOT validate stream/query/settings in anomaly mode", () => { + const base = validScheduled({ + name: "my_anomaly", + stream_type: "", + stream_name: "", + destinations: [], + is_real_time: "anomaly", + }); + expect(addAlertSchema.safeParse(base).success).toBe(true); + }); + + it("requires name in anomaly mode", () => { + const base = validScheduled({ + name: "", + stream_type: "", + stream_name: "", + destinations: [], + is_real_time: "anomaly", + }); + const m = issuesByPath(base); + expect(m["name"]).toEqual(["Anomaly name is required."]); + // Still the ONLY issue — nothing else tightened alongside it. + expect(Object.keys(m)).toEqual(["name"]); + }); + + it("does NOT apply the alert special-chars rule to an anomaly name", () => { + const base = validScheduled({ + name: "has space:and#chars", + is_real_time: "anomaly", + }); + expect(addAlertSchema.safeParse(base).success).toBe(true); + }); +}); diff --git a/web/src/components/alerts/AddAlert.schema.ts b/web/src/components/alerts/AddAlert.schema.ts new file mode 100644 index 0000000000..6e98e1e867 --- /dev/null +++ b/web/src/components/alerts/AddAlert.schema.ts @@ -0,0 +1,287 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// ════════════════════════════════════════════════════════════════════════════ +// AddAlert orchestrator schema (Phase 4 — DESIGN Y owner). +// +// AddAlert OWNS the ONE for the whole wizard. The step children +// (QueryConfig, AlertSettings, Advanced, Deduplication) render as DESCENDANTS +// and bind their fields by nested `name=` into THIS form, so the ONE schema +// below must cover every nested path they bind. This file COMPOSES their +// exported rules (single source of truth — never re-hand-write them) and adds +// the topbar scalars: +// +// • name — required (min 1) + no ALERT_NAME_UNSUPPORTED_CHARS (§4) +// • stream_type — required +// • stream_name — required (min 1) +// + QueryConfig — queryConfigSchema (trigger_condition.*, query_condition.* +// incl. refineConditionsTree, promql §4 restores, aggregation, +// group_by[]) — self-gated by its `_meta` discriminators. +// + AlertSettings — createAlertSettingsSchema(isRealTime) (silence ≥ 0, +// destinations ≥ 1, period ≥ 1 SCHEDULED-only). +// +// DISCRIMINATOR — `is_real_time`: "false" (scheduled) | "true" (realtime) | +// "anomaly". The QueryConfig sub-schema already self-gates realtime-vs-scheduled +// via `_meta.isRealTime`; AlertSettings' period rule is applied scheduled-only +// by choosing createAlertSettingsSchema(isRealTime). The ANOMALY branch is a +// NEAR-pass-through: AnomalyDetectionConfig OWNS its OWN +schema+validate() +// (verified live — it calls useOForm unconditionally, re-provides its own +// context, and writes back to props.config), and the anomaly SAVE path +// (saveAnomalyDetection) bypasses this form entirely and reads `anomalyConfig`. +// Composing the DETECTION-CONFIG fields here would be dead code (this form never +// holds them), so the anomaly branch enforces only `name` — which this form DOES +// hold (the topbar OFormInput binds `name` in both modes, and the +// formData.name → anomalyConfig.name watcher in useAlertForm feeds the value +// saveAnomalyDetection reads). +// +// `name` here is a RE-HOME, not a new rule (Rule ④): saveAnomalyDetection has +// always blocked a blank anomaly name, but only as an imperative toast — and a +// toast cannot paint a field, so the topbar highlighted nothing while the toast +// said "highlighted fields". Same rule, same message, now on the field. The +// blank check is deliberately the ONLY rule: the special-chars refine below is +// alert-only and adding it here would tighten anomaly vs. BEFORE. +// `stream_type`/`stream_name` stay unenforced in anomaly — unlike `name` those +// have NO pre-existing rule anywhere, so requiring them would be a real +// behavior change. Still OPEN (see the report's OPEN DECISION 7 note). +// +// Rule ④ — the composition reuses the step schemas VERBATIM (import, never +// re-declare) so the step and the orchestrator can never drift. Numbers still +// come out of OFormInput as strings; the sub-schemas own their z.coerce.number() +// / raw-value checks (nothing tightened here). +// ════════════════════════════════════════════════════════════════════════════ + +import { z } from "zod"; + +import { + makeQueryConfigSchema, + type QueryConfigMeta, + type Translator, +} from "./steps/QueryConfig.schema"; +import { createAlertSettingsSchema } from "./steps/AlertSettings.schema"; + +// ── Topbar messages are i18n-driven, resolved via the injected `t`: +// name required → alerts.nameRequired +// name special chars → alerts.nameNoSpecialChars +// stream type → alerts.validation.streamTypeRequired +// stream name → alerts.validation.streamNameRequired +// (English values are byte-for-byte the pre-migration toast strings.) ────── + +/** §4 restore — the exact regex useAlertForm used (RE_OFGA_UNSUPPORTED_NAME). + * Requiredness = min 1 AND this regex (truthy→Zod inversion, Rule ④). */ +export const ALERT_NAME_UNSUPPORTED_CHARS = /[:#?\s'"%&]+/; + +const isBlank = (v: unknown): boolean => + v === undefined || v === null || (typeof v === "string" && v.trim() === ""); + +// ════════════════════════════════════════════════════════════════════════════ +// Step-B field schemas (Phase 4b — the remaining wizard STEP children: +// Advanced / Deduplication / CompareWithPast). These are ADDITIVE and +// PERMISSIVE: none of these fields carried a required rule pre-migration, so +// nothing here tightens (Rule ④ parity — a previously-valid alert must still +// save). The step components bind these exact paths by nested `name=` into +// THIS orchestrator form (the composites below also drive the spec hosts). +// ════════════════════════════════════════════════════════════════════════════ + +/** One context-variable row. key/value are OPTIONAL (no pre-migration required + * rule — a blank row is allowed; getAlertPayload only copies non-blank key/value + * pairs into the payload object). `id` is kept for row identity in the help + * drawer / preview — it is NEVER used as the v-for `:key` (that must be the + * array index so an index-based `name=` binding survives a mid-list delete). */ +export const contextAttributeRowSchema = z.object({ + id: z.string().optional(), + key: z.string().optional(), + value: z.string().optional(), +}); + +/** `context_attributes` as the array of key/value rows the FORM holds. The + * payload transform (getAlertPayload) folds this array → an object, dropping + * blank rows — so this array shape is the form-side single source of truth. */ +export const contextAttributesSchema = z.array(contextAttributeRowSchema); + +/** Advanced-tab composite (Advanced.vue own-mode schema). `looseObject` + * so the rest of the shared alert object passes through untouched. */ +export const advancedShape = { + template: z.string().optional(), + context_attributes: contextAttributesSchema.optional(), + description: z.string().optional(), + row_template: z.string().optional(), + row_template_type: z.string().optional(), +} as const; + +export const createAdvancedSchema = () => z.looseObject({ ...advancedShape }); +export type AdvancedForm = z.infer>; + +/** Deduplication (scheduled-only). All fields optional (no pre-migration + * required rule). `enabled` is DERIVED from `fingerprint_fields.length > 0` by + * the component; `time_window_minutes` is sanitized to number|undefined by the + * component — the raw form state may transiently hold a string, so the union + * keeps validation permissive (never rejects while typing). */ +export const deduplicationSchema = z.object({ + enabled: z.boolean().optional(), + fingerprint_fields: z.array(z.string()).optional(), + time_window_minutes: z + .union([z.number(), z.string(), z.undefined()]) + .optional(), +}); +export type DeduplicationForm = z.infer; + +/** Compare-with-past reference-window row. `offSet` is a relative-time string + * (e.g. "15m") edited by the BARE CustomDateTimePicker (a genuine non-form + * widget, kept out of OForm* and bridged). `uuid` is the row identity used as + * the v-for `:key` — correct HERE because the row is bound by object reference, + * NOT by an index-based OForm* `name=` (so the mid-list-delete index bug that + * forces `:key="index"` on OForm* arrays does not apply). */ +export const multiTimeRangeRowSchema = z.object({ + offSet: z.string(), + uuid: z.string().optional(), +}); +export const multiTimeRangeSchema = z.array(multiTimeRangeRowSchema); + +/** + * Full alert form schema. `looseObject` so all the non-form alert fields + * (template, context_attributes, enabled, description, row_template*, folder_id, + * multi_time_range, vrl_function, …) that the payload reads pass through + * untouched — requiredness lives entirely in the superRefine, so nothing + * tightens by accident (Rule ④) and an untouched alert object parses clean. + * + * `is_real_time` discriminates; the QueryConfig `_meta` discriminators (bridged + * into this form by QueryConfig's syncMeta watcher) gate the QueryConfig rules. + */ +export const makeAddAlertSchema = (t: Translator) => + z + .looseObject({ + name: z.string().optional(), + stream_type: z.string().optional(), + stream_name: z.string().optional(), + is_real_time: z.string().optional(), + destinations: z.array(z.string()).optional(), + creates_incident: z.boolean().optional(), + trigger_condition: z.looseObject({}).optional(), + query_condition: z.looseObject({}).optional(), + logGroupBy: z.array(z.string()).optional(), + // ── Step-B fields (Advanced / Deduplication tabs). Permissive/optional — + // documented pass-throughs, NOT validated (parity: no required rule + // pre-migration). The superRefine below adds NO issues for them, so the + // assembled payload + form validity stay byte-for-byte unchanged. ──── + context_attributes: contextAttributesSchema.optional(), + deduplication: deduplicationSchema.optional(), + template: z.string().optional(), + description: z.string().optional(), + row_template: z.string().optional(), + row_template_type: z.string().optional(), + // Required by the composed queryConfigSchema; QueryConfig keeps it fresh. + _meta: z.looseObject({}).optional(), + }) + .superRefine((val: any, ctx) => { + const mode = val.is_real_time; + + // ── ANOMALY branch: the detection-config fields are validated by + // AnomalyDetectionConfig's own OForm; only `name` lives on THIS form. Blank + // check re-homed from saveAnomalyDetection's toast (same message) so the + // topbar field actually highlights. No special-chars refine here — that rule + // is alert-only and would tighten anomaly vs. BEFORE. + if (mode === "anomaly") { + if (isBlank(val.name)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["name"], + message: t("alerts.anomalyNameRequired"), + }); + } + return; + } + + // ── Topbar: name required + no special chars (§4), stream type/name required. + if (isBlank(val.name)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["name"], + message: t("alerts.nameRequired"), + }); + } else if (ALERT_NAME_UNSUPPORTED_CHARS.test(String(val.name))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["name"], + message: t("alerts.nameNoSpecialChars"), + }); + } + if (isBlank(val.stream_type)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["stream_type"], + message: t("alerts.validation.streamTypeRequired"), + }); + } + if (isBlank(val.stream_name)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["stream_name"], + message: t("alerts.validation.streamNameRequired"), + }); + } + + // ── QueryConfig rules (REUSED verbatim). Delegate to the exported schema and + // re-home every issue at its exact nested path so the descendant OForm* + // fields surface them. Its superRefine self-gates realtime vs scheduled via + // `_meta.isRealTime`, and the promql/threshold/frequency/group_by/conditions + // rules land only in the branch where they applied pre-migration. + const qc = makeQueryConfigSchema(t).safeParse(val); + if (!qc.success) { + for (const issue of qc.error.issues) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: issue.path as (string | number)[], + message: issue.message, + }); + } + } + + // ── AlertSettings rules (REUSED verbatim). Period is enforced ≥ 1 for + // scheduled only; realtime keeps period rule-free (createAlertSettingsSchema + // toggles it). silence ≥ 0 + destinations ≥ 1 apply in both non-anomaly modes. + const isRealTime = mode === "true"; + const as = createAlertSettingsSchema(t, isRealTime).safeParse(val); + if (!as.success) { + for (const issue of as.error.issues) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: issue.path as (string | number)[], + message: issue.message, + }); + } + } + }); + +export type AddAlertForm = z.infer>; + +/** A complete, valid `_meta` block for the form defaults (before QueryConfig + * mounts and takes over via its syncMeta watcher). Mirrors QueryConfig's + * initialMeta for a fresh custom/logs/scheduled alert. */ +export const defaultAddAlertMeta = ( + overrides: Partial = {}, +): QueryConfigMeta => ({ + tab: "custom", + isRealTime: "false", + isEventBased: true, + selectedFunction: "total_events", + frequencyMode: "minutes", + hasConditions: false, + hasGroupBy: false, + aggregationEnabled: false, + // No org floor by default — the rule is skipped until QueryConfig/useAlertForm + // bridge the real zoConfig value in (never invent a floor). + minAutoRefreshInterval: 0, + ...overrides, +}); diff --git a/web/src/components/alerts/AddAlert.spec.ts b/web/src/components/alerts/AddAlert.spec.ts index 33b1c12440..fcddb10039 100644 --- a/web/src/components/alerts/AddAlert.spec.ts +++ b/web/src/components/alerts/AddAlert.spec.ts @@ -12,189 +12,145 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { flushPromises, mount, shallowMount } from "@vue/test-utils"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +// ── AddAlert orchestrator (OForm + Zod owner pattern) ──────────────────────── +// Behaviour-first spec. The wizard OWNS the ONE ; these tests drive the +// real form (form.handleSubmit) and assert schema-gated block-on-invalid + the +// EXACT save payload (Rule ④). Heavy step children are stubbed (the form is the +// single source of truth, so the save chain doesn't depend on their markup); the +// composed schema still validates from the seeded form values + default `_meta`. + +import { flushPromises, mount } from "@vue/test-utils"; +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; import { nextTick } from "vue"; import AddAlert from "@/components/alerts/AddAlert.vue"; import alertsService from "@/services/alerts"; +import { toast } from "@/lib/feedback/Toast/useToast"; +import anomalyDetectionService from "@/services/anomaly_detection"; import store from "@/test/unit/helpers/store"; import router from "@/test/unit/helpers/router"; -import { generateWhereClause } from "@/utils/alerts/alertQueryBuilder"; -import { detectConditionsVersion } from "@/utils/alerts/alertDataTransforms"; - -import PreviewAlert from "@/components/alerts/PreviewAlert.vue"; - import i18n from "@/locales"; -import CronExpressionParser from "cron-parser"; +import { generateWhereClause } from "@/utils/alerts/alertQueryBuilder"; -import { useLocalOrganization } from "@/utils/zincutils"; - -import searchService from "@/services/search"; - -vi.mock('@/composables/useStreams', () => { - return { - default: () => ({ - getStream: vi.fn().mockResolvedValue({ - schema: [ - { name: 'field1', type: 'string' }, - { name: 'field2', type: 'int' }, - ] - }), - getStreams: vi.fn().mockResolvedValue({ list: [] }), +vi.mock("@/composables/useStreams", () => ({ + default: () => ({ + getStream: vi.fn().mockResolvedValue({ + schema: [ + { name: "field1", type: "string" }, + { name: "field2", type: "int" }, + ], }), - }; -}); - -vi.mock('@/composables/useFunctions', () => { - return { - default: () => ({ - getAllFunctions: vi.fn().mockResolvedValue({ - functions: [ - { name: 'avg', description: 'Average' }, - { name: 'sum', description: 'Sum' }, - ] - }), - }), - }; -}); -vi.mock('@/services/search', () => ({ - default: { - search: vi.fn() - } + getStreams: vi.fn().mockResolvedValue({ list: [] }), + }), })); +vi.mock("@/composables/useFunctions", () => ({ + default: () => ({ getAllFunctions: vi.fn().mockResolvedValue({ functions: [] }) }), +})); +vi.mock("@/services/search", () => ({ default: { search: vi.fn() } })); -vi.mock('@/composables/useParser', () => { - return { - default: () => ({ - sqlParser: async () => ({ - astify: vi.fn((query) => { - const lowerQuery = query.toLowerCase(); - - if (lowerQuery.includes("select *")) { - return { - columns: [ - { expr: { column: "*" } } - ] - }; - } - if (lowerQuery.includes("valid_column")) { - return { - columns: [ - { expr: { column: "valid_column" } } - ] - }; - } - if (lowerQuery.includes("default")) { - throw new Error("Syntax error near 'default'"); - } - return { columns: [] }; - }), - sqlify: vi.fn(), - columnList: vi.fn(), - tableList: vi.fn(), - whiteListCheck: vi.fn(), - exprToSQL: vi.fn(), - parse: vi.fn(), - }) - }) - }; -}); +vi.mock("@/composables/useParser", () => ({ + default: () => ({ + sqlParser: async () => ({ + astify: vi.fn(() => ({ columns: [] })), + parse: vi.fn(), + sqlify: vi.fn(), + }), + }), +})); -vi.mock('cron-parser', () => { - return { - default: { - parseExpression: vi.fn() - } - }; -}); -vi.mock('@/utils/zincutils', async () => { - const actual: any = await vi.importActual('@/utils/zincutils'); +// NOTE: `cron-parser` is deliberately NOT mocked. It is a pure, fast library and +// the cron save gate below asserts real interval math. (There used to be a +// `{ default: { parseExpression: vi.fn() } }` mock here — stale: queryUtils calls +// `CronExpressionParser.parse`, so the mock made EVERY expression, valid or not, +// throw "Invalid cron expression". queryUtils.spec.ts uses the real parser too.) + +vi.mock("@/utils/zincutils", async () => { + const actual: any = await vi.importActual("@/utils/zincutils"); return { ...actual, - getUUID: vi.fn(() => 'mock-uuid'), - getTimezonesByOffset: vi.fn(() => Promise.resolve(['UTC'])), + getUUID: vi.fn(() => "mock-uuid"), + getTimezonesByOffset: vi.fn(() => Promise.resolve(["UTC"])), } as any; }); -vi.mock('@/services/alerts', () => { - return { - default: { - create_by_alert_id: vi.fn(() => - Promise.resolve({ - data: { - code: 200, - message: "Alert saved", - id: "2yis4t0dJCU7Vs4D3sEYHDsvDKF", - name: "test_alert" - } - }) - ), - update_by_alert_id: vi.fn(() => - Promise.resolve({ - data: { success: true } - }) - ), - generate_sql: vi.fn(() => - Promise.resolve({ - data: { - sql: "SELECT * FROM test" - } - }) - ) - } - }; -}); -// Additional composable mocks to prevent inject() warnings -vi.mock("@/composables/useLogs", () => ({ - default: vi.fn(() => ({ - searchObj: { value: { loading: false, data: { queryResults: [], aggs: { histogram: [] } } } }, - searchAggData: { value: { histogram: [], total: 0 } }, - searchResultData: { value: { list: [] } }, - getFunctions: vi.fn().mockResolvedValue([]) - })) -})); +// Toast returns a dismiss fn (loading toast). No-op in tests. +vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast: vi.fn(() => vi.fn()) })); -vi.mock("@/composables/useDashboard", () => ({ - default: vi.fn(() => ({ - dashboards: { value: [] }, - loading: { value: false }, - error: { value: null } - })) -})); - -vi.mock("@/services/segment_analytics", () => ({ +vi.mock("@/services/alerts", () => ({ default: { - track: vi.fn() - } + create_by_alert_id: vi.fn(() => + Promise.resolve({ data: { code: 200, message: "Alert saved" } }), + ), + update_by_alert_id: vi.fn(() => Promise.resolve({ data: { success: true } })), + generate_sql: vi.fn(() => Promise.resolve({ data: { sql: "SELECT * FROM test" } })), + }, })); -vi.mock("@/services/auth", () => ({ +vi.mock("@/services/anomaly_detection", () => ({ default: { - sign_in_user: vi.fn(), - sign_out: vi.fn(), - get_dex_config: vi.fn() - } + get: vi.fn(), + create: vi.fn(() => Promise.resolve({ data: { id: "anom-1" } })), + update: vi.fn(() => Promise.resolve({ data: { id: "anom-1" } })), + triggerTraining: vi.fn(() => Promise.resolve({})), + }, })); -vi.mock("@/services/organizations", () => ({ - default: { - get_organization: vi.fn(), - list: vi.fn(), - add_members: vi.fn() - } -})); +vi.mock("@/services/segment_analytics", () => ({ default: { track: vi.fn() } })); +vi.mock("@/services/reodotdev_analytics", () => ({ useReo: () => ({ track: vi.fn() }) })); -vi.mock("@/services/billings", () => ({ - default: { - get_billing_info: vi.fn(), - get_invoice_history: vi.fn() - } -})); +const stubs = { + QueryConfig: true, + AlertSettings: true, + CompareWithPast: true, + Deduplication: true, + Advanced: true, + PreviewAlert: true, + AlertSummary: true, + // Custom stub exposing validate() so the template ref (anomalyStep2Ref) the + // orchestrator calls during saveAnomalyDetection resolves truthy. + AnomalyDetectionConfig: { + template: "
", + methods: { async validate() { return true; } }, + }, + AnomalyAlerting: true, + AnomalySummary: true, + QueryEditor: true, + JsonEditor: true, + InlineSelectFolderDropdown: true, + AppPageHeader: true, +}; +const mountAlert = ( + props: Record = {}, + options: Record = {}, +) => + mount(AddAlert, { + global: { provide: { store }, plugins: [i18n, router], stubs }, + props, + ...options, + }); -describe("AddAlert Component", () => { +/** Seed a complete, valid scheduled-custom alert into the ONE form. */ +const seedValidScheduled = (form: any) => { + form.setFieldValue("name", "test_alert"); + form.setFieldValue("stream_type", "logs"); + form.setFieldValue("stream_name", "_rundata"); + form.setFieldValue("destinations", ["dest1"]); + form.setFieldValue("query_condition.type", "custom"); + form.setFieldValue("trigger_condition", { + period: 10, + operator: ">=", + frequency: 10, + cron: "", + threshold: 3, + silence: 10, + frequency_type: "minutes", + timezone: "UTC", + }); +}; + +describe("AddAlert (OForm owner)", () => { let wrapper: any; afterEach(() => { @@ -203,2832 +159,704 @@ describe("AddAlert Component", () => { vi.clearAllMocks(); }); - // Ensure async operations are complete - beforeEach(async () => { - const updateStreamsMock = vi.fn().mockResolvedValue({ success: true }); - - - - wrapper = shallowMount(AddAlert, { - global: { - provide: { - store: store, - }, - plugins: [i18n, router], - }, - props: { - title: "Add Alert", - }, - mocks: { - updateStreams: updateStreamsMock, - }, - }); - - wrapper.vm.formData = { - name: "", - stream_type: "", - stream_name: "", - is_real_time: "false", - query_condition: { - conditions: - { - "or": [ - { - "column": "", - "operator": ">", - "value": "", - "ignore_case": false - } - ] - }, - sql: "", - promql: "", - type: "custom", - aggregation: { - group_by: [""], - function: "avg", - having: { - column: "", - operator: ">=", - value: 1, - }, - }, - promql_condition: null, - vrl_function: null, - multi_time_range: [], - }, - trigger_condition: { - period: 10, - operator: ">=", - frequency: 1, - cron: "", - threshold: 3, - silence: 10, - frequency_type: "minutes", - timezone: "UTC", - }, - destinations: [], - context_attributes: {}, - enabled: true, - description: "", - lastTriggeredAt: 0, - createdAt: "", - updatedAt: "", - owner: "", - lastEditedBy: "", - folder_id : "", - - } - }); - - describe("Functions with input and output as expected", () => { - describe('general functions', () => { - it('filters streams via filterStreams', async () => { - // Use splice to mutate the array in-place to avoid triggering a full re-render - const opts = wrapper.vm.indexOptions; - opts.splice(0, opts.length, 'stream1', 'stream2', 'logstream', 'metrics'); - wrapper.vm.filteredStreams.splice(0); - - const mockUpdate = (cb: Function) => cb(); // immediately execute callback - - // Call the filterStreams method - wrapper.vm.filterStreams('stream', mockUpdate); - await wrapper.vm.$nextTick(); // wait for reactivity to apply - - // Verify the result - expect(wrapper.vm.filteredStreams).toEqual(['stream1', 'stream2', 'logstream']); - }); - it('returns all streams when filter input is empty', async () => { - const opts = wrapper.vm.indexOptions; - opts.splice(0, opts.length, 'stream1', 'stream2', 'logstream', 'metrics'); - wrapper.vm.filteredStreams.splice(0); - - const mockUpdate = (cb: Function) => cb(); - - wrapper.vm.filterStreams('', mockUpdate); - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.filteredStreams).toEqual(['stream1', 'stream2', 'logstream', 'metrics']); - }); - it('updates stream fields via updateStreamFields', async () => { - - await flushPromises(); - - // 🧪 2. Mock onInputUpdate - wrapper.vm.onInputUpdate = vi.fn(); - - // 🧪 4. Call the function - await wrapper.vm.updateStreamFields('testStream'); - await wrapper.vm.$nextTick(); - - // 🧪 5. Expect originalStreamFields and filteredColumns to be updated - const expected = [ - { label: 'field1', value: 'field1', type: 'string' }, - { label: 'field2', value: 'field2', type: 'int' } - ]; - - expect(wrapper.vm.originalStreamFields).toEqual(expected); - expect(wrapper.vm.filteredColumns).toEqual(expected); - - }); - }); - - describe('generateWhereClause function with all the possible combinations (V2 format)', () => { - let testStreamFieldsMap: any; - - beforeEach(() => { - // Create streamFieldsMap for testing (don't use wrapper.vm.streamFieldsMap as it's computed) - testStreamFieldsMap = { - age: { label: 'age', value: 'age', type: 'Int64' }, - city: { label: 'city', value: 'city', type: 'String' }, - }; - }); - it('generates a simple where clause', () => { - // V2 format: filterType, logicalOperator, conditions array - const group = { - filterType: 'group', - logicalOperator: 'AND', - conditions: [ - { filterType: 'condition', column: 'age', operator: '>', value: 30, logicalOperator: 'AND' } - ] - }; - - const result = generateWhereClause(group, testStreamFieldsMap); - // age is Int64 type, so no quotes around the value - expect(result).toBe("WHERE age > 30"); - }); - - it('handles string value with quotes', () => { - const group = { - filterType: 'group', - logicalOperator: 'AND', - conditions: [ - { filterType: 'condition', column: 'city', operator: '=', value: 'delhi', logicalOperator: 'AND' } - ] - }; - - const result = generateWhereClause(group, testStreamFieldsMap); - expect(result).toBe("WHERE city = 'delhi'"); - }); - - it('handles contains operator without quotes', () => { - const group = { - filterType: 'group', - logicalOperator: 'AND', - conditions: [ - { filterType: 'condition', column: 'city', operator: 'contains', value: 'delhi', logicalOperator: 'AND' } - ] - }; - - const result = generateWhereClause(group, testStreamFieldsMap); - expect(result).toBe("WHERE city LIKE '%delhi%'"); - }); - - it('handles nested groups with AND/OR', () => { - // V2 format with nested group - // In V2, each item (condition or group) has logicalOperator field - // The operator connects the item to the PREVIOUS sibling (not used for first item) - // For nested groups, the group's logicalOperator field is used for BOTH: - // 1. Connecting this group to the previous sibling item - // 2. As the default operator for new items added within this group - // - // In the UI: when you add a nested group, it defaults to logicalOperator: 'OR' - // So if added to an AND parent as the 2nd item, it would show: "age > 30 OR (nested group)" - // - // For this test, we're modeling a case where: - // - Parent group has AND - // - First condition has AND (inherited from parent when created) - // - Nested group has AND to connect to previous condition - // - Inside nested group, conditions use OR - const group = { - filterType: 'group', - logicalOperator: 'AND', // Parent group uses AND - conditions: [ - { - filterType: 'condition', - column: 'age', - operator: '>', - value: 30, - logicalOperator: 'AND' // Condition inherits parent's AND - }, - { - filterType: 'group', - logicalOperator: 'AND', // This connects the group to previous condition with AND - groupId: 'nested-group-1', - conditions: [ - // Conditions inside nested group use OR to join with each other - { filterType: 'condition', column: 'city', operator: '=', value: 'delhi', logicalOperator: 'OR' }, - { filterType: 'condition', column: 'city', operator: '=', value: 'mumbai', logicalOperator: 'OR' } - ] - } - ] - }; - - const result = generateWhereClause(group, testStreamFieldsMap); - // Expected: age > 30 (first condition, Int64 no quotes) AND (nested group connected with AND) (city = delhi OR city = mumbai, String with quotes) - expect(result).toBe("WHERE age > 30 AND (city = 'delhi' OR city = 'mumbai')"); - }); - - it('returns empty string if group is invalid', () => { - const result = generateWhereClause(null, testStreamFieldsMap); - expect(result).toBe(""); - }); - }) - describe('generateSqlQuery function with all the possible combinations', () => { - beforeEach(() => { - wrapper.vm.originalStreamFields = [ - { value: 'geo_info_country', type: 'string', label: 'geo_info_country' } - ]; - - // V2 format conditions - wrapper.vm.formData.query_condition.conditions = { - filterType: 'group', - logicalOperator: 'OR', - conditions: [ - { - filterType: 'condition', - column: 'geo_info_country', - operator: '=', - value: 'india', - logicalOperator: 'OR', - }, - ], - }; - wrapper.vm.formData.query_condition.aggregation.function = ""; - wrapper.vm.formData.query_condition.aggregation.having.column = ""; - wrapper.vm.formData.query_condition.aggregation.having.operator = ">="; - wrapper.vm.formData.query_condition.aggregation.having.value = 1; - wrapper.vm.formData.stream_name = "_rundata"; - wrapper.vm.formData.stream_type = "logs"; - - wrapper.vm.generateWhereClause = vi.fn().mockReturnValue("WHERE geo_info_country = 'india'"); - - wrapper.vm.isAggregationEnabled = false; - - }); - - it('generates basic count query when aggregation is disabled', async () => { - // Set aggregation disabled - wrapper.vm.isAggregationEnabled = false; - wrapper.vm.generateWhereClause = vi.fn().mockReturnValue("WHERE geo_info_country = 'india'"); - - - const result = wrapper.vm.generateSqlQuery(); - await flushPromises(); - expect(result).toBe( - 'SELECT histogram(_timestamp) AS zo_sql_key, COUNT(*) as zo_sql_val FROM "_rundata" WHERE geo_info_country = \'india\' GROUP BY zo_sql_key ORDER BY zo_sql_key ASC' - ); - }); - it('generates query with aggregation function and no groupBy', async () => { - wrapper.vm.isAggregationEnabled = true; - wrapper.vm.formData.query_condition.aggregation.function = 'avg'; - wrapper.vm.formData.query_condition.aggregation.having.column = 'action_error_count'; - wrapper.vm.formData.query_condition.aggregation.group_by = []; - - const result = wrapper.vm.generateSqlQuery(); - await flushPromises(); - - expect(result).toBe( - 'SELECT histogram(_timestamp) AS zo_sql_key, avg(action_error_count) as zo_sql_val FROM "_rundata" WHERE geo_info_country = \'india\' GROUP BY zo_sql_key ORDER BY zo_sql_key ASC' - ); - }); - it('generates query with groupBy and aggregation function', async () => { - wrapper.vm.isAggregationEnabled = true; - wrapper.vm.formData.query_condition.aggregation.function = 'avg'; - wrapper.vm.formData.query_condition.aggregation.having.column = 'action_error_count'; - wrapper.vm.formData.query_condition.aggregation.group_by = ['geo_info_city']; - - const result = wrapper.vm.generateSqlQuery(); - await flushPromises(); - - expect(result).toBe( - 'SELECT histogram(_timestamp) AS zo_sql_key, avg(action_error_count) as zo_sql_val , concat(geo_info_city) as x_axis_2 FROM "_rundata" WHERE geo_info_country = \'india\' GROUP BY zo_sql_key , x_axis_2 ORDER BY zo_sql_key ASC' - ); - }); - it('generates query with percentile aggregation function', async () => { - wrapper.vm.isAggregationEnabled = true; - wrapper.vm.formData.query_condition.aggregation.function = 'p95'; - wrapper.vm.formData.query_condition.aggregation.having.column = 'latency'; - wrapper.vm.formData.query_condition.aggregation.group_by = []; - - const result = wrapper.vm.generateSqlQuery(); - await flushPromises(); - - expect(result).toBe( - 'SELECT histogram(_timestamp) AS zo_sql_key, approx_percentile_cont(latency, 0.95) as zo_sql_val FROM "_rundata" WHERE geo_info_country = \'india\' GROUP BY zo_sql_key ORDER BY zo_sql_key ASC' - ); - }); - - - - - afterEach(() => { - vi.restoreAllMocks(); - }); - }); - describe('getParser', () => { - beforeEach(async () => { - wrapper.vm.sqlQueryErrorMsg = ""; - // Initialize parser manually by importing and setting it - const useParserModule = await import("@/composables/useParser"); - const { sqlParser } = useParserModule.default(); - wrapper.vm.parser = await sqlParser(); - }); - - - it('returns false and sets error for query with *', async() => { - const result = wrapper.vm.getParser("SELECT * FROM INDEX_NAME"); - await flushPromises(); - expect(result).toBe(false); - expect(wrapper.vm.sqlQueryErrorMsg).toBe("Selecting all columns is not allowed"); - }); - - it('returns true for valid query without *', () => { - const result = wrapper.vm.getParser("SELECT valid_column FROM my_table"); - expect(result).toBe(true); - expect(wrapper.vm.sqlQueryErrorMsg).toBe(""); - }); - it('returns true when parser throws an error (e.g. reserved keyword)', () => { - const result = wrapper.vm.getParser("SELECT field FROM default"); - expect(result).toBe(true); - expect(wrapper.vm.sqlQueryErrorMsg).toBe(""); // No change expected - }); - }); - - describe('getAlertPayload', () => { - - beforeEach(() => { - wrapper.vm.formData = { - uuid: 'abc-123', - is_real_time: 'true', - description: ' Sample Alert ', - query_condition: { - type: 'custom', - vrl_function: null, - sql: undefined, - aggregation: null, - conditions: [{ column: 'status', operator: '=', value: '200' }], - promql_condition: null - }, - trigger_condition: { - threshold: '5', - period: '60', - frequency: '10', - silence: '15', - }, - context_attributes: [ - { key: 'env', value: 'prod' }, - { key: 'team', value: 'infra' }, - ] - } - }); - it('generates correct payload for a new alert in custom tab', () => { - const { formData, getSelectedTab, isAggregationEnabled, store, beingUpdated } = wrapper.vm; - - const result = wrapper.vm.getAlertPayload(); - - expect(result.uuid).toBeUndefined(); - expect(result.is_real_time).toBe(true); - expect(result.context_attributes).toEqual({ - env: 'prod', - team: 'infra' - }); - expect(result.query_condition.type).toBe('custom'); - expect(result.trigger_condition.threshold).toBe(5); - expect(result.trigger_condition.period).toBe(60); - expect(result.trigger_condition.frequency).toBe(10); - expect(result.trigger_condition.silence).toBe(15); - expect(result.description).toBe('Sample Alert'); - expect(result.query_condition.aggregation).toBeNull(); - expect(result.query_condition.promql_condition).toBeNull(); - expect(result.query_condition.sql).toBeUndefined(); - expect(result.createdAt).toBeDefined(); - expect(result.owner).toBe(wrapper.vm.store.state.userInfo.email); - expect(result.lastTriggeredAt).toBeGreaterThan(0); - expect(result.lastEditedBy).toBe(wrapper.vm.store.state.userInfo.email); - }); - - - }); - - describe('validate input fields with all the possible combinations', () => { - it('passes validation for real-time alert', () => { - wrapper.vm.formData.is_real_time = true; - wrapper.vm.formData.trigger_condition.silence = 5; - - const result = wrapper.vm.validateInputs(wrapper.vm.formData, false); - expect(result).toBe(true); - }); - it('fails when silence is NaN', () => { - wrapper.vm.formData.trigger_condition.silence = "a"; - // validateInputs uses toast() not q.notify; just verify the return value - const result = wrapper.vm.validateInputs(wrapper.vm.formData, false); - expect(result).toBe(false); - }); - it('fails when period is < 1 or NaN', () => { - wrapper.vm.formData.trigger_condition.period = 0; - wrapper.vm.formData.is_real_time = false; - const result = wrapper.vm.validateInputs(wrapper.vm.formData, false); - expect(result).toBe(false); - }); - it('fails when aggregation fields are incomplete', () => { - wrapper.vm.formData.is_real_time = false; - wrapper.vm.formData.trigger_condition.threshold = "a"; - const result = wrapper.vm.validateInputs(wrapper.vm.formData, false); - expect(result).toBe(false); - }); - it('fails when invalid cron expression is passed', () => { - CronExpressionParser.parse = vi.fn().mockImplementation(() => { - throw new Error('Invalid cron'); - }); - - wrapper.vm.formData.is_real_time = false; - wrapper.vm.formData.query_condition.aggregation = null; - wrapper.vm.formData.trigger_condition.silence = '5'; - wrapper.vm.formData.trigger_condition.period = '60'; - wrapper.vm.formData.trigger_condition.threshold = '10'; - wrapper.vm.formData.trigger_condition.frequency_type = 'cron'; - wrapper.vm.formData.trigger_condition.operator = '>'; - wrapper.vm.formData.trigger_condition.cron = 'invalid-cron'; - - const result = wrapper.vm.validateInputs(wrapper.vm.formData); - expect(result).toBe(false); // function returns false when cron is invalid - }); - it('passes when valid cron expression is provided', () => { - CronExpressionParser.parse = vi.fn().mockImplementation(() => { - return { - next: vi.fn(), - } - }); - - wrapper.vm.formData.is_real_time = false - wrapper.vm.formData.trigger_condition.silence = '5'; - wrapper.vm.formData.trigger_condition.period = '60'; - wrapper.vm.formData.trigger_condition.threshold = '10'; - wrapper.vm.formData.trigger_condition.frequency_type = 'cron'; - wrapper.vm.formData.trigger_condition.operator = '>'; - wrapper.vm.formData.trigger_condition.cron = '* * * * *'; - wrapper.vm.formData.trigger_condition.timezone = 'UTC'; - wrapper.vm.formData.query_condition.aggregation = null; - - const result = wrapper.vm.validateInputs(wrapper.vm.formData, false); - expect(result).toBe(true); // function returns true when cron is valid - }); - - - - }); - describe('onSubmit', () => { - let notifyMock: any; - beforeEach(() => { - window.HTMLElement.prototype.scrollIntoView = vi.fn(); - wrapper.vm.q = { - notify: vi.fn().mockReturnValue(() => {}) - }; - wrapper.vm.navigateToErrorField = vi.fn(); - wrapper.vm.validateFormAndNavigateToErrorField = vi.fn().mockResolvedValue(true); - wrapper.vm.validateSqlQueryPromise = vi.fn().mockResolvedValue(true); - }); - it.skip('should submit alert successfully for creating new alert', async () => { - const createAlertSpy = vi.spyOn(alertsService, 'create_by_alert_id'); - wrapper.vm.q = { - notify: vi.fn(() => vi.fn()) - }; - - wrapper.vm.formData.name = "test_alert"; - wrapper.vm.formData.stream_name = "_rundata"; - wrapper.vm.formData.stream_type = "logs"; - wrapper.vm.formData.query_condition.conditions = { - "or": [ - { "column": "status", "operator": "=", "value": "200" } - ] - }; - wrapper.vm.formData.query_condition.aggregation = null; - wrapper.vm.formData.query_condition.promql_condition = null; - wrapper.vm.formData.query_condition.sql = undefined; - wrapper.vm.formData.query_condition.vrl_function = null; - wrapper.vm.formData.query_condition.multi_time_range = []; - wrapper.vm.formData.query_condition.type = "custom"; - wrapper.vm.formData.query_condition.vrl_function = null; - wrapper.vm.formData.query_condition.sql = undefined; - wrapper.vm.formData.query_condition.promql_condition = null; - wrapper.vm.formData.query_condition.aggregation = null; - wrapper.vm.formData.destinations = ['test_destinations']; - wrapper.vm.formData.context_attributes = ['test_context_attributes']; - wrapper.vm.formData.enabled = true; - wrapper.vm.formData.owner = 'test_owner'; - wrapper.vm.formData.lastTriggeredAt = 0; - wrapper.vm.formData.lastEditedBy = '122122122'; - wrapper.vm.formData.folder_id = 'test_folder_id'; - - await wrapper.vm.validateInputs(wrapper.vm.formData); - await wrapper.vm.getAlertPayload(); - await wrapper.vm.onSubmit(); - await flushPromises(); - - - let mockResponse: any = { - data: { - "code": 200, - "message": "Alert saved", - "id": "2yis4t0dJCU7Vs4D3sEYHDsvDKF", - "name": "test_alert" - }, - } - - vi.mocked(alertsService.create_by_alert_id).mockResolvedValue(mockResponse); - - - await flushPromises(); - //because we reset the data in the formData after the alert is saved - expect(wrapper.vm.formData.name).toBe(''); - expect(wrapper.vm.formData.stream_name).toBe(''); - expect(wrapper.vm.formData.stream_type).toBe(''); - - }); - - it.skip('should update alert successfully for updating existing alert', async () => { - wrapper.vm.q = { - notify: vi.fn(() => vi.fn()) - }; - wrapper.vm.formData.name = "test_alert_for_updating"; - wrapper.vm.formData.id = "2yis4t0dJCU7Vs4D3sEYHDsvDKF"; - wrapper.vm.formData.is_real_time = "false"; - wrapper.vm.beingUpdated = true; - wrapper.vm.formData.stream_name = "_rundata"; - wrapper.vm.formData.stream_type = "logs"; - wrapper.vm.formData.query_condition.conditions = { - "or": [ - { "column": "status", "operator": "=", "value": "200" } - ] - }; - wrapper.vm.formData.query_condition.aggregation = null; - wrapper.vm.formData.query_condition.promql_condition = null; - wrapper.vm.formData.query_condition.sql = undefined; - wrapper.vm.formData.query_condition.vrl_function = null; - wrapper.vm.formData.query_condition.multi_time_range = []; - wrapper.vm.formData.query_condition.type = "custom"; - wrapper.vm.formData.query_condition.vrl_function = null; - wrapper.vm.formData.query_condition.sql = undefined; - wrapper.vm.formData.query_condition.promql_condition = null; - wrapper.vm.formData.query_condition.aggregation = null; - wrapper.vm.formData.destinations = ['test_destinations']; - wrapper.vm.formData.context_attributes = ['test_context_attributes']; - wrapper.vm.formData.enabled = true; - wrapper.vm.formData.owner = 'test_owner'; - wrapper.vm.formData.lastTriggeredAt = 0; - wrapper.vm.formData.lastEditedBy = '122122122'; - wrapper.vm.formData.folder_id = 'test_folder_id'; - - await wrapper.vm.validateInputs(wrapper.vm.formData); - await wrapper.vm.getAlertPayload(); - await wrapper.vm.onSubmit(); - await flushPromises(); - - - let mockResponse: any = { - data: { - "code": 200, - "message": "Alert updated", - "id": "2yis4t0dJCU7Vs4D3sEYHDsvDKF", - "name": "test_alert_for_updating" - }, - } - - vi.mocked(alertsService.update_by_alert_id).mockResolvedValue(mockResponse); - - await flushPromises(); - //because we reset the data in the formData after the alert is saved - expect(wrapper.vm.formData.name).toBe(''); - expect(wrapper.vm.formData.stream_name).toBe(''); - expect(wrapper.vm.formData.stream_type).toBe(''); - }); - - it('should wait for validateSqlQueryPromise to resolve successfully', { timeout: 10000 }, async () => { - const dismissMock = vi.fn(); - wrapper.vm.q = { - notify: vi.fn(() => dismissMock) - }; - - // Required for triggering that SQL block - wrapper.vm.formData.is_real_time = "false"; - wrapper.vm.formData.query_condition.type = "sql"; - wrapper.vm.formData.query_condition.sql = "SELECT * FROM test_table"; - - // Provide a fake resolve that resolves immediately - wrapper.vm.validateSqlQueryPromise = Promise.resolve(); - - // Return valid payload - wrapper.vm.getAlertPayload = vi.fn(() => ({ - query_condition: { - type: "sql", - conditions: [], - }, - })); - - // Mock alertsService.create_by_alert_id - vi.mocked(alertsService.create_by_alert_id).mockResolvedValue({ - data: { - code: 200, - message: "Success", - } - }); - - // Bypass validation - wrapper.vm.validateFormAndNavigateToErrorField = vi.fn().mockResolvedValue(true); - wrapper.vm.validateInputs = vi.fn().mockReturnValue(true); - wrapper.vm.transformFEToBE = vi.fn().mockReturnValue([]); - wrapper.vm.beingUpdated = false; // Setting to false to test creation path - - await wrapper.vm.onSubmit(); - await flushPromises(); - expect(wrapper.vm.formData.name).toBe(''); - expect(wrapper.vm.formData.stream_name).toBe(''); - expect(wrapper.vm.formData.stream_type).toBe(''); - }); - - - }); - describe('validateSqlQuery', () => { - beforeEach(() => { - wrapper.vm.q = { notify: vi.fn() }; - wrapper.vm.getParser = vi.fn(() => true); - - wrapper.vm.formData.query_condition.vrl_function = ''; - wrapper.vm.formData.query_condition.type = 'sql'; - wrapper.vm.formData.stream_name = '_rundata'; - wrapper.vm.formData.stream_type = 'logs'; - - wrapper.vm.buildQueryPayload = vi.fn(() => ({ - query: { start_time: 100000 }, - aggs: {} - })); - - wrapper.vm.store = { - state: { - selectedOrganization: { identifier: 'org-123' } - } - }; - }); - - it('validates successfully when SQL is valid', async () => { - wrapper.vm.formData.query_condition.sql = 'SELECT * FROM "_rundata"'; - - searchService.search.mockResolvedValue({ data: {} }); - - await wrapper.vm.validateSqlQuery(); - await flushPromises(); - - expect(wrapper.vm.sqlQueryErrorMsg).toBe('Selecting all columns is not allowed'); - }); - - - it('validates successfully when SQL is valid', async () => { - wrapper.vm.formData.query_condition.sql = 'SELECT job FROM "_rundata"'; - - searchService.search.mockResolvedValue({ data: {} }); - - await wrapper.vm.validateSqlQuery(); - await flushPromises(); - - expect(wrapper.vm.sqlQueryErrorMsg).toBe(''); - }); - - }); - describe('retransformBEToFE', () => { - it('should return null if input is null or undefined', () => { - expect(wrapper.vm.retransformBEToFE(null)).toBeNull(); - expect(wrapper.vm.retransformBEToFE(undefined)).toBeNull(); - }); - - it('should return null if input has multiple keys', () => { - const invalid = { and: [], or: [] }; - expect(wrapper.vm.retransformBEToFE(invalid)).toBeNull(); - }); - - it('should transform a simple AND condition', () => { - const input = { - and: [ - { column: 'name', operator: '=', value: 'John', ignore_case: false } - ] - }; - - const result = wrapper.vm.retransformBEToFE(input); - - expect(result).toEqual({ - groupId: 'mock-uuid', - label: 'and', - items: [ - { - column: 'name', - operator: '=', - value: 'John', - ignore_case: false, - id: 'mock-uuid' - } - ] - }); - }); - - it('should transform nested condition groups', () => { - const input = { - or: [ - { - and: [ - { column: 'age', operator: '>', value: 30, ignore_case: false } - ] - }, - { column: 'status', operator: '=', value: 'active', ignore_case: true } - ] - }; - - const result = wrapper.vm.retransformBEToFE(input); - - expect(result).toEqual({ - groupId: 'mock-uuid', - label: 'or', - items: [ - { - groupId: 'mock-uuid', - label: 'and', - items: [ - { - column: 'age', - operator: '>', - value: 30, - ignore_case: false, - id: 'mock-uuid' - } - ] - }, - { - column: 'status', - operator: '=', - value: 'active', - ignore_case: true, - id: 'mock-uuid' - } - ] - }); - }); + describe("owner pattern wiring", () => { + it("owns the ONE form and exposes formData as a read-view + _meta seeded", async () => { + wrapper = mountAlert(); + await flushPromises(); + expect(wrapper.vm.form).toBeTruthy(); + expect(wrapper.vm.formData.name).toBe(""); + expect(wrapper.vm.formData._meta).toBeTruthy(); + expect(wrapper.vm.formData.is_real_time).toBe("false"); }); }); - describe('lifecycle-created and computed handlers', () => { - it('sets beingUpdated based on modelValue and normalizes is_real_time', async () => { - const wrapper = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router], - }, - props: { - isUpdated: true, - modelValue: { - name: 'n', - stream_type: 'logs', - stream_name: 's', - is_real_time: false, - query_condition: { type: 'custom', conditions: { or: [] }, aggregation: null }, - trigger_condition: { period: 1, operator: '>=', frequency: 1, cron: '', threshold: 1, silence: 1, frequency_type: 'minutes', timezone: 'UTC' }, - destinations: [], - context_attributes: {}, - enabled: true, - }, - destinations: [{ name: 'email' }], - }, - }); + describe("schema-gated save (block-on-invalid)", () => { + it("blocks save when required fields are empty and does NOT call the create service", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; - expect(wrapper.vm.beingUpdated).toBe(true); - expect(wrapper.vm.formData.is_real_time).toBe('false'); - expect(wrapper.vm.getFormattedDestinations).toEqual(['email']); + await form.handleSubmit(); + await flushPromises(); + + expect(form.state.isValid).toBe(false); + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); }); - }); + it("blocks save when only the name is missing", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedValidScheduled(form); + form.setFieldValue("name", ""); // clear name - describe('getFormattedCondition and generateWhereClause edge cases (V2 format)', () => { - let wrapper: any; - beforeEach(() => { - wrapper = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); - wrapper.vm.originalStreamFields = [ - { value: 'n', type: 'Int64' }, - { value: 's', type: 'String' } - ]; + await form.handleSubmit(); + await flushPromises(); + + expect(form.state.isValid).toBe(false); + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); }); - it('formats numeric types without quotes and string with quotes', () => { - const group = { - filterType: 'group', - logicalOperator: 'AND', - conditions: [ - { filterType: 'condition', column: 'n', operator: '>=', value: 10, logicalOperator: 'AND' }, - { filterType: 'condition', column: 's', operator: '=', value: 'x', logicalOperator: 'AND' } - ] - }; - expect(generateWhereClause(group, wrapper.vm.streamFieldsMap)).toBe("WHERE n >= 10 AND s = 'x'"); + it("blocks save when the name contains unsupported characters (§4 restore)", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedValidScheduled(form); + form.setFieldValue("name", "bad name"); // space is unsupported + + await form.handleSubmit(); + await flushPromises(); + + expect(form.state.isValid).toBe(false); + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); }); - it('supports not_contains/NotContains variations', () => { - const group = { - filterType: 'group', - logicalOperator: 'OR', - conditions: [ - { filterType: 'condition', column: 's', operator: 'not_contains', value: 'bad', logicalOperator: 'OR' }, - { filterType: 'condition', column: 's', operator: 'NotContains', value: 'worse', logicalOperator: 'OR' } - ] - }; - expect(generateWhereClause(group, wrapper.vm.streamFieldsMap)).toBe("WHERE s NOT LIKE '%bad%' OR s NOT LIKE '%worse%'"); - }); + it("blocks save when there are no destinations", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedValidScheduled(form); + form.setFieldValue("destinations", []); - it('returns empty for invalid items', () => { - const group = { - filterType: 'group', - logicalOperator: 'AND', - conditions: [{ foo: 1 }] - } as any; - expect(generateWhereClause(group, wrapper.vm.streamFieldsMap)).toBe(''); + await form.handleSubmit(); + await flushPromises(); + + expect(form.state.isValid).toBe(false); + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); }); }); - describe('generateSqlQuery variations (V2 format)', () => { - let wrapper: any; - beforeEach(() => { - wrapper = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); - wrapper.vm.formData.stream_name = '_rundata'; - wrapper.vm.formData.stream_type = 'logs'; - // V2 format conditions - wrapper.vm.formData.query_condition.conditions = { - filterType: 'group', - logicalOperator: 'AND', - conditions: [ - { filterType: 'condition', column: 'status', operator: '=', value: '200', logicalOperator: 'AND' } - ] - }; - wrapper.vm.generateWhereClause = vi.fn().mockReturnValue("WHERE status = '200'"); - }); - - it('counts when aggregation invalid/missing', () => { - wrapper.vm.isAggregationEnabled = true; - wrapper.vm.formData.query_condition.aggregation = { group_by: [], function: '', having: { column: '', operator: '>=', value: 1 } }; - const q = wrapper.vm.generateSqlQuery(); - expect(q).toContain('COUNT(*) as zo_sql_val'); - }); - - it('handles multiple group_by concat alias', () => { - wrapper.vm.isAggregationEnabled = true; - wrapper.vm.formData.query_condition.aggregation = { group_by: ['c1','c2'], function: 'sum', having: { column: 'latency', operator: '>=', value: 1 } }; - const q = wrapper.vm.generateSqlQuery(); - expect(q).toContain('GROUP BY zo_sql_key , x_axis_2'); - }); - - it('handles percentile map p50', () => { - wrapper.vm.isAggregationEnabled = true; - wrapper.vm.formData.query_condition.aggregation = { group_by: [], function: 'p50', having: { column: 'latency', operator: '>=', value: 1 } }; - const q = wrapper.vm.generateSqlQuery(); - expect(q).toContain('approx_percentile_cont(latency, 0.5)'); - }); - }); - - - describe('getParser reserved word and star', () => { - let wrapper: any; - beforeEach(async () => { - wrapper = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); + // The pre-save gates that are NOT schema rules — re-homed from the old + // QueryConfig.validate() into useAlertForm.runImperativeQueryChecks (run at + // the top of onSubmit, i.e. only after the composed schema passes). Same + // toast messages as pre-migration. + describe("imperative query-text gates (runImperativeQueryChecks)", () => { + it("blocks a SQL save when the SQL query is empty", async () => { + wrapper = mountAlert(); await flushPromises(); - }); - it('rejects SELECT * detection', () => { - expect(wrapper.vm.getParser('SELECT * FROM t')).toBe(false); - expect(wrapper.vm.sqlQueryErrorMsg).toBe('Selecting all columns is not allowed'); - }); - it('ignores parser errors and returns true', () => { - expect(wrapper.vm.getParser('SELECT c FROM default')).toBe(true); - }); - }); + const form = wrapper.vm.form; + seedValidScheduled(form); + form.setFieldValue("query_condition.type", "sql"); + form.setFieldValue("query_condition.sql", ""); - describe('getAlertPayload branches', () => { - let wrapper: any; - beforeEach(() => { - wrapper = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); - }); - it('sets tabs: promql clears sql and conditions', () => { - wrapper.vm.scheduledAlertRef = { tab: 'promql' }; - wrapper.vm.formData.query_condition.promql = 'up'; - const p = wrapper.vm.getAlertPayload(); - expect(p.query_condition.sql).toBe(''); - // Conditions may be initialized as V2 structure or empty array - expect(p.query_condition.conditions).toBeDefined(); - }); - it('sql tab keeps sql and nulls promql_condition', () => { - wrapper.vm.scheduledAlertRef = { tab: 'sql' }; - wrapper.vm.formData.query_condition.sql = 'select x'; - const p = wrapper.vm.getAlertPayload(); - expect(p.query_condition.promql_condition).toBeNull(); - }); - it('custom tab nulls aggregation when disabled', () => { - wrapper.vm.scheduledAlertRef = { tab: 'custom' }; - wrapper.vm.isAggregationEnabled = false; - const p = wrapper.vm.getAlertPayload(); - expect(p.query_condition.aggregation).toBeNull(); - }); - }); - - describe('routeToCreateDestination builds URL', () => { - it('calls window.open with route href', () => { - const w = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); - const spy = vi.spyOn(window, 'open').mockImplementation(() => null); - w.vm.routeToCreateDestination(); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); - }); - }); - - - describe('expand state and destinations updates', () => { - it('updateExpandState replaces object', () => { - const w = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); - const v = { alertSetup: false, queryMode: false, advancedSetup: false, realTimeMode: false, thresholds: false, multiWindowSelection: true }; - w.vm.updateExpandState(v); - expect(w.vm.expandState).toEqual(v); - }); - it('updateDestinations writes list', () => { - const w = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); - w.vm.updateDestinations(['a']); - expect(w.vm.formData.destinations).toEqual(['a']); - }); - }); - - - describe('transformFEToBE and retransformBEToFE additional', () => { - let w: any; - beforeEach(() => { - w = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); - }); - afterEach(() => { w?.unmount(); w = null; }); - it('transformFEToBE returns {} for invalid', () => { - expect(w.vm.transformFEToBE(null)).toEqual({}); - expect(w.vm.transformFEToBE({ label: 'x', items: [] })).toEqual({}); - }); - it('retransformBEToFE returns null for invalid keys', () => { - expect(w.vm.retransformBEToFE({} as any)).toBeNull(); - }); - }); - - describe('V2 Conditions Version Field Placement', () => { - let w: any; - beforeEach(() => { - w = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); - w.vm.formData.name = "test_v2_alert"; - w.vm.formData.stream_name = "_rundata"; - w.vm.formData.stream_type = "logs"; - w.vm.formData.is_real_time = false; - w.vm.scheduledAlertRef = { tab: 'custom' }; - w.vm.isAggregationEnabled = false; - }); - afterEach(() => { w?.unmount(); w = null; }); - - it('should wrap V2 conditions with version field INSIDE conditions object when saving', () => { - // Set up V2 format conditions - const v2Conditions = { - filterType: 'group', - logicalOperator: 'AND', - groupId: 'test-group', - conditions: [ - { - id: 'test-id', - filterType: 'condition', - column: 'status', - operator: '=', - value: '200', - logicalOperator: 'AND', - } - ] - }; - w.vm.formData.query_condition.conditions = v2Conditions; - w.vm.formData.query_condition.aggregation = null; - w.vm.formData.query_condition.type = 'custom'; - - const payload = w.vm.getAlertPayload(); - - // Simulate the version wrapping logic from onSubmit() - const version = detectConditionsVersion(w.vm.formData.query_condition.conditions); - if (version === 2) { - payload.query_condition.conditions = { - version: 2, - conditions: w.vm.formData.query_condition.conditions, - }; - } - - // Backend expects: conditions: { version: 2, conditions: {...} } - expect(payload.query_condition.conditions).toHaveProperty('version'); - expect(payload.query_condition.conditions.version).toBe(2); - expect(payload.query_condition.conditions).toHaveProperty('conditions'); - expect(payload.query_condition.conditions.conditions.filterType).toBe('group'); - expect(payload.query_condition.conditions.conditions.logicalOperator).toBe('AND'); - }); - - it('should NOT have version field at query_condition level for V2', () => { - // Set up V2 format conditions - w.vm.formData.query_condition.conditions = { - filterType: 'group', - logicalOperator: 'OR', - groupId: 'test-group', - conditions: [] - }; - - const payload = w.vm.getAlertPayload(); - - // Simulate the version wrapping logic from onSubmit() - const version = detectConditionsVersion(w.vm.formData.query_condition.conditions); - if (version === 2) { - payload.query_condition.conditions = { - version: 2, - conditions: w.vm.formData.query_condition.conditions, - }; - } - - // Version should NOT be at this level - expect(payload.query_condition).not.toHaveProperty('version'); - // But should be inside conditions - expect(payload.query_condition.conditions).toHaveProperty('version'); - }); - - it('should handle V1 conditions without version field', () => { - // Set up V1 format conditions (tree-based) - w.vm.formData.query_condition.conditions = { - or: [ - { column: 'status', operator: '=', value: '200', ignore_case: true } - ] - }; - - const payload = w.vm.getAlertPayload(); - - // V1 format should not have version field at any level - expect(payload.query_condition).not.toHaveProperty('version'); - expect(payload.query_condition.conditions).not.toHaveProperty('version'); - // Should be transformed to V1 backend format - expect(payload.query_condition.conditions).toHaveProperty('or'); - }); - - it('should correctly extract V2 conditions when loading from backend', async () => { - // Simulate backend response with V2 format - w.vm.formData.query_condition.conditions = { - version: 2, - conditions: { - filterType: 'group', - logicalOperator: 'AND', - groupId: 'backend-group', - conditions: [ - { - id: 'backend-id', - filterType: 'condition', - column: 'level', - operator: '=', - value: 'error', - logicalOperator: 'AND', - } - ] - } - }; - - // Call the data loading logic (simulating mounted hook behavior) - await w.vm.$nextTick(); - - // After loading, conditions should be extracted from nested structure - // The actual extraction happens in the component's data loading logic - // This test verifies the structure matches backend expectations - expect(w.vm.formData.query_condition.conditions.version).toBe(2); - expect(w.vm.formData.query_condition.conditions.conditions).toBeDefined(); - expect(w.vm.formData.query_condition.conditions.conditions.filterType).toBe('group'); - }); - }); - - describe('validateFormAndNavigateToErrorField and navigateToErrorField', () => { - it('navigates on invalid and returns false', async () => { - const w = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); - const fakeRef = { validate: vi.fn().mockResolvedValue(false), $el: { querySelector: vi.fn().mockReturnValue({ scrollIntoView: vi.fn() }) } } as any; - const spy = vi.spyOn(fakeRef.$el, 'querySelector'); - const res = await w.vm.validateFormAndNavigateToErrorField(fakeRef); - expect(res).toBe(false); - expect(spy).toHaveBeenCalled(); - }); - it('returns true on valid', async () => { - const w = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); - const fakeRef = { validate: vi.fn().mockResolvedValue(true), $el: { querySelector: vi.fn() } } as any; - const res = await w.vm.validateFormAndNavigateToErrorField(fakeRef); - expect(res).toBe(true); - }); - }); - - describe('openJsonEditor, saveAlertJson, prepareAndSaveAlert', () => { - it('opens editor dialog', () => { - const w = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); - w.vm.openJsonEditor(); - expect(w.vm.showJsonEditorDialog).toBe(true); - }); - - it('saveAlertJson validates and sets validationErrors on failure', async () => { - const w = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] }, props: { destinations: [{ name: 'email' }] } }); - const bad = JSON.stringify({ name: '', stream_type: 'logs', stream_name: 's', is_real_time: false, query_condition: { type: 'sql', sql: '' }, trigger_condition: { period: 0, operator: '>=', frequency: 1, cron: '', threshold: 1, silence: 1, frequency_type: 'minutes', timezone: 'UTC' }, destinations: [], context_attributes: [], enabled: true }); - await w.vm.saveAlertJson(bad); - expect(Array.isArray(w.vm.validationErrors)).toBe(true); - expect(w.vm.validationErrors.length).toBeGreaterThan(0); - }); - - }); - - describe('Wizard Step Navigation', () => { - let w: any; - beforeEach(() => { - w = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); - w.vm.formData.name = "test_alert"; - w.vm.formData.stream_name = "_rundata"; - w.vm.formData.stream_type = "logs"; - }); - afterEach(() => { w?.unmount(); w = null; }); - - it('should initialize at step 1', () => { - expect(w.vm.wizardStep).toBe(1); - }); - - it('should initialize lastValidStep at 1', () => { - expect(w.vm.lastValidStep).toBe(1); - }); - - it('should have wizardStepper ref', () => { - expect(w.vm.wizardStepper).toBeDefined(); - }); - - it('should navigate to step 2 via goToNextStep when step 1 is valid', async () => { - w.vm.wizardStep = 1; - // Mock validateStep instead of validateCurrentStep - w.vm.validateStep = vi.fn().mockResolvedValue(true); - - await w.vm.goToNextStep(); + await form.handleSubmit(); await flushPromises(); - expect(w.vm.wizardStep).toBe(2); + // The schema passes (query text is not a schema rule) … + expect(form.state.isValid).toBe(true); + // … but the imperative gate blocks the save with the same toast. + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ message: "SQL query cannot be empty." }), + ); }); - it('should not navigate to step 2 if step 1 validation fails', async () => { - w.vm.wizardStep = 1; - // Mock step1Ref validate to return false - w.vm.step1Ref = { - validate: vi.fn().mockResolvedValue(false) - }; + it("blocks a SQL save while a SQL validation error is present", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedValidScheduled(form); + form.setFieldValue("query_condition.type", "sql"); + form.setFieldValue("query_condition.sql", "SELECT * FROM logs"); + wrapper.vm.sqlQueryErrorMsg = "Invalid syntax"; - await w.vm.goToNextStep(); + await form.handleSubmit(); await flushPromises(); - expect(w.vm.wizardStep).toBe(1); + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Please fix the SQL error before saving.", + }), + ); }); - it('should go back from step 2 to step 1', () => { - w.vm.wizardStep = 2; - w.vm.goToPreviousStep(); - expect(w.vm.wizardStep).toBe(1); - }); + it("blocks a PromQL save when the PromQL query is empty", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedValidScheduled(form); + form.setFieldValue("query_condition.type", "promql"); + form.setFieldValue("query_condition.promql", ""); - it('should go back from step 3 to step 2 for scheduled alerts', () => { - w.vm.formData.is_real_time = 'false'; - w.vm.wizardStep = 3; - w.vm.goToPreviousStep(); - expect(w.vm.wizardStep).toBe(2); - }); - - it('should go back from step 4 to step 3 for scheduled alerts', () => { - w.vm.formData.is_real_time = 'false'; - w.vm.wizardStep = 4; - w.vm.goToPreviousStep(); - expect(w.vm.wizardStep).toBe(3); - }); - - it('should go back from step 5 to step 4 for scheduled alerts', () => { - w.vm.formData.is_real_time = 'false'; - w.vm.wizardStep = 5; - w.vm.goToPreviousStep(); - expect(w.vm.wizardStep).toBe(4); - }); - - it('should go back from step 6 to step 5 for scheduled alerts', () => { - w.vm.formData.is_real_time = 'false'; - w.vm.wizardStep = 6; - w.vm.goToPreviousStep(); - expect(w.vm.wizardStep).toBe(5); - }); - - it('should skip steps 3 and 5 for real-time alerts when going back', () => { - w.vm.formData.is_real_time = 'true'; - - // From step 6 should go to 4 - w.vm.wizardStep = 6; - w.vm.goToPreviousStep(); - expect(w.vm.wizardStep).toBe(4); - - // From step 4 should go to 2 - w.vm.goToPreviousStep(); - expect(w.vm.wizardStep).toBe(2); - - // From step 2 should go to 1 - w.vm.goToPreviousStep(); - expect(w.vm.wizardStep).toBe(1); - }); - - it('should not go back from step 1', () => { - w.vm.wizardStep = 1; - w.vm.goToPreviousStep(); - // goToPreviousStep decrements by 1, so it goes to 0 - // In practice, the UI should prevent navigation below step 1 - expect(w.vm.wizardStep).toBe(0); - }); - - it('should identify step 6 as last step for scheduled alerts', () => { - w.vm.formData.is_real_time = 'false'; - w.vm.wizardStep = 6; - expect(w.vm.isLastStep).toBe(true); - }); - - it('should identify step 6 as last step for real-time alerts', () => { - w.vm.formData.is_real_time = 'true'; - w.vm.wizardStep = 6; - expect(w.vm.isLastStep).toBe(true); - }); - - it('should not identify step 5 as last step', () => { - w.vm.wizardStep = 5; - expect(w.vm.isLastStep).toBe(false); - }); - - it('should not identify step 1 as last step', () => { - w.vm.wizardStep = 1; - expect(w.vm.isLastStep).toBe(false); - }); - - it('should skip step 3 for real-time alerts when navigating forward', async () => { - w.vm.formData.is_real_time = 'true'; - w.vm.wizardStep = 2; - w.vm.validateStep = vi.fn().mockResolvedValue(true); - - await w.vm.goToNextStep(); + await form.handleSubmit(); await flushPromises(); - // Should skip step 3 and go to step 4 - expect(w.vm.wizardStep).toBe(4); + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ message: "PromQL query cannot be empty." }), + ); }); - it('should skip step 5 for real-time alerts when navigating forward', async () => { - w.vm.formData.is_real_time = 'true'; - w.vm.wizardStep = 4; - // Mock step4Ref so validateStep(4) passes - w.vm.step4Ref = { validate: vi.fn().mockResolvedValue({ valid: true }) }; - - await w.vm.goToNextStep(); - await flushPromises(); - - // Should skip step 5 and go to step 6 - expect(w.vm.wizardStep).toBe(6); - }); - - it('should navigate through all steps for scheduled alerts', async () => { - w.vm.formData.is_real_time = 'false'; - // Add a destination so step 4 validation passes - w.vm.formData.destinations = ['test-destination']; - - // Mock all step refs to validate successfully - w.vm.step1Ref = { validate: vi.fn().mockResolvedValue(true) }; - w.vm.step2Ref = { validate: vi.fn().mockResolvedValue(true) }; - w.vm.step4Ref = { validate: vi.fn().mockResolvedValue(true) }; - - expect(w.vm.wizardStep).toBe(1); - - await w.vm.goToNextStep(); - await flushPromises(); - expect(w.vm.wizardStep).toBe(2); - - await w.vm.goToNextStep(); - await flushPromises(); - expect(w.vm.wizardStep).toBe(3); - - await w.vm.goToNextStep(); - await flushPromises(); - expect(w.vm.wizardStep).toBe(4); - - await w.vm.goToNextStep(); - await flushPromises(); - expect(w.vm.wizardStep).toBe(5); - - await w.vm.goToNextStep(); - await flushPromises(); - expect(w.vm.wizardStep).toBe(6); - }); - }); - - describe('Wizard Step Validation', () => { - let w: any; - beforeEach(() => { - w = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); - }); - afterEach(() => { w?.unmount(); w = null; }); - - it('should validate step 1 (Alert Setup)', async () => { - w.vm.step1Ref = { - validate: vi.fn().mockResolvedValue(true) - }; - w.vm.wizardStep = 1; - - const isValid = await w.vm.validateStep(1); - - expect(w.vm.step1Ref.validate).toHaveBeenCalled(); - expect(isValid).toBe(true); - }); - - it('should fail validation if step 1 ref has no validate method', async () => { - w.vm.step1Ref = {}; - w.vm.wizardStep = 1; - - const isValid = await w.vm.validateStep(1); - - // When step ref has no validate method, validateStep returns true (continues) - expect(isValid).toBe(true); - }); - - it('should validate step 2 (Query Configuration)', async () => { - w.vm.step2Ref = { - validate: vi.fn().mockResolvedValue(true) - }; - w.vm.wizardStep = 2; - - const isValid = await w.vm.validateStep(2); - - expect(w.vm.step2Ref.validate).toHaveBeenCalled(); - expect(isValid).toBe(true); - }); - - it('should return false if step 2 validation fails', async () => { - w.vm.step2Ref = { - validate: vi.fn().mockResolvedValue(false) - }; - w.vm.wizardStep = 2; - - const isValid = await w.vm.validateStep(2); - - expect(isValid).toBe(false); - }); - - it('should validate step 3 (Compare with Past) for scheduled alerts', async () => { - w.vm.formData.is_real_time = 'false'; - w.vm.wizardStep = 3; - - const isValid = await w.vm.validateStep(3); - - // Step 3 has no validation, should return true - expect(isValid).toBe(true); - }); - - it('should validate step 4 (Alert Settings)', async () => { - w.vm.step4Ref = { - validate: vi.fn().mockResolvedValue(true) - }; - w.vm.wizardStep = 4; - - const isValid = await w.vm.validateStep(4); - - expect(w.vm.step4Ref.validate).toHaveBeenCalled(); - expect(isValid).toBe(true); - }); - - it('should return false if step 4 validation fails', async () => { - w.vm.step4Ref = { - validate: vi.fn().mockResolvedValue(false) - }; - w.vm.wizardStep = 4; - - const isValid = await w.vm.validateStep(4); - - expect(isValid).toBe(false); - }); - - it('should validate step 5 (Deduplication) for scheduled alerts', async () => { - w.vm.formData.is_real_time = 'false'; - w.vm.wizardStep = 5; - - const isValid = await w.vm.validateStep(5); - - // Step 5 has no validation, should return true - expect(isValid).toBe(true); - }); - - it('should validate step 6 (Advanced)', async () => { - w.vm.wizardStep = 6; - - const isValid = await w.vm.validateStep(6); - - // Step 6 has no validation, should return true - expect(isValid).toBe(true); - }); - - it('should update lastValidStep when validation succeeds', async () => { - w.vm.step1Ref = { - validate: vi.fn().mockResolvedValue(true) - }; - w.vm.wizardStep = 1; - w.vm.lastValidStep = 1; - - await w.vm.goToNextStep(); - await flushPromises(); - - expect(w.vm.lastValidStep).toBeGreaterThanOrEqual(1); - }); - - it('should not update lastValidStep when validation fails', async () => { - w.vm.step1Ref = { - validate: vi.fn().mockResolvedValue(false) - }; - w.vm.wizardStep = 1; - w.vm.lastValidStep = 1; - - await w.vm.goToNextStep(); - await flushPromises(); - - expect(w.vm.lastValidStep).toBe(1); - }); - }); - - describe('Wizard Step State Management', () => { - let w: any; - beforeEach(() => { - w = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); - }); - afterEach(() => { w?.unmount(); w = null; }); - - it('should track current wizard step', () => { - expect(w.vm.wizardStep).toBeDefined(); - expect(typeof w.vm.wizardStep).toBe('number'); - }); - - it('should update wizardStep reactively', async () => { - w.vm.wizardStep = 2; - await nextTick(); - expect(w.vm.wizardStep).toBe(2); - - w.vm.wizardStep = 3; - await nextTick(); - expect(w.vm.wizardStep).toBe(3); - }); - - it('should provide currentStepCaption for step 1', () => { - w.vm.wizardStep = 1; - expect(w.vm.currentStepCaption).toBe('Set the stage for your alert'); - }); - - it('should provide currentStepCaption for step 2', () => { - w.vm.wizardStep = 2; - expect(w.vm.currentStepCaption).toBe('What should trigger the alert'); - }); - - it('should provide currentStepCaption for step 3', () => { - w.vm.wizardStep = 3; - expect(w.vm.currentStepCaption).toBe('Compare current results with data from another time period'); - }); - - it('should provide currentStepCaption for step 4', () => { - w.vm.wizardStep = 4; - expect(w.vm.currentStepCaption).toBe('Set your alert rules and choose how you\'d like to be notified.'); - }); - - it('should provide currentStepCaption for step 5', () => { - w.vm.wizardStep = 5; - expect(w.vm.currentStepCaption).toBe('Avoid sending the same alert multiple times by grouping similar alerts together.'); - }); - - it('should provide currentStepCaption for step 6', () => { - w.vm.wizardStep = 6; - expect(w.vm.currentStepCaption).toBe('Context variables, description, and row template'); - }); - - it('should return empty caption for invalid step', () => { - w.vm.wizardStep = 99; - expect(w.vm.currentStepCaption).toBe(''); - }); - - it('should have step refs defined', () => { - expect(w.vm.step1Ref).toBeDefined(); - expect(w.vm.step2Ref).toBeDefined(); - expect(w.vm.step4Ref).toBeDefined(); - }); - }); - - describe('Wizard Flow for Real-Time vs Scheduled', () => { - let w: any; - beforeEach(() => { - w = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); - }); - afterEach(() => { w?.unmount(); w = null; }); - - it('should show all 6 steps for scheduled alerts', () => { - w.vm.formData.is_real_time = 'false'; - // Steps 1, 2, 3, 4, 5, 6 should all be accessible - expect(w.vm.wizardStep).toBeDefined(); - }); - - it('should skip step 3 for real-time alerts', () => { - w.vm.formData.is_real_time = 'true'; - // Step 3 (Compare with Past) should be skipped - // This is controlled by v-if in template - }); - - it('should skip step 5 for real-time alerts', () => { - w.vm.formData.is_real_time = 'true'; - // Step 5 (Deduplication) should be skipped - // This is controlled by v-if in template - }); - - it('should navigate 1->2->4->6 for real-time alerts', async () => { - w.vm.formData.is_real_time = 'true'; - // Add a destination so step 4 validation passes - w.vm.formData.destinations = ['test-destination']; - - // Mock all step refs to validate successfully - w.vm.step1Ref = { validate: vi.fn().mockResolvedValue(true) }; - w.vm.step2Ref = { validate: vi.fn().mockResolvedValue(true) }; - w.vm.step4Ref = { validate: vi.fn().mockResolvedValue(true) }; - - expect(w.vm.wizardStep).toBe(1); - - await w.vm.goToNextStep(); - await flushPromises(); - expect(w.vm.wizardStep).toBe(2); - - await w.vm.goToNextStep(); - await flushPromises(); - expect(w.vm.wizardStep).toBe(4); // Skips 3 - - await w.vm.goToNextStep(); - await flushPromises(); - expect(w.vm.wizardStep).toBe(6); // Skips 5 - }); - - it('should navigate 1->2->3->4->5->6 for scheduled alerts', async () => { - w.vm.formData.is_real_time = 'false'; - // Add a destination so step 4 validation passes - w.vm.formData.destinations = ['test-destination']; - - // Mock all step refs to validate successfully - w.vm.step1Ref = { validate: vi.fn().mockResolvedValue(true) }; - w.vm.step2Ref = { validate: vi.fn().mockResolvedValue(true) }; - w.vm.step4Ref = { validate: vi.fn().mockResolvedValue(true) }; - - expect(w.vm.wizardStep).toBe(1); - - await w.vm.goToNextStep(); - await flushPromises(); - expect(w.vm.wizardStep).toBe(2); - - await w.vm.goToNextStep(); - await flushPromises(); - expect(w.vm.wizardStep).toBe(3); - - await w.vm.goToNextStep(); - await flushPromises(); - expect(w.vm.wizardStep).toBe(4); - - await w.vm.goToNextStep(); - await flushPromises(); - expect(w.vm.wizardStep).toBe(5); - - await w.vm.goToNextStep(); - await flushPromises(); - expect(w.vm.wizardStep).toBe(6); - }); - - it('should handle switching from scheduled to real-time', async () => { - w.vm.formData.is_real_time = 'false'; - w.vm.wizardStep = 3; // On Compare with Past step - - w.vm.formData.is_real_time = 'true'; - await nextTick(); - - // Should still maintain current step, but step 3 won't be visible - expect(w.vm.wizardStep).toBe(3); - }); - }); - - describe('Wizard Step Component Props', () => { - let w: any; - beforeEach(() => { - w = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); - }); - afterEach(() => { w?.unmount(); w = null; }); - - it('should pass formData to QueryConfig', () => { - const queryConfig = w.findComponent({ name: 'QueryConfig' }); - if (queryConfig.exists()) { - expect(queryConfig.props().inputData).toBeDefined(); - } - }); - - it('should pass formData to AlertSettings', () => { - const alertSettings = w.findComponent({ name: 'AlertSettings' }); - if (alertSettings.exists()) { - expect(alertSettings.props().formData).toBeDefined(); - } - }); - - it('should pass formData to CompareWithPast', async () => { - w.vm.formData.is_real_time = 'false'; - w.vm.wizardStep = 3; - await nextTick(); - - const compareWithPast = w.findComponent({ name: 'CompareWithPast' }); - if (compareWithPast.exists()) { - expect(compareWithPast.props().multiTimeRange).toBeDefined(); - } - }); - - it('should pass formData to Deduplication', async () => { - w.vm.formData.is_real_time = 'false'; - w.vm.wizardStep = 5; - await nextTick(); - - const deduplication = w.findComponent({ name: 'Deduplication' }); - if (deduplication.exists()) { - expect(deduplication.props().deduplication).toBeDefined(); - } - }); - - it('should pass formData to Advanced', async () => { - w.vm.wizardStep = 6; - await nextTick(); - - const advanced = w.findComponent({ name: 'Advanced' }); - if (advanced.exists()) { - expect(advanced.props().contextAttributes).toBeDefined(); - } - }); - }); - - describe('Wizard Right Column Integration', () => { - let w: any; - beforeEach(() => { - w = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); - }); - afterEach(() => { w?.unmount(); w = null; }); - - it('should pass formData to AlertWizardRightColumn', () => { - const rightColumn = w.findComponent({ name: 'AlertWizardRightColumn' }); - if (rightColumn.exists()) { - expect(rightColumn.props().formData).toBeDefined(); - } - }); - - it('should pass wizardStep to AlertWizardRightColumn', () => { - const rightColumn = w.findComponent({ name: 'AlertWizardRightColumn' }); - if (rightColumn.exists()) { - expect(rightColumn.props().wizardStep).toBeDefined(); - } - }); - - it('should pass previewQuery to AlertWizardRightColumn', () => { - const rightColumn = w.findComponent({ name: 'AlertWizardRightColumn' }); - if (rightColumn.exists()) { - expect(rightColumn.props().previewQuery).toBeDefined(); - } - }); - - it('should pass isUsingBackendSql to AlertWizardRightColumn', () => { - const rightColumn = w.findComponent({ name: 'AlertWizardRightColumn' }); - if (rightColumn.exists()) { - expect(rightColumn.props().isUsingBackendSql).toBeDefined(); - } - }); - - it('should have previewAlertRef defined', () => { - expect(w.vm.previewAlertRef).toBeDefined(); - }); - }); - - describe('Wizard Step Button States', () => { - let w: any; - beforeEach(() => { - w = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); - }); - afterEach(() => { w?.unmount(); w = null; }); - - it('should disable Back button on step 1', () => { - w.vm.wizardStep = 1; - // Back button should be disabled at step 1 - expect(w.vm.wizardStep === 1).toBe(true); - }); - - it('should enable Back button on step 2+', () => { - w.vm.wizardStep = 2; - expect(w.vm.wizardStep > 1).toBe(true); - }); - - it('should disable Continue button on last step', () => { - w.vm.wizardStep = 6; - expect(w.vm.isLastStep).toBe(true); - }); - - it('should enable Continue button on non-last steps', () => { - w.vm.wizardStep = 1; - expect(w.vm.isLastStep).toBe(false); - - w.vm.wizardStep = 2; - expect(w.vm.isLastStep).toBe(false); - }); - - it('should disable Save button until last step', () => { - w.vm.wizardStep = 1; - expect(w.vm.isLastStep).toBe(false); - - w.vm.wizardStep = 5; - expect(w.vm.isLastStep).toBe(false); - }); - - it('should enable Save button on last step', () => { - w.vm.wizardStep = 6; - expect(w.vm.isLastStep).toBe(true); - }); - }); - - describe('Wizard Navigation Edge Cases', () => { - let w: any; - beforeEach(() => { - w = shallowMount(AddAlert, { global: { provide: { store }, plugins: [i18n, router] } }); - }); - afterEach(() => { w?.unmount(); w = null; }); - - it('should handle navigation with missing step refs', async () => { - w.vm.step1Ref = null; - w.vm.wizardStep = 1; - - await w.vm.goToNextStep(); - await flushPromises(); - - // When step ref is null, validation returns true and navigation proceeds - expect(w.vm.wizardStep).toBe(2); - }); - - it('should handle validation errors gracefully', async () => { - w.vm.step1Ref = { - validate: vi.fn().mockRejectedValue(new Error('Validation error')) - }; - w.vm.wizardStep = 1; - - // When validation throws an error, it may propagate - try { - await w.vm.goToNextStep(); - await flushPromises(); - } catch (error) { - // Error is expected - } - - // Wizard step should remain at 1 or handle error gracefully - expect(w.vm.wizardStep).toBeGreaterThanOrEqual(1); - }); - - it('should handle rapid navigation clicks', async () => { - w.vm.validateStep = vi.fn().mockResolvedValue(true); - w.vm.wizardStep = 1; - - // Simulate rapid clicks - const promise1 = w.vm.goToNextStep(); - const promise2 = w.vm.goToNextStep(); - - await Promise.all([promise1, promise2]); - await flushPromises(); - - // Should handle gracefully - expect(w.vm.wizardStep).toBeGreaterThanOrEqual(1); - }); - - it('should handle switching alert type mid-wizard', async () => { - w.vm.formData.is_real_time = 'false'; - w.vm.wizardStep = 3; // On Compare with Past - - // Switch to real-time - w.vm.formData.is_real_time = 'true'; - await nextTick(); - - // Navigation should work correctly - w.vm.goToPreviousStep(); - expect(w.vm.wizardStep).toBe(2); - }); - }); - - describe('Panel Data Import - Alert Name Sanitization', () => { - let w: any; - - afterEach(() => { - w?.unmount(); - w = null; - }); - - it('should sanitize spaces in panel title when creating alert name', async () => { - const panelData = { - panelTitle: 'My Test Panel', - queries: [{ - fields: { - stream_type: 'logs', - stream: 'test-stream' - } - }] - }; - - const encodedData = encodeURIComponent(JSON.stringify(panelData)); - - // Mock router with panel query params - router.currentRoute.value.query = { - fromPanel: 'true', - panelData: encodedData - }; - - w = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router] - } + // The measure-column rule is SCHEMA-owned (QueryConfig.schema.ts), not an + // imperative toast — that is what lets the name-bound paint + // itself red (it derives `:error` from the field's own errors). So these + // assert the FIELD ERROR, not a toast, and must seed `_meta` (QueryConfig is + // stubbed here, so nothing bridges the discriminators in). + const seedMeasureAggregation = (form: any, column: string) => { + form.setFieldValue("query_condition.type", "custom"); + form.setFieldValue("query_condition.aggregation", { + function: "avg", + group_by: [], + having: { column, operator: ">=", value: 10 }, }); - - await w.vm.loadPanelDataIfPresent(); - await nextTick(); - - expect(w.vm.formData.name).toBe('Alert_from_My_Test_Panel'); - }); - - it('should sanitize colon characters in panel title', async () => { - const panelData = { - panelTitle: 'CPU:Usage', - queries: [{ - fields: { - stream_type: 'logs', - stream: 'test-stream' - } - }] - }; - - const encodedData = encodeURIComponent(JSON.stringify(panelData)); - - router.currentRoute.value.query = { - fromPanel: 'true', - panelData: encodedData - }; - - w = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router] - } - }); - - await w.vm.loadPanelDataIfPresent(); - await nextTick(); - - expect(w.vm.formData.name).toBe('Alert_from_CPU_Usage'); - }); - - it('should sanitize multiple special characters in panel title', async () => { - const panelData = { - panelTitle: 'Panel #1: Usage?', - queries: [{ - fields: { - stream_type: 'logs', - stream: 'test-stream' - } - }] - }; - - const encodedData = encodeURIComponent(JSON.stringify(panelData)); - - router.currentRoute.value.query = { - fromPanel: 'true', - panelData: encodedData - }; - - w = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router] - } - }); - - await w.vm.loadPanelDataIfPresent(); - await nextTick(); - - // Panel #1: Usage? -> Panel_1_Usage (trailing underscore trimmed) - expect(w.vm.formData.name).toBe('Alert_from_Panel_1_Usage'); - }); - - it('should sanitize all forbidden characters: : # ? & % \' "', async () => { - const panelData = { - panelTitle: 'Test:#?&%\'"Panel', - queries: [{ - fields: { - stream_type: 'logs', - stream: 'test-stream' - } - }] - }; - - const encodedData = encodeURIComponent(JSON.stringify(panelData)); - - router.currentRoute.value.query = { - fromPanel: 'true', - panelData: encodedData - }; - - w = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router] - } - }); - - await w.vm.loadPanelDataIfPresent(); - await nextTick(); - - expect(w.vm.formData.name).toBe('Alert_from_Test_Panel'); - }); - - it('should handle consecutive special characters by replacing with single underscore', async () => { - const panelData = { - panelTitle: 'Panel :: Name', - queries: [{ - fields: { - stream_type: 'logs', - stream: 'test-stream' - } - }] - }; - - const encodedData = encodeURIComponent(JSON.stringify(panelData)); - - router.currentRoute.value.query = { - fromPanel: 'true', - panelData: encodedData - }; - - w = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router] - } - }); - - await w.vm.loadPanelDataIfPresent(); - await nextTick(); - - expect(w.vm.formData.name).toBe('Alert_from_Panel_Name'); - }); - - it('should handle empty/null panel title with fallback', async () => { - const panelData = { - panelTitle: null, - queries: [{ - fields: { - stream_type: 'logs', - stream: 'test-stream' - } - }] - }; - - const encodedData = encodeURIComponent(JSON.stringify(panelData)); - - router.currentRoute.value.query = { - fromPanel: 'true', - panelData: encodedData - }; - - w = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router] - } - }); - - await w.vm.loadPanelDataIfPresent(); - await nextTick(); - - expect(w.vm.formData.name).toBe('Alert_from_panel'); - }); - - it('should handle empty string panel title with fallback', async () => { - const panelData = { - panelTitle: '', - queries: [{ - fields: { - stream_type: 'logs', - stream: 'test-stream' - } - }] - }; - - const encodedData = encodeURIComponent(JSON.stringify(panelData)); - - router.currentRoute.value.query = { - fromPanel: 'true', - panelData: encodedData - }; - - w = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router] - } - }); - - await w.vm.loadPanelDataIfPresent(); - await nextTick(); - - expect(w.vm.formData.name).toBe('Alert_from_panel'); - }); - - it('should handle whitespace-only panel title with fallback', async () => { - const panelData = { - panelTitle: ' ', - queries: [{ - fields: { - stream_type: 'logs', - stream: 'test-stream' - } - }] - }; - - const encodedData = encodeURIComponent(JSON.stringify(panelData)); - - router.currentRoute.value.query = { - fromPanel: 'true', - panelData: encodedData - }; - - w = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router] - } - }); - - await w.vm.loadPanelDataIfPresent(); - await nextTick(); - - expect(w.vm.formData.name).toBe('Alert_from_panel'); - }); - - it('should handle panel title with only special characters with fallback', async () => { - const panelData = { - panelTitle: ':#?&%\'"', - queries: [{ - fields: { - stream_type: 'logs', - stream: 'test-stream' - } - }] - }; - - const encodedData = encodeURIComponent(JSON.stringify(panelData)); - - router.currentRoute.value.query = { - fromPanel: 'true', - panelData: encodedData - }; - - w = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router] - } - }); - - await w.vm.loadPanelDataIfPresent(); - await nextTick(); - - expect(w.vm.formData.name).toBe('Alert_from_panel'); - }); - - it('should trim leading underscores from sanitized panel title', async () => { - const panelData = { - panelTitle: ' ##Panel Name', - queries: [{ - fields: { - stream_type: 'logs', - stream: 'test-stream' - } - }] - }; - - const encodedData = encodeURIComponent(JSON.stringify(panelData)); - - router.currentRoute.value.query = { - fromPanel: 'true', - panelData: encodedData - }; - - w = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router] - } - }); - - await w.vm.loadPanelDataIfPresent(); - await nextTick(); - - expect(w.vm.formData.name).toBe('Alert_from_Panel_Name'); - }); - - it('should trim trailing underscores from sanitized panel title', async () => { - const panelData = { - panelTitle: 'Panel Name## ', - queries: [{ - fields: { - stream_type: 'logs', - stream: 'test-stream' - } - }] - }; - - const encodedData = encodeURIComponent(JSON.stringify(panelData)); - - router.currentRoute.value.query = { - fromPanel: 'true', - panelData: encodedData - }; - - w = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router] - } - }); - - await w.vm.loadPanelDataIfPresent(); - await nextTick(); - - expect(w.vm.formData.name).toBe('Alert_from_Panel_Name'); - }); - - it('should truncate very long panel titles to 200 characters', async () => { - const longTitle = 'A'.repeat(250); // 250 characters - const panelData = { - panelTitle: longTitle, - queries: [{ - fields: { - stream_type: 'logs', - stream: 'test-stream' - } - }] - }; - - const encodedData = encodeURIComponent(JSON.stringify(panelData)); - - router.currentRoute.value.query = { - fromPanel: 'true', - panelData: encodedData - }; - - w = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router] - } - }); - - await w.vm.loadPanelDataIfPresent(); - await nextTick(); - - // Should be Alert_from_ (11 chars) + truncated title (200 chars) = 211 chars max - expect(w.vm.formData.name).toBe('Alert_from_' + 'A'.repeat(200)); - expect(w.vm.formData.name.length).toBe(211); - }); - - it('should handle panel title with mix of valid and invalid characters', async () => { - const panelData = { - panelTitle: 'CPU_Usage-Metrics:2024#Q1?Test&Info%', - queries: [{ - fields: { - stream_type: 'logs', - stream: 'test-stream' - } - }] - }; - - const encodedData = encodeURIComponent(JSON.stringify(panelData)); - - router.currentRoute.value.query = { - fromPanel: 'true', - panelData: encodedData - }; - - w = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router] - } - }); - - await w.vm.loadPanelDataIfPresent(); - await nextTick(); - - // CPU_Usage-Metrics:2024#Q1?Test&Info% - // Underscores and hyphens are valid, others replaced - // Expected: CPU_Usage-Metrics_2024_Q1_Test_Info_ - // After trimming trailing underscore: CPU_Usage-Metrics_2024_Q1_Test_Info - expect(w.vm.formData.name).toBe('Alert_from_CPU_Usage-Metrics_2024_Q1_Test_Info'); - }); - - it('should collapse multiple consecutive invalid characters into single underscore', async () => { - const panelData = { - panelTitle: 'Panel:::Name???Test', - queries: [{ - fields: { - stream_type: 'logs', - stream: 'test-stream' - } - }] - }; - - const encodedData = encodeURIComponent(JSON.stringify(panelData)); - - router.currentRoute.value.query = { - fromPanel: 'true', - panelData: encodedData - }; - - w = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router] - } - }); - - await w.vm.loadPanelDataIfPresent(); - await nextTick(); - - // Panel:::Name???Test -> Panel_Name_Test (collapsed) - expect(w.vm.formData.name).toBe('Alert_from_Panel_Name_Test'); - }); - }); - - describe('PromQL Condition Handling', () => { - let w: any; - - beforeEach(async () => { - vi.clearAllMocks(); - // Reset router query so prior fromPanel tests don't pollute initializeFormData - router.currentRoute.value.query = {} as any; - w = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router] - } - }); - await nextTick(); - await flushPromises(); - }); - - afterEach(() => { - w.unmount(); - }); - - it('should initialize promql_condition with column field when switching to promql tab', async () => { - // Initially promql_condition should be null - expect(w.vm.formData.query_condition.promql_condition).toBeNull(); - - // Switch to promql mode - w.vm.formData.query_condition.type = 'promql'; - await nextTick(); - await flushPromises(); - - // promql_condition should be initialized with all required fields - expect(w.vm.formData.query_condition.promql_condition).toBeDefined(); - expect(w.vm.formData.query_condition.promql_condition.column).toBe('value'); - expect(w.vm.formData.query_condition.promql_condition.operator).toBe('>='); - expect(w.vm.formData.query_condition.promql_condition.value).toBe(1); - }); - - it('should not reinitialize promql_condition if it already exists', async () => { - // Set up existing promql_condition - w.vm.formData.query_condition.promql_condition = { - column: 'value', - operator: '>', - value: 50, - }; - - // Switch to promql mode - w.vm.formData.query_condition.type = 'promql'; - await nextTick(); - await flushPromises(); - - // Should preserve existing values - expect(w.vm.formData.query_condition.promql_condition.operator).toBe('>'); - expect(w.vm.formData.query_condition.promql_condition.value).toBe(50); - }); - - it('should include column field in promql_condition payload', () => { - w.vm.formData.query_condition.type = 'promql'; - w.vm.formData.query_condition.promql = 'up{job="test"}'; - w.vm.formData.query_condition.promql_condition = { - column: 'value', - operator: '>=', - value: 10, - }; - - const payload = w.vm.getAlertPayload(); - - expect(payload.query_condition.promql_condition).toBeDefined(); - expect(payload.query_condition.promql_condition.column).toBe('value'); - expect(payload.query_condition.promql_condition.operator).toBe('>='); - expect(payload.query_condition.promql_condition.value).toBe(10); - }); - - it('should set promql_condition to null when switching from promql to sql', () => { - // Start with promql - w.vm.formData.query_condition.type = 'promql'; - w.vm.formData.query_condition.promql_condition = { - column: 'value', - operator: '>=', - value: 10, - }; - - // Switch to sql - w.vm.formData.query_condition.type = 'sql'; - const payload = w.vm.getAlertPayload(); - - expect(payload.query_condition.promql_condition).toBeNull(); - }); - - it('should set promql_condition to null when switching from promql to custom', () => { - // Start with promql - w.vm.formData.query_condition.type = 'promql'; - w.vm.formData.query_condition.promql_condition = { - column: 'value', - operator: '>=', - value: 10, - }; - - // Switch to custom - w.vm.formData.query_condition.type = 'custom'; - const payload = w.vm.getAlertPayload(); - - expect(payload.query_condition.promql_condition).toBeNull(); - }); - - it('should initialize promql_condition when loading from dashboard panel with promql query', async () => { - const panelData = { - queryType: 'promql', - queries: [{ - query: 'up{job="test"}', - customQuery: true - }], - threshold: 75, - condition: 'above', - }; - const encodedData = encodeURIComponent(JSON.stringify(panelData)); - - router.currentRoute.value.query = { - fromPanel: 'true', - panelData: encodedData - }; - - w = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router] - } - }); - - await w.vm.loadPanelDataIfPresent(); - await nextTick(); - await flushPromises(); - - // Should have promql_condition with column field - expect(w.vm.formData.query_condition.promql_condition).toBeDefined(); - expect(w.vm.formData.query_condition.promql_condition).not.toBeNull(); - expect(w.vm.formData.query_condition.promql_condition.column).toBe('value'); - expect(w.vm.formData.query_condition.promql_condition.operator).toBe('>='); - expect(w.vm.formData.query_condition.promql_condition.value).toBe(75); - }); - - it('should use <= operator when panel condition is below', async () => { - const panelData = { - queryType: 'promql', - queries: [{ - query: 'up{job="test"}', - customQuery: true - }], - threshold: 25, - condition: 'below', - }; - const encodedData = encodeURIComponent(JSON.stringify(panelData)); - - router.currentRoute.value.query = { - fromPanel: 'true', - panelData: encodedData - }; - - w = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router] - } - }); - - await w.vm.loadPanelDataIfPresent(); - await nextTick(); - await flushPromises(); - - expect(w.vm.formData.query_condition.promql_condition).not.toBeNull(); - expect(w.vm.formData.query_condition.promql_condition.operator).toBe('<='); - expect(w.vm.formData.query_condition.promql_condition.value).toBe(25); - }); - - it('should preserve promql_condition when editing existing alert', async () => { - const existingAlert = { - name: 'Test Alert', - stream_type: 'metrics', - stream_name: 'test_stream', - is_real_time: false, - query_condition: { - type: 'promql', - promql: 'up{job="test"}', - promql_condition: { - column: 'value', - operator: '>', - value: 100, - }, - conditions: null, - sql: null, - aggregation: null, - }, - trigger_condition: { - period: 10, - operator: '>=', - threshold: 1, - frequency: 10, - silence: 10, - }, - destinations: ['test-dest'], - }; - - w.vm.formData = { ...w.vm.formData, ...existingAlert }; - await nextTick(); - - // promql_condition should be preserved with all fields - expect(w.vm.formData.query_condition.promql_condition).toBeDefined(); - expect(w.vm.formData.query_condition.promql_condition.column).toBe('value'); - expect(w.vm.formData.query_condition.promql_condition.operator).toBe('>'); - expect(w.vm.formData.query_condition.promql_condition.value).toBe(100); - }); - - it('should clear sql when query type is promql in payload', () => { - w.vm.formData.query_condition.type = 'promql'; - w.vm.formData.query_condition.promql = 'up{job="test"}'; - w.vm.formData.query_condition.sql = 'SELECT * FROM test'; - w.vm.formData.query_condition.promql_condition = { - column: 'value', - operator: '>=', - value: 1, - }; - - const payload = w.vm.getAlertPayload(); - - expect(payload.query_condition.type).toBe('promql'); - expect(payload.query_condition.promql).toBe('up{job="test"}'); - expect(payload.query_condition.sql).toBe(''); - expect(payload.query_condition.promql_condition).toBeDefined(); - }); - - it('should clear conditions array when query type is promql in payload', () => { - w.vm.formData.query_condition.type = 'promql'; - w.vm.formData.query_condition.promql = 'up{job="test"}'; - w.vm.formData.query_condition.conditions = { - version: 2, - conditions: { - filterType: 'group', - logicalOperator: 'AND', - groupId: 'test', - conditions: [{ column: 'test', operator: '=', value: '1' }] - } - }; - w.vm.formData.query_condition.promql_condition = { - column: 'value', - operator: '>=', - value: 1, - }; - - const payload = w.vm.getAlertPayload(); - - expect(payload.query_condition.type).toBe('promql'); - expect(payload.query_condition.conditions).toEqual([]); - expect(payload.query_condition.promql_condition).toBeDefined(); - }); - - it('should add column field to legacy promql_condition on load (backward compatibility)', async () => { - // Simulate loading a legacy alert without the column field - const legacyAlert = { - name: 'Legacy Alert', - stream_type: 'metrics', - stream_name: 'test_stream', - is_real_time: false, - query_condition: { - type: 'promql', - promql: 'up{job="test"}', - promql_condition: { - // No column field - this is a legacy alert - operator: '>', - value: 100, - }, - conditions: null, - sql: null, - aggregation: null, - }, - trigger_condition: { - period: 10, - operator: '>=', - threshold: 1, - frequency: 10, - silence: 10, - }, - destinations: ['test-dest'], - }; - - // Mount with isUpdated=true to simulate editing an existing alert - w = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router] - }, - props: { - modelValue: legacyAlert, - isUpdated: true, - } - }); - - await flushPromises(); - await nextTick(); - - // The column field should be automatically added during created() lifecycle - expect(w.vm.formData.query_condition.promql_condition).toBeDefined(); - expect(w.vm.formData.query_condition.promql_condition.column).toBe('value'); - expect(w.vm.formData.query_condition.promql_condition.operator).toBe('>'); - expect(w.vm.formData.query_condition.promql_condition.value).toBe(100); - }); - - it('should initialize all missing fields in malformed promql_condition', async () => { - // Simulate a severely malformed alert missing multiple fields - const malformedAlert = { - name: 'Malformed Alert', - stream_type: 'metrics', - stream_name: 'test_stream', - is_real_time: false, - query_condition: { - type: 'promql', - promql: 'up{job="test"}', - promql_condition: { - // Missing column, operator, and value - }, - conditions: null, - sql: null, - aggregation: null, - }, - trigger_condition: { - period: 10, - operator: '>=', - threshold: 1, - frequency: 10, - silence: 10, - }, - destinations: ['test-dest'], - }; - - w = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router] - }, - props: { - modelValue: malformedAlert, - isUpdated: true, - } - }); - - await flushPromises(); - await nextTick(); - - // All missing fields should be initialized with defaults - expect(w.vm.formData.query_condition.promql_condition).toBeDefined(); - expect(w.vm.formData.query_condition.promql_condition.column).toBe('value'); - expect(w.vm.formData.query_condition.promql_condition.operator).toBe('>='); - expect(w.vm.formData.query_condition.promql_condition.value).toBe(1); - }); - - it('should preserve existing values and only add missing column field', async () => { - // Alert with operator and value, but missing column - const partialAlert = { - name: 'Partial Alert', - stream_type: 'metrics', - stream_name: 'test_stream', - is_real_time: false, - query_condition: { - type: 'promql', - promql: 'up{job="test"}', - promql_condition: { - operator: '<', - value: 50, - // Missing column only - }, - conditions: null, - sql: null, - aggregation: null, - }, - trigger_condition: { - period: 10, - operator: '>=', - threshold: 1, - frequency: 10, - silence: 10, - }, - destinations: ['test-dest'], - }; - - w = shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router] - }, - props: { - modelValue: partialAlert, - isUpdated: true, - } - }); - - await flushPromises(); - await nextTick(); - - // Should add column but preserve existing operator and value - expect(w.vm.formData.query_condition.promql_condition.column).toBe('value'); - expect(w.vm.formData.query_condition.promql_condition.operator).toBe('<'); - expect(w.vm.formData.query_condition.promql_condition.value).toBe(50); - }); - }); - - // Coverage for the q-dialog -> ODrawer migration of the JSON editor dialog. - // Source change: - // -> - // These tests assert the v-model:open contract between AddAlert.vue and ODrawer, - // and that JsonEditor is slotted inside the drawer. - describe('ODrawer Migration', () => { - // Lightweight stub exposing the migrated prop surface and the update:open emit - // so we can assert the v-model:open binding without pulling in reka-ui portals. - const ODrawerStub = { - name: 'ODrawer', - props: { - open: { type: Boolean, default: false }, - size: { type: String, default: undefined }, - persistent: { type: Boolean, default: false }, - showClose: { type: Boolean, default: true }, - }, - emits: ['update:open'], - template: ` -
- - - - -
- `, + form.setFieldValue("_meta.selectedFunction", "avg"); + form.setFieldValue("_meta.aggregationEnabled", true); }; - const mountWithStub = () => - shallowMount(AddAlert, { - global: { - provide: { store }, - plugins: [i18n, router], - stubs: { ODrawer: ODrawerStub }, - }, - props: { title: 'Add Alert' }, - }); - - // AddAlert renders several child components that also use ODrawer (e.g. - // QueryEditorDialog, AlertHistoryDrawer, SemanticFieldGroupsConfig). The - // global ODrawer stub matches all of them, so we narrow to the JSON-editor - // drawer using its unique prop signature: size="lg" + persistent=true. - const findJsonEditorDrawer = (wrap: any) => - wrap - .findAllComponents(ODrawerStub) - .find( - (d: any) => - d.props('size') === 'lg' && d.props('persistent') === true, - ); - - let w: any; - - beforeEach(async () => { - w = mountWithStub(); + it("blocks a custom measure save when the aggregation column is empty", async () => { + wrapper = mountAlert(); await flushPromises(); + const form = wrapper.vm.form; + seedValidScheduled(form); + seedMeasureAggregation(form, ""); + wrapper.vm.isAggregationEnabled = true; + + await form.handleSubmit(); + await flushPromises(); + + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); + expect(form.state.isValid).toBe(false); + // The error lands on the exact path both the logs and the metrics + // bind, so the field renders it (red border + message). + const errors = + form.getFieldMeta("query_condition.aggregation.having.column")?.errors ?? + []; + expect(errors.map((e: any) => e?.message ?? e)).toContain( + "Column is required when using an aggregate function.", + ); }); - afterEach(() => { - w?.unmount(); + it("blocks a custom measure save when the aggregation column is only whitespace", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedValidScheduled(form); + seedMeasureAggregation(form, " "); + wrapper.vm.isAggregationEnabled = true; + + await form.handleSubmit(); + await flushPromises(); + + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); + expect(form.state.isValid).toBe(false); }); - it('renders the JSON editor ODrawer with size="lg" and persistent props', () => { - const drawer = findJsonEditorDrawer(w); - expect(drawer).toBeTruthy(); - expect(drawer.props('size')).toBe('lg'); - expect(drawer.props('persistent')).toBe(true); - }); + it("does NOT block a custom measure save when the aggregation column is set", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedValidScheduled(form); + seedMeasureAggregation(form, "field2"); + wrapper.vm.isAggregationEnabled = true; - it('keeps the JSON editor ODrawer closed by default (showJsonEditorDialog=false)', () => { - const drawer = findJsonEditorDrawer(w); - expect(w.vm.showJsonEditorDialog).toBe(false); - expect(drawer.props('open')).toBe(false); - }); + await form.handleSubmit(); + await flushPromises(); - it('forwards showJsonEditorDialog=true to the ODrawer open prop', async () => { - w.vm.showJsonEditorDialog = true; - await nextTick(); - - const drawer = findJsonEditorDrawer(w); - expect(drawer.props('open')).toBe(true); - }); - - it('opens the JSON editor ODrawer when openJsonEditor() is invoked', async () => { - w.vm.openJsonEditor(); - await nextTick(); - - const drawer = findJsonEditorDrawer(w); - expect(w.vm.showJsonEditorDialog).toBe(true); - expect(drawer.props('open')).toBe(true); - }); - - it('sets showJsonEditorDialog=false when the ODrawer emits update:open=false', async () => { - // Pre-condition: open the drawer. - w.vm.showJsonEditorDialog = true; - await nextTick(); - const drawer = findJsonEditorDrawer(w); - expect(drawer.props('open')).toBe(true); - - // Act: drawer emits update:open(false) — v-model:open should write back. - await drawer.vm.$emit('update:open', false); - await nextTick(); - - expect(w.vm.showJsonEditorDialog).toBe(false); - }); - - it('sets showJsonEditorDialog=true when the ODrawer emits update:open=true', async () => { - // Sanity-check the v-model:open contract works in both directions. - expect(w.vm.showJsonEditorDialog).toBe(false); - const drawer = findJsonEditorDrawer(w); - - await drawer.vm.$emit('update:open', true); - await nextTick(); - - expect(w.vm.showJsonEditorDialog).toBe(true); - }); - - it('renders the JsonEditor child inside the ODrawer when open', async () => { - w.vm.showJsonEditorDialog = true; - await nextTick(); - - const drawer = findJsonEditorDrawer(w); - const jsonEditor = drawer.findComponent({ name: 'JsonEditor' }); - expect(jsonEditor.exists()).toBe(true); - }); - - it('passes formData and isEditing props to the slotted JsonEditor', async () => { - w.vm.showJsonEditorDialog = true; - await nextTick(); - - const drawer = findJsonEditorDrawer(w); - const jsonEditor = drawer.findComponent({ name: 'JsonEditor' }); - expect(jsonEditor.exists()).toBe(true); - // Template binds :data="formData" and :isEditing="beingUpdated". - expect(jsonEditor.props('data')).toEqual(w.vm.formData); - expect(jsonEditor.props('isEditing')).toBe(w.vm.beingUpdated); - }); - - it('closes the ODrawer when the slotted JsonEditor emits @close', async () => { - // Template wires: @close="showJsonEditorDialog = false" - w.vm.showJsonEditorDialog = true; - await nextTick(); - - const drawer = findJsonEditorDrawer(w); - const jsonEditor = drawer.findComponent({ name: 'JsonEditor' }); - expect(jsonEditor.exists()).toBe(true); - - await jsonEditor.vm.$emit('close'); - await nextTick(); - - expect(w.vm.showJsonEditorDialog).toBe(false); + const errors = + form.getFieldMeta("query_condition.aggregation.having.column")?.errors ?? + []; + expect(errors).toHaveLength(0); + expect(alertsService.create_by_alert_id).toHaveBeenCalledTimes(1); }); }); + // ── Cron save gates (R4 RESTORE) ────────────────────────────────────────── + // Pre-migration AlertSettings.validate() ran validateFrequency() first and + // returned {valid:false, message:cronJobError}; the orchestrator blocked the + // save, toasted `if (message)`, and switched to the condition tab. After the + // migration QueryConfig still RENDERED cronError but nothing gated save — a + // 1-second cron saved happily. Re-homed into runImperativeQueryChecks. + describe("cron save gates (runImperativeQueryChecks)", () => { + const seedCron = (form: any, cron: string, timezone = "UTC") => { + seedValidScheduled(form); + form.setFieldValue("query_condition.type", "custom"); + form.setFieldValue("trigger_condition", { + period: 10, + operator: ">=", + frequency: 10, + cron, + threshold: 3, + silence: 10, + frequency_type: "cron", + timezone, + }); + }; + + /** Set the org floor (SECONDS) on the shared test store, restoring after. */ + const withMinInterval = (secs: number | undefined) => { + const prev = store.state.zoConfig; + store.state.zoConfig = { ...prev, min_auto_refresh_interval: secs }; + return () => { + store.state.zoConfig = prev; + }; + }; + + it("blocks a cron save when the interval is below the org minimum", async () => { + const restore = withMinInterval(300); // 5 minutes + try { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + // Every minute = 60s < 300s. + seedCron(form, "0 * * * * *"); + + await form.handleSubmit(); + await flushPromises(); + + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); + // Verbatim pre-migration message (minInterval - 1). + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Frequency should be greater than 299 seconds.", + }), + ); + expect(wrapper.vm.activeTab).toBe("condition"); + } finally { + restore(); + } + }); + + it("blocks a cron save when the expression is invalid", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedCron(form, "not-a-cron"); + + await form.handleSubmit(); + await flushPromises(); + + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ message: "Invalid cron expression" }), + ); + expect(wrapper.vm.activeTab).toBe("condition"); + }); + + it("blocks a cron save when the timezone is missing (inline-only, NO toast)", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedCron(form, "0 */10 * * * *", ""); + + await form.handleSubmit(); + await flushPromises(); + + // Pre-migration returned {valid:false, message:null} here and the + // orchestrator only toasted `if (message)` — so this case blocks + moves + // to the condition tab WITHOUT a toast (QueryConfig renders the inline + // "Cron expression and timezone are required"). + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); + expect(wrapper.vm.activeTab).toBe("condition"); + }); + + it("blocks a cron save when the expression is missing", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedCron(form, ""); + + await form.handleSubmit(); + await flushPromises(); + + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); + }); + + it("ALLOWS a cron save when the interval clears the org minimum", async () => { + const restore = withMinInterval(300); + try { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + // Every 10 minutes = 600s > 300s. + seedCron(form, "0 */10 * * * *"); + + await form.handleSubmit(); + await flushPromises(); + + expect(alertsService.create_by_alert_id).toHaveBeenCalledTimes(1); + } finally { + restore(); + } + }); + + it("does NOT run the cron gate in minutes mode", async () => { + const restore = withMinInterval(300); + try { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedValidScheduled(form); + // A garbage cron is irrelevant while frequency_type is 'minutes'. + form.setFieldValue("trigger_condition.cron", "not-a-cron"); + // 10 min clears the 5-min floor, so the schema rule passes too. + form.setFieldValue("_ui.checkEvery", 10); + + await form.handleSubmit(); + await flushPromises(); + + expect(toast).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Invalid cron expression" }), + ); + expect(alertsService.create_by_alert_id).toHaveBeenCalledTimes(1); + } finally { + restore(); + } + }); + }); + + describe("create save path (payload parity — Rule ④)", () => { + it("creates an alert with the EXACT payload (keys + types + conditions {version:2})", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + seedValidScheduled(form); + + await form.handleSubmit(); + await flushPromises(); + + expect(form.state.isValid).toBe(true); + expect(alertsService.create_by_alert_id).toHaveBeenCalledTimes(1); + + const [orgId, payload, folderId] = + (alertsService.create_by_alert_id as any).mock.calls[0]; + + expect(orgId).toBe("default"); + expect(folderId).toBeDefined(); + + // ── EXACT top-level key set (Rule ④ payload parity) ────────────────── + // Asserted as a SET, not key-by-key: a subset check cannot catch a NEW + // form-only key leaking to the backend. The form value set is cloneDeep'd + // wholesale into the payload, so every key seeded into the form ships + // unless getAlertPayload drops it (it strips uuid/_ui/_meta/logGroupBy — + // alertPayload.ts:70-84). This list = defaultAlertValue()'s 20 keys + // (useAlertForm.ts:107-161); prepareAndSaveAlert only re-stamps keys that + // already exist (folder_id/createdAt/owner/lastTriggeredAt/lastEditedBy). + // If this fails, do NOT just add the key — check whether it BELONGS on the + // wire or needs stripping in getAlertPayload. + expect(Object.keys(payload).sort()).toEqual( + [ + "context_attributes", + "createdAt", + "creates_incident", + "description", + "destinations", + "enabled", + "folder_id", + "is_real_time", + "lastEditedBy", + "lastTriggeredAt", + "name", + "owner", + "query_condition", + "row_template", + "row_template_type", + "stream_name", + "stream_type", + "template", + "trigger_condition", + "updatedAt", + ].sort(), + ); + + // is_real_time coerced string → boolean + expect(payload.is_real_time).toBe(false); + // numeric trigger fields parseInt'd to numbers + expect(payload.trigger_condition.threshold).toBe(3); + expect(typeof payload.trigger_condition.threshold).toBe("number"); + expect(payload.trigger_condition.period).toBe(10); + expect(payload.trigger_condition.frequency).toBe(10); + expect(payload.trigger_condition.silence).toBe(10); + // context_attributes array → object (empty here) + expect(payload.context_attributes).toEqual({}); + // custom + aggregation disabled → aggregation null + expect(payload.query_condition.aggregation).toBeNull(); + // sql/custom → promql_condition null + expect(payload.query_condition.promql_condition).toBeNull(); + // conditions wrapped { version: 2, conditions } + expect(payload.query_condition.conditions).toHaveProperty("version", 2); + expect(payload.query_condition.conditions).toHaveProperty("conditions"); + expect(payload.query_condition.conditions.conditions.filterType).toBe("group"); + // owner / lastEditedBy from store.state.userInfo.email (create path) + expect(payload.owner).toBe(store.state.userInfo.email); + expect(payload.lastEditedBy).toBe(store.state.userInfo.email); + expect(payload.createdAt).toBeDefined(); + // uuid stripped + expect(payload.uuid).toBeUndefined(); + + expect(alertsService.update_by_alert_id).not.toHaveBeenCalled(); + }); + }); + + describe("update save path", () => { + it("updates an existing alert via update_by_alert_id (beingUpdated)", async () => { + wrapper = mountAlert({ + isUpdated: true, + modelValue: { + name: "existing_alert", + stream_type: "logs", + stream_name: "_rundata", + is_real_time: "false", + query_condition: { + type: "custom", + conditions: { + filterType: "group", + logicalOperator: "AND", + groupId: "g1", + conditions: [], + }, + sql: "", + promql: "", + aggregation: null, + promql_condition: null, + vrl_function: null, + multi_time_range: [], + }, + trigger_condition: { + period: 10, + operator: ">=", + frequency: 10, + cron: "", + threshold: 5, + silence: 10, + frequency_type: "minutes", + timezone: "UTC", + }, + destinations: ["email"], + context_attributes: {}, + enabled: true, + description: "", + row_template: "", + row_template_type: "String", + }, + destinations: [{ name: "email" }], + }); + await flushPromises(); + const form = wrapper.vm.form; + + expect(wrapper.vm.beingUpdated).toBe(true); + // edit-prefill seeded the form via form.reset + expect(form.state.values.name).toBe("existing_alert"); + expect(form.state.values.destinations).toEqual(["email"]); + + await form.handleSubmit(); + await flushPromises(); + + expect(form.state.isValid).toBe(true); + expect(alertsService.update_by_alert_id).toHaveBeenCalledTimes(1); + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); + + const payload = (alertsService.update_by_alert_id as any).mock.calls[0][1]; + expect(payload.is_real_time).toBe(false); + expect(payload.trigger_condition.threshold).toBe(5); + expect(payload.query_condition.conditions).toHaveProperty("version", 2); + // update path stamps lastEditedBy + updatedAt + expect(payload.lastEditedBy).toBe(store.state.userInfo.email); + expect(payload.updatedAt).toBeDefined(); + }); + }); + + describe("anomaly save path", () => { + it("saves via anomalyDetectionService.create with the anomaly_config payload", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + + // Switch to anomaly mode first; the is_real_time watcher re-seeds + // anomalyConfig from formData, so set the anomaly fields AFTER it settles. + form.setFieldValue("is_real_time", "anomaly"); + await flushPromises(); + // name/stream_type/stream_name are FORM fields in anomaly mode too (the + // topbar binds all three), and useAlertForm's watcher copies them into + // anomalyConfig for the payload. Drive them through the form like the real + // UI does — writing anomalyConfig directly is the pre-migration dual-source + // path, and that watcher fires on any of the three and rewrites all three, + // so a direct write gets clobbered by the next form edit anyway. + form.setFieldValue("name", "anom_alert"); + form.setFieldValue("stream_type", "logs"); + form.setFieldValue("stream_name", "_rundata"); + await flushPromises(); + wrapper.vm.anomalyConfig.alert_enabled = false; // no destination requirement + wrapper.vm.anomalyConfig.query_mode = "filters"; + + await wrapper.vm.handleSave(); + await flushPromises(); + + expect(alertsService.create_by_alert_id).not.toHaveBeenCalled(); + expect(anomalyDetectionService.create).toHaveBeenCalledTimes(1); + + const [orgId, payload] = (anomalyDetectionService.create as any).mock.calls[0]; + expect(orgId).toBe("default"); + expect(payload.alert_type).toBe("anomaly_detection"); + expect(payload.name).toBe("anom_alert"); + expect(payload.stream_name).toBe("_rundata"); + expect(payload.anomaly_config).toBeTruthy(); + expect(payload.anomaly_config.query_mode).toBe("filters"); + }); + + it("blocks anomaly save when the anomaly name is empty", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + + form.setFieldValue("is_real_time", "anomaly"); + form.setFieldValue("name", ""); + await flushPromises(); + + await wrapper.vm.handleSave(); + await flushPromises(); + + expect(anomalyDetectionService.create).not.toHaveBeenCalled(); + }); + + // The blank name used to be an imperative toast inside saveAnomalyDetection, + // which cannot paint a field — so the topbar stayed unhighlighted while the + // toast said to fix the highlighted fields. The rule now lives in the schema + // and routes to the topbar `name` field. + it("surfaces the empty anomaly name on the name FIELD, not just a toast", async () => { + wrapper = mountAlert(); + await flushPromises(); + const form = wrapper.vm.form; + + form.setFieldValue("is_real_time", "anomaly"); + form.setFieldValue("name", ""); + await flushPromises(); + + await wrapper.vm.handleSave(); + await flushPromises(); + + expect(form.state.isValid).toBe(false); + expect( + form.state.fieldMeta.name.errors.map((e: any) => + typeof e === "string" ? e : e?.message, + ), + ).toContain("Anomaly name is required."); + expect(anomalyDetectionService.create).not.toHaveBeenCalled(); + }); + }); + + // ── Cross-tab error focus ────────────────────────────────────────────────── + // Regression: leave a required field blank on the Alert Rules tab, switch to + // Advanced, hit Save → "Please fix the highlighted fields" fired while the + // highlighted field sat on a v-show'd-off tab the user could not see, and + // nothing steered them back. focusOnFirstError skips messages with a null + // offsetParent (correct on its own — focusing a display:none control no-ops), + // so every message on the inactive pane was filtered out and it focused + // nothing. It must bring the tab OWNING the error forward first. + describe("focusOnFirstError (cross-tab)", () => { + let offsetParent: any; + + beforeEach(() => { + // jsdom has no layout, so offsetParent is null for EVERYTHING — which would + // make an already-visible message look hidden and mask which branch runs. + // Model just the rule this code leans on: display:none has no offsetParent. + offsetParent = vi + .spyOn(HTMLElement.prototype, "offsetParent", "get") + .mockImplementation(function (this: HTMLElement) { + let node: HTMLElement | null = this; + while (node) { + if (node.style?.display === "none") return null; + node = node.parentElement; + } + return document.body; + }); + // Not implemented in jsdom. + (Element.prototype as any).scrollIntoView = vi.fn(); + }); + + afterEach(() => offsetParent?.mockRestore()); + + /** Stand in for the error an OForm* field renders once the schema fails — + * the step children are stubbed here, so inject the same shape they emit + * (a [role="alert"] message beside its control) into the real pane. */ + const seedFieldError = (tab: string): HTMLInputElement => { + const pane = wrapper.find(`[data-tab-pane="${tab}"]`).element; + const field = document.createElement("div"); + field.innerHTML = + 'At least one destination is required.'; + pane.appendChild(field); + return field.querySelector("input") as HTMLInputElement; + }; + + it("brings the tab owning the error forward and focuses the field", async () => { + // Attached: focusOnFirstError reaches for the live document (the real + //
+ focus()), which a detached wrapper has no place in. + wrapper = mountAlert({}, { attachTo: document.body }); + await flushPromises(); + const input = seedFieldError("condition"); + wrapper.vm.activeTab = "advanced"; + await nextTick(); + + wrapper.vm.focusOnFirstError(); + await flushPromises(); + + expect(wrapper.vm.activeTab).toBe("condition"); + expect(document.activeElement).toBe(input); + }); + + it("stays put when the error is already on the visible tab", async () => { + wrapper = mountAlert({}, { attachTo: document.body }); + await flushPromises(); + const input = seedFieldError("condition"); + wrapper.vm.activeTab = "condition"; + await nextTick(); + + wrapper.vm.focusOnFirstError(); + await flushPromises(); + + expect(wrapper.vm.activeTab).toBe("condition"); + expect(document.activeElement).toBe(input); + }); + + // Anomaly mode swaps the tab headers wholesale, but every pane stays in the + // DOM (v-show). Hopping onto a pane with no header would strand the user on + // a tab the toggle group cannot show or leave. + it("never hops to a tab that has no header in the current mode", async () => { + wrapper = mountAlert({}, { attachTo: document.body }); + await flushPromises(); + wrapper.vm.form.setFieldValue("is_real_time", "anomaly"); + await flushPromises(); + expect(wrapper.vm.activeTab).toBe("anomaly-config"); + + seedFieldError("condition"); // stale message on the now-headerless pane + wrapper.vm.focusOnFirstError(); + await flushPromises(); + + expect(wrapper.vm.activeTab).toBe("anomaly-config"); + }); + }); + + // ── Pure util regression (independent of the form) ───────────────────────── + describe("generateWhereClause (V2 conditions)", () => { + const streamFieldsMap = { + age: { label: "age", value: "age", type: "Int64" }, + city: { label: "city", value: "city", type: "String" }, + }; + + it("builds a simple numeric where clause without quotes", () => { + const group = { + filterType: "group", + logicalOperator: "AND", + conditions: [ + { filterType: "condition", column: "age", operator: ">", value: 30, logicalOperator: "AND" }, + ], + }; + expect(generateWhereClause(group, streamFieldsMap)).toBe("WHERE age > 30"); + }); + + it("quotes string values", () => { + const group = { + filterType: "group", + logicalOperator: "AND", + conditions: [ + { filterType: "condition", column: "city", operator: "=", value: "delhi", logicalOperator: "AND" }, + ], + }; + expect(generateWhereClause(group, streamFieldsMap)).toBe("WHERE city = 'delhi'"); + }); + + it("returns empty string for an invalid group", () => { + expect(generateWhereClause(null as any, streamFieldsMap)).toBe(""); + }); + }); }); diff --git a/web/src/components/alerts/AddAlert.vue b/web/src/components/alerts/AddAlert.vue index cb17b4abfd..d48352749e 100644 --- a/web/src/components/alerts/AddAlert.vue +++ b/web/src/components/alerts/AddAlert.vue @@ -15,7 +15,11 @@ along with this program. If not, see . -->