// @vitest-environment happy-dom import { createApp, nextTick, type App } from "vue"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ connection: { id: "structure-test", name: "Dameng", db_type: "dameng", driver_label: "Dameng", }, ensureConnected: vi.fn(), listDataTypes: vi.fn(), buildTableStructureChangeSql: vi.fn(), updateEditorSettings: vi.fn(), loadObjectDdl: vi.fn(), invalidateObjectDdl: vi.fn(), loadObjectMetadataFacet: vi.fn(), invalidateTableMetadataCache: vi.fn(), toast: vi.fn(), })); vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) })); vi.mock("@lucide/vue", async () => { const { defineComponent, h } = await import("vue"); const Icon = defineComponent({ name: "Icon", setup: () => () => h("span") }); return { AlertTriangle: Icon, Check: Icon, ChevronDown: Icon, ChevronUp: Icon, Copy: Icon, Database: Icon, Info: Icon, KeyRound: Icon, ListChevronsUpDown: Icon, Loader2: Icon, Maximize2: Icon, Plus: Icon, RefreshCw: Icon, Save: Icon, Search: Icon, Settings: Icon, SlidersHorizontal: Icon, Trash2: Icon, X: Icon, }; }); vi.mock("@/components/ui/button", async () => { const { defineComponent, h } = await import("vue"); return { Button: defineComponent({ name: "Button", inheritAttrs: false, setup: (_props, { attrs, slots }) => () => h("button", attrs, slots.default?.()), }), }; }); vi.mock("@/components/ui/input", async () => { const { defineComponent, h } = await import("vue"); return { Input: defineComponent({ name: "Input", inheritAttrs: false, setup: (_props, { attrs }) => () => h("input", attrs), }), }; }); vi.mock("@/components/ui/badge", async () => { const { defineComponent, h } = await import("vue"); return { Badge: defineComponent({ name: "Badge", inheritAttrs: false, setup: (_props, { attrs, slots }) => () => h("span", attrs, slots.default?.()), }), }; }); vi.mock("@/components/ui/tabs", async () => { const { defineComponent, h } = await import("vue"); const Div = defineComponent({ inheritAttrs: false, setup: (_props, { attrs, slots }) => () => h("div", attrs, slots.default?.()), }); const Button = defineComponent({ inheritAttrs: false, setup: (_props, { attrs, slots }) => () => h("button", attrs, slots.default?.()), }); return { Tabs: Div, TabsContent: Div, TabsList: Div, TabsTrigger: Button }; }); vi.mock("@/components/ui/dropdown-menu", async () => { const { defineComponent, h } = await import("vue"); const Div = defineComponent({ inheritAttrs: false, setup: (_props, { attrs, slots }) => () => h("div", attrs, slots.default?.()), }); const Button = defineComponent({ inheritAttrs: false, setup: (_props, { attrs, slots }) => () => h("button", attrs, slots.default?.()), }); return { DropdownMenu: Div, DropdownMenuCheckboxItem: Div, DropdownMenuContent: Div, DropdownMenuItem: Button, DropdownMenuTrigger: Div }; }); vi.mock("@/components/ui/popover", async () => { const { defineComponent, h } = await import("vue"); const Div = defineComponent({ inheritAttrs: false, setup: (_props, { attrs, slots }) => () => h("div", attrs, slots.default?.()), }); return { Popover: Div, PopoverContent: Div, PopoverTrigger: Div }; }); vi.mock("@/components/ui/tooltip", async () => { const { defineComponent, h } = await import("vue"); const Div = defineComponent({ inheritAttrs: false, setup: (_props, { attrs, slots }) => () => h("div", attrs, slots.default?.()), }); return { Tooltip: Div, TooltipContent: Div, TooltipTrigger: Div }; }); vi.mock("@/components/ui/searchable-select", async () => { const { defineComponent, h } = await import("vue"); return { SearchableSelect: defineComponent({ name: "SearchableSelect", inheritAttrs: false, setup: (_props, { attrs }) => () => h("div", attrs), }), }; }); vi.mock("@/components/ui/select", async () => { const { defineComponent, h } = await import("vue"); const Div = defineComponent({ inheritAttrs: false, setup: (_props, { attrs, slots }) => () => h("div", attrs, slots.default?.()), }); return { Select: Div, SelectContent: Div, SelectItem: Div, SelectTrigger: Div, SelectValue: Div }; }); vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ ensureConnected: mocks.ensureConnected, getConfig: (connectionId: string) => (connectionId === mocks.connection.id ? mocks.connection : undefined), }), })); vi.mock("@/stores/productionSafetyStore", () => ({ useProductionSafetyStore: () => ({ requestConfirmation: vi.fn() }) })); vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ tableStructureRefreshVersion: () => 0 }) })); vi.mock("@/stores/historyStore", () => ({ useHistoryStore: () => ({ add: vi.fn() }) })); vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => ({ editorSettings: { structureEditorDensity: "compact", sqlFormatter: {}, tableColumnTemplateFields: [] }, updateEditorSettings: mocks.updateEditorSettings, }), })); vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false } }) })); vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) })); vi.mock("@/lib/sql/sqlHighlighter", () => ({ createShikiSqlHighlighter: vi.fn(async () => (sql: string) => sql) })); vi.mock("@/lib/metadata/objectDdlCache", () => ({ loadObjectDdl: mocks.loadObjectDdl, invalidateObjectDdl: mocks.invalidateObjectDdl, })); vi.mock("@/lib/metadata/objectMetadataCache", () => ({ loadObjectMetadataFacet: mocks.loadObjectMetadataFacet })); vi.mock("@/lib/metadata/tableMetadataCache", () => ({ invalidateTableMetadataCache: mocks.invalidateTableMetadataCache })); vi.mock("@/lib/backend/api", () => ({ listDataTypes: mocks.listDataTypes, buildTableStructureChangeSql: mocks.buildTableStructureChangeSql, })); import TableStructureEditor from "@/components/structure/TableStructureEditor.vue"; const mountedApps: App[] = []; function draft(isPrimaryKey = false) { return { initialized: true, activeTab: "columns" as const, newTableName: "", tableComment: "", originalTableComment: "", columns: [ { id: "existing:id", name: "id", dataType: "INT", isNullable: !isPrimaryKey, defaultValue: "", comment: "", isPrimaryKey, extra: {}, original: { name: "id", data_type: "INT", is_nullable: !isPrimaryKey, column_default: null, is_primary_key: isPrimaryKey, extra: null, comment: null, }, originalPosition: 0, markedForDrop: false, }, ], indexes: [], foreignKeys: [], triggers: [], }; } async function mountEditor(databaseType: "sqlserver" | "postgres" | "sqlite" | "oracle" | "dameng" | "duckdb" | "informix", isPrimaryKey = false) { mocks.connection.db_type = databaseType; mocks.connection.name = databaseType; mocks.connection.driver_label = databaseType; mocks.ensureConnected.mockResolvedValue(undefined); mocks.listDataTypes.mockResolvedValue([]); mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [], warnings: [] }); const root = document.createElement("div"); document.body.append(root); const app = createApp(TableStructureEditor, { connectionId: mocks.connection.id, database: "test", schema: "SYSDBA", tableName: "users", draft: draft(isPrimaryKey), }); mountedApps.push(app); app.mount(root); await nextTick(); await Promise.resolve(); await nextTick(); return root; } async function mountLoadingEditor(initialTab: "columns" | "indexes" | "foreignKeys" | "triggers" | "ddl") { mocks.connection.db_type = "postgres"; mocks.connection.name = "postgres"; mocks.connection.driver_label = "postgres"; mocks.ensureConnected.mockResolvedValue(undefined); mocks.listDataTypes.mockResolvedValue([]); mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [], warnings: [] }); mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE users (id bigint)", cacheStatus: "remote" }); mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ value: facet === "comment" ? "" : [], cacheStatus: "remote" })); const root = document.createElement("div"); document.body.append(root); const app = createApp(TableStructureEditor, { connectionId: mocks.connection.id, database: "test", schema: "public", tableName: "users", initialTab, }); mountedApps.push(app); app.mount(root); await nextTick(); await Promise.resolve(); await nextTick(); return root; } function columnCheckbox(root: HTMLElement, header: string): HTMLInputElement { const headerIndex = Array.from(root.querySelectorAll("thead th")).findIndex((cell) => cell.textContent?.trim() === header); if (headerIndex < 0) throw new Error(`Missing ${header} column`); const row = root.querySelector('[data-column-row-index="0"]'); const cell = row?.querySelectorAll("td")[headerIndex]; const checkbox = cell?.querySelector('input[type="checkbox"]'); if (!checkbox) throw new Error(`Missing ${header} checkbox`); return checkbox; } beforeEach(() => { vi.clearAllMocks(); mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE users (id bigint)", cacheStatus: "remote" }); mocks.invalidateObjectDdl.mockResolvedValue(undefined); mocks.loadObjectMetadataFacet.mockResolvedValue({ value: [], cacheStatus: "remote" }); }); afterEach(() => { for (const app of mountedApps.splice(0)) app.unmount(); document.body.innerHTML = ""; }); describe("TableStructureEditor primary key editing", () => { it("enables the primary-key checkbox for an existing Dameng column and makes it not null", async () => { const root = await mountEditor("dameng"); const primaryKey = columnCheckbox(root, "structureEditor.primaryKey"); const nullable = columnCheckbox(root, "structureEditor.nullable"); expect(primaryKey.disabled).toBe(false); expect(nullable.checked).toBe(true); primaryKey.checked = true; primaryKey.dispatchEvent(new Event("change", { bubbles: true })); await nextTick(); expect(primaryKey.checked).toBe(true); expect(nullable.checked).toBe(false); await vi.waitFor(() => expect(mocks.buildTableStructureChangeSql).toHaveBeenCalled()); expect(mocks.buildTableStructureChangeSql).toHaveBeenLastCalledWith( expect.objectContaining({ columns: [expect.objectContaining({ isPrimaryKey: true, isNullable: false })], }), ); }); it("keeps the primary-key checkbox disabled for an existing Oracle column", async () => { const root = await mountEditor("oracle"); expect(columnCheckbox(root, "structureEditor.primaryKey").disabled).toBe(true); }); it("allows an existing Dameng primary key to be cleared", async () => { const root = await mountEditor("dameng", true); const primaryKey = columnCheckbox(root, "structureEditor.primaryKey"); expect(primaryKey.disabled).toBe(false); expect(primaryKey.checked).toBe(true); primaryKey.checked = false; primaryKey.dispatchEvent(new Event("change", { bubbles: true })); await nextTick(); expect(primaryKey.checked).toBe(false); await vi.waitFor(() => expect(mocks.buildTableStructureChangeSql).toHaveBeenCalled()); expect(mocks.buildTableStructureChangeSql).toHaveBeenLastCalledWith( expect.objectContaining({ columns: [expect.objectContaining({ isPrimaryKey: false })], }), ); }); }); describe("TableStructureEditor local column order notice", () => { it.each(["sqlserver", "postgres", "sqlite", "oracle", "dameng", "duckdb", "informix"] as const)("does not show the reorder notice when adding a %s column", async (databaseType) => { const root = await mountEditor(databaseType); const addColumnButton = Array.from(root.querySelectorAll("button")).find((button) => button.textContent?.includes("structureEditor.addColumn")); if (!addColumnButton) throw new Error("Missing add column button"); addColumnButton.click(); await nextTick(); expect(mocks.toast).not.toHaveBeenCalled(); }); }); describe("TableStructureEditor metadata loading", () => { it("opens the initial DDL tab without starting structure metadata loads", async () => { await mountLoadingEditor("ddl"); await vi.waitFor(() => expect(mocks.loadObjectDdl).toHaveBeenCalledTimes(1)); expect(mocks.loadObjectMetadataFacet).not.toHaveBeenCalled(); }); it.each([ ["columns", ["columns", "comment"]], ["indexes", ["columns", "indexes", "comment"]], ["foreignKeys", ["columns", "foreign-keys", "comment"]], ["triggers", ["triggers", "comment"]], ] as const)("loads only the required facets for the initial %s tab", async (initialTab, expectedFacets) => { await mountLoadingEditor(initialTab); await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet).toHaveBeenCalledTimes(expectedFacets.length)); expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1])).toEqual(expectedFacets); expect(mocks.loadObjectDdl).not.toHaveBeenCalled(); }); it("loads index metadata when an initialized column draft opens the indexes tab", async () => { mocks.connection.db_type = "postgres"; mocks.connection.name = "postgres"; mocks.connection.driver_label = "postgres"; mocks.ensureConnected.mockResolvedValue(undefined); mocks.listDataTypes.mockResolvedValue([]); mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [], warnings: [] }); mocks.loadObjectMetadataFacet.mockImplementation(async (_request, facet: string) => ({ value: facet === "indexes" ? [ { name: "idx_users_id", columns: ["id"], is_unique: false, is_primary: false, }, ] : facet === "comment" ? "" : [], cacheStatus: "remote", })); const root = document.createElement("div"); document.body.append(root); const app = createApp(TableStructureEditor, { connectionId: mocks.connection.id, database: "test", schema: "public", tableName: "users", initialTab: "indexes", draft: { ...draft(), loadedMetadataFacets: ["columns", "comment"], }, }); mountedApps.push(app); app.mount(root); await nextTick(); await Promise.resolve(); await nextTick(); await vi.waitFor(() => expect(mocks.loadObjectMetadataFacet).toHaveBeenCalledTimes(1)); expect(mocks.loadObjectMetadataFacet.mock.calls.map((call) => call[1])).toEqual(["indexes"]); await vi.waitFor(() => expect(root.querySelector('[data-index-row-index="0"]')).not.toBeNull()); }); });