// @vitest-environment node // No DOM needed — the pure helpers under test (findOption, pageUrlFromPath, // valueToText) never touch the platform. The hook module's API layer is // mocked so the import chain stays light (jsdom is not installed here). import { describe, expect, it, vi } from "vitest"; vi.mock("@/lib/api", () => ({ apiFetch: vi.fn(async () => ({ data: [] })), blockApi: {}, })); import { findOption, normalizePropertyType, pageUrlFromPath, valueToText } from "@/hooks/use-database"; import type { DatabaseProperty, SelectOption } from "@/hooks/use-database"; const OPTIONS: SelectOption[] = [ { id: "opt-to-do", name: "To Do", color: "default" }, { id: "opt-in-progress", name: "In Progress", color: "blue" }, { id: "opt-done", name: "Done", color: "green" } ]; const statusProp: DatabaseProperty = { id: "prop_status", databaseId: "db1", name: "Status", type: "status", position: "a1", isTitle: false, isVisible: true, required: false, config: { options: OPTIONS }, default: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as DatabaseProperty; describe("findOption (tolerant id-or-name option lookup)", () => { it("matches by id (the canonical contract)", () => { expect(findOption(OPTIONS, "opt-done")?.name).toBe("Done"); }); it("falls back to NAME for a legacy stored option name", () => { expect(findOption(OPTIONS, "To Do")?.id).toBe("opt-to-do"); expect(findOption(OPTIONS, "In Progress")?.id).toBe("opt-in-progress"); }); it("returns undefined for unknown refs, empty, non-string, or no options", () => { expect(findOption(OPTIONS, "Unknown")).toBeUndefined(); expect(findOption(OPTIONS, "")).toBeUndefined(); expect(findOption(OPTIONS, null)).toBeUndefined(); expect(findOption(OPTIONS, 42)).toBeUndefined(); expect(findOption(undefined, "opt-done")).toBeUndefined(); expect(findOption(null, "To Do")).toBeUndefined(); }); }); describe("pageUrlFromPath (the only page route is /w/[workspace]/page/[id])", () => { it("builds the workspace-scoped page URL from the second path segment", () => { expect(pageUrlFromPath("/w/personal-fep9/page/abc", "page123")).toBe( "/w/personal-fep9/page/page123" ); // Deeper routes (the record panel opens FROM a database page) still // resolve the slug. expect(pageUrlFromPath("/w/default/page/cmuj/page2", "page2")).toBe( "/w/default/page/page2" ); }); it("falls back to the workspace-less URL when the slug cannot be read", () => { expect(pageUrlFromPath("/page/abc", "page123")).toBe("/page/page123"); expect(pageUrlFromPath("/", "page123")).toBe("/page/page123"); expect(pageUrlFromPath(null, "page123")).toBe("/page/page123"); expect(pageUrlFromPath(undefined, "page123")).toBe("/page/page123"); }); it("does NOT mistake the literal 'w' segment for the slug (the reported 404)", () => { // The bug: the old code took the FIRST segment → /w/p/{id} → 404. expect(pageUrlFromPath("/w/personal-fep9/page/abc", "x")).not.toBe("/w/w/page/x"); expect(pageUrlFromPath("/w/personal-fep9/page/abc", "x")).not.toContain("/p/"); }); }); describe("normalizePropertyType (read-boundary option-id backfill — the fix)", () => { // A legacy wire payload: the API returned id-less options (bare { name, // color }) — exactly what a stale/legacy database carries. The stored // value is the canonical id `opt-in-progress`; without the backfill, // findOption matches neither id nor name → the cell renders BLANK. const legacyProp: DatabaseProperty = { id: "prop_status", databaseId: "db1", name: "Status", type: "STATUS", // wire casing (UPPERCASE Prisma enum) position: "a1", isTitle: false, isVisible: true, required: false, config: { options: [ { id: "", name: "To Do", color: "default" }, { id: "", name: "In Progress", color: "blue" }, { id: "", name: "Done", color: "green" } ] as SelectOption[] }, default: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as unknown as DatabaseProperty; it("backfills deterministic option ids AND lowercases the wire type", () => { const out = normalizePropertyType(legacyProp); expect(out.type).toBe("status"); expect(out.config.options?.map((o) => o.id)).toEqual([ "opt-to-do", "opt-in-progress", "opt-done" ]); }); it("a canonical stored optionId RESOLVES after normalization (no blank cell)", () => { const out = normalizePropertyType(legacyProp); // THE reported symptom: before the fix this was undefined → blank. expect(findOption(out.config.options, "opt-in-progress")?.name).toBe("In Progress"); expect(valueToText({ optionId: "opt-in-progress" } as never, out)).toBe("In Progress"); expect(valueToText({ optionId: "opt-done" } as never, out)).toBe("Done"); }); it("a legacy stored option NAME still resolves (double safety)", () => { const out = normalizePropertyType(legacyProp); expect(findOption(out.config.options, "To Do")?.name).toBe("To Do"); }); it("is referentially stable for an already-conformant (id'd, lowercase) property", () => { const good: DatabaseProperty = { ...statusProp, config: { options: [ { id: "opt-to-do", name: "To Do", color: "default" }, { id: "opt-in-progress", name: "In Progress", color: "blue" }, { id: "opt-done", name: "Done", color: "green" } ] } } as DatabaseProperty; expect(normalizePropertyType(good)).toBe(good); }); it("does NOT mutate the input property", () => { const snapshot = legacyProp.config.options?.[1]; normalizePropertyType(legacyProp); expect(snapshot).toEqual({ id: "", name: "In Progress", color: "blue" }); }); it("leaves non-select configs untouched", () => { const numProp = { ...legacyProp, type: "NUMBER", config: { format: "dollar" } } as unknown as DatabaseProperty; const out = normalizePropertyType(numProp); expect(out.type).toBe("number"); expect(out.config.options).toBeUndefined(); }); }); describe("valueToText (option values render the option NAME)", () => { it("resolves a canonical option id to the name", () => { expect(valueToText({ optionId: "opt-done" } as never, statusProp)).toBe("Done"); }); it("resolves a legacy option NAME to the name (no blank cell)", () => { expect(valueToText({ optionId: "To Do" } as never, statusProp)).toBe("To Do"); }); it("resolves multi_select by id and by legacy name", () => { expect(valueToText({ optionIds: ["opt-to-do", "opt-done"] } as never, statusProp)).toBe( "To Do, Done" ); expect(valueToText({ optionIds: ["To Do", "Done"] } as never, statusProp)).toBe("To Do, Done"); }); });