Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@leader/web",
"private": true,
"version": "0.2.3",
"version": "0.2.4",
"type": "module",
"scripts": {
"dev": "vite dev",
Expand Down
39 changes: 38 additions & 1 deletion apps/web/src/routes/(app)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,43 @@
</header>

<main class="relative">
{@render children()}
<svelte:boundary>
{@render children()}

{#snippet pending()}
<div class="leader-page">
<div class="flex items-center justify-center py-24">
<p
class="font-mono text-xs font-bold tracking-wider text-neutral-400 uppercase"
>
Loading…
</p>
</div>
</div>
{/snippet}

{#snippet failed(error, reset)}
<div class="leader-page">
<div
class="mx-auto max-w-sm space-y-4 py-24 text-center"
>
<p
class="font-mono text-xs font-bold tracking-wider text-red-600 uppercase"
>
Something went wrong
</p>
<p class="font-mono text-xs text-neutral-500">
{error instanceof Error ? error.message : "An unexpected error occurred"}
</p>
<button
onclick={reset}
class="bg-primary-500 px-4 py-2 font-mono text-xs font-bold tracking-wider text-white uppercase transition-colors hover:bg-primary-600"
>
Try again
</button>
</div>
</div>
{/snippet}
</svelte:boundary>
</main>
</div>
42 changes: 42 additions & 0 deletions apps/web/src/routes/(app)/layout.test.ts
Original file line number Diff line number Diff line change
@@ -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 <svelte:boundary> 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(/<svelte:boundary>/);
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("<svelte:boundary>");
const boundaryEnd = layoutSource.indexOf("</svelte:boundary>");
const childrenRender = layoutSource.indexOf("{@render children()}");

expect(childrenRender).toBeGreaterThan(boundaryStart);
expect(childrenRender).toBeLessThan(boundaryEnd);
});
});
4 changes: 2 additions & 2 deletions apps/web/src/routes/(app)/leads-search-form.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,9 @@
<Button
class="h-10 self-start transition-all active:scale-[0.98]"
type="submit"
disabled={isLoading}
loading={isLoading}
>
{isLoading ? "Searching…" : "Find"}
Find
</Button>
</div>

Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/routes/(app)/leads/[id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
createProjectCustomField,
deleteLead,
getLeadData,
getLeads,
updateLeadCore,
upsertLeadCustomFieldValue,
} from "$lib/remote/leads.remote.js";
Expand All @@ -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);
Expand Down
188 changes: 188 additions & 0 deletions apps/web/src/routes/(app)/leads/[id]/lead-detail.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
33 changes: 24 additions & 9 deletions apps/web/src/test-helpers/sveltekit-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T extends (...args: unknown[]) => 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<unknown>;
return Object.assign(result, {
updates: () => result,
});
};
await cb({ submit });
}
},
}),
fields: fieldProxy,
});

Expand All @@ -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));
}

Expand Down
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading