From 4f6d9c3f38bd1adf27f0237a3a0c399522b15497 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 01:06:38 +0800 Subject: [PATCH] test(web): unit tests for accounts-list layout and behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor the /accounts page into a thin data-fetching shell plus a pure presentational AccountsListView. The view has no DB or server- action dependencies (the deleteFormAction is passed in), which makes it directly unit-testable. Tests use react-dom/server's renderToStaticMarkup — no jsdom or DOM testing-library needed. next/link and the radix Dialog are mocked to plain wrappers so the markup is deterministic. Coverage: - one cell per account, each with one main account-card and one delete-card - main card links to /accounts/[id] - account label appears in main card, delete card description, and the destructive confirm dialog - delete card is a - - - {accounts.length > 0 ? ( -
- {accounts.map((account) => ( -
- - - -
- - {account.label} - - -
-
- - {account.phoneNumber ? ( -
- - {account.phoneNumber} -
- ) : ( -

Not paired yet

- )} - {account.lastConnectedAt ? ( -
- - - Last connected{" "} - {account.lastConnectedAt.toLocaleDateString("en-MY", { - timeZone: "Asia/Kuala_Lumpur", - year: "numeric", - month: "short", - day: "numeric", - })} - -
- ) : null} -
-
- - - {/* Dedicated Delete card — entire card is the dialog trigger. */} - - - - - - - Delete this account? - - {account.label} and all its reminders, groups, - and history will be permanently removed. This cannot be undone. - - - -
- - -
-
-
-
-
- ))} -
- ) : ( - - - -
-

No accounts paired yet.

-

- Pair a WhatsApp account to start scheduling reminders. -

-
- -
-
- )} - - ); + return ; } diff --git a/apps/web/src/components/accounts-list-view.test.tsx b/apps/web/src/components/accounts-list-view.test.tsx new file mode 100644 index 0000000..c1aac06 --- /dev/null +++ b/apps/web/src/components/accounts-list-view.test.tsx @@ -0,0 +1,184 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; +import { + AccountsListView, + type AccountsListAccount, +} from "./accounts-list-view"; + +// next/link in node tests can't access the Next router context — render a +// plain anchor with the href so we can assert on it. +vi.mock("next/link", () => ({ + default: ({ href, children, ...rest }: { href: string; children: ReactNode } & Record) => ( + + {children} + + ), +})); + +// Radix Dialog uses portals + client refs. For SSR markup tests we want +// the trigger to be rendered inline (with `asChild`) and the content tree +// to render too, so we can assert dialog text deterministically. +vi.mock("./ui/dialog", () => ({ + Dialog: ({ children }: { children: ReactNode }) => <>{children}, + DialogTrigger: ({ children }: { children: ReactNode; asChild?: boolean }) => <>{children}, + DialogContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>

{children}

, + DialogDescription: ({ children }: { children: ReactNode }) =>
{children}
, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +const mkAccount = (over: Partial = {}): AccountsListAccount => ({ + id: "a-1", + label: "Personal", + status: "connected", + phoneNumber: "+60123456789", + lastConnectedAt: new Date("2026-05-01T10:00:00Z"), + ...over, +}); + +function render(html: string) { + // Count occurrences of a substring. + return { + html, + count(needle: string): number { + let n = 0; + let i = 0; + while (true) { + const j = html.indexOf(needle, i); + if (j === -1) return n; + n++; + i = j + needle.length; + } + }, + has(re: RegExp): boolean { + return re.test(html); + }, + }; +} + +const noopAction = () => {}; + +describe("AccountsListView", () => { + describe("layout — accounts present", () => { + it("renders one cell per account, each with a main card and a delete card", () => { + const accounts = [ + mkAccount({ id: "a-1", label: "Personal" }), + mkAccount({ id: "a-2", label: "Work" }), + mkAccount({ id: "a-3", label: "Support" }), + ]; + + const { count } = render( + renderToStaticMarkup( + , + ), + ); + + expect(count('data-testid="account-cell"')).toBe(3); + expect(count('data-testid="account-card"')).toBe(3); + expect(count('data-testid="account-delete-card"')).toBe(3); + }); + + it("each main card links to /accounts/[id]", () => { + const { has } = render( + renderToStaticMarkup( + , + ), + ); + expect(has(/href="\/accounts\/abc-123"/)).toBe(true); + }); + + it("renders the account label in both the main card and the delete card", () => { + const html = renderToStaticMarkup( + , + ); + // Main card title + expect(html).toContain(">MyBiz<"); + // Delete card description references the account by name + expect(html).toContain("Remove MyBiz and its reminders"); + // Dialog confirmation also uses the label + expect(html).toMatch(/MyBiz<\/strong>/); + }); + + it("delete card is a button with the destructive aria-label", () => { + const html = renderToStaticMarkup( + , + ); + // Order of attributes in the rendered + + + {accounts.length > 0 ? ( +
+ {accounts.map((account) => ( +
+ + + +
+ {account.label} + +
+
+ + {account.phoneNumber ? ( +
+ + {account.phoneNumber} +
+ ) : ( +

Not paired yet

+ )} + {account.lastConnectedAt ? ( +
+ + + Last connected{" "} + {account.lastConnectedAt.toLocaleDateString("en-MY", { + timeZone: "Asia/Kuala_Lumpur", + year: "numeric", + month: "short", + day: "numeric", + })} + +
+ ) : null} +
+
+ + + {/* Dedicated Delete card — entire card is the dialog trigger. */} + + + + + + + Delete this account? + + {account.label} and all its reminders, groups, and + history will be permanently removed. This cannot be undone. + + + +
+ + +
+
+
+
+
+ ))} +
+ ) : ( + + + +
+

No accounts paired yet.

+

+ Pair a WhatsApp account to start scheduling reminders. +

+
+ +
+
+ )} + + ); +} diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 76c330f..6175dd7 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -4,7 +4,10 @@ import path from "node:path"; export default defineConfig({ test: { environment: "node", - include: ["src/**/*.test.ts"], + include: ["src/**/*.test.{ts,tsx}"], + }, + esbuild: { + jsx: "automatic", }, resolve: { alias: {