mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-06 00:09:22 +00:00
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
993 lines
41 KiB
TypeScript
993 lines
41 KiB
TypeScript
// 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 <http://www.gnu.org/licenses/>.
|
||
|
||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||
import { mount, flushPromises } from "@vue/test-utils";
|
||
import CreateDestinationForm from "./CreateDestinationForm.vue";
|
||
import OInput from "@/lib/forms/Input/OInput.vue";
|
||
import { createStore } from "vuex";
|
||
import destinationService from "@/services/alert_destination";
|
||
import i18n from "@/locales";
|
||
import { nextTick } from "vue";
|
||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||
|
||
// Passthrough spy: real toasts still render, and specs can read what was raised.
|
||
vi.mock("@/lib/feedback/Toast/useToast", async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import("@/lib/feedback/Toast/useToast")>();
|
||
return { ...actual, toast: vi.fn((...args: unknown[]) => actual.toast(...(args as [never]))) };
|
||
});
|
||
|
||
// Mock the destination service
|
||
vi.mock("@/services/alert_destination", async (importOriginal) => {
|
||
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
|
||
return overlayServiceMock(await importOriginal(), {
|
||
default: {
|
||
create: vi.fn(),
|
||
update: vi.fn(),
|
||
},
|
||
});
|
||
});
|
||
|
||
// Mock zincutils with deterministic UUIDs
|
||
let uuidCounter = 0;
|
||
vi.mock("@/utils/zincutils", () => ({
|
||
isValidResourceName: vi.fn((val: string) => {
|
||
const invalidChars = /[:/?#\s]/;
|
||
return !invalidChars.test(val);
|
||
}),
|
||
getImageURL: vi.fn((path: string) => `/mock/${path}`),
|
||
getUUID: vi.fn(() => `test-uuid-${++uuidCounter}`),
|
||
}));
|
||
|
||
// CreateDestinationForm is migrated to the headless useOForm OWNER pattern
|
||
// (Rule ③): the TanStack form is the SINGLE source of truth for EVERY field.
|
||
// There is no `formData` mirror and no sync watches — the component reads form
|
||
// state via form.useStore (driving the card-grid selection + every conditional
|
||
// v-if) and writes it via form.setFieldValue. These tests drive every field
|
||
// through the real form (`form.state.values` to read, `form.setFieldValue` to
|
||
// write), and keep the multi-step / edit / headers / auto-prefill coverage.
|
||
|
||
// Read a form-owned field from the REAL OForm (single source of truth).
|
||
function getFormField(w: any, name: string) {
|
||
return w.vm.form.state.values[name];
|
||
}
|
||
|
||
// Set a form-owned field on the REAL OForm (single source of truth — there is
|
||
// no `formData` mirror anymore).
|
||
function setFormField(w: any, name: string, value: unknown) {
|
||
w.vm.form.setFieldValue(name, value);
|
||
}
|
||
|
||
describe("CreateDestinationForm", () => {
|
||
let wrapper: any;
|
||
let store: any;
|
||
|
||
function createWrapper(props: Record<string, any> = {}) {
|
||
store = createStore({
|
||
state: {
|
||
theme: "light",
|
||
selectedOrganization: { identifier: "test-org" },
|
||
},
|
||
});
|
||
|
||
return mount(CreateDestinationForm, {
|
||
props,
|
||
global: {
|
||
plugins: [store, i18n],
|
||
stubs: {
|
||
OIcon: false,
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
beforeEach(() => {
|
||
uuidCounter = 0;
|
||
vi.clearAllMocks();
|
||
wrapper = createWrapper();
|
||
});
|
||
|
||
afterEach(() => {
|
||
if (wrapper) {
|
||
wrapper.unmount();
|
||
}
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe("Destination Type Cards", () => {
|
||
// Rule ③ proof: selecting a destination-type CARD drives the conditional
|
||
// sections THROUGH THE FORM (form.setFieldValue → form.useStore), with NO
|
||
// `formData` mirror. Clicking the 'custom' card (on step 1) must (a) set the
|
||
// form value and (b) reveal the custom-only Method field on step 2 that is
|
||
// v-if'd off it.
|
||
it("clicking a card drives conditional sections through the form", async () => {
|
||
// Cards live on step 1 — click the custom card there.
|
||
const custom = wrapper.find('[data-test="destination-type-card-custom"]');
|
||
await custom.trigger("click");
|
||
await flushPromises();
|
||
|
||
// The form is the single source of truth for the selection …
|
||
expect(getFormField(wrapper, "destination_type")).toBe("custom");
|
||
|
||
// … and on step 2 the custom-only Method field now renders (driven off the
|
||
// form's destination_type via form.useStore, NOT a `formData` mirror).
|
||
wrapper.vm.step = 2;
|
||
await nextTick();
|
||
await flushPromises();
|
||
expect(wrapper.find('[data-test="add-destination-method-select"]').exists()).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe("Auto-prefill on destination type change", () => {
|
||
it("sets url_endpoint for Splunk on type change", async () => {
|
||
setFormField(wrapper, "destination_type", "splunk");
|
||
await nextTick();
|
||
await flushPromises();
|
||
expect(getFormField(wrapper, "url_endpoint")).toBe("/services/collector");
|
||
});
|
||
|
||
it("sets url_endpoint to empty for Custom on type change", async () => {
|
||
setFormField(wrapper, "destination_type", "custom");
|
||
await nextTick();
|
||
expect(getFormField(wrapper, "url_endpoint")).toBe("");
|
||
});
|
||
|
||
it("sets output_format to nestedevent for Splunk", async () => {
|
||
setFormField(wrapper, "destination_type", "splunk");
|
||
await nextTick();
|
||
await flushPromises();
|
||
expect(getFormField(wrapper, "output_format")).toBe("nestedevent");
|
||
});
|
||
|
||
it("sets output_format to esbulk for Elasticsearch and provides default index", async () => {
|
||
setFormField(wrapper, "destination_type", "elasticsearch");
|
||
await nextTick();
|
||
await flushPromises();
|
||
expect(getFormField(wrapper, "output_format")).toBe("esbulk");
|
||
expect(getFormField(wrapper, "esbulk_index")).toBe("default");
|
||
});
|
||
|
||
it("sets method to 'post' when switching from custom to a non-custom type", async () => {
|
||
setFormField(wrapper, "destination_type", "custom");
|
||
setFormField(wrapper, "method", "get");
|
||
await nextTick();
|
||
|
||
setFormField(wrapper, "destination_type", "splunk");
|
||
await nextTick();
|
||
await flushPromises();
|
||
expect(getFormField(wrapper, "method")).toBe("post");
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe("Default Headers per Destination Type", () => {
|
||
it.each([
|
||
["openobserve", 1, [{ key: "Authorization", value: "Basic <token>" }]],
|
||
["splunk", 1, [{ key: "Authorization", value: "Splunk <splunk_token>" }]],
|
||
[
|
||
"elasticsearch",
|
||
2,
|
||
[
|
||
{ key: "Authorization", value: "ApiKey <token>" },
|
||
{ key: "Content-Type", value: "application/json" },
|
||
],
|
||
],
|
||
[
|
||
"datadog",
|
||
3,
|
||
[
|
||
{ key: "DD-API-KEY", value: "<token>" },
|
||
{ key: "Content-Encoding", value: "gzip" },
|
||
{ key: "Content-Type", value: "application/json" },
|
||
],
|
||
],
|
||
[
|
||
"dynatrace",
|
||
2,
|
||
[
|
||
{ key: "Authorization", value: "Api-Token <token>" },
|
||
{ key: "Content-Type", value: "application/json; charset=utf-8" },
|
||
],
|
||
],
|
||
[
|
||
"newrelic",
|
||
2,
|
||
[
|
||
{ key: "Api-Key", value: "<token>" },
|
||
{ key: "Content-Type", value: "application/json" },
|
||
],
|
||
],
|
||
["custom", 1, [{ key: "", value: "" }]],
|
||
])(
|
||
"sets correct default headers for %s (%i header(s))",
|
||
async (type, count, expectedHeaders) => {
|
||
setFormField(wrapper, "destination_type", type);
|
||
await nextTick();
|
||
|
||
expect(wrapper.vm.apiHeaders).toHaveLength(count);
|
||
expectedHeaders.forEach(({ key, value }: { key: string; value: string }, i: number) => {
|
||
expect(wrapper.vm.apiHeaders[i].key).toBe(key);
|
||
expect(wrapper.vm.apiHeaders[i].value).toBe(value);
|
||
});
|
||
},
|
||
);
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe("Header Management", () => {
|
||
it("deleteApiHeader adds an empty header row when all headers are deleted", () => {
|
||
// Delete each existing row by index 0; the handler backfills one blank row.
|
||
const before = wrapper.vm.apiHeaders.length;
|
||
for (let i = 0; i < before; i++) wrapper.vm.deleteApiHeader(0);
|
||
|
||
expect(wrapper.vm.apiHeaders).toHaveLength(1);
|
||
expect(wrapper.vm.apiHeaders[0].key).toBe("");
|
||
expect(wrapper.vm.apiHeaders[0].value).toBe("");
|
||
});
|
||
|
||
// ── Field-array :key guard (playbook §2 / START-HERE Rule ①) ─────────────
|
||
// Deleting a NON-LAST row must leave the RENDERED inputs — not just the form
|
||
// data — aligned to the surviving rows in order. The header rows bind by
|
||
// index-based name (`headers[i].key`) and TanStack resolves that name at
|
||
// field CREATION, so the v-for :key MUST be the array index. A uuid/stable-id
|
||
// :key would make Vue reuse+reorder the row components on a mid-list delete,
|
||
// leaving each surviving input bound to its OLD index → inputs render
|
||
// shifted/blank while `form.state.values` stays correct (a data-only
|
||
// assertion passes while the UI is broken). So assert the rendered OInput
|
||
// model-values, selected by field `name` (NOT by data-test, which is derived
|
||
// from the row value and would itself mask a stale binding).
|
||
it("keeps rendered header inputs aligned after deleting a NON-last row", async () => {
|
||
const renderedByName = (suffix: "key" | "value") => {
|
||
const re = new RegExp(`^headers\\[\\d+\\]\\.${suffix}$`);
|
||
return wrapper
|
||
.findAllComponents(OInput)
|
||
.filter((c: any) => re.test(c.props("name")))
|
||
.map((c: any) => c.props("modelValue"));
|
||
};
|
||
|
||
// Headers live on step 2 of the stepper — navigate there so the row inputs
|
||
// actually render into the DOM.
|
||
wrapper.vm.step = 2;
|
||
await nextTick();
|
||
await flushPromises();
|
||
|
||
// Seed a deterministic 3-row set through the real form.
|
||
wrapper.vm.form.setFieldValue("headers", [
|
||
{ key: "H0", value: "V0" },
|
||
{ key: "H1", value: "V1" },
|
||
{ key: "H2", value: "V2" },
|
||
]);
|
||
await nextTick();
|
||
await flushPromises();
|
||
|
||
expect(renderedByName("key")).toEqual(["H0", "H1", "H2"]);
|
||
expect(renderedByName("value")).toEqual(["V0", "V1", "V2"]);
|
||
|
||
// Delete the MIDDLE row (index 1) via the real handler (removeFieldValue).
|
||
wrapper.vm.deleteApiHeader(1);
|
||
await nextTick();
|
||
await flushPromises();
|
||
|
||
// The RENDERED inputs must show the two surviving rows in order …
|
||
expect(renderedByName("key")).toEqual(["H0", "H2"]);
|
||
expect(renderedByName("value")).toEqual(["V0", "V2"]);
|
||
// … and the form data agrees (index-based names re-bound correctly).
|
||
expect(wrapper.vm.apiHeaders.map((h: any) => h.key)).toEqual(["H0", "H2"]);
|
||
expect(wrapper.vm.apiHeaders.map((h: any) => h.value)).toEqual(["V0", "V2"]);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe("OpenObserve Org/Stream Dynamic Endpoint", () => {
|
||
// org/stream are form-owned now (name="org"/"stream") — set them on the real
|
||
// form; the endpoint-sync watch rebuilds url_endpoint off the form values.
|
||
|
||
it("builds correct endpoint from org and stream", async () => {
|
||
setFormField(wrapper, "destination_type", "openobserve");
|
||
setFormField(wrapper, "org", "acme");
|
||
setFormField(wrapper, "stream", "events");
|
||
await nextTick();
|
||
expect(getFormField(wrapper, "url_endpoint")).toBe("/api/acme/events/_json");
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe("Step Navigation", () => {
|
||
it("nextStep does not advance if destination_type is not selected", async () => {
|
||
setFormField(wrapper, "destination_type", "");
|
||
wrapper.vm.step = 1;
|
||
wrapper.vm.nextStep();
|
||
await nextTick();
|
||
expect(wrapper.vm.step).toBe(1);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe("forcedType (skip step 1, locked type, no going back)", () => {
|
||
it("opens directly on step 2, locked to the forced type", () => {
|
||
const w = createWrapper({ forcedType: "custom" });
|
||
expect(w.vm.step).toBe(2);
|
||
expect(w.vm.destinationType).toBe("custom");
|
||
w.unmount();
|
||
});
|
||
|
||
it("seeds the create defaults for the forced type", () => {
|
||
const w = createWrapper({ forcedType: "custom" });
|
||
const v = w.vm.form.state.values;
|
||
expect(v.destination_type).toBe("custom");
|
||
expect(v.method).toBe("post");
|
||
expect(v.output_format).toBe("json");
|
||
expect(v.url_endpoint).toBe("");
|
||
expect(v.headers).toEqual([{ key: "", value: "" }]);
|
||
w.unmount();
|
||
});
|
||
|
||
it("hides the Back button (there's no step 1 to return to)", () => {
|
||
const w = createWrapper({ forcedType: "custom" });
|
||
expect(w.find('[data-test="step3-back-btn"]').exists()).toBe(false);
|
||
w.unmount();
|
||
});
|
||
|
||
it("hides the stepper header (single-step flow)", () => {
|
||
const forced = createWrapper({ forcedType: "custom" });
|
||
expect(forced.findComponent({ name: "OStepper" }).props("hideHeader")).toBe(true);
|
||
forced.unmount();
|
||
// ...and the default (no forcedType) keeps the header.
|
||
expect(wrapper.findComponent({ name: "OStepper" }).props("hideHeader")).toBe(false);
|
||
});
|
||
|
||
it("resetForm keeps it on step 2 when the type is forced", async () => {
|
||
const w = createWrapper({ forcedType: "custom" });
|
||
w.vm.resetForm();
|
||
await nextTick();
|
||
expect(w.vm.step).toBe(2);
|
||
expect(w.vm.form.state.values.destination_type).toBe("custom");
|
||
w.unmount();
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe("canProceedStep2 Validation", () => {
|
||
it("is false when name is missing", async () => {
|
||
setFormField(wrapper, "name", "");
|
||
setFormField(wrapper, "url", "https://example.com");
|
||
await nextTick();
|
||
expect(wrapper.vm.canProceedStep2).toBeFalsy();
|
||
});
|
||
|
||
it("is false when URL is missing", async () => {
|
||
setFormField(wrapper, "name", "test");
|
||
setFormField(wrapper, "url", "");
|
||
await nextTick();
|
||
expect(wrapper.vm.canProceedStep2).toBeFalsy();
|
||
});
|
||
|
||
it("is false for non-custom when url_endpoint is empty", async () => {
|
||
setFormField(wrapper, "destination_type", "openobserve");
|
||
setFormField(wrapper, "name", "test");
|
||
setFormField(wrapper, "url", "https://example.com");
|
||
setFormField(wrapper, "url_endpoint", "");
|
||
setFormField(wrapper, "method", "post");
|
||
setFormField(wrapper, "output_format", "json");
|
||
await nextTick();
|
||
expect(wrapper.vm.canProceedStep2).toBe(false);
|
||
});
|
||
|
||
it("is false for stringseparated when separator is empty", async () => {
|
||
setFormField(wrapper, "destination_type", "custom");
|
||
setFormField(wrapper, "name", "test");
|
||
setFormField(wrapper, "url", "https://example.com");
|
||
setFormField(wrapper, "method", "post");
|
||
setFormField(wrapper, "output_format", "stringseparated");
|
||
setFormField(wrapper, "separator", "");
|
||
await nextTick();
|
||
expect(wrapper.vm.canProceedStep2).toBe(false);
|
||
});
|
||
|
||
it("is true for stringseparated when separator is provided", async () => {
|
||
setFormField(wrapper, "destination_type", "custom");
|
||
setFormField(wrapper, "name", "test");
|
||
setFormField(wrapper, "url", "https://example.com");
|
||
setFormField(wrapper, "method", "post");
|
||
setFormField(wrapper, "output_format", "stringseparated");
|
||
setFormField(wrapper, "separator", "|");
|
||
await nextTick();
|
||
expect(wrapper.vm.canProceedStep2).toBe(true);
|
||
});
|
||
|
||
it("allows single space as a valid separator", async () => {
|
||
setFormField(wrapper, "destination_type", "custom");
|
||
setFormField(wrapper, "name", "test");
|
||
setFormField(wrapper, "url", "https://example.com");
|
||
setFormField(wrapper, "method", "post");
|
||
setFormField(wrapper, "output_format", "stringseparated");
|
||
setFormField(wrapper, "separator", " ");
|
||
await nextTick();
|
||
expect(wrapper.vm.canProceedStep2).toBe(true);
|
||
});
|
||
|
||
// metadata is FORM-OWNED (name="metadata.*"), so canProceedStep2 must read it
|
||
// from the form store, not from formData.metadata (which the inputs no longer
|
||
// write to). Set metadata via the real form.
|
||
it("is true for splunk when all metadata fields are provided", async () => {
|
||
setFormField(wrapper, "destination_type", "splunk");
|
||
setFormField(wrapper, "name", "test");
|
||
setFormField(wrapper, "url", "https://splunk.example.com");
|
||
setFormField(wrapper, "url_endpoint", "/services/collector");
|
||
setFormField(wrapper, "method", "post");
|
||
setFormField(wrapper, "output_format", "nestedevent");
|
||
setFormField(wrapper, "metadata", {
|
||
source: "my_source",
|
||
sourcetype: "_json",
|
||
hostname: "server01",
|
||
});
|
||
await nextTick();
|
||
expect(wrapper.vm.canProceedStep2).toBe(true);
|
||
});
|
||
|
||
it("is false for splunk when metadata is incomplete", async () => {
|
||
setFormField(wrapper, "destination_type", "splunk");
|
||
setFormField(wrapper, "name", "test");
|
||
setFormField(wrapper, "url", "https://splunk.example.com");
|
||
setFormField(wrapper, "url_endpoint", "/services/collector");
|
||
setFormField(wrapper, "method", "post");
|
||
setFormField(wrapper, "output_format", "nestedevent");
|
||
// Only source provided — sourcetype + hostname missing.
|
||
setFormField(wrapper, "metadata", { source: "my_source" });
|
||
await nextTick();
|
||
expect(wrapper.vm.canProceedStep2).toBe(false);
|
||
});
|
||
|
||
it("is true for datadog when ddsource + ddtags are provided", async () => {
|
||
setFormField(wrapper, "destination_type", "datadog");
|
||
setFormField(wrapper, "name", "test");
|
||
setFormField(wrapper, "url", "https://http-intake.logs.datadoghq.com");
|
||
setFormField(wrapper, "url_endpoint", "/v1/input");
|
||
setFormField(wrapper, "method", "post");
|
||
setFormField(wrapper, "output_format", "json");
|
||
setFormField(wrapper, "metadata", {
|
||
ddsource: "nginx",
|
||
ddtags: "env:prod",
|
||
});
|
||
await nextTick();
|
||
expect(wrapper.vm.canProceedStep2).toBe(true);
|
||
});
|
||
|
||
it("is false for datadog when dd fields are missing", async () => {
|
||
setFormField(wrapper, "destination_type", "datadog");
|
||
setFormField(wrapper, "name", "test");
|
||
setFormField(wrapper, "url", "https://http-intake.logs.datadoghq.com");
|
||
setFormField(wrapper, "url_endpoint", "/v1/input");
|
||
setFormField(wrapper, "method", "post");
|
||
setFormField(wrapper, "output_format", "json");
|
||
setFormField(wrapper, "metadata", {});
|
||
await nextTick();
|
||
expect(wrapper.vm.canProceedStep2).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe("Template Rendering at Step 2", () => {
|
||
beforeEach(async () => {
|
||
setFormField(wrapper, "destination_type", "openobserve");
|
||
wrapper.vm.step = 2;
|
||
await nextTick();
|
||
await flushPromises();
|
||
});
|
||
|
||
it("does NOT render Method field for openobserve type", () => {
|
||
expect(wrapper.find('[data-test="add-destination-method-select"]').exists()).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe("Custom Type Specific Fields", () => {
|
||
beforeEach(async () => {
|
||
setFormField(wrapper, "destination_type", "custom");
|
||
wrapper.vm.step = 2;
|
||
await nextTick();
|
||
await flushPromises();
|
||
});
|
||
|
||
it("renders Method select for custom destination type", () => {
|
||
expect(wrapper.find('[data-test="add-destination-method-select"]').exists()).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe("StringSeparated Output Format", () => {
|
||
it("shows separator field when stringseparated format is selected", async () => {
|
||
setFormField(wrapper, "destination_type", "custom");
|
||
setFormField(wrapper, "output_format", "stringseparated");
|
||
wrapper.vm.step = 2;
|
||
await nextTick();
|
||
await flushPromises();
|
||
|
||
expect(wrapper.find('[data-test="add-destination-separator-input"]').exists()).toBe(true);
|
||
});
|
||
|
||
it("hides separator field for other output formats", async () => {
|
||
setFormField(wrapper, "destination_type", "custom");
|
||
setFormField(wrapper, "output_format", "json");
|
||
wrapper.vm.step = 2;
|
||
await nextTick();
|
||
await flushPromises();
|
||
|
||
expect(wrapper.find('[data-test="add-destination-separator-input"]').exists()).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe("ESBulk Index Field", () => {
|
||
it("shows esbulk index field when output format is esbulk", async () => {
|
||
setFormField(wrapper, "destination_type", "custom");
|
||
setFormField(wrapper, "output_format", "esbulk");
|
||
wrapper.vm.step = 2;
|
||
await nextTick();
|
||
await flushPromises();
|
||
|
||
expect(wrapper.find('[data-test="add-destination-esbulk-index-input"]').exists()).toBe(true);
|
||
});
|
||
|
||
it("hides esbulk index field for non-esbulk formats", async () => {
|
||
setFormField(wrapper, "output_format", "json");
|
||
wrapper.vm.step = 2;
|
||
await nextTick();
|
||
await flushPromises();
|
||
|
||
expect(wrapper.find('[data-test="add-destination-esbulk-index-input"]').exists()).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe("Form Submission", () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it("merges URL and endpoint and calls destinationService.create", async () => {
|
||
(destinationService.create as any).mockResolvedValue({ data: {} });
|
||
|
||
// Every field is form-owned now — set the whole record on the real form
|
||
// (single source of truth, no `formData` mirror).
|
||
setFormField(wrapper, "destination_type", "openobserve");
|
||
setFormField(wrapper, "name", "Test Destination");
|
||
setFormField(wrapper, "url", "https://example.com");
|
||
setFormField(wrapper, "url_endpoint", "/api/test");
|
||
setFormField(wrapper, "method", "post");
|
||
setFormField(wrapper, "output_format", "json");
|
||
setFormField(wrapper, "headers", [{ key: "Authorization", value: "Basic token123" }]);
|
||
|
||
await wrapper.vm.createDestination();
|
||
await flushPromises();
|
||
|
||
expect(destinationService.create).toHaveBeenCalledWith({
|
||
org_identifier: "test-org",
|
||
destination_name: "Test Destination",
|
||
module: "pipeline",
|
||
data: expect.objectContaining({
|
||
name: "Test Destination",
|
||
url: "https://example.com/api/test",
|
||
method: "post",
|
||
output_format: "json",
|
||
type: "http",
|
||
destination_type_name: "openobserve",
|
||
headers: { Authorization: "Basic token123" },
|
||
}),
|
||
});
|
||
});
|
||
|
||
it("does not append empty endpoint to base URL for custom type", async () => {
|
||
(destinationService.create as any).mockResolvedValue({ data: {} });
|
||
|
||
setFormField(wrapper, "destination_type", "custom");
|
||
setFormField(wrapper, "name", "Custom Dest");
|
||
setFormField(wrapper, "url", "https://custom.example.com");
|
||
setFormField(wrapper, "url_endpoint", "");
|
||
setFormField(wrapper, "method", "post");
|
||
setFormField(wrapper, "output_format", "json");
|
||
setFormField(wrapper, "headers", [{ key: "X-Custom", value: "value" }]);
|
||
|
||
await wrapper.vm.createDestination();
|
||
await flushPromises();
|
||
|
||
expect(destinationService.create).toHaveBeenCalledWith({
|
||
org_identifier: "test-org",
|
||
destination_name: "Custom Dest",
|
||
module: "pipeline",
|
||
data: expect.objectContaining({
|
||
url: "https://custom.example.com",
|
||
}),
|
||
});
|
||
});
|
||
|
||
it("emits 'created' event with destination name on success", async () => {
|
||
(destinationService.create as any).mockResolvedValue({ data: {} });
|
||
|
||
setFormField(wrapper, "name", "My Dest");
|
||
setFormField(wrapper, "url", "https://example.com");
|
||
setFormField(wrapper, "url_endpoint", "/api/test");
|
||
setFormField(wrapper, "method", "post");
|
||
|
||
await wrapper.vm.createDestination();
|
||
await flushPromises();
|
||
|
||
expect(wrapper.emitted("created")).toBeTruthy();
|
||
expect(wrapper.emitted("created")![0]).toEqual(["My Dest"]);
|
||
});
|
||
|
||
it.each(["create", "update"] as const)(
|
||
"closes its loading toast when a %s is refused with a 403",
|
||
async (op) => {
|
||
if (op === "update") {
|
||
wrapper.unmount();
|
||
wrapper = createWrapper({
|
||
destination: {
|
||
name: "My Dest",
|
||
url: "https://example.com",
|
||
method: "post",
|
||
type: "http",
|
||
},
|
||
});
|
||
await flushPromises();
|
||
}
|
||
const dismissLoading = vi.fn();
|
||
vi.mocked(toast).mockImplementationOnce(() => dismissLoading);
|
||
(destinationService[op] as any).mockRejectedValueOnce({ response: { status: 403 } });
|
||
setFormField(wrapper, "name", "My Dest");
|
||
setFormField(wrapper, "url", "https://example.com");
|
||
setFormField(wrapper, "method", "post");
|
||
|
||
await wrapper.vm.createDestination();
|
||
await flushPromises();
|
||
|
||
expect(destinationService[op]).toHaveBeenCalledTimes(1);
|
||
expect(dismissLoading).toHaveBeenCalledTimes(1);
|
||
expect(vi.mocked(toast).mock.calls.map((c) => c[0].variant)).toEqual(["loading"]);
|
||
},
|
||
);
|
||
|
||
it("does not call destinationService.create when name is empty", async () => {
|
||
setFormField(wrapper, "name", "");
|
||
setFormField(wrapper, "url", "");
|
||
|
||
await wrapper.vm.createDestination();
|
||
|
||
expect(destinationService.create).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe("Form Reset", () => {
|
||
it("resetForm resets all formData fields to defaults", () => {
|
||
setFormField(wrapper, "name", "Changed");
|
||
setFormField(wrapper, "url", "https://changed.com");
|
||
setFormField(wrapper, "url_endpoint", "/changed");
|
||
setFormField(wrapper, "destination_type", "splunk");
|
||
wrapper.vm.step = 2;
|
||
|
||
wrapper.vm.resetForm();
|
||
|
||
expect(getFormField(wrapper, "name")).toBe("");
|
||
expect(getFormField(wrapper, "url")).toBe("");
|
||
// Reset returns the form to its create-mode defaults (the OpenObserve
|
||
// default endpoint) — the single source of truth, no mirror to diverge.
|
||
expect(getFormField(wrapper, "url_endpoint")).toBe("/api/default/default/_json");
|
||
expect(getFormField(wrapper, "destination_type")).toBe("openobserve");
|
||
expect(wrapper.vm.step).toBe(1);
|
||
});
|
||
|
||
it("resetForm resets apiHeaders to OpenObserve defaults", () => {
|
||
// headers are form-owned — set them on the real form, not a mirror.
|
||
setFormField(wrapper, "headers", [{ key: "X-Custom", value: "Val" }]);
|
||
|
||
wrapper.vm.resetForm();
|
||
|
||
expect(wrapper.vm.apiHeaders).toHaveLength(1);
|
||
expect(wrapper.vm.apiHeaders[0].key).toBe("Authorization");
|
||
expect(wrapper.vm.apiHeaders[0].value).toBe("Basic <token>");
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe("Edit Mode – populateFormForEdit", () => {
|
||
it("splits URL into base and endpoint for non-custom types", () => {
|
||
wrapper.vm.populateFormForEdit({
|
||
name: "Edit Dest",
|
||
url: "https://example.com/api/test",
|
||
method: "post",
|
||
output_format: "json",
|
||
destination_type_name: "openobserve",
|
||
skip_tls_verify: false,
|
||
});
|
||
|
||
expect(getFormField(wrapper, "url")).toBe("https://example.com");
|
||
expect(getFormField(wrapper, "url_endpoint")).toBe("/api/test");
|
||
});
|
||
|
||
it("handles URL with query parameters during split", () => {
|
||
wrapper.vm.populateFormForEdit({
|
||
name: "Splunk Dest",
|
||
url: "https://splunk.host.com/services/collector?key=val",
|
||
method: "post",
|
||
output_format: "nestedevent",
|
||
destination_type_name: "splunk",
|
||
skip_tls_verify: false,
|
||
});
|
||
|
||
expect(getFormField(wrapper, "url")).toBe("https://splunk.host.com");
|
||
expect(getFormField(wrapper, "url_endpoint")).toBe("/services/collector?key=val");
|
||
});
|
||
|
||
it("handles URL without path (sets url_endpoint to empty)", () => {
|
||
wrapper.vm.populateFormForEdit({
|
||
name: "No Path",
|
||
url: "https://example.com",
|
||
method: "post",
|
||
output_format: "json",
|
||
destination_type_name: "openobserve",
|
||
skip_tls_verify: false,
|
||
});
|
||
|
||
expect(getFormField(wrapper, "url")).toBe("https://example.com");
|
||
expect(getFormField(wrapper, "url_endpoint")).toBe("");
|
||
});
|
||
|
||
it("does not split URL for custom type", () => {
|
||
wrapper.vm.populateFormForEdit({
|
||
name: "Custom Dest",
|
||
url: "https://example.com/api/custom/endpoint",
|
||
method: "post",
|
||
output_format: "json",
|
||
destination_type_name: "custom",
|
||
skip_tls_verify: false,
|
||
});
|
||
|
||
expect(getFormField(wrapper, "url")).toBe("https://example.com/api/custom/endpoint");
|
||
expect(getFormField(wrapper, "url_endpoint")).toBe("");
|
||
});
|
||
|
||
it("falls back to 'openobserve' when destination_type_name is absent", () => {
|
||
wrapper.vm.populateFormForEdit({
|
||
name: "Unknown",
|
||
url: "https://example.com/api",
|
||
method: "post",
|
||
output_format: "json",
|
||
skip_tls_verify: false,
|
||
});
|
||
|
||
expect(getFormField(wrapper, "destination_type")).toBe("openobserve");
|
||
});
|
||
|
||
it("populates headers from existing destination headers", () => {
|
||
wrapper.vm.populateFormForEdit({
|
||
name: "Dest with headers",
|
||
url: "https://example.com/api",
|
||
method: "post",
|
||
output_format: "json",
|
||
destination_type_name: "custom",
|
||
skip_tls_verify: false,
|
||
headers: {
|
||
"X-Custom-Header": "value1",
|
||
Authorization: "Bearer token",
|
||
},
|
||
});
|
||
|
||
expect(wrapper.vm.apiHeaders).toHaveLength(2);
|
||
expect(
|
||
wrapper.vm.apiHeaders.some((h: any) => h.key === "X-Custom-Header" && h.value === "value1"),
|
||
).toBe(true);
|
||
expect(
|
||
wrapper.vm.apiHeaders.some(
|
||
(h: any) => h.key === "Authorization" && h.value === "Bearer token",
|
||
),
|
||
).toBe(true);
|
||
});
|
||
|
||
it("parses openobserveOrg and openobserveStream from openobserve endpoint", () => {
|
||
wrapper.vm.populateFormForEdit({
|
||
name: "OO Dest",
|
||
url: "https://cloud.openobserve.ai/api/my-org/my-stream/_json",
|
||
method: "post",
|
||
output_format: "json",
|
||
destination_type_name: "openobserve",
|
||
skip_tls_verify: false,
|
||
});
|
||
|
||
expect(wrapper.vm.openobserveOrg).toBe("my-org");
|
||
expect(wrapper.vm.openobserveStream).toBe("my-stream");
|
||
});
|
||
|
||
it("handles URL without protocol for non-custom types", () => {
|
||
wrapper.vm.populateFormForEdit({
|
||
name: "No Protocol",
|
||
url: "splunk.test.com/services/collector",
|
||
method: "post",
|
||
output_format: "nestedevent",
|
||
destination_type_name: "splunk",
|
||
skip_tls_verify: false,
|
||
});
|
||
|
||
expect(getFormField(wrapper, "url")).toBe("https://splunk.test.com");
|
||
expect(getFormField(wrapper, "url_endpoint")).toBe("/services/collector");
|
||
});
|
||
|
||
it("advances to step 2 when populating edit form", () => {
|
||
wrapper.vm.populateFormForEdit({
|
||
name: "Edit Dest",
|
||
url: "https://example.com/api",
|
||
method: "post",
|
||
output_format: "json",
|
||
destination_type_name: "openobserve",
|
||
skip_tls_verify: false,
|
||
});
|
||
|
||
expect(wrapper.vm.step).toBe(2);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe("Edit Mode via Prop", () => {
|
||
it("populates form when destination prop is provided", async () => {
|
||
const editWrapper = createWrapper({
|
||
destination: {
|
||
name: "Prop Dest",
|
||
url: "https://prop.example.com/api/endpoint",
|
||
method: "post",
|
||
output_format: "json",
|
||
destination_type_name: "splunk",
|
||
skip_tls_verify: true,
|
||
headers: {},
|
||
},
|
||
});
|
||
await flushPromises();
|
||
|
||
expect(getFormField(editWrapper, "name")).toBe("Prop Dest");
|
||
expect(getFormField(editWrapper, "skip_tls_verify")).toBe(true);
|
||
editWrapper.unmount();
|
||
});
|
||
|
||
// Payload parity (Rule ④b): editing a destination that carries a `template`
|
||
// must round-trip it unchanged — the pre-migration form sent
|
||
// `destination.template || ""`, so a template must NOT be silently wiped to
|
||
// "" on save. (The pipeline form has no template input; it's a pass-through.)
|
||
it("round-trips the destination's template unchanged on update (no wipe)", async () => {
|
||
(destinationService.update as any).mockResolvedValue({ data: {} });
|
||
const editWrapper = createWrapper({
|
||
destination: {
|
||
name: "tmpl-dest",
|
||
url: "https://prop.example.com/api/endpoint",
|
||
method: "post",
|
||
output_format: "json",
|
||
destination_type_name: "splunk",
|
||
skip_tls_verify: true,
|
||
headers: {},
|
||
template: "my_alert_template",
|
||
},
|
||
});
|
||
await flushPromises();
|
||
editWrapper.vm.step = 2;
|
||
await nextTick();
|
||
|
||
await editWrapper.vm.form.handleSubmit();
|
||
await flushPromises();
|
||
|
||
expect(editWrapper.vm.form.state.isValid).toBe(true);
|
||
expect(destinationService.update).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
destination_name: "tmpl-dest",
|
||
module: "pipeline",
|
||
data: expect.objectContaining({ template: "my_alert_template" }),
|
||
}),
|
||
);
|
||
editWrapper.unmount();
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Real-OForm validation wiring (per the playbook §5 — at least one test MUST
|
||
// mount the real OForm and prove the schema actually gates an empty submit, so
|
||
// an unwired `:schema` would be caught).
|
||
describe("OForm schema validation (real form)", () => {
|
||
const submitForm = async (w: any) => {
|
||
// Drive the form's own submit so the schema runs + the handler is awaited
|
||
// deterministically (a fire-and-forget native submit wouldn't be).
|
||
await w.vm.form.handleSubmit();
|
||
await flushPromises();
|
||
};
|
||
|
||
it("blocks submit and does NOT call the service when required fields are empty", async () => {
|
||
(destinationService.create as any).mockResolvedValue({ data: {} });
|
||
// openobserve default; name/url left blank → schema must fail.
|
||
wrapper.vm.step = 2;
|
||
await nextTick();
|
||
|
||
await submitForm(wrapper);
|
||
|
||
expect(wrapper.vm.form.state.isValid).toBe(false);
|
||
expect(destinationService.create).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("submits and calls the service when the schema passes", async () => {
|
||
(destinationService.create as any).mockResolvedValue({ data: {} });
|
||
setFormField(wrapper, "destination_type", "openobserve");
|
||
wrapper.vm.step = 2;
|
||
// url_endpoint/org/stream are auto-prefilled for openobserve; provide the
|
||
// user-typed name + url so the whole schema (incl. superRefine) passes.
|
||
setFormField(wrapper, "name", "valid-dest");
|
||
setFormField(wrapper, "url", "https://example.com");
|
||
await nextTick();
|
||
await flushPromises();
|
||
|
||
await submitForm(wrapper);
|
||
|
||
expect(wrapper.vm.form.state.isValid).toBe(true);
|
||
expect(destinationService.create).toHaveBeenCalledTimes(1);
|
||
expect(destinationService.create).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
destination_name: "valid-dest",
|
||
data: expect.objectContaining({
|
||
name: "valid-dest",
|
||
destination_type_name: "openobserve",
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("rejects a name with invalid resource characters", async () => {
|
||
(destinationService.create as any).mockResolvedValue({ data: {} });
|
||
setFormField(wrapper, "destination_type", "openobserve");
|
||
wrapper.vm.step = 2;
|
||
setFormField(wrapper, "name", "bad name?");
|
||
setFormField(wrapper, "url", "https://example.com");
|
||
await nextTick();
|
||
await flushPromises();
|
||
|
||
await submitForm(wrapper);
|
||
|
||
expect(wrapper.vm.form.state.isValid).toBe(false);
|
||
expect(destinationService.create).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("rejects a url that ends with a trailing slash", async () => {
|
||
(destinationService.create as any).mockResolvedValue({ data: {} });
|
||
setFormField(wrapper, "destination_type", "openobserve");
|
||
wrapper.vm.step = 2;
|
||
setFormField(wrapper, "name", "valid-dest");
|
||
setFormField(wrapper, "url", "https://example.com/");
|
||
await nextTick();
|
||
await flushPromises();
|
||
|
||
await submitForm(wrapper);
|
||
|
||
expect(wrapper.vm.form.state.isValid).toBe(false);
|
||
expect(destinationService.create).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
});
|