Fixes #41. MySQL columns now decode by their declared type instead of a blind try_get chain, so every non-string type (integers, DECIMAL, BIT, YEAR, DATE/TIME/DATETIME/TIMESTAMP) renders its value instead of NULL. MySQL restore runs in-process over the sqlx driver (no bundled MariaDB client, so MySQL 8 caching_sha2_password works) and the Clean toggle is implemented. .sql files can be opened with Gridline from the OS file manager. Verified: cargo test --lib 442 passed / 0 failed; bun run test 133 files / 1151 tests exit 0; tsc clean; both live MySQL 8 integration tests pass with the bundled CLI hidden.
684 lines
28 KiB
TypeScript
684 lines
28 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { useDbViewerStore } from "./dbViewerStore";
|
|
import type { QueryResult, TableInfo, RoleInfo } from "../lib/types";
|
|
import * as commands from "../lib/commands";
|
|
|
|
vi.mock("../lib/commands", () => ({
|
|
getDatabases: vi.fn(),
|
|
getSchemas: vi.fn(),
|
|
getTables: vi.fn(),
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
useDbViewerStore.getState().reset();
|
|
});
|
|
|
|
describe("dbViewerStore", () => {
|
|
it("starts empty", () => {
|
|
const state = useDbViewerStore.getState();
|
|
expect(state.tabs).toEqual([]);
|
|
expect(state.activeTabId).toBeNull();
|
|
expect(state.changesQueue).toEqual([]);
|
|
expect(state.databases).toEqual([]);
|
|
expect(state.schemas).toEqual([]);
|
|
expect(state.tables).toEqual([]);
|
|
expect(state.currentDatabase).toBeNull();
|
|
expect(state.currentSchema).toBeNull();
|
|
expect(state.objectSearchOpen).toBe(false);
|
|
expect(state.selectedObjectType).toBeNull();
|
|
expect(state.schemaTreeLoading).toBe(false);
|
|
});
|
|
|
|
it("object search open + selected object type setters", () => {
|
|
const { setObjectSearchOpen, setSelectedObjectType } =
|
|
useDbViewerStore.getState();
|
|
setObjectSearchOpen(true);
|
|
expect(useDbViewerStore.getState().objectSearchOpen).toBe(true);
|
|
setSelectedObjectType("functions");
|
|
expect(useDbViewerStore.getState().selectedObjectType).toBe("functions");
|
|
setSelectedObjectType(null);
|
|
expect(useDbViewerStore.getState().selectedObjectType).toBeNull();
|
|
});
|
|
|
|
it("requestedView setter stores then clears", () => {
|
|
const { setRequestedView } = useDbViewerStore.getState();
|
|
setRequestedView("objects");
|
|
expect(useDbViewerStore.getState().requestedView).toBe("objects");
|
|
setRequestedView(null);
|
|
expect(useDbViewerStore.getState().requestedView).toBeNull();
|
|
});
|
|
|
|
it("openTab adds a new tab", () => {
|
|
const store = useDbViewerStore.getState();
|
|
store.openTab("public", "users");
|
|
const state = useDbViewerStore.getState();
|
|
expect(state.tabs).toHaveLength(1);
|
|
const tab = state.tabs[0];
|
|
expect(tab.schema).toBe("public");
|
|
expect(tab.table).toBe("users");
|
|
expect(tab.page).toBe(1);
|
|
expect(state.activeTabId).toBe(tab.id);
|
|
});
|
|
|
|
it("openTab does not duplicate", () => {
|
|
const store = useDbViewerStore.getState();
|
|
store.openTab("public", "users");
|
|
store.openTab("public", "users");
|
|
const state = useDbViewerStore.getState();
|
|
expect(state.tabs).toHaveLength(1);
|
|
});
|
|
|
|
it("openTab forceNew creates duplicate", () => {
|
|
const store = useDbViewerStore.getState();
|
|
store.openTab("public", "users");
|
|
store.openTab("public", "users");
|
|
const tabsAfterTwo = useDbViewerStore.getState().tabs;
|
|
expect(tabsAfterTwo).toHaveLength(1);
|
|
|
|
store.openTab("public", "users", true);
|
|
const state = useDbViewerStore.getState();
|
|
expect(state.tabs).toHaveLength(2);
|
|
});
|
|
|
|
it("closeTab removes tab and switches activeTabId", () => {
|
|
const store = useDbViewerStore.getState();
|
|
store.openTab("public", "users");
|
|
const tab1Id = useDbViewerStore.getState().tabs[0].id;
|
|
store.openTab("public", "posts", true);
|
|
|
|
store.closeTab(tab1Id);
|
|
const state = useDbViewerStore.getState();
|
|
expect(state.tabs).toHaveLength(1);
|
|
expect(state.activeTabId).toBe(state.tabs[0].id);
|
|
});
|
|
|
|
it("closeTabsForTable closes matching table tabs and keeps others", () => {
|
|
useDbViewerStore.getState().openTab("public", "users");
|
|
useDbViewerStore.getState().openTab("public", "posts", true);
|
|
useDbViewerStore.getState().closeTabsForTable("public", "users");
|
|
const tabs = useDbViewerStore.getState().tabs;
|
|
expect(tabs).toHaveLength(1);
|
|
expect(tabs[0].table).toBe("posts");
|
|
});
|
|
|
|
it("closeTabsForTable fixes the active tab when it is closed", () => {
|
|
useDbViewerStore.getState().openTab("public", "users");
|
|
const usersId = useDbViewerStore.getState().tabs[0].id;
|
|
useDbViewerStore.getState().openTab("public", "posts", true);
|
|
useDbViewerStore.getState().setActiveTab(usersId);
|
|
useDbViewerStore.getState().closeTabsForTable("public", "users");
|
|
const st = useDbViewerStore.getState();
|
|
expect(st.tabs).toHaveLength(1);
|
|
expect(st.activeTabId).toBe(st.tabs[0].id);
|
|
});
|
|
|
|
it("reorderTab moves a tab and keeps the active tab by id", () => {
|
|
useDbViewerStore.getState().openTab("public", "users");
|
|
useDbViewerStore.getState().openTab("public", "posts", true);
|
|
useDbViewerStore.getState().openTab("public", "comments", true);
|
|
const before = useDbViewerStore.getState().tabs;
|
|
const activeId = useDbViewerStore.getState().activeTabId;
|
|
const activeTable = before.find((t) => t.id === activeId)!.table;
|
|
|
|
useDbViewerStore.getState().reorderTab(0, 2);
|
|
const after = useDbViewerStore.getState().tabs;
|
|
expect(after.map((t) => t.table)).toEqual(["posts", "comments", "users"]);
|
|
// Active tab must follow the moved item (tracked by id, not index).
|
|
expect(useDbViewerStore.getState().activeTabId).toBe(activeId);
|
|
expect(after.find((t) => t.id === activeId)!.table).toBe(activeTable);
|
|
});
|
|
|
|
it("reorderTab ignores out-of-range and no-op moves", () => {
|
|
useDbViewerStore.getState().openTab("public", "users");
|
|
useDbViewerStore.getState().openTab("public", "posts", true);
|
|
const reorderTab = useDbViewerStore.getState().reorderTab;
|
|
reorderTab(0, 0);
|
|
expect(useDbViewerStore.getState().tabs.map((t) => t.table)).toEqual([
|
|
"users",
|
|
"posts",
|
|
]);
|
|
reorderTab(-1, 1);
|
|
reorderTab(0, 5);
|
|
expect(useDbViewerStore.getState().tabs.map((t) => t.table)).toEqual([
|
|
"users",
|
|
"posts",
|
|
]);
|
|
});
|
|
|
|
it("setPage updates pagination", () => {
|
|
const store = useDbViewerStore.getState();
|
|
store.openTab("public", "users");
|
|
const tabId = useDbViewerStore.getState().tabs[0].id;
|
|
|
|
store.setPage(tabId, 3);
|
|
const tab = useDbViewerStore.getState().tabs[0];
|
|
expect(tab.page).toBe(3);
|
|
});
|
|
|
|
it("setTabData updates tab data", () => {
|
|
const store = useDbViewerStore.getState();
|
|
store.openTab("public", "users");
|
|
const tabId = useDbViewerStore.getState().tabs[0].id;
|
|
|
|
// First set loading to true to verify it gets cleared
|
|
store.setTabLoading(tabId, true);
|
|
|
|
const mockData: QueryResult = {
|
|
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false}],
|
|
rows: [[1, "Alice"]],
|
|
total_rows: 1, page: 1, page_size: 50,
|
|
};
|
|
store.setTabData(tabId, mockData);
|
|
|
|
const tab = useDbViewerStore.getState().tabs[0];
|
|
expect(tab.data).toEqual(mockData);
|
|
expect(tab.loading).toBe(false);
|
|
expect(tab.error).toBeNull();
|
|
});
|
|
|
|
it("setTabError sets error", () => {
|
|
const store = useDbViewerStore.getState();
|
|
store.openTab("public", "users");
|
|
const tabId = useDbViewerStore.getState().tabs[0].id;
|
|
|
|
store.setTabError(tabId, "Something went wrong");
|
|
const tab = useDbViewerStore.getState().tabs[0];
|
|
expect(tab.error).toBe("Something went wrong");
|
|
expect(tab.loading).toBe(false);
|
|
});
|
|
|
|
it("addChange appends to queue", () => {
|
|
const store = useDbViewerStore.getState();
|
|
store.addChange({ type: "insert", sql: "INSERT INTO users (id) VALUES (1)" });
|
|
|
|
const queue = useDbViewerStore.getState().changesQueue;
|
|
expect(queue).toHaveLength(1);
|
|
expect(queue[0].status).toBe("pending");
|
|
expect(queue[0].id).toBeTruthy();
|
|
expect(queue[0].createdAt).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("addChange stages a bulk_insert with columns+rows", () => {
|
|
useDbViewerStore.getState().addChange({
|
|
type: "bulk_insert", schema: "public", table: "t",
|
|
columns: ["a", "b"], rows: [[1, 2]], description: "Import",
|
|
} as any);
|
|
const q = useDbViewerStore.getState().changesQueue;
|
|
expect(q[q.length - 1].type).toBe("bulk_insert");
|
|
expect((q[q.length - 1] as any).columns).toEqual(["a", "b"]);
|
|
expect((q[q.length - 1] as any).rows).toEqual([[1, 2]]);
|
|
});
|
|
|
|
it("cancelChange marks change as cancelled", () => {
|
|
const store = useDbViewerStore.getState();
|
|
store.addChange({ type: "insert", sql: "INSERT INTO users (id) VALUES (1)" });
|
|
const changeId = useDbViewerStore.getState().changesQueue[0].id;
|
|
|
|
store.cancelChange(changeId);
|
|
const change = useDbViewerStore.getState().changesQueue[0];
|
|
expect(change.status).toBe("cancelled");
|
|
});
|
|
|
|
it("markChangeCommitted updates status", () => {
|
|
const store = useDbViewerStore.getState();
|
|
store.addChange({ type: "insert", sql: "INSERT INTO users (id) VALUES (1)" });
|
|
const changeId = useDbViewerStore.getState().changesQueue[0].id;
|
|
|
|
store.markChangeCommitted(changeId);
|
|
const change = useDbViewerStore.getState().changesQueue[0];
|
|
expect(change.status).toBe("committed");
|
|
});
|
|
|
|
it("markChangeFailed updates status and records error", () => {
|
|
const store = useDbViewerStore.getState();
|
|
store.addChange({ type: "insert", sql: "INSERT INTO users (id) VALUES (1)" });
|
|
const changeId = useDbViewerStore.getState().changesQueue[0].id;
|
|
|
|
store.markChangeFailed(changeId, "Constraint violation");
|
|
const change = useDbViewerStore.getState().changesQueue[0];
|
|
expect(change.status).toBe("failed");
|
|
expect(change.error).toBe("Constraint violation");
|
|
});
|
|
|
|
it("removeChange drops the change from the queue", () => {
|
|
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "x" } as any);
|
|
const id = useDbViewerStore.getState().changesQueue[0].id;
|
|
useDbViewerStore.getState().removeChange(id);
|
|
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
|
});
|
|
|
|
it("updateChange patches a single change in place", () => {
|
|
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: null }, description: "x" } as any);
|
|
const id = useDbViewerStore.getState().changesQueue[0].id;
|
|
useDbViewerStore.getState().updateChange(id, { newData: { a: "hello" } });
|
|
const item = useDbViewerStore.getState().changesQueue[0];
|
|
expect(item.newData).toEqual({ a: "hello" });
|
|
expect(item.type).toBe("insert");
|
|
expect(item.description).toBe("x");
|
|
});
|
|
|
|
it("clearChanges empties the queue", () => {
|
|
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "x" } as any);
|
|
useDbViewerStore.getState().addChange({ type: "delete", schema: "public", table: "t", primaryKey: { id: 1 }, description: "y" } as any);
|
|
useDbViewerStore.getState().clearChanges();
|
|
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
|
});
|
|
|
|
it("setSchemaTreeLoading toggles the loading flag", () => {
|
|
const store = useDbViewerStore.getState();
|
|
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(false);
|
|
store.setSchemaTreeLoading(true);
|
|
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(true);
|
|
store.setSchemaTreeLoading(false);
|
|
expect(useDbViewerStore.getState().schemaTreeLoading).toBe(false);
|
|
});
|
|
|
|
it("reset clears all state", () => {
|
|
const store = useDbViewerStore.getState();
|
|
store.openTab("public", "users");
|
|
store.addChange({ type: "insert", sql: "INSERT INTO users (id) VALUES (1)" });
|
|
store.populate(["mydb"], ["public"], [{ name: "users", schema: "public", table_type: "TABLE" }]);
|
|
|
|
store.reset();
|
|
|
|
const state = useDbViewerStore.getState();
|
|
expect(state.tabs).toEqual([]);
|
|
expect(state.activeTabId).toBeNull();
|
|
expect(state.changesQueue).toEqual([]);
|
|
expect(state.databases).toEqual([]);
|
|
expect(state.schemas).toEqual([]);
|
|
expect(state.tables).toEqual([]);
|
|
expect(state.currentDatabase).toBeNull();
|
|
expect(state.currentSchema).toBeNull();
|
|
expect(state.schemaTreeLoading).toBe(false);
|
|
});
|
|
|
|
it("populate sets databases, schemas, tables", () => {
|
|
const tables: TableInfo[] = [
|
|
{ name: "users", schema: "public", table_type: "TABLE" },
|
|
{ name: "posts", schema: "public", table_type: "TABLE" },
|
|
];
|
|
const store = useDbViewerStore.getState();
|
|
store.populate(["mydb", "testdb"], ["public", "private"], tables);
|
|
|
|
const state = useDbViewerStore.getState();
|
|
expect(state.databases).toEqual(["mydb", "testdb"]);
|
|
expect(state.schemas).toEqual(["public", "private"]);
|
|
expect(state.tables).toEqual(tables);
|
|
});
|
|
|
|
it("stageCellEdit appends an update QueueItem with primaryKey + old/new data", () => {
|
|
const store = useDbViewerStore.getState();
|
|
store.openTab("public", "users");
|
|
const tabId = useDbViewerStore.getState().activeTabId!;
|
|
store.stageCellEdit({
|
|
tabId,
|
|
schema: "public",
|
|
table: "users",
|
|
primaryKey: { id: 1 },
|
|
oldData: { name: "Alice" },
|
|
newData: { name: "Alicia" },
|
|
description: "Edit users.name",
|
|
});
|
|
const q = useDbViewerStore.getState().changesQueue;
|
|
expect(q).toHaveLength(1);
|
|
expect(q[0].type).toBe("update");
|
|
expect(q[0].primaryKey).toEqual({ id: 1 });
|
|
expect(q[0].oldData).toEqual({ name: "Alice" });
|
|
expect(q[0].newData).toEqual({ name: "Alicia" });
|
|
});
|
|
|
|
it("re-staging the same cell replaces the pending entry (keeps original oldData)", () => {
|
|
const store = useDbViewerStore.getState();
|
|
store.openTab("public", "users");
|
|
const tabId = useDbViewerStore.getState().activeTabId!;
|
|
store.stageCellEdit({
|
|
tabId, schema: "public", table: "users", primaryKey: { id: 1 },
|
|
oldData: { name: "Alice" }, newData: { name: "Alicia" },
|
|
description: "Edit users.name",
|
|
});
|
|
store.stageCellEdit({
|
|
tabId, schema: "public", table: "users", primaryKey: { id: 1 },
|
|
oldData: { name: "Alice" }, newData: { name: "Alicia 2" },
|
|
description: "Edit users.name",
|
|
});
|
|
const q = useDbViewerStore.getState().changesQueue;
|
|
expect(q).toHaveLength(1);
|
|
expect(q[0].newData).toEqual({ name: "Alicia 2" });
|
|
expect(q[0].oldData).toEqual({ name: "Alice" }); // original DB value preserved
|
|
});
|
|
|
|
it("staging a different cell appends a second entry", () => {
|
|
const store = useDbViewerStore.getState();
|
|
store.openTab("public", "users");
|
|
const tabId = useDbViewerStore.getState().activeTabId!;
|
|
store.stageCellEdit({
|
|
tabId, schema: "public", table: "users", primaryKey: { id: 1 },
|
|
oldData: { name: "Alice" }, newData: { name: "Alicia" },
|
|
});
|
|
store.stageCellEdit({
|
|
tabId, schema: "public", table: "users", primaryKey: { id: 1 },
|
|
oldData: { age: 30 }, newData: { age: 31 },
|
|
});
|
|
expect(useDbViewerStore.getState().changesQueue).toHaveLength(2);
|
|
});
|
|
|
|
it("setIndexes / setConstraints update store slices", () => {
|
|
const store = useDbViewerStore.getState();
|
|
store.setIndexes([{ name: "idx", schema: "public", table: "t", definition: "", is_unique: true, method: "btree", columns: ["id"], size_bytes: 1, tablespace: null }]);
|
|
store.setConstraints([{ name: "ck", schema: "public", table: "t", contype: "CHECK", definition: "", deferrable: false, validated: true, columns: ["x"] }]);
|
|
expect(useDbViewerStore.getState().indexes).toHaveLength(1);
|
|
expect(useDbViewerStore.getState().constraints).toHaveLength(1);
|
|
});
|
|
|
|
it("reset clears indexes and constraints", () => {
|
|
useDbViewerStore.getState().setIndexes([{ name: "x", schema: "s", table: "t", definition: "", is_unique: false, method: "btree", columns: [], size_bytes: null, tablespace: null }]);
|
|
useDbViewerStore.getState().reset();
|
|
expect(useDbViewerStore.getState().indexes).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("refreshTree", () => {
|
|
it("fetches databases/schemas/tables and populates", async () => {
|
|
vi.mocked(commands.getDatabases).mockResolvedValue(["mydb"]);
|
|
vi.mocked(commands.getSchemas).mockResolvedValue(["public"]);
|
|
vi.mocked(commands.getTables).mockResolvedValue([
|
|
{ name: "users", schema: "public", table_type: "TABLE" },
|
|
] as any);
|
|
useDbViewerStore.setState({ currentSchema: "public" });
|
|
await useDbViewerStore.getState().refreshTree("c1");
|
|
expect(useDbViewerStore.getState().databases).toEqual(["mydb"]);
|
|
expect(useDbViewerStore.getState().schemas).toEqual(["public"]);
|
|
expect(useDbViewerStore.getState().tables).toHaveLength(1);
|
|
expect(commands.getTables).toHaveBeenCalledWith("c1", "public");
|
|
});
|
|
it("falls back to no schema when currentSchema is null", async () => {
|
|
vi.mocked(commands.getDatabases).mockResolvedValue([] as any);
|
|
vi.mocked(commands.getSchemas).mockResolvedValue([] as any);
|
|
vi.mocked(commands.getTables).mockResolvedValue([] as any);
|
|
useDbViewerStore.setState({ currentSchema: null });
|
|
await useDbViewerStore.getState().refreshTree("c1");
|
|
expect(commands.getTables).toHaveBeenCalledWith("c1", undefined);
|
|
});
|
|
it("swallows fetch errors", async () => {
|
|
vi.mocked(commands.getDatabases).mockResolvedValue([] as any);
|
|
vi.mocked(commands.getSchemas).mockRejectedValue(new Error("boom"));
|
|
await expect(
|
|
useDbViewerStore.getState().refreshTree("c1"),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("tabType discriminator", () => {
|
|
beforeEach(() => {
|
|
useDbViewerStore.getState().reset();
|
|
useDbViewerStore.setState({
|
|
schemas: ["public"],
|
|
currentSchema: "public",
|
|
currentDatabase: "mydb",
|
|
});
|
|
});
|
|
|
|
it("openQueryTab creates a query-type tab with empty query", () => {
|
|
useDbViewerStore.getState().openQueryTab();
|
|
const state = useDbViewerStore.getState();
|
|
expect(state.tabs).toHaveLength(1);
|
|
const tab = state.tabs[0];
|
|
expect(tab.tabType).toBe("query");
|
|
expect(tab.query).toBe("");
|
|
expect(tab.schema).toBe("public");
|
|
expect(tab.table).toBe("Query");
|
|
expect(state.activeTabId).toBe(tab.id);
|
|
});
|
|
|
|
it("creates sequential query tabs with unique IDs", () => {
|
|
const store = useDbViewerStore.getState();
|
|
store.openQueryTab();
|
|
store.openQueryTab();
|
|
store.openQueryTab();
|
|
const tabs = useDbViewerStore.getState().tabs;
|
|
expect(tabs).toHaveLength(3);
|
|
const ids = tabs.map((t) => t.id);
|
|
const uniqueIds = new Set(ids);
|
|
expect(uniqueIds.size).toBe(3);
|
|
tabs.forEach((t) => expect(t.tabType).toBe("query"));
|
|
});
|
|
|
|
it("openTab creates table-type tabs by default (backward compat)", () => {
|
|
useDbViewerStore.getState().openTab("public", "users");
|
|
const tab = useDbViewerStore.getState().tabs[0];
|
|
expect(tab.tabType).toBe("table");
|
|
});
|
|
});
|
|
describe("openObjectTab", () => {
|
|
beforeEach(() => useDbViewerStore.getState().reset());
|
|
|
|
it("opens an object tab carrying the per-type identity + item", () => {
|
|
useDbViewerStore.getState().openObjectTab("functions", "public", "add", { name: "add", schema: "public" });
|
|
const st = useDbViewerStore.getState();
|
|
expect(st.tabs).toHaveLength(1);
|
|
expect(st.tabs[0]).toMatchObject({ tabType: "object", objectType: "functions", schema: "public", table: "add" });
|
|
expect(st.tabs[0].objectItem).toEqual({ name: "add", schema: "public" });
|
|
expect(st.activeTabId).toBe(st.tabs[0].id);
|
|
});
|
|
|
|
it("dedups: re-clicking the same object focuses the existing tab", () => {
|
|
useDbViewerStore.getState().openObjectTab("enums", "public", "role", { name: "role" });
|
|
const first = useDbViewerStore.getState().tabs[0];
|
|
useDbViewerStore.getState().openObjectTab("enums", "public", "role", { name: "role" });
|
|
const st = useDbViewerStore.getState();
|
|
expect(st.tabs).toHaveLength(1);
|
|
expect(st.activeTabId).toBe(first.id);
|
|
});
|
|
|
|
it("does not collide with a table tab of the same name", () => {
|
|
useDbViewerStore.getState().openTab("public", "users");
|
|
useDbViewerStore.getState().openObjectTab("functions", "public", "users", { name: "users" });
|
|
const st = useDbViewerStore.getState();
|
|
expect(st.tabs).toHaveLength(2);
|
|
expect(st.tabs.find((t) => t.tabType === "object")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("openFormTab", () => {
|
|
beforeEach(() => useDbViewerStore.getState().reset());
|
|
|
|
const createOpts = (name: string, title = "Create sequence") => ({
|
|
kind: "sequence" as const,
|
|
schema: "public",
|
|
name,
|
|
title,
|
|
description: `Create ${name}`,
|
|
mode: "create" as const,
|
|
params: { schema: "public", name, action: { op: "create" } },
|
|
});
|
|
|
|
it("opens a create form tab carrying kind/params/title/description/mode", () => {
|
|
useDbViewerStore.getState().openFormTab(createOpts("my_seq"));
|
|
const st = useDbViewerStore.getState();
|
|
expect(st.tabs).toHaveLength(1);
|
|
const tab = st.tabs[0];
|
|
expect(tab.tabType).toBe("objectForm");
|
|
// table holds the display title, used as the TabBar label
|
|
expect(tab.table).toBe("Create sequence");
|
|
expect(tab.schema).toBe("public");
|
|
expect(tab.objectType).toBeNull();
|
|
expect(tab.form).toBeDefined();
|
|
expect(tab.form!.kind).toBe("sequence");
|
|
expect(tab.form!.mode).toBe("create");
|
|
expect(tab.form!.title).toBe("Create sequence");
|
|
expect(tab.form!.description).toBe("Create my_seq");
|
|
expect(tab.form!.params).toEqual({
|
|
schema: "public",
|
|
name: "my_seq",
|
|
action: { op: "create" },
|
|
});
|
|
expect(st.activeTabId).toBe(tab.id);
|
|
});
|
|
|
|
it("dedups create tabs per kind regardless of schema/name and does not clobber params", () => {
|
|
useDbViewerStore.getState().openFormTab(createOpts("first_seq"));
|
|
const first = useDbViewerStore.getState().tabs[0];
|
|
useDbViewerStore.getState().openFormTab(createOpts("second_seq"));
|
|
const st = useDbViewerStore.getState();
|
|
expect(st.tabs).toHaveLength(1);
|
|
expect(st.activeTabId).toBe(first.id);
|
|
// the existing tab keeps its original params (name not clobbered)
|
|
expect(st.tabs[0].form!.params.name).toBe("first_seq");
|
|
});
|
|
|
|
it("dedups edit tabs by kind+schema+name but a create tab of the same kind coexists", () => {
|
|
const editOpts = {
|
|
kind: "enum" as const,
|
|
schema: "public",
|
|
name: "role",
|
|
title: "Edit role",
|
|
description: "Edit public.role",
|
|
mode: "edit" as const,
|
|
params: { schema: "public", name: "role", action: { op: "update" } },
|
|
};
|
|
useDbViewerStore.getState().openFormTab(editOpts);
|
|
const first = useDbViewerStore.getState().tabs[0];
|
|
useDbViewerStore.getState().openFormTab(editOpts);
|
|
let st = useDbViewerStore.getState();
|
|
expect(st.tabs).toHaveLength(1);
|
|
expect(st.activeTabId).toBe(first.id);
|
|
|
|
// A create tab of the same kind is a distinct tab.
|
|
useDbViewerStore.getState().openFormTab(createOpts("new_enum", "Create enum"));
|
|
st = useDbViewerStore.getState();
|
|
expect(st.tabs).toHaveLength(2);
|
|
expect(st.tabs.every((t) => t.tabType === "objectForm")).toBe(true);
|
|
expect(st.tabs.some((t) => t.form!.mode === "edit")).toBe(true);
|
|
expect(st.tabs.some((t) => t.form!.mode === "create")).toBe(true);
|
|
});
|
|
|
|
it("does not collide with a table tab (distinct types)", () => {
|
|
useDbViewerStore.getState().openTab("public", "users");
|
|
useDbViewerStore.getState().openFormTab(createOpts("users_id_seq"));
|
|
const st = useDbViewerStore.getState();
|
|
expect(st.tabs).toHaveLength(2);
|
|
expect(st.tabs[0].tabType).toBe("table");
|
|
expect(st.tabs[1].tabType).toBe("objectForm");
|
|
});
|
|
|
|
it("resolves an empty schema to the current schema", () => {
|
|
useDbViewerStore.setState({ currentSchema: "utils" });
|
|
useDbViewerStore
|
|
.getState()
|
|
.openFormTab({ ...createOpts("my_seq"), schema: "" });
|
|
const tab = useDbViewerStore.getState().tabs[0];
|
|
expect(tab.schema).toBe("utils");
|
|
// params stay untouched — the form owns them
|
|
expect(tab.form!.params.schema).toBe("public");
|
|
});
|
|
|
|
it("falls back to public when no schema and no currentSchema", () => {
|
|
useDbViewerStore
|
|
.getState()
|
|
.openFormTab({ ...createOpts("my_seq"), schema: "" });
|
|
const tab = useDbViewerStore.getState().tabs[0];
|
|
expect(tab.schema).toBe("public");
|
|
});
|
|
|
|
it("keeps an explicit schema even when currentSchema is set", () => {
|
|
useDbViewerStore.setState({ currentSchema: "utils" });
|
|
useDbViewerStore
|
|
.getState()
|
|
.openFormTab({ ...createOpts("my_seq"), schema: "public" });
|
|
const tab = useDbViewerStore.getState().tabs[0];
|
|
expect(tab.schema).toBe("public");
|
|
});
|
|
|
|
it("updateFormTabParams updates params without touching title/description/mode/kind", () => {
|
|
useDbViewerStore.getState().openFormTab(createOpts("my_seq"));
|
|
const tabId = useDbViewerStore.getState().tabs[0].id;
|
|
const before = useDbViewerStore.getState().tabs[0].form;
|
|
expect(before).toBeDefined();
|
|
|
|
useDbViewerStore
|
|
.getState()
|
|
.updateFormTabParams(tabId, { schema: "other", name: "renamed", action: { op: "create" } });
|
|
|
|
const after = useDbViewerStore.getState().tabs[0].form;
|
|
expect(after?.params).toEqual({ schema: "other", name: "renamed", action: { op: "create" } });
|
|
expect(after?.title).toBe(before?.title);
|
|
expect(after?.description).toBe(before?.description);
|
|
expect(after?.mode).toBe(before?.mode);
|
|
expect(after?.kind).toBe(before?.kind);
|
|
});
|
|
});
|
|
|
|
describe("roles slice", () => {
|
|
it("setRoles stores roles", () => {
|
|
useDbViewerStore.getState().reset();
|
|
const r: RoleInfo = {
|
|
name: "app", superuser: false, inherit: true, create_db: false, create_role: false,
|
|
can_login: true, replication: false, bypass_rls: false, connection_limit: -1,
|
|
valid_until: null, memberships: [],
|
|
};
|
|
useDbViewerStore.getState().setRoles([r]);
|
|
expect(useDbViewerStore.getState().roles).toEqual([r]);
|
|
});
|
|
|
|
it("openFormTab accepts kind table and role", () => {
|
|
useDbViewerStore.getState().reset();
|
|
useDbViewerStore.getState().openFormTab({
|
|
kind: "table", schema: "public", name: "", title: "Create Table",
|
|
description: "Create Table", mode: "create", params: { schema: "public", name: "", action: { op: "create", columns: [] } },
|
|
});
|
|
const t = useDbViewerStore.getState().tabs[0];
|
|
expect(t.tabType).toBe("objectForm");
|
|
expect(t.form?.kind).toBe("table");
|
|
// dedup: second create of same kind focuses existing
|
|
useDbViewerStore.getState().openFormTab({
|
|
kind: "table", schema: "public", name: "", title: "Create Table",
|
|
description: "Create Table", mode: "create", params: { schema: "public", name: "", action: { op: "create", columns: [] } },
|
|
});
|
|
expect(useDbViewerStore.getState().tabs.filter((x) => x.form?.kind === "table").length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("openQueryTab with an initial script", () => {
|
|
it("seeds the tab text and label from the payload", () => {
|
|
useDbViewerStore.setState({ tabs: [], activeTabId: null });
|
|
|
|
useDbViewerStore.getState().openQueryTab({
|
|
sql: "SELECT 1;",
|
|
label: "student_db.sql",
|
|
filePath: "/Users/me/student_db.sql",
|
|
});
|
|
|
|
const tab = useDbViewerStore.getState().tabs.slice(-1)[0]!;
|
|
expect(tab.tabType).toBe("query");
|
|
expect(tab.query).toBe("SELECT 1;");
|
|
expect(tab.table).toBe("student_db.sql");
|
|
expect(tab.filePath).toBe("/Users/me/student_db.sql");
|
|
});
|
|
|
|
it("focuses an existing tab for the same file instead of duplicating it", () => {
|
|
useDbViewerStore.setState({ tabs: [], activeTabId: null });
|
|
const payload = {
|
|
sql: "SELECT 1;",
|
|
label: "student_db.sql",
|
|
filePath: "/Users/me/student_db.sql",
|
|
};
|
|
useDbViewerStore.getState().openQueryTab(payload);
|
|
useDbViewerStore.getState().openQueryTab({ ...payload, sql: "SELECT 2;" });
|
|
|
|
const queryTabs = useDbViewerStore.getState().tabs.filter((t) => t.tabType === "query");
|
|
expect(queryTabs).toHaveLength(1);
|
|
// The already-open tab keeps its text: re-opening must not clobber edits.
|
|
expect(queryTabs[0].query).toBe("SELECT 1;");
|
|
});
|
|
|
|
it("still opens a plain empty tab when called with no payload", () => {
|
|
useDbViewerStore.setState({ tabs: [], activeTabId: null });
|
|
useDbViewerStore.getState().openQueryTab();
|
|
const tab = useDbViewerStore.getState().tabs.slice(-1)[0]!;
|
|
expect(tab.tabType).toBe("query");
|
|
expect(tab.query).toBe("");
|
|
expect(tab.filePath).toBeUndefined();
|
|
});
|
|
});
|