diff --git a/docs/superpowers/specs/2026-05-29-v5-test-suite-design.md b/docs/superpowers/specs/2026-05-29-v5-test-suite-design.md new file mode 100644 index 0000000..66918df --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-v5-test-suite-design.md @@ -0,0 +1,138 @@ +# v5 Comprehensive Test Suite — Design & Dispatch Plan + +**Date:** 2026-05-29 +**Target:** `v5/` (Next.js 16, React 19, TypeScript) +**Goal:** Add a comprehensive, fully-mocked, CI-friendly test suite — unit, integration, component/UI, and end-to-end — plus a runbook. No live external calls. + +--- + +## 1. Decisions (locked) + +| Decision | Choice | +|---|---| +| External services in tests | **Fully mocked everywhere.** No real Notion / Anthropic / Redis calls. Deterministic, offline, no API keys, no cost. | +| Unit/integration/UI runner | **Vitest** (`@vitejs/plugin-react`, `jsdom`) | +| Component testing | **React Testing Library** + `@testing-library/user-event` + `@testing-library/jest-dom` | +| HTTP mocking | **MSW** (Mock Service Worker) for Notion, Anthropic, Upstash REST | +| E2E | **Playwright** — app runs with **no Notion env vars** so it serves the built-in mock catalog; `/api/chat` intercepted at the network layer | +| CI / coverage gate | **None.** Tooling + tests + runbook only. (Coverage *reporter* is configured, but no threshold gate and no CI workflow.) | + +--- + +## 2. Architecture & key constraints (read before writing tests) + +The scout + source read surfaced these gotchas. Every agent must respect them: + +1. **`server-only` import.** `src/lib/rate-limit.ts` does `import "server-only"`, which throws outside a server runtime. Vitest must alias `server-only` to an empty module (foundation handles this in `vitest.config.ts`). + +2. **`next/cache`.** `catalog.ts` imports `cacheTag`/`cacheLife`; `admin/revalidate/route.ts` imports `revalidateTag`. These only work inside the Next build. Tests mock them with `vi.mock("next/cache", ...)`. Foundation provides a reusable factory in `test/mocks/`. The `"use cache"` *directive string* is harmless under esbuild (treated like `"use client"`). + +3. **Mock-catalog fallback.** `getCatalogTools()` / `getCatalogTool(id)` return the built-in `src/components/mock-catalog.ts` data when `hasNotionCatalogEnv()` is false (i.e. any `NOTION_*` var missing), **and** on any thrown error during fetch. This is the backbone of fully-mocked tests: + - Tests that exercise the **mock path**: ensure `NOTION_*` env is unset. + - Tests that exercise the **real Notion path** (`notion.ts`): `vi.stubEnv` all 7 `NOTION_DB_*` + `NOTION_API_KEY`, then intercept `https://api.notion.com/v1/*` with MSW. + +4. **Env stubbing.** `site-config.ts` reads `process.env.NEXT_PUBLIC_*` / `AUDIENCE` at **module load**. Tests that vary these must `vi.stubEnv(...)` then re-import the module (`vi.resetModules()` + dynamic `import()`). + +5. **Chat route is hard to unit-test directly** — all helpers (`buildSystemPrompt`, `findUnit`, `attachManualsToFirstUserMessage`, etc.) are module-private and the tool `execute` fns are defined inline inside `POST`. **Strategy: mock `ai`'s `streamText`** to capture the `{ system, messages, tools }` it receives, then call `POST(req)` and assert on the captured args. The captured `tools.get_unit_details.execute(...)` / `tools.report_issue.execute(...)` can be invoked directly to test tool behavior. Also mock `@ai-sdk/anthropic` (`anthropic` model factory + `anthropic.tools.webFetch_20250910`). + +6. **MCP route uses a real `McpServer`.** Don't mock it. POST real JSON-RPC envelopes (`initialize`, `tools/list`, `tools/call`) to the `POST` handler and assert the JSON responses. Catalog comes from the mock fallback (no Notion env). `transport` returns JSON (not SSE) because `enableJsonResponse: true`. + +7. **Rate limiter is per-process in-memory** (`Map`) when Upstash env is unset. It's a singleton module — call `vi.resetModules()` between tests that need a clean window, or use distinct keys. The `rateLimitAsync` Upstash path only activates when **both** `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` are set; mock `${URL}/pipeline` with MSW for those tests. + +8. **Parallel-safety rule for dispatch:** only the **foundation agent** edits `package.json` and creates shared config/infra. Every other agent **only creates new test files** in its own slice and **never** runs `npm install`. This keeps the fan-out conflict-free. + +--- + +## 3. Tooling & files the foundation agent creates + +**Dev deps** (`v5/package.json`): +`vitest`, `@vitejs/plugin-react`, `jsdom`, `@testing-library/react`, `@testing-library/user-event`, `@testing-library/jest-dom`, `@testing-library/dom`, `msw`, `@playwright/test`. + +**Config / infra:** +- `v5/vitest.config.ts` — `jsdom` env, `@/` → `src/` alias, alias `server-only` → empty stub, `setupFiles: ["./vitest.setup.ts"]`, coverage (`v8`, reporter only — no thresholds), `exclude` the `e2e/` dir. +- `v5/vitest.setup.ts` — `import "@testing-library/jest-dom"`; start/stop the MSW server (`beforeAll`/`afterEach reset`/`afterAll`); `afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); })`. +- `v5/playwright.config.ts` — `webServer` boots `npm run dev` with `NOTION_API_KEY`/`NOTION_DB_*` **unset** and `reuseExistingServer: true`, `baseURL: http://localhost:3000`, `testDir: ./e2e`. +- `v5/test/msw/handlers.ts` + `server.ts` — MSW handlers for `api.notion.com/v1/databases/:id/query`, `/pages`, `/pages/:id`, `/file_uploads`, the Upstash `/pipeline` endpoint, and the Anthropic web-fetch host (as needed). Handlers return fixture pages and are overridable per-test via `server.use(...)`. +- `v5/test/fixtures/notion.ts` — raw `NotionPage` fixtures (tool/category/location/unit/resource/maintenance-log shapes matching `pageToX` parsers in `notion.ts`) and a `notionQueryResponse(pages, { hasMore })` helper for pagination tests. +- `v5/test/fixtures/catalog.ts` — ready-made `MakerLabTool` / `MakerLabUnit` objects for component tests. +- `v5/test/mocks/next-cache.ts` — factory returning `{ cacheLife: vi.fn(), cacheTag: vi.fn(), revalidateTag: vi.fn() }` for `vi.mock("next/cache", ...)`. +- `v5/test/utils/render.tsx` — RTL render helper wrapping components in `NextIntlClientProvider` with the `messages/en.json` catalog (needed by i18n-aware components). +- `v5/test/README.md` — short "how the harness fits together" note for the other agents (env stubbing patterns, MSW override pattern, the streamText-capture pattern). + +**New `package.json` scripts:** +```jsonc +"test": "vitest run", +"test:watch": "vitest", +"test:coverage": "vitest run --coverage", +"test:e2e": "playwright test", +"test:e2e:ui": "playwright test --ui" +``` + +Foundation must end by running `npm install` and `npx vitest run` against **one trivial smoke test** it writes (e.g. `test/smoke.test.ts` asserting `1+1===3`→fix to 2) to prove the harness boots, then delete the smoke test or leave a real one. + +--- + +## 4. The four layers & coverage + +### Layer A — Unit (`src/lib`, `src/i18n`) +- **`catalog.ts`**: `hasNotionCatalogEnv` true/false; `getCatalogTools` mock-fallback (no env) and Notion path (env + MSW) incl. error→fallback; `getCatalogTool(id)` found / not-found / fallback-by-slug; `getCatalogStats` count; status derivation (`In Use` / all-`Offline` / training); `deriveTrainingLevel` (advanced/authorized keyword, training_required, default); `toCondition`; `resourceLinks` (url + files, skips `published === false`); `groupUnitsByTool`/`groupResourcesByTool`. +- **`notion.ts`**: each `pageToX` parser against fixtures (title/rich_text/select/multi_select/relation/checkbox/url/date/files extraction, header-case fallbacks like `["name","Name"]`); `multiSelectValue` comma-string fallback; `fileAttachments` external vs file URL + stale-host filtering (`pickFreshImageUrl` drops `airtableusercontent.com`); pagination via `next_cursor`; `429` retry-after path; `getNotionEnv` missing-var error; `getNotionEnvContract`; `resolveTools` category/location joins + defaults; `createMaintenanceLog` payload shape (`formatTicketDescription`, select/relation/date/file_upload props); `fetchAllTools` published-filter fallback chain. +- **`rate-limit.ts`**: `rateLimit` allow under limit → deny over limit → window reset after `windowMs`; `remaining` math; throws when Upstash configured (sync path); `rateLimitAsync` in-memory delegation; Upstash path (MSW `/pipeline`) allow/deny + fail-open on non-ok; `getClientIp` `x-forwarded-for` (first of list) / `x-real-ip` / `"unknown"`. +- **`site-config.ts`**: defaults when env unset; overrides when set (re-import after `stubEnv`). +- **`i18n/config.ts`**: `isSupportedLocale`, `getLocaleOption` fallback to `en`, `getDirection` (rtl for `ar`/`he`), `languageNameForLocale`. + +### Layer B — Integration (API routes, MSW + mocks) +- **`/api/chat`**: 429 when rate-limited (assert before any model call); `streamText` (mocked) receives a `system` prompt containing the catalog + the locale section when `locale!=="en"`; `tools` wired (`get_unit_details`, `report_issue`, `web_fetch`); invoking captured `get_unit_details.execute` returns found/not-found shape; `report_issue.execute` calls `createMaintenanceLog` (mocked) and returns `success`/`ticket_id`, and error shape on throw; PDF manual collection caps at 3 / skips >10MB / non-PDF (mock `fetchAllResources` + global `fetch` for PDF bytes); response is a streamed `Response`. +- **`/api/mcp`**: `GET` → 405; missing/invalid bearer when `MCP_TOKEN` set → 401, valid → 200; open when unset; 429 over limit; real JSON-RPC `initialize` handshake; `tools/list` lists the 5 tools; `tools/call` for `list_tools` (+ category/location filter), `search_tools` (hit + miss), `get_tool_details` (found + `isError` not-found), `get_unit_details`, `get_maintenance_history`. +- **`/api/upload-notion`**: 429; 500 when `NOTION_API_KEY` unset; 400 invalid form / missing file / non-image / >18MB / empty; happy path two-stage flow (MSW: create session → send bytes) returns `file_upload_id`; 502 on create/send failure. +- **`/api/admin/revalidate`**: 503 when secret unset; 403 on wrong `x-admin-secret`; 200 + `revalidateTag("catalog", ...)` called (mocked) on correct secret. + +### Layer C — Component / UI (RTL) +- **ToolCard** — renders name/category/training/status badge; links to `/tools/[slug]`. +- **GalleryShell** — renders grid from fixtures; search filters by name/tag/material; materials/location facet filtering; empty-state. +- **UnitsList** — renders units; status/condition badges; maintenance-history interaction/popup. +- **DetailShell** — hero, metadata, PPE, resources/links, units, markdown description. +- **ChatFab** — open/close; renders messages; submit calls `useChat` send (mock `@ai-sdk/react`'s `useChat`); shows assistant reply; passes `toolId`/`locale`. +- **LanguageSelector** — lists 12 locales; selecting sets `NEXT_LOCALE` cookie / calls the locale action (mock `src/i18n/actions`). +- **ThemeToggle** — toggles theme; persists to `localStorage`; reflects current state. +- **GlobalChrome / PrimaryNav** — nav links present and correct; brand lockup uses `siteConfig`; catalog stats shown. + +### Layer D — E2E (Playwright, mock-catalog backend) — single agent +- Gallery loads, shows mock tools. +- Open a tool → detail page shows units + resources; deep-link `/tools/form-4`. +- Search / filter narrows the grid. +- ChatFab: open, type, send → mocked streamed reply (intercept `POST /api/chat` via `page.route` returning a UI-message stream chunk). +- Theme toggle persists across reload. +- Language switch updates visible chrome + ``/`dir`. +- All nav links reachable; unknown tool slug → not-found. + +### Layer E — Runbook +- `v5/TESTING.md`: how to run each layer, the mocking model, how to add a fixture, how to add an MSW override, env conventions, Playwright notes. References this design doc. + +--- + +## 5. Dispatch plan (max parallelism) + +**Phase 1 — blocking:** `Agent 0 · Foundation` (§3). Must finish and prove the harness boots before any other agent starts. + +**Phase 2 — parallel fan-out** (all create only new files in their slice; none touch `package.json` or run `npm install`): + +| # | Agent | Files owned | +|---|---|---| +| A1 | lib: catalog | `src/lib/catalog.test.ts` | +| A2 | lib: notion | `src/lib/notion.test.ts` | +| A3 | lib: rate-limit | `src/lib/rate-limit.test.ts` | +| A4 | lib: config+i18n | `src/lib/site-config.test.ts`, `src/i18n/config.test.ts` | +| B1 | api: chat | `src/app/api/chat/route.test.ts` | +| B2 | api: mcp | `src/app/api/mcp/route.test.ts` | +| B3 | api: upload-notion | `src/app/api/upload-notion/route.test.ts` | +| B4 | api: revalidate | `src/app/api/admin/revalidate/route.test.ts` | +| C1 | ui: cards/gallery | `src/components/ToolCard.test.tsx`, `GalleryShell.test.tsx` | +| C2 | ui: units/detail | `src/components/UnitsList.test.tsx`, `DetailShell.test.tsx` | +| C3 | ui: chat fab | `src/components/ChatFab.test.tsx` | +| C4 | ui: locale/theme | `src/components/LanguageSelector.test.tsx`, `ThemeToggle.test.tsx` | +| C5 | ui: chrome/nav | `src/components/GlobalChrome.test.tsx`, `PrimaryNav.test.tsx` | +| D | e2e (all specs) | `e2e/*.spec.ts` (single agent — avoids dev-server port contention) | +| E | runbook | `v5/TESTING.md` | + +Each Phase-2 agent verifies its own work with `npx vitest run ` (or `npx playwright test` for D) and reports pass/fail with output. They must not claim success without green output. diff --git a/v5/.gitignore b/v5/.gitignore index d5e13b0..ed3d3e8 100644 --- a/v5/.gitignore +++ b/v5/.gitignore @@ -4,3 +4,9 @@ out/ build/ next-env.d.ts tsconfig.tsbuildinfo + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/v5/TESTING.md b/v5/TESTING.md new file mode 100644 index 0000000..f42b946 --- /dev/null +++ b/v5/TESTING.md @@ -0,0 +1,164 @@ +# Testing — v5 + +Practical runbook for the v5 test suite. For the full design rationale and the +per-file coverage matrix, see the design doc: +[`docs/superpowers/specs/2026-05-29-v5-test-suite-design.md`](../docs/superpowers/specs/2026-05-29-v5-test-suite-design.md) +(repo root). For harness internals (fixtures, mocks, render helper, the exact +import paths), see [`test/README.md`](./test/README.md). + +## Overview + +The suite has four layers, all **fully mocked — no live services**. Tests never +hit Notion, Anthropic, or Upstash; there are no API keys and no network cost. +Everything is deterministic and offline. + +| Layer | What | Where it lives | Runner | +|---|---|---|---| +| Unit | `src/lib`, `src/i18n` pure logic | colocated `*.test.ts` next to source | Vitest | +| Integration | API routes (`/api/*`) with HTTP + module mocks | colocated `route.test.ts` next to the route | Vitest | +| Component | React UI via React Testing Library | colocated `*.test.tsx` next to the component | Vitest | +| E2E | Full app against the mock catalog | `e2e/*.spec.ts` | Playwright | + +Shared infra lives in `test/` (MSW server + handlers, fixtures, mocks, the RTL +`render` helper). Tests import from there; **don't edit `package.json` or run +`npm install`** — the harness deps and scripts are already wired. + +## How to run + +```bash +npm test # vitest run — one-shot, runs unit + integration + component +npm run test:watch # vitest watch mode +npm run test:coverage # vitest run --coverage (v8 reporter: text + html) +npm run test:e2e # playwright test (E2E) +npm run test:e2e:ui # playwright test --ui (interactive) +``` + +**Prerequisite for E2E:** run this **once** before your first `npm run test:e2e` +— the harness installs `@playwright/test` but not the browser binary: + +```bash +npx playwright install chromium +``` + +Playwright boots its own dev server (see E2E notes below), so no separate +`npm run dev` is required. + +## The mocking model + +- **MSW (Mock Service Worker)** intercepts all outbound HTTP: Notion + (`api.notion.com/v1/*`), the Anthropic web-fetch host, and the Upstash + `*/pipeline` REST endpoint. The node `server` lives in `test/msw/server.ts`; + default handlers in `test/msw/handlers.ts`. Lifecycle (start / reset / stop) is + managed in `vitest.setup.ts`. **Unhandled outbound requests fail the test by + design** (`onUnhandledRequest: "error"`). +- **`vi.mock("next/cache", …)`** — `catalog.ts` uses `cacheTag`/`cacheLife` and + `admin/revalidate/route.ts` uses `revalidateTag`; these only work inside a Next + build. Mock them with the `nextCacheMock()` factory from + `test/mocks/next-cache.ts`. +- **`server-only`** is aliased to an empty stub (`test/mocks/server-only.ts`) in + `vitest.config.ts`, so `rate-limit.ts` and its importers load under Vitest. +- **Mock-catalog fallback rule.** `getCatalogTools()` / `getCatalogTool(id)` + return the built-in `src/components/mock-catalog.ts` data when + `hasNotionCatalogEnv()` is false — i.e. **any** of the 8 Notion env vars + (`NOTION_API_KEY` + the 7 `NOTION_DB_*`) is missing — and also on any thrown + error during fetch. So: + - **Mock path** (default in tests): leave `NOTION_*` unset → mock data, no MSW + needed. + - **Real Notion path**: `vi.stubEnv` all 8 vars (set `NOTION_DB_*` to the + `DB_IDS` sentinels from `test/msw/handlers.ts`) → MSW serves + `api.notion.com`. See the `stubNotionEnv()` helper in `test/README.md`. + +## How to add a test + +Place tests next to the code they cover. `describe` / `it` / `expect` / `vi` and +the lifecycle hooks are **globals** — no imports needed. + +**Add a unit test** — create `src/lib/.test.ts`: + +```ts +import { isSupportedLocale } from "@/i18n/config"; + +it("recognizes a supported locale", () => { + expect(isSupportedLocale("en")).toBe(true); +}); +``` + +**Add a component test** — create `src/components/.test.tsx` and use the +custom `render` (it wraps the component in `NextIntlClientProvider` with the +`en` messages, which i18n-aware components need): + +```ts +import { render, screen, userEvent } from "../../test/utils/render"; +import { ToolCard } from "./ToolCard"; +import { availableTool } from "../../test/fixtures/catalog"; + +it("renders the tool name", () => { + render(); + expect(screen.getByText(availableTool.name)).toBeInTheDocument(); +}); +``` + +**Add an MSW override** — defaults live in `handlers.ts`; override per test with +`server.use(...)` (the `afterEach` reset undoes it): + +```ts +import { server } from "../../test/msw/server"; +import { http, HttpResponse } from "msw"; + +server.use( + http.post("https://api.notion.com/v1/databases/:id/query", () => + HttpResponse.json({ object: "error" }, { status: 500 }) + ) +); +``` + +**Add a fixture** — extend `test/fixtures/notion.ts` (raw `NotionPage` shapes + +the `notionQueryResponse(pages, { hasMore })` pagination helper) or +`test/fixtures/catalog.ts` (resolved `MakerLabTool` / `MakerLabUnit` objects for +component tests). Reuse the existing exports before adding new ones. + +**Env stubbing for module-load-time reads.** `site-config.ts` reads +`NEXT_PUBLIC_*` / `AUDIENCE` at module load, and `rate-limit.ts` computes its +Upstash branch from `UPSTASH_*` at load. `vi.stubEnv` after import won't change +those captured values — stub, then `vi.resetModules()`, then dynamic `import()`: + +```ts +it("honors NEXT_PUBLIC_SITE_NAME override", async () => { + vi.stubEnv("NEXT_PUBLIC_SITE_NAME", "Acme Lab"); + vi.resetModules(); + const { siteConfig } = await import("@/lib/site-config"); + expect(siteConfig.name).toBe("Acme Lab"); +}); +``` + +`vi.unstubAllEnvs()` and `vi.restoreAllMocks()` run automatically after every +test (the setup file). The in-memory rate limiter is a per-process singleton +`Map` — use distinct keys per test, or `resetModules()` + re-import for a fresh +window. + +**streamText-capture pattern (chat route).** The chat route's tool `execute` +functions are inline and its helpers are module-private, so don't unit-test them +directly. Instead mock `ai`'s `streamText` (spreading `...actual`) to capture +the `{ system, messages, tools }` it receives, call `POST(req)`, and assert on +the captured args — you can also invoke the captured `tools.*.execute(...)` +directly. Also mock `@ai-sdk/anthropic`. The full verified snippet is in +[`test/README.md`](./test/README.md#streamtext-capture-pattern-chat-route--verified). + +## E2E notes + +- Playwright's `webServer` boots `npm run dev` with all `NOTION_*` vars set to + `""`, so `hasNotionCatalogEnv()` is false and the app serves the built-in mock + catalog (`src/components/mock-catalog.ts`) regardless of your dev shell's + environment. `testDir` is `./e2e`; `baseURL` is `http://localhost:3000`. +- `/api/chat` is intercepted **inside each spec** at the network layer via + `page.route()` returning a UI-message stream chunk — no real Anthropic call. +- `reuseExistingServer: true` (when not in CI) means an already-running dev + server on port 3000 is reused instead of starting a fresh one; in CI a new + server is always spawned. If you have a stale dev server with the wrong env, + stop it so Playwright boots its own with Notion unset. + +## Coverage + +`npm run test:coverage` produces a `v8` coverage report (`text` to stdout + +`html`). There is **no enforced threshold** and **no CI workflow** — by design. +Coverage is a diagnostic for developers, not a gate. diff --git a/v5/e2e/.gitkeep b/v5/e2e/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/v5/e2e/chat.spec.ts b/v5/e2e/chat.spec.ts new file mode 100644 index 0000000..ef120c1 --- /dev/null +++ b/v5/e2e/chat.spec.ts @@ -0,0 +1,84 @@ +import { test, expect } from "@playwright/test"; + +// ChatFab uses @ai-sdk/react's useChat with a DefaultChatTransport posting to +// /api/chat. We intercept that request with page.route() so no real Anthropic +// call is made, and return a valid AI SDK v6 UI message stream. +// +// --- Mock stream shape (AI SDK v6 "x-vercel-ai-ui-message-stream: v1") --- +// The transport reads an SSE body: each event is a `data: \n\n` line and +// the stream terminates with `data: [DONE]\n\n`. The minimal chunk sequence +// useChat needs to render an assistant text bubble is: +// { type: "start" } +// { type: "text-start", id: "0" } +// { type: "text-delta", id: "0", delta: "..." } (one or more) +// { type: "text-end", id: "0" } +// { type: "finish" } +// Response headers MUST include content-type: text/event-stream and +// x-vercel-ai-ui-message-stream: v1 (matches the real route's +// createUIMessageStreamResponse output). + +const ASSISTANT_REPLY = "The Form 4 is a resin SLA printer in the MakerLab."; + +function uiMessageStreamBody(text: string): string { + const chunks = [ + { type: "start" }, + { type: "text-start", id: "0" }, + { type: "text-delta", id: "0", delta: text }, + { type: "text-end", id: "0" }, + { type: "finish" }, + ]; + const lines = chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`).join(""); + return `${lines}data: [DONE]\n\n`; +} + +test.describe("Chat assistant", () => { + test("opens, sends a message, and renders a streamed assistant reply", async ({ + page, + }) => { + // Intercept BEFORE triggering the send so the route is never hit. + await page.route("**/api/chat", async (route) => { + await route.fulfill({ + status: 200, + headers: { + "content-type": "text/event-stream", + "x-vercel-ai-ui-message-stream": "v1", + }, + body: uiMessageStreamBody(ASSISTANT_REPLY), + }); + }); + + await page.goto("/"); + + // Open the chat sheet via the FAB (aria-label "Open MakerLab assistant"). + await page.getByRole("button", { name: "Open MakerLab assistant" }).click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByRole("heading", { name: "MAKERLAB ASSISTANT" }) + ).toBeVisible(); + + // Type into the composer (aria-label "Ask the lab console") and submit. + const input = dialog.getByRole("textbox", { name: "Ask the lab console" }); + await input.fill("What is the Form 4?"); + await dialog.getByRole("button", { name: "Send" }).click(); + + // The user's message renders. + await expect(dialog.getByText("What is the Form 4?")).toBeVisible(); + + // The mocked assistant reply renders (markdown -> visible text). + await expect(dialog.getByText(ASSISTANT_REPLY)).toBeVisible(); + }); + + test("the chat FAB toggles the panel closed", async ({ page }) => { + await page.goto("/"); + + await page.getByRole("button", { name: "Open MakerLab assistant" }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + + // Close button (aria-label "Close assistant"). + await dialog.getByRole("button", { name: "Close assistant" }).click(); + await expect(dialog).toHaveCount(0); + }); +}); diff --git a/v5/e2e/gallery.spec.ts b/v5/e2e/gallery.spec.ts new file mode 100644 index 0000000..cc71e35 --- /dev/null +++ b/v5/e2e/gallery.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from "@playwright/test"; + +// The app boots with NOTION_* unset (see playwright.config.ts webServer.env), +// so getCatalogTools() serves the built-in mock catalog +// (src/components/mock-catalog.ts): "Form 4" and "Trotec Speedy 400". + +test.describe("Gallery", () => { + test("loads at / and shows the mock-catalog tools", async ({ page }) => { + await page.goto("/"); + + // Gallery heading from messages/en.json gallery.title => "TOOLS // MACHINES". + await expect( + page.getByRole("heading", { name: "TOOLS // MACHINES" }) + ).toBeVisible(); + + // Both mock tools render as cards (ToolCard renders an

