diff --git a/apps/web/package.json b/apps/web/package.json index 7b479df..8697ee6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@leader/web", "private": true, - "version": "0.2.3", + "version": "0.2.4", "type": "module", "scripts": { "dev": "vite dev", diff --git a/apps/web/src/routes/(app)/+layout.svelte b/apps/web/src/routes/(app)/+layout.svelte index 6a23066..f27deff 100644 --- a/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/web/src/routes/(app)/+layout.svelte @@ -72,6 +72,43 @@
- {@render children()} + + {@render children()} + + {#snippet pending()} +
+
+

+ Loading… +

+
+
+ {/snippet} + + {#snippet failed(error, reset)} +
+
+

+ Something went wrong +

+

+ {error instanceof Error ? error.message : "An unexpected error occurred"} +

+ +
+
+ {/snippet} +
diff --git a/apps/web/src/routes/(app)/layout.test.ts b/apps/web/src/routes/(app)/layout.test.ts new file mode 100644 index 0000000..47c360d --- /dev/null +++ b/apps/web/src/routes/(app)/layout.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "bun:test"; + +/** + * Structural guard tests for the app layout error boundary. + * + * The (app) layout wraps all page content in a so that + * async `$derived(await query())` calls have proper pending/error fallbacks. + * Without the boundary, navigation to a page with an async load can crash + * because there is no ancestor to handle the pending or error state. + * + * These tests read the raw source to verify the boundary is present. + * This approach is intentional: the boundary is an architectural requirement + * that is difficult to exercise through component rendering alone, and + * removing it would silently break the app at runtime. + */ +const layoutSource = await Bun.file( + new URL("./+layout.svelte", import.meta.url) +).text(); + +describe("App layout boundary (structural guard)", () => { + it("contains a svelte:boundary element", () => { + expect(layoutSource).toMatch(//); + expect(layoutSource).toMatch(/<\/svelte:boundary>/); + }); + + it("provides a pending snippet for loading state", () => { + expect(layoutSource).toMatch(/\{#snippet pending\b/); + }); + + it("provides a failed snippet for error recovery", () => { + expect(layoutSource).toMatch(/\{#snippet failed\b/); + }); + + it("renders children inside the boundary", () => { + const boundaryStart = layoutSource.indexOf(""); + const boundaryEnd = layoutSource.indexOf(""); + const childrenRender = layoutSource.indexOf("{@render children()}"); + + expect(childrenRender).toBeGreaterThan(boundaryStart); + expect(childrenRender).toBeLessThan(boundaryEnd); + }); +}); diff --git a/apps/web/src/routes/(app)/leads-search-form.svelte b/apps/web/src/routes/(app)/leads-search-form.svelte index a6f55a1..eeb94b0 100644 --- a/apps/web/src/routes/(app)/leads-search-form.svelte +++ b/apps/web/src/routes/(app)/leads-search-form.svelte @@ -142,9 +142,9 @@ diff --git a/apps/web/src/routes/(app)/leads/[id]/+page.svelte b/apps/web/src/routes/(app)/leads/[id]/+page.svelte index e8e4325..5a9bfa4 100644 --- a/apps/web/src/routes/(app)/leads/[id]/+page.svelte +++ b/apps/web/src/routes/(app)/leads/[id]/+page.svelte @@ -13,6 +13,7 @@ createProjectCustomField, deleteLead, getLeadData, + getLeads, updateLeadCore, upsertLeadCustomFieldValue, } from "$lib/remote/leads.remote.js"; @@ -28,7 +29,7 @@ const deleteForm = deleteLead.enhance(async ({ submit }) => { deleteError = null; try { - await submit(); + await submit().updates(getLeads()); await goto(resolve("/leads")); } catch (err) { console.error("Failed to delete lead:", err); diff --git a/apps/web/src/routes/(app)/leads/[id]/lead-detail.test.ts b/apps/web/src/routes/(app)/leads/[id]/lead-detail.test.ts new file mode 100644 index 0000000..90c6274 --- /dev/null +++ b/apps/web/src/routes/(app)/leads/[id]/lead-detail.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, mock, beforeEach, spyOn } from "bun:test"; +import { render, screen, waitFor } from "@testing-library/svelte"; +import { + createFormMock, + createQueryMock, +} from "../../../../test-helpers/sveltekit-mocks"; + +const mockLeadData = { + lead: { + id: "lead-1", + name: "Acme Corp", + placeId: "place_123", + email: "hello@acme.com", + phone: "+1234567890", + website: "https://acme.com", + address: "123 Main St", + googleMapsUrl: "https://maps.google.com/?cid=123", + types: ["restaurant"], + rating: 4.5, + ratingsTotal: 100, + businessStatus: "OPERATIONAL", + createdAt: new Date("2024-01-01"), + }, + customFieldSections: [ + { + projectId: "proj-1", + projectName: "Test Project", + fields: [], + }, + ], +}; + +const mockGetLeadData = createQueryMock(mockLeadData); +const mockDeleteLead = createFormMock({ ok: true }); +const mockUpdateLeadCore = createFormMock({ ok: true }); +const mockCreateProjectCustomField = createFormMock({ ok: true }); +const mockUpsertLeadCustomFieldValue = createFormMock({ ok: true }); + +const mockGoto = mock(() => Promise.resolve()); +const mockResolve = mock((path: string) => path); + +mock.module("$lib/remote/leads.remote.js", () => ({ + getLeadData: mockGetLeadData, + deleteLead: mockDeleteLead, + updateLeadCore: mockUpdateLeadCore, + createProjectCustomField: mockCreateProjectCustomField, + upsertLeadCustomFieldValue: mockUpsertLeadCustomFieldValue, + getLeads: createQueryMock([]), + createManualLead: createFormMock(), + getDiscoveryCapabilities: createQueryMock({ hasOpenRouter: false }), + discoverLeads: createFormMock(), +})); + +mock.module("$app/navigation", () => ({ + goto: mockGoto, +})); + +mock.module("$app/paths", () => ({ + resolve: mockResolve, +})); + +mock.module("$app/server", () => ({ + query: (fn: (...args: unknown[]) => unknown) => fn, + form: () => createFormMock(), + getRequestEvent: () => ({}), +})); + +const { default: LeadDetailPage } = await import("./+page.svelte"); + +describe("Lead detail page", () => { + beforeEach(() => { + mockGetLeadData.mockClear(); + mockGoto.mockClear(); + mockResolve.mockClear(); + mockDeleteLead.mockClear(); + mockGetLeadData.mockImplementation(() => + Promise.resolve(mockLeadData) + ); + mockDeleteLead.mockImplementation(() => Promise.resolve({ ok: true })); + mockResolve.mockImplementation((path: string) => path); + }); + + it("renders the lead name in heading", async () => { + render(LeadDetailPage, { params: { id: "lead-1" } }); + await waitFor(() => { + expect( + screen.getByRole("heading", { name: "Acme Corp" }) + ).toBeTruthy(); + }); + }); + + it("renders breadcrumbs with Leads link", async () => { + render(LeadDetailPage, { params: { id: "lead-1" } }); + await waitFor(() => { + expect(screen.getByText("Leads")).toBeTruthy(); + }); + }); + + it("renders the Google Maps link", async () => { + render(LeadDetailPage, { params: { id: "lead-1" } }); + await waitFor(() => { + expect(screen.getByText(/Open in Google Maps/)).toBeTruthy(); + }); + }); + + it("renders the delete button", async () => { + render(LeadDetailPage, { params: { id: "lead-1" } }); + await waitFor(() => { + expect(screen.getByText("Delete")).toBeTruthy(); + }); + }); + + describe("delete lead", () => { + it("shows confirmation when Delete is clicked", async () => { + render(LeadDetailPage, { params: { id: "lead-1" } }); + + await waitFor(() => { + expect(screen.getByText("Delete")).toBeTruthy(); + }); + + screen.getByText("Delete").click(); + + await waitFor(() => { + expect(screen.getByText("Confirm Delete")).toBeTruthy(); + expect( + screen.getByText("Delete this lead from all projects?") + ).toBeTruthy(); + }); + }); + + it("navigates to /leads after successful delete", async () => { + render(LeadDetailPage, { params: { id: "lead-1" } }); + + await waitFor(() => { + expect(screen.getByText("Delete")).toBeTruthy(); + }); + + screen.getByText("Delete").click(); + + await waitFor(() => { + expect(screen.getByText("Confirm Delete")).toBeTruthy(); + }); + + const confirmButton = screen.getByText("Confirm Delete"); + const form = confirmButton.closest("form")!; + form.dispatchEvent(new Event("submit", { bubbles: true })); + + await waitFor(() => { + expect(mockGoto).toHaveBeenCalledTimes(1); + expect(mockGoto).toHaveBeenCalledWith("/leads"); + }); + }); + + it("shows error message when delete fails", async () => { + const consoleSpy = spyOn(console, "error").mockImplementation( + () => {} + ); + mockDeleteLead.mockImplementation(() => + Promise.reject(new Error("Server error")) + ); + + render(LeadDetailPage, { params: { id: "lead-1" } }); + + await waitFor(() => { + expect(screen.getByText("Delete")).toBeTruthy(); + }); + + screen.getByText("Delete").click(); + + await waitFor(() => { + expect(screen.getByText("Confirm Delete")).toBeTruthy(); + }); + + const confirmButton = screen.getByText("Confirm Delete"); + const form = confirmButton.closest("form")!; + form.dispatchEvent(new Event("submit", { bubbles: true })); + + await waitFor(() => { + expect( + screen.getByText("Failed to delete lead. Please try again.") + ).toBeTruthy(); + }); + + expect(mockGoto).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/apps/web/src/test-helpers/sveltekit-mocks.ts b/apps/web/src/test-helpers/sveltekit-mocks.ts index bc51760..68089df 100644 --- a/apps/web/src/test-helpers/sveltekit-mocks.ts +++ b/apps/web/src/test-helpers/sveltekit-mocks.ts @@ -14,21 +14,34 @@ export function createFormMock(returnValue: unknown = { error: null }) { if (prop === "set") return () => {}; return { as: () => ({ name: "field", value: "" }), + issues: () => null, }; }, - }, + } ); - const enhanceFn = () => ({ - action: "?/action", - method: "POST", - }); - - const addFormApi = (target: (...args: unknown[]) => unknown) => + const addFormApi = unknown>( + target: T + ) => Object.assign(target, { for: () => addFormApi(mock(() => Promise.resolve(returnValue))), pending: 0, - enhance: enhanceFn, + enhance: (cb?: (...args: unknown[]) => unknown) => ({ + action: "?/action", + method: "POST", + onsubmit: async (e: Event) => { + e.preventDefault(); + if (cb) { + const submit = () => { + const result = target() as Promise; + return Object.assign(result, { + updates: () => result, + }); + }; + await cb({ submit }); + } + }, + }), fields: fieldProxy, }); @@ -45,7 +58,9 @@ export function createQueryMock(returnValue: unknown = []) { /** * Creates a mock for SvelteKit remote `command()` functions. */ -export function createCommandMock(returnValue: unknown = { success: true }) { +export function createCommandMock( + returnValue: unknown = { success: true } +) { return mock(() => Promise.resolve(returnValue)); } diff --git a/bun.lock b/bun.lock index 24d48d0..3afd451 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ }, "apps/web": { "name": "@leader/web", - "version": "0.2.3", + "version": "0.2.4", "devDependencies": { "@leader/auth": "workspace:*", "@leader/db": "workspace:*", diff --git a/packages/ui/src/button.svelte b/packages/ui/src/button.svelte index e80c962..f580ab1 100644 --- a/packages/ui/src/button.svelte +++ b/packages/ui/src/button.svelte @@ -1,6 +1,8 @@ diff --git a/packages/ui/src/button.test.ts b/packages/ui/src/button.test.ts index 67a751c..ea411f9 100644 --- a/packages/ui/src/button.test.ts +++ b/packages/ui/src/button.test.ts @@ -50,6 +50,43 @@ describe("Button", () => { it("passes extra attributes to the button", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any render(Button, { "aria-label": "Test" } as any); - expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Test"); + expect(screen.getByRole("button").getAttribute("aria-label")).toBe( + "Test" + ); + }); + + describe("loading prop", () => { + it("renders a spinner when loading=true", () => { + render(Button, { loading: true }); + const btn = screen.getByRole("button"); + expect(btn.querySelector(".animate-spin")).toBeTruthy(); + }); + + it("renders children alongside the spinner when loading=true", () => { + render(Button, { loading: true, children: textSnippet("Find") }); + expect(screen.getByText("Find")).toBeTruthy(); + const btn = screen.getByRole("button"); + expect(btn.querySelector(".animate-spin")).toBeTruthy(); + }); + + it("disables the button when loading=true", () => { + render(Button, { loading: true }); + expect(screen.getByRole("button").hasAttribute("disabled")).toBe( + true + ); + }); + + it("does not render a spinner when loading=false", () => { + render(Button, { loading: false }); + const btn = screen.getByRole("button"); + expect(btn.querySelector(".animate-spin")).toBeNull(); + }); + + it("disables the button when both loading=true and disabled=true", () => { + render(Button, { loading: true, disabled: true }); + expect(screen.getByRole("button").hasAttribute("disabled")).toBe( + true + ); + }); }); });