with the name). + await expect( + page.getByRole("heading", { name: "Form 4", level: 2 }) + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Trotec Speedy 400", level: 2 }) + ).toBeVisible(); + }); + + test("each tool card links to its detail route", async ({ page }) => { + await page.goto("/"); + + const formCard = page.getByRole("link").filter({ + has: page.getByRole("heading", { name: "Form 4", level: 2 }), + }); + await expect(formCard).toHaveAttribute("href", "/tools/form-4"); + }); + + test("shows the catalog status strip count", async ({ page }) => { + await page.goto("/"); + // GlobalChrome status strip renders status.toolsInInventory: + // "{count} TOOLS IN INVENTORY" with count=2 (mock catalog has 2 tools). + await expect(page.getByText("2 TOOLS IN INVENTORY")).toBeVisible(); + }); +}); diff --git a/v5/e2e/navigation.spec.ts b/v5/e2e/navigation.spec.ts new file mode 100644 index 0000000..b6c878c --- /dev/null +++ b/v5/e2e/navigation.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from "@playwright/test"; + +// PrimaryNav links: / (TOOLS), /projects (PROJECTS), /about (ABOUT). +// An unknown tool slug calls notFound() -> Next's not-found page. + +test.describe("Navigation", () => { + test("all primary nav links are reachable", async ({ page }) => { + await page.goto("/"); + + const nav = page.getByRole("navigation", { name: "Primary navigation" }); + + await expect( + nav.getByRole("link", { name: "TOOLS", exact: true }) + ).toHaveAttribute("href", "/"); + + await nav.getByRole("link", { name: "PROJECTS" }).click(); + await expect(page).toHaveURL(/\/projects$/); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + + await page + .getByRole("navigation", { name: "Primary navigation" }) + .getByRole("link", { name: "ABOUT" }) + .click(); + await expect(page).toHaveURL(/\/about$/); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + + // Brand lockup returns home. + await page.getByRole("link", { name: /MakerLab/i }).first().click(); + await expect(page).toHaveURL(/\/$/); + }); + + test("an unknown tool slug renders the not-found page", async ({ page }) => { + await page.goto("/tools/does-not-exist"); + + // notFound() renders Next's default not-found page. Under `npm run dev` + // (what the E2E webServer runs) Next streams it with HTTP 200, so we assert + // on the rendered content rather than the status code. (Production builds + // return a real 404.) + await expect( + page.getByText(/404|not found|could not be found/i).first() + ).toBeVisible(); + + // The known tool name must NOT appear. + await expect( + page.getByRole("heading", { name: "Form 4", level: 1 }) + ).toHaveCount(0); + }); +}); diff --git a/v5/e2e/search.spec.ts b/v5/e2e/search.spec.ts new file mode 100644 index 0000000..fca84ea --- /dev/null +++ b/v5/e2e/search.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from "@playwright/test"; + +// GalleryShell has a search input (aria-label from gallery.searchAria) that +// fuzzy-ranks tools via match-sorter, plus single-select facet chips for +// category / materials / location. Mock catalog: "Form 4" (3D Printing, +// Standard resin) and "Trotec Speedy 400" (Laser, Acrylic). + +test.describe("Search and filter", () => { + test("typing a query narrows the grid to the matching tool", async ({ + page, + }) => { + await page.goto("/"); + + const form = page.getByRole("heading", { name: "Form 4", level: 2 }); + const trotec = page.getByRole("heading", { + name: "Trotec Speedy 400", + level: 2, + }); + + await expect(form).toBeVisible(); + await expect(trotec).toBeVisible(); + + // Search input is labelled by gallery.searchAria => "Search inventory". + // match-sorter is fuzzy and ranks across long description text, so most + // queries surface both tools; "Speedy" is distinctive enough to isolate + // the Trotec Speedy 400 and drop Form 4 entirely. + const search = page.getByRole("textbox", { name: "Search inventory" }); + await search.fill("Speedy"); + + await expect(trotec).toBeVisible(); + await expect(form).toHaveCount(0); + }); + + test("a no-match query shows the empty state", async ({ page }) => { + await page.goto("/"); + + await page + .getByRole("textbox", { name: "Search inventory" }) + .fill("zzzznotarealtool"); + + // gallery.empty => "No matching tools found." + await expect(page.getByText("No matching tools found.")).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Form 4", level: 2 }) + ).toHaveCount(0); + }); + + test("a category facet chip filters the grid", async ({ page }) => { + await page.goto("/"); + + // Category chips are buttons labelled with the category name. Selecting + // "Laser" should keep Trotec and drop Form 4. + await page.getByRole("button", { name: "Laser", exact: true }).click(); + + await expect( + page.getByRole("heading", { name: "Trotec Speedy 400", level: 2 }) + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Form 4", level: 2 }) + ).toHaveCount(0); + }); +}); diff --git a/v5/e2e/theme-i18n.spec.ts b/v5/e2e/theme-i18n.spec.ts new file mode 100644 index 0000000..05847c9 --- /dev/null +++ b/v5/e2e/theme-i18n.spec.ts @@ -0,0 +1,86 @@ +import { test, expect } from "@playwright/test"; + +// ThemeToggle cycles system -> light -> dark, writing data-theme on and +// persisting to localStorage["theme"]. ThemeScript re-applies it before paint. +// +// LanguageSelector is a with all 12 locales. + const select = screen.getByRole("combobox", { name: "Select language" }); + expect(select).toBeInTheDocument(); + expect(screen.getAllByRole("option")).toHaveLength(12); + // ThemeToggle renders the cycle button. + expect( + screen.getByRole("button", { + name: "Cycle color theme (system → light → dark)", + }) + ).toBeInTheDocument(); + }); + + it("labels the lab-status strip from the catalog", () => { + render(); + expect(screen.getByLabelText("Lab status")).toBeInTheDocument(); + }); +}); diff --git a/v5/src/components/LanguageSelector.test.tsx b/v5/src/components/LanguageSelector.test.tsx new file mode 100644 index 0000000..9640376 --- /dev/null +++ b/v5/src/components/LanguageSelector.test.tsx @@ -0,0 +1,77 @@ +import { render, screen, userEvent } from "../../test/utils/render"; +import { LanguageSelector } from "./LanguageSelector"; +import { LOCALES } from "../i18n/config"; + +// The component calls the `changeLocale` server action and then `router.refresh()`. +const changeLocale = vi.fn<(locale: string) => Promise>(); +const refresh = vi.fn(); + +vi.mock("@/i18n/actions", () => ({ + changeLocale: (locale: string) => changeLocale(locale), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh }), +})); + +describe("LanguageSelector", () => { + beforeEach(() => { + changeLocale.mockClear(); + refresh.mockClear(); + }); + + it("renders a select with all 12 locale options using their endonym labels", () => { + render(); + + const select = screen.getByRole("combobox", { name: "Select language" }); + expect(select).toBeInTheDocument(); + + const options = screen.getAllByRole("option"); + expect(options).toHaveLength(LOCALES.length); + expect(LOCALES).toHaveLength(12); + + for (const locale of LOCALES) { + const option = screen.getByRole("option", { name: locale.label }); + expect(option).toHaveValue(locale.code); + } + }); + + it("reflects the active locale as the selected value", () => { + render(, { locale: "fr" }); + const select = screen.getByRole("combobox", { name: "Select language" }); + expect(select).toHaveValue("fr"); + }); + + it("calls changeLocale with the chosen code and refreshes the router", async () => { + const user = userEvent.setup(); + render(); // active locale defaults to "en" + + const select = screen.getByRole("combobox", { name: "Select language" }); + await user.selectOptions(select, "es"); + + expect(changeLocale).toHaveBeenCalledTimes(1); + expect(changeLocale).toHaveBeenCalledWith("es"); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("handles selecting a non-Latin locale code", async () => { + const user = userEvent.setup(); + render(); + + const select = screen.getByRole("combobox", { name: "Select language" }); + await user.selectOptions(select, "zh-CN"); + + expect(changeLocale).toHaveBeenCalledWith("zh-CN"); + }); + + it("does not call the action when re-selecting the already-active locale", async () => { + const user = userEvent.setup(); + render(, { locale: "ko" }); + + const select = screen.getByRole("combobox", { name: "Select language" }); + await user.selectOptions(select, "ko"); + + expect(changeLocale).not.toHaveBeenCalled(); + expect(refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/v5/src/components/PrimaryNav.test.tsx b/v5/src/components/PrimaryNav.test.tsx new file mode 100644 index 0000000..893640b --- /dev/null +++ b/v5/src/components/PrimaryNav.test.tsx @@ -0,0 +1,89 @@ +import { PrimaryNav } from "./PrimaryNav"; +import { render, screen } from "../../test/utils/render"; + +// PrimaryNav is a client component that reads the active route from +// `usePathname`. Mock `next/navigation` so each test can control the path +// and assert the active-link treatment (`is-active` class). +const usePathname = vi.fn(() => "/"); +vi.mock("next/navigation", () => ({ + usePathname: () => usePathname(), +})); + +// en.json: nav.tools = "TOOLS", nav.projects = "PROJECTS", nav.about = "ABOUT". +describe("PrimaryNav", () => { + beforeEach(() => { + usePathname.mockReturnValue("/"); + }); + + it("renders the three primary nav links with correct hrefs", () => { + render(); + + const tools = screen.getByRole("link", { name: "TOOLS" }); + const projects = screen.getByRole("link", { name: "PROJECTS" }); + const about = screen.getByRole("link", { name: "ABOUT" }); + + expect(tools).toHaveAttribute("href", "/"); + expect(projects).toHaveAttribute("href", "/projects"); + expect(about).toHaveAttribute("href", "/about"); + }); + + it("labels the nav landmark from the translation catalog", () => { + render(); + expect( + screen.getByRole("navigation", { name: "Primary navigation" }) + ).toBeInTheDocument(); + }); + + it("marks the Tools link active on the home route", () => { + usePathname.mockReturnValue("/"); + render(); + + expect(screen.getByRole("link", { name: "TOOLS" })).toHaveClass("is-active"); + expect(screen.getByRole("link", { name: "PROJECTS" })).not.toHaveClass( + "is-active" + ); + expect(screen.getByRole("link", { name: "ABOUT" })).not.toHaveClass( + "is-active" + ); + }); + + it("treats any /tools/* path as the active Tools link", () => { + usePathname.mockReturnValue("/tools/form-4"); + render(); + + expect(screen.getByRole("link", { name: "TOOLS" })).toHaveClass("is-active"); + expect(screen.getByRole("link", { name: "PROJECTS" })).not.toHaveClass( + "is-active" + ); + }); + + it("marks the Projects link active on /projects routes", () => { + usePathname.mockReturnValue("/projects/123"); + render(); + + expect(screen.getByRole("link", { name: "PROJECTS" })).toHaveClass( + "is-active" + ); + expect(screen.getByRole("link", { name: "TOOLS" })).not.toHaveClass( + "is-active" + ); + }); + + it("marks the About link active on /about routes", () => { + usePathname.mockReturnValue("/about"); + render(); + + expect(screen.getByRole("link", { name: "ABOUT" })).toHaveClass("is-active"); + expect(screen.getByRole("link", { name: "TOOLS" })).not.toHaveClass( + "is-active" + ); + }); + + it("falls back to '/' (Tools active) when usePathname returns null", () => { + // The component does `usePathname() || "/"`. + usePathname.mockReturnValue(null as unknown as string); + render(); + + expect(screen.getByRole("link", { name: "TOOLS" })).toHaveClass("is-active"); + }); +}); diff --git a/v5/src/components/ThemeToggle.test.tsx b/v5/src/components/ThemeToggle.test.tsx new file mode 100644 index 0000000..5ae68d7 --- /dev/null +++ b/v5/src/components/ThemeToggle.test.tsx @@ -0,0 +1,115 @@ +import { render, screen, userEvent } from "../../test/utils/render"; +import { ThemeToggle } from "./ThemeToggle"; + +// Mirrors the constant in ThemeToggle.tsx. +const STORAGE_KEY = "theme"; + +/** + * This environment's `window.localStorage` is a bare object (jsdom's Storage is + * clobbered by Node's experimental `--localstorage-file`), so getItem/setItem/ + * removeItem are missing. Install a self-contained in-memory Storage shim on + * `window` for these tests — the component reads `window.localStorage` directly. + */ +function installLocalStorageShim() { + const store = new Map(); + const shim: Storage = { + get length() { + return store.size; + }, + clear: () => store.clear(), + getItem: (key: string) => (store.has(key) ? store.get(key)! : null), + key: (index: number) => Array.from(store.keys())[index] ?? null, + removeItem: (key: string) => void store.delete(key), + setItem: (key: string, value: string) => void store.set(key, String(value)), + }; + Object.defineProperty(window, "localStorage", { + value: shim, + configurable: true, + writable: true, + }); +} + +describe("ThemeToggle", () => { + beforeEach(() => { + installLocalStorageShim(); + document.documentElement.removeAttribute("data-theme"); + }); + + afterEach(() => { + window.localStorage.clear(); + document.documentElement.removeAttribute("data-theme"); + }); + + it("renders a labelled toggle button", () => { + render(); + const button = screen.getByRole("button", { + name: "Cycle color theme (system → light → dark)", + }); + expect(button).toBeInTheDocument(); + }); + + it("cycles system → light on first click and persists to localStorage", async () => { + const user = userEvent.setup(); + render(); + + // No data-theme attribute on mount → readChoice() returns "system". + await user.click(screen.getByRole("button")); + + expect(document.documentElement.getAttribute("data-theme")).toBe("light"); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe("light"); + }); + + it("cycles light → dark and persists the new value", async () => { + const user = userEvent.setup(); + // Start from the "light" state. + document.documentElement.setAttribute("data-theme", "light"); + window.localStorage.setItem(STORAGE_KEY, "light"); + + render(); + await user.click(screen.getByRole("button")); + + expect(document.documentElement.getAttribute("data-theme")).toBe("dark"); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe("dark"); + }); + + it("cycles dark → system, clearing the attribute and the stored value", async () => { + const user = userEvent.setup(); + document.documentElement.setAttribute("data-theme", "dark"); + window.localStorage.setItem(STORAGE_KEY, "dark"); + + render(); + await user.click(screen.getByRole("button")); + + expect(document.documentElement.hasAttribute("data-theme")).toBe(false); + expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it("walks the full system → light → dark → system cycle across clicks", async () => { + const user = userEvent.setup(); + render(); + const button = screen.getByRole("button"); + + await user.click(button); + expect(document.documentElement.getAttribute("data-theme")).toBe("light"); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe("light"); + + await user.click(button); + expect(document.documentElement.getAttribute("data-theme")).toBe("dark"); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe("dark"); + + await user.click(button); + expect(document.documentElement.hasAttribute("data-theme")).toBe(false); + expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it("reads the current theme from the documentElement on mount", async () => { + const user = userEvent.setup(); + // Existing "light" state should advance to "dark" (not back to "light"). + document.documentElement.setAttribute("data-theme", "light"); + + render(); + await user.click(screen.getByRole("button")); + + expect(document.documentElement.getAttribute("data-theme")).toBe("dark"); + }); +}); diff --git a/v5/src/components/ToolCard.test.tsx b/v5/src/components/ToolCard.test.tsx new file mode 100644 index 0000000..5d88936 --- /dev/null +++ b/v5/src/components/ToolCard.test.tsx @@ -0,0 +1,91 @@ +import { render, screen } from "../../test/utils/render"; +import { ToolCard } from "./ToolCard"; +import { + availableTool, + inUseTool, + offlineTool, +} from "../../test/fixtures/catalog"; + +// next/image and next/link render fine in jsdom, but mocking them to plain +// elements keeps these unit tests deterministic (no Next image-optimization +// internals, no router context) and makes the rendered DOM trivial to assert. +vi.mock("next/image", () => ({ + __esModule: true, + // Strip Next-only props (fill, sizes) so React doesn't warn about unknown + // attributes on a plain . + default: ({ src, alt }: { src: string; alt: string }) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ), +})); + +vi.mock("next/link", () => ({ + __esModule: true, + default: ({ + href, + children, + ...rest + }: { + href: string; + children: React.ReactNode; + }) => ( + + {children} + + ), +})); + +describe("ToolCard", () => { + it("renders the tool name and category", () => { + render(); + + expect( + screen.getByRole("heading", { name: "Bandsaw" }) + ).toBeInTheDocument(); + expect(screen.getByText("Woodworking")).toBeInTheDocument(); + }); + + it("links to the tool detail page by slug", () => { + render(); + + const link = screen.getByRole("link", { name: /Bandsaw/ }); + expect(link).toHaveAttribute("href", "/tools/bandsaw"); + }); + + it("renders the tool image (decorative alt) with the tool's imageSrc", () => { + render(); + + // The component renders a decorative image with an empty alt; assert the + // source rather than an accessible name (which the component does not set). + const img = document.querySelector("img"); + expect(img).not.toBeNull(); + expect(img).toHaveAttribute("src", availableTool.imageSrc); + expect(img).toHaveAttribute("alt", ""); + }); + + it("shows the 'In use' status dot for an In Use tool", () => { + render(); + + expect(screen.getByLabelText("In use")).toBeInTheDocument(); + }); + + it("does not show the 'In use' dot for an Available tool", () => { + render(); + + expect(screen.queryByLabelText("In use")).not.toBeInTheDocument(); + }); + + it("does not show the 'In use' dot for an Offline tool", () => { + render(); + + expect(screen.queryByLabelText("In use")).not.toBeInTheDocument(); + // Offline tools still render name + category + link like any other card. + expect( + screen.getByRole("heading", { name: "Trotec Speedy 400" }) + ).toBeInTheDocument(); + expect(screen.getByRole("link")).toHaveAttribute( + "href", + "/tools/trotec-speedy-400" + ); + }); +}); diff --git a/v5/src/components/UnitsList.test.tsx b/v5/src/components/UnitsList.test.tsx new file mode 100644 index 0000000..ff512fc --- /dev/null +++ b/v5/src/components/UnitsList.test.tsx @@ -0,0 +1,90 @@ +import { render, screen, within } from "../../test/utils/render"; +import { UnitsList } from "./UnitsList"; +import { + availableUnit, + inUseUnit, + offlineUnit, +} from "../../test/fixtures/catalog"; +import type { MakerLabUnit } from "./catalog-types"; + +describe("UnitsList", () => { + it("renders the panel headings", () => { + render(); + expect(screen.getByText("LINKED UNITS")).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Physical Machines" }) + ).toBeInTheDocument(); + }); + + it("renders a row per unit with name, location, serial, status, and condition", () => { + const units: MakerLabUnit[] = [availableUnit, inUseUnit, offlineUnit]; + render(); + + for (const unit of units) { + // name is an

+ expect( + screen.getByRole("heading", { name: unit.name }) + ).toBeInTheDocument(); + // location, serial, condition appear as text + expect(screen.getByText(unit.serial)).toBeInTheDocument(); + } + + // location can repeat across units, so just confirm each is present at least once + expect(screen.getByText(availableUnit.location)).toBeInTheDocument(); + expect(screen.getByText(inUseUnit.location)).toBeInTheDocument(); + expect(screen.getByText(offlineUnit.location)).toBeInTheDocument(); + }); + + it("scopes status/serial/condition values to the correct unit row", () => { + render(); + + const heading = screen.getByRole("heading", { name: availableUnit.name }); + const row = heading.closest("article.unit-row") as HTMLElement; + expect(row).not.toBeNull(); + + const scoped = within(row); + expect(scoped.getByText("Status")).toBeInTheDocument(); + expect(scoped.getByText(availableUnit.status)).toBeInTheDocument(); + expect(scoped.getByText("Serial")).toBeInTheDocument(); + expect(scoped.getByText(availableUnit.serial)).toBeInTheDocument(); + expect(scoped.getByText("Condition")).toBeInTheDocument(); + expect(scoped.getByText(availableUnit.condition)).toBeInTheDocument(); + }); + + it("uses the live-dot status indicator only for In Use units", () => { + const { container } = render(); + // In Use -> live-dot + expect(container.querySelector(".live-dot")).not.toBeNull(); + expect(container.querySelector(".status-square")).toBeNull(); + }); + + it("uses the status-square indicator for non-In-Use units", () => { + const { container } = render(); + // Available -> status-square + expect(container.querySelector(".status-square")).not.toBeNull(); + expect(container.querySelector(".live-dot")).toBeNull(); + }); + + it("reflects each unit's status text from the data", () => { + render(); + expect(screen.getByText("Available")).toBeInTheDocument(); + expect(screen.getByText("In Use")).toBeInTheDocument(); + // "Offline" is both a status and the offline unit's condition -> 2 matches + expect(screen.getAllByText("Offline")).toHaveLength(2); + }); + + it("renders condition values from the data", () => { + render(); + expect(screen.getByText("Excellent")).toBeInTheDocument(); + expect(screen.getByText("Good")).toBeInTheDocument(); + }); + + it("renders nothing in the list when given an empty units array", () => { + const { container } = render(); + // Headings still render, but no unit rows exist. + expect(container.querySelectorAll("article.unit-row")).toHaveLength(0); + expect( + screen.getByRole("heading", { name: "Physical Machines" }) + ).toBeInTheDocument(); + }); +}); diff --git a/v5/src/i18n/config.test.ts b/v5/src/i18n/config.test.ts new file mode 100644 index 0000000..4292773 --- /dev/null +++ b/v5/src/i18n/config.test.ts @@ -0,0 +1,97 @@ +import { + LOCALES, + LOCALE_CODES, + DEFAULT_LOCALE, + isSupportedLocale, + getLocaleOption, + getDirection, + languageNameForLocale, +} from "@/i18n/config"; + +describe("i18n/config", () => { + describe("LOCALES / LOCALE_CODES", () => { + it("has 12 locales", () => { + expect(LOCALES).toHaveLength(12); + expect(LOCALE_CODES).toHaveLength(12); + }); + + it("includes the expected codes", () => { + expect(LOCALE_CODES).toEqual([ + "en", + "zh-CN", + "es", + "hi", + "ko", + "ar", + "fr", + "pt-BR", + "ru", + "tr", + "ja", + "he", + ]); + }); + + it("defaults to en", () => { + expect(DEFAULT_LOCALE).toBe("en"); + expect(LOCALES[0].code).toBe("en"); + }); + }); + + describe("isSupportedLocale", () => { + it("is true for supported codes", () => { + expect(isSupportedLocale("en")).toBe(true); + expect(isSupportedLocale("ar")).toBe(true); + expect(isSupportedLocale("zh-CN")).toBe(true); + }); + + it("is false for unsupported / empty / nullish values", () => { + expect(isSupportedLocale(undefined)).toBe(false); + expect(isSupportedLocale(null)).toBe(false); + expect(isSupportedLocale("")).toBe(false); + expect(isSupportedLocale("xx")).toBe(false); + }); + }); + + describe("getLocaleOption", () => { + it("returns the matching option", () => { + const ar = getLocaleOption("ar"); + expect(ar.code).toBe("ar"); + expect(ar.englishName).toBe("Arabic"); + expect(ar.dir).toBe("rtl"); + }); + + it("falls back to LOCALES[0] (en) for an unknown code", () => { + expect(getLocaleOption("xx")).toBe(LOCALES[0]); + expect(getLocaleOption("xx").code).toBe("en"); + }); + }); + + describe("getDirection", () => { + it("returns rtl for ar and he", () => { + expect(getDirection("ar")).toBe("rtl"); + expect(getDirection("he")).toBe("rtl"); + }); + + it("returns ltr for ltr languages", () => { + expect(getDirection("en")).toBe("ltr"); + expect(getDirection("es")).toBe("ltr"); + expect(getDirection("zh-CN")).toBe("ltr"); + }); + + it("returns ltr (en fallback) for an unknown code", () => { + expect(getDirection("xx")).toBe("ltr"); + }); + }); + + describe("languageNameForLocale", () => { + it("maps known codes to their English names", () => { + expect(languageNameForLocale("ar")).toBe("Arabic"); + expect(languageNameForLocale("zh-CN")).toBe("Simplified Chinese"); + }); + + it("falls back to English for an unknown code", () => { + expect(languageNameForLocale("xx")).toBe("English"); + }); + }); +}); diff --git a/v5/src/lib/catalog.test.ts b/v5/src/lib/catalog.test.ts new file mode 100644 index 0000000..2589baf --- /dev/null +++ b/v5/src/lib/catalog.test.ts @@ -0,0 +1,520 @@ +import { http, HttpResponse } from "msw"; + +import { server } from "../../test/msw/server"; +import { DB_IDS } from "../../test/msw/handlers"; +import { + categoriesPage, + locationsPage, + notionQueryResponse, + resourcesPage, + toolsPage, + unitsPage, + selectProp, + relationProp, + titleProp, + richTextProp, + checkboxProp, + multiSelectProp, + filesProp, + externalFile, + hostedFile, + urlProp, + STALE_IMAGE_URL, + FRESH_IMAGE_URL, + type NotionPageFixture, +} from "../../test/fixtures/notion"; +import { nextCacheMock } from "../../test/mocks/next-cache"; + +vi.mock("next/cache", () => nextCacheMock()); + +// ── Helpers ───────────────────────────────────────────────────────── + +function stubNotionEnv() { + vi.stubEnv("NOTION_API_KEY", "secret_test"); + vi.stubEnv("NOTION_DB_TOOLS", DB_IDS.tools); + vi.stubEnv("NOTION_DB_CATEGORIES", DB_IDS.categories); + vi.stubEnv("NOTION_DB_LOCATIONS", DB_IDS.locations); + vi.stubEnv("NOTION_DB_UNITS", DB_IDS.units); + vi.stubEnv("NOTION_DB_RESOURCES", DB_IDS.resources); + vi.stubEnv("NOTION_DB_MAINTENANCE_LOGS", DB_IDS.maintenance_logs); + vi.stubEnv("NOTION_DB_FLAGS", DB_IDS.flags); +} + +function unsetNotionEnv() { + vi.stubEnv("NOTION_API_KEY", ""); + vi.stubEnv("NOTION_DB_TOOLS", ""); + vi.stubEnv("NOTION_DB_CATEGORIES", ""); + vi.stubEnv("NOTION_DB_LOCATIONS", ""); + vi.stubEnv("NOTION_DB_UNITS", ""); + vi.stubEnv("NOTION_DB_RESOURCES", ""); + vi.stubEnv("NOTION_DB_MAINTENANCE_LOGS", ""); + vi.stubEnv("NOTION_DB_FLAGS", ""); +} + +// Route the standard catalog DBs to a custom set of pages. tools / categories / +// locations / units / resources can each be overridden; anything else returns +// empty. Used to drive status + training-level derivation through the real path. +function routeCatalog(opts: { + tools?: NotionPageFixture[]; + categories?: NotionPageFixture[]; + locations?: NotionPageFixture[]; + units?: NotionPageFixture[]; + resources?: NotionPageFixture[]; +}) { + const byId: Record = { + [DB_IDS.tools]: opts.tools ?? [toolsPage], + [DB_IDS.categories]: opts.categories ?? [categoriesPage], + [DB_IDS.locations]: opts.locations ?? [locationsPage], + [DB_IDS.units]: opts.units ?? [unitsPage], + [DB_IDS.resources]: opts.resources ?? [resourcesPage], + }; + server.use( + http.post("https://api.notion.com/v1/databases/:id/query", ({ params }) => { + const id = params.id as string; + return HttpResponse.json(notionQueryResponse(byId[id] ?? [])); + }) + ); +} + +// Reset modules + re-import so catalog.ts re-reads process.env at module load. +async function importCatalog() { + vi.resetModules(); + return import("@/lib/catalog"); +} + +// ── hasNotionCatalogEnv ───────────────────────────────────────────── + +describe("hasNotionCatalogEnv", () => { + it("returns true when all NOTION_* vars are set", async () => { + stubNotionEnv(); + const { hasNotionCatalogEnv } = await importCatalog(); + expect(hasNotionCatalogEnv()).toBe(true); + }); + + it("returns false when any NOTION_* var is missing", async () => { + stubNotionEnv(); + vi.stubEnv("NOTION_DB_UNITS", ""); + const { hasNotionCatalogEnv } = await importCatalog(); + expect(hasNotionCatalogEnv()).toBe(false); + }); + + it("returns false when NOTION_API_KEY is missing", async () => { + stubNotionEnv(); + vi.stubEnv("NOTION_API_KEY", ""); + const { hasNotionCatalogEnv } = await importCatalog(); + expect(hasNotionCatalogEnv()).toBe(false); + }); +}); + +// ── getCatalogTools — mock fallback ───────────────────────────────── + +describe("getCatalogTools (mock fallback)", () => { + it("returns the built-in mockTools when Notion env is unset", async () => { + unsetNotionEnv(); + const catalog = await importCatalog(); + const { mockTools } = await import("@/components/mock-catalog"); + + const tools = await catalog.getCatalogTools(); + expect(tools).toBe(mockTools); + expect(tools.map((t) => t.slug)).toEqual(["form-4", "trotec-speedy-400"]); + }); +}); + +// ── getCatalogTools — real Notion path ────────────────────────────── + +describe("getCatalogTools (Notion path)", () => { + it("resolves tools from Notion fixtures", async () => { + stubNotionEnv(); + const { getCatalogTools } = await importCatalog(); + + const tools = await getCatalogTools(); + expect(tools).toHaveLength(1); + + const [tool] = tools; + expect(tool.id).toBe("tool-1"); + expect(tool.slug).toBe("tool-1"); + expect(tool.name).toBe("Form 4"); + // category resolved via relation -> categoriesPage + expect(tool.category).toBe("3D Printing"); + expect(tool.categorySub).toBe("Resin"); + // location resolved via relation -> locationsPage + expect(tool.location).toBe("MakerLab"); + expect(tool.zone).toBe("Resin Bench"); + expect(tool.materials).toEqual(["Standard resin", "Tough resin"]); + expect(tool.ppe).toEqual(["Nitrile gloves", "Safety glasses"]); + // The first image_attachment is the stale airtable host; pickFreshImageUrl + // only inspects the *first* attachment's own url candidates and rejects it, + // so image_url is null and toMakerLabTool falls back to the local path. + expect(tool.imageSrc).toBe("/tool-images/Form%204.png"); + // mapId from resolved location.id + expect(tool.mapId).toBe("ML-RESIN-01"); + }); + + it("keeps a fresh first-attachment image url and drops a stale one", async () => { + stubNotionEnv(); + const freshFirst: NotionPageFixture = { + ...toolsPage, + properties: { + ...toolsPage.properties, + image_attachments: filesProp([externalFile("fresh.png", FRESH_IMAGE_URL)]), + }, + }; + const staleFirst: NotionPageFixture = { + ...toolsPage, + properties: { + ...toolsPage.properties, + image_attachments: filesProp([externalFile("stale.png", STALE_IMAGE_URL)]), + }, + }; + + routeCatalog({ tools: [freshFirst] }); + const fresh = await importCatalog(); + expect((await fresh.getCatalogTools())[0].imageSrc).toBe(FRESH_IMAGE_URL); + + routeCatalog({ tools: [staleFirst] }); + const stale = await importCatalog(); + // stale first-attachment rejected -> local fallback + expect((await stale.getCatalogTools())[0].imageSrc).toBe("/tool-images/Form%204.png"); + }); + + it("attaches the unit grouped by tool id", async () => { + stubNotionEnv(); + const { getCatalogTools } = await importCatalog(); + + const [tool] = await getCatalogTools(); + expect(tool.units).toHaveLength(1); + expect(tool.units[0].name).toBe("Form 4 #1"); + expect(tool.units[0].serial).toBe("ML-F4-001"); + }); + + it("falls back to mockTools and warns when the fetch errors (500)", async () => { + stubNotionEnv(); + server.use( + http.post("https://api.notion.com/v1/databases/:id/query", () => + HttpResponse.json({ message: "boom" }, { status: 500 }) + ) + ); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const catalog = await importCatalog(); + const { mockTools } = await import("@/components/mock-catalog"); + + const tools = await catalog.getCatalogTools(); + expect(tools).toBe(mockTools); + expect(warn).toHaveBeenCalledWith( + "Falling back to mock catalog:", + expect.anything() + ); + }); +}); + +// ── getCatalogTool(id) ────────────────────────────────────────────── + +describe("getCatalogTool", () => { + it("returns the tool found via the Notion path by id", async () => { + stubNotionEnv(); + const { getCatalogTool } = await importCatalog(); + + const tool = await getCatalogTool("tool-1"); + expect(tool).not.toBeNull(); + expect(tool?.name).toBe("Form 4"); + expect(tool?.units).toHaveLength(1); + }); + + it("returns null when the id is not found via the Notion path", async () => { + stubNotionEnv(); + const { getCatalogTool } = await importCatalog(); + + const tool = await getCatalogTool("does-not-exist"); + expect(tool).toBeNull(); + }); + + it("resolves a tool by slug from the mock catalog when env is unset", async () => { + unsetNotionEnv(); + const { getCatalogTool } = await importCatalog(); + + const tool = await getCatalogTool("trotec-speedy-400"); + expect(tool?.name).toBe("Trotec Speedy 400"); + }); + + it("returns null for an unknown slug in the mock fallback", async () => { + unsetNotionEnv(); + const { getCatalogTool } = await importCatalog(); + + expect(await getCatalogTool("nope")).toBeNull(); + }); +}); + +// ── getCatalogStats ───────────────────────────────────────────────── + +describe("getCatalogStats", () => { + it("counts tools in inventory and reports lab hours (mock path)", async () => { + unsetNotionEnv(); + const { getCatalogStats } = await importCatalog(); + + const stats = await getCatalogStats(); + expect(stats.toolsInInventory).toBe(2); + expect(stats.labHours).toBe("LAB OPEN 9AM-9PM"); + }); + + it("counts tools resolved from the Notion path", async () => { + stubNotionEnv(); + const { getCatalogStats } = await importCatalog(); + + const stats = await getCatalogStats(); + expect(stats.toolsInInventory).toBe(1); + }); +}); + +// ── Status derivation ─────────────────────────────────────────────── + +describe("status derivation (via Notion path)", () => { + const toolNoTraining: NotionPageFixture = { + ...toolsPage, + properties: { ...toolsPage.properties, training_required: checkboxProp(false) }, + }; + + function unit(id: string, status: string): NotionPageFixture { + return { + object: "page", + id, + created_time: "2024-08-12T10:00:00.000Z", + last_edited_time: "2024-08-12T10:00:00.000Z", + properties: { + unit_label: titleProp(`Unit ${id}`), + tool: relationProp(["tool-1"]), + status: selectProp(status), + condition: selectProp("Good"), + }, + }; + } + + it('derives "In Use" when any unit is In Use', async () => { + stubNotionEnv(); + routeCatalog({ units: [unit("u1", "Available"), unit("u2", "In Use")] }); + const { getCatalogTools } = await importCatalog(); + + const [tool] = await getCatalogTools(); + expect(tool.status).toBe("In Use"); + }); + + it('derives "Offline" when all units are Offline (Out of Service)', async () => { + stubNotionEnv(); + routeCatalog({ units: [unit("u1", "Out of Service"), unit("u2", "Retired")] }); + const { getCatalogTools } = await importCatalog(); + + const [tool] = await getCatalogTools(); + expect(tool.status).toBe("Offline"); + }); + + it('derives "Training Required" when training_required and no units gate it', async () => { + stubNotionEnv(); + // toolsPage has training_required: true; give it no units so unit-based + // status does not apply. + routeCatalog({ tools: [toolsPage], units: [] }); + const { getCatalogTools } = await importCatalog(); + + const [tool] = await getCatalogTools(); + expect(tool.status).toBe("Training Required"); + }); + + it('derives "Available" when no training required and no gating units', async () => { + stubNotionEnv(); + routeCatalog({ tools: [toolNoTraining], units: [] }); + const { getCatalogTools } = await importCatalog(); + + const [tool] = await getCatalogTools(); + expect(tool.status).toBe("Available"); + }); +}); + +// ── Training level / label derivation ─────────────────────────────── + +describe("training level + label derivation (via Notion path)", () => { + function toolWith(props: Record): NotionPageFixture { + return { ...toolsPage, properties: { ...toolsPage.properties, ...props } }; + } + + it('derives "Advanced" when restrictions mention "authorized"', async () => { + stubNotionEnv(); + routeCatalog({ + tools: [toolWith({ use_restrictions: richTextProp("Authorized users only.") })], + units: [], + }); + const { getCatalogTools } = await importCatalog(); + + const [tool] = await getCatalogTools(); + expect(tool.trainingLevel).toBe("Advanced"); + // training_required true + use_restrictions set -> label is the restriction + expect(tool.trainingLabel).toBe("Authorized users only."); + }); + + it('derives "Advanced" when a tag contains "advanced"', async () => { + stubNotionEnv(); + routeCatalog({ + tools: [ + toolWith({ + use_restrictions: richTextProp(""), + tags: multiSelectProp(["Advanced", "Laser"]), + }), + ], + units: [], + }); + const { getCatalogTools } = await importCatalog(); + + const [tool] = await getCatalogTools(); + expect(tool.trainingLevel).toBe("Advanced"); + }); + + it('derives "Intermediate" when training_required and no advanced keyword', async () => { + stubNotionEnv(); + // toolsPage: training_required true, restrictions "Resin handling..." (no + // advanced/authorized keyword), tags Resin/SLA. + routeCatalog({ tools: [toolsPage], units: [] }); + const { getCatalogTools } = await importCatalog(); + + const [tool] = await getCatalogTools(); + expect(tool.trainingLevel).toBe("Intermediate"); + expect(tool.trainingLabel).toBe("Resin handling training required."); + }); + + it('derives "Beginner" when no training required and no keywords', async () => { + stubNotionEnv(); + routeCatalog({ + tools: [ + toolWith({ + training_required: checkboxProp(false), + use_restrictions: richTextProp(""), + tags: multiSelectProp(["Resin"]), + }), + ], + units: [], + }); + const { getCatalogTools } = await importCatalog(); + + const [tool] = await getCatalogTools(); + expect(tool.trainingLevel).toBe("Beginner"); + expect(tool.trainingLabel).toBe("Beginner orientation"); + }); +}); + +// ── resourceLinks ─────────────────────────────────────────────────── + +describe("resourceLinks (via Notion path)", () => { + it("emits a url link plus a link per file attachment", async () => { + stubNotionEnv(); + // Default resourcesPage: url + 2 files (external pdf + hosted png). + const { getCatalogTool } = await importCatalog(); + + const tool = await getCatalogTool("tool-1"); + expect(tool).not.toBeNull(); + + const links = tool!.links; + // 1 url link + 2 file links + expect(links).toHaveLength(3); + + const urlLink = links.find((l) => l.href === "https://example.com/form4-sop"); + expect(urlLink).toMatchObject({ label: "Form 4 SOP", kind: "SOP" }); + + const pdfLink = links.find((l) => l.href === "https://example.com/form4-manual.pdf"); + expect(pdfLink).toBeDefined(); + const pngLink = links.find((l) => l.href === "https://files.notion.so/safety.png"); + expect(pngLink).toBeDefined(); + }); + + it("excludes resources where published === false", async () => { + stubNotionEnv(); + const unpublished: NotionPageFixture = { + object: "page", + id: "res-unpublished", + created_time: "2024-08-12T10:00:00.000Z", + last_edited_time: "2024-08-12T10:00:00.000Z", + properties: { + title: titleProp("Hidden SOP"), + tool: relationProp(["tool-1"]), + type: selectProp("SOP"), + url: urlProp("https://example.com/hidden"), + files: filesProp([]), + published: checkboxProp(false), + }, + }; + routeCatalog({ resources: [unpublished] }); + const { getCatalogTool } = await importCatalog(); + + const tool = await getCatalogTool("tool-1"); + expect(tool!.links).toEqual([]); + }); + + it("emits only file links when a resource has files but no url", async () => { + stubNotionEnv(); + const filesOnly: NotionPageFixture = { + object: "page", + id: "res-files-only", + created_time: "2024-08-12T10:00:00.000Z", + last_edited_time: "2024-08-12T10:00:00.000Z", + properties: { + title: titleProp("Manual"), + tool: relationProp(["tool-1"]), + type: selectProp("Manual"), + url: urlProp(null), + files: filesProp([ + externalFile("a.pdf", "https://example.com/a.pdf"), + hostedFile("b.pdf", "https://files.notion.so/b.pdf"), + ]), + published: checkboxProp(true), + }, + }; + routeCatalog({ resources: [filesOnly] }); + const { getCatalogTool } = await importCatalog(); + + const tool = await getCatalogTool("tool-1"); + expect(tool!.links).toHaveLength(2); + expect(tool!.links.every((l) => l.href.endsWith(".pdf"))).toBe(true); + }); +}); + +// ── units / resources grouping ────────────────────────────────────── + +describe("units + resources grouping by tool id", () => { + it("only attaches units/resources whose relation points at the tool", async () => { + stubNotionEnv(); + + const otherUnit: NotionPageFixture = { + object: "page", + id: "unit-other", + created_time: "2024-08-12T10:00:00.000Z", + last_edited_time: "2024-08-12T10:00:00.000Z", + properties: { + unit_label: titleProp("Other unit"), + tool: relationProp(["tool-2"]), + status: selectProp("Available"), + condition: selectProp("Good"), + }, + }; + const otherResource: NotionPageFixture = { + object: "page", + id: "res-other", + created_time: "2024-08-12T10:00:00.000Z", + last_edited_time: "2024-08-12T10:00:00.000Z", + properties: { + title: titleProp("Other SOP"), + tool: relationProp(["tool-2"]), + type: selectProp("SOP"), + url: urlProp("https://example.com/other"), + files: filesProp([]), + published: checkboxProp(true), + }, + }; + + routeCatalog({ + units: [unitsPage, otherUnit], + resources: [resourcesPage, otherResource], + }); + const { getCatalogTool } = await importCatalog(); + + const tool = await getCatalogTool("tool-1"); + // only the unit/resource pointing at tool-1 are attached + expect(tool!.units).toHaveLength(1); + expect(tool!.units[0].id).toBe("unit-1"); + expect(tool!.links.some((l) => l.href.includes("/other"))).toBe(false); + }); +}); diff --git a/v5/src/lib/notion.test.ts b/v5/src/lib/notion.test.ts new file mode 100644 index 0000000..d9b5b44 --- /dev/null +++ b/v5/src/lib/notion.test.ts @@ -0,0 +1,739 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- a couple of assertions + capture the raw Notion write payload (a heterogeneous property bag) and read + fields off it; `any` is the pragmatic type for that inspection. */ +import { http, HttpResponse } from "msw"; + +import { server } from "../../test/msw/server"; +import { DB_IDS } from "../../test/msw/handlers"; +import { + toolsPage, + maintenanceLogsPage, + notionQueryResponse, + STALE_IMAGE_URL, + FRESH_IMAGE_URL, + type NotionPageFixture, +} from "../../test/fixtures/notion"; + +import { + fetchAllTools, + fetchTool, + fetchAllCategories, + fetchAllLocations, + fetchAllUnits, + fetchAllResources, + fetchUnit, + fetchMaintenanceLogsByUnit, + createMaintenanceLog, + resolveTools, + getNotionEnvContract, +} from "@/lib/notion"; + +import type { + CategoryRecord, + LocationRecord, + ToolRecord, +} from "@/lib/types"; + +const NOTION = "https://api.notion.com/v1"; + +// Local page-envelope builder (the fixture module keeps its `page` helper +// private). Properties are loosely typed so each test can hand-craft the exact +// Notion property shapes its parser case needs. +function _page( + id: string, + properties: Record +): NotionPageFixture { + return { + object: "page", + id, + created_time: "2024-08-12T10:00:00.000Z", + last_edited_time: "2024-08-12T10:00:00.000Z", + properties, + }; +} + +// Stub all 8 Notion env vars so notion.ts routes its requests at the DB_IDS +// sentinels the default MSW handlers expect. Auto-undone by vitest.setup.ts. +function stubNotionEnv() { + vi.stubEnv("NOTION_API_KEY", "secret_test"); + vi.stubEnv("NOTION_DB_TOOLS", DB_IDS.tools); + vi.stubEnv("NOTION_DB_CATEGORIES", DB_IDS.categories); + vi.stubEnv("NOTION_DB_LOCATIONS", DB_IDS.locations); + vi.stubEnv("NOTION_DB_UNITS", DB_IDS.units); + vi.stubEnv("NOTION_DB_RESOURCES", DB_IDS.resources); + vi.stubEnv("NOTION_DB_MAINTENANCE_LOGS", DB_IDS.maintenance_logs); + vi.stubEnv("NOTION_DB_FLAGS", DB_IDS.flags); +} + +// ───────────────────────────────────────────────────────────────────── +// Parsers (via the fetchers, which call pageToX under the hood) +// ───────────────────────────────────────────────────────────────────── + +describe("pageToX parsers", () => { + beforeEach(stubNotionEnv); + + it("pageToTool extracts title/rich_text/select/multi_select/relation/checkbox/files", async () => { + const [tool] = await fetchAllTools(); + + expect(tool.id).toBe("tool-1"); + expect(tool.fields.name).toBe("Form 4"); + expect(tool.fields.description).toBe("A production-grade resin printer."); + // relation → array of ids + expect(tool.fields.category).toEqual(["cat-1"]); + expect(tool.fields.location).toEqual(["loc-1"]); + // multi_select → array of names + expect(tool.fields.materials).toEqual(["Standard resin", "Tough resin"]); + expect(tool.fields.ppe_required).toEqual(["Nitrile gloves", "Safety glasses"]); + expect(tool.fields.tags).toEqual(["Resin", "SLA"]); + // checkbox → boolean + expect(tool.fields.training_required).toBe(true); + expect(tool.fields.published).toBe(true); + expect(tool.fields.use_restrictions).toBe("Resin handling training required."); + expect(tool.fields.emergency_stop).toBe("Lift the lid to halt the print."); + expect(tool.fields.notes).toBe("Always wear nitrile gloves."); + // files → both external files extracted, external.url read + expect(tool.fields.image_attachments).toHaveLength(2); + expect(tool.fields.image_attachments?.[0]).toMatchObject({ + id: "tool-1:image_attachments:0", + url: STALE_IMAGE_URL, + filename: "stale.png", + }); + expect(tool.fields.image_attachments?.[1]).toMatchObject({ + url: FRESH_IMAGE_URL, + filename: "fresh.png", + }); + }); + + it("pageToCategory extracts title + select", async () => { + const [cat] = await fetchAllCategories(); + expect(cat.id).toBe("cat-1"); + expect(cat.fields.name).toBe("Resin"); + expect(cat.fields.group).toBe("3D Printing"); + }); + + it("pageToLocation reads the human id from the title and selects for zone/room", async () => { + const [loc] = await fetchAllLocations(); + expect(loc.id).toBe("loc-1"); // notion page id + expect(loc.fields.id).toBe("ML-RESIN-01"); // title value + expect(loc.fields.zone).toBe("Resin Bench"); + expect(loc.fields.room).toBe("MakerLab"); + }); + + it("pageToUnit extracts label/relation/rich_text/select", async () => { + const [unit] = await fetchAllUnits(); + expect(unit.id).toBe("unit-1"); + expect(unit.fields.unit_label).toBe("Form 4 #1"); + expect(unit.fields.tool).toEqual(["tool-1"]); + expect(unit.fields.serial_number).toBe("ML-F4-001"); + expect(unit.fields.asset_tag).toBe("AT-0001"); + expect(unit.fields.status).toBe("Available"); + expect(unit.fields.condition).toBe("Excellent"); + expect(unit.fields.date_acquired).toBe("2024-08-12"); + expect(unit.fields.notes).toBe("Primary resin unit."); + }); + + it("pageToResource extracts title/select/url/files and skips files without urls", async () => { + const [res] = await fetchAllResources(); + expect(res.id).toBe("res-1"); + expect(res.fields.title).toBe("Form 4 SOP"); + expect(res.fields.tool).toEqual(["tool-1"]); + expect(res.fields.type).toBe("SOP"); + expect(res.fields.url).toBe("https://example.com/form4-sop"); + expect(res.fields.notes).toBe("Standard operating procedure."); + expect(res.fields.published).toBe(true); + // one external + one hosted file → both extracted (external.url / file.url) + expect(res.fields.files).toHaveLength(2); + expect(res.fields.files?.[0]).toMatchObject({ + url: "https://example.com/form4-manual.pdf", + filename: "form4-manual.pdf", + }); + expect(res.fields.files?.[1]).toMatchObject({ + url: "https://files.notion.so/safety.png", + filename: "safety.png", + }); + }); + + it("pageToMaintenanceLog extracts title/relation/select/rich_text/date/files", async () => { + const [log] = await fetchMaintenanceLogsByUnit("unit-1"); + expect(log.id).toBe("log-1"); + expect(log.fields.title).toBe("Resin tank cloudy"); + expect(log.fields.unit).toEqual(["unit-1"]); + expect(log.fields.type).toBe("Issue Report"); + expect(log.fields.priority).toBe("Medium"); + expect(log.fields.status).toBe("Open"); + expect(log.fields.reported_by).toBe("Ada Lovelace"); + expect(log.fields.assigned_to).toBe("Lab Staff"); + expect(log.fields.description).toBe( + "The resin tank looks cloudy after the last print." + ); + // date → start value + expect(log.fields.date_reported).toBe("2024-09-01"); + // empty date → "" + expect(log.fields.date_resolved).toBe(""); + // empty rich_text → "" + expect(log.fields.resolution).toBe(""); + expect(log.fields.photo_attachments).toHaveLength(1); + expect(log.fields.photo_attachments?.[0]).toMatchObject({ + url: "https://example.com/photo.jpg", + filename: "photo.jpg", + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Header-case fallback names ("Name" vs "name") +// ───────────────────────────────────────────────────────────────────── + +describe("header-case property fallbacks", () => { + beforeEach(stubNotionEnv); + + it("resolves a tool whose properties use Header Case keys", async () => { + const headerTool = _page("tool-hdr", { + Name: { type: "title", title: [{ plain_text: "Bandsaw" }] }, + Description: { type: "rich_text", rich_text: [{ plain_text: "Cuts wood." }] }, + Category: { type: "relation", relation: [{ id: "cat-x" }] }, + Materials: { type: "multi_select", multi_select: [{ name: "Wood" }] }, + "PPE Required": { type: "multi_select", multi_select: [{ name: "Goggles" }] }, + "Training Required": { type: "checkbox", checkbox: false }, + Published: { type: "checkbox", checkbox: true }, + }); + server.use( + http.post(`${NOTION}/databases/:id/query`, () => + HttpResponse.json(notionQueryResponse([headerTool])) + ) + ); + + const [tool] = await fetchAllTools(); + expect(tool.fields.name).toBe("Bandsaw"); + expect(tool.fields.description).toBe("Cuts wood."); + expect(tool.fields.category).toEqual(["cat-x"]); + expect(tool.fields.materials).toEqual(["Wood"]); + expect(tool.fields.ppe_required).toEqual(["Goggles"]); + expect(tool.fields.training_required).toBe(false); + }); + + it("resolves a unit whose label falls back to the Name title", async () => { + const headerUnit = _page("unit-hdr", { + Name: { type: "title", title: [{ plain_text: "Bandsaw // A" }] }, + Status: { type: "select", select: { name: "In Use" } }, + }); + server.use( + http.post(`${NOTION}/databases/:id/query`, () => + HttpResponse.json(notionQueryResponse([headerUnit])) + ) + ); + + const [unit] = await fetchAllUnits(); + expect(unit.fields.unit_label).toBe("Bandsaw // A"); + expect(unit.fields.status).toBe("In Use"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// multiSelectValue comma-string fallback +// ───────────────────────────────────────────────────────────────────── + +describe("multiSelectValue comma-string fallback", () => { + beforeEach(stubNotionEnv); + + it("splits a rich_text 'a, b, c' value into trimmed tokens", async () => { + const tool = _page("tool-csv", { + name: { type: "title", title: [{ plain_text: "CSV tool" }] }, + // materials provided as rich_text rather than multi_select + materials: { type: "rich_text", rich_text: [{ plain_text: "Wood, Acrylic , MDF" }] }, + }); + server.use( + http.post(`${NOTION}/databases/:id/query`, () => + HttpResponse.json(notionQueryResponse([tool])) + ) + ); + + const [parsed] = await fetchAllTools(); + expect(parsed.fields.materials).toEqual(["Wood", "Acrylic", "MDF"]); + }); + + it("returns [] for an empty rich_text value", async () => { + const tool = _page("tool-empty", { + name: { type: "title", title: [{ plain_text: "Empty" }] }, + materials: { type: "rich_text", rich_text: [] }, + }); + server.use( + http.post(`${NOTION}/databases/:id/query`, () => + HttpResponse.json(notionQueryResponse([tool])) + ) + ); + + const [parsed] = await fetchAllTools(); + expect(parsed.fields.materials).toEqual([]); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// fileAttachments — external vs hosted url + filename fallback +// ───────────────────────────────────────────────────────────────────── + +describe("fileAttachments", () => { + beforeEach(stubNotionEnv); + + it("reads external.url for external files and file.url for hosted, deriving filename from url when name is absent", async () => { + const res = _page("res-files", { + title: { type: "title", title: [{ plain_text: "Files" }] }, + // external file with no name → filename from url tail + // hosted file with no name → filename from url tail + files: { + type: "files", + files: [ + { type: "external", external: { url: "https://cdn.example.com/docs/manual.pdf" } }, + { type: "file", file: { url: "https://files.notion.so/path/guide.png" } }, + ], + }, + }); + server.use( + http.post(`${NOTION}/databases/:id/query`, () => + HttpResponse.json(notionQueryResponse([res])) + ) + ); + + const [parsed] = await fetchAllResources(); + expect(parsed.fields.files).toHaveLength(2); + expect(parsed.fields.files?.[0]).toMatchObject({ + url: "https://cdn.example.com/docs/manual.pdf", + filename: "manual.pdf", + }); + expect(parsed.fields.files?.[1]).toMatchObject({ + url: "https://files.notion.so/path/guide.png", + filename: "guide.png", + }); + }); + + it("skips files that have no url", async () => { + const res = _page("res-nofile", { + title: { type: "title", title: [{ plain_text: "No url" }] }, + files: { + type: "files", + files: [ + { type: "external", external: {} }, + { type: "external", external: { url: "https://example.com/ok.pdf" } }, + ], + }, + }); + server.use( + http.post(`${NOTION}/databases/:id/query`, () => + HttpResponse.json(notionQueryResponse([res])) + ) + ); + + const [parsed] = await fetchAllResources(); + expect(parsed.fields.files).toHaveLength(1); + expect(parsed.fields.files?.[0].url).toBe("https://example.com/ok.pdf"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// resolveTools — joins, defaults, stale-image filtering +// ───────────────────────────────────────────────────────────────────── + +describe("resolveTools", () => { + const category: CategoryRecord = { + id: "cat-1", + createdTime: "t", + lastEditedTime: "t", + fields: { name: "Resin", group: "3D Printing" }, + }; + const location: LocationRecord = { + id: "loc-1", + createdTime: "t", + lastEditedTime: "t", + fields: { id: "ML-RESIN-01", zone: "Resin Bench", room: "MakerLab" }, + }; + + function toolRecord(overrides: Partial = {}): ToolRecord { + return { + id: "tool-1", + createdTime: "t", + lastEditedTime: "t", + fields: { + name: "Form 4", + category: ["cat-1"], + location: ["loc-1"], + ...overrides, + }, + }; + } + + it("joins category group/sub and location room/zone/map_tag", () => { + const [resolved] = resolveTools([toolRecord()], [category], [location]); + expect(resolved.category_group).toBe("3D Printing"); + expect(resolved.category_sub).toBe("Resin"); + expect(resolved.location_room).toBe("MakerLab"); + expect(resolved.location_zone).toBe("Resin Bench"); + // map_tag is the location's human id field, not the notion page id. + expect(resolved.map_tag).toBe("ML-RESIN-01"); + }); + + it("applies defaults when relations are missing", () => { + const [resolved] = resolveTools( + [toolRecord({ category: [], location: undefined })], + [], + [] + ); + expect(resolved.category_group).toBe("Uncategorized"); + expect(resolved.category_sub).toBe("Other"); + expect(resolved.location_room).toBe("Unknown"); + expect(resolved.location_zone).toBe("Unknown"); + expect(resolved.map_tag).toBeNull(); + }); + + it("drops a stale airtableusercontent.com image_url (first attachment stale → null)", () => { + const [resolved] = resolveTools( + [ + toolRecord({ + image_attachments: [ + { id: "a", url: STALE_IMAGE_URL, filename: "s.png", size: 0, type: "" }, + ], + }), + ], + [category], + [location] + ); + expect(resolved.image_url).toBeNull(); + }); + + it("keeps a fresh image_url when the first attachment is not stale", () => { + const [resolved] = resolveTools( + [ + toolRecord({ + image_attachments: [ + { id: "a", url: FRESH_IMAGE_URL, filename: "f.png", size: 0, type: "" }, + ], + }), + ], + [category], + [location] + ); + expect(resolved.image_url).toBe(FRESH_IMAGE_URL); + }); + + it("prefers a fresh large thumbnail over a stale base url", () => { + const [resolved] = resolveTools( + [ + toolRecord({ + image_attachments: [ + { + id: "a", + url: STALE_IMAGE_URL, + filename: "s.png", + size: 0, + type: "", + thumbnails: { + small: { url: STALE_IMAGE_URL, width: 1, height: 1 }, + large: { url: FRESH_IMAGE_URL, width: 2, height: 2 }, + }, + }, + ], + }), + ], + [category], + [location] + ); + expect(resolved.image_url).toBe(FRESH_IMAGE_URL); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Pagination (next_cursor) +// ───────────────────────────────────────────────────────────────────── + +describe("pagination via next_cursor", () => { + beforeEach(stubNotionEnv); + + it("concatenates results across pages, following next_cursor", async () => { + const pageOne = _page("tool-a", { + name: { type: "title", title: [{ plain_text: "Tool A" }] }, + }); + const pageTwo = _page("tool-b", { + name: { type: "title", title: [{ plain_text: "Tool B" }] }, + }); + + const cursorsSeen: (string | undefined)[] = []; + server.use( + http.post(`${NOTION}/databases/:id/query`, async ({ request }) => { + const body = (await request.json()) as { start_cursor?: string }; + cursorsSeen.push(body.start_cursor); + if (!body.start_cursor) { + return HttpResponse.json( + notionQueryResponse([pageOne], { hasMore: true, nextCursor: "cursor-2" }) + ); + } + return HttpResponse.json(notionQueryResponse([pageTwo])); + }) + ); + + const tools = await fetchAllTools(); + expect(tools.map((t) => t.fields.name)).toEqual(["Tool A", "Tool B"]); + // first request has no cursor, second sends the cursor from page 1 + expect(cursorsSeen).toEqual([undefined, "cursor-2"]); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// 429 retry-after handling +// ───────────────────────────────────────────────────────────────────── + +describe("429 Retry-After handling", () => { + beforeEach(stubNotionEnv); + + it("retries once after a 429 then resolves", async () => { + let calls = 0; + server.use( + http.post(`${NOTION}/databases/:id/query`, () => { + calls += 1; + if (calls === 1) { + // Retry-After 0 → no real wait, keeps the test fast & reliable. + return new HttpResponse(null, { + status: 429, + headers: { "Retry-After": "0" }, + }); + } + return HttpResponse.json(notionQueryResponse([toolsPage])); + }) + ); + + const tools = await fetchAllTools(); + expect(calls).toBe(2); + expect(tools[0].fields.name).toBe("Form 4"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Env contract + missing-var errors +// ───────────────────────────────────────────────────────────────────── + +describe("Notion env contract", () => { + it("getNotionEnvContract returns the 8 expected keys", () => { + expect(getNotionEnvContract()).toEqual([ + "NOTION_API_KEY", + "NOTION_DB_TOOLS", + "NOTION_DB_CATEGORIES", + "NOTION_DB_LOCATIONS", + "NOTION_DB_UNITS", + "NOTION_DB_RESOURCES", + "NOTION_DB_MAINTENANCE_LOGS", + "NOTION_DB_FLAGS", + ]); + }); + + it("throws listing every missing var when env is fully unset", async () => { + // No env stubbed → getNotionEnv (reached via fetchAllTools) should throw. + // queryDatabase calls getNotionEnv before any fetch, so this rejects. + await expect(fetchAllTools()).rejects.toThrow(/Missing Notion catalog env vars/); + await expect(fetchAllTools()).rejects.toThrow(/NOTION_API_KEY/); + await expect(fetchAllTools()).rejects.toThrow(/NOTION_DB_TOOLS/); + await expect(fetchAllTools()).rejects.toThrow(/NOTION_DB_FLAGS/); + }); + + it("lists only the specific missing var when others are present", async () => { + vi.stubEnv("NOTION_API_KEY", "secret_test"); + vi.stubEnv("NOTION_DB_TOOLS", DB_IDS.tools); + vi.stubEnv("NOTION_DB_CATEGORIES", DB_IDS.categories); + vi.stubEnv("NOTION_DB_LOCATIONS", DB_IDS.locations); + vi.stubEnv("NOTION_DB_UNITS", DB_IDS.units); + vi.stubEnv("NOTION_DB_RESOURCES", DB_IDS.resources); + vi.stubEnv("NOTION_DB_MAINTENANCE_LOGS", DB_IDS.maintenance_logs); + // NOTION_DB_FLAGS intentionally left unset. + + await expect(fetchAllTools()).rejects.toThrow( + /Missing Notion catalog env vars: NOTION_DB_FLAGS/ + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// createMaintenanceLog — POST body shape +// ───────────────────────────────────────────────────────────────────── + +describe("createMaintenanceLog", () => { + beforeEach(stubNotionEnv); + + it("posts to /pages with correct parent.database_id and properties shape", async () => { + let captured: any; + server.use( + http.post(`${NOTION}/pages`, async ({ request }) => { + captured = await request.json(); + return HttpResponse.json({ + object: "page", + id: "created-page-1", + created_time: "2024-09-01T10:00:00.000Z", + last_edited_time: "2024-09-01T10:00:00.000Z", + properties: captured.properties ?? {}, + }); + }) + ); + + const result = await createMaintenanceLog({ + title: "Laser not cutting", + unit: ["unit-1"], + type: "Issue Report", + priority: "High", + status: "Open", + reported_by: "Grace Hopper", + description: "The laser fails to cut through 3mm acrylic.", + date_reported: "2024-09-02", + photo_uploads: [{ id: "file-upload-1", name: "evidence.jpg" }], + }); + + // parent points at the maintenance_logs database id + expect(captured.parent).toEqual({ database_id: DB_IDS.maintenance_logs }); + + const props = captured.properties; + // title + expect(props.title).toEqual({ title: [{ text: { content: "Laser not cutting" } }] }); + // relation for unit + expect(props.unit).toEqual({ relation: [{ id: "unit-1" }] }); + // selects for type/priority/status + expect(props.type).toEqual({ select: { name: "Issue Report" } }); + expect(props.priority).toEqual({ select: { name: "High" } }); + expect(props.status).toEqual({ select: { name: "Open" } }); + // reported_by rich_text + expect(props.reported_by).toEqual({ + rich_text: [{ text: { content: "Grace Hopper" } }], + }); + // date + expect(props.date_reported).toEqual({ date: { start: "2024-09-02" } }); + // file_upload files + expect(props.photo_attachments).toEqual({ + files: [ + { + type: "file_upload", + file_upload: { id: "file-upload-1" }, + name: "evidence.jpg", + }, + ], + }); + + // description is the templated ticket description built by formatTicketDescription + const desc = props.description.rich_text[0].text.content as string; + expect(desc).toContain("**What happened**"); + expect(desc).toContain("The laser fails to cut through 3mm acrylic."); + expect(desc).toContain("**Reported by**"); + expect(desc).toContain("Grace Hopper"); + expect(desc).toContain("**Date reported**"); + expect(desc).toContain("2024-09-02"); + expect(desc).toContain("**Priority**"); + expect(desc).toContain("High"); + + // returns a parsed record + expect(result.id).toBe("created-page-1"); + }); + + it("defaults the title and omits optional props when fields are sparse", async () => { + let captured: any; + server.use( + http.post(`${NOTION}/pages`, async ({ request }) => { + captured = await request.json(); + return HttpResponse.json({ + object: "page", + id: "created-page-2", + created_time: "t", + last_edited_time: "t", + properties: captured.properties ?? {}, + }); + }) + ); + + await createMaintenanceLog({}); + + expect(captured.properties.title).toEqual({ + title: [{ text: { content: "Untitled issue" } }], + }); + // no description sections → description prop omitted + expect(captured.properties.description).toBeUndefined(); + expect(captured.properties.unit).toBeUndefined(); + expect(captured.properties.type).toBeUndefined(); + expect(captured.properties.photo_attachments).toBeUndefined(); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// fetchAllTools published-filter fallback chain +// ───────────────────────────────────────────────────────────────────── + +describe("fetchAllTools published-filter fallback", () => { + beforeEach(stubNotionEnv); + + it("returns mapped tools on the happy path (lowercase published filter)", async () => { + const tools = await fetchAllTools(); + expect(tools).toHaveLength(1); + expect(tools[0].fields.name).toBe("Form 4"); + }); + + it("falls back through the filter chain when earlier queries fail", async () => { + let attempt = 0; + server.use( + http.post(`${NOTION}/databases/:id/query`, async ({ request }) => { + const body = (await request.json()) as { + filter?: { property?: string }; + sorts?: unknown; + }; + attempt += 1; + // First two attempts (published / Published filters) 400, third + // (sort-only) succeeds. + if (body.filter) { + return new HttpResponse("bad filter", { status: 400 }); + } + return HttpResponse.json(notionQueryResponse([toolsPage])); + }) + ); + + const tools = await fetchAllTools(); + expect(attempt).toBeGreaterThanOrEqual(3); + expect(tools[0].fields.name).toBe("Form 4"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Single-page fetchers (GET /pages/:id) +// ───────────────────────────────────────────────────────────────────── + +describe("single-page fetchers", () => { + beforeEach(stubNotionEnv); + + it("fetchTool fetches and parses a single page", async () => { + const tool = await fetchTool("tool-1"); + expect(tool.fields.name).toBe("Form 4"); + }); + + it("fetchUnit fetches and parses a single unit page", async () => { + const unit = await fetchUnit("unit-1"); + expect(unit.fields.unit_label).toBe("Form 4 #1"); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// fetchMaintenanceLogsByUnit — relation filter +// ───────────────────────────────────────────────────────────────────── + +describe("fetchMaintenanceLogsByUnit", () => { + beforeEach(stubNotionEnv); + + it("returns logs whose unit relation includes the requested unit id", async () => { + const logs = await fetchMaintenanceLogsByUnit("unit-1"); + expect(logs).toHaveLength(1); + expect(logs[0].fields.title).toBe("Resin tank cloudy"); + }); + + it("filters out logs that do not reference the requested unit", async () => { + const otherLog = _page("log-other", { + title: { type: "title", title: [{ plain_text: "Other unit issue" }] }, + unit: { type: "relation", relation: [{ id: "unit-999" }] }, + }); + server.use( + http.post(`${NOTION}/databases/:id/query`, () => + HttpResponse.json(notionQueryResponse([otherLog, maintenanceLogsPage])) + ) + ); + + const logs = await fetchMaintenanceLogsByUnit("unit-1"); + expect(logs.map((l) => l.fields.title)).toEqual(["Resin tank cloudy"]); + }); +}); diff --git a/v5/src/lib/rate-limit.test.ts b/v5/src/lib/rate-limit.test.ts new file mode 100644 index 0000000..e5b62d3 --- /dev/null +++ b/v5/src/lib/rate-limit.test.ts @@ -0,0 +1,193 @@ +import { server } from "../../test/msw/server"; +import { http, HttpResponse } from "msw"; + +// NOTE: rate-limit.ts is a module singleton. `useUpstash` and the in-memory +// `store` Map are captured at module load. Tests that need a clean window OR a +// different Upstash config use `vi.resetModules()` + dynamic `import()`. +// +// For tests against the *default* (no-Upstash) build, we import lazily inside +// each test (after `vi.resetModules()`) and use distinct keys to avoid +// cross-test bleed through the shared singleton. + +async function freshModule() { + vi.resetModules(); + return import("@/lib/rate-limit"); +} + +function stubUpstashEnv() { + vi.stubEnv("UPSTASH_REDIS_REST_URL", "https://redis.example.com"); + vi.stubEnv("UPSTASH_REDIS_REST_TOKEN", "tok"); +} + +describe("rateLimit (in-memory, sync)", () => { + it("allows requests while under the limit", async () => { + const { rateLimit } = await freshModule(); + const opts = { limit: 3, windowMs: 60_000 }; + + expect(rateLimit("under-1", opts)).toEqual({ allowed: true, remaining: 2 }); + expect(rateLimit("under-1", opts)).toEqual({ allowed: true, remaining: 1 }); + expect(rateLimit("under-1", opts)).toEqual({ allowed: true, remaining: 0 }); + }); + + it("denies once the count exceeds the limit", async () => { + const { rateLimit } = await freshModule(); + const opts = { limit: 2, windowMs: 60_000 }; + + expect(rateLimit("over-1", opts).allowed).toBe(true); // count 1 + expect(rateLimit("over-1", opts).allowed).toBe(true); // count 2 (== limit) + expect(rateLimit("over-1", opts).allowed).toBe(false); // count 3 (> limit) + expect(rateLimit("over-1", opts).allowed).toBe(false); // still denied + }); + + it("decrements remaining and floors it at 0", async () => { + const { rateLimit } = await freshModule(); + const opts = { limit: 2, windowMs: 60_000 }; + + expect(rateLimit("floor-1", opts).remaining).toBe(1); + expect(rateLimit("floor-1", opts).remaining).toBe(0); + // Once over the limit, remaining must not go negative. + expect(rateLimit("floor-1", opts).remaining).toBe(0); + expect(rateLimit("floor-1", opts).remaining).toBe(0); + }); + + it("resets the window after windowMs elapses (fake timers)", async () => { + vi.useFakeTimers(); + try { + const { rateLimit } = await freshModule(); + const opts = { limit: 1, windowMs: 1_000 }; + + // First call allowed, second (same window) denied. + expect(rateLimit("reset-1", opts).allowed).toBe(true); + expect(rateLimit("reset-1", opts).allowed).toBe(false); + + // Advance past the window; the entry's resetAt is exceeded → fresh window. + vi.advanceTimersByTime(1_001); + + expect(rateLimit("reset-1", opts)).toEqual({ allowed: true, remaining: 0 }); + } finally { + vi.useRealTimers(); + } + }); + + it("tracks distinct keys independently", async () => { + const { rateLimit } = await freshModule(); + const opts = { limit: 1, windowMs: 60_000 }; + + expect(rateLimit("key-a", opts).allowed).toBe(true); + // Different key is unaffected by key-a's consumption. + expect(rateLimit("key-b", opts).allowed).toBe(true); + // key-a is now over its own limit. + expect(rateLimit("key-a", opts).allowed).toBe(false); + }); + + it("throws (sync) when Upstash is configured", async () => { + stubUpstashEnv(); + const { rateLimit } = await freshModule(); + + expect(() => rateLimit("k", { limit: 5, windowMs: 1_000 })).toThrow( + /rateLimitAsync must be used/ + ); + }); +}); + +describe("rateLimitAsync — in-memory delegation (no Upstash env)", () => { + it("allows under the limit and denies over it", async () => { + const { rateLimitAsync } = await freshModule(); + const opts = { limit: 2, windowMs: 60_000 }; + + expect(await rateLimitAsync("async-mem", opts)).toEqual({ + allowed: true, + remaining: 1, + }); + expect(await rateLimitAsync("async-mem", opts)).toEqual({ + allowed: true, + remaining: 0, + }); + expect(await rateLimitAsync("async-mem", opts)).toEqual({ + allowed: false, + remaining: 0, + }); + }); +}); + +describe("rateLimitAsync — Upstash path (MSW /pipeline)", () => { + it("returns allowed based on the INCR count (default handler → count 1)", async () => { + stubUpstashEnv(); + const { rateLimitAsync } = await freshModule(); + + // Default handler returns [{result:1},{result:1}] → count 1, allowed. + const r = await rateLimitAsync("upstash-allow", { limit: 5, windowMs: 1_000 }); + expect(r).toEqual({ allowed: true, remaining: 4 }); + }); + + it("denies when the INCR count exceeds the limit", async () => { + stubUpstashEnv(); + server.use( + http.post(/\/pipeline$/, () => + HttpResponse.json([{ result: 6 }, { result: 1 }]) + ) + ); + const { rateLimitAsync } = await freshModule(); + + const r = await rateLimitAsync("upstash-deny", { limit: 5, windowMs: 1_000 }); + expect(r).toEqual({ allowed: false, remaining: 0 }); + }); + + it("allows exactly at the limit (count === limit)", async () => { + stubUpstashEnv(); + server.use( + http.post(/\/pipeline$/, () => + HttpResponse.json([{ result: 5 }, { result: 1 }]) + ) + ); + const { rateLimitAsync } = await freshModule(); + + const r = await rateLimitAsync("upstash-edge", { limit: 5, windowMs: 1_000 }); + expect(r).toEqual({ allowed: true, remaining: 0 }); + }); + + it("fails open (allowed=true) when the pipeline responds non-ok", async () => { + stubUpstashEnv(); + server.use( + http.post(/\/pipeline$/, () => + HttpResponse.json({ error: "boom" }, { status: 500 }) + ) + ); + const { rateLimitAsync } = await freshModule(); + + const r = await rateLimitAsync("upstash-failopen", { limit: 5, windowMs: 1_000 }); + expect(r).toEqual({ allowed: true, remaining: 4 }); + }); +}); + +describe("getClientIp", () => { + it("uses the first entry of x-forwarded-for", async () => { + const { getClientIp } = await freshModule(); + const req = new Request("http://x", { + headers: { "x-forwarded-for": "1.2.3.4, 5.6.7.8" }, + }); + expect(getClientIp(req)).toBe("1.2.3.4"); + }); + + it("trims whitespace around the forwarded ip", async () => { + const { getClientIp } = await freshModule(); + const req = new Request("http://x", { + headers: { "x-forwarded-for": " 9.9.9.9 , 1.1.1.1" }, + }); + expect(getClientIp(req)).toBe("9.9.9.9"); + }); + + it("falls back to x-real-ip when x-forwarded-for is absent", async () => { + const { getClientIp } = await freshModule(); + const req = new Request("http://x", { + headers: { "x-real-ip": "8.8.8.8" }, + }); + expect(getClientIp(req)).toBe("8.8.8.8"); + }); + + it('returns "unknown" when neither header is present', async () => { + const { getClientIp } = await freshModule(); + const req = new Request("http://x"); + expect(getClientIp(req)).toBe("unknown"); + }); +}); diff --git a/v5/src/lib/site-config.test.ts b/v5/src/lib/site-config.test.ts new file mode 100644 index 0000000..6ad1127 --- /dev/null +++ b/v5/src/lib/site-config.test.ts @@ -0,0 +1,84 @@ +// site-config reads process.env at MODULE LOAD, so to test overrides we must +// stubEnv → resetModules → dynamic import. See test/README.md "Env stubbing for +// module-load-time reads" and the design doc §2 constraint #4. + +describe("site-config", () => { + describe("defaults (env unset)", () => { + it("uses documented defaults when no NEXT_PUBLIC_* / AUDIENCE vars are set", async () => { + // Ensure none of the relevant vars leak in from the environment. + vi.stubEnv("NEXT_PUBLIC_SITE_NAME", ""); + vi.stubEnv("NEXT_PUBLIC_INSTITUTION", ""); + vi.stubEnv("NEXT_PUBLIC_TAGLINE", ""); + vi.stubEnv("NEXT_PUBLIC_CHAT_ASSISTANT_NAME", ""); + vi.stubEnv("AUDIENCE", ""); + vi.stubEnv("NEXT_PUBLIC_LOGO", ""); + vi.stubEnv("NEXT_PUBLIC_COLOR_PRIMARY", ""); + vi.stubEnv("NEXT_PUBLIC_COLOR_PRIMARY_DARK", ""); + // `vi.stubEnv(name, "")` sets the var to "" rather than deleting it. The + // source uses `??`, so we want genuinely-undefined values to exercise the + // defaults. Delete them outright. + delete process.env.NEXT_PUBLIC_SITE_NAME; + delete process.env.NEXT_PUBLIC_INSTITUTION; + delete process.env.NEXT_PUBLIC_TAGLINE; + delete process.env.NEXT_PUBLIC_CHAT_ASSISTANT_NAME; + delete process.env.AUDIENCE; + delete process.env.NEXT_PUBLIC_LOGO; + delete process.env.NEXT_PUBLIC_COLOR_PRIMARY; + delete process.env.NEXT_PUBLIC_COLOR_PRIMARY_DARK; + + vi.resetModules(); + const { siteConfig } = await import("@/lib/site-config"); + + expect(siteConfig.name).toBe("MakerLab Tools"); + expect(siteConfig.institution).toBe("Cornell Tech"); + expect(siteConfig.tagline).toBe( + "Browse, search, and learn about makerspace equipment.", + ); + expect(siteConfig.chatAssistantName).toBe("MakerLab Assistant"); + expect(siteConfig.audience).toBe("students who may be beginners"); + expect(siteConfig.logo).toBe("/makerlab-logo-transparent.png"); + expect(siteConfig.colors).toEqual({ + primary: "#ff6b35", + primaryDark: "#cc4f1f", + }); + }); + }); + + describe("overrides (env set)", () => { + it("flows every stubbed var through, including the nested colors object", async () => { + vi.stubEnv("NEXT_PUBLIC_SITE_NAME", "Acme Lab"); + vi.stubEnv("NEXT_PUBLIC_INSTITUTION", "Acme University"); + vi.stubEnv("NEXT_PUBLIC_TAGLINE", "Make all the things."); + vi.stubEnv("NEXT_PUBLIC_CHAT_ASSISTANT_NAME", "Acme Helper"); + vi.stubEnv("AUDIENCE", "expert machinists"); + vi.stubEnv("NEXT_PUBLIC_LOGO", "/acme-logo.svg"); + vi.stubEnv("NEXT_PUBLIC_COLOR_PRIMARY", "#123456"); + vi.stubEnv("NEXT_PUBLIC_COLOR_PRIMARY_DARK", "#0a1a2a"); + + vi.resetModules(); + const { siteConfig } = await import("@/lib/site-config"); + + expect(siteConfig.name).toBe("Acme Lab"); + expect(siteConfig.institution).toBe("Acme University"); + expect(siteConfig.tagline).toBe("Make all the things."); + expect(siteConfig.chatAssistantName).toBe("Acme Helper"); + expect(siteConfig.audience).toBe("expert machinists"); + expect(siteConfig.logo).toBe("/acme-logo.svg"); + expect(siteConfig.colors).toEqual({ + primary: "#123456", + primaryDark: "#0a1a2a", + }); + }); + + it("applies an individual override while leaving other fields at their defaults", async () => { + delete process.env.NEXT_PUBLIC_INSTITUTION; + vi.stubEnv("NEXT_PUBLIC_SITE_NAME", "Just The Name"); + + vi.resetModules(); + const { siteConfig } = await import("@/lib/site-config"); + + expect(siteConfig.name).toBe("Just The Name"); + expect(siteConfig.institution).toBe("Cornell Tech"); + }); + }); +}); diff --git a/v5/test/README.md b/v5/test/README.md new file mode 100644 index 0000000..e77ffa8 --- /dev/null +++ b/v5/test/README.md @@ -0,0 +1,255 @@ +# v5 Test Harness + +Shared tooling every test in `v5/` builds on. **Don't edit `package.json` or run +`npm install`** — the foundation already wired the deps and scripts. Just add +your `*.test.ts(x)` files and import from here. + +## Layout + +| Path | What it is | +|---|---| +| `vitest.config.ts` | jsdom env, `@/`→`src/`, `server-only`→stub, globals on, `e2e/` excluded | +| `vitest.setup.ts` | jest-dom matchers, MSW lifecycle, per-test env/mocks cleanup | +| `test/msw/server.ts` | the MSW node `server` (lifecycle managed in setup) | +| `test/msw/handlers.ts` | default Notion + Upstash handlers, `DB_IDS` sentinels | +| `test/fixtures/notion.ts` | raw `NotionPage` fixtures + `notionQueryResponse(...)` | +| `test/fixtures/catalog.ts` | resolved `MakerLabTool` / `MakerLabUnit` objects | +| `test/mocks/next-cache.ts` | `nextCacheMock()` factory for `vi.mock("next/cache", …)` | +| `test/mocks/server-only.ts` | empty stub aliased for `import "server-only"` | +| `test/utils/render.tsx` | RTL `render` wrapped in `NextIntlClientProvider` + `userEvent` | +| `playwright.config.ts` | E2E config; dev server boots with Notion env unset | + +## Scripts + +```bash +npm test # vitest run (one-shot) +npm run test:watch # vitest (watch) +npm run test:coverage +npm run test:e2e # playwright (run `npx playwright install chromium` first, once) +npm run test:e2e:ui +``` + +`describe` / `it` / `expect` / `vi` and the lifecycle hooks are **globals** — +no imports needed. Types come from `test/vitest.d.ts`. + +## Import paths (copy these) + +```ts +import { server } from "../../test/msw/server"; // adjust depth to your file +import { handlers, DB_IDS } from "../../test/msw/handlers"; +import { http, HttpResponse } from "msw"; // for server.use(...) overrides + +import { + toolsPage, categoriesPage, locationsPage, unitsPage, + resourcesPage, maintenanceLogsPage, pagesById, + notionQueryResponse, STALE_IMAGE_URL, FRESH_IMAGE_URL, +} from "../../test/fixtures/notion"; + +import { + availableTool, inUseTool, offlineTool, toolWithLinks, mockCatalog, +} from "../../test/fixtures/catalog"; + +import { nextCacheMock } from "../../test/mocks/next-cache"; +import { render, screen, userEvent } from "../../test/utils/render"; +``` + +> Relative depth varies: from `src/lib/*.test.ts` use `../../test/...`; from +> `src/components/*.test.tsx` use `../../test/...`; from +> `src/app/api/**/route.test.ts` go up to the v5 root then into `test/`. + +--- + +## The mock-catalog fallback rule (read this first) + +`getCatalogTools()` / `getCatalogTool(id)` return the built-in mock catalog when +`hasNotionCatalogEnv()` is false — i.e. **any** of the 8 Notion env vars +(`NOTION_API_KEY` + the 7 `NOTION_DB_*`) is missing — **and** on any thrown +error during the Notion fetch. + +- **Mock path** (default in tests): leave Notion env unset. Catalog comes from + `src/components/mock-catalog.ts`. No MSW needed. +- **Real Notion path**: `vi.stubEnv` all 8 vars (set the `NOTION_DB_*` ones to + the `DB_IDS` sentinels so the default handlers route correctly), then let MSW + serve `api.notion.com`. + +```ts +import { DB_IDS } from "../../test/msw/handlers"; + +function stubNotionEnv() { + vi.stubEnv("NOTION_API_KEY", "secret_test"); + vi.stubEnv("NOTION_DB_TOOLS", DB_IDS.tools); + vi.stubEnv("NOTION_DB_CATEGORIES", DB_IDS.categories); + vi.stubEnv("NOTION_DB_LOCATIONS", DB_IDS.locations); + vi.stubEnv("NOTION_DB_UNITS", DB_IDS.units); + vi.stubEnv("NOTION_DB_RESOURCES", DB_IDS.resources); + vi.stubEnv("NOTION_DB_MAINTENANCE_LOGS", DB_IDS.maintenance_logs); + vi.stubEnv("NOTION_DB_FLAGS", DB_IDS.flags); +} +``` + +`vi.unstubAllEnvs()` runs automatically after every test (setup file). + +--- + +## Env stubbing for module-load-time reads + +`site-config.ts` reads `process.env.NEXT_PUBLIC_*` / `AUDIENCE` **at module +load**, and `rate-limit.ts` computes `useUpstash` from `UPSTASH_*` **at module +load**. `vi.stubEnv` after the module is already imported won't change those +captured values. Pattern: stub → `resetModules` → dynamic `import()`. + +```ts +it("honors NEXT_PUBLIC_SITE_NAME override", async () => { + vi.stubEnv("NEXT_PUBLIC_SITE_NAME", "Acme Lab"); + vi.resetModules(); // drop the cached module + const { siteConfig } = await import("@/lib/site-config"); + expect(siteConfig.name).toBe("Acme Lab"); +}); +``` + +Same for the Upstash branch of the rate limiter: + +```ts +it("uses the Upstash path when configured", async () => { + vi.stubEnv("UPSTASH_REDIS_REST_URL", "https://redis.example.com"); + vi.stubEnv("UPSTASH_REDIS_REST_TOKEN", "tok"); + vi.resetModules(); + const { rateLimitAsync } = await import("@/lib/rate-limit"); + // MSW already mocks POST */pipeline → [{result:1},{result:1}] (allowed). + const r = await rateLimitAsync("k", { limit: 5, windowMs: 1000 }); + expect(r.allowed).toBe(true); +}); +``` + +> The in-memory limiter is a per-process singleton `Map`. Use distinct keys per +> test, or `vi.resetModules()` + re-import to get a fresh window. + +--- + +## MSW `server.use(...)` override pattern + +Defaults live in `handlers.ts`. Override per-test; the `afterEach` resets them. + +```ts +import { server } from "../../test/msw/server"; +import { http, HttpResponse } from "msw"; +import { notionQueryResponse, toolsPage } from "../../test/fixtures/notion"; + +it("follows next_cursor pagination", async () => { + let call = 0; + server.use( + http.post("https://api.notion.com/v1/databases/:id/query", () => { + call += 1; + return call === 1 + ? HttpResponse.json(notionQueryResponse([toolsPage], { hasMore: true, nextCursor: "c2" })) + : HttpResponse.json(notionQueryResponse([toolsPage])); + }) + ); + // ... call into notion.ts and assert both pages were collected +}); +``` + +To simulate a 429 retry, a 500, or an Upstash fail-open, override the relevant +handler the same way (return `HttpResponse.json(..., { status })` or set +`Retry-After`). Unhandled outbound requests **fail the test** by design +(`onUnhandledRequest: "error"`). + +--- + +## `vi.mock("next/cache")` pattern + +`catalog.ts` imports `cacheTag`/`cacheLife`; `admin/revalidate/route.ts` imports +`revalidateTag`. Mock them with the factory: + +```ts +import { nextCacheMock } from "../../test/mocks/next-cache"; + +vi.mock("next/cache", () => nextCacheMock()); + +// later, assert it was called: +import { revalidateTag } from "next/cache"; +expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith("catalog"); +``` + +**Hoisting caveat:** `vi.mock(...)` is hoisted above your imports, so the +factory must not close over local module variables. Calling `nextCacheMock()` +inline (as above) is safe — its import is hoisted alongside the mock. The +`"use cache"` directive string in `catalog.ts` is harmless under esbuild. + +--- + +## streamText-capture pattern (chat route) — VERIFIED + +The chat route's tool `execute` fns are defined inline inside `POST` and the +helpers are module-private. Don't unit-test them directly — instead **mock +`ai`'s `streamText`** to capture the `{ system, messages, tools }` it receives, +then call `POST(req)` and assert on the captured args. You can invoke the +captured `tools.*.execute(...)` directly. Also mock `@ai-sdk/anthropic` +(`anthropic` model factory + `anthropic.tools.webFetch_20250910`). + +This exact snippet was run against the real route and passes: + +```ts +const captured: { args?: any } = {}; + +vi.mock("@ai-sdk/anthropic", () => { + const anthropic = Object.assign( + vi.fn(() => ({ modelId: "mock-model" })), + { tools: { webFetch_20250910: vi.fn(() => ({ type: "web_fetch_mock" })) } } + ); + return { anthropic }; +}); + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, // keep convertToModelMessages, createUIMessageStream, tool, stepCountIs, ... + streamText: vi.fn((args: unknown) => { + captured.args = args; + return { + toUIMessageStream: () => + new ReadableStream({ start(c) { c.close(); } }), + }; + }), + }; +}); + +import { POST } from "@/app/api/chat/route"; + +it("wires system + tools and runs a captured tool.execute", async () => { + const req = new Request("http://localhost/api/chat", { + method: "POST", + headers: { "content-type": "application/json", "x-forwarded-for": "1.2.3.4" }, + body: JSON.stringify({ + messages: [{ id: "1", role: "user", parts: [{ type: "text", text: "hi" }] }], + }), + }); + + const res = await POST(req); + expect(res).toBeInstanceOf(Response); + + expect(typeof captured.args.system).toBe("string"); + expect(captured.args.tools).toHaveProperty("get_unit_details"); + expect(captured.args.tools).toHaveProperty("report_issue"); + expect(captured.args.tools).toHaveProperty("web_fetch"); + + // Invoke a tool.execute directly (mock-catalog path — no Notion env): + const miss = await captured.args.tools.get_unit_details.execute({ + unit_label: "no-such-unit", + }); + expect(miss.found).toBe(false); +}); +``` + +Notes: +- Spread `...actual` so the route's other `ai` imports (`convertToModelMessages`, + `createUIMessageStream`, `createUIMessageStreamResponse`, `tool`, + `stepCountIs`) still work — only `streamText` is replaced. +- The route rate-limits **before** parsing. To assert the 429 path, drive the + in-memory limiter over its limit (21 calls in a window) or stub Upstash + + override the `*/pipeline` handler to return a count over the limit. +- To test `report_issue.execute` filing a ticket, stub the Notion env and let + MSW's `POST /pages` handler respond (returns `id: "created-page-1"`), then + assert `result.success === true` and `result.ticket_id`. +- For `get_unit_details` / `report_issue` against the **mock catalog** (no env), + the catalog units come from `mock-catalog.ts` (`Form 4 // A`, `Trotec Speedy 400`). diff --git a/v5/test/fixtures/catalog.ts b/v5/test/fixtures/catalog.ts new file mode 100644 index 0000000..c70cf87 --- /dev/null +++ b/v5/test/fixtures/catalog.ts @@ -0,0 +1,172 @@ +// Ready-made MakerLabTool / MakerLabUnit fixtures for component + integration +// tests. These are the *resolved* domain objects (the shape getCatalogTools +// returns), not raw Notion pages — use test/fixtures/notion.ts for the Notion +// API layer. +import type { + MakerLabTool, + MakerLabUnit, +} from "@/components/catalog-types"; + +const availableUnit: MakerLabUnit = { + id: "unit-available", + name: "Form 4 #1", + serial: "ML-F4-001", + status: "Available", + condition: "Excellent", + location: "Resin Bench", + dateAcquired: "2024-08-12", +}; + +const inUseUnit: MakerLabUnit = { + id: "unit-in-use", + name: "Prusa #1", + serial: "ML-PR-001", + status: "In Use", + condition: "Good", + location: "FDM Bench", + dateAcquired: "2023-01-15", +}; + +const offlineUnit: MakerLabUnit = { + id: "unit-offline", + name: "Trotec #1", + serial: "ML-LSR-001", + status: "Offline", + condition: "Offline", + location: "Laser Bay", + dateAcquired: "2022-04-03", +}; + +/** A simple Available tool (training not required), no units. */ +export const availableTool: MakerLabTool = { + id: "tool-available", + slug: "bandsaw", + name: "Bandsaw", + category: "Woodworking", + categorySub: "Cutting", + location: "Wood Shop", + zone: "Cutting Bay", + trainingLevel: "Beginner", + trainingLabel: "Beginner orientation", + status: "Available", + shortDescription: "A floor-standing bandsaw for curved cuts in wood.", + description: "A floor-standing bandsaw for curved cuts in wood.", + imageSrc: "/tool-images/Bandsaw.png", + ppe: ["Safety glasses"], + materials: ["Plywood", "Hardwood"], + tags: ["Woodworking", "Cutting"], + emergencyStop: "Press the red paddle to stop the blade.", + useRestrictions: null, + mapId: "WS-BAND-01", + notes: null, + links: [], + units: [availableUnit], +}; + +/** An In-Use tool with multiple units (one in use, one available). */ +export const inUseTool: MakerLabTool = { + id: "tool-in-use", + slug: "prusa-mk4", + name: "Prusa MK4", + category: "3D Printing", + categorySub: "FDM", + location: "MakerLab", + zone: "FDM Bench", + trainingLevel: "Intermediate", + trainingLabel: "Intermediate checkout required", + status: "In Use", + shortDescription: "A reliable FDM printer for PLA and PETG prototyping.", + description: "A reliable FDM printer for PLA and PETG prototyping.", + imageSrc: "/tool-images/Prusa MK4.png", + ppe: ["Safety glasses"], + materials: ["PLA", "PETG"], + tags: ["FDM", "Prototyping"], + emergencyStop: null, + useRestrictions: "Complete the FDM orientation before first use.", + mapId: "ML-FDM-01", + notes: "Bed adhesion can be finicky — clean with IPA between prints.", + links: [], + units: [ + inUseUnit, + { ...availableUnit, id: "unit-in-use-b", name: "Prusa #2" }, + ], +}; + +/** An Offline tool (all units offline). */ +export const offlineTool: MakerLabTool = { + id: "tool-offline", + slug: "trotec-speedy-400", + name: "Trotec Speedy 400", + category: "Laser", + categorySub: "CO2", + location: "Laser Room", + zone: "Laser Bay", + trainingLevel: "Advanced", + trainingLabel: "Advanced authorization required", + status: "Offline", + shortDescription: "Large-format CO2 laser cutter — currently out of service.", + description: "Large-format CO2 laser cutter — currently out of service.", + imageSrc: "/tool-images/Trotec Speedy 400.png", + ppe: ["Safety glasses", "Fire watch"], + materials: ["Acrylic", "Plywood"], + tags: ["Laser", "CO2", "Cutting"], + emergencyStop: "Press the red E-stop on the right of the gantry.", + useRestrictions: "Authorized users only.", + mapId: "ML-LSR-400", + notes: null, + links: [], + units: [offlineUnit], +}; + +/** + * A tool with rich links (both a url-kind and a file-kind), PPE, materials, + * and tags — exercises DetailShell resource rendering and the chat route's + * manual collection / web_fetch allowedDomains logic. + */ +export const toolWithLinks: MakerLabTool = { + id: "tool-links", + slug: "form-4", + name: "Form 4", + category: "3D Printing", + categorySub: "Resin", + location: "MakerLab", + zone: "Resin Bench", + trainingLevel: "Intermediate", + trainingLabel: "Intermediate checkout required", + status: "Available", + shortDescription: "A production-grade resin printer for detailed parts.", + description: "A production-grade resin printer for detailed parts.", + imageSrc: "/tool-images/Form 4.png", + ppe: ["Nitrile gloves", "Safety glasses", "Lab coat"], + materials: ["Standard resin", "Tough resin", "Flexible resin"], + tags: ["Resin", "SLA", "Prototyping"], + emergencyStop: "Lift the lid to immediately halt the print.", + useRestrictions: "Resin handling training required before first print.", + mapId: "ML-RESIN-01", + notes: "Ventilation must be running.", + links: [ + { + label: "Form 4 Manual", + href: "https://example.com/form4-manual.pdf", + kind: "Manual", + description: "Manufacturer manual (PDF).", + }, + { + label: "Form 4 SOP", + href: "https://example.com/form4-sop", + kind: "SOP", + description: "Standard operating procedure.", + }, + ], + units: [availableUnit], +}; + +/** A small ready-made catalog covering each status. */ +export const mockCatalog: MakerLabTool[] = [ + availableTool, + inUseTool, + offlineTool, + toolWithLinks, +]; + +export { availableUnit, inUseUnit, offlineUnit }; diff --git a/v5/test/fixtures/notion.ts b/v5/test/fixtures/notion.ts new file mode 100644 index 0000000..9357ca2 --- /dev/null +++ b/v5/test/fixtures/notion.ts @@ -0,0 +1,228 @@ +// Raw NotionPage fixtures. +// +// These mirror the *exact* shape that the `pageToX` parsers in +// `src/lib/notion.ts` read: each property carries a `type` discriminator +// (`title` | `rich_text` | `select` | `multi_select` | `relation` | +// `checkbox` | `url` | `date` | `files`) and the matching payload key. The +// parsers use `prop(page, names)` which picks the first present property from +// a list of candidate keys (snake_case first, then header-case fallbacks like +// `["name", "Name"]`). +// +// Pair these with the MSW handlers (test/msw/handlers.ts), which return them +// wrapped in `notionQueryResponse(...)`. + +// ── Property builders (keep fixtures readable + correct) ──────────── + +export const titleProp = (text: string) => ({ + type: "title" as const, + title: text ? [{ plain_text: text }] : [], +}); + +export const richTextProp = (text: string) => ({ + type: "rich_text" as const, + rich_text: text ? [{ plain_text: text }] : [], +}); + +export const selectProp = (name: string | null) => ({ + type: "select" as const, + select: name ? { name } : null, +}); + +export const multiSelectProp = (names: string[]) => ({ + type: "multi_select" as const, + multi_select: names.map((name) => ({ name })), +}); + +export const relationProp = (ids: string[]) => ({ + type: "relation" as const, + relation: ids.map((id) => ({ id })), +}); + +export const checkboxProp = (value: boolean) => ({ + type: "checkbox" as const, + checkbox: value, +}); + +export const urlProp = (url: string | null) => ({ + type: "url" as const, + url, +}); + +export const dateProp = (start: string | null) => ({ + type: "date" as const, + date: start ? { start } : null, +}); + +/** External file (URL-hosted, e.g. a manufacturer link). */ +export const externalFile = (name: string, url: string) => ({ + name, + type: "external" as const, + external: { url }, +}); + +/** Notion-hosted file (the `file.url` form). */ +export const hostedFile = (name: string, url: string) => ({ + name, + type: "file" as const, + file: { url }, +}); + +export const filesProp = ( + files: ReturnType[] +) => ({ + type: "files" as const, + files, +}); + +// A raw Notion page envelope. Properties are intentionally loosely typed +// (`Record`) so the builders above can populate them; the +// parsers in notion.ts narrow each property via its `type` field at runtime. +export interface NotionPageFixture { + object: "page"; + id: string; + created_time: string; + last_edited_time: string; + properties: Record; +} + +const TS = "2024-08-12T10:00:00.000Z"; + +function page( + id: string, + properties: Record +): NotionPageFixture { + return { + object: "page", + id, + created_time: TS, + last_edited_time: TS, + properties, + }; +} + +// ── Image attachment URLs ────────────────────────────────────────── +// +// `pickFreshImageUrl` drops any URL containing a stale host +// (`airtableusercontent.com`). The tool fixture's `image_attachments` includes +// BOTH a stale URL and a fresh one so tests can exercise the filtering. +export const STALE_IMAGE_URL = + "https://v5.airtableusercontent.com/stale/form4.png"; +export const FRESH_IMAGE_URL = + "https://files.notion.so/fresh/form4.png"; + +// ── Fixtures ──────────────────────────────────────────────────────── + +export const toolsPage: NotionPageFixture = page("tool-1", { + name: titleProp("Form 4"), + description: richTextProp("A production-grade resin printer."), + category: relationProp(["cat-1"]), + location: relationProp(["loc-1"]), + materials: multiSelectProp(["Standard resin", "Tough resin"]), + ppe_required: multiSelectProp(["Nitrile gloves", "Safety glasses"]), + tags: multiSelectProp(["Resin", "SLA"]), + training_required: checkboxProp(true), + use_restrictions: richTextProp("Resin handling training required."), + emergency_stop: richTextProp("Lift the lid to halt the print."), + // First file is stale (airtableusercontent.com), second is fresh — exercises + // pickFreshImageUrl. fileAttachments reads `external.url` for external files. + image_attachments: filesProp([ + externalFile("stale.png", STALE_IMAGE_URL), + externalFile("fresh.png", FRESH_IMAGE_URL), + ]), + notes: richTextProp("Always wear nitrile gloves."), + published: checkboxProp(true), +}); + +export const categoriesPage: NotionPageFixture = page("cat-1", { + name: titleProp("Resin"), + group: selectProp("3D Printing"), +}); + +export const locationsPage: NotionPageFixture = page("loc-1", { + // pageToLocation reads the title via ["id","ID","Name"] — the human-readable + // location id lives in the title. + id: titleProp("ML-RESIN-01"), + zone: selectProp("Resin Bench"), + room: selectProp("MakerLab"), +}); + +export const unitsPage: NotionPageFixture = page("unit-1", { + unit_label: titleProp("Form 4 #1"), + tool: relationProp(["tool-1"]), + serial_number: richTextProp("ML-F4-001"), + asset_tag: richTextProp("AT-0001"), + status: selectProp("Available"), + condition: selectProp("Excellent"), + date_acquired: richTextProp("2024-08-12"), + notes: richTextProp("Primary resin unit."), +}); + +// Resource with BOTH a url AND a files entry (one external, one hosted) so the +// resourceLinks / pickPdfUrl logic can be exercised. One PDF file is included. +export const resourcesPage: NotionPageFixture = page("res-1", { + title: titleProp("Form 4 SOP"), + tool: relationProp(["tool-1"]), + type: selectProp("SOP"), + url: urlProp("https://example.com/form4-sop"), + files: filesProp([ + externalFile("form4-manual.pdf", "https://example.com/form4-manual.pdf"), + hostedFile("safety.png", "https://files.notion.so/safety.png"), + ]), + notes: richTextProp("Standard operating procedure."), + published: checkboxProp(true), +}); + +export const maintenanceLogsPage: NotionPageFixture = page("log-1", { + title: titleProp("Resin tank cloudy"), + unit: relationProp(["unit-1"]), + type: selectProp("Issue Report"), + priority: selectProp("Medium"), + status: selectProp("Open"), + reported_by: richTextProp("Ada Lovelace"), + assigned_to: richTextProp("Lab Staff"), + description: richTextProp("The resin tank looks cloudy after the last print."), + resolution: richTextProp(""), + date_reported: dateProp("2024-09-01"), + date_resolved: dateProp(null), + photo_attachments: filesProp([ + externalFile("photo.jpg", "https://example.com/photo.jpg"), + ]), +}); + +// ── Query response helper ─────────────────────────────────────────── + +export interface NotionQueryResponse { + object: "list"; + results: NotionPageFixture[]; + has_more: boolean; + next_cursor: string | null; +} + +/** + * Wrap fixture pages in a Notion `databases/:id/query` response envelope. + * + * @param pages the page fixtures to return as `results` + * @param opts.hasMore sets `has_more` (drives pagination loops in notion.ts) + * @param opts.nextCursor sets `next_cursor` (the cursor the client sends next) + */ +export function notionQueryResponse( + pages: NotionPageFixture[], + opts: { hasMore?: boolean; nextCursor?: string } = {} +): NotionQueryResponse { + return { + object: "list", + results: pages, + has_more: opts.hasMore ?? false, + next_cursor: opts.nextCursor ?? null, + }; +} + +/** All single-record fixtures keyed by id — handy for `GET /pages/:id`. */ +export const pagesById: Record = { + [toolsPage.id]: toolsPage, + [categoriesPage.id]: categoriesPage, + [locationsPage.id]: locationsPage, + [unitsPage.id]: unitsPage, + [resourcesPage.id]: resourcesPage, + [maintenanceLogsPage.id]: maintenanceLogsPage, +}; diff --git a/v5/test/mocks/next-cache.ts b/v5/test/mocks/next-cache.ts new file mode 100644 index 0000000..2c266e5 --- /dev/null +++ b/v5/test/mocks/next-cache.ts @@ -0,0 +1,29 @@ +import { vi } from "vitest"; + +/** + * Factory for a `next/cache` mock. `catalog.ts` imports `cacheTag`/`cacheLife` + * and `admin/revalidate/route.ts` imports `revalidateTag`; all three only work + * inside the Next build, so tests stub them. + * + * Usage: + * + * import { nextCacheMock } from "@/../test/mocks/next-cache"; + * vi.mock("next/cache", () => nextCacheMock()); + * + * HOISTING CAVEAT: `vi.mock(...)` is hoisted to the top of the module by Vitest, + * *above* your imports. The factory passed to `vi.mock` must therefore not + * reference any variable from the surrounding module scope (it runs before they + * exist). Calling `nextCacheMock()` *inline* inside the factory is safe because + * the import of this file is itself hoisted alongside the mock. If you need to + * assert on the mock fns later, grab them from the mocked module: + * + * import { cacheTag, revalidateTag } from "next/cache"; + * expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith("catalog"); + */ +export function nextCacheMock() { + return { + cacheLife: vi.fn(), + cacheTag: vi.fn(), + revalidateTag: vi.fn(), + }; +} diff --git a/v5/test/mocks/server-only.ts b/v5/test/mocks/server-only.ts new file mode 100644 index 0000000..2d85fb7 --- /dev/null +++ b/v5/test/mocks/server-only.ts @@ -0,0 +1,8 @@ +// Stub for the `server-only` package. +// +// `src/lib/rate-limit.ts` (and any module that transitively imports it) does +// `import "server-only"`, whose real implementation throws when evaluated +// outside a React Server Component / Next build. Vitest aliases `server-only` +// to this empty module (see vitest.config.ts) so those modules import cleanly +// in the test environment. +export {}; diff --git a/v5/test/msw/handlers.ts b/v5/test/msw/handlers.ts new file mode 100644 index 0000000..6792683 --- /dev/null +++ b/v5/test/msw/handlers.ts @@ -0,0 +1,113 @@ +import { http, HttpResponse } from "msw"; +import { + categoriesPage, + locationsPage, + maintenanceLogsPage, + notionQueryResponse, + pagesById, + resourcesPage, + toolsPage, + unitsPage, + type NotionPageFixture, +} from "../fixtures/notion"; + +const NOTION = "https://api.notion.com/v1"; + +// Map a database id to the fixture page(s) it should return. The Notion DB ids +// come from the NOTION_DB_* env vars; tests that exercise the real Notion path +// stub those envs to these sentinel values so the query handler can route by id. +// +// Agents that stub different db ids can override the query handler per-test +// with `server.use(...)`. +export const DB_IDS = { + tools: "db-tools", + categories: "db-categories", + locations: "db-locations", + units: "db-units", + resources: "db-resources", + maintenance_logs: "db-maintenance", + flags: "db-flags", +} as const; + +const PAGES_BY_DB: Record = { + [DB_IDS.tools]: [toolsPage], + [DB_IDS.categories]: [categoriesPage], + [DB_IDS.locations]: [locationsPage], + [DB_IDS.units]: [unitsPage], + [DB_IDS.resources]: [resourcesPage], + [DB_IDS.maintenance_logs]: [maintenanceLogsPage], + [DB_IDS.flags]: [], +}; + +// ── Default handlers ──────────────────────────────────────────────── +// +// All handlers are fixture-driven and overridable per-test via +// `server.use(...)`. The defaults assume the env's NOTION_DB_* vars are set to +// the DB_IDS sentinels above; if a request arrives for an unknown database id +// the handler returns an empty result set (so unknown-id paths don't 500). +export const handlers = [ + // POST /databases/:id/query — paginated query. Returns the fixtures mapped to + // the database id. has_more is always false here (single page); override with + // server.use(...) to test the next_cursor pagination loop. + http.post(`${NOTION}/databases/:id/query`, ({ params }) => { + const id = params.id as string; + const pages = PAGES_BY_DB[id] ?? []; + return HttpResponse.json(notionQueryResponse(pages)); + }), + + // POST /pages — create a page (e.g. createMaintenanceLog). Echoes back a page + // envelope so pageToMaintenanceLog can parse it. + http.post(`${NOTION}/pages`, async ({ request }) => { + const body = (await request.json().catch(() => ({}))) as { + properties?: Record; + }; + return HttpResponse.json({ + object: "page", + id: "created-page-1", + created_time: "2024-09-01T10:00:00.000Z", + last_edited_time: "2024-09-01T10:00:00.000Z", + properties: body.properties ?? {}, + }); + }), + + // GET /pages/:id — single page fetch. Returns the matching fixture or 404. + http.get(`${NOTION}/pages/:id`, ({ params }) => { + const id = params.id as string; + const fixture = pagesById[id]; + if (!fixture) { + return HttpResponse.json( + { object: "error", status: 404, code: "object_not_found", message: "Not found" }, + { status: 404 } + ); + } + return HttpResponse.json(fixture); + }), + + // POST /file_uploads — Notion file-upload session creation. Returns an id and + // an upload_url the client then POSTs bytes to. + http.post(`${NOTION}/file_uploads`, () => { + return HttpResponse.json({ + id: "file-upload-1", + object: "file_upload", + status: "pending", + upload_url: `${NOTION}/file_uploads/file-upload-1/send`, + }); + }), + + // POST /file_uploads/:id/send — receive the bytes for an upload session. + http.post(`${NOTION}/file_uploads/:id/send`, ({ params }) => { + return HttpResponse.json({ + id: params.id as string, + object: "file_upload", + status: "uploaded", + }); + }), + + // Upstash Redis REST pipeline (rate-limit.ts). The pipeline body is an array + // of commands; the default returns INCR=1 (allowed) + EXPIRE ok. Wildcard host + // so any UPSTASH_REDIS_REST_URL value matches. Override per-test to simulate + // over-limit counts or non-ok responses (fail-open). + http.post(/\/pipeline$/, () => { + return HttpResponse.json([{ result: 1 }, { result: 1 }]); + }), +]; diff --git a/v5/test/msw/server.ts b/v5/test/msw/server.ts new file mode 100644 index 0000000..91ecfa0 --- /dev/null +++ b/v5/test/msw/server.ts @@ -0,0 +1,7 @@ +import { setupServer } from "msw/node"; +import { handlers } from "./handlers"; + +// The shared MSW node server. Lifecycle (listen/resetHandlers/close) is wired in +// vitest.setup.ts. Tests override defaults per-test with `server.use(...)`; +// overrides are dropped after each test by the `afterEach(resetHandlers)` hook. +export const server = setupServer(...handlers); diff --git a/v5/test/smoke.test.ts b/v5/test/smoke.test.ts new file mode 100644 index 0000000..5fcaad3 --- /dev/null +++ b/v5/test/smoke.test.ts @@ -0,0 +1,15 @@ +// Canary test proving the harness boots: globals work, the `@/` alias resolves, +// and TS/TSX source transforms under Vitest. Leave this in place — if it goes +// red, the shared harness is broken before any feature test runs. +import { isSupportedLocale } from "@/i18n/config"; + +describe("harness smoke", () => { + it("runs with globals enabled", () => { + expect(1 + 1).toBe(2); + }); + + it("resolves the @/ alias and transforms src TS", () => { + expect(isSupportedLocale("en")).toBe(true); + expect(isSupportedLocale("xx")).toBe(false); + }); +}); diff --git a/v5/test/utils/render.tsx b/v5/test/utils/render.tsx new file mode 100644 index 0000000..2ad1230 --- /dev/null +++ b/v5/test/utils/render.tsx @@ -0,0 +1,44 @@ +import type { ReactElement, ReactNode } from "react"; +import { NextIntlClientProvider } from "next-intl"; +import { + render as rtlRender, + type RenderOptions, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import enMessages from "../../messages/en.json"; + +type Messages = typeof enMessages; + +interface ProvidersOptions { + /** BCP-47 locale passed to the i18n provider. Defaults to "en". */ + locale?: string; + /** Override the message catalog (defaults to messages/en.json). */ + messages?: Messages; +} + +/** + * Custom RTL render that wraps the UI in `NextIntlClientProvider` so + * i18n-aware components (anything using `useTranslations`) render without + * throwing. Components that don't use i18n can use this harmlessly. + * + * import { render, screen, userEvent } from "@/../test/utils/render"; + * render(); + */ +export function render( + ui: ReactElement, + { locale = "en", messages = enMessages, ...options }: ProvidersOptions & + Omit = {} +) { + function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + } + return rtlRender(ui, { wrapper: Wrapper, ...options }); +} + +// Re-export the full RTL surface so test files import everything from here. +export * from "@testing-library/react"; +export { userEvent }; diff --git a/v5/test/vitest.d.ts b/v5/test/vitest.d.ts new file mode 100644 index 0000000..c1ece19 --- /dev/null +++ b/v5/test/vitest.d.ts @@ -0,0 +1,6 @@ +// Makes Vitest's globals (describe/it/expect/vi/beforeAll/...) and the +// jest-dom matchers available to TypeScript everywhere, since the harness runs +// with `test.globals: true` (see vitest.config.ts). Picked up by the root +// tsconfig's `**/*.ts` include so `tsc --noEmit` typechecks test files too. +/// +/// diff --git a/v5/vitest.config.ts b/v5/vitest.config.ts new file mode 100644 index 0000000..e3f563a --- /dev/null +++ b/v5/vitest.config.ts @@ -0,0 +1,32 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +const r = (p: string) => fileURLToPath(new URL(p, import.meta.url)); + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + // `@/foo` → `src/foo` (matches tsconfig paths). + "@": r("./src"), + // `import "server-only"` throws outside a server runtime; swap it for an + // empty module so rate-limit.ts and its importers load under Vitest. + "server-only": r("./test/mocks/server-only.ts"), + }, + }, + test: { + // describe/it/expect/vi available without imports. + globals: true, + environment: "jsdom", + setupFiles: ["./vitest.setup.ts"], + // Playwright specs live in e2e/ and must not be collected by Vitest. + exclude: ["**/node_modules/**", "**/dist/**", "e2e/**"], + coverage: { + provider: "v8", + // Reporters only — no thresholds, no gate. + reporter: ["text", "html"], + exclude: ["e2e/**", "test/**", "**/*.config.*", "**/*.d.ts"], + }, + }, +}); diff --git a/v5/vitest.setup.ts b/v5/vitest.setup.ts new file mode 100644 index 0000000..9b55d82 --- /dev/null +++ b/v5/vitest.setup.ts @@ -0,0 +1,77 @@ +import "@testing-library/jest-dom"; +import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest"; +import { server } from "./test/msw/server"; + +// ── Web Storage shim ─────────────────────────────────────────────── +// +// Under this Node/jsdom combo `window.localStorage` is a bare object missing +// getItem/setItem/removeItem/clear (Node's experimental `--localstorage-file` +// clobbers jsdom's Storage — hence the startup warning). Any component that +// touches localStorage (ThemeToggle, etc.) would throw. Install a real +// in-memory Storage so the whole suite has working localStorage/sessionStorage, +// and reset it before each test for isolation. +function createStorage(): Storage { + let map = new Map(); + return { + get length() { + return map.size; + }, + key: (i: number) => [...map.keys()][i] ?? null, + getItem: (k: string) => (map.has(k) ? map.get(k)! : null), + setItem: (k: string, v: string) => { + map.set(k, String(v)); + }, + removeItem: (k: string) => { + map.delete(k); + }, + clear: () => { + map = new Map(); + }, + } as Storage; +} + +function ensureStorage(name: "localStorage" | "sessionStorage") { + const current = (globalThis as Record)[name] as Storage | undefined; + if (!current || typeof current.setItem !== "function") { + Object.defineProperty(globalThis, name, { + configurable: true, + value: createStorage(), + }); + } +} + +beforeEach(() => { + ensureStorage("localStorage"); + ensureStorage("sessionStorage"); + globalThis.localStorage?.clear(); + globalThis.sessionStorage?.clear(); +}); + +// ── MSW lifecycle ────────────────────────────────────────────────── +// +// `onUnhandledRequest: "error"` makes any un-mocked outbound HTTP call fail +// loudly — tests must never hit the real network. If a specific test +// legitimately needs to relax this (e.g. it asserts a fetch rejects), pass a +// per-call override via `server.listen(...)` inside that test, or register a +// passthrough/override handler with `server.use(...)`. +beforeAll(() => { + server.listen({ onUnhandledRequest: "error" }); +}); + +// Drop per-test `server.use(...)` overrides so tests stay isolated. +afterEach(() => { + server.resetHandlers(); +}); + +afterAll(() => { + server.close(); +}); + +// ── Per-test cleanup ─────────────────────────────────────────────── +// +// Undo `vi.stubEnv(...)` and restore any spies/mocks created with +// `vi.spyOn` / `vi.fn` automatically after every test. +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +});