diff --git a/.claude/TODO/2026-04-17-components-template-editor-missing-tests.md b/.claude/TODO/2026-04-17-components-template-editor-missing-tests.md
index 42f1872..5b184da 100644
--- a/.claude/TODO/2026-04-17-components-template-editor-missing-tests.md
+++ b/.claude/TODO/2026-04-17-components-template-editor-missing-tests.md
@@ -16,7 +16,7 @@ files:
- src/components/template-editor/actions.ts
discovered: 2026-04-17
discovered_by: components-template-editor
-status: open
+status: resolved
---
## Problem
@@ -39,3 +39,40 @@ Add at minimum:
- Regressions in `compileMjml`'s auth gate or size cap can ship unnoticed (security/DoS risk).
- Merge-field category changes or taxonomy refactors will not surface breakage.
- Phase 2 of the template editor (adding MP persistence, token resolution) will be harder to land safely without a baseline.
+
+---
+
+## Resolution (2026-09-13)
+Closed by the coverage push. Every file in `src/components/template-editor/`
+now has a co-located test, plus the two route files:
+
+| File | Tests | Statements | Lines |
+|---|---|---|---|
+| `merge-fields.ts` | 5 | 100% | 100% |
+| `grapes-config.ts` | 6 | 100% | 100% |
+| `actions.ts` | 7 | 100% | 100% |
+| `merge-field-picker.tsx` | 7 | 100% | 100% |
+| `editor-import-dialog.tsx` | 8 | 95.65% | 100% |
+| `editor-code-dialog.tsx` | 11 | 97.67% | 100% |
+| `editor-export-dialog.tsx` | 11 | 98.08% | 100% |
+| `editor-toolbar.tsx` | 21 | 100% | 100% |
+| `editor-canvas.tsx` | 7 | 100% | 100% |
+| `template-editor-form.tsx` | 3 | 100% | 100% |
+| `templateeditor/template-editor.tsx` | 6 | 100% | 100% |
+| `templateeditor/page.tsx` | 1 | 100% | 100% |
+
+Directory aggregate: 98.8% statements, 100% lines, across 90 tests.
+
+Two mocking notes worth keeping, both learned the hard way:
+- `useEditor()` must be mocked with a **stable object reference** built once via
+ `vi.hoisted()`. Several components run `useEffect`s keyed on the editor
+ object's identity, so a `useEditor: () => ({...})` factory returns a new
+ object on every render and spins an infinite render loop that hangs the runner.
+- `editor-canvas.tsx` imports real `.css`, which this repo's Vite/PostCSS
+ config cannot process under test — both stylesheet imports are mocked to
+ empty modules.
+
+The two sibling template-editor TODOs (`-no-mp-persistence`,
+`-merge-token-resolver`) were re-verified against the code during this work and
+remain **accurate and open**. The new tests document current behaviour; they do
+not paper over either defect.
diff --git a/.claude/TODO/2026-09-13-components-select-uncontrolled-to-controlled.md b/.claude/TODO/2026-09-13-components-select-uncontrolled-to-controlled.md
new file mode 100644
index 0000000..e39eaba
--- /dev/null
+++ b/.claude/TODO/2026-09-13-components-select-uncontrolled-to-controlled.md
@@ -0,0 +1,71 @@
+---
+title: Radix Select fields switched from uncontrolled to controlled on first selection
+severity: medium
+tags: [bug]
+area: components
+files: [src/components/group-wizard/step-attributes.tsx, src/components/group-wizard/step-identity.tsx, src/components/group-wizard/step-meeting.tsx, src/components/group-wizard/step-organization.tsx, src/app/(web)/tools/addeditfamily/add-edit-family.tsx]
+discovered: 2026-09-13
+discovered_by: coverage-review-orchestrator
+status: resolved
+---
+
+## Problem
+Fourteen `Select` fields across the group wizard and Add/Edit Family passed
+`undefined` as their value while nothing was selected:
+
+```tsx
+value={field.value ? String(field.value) : undefined}
+```
+
+A Radix `Select` with `value={undefined}` is **uncontrolled**. As soon as the
+user picked an option, `field.value` became truthy and the component received a
+string — switching to **controlled** mid-life. React logs:
+
+```
+Select is changing from uncontrolled to controlled. Components should not
+switch from controlled to uncontrolled (or vice versa). Decide between using
+a controlled or uncontrolled value for the lifetime of the component.
+```
+
+Beyond the console noise, this is a real correctness problem: while the field
+is uncontrolled, React state is not the source of truth for it. Programmatic
+resets — `form.reset()` between wizard runs, or clearing a field back to its
+empty value — are not guaranteed to be reflected in the rendered trigger,
+because the component keeps its own internal value until it becomes controlled.
+
+The falsy check compounds it. `field.value` of `0` is a legitimate lookup id in
+Ministry Platform, and it is falsy, so a genuinely-selected id of 0 would also
+have rendered as "no selection".
+
+## Evidence
+- `src/components/group-wizard/step-meeting.tsx:49,95,123,151`
+- `src/components/group-wizard/step-attributes.tsx:70,98,126,154`
+- `src/components/group-wizard/step-organization.tsx:59,87,162`
+- `src/components/group-wizard/step-identity.tsx:64,128`
+- `src/app/(web)/tools/addeditfamily/add-edit-family.tsx:1168`
+ (variant: `value={value > 0 ? String(value) : undefined}`)
+- Surfaced by the new step tests — 7 warnings per `vitest run`, e.g.
+ `step-organization.test.tsx > StepOrganization > selects a congregation`.
+
+## Fix applied
+All fourteen sites now pass `""` instead of `undefined`:
+
+```tsx
+value={field.value ? String(field.value) : ""}
+```
+
+`""` is Radix's documented "no selection" value for a controlled `Select`: the
+`SelectValue` placeholder still renders, and the component is controlled for
+its entire lifetime. (`SelectItem` may not use `""` as its own value, which is
+what makes it safe as the sentinel.)
+
+Verified: 1,535 tests pass, `npm run test:run` emits zero warnings, `tsc
+--noEmit` is clean, and `npm run build` succeeds.
+
+## Follow-up worth considering
+The `field.value ? ...` truthiness test is still wrong for a legitimate id of
+`0`. No current lookup uses 0 as a real id, so this is latent rather than
+live — but the correct predicate is `field.value != null`, and a future MP
+lookup that does use 0 would fail silently. Left as-is here because changing
+the predicate is a behavioural change beyond the warning fix, and it deserves
+its own review against the actual lookup data.
diff --git a/.claude/TODO/2026-09-13-dead-empty-fields-branch-handlenext.md b/.claude/TODO/2026-09-13-dead-empty-fields-branch-handlenext.md
new file mode 100644
index 0000000..643b5f0
--- /dev/null
+++ b/.claude/TODO/2026-09-13-dead-empty-fields-branch-handlenext.md
@@ -0,0 +1,66 @@
+---
+title: Unreachable "empty STEP_FIELDS" branch in GroupWizard.handleNext
+severity: low
+tags: [refactor, missing-test]
+area: components
+files: [src/app/(web)/tools/groupwizard/group-wizard.tsx]
+discovered: 2026-09-13
+discovered_by: coverage-agent-group-wizard
+status: open
+---
+
+## Problem
+`handleNext` in `group-wizard.tsx` (lines 120-133) special-cases a step whose
+`STEP_FIELDS[currentStep]` entry is missing or empty by skipping validation
+and advancing immediately:
+
+```ts
+const fields = STEP_FIELDS[currentStep];
+if (!fields || fields.length === 0) {
+ setCompletedSteps((prev) => new Set(prev).add(currentStep));
+ setCurrentStep((prev) => Math.min(prev + 1, WIZARD_STEPS.length - 1));
+ return;
+}
+```
+
+The only entry in `STEP_FIELDS` (see `schema.ts`) with an empty array is
+index `5` (the Review step, `STEP_FIELDS[5] = []`). But `WizardNavigation`
+never renders a "Next" button on the last step (`currentStep === totalSteps - 1`)
+— it renders the Submit/"Save Changes" button instead, which calls
+`handleSubmit`, not `handleNext`. `STEP_FIELDS` is otherwise fully populated
+for indices 0-4, and `currentStep` never exceeds `WIZARD_STEPS.length - 1`
+(5), so this branch is unreachable through the actual UI.
+
+This isn't a security or correctness bug (nothing lets a user skip real
+validation — the branch simply can never execute), but it is 3 statements of
+dead defensive code that both inflates the file's apparent branch count and
+cannot be exercised by any user-facing test, which is why coverage tooling
+flags `group-wizard.tsx` at 94.5% statements instead of ≥95% despite every
+reachable branch being covered.
+
+## Evidence
+- `src/app/(web)/tools/groupwizard/group-wizard.tsx:120-126` — the
+ `!fields || fields.length === 0` branch.
+- `src/components/group-wizard/schema.ts:58-65` — `STEP_FIELDS`, only index
+ `5` is empty, and index 5 is the last step.
+- `src/components/group-wizard/wizard-navigation.tsx:28,55-75` — `isReviewStep`
+ gates rendering Submit vs. Next; the last step never shows the Next button
+ that calls `handleNext`.
+- `npx vitest run --coverage` on `group-wizard.tsx` reports lines 123-125
+ uncovered even after `src/app/(web)/tools/groupwizard/group-wizard.test.tsx`
+ was extended with 12 additional cases covering every other branch/function
+ in the file (94.5% statements / 96.25% lines final).
+
+## Proposed fix
+Either:
+1. Remove the `!fields || fields.length === 0` special case entirely (the
+ `form.trigger([])` call with an empty array resolves `true` immediately
+ in react-hook-form, so simplifying to always call `form.trigger(fields)`
+ would behave identically and remove the dead branch), or
+2. If the branch is meant as future-proofing for a step reachable via some
+ other entry point, add a code comment explaining that intent so the next
+ reader (and coverage tooling) doesn't flag it as untested dead code.
+
+## Impact if not fixed
+None functionally — this is a coverage/clarity issue, not a behavior bug.
+Low priority; safe to leave as documented, known-dead code.
diff --git a/.claude/TODO/2026-09-13-mergetemplate-logs-address-pii-on-error.md b/.claude/TODO/2026-09-13-mergetemplate-logs-address-pii-on-error.md
new file mode 100644
index 0000000..77ba706
--- /dev/null
+++ b/.claude/TODO/2026-09-13-mergetemplate-logs-address-pii-on-error.md
@@ -0,0 +1,58 @@
+---
+title: mergeTemplate (and sibling actions) console.error the raw docxtemplater error, which can carry household addresses
+severity: high
+tags: [security, bug]
+area: components
+files: [src/components/address-labels/actions.ts]
+discovered: 2026-09-13
+discovered_by: coverage-agent-address-labels
+status: open
+---
+
+## Problem
+
+CLAUDE.md rule 14 requires that errors log "identifiers and shape" only — never
+record content — "including inside thrown error messages, which travel further
+than logs do." `mergeTemplate` (and its siblings `generateLabelPdf` /
+`generateLabelDocx`) violate this by passing the entire caught `error` object to
+`console.error`, not just `error.message`.
+
+For `mergeTemplate` this is a concrete PII leak, not a theoretical one:
+docxtemplater's scope-parser error path attaches the live merge scope object to
+`err.properties.scope` (see `node_modules/docxtemplater/js/errors.js:593-600`,
+function that throws `"scopeparser_execution_failed"`). In this feature, the
+merge scope is exactly the `addresses` array built in `mergeTemplate` — each
+entry carries `Name`, `AddressLine1`, `AddressLine2`, `City`, `State`,
+`PostalCode` for a real household. A user-uploaded template with a malformed
+tag (e.g. a token that calls a method that throws, or a bad expression) causes
+docxtemplater to throw with that scope attached, and
+`console.error('mergeTemplate error:', error)` writes the full object —
+including the nested `.properties.scope` with every printable household's
+name and address — to server logs.
+
+## Evidence
+
+- `src/components/address-labels/actions.ts:328` — `console.error('mergeTemplate error:', error);` logs the raw `error`, not `error.message`.
+- `src/components/address-labels/actions.ts:189` and `:232` — same pattern for `generateLabelPdf` / `generateLabelDocx` (lower risk here, but same anti-pattern).
+- `node_modules/docxtemplater/js/errors.js:593-601` — `err.properties.scope = scope;` attaches the full render scope (the caller's data) to certain template errors.
+- `mergeTemplate`'s `addresses` array (`actions.ts:286-296`) contains `Name`, `AddressLine1`, `AddressLine2`, `City`, `State`, `PostalCode` — real household PII — which is exactly what would appear in that scope.
+
+## Proposed fix
+
+Log only identifiers/shape, per rule 14: e.g.
+```ts
+console.error('mergeTemplate error:', error instanceof Error ? error.message : 'non-Error thrown');
+```
+or, if the `docxtemplater` error's `.properties.id`/`.name` is useful for
+diagnosing which failure mode occurred, log those specific fields explicitly
+rather than the object itself — never `error` or `error.properties` in bulk.
+Apply the same change to the `console.error` calls in `generateLabelPdf` and
+`generateLabelDocx` for consistency and defense in depth.
+
+## Impact if not fixed
+
+Any user who uploads a mail-merge template with a tag that fails docxtemplater's
+scope parser causes every printable household's name and mailing address for
+that batch to be written to application/server logs (e.g. Vercel function
+logs), which are typically retained, more widely readable, and less access
+controlled than the MP database itself.
diff --git a/.claude/TODO/2026-09-13-page-logs-raw-error-object.md b/.claude/TODO/2026-09-13-page-logs-raw-error-object.md
new file mode 100644
index 0000000..14cf70b
--- /dev/null
+++ b/.claude/TODO/2026-09-13-page-logs-raw-error-object.md
@@ -0,0 +1,62 @@
+---
+title: "AddEditFamilyPage logs the raw error object instead of an identifier"
+severity: medium
+tags: [security, drift]
+area: components
+files: [src/app/(web)/tools/addeditfamily/page.tsx]
+discovered: 2026-09-13
+discovered_by: coverage-agent-addeditfamily
+status: open
+---
+
+## Problem
+`AddEditFamilyPage` swallows a failed `resolveContactIdFromPage` call with:
+
+```ts
+console.warn("Failed to resolve Contact_ID from page record:", error);
+```
+
+CLAUDE.md rule 14 requires errors to log identifiers and shape only — never
+record content, `$filter` strings, or request/response bodies, "including
+inside thrown error messages, which travel further than logs do." Logging the
+raw `error` object prints its `.message` (and, depending on the runtime,
+`.stack`), which can carry the interpolated MP `$filter` string built in
+`FamilyService.resolveContactIdFromPage` (`` `${primaryKey} = ${recordId}` ``)
+or other request detail if the underlying `getTableRecords` call or
+`validateColumnName`/`validatePositiveInt` throws with that detail embedded.
+
+The sibling file `src/lib/tool-params.server.ts` (same call-swallowing shape,
+literally a few lines away in the import graph) already gets this right:
+
+```ts
+// Identifier only. A caller without an MP security role also lands
+// here — the tools layout redirects them to /no-access, and the page
+// simply renders without page metadata in the meantime.
+console.warn('tool_params.page_data_unavailable', { pageID: parsedPageID });
+```
+
+`page.tsx` should follow the same pattern instead of passing `error` through.
+
+## Evidence
+- `src/app/(web)/tools/addeditfamily/page.tsx:29` — `console.warn("Failed to resolve Contact_ID from page record:", error)`
+- `src/lib/tool-params.server.ts:58-60` — the correct pattern, logging an identifier object with no error content
+- `src/services/familyService.ts:140-165` — `resolveContactIdFromPage` builds a `$filter` string from `primaryKey`/`recordId` that could surface in a thrown error's `.message`
+
+## Proposed fix
+Replace the `console.warn` call with an identifier-only log, e.g.:
+
+```ts
+console.warn("addeditfamily.resolve_contact_id_failed", {
+ tableName: params.pageData.Table_Name,
+ recordID: params.recordID,
+});
+```
+
+and drop `error` from the log call entirely (or log only `error instanceof Error ? error.name : typeof error` if a coarse classification is wanted).
+
+## Impact if not fixed
+Low likelihood, low blast radius — this only logs when the page-record
+resolution has already failed, and only to server logs. But it is the second
+place after `tool-params.server.ts`'s example that this exact swallow-and-warn
+shape appears, and it is the one that gets it wrong; left uncorrected, it is
+an easy pattern to copy into the next new page.
diff --git a/.claude/TODO/2026-09-13-removegroup-order-guard-mismatch.md b/.claude/TODO/2026-09-13-removegroup-order-guard-mismatch.md
new file mode 100644
index 0000000..0cab77d
--- /dev/null
+++ b/.claude/TODO/2026-09-13-removegroup-order-guard-mismatch.md
@@ -0,0 +1,89 @@
+---
+title: removeGroup drops a non-empty group's fields from the save payload
+severity: high
+tags: [bug]
+area: components
+files: [src/components/field-management/use-field-order-state.ts]
+discovered: 2026-09-13
+discovered_by: coverage-agent-field-management
+status: open
+---
+
+## Problem
+
+`removeGroup(name)` in `use-field-order-state.ts` guards `groupedFields` against
+removing a group that still has fields, but does not apply the same guard to
+`groupOrder`. The two state updates run independently:
+
+```ts
+const removeGroup = useCallback((name: string) => {
+ setGroupedFields((prev) => {
+ if ((prev[name] || []).length > 0) return prev; // <-- guarded: no-op if non-empty
+ const { [name]: _, ...rest } = prev;
+ return rest;
+ });
+ setGroupOrder((prev) => prev.filter((g) => g !== name)); // <-- unconditional
+ setIsDirty(true);
+}, []);
+```
+
+If a caller invokes `removeGroup` on a group that still has fields (the UI's
+trash-can button is only rendered for empty groups, but `removeGroup` is a
+plain callback with no such enforcement — any other call site, a future UI
+change, or a test/automation calling the hook directly can hit this), the
+group's fields survive in `groupedFields` but the group name is removed from
+`groupOrder`.
+
+`buildSavePayload()` iterates `groupOrder` (not `Object.keys(groupedFields)`)
+to build the save payload:
+
+```ts
+for (const groupName of groupOrder) {
+ const fieldIds = groupedFields[groupName] || [];
+ ...
+}
+```
+
+Because the group name is no longer in `groupOrder`, its fields are silently
+skipped — they never appear in the payload sent to
+`savePageFieldOrder`/`api_Tools_...` stored proc. Since this feature writes
+live Ministry Platform page field configuration, this is a silent
+configuration-loss bug: fields simply vanish from the page's field list on
+next save, with no error or warning to the user.
+
+## Evidence
+
+- `src/components/field-management/use-field-order-state.ts:123-130` (removeGroup)
+- `src/components/field-management/use-field-order-state.ts:222-223` (buildSavePayload
+ iterates `groupOrder`, using `groupedFields[groupName] || []` as a fallback for
+ names no longer present)
+- Reproduced in
+ `src/components/field-management/use-field-order-state.test.ts`, describe
+ block `useFieldOrderState > removeGroup`, test `'is a no-op on groupedFields
+ when the group still has fields, but still removes it from groupOrder'`:
+ after calling `removeGroup('1 - First')` on a group with one field, the test
+ asserts `groupedFields['1 - First']` still equals `[1]` but
+ `buildSavePayload()`'s output has no entry for that field at all.
+
+## Proposed fix
+
+Make the two updates consistent — either:
+1. Only remove from `groupOrder` when the group was actually empty (mirror the
+ same guard used for `groupedFields`), or
+2. If `removeGroup` is meant to force-delete a non-empty group (moving its
+ fields elsewhere, e.g. into "99 - Other Fields"), do that explicitly instead
+ of silently orphaning the fields.
+
+Given the existing UI only calls `removeGroup` for empty groups (see
+`sortable-group.tsx`'s trash button, rendered only when `fieldIds.length ===
+0`), option 1 (guard `groupOrder` the same way) is the minimal, safe fix and
+matches the function's only current call site's expectations.
+
+## Impact if not fixed
+
+Any caller of `removeGroup` on a non-empty group — now or in a future UI
+change — silently drops that group's fields from the next save, with no error
+surfaced to the admin performing the edit. This directly corrupts live
+Ministry Platform page field configuration (view order, group assignment,
+required/hidden/filter settings all lost for the affected fields) until
+someone notices fields missing from the page and manually re-adds them.
diff --git a/.claude/TODO/2026-09-13-search-empty-state-never-renders.md b/.claude/TODO/2026-09-13-search-empty-state-never-renders.md
new file mode 100644
index 0000000..6b6e3d7
--- /dev/null
+++ b/.claude/TODO/2026-09-13-search-empty-state-never-renders.md
@@ -0,0 +1,51 @@
+---
+title: "Add/Edit Family search: 'No contacts found' empty state never renders"
+severity: low
+tags: [bug]
+area: components
+files: [src/app/(web)/tools/addeditfamily/add-edit-family.tsx]
+discovered: 2026-09-13
+discovered_by: coverage-agent-addeditfamily
+status: open
+---
+
+## Problem
+`FamilySearchBar` (in `add-edit-family.tsx`) renders a `No contacts
+found. ` whenever the query is at least 2 characters and
+`searchContacts` returned zero results. In practice this text never appears in
+the DOM, because `cmdk`'s `CommandEmpty` only renders when the command's
+*global* registered-item count (`filtered.count`, tracked across the whole
+`Command` tree, not just the group it lives in) is zero. The sibling "Or"
+`CommandGroup` always registers a `+ New Family with last name "…"` item as
+soon as the query is >= 2 characters, so that count is never zero, and
+`CommandEmpty` unconditionally returns `null` regardless of our own
+`results.length === 0` check.
+
+Net effect: a user who searches for a name with no matches sees only the
+"+ New Family" suggestion, with no indication that the search itself came up
+empty (they may not notice there's a difference between "no matches" and
+"you haven't typed enough").
+
+## Evidence
+- `src/app/(web)/tools/addeditfamily/add-edit-family.tsx:457-459` — the
+ `CommandEmpty` block, gated on local `results`/`query` state.
+- `node_modules/cmdk/dist/index.mjs` — `CommandEmpty` (`Ie`) renders based on
+ `useCommandState(v => v.filtered.count === 0)`, and `filtered.count` is
+ computed from `shouldFilter === false` as `u.current.size` (total item
+ registrations), not scoped to a single `CommandGroup`.
+- `src/app/(web)/tools/addeditfamily/add-edit-family.test.tsx` — the test
+ `"finds no CommandEmpty text when the search returns nothing"` documents and
+ asserts this actual (broken) behavior instead of the intended message.
+
+## Proposed fix
+Don't rely on `CommandEmpty`'s built-in visibility heuristic here. Replace it
+with an explicit conditional `
` (matching the style already used for the
+"Type at least 2 characters" and "Searching…" states a few lines above) that
+renders whenever `!isSearching && query.trim().length >= 2 && results.length
+=== 0`, instead of wrapping the message in `
`.
+
+## Impact if not fixed
+Cosmetic/UX only — the "+ New Family" path still works, so no data is lost or
+corrupted. A user searching for an existing family that doesn't actually exist
+gets a slightly less clear affordance (no explicit "not found" feedback), but
+can still proceed by creating a new family.
diff --git a/.claude/TODO/2026-09-13-testing-coverage-report-masked-untested-files.md b/.claude/TODO/2026-09-13-testing-coverage-report-masked-untested-files.md
new file mode 100644
index 0000000..6e721d0
--- /dev/null
+++ b/.claude/TODO/2026-09-13-testing-coverage-report-masked-untested-files.md
@@ -0,0 +1,64 @@
+---
+title: Coverage config omitted `coverage.include`, hiding every untested file from the report
+severity: high
+tags: [bug, testing, drift]
+area: testing
+files: [vitest.config.mts, .github/workflows/test.yml]
+discovered: 2026-09-13
+discovered_by: coverage-review-orchestrator
+status: resolved
+---
+
+## Problem
+`vitest.config.mts` (then `vitest.config.ts`) configured v8 coverage without a `coverage.include` glob.
+The v8 provider only instruments files that were actually loaded during the
+test run, so any source file that no test imported was absent from the report
+entirely rather than counted as 0%.
+
+Note for anyone fixing this from memory or from an older guide: the flag that
+used to do this was `coverage.all: true`, and it was **removed in Vitest 5**.
+Setting it now is a TypeScript error (`No overload matches this call`) and has
+no runtime effect — `coverage.include` is the only lever. This was verified
+in-session: adding `all: true` alongside `include` changed nothing but the type
+check, and removing it changed no coverage number.
+
+The reported figure was therefore a percentage of *the code tests already
+touch*, not of the codebase. It read 83.14% statements while true coverage over
+`src/**` was 48.41%. 131 of 176 source files never appeared in the report at
+all, including `src/services/familyService.ts` (756 lines, zero tests) and
+`src/app/(web)/tools/layout.tsx` (the `/tools` authorization gate, zero tests).
+
+CI uploads this report to Codecov, so the inflated number was the project's
+published quality signal.
+
+## Evidence
+- `vitest.config.ts:15-24` (pre-fix, before the rename to `.mts`) — `coverage` block had `provider`,
+ `reporter`, `exclude`, but no `all` and no `include`.
+- `npx vitest run --coverage` before fix: `Statements : 83.14% ( 1845/2219 )`
+- Same suite with `include: ['src/**/*.{ts,tsx}']`:
+ `Statements : 48.41% ( 1845/3811 )` — identical numerator, denominator up 72%.
+- Files absent from the pre-fix report despite existing: `familyService.ts`,
+ `googlePlacesService.ts`, all of `src/components/template-editor/`, all of
+ `src/app/(web)/tools/*/page.tsx`.
+
+## Proposed fix
+Applied. `vitest.config.mts` now sets `include: ['src/**/*.{ts,tsx}']`,
+plus explicit exclusions for vendored
+shadcn/ui primitives, generated MP models, build-time scripts, barrel files,
+type-only modules, and declarative `loading.tsx` shells.
+
+A second, related defect was found while applying this: directory exclusions
+were written with a bare trailing slash (`'src/components/ui/'`), which matches
+nothing. Those files stayed in the denominator until the patterns were
+rewritten as `'src/components/ui/**'`. Any directory exclusion added here must
+end in `**`.
+
+Codecov will show a one-time drop from 83% to ~49% on the first push carrying
+this change — before the new tests land on top of it. That is the correction,
+not a regression.
+
+## Impact if not fixed
+Every coverage claim in `CLAUDE.md`, `.claude/references/testing/`, and Codecov
+was overstating the tested share of the codebase by ~35 points. Untested
+files stayed invisible, so no one could see that a 756-line service and a
+route authorization gate had no tests at all.
diff --git a/.claude/TODO/2026-09-13-testing-no-typecheck-gate-in-ci.md b/.claude/TODO/2026-09-13-testing-no-typecheck-gate-in-ci.md
new file mode 100644
index 0000000..e3cfd4f
--- /dev/null
+++ b/.claude/TODO/2026-09-13-testing-no-typecheck-gate-in-ci.md
@@ -0,0 +1,85 @@
+---
+title: Add a type-check gate to CI (stale test fixtures had silently broken `npm run build`)
+severity: medium
+tags: [bug, testing, drift]
+area: testing
+files: [.github/workflows/test.yml, package.json]
+discovered: 2026-09-13
+discovered_by: coverage-review-orchestrator
+status: open
+---
+
+## Problem
+Two test files that are committed and unmodified on `dev` fail type checking.
+`tsconfig.json` includes `**/*.ts` and `**/*.tsx`, `next.config.ts` sets no
+`typescript.ignoreBuildErrors`, and `npm run build` runs `next build` — which
+type-checks the project. So `npm run build` fails on a clean `dev` checkout.
+
+This is invisible because, as `CLAUDE.md` already notes, CI runs only
+`npm run test:coverage` and never `npm run build`. The tests themselves pass:
+the arguments are wrong *types* but the mocked implementations never inspect
+the missing fields, so the assertions still hold. Type safety is the only thing
+catching it, and nothing automated runs type safety.
+
+The underlying defect is that these tests assert against object shapes the
+production types do not accept — `helper.test.ts` builds `CommunicationInfo`
+and `MessageInfo` values out of raw MP column names (`Author_User_ID`,
+`From_Contact`, `To_Contact_List`) instead of the camelCase DTO fields the
+helper actually requires (`AuthorUserId`, `FromContactId`, `ReplyToContactId`,
+`CommunicationType`, ...). A test that type-checks would have caught the drift;
+these ones silently encode the wrong contract.
+
+## Evidence
+```
+$ git status --porcelain src/lib/providers/ministry-platform/helper.test.ts \
+ src/components/address-labels/word-document.test.ts
+(no output — both unmodified)
+
+$ npx tsc --noEmit
+src/components/address-labels/word-document.test.ts(62,46): error TS1355:
+ A 'const' assertion can only be applied to references to enum members, or
+ string, number, boolean, array, or object literals.
+src/lib/providers/ministry-platform/helper.test.ts(762,57): error TS2345:
+ ... is missing the following properties from type 'CommunicationInfo':
+ AuthorUserId, FromContactId, ReplyToContactId, CommunicationType, and 4 more.
+src/lib/providers/ministry-platform/helper.test.ts(782,57): error TS2345: (same)
+src/lib/providers/ministry-platform/helper.test.ts(822,49): error TS2345:
+ ... is missing the following properties from type 'MessageInfo':
+ FromAddress, ToAddresses
+src/lib/providers/ministry-platform/helper.test.ts(841,49): error TS2345: (same)
+src/lib/providers/ministry-platform/helper.test.ts(923,11): error TS2559:
+ Type '{ IsDefault: boolean; Description: string; }' has no properties in
+ common with type 'FileUploadParams'.
+```
+- `tsconfig.json` — `"include": ["**/*.ts", "**/*.tsx", ...]`, so tests are in
+ the program.
+- `next.config.ts` — no `typescript` block, so `ignoreBuildErrors` is false.
+- `.github/workflows/test.yml` — runs `npm run test:coverage` only.
+
+## Fixed in this branch
+The stale fixtures have been corrected. `helper.test.ts` and `provider.test.ts`
+now build real `CommunicationInfo` / `MessageInfo` / `FileUploadParams` /
+`FileUpdateParams` values with explicit type annotations, so the compiler
+enforces the shape, and the `as const` misuse at `word-document.test.ts:62` is
+gone. `npx tsc --noEmit` is clean and all 96 tests in those files still pass.
+
+## Remaining work
+**Add a type-check gate to CI.** Add a `typecheck` script (`tsc --noEmit`) to
+`package.json` and run it in `.github/workflows/test.yml` alongside
+`test:coverage`.
+
+`CLAUDE.md` documents "CI gates tests only ... Type-check locally" as the
+mitigation. This issue is the evidence that the honour system did not hold: two
+committed test files sat on `dev` in a state that broke `npm run build`, and
+nothing caught it because the tests themselves passed — the arguments were the
+wrong *types*, but the mocks never inspected the missing fields.
+
+Note that the new `coverage.thresholds` in `vitest.config.mts` are enforced by
+the existing `test:coverage` CI job, so coverage regressions now fail the build.
+Type regressions still do not.
+
+## Impact if not fixed
+The specific breakage is repaired, but the gap that allowed it is still open. A
+type error reaching `dev` breaks `npm run build` and therefore a Vercel
+production deploy, while CI stays green — so it is found at deploy time by
+whoever is shipping, not at PR time by whoever wrote it.
diff --git a/.claude/TODO/2026-09-13-testing-vitest-config-loaded-as-cjs.md b/.claude/TODO/2026-09-13-testing-vitest-config-loaded-as-cjs.md
new file mode 100644
index 0000000..d2b2562
--- /dev/null
+++ b/.claude/TODO/2026-09-13-testing-vitest-config-loaded-as-cjs.md
@@ -0,0 +1,77 @@
+---
+title: `vitest.config.ts` was loaded as CommonJS, warned as unsupported by Vite's future default loader
+severity: low
+tags: [drift, testing]
+area: testing
+files: [vitest.config.mts]
+discovered: 2026-09-13
+discovered_by: coverage-review-orchestrator
+status: resolved
+---
+
+## Problem
+Every Vitest invocation prints:
+
+```
+(!) Your Vite config uses features that are unsupported by `configLoader: 'native'`,
+which is planned to become the default in a future major version of Vite:
+ - ESM syntax in a file loaded as CommonJS (vitest.config.ts:1:1).
+ Use a `.mjs` extension or set `"type": "module"` in the closest package.json
+```
+
+`vitest.config.ts` uses ESM syntax (`import`/`export default`) but `package.json`
+has no `"type": "module"`, so Vite loads it through the CommonJS path. That path
+still works today, but Vite plans to make `configLoader: 'native'` the default,
+at which point the config stops loading and the whole suite fails to start.
+
+This is noise on every local run and every CI run today, and a hard break on a
+future Vite major.
+
+## Evidence
+- `vitest.config.ts:1` — `import { defineConfig } from 'vitest/config';`
+- `package.json` — no `"type"` field.
+- Warning reproduces on any `npx vitest run`.
+- Vite 8 / Vitest 6 are the likely forcing versions; neither is adopted here yet.
+
+## Proposed fix
+Lowest-risk option is to rename `vitest.config.ts` -> `vitest.config.mts`. Vitest
+resolves the `.mts` extension automatically, so no script changes are needed, and
+it does not touch how Next.js or the rest of the toolchain treat the package.
+
+Do NOT add `"type": "module"` to `package.json` as the fix — this project also
+contains CommonJS tooling and Next.js build config, and flipping the package type
+has a much wider blast radius than renaming one file.
+
+Verify with `npx vitest run` (warning gone, suite still runs) and
+`npm run test:coverage` (Codecov path `coverage/coverage-final.json` still
+produced).
+
+## Impact if not fixed
+Cosmetic today. On the Vite major that flips `configLoader` to `'native'`, the
+config fails to load and the entire test suite — and therefore the CI `test`
+gate that protects `dev` and `main` — stops running. Cheap to fix now, and the
+dependency-audit process should not have to rediscover it under time pressure.
+
+---
+
+## Resolution (2026-09-13)
+Applied the proposed fix: renamed `vitest.config.ts` -> `vitest.config.mts`.
+Vitest resolves the `.mts` extension automatically, so no script or CI changes
+were needed, and `package.json` was left alone (adding `"type": "module"` would
+have had a far wider blast radius, as noted above).
+
+The rename surfaced a second, hidden instance of the same problem. With the
+file loaded as real ESM, Vite immediately flagged:
+
+```
+- `__dirname` (vitest.config.mts:66:25). Use `import.meta.dirname` instead
+```
+
+`__dirname` is a CommonJS global and does not exist in ESM; it had been working
+only because the file was being loaded as CJS. The `@` path alias is now
+resolved with `import.meta.dirname`. Had the rename been done without running
+the suite, the alias would have thrown at config load and every test would have
+failed to start.
+
+Verified: `npm run test:run` produces 17 lines of output with zero warnings,
+116 files / 1,535 tests passing, and `npx tsc --noEmit` is clean.
diff --git a/.claude/TODO/2026-09-13-unvalidated-envelope-donor-ids-in-filter.md b/.claude/TODO/2026-09-13-unvalidated-envelope-donor-ids-in-filter.md
new file mode 100644
index 0000000..a59cd7a
--- /dev/null
+++ b/.claude/TODO/2026-09-13-unvalidated-envelope-donor-ids-in-filter.md
@@ -0,0 +1,77 @@
+---
+title: Unvalidated client-supplied numeric fields interpolated into MP $filter strings in FamilyService
+severity: high
+tags: [security, bug]
+area: services
+files: [src/services/familyService.ts, src/app/(web)/tools/addeditfamily/actions.ts, src/lib/dto/family.ts]
+discovered: 2026-09-13
+discovered_by: coverage-agent-services
+status: open
+---
+
+## Problem
+`saveFamily` (the `"use server"` action in `addeditfamily/actions.ts`) accepts a
+`Household` object straight from the client and passes it directly to
+`FamilyService.saveHousehold()` — it is never parsed through the
+`HouseholdSchema`/`FamilyMemberSchema` Zod schemas that already exist in
+`src/lib/dto/family.ts` for exactly this purpose. TypeScript's compile-time
+`number` type on `FamilyMember.envelopeNo` and `FamilyMember.donorId` is not
+enforced at runtime — a server action is a public POST endpoint, and any JSON
+body can be sent to it, including e.g. `envelopeNo: "1 OR 1=1"`.
+
+Inside `FamilyService.saveHousehold` → `upsertDonor` → `resolveUniqueEnvelopeNo`,
+those two client-controlled values are interpolated directly into an MP
+`$filter` string with no `validatePositiveInt` (or any other) check:
+
+```ts
+private async resolveUniqueEnvelopeNo(
+ requested: number,
+ excludeDonorId: number | null,
+): Promise<{ envelopeNo: number; bumped: boolean }> {
+ let candidate = requested;
+ ...
+ const filter =
+ excludeDonorId && excludeDonorId > 0
+ ? `Envelope_No = ${candidate} AND Donor_ID <> ${excludeDonorId}`
+ : `Envelope_No = ${candidate}`;
+```
+
+Every other filter built in this file first validates its numeric inputs
+(`validatePositiveInt(contactId)` in `getHousehold`, `validatePositiveInt(recordId)`
+in `resolveContactIdFromPage`), so this is an inconsistency, not the intended
+design. This is the same shape of hole CLAUDE.md rule 11 calls out for strings
+("escape user input in filters") — here the payload is untyped/unvalidated
+numeric input instead of a string, but it reaches a raw `$filter` string the
+same way.
+
+## Evidence
+- `src/services/familyService.ts:687-712` (`resolveUniqueEnvelopeNo`) — `candidate`
+ (from `member.envelopeNo`) and `excludeDonorId` (from `member.donorId`) are
+ interpolated into the filter with no type/range check.
+- `src/app/(web)/tools/addeditfamily/actions.ts:137-154` (`saveFamily`) — takes
+ `household: Household` straight from the client with no `HouseholdSchema.parse()`
+ or `.safeParse()` call before it reaches the service.
+- Contrast with `src/services/familyService.ts:150,175` where the same file
+ validates `recordId`/`contactId` via `validatePositiveInt` before use in a
+ filter.
+- Confirmed via test: `src/services/familyService.test.ts` exercises
+ `resolveUniqueEnvelopeNo` only with well-formed numbers (current behavior is
+ documented, not exploited, per this task's instructions not to modify source).
+
+## Proposed fix
+1. In `saveFamily` (`actions.ts`), parse the incoming `household` argument with
+ `HouseholdSchema.safeParse()` before calling `FamilyService.saveHousehold()`,
+ returning an `ActionError` on failure — mirroring the "Validate at API
+ boundaries" guidance in CLAUDE.md.
+2. Defense in depth: in `resolveUniqueEnvelopeNo` and `upsertDonor`, call
+ `validatePositiveInt` (or an equivalent nullable-safe check) on `requested`/
+ `envelopeNo` and `excludeDonorId`/`existingDonorId` before they are
+ interpolated into any `$filter` string.
+
+## Impact if not fixed
+An authenticated user with write access to `Households` (the only gate this
+action checks) can submit a crafted `envelopeNo` or `donorId` value that is
+not a plain positive integer. Depending on how the MP OData-style `$filter`
+parser handles the resulting string, this ranges from a confusing 400/500
+error to a filter-injection primitive against the `Donors` table — the same
+class of risk rule 11 exists to close off for string fields.
diff --git a/.claude/TODO/INDEX.md b/.claude/TODO/INDEX.md
index 8881b44..adc8793 100644
--- a/.claude/TODO/INDEX.md
+++ b/.claude/TODO/INDEX.md
@@ -1,7 +1,7 @@
---
title: TODO Index
type: index
-last_updated: 2026-05-21
+last_updated: 2026-09-13
---
@@ -9,18 +9,27 @@ last_updated: 2026-05-21
+
# TODO Index
-All open TODOs dropped during the context-engineering review (2026-04-17) and any later additions. Severity tiers:
+Open TODOs from the context-engineering review (2026-04-17) and later additions.
+Severity tiers:
- **critical**: security hole, data loss, auth bypass
- **high**: broken behavior, convention violation causing bugs
- **medium**: doc drift, missing test, refactor with real cost
- **low**: nits, minor doc fixes, stylistic improvements
-Total: **3 open TODOs**.
+Total: **9 open TODOs**.
+
+> **2026-09-13 — unit-test coverage push.** Statement coverage over authored
+> code went from 49.67% to 98.84% (3,610/3,652), lines to 99.70%, across 1,535
+> tests in 116 files. Most of the items below were opened during that work,
+> found by reading code while writing tests for it. A follow-up pass cleaned
+> the runner output from 1,507 lines to 17 with zero warnings. Details in each
+> file.
---
@@ -29,40 +38,62 @@ Total: **3 open TODOs**.
### Critical (0)
_none open_
-### High
-_none open_
+### High (3)
+| Area | Tags | Title | File |
+|---|---|---|---|
+| services | security, bug | Unvalidated client-supplied numeric fields interpolated into MP `$filter` strings in FamilyService | [→](2026-09-13-unvalidated-envelope-donor-ids-in-filter.md) |
+| components | bug | `removeGroup` drops a non-empty group's fields from the save payload | [→](2026-09-13-removegroup-order-guard-mismatch.md) |
+| components | security, bug | `mergeTemplate` console.errors the raw docxtemplater error, which can carry household addresses | [→](2026-09-13-mergetemplate-logs-address-pii-on-error.md) |
-### Medium (3)
+### Medium (4)
| Area | Tags | Title | File |
|---|---|---|---|
+| testing | bug, testing, drift | Add a type-check gate to CI (stale fixtures had silently broken `npm run build`) | [→](2026-09-13-testing-no-typecheck-gate-in-ci.md) |
+| components | security, drift | `AddEditFamilyPage` logs the raw error object instead of an identifier | [→](2026-09-13-page-logs-raw-error-object.md) |
| components | bug, drift | Template editor ignores pageID/recordID (no MP persistence) | [→](2026-04-17-components-template-editor-no-mp-persistence.md) |
| components | bug, refactor | Merge tokens `{{Field_Name}}` have no resolver anywhere | [→](2026-04-17-components-template-editor-merge-token-resolver.md) |
-| components | missing-test | No tests for `src/components/template-editor/` | [→](2026-04-17-components-template-editor-missing-tests.md) |
-### Low (0)
-_none open_
+### Low (2)
+| Area | Tags | Title | File |
+|---|---|---|---|
+| components | bug | Add/Edit Family search: "No contacts found" empty state never renders | [→](2026-09-13-search-empty-state-never-renders.md) |
+| components | refactor, missing-test | Unreachable "empty STEP_FIELDS" branch in `GroupWizard.handleNext` | [→](2026-09-13-dead-empty-fields-branch-handlenext.md) |
---
## By tag
-### security (0)
-_none open_
-
-### bug (2)
-_see severity sections above; tag appears on items involving a functional defect_
-
-### drift (2)
-_doc-to-code or doc-to-doc divergence; mostly resolved inline by Phase 4 verification_
+### security (3)
+- unvalidated-envelope-donor-ids-in-filter — high
+- mergetemplate-logs-address-pii-on-error — high
+- page-logs-raw-error-object — medium
+
+### bug (6)
+- unvalidated-envelope-donor-ids-in-filter — high
+- removegroup-order-guard-mismatch — high
+- mergetemplate-logs-address-pii-on-error — high
+- testing-no-typecheck-gate-in-ci — medium
+- components-template-editor-no-mp-persistence — medium
+- components-template-editor-merge-token-resolver — medium
+- search-empty-state-never-renders — low
+
+### drift (3)
+- testing-no-typecheck-gate-in-ci — medium
+- page-logs-raw-error-object — medium
+- components-template-editor-no-mp-persistence — medium
### missing-test (1)
-- components-template-editor-missing-tests — medium
+- dead-empty-fields-branch-handlenext — low
-### refactor (1)
-_improvements with real value but no functional defect_
+### refactor (2)
+- components-template-editor-merge-token-resolver — medium
+- dead-empty-fields-branch-handlenext — low
-### doc (1)
-_documentation-only tasks, mostly retiring old flat files or updating CLAUDE.md / README.md_
+### testing (1)
+- testing-no-typecheck-gate-in-ci — medium
+
+### doc (0)
+_none open_
### perf (0)
_none open_
@@ -73,19 +104,31 @@ _none open_
| Area | Count |
|---|---|
-| components | 3 |
+| components | 6 |
+| testing | 1 |
+| services | 1 |
| auth | 0 |
| mp-provider | 0 |
-| services | 0 |
| utils | 0 |
| routing | 0 |
| mp-schema | 0 |
-| testing | 0 |
| contexts | 0 |
| dto-constants | 0 |
| doc (cross-cutting) | 0 |
---
+## Recently resolved
+
+| Date | Title | File |
+|---|---|---|
+| 2026-09-13 | Coverage config omitted `coverage.include`, hiding every untested file | [→](2026-09-13-testing-coverage-report-masked-untested-files.md) |
+| 2026-09-13 | No tests for `src/components/template-editor/` | [→](2026-04-17-components-template-editor-missing-tests.md) |
+| 2026-09-13 | `vitest.config.ts` loaded as CommonJS (renamed to `.mts`) | [→](2026-09-13-testing-vitest-config-loaded-as-cjs.md) |
+| 2026-09-13 | Radix Select fields switched uncontrolled -> controlled | [→](2026-09-13-components-select-uncontrolled-to-controlled.md) |
+
+---
+
## Schema
-Every TODO follows [`SCHEMA.md`](SCHEMA.md) with required `severity`, `tags`, `area`, `files`, `discovered`, `discovered_by`, `status` frontmatter.
+Every TODO follows [`SCHEMA.md`](SCHEMA.md) with required `severity`, `tags`,
+`area`, `files`, `discovered`, `discovered_by`, `status` frontmatter.
diff --git a/.claude/references/DECISIONS.md b/.claude/references/DECISIONS.md
index e98eb01..a3ea270 100644
--- a/.claude/references/DECISIONS.md
+++ b/.claude/references/DECISIONS.md
@@ -350,7 +350,7 @@ Architectural decisions captured by the context-engineering review at SHA `971c4
**Date:** 2026-04-17
**Status:** Accepted
**Context:** The project needs a TypeScript-first test runner that supports `import.meta`, runs fast against ESM without a transform step, and shares the `@/` alias configuration with Vite/Turbopack.
-**Decision:** Vitest 4.x with `@vitejs/plugin-react` (`vitest.config.ts`). `globals: true` so tests use bare `describe` / `it` / `expect`. The `@/` alias is resolved via `path.resolve(__dirname, './src')`.
+**Decision:** Vitest 5.x with `@vitejs/plugin-react` (`vitest.config.mts`). `globals: true` so tests use bare `describe` / `it` / `expect`. The `@/` alias is resolved via `path.resolve(import.meta.dirname, './src')` — the config is ESM (`.mts`), so `__dirname` is not available.
**Consequences:** All mock patterns assume `vi.mock()` hoisting (`vi.hoisted()` is required for any variable referenced inside a mock factory). No Jest-specific matchers — only `@testing-library/jest-dom` matchers. Shared resolver config with the bundler.
**Alternatives considered:**
- **Jest with `ts-jest` / `@swc/jest`** — transform overhead and a second resolver config to maintain.
@@ -360,7 +360,7 @@ Architectural decisions captured by the context-engineering review at SHA `971c4
**Date:** 2026-04-17
**Status:** Accepted
**Context:** React component tests need a DOM that supports `localStorage`, `window.location`, `Headers`, and the full `@testing-library` API surface.
-**Decision:** `environment: 'jsdom'` in `vitest.config.ts` with `jsdom@^28.1.0`.
+**Decision:** `environment: 'jsdom'` in `vitest.config.mts` with `jsdom@^30.0.1`.
**Consequences:** Slightly slower than happy-dom on pure-DOM tests, but compatibility is higher for edge APIs (`Headers`, `Storage`, `fetch` mocks).
**Alternatives considered:**
- **happy-dom** — faster, but historically has gaps around `Headers`, `fetch`, and `Storage` mocks that affect MP client tests.
diff --git a/.claude/references/_meta/facts/2026-09-13.md b/.claude/references/_meta/facts/2026-09-13.md
new file mode 100644
index 0000000..88b1914
--- /dev/null
+++ b/.claude/references/_meta/facts/2026-09-13.md
@@ -0,0 +1,74 @@
+---
+title: Facts Snapshot 2026-09-13
+type: meta
+git_sha: eb9d792059403f70fccd5af437d47ef5b601b288
+---
+
+## Purpose
+Authoritative counts after the unit-test coverage push. Supersedes the counts
+in `2026-04-17.md` (that file remains the frozen baseline for the
+context-engineering review and should not be edited).
+
+## Git baseline
+- **SHA:** `eb9d792059403f70fccd5af437d47ef5b601b288` (parent of the coverage work)
+- **Branch:** `dev`
+- **Created:** 2026-09-13
+
+## Counts (authoritative)
+
+- **Test files:** 116
+- **Test cases:** 1,535 (from `vitest run`)
+- **Source files in the coverage denominator:** 143
+
+### Coverage (from `npm run test:coverage`)
+
+| Metric | Before | After |
+|---|---|---|
+| Statements | 49.67% (1,814/3,652) | **98.84%** (3,610/3,652) |
+| Lines | 50.20% | **99.70%** (3,363/3,373) |
+| Branches | 37.51% | **92.21%** (2,228/2,416) |
+| Functions | 38.35% | **98.81%** (915/926) |
+
+The "before" figures are measured on the same denominator as "after". The
+number the config *reported* before this work was 83.14%, which was wrong — see
+`.claude/TODO/2026-09-13-testing-coverage-report-masked-untested-files.md`.
+
+### Test files by area
+
+| Area | Files |
+|---|---|
+| `src/app/**` | 23 |
+| `src/components/group-wizard` | 12 |
+| `src/components/dev-panel` | 11 |
+| `src/components/address-labels` | 10 |
+| `src/components/template-editor` | 10 |
+| `src/components/field-management` | 7 |
+| `src/components/*` (layout, shared-actions, tool, user-menu) | 6 |
+| `src/lib/providers/ministry-platform` | 12 |
+| `src/lib/**` (excl. MP provider) | 12 |
+| `src/services` | 9 |
+| `src/contexts` | 2 |
+| `src/` (proxy, auth) | 2 |
+
+## Coverage configuration contract
+
+`vitest.config.mts` sets `coverage.include: ['src/**/*.{ts,tsx}']`. Without it
+the v8 provider reports only files some test imported, so untested files vanish
+from the report instead of counting as 0%.
+
+- `coverage.all` was **removed in Vitest 5** — setting it is a type error and
+ does nothing. `include` is the only lever.
+- Directory exclusions must end in `**`. A bare trailing slash
+ (`'src/components/ui/'`) matches nothing.
+- Enforced thresholds: **statements 97, lines 98**. Branches and functions are
+ deliberately unenforced (noisier; a single defensive guard can trip them).
+
+### Excluded from the denominator
+Vendored shadcn/ui primitives (`src/components/ui/**`), generated MP models and
+build-time scripts, `**/loading.tsx`, the two font/metadata root layouts,
+`**/index.ts` barrels, and `**/types.ts` type-only modules.
+
+## Services (9)
+`addressLabelService`, `authorizationService`, `domainTimezoneService`,
+`familyService`, `fieldManagementService`, `googlePlacesService`,
+`groupService`, `toolService`, `userService`
diff --git a/.claude/references/testing/README.md b/.claude/references/testing/README.md
index 33493f4..0a92324 100644
--- a/.claude/references/testing/README.md
+++ b/.claude/references/testing/README.md
@@ -5,20 +5,20 @@ domain: testing
---
## What's in this domain
-Vitest test runner config, global setup, mocking patterns, and inventory of 37 test files (507 cases) co-located next to source.
+Vitest test runner config, global setup, mocking patterns, and inventory of 116 test files (1,535 cases) co-located next to source. Coverage over authored code is 98.84% statements / 99.70% lines, enforced by thresholds in `vitest.config.mts`.
## File map
| File | Purpose | When to read |
|------|---------|--------------|
-| `setup.md` | `vitest.config.ts`, `src/test-setup.ts`, env vars, jsdom, v8 coverage | Adding Vitest config or adjusting global setup |
+| `setup.md` | `vitest.config.mts`, `src/test-setup.ts`, env vars, jsdom, v8 coverage, the `coverage.include` contract and enforced thresholds | Adding Vitest config or adjusting global setup |
| `mocks.md` | Mandatory mock patterns (`vi.hoisted`, MPHelper mock class, singleton reset, auth/headers, fake timers) | Writing any new test that mocks an import |
| `cookbook.md` | Copy-paste recipes pulled from real tests (service, server action, component, OAuth) | Writing a new test in an existing category |
-| `inventory.md` | All 37 test files grouped by area with one-liner coverage | Locating where a surface is tested |
+| `inventory.md` | All 116 test files grouped by area with one-liner coverage | Locating where a surface is tested |
## Code surfaces
| Path | Role |
|------|------|
-| `vitest.config.ts` | Runner config (jsdom, globals, v8, `@` alias) |
+| `vitest.config.mts` | Runner config (jsdom, globals, v8 coverage + thresholds, `@` alias) |
| `src/test-setup.ts` | Env stubs + `@testing-library/jest-dom` |
| `src/**/*.test.ts` | Unit tests (services, lib, auth, proxy) |
| `src/**/*.test.tsx` | React component/hook tests |
diff --git a/.claude/references/testing/inventory.md b/.claude/references/testing/inventory.md
index 148caf5..5797e91 100644
--- a/.claude/references/testing/inventory.md
+++ b/.claude/references/testing/inventory.md
@@ -5,11 +5,33 @@ type: reference
applies_to: [src/**/*.test.ts, src/**/*.test.tsx]
symbols: []
related: [setup.md, cookbook.md]
-last_verified: 2026-04-17
+last_verified: 2026-09-13
---
## Purpose
-All 37 test files grouped by area. Totals from facts snapshot: **37 files / 507 test cases** (`vitest run` at SHA `971c40b1`).
+Test files grouped by area. Totals from the current facts snapshot:
+**116 files / 1,535 test cases** (`vitest run`, see
+`../_meta/facts/2026-09-13.md`).
+
+> The per-file tables below were written at 37 files and have NOT been expanded
+> to all 116. They remain accurate for the files they list. For anything not
+> listed, the co-location rule is reliable: a source file `foo.ts` is tested by
+> `foo.test.ts` beside it. Counts by area:
+>
+> | Area | Files |
+> |---|---|
+> | `src/app/**` | 23 |
+> | `src/components/group-wizard` | 12 |
+> | `src/lib/providers/ministry-platform` | 12 |
+> | `src/lib/**` (excl. MP provider) | 12 |
+> | `src/components/dev-panel` | 11 |
+> | `src/components/address-labels` | 10 |
+> | `src/components/template-editor` | 10 |
+> | `src/services` | 9 |
+> | `src/components/field-management` | 7 |
+> | `src/components/*` (layout, shared-actions, tool, user-menu) | 6 |
+> | `src/contexts` | 2 |
+> | `src/` (proxy, auth) | 2 |
## Ministry Platform provider (12 files)
@@ -78,7 +100,7 @@ All 37 test files grouped by area. Totals from facts snapshot: **37 files / 507
## Known uncovered surfaces
- `src/lib/providers/ministry-platform/scripts/` (`build-sql-install.ts`, `generate-types.ts`, `generate-storedprocs.ts`) — CLI build scripts exercised via `npm run mp:build:install` / `npm run mp:generate:models`
-- Auto-generated model files under `src/lib/providers/ministry-platform/models/` — excluded from coverage via `vitest.config.ts`
+- Auto-generated model files under `src/lib/providers/ministry-platform/models/` — excluded from coverage via `vitest.config.mts`
## Related docs
- `setup.md` — runner config
diff --git a/.claude/references/testing/setup.md b/.claude/references/testing/setup.md
index 31a8991..1697354 100644
--- a/.claude/references/testing/setup.md
+++ b/.claude/references/testing/setup.md
@@ -2,21 +2,25 @@
title: Test Runner Setup
domain: testing
type: reference
-applies_to: [vitest.config.ts, src/test-setup.ts, package.json]
+applies_to: [vitest.config.mts, src/test-setup.ts, package.json]
symbols: [defineConfig]
related: [mocks.md, cookbook.md]
-last_verified: 2026-04-17
+last_verified: 2026-09-13
---
## Purpose
Vitest runner config, global setup, and commands. Everything the harness needs before a single test runs.
## Files
-- `vitest.config.ts` — runner config (environment, globals, coverage, alias)
+- `vitest.config.mts` — runner config (environment, globals, coverage, alias).
+ The `.mts` extension is deliberate: as `.ts` the file was loaded as CommonJS
+ and Vite warned it would break when `configLoader: 'native'` becomes the
+ default. `.mts` is resolved automatically, so no script changes were needed.
+ Being real ESM, it must use `import.meta.dirname`, not `__dirname`.
- `src/test-setup.ts` — env stubs + `@testing-library/jest-dom` import
- `package.json` — `test`, `test:run`, `test:coverage` scripts
-## Versions (from `.claude/references/_meta/facts/2026-04-17.md`)
+## Versions (from `.claude/references/_meta/facts/2026-09-13.md`)
| Package | Version |
|---|---|
| vitest | ^4.1.0 |
@@ -33,7 +37,7 @@ Vitest runner config, global setup, and commands. Everything the harness needs b
| `npm run test:run` | Single run (`vitest run`) |
| `npm run test:coverage` | Single run with v8 coverage (`vitest run --coverage`) |
-## `vitest.config.ts` (full source)
+## `vitest.config.mts` (full source)
```typescript
import { defineConfig } from 'vitest/config';
@@ -51,24 +55,67 @@ export default defineConfig({
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
+ // `include` is load-bearing. Without it the v8 provider only reports
+ // files that some test imported, so an untested file is invisible
+ // rather than counted as 0%. That masked ~1,600 uncovered statements
+ // and inflated the reported number from 48% to 83%.
+ //
+ // Note: this is the Vitest 5 spelling. The old `coverage.all: true`
+ // flag was REMOVED in Vitest 5 — setting it is a type error and does
+ // nothing. `include` is now the only way to widen the denominator.
+ include: ['src/**/*.{ts,tsx}'],
+ /**
+ * Statements and lines only, by deliberate choice.
+ *
+ * Branch and function coverage are noisier — a single added guard
+ * clause or a defensive `?? []` can drop branch coverage below a bar
+ * that nothing is actually wrong with, and a threshold people learn to
+ * override is worse than no threshold. Statements and lines move
+ * predictably with real test work.
+ *
+ * Set below the achieved figures (98.84% statements / 99.70% lines) so
+ * ordinary work has room, but above the 95% target so the suite cannot
+ * quietly slide back under it. Raise these when coverage rises; never
+ * lower them to make a red build green.
+ */
+ thresholds: {
+ statements: 97,
+ lines: 98,
+ },
+ // NOTE: directory exclusions must end in `**`. A bare trailing slash
+ // (e.g. 'src/components/ui/') matches nothing, so the files stay in the
+ // denominator — which is how the shadcn primitives were silently being
+ // counted before.
exclude: [
- 'node_modules/',
- '.next/',
+ 'node_modules/**',
+ '.next/**',
'src/test-setup.ts',
'**/*.d.ts',
- 'src/lib/providers/ministry-platform/models/', // Auto-generated files
+ '**/*.{test,spec}.{ts,tsx}',
+ 'src/lib/providers/ministry-platform/models/**', // Auto-generated files
+ 'src/lib/providers/ministry-platform/scripts/**', // Build-time CLI scripts
+ 'src/components/ui/**', // Vendored shadcn/ui primitives
+ '**/loading.tsx', // Declarative skeleton markup
+ 'src/app/layout.tsx', // Root font/metadata shell
+ 'src/app/(web)/layout.tsx', // Font/metadata shell
+ '**/index.ts', // Barrel re-exports
+ '**/types.ts', // Type-only modules
],
},
},
resolve: {
alias: {
- '@': path.resolve(__dirname, './src'),
+ // `import.meta.dirname`, not `__dirname`: this file is ESM (.mts), and
+ // Vite's native config loader has no CommonJS globals.
+ '@': path.resolve(import.meta.dirname, './src'),
},
},
});
```
## Key settings
+- **`coverage.include`** — REQUIRED. Without it the v8 provider only reports files a test imported, so untested files vanish from the report instead of counting as 0%. Note `coverage.all` was removed in Vitest 5 and directory exclusions must end in `**`.
+- **`coverage.thresholds`** — statements 97 / lines 98, enforced by the `test:coverage` CI job. Branches and functions are deliberately unenforced.
- **`environment: 'jsdom'`** — DOM available without a browser (see `../DECISIONS.md` for rationale vs happy-dom)
- **`globals: true`** — `describe/it/expect/vi` available without imports (tests still import explicitly by convention)
- **`plugins: [react()]`** — `@vitejs/plugin-react` required for `.tsx` files and JSX transform
diff --git a/CLAUDE.md b/CLAUDE.md
index 5e788b6..5ff425a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -228,8 +228,10 @@ await mp.createTableRecords('Contact_Log', records, {
## Testing
-- **Framework**: Vitest with jsdom environment, `@testing-library/react` for hooks/components, v8 coverage
+- **Framework**: Vitest with jsdom environment, `@testing-library/react` + `@testing-library/user-event` for hooks/components, v8 coverage
- **Counts**: test/file totals live in `.claude/references/_meta/facts/` (bit-rots quickly — trust `vitest run` output over any doc claim)
+- **Coverage is enforced**: `vitest.config.mts` sets `thresholds: { statements: 97, lines: 98 }`, checked by the existing `test:coverage` CI job. Branches/functions are deliberately unenforced. Raise the thresholds as coverage rises; never lower them to green a red build.
+- **`coverage.include` is load-bearing**: without `include: ['src/**/*.{ts,tsx}']` the v8 provider reports only files a test imported, so untested files vanish instead of counting as 0%. Two traps: `coverage.all` was **removed in Vitest 5** (setting it is a type error and does nothing), and directory exclusions must end in `**` — a bare `'src/components/ui/'` matches nothing.
- **Config**: `vitest.config.ts` (runner), `src/test-setup.ts` (env vars + jest-dom)
- **jest-dom import**: `src/test-setup.ts` must import `@testing-library/jest-dom/vitest`, **not** the bare `@testing-library/jest-dom`. Since jest-dom v7 only the `/vitest` entry augments Vitest's `expect` types; the bare import registers matchers at runtime, so tests pass while `next build` fails with `Property 'toBeInTheDocument' does not exist`.
- **Co-location**: Test files live next to source — `foo.ts` → `foo.test.ts`
@@ -237,6 +239,7 @@ await mp.createTableRecords('Contact_Log', records, {
- **MPHelper mock**: Use mock class (`MPHelper: class { method = mockFn; }`), not `vi.fn().mockImplementation()`
- **Singleton reset**: Reset `(ServiceClass as any).instance = undefined` in `beforeEach` to prevent state leakage
- **Server action tests**: Mock `@/lib/auth` (`auth.api.getSession`), `next/headers` (`headers()`), and service singletons
+- **Type-check locally before pushing**: CI does not run `tsc`, and `tsconfig.json` includes `**/*.ts`/`**/*.tsx`, so a type error in a *test* file breaks `npm run build` while CI stays green. This has happened — see `.claude/TODO/2026-09-13-testing-no-typecheck-gate-in-ci.md`.
- See **[Testing Reference](.claude/references/testing/README.md)** for all mock patterns, coverage data, and test inventory
## Dependencies
diff --git a/package-lock.json b/package-lock.json
index 5b8aee2..fde644f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -55,6 +55,7 @@
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.7",
"@types/mjml": "^5.0.0",
"@types/node": "^24.13.4",
"@types/react": "^19.3.0",
@@ -4655,6 +4656,20 @@
}
}
},
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.7",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz",
+ "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
diff --git a/package.json b/package.json
index 2966cb7..3faf4a4 100644
--- a/package.json
+++ b/package.json
@@ -68,6 +68,7 @@
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.7",
"@types/mjml": "^5.0.0",
"@types/node": "^24.13.4",
"@types/react": "^19.3.0",
diff --git a/src/app/(web)/no-access/page.test.tsx b/src/app/(web)/no-access/page.test.tsx
new file mode 100644
index 0000000..ddb53f4
--- /dev/null
+++ b/src/app/(web)/no-access/page.test.tsx
@@ -0,0 +1,36 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+
+import NoAccessPage from './page';
+
+/**
+ * This page is the landing spot for a signed-in user with no MP security role.
+ * It lives inside the (web) route group specifically so the app shell — and
+ * therefore the sign-out control — stays reachable. These tests pin the copy
+ * that tells the user what to do, since that copy IS the feature.
+ */
+
+afterEach(cleanup);
+
+describe('NoAccessPage', () => {
+ it('renders a heading explaining the user lacks access', () => {
+ render( );
+
+ expect(
+ screen.getByRole('heading', { level: 1, name: /don't have access to this tool/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('tells the user sign-in succeeded but no role is assigned', () => {
+ render( );
+
+ expect(screen.getByText(/signed in successfully/i)).toBeInTheDocument();
+ expect(screen.getByText(/doesn't have a security role/i)).toBeInTheDocument();
+ });
+
+ it('directs the user to their Ministry Platform administrator', () => {
+ render( );
+
+ expect(screen.getByText(/ask your ministry platform administrator/i)).toBeInTheDocument();
+ });
+});
diff --git a/src/app/(web)/page.test.tsx b/src/app/(web)/page.test.tsx
new file mode 100644
index 0000000..226e64b
--- /dev/null
+++ b/src/app/(web)/page.test.tsx
@@ -0,0 +1,47 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+
+import Home from './page';
+
+/**
+ * The home page is the tool index. Its job is to link to every tool, so the
+ * test that matters is that each tool has a card AND a working href — a card
+ * whose link rotted is the failure mode this catches.
+ */
+
+afterEach(cleanup);
+
+const TOOLS = [
+ { name: 'Template Tool', href: '/tools/template' },
+ { name: 'Template Editor', href: '/tools/templateeditor' },
+ { name: 'Address Labels', href: '/tools/addresslabels' },
+ { name: 'Group Wizard', href: '/tools/groupwizard' },
+ { name: 'Field Management', href: '/tools/fieldmanagement' },
+ { name: 'Add/Edit Family', href: '/tools/addeditfamily' },
+];
+
+describe('Home', () => {
+ it('renders the app title', () => {
+ render( );
+
+ expect(screen.getByRole('heading', { level: 1, name: 'MPNext Tools' })).toBeInTheDocument();
+ });
+
+ it.each(TOOLS)('lists the $name card', ({ name }) => {
+ render( );
+
+ expect(screen.getByText(name)).toBeInTheDocument();
+ });
+
+ it.each(TOOLS)('links $name to $href', ({ href }) => {
+ const { container } = render( );
+
+ expect(container.querySelector(`a[href="${href}"]`)).toBeInTheDocument();
+ });
+
+ it('renders exactly one link per tool and no others', () => {
+ const { container } = render( );
+
+ expect(container.querySelectorAll('a')).toHaveLength(TOOLS.length);
+ });
+});
diff --git a/src/app/(web)/tools/addeditfamily/actions.test.ts b/src/app/(web)/tools/addeditfamily/actions.test.ts
new file mode 100644
index 0000000..67a80b2
--- /dev/null
+++ b/src/app/(web)/tools/addeditfamily/actions.test.ts
@@ -0,0 +1,384 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { Household, SaveProgress } from "@/lib/dto/family";
+
+const { mockRequireSecurityRole } = vi.hoisted(() => ({
+ mockRequireSecurityRole: vi.fn(async () => 42),
+}));
+
+vi.mock("@/services/authorizationService", () => ({
+ AuthorizationService: {
+ getInstance: () => ({
+ requireSecurityRole: mockRequireSecurityRole,
+ }),
+ },
+}));
+
+const {
+ mockSearchContacts,
+ mockGetLookups,
+ mockGetDefaults,
+ mockGetHousehold,
+ mockResolveContactIdFromPage,
+ mockGetNextEnvelopeNumber,
+ mockSaveHousehold,
+} = vi.hoisted(() => ({
+ mockSearchContacts: vi.fn(),
+ mockGetLookups: vi.fn(),
+ mockGetDefaults: vi.fn(),
+ mockGetHousehold: vi.fn(),
+ mockResolveContactIdFromPage: vi.fn(),
+ mockGetNextEnvelopeNumber: vi.fn(),
+ mockSaveHousehold: vi.fn(),
+}));
+
+const FakePartialSaveError = vi.hoisted(() => {
+ return class FakePartialSaveError extends Error {
+ progress: SaveProgress;
+ underlying: unknown;
+ constructor(progress: SaveProgress, underlying: unknown) {
+ super(underlying instanceof Error ? underlying.message : String(underlying));
+ this.name = "PartialSaveError";
+ this.progress = progress;
+ this.underlying = underlying;
+ }
+ };
+});
+
+vi.mock("@/services/familyService", () => ({
+ FamilyService: {
+ getInstance: vi.fn(async () => ({
+ searchContacts: mockSearchContacts,
+ getLookups: mockGetLookups,
+ getDefaults: mockGetDefaults,
+ getHousehold: mockGetHousehold,
+ resolveContactIdFromPage: mockResolveContactIdFromPage,
+ getNextEnvelopeNumber: mockGetNextEnvelopeNumber,
+ saveHousehold: mockSaveHousehold,
+ })),
+ },
+ PartialSaveError: FakePartialSaveError,
+}));
+
+const { mockIsEnabled, mockAutocomplete, mockGetPlaceDetails } = vi.hoisted(() => ({
+ mockIsEnabled: vi.fn(),
+ mockAutocomplete: vi.fn(),
+ mockGetPlaceDetails: vi.fn(),
+}));
+
+vi.mock("@/services/googlePlacesService", () => ({
+ GooglePlacesService: {
+ getInstance: vi.fn(async () => ({
+ isEnabled: mockIsEnabled,
+ autocomplete: mockAutocomplete,
+ getPlaceDetails: mockGetPlaceDetails,
+ })),
+ },
+}));
+
+import {
+ searchContacts,
+ fetchFamilyLookups,
+ fetchFamilyDefaults,
+ fetchHousehold,
+ resolveContactIdFromPage,
+ fetchNextEnvelopeNumber,
+ placesEnabled,
+ placeAutocomplete,
+ placeDetails,
+ saveFamily,
+} from "./actions";
+
+function makeHousehold(overrides: Partial = {}): Household {
+ return {
+ householdId: 1,
+ householdName: "Smith",
+ householdPhone: "",
+ congregationId: 1,
+ sourceId: 18,
+ address: {
+ addressId: 0,
+ addressLine1: null,
+ addressLine2: null,
+ city: null,
+ state: null,
+ region: null,
+ postalCode: "",
+ countryCode: null,
+ },
+ alternateMailingAddress: {
+ addressId: 0,
+ addressLine1: null,
+ addressLine2: null,
+ city: null,
+ state: null,
+ region: null,
+ postalCode: "",
+ countryCode: null,
+ },
+ seasonStart: null,
+ seasonEnd: null,
+ repeatsAnnually: false,
+ areHeadsMarried: false,
+ members: [],
+ ...overrides,
+ };
+}
+
+describe("addeditfamily actions", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockRequireSecurityRole.mockResolvedValue(42);
+ });
+
+ describe("searchContacts", () => {
+ it("authorizes a read against Contacts and delegates to the service", async () => {
+ mockSearchContacts.mockResolvedValueOnce([
+ { contactId: 1, displayName: "Smith, John", detail: "" },
+ ]);
+ const result = await searchContacts("smith");
+ expect(mockRequireSecurityRole).toHaveBeenCalledWith({
+ table: "Contacts",
+ operation: "read",
+ });
+ expect(mockSearchContacts).toHaveBeenCalledWith("smith");
+ expect(result).toEqual([{ contactId: 1, displayName: "Smith, John", detail: "" }]);
+ });
+
+ it("propagates authorization failures", async () => {
+ mockRequireSecurityRole.mockRejectedValueOnce(new Error("Not authorized"));
+ await expect(searchContacts("smith")).rejects.toThrow("Not authorized");
+ expect(mockSearchContacts).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("fetchFamilyLookups", () => {
+ it("authorizes and returns lookups", async () => {
+ const lookups = { congregations: [] } as unknown as Awaited>;
+ mockGetLookups.mockResolvedValueOnce(lookups);
+ const result = await fetchFamilyLookups();
+ expect(mockRequireSecurityRole).toHaveBeenCalledWith({ table: "Contacts", operation: "read" });
+ expect(result).toBe(lookups);
+ });
+ });
+
+ describe("fetchFamilyDefaults", () => {
+ it("authorizes and returns defaults", async () => {
+ const defaults = { congregationId: 1 } as unknown as Awaited>;
+ mockGetDefaults.mockResolvedValueOnce(defaults);
+ const result = await fetchFamilyDefaults();
+ expect(mockRequireSecurityRole).toHaveBeenCalledWith({ table: "Contacts", operation: "read" });
+ expect(result).toBe(defaults);
+ });
+ });
+
+ describe("fetchHousehold", () => {
+ it("returns success with the household when found", async () => {
+ const household = makeHousehold();
+ mockGetHousehold.mockResolvedValueOnce(household);
+ const result = await fetchHousehold(5);
+ expect(mockRequireSecurityRole).toHaveBeenCalledWith({ table: "Households", operation: "read" });
+ expect(result).toEqual({ success: true, household });
+ });
+
+ it("returns a not-found error when the service returns null", async () => {
+ mockGetHousehold.mockResolvedValueOnce(null);
+ const result = await fetchHousehold(5);
+ expect(result).toEqual({ success: false, error: "Household not found" });
+ });
+
+ it("returns a generic error message for a non-Error throw", async () => {
+ mockGetHousehold.mockRejectedValueOnce("boom");
+ const result = await fetchHousehold(5);
+ expect(result).toEqual({ success: false, error: "Failed to load household" });
+ });
+
+ it("returns the Error message when the service throws an Error", async () => {
+ mockGetHousehold.mockRejectedValueOnce(new Error("db exploded"));
+ const result = await fetchHousehold(5);
+ expect(result).toEqual({ success: false, error: "db exploded" });
+ });
+
+ it("surfaces authorization failures as ActionError", async () => {
+ mockRequireSecurityRole.mockRejectedValueOnce(new Error("Not authorized"));
+ const result = await fetchHousehold(5);
+ expect(result).toEqual({ success: false, error: "Not authorized" });
+ });
+ });
+
+ describe("resolveContactIdFromPage", () => {
+ const args = {
+ tableName: "Event_Participants",
+ primaryKey: "Event_Participant_ID",
+ recordId: 10,
+ contactIdField: "Participant_ID_TABLE_Contact_ID",
+ };
+
+ it("authorizes against the given table and returns the contactId", async () => {
+ mockResolveContactIdFromPage.mockResolvedValueOnce(99);
+ const result = await resolveContactIdFromPage(args);
+ expect(mockRequireSecurityRole).toHaveBeenCalledWith({
+ table: "Event_Participants",
+ operation: "read",
+ });
+ expect(mockResolveContactIdFromPage).toHaveBeenCalledWith(
+ args.tableName,
+ args.primaryKey,
+ args.recordId,
+ args.contactIdField,
+ );
+ expect(result).toEqual({ success: true, contactId: 99 });
+ });
+
+ it("returns null contactId when unresolved", async () => {
+ mockResolveContactIdFromPage.mockResolvedValueOnce(null);
+ const result = await resolveContactIdFromPage(args);
+ expect(result).toEqual({ success: true, contactId: null });
+ });
+
+ it("returns a generic error message for a non-Error throw", async () => {
+ mockResolveContactIdFromPage.mockRejectedValueOnce("boom");
+ const result = await resolveContactIdFromPage(args);
+ expect(result).toEqual({ success: false, error: "Failed to resolve contact" });
+ });
+
+ it("returns the Error message when the service throws an Error", async () => {
+ mockResolveContactIdFromPage.mockRejectedValueOnce(new Error("invalid column"));
+ const result = await resolveContactIdFromPage(args);
+ expect(result).toEqual({ success: false, error: "invalid column" });
+ });
+ });
+
+ describe("fetchNextEnvelopeNumber", () => {
+ it("authorizes and returns the next envelope number", async () => {
+ mockGetNextEnvelopeNumber.mockResolvedValueOnce(1234);
+ const result = await fetchNextEnvelopeNumber();
+ expect(mockRequireSecurityRole).toHaveBeenCalledWith({ table: "Contacts", operation: "read" });
+ expect(result).toBe(1234);
+ });
+ });
+
+ describe("placesEnabled", () => {
+ it("authorizes against Addresses and returns the enabled flag", async () => {
+ mockIsEnabled.mockResolvedValueOnce(true);
+ const result = await placesEnabled();
+ expect(mockRequireSecurityRole).toHaveBeenCalledWith({ table: "Addresses", operation: "read" });
+ expect(result).toBe(true);
+ });
+ });
+
+ describe("placeAutocomplete", () => {
+ it("short-circuits to [] for inputs under 3 chars without calling the service", async () => {
+ const result = await placeAutocomplete(" a ", "token-1");
+ expect(mockRequireSecurityRole).toHaveBeenCalledWith({ table: "Addresses", operation: "read" });
+ expect(result).toEqual([]);
+ expect(mockIsEnabled).not.toHaveBeenCalled();
+ });
+
+ it("returns [] when the feature is disabled", async () => {
+ mockIsEnabled.mockResolvedValueOnce(false);
+ const result = await placeAutocomplete("123 Main", "token-1");
+ expect(result).toEqual([]);
+ expect(mockAutocomplete).not.toHaveBeenCalled();
+ });
+
+ it("delegates to the provider when enabled and input is long enough", async () => {
+ mockIsEnabled.mockResolvedValueOnce(true);
+ mockAutocomplete.mockResolvedValueOnce([
+ { placeId: "p1", primary: "123 Main St", secondary: "", full: "123 Main St" },
+ ]);
+ const result = await placeAutocomplete("123 Main", "token-1");
+ expect(mockAutocomplete).toHaveBeenCalledWith("123 Main", "token-1");
+ expect(result).toEqual([
+ { placeId: "p1", primary: "123 Main St", secondary: "", full: "123 Main St" },
+ ]);
+ });
+ });
+
+ describe("placeDetails", () => {
+ it("returns success with details", async () => {
+ const details = {
+ placeId: "p1",
+ formattedAddress: "123 Main St",
+ addressLine1: "123 Main St",
+ city: "Springfield",
+ state: "IL",
+ postalCode: "62701",
+ countryCode: "US",
+ };
+ mockGetPlaceDetails.mockResolvedValueOnce(details);
+ const result = await placeDetails("p1", "token-1");
+ expect(mockRequireSecurityRole).toHaveBeenCalledWith({ table: "Addresses", operation: "read" });
+ expect(result).toEqual({ success: true, details });
+ });
+
+ it("returns a generic error message for a non-Error throw", async () => {
+ mockGetPlaceDetails.mockRejectedValueOnce("boom");
+ const result = await placeDetails("p1", "token-1");
+ expect(result).toEqual({ success: false, error: "Failed to fetch place details" });
+ });
+
+ it("returns the Error message when the service throws an Error", async () => {
+ mockGetPlaceDetails.mockRejectedValueOnce(new Error("upstream failure"));
+ const result = await placeDetails("p1", "token-1");
+ expect(result).toEqual({ success: false, error: "upstream failure" });
+ });
+ });
+
+ describe("saveFamily", () => {
+ it("authorizes an update against Households and returns progress on success", async () => {
+ const household = makeHousehold();
+ const progress: SaveProgress = {
+ mainAddressId: 1,
+ altAddressId: null,
+ householdId: 1,
+ members: [],
+ };
+ mockSaveHousehold.mockResolvedValueOnce(progress);
+ const result = await saveFamily(household);
+ expect(mockRequireSecurityRole).toHaveBeenCalledWith({ table: "Households", operation: "update" });
+ expect(mockSaveHousehold).toHaveBeenCalledWith(household);
+ expect(result).toEqual({ success: true, progress });
+ });
+
+ it("returns partial progress and message when a PartialSaveError is thrown", async () => {
+ const household = makeHousehold();
+ const progress: SaveProgress = {
+ mainAddressId: 1,
+ altAddressId: null,
+ householdId: null,
+ members: [],
+ };
+ mockSaveHousehold.mockRejectedValueOnce(
+ new FakePartialSaveError(progress, new Error("household insert failed")),
+ );
+ const result = await saveFamily(household);
+ expect(result).toEqual({
+ success: false,
+ error: "household insert failed",
+ progress,
+ });
+ });
+
+ it("returns a generic error message for a non-Error, non-PartialSaveError throw", async () => {
+ const household = makeHousehold();
+ mockSaveHousehold.mockRejectedValueOnce("boom");
+ const result = await saveFamily(household);
+ expect(result).toEqual({ success: false, error: "Failed to save family" });
+ });
+
+ it("returns the Error message for a plain Error throw", async () => {
+ const household = makeHousehold();
+ mockSaveHousehold.mockRejectedValueOnce(new Error("connection reset"));
+ const result = await saveFamily(household);
+ expect(result).toEqual({ success: false, error: "connection reset" });
+ });
+
+ it("surfaces authorization failures as ActionError", async () => {
+ const household = makeHousehold();
+ mockRequireSecurityRole.mockRejectedValueOnce(new Error("Not authorized"));
+ const result = await saveFamily(household);
+ expect(result).toEqual({ success: false, error: "Not authorized" });
+ expect(mockSaveHousehold).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/app/(web)/tools/addeditfamily/add-edit-family.test.tsx b/src/app/(web)/tools/addeditfamily/add-edit-family.test.tsx
new file mode 100644
index 0000000..c0a1897
--- /dev/null
+++ b/src/app/(web)/tools/addeditfamily/add-edit-family.test.tsx
@@ -0,0 +1,931 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup, within, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { ToolParams } from "@/lib/tool-params";
+import type {
+ FamilyLookups,
+ FamilyDefaults,
+ Household,
+ FamilyMember,
+ ContactSearchResult,
+ SaveProgress,
+} from "@/lib/dto/family";
+import { emptyAddress } from "@/lib/dto/family";
+import type { PlacePrediction, PlaceDetails } from "@/lib/providers/google-places";
+
+/**
+ * AddEditFamily component tests.
+ *
+ * Covers: initial load (success/error), search bar (debounce, empty, error,
+ * new-family creation), household panel field editing (incl. address tabs,
+ * Google Places autocomplete on/off), member card editing (always-visible +
+ * expanded fields, donor toggle, envelope assignment), add member, save
+ * (success/bumped-envelope/failure/exception), and the dirty-close confirm
+ * dialog.
+ */
+
+const {
+ mockSearchContacts,
+ mockFetchFamilyLookups,
+ mockFetchFamilyDefaults,
+ mockFetchHousehold,
+ mockFetchNextEnvelopeNumber,
+ mockSaveFamily,
+ mockPlacesEnabled,
+ mockPlaceAutocomplete,
+ mockPlaceDetails,
+ mockRouterBack,
+ mockToastSuccess,
+ mockToastError,
+ mockToastWarning,
+} = vi.hoisted(() => ({
+ mockSearchContacts: vi.fn(),
+ mockFetchFamilyLookups: vi.fn(),
+ mockFetchFamilyDefaults: vi.fn(),
+ mockFetchHousehold: vi.fn(),
+ mockFetchNextEnvelopeNumber: vi.fn(),
+ mockSaveFamily: vi.fn(),
+ mockPlacesEnabled: vi.fn(),
+ mockPlaceAutocomplete: vi.fn(),
+ mockPlaceDetails: vi.fn(),
+ mockRouterBack: vi.fn(),
+ mockToastSuccess: vi.fn(),
+ mockToastError: vi.fn(),
+ mockToastWarning: vi.fn(),
+}));
+
+vi.mock("./actions", () => ({
+ searchContacts: mockSearchContacts,
+ fetchFamilyLookups: mockFetchFamilyLookups,
+ fetchFamilyDefaults: mockFetchFamilyDefaults,
+ fetchHousehold: mockFetchHousehold,
+ fetchNextEnvelopeNumber: mockFetchNextEnvelopeNumber,
+ saveFamily: mockSaveFamily,
+ placesEnabled: mockPlacesEnabled,
+ placeAutocomplete: mockPlaceAutocomplete,
+ placeDetails: mockPlaceDetails,
+}));
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({
+ back: mockRouterBack,
+ push: vi.fn(),
+ replace: vi.fn(),
+ refresh: vi.fn(),
+ prefetch: vi.fn(),
+ forward: vi.fn(),
+ }),
+}));
+
+vi.mock("@/components/dev-panel", () => ({
+ DevPanel: () => null,
+}));
+
+vi.mock("sonner", () => ({
+ toast: {
+ success: mockToastSuccess,
+ error: mockToastError,
+ warning: mockToastWarning,
+ },
+}));
+
+import { AddEditFamily } from "./add-edit-family";
+
+// Radix Select / cmdk need these present in jsdom.
+beforeEach(() => {
+ Element.prototype.hasPointerCapture = vi.fn().mockReturnValue(false);
+ Element.prototype.releasePointerCapture = vi.fn();
+ Element.prototype.scrollIntoView = vi.fn();
+});
+
+const LOOKUPS: FamilyLookups = {
+ congregations: [{ id: 1, name: "Main Campus" }, { id: 2, name: "East Campus" }],
+ sources: [{ id: 1, name: "Walk-in" }, { id: 2, name: "Referral" }],
+ householdPositions: [{ id: 1, name: "Head" }, { id: 2, name: "Spouse" }],
+ participantTypes: [{ id: 1, name: "Adult" }, { id: 2, name: "Child" }],
+ maritalStatuses: [{ id: 1, name: "Single" }, { id: 2, name: "Married" }],
+ prefixes: [{ id: 1, name: "Mr." }],
+ suffixes: [{ id: 1, name: "Jr." }],
+ genders: [{ id: 1, name: "Male" }, { id: 2, name: "Female" }],
+ contactStatuses: [{ id: 1, name: "Active" }, { id: 2, name: "Inactive" }],
+ primaryLanguages: [{ id: 1, name: "English" }],
+ faithBackgrounds: [{ id: 1, name: "Christian" }],
+ states: [{ code: "CA", name: "California" }, { code: "NY", name: "New York" }],
+ countries: [{ code: "US", name: "United States" }, { code: "CA", name: "Canada" }],
+};
+
+const DEFAULTS: FamilyDefaults = {
+ congregationId: 1,
+ sourceId: 1,
+ countryCode: "US",
+ state: "CA",
+ householdPositionId: 1,
+ participantTypeId: 1,
+ showEnvelopeNumbers: true,
+};
+
+function makeMember(overrides: Partial = {}): FamilyMember {
+ return {
+ contactId: 201,
+ firstName: "John",
+ middleName: "",
+ maidenName: "",
+ lastName: "Smith",
+ nickname: "",
+ prefixId: 0,
+ suffixId: 0,
+ birthDate: null,
+ genderId: 0,
+ maritalStatusId: 0,
+ mobilePhone: "",
+ emailAddress: "",
+ bulkEmailOpt: false,
+ envelopeNo: null,
+ contactStatusId: 1,
+ primaryLanguageId: null,
+ faithBackgroundId: null,
+ householdPositionId: 1,
+ participant: { participantId: 1, participantTypeId: 1, notes: null },
+ donorId: null,
+ isDonor: false,
+ ...overrides,
+ };
+}
+
+function makeHousehold(overrides: Partial = {}): Household {
+ return {
+ householdId: 500,
+ householdName: "Smith",
+ householdPhone: "555-1111",
+ congregationId: 1,
+ sourceId: 1,
+ address: {
+ ...emptyAddress(),
+ addressId: 10,
+ addressLine1: "1 Main St",
+ city: "Springfield",
+ state: "CA",
+ postalCode: "90001",
+ countryCode: "US",
+ },
+ alternateMailingAddress: { ...emptyAddress(), countryCode: "US", state: "CA" },
+ seasonStart: null,
+ seasonEnd: null,
+ repeatsAnnually: false,
+ areHeadsMarried: false,
+ members: [
+ makeMember({ contactId: 201, firstName: "John" }),
+ makeMember({ contactId: 202, firstName: "Jane", householdPositionId: 2 }),
+ ],
+ ...overrides,
+ };
+}
+
+const params: ToolParams = { pageID: 292 };
+
+function setup(overrides: { initialContactId?: number | null } = {}) {
+ return render( );
+}
+
+async function selectByLabel(labelText: string, optionText: string, occurrence = 0) {
+ const labels = screen.getAllByText(labelText);
+ const container = labels[occurrence].parentElement as HTMLElement;
+ const trigger = within(container).getByRole("combobox");
+ await userEvent.click(trigger);
+ const option = await screen.findByText(optionText, { selector: '[role="option"] *, [role="option"]' });
+ await userEvent.click(option);
+}
+
+// The `Field` wrapper does not associate its with the input via
+// htmlFor/id, so getByLabelText does not work here — scope by the label
+// text's sibling container instead (mirrors selectByLabel above).
+function inputByLabel(labelText: string, occurrence = 0): HTMLInputElement {
+ const labels = screen.getAllByText(labelText);
+ const container = labels[occurrence].parentElement as HTMLElement;
+ const input = container.querySelector("input");
+ if (!input) throw new Error(`No found under label "${labelText}"`);
+ return input;
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockFetchFamilyLookups.mockResolvedValue(LOOKUPS);
+ mockFetchFamilyDefaults.mockResolvedValue(DEFAULTS);
+ mockPlacesEnabled.mockResolvedValue(false);
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+/**
+ * These tests deliberately drive failure paths, and the code under test logs
+ * them on purpose. Silence the channel so a real, unexpected error still
+ * stands out in the runner output instead of drowning in expected noise.
+ * `mockImplementation` keeps the spy recording, so assertions on what was
+ * logged still work.
+ */
+beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+});
+
+describe("AddEditFamily — initial load", () => {
+ it("shows the search-only placeholder card once lookups/defaults resolve", async () => {
+ setup();
+ await waitFor(() => expect(mockFetchFamilyLookups).toHaveBeenCalledTimes(1));
+ expect(
+ await screen.findByText(/Search to find an existing household/i),
+ ).toBeInTheDocument();
+ });
+
+ it("shows a load error when the initial lookups/defaults fetch rejects", async () => {
+ mockFetchFamilyLookups.mockRejectedValueOnce(new Error("network down"));
+ setup();
+ expect(await screen.findByText(/network down/)).toBeInTheDocument();
+ });
+
+ it("loads an existing household via initialContactId once defaults resolve", async () => {
+ const household = makeHousehold();
+ mockFetchHousehold.mockResolvedValueOnce({ success: true, household });
+ setup({ initialContactId: 201 });
+
+ await waitFor(() => expect(mockFetchHousehold).toHaveBeenCalledWith(201));
+ expect(await screen.findByDisplayValue("Smith")).toBeInTheDocument();
+ });
+
+ it("does not load a household when initialContactId is 0", async () => {
+ setup({ initialContactId: 0 });
+ await waitFor(() => expect(mockFetchFamilyDefaults).toHaveBeenCalled());
+ expect(mockFetchHousehold).not.toHaveBeenCalled();
+ });
+
+ it("shows a load error when fetchHousehold fails for initialContactId", async () => {
+ mockFetchHousehold.mockResolvedValueOnce({ success: false, error: "Household not found" });
+ setup({ initialContactId: 999 });
+ expect(await screen.findByText("Household not found")).toBeInTheDocument();
+ // Falls back to the placeholder card since household stays null.
+ expect(screen.getByText(/Search to find an existing household/i)).toBeInTheDocument();
+ });
+});
+
+describe("AddEditFamily — search bar", () => {
+ it("prompts for at least 2 characters", async () => {
+ const user = userEvent.setup();
+ setup();
+ await screen.findByText(/Search to find an existing household/i);
+ await user.click(screen.getByRole("combobox", { name: "" }));
+ const input = screen.getByPlaceholderText("Type a name…");
+ await user.type(input, "a");
+ expect(await screen.findByText("Type at least 2 characters")).toBeInTheDocument();
+ expect(mockSearchContacts).not.toHaveBeenCalled();
+ });
+
+ it("debounces, searches, and lets the user select an existing household", async () => {
+ const user = userEvent.setup();
+ const results: ContactSearchResult[] = [
+ { contactId: 301, displayName: "Bob Jones", detail: "bob@example.com" },
+ ];
+ mockSearchContacts.mockResolvedValue(results);
+ const household = makeHousehold({ householdName: "Jones" });
+ mockFetchHousehold.mockResolvedValueOnce({ success: true, household });
+
+ setup();
+ await screen.findByText(/Search to find an existing household/i);
+ await user.click(screen.getByRole("combobox", { name: "" }));
+ const input = screen.getByPlaceholderText("Type a name…");
+ await user.type(input, "Bob");
+
+ await waitFor(() => expect(mockSearchContacts).toHaveBeenCalledWith("Bob"));
+ const item = await screen.findByText("Bob Jones");
+ await user.click(item);
+
+ await waitFor(() => expect(mockFetchHousehold).toHaveBeenCalledWith(301));
+ expect(await screen.findByDisplayValue("Jones")).toBeInTheDocument();
+ });
+
+ it("finds no CommandEmpty text when the search returns nothing (see TODO: cmdk always counts the '+ New Family' item)", async () => {
+ // NOTE: the source renders No contacts found.
+ // whenever query.length >= 2 && results.length === 0, but cmdk's
+ // CommandEmpty only paints when its *global* registered-item count is 0.
+ // The "+ New Family" CommandItem is always registered once the query is
+ // >= 2 chars, so that count is never 0 and this message never renders.
+ // See .claude/TODO/2026-09-13-search-empty-state-never-renders.md
+ const user = userEvent.setup();
+ mockSearchContacts.mockResolvedValue([]);
+ setup();
+ await screen.findByText(/Search to find an existing household/i);
+ await user.click(screen.getByRole("combobox", { name: "" }));
+ await user.type(screen.getByPlaceholderText("Type a name…"), "zz");
+ await waitFor(() => expect(mockSearchContacts).toHaveBeenCalledWith("zz"));
+ expect(screen.queryByText("No contacts found.")).not.toBeInTheDocument();
+ expect(await screen.findByText(/\+ New Family with last name/)).toBeInTheDocument();
+ });
+
+ it("clears results silently when the search action throws", async () => {
+ const user = userEvent.setup();
+ mockSearchContacts.mockRejectedValue(new Error("boom"));
+ setup();
+ await screen.findByText(/Search to find an existing household/i);
+ await user.click(screen.getByRole("combobox", { name: "" }));
+ await user.type(screen.getByPlaceholderText("Type a name…"), "zz");
+ await waitFor(() => expect(mockSearchContacts).toHaveBeenCalledWith("zz"));
+ // No crash, and no stale results rendered.
+ expect(screen.queryByText("Existing households")).not.toBeInTheDocument();
+ });
+
+ it("creates a new family from the search box", async () => {
+ const user = userEvent.setup();
+ mockSearchContacts.mockResolvedValue([]);
+ setup();
+ await screen.findByText(/Search to find an existing household/i);
+ await user.click(screen.getByRole("combobox", { name: "" }));
+ await user.type(screen.getByPlaceholderText("Type a name…"), "Newman");
+
+ const createItem = await screen.findByText(/\+ New Family with last name/);
+ await user.click(createItem);
+
+ expect(await screen.findAllByDisplayValue("Newman")).not.toHaveLength(0);
+ // Two empty members seeded (Head of House 1 / 2).
+ expect(screen.getByText("Head of House 1")).toBeInTheDocument();
+ expect(screen.getByText("Head of House 2")).toBeInTheDocument();
+ });
+});
+
+describe("AddEditFamily — household panel editing", () => {
+ async function loadNewFamily(user: ReturnType) {
+ mockSearchContacts.mockResolvedValue([]);
+ setup();
+ await screen.findByText(/Search to find an existing household/i);
+ await user.click(screen.getByRole("combobox", { name: "" }));
+ await user.type(screen.getByPlaceholderText("Type a name…"), "Newman");
+ const createItem = await screen.findByText(/\+ New Family with last name/);
+ await user.click(createItem);
+ await waitFor(() => expect(screen.getAllByDisplayValue("Newman").length).toBeGreaterThan(0));
+ }
+
+ it("edits last name, phone, congregation, and source", async () => {
+ const user = userEvent.setup();
+ await loadNewFamily(user);
+
+ // First "Newman" display value in DOM order is the household name input
+ // (the member-1 card is expanded by default and also shows "Newman" as
+ // its seeded last name).
+ const lastName = screen.getAllByDisplayValue("Newman")[0];
+ await user.clear(lastName);
+ await user.type(lastName, "Updated");
+ expect(await screen.findByDisplayValue("Updated")).toBeInTheDocument();
+
+ await selectByLabel("Congregation", "East Campus");
+ await selectByLabel("Source", "Referral");
+ });
+
+ it("edits main address fields with places disabled (plain input)", async () => {
+ const user = userEvent.setup();
+ await loadNewFamily(user);
+
+ const addrInput = screen.getByPlaceholderText("Enter a location");
+ await user.type(addrInput, "123 Elm St");
+ expect(addrInput).toHaveValue("123 Elm St");
+
+ await selectByLabel("Country", "Canada");
+ await selectByLabel("State", "NY — New York");
+
+ const line2 = inputByLabel("Address Line 2");
+ await user.type(line2, "Apt 4");
+ expect(line2).toHaveValue("Apt 4");
+
+ const city = inputByLabel("City");
+ await user.type(city, "Metropolis");
+ expect(city).toHaveValue("Metropolis");
+
+ const zip = inputByLabel("Zip Code");
+ await user.clear(zip);
+ await user.type(zip, "10001");
+ expect(zip).toHaveValue("10001");
+ });
+
+ it("edits the household phone number", async () => {
+ const user = userEvent.setup();
+ await loadNewFamily(user);
+ const phone = inputByLabel("Home Phone");
+ await user.type(phone, "555-9999");
+ expect(phone).toHaveValue("555-9999");
+ });
+
+ it("switches to the Alt tab and edits season fields", async () => {
+ const user = userEvent.setup();
+ await loadNewFamily(user);
+
+ await user.click(screen.getByText("Alt Address"));
+ expect(screen.getByText("Season Start")).toBeInTheDocument();
+ expect(screen.getByText("Repeats Annually")).toBeInTheDocument();
+
+ const seasonStart = inputByLabel("Season Start");
+ await user.type(seasonStart, "2026-01-01");
+ expect(seasonStart).toHaveValue("2026-01-01");
+
+ const seasonEnd = inputByLabel("Season End");
+ await user.type(seasonEnd, "2026-05-01");
+ expect(seasonEnd).toHaveValue("2026-05-01");
+
+ const checkbox = screen.getByRole("checkbox", { name: "Repeats Annually" });
+ await user.click(checkbox);
+ expect(checkbox).toBeChecked();
+
+ await user.click(screen.getByText("Main Address"));
+ expect(screen.queryByText("Season Start")).not.toBeInTheDocument();
+ });
+
+ it("uses Google Places autocomplete when enabled, and falls back on details failure", async () => {
+ mockPlacesEnabled.mockResolvedValue(true);
+ const predictions: PlacePrediction[] = [
+ { placeId: "p1", primary: "123 Elm St", secondary: "Springfield", full: "123 Elm St, Springfield" },
+ ];
+ mockPlaceAutocomplete.mockResolvedValue(predictions);
+ mockPlaceDetails.mockResolvedValueOnce({ success: false, error: "no details" });
+
+ const user = userEvent.setup();
+ await loadNewFamily(user);
+
+ const addrInput = screen.getByPlaceholderText("Start typing an address…");
+ await user.type(addrInput, "123 Elm");
+
+ await waitFor(() => expect(mockPlaceAutocomplete).toHaveBeenCalled());
+ const prediction = await screen.findByText("123 Elm St");
+ await userEvent.pointer({ keys: "[MouseLeft]", target: prediction });
+
+ await waitFor(() => expect(mockPlaceDetails).toHaveBeenCalledWith("p1", expect.any(String)));
+ // Fallback path: text set to full prediction string since details failed.
+ await waitFor(() => expect(screen.getByPlaceholderText("Start typing an address…")).toHaveValue("123 Elm St, Springfield"));
+ });
+
+ it("applies place details on successful selection", async () => {
+ mockPlacesEnabled.mockResolvedValue(true);
+ const predictions: PlacePrediction[] = [
+ { placeId: "p2", primary: "456 Oak Ave", secondary: "Metropolis", full: "456 Oak Ave, Metropolis" },
+ ];
+ mockPlaceAutocomplete.mockResolvedValue(predictions);
+ const details: PlaceDetails = {
+ placeId: "p2",
+ formattedAddress: "456 Oak Ave, Metropolis",
+ addressLine1: "456 Oak Ave",
+ city: "Metropolis",
+ state: "NY",
+ postalCode: "10001",
+ countryCode: "US",
+ };
+ mockPlaceDetails.mockResolvedValueOnce({ success: true, details });
+
+ const user = userEvent.setup();
+ await loadNewFamily(user);
+
+ const addrInput = screen.getByPlaceholderText("Start typing an address…");
+ await user.type(addrInput, "456 Oak");
+ await waitFor(() => expect(mockPlaceAutocomplete).toHaveBeenCalled());
+ const prediction = await screen.findByText("456 Oak Ave");
+ await userEvent.pointer({ keys: "[MouseLeft]", target: prediction });
+
+ await waitFor(() => expect(screen.getByDisplayValue("Metropolis")).toBeInTheDocument());
+ });
+
+ it("clears predictions when the address text is shortened", async () => {
+ mockPlacesEnabled.mockResolvedValue(true);
+ mockPlaceAutocomplete.mockResolvedValue([
+ { placeId: "p3", primary: "1 Long Rd", secondary: "", full: "1 Long Rd" },
+ ]);
+ const user = userEvent.setup();
+ await loadNewFamily(user);
+
+ const addrInput = screen.getByPlaceholderText("Start typing an address…");
+ await user.type(addrInput, "1 Long Rd");
+ await waitFor(() => expect(mockPlaceAutocomplete).toHaveBeenCalled());
+ await user.clear(addrInput);
+ await user.type(addrInput, "1");
+ await waitFor(() => expect(screen.queryByText("1 Long Rd")).not.toBeInTheDocument());
+ });
+
+ it("clears predictions silently when placeAutocomplete throws", async () => {
+ mockPlacesEnabled.mockResolvedValue(true);
+ mockPlaceAutocomplete.mockRejectedValue(new Error("places down"));
+ const user = userEvent.setup();
+ await loadNewFamily(user);
+
+ const addrInput = screen.getByPlaceholderText("Start typing an address…");
+ await user.type(addrInput, "1 Long Rd");
+ await waitFor(() => expect(mockPlaceAutocomplete).toHaveBeenCalled());
+ expect(screen.queryByText("Searching…")).not.toBeInTheDocument();
+ });
+
+ it("closes the predictions dropdown on blur", async () => {
+ mockPlacesEnabled.mockResolvedValue(true);
+ mockPlaceAutocomplete.mockResolvedValue([
+ { placeId: "p4", primary: "9 Blur Ave", secondary: "", full: "9 Blur Ave" },
+ ]);
+ const user = userEvent.setup();
+ await loadNewFamily(user);
+
+ const addrInput = screen.getByPlaceholderText("Start typing an address…");
+ await user.type(addrInput, "9 Blur Ave");
+ await waitFor(() => expect(screen.getByText("9 Blur Ave")).toBeInTheDocument());
+
+ await user.tab();
+ await waitFor(() => expect(screen.queryByText("9 Blur Ave")).not.toBeInTheDocument(), {
+ timeout: 2000,
+ });
+ });
+});
+
+describe("AddEditFamily — member card editing", () => {
+ async function loadExisting(overrides: Partial = {}) {
+ const household = makeHousehold(overrides);
+ mockFetchHousehold.mockResolvedValueOnce({ success: true, household });
+ setup({ initialContactId: 201 });
+ await screen.findByDisplayValue("John");
+ return household;
+ }
+
+ it("shows head labels, member number, and a generic label for a third member", async () => {
+ await loadExisting({
+ members: [
+ makeMember({ contactId: 201, firstName: "John" }),
+ makeMember({ contactId: 202, firstName: "Jane", householdPositionId: 2 }),
+ makeMember({ contactId: 203, firstName: "Kid" }),
+ ],
+ });
+ expect(screen.getByText("Head of House 1")).toBeInTheDocument();
+ expect(screen.getByText("Head of House 2")).toBeInTheDocument();
+ expect(screen.getByText("Family Member 3")).toBeInTheDocument();
+ expect(screen.getByText("#201")).toBeInTheDocument();
+ });
+
+ it("toggles the Heads Are Married checkbox", async () => {
+ const user = userEvent.setup();
+ await loadExisting();
+ const married = screen.getByRole("checkbox", { name: "Heads are Married" });
+ expect(married).not.toBeChecked();
+ await user.click(married);
+ expect(married).toBeChecked();
+ });
+
+ it("edits always-visible member fields: gender, first name, household position, participant type, email, mobile", async () => {
+ const user = userEvent.setup();
+ await loadExisting();
+
+ const firstName = screen.getByDisplayValue("John");
+ await user.clear(firstName);
+ await user.type(firstName, "Jonathan");
+ expect(await screen.findByDisplayValue("Jonathan")).toBeInTheDocument();
+
+ await selectByLabel("Gender", "Male");
+ await selectByLabel("Household Position", "Spouse");
+ await selectByLabel("Participant Type", "Child");
+
+ const email = inputByLabel("Email Address");
+ await user.type(email, "jonathan@example.com");
+ expect(email).toHaveValue("jonathan@example.com");
+
+ const mobile = inputByLabel("Mobile Phone");
+ await user.type(mobile, "555-2222");
+ expect(mobile).toHaveValue("555-2222");
+ });
+
+ it("handles a member with a null participant (fallback ids)", async () => {
+ await loadExisting({
+ members: [
+ makeMember({ contactId: 201, firstName: "John", participant: null }),
+ makeMember({ contactId: 202, firstName: "Jane", householdPositionId: 2 }),
+ ],
+ });
+ // Participant Type falls back to 0 -> renders the placeholder, no crash.
+ expect(screen.getByText("Head of House 1")).toBeInTheDocument();
+ });
+
+ it("expands and collapses a member card, editing expanded fields", async () => {
+ const user = userEvent.setup();
+ await loadExisting();
+
+ const moreButtons = screen.getAllByRole("button", { name: /More/ });
+ await user.click(moreButtons[0]);
+ expect(screen.getAllByRole("button", { name: /Less/ })[0]).toBeInTheDocument();
+
+ await selectByLabel("Prefix", "Mr.");
+ await selectByLabel("Suffix", "Jr.");
+ await selectByLabel("Marital Status", "Single");
+ await selectByLabel("Contact Status", "Inactive");
+ await selectByLabel("Primary Language", "English");
+ await selectByLabel("Faith Background", "Christian");
+
+ const nickname = inputByLabel("Nickname");
+ await user.type(nickname, "Johnny");
+ expect(nickname).toHaveValue("Johnny");
+
+ const middleName = inputByLabel("Middle Name");
+ await user.type(middleName, "Q");
+ expect(middleName).toHaveValue("Q");
+
+ const maidenName = inputByLabel("Maiden Name");
+ await user.type(maidenName, "Doe");
+ expect(maidenName).toHaveValue("Doe");
+
+ const memberLastName = inputByLabel("Last Name", 1); // occurrence 0 is the household field
+ await user.clear(memberLastName);
+ await user.type(memberLastName, "Smithson");
+ expect(memberLastName).toHaveValue("Smithson");
+
+ const birthDate = inputByLabel("Date of Birth");
+ await user.type(birthDate, "1990-05-01");
+ expect(birthDate).toHaveValue("1990-05-01");
+
+ await user.click(screen.getAllByRole("button", { name: /Less/ })[0]);
+ expect(screen.queryByText("Prefix")).not.toBeInTheDocument();
+ });
+
+ it("clears an allow-clear select back to none", async () => {
+ const user = userEvent.setup();
+ await loadExisting();
+ await user.click(screen.getAllByRole("button", { name: /More/ })[0]);
+ await selectByLabel("Marital Status", "Single");
+ // Re-open and clear it.
+ const label = screen.getAllByText("Marital Status")[0];
+ const container = label.parentElement as HTMLElement;
+ const trigger = within(container).getByRole("combobox");
+ await user.click(trigger);
+ const noneOption = await screen.findByText("— None —");
+ await user.click(noneOption);
+ });
+
+ it("toggles bulk email opt-out", async () => {
+ const user = userEvent.setup();
+ await loadExisting();
+ await user.click(screen.getAllByRole("button", { name: /More/ })[0]);
+ const bulk = screen.getAllByRole("checkbox", { name: "Bulk Email Opt Out" })[0];
+ await user.click(bulk);
+ expect(bulk).toBeChecked();
+ });
+
+ it("turns on Donor, assigns the next envelope number, then turns Donor off (clears envelope)", async () => {
+ const user = userEvent.setup();
+ await loadExisting();
+ await user.click(screen.getAllByRole("button", { name: /More/ })[0]);
+
+ const donor = screen.getAllByRole("checkbox", { name: "Donor" })[0];
+ expect(donor).not.toBeDisabled();
+ await user.click(donor);
+ expect(donor).toBeChecked();
+
+ mockFetchNextEnvelopeNumber.mockResolvedValueOnce(42);
+ const assignBtn = await screen.findByRole("button", { name: "Assign Next Envelope #" });
+ await user.click(assignBtn);
+ await waitFor(() => expect(screen.getByDisplayValue("42")).toBeInTheDocument());
+
+ await user.click(donor);
+ expect(donor).not.toBeChecked();
+ expect(screen.queryByDisplayValue("42")).not.toBeInTheDocument();
+ });
+
+ it("shows a toast error when assigning the next envelope number fails", async () => {
+ const user = userEvent.setup();
+ await loadExisting();
+ await user.click(screen.getAllByRole("button", { name: /More/ })[0]);
+ const donor = screen.getAllByRole("checkbox", { name: "Donor" })[0];
+ await user.click(donor);
+
+ mockFetchNextEnvelopeNumber.mockRejectedValueOnce(new Error("no envelopes left"));
+ const assignBtn = await screen.findByRole("button", { name: "Assign Next Envelope #" });
+ await user.click(assignBtn);
+
+ await waitFor(() =>
+ expect(mockToastError).toHaveBeenCalledWith(
+ expect.stringContaining("no envelopes left"),
+ ),
+ );
+ });
+
+ it("disables the Donor checkbox with a tooltip when a donor record already exists", async () => {
+ const user = userEvent.setup();
+ await loadExisting({
+ members: [
+ makeMember({ contactId: 201, firstName: "John", isDonor: true, donorId: 55, envelopeNo: 7 }),
+ makeMember({ contactId: 202, firstName: "Jane", householdPositionId: 2 }),
+ ],
+ });
+ await user.click(screen.getAllByRole("button", { name: /More/ })[0]);
+ const donor = screen.getAllByRole("checkbox", { name: "Donor" })[0];
+ expect(donor).toBeDisabled();
+ await user.click(donor); // no-op, but should not throw
+ expect(donor).toBeDisabled();
+ });
+
+ it("edits the envelope number input directly, including clearing it to null", async () => {
+ const user = userEvent.setup();
+ await loadExisting({
+ members: [
+ makeMember({ contactId: 201, firstName: "John", isDonor: true, donorId: 55, envelopeNo: 7 }),
+ makeMember({ contactId: 202, firstName: "Jane", householdPositionId: 2 }),
+ ],
+ });
+ await user.click(screen.getAllByRole("button", { name: /More/ })[0]);
+ const envelopeInput = await screen.findByDisplayValue("7");
+ await user.clear(envelopeInput);
+ expect((envelopeInput as HTMLInputElement).value).toBe("");
+ await user.type(envelopeInput, "9");
+ expect(await screen.findByDisplayValue("9")).toBeInTheDocument();
+ });
+
+ it("adds a new family member", async () => {
+ const user = userEvent.setup();
+ await loadExisting();
+ await user.click(screen.getByRole("button", { name: /Add New Family Member/ }));
+ expect(await screen.findByText("Family Member 3")).toBeInTheDocument();
+ });
+});
+
+describe("AddEditFamily — save", () => {
+ const progress: SaveProgress = {
+ mainAddressId: 10,
+ altAddressId: null,
+ householdId: 500,
+ members: [
+ {
+ tempContactId: 201,
+ contactId: 201,
+ participantId: 1,
+ donorId: null,
+ envelopeNo: null,
+ envelopeBumped: false,
+ },
+ {
+ tempContactId: 202,
+ contactId: 202,
+ participantId: 2,
+ donorId: null,
+ envelopeNo: null,
+ envelopeBumped: false,
+ },
+ ],
+ };
+
+ async function loadExisting() {
+ const household = makeHousehold();
+ mockFetchHousehold.mockResolvedValueOnce({ success: true, household });
+ setup({ initialContactId: 201 });
+ await screen.findByDisplayValue("John");
+ }
+
+ it("saves successfully, reloads the household, and shows a success toast", async () => {
+ const user = userEvent.setup();
+ await loadExisting();
+ mockSaveFamily.mockResolvedValueOnce({ success: true, progress });
+ const reloaded = makeHousehold({ householdName: "Smith Reloaded" });
+ mockFetchHousehold.mockResolvedValueOnce({ success: true, household: reloaded });
+
+ await user.click(screen.getByRole("button", { name: "Save" }));
+
+ await waitFor(() => expect(mockToastSuccess).toHaveBeenCalledWith("Family saved"));
+ expect(mockFetchHousehold).toHaveBeenLastCalledWith(201);
+ expect(await screen.findByDisplayValue("Smith Reloaded")).toBeInTheDocument();
+ });
+
+ it("keeps the current household when the post-save reload fails", async () => {
+ const user = userEvent.setup();
+ await loadExisting();
+ mockSaveFamily.mockResolvedValueOnce({ success: true, progress });
+ mockFetchHousehold.mockResolvedValueOnce({ success: false, error: "reload failed" });
+
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() => expect(mockToastSuccess).toHaveBeenCalledWith("Family saved"));
+ // Original name is still shown since the reload branch was skipped.
+ expect(screen.getByDisplayValue("Smith")).toBeInTheDocument();
+ });
+
+ it("skips reload when no member has a positive saved contactId", async () => {
+ const user = userEvent.setup();
+ await loadExisting();
+ mockSaveFamily.mockResolvedValueOnce({
+ success: true,
+ progress: { ...progress, members: [{ ...progress.members[0], contactId: -1 }] },
+ });
+
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() => expect(mockToastSuccess).toHaveBeenCalledWith("Family saved"));
+ expect(mockFetchHousehold).toHaveBeenCalledTimes(1); // only the initial load
+ });
+
+ it("shows an envelope-bumped warning instead of the success toast", async () => {
+ const user = userEvent.setup();
+ await loadExisting();
+ mockSaveFamily.mockResolvedValueOnce({
+ success: true,
+ progress: {
+ ...progress,
+ members: [
+ { ...progress.members[0], envelopeBumped: true, envelopeNo: 15 },
+ progress.members[1],
+ ],
+ },
+ });
+ mockFetchHousehold.mockResolvedValueOnce({ success: true, household: makeHousehold() });
+
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(mockToastWarning).toHaveBeenCalledWith(expect.stringContaining("#15")),
+ );
+ expect(mockToastSuccess).not.toHaveBeenCalled();
+ });
+
+ it("shows an error toast and applies partial progress on save failure", async () => {
+ const user = userEvent.setup();
+ await loadExisting();
+ mockSaveFamily.mockResolvedValueOnce({
+ success: false,
+ error: "duplicate household",
+ progress,
+ });
+
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(mockToastError).toHaveBeenCalledWith("Save failed: duplicate household"),
+ );
+ });
+
+ it("shows an error toast on save failure without progress", async () => {
+ const user = userEvent.setup();
+ await loadExisting();
+ mockSaveFamily.mockResolvedValueOnce({ success: false, error: "validation error" });
+
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(mockToastError).toHaveBeenCalledWith("Save failed: validation error"),
+ );
+ });
+
+ it("shows an error toast when saveFamily throws an Error", async () => {
+ const user = userEvent.setup();
+ await loadExisting();
+ mockSaveFamily.mockRejectedValueOnce(new Error("network blip"));
+
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(mockToastError).toHaveBeenCalledWith("Save failed: network blip"),
+ );
+ });
+
+ it("shows a generic error toast when saveFamily throws a non-Error", async () => {
+ const user = userEvent.setup();
+ await loadExisting();
+ mockSaveFamily.mockRejectedValueOnce("weird failure");
+
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() =>
+ expect(mockToastError).toHaveBeenCalledWith("Save failed: Unknown error"),
+ );
+ });
+
+ it("does nothing when Save is clicked with no household loaded", async () => {
+ const user = userEvent.setup();
+ setup();
+ await screen.findByText(/Search to find an existing household/i);
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ expect(mockSaveFamily).not.toHaveBeenCalled();
+ });
+});
+
+describe("AddEditFamily — close / dirty confirmation", () => {
+ it("closes immediately when the household is unmodified", async () => {
+ const user = userEvent.setup();
+ setup();
+ await screen.findByText(/Search to find an existing household/i);
+ await user.click(screen.getByRole("button", { name: "Close" }));
+ expect(mockRouterBack).toHaveBeenCalledTimes(1);
+ });
+
+ it("confirms discard before closing when the household is dirty", async () => {
+ const user = userEvent.setup();
+ mockSearchContacts.mockResolvedValue([]);
+ setup();
+ await screen.findByText(/Search to find an existing household/i);
+ await user.click(screen.getByRole("combobox", { name: "" }));
+ await user.type(screen.getByPlaceholderText("Type a name…"), "Newman");
+ await user.click(await screen.findByText(/\+ New Family with last name/));
+ await waitFor(() => expect(screen.getAllByDisplayValue("Newman").length).toBeGreaterThan(0));
+
+ // Household is now dirty once we change a field from its seeded snapshot.
+ const lastName = screen.getAllByDisplayValue("Newman")[0];
+ await user.type(lastName, " Jr");
+
+ await user.click(screen.getByRole("button", { name: "Close" }));
+ expect(await screen.findByText("Discard unsaved changes?")).toBeInTheDocument();
+ expect(mockRouterBack).not.toHaveBeenCalled();
+
+ await user.click(screen.getByRole("button", { name: "Keep editing" }));
+ expect(screen.queryByText("Discard unsaved changes?")).not.toBeInTheDocument();
+ expect(mockRouterBack).not.toHaveBeenCalled();
+
+ await user.click(screen.getByRole("button", { name: "Close" }));
+ await screen.findByText("Discard unsaved changes?");
+ await user.click(screen.getByRole("button", { name: "Discard" }));
+ expect(mockRouterBack).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/app/(web)/tools/addeditfamily/add-edit-family.tsx b/src/app/(web)/tools/addeditfamily/add-edit-family.tsx
index b91776a..2164938 100644
--- a/src/app/(web)/tools/addeditfamily/add-edit-family.tsx
+++ b/src/app/(web)/tools/addeditfamily/add-edit-family.tsx
@@ -1165,7 +1165,7 @@ interface LookupSelectProps {
function LookupSelect({ value, options, onChange, allowClear }: LookupSelectProps) {
return (
0 ? String(value) : undefined}
+ value={value > 0 ? String(value) : ""}
onValueChange={(v) => onChange(v === "__none__" ? 0 : Number(v))}
>
diff --git a/src/app/(web)/tools/addeditfamily/page.test.tsx b/src/app/(web)/tools/addeditfamily/page.test.tsx
new file mode 100644
index 0000000..301a751
--- /dev/null
+++ b/src/app/(web)/tools/addeditfamily/page.test.tsx
@@ -0,0 +1,152 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, cleanup } from "@testing-library/react";
+
+/**
+ * AddEditFamilyPage tests.
+ *
+ * Covers the branching in the async server component: resolving
+ * initialContactId from page params (success and failure — the failure
+ * path is swallowed with a console.warn and initialContactId stays null),
+ * and the various guard conditions that skip resolution entirely.
+ */
+
+const { mockParseToolParams, mockGetInstance, mockResolveContactIdFromPage } = vi.hoisted(() => ({
+ mockParseToolParams: vi.fn(),
+ mockGetInstance: vi.fn(),
+ mockResolveContactIdFromPage: vi.fn(),
+}));
+
+vi.mock("@/lib/tool-params.server", () => ({
+ parseToolParams: mockParseToolParams,
+}));
+
+vi.mock("@/services/familyService", () => ({
+ FamilyService: {
+ getInstance: mockGetInstance,
+ },
+}));
+
+vi.mock("./add-edit-family", () => ({
+ AddEditFamily: ({
+ params,
+ initialContactId,
+ }: {
+ params: unknown;
+ initialContactId: number | null;
+ }) => (
+
+ {String(initialContactId)}
+ {JSON.stringify(params)}
+
+ ),
+}));
+
+import AddEditFamilyPage, { generateMetadata } from "./page";
+
+function searchParamsOf(obj: Record) {
+ return Promise.resolve(obj);
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetInstance.mockResolvedValue({
+ resolveContactIdFromPage: mockResolveContactIdFromPage,
+ });
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+describe("AddEditFamilyPage", () => {
+ it("renders with initialContactId null when there is no recordID", async () => {
+ mockParseToolParams.mockResolvedValue({ recordID: undefined });
+ const jsx = await AddEditFamilyPage({ searchParams: searchParamsOf({}) });
+ render(jsx);
+ expect(screen.getByTestId("initial-contact-id").textContent).toBe("null");
+ expect(mockGetInstance).not.toHaveBeenCalled();
+ });
+
+ it("does not resolve when recordID is not positive", async () => {
+ mockParseToolParams.mockResolvedValue({
+ recordID: -1,
+ pageData: {
+ Table_Name: "Contacts",
+ Primary_Key: "Contact_ID",
+ Contact_ID_Field: "Contact_ID",
+ },
+ });
+ const jsx = await AddEditFamilyPage({ searchParams: searchParamsOf({ recordID: "-1" }) });
+ render(jsx);
+ expect(screen.getByTestId("initial-contact-id").textContent).toBe("null");
+ expect(mockGetInstance).not.toHaveBeenCalled();
+ });
+
+ it("does not resolve when pageData is missing required fields", async () => {
+ mockParseToolParams.mockResolvedValue({
+ recordID: 42,
+ pageData: { Table_Name: "Contacts" }, // missing Primary_Key/Contact_ID_Field
+ });
+ const jsx = await AddEditFamilyPage({ searchParams: searchParamsOf({ recordID: "42" }) });
+ render(jsx);
+ expect(screen.getByTestId("initial-contact-id").textContent).toBe("null");
+ expect(mockGetInstance).not.toHaveBeenCalled();
+ });
+
+ it("resolves the Contact_ID from the page record when all params are present", async () => {
+ mockParseToolParams.mockResolvedValue({
+ recordID: 42,
+ pageData: {
+ Table_Name: "Households",
+ Primary_Key: "Household_ID",
+ Contact_ID_Field: "Contact_ID",
+ },
+ });
+ mockResolveContactIdFromPage.mockResolvedValue(999);
+
+ const jsx = await AddEditFamilyPage({ searchParams: searchParamsOf({ recordID: "42" }) });
+ render(jsx);
+
+ expect(mockResolveContactIdFromPage).toHaveBeenCalledWith(
+ "Households",
+ "Household_ID",
+ 42,
+ "Contact_ID",
+ );
+ expect(screen.getByTestId("initial-contact-id").textContent).toBe("999");
+ });
+
+ it("swallows a resolution failure and renders with initialContactId null", async () => {
+ mockParseToolParams.mockResolvedValue({
+ recordID: 42,
+ pageData: {
+ Table_Name: "Households",
+ Primary_Key: "Household_ID",
+ Contact_ID_Field: "Contact_ID",
+ },
+ });
+ mockResolveContactIdFromPage.mockRejectedValue(new Error("no security role"));
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ const jsx = await AddEditFamilyPage({ searchParams: searchParamsOf({ recordID: "42" }) });
+ render(jsx);
+
+ expect(screen.getByTestId("initial-contact-id").textContent).toBe("null");
+ expect(warnSpy).toHaveBeenCalledWith(
+ "Failed to resolve Contact_ID from page record:",
+ expect.any(Error),
+ );
+ });
+
+ it("passes params through to AddEditFamily", async () => {
+ mockParseToolParams.mockResolvedValue({ recordID: undefined, pageID: 292 });
+ const jsx = await AddEditFamilyPage({ searchParams: searchParamsOf({ pageID: "292" }) });
+ render(jsx);
+ expect(screen.getByTestId("params").textContent).toContain("292");
+ });
+
+ it("generateMetadata returns the tool title", async () => {
+ await expect(generateMetadata()).resolves.toEqual({ title: "Add/Edit Family" });
+ });
+});
diff --git a/src/app/(web)/tools/addresslabels/address-labels.test.tsx b/src/app/(web)/tools/addresslabels/address-labels.test.tsx
new file mode 100644
index 0000000..e0a9299
--- /dev/null
+++ b/src/app/(web)/tools/addresslabels/address-labels.test.tsx
@@ -0,0 +1,357 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, cleanup, screen, fireEvent, waitFor } from '@testing-library/react';
+import type { LabelConfig, LabelData, SkipRecord } from '@/lib/dto';
+import type { ToolParams } from '@/lib/tool-params';
+
+const {
+ mockFetchAddressLabels,
+ mockGenerateLabelPdf,
+ mockGenerateLabelDocx,
+ mockRouterBack,
+} = vi.hoisted(() => ({
+ mockFetchAddressLabels: vi.fn(),
+ mockGenerateLabelPdf: vi.fn(),
+ mockGenerateLabelDocx: vi.fn(),
+ mockRouterBack: vi.fn(),
+}));
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ back: mockRouterBack,
+ push: vi.fn(),
+ replace: vi.fn(),
+ refresh: vi.fn(),
+ prefetch: vi.fn(),
+ forward: vi.fn(),
+ }),
+}));
+
+vi.mock('@/components/dev-panel', () => ({
+ DevPanel: () => null,
+}));
+
+vi.mock('@/components/address-labels/actions', () => ({
+ fetchAddressLabels: mockFetchAddressLabels,
+ generateLabelPdf: mockGenerateLabelPdf,
+ generateLabelDocx: mockGenerateLabelDocx,
+}));
+
+vi.mock('@/components/address-labels', () => ({
+ AddressLabelsForm: ({
+ config,
+ onChange,
+ maxStartPosition,
+ }: {
+ config: LabelConfig;
+ onChange: (c: LabelConfig) => void;
+ maxStartPosition: number;
+ }) => (
+
+ {JSON.stringify(config)}
+ {maxStartPosition}
+ onChange({ ...config, stockId: '5161' })}>change-stock
+ onChange({ ...config, barcodeFormat: 'imb', mailerId: '123' })}>
+ set-invalid-imb
+
+ onChange({ ...config, barcodeFormat: 'imb', mailerId: '123456789' })}>
+ set-valid-imb
+
+
+ ),
+ AddressLabelsSummary: ({
+ printableCount,
+ skipped,
+ }: {
+ printableCount: number;
+ skipped: SkipRecord[];
+ }) => (
+
+ {printableCount} printable / {skipped.length} skipped
+
+ ),
+}));
+
+vi.mock('@/components/address-labels/mail-merge-tab', () => ({
+ MailMergeTab: ({ printable }: { printable: LabelData[] }) => (
+ merge-tab:{printable.length}
+ ),
+}));
+
+import { AddressLabels } from './address-labels';
+
+const params: ToolParams = { recordID: 1 };
+
+const printableLabel: LabelData = {
+ name: 'Jane Doe',
+ addressLine1: '123 Main St',
+ city: 'Chicago',
+ state: 'IL',
+ postalCode: '60601',
+};
+
+function arrayBufferToBase64(text: string): string {
+ return Buffer.from(text).toString('base64');
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ mockFetchAddressLabels.mockResolvedValue({ printable: [printableLabel], skipped: [] });
+ URL.createObjectURL = vi.fn(() => 'blob:mock-url');
+ URL.revokeObjectURL = vi.fn();
+ window.open = vi.fn();
+ HTMLAnchorElement.prototype.click = vi.fn();
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+describe('AddressLabels', () => {
+ it('shows a loading indicator while address data is being fetched', async () => {
+ let resolveFetch!: (v: { printable: LabelData[]; skipped: SkipRecord[] }) => void;
+ mockFetchAddressLabels.mockReturnValue(
+ new Promise((resolve) => {
+ resolveFetch = resolve;
+ })
+ );
+
+ render( );
+ expect(screen.getByText('Loading address data...')).toBeInTheDocument();
+
+ await waitFor(() => resolveFetch({ printable: [], skipped: [] }));
+ await waitFor(() => expect(screen.queryByText('Loading address data...')).not.toBeInTheDocument());
+ });
+
+ it('loads and displays the labels tab by default', async () => {
+ render( );
+ await waitFor(() => expect(screen.getByTestId('labels-summary')).toBeInTheDocument());
+ expect(screen.getByText('1 printable / 0 skipped')).toBeInTheDocument();
+ expect(screen.queryByTestId('mail-merge-tab')).not.toBeInTheDocument();
+ });
+
+ it('switches to the Mail Merge tab and back', async () => {
+ render( );
+ await waitFor(() => expect(screen.getByTestId('labels-summary')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: 'Mail Merge' }));
+ expect(screen.getByTestId('mail-merge-tab')).toBeInTheDocument();
+ expect(screen.getByText('merge-tab:1')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Labels' }));
+ expect(screen.getByTestId('labels-summary')).toBeInTheDocument();
+ });
+
+ it('shows an error message when fetchAddressLabels rejects with an Error', async () => {
+ mockFetchAddressLabels.mockRejectedValueOnce(new Error('MP is down'));
+ render( );
+ expect(await screen.findByText('MP is down')).toBeInTheDocument();
+ });
+
+ it('shows a fallback error message when fetchAddressLabels rejects with a non-Error', async () => {
+ mockFetchAddressLabels.mockRejectedValueOnce('nope');
+ render( );
+ expect(await screen.findByText('Failed to load address data')).toBeInTheDocument();
+ });
+
+ it('loads a saved config from localStorage and uses it as the initial state', async () => {
+ localStorage.setItem(
+ 'address-labels-config',
+ JSON.stringify({ stockId: '5161', mailerId: '999999999' })
+ );
+ render( );
+ await waitFor(() => expect(screen.getByTestId('config-json')).toBeInTheDocument());
+ const config = JSON.parse(screen.getByTestId('config-json').textContent ?? '{}');
+ expect(config.stockId).toBe('5161');
+ expect(config.mailerId).toBe('999999999');
+ });
+
+ it('falls back to defaults when localStorage holds invalid JSON', async () => {
+ localStorage.setItem('address-labels-config', 'not-json{{');
+ render( );
+ await waitFor(() => expect(screen.getByTestId('config-json')).toBeInTheDocument());
+ const config = JSON.parse(screen.getByTestId('config-json').textContent ?? '{}');
+ expect(config.stockId).toBe('5160');
+ });
+
+ it('persists config changes to localStorage and re-derives maxStartPosition', async () => {
+ render( );
+ await waitFor(() => expect(screen.getByTestId('labels-form')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: 'change-stock' }));
+
+ await waitFor(() => {
+ const config = JSON.parse(screen.getByTestId('config-json').textContent ?? '{}');
+ expect(config.stockId).toBe('5161');
+ });
+ expect(screen.getByTestId('max-start').textContent).toBe('20'); // 5161: 2 cols x 10 rows
+
+ const stored = JSON.parse(localStorage.getItem('address-labels-config') ?? '{}');
+ expect(stored.stockId).toBe('5161');
+ });
+
+ it('generates a PDF, opens it in a new tab, and revokes the object URL after the timeout', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ mockGenerateLabelPdf.mockResolvedValue({ success: true, data: arrayBufferToBase64('pdf-bytes') });
+ render( );
+ await waitFor(() => expect(screen.getByRole('button', { name: /generate pdf/i })).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: /generate pdf/i }));
+
+ await waitFor(() => expect(mockGenerateLabelPdf).toHaveBeenCalled());
+ await waitFor(() => expect(window.open).toHaveBeenCalledWith('blob:mock-url', '_blank'));
+
+ vi.advanceTimersByTime(1000);
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
+ vi.useRealTimers();
+ });
+
+ it('shows an error when PDF generation returns success: false', async () => {
+ mockGenerateLabelPdf.mockResolvedValue({ success: false, error: 'stock mismatch' });
+ render( );
+ await waitFor(() => expect(screen.getByRole('button', { name: /generate pdf/i })).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: /generate pdf/i }));
+
+ expect(await screen.findByText('stock mismatch')).toBeInTheDocument();
+ });
+
+ it('shows an error when generateLabelPdf rejects with an Error', async () => {
+ mockGenerateLabelPdf.mockRejectedValue(new Error('pdf explode'));
+ render( );
+ await waitFor(() => expect(screen.getByRole('button', { name: /generate pdf/i })).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: /generate pdf/i }));
+
+ expect(await screen.findByText('pdf explode')).toBeInTheDocument();
+ });
+
+ it('shows a fallback error when generateLabelPdf rejects with a non-Error', async () => {
+ mockGenerateLabelPdf.mockRejectedValue('nope');
+ render( );
+ await waitFor(() => expect(screen.getByRole('button', { name: /generate pdf/i })).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: /generate pdf/i }));
+
+ expect(await screen.findByText('PDF generation failed')).toBeInTheDocument();
+ });
+
+ it('blocks PDF generation when IMb mailerId is invalid and never calls the server action', async () => {
+ render( );
+ await waitFor(() => expect(screen.getByTestId('labels-form')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: 'set-invalid-imb' }));
+ fireEvent.click(screen.getByRole('button', { name: /generate pdf/i }));
+
+ expect(await screen.findByText('IMb requires a 6 or 9 digit USPS Mailer ID')).toBeInTheDocument();
+ expect(mockGenerateLabelPdf).not.toHaveBeenCalled();
+ });
+
+ it('allows PDF generation when IMb mailerId is a valid 9-digit value', async () => {
+ mockGenerateLabelPdf.mockResolvedValue({ success: true, data: arrayBufferToBase64('pdf-bytes') });
+ render( );
+ await waitFor(() => expect(screen.getByTestId('labels-form')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: 'set-valid-imb' }));
+ fireEvent.click(screen.getByRole('button', { name: /generate pdf/i }));
+
+ await waitFor(() => expect(mockGenerateLabelPdf).toHaveBeenCalled());
+ });
+
+ it('does nothing when Generate PDF is clicked with no printable labels', async () => {
+ mockFetchAddressLabels.mockResolvedValue({ printable: [], skipped: [] });
+ render( );
+ await waitFor(() => expect(screen.getByTestId('labels-summary')).toBeInTheDocument());
+
+ const btn = screen.getByRole('button', { name: /generate pdf/i });
+ expect(btn).toBeDisabled();
+ });
+
+ it('downloads a Word document and revokes the object URL after the timeout', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ mockGenerateLabelDocx.mockResolvedValue({ success: true, data: arrayBufferToBase64('docx-bytes') });
+ render( );
+ await waitFor(() => expect(screen.getByRole('button', { name: /download word/i })).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: /download word/i }));
+
+ await waitFor(() => expect(mockGenerateLabelDocx).toHaveBeenCalled());
+ await waitFor(() => expect(HTMLAnchorElement.prototype.click).toHaveBeenCalled());
+
+ vi.advanceTimersByTime(1000);
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
+ vi.useRealTimers();
+ });
+
+ it('shows an error when Word generation returns success: false', async () => {
+ mockGenerateLabelDocx.mockResolvedValue({ success: false, error: 'docx failure' });
+ render( );
+ await waitFor(() => expect(screen.getByRole('button', { name: /download word/i })).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: /download word/i }));
+
+ expect(await screen.findByText('docx failure')).toBeInTheDocument();
+ });
+
+ it('shows an error when generateLabelDocx rejects with an Error', async () => {
+ mockGenerateLabelDocx.mockRejectedValue(new Error('word explode'));
+ render( );
+ await waitFor(() => expect(screen.getByRole('button', { name: /download word/i })).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: /download word/i }));
+
+ expect(await screen.findByText('word explode')).toBeInTheDocument();
+ });
+
+ it('shows a fallback error when generateLabelDocx rejects with a non-Error', async () => {
+ mockGenerateLabelDocx.mockRejectedValue('nope');
+ render( );
+ await waitFor(() => expect(screen.getByRole('button', { name: /download word/i })).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: /download word/i }));
+
+ expect(await screen.findByText('Word generation failed')).toBeInTheDocument();
+ });
+
+ it('blocks Word download when IMb mailerId is invalid', async () => {
+ render( );
+ await waitFor(() => expect(screen.getByTestId('labels-form')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: 'set-invalid-imb' }));
+ fireEvent.click(screen.getByRole('button', { name: /download word/i }));
+
+ expect(await screen.findByText('IMb requires a 6 or 9 digit USPS Mailer ID')).toBeInTheDocument();
+ expect(mockGenerateLabelDocx).not.toHaveBeenCalled();
+ });
+
+ it('does nothing when Download Word is clicked with no printable labels', async () => {
+ mockFetchAddressLabels.mockResolvedValue({ printable: [], skipped: [] });
+ render( );
+ await waitFor(() => expect(screen.getByTestId('labels-summary')).toBeInTheDocument());
+
+ expect(screen.getByRole('button', { name: /download word/i })).toBeDisabled();
+ });
+
+ it('calls router.back on Close', async () => {
+ render( );
+ await waitFor(() => expect(screen.getByTestId('labels-summary')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }));
+ expect(mockRouterBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('re-fetches when addressMode or includeMissingBarcodes changes, but not for layout-only changes', async () => {
+ render( );
+ await waitFor(() => expect(screen.getByTestId('labels-form')).toBeInTheDocument());
+ expect(mockFetchAddressLabels).toHaveBeenCalledTimes(1);
+
+ // Layout-only change (stockId) must not trigger a re-fetch.
+ fireEvent.click(screen.getByRole('button', { name: 'change-stock' }));
+ await waitFor(() => {
+ const config = JSON.parse(screen.getByTestId('config-json').textContent ?? '{}');
+ expect(config.stockId).toBe('5161');
+ });
+ expect(mockFetchAddressLabels).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/app/(web)/tools/addresslabels/page.test.tsx b/src/app/(web)/tools/addresslabels/page.test.tsx
new file mode 100644
index 0000000..4859cae
--- /dev/null
+++ b/src/app/(web)/tools/addresslabels/page.test.tsx
@@ -0,0 +1,36 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, cleanup, screen } from '@testing-library/react';
+import type { ToolParams } from '@/lib/tool-params';
+
+const mockParseToolParams = vi.hoisted(() => vi.fn());
+
+vi.mock('@/lib/tool-params.server', () => ({
+ parseToolParams: mockParseToolParams,
+}));
+
+vi.mock('./address-labels', () => ({
+ AddressLabels: ({ params }: { params: ToolParams }) => (
+ {JSON.stringify(params)}
+ ),
+}));
+
+import AddressLabelsPage from './page';
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+describe('AddressLabelsPage', () => {
+ it('parses search params and renders AddressLabels with the result', async () => {
+ const parsed: ToolParams = { recordID: 42 };
+ mockParseToolParams.mockResolvedValue(parsed);
+
+ const searchParams = Promise.resolve({ recordID: '42' });
+ const ui = await AddressLabelsPage({ searchParams });
+ render(ui);
+
+ expect(mockParseToolParams).toHaveBeenCalledWith({ recordID: '42' });
+ expect(screen.getByTestId('address-labels').textContent).toBe(JSON.stringify(parsed));
+ });
+});
diff --git a/src/app/(web)/tools/fieldmanagement/field-management.test.tsx b/src/app/(web)/tools/fieldmanagement/field-management.test.tsx
new file mode 100644
index 0000000..d038be0
--- /dev/null
+++ b/src/app/(web)/tools/fieldmanagement/field-management.test.tsx
@@ -0,0 +1,314 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup, act, fireEvent, waitFor } from '@testing-library/react';
+import { forwardRef, useImperativeHandle } from 'react';
+import type { ToolParams } from '@/lib/tool-params';
+import type { FieldOrderEditorHandle, PageListItem } from '@/components/field-management';
+
+const {
+ mockRouterBack,
+ mockFetchPageFieldData,
+ mockSavePageFieldOrder,
+ mockToastSuccess,
+ mockToastError,
+ mockGetSavePayload,
+ mockMoveHiddenToOther,
+ mockHideAllSeparators,
+ mockOnSelectHolder,
+} = vi.hoisted(() => ({
+ mockRouterBack: vi.fn(),
+ mockFetchPageFieldData: vi.fn(),
+ mockSavePageFieldOrder: vi.fn(),
+ mockToastSuccess: vi.fn(),
+ mockToastError: vi.fn(),
+ mockGetSavePayload: vi.fn(),
+ mockMoveHiddenToOther: vi.fn(),
+ mockHideAllSeparators: vi.fn(),
+ mockOnSelectHolder: { current: undefined as ((page: PageListItem) => void) | undefined },
+}));
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ back: mockRouterBack }),
+}));
+
+vi.mock('@/components/field-management/actions', () => ({
+ fetchPageFieldData: mockFetchPageFieldData,
+ savePageFieldOrder: mockSavePageFieldOrder,
+}));
+
+vi.mock('sonner', () => ({
+ toast: { success: mockToastSuccess, error: mockToastError },
+}));
+
+vi.mock('@/components/tool', () => ({
+ ToolContainer: (props: Record) => (
+
+
{props.footerExtra as React.ReactNode}
+ {!props.hideFooter && (
+
void}>
+ {props.saveLabel as string}
+
+ )}
+
void}>
+ Close
+
+ {props.children as React.ReactNode}
+
+ ),
+}));
+
+vi.mock('@/components/field-management', () => ({
+ PageSearch: ({ onSelect }: { onSelect: (page: PageListItem) => void }) => {
+ mockOnSelectHolder.current = onSelect;
+ return
;
+ },
+}));
+
+vi.mock('@/components/field-management/field-order-editor', () => ({
+ FieldOrderEditor: forwardRef void }>(
+ function MockFieldOrderEditor({ onDirtyChange }, ref) {
+ useImperativeHandle(ref, () => ({
+ getSavePayload: mockGetSavePayload,
+ moveHiddenToOther: mockMoveHiddenToOther,
+ hideAllSeparators: mockHideAllSeparators,
+ }));
+ return (
+
+ onDirtyChange(true)}>
+ mark dirty
+
+
+ );
+ }
+ ),
+}));
+
+import { FieldManagement } from './field-management';
+
+const params: ToolParams = { pageID: 292 };
+const page: PageListItem = { Page_ID: 292, Display_Name: 'Contacts', Table_Name: 'Contacts' };
+
+async function selectPage() {
+ render( );
+ await act(async () => {
+ mockOnSelectHolder.current!(page);
+ });
+}
+
+describe('FieldManagement', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetSavePayload.mockReturnValue([]);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('renders the page search in step 1', () => {
+ render( );
+ expect(screen.getByTestId('page-search')).toBeInTheDocument();
+ });
+
+ it('loads field data and renders the editor after selecting a page', async () => {
+ mockFetchPageFieldData.mockResolvedValueOnce({
+ fields: [{ Page_Field_ID: 1 }],
+ tableMetadata: null,
+ });
+
+ await selectPage();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('field-order-editor')).toBeInTheDocument();
+ });
+ expect(mockFetchPageFieldData).toHaveBeenCalledWith(292, 'Contacts');
+ expect(screen.getAllByText('Contacts').length).toBeGreaterThan(0);
+ });
+
+ it('shows a loading indicator while field data is being fetched', async () => {
+ let resolveFetch: (value: { fields: unknown[]; tableMetadata: null }) => void = () => {};
+ mockFetchPageFieldData.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveFetch = resolve;
+ })
+ );
+
+ render( );
+ await act(async () => {
+ mockOnSelectHolder.current!(page);
+ });
+
+ expect(screen.getByText('Loading fields...')).toBeInTheDocument();
+
+ await act(async () => {
+ resolveFetch({ fields: [], tableMetadata: null });
+ });
+
+ expect(screen.queryByText('Loading fields...')).not.toBeInTheDocument();
+ });
+
+ it('shows "No fields found" when fields array is empty', async () => {
+ mockFetchPageFieldData.mockResolvedValueOnce({ fields: [], tableMetadata: null });
+
+ await selectPage();
+
+ await waitFor(() => {
+ expect(screen.getByText('No fields found for this page.')).toBeInTheDocument();
+ });
+ });
+
+ it('shows a failure message when fetchPageFieldData rejects', async () => {
+ mockFetchPageFieldData.mockRejectedValueOnce(new Error('boom'));
+
+ await selectPage();
+
+ await waitFor(() => {
+ expect(screen.getByText('Failed to load field data.')).toBeInTheDocument();
+ });
+ });
+
+ it('shows column count when tableMetadata has Columns', async () => {
+ mockFetchPageFieldData.mockResolvedValueOnce({
+ fields: [{ Page_Field_ID: 1 }],
+ tableMetadata: { Table_Name: 'Contacts', Columns: [{ Name: 'A' }, { Name: 'B' }] },
+ });
+
+ await selectPage();
+
+ await waitFor(() => {
+ expect(screen.getByText('2')).toBeInTheDocument();
+ });
+ });
+
+ it('returns to step 1 and clears field data when Back is clicked', async () => {
+ mockFetchPageFieldData.mockResolvedValueOnce({
+ fields: [{ Page_Field_ID: 1 }],
+ tableMetadata: null,
+ });
+
+ await selectPage();
+ await waitFor(() => expect(screen.getByTestId('field-order-editor')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByText('Back'));
+
+ expect(screen.getByTestId('page-search')).toBeInTheDocument();
+ });
+
+ it('calls router.back() when close is triggered', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('close-button'));
+ expect(mockRouterBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows the "Unsaved changes" badge once the editor reports dirty', async () => {
+ mockFetchPageFieldData.mockResolvedValueOnce({
+ fields: [{ Page_Field_ID: 1 }],
+ tableMetadata: null,
+ });
+
+ await selectPage();
+ await waitFor(() => expect(screen.getByTestId('field-order-editor')).toBeInTheDocument());
+
+ expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByTestId('mark-dirty'));
+
+ expect(screen.getByText('Unsaved changes')).toBeInTheDocument();
+ });
+
+ it('saves successfully, toasts success, refetches, and clears dirty', async () => {
+ mockFetchPageFieldData.mockResolvedValueOnce({
+ fields: [{ Page_Field_ID: 1 }],
+ tableMetadata: null,
+ });
+ await selectPage();
+ await waitFor(() => expect(screen.getByTestId('field-order-editor')).toBeInTheDocument());
+ fireEvent.click(screen.getByTestId('mark-dirty'));
+ expect(screen.getByText('Unsaved changes')).toBeInTheDocument();
+
+ mockSavePageFieldOrder.mockResolvedValueOnce({ success: true });
+ mockFetchPageFieldData.mockResolvedValueOnce({
+ fields: [{ Page_Field_ID: 1 }],
+ tableMetadata: null,
+ });
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('save-button'));
+ });
+
+ expect(mockToastSuccess).toHaveBeenCalledWith('Field order saved successfully');
+ expect(mockFetchPageFieldData).toHaveBeenCalledTimes(2);
+ expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument();
+ });
+
+ it('toasts the service error message when save reports failure', async () => {
+ mockFetchPageFieldData.mockResolvedValueOnce({
+ fields: [{ Page_Field_ID: 1 }],
+ tableMetadata: null,
+ });
+ await selectPage();
+ await waitFor(() => expect(screen.getByTestId('field-order-editor')).toBeInTheDocument());
+
+ mockSavePageFieldOrder.mockResolvedValueOnce({ success: false, error: 'nope' });
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('save-button'));
+ });
+
+ expect(mockToastError).toHaveBeenCalledWith('nope');
+ });
+
+ it('toasts a generic failure message when the result has no error string', async () => {
+ mockFetchPageFieldData.mockResolvedValueOnce({
+ fields: [{ Page_Field_ID: 1 }],
+ tableMetadata: null,
+ });
+ await selectPage();
+ await waitFor(() => expect(screen.getByTestId('field-order-editor')).toBeInTheDocument());
+
+ mockSavePageFieldOrder.mockResolvedValueOnce({ success: false });
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('save-button'));
+ });
+
+ expect(mockToastError).toHaveBeenCalledWith('Failed to save field order');
+ });
+
+ it('toasts an unexpected-error message when savePageFieldOrder throws', async () => {
+ mockFetchPageFieldData.mockResolvedValueOnce({
+ fields: [{ Page_Field_ID: 1 }],
+ tableMetadata: null,
+ });
+ await selectPage();
+ await waitFor(() => expect(screen.getByTestId('field-order-editor')).toBeInTheDocument());
+
+ mockSavePageFieldOrder.mockRejectedValueOnce(new Error('down'));
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('save-button'));
+ });
+
+ expect(mockToastError).toHaveBeenCalledWith('An unexpected error occurred while saving');
+ });
+
+ it('does not save when there is no selected page (footer hidden in step 1)', () => {
+ render( );
+ expect(screen.queryByTestId('save-button')).not.toBeInTheDocument();
+ });
+
+ it('wires Hide All Separators and Move Hidden to Other buttons to the editor handle', async () => {
+ mockFetchPageFieldData.mockResolvedValueOnce({
+ fields: [{ Page_Field_ID: 1 }],
+ tableMetadata: null,
+ });
+ await selectPage();
+ await waitFor(() => expect(screen.getByTestId('field-order-editor')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByText('Hide All Separators'));
+ expect(mockHideAllSeparators).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(screen.getByText('Move Hidden to Other'));
+ expect(mockMoveHiddenToOther).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/app/(web)/tools/fieldmanagement/page.test.tsx b/src/app/(web)/tools/fieldmanagement/page.test.tsx
new file mode 100644
index 0000000..171142f
--- /dev/null
+++ b/src/app/(web)/tools/fieldmanagement/page.test.tsx
@@ -0,0 +1,47 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+const { mockParseToolParams } = vi.hoisted(() => ({
+ mockParseToolParams: vi.fn(),
+}));
+
+vi.mock('@/lib/tool-params.server', () => ({
+ parseToolParams: mockParseToolParams,
+}));
+
+vi.mock('./field-management', () => ({
+ FieldManagement: () => null,
+}));
+
+import FieldManagementPage from './page';
+
+describe('FieldManagementPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('awaits searchParams before parsing them', async () => {
+ const raw = { pageID: '292' };
+ mockParseToolParams.mockResolvedValueOnce({ pageID: 292 });
+
+ await FieldManagementPage({ searchParams: Promise.resolve(raw) });
+
+ expect(mockParseToolParams).toHaveBeenCalledExactlyOnceWith(raw);
+ });
+
+ it('passes the parsed params through to FieldManagement', async () => {
+ const parsed = { pageID: 292 };
+ mockParseToolParams.mockResolvedValueOnce(parsed);
+
+ const element = await FieldManagementPage({ searchParams: Promise.resolve({}) });
+
+ expect(element.props.params).toBe(parsed);
+ });
+
+ it('propagates a parse failure rather than rendering with bad params', async () => {
+ mockParseToolParams.mockRejectedValueOnce(new Error('bad params'));
+
+ await expect(
+ FieldManagementPage({ searchParams: Promise.resolve({}) }),
+ ).rejects.toThrow('bad params');
+ });
+});
diff --git a/src/app/(web)/tools/groupwizard/group-wizard.test.tsx b/src/app/(web)/tools/groupwizard/group-wizard.test.tsx
index af6d716..fd7927e 100644
--- a/src/app/(web)/tools/groupwizard/group-wizard.test.tsx
+++ b/src/app/(web)/tools/groupwizard/group-wizard.test.tsx
@@ -86,9 +86,13 @@ vi.mock('@/components/group-wizard', async () => {
StepOrganization: ({
contactDisplayMap,
groupDisplayMap,
+ onContactSelect,
+ onGroupSelect,
}: {
contactDisplayMap: Map;
groupDisplayMap: Map;
+ onContactSelect: (id: number, name: string) => void;
+ onGroupSelect: (field: string, id: number | null, name: string) => void;
}) => (
{contactDisplayMap.size}
@@ -103,6 +107,20 @@ vi.mock('@/components/group-wizard', async () => {
{name}
))}
+ onContactSelect(99, 'Selected Contact')}
+ >
+ Select Contact
+
+ onGroupSelect('Parent_Group', 55, 'Selected Group')}
+ >
+ Select Group
+
),
StepMeeting: () =>
,
@@ -442,4 +460,209 @@ describe('GroupWizard shell', () => {
// Back button is hidden on step 0
expect(screen.queryByRole('button', { name: /back/i })).not.toBeInTheDocument();
});
+
+ it('shows the thrown-Error message when fetchGroupWizardLookups rejects', async () => {
+ mockFetchGroupWizardLookups.mockRejectedValueOnce(new Error('Lookup service down'));
+ const params: ToolParams = { recordID: -1 };
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText('Lookup service down')).toBeInTheDocument();
+ });
+ });
+
+ it('falls back to a generic message when fetchGroupWizardLookups rejects with a non-Error', async () => {
+ mockFetchGroupWizardLookups.mockRejectedValueOnce('boom');
+ const params: ToolParams = { recordID: -1 };
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText('Failed to load lookup data')).toBeInTheDocument();
+ });
+ });
+
+ it('sets loadError and stops the record-loading spinner when fetchGroupRecord rejects (network failure)', async () => {
+ mockFetchGroupRecord.mockRejectedValueOnce(new Error('Network down'));
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const params: ToolParams = { recordID: 100 };
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText('Network down')).toBeInTheDocument();
+ });
+ expect(consoleErrorSpy).toHaveBeenCalled();
+ });
+
+ it('shows the ActionError message and stays on the review step when createGroup fails', async () => {
+ mockCreateGroup.mockResolvedValueOnce({ success: false, error: 'Duplicate group name' });
+
+ const params: ToolParams = { recordID: -1 };
+ render( );
+ await waitFor(() => expect(screen.getByTestId('step-identity')).toBeInTheDocument());
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('fill-valid'));
+ });
+ for (let i = 0; i < 5; i++) {
+ const nextBtn = screen.getByRole('button', { name: /next/i });
+ await act(async () => {
+ fireEvent.click(nextBtn);
+ });
+ }
+ await waitFor(() => expect(screen.getByTestId('step-review')).toBeInTheDocument());
+
+ const createBtn = screen.getByRole('button', { name: /create group/i });
+ await act(async () => {
+ fireEvent.click(createBtn);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('Duplicate group name')).toBeInTheDocument();
+ });
+ expect(screen.queryByTestId('step-review-success')).not.toBeInTheDocument();
+ });
+
+ it('shows the ActionError message when updateGroup fails in edit mode', async () => {
+ mockFetchGroupRecord.mockResolvedValueOnce({
+ success: true,
+ data: BASE_FORM,
+ displayNames: { contacts: {}, groups: {} },
+ });
+ mockUpdateGroup.mockResolvedValueOnce({ success: false, error: 'Stale record' });
+
+ const params: ToolParams = { recordID: 100 };
+ render( );
+ await waitFor(() => expect(screen.getByTestId('step-identity')).toBeInTheDocument());
+
+ for (let i = 0; i < 5; i++) {
+ const nextBtn = screen.getByRole('button', { name: /next/i });
+ await act(async () => {
+ fireEvent.click(nextBtn);
+ });
+ }
+ await waitFor(() => expect(screen.getByTestId('step-review')).toBeInTheDocument());
+
+ const saveBtn = screen.getByRole('button', { name: /save changes/i });
+ await act(async () => {
+ fireEvent.click(saveBtn);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('Stale record')).toBeInTheDocument();
+ });
+ expect(screen.queryByTestId('step-review-success')).not.toBeInTheDocument();
+ });
+
+ it('handleContactSelect seeds the contact display map from Step Organization', async () => {
+ const params: ToolParams = { recordID: -1 };
+ render( );
+ await waitFor(() => expect(screen.getByTestId('step-identity')).toBeInTheDocument());
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /next/i }));
+ });
+ // Step 0 validation fails on empty fields, so use fill-valid + Next again
+ // is unnecessary here — Step Organization is only reachable once Step 1
+ // validates. Fill valid first, then advance.
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('fill-valid'));
+ });
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /next/i }));
+ });
+ await waitFor(() => expect(screen.getByTestId('step-organization')).toBeInTheDocument());
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('select-contact'));
+ });
+
+ expect(screen.getByTestId('contact-map-size').textContent).toBe('1');
+ expect(screen.getByTestId('contact-name-99').textContent).toBe('Selected Contact');
+ });
+
+ it('handleGroupSelect seeds the group display map when an id is selected', async () => {
+ const params: ToolParams = { recordID: -1 };
+ render( );
+ await waitFor(() => expect(screen.getByTestId('step-identity')).toBeInTheDocument());
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('fill-valid'));
+ });
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /next/i }));
+ });
+ await waitFor(() => expect(screen.getByTestId('step-organization')).toBeInTheDocument());
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('select-group'));
+ });
+
+ expect(screen.getByTestId('group-map-size').textContent).toBe('1');
+ expect(screen.getByTestId('group-name-55').textContent).toBe('Selected Group');
+ });
+
+ it('handleBack returns to the previous step', async () => {
+ const params: ToolParams = { recordID: -1 };
+ render( );
+ await waitFor(() => expect(screen.getByTestId('step-identity')).toBeInTheDocument());
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('fill-valid'));
+ });
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /next/i }));
+ });
+ await waitFor(() => expect(screen.getByTestId('step-organization')).toBeInTheDocument());
+
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /back/i }));
+ });
+
+ await waitFor(() => expect(screen.getByTestId('step-identity')).toBeInTheDocument());
+ });
+
+ it('clicking a completed step in the real WizardStepper jumps back to it (handleStepClick)', async () => {
+ const params: ToolParams = { recordID: -1 };
+ render( );
+ await waitFor(() => expect(screen.getByTestId('step-identity')).toBeInTheDocument());
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('fill-valid'));
+ });
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /next/i }));
+ });
+ await waitFor(() => expect(screen.getByTestId('step-organization')).toBeInTheDocument());
+
+ // Step 0 ("Identity") is now completed, so its real WizardStepper button
+ // is clickable — jump back to it directly (not via the Back button).
+ const identityStepBtn = screen
+ .getAllByRole('button')
+ .find((b) => b.textContent?.includes('Identity') && !b.textContent?.includes('Next'));
+ expect(identityStepBtn).toBeDefined();
+ await act(async () => {
+ fireEvent.click(identityStepBtn!);
+ });
+
+ await waitFor(() => expect(screen.getByTestId('step-identity')).toBeInTheDocument());
+ });
+
+ it('the form element swallows native submit via preventDefault without navigating away', async () => {
+ const params: ToolParams = { recordID: -1 };
+ const { container } = render( );
+ await waitFor(() => expect(screen.getByTestId('step-identity')).toBeInTheDocument());
+
+ const formEl = container.querySelector('form');
+ expect(formEl).toBeTruthy();
+ await act(async () => {
+ fireEvent.submit(formEl!);
+ });
+
+ // Still on step 0 — no crash, no create/update call triggered by the
+ // native submit event.
+ expect(screen.getByTestId('step-identity')).toBeInTheDocument();
+ expect(mockCreateGroup).not.toHaveBeenCalled();
+ expect(mockUpdateGroup).not.toHaveBeenCalled();
+ });
});
diff --git a/src/app/(web)/tools/groupwizard/page.test.tsx b/src/app/(web)/tools/groupwizard/page.test.tsx
new file mode 100644
index 0000000..5d33161
--- /dev/null
+++ b/src/app/(web)/tools/groupwizard/page.test.tsx
@@ -0,0 +1,61 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+import type { ToolParams } from '@/lib/tool-params';
+
+const { mockParseToolParams, mockGetMpTimezone } = vi.hoisted(() => ({
+ mockParseToolParams: vi.fn(),
+ mockGetMpTimezone: vi.fn(),
+}));
+
+vi.mock('@/lib/tool-params.server', () => ({
+ parseToolParams: mockParseToolParams,
+}));
+
+vi.mock('@/components/shared-actions/domain', () => ({
+ getMpTimezone: mockGetMpTimezone,
+}));
+
+vi.mock('./group-wizard', () => ({
+ GroupWizard: ({ params, mpTimezone }: { params: ToolParams; mpTimezone: string }) => (
+
+ {JSON.stringify(params)}
+ {mpTimezone}
+
+ ),
+}));
+
+import GroupWizardPage from './page';
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+describe('GroupWizardPage', () => {
+ it('parses search params and MP timezone, forwarding both to GroupWizard', async () => {
+ const parsedParams: ToolParams = { recordID: 5 };
+ mockParseToolParams.mockResolvedValueOnce(parsedParams);
+ mockGetMpTimezone.mockResolvedValueOnce('America/Chicago');
+
+ const jsx = await GroupWizardPage({
+ searchParams: Promise.resolve({ recordID: '5' }),
+ });
+ render(jsx);
+
+ expect(screen.getByTestId('group-wizard-stub')).toBeInTheDocument();
+ expect(screen.getByTestId('params').textContent).toBe(JSON.stringify(parsedParams));
+ expect(screen.getByTestId('mp-timezone').textContent).toBe('America/Chicago');
+ expect(mockParseToolParams).toHaveBeenCalledWith({ recordID: '5' });
+ expect(mockGetMpTimezone).toHaveBeenCalledTimes(1);
+ });
+
+ it('awaits the searchParams promise before parsing', async () => {
+ mockParseToolParams.mockResolvedValueOnce({ recordID: -1 });
+ mockGetMpTimezone.mockResolvedValueOnce('Etc/UTC');
+
+ const rawParams = { pageID: '292' };
+ await GroupWizardPage({ searchParams: Promise.resolve(rawParams) });
+
+ expect(mockParseToolParams).toHaveBeenCalledWith(rawParams);
+ });
+});
diff --git a/src/app/(web)/tools/layout.test.tsx b/src/app/(web)/tools/layout.test.tsx
new file mode 100644
index 0000000..f606db1
--- /dev/null
+++ b/src/app/(web)/tools/layout.test.tsx
@@ -0,0 +1,94 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { isValidElement } from 'react';
+
+/**
+ * ToolsLayout Tests
+ *
+ * This layout is the page-level authorization gate for every route under
+ * /tools. React renders a layout before its children, so a redirect() here
+ * means no tool page component ever runs.
+ *
+ * It is the UX layer, not the security control — enforcement lives in the
+ * server actions and service methods. These tests pin both halves of that
+ * contract: that a permitted user gets their children rendered, and that a
+ * non-permitted user is redirected to /no-access before children are touched.
+ */
+
+const { mockHasSecurityRole, mockRedirect } = vi.hoisted(() => ({
+ mockHasSecurityRole: vi.fn(),
+ mockRedirect: vi.fn((url: string) => {
+ // redirect() throws in real Next.js to abort rendering; mirror that so
+ // code after it doesn't execute in tests.
+ throw new Error(`NEXT_REDIRECT:${url}`);
+ }),
+}));
+
+vi.mock('next/navigation', () => ({
+ redirect: mockRedirect,
+}));
+
+vi.mock('@/services/authorizationService', () => ({
+ AuthorizationService: {
+ getInstance: vi.fn(() => ({
+ hasSecurityRole: mockHasSecurityRole,
+ })),
+ },
+}));
+
+import ToolsLayout from './layout';
+
+describe('ToolsLayout authorization gate', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders children when the user holds a security role', async () => {
+ mockHasSecurityRole.mockResolvedValueOnce(true);
+
+ const result = await ToolsLayout({ children: 'tool-content' });
+
+ expect(mockRedirect).not.toHaveBeenCalled();
+ expect(isValidElement(result)).toBe(true);
+ expect(result.props.children).toBe('tool-content');
+ });
+
+ it('redirects to /no-access when the user holds no security role', async () => {
+ mockHasSecurityRole.mockResolvedValueOnce(false);
+
+ await expect(
+ ToolsLayout({ children: 'tool-content' }),
+ ).rejects.toThrow('NEXT_REDIRECT:/no-access');
+
+ expect(mockRedirect).toHaveBeenCalledExactlyOnceWith('/no-access');
+ });
+
+ it('consults the authorization service exactly once per render', async () => {
+ mockHasSecurityRole.mockResolvedValueOnce(true);
+
+ await ToolsLayout({ children: 'tool-content' });
+
+ expect(mockHasSecurityRole).toHaveBeenCalledTimes(1);
+ });
+
+ it('propagates an infrastructure failure rather than rendering the tool', async () => {
+ // hasSecurityRole() is documented to fail closed and return false, but if
+ // it ever throws, the gate must not fall through to rendering children.
+ mockHasSecurityRole.mockRejectedValueOnce(new Error('MP unreachable'));
+
+ await expect(
+ ToolsLayout({ children: 'tool-content' }),
+ ).rejects.toThrow('MP unreachable');
+
+ expect(mockRedirect).not.toHaveBeenCalled();
+ });
+
+ it('does not evaluate children before the gate decides', async () => {
+ // Children arrive as an already-constructed element tree, so the gate
+ // cannot "skip" work — but it must not pass them through on denial.
+ mockHasSecurityRole.mockResolvedValueOnce(false);
+
+ await expect(
+ ToolsLayout({ children: 'tool-content' }),
+ ).rejects.toThrow(/NEXT_REDIRECT/);
+ });
+});
diff --git a/src/app/(web)/tools/template/page.test.tsx b/src/app/(web)/tools/template/page.test.tsx
new file mode 100644
index 0000000..4645172
--- /dev/null
+++ b/src/app/(web)/tools/template/page.test.tsx
@@ -0,0 +1,47 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+const { mockParseToolParams } = vi.hoisted(() => ({
+ mockParseToolParams: vi.fn(),
+}));
+
+vi.mock('@/lib/tool-params.server', () => ({
+ parseToolParams: mockParseToolParams,
+}));
+
+vi.mock('./template-tool', () => ({
+ TemplateTool: () => null,
+}));
+
+import TemplateToolPage from './page';
+
+describe('TemplateToolPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('awaits searchParams before parsing them', async () => {
+ const raw = { pageID: '312', recordID: '7' };
+ mockParseToolParams.mockResolvedValueOnce({ pageID: 312, recordID: 7 });
+
+ await TemplateToolPage({ searchParams: Promise.resolve(raw) });
+
+ expect(mockParseToolParams).toHaveBeenCalledExactlyOnceWith(raw);
+ });
+
+ it('passes the parsed params through to TemplateTool', async () => {
+ const parsed = { pageID: 312, recordID: 7 };
+ mockParseToolParams.mockResolvedValueOnce(parsed);
+
+ const element = await TemplateToolPage({ searchParams: Promise.resolve({}) });
+
+ expect(element.props.params).toBe(parsed);
+ });
+
+ it('propagates a parse failure rather than rendering with bad params', async () => {
+ mockParseToolParams.mockRejectedValueOnce(new Error('bad params'));
+
+ await expect(
+ TemplateToolPage({ searchParams: Promise.resolve({}) }),
+ ).rejects.toThrow('bad params');
+ });
+});
diff --git a/src/app/(web)/tools/template/template-tool.test.tsx b/src/app/(web)/tools/template/template-tool.test.tsx
new file mode 100644
index 0000000..6f6e637
--- /dev/null
+++ b/src/app/(web)/tools/template/template-tool.test.tsx
@@ -0,0 +1,139 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup, act, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+const { mockBack, mockToolContainer } = vi.hoisted(() => ({
+ mockBack: vi.fn(),
+ mockToolContainer: vi.fn(),
+}));
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ back: mockBack }),
+}));
+
+vi.mock('@/components/tool', () => ({
+ ToolContainer: (props: Record) => {
+ mockToolContainer(props);
+ return (
+
+
{props.infoContent as React.ReactNode}
+
void}>save
+
void}>close
+
{String(props.isSaving)}
+ {props.children as React.ReactNode}
+
+ );
+ },
+}));
+
+import { TemplateTool } from './template-tool';
+import type { ToolParams } from '@/lib/tool-params';
+
+// isNewRecord() treats -1 and undefined as "new"; any other id is an edit.
+const baseParams = { recordID: -1 } as ToolParams;
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('TemplateTool', () => {
+ it('renders inside a ToolContainer titled "Template Tool"', () => {
+ render( );
+
+ expect(screen.getByTestId('tool-container')).toBeInTheDocument();
+ expect(mockToolContainer).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Template Tool' }),
+ );
+ });
+
+ it.each([{ recordID: -1 }, {}])(
+ 'describes creating a record for new-record params %j',
+ (params) => {
+ render( );
+
+ expect(screen.getByTestId('info')).toHaveTextContent('Create a new record');
+ },
+ );
+
+ it('treats recordID 0 as an edit, not a new record', () => {
+ // Only -1 and undefined mean "new" — 0 falls through to edit mode.
+ render( );
+
+ expect(screen.getByTestId('info')).toHaveTextContent('Edit an existing record');
+ });
+
+ it('describes editing a record when an existing recordID is supplied', () => {
+ render( );
+
+ expect(screen.getByTestId('info')).toHaveTextContent('Edit an existing record');
+ });
+
+ it('omits the launch context line when there is no pageID', () => {
+ render( );
+
+ expect(screen.getByTestId('info')).not.toHaveTextContent('Launched from Page ID');
+ });
+
+ it('shows the launch pageID when present', () => {
+ render( );
+
+ expect(screen.getByTestId('info')).toHaveTextContent('Launched from Page ID: 312');
+ });
+
+ it('appends the selection id only when s is defined', () => {
+ const { unmount } = render(
+ ,
+ );
+ expect(screen.getByTestId('info')).toHaveTextContent('Selection: 9');
+ unmount();
+
+ render( );
+ expect(screen.getByTestId('info')).not.toHaveTextContent('Selection:');
+ });
+
+ it('shows a selection of 0 rather than treating it as absent', () => {
+ // `s === 0` is falsy but meaningful; the source guards on `!== undefined`.
+ render( );
+
+ expect(screen.getByTestId('info')).toHaveTextContent('Selection: 0');
+ });
+
+ it('navigates back when closed', async () => {
+ render( );
+
+ await userEvent.click(screen.getByRole('button', { name: 'close' }));
+
+ expect(mockBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('flips isSaving while the save is in flight and clears it afterwards', async () => {
+ // fireEvent rather than userEvent here: userEvent's own async scheduling
+ // deadlocks against fake timers in this jsdom setup.
+ vi.useFakeTimers();
+ render( );
+
+ expect(screen.getByTestId('saving')).toHaveTextContent('false');
+
+ fireEvent.click(screen.getByRole('button', { name: 'save' }));
+ expect(screen.getByTestId('saving')).toHaveTextContent('true');
+
+ // No waitFor here: it schedules on the same fake timers and would hang.
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1000);
+ });
+
+ expect(screen.getByTestId('saving')).toHaveTextContent('false');
+ });
+
+ it('renders the tool body section', () => {
+ render( );
+
+ expect(screen.getByRole('heading', { name: /tool section/i })).toBeInTheDocument();
+ });
+});
diff --git a/src/app/(web)/tools/templateeditor/page.test.tsx b/src/app/(web)/tools/templateeditor/page.test.tsx
new file mode 100644
index 0000000..e539270
--- /dev/null
+++ b/src/app/(web)/tools/templateeditor/page.test.tsx
@@ -0,0 +1,38 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+const { mockParseToolParams } = vi.hoisted(() => ({
+ mockParseToolParams: vi.fn(),
+}));
+
+vi.mock('@/lib/tool-params.server', () => ({
+ parseToolParams: mockParseToolParams,
+}));
+
+vi.mock('./template-editor', () => ({
+ TemplateEditor: () => null,
+}));
+
+import TemplateEditorPage from './page';
+import { TemplateEditor } from './template-editor';
+
+describe('TemplateEditorPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('parses search params and passes them through to TemplateEditor', async () => {
+ const params = { pageID: 292, recordID: 7 };
+ mockParseToolParams.mockResolvedValue(params);
+ const searchParams = Promise.resolve({ pageID: '292', recordID: '7' });
+
+ const element = await TemplateEditorPage({ searchParams });
+
+ expect(mockParseToolParams).toHaveBeenCalledWith({ pageID: '292', recordID: '7' });
+ expect(element.type).toBe(TemplateEditor);
+ expect(element.props).toEqual({ params });
+ });
+});
diff --git a/src/app/(web)/tools/templateeditor/template-editor.test.tsx b/src/app/(web)/tools/templateeditor/template-editor.test.tsx
new file mode 100644
index 0000000..0cbc97c
--- /dev/null
+++ b/src/app/(web)/tools/templateeditor/template-editor.test.tsx
@@ -0,0 +1,86 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup, fireEvent } from '@testing-library/react';
+import type { ToolParams } from '@/lib/tool-params';
+
+const { mockRouterBack } = vi.hoisted(() => ({
+ mockRouterBack: vi.fn(),
+}));
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ back: mockRouterBack }),
+}));
+
+vi.mock('@/components/tool', () => ({
+ ToolContainer: ({
+ title,
+ infoContent,
+ children,
+ }: {
+ title: string;
+ infoContent?: React.ReactNode;
+ children?: React.ReactNode;
+ }) => (
+
+
{title}
+
{infoContent}
+ {children}
+
+ ),
+}));
+
+vi.mock('@/components/template-editor', () => ({
+ TemplateEditorForm: ({ onClose }: { onClose: () => void }) => (
+
+ form
+
+ ),
+}));
+
+import { TemplateEditor } from './template-editor';
+
+describe('TemplateEditor', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('renders the ToolContainer with the Template Editor title and form', () => {
+ const params: ToolParams = {};
+ render( );
+
+ expect(screen.getByTestId('title')).toHaveTextContent('Template Editor');
+ expect(screen.getByTestId('template-editor-form')).toBeInTheDocument();
+ });
+
+ it('navigates back via router.back() when the form requests close', () => {
+ render( );
+
+ fireEvent.click(screen.getByTestId('template-editor-form'));
+
+ expect(mockRouterBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('omits the pageID/recordID line from the info popover when pageID is absent', () => {
+ render( );
+ expect(screen.queryByText(/Page ID:/)).not.toBeInTheDocument();
+ });
+
+ it('shows pageID (without recordID) in the info popover when only pageID is set', () => {
+ render( );
+ expect(screen.getByText('Page ID: 292')).toBeInTheDocument();
+ });
+
+ it('shows pageID and recordID together in the info popover when both are set', () => {
+ render( );
+ expect(screen.getByText('Page ID: 292 | Record: 7')).toBeInTheDocument();
+ });
+
+ it('shows the recordID line even when recordID is 0 (falsy but defined)', () => {
+ render( );
+ expect(screen.getByText('Page ID: 292 | Record: 0')).toBeInTheDocument();
+ });
+});
diff --git a/src/app/global-error.test.tsx b/src/app/global-error.test.tsx
new file mode 100644
index 0000000..29445aa
--- /dev/null
+++ b/src/app/global-error.test.tsx
@@ -0,0 +1,73 @@
+import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import GlobalError from './global-error';
+
+/**
+ * GlobalError replaces the root layout when the layout itself throws. It must
+ * not import app code and must render without app styles.
+ *
+ * jsdom warns about nesting / inside the test container; that is
+ * expected for this component and does not affect the assertions.
+ */
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+});
+
+function makeError(overrides: Partial = {}) {
+ const error = new Error('boom') as Error & { digest?: string };
+ return Object.assign(error, overrides);
+}
+
+describe('GlobalError', () => {
+ it('renders the failure message and a retry control', () => {
+ render( );
+
+ expect(screen.getByRole('heading', { name: /something went wrong/i })).toBeInTheDocument();
+ expect(screen.getByText(/application failed to load/i)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
+ });
+
+ it('invokes retry when the button is clicked', async () => {
+ const retry = vi.fn();
+ render( );
+
+ await userEvent.click(screen.getByRole('button', { name: /try again/i }));
+
+ expect(retry).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows the digest reference when one is present', () => {
+ render( );
+
+ expect(screen.getByText(/reference:/i)).toBeInTheDocument();
+ expect(screen.getByText('abc123')).toBeInTheDocument();
+ });
+
+ it('omits the reference line when there is no digest', () => {
+ render( );
+
+ expect(screen.queryByText(/reference:/i)).not.toBeInTheDocument();
+ });
+
+ it('logs identifiers only — never the error message or stack', () => {
+ // CLAUDE.md rule 14: errors log identifiers and shape, never content.
+ const error = makeError({ digest: 'digest-xyz' });
+ render( );
+
+ expect(console.error).toHaveBeenCalledWith('ui.render.error', {
+ boundary: 'global',
+ name: 'Error',
+ digest: 'digest-xyz',
+ });
+ const logged = JSON.stringify((console.error as unknown as { mock: { calls: unknown[][] } }).mock.calls);
+ expect(logged).not.toContain('boom');
+ });
+});
diff --git a/src/app/providers.test.tsx b/src/app/providers.test.tsx
new file mode 100644
index 0000000..738d449
--- /dev/null
+++ b/src/app/providers.test.tsx
@@ -0,0 +1,62 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+
+const { mockToaster } = vi.hoisted(() => ({
+ mockToaster: vi.fn(),
+}));
+
+vi.mock('@/contexts/user-context', () => ({
+ UserProvider: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+vi.mock('sonner', () => ({
+ Toaster: (props: Record) => {
+ mockToaster(props);
+ return
;
+ },
+}));
+
+import { Providers } from './providers';
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+describe('Providers', () => {
+ it('wraps children in the UserProvider', () => {
+ render(
+
+ app-content
+ ,
+ );
+
+ const provider = screen.getByTestId('user-provider');
+ expect(provider).toBeInTheDocument();
+ expect(provider).toHaveTextContent('app-content');
+ });
+
+ it('mounts the Toaster inside the provider so toasts can read user context', () => {
+ render(
+
+ app-content
+ ,
+ );
+
+ expect(screen.getByTestId('user-provider')).toContainElement(screen.getByTestId('toaster'));
+ });
+
+ it('configures the Toaster bottom-right with rich colors', () => {
+ render(
+
+ app-content
+ ,
+ );
+
+ expect(mockToaster).toHaveBeenCalledWith(
+ expect.objectContaining({ position: 'bottom-right', richColors: true }),
+ );
+ });
+});
diff --git a/src/app/session-error/page.test.tsx b/src/app/session-error/page.test.tsx
new file mode 100644
index 0000000..66c2739
--- /dev/null
+++ b/src/app/session-error/page.test.tsx
@@ -0,0 +1,50 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+
+/**
+ * SessionErrorPage is the only escape hatch for an authenticated-but-unusable
+ * session (one with no userGuid). AuthWrapper redirects here, and because the
+ * page sits outside the (web) group it is not itself wrapped — so it cannot
+ * loop. The sign-out form must always render.
+ */
+
+const { mockHandleSignOut } = vi.hoisted(() => ({
+ mockHandleSignOut: vi.fn(),
+}));
+
+vi.mock('@/components/user-menu/actions', () => ({
+ handleSignOut: mockHandleSignOut,
+}));
+
+import SessionErrorPage, { dynamic } from './page';
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+describe('SessionErrorPage', () => {
+ it('is force-dynamic so the per-request CSP nonce is available', () => {
+ // A prerendered page has no request, therefore no nonce, therefore a
+ // blocked bootstrap script — which would leave this page's only
+ // sign-out control non-functional.
+ expect(dynamic).toBe('force-dynamic');
+ });
+
+ it('explains that the MP user link was missing', () => {
+ render( );
+
+ expect(
+ screen.getByRole('heading', { level: 1, name: /couldn't load your account/i }),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/ministry platform\s+user link/i)).toBeInTheDocument();
+ });
+
+ it('renders a sign-out submit button wired to the handleSignOut action', () => {
+ const { container } = render( );
+
+ const button = screen.getByRole('button', { name: /sign out and try again/i });
+ expect(button).toHaveAttribute('type', 'submit');
+ expect(container.querySelector('form')).toBeInTheDocument();
+ });
+});
diff --git a/src/app/signin/page.test.tsx b/src/app/signin/page.test.tsx
index cc5b3d2..44e978f 100644
--- a/src/app/signin/page.test.tsx
+++ b/src/app/signin/page.test.tsx
@@ -47,6 +47,18 @@ function setSearchParams(params: Record) {
mockUseSearchParams.mockReturnValue(sp);
}
+/**
+ * These tests deliberately drive failure paths, and the code under test logs
+ * them on purpose. Silence the channel so a real, unexpected error still
+ * stands out in the runner output instead of drowning in expected noise.
+ * `mockImplementation` keeps the spy recording, so assertions on what was
+ * logged still work.
+ */
+beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+});
+
describe('SignIn page', () => {
let originalLocation: Location;
let locationHref: string;
@@ -280,3 +292,140 @@ describe('SignIn route rendering mode', () => {
expect(source).toMatch(/^["']use client["']/m);
});
});
+
+/**
+ * Error-code classification.
+ *
+ * `describeOAuthError` is not exported, so it is exercised through the rendered
+ * error card. Each branch maps a provider error code to the guidance the user
+ * actually reads — getting this wrong tells people to retry something that
+ * will never succeed, or to contact support for a cancelled sign-in.
+ */
+describe('SignIn OAuth error classification', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetSession.mockResolvedValue(null);
+ });
+
+ it.each([
+ 'invalid_request',
+ 'invalid_client',
+ 'invalid_grant',
+ 'unauthorized_client',
+ 'unsupported_response_type',
+ 'invalid_scope',
+ ])('describes %s as a request rejected by the provider', async (code) => {
+ setSearchParams({ error: code });
+
+ render( );
+
+ expect(
+ await screen.findByText(/sign-in request was rejected by the provider/i),
+ ).toBeInTheDocument();
+ expect(mockSignInSocial).not.toHaveBeenCalled();
+ });
+
+ it.each(['server_error', 'temporarily_unavailable'])(
+ 'describes %s as a temporary provider outage',
+ async (code) => {
+ setSearchParams({ error: code });
+
+ render( );
+
+ expect(
+ await screen.findByText(/temporarily unavailable\. please retry in a moment/i),
+ ).toBeInTheDocument();
+ },
+ );
+
+ it('falls back to a generic message that names an unrecognised code', async () => {
+ setSearchParams({ error: 'some_unmapped_code' });
+
+ render( );
+
+ expect(await screen.findByText(/sign-in failed \(some_unmapped_code\)/i)).toBeInTheDocument();
+ });
+
+ it('describes access_denied as a cancellation the user can retry', async () => {
+ setSearchParams({ error: 'access_denied' });
+
+ render( );
+
+ expect(await screen.findByText(/sign-in was cancelled/i)).toBeInTheDocument();
+ });
+});
+
+/**
+ * Failure to even START the OAuth handshake.
+ *
+ * `signIn.social()` can fail two ways: it can throw synchronously, or it can
+ * return a promise that rejects. Both must clear the redirect timeout, release
+ * the once-only guard, and surface a retryable error — otherwise the user is
+ * left on a spinner with no way forward.
+ */
+describe('SignIn when signIn.social fails to start', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetSession.mockResolvedValue(null);
+ setSearchParams({});
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('surfaces a retryable error when signIn.social throws synchronously', async () => {
+ mockSignInSocial.mockImplementation(() => {
+ throw new Error('provider exploded');
+ });
+
+ render( );
+
+ expect(await screen.findByText(/failed to start sign-in/i)).toBeInTheDocument();
+ });
+
+ it('surfaces a retryable error when the returned promise rejects', async () => {
+ mockSignInSocial.mockRejectedValue(new Error('network down'));
+
+ render( );
+
+ expect(await screen.findByText(/failed to start sign-in/i)).toBeInTheDocument();
+ });
+
+ it('allows a retry after a start failure, re-invoking signIn.social', async () => {
+ mockSignInSocial.mockRejectedValueOnce(new Error('network down'));
+
+ render( );
+ await screen.findByText(/failed to start sign-in/i);
+
+ const callsBeforeRetry = mockSignInSocial.mock.calls.length;
+ mockSignInSocial.mockResolvedValueOnce(undefined);
+ fireEvent.click(screen.getByRole('button', { name: /retry/i }));
+
+ await waitFor(() =>
+ expect(mockSignInSocial.mock.calls.length).toBeGreaterThan(callsBeforeRetry),
+ );
+ });
+
+ it('tolerates a non-thenable return value without throwing', async () => {
+ // The source guards on `typeof result.catch === "function"`; a void return
+ // must not crash the effect.
+ mockSignInSocial.mockReturnValue(undefined);
+
+ render( );
+
+ await waitFor(() => expect(mockSignInSocial).toHaveBeenCalled());
+ expect(screen.queryByText(/failed to start sign-in/i)).not.toBeInTheDocument();
+ });
+});
+
+describe('SignInFallback', () => {
+ it('renders a loading state for the Suspense boundary', async () => {
+ const { SignInFallback } = await import('./sign-in-content');
+
+ render( );
+
+ expect(screen.getByRole('heading', { name: /loading/i })).toBeInTheDocument();
+ });
+});
diff --git a/src/auth.test.ts b/src/auth.test.ts
index 47692e7..45f7a92 100644
--- a/src/auth.test.ts
+++ b/src/auth.test.ts
@@ -21,6 +21,18 @@ import {
* - User profile loading is handled client-side by UserProvider
*/
+/**
+ * These tests deliberately drive failure paths, and the code under test logs
+ * them on purpose. Silence the channel so a real, unexpected error still
+ * stands out in the runner output instead of drowning in expected noise.
+ * `mockImplementation` keeps the spy recording, so assertions on what was
+ * logged still work.
+ */
+beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+});
+
describe('Auth - Custom Session Enrichment Logic', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -616,3 +628,146 @@ describe('Auth - provider account key (better-auth 1.7)', () => {
expect(mapped.mpEmail).toBe('jane@example.org');
});
});
+
+/**
+ * `getUserInfo` failure paths.
+ *
+ * Better Auth does NOT wrap `provider.getUserInfo` in a try/catch inside its
+ * callback route, so this function must return `null` rather than throw on
+ * every failure — a throw surfaces as an unhandled error instead of a clean
+ * `unable_to_get_user_info` redirect.
+ *
+ * These paths also carry a logging contract (CLAUDE.md rule 14): the userinfo
+ * body can echo profile content, so only status and shape may be logged.
+ */
+describe('Auth - getUserInfo failure handling', () => {
+ const GUID = 'ab12cd34-ef56-7890-abcd-ef1234567890';
+
+ function callGetUserInfo() {
+ const fn = ministryPlatformProviderConfig.getUserInfo;
+ if (!fn) throw new Error('getUserInfo must be declared');
+ return fn({ accessToken: 'token-abc' } as never);
+ }
+
+ beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('returns null — never throws — when the userinfo endpoint errors', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({ ok: false, status: 502, json: vi.fn() }),
+ );
+
+ await expect(callGetUserInfo()).resolves.toBeNull();
+ });
+
+ it('logs only the HTTP status on a failed fetch, never the body', async () => {
+ const json = vi.fn().mockResolvedValue({ secret: 'profile-content' });
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403, json }));
+
+ await callGetUserInfo();
+
+ expect(console.error).toHaveBeenCalledWith('auth.userinfo.fetch_failed', { status: 403 });
+ expect(json).not.toHaveBeenCalled();
+ });
+
+ it('sends the access token as a bearer credential', async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 500, json: vi.fn() });
+ vi.stubGlobal('fetch', fetchMock);
+
+ await callGetUserInfo();
+
+ const [, init] = fetchMock.mock.calls[0];
+ expect(init.headers.Authorization).toBe('Bearer token-abc');
+ });
+
+ it('returns null when the profile carries no usable sub', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: vi.fn().mockResolvedValue({ email: 'someone@example.com' }),
+ }),
+ );
+
+ await expect(callGetUserInfo()).resolves.toBeNull();
+ expect(console.error).toHaveBeenCalledWith(
+ 'auth.userinfo.invalid_sub',
+ expect.objectContaining({ hasSub: false }),
+ );
+ });
+
+ it('returns the normalized sub and a trimmed mpEmail on success', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: vi.fn().mockResolvedValue({
+ sub: GUID.toUpperCase(),
+ email: ' person@church.org ',
+ }),
+ }),
+ );
+
+ const result = (await callGetUserInfo()) as Record;
+
+ expect(result.sub).toBe(GUID);
+ expect(result.mpEmail).toBe('person@church.org');
+ });
+
+ it('nulls mpEmail when the profile email is blank', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: vi.fn().mockResolvedValue({ sub: GUID, email: ' ' }),
+ }),
+ );
+
+ const result = (await callGetUserInfo()) as Record;
+
+ expect(result.mpEmail).toBeNull();
+ });
+});
+
+/**
+ * `mapProfileToUser` must refuse to build a user record it cannot tie back to
+ * an MP `dp_Users` row. `getUserInfo` has already validated `sub`, so reaching
+ * the throw means the provider contract changed underneath us — failing loudly
+ * is correct there, unlike in `getUserInfo`.
+ */
+describe('Auth - mapProfileToUser', () => {
+ const GUID = 'ab12cd34-ef56-7890-abcd-ef1234567890';
+
+ function callMapProfileToUser(profile: Record) {
+ const fn = ministryPlatformProviderConfig.mapProfileToUser;
+ if (!fn) throw new Error('mapProfileToUser must be declared');
+ return fn(profile as never) as Record;
+ }
+
+ it('throws when the profile has no usable sub', () => {
+ expect(() => callMapProfileToUser({ email: 'x@y.z' })).toThrow(/no usable sub/i);
+ });
+
+ it('maps sub to userGuid and derives the synthetic email', () => {
+ const mapped = callMapProfileToUser({ sub: GUID });
+
+ expect(mapped.userGuid).toBe(GUID);
+ expect(mapped.email).toBe(syntheticEmailForSub(GUID));
+ });
+
+ it('passes an mpEmail through and defaults it to null', () => {
+ expect(callMapProfileToUser({ sub: GUID, mpEmail: 'real@church.org' }).mpEmail).toBe(
+ 'real@church.org',
+ );
+ expect(callMapProfileToUser({ sub: GUID }).mpEmail).toBeNull();
+ });
+});
diff --git a/src/components/address-labels/actions.test.ts b/src/components/address-labels/actions.test.ts
index c88ea10..7192bac 100644
--- a/src/components/address-labels/actions.test.ts
+++ b/src/components/address-labels/actions.test.ts
@@ -7,6 +7,7 @@ const mockGetAddressForContact = vi.hoisted(() => vi.fn());
const mockToBlob = vi.hoisted(() => vi.fn());
const mockDocxtemplaterRender = vi.hoisted(() => vi.fn());
const mockDocxtemplaterGetZip = vi.hoisted(() => vi.fn());
+const mockImageModuleCtor = vi.hoisted(() => vi.fn());
vi.mock('@/lib/auth', () => ({
auth: {
@@ -91,7 +92,11 @@ vi.mock('pizzip', () => ({
}));
vi.mock('docxtemplater-image', () => ({
- default: class {},
+ default: class {
+ constructor(...args: unknown[]) {
+ mockImageModuleCtor(...args);
+ }
+ },
}));
vi.mock('@/lib/barcode-helpers', () => ({
@@ -109,6 +114,18 @@ import { fetchAddressLabels, generateLabelPdf, mergeTemplate } from './actions';
import type { LabelConfig, LabelData } from '@/lib/dto';
import type { ToolParams } from '@/lib/tool-params';
+/**
+ * These tests deliberately drive failure paths, and the code under test logs
+ * them on purpose. Silence the channel so a real, unexpected error still
+ * stands out in the runner output instead of drowning in expected noise.
+ * `mockImplementation` keeps the spy recording, so assertions on what was
+ * logged still work.
+ */
+beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+});
+
describe('fetchAddressLabels', () => {
const defaultConfig: LabelConfig = {
stockId: '5160',
@@ -292,6 +309,51 @@ describe('fetchAddressLabels', () => {
expect(result.skipped).toHaveLength(1);
expect(result.skipped[0].reason).toBe('no_barcode');
});
+
+ it('returns empty results when neither selection nor recordID params are provided', async () => {
+ const params: ToolParams = {};
+ const result = await fetchAddressLabels(params, defaultConfig);
+ expect(result).toEqual({ printable: [], skipped: [] });
+ });
+
+ it('returns empty results in selection mode when the selection has no records', async () => {
+ mockGetSelectionRecordIds.mockResolvedValue([]);
+ const params: ToolParams = { pageID: 292, s: 1, sc: 0 };
+ const result = await fetchAddressLabels(params, defaultConfig);
+ expect(result).toEqual({ printable: [], skipped: [] });
+ expect(mockGetAddressesForContacts).not.toHaveBeenCalled();
+ });
+
+ it('returns empty results in recordID mode when the contact address lookup finds nothing', async () => {
+ mockGetAddressForContact.mockResolvedValue(undefined);
+ const params: ToolParams = { recordID: 7 };
+ const result = await fetchAddressLabels(params, defaultConfig);
+ expect(result).toEqual({ printable: [], skipped: [] });
+ });
+
+ it('sorts multiple printable results by postal code', async () => {
+ mockGetSelectionRecordIds.mockResolvedValue([1, 2]);
+ mockGetAddressesForContacts.mockResolvedValue([
+ {
+ Contact_ID: 1, Display_Name: 'Zeta Person', Household_ID: null,
+ Household_Name: null, Bulk_Mail_Opt_Out: false,
+ Address_Line_1: '1 Z St', City: 'Zeta', 'State/Region': 'TX',
+ Postal_Code: '99999', Bar_Code: '01234567094987654321',
+ },
+ {
+ Contact_ID: 2, Display_Name: 'Alpha Person', Household_ID: null,
+ Household_Name: null, Bulk_Mail_Opt_Out: false,
+ Address_Line_1: '1 A St', City: 'Alpha', 'State/Region': 'TX',
+ Postal_Code: '10000', Bar_Code: '01234567094987654321',
+ },
+ ]);
+
+ const params: ToolParams = { pageID: 292, s: 1, sc: 2 };
+ const config: LabelConfig = { ...defaultConfig, addressMode: 'individual' };
+ const result = await fetchAddressLabels(params, config);
+
+ expect(result.printable.map((p) => p.postalCode)).toEqual(['10000', '99999']);
+ });
});
describe('generateLabelPdf', () => {
@@ -348,6 +410,14 @@ describe('generateLabelPdf', () => {
expect(result.error).toContain('No labels to print');
}
});
+
+ it('returns a validation error and skips PDF rendering for an invalid IMb mailerId', async () => {
+ const labels: LabelData[] = [{ name: 'Test', addressLine1: '123 Main', city: 'Test', state: 'TX', postalCode: '75001' }];
+ const result = await generateLabelPdf(labels, { ...pdfConfig, barcodeFormat: 'imb', mailerId: '123' });
+ expect(result.success).toBe(false);
+ if (!result.success) expect(result.error).toContain('Mailer ID must be exactly 6 or 9 digits');
+ expect(mockToBlob).not.toHaveBeenCalled();
+ });
});
describe('mergeTemplate', () => {
@@ -455,6 +525,47 @@ describe('mergeTemplate', () => {
const result = await mergeTemplate(Buffer.from('x').toString('base64'), labels, mergeConfig);
expect(result.success).toBe(true);
});
+
+ it('returns a validation error and never renders when the IMb mailerId is invalid', async () => {
+ const labels: LabelData[] = [{ name: 'T', addressLine1: 'A', city: 'C', state: 'S', postalCode: '12345' }];
+ const invalidImbConfig: LabelConfig = { ...mergeConfig, barcodeFormat: 'imb', mailerId: '123' };
+
+ const result = await mergeTemplate(Buffer.from('x').toString('base64'), labels, invalidImbConfig);
+
+ expect(result.success).toBe(false);
+ if (!result.success) expect(result.error).toContain('Mailer ID must be exactly 6 or 9 digits');
+ expect(mockDocxtemplaterRender).not.toHaveBeenCalled();
+ });
+
+ it('accepts a valid 9-digit IMb mailerId and proceeds to render', async () => {
+ const labels: LabelData[] = [{ name: 'T', addressLine1: 'A', city: 'C', state: 'S', postalCode: '12345' }];
+ const validImbConfig: LabelConfig = { ...mergeConfig, barcodeFormat: 'imb', mailerId: '123456789' };
+
+ const result = await mergeTemplate(Buffer.from('x').toString('base64'), labels, validImbConfig);
+
+ expect(result.success).toBe(true);
+ expect(mockDocxtemplaterRender).toHaveBeenCalled();
+ });
+
+ it('resolves the getImage/getSize callbacks passed to the image module', async () => {
+ mockImageModuleCtor.mockClear();
+ const labels: LabelData[] = [{ name: 'NoKey', addressLine1: '1 A', city: 'C', state: 'S', postalCode: '12345' }];
+ const result = await mergeTemplate(Buffer.from('x').toString('base64'), labels, mergeConfig);
+ expect(result.success).toBe(true);
+
+ expect(mockImageModuleCtor).toHaveBeenCalled();
+ const [options] = mockImageModuleCtor.mock.calls[0] as [{
+ getImage: (tagValue: unknown) => Buffer;
+ getSize: (img: Buffer | string, tagValue: unknown, tagName: string) => [number, number];
+ }];
+
+ // getImage falls back to an empty buffer for a key with no matching barcode
+ expect(options.getImage('not-a-real-key')).toEqual(Buffer.alloc(0));
+ // getSize returns the Barcode-specific size for the Barcode tag...
+ expect(options.getSize(Buffer.alloc(0), 'barcode_0', 'Barcode')).toEqual([200, 25]);
+ // ...and a generic fallback size for any other tag
+ expect(options.getSize(Buffer.alloc(0), 'x', 'SomeOtherTag')).toEqual([100, 100]);
+ });
});
describe('generateLabelDocx', () => {
@@ -500,6 +611,29 @@ describe('generateLabelDocx', () => {
expect(result.success).toBe(false);
if (!result.success) expect(result.error).toContain('No labels to export');
});
+
+ it('returns a validation error and skips docx rendering for an invalid IMb mailerId', async () => {
+ const { generateLabelDocx } = await import('./actions');
+ const labels: LabelData[] = [{ name: 'Docx', addressLine1: '1 Docx Rd', city: 'Town', state: 'TX', postalCode: '75001' }];
+ const result = await generateLabelDocx(labels, { ...docxConfig, barcodeFormat: 'imb', mailerId: '1234' });
+ expect(result.success).toBe(false);
+ if (!result.success) expect(result.error).toContain('Mailer ID must be exactly 6 or 9 digits');
+ });
+
+ it('returns a generic error when building the docx throws', async () => {
+ const { generateLabelDocx } = await import('./actions');
+ const labels: LabelData[] = [{ name: 'Docx', addressLine1: '1 Docx Rd', city: 'Town', state: 'TX', postalCode: '75001' }];
+ const { preEncodeBarcodes } = await import('@/lib/barcode-helpers');
+ (preEncodeBarcodes as unknown as { mockImplementationOnce: (fn: unknown) => void }).mockImplementationOnce(
+ () => {
+ throw new Error('encode failure');
+ }
+ );
+
+ const result = await generateLabelDocx(labels, docxConfig);
+ expect(result.success).toBe(false);
+ if (!result.success) expect(result.error).toBe('encode failure');
+ });
});
describe('generateLabelPdf error branches', () => {
diff --git a/src/components/address-labels/address-label.test.tsx b/src/components/address-labels/address-label.test.tsx
new file mode 100644
index 0000000..e97b1ed
--- /dev/null
+++ b/src/components/address-labels/address-label.test.tsx
@@ -0,0 +1,109 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, cleanup, screen } from '@testing-library/react';
+
+// react-pdf's View/Text accept array `style` props (merged internally by the
+// PDF renderer); real DOM elements don't support that, so the mock flattens
+// array styles into a single object the way react-pdf effectively does.
+function flattenStyle(style: unknown): Record | undefined {
+ if (Array.isArray(style)) return Object.assign({}, ...style);
+ return style as Record | undefined;
+}
+
+vi.mock('@react-pdf/renderer', () => ({
+ View: ({ style, children, ...props }: { style?: unknown; children?: React.ReactNode }) => (
+ {children}
+ ),
+ Text: ({ style, children, ...props }: { style?: unknown; children?: React.ReactNode }) => (
+ {children}
+ ),
+ StyleSheet: { create: (s: Record) => s },
+}));
+
+vi.mock('./imb-barcode', () => ({
+ ImbBarcode: ({ barStates }: { barStates: string }) => (
+ {barStates}
+ ),
+}));
+
+vi.mock('./postnet-barcode', () => ({
+ PostnetBarcode: ({ barStates }: { barStates: string }) => (
+ {barStates}
+ ),
+}));
+
+import { AddressLabel } from './address-label';
+import type { LabelData } from '@/lib/dto';
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+const baseLabel: LabelData = {
+ name: 'Jane Doe',
+ addressLine1: '123 Main St',
+ city: 'Chicago',
+ state: 'IL',
+ postalCode: '60601',
+};
+
+describe('AddressLabel', () => {
+ it('renders name, address line 1, and city/state/zip', () => {
+ render( );
+ expect(screen.getByText('Jane Doe')).toBeInTheDocument();
+ expect(screen.getByText('123 Main St')).toBeInTheDocument();
+ expect(screen.getByText((_, el) => el?.textContent === 'Chicago, IL 60601')).toBeInTheDocument();
+ });
+
+ it('renders address line 2 when present', () => {
+ render( );
+ expect(screen.getByText('Suite 200')).toBeInTheDocument();
+ });
+
+ it('omits address line 2 when absent', () => {
+ render( );
+ expect(screen.queryByText('Suite 200')).not.toBeInTheDocument();
+ });
+
+ it('omits city/state when missing', () => {
+ render(
+
+ );
+ // cityStateZip joins to an empty string when all three parts are falsy
+ expect(screen.queryByText('Chicago, IL 60601')).not.toBeInTheDocument();
+ });
+
+ it('renders IMb barcode when barType is imb and barStates present', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('imb-barcode')).toBeInTheDocument();
+ expect(screen.queryByTestId('postnet-barcode')).not.toBeInTheDocument();
+ });
+
+ it('renders POSTNET barcode when barType is postnet and barStates present', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('postnet-barcode')).toBeInTheDocument();
+ expect(screen.queryByTestId('imb-barcode')).not.toBeInTheDocument();
+ });
+
+ it('renders no barcode when barStates is absent', () => {
+ render( );
+ expect(screen.queryByTestId('imb-barcode')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('postnet-barcode')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/address-labels/address-labels-form.test.tsx b/src/components/address-labels/address-labels-form.test.tsx
new file mode 100644
index 0000000..95a22bb
--- /dev/null
+++ b/src/components/address-labels/address-labels-form.test.tsx
@@ -0,0 +1,185 @@
+import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest';
+import { render, cleanup, screen, fireEvent } from '@testing-library/react';
+import { AddressLabelsForm } from './address-labels-form';
+import type { LabelConfig } from '@/lib/dto';
+
+// jsdom does not implement these APIs that Radix Select/pointer-based
+// primitives call during open/close and scroll positioning.
+beforeAll(() => {
+ Element.prototype.hasPointerCapture = Element.prototype.hasPointerCapture ?? (() => false);
+ Element.prototype.releasePointerCapture = Element.prototype.releasePointerCapture ?? (() => {});
+ Element.prototype.scrollIntoView = Element.prototype.scrollIntoView ?? (() => {});
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+const baseConfig: LabelConfig = {
+ stockId: '5160',
+ addressMode: 'household',
+ startPosition: 1,
+ includeMissingBarcodes: true,
+ barcodeFormat: 'postnet',
+ mailerId: '',
+ serviceType: '040',
+};
+
+describe('AddressLabelsForm', () => {
+ it('renders the current config values', () => {
+ render( );
+ expect(screen.getByLabelText('Start Position')).toHaveValue(1);
+ expect(screen.getByLabelText('Household')).toBeChecked();
+ expect(screen.getByLabelText('POSTNET')).toBeChecked();
+ });
+
+ it('does not show the IMb fields when barcodeFormat is not imb', () => {
+ render( );
+ expect(screen.queryByLabelText('USPS Mailer ID')).not.toBeInTheDocument();
+ });
+
+ it('shows the IMb fields when barcodeFormat is imb', () => {
+ render(
+
+ );
+ expect(screen.getByLabelText('USPS Mailer ID')).toBeInTheDocument();
+ });
+
+ it('updates startPosition on input change, clamped within [1, maxStartPosition]', () => {
+ const onChange = vi.fn();
+ render( );
+
+ fireEvent.change(screen.getByLabelText('Start Position'), { target: { value: '15' } });
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ startPosition: 15 }));
+ });
+
+ it('clamps startPosition above maxStartPosition down to the max', () => {
+ const onChange = vi.fn();
+ render( );
+
+ fireEvent.change(screen.getByLabelText('Start Position'), { target: { value: '999' } });
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ startPosition: 30 }));
+ });
+
+ it('clamps a non-numeric startPosition input down to 1', () => {
+ const onChange = vi.fn();
+ render( );
+
+ fireEvent.change(screen.getByLabelText('Start Position'), { target: { value: 'abc' } });
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ startPosition: 1 }));
+ });
+
+ it('switches addressMode via the radio group', () => {
+ const onChange = vi.fn();
+ render( );
+
+ fireEvent.click(screen.getByLabelText('Individual'));
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ addressMode: 'individual' }));
+ });
+
+ it('switches barcodeFormat via the radio group', () => {
+ const onChange = vi.fn();
+ render( );
+
+ fireEvent.click(screen.getByLabelText('Intelligent Mail (IMb)'));
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ barcodeFormat: 'imb' }));
+ });
+
+ it('strips non-digit characters and caps mailerId at 9 characters', () => {
+ const onChange = vi.fn();
+ render(
+
+ );
+
+ fireEvent.change(screen.getByLabelText('USPS Mailer ID'), {
+ target: { value: '12a3-4567890' },
+ });
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ mailerId: '123456789' }));
+ });
+
+ it('shows a warning when mailerId length is neither 6 nor 9', () => {
+ render(
+
+ );
+ expect(screen.getByText('Must be 6 or 9 digits')).toBeInTheDocument();
+ });
+
+ it('shows no warning when mailerId is empty', () => {
+ render(
+
+ );
+ expect(screen.queryByText('Must be 6 or 9 digits')).not.toBeInTheDocument();
+ });
+
+ it('shows no warning when mailerId is a valid 6-digit value', () => {
+ render(
+
+ );
+ expect(screen.queryByText('Must be 6 or 9 digits')).not.toBeInTheDocument();
+ });
+
+ it('toggles includeMissingBarcodes via the checkbox', () => {
+ const onChange = vi.fn();
+ render( );
+
+ fireEvent.click(screen.getByLabelText('Include labels without barcodes'));
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ includeMissingBarcodes: false }));
+ });
+
+ it('changes the label stock via the select and resets startPosition to 1', () => {
+ const onChange = vi.fn();
+ render(
+
+ );
+
+ fireEvent.click(screen.getByLabelText('Label Stock'));
+ const option = screen.getByRole('option', { name: /Avery 5161/i });
+ fireEvent.click(option);
+
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining({ stockId: '5161', startPosition: 1 })
+ );
+ });
+
+ it('changes the service type via the select', () => {
+ const onChange = vi.fn();
+ render(
+
+ );
+
+ fireEvent.click(screen.getByLabelText('Service Type'));
+ const option = screen.getByRole('option', { name: /Priority Mail/i });
+ fireEvent.click(option);
+
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ serviceType: '200' }));
+ });
+});
diff --git a/src/components/address-labels/address-labels-summary.test.tsx b/src/components/address-labels/address-labels-summary.test.tsx
new file mode 100644
index 0000000..41f0cd7
--- /dev/null
+++ b/src/components/address-labels/address-labels-summary.test.tsx
@@ -0,0 +1,77 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, cleanup, screen, fireEvent } from '@testing-library/react';
+import { AddressLabelsSummary } from './address-labels-summary';
+import type { SkipRecord } from '@/lib/dto';
+
+afterEach(() => {
+ cleanup();
+});
+
+describe('AddressLabelsSummary', () => {
+ it('shows singular label count', () => {
+ render( );
+ expect(screen.getByText('1 label ready to print')).toBeInTheDocument();
+ });
+
+ it('shows plural label count', () => {
+ render( );
+ expect(screen.getByText('3 labels ready to print')).toBeInTheDocument();
+ });
+
+ it('shows zero labels as plural', () => {
+ render( );
+ expect(screen.getByText('0 labels ready to print')).toBeInTheDocument();
+ });
+
+ it('does not show skipped section when there are no skipped records', () => {
+ render( );
+ expect(screen.queryByText(/skipped/)).not.toBeInTheDocument();
+ });
+
+ it('groups skipped records by reason with known labels', () => {
+ const skipped: SkipRecord[] = [
+ { name: 'A', contactId: 1, reason: 'no_address' },
+ { name: 'B', contactId: 2, reason: 'no_address' },
+ { name: 'C', contactId: 3, reason: 'opted_out' },
+ ];
+ render( );
+ expect(screen.getByText('3 skipped')).toBeInTheDocument();
+ expect(screen.getByText('(2 Missing address)')).toBeInTheDocument();
+ expect(screen.getByText('(1 Opted out of bulk mail)')).toBeInTheDocument();
+ });
+
+ it('falls back to the raw reason string for an unknown reason', () => {
+ const skipped = [
+ { name: 'Z', contactId: 9, reason: 'some_unknown_reason' } as unknown as SkipRecord,
+ ];
+ render( );
+ expect(screen.getByText('(1 some_unknown_reason)')).toBeInTheDocument();
+ });
+
+ it('toggles the skipped record list open and closed', () => {
+ const skipped: SkipRecord[] = [
+ { name: 'Jane Doe', contactId: 1, reason: 'no_postal_code' },
+ ];
+ render( );
+
+ expect(screen.queryByText(/Jane Doe/)).not.toBeInTheDocument();
+
+ const toggleBtn = screen.getByRole('button', { name: /view skipped records/i });
+ fireEvent.click(toggleBtn);
+
+ expect(screen.getByText(/Jane Doe/)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /hide skipped records/i })).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: /hide skipped records/i }));
+ expect(screen.queryByText(/Jane Doe/)).not.toBeInTheDocument();
+ });
+
+ it('renders the reason label for each skipped record row using fallback for unknown reasons', () => {
+ const skipped = [
+ { name: 'Weird Case', contactId: 5, reason: 'totally_unknown' } as unknown as SkipRecord,
+ ];
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /view skipped records/i }));
+ expect(screen.getByText(/Weird Case — totally_unknown/)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/address-labels/imb-barcode.test.tsx b/src/components/address-labels/imb-barcode.test.tsx
new file mode 100644
index 0000000..0fc334c
--- /dev/null
+++ b/src/components/address-labels/imb-barcode.test.tsx
@@ -0,0 +1,65 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, cleanup } from '@testing-library/react';
+
+/**
+ * `View` is a react-pdf primitive with no DOM equivalent. Mapping it to the
+ * literal string 'View' made React treat it as a capitalised unknown HTML tag
+ * and log two warnings per rendered element — ~800 lines of noise across the
+ * suite for a single 65-bar barcode.
+ *
+ * The tag name needs a dash: React renders a hyphenated name as a custom
+ * element without complaint, whereas any undashed unknown tag (`view`
+ * included) still draws "The tag is unrecognized in this browser".
+ */
+vi.mock('@react-pdf/renderer', () => ({
+ View: 'rpdf-view',
+}));
+
+import { ImbBarcode } from './imb-barcode';
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+const VALID_BAR_STATES = 'T'.repeat(65);
+
+describe('ImbBarcode', () => {
+ it('renders null when barStates is empty', () => {
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders null when barStates is not 65 characters', () => {
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders 65 bars for a valid bar state string', () => {
+ const { container } = render( );
+ // One outer View + 65 inner bar Views, each with a nested pad + bar View.
+ const views = container.querySelectorAll('rpdf-view');
+ // outer(1) + per-bar wrapper(65) + pad(65) + bar(65) = 196
+ expect(views.length).toBe(1 + 65 * 3);
+ });
+
+ it('renders all four bar state types without error', () => {
+ const mixed = ('T'.repeat(17) + 'D'.repeat(16) + 'A'.repeat(16) + 'F'.repeat(16)).slice(0, 65);
+ const { container } = render( );
+ expect(container.querySelectorAll('rpdf-view').length).toBeGreaterThan(0);
+ });
+
+ it('skips a bar when the state character is unrecognized', () => {
+ const withInvalid = 'X' + 'T'.repeat(64);
+ const { container } = render( );
+ // outer(1) + 64 valid bars * 3 nested views (unrecognized char renders null, no nested views)
+ const views = container.querySelectorAll('rpdf-view');
+ expect(views.length).toBe(1 + 64 * 3);
+ });
+
+ it('applies custom width and height', () => {
+ const { container } = render( );
+ const outer = container.querySelector('rpdf-view');
+ expect(outer).not.toBeNull();
+ });
+});
diff --git a/src/components/address-labels/label-document.test.tsx b/src/components/address-labels/label-document.test.tsx
new file mode 100644
index 0000000..3359b75
--- /dev/null
+++ b/src/components/address-labels/label-document.test.tsx
@@ -0,0 +1,96 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, cleanup } from '@testing-library/react';
+
+vi.mock('@react-pdf/renderer', () => ({
+ Document: ({ children }: { children?: React.ReactNode }) => {children}
,
+ Page: ({ children }: { children?: React.ReactNode }) => {children}
,
+ View: ({ children, style }: { children?: React.ReactNode; style?: unknown }) => (
+ {children}
+ ),
+ StyleSheet: { create: (s: Record) => s },
+}));
+
+vi.mock('./address-label', () => ({
+ AddressLabel: ({ data }: { data: { name: string } }) => (
+ {data.name}
+ ),
+}));
+
+import { LabelDocument } from './label-document';
+import type { LabelStockConfig } from '@/lib/label-stock';
+import type { LabelData } from '@/lib/dto';
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+const stock: LabelStockConfig = {
+ id: '5160',
+ name: 'Avery 5160',
+ pageWidth: 612,
+ pageHeight: 792,
+ labelWidth: 189,
+ labelHeight: 72,
+ columns: 3,
+ rows: 10,
+ marginTop: 36,
+ marginLeft: 13.5,
+ columnGap: 9,
+ rowGap: 0,
+};
+
+function makeLabels(count: number): LabelData[] {
+ return Array.from({ length: count }, (_, i) => ({
+ name: `Label ${i}`,
+ addressLine1: '123 Main St',
+ city: 'Chicago',
+ state: 'IL',
+ postalCode: '60601',
+ }));
+}
+
+describe('LabelDocument', () => {
+ it('renders a single page for labels that fit within one page', () => {
+ const { getAllByTestId } = render(
+
+ );
+ expect(getAllByTestId('page')).toHaveLength(1);
+ expect(getAllByTestId('address-label')).toHaveLength(5);
+ });
+
+ it('spills onto a second page when labels exceed one page capacity', () => {
+ // 30 per page (3 cols x 10 rows); 35 labels should produce 2 pages.
+ const { getAllByTestId } = render(
+
+ );
+ expect(getAllByTestId('page')).toHaveLength(2);
+ expect(getAllByTestId('address-label')).toHaveLength(35);
+ });
+
+ it('skips slots before startPosition on the first page', () => {
+ const { getAllByTestId } = render(
+
+ );
+ // Still 1 page, but only 3 address labels rendered (skip count = 4 empty slots)
+ expect(getAllByTestId('page')).toHaveLength(1);
+ expect(getAllByTestId('address-label')).toHaveLength(3);
+ });
+
+ it('renders at least one page even when labels array is empty', () => {
+ const { getAllByTestId } = render(
+
+ );
+ expect(getAllByTestId('page')).toHaveLength(1);
+ expect(() => getAllByTestId('address-label')).toThrow();
+ });
+
+ it('carries a large startPosition offset across into a second page', () => {
+ // startPosition 29 (skip 28) + 5 labels = totalSlots 33 > 30/page -> 2 pages
+ const { getAllByTestId } = render(
+
+ );
+ expect(getAllByTestId('page')).toHaveLength(2);
+ expect(getAllByTestId('address-label')).toHaveLength(5);
+ });
+});
diff --git a/src/components/address-labels/mail-merge-tab.test.tsx b/src/components/address-labels/mail-merge-tab.test.tsx
new file mode 100644
index 0000000..ed73768
--- /dev/null
+++ b/src/components/address-labels/mail-merge-tab.test.tsx
@@ -0,0 +1,227 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, cleanup, screen, fireEvent, waitFor } from '@testing-library/react';
+import { MailMergeTab } from './mail-merge-tab';
+import type { LabelConfig, LabelData, SkipRecord } from '@/lib/dto';
+
+const mockGenerateSampleTemplate = vi.hoisted(() => vi.fn());
+const mockMergeTemplate = vi.hoisted(() => vi.fn());
+
+vi.mock('./sample-template', () => ({
+ generateSampleTemplate: mockGenerateSampleTemplate,
+}));
+
+vi.mock('./actions', () => ({
+ mergeTemplate: mockMergeTemplate,
+}));
+
+const mockCreateObjectURL = vi.hoisted(() => vi.fn(() => 'blob:mock-url'));
+const mockRevokeObjectURL = vi.hoisted(() => vi.fn());
+const mockAnchorClick = vi.hoisted(() => vi.fn());
+
+const printable: LabelData[] = [
+ { name: 'Jane Doe', addressLine1: '123 Main St', city: 'Chicago', state: 'IL', postalCode: '60601' },
+];
+const skipped: SkipRecord[] = [];
+const baseConfig: LabelConfig = {
+ stockId: '5160',
+ addressMode: 'household',
+ startPosition: 1,
+ includeMissingBarcodes: true,
+ barcodeFormat: 'postnet',
+ mailerId: '',
+ serviceType: '040',
+};
+
+function makeDocxFile(name: string, sizeBytes: number): File {
+ const content = new Uint8Array(Math.max(sizeBytes, 1));
+ const file = new File([content], name, {
+ type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ });
+ Object.defineProperty(file, 'size', { value: sizeBytes });
+ return file;
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ URL.createObjectURL = mockCreateObjectURL;
+ URL.revokeObjectURL = mockRevokeObjectURL;
+ HTMLAnchorElement.prototype.click = mockAnchorClick;
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+describe('MailMergeTab', () => {
+ it('renders the summary and a disabled Merge button with no template selected', () => {
+ render( );
+ expect(screen.getByText('1 label ready to print')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /merge & download/i })).toBeDisabled();
+ });
+
+ it('downloads a sample template on click and revokes the object URL after the timeout', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ mockGenerateSampleTemplate.mockResolvedValue(Buffer.from('sample-docx').toString('base64'));
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /download sample template/i }));
+
+ await waitFor(() => expect(mockCreateObjectURL).toHaveBeenCalled());
+ expect(mockAnchorClick).toHaveBeenCalled();
+
+ vi.advanceTimersByTime(1000);
+ expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
+ vi.useRealTimers();
+ });
+
+ it('shows an error when sample template generation fails', async () => {
+ mockGenerateSampleTemplate.mockRejectedValue(new Error('template gen failed'));
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /download sample template/i }));
+
+ expect(await screen.findByText('template gen failed')).toBeInTheDocument();
+ });
+
+ it('shows a fallback error message for a non-Error sample template rejection', async () => {
+ mockGenerateSampleTemplate.mockRejectedValue('nope');
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /download sample template/i }));
+
+ expect(await screen.findByText('Failed to generate template')).toBeInTheDocument();
+ });
+
+ it('rejects a non-.docx file', async () => {
+ render( );
+ const input = screen.getByLabelText(/upload your template/i);
+ const file = new File(['x'], 'template.txt', { type: 'text/plain' });
+
+ fireEvent.change(input, { target: { files: [file] } });
+
+ expect(await screen.findByText('Please select a .docx file')).toBeInTheDocument();
+ });
+
+ it('rejects a file over the 5MB limit', async () => {
+ render( );
+ const input = screen.getByLabelText(/upload your template/i);
+ const file = makeDocxFile('big.docx', 6 * 1024 * 1024);
+
+ fireEvent.change(input, { target: { files: [file] } });
+
+ expect(await screen.findByText('Template file must be under 5MB')).toBeInTheDocument();
+ });
+
+ it('accepts a valid .docx file, shows its name, and enables Merge', async () => {
+ render( );
+ const input = screen.getByLabelText(/upload your template/i);
+ const file = makeDocxFile('template.docx', 1024);
+
+ fireEvent.change(input, { target: { files: [file] } });
+
+ await waitFor(() => expect(screen.getByText(/template\.docx/)).toBeInTheDocument());
+ await waitFor(() => expect(screen.getByRole('button', { name: /merge & download/i })).toBeEnabled());
+ });
+
+ it('shows an error and clears the selection when reading the template file fails', async () => {
+ const OriginalFileReader = globalThis.FileReader;
+ class FailingFileReader {
+ onerror: (() => void) | null = null;
+ onload: (() => void) | null = null;
+ readAsDataURL() {
+ this.onerror?.();
+ }
+ }
+ // @ts-expect-error -- minimal stub is sufficient for the code under test
+ globalThis.FileReader = FailingFileReader;
+
+ render( );
+ const input = screen.getByLabelText(/upload your template/i);
+ const file = makeDocxFile('template.docx', 1024);
+
+ fireEvent.change(input, { target: { files: [file] } });
+
+ expect(await screen.findByText('Failed to read file')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /merge & download/i })).toBeDisabled();
+
+ globalThis.FileReader = OriginalFileReader;
+ });
+
+ it('clears the selected file when the file input is cleared', () => {
+ render( );
+ const input = screen.getByLabelText(/upload your template/i);
+
+ fireEvent.change(input, { target: { files: [] } });
+
+ expect(screen.getByRole('button', { name: /merge & download/i })).toBeDisabled();
+ });
+
+ it('requires a valid mailerId before merging when barcodeFormat is imb', async () => {
+ render(
+
+ );
+ const input = screen.getByLabelText(/upload your template/i);
+ const file = makeDocxFile('template.docx', 1024);
+ fireEvent.change(input, { target: { files: [file] } });
+ await waitFor(() => expect(screen.getByRole('button', { name: /merge & download/i })).toBeEnabled());
+
+ fireEvent.click(screen.getByRole('button', { name: /merge & download/i }));
+
+ expect(await screen.findByText('IMb requires a 6 or 9 digit USPS Mailer ID')).toBeInTheDocument();
+ expect(mockMergeTemplate).not.toHaveBeenCalled();
+ });
+
+ it('merges and downloads the result on success', async () => {
+ mockMergeTemplate.mockResolvedValue({
+ success: true,
+ data: Buffer.from('merged-doc').toString('base64'),
+ });
+ render( );
+ const input = screen.getByLabelText(/upload your template/i);
+ const file = makeDocxFile('template.docx', 1024);
+ fireEvent.change(input, { target: { files: [file] } });
+ await waitFor(() => expect(screen.getByRole('button', { name: /merge & download/i })).toBeEnabled());
+
+ fireEvent.click(screen.getByRole('button', { name: /merge & download/i }));
+
+ await waitFor(() => expect(mockMergeTemplate).toHaveBeenCalled());
+ await waitFor(() => expect(mockAnchorClick).toHaveBeenCalled());
+ });
+
+ it('shows the server error when mergeTemplate returns success: false', async () => {
+ mockMergeTemplate.mockResolvedValue({ success: false, error: 'Template error: bad tag' });
+ render( );
+ const input = screen.getByLabelText(/upload your template/i);
+ const file = makeDocxFile('template.docx', 1024);
+ fireEvent.change(input, { target: { files: [file] } });
+ await waitFor(() => expect(screen.getByRole('button', { name: /merge & download/i })).toBeEnabled());
+
+ fireEvent.click(screen.getByRole('button', { name: /merge & download/i }));
+
+ expect(await screen.findByText('Template error: bad tag')).toBeInTheDocument();
+ });
+
+ it('shows a fallback error message when mergeTemplate rejects with a non-Error', async () => {
+ mockMergeTemplate.mockRejectedValue('boom');
+ render( );
+ const input = screen.getByLabelText(/upload your template/i);
+ const file = makeDocxFile('template.docx', 1024);
+ fireEvent.change(input, { target: { files: [file] } });
+ await waitFor(() => expect(screen.getByRole('button', { name: /merge & download/i })).toBeEnabled());
+
+ fireEvent.click(screen.getByRole('button', { name: /merge & download/i }));
+
+ expect(await screen.findByText('Merge failed')).toBeInTheDocument();
+ });
+
+ it('does nothing when Merge is clicked with no printable labels', () => {
+ render( );
+ // Merge button stays disabled since printable.length === 0
+ expect(screen.getByRole('button', { name: /merge & download/i })).toBeDisabled();
+ });
+});
diff --git a/src/components/address-labels/postnet-barcode.test.tsx b/src/components/address-labels/postnet-barcode.test.tsx
new file mode 100644
index 0000000..9049b25
--- /dev/null
+++ b/src/components/address-labels/postnet-barcode.test.tsx
@@ -0,0 +1,56 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, cleanup } from '@testing-library/react';
+
+/**
+ * `View` is a react-pdf primitive with no DOM equivalent. Mapping it to the
+ * literal string 'View' made React treat it as a capitalised unknown HTML tag
+ * and log two warnings per rendered element — ~800 lines of noise across the
+ * suite for a single 65-bar barcode.
+ *
+ * The tag name needs a dash: React renders a hyphenated name as a custom
+ * element without complaint, whereas any undashed unknown tag (`view`
+ * included) still draws "The tag is unrecognized in this browser".
+ */
+vi.mock('@react-pdf/renderer', () => ({
+ View: 'rpdf-view',
+}));
+
+import { PostnetBarcode } from './postnet-barcode';
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+describe('PostnetBarcode', () => {
+ it('renders null for invalid JSON', () => {
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders null when parsed value is not an array', () => {
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders null when parsed array is empty', () => {
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders one bar per entry for a valid tall/short array', () => {
+ const bars = ['tall', 'short', 'tall', 'short', 'tall'];
+ const { container } = render( );
+ // outer View + one wrapper View per bar + one inner bar View per bar
+ const views = container.querySelectorAll('rpdf-view');
+ expect(views.length).toBe(1 + bars.length * 2);
+ });
+
+ it('applies custom width and height', () => {
+ const bars = ['tall', 'short'];
+ const { container } = render(
+
+ );
+ expect(container.querySelector('rpdf-view')).not.toBeNull();
+ });
+});
diff --git a/src/components/address-labels/sample-template.test.ts b/src/components/address-labels/sample-template.test.ts
new file mode 100644
index 0000000..2dc2e0c
--- /dev/null
+++ b/src/components/address-labels/sample-template.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import PizZip from 'pizzip';
+
+const mockRequireSecurityRole = vi.hoisted(() => vi.fn());
+
+vi.mock('@/services/authorizationService', () => ({
+ AuthorizationService: {
+ getInstance: () => ({
+ requireSecurityRole: mockRequireSecurityRole,
+ }),
+ },
+ UnauthorizedError: class UnauthorizedError extends Error {},
+}));
+
+import { generateSampleTemplate } from './sample-template';
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('generateSampleTemplate', () => {
+ beforeEach(() => {
+ mockRequireSecurityRole.mockReset();
+ mockRequireSecurityRole.mockResolvedValue(42);
+ });
+
+ it('authorizes a Contacts read before building the template', async () => {
+ await generateSampleTemplate();
+ expect(mockRequireSecurityRole).toHaveBeenCalledWith({
+ table: 'Contacts',
+ operation: 'read',
+ });
+ });
+
+ it('rejects when the security role gate refuses', async () => {
+ mockRequireSecurityRole.mockRejectedValueOnce(new Error('Unauthorized'));
+ await expect(generateSampleTemplate()).rejects.toThrow('Unauthorized');
+ });
+
+ it('returns a base64-encoded .docx zip containing the merge tokens', async () => {
+ const base64 = await generateSampleTemplate();
+ expect(typeof base64).toBe('string');
+ expect(base64.length).toBeGreaterThan(0);
+
+ const buffer = Buffer.from(base64, 'base64');
+ // A valid .docx is a zip archive — verify it parses and carries the
+ // expected merge-token content in its main document part.
+ const zip = new PizZip(buffer);
+ const documentXml = zip.file('word/document.xml')?.asText() ?? '';
+ expect(documentXml).toContain('{#addresses}');
+ expect(documentXml).toContain('{Name}');
+ expect(documentXml).toContain('{AddressLine1}');
+ expect(documentXml).toContain('{#AddressLine2}');
+ expect(documentXml).toContain('{City}, {State}');
+ expect(documentXml).toContain('{PostalCode}');
+ expect(documentXml).toContain('{%Barcode}');
+ expect(documentXml).toContain('{#isNotLast}');
+ expect(documentXml).toContain('{/addresses}');
+ });
+});
diff --git a/src/components/address-labels/word-document.test.ts b/src/components/address-labels/word-document.test.ts
index e58844b..e8c4853 100644
--- a/src/components/address-labels/word-document.test.ts
+++ b/src/components/address-labels/word-document.test.ts
@@ -59,7 +59,7 @@ describe('buildWordDocument', () => {
...baseLabel,
barType: 'postnet',
barStates: JSON.stringify(
- Array.from({ length: 32 }, (_, i) => (i % 2 ? 'tall' : 'short') as const)
+ Array.from({ length: 32 }, (_, i) => (i % 2 ? 'tall' : 'short'))
),
};
const doc = buildWordDocument([label], stock, 1);
diff --git a/src/components/dev-panel/dev-panel.test.tsx b/src/components/dev-panel/dev-panel.test.tsx
index 1124035..1d0f2e5 100644
--- a/src/components/dev-panel/dev-panel.test.tsx
+++ b/src/components/dev-panel/dev-panel.test.tsx
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, act } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import type { ToolParams } from "@/lib/tool-params";
// Mock all sub-panels — DevPanel tests should not depend on server actions.
@@ -13,10 +14,18 @@ vi.mock("./panels/contact-records-panel", () => ({
ContactRecordsPanel: () =>
,
}));
vi.mock("./panels/user-tools-panel", () => ({
- UserToolsPanel: () =>
,
+ UserToolsPanel: ({ refreshKey }: { refreshKey?: number }) => (
+ {refreshKey}
+ ),
}));
vi.mock("./panels/deploy-tool-panel", () => ({
- DeployToolPanel: () =>
,
+ DeployToolPanel: ({ onDeployed }: { onDeployed?: () => void }) => (
+
+
+ simulate deploy
+
+
+ ),
}));
import { DevPanel } from "./dev-panel";
@@ -97,4 +106,42 @@ describe("DevPanel", () => {
expect(screen.getByTestId("dev-panel")).toBeInTheDocument();
expect(screen.queryByTestId("dev-panel-body")).not.toBeInTheDocument();
});
+
+ it("swallows localStorage write errors when toggled", async () => {
+ const user = userEvent.setup();
+ vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
+ throw new Error("quota exceeded");
+ });
+ render( );
+ await act(async () => {});
+ const button = screen.getByRole("button", { name: /expand dev panel/i });
+ await user.click(button);
+ expect(screen.getByTestId("dev-panel-body")).toBeInTheDocument();
+ });
+
+ it("summarizes selection and record params", async () => {
+ render( );
+ await act(async () => {});
+ expect(screen.getByText("page 292 · selection 5 · record 10")).toBeInTheDocument();
+ });
+
+ it("shows a placeholder summary when no params are set", async () => {
+ render( );
+ await act(async () => {});
+ expect(screen.getByText("no params")).toBeInTheDocument();
+ });
+
+ it("bumps the user-tools refresh key when DeployToolPanel reports a deploy", async () => {
+ const user = userEvent.setup();
+ render( );
+ await act(async () => {});
+ // Open the panel body so the child panels render.
+ await user.click(screen.getByRole("button", { name: /expand dev panel/i }));
+
+ expect(screen.getByTestId("user-tools-panel")).toHaveTextContent("0");
+
+ await user.click(screen.getByRole("button", { name: /simulate deploy/i }));
+
+ expect(screen.getByTestId("user-tools-panel")).toHaveTextContent("1");
+ });
});
diff --git a/src/components/dev-panel/panels/contact-records-actions.test.ts b/src/components/dev-panel/panels/contact-records-actions.test.ts
new file mode 100644
index 0000000..30d9651
--- /dev/null
+++ b/src/components/dev-panel/panels/contact-records-actions.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+const mockRequireDevSession = vi.hoisted(() => vi.fn());
+const mockResolveContactIds = vi.hoisted(() => vi.fn());
+
+vi.mock('./require-dev-session', () => ({
+ requireDevSession: mockRequireDevSession,
+}));
+
+vi.mock('@/services/toolService', () => ({
+ ToolService: {
+ getInstance: vi.fn().mockResolvedValue({
+ resolveContactIds: mockResolveContactIds,
+ }),
+ },
+}));
+
+import { resolveContactRecords } from './contact-records-actions';
+
+describe('resolveContactRecords', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockRequireDevSession.mockResolvedValue({
+ user: { id: 'internal-id', userGuid: '550e8400-e29b-41d4-a716-446655440000' },
+ });
+ });
+
+ it('gates on requireDevSession before delegating to the service', async () => {
+ const expected = {
+ tableName: 'Contacts',
+ primaryKey: 'Contact_ID',
+ contactIdField: 'Contact_ID',
+ records: [{ recordId: 1, contactId: 1 }],
+ };
+ mockResolveContactIds.mockResolvedValueOnce(expected);
+
+ const result = await resolveContactRecords('Contacts', 'Contact_ID', 'Contact_ID', [1, 2, 3]);
+
+ expect(mockRequireDevSession).toHaveBeenCalledWith('Dev panel');
+ expect(mockResolveContactIds).toHaveBeenCalledWith('Contacts', 'Contact_ID', 'Contact_ID', [1, 2, 3]);
+ expect(result).toEqual(expected);
+ });
+
+ it('propagates a refusal from requireDevSession without calling the service', async () => {
+ mockRequireDevSession.mockRejectedValueOnce(new Error('Unauthorized - Missing user session data'));
+
+ await expect(
+ resolveContactRecords('Contacts', 'Contact_ID', 'Contact_ID', [1])
+ ).rejects.toThrow('Unauthorized - Missing user session data');
+ expect(mockResolveContactIds).not.toHaveBeenCalled();
+ });
+
+ it('propagates a refusal from the service-layer authorization gate', async () => {
+ mockResolveContactIds.mockRejectedValueOnce(new Error('Not authorized'));
+
+ await expect(
+ resolveContactRecords('Contacts', 'Contact_ID', 'Contact_ID', [1])
+ ).rejects.toThrow('Not authorized');
+ });
+});
diff --git a/src/components/dev-panel/panels/contact-records-panel.test.tsx b/src/components/dev-panel/panels/contact-records-panel.test.tsx
new file mode 100644
index 0000000..43472fd
--- /dev/null
+++ b/src/components/dev-panel/panels/contact-records-panel.test.tsx
@@ -0,0 +1,146 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, cleanup, waitFor } from "@testing-library/react";
+
+const mockResolveContactRecords = vi.hoisted(() => vi.fn());
+
+vi.mock("./contact-records-actions", () => ({
+ resolveContactRecords: mockResolveContactRecords,
+}));
+
+import { ContactRecordsPanel } from "./contact-records-panel";
+import type { ToolParams } from "@/lib/tool-params";
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+const pageData = {
+ Page_ID: 292,
+ Display_Name: "Contacts",
+ Singular_Name: "Contact",
+ Table_Name: "Contacts",
+ Primary_Key: "Contact_ID",
+ Contact_ID_Field: "Contact_ID",
+};
+
+describe("ContactRecordsPanel", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders nothing when pageData has no Contact_ID_Field", () => {
+ const params: ToolParams = { recordID: 1 };
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ expect(mockResolveContactRecords).not.toHaveBeenCalled();
+ });
+
+ it("renders nothing when there is neither a single record nor a selection", () => {
+ const params: ToolParams = { pageData };
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders nothing when recordID is 0 (falsy for hasSingleRecord) and no selection", () => {
+ const params: ToolParams = { pageData, recordID: 0 };
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("resolves a single record and shows the loading then loaded state", async () => {
+ mockResolveContactRecords.mockResolvedValueOnce({
+ tableName: "Contacts",
+ primaryKey: "Contact_ID",
+ contactIdField: "Contact_ID",
+ records: [{ recordId: 1, contactId: 100 }],
+ });
+
+ const params: ToolParams = { pageData, recordID: 1 };
+ render( );
+
+ expect(
+ screen.getByText("Development Mode - Loading Contact Records...")
+ ).toBeInTheDocument();
+ expect(screen.getByText(/Resolving Contact IDs from record/)).toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(screen.getByText("Development Mode - Contact Records")).toBeInTheDocument();
+ });
+ expect(screen.getByText("1 contact resolved")).toBeInTheDocument();
+ expect(mockResolveContactRecords).toHaveBeenCalledWith("Contacts", "Contact_ID", "Contact_ID", [1]);
+ });
+
+ it("resolves a selection of record IDs and pluralizes the count", async () => {
+ mockResolveContactRecords.mockResolvedValueOnce({
+ tableName: "Contacts",
+ primaryKey: "Contact_ID",
+ contactIdField: "Contact_ID",
+ records: [
+ { recordId: 1, contactId: 100 },
+ { recordId: 2, contactId: 200 },
+ ],
+ });
+
+ const params: ToolParams = { pageData };
+ render( );
+
+ expect(screen.getByText(/Resolving Contact IDs from selection/)).toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(screen.getByText("2 contacts resolved")).toBeInTheDocument();
+ });
+ expect(mockResolveContactRecords).toHaveBeenCalledWith("Contacts", "Contact_ID", "Contact_ID", [1, 2]);
+ });
+
+ it("shows an error state when resolution fails", async () => {
+ mockResolveContactRecords.mockRejectedValueOnce(new Error("resolve failed"));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Development Mode - Contact Records Error")).toBeInTheDocument();
+ });
+ expect(screen.getByText("resolve failed")).toBeInTheDocument();
+ });
+
+ it("shows a generic error message for a non-Error rejection", async () => {
+ mockResolveContactRecords.mockRejectedValueOnce("nope");
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Failed to resolve contact records")).toBeInTheDocument();
+ });
+ });
+
+ it("truncates displayed contact IDs beyond 5 and shows a +N more indicator", async () => {
+ mockResolveContactRecords.mockResolvedValueOnce({
+ tableName: "Contacts",
+ primaryKey: "Contact_ID",
+ contactIdField: "Contact_ID",
+ records: Array.from({ length: 7 }, (_, i) => ({ recordId: i + 1, contactId: 100 + i })),
+ });
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("+2 more")).toBeInTheDocument();
+ });
+ });
+
+ it("renders the raw JSON details for the resolved result", async () => {
+ mockResolveContactRecords.mockResolvedValueOnce({
+ tableName: "Contacts",
+ primaryKey: "Contact_ID",
+ contactIdField: "Contact_ID",
+ records: [{ recordId: 1, contactId: 100 }],
+ });
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("View Raw JSON")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/dev-panel/panels/deploy-tool-panel.test.tsx b/src/components/dev-panel/panels/deploy-tool-panel.test.tsx
new file mode 100644
index 0000000..93fd20a
--- /dev/null
+++ b/src/components/dev-panel/panels/deploy-tool-panel.test.tsx
@@ -0,0 +1,445 @@
+import { describe, it, expect, vi, beforeEach, beforeAll, afterEach } from "vitest";
+import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
+
+const mockUsePathname = vi.hoisted(() => vi.fn());
+const mockDeployToolAction = vi.hoisted(() => vi.fn());
+const mockGetDeployToolEnvStatusAction = vi.hoisted(() => vi.fn());
+const mockListPagesAction = vi.hoisted(() => vi.fn());
+const mockListRolesAction = vi.hoisted(() => vi.fn());
+
+vi.mock("next/navigation", () => ({
+ usePathname: mockUsePathname,
+}));
+
+vi.mock("./deploy-tool-actions", () => ({
+ deployToolAction: mockDeployToolAction,
+ getDeployToolEnvStatusAction: mockGetDeployToolEnvStatusAction,
+ listPagesAction: mockListPagesAction,
+ listRolesAction: mockListRolesAction,
+}));
+
+import { DeployToolPanel } from "./deploy-tool-panel";
+
+// cmdk scrolls the highlighted item into view; jsdom doesn't implement it.
+beforeAll(() => {
+ Element.prototype.scrollIntoView = vi.fn();
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+});
+
+const okEnvStatus = { hasDevCreds: true, missing: [] };
+const badEnvStatus = {
+ hasDevCreds: false,
+ missing: ["MINISTRY_PLATFORM_DEV_CLIENT_ID", "MINISTRY_PLATFORM_DEV_CLIENT_SECRET"],
+};
+
+const samplePages = [
+ { Page_ID: 292, Display_Name: "Contacts", Table_Name: "Contacts" },
+];
+const sampleRoles = [
+ { Role_ID: 1, Role_Name: "Administrators" },
+ { Role_ID: 2, Role_Name: "Staff" },
+];
+
+const sampleResult = {
+ tool: {
+ Tool_ID: 42,
+ Tool_Name: "FooTool",
+ Description: "A test tool",
+ Launch_Page: "https://tools.example.org/tools/foo",
+ Launch_with_Credentials: true,
+ Launch_with_Parameters: true,
+ Launch_in_New_Tab: false,
+ Show_On_Mobile: false,
+ },
+ pages: [{ Page_ID: 292 }],
+ roles: [{ Role_ID: 1 }],
+};
+
+async function fillRequiredFields() {
+ const toolName = screen.getByPlaceholderText("AddressLabelPrinter");
+ fireEvent.change(toolName, { target: { value: "FooTool" } });
+ const launchPage = screen.getByPlaceholderText(
+ "https://tools.example.org/tools/address-labels"
+ );
+ fireEvent.change(launchPage, { target: { value: "https://tools.example.org/tools/foo" } });
+}
+
+describe("DeployToolPanel", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUsePathname.mockReturnValue("/tools/address-labels");
+ mockListPagesAction.mockResolvedValue([]);
+ mockListRolesAction.mockResolvedValue([]);
+ mockGetDeployToolEnvStatusAction.mockResolvedValue(okEnvStatus);
+ });
+
+ it("renders the collapsed summary heading", async () => {
+ render( );
+ expect(
+ screen.getByText("Development Mode - Deploy Tool to Ministry Platform")
+ ).toBeInTheDocument();
+ await waitFor(() => expect(mockGetDeployToolEnvStatusAction).toHaveBeenCalled());
+ });
+
+ it("prefills Launch Page from NEXT_PUBLIC_PROD_URL + pathname", async () => {
+ vi.stubEnv("NEXT_PUBLIC_PROD_URL", "https://tools.example.org/");
+ render( );
+ const launchPage = await screen.findByPlaceholderText(
+ "https://tools.example.org/tools/address-labels"
+ );
+ expect((launchPage as HTMLInputElement).value).toBe(
+ "https://tools.example.org/tools/address-labels"
+ );
+ });
+
+ it("leaves Launch Page blank when NEXT_PUBLIC_PROD_URL is unset", async () => {
+ vi.stubEnv("NEXT_PUBLIC_PROD_URL", "");
+ render( );
+ await waitFor(() => expect(mockGetDeployToolEnvStatusAction).toHaveBeenCalled());
+ const launchPage = screen.getByPlaceholderText(
+ "https://tools.example.org/tools/address-labels"
+ ) as HTMLInputElement;
+ expect(launchPage.value).toBe("");
+ });
+
+ it("shows no warning banner and an enabled submit button when dev creds are present", async () => {
+ render( );
+ await waitFor(() => expect(mockGetDeployToolEnvStatusAction).toHaveBeenCalled());
+ expect(screen.queryByText("Dev credentials not configured")).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /deploy tool/i })).not.toBeDisabled();
+ });
+
+ it("shows a warning banner and disables submit when dev creds are missing", async () => {
+ mockGetDeployToolEnvStatusAction.mockResolvedValueOnce(badEnvStatus);
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Dev credentials not configured")).toBeInTheDocument();
+ });
+ expect(screen.getByText("MINISTRY_PLATFORM_DEV_CLIENT_ID")).toBeInTheDocument();
+ expect(screen.getByText("MINISTRY_PLATFORM_DEV_CLIENT_SECRET")).toBeInTheDocument();
+ expect(screen.getByText(/environment variables are missing/)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /deploy tool/i })).toBeDisabled();
+ });
+
+ it("shows singular wording when exactly one env var is missing", async () => {
+ mockGetDeployToolEnvStatusAction.mockResolvedValueOnce({
+ hasDevCreds: false,
+ missing: ["MINISTRY_PLATFORM_DEV_CLIENT_ID"],
+ });
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText(/environment variable is missing/)).toBeInTheDocument();
+ });
+ });
+
+ it("falls back to a fully-missing env status when the status check itself rejects", async () => {
+ mockGetDeployToolEnvStatusAction.mockRejectedValueOnce(new Error("Unauthorized"));
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Dev credentials not configured")).toBeInTheDocument();
+ });
+ expect(screen.getByRole("button", { name: /deploy tool/i })).toBeDisabled();
+ });
+
+ it("submits the form and shows the deployed result", async () => {
+ mockDeployToolAction.mockResolvedValueOnce(sampleResult);
+ const onDeployed = vi.fn();
+ render( );
+ await waitFor(() => expect(mockGetDeployToolEnvStatusAction).toHaveBeenCalled());
+
+ await fillRequiredFields();
+ fireEvent.change(screen.getByPlaceholderText("https://tools.example.org/tools/address-labels"), {
+ target: { value: "https://tools.example.org/tools/foo" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: /deploy tool/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText("FooTool")).toBeInTheDocument();
+ });
+ expect(screen.getByText(/1 page mapping/)).toBeInTheDocument();
+ expect(screen.getByText(/1 role grant/)).toBeInTheDocument();
+ expect(onDeployed).toHaveBeenCalled();
+ expect(mockDeployToolAction).toHaveBeenCalledWith(
+ expect.objectContaining({ toolName: "FooTool", launchPage: "https://tools.example.org/tools/foo" })
+ );
+ });
+
+ it("shows an error message when deployToolAction rejects with an Error", async () => {
+ mockDeployToolAction.mockRejectedValueOnce(new Error("SP failed"));
+ render( );
+ await waitFor(() => expect(mockGetDeployToolEnvStatusAction).toHaveBeenCalled());
+
+ await fillRequiredFields();
+ fireEvent.click(screen.getByRole("button", { name: /deploy tool/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText("SP failed")).toBeInTheDocument();
+ });
+ });
+
+ it("shows a generic error message for a non-Error rejection", async () => {
+ mockDeployToolAction.mockRejectedValueOnce("nope");
+ render( );
+ await waitFor(() => expect(mockGetDeployToolEnvStatusAction).toHaveBeenCalled());
+
+ await fillRequiredFields();
+ fireEvent.click(screen.getByRole("button", { name: /deploy tool/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText("Unknown error")).toBeInTheDocument();
+ });
+ });
+
+ it("shows a spinner label while a submission is in flight", async () => {
+ let resolveDeploy: (v: typeof sampleResult) => void;
+ mockDeployToolAction.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveDeploy = resolve;
+ })
+ );
+ render( );
+ await waitFor(() => expect(mockGetDeployToolEnvStatusAction).toHaveBeenCalled());
+
+ await fillRequiredFields();
+ fireEvent.click(screen.getByRole("button", { name: /deploy tool/i }));
+
+ expect(screen.getByText("Deploying…")).toBeInTheDocument();
+
+ await waitFor(() => {
+ resolveDeploy!(sampleResult);
+ });
+ });
+
+ it("toggles each flag checkbox", async () => {
+ render( );
+ await waitFor(() => expect(mockGetDeployToolEnvStatusAction).toHaveBeenCalled());
+
+ const credsCheckbox = document.getElementById("flag-creds")!;
+ const paramsCheckbox = document.getElementById("flag-params")!;
+ const newtabCheckbox = document.getElementById("flag-newtab")!;
+ const mobileCheckbox = document.getElementById("flag-mobile")!;
+
+ expect(credsCheckbox).toHaveAttribute("data-state", "unchecked");
+ expect(paramsCheckbox).toHaveAttribute("data-state", "checked");
+
+ fireEvent.click(credsCheckbox);
+ fireEvent.click(paramsCheckbox);
+ fireEvent.click(newtabCheckbox);
+ fireEvent.click(mobileCheckbox);
+
+ expect(credsCheckbox).toHaveAttribute("data-state", "checked");
+ expect(paramsCheckbox).toHaveAttribute("data-state", "unchecked");
+ expect(newtabCheckbox).toHaveAttribute("data-state", "checked");
+ expect(mobileCheckbox).toHaveAttribute("data-state", "checked");
+ });
+
+ it("caps Tool Name, Description and Additional Data at their max lengths", async () => {
+ render( );
+ await waitFor(() => expect(mockGetDeployToolEnvStatusAction).toHaveBeenCalled());
+
+ const toolName = screen.getByPlaceholderText("AddressLabelPrinter") as HTMLInputElement;
+ fireEvent.change(toolName, { target: { value: "x".repeat(40) } });
+ expect(toolName.value).toHaveLength(30);
+
+ const description = screen.getByPlaceholderText(
+ "Prints address labels with IMb barcodes"
+ ) as HTMLTextAreaElement;
+ fireEvent.change(description, { target: { value: "y".repeat(150) } });
+ expect(description.value).toHaveLength(100);
+
+ const additionalData = screen.getByPlaceholderText(
+ "Optional query string, key, etc."
+ ) as HTMLTextAreaElement;
+ fireEvent.change(additionalData, { target: { value: "z".repeat(100) } });
+ expect(additionalData.value).toHaveLength(65);
+ });
+
+ it("selects and removes a page from the Pages lookup", async () => {
+ mockListPagesAction.mockResolvedValue(samplePages);
+ render( );
+ await waitFor(() => expect(mockGetDeployToolEnvStatusAction).toHaveBeenCalled());
+
+ expect(screen.getAllByText("No items selected").length).toBe(2);
+
+ const addButtons = screen.getAllByRole("button", { name: "Add…" });
+ fireEvent.click(addButtons[0]); // Pages popover
+
+ await waitFor(() => {
+ expect(mockListPagesAction).toHaveBeenCalled();
+ });
+
+ const option = await screen.findByText("Contacts (#292)");
+ fireEvent.click(option);
+
+ await waitFor(() => {
+ expect(screen.getAllByText("Contacts (#292)").length).toBeGreaterThan(0);
+ });
+
+ // Remove the chip.
+ fireEvent.click(screen.getByRole("button", { name: "Remove Contacts (#292)" }));
+
+ await waitFor(() => {
+ expect(
+ screen.queryByRole("button", { name: "Remove Contacts (#292)" })
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it("filters the Pages lookup as the user types a search term", async () => {
+ mockListPagesAction.mockResolvedValue(samplePages);
+ render( );
+ await waitFor(() => expect(mockGetDeployToolEnvStatusAction).toHaveBeenCalled());
+
+ const addButtons = screen.getAllByRole("button", { name: "Add…" });
+ fireEvent.click(addButtons[0]);
+
+ await waitFor(() => expect(mockListPagesAction).toHaveBeenCalledWith(""));
+
+ const search = screen.getByPlaceholderText("Search pages…");
+ fireEvent.change(search, { target: { value: "Cont" } });
+
+ await waitFor(() => {
+ expect(mockListPagesAction).toHaveBeenCalledWith("Cont");
+ });
+ });
+
+ it("shows a loading indicator, then 'No results.' when the Pages lookup returns nothing", async () => {
+ let resolvePages: (v: typeof samplePages) => void;
+ mockListPagesAction.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolvePages = resolve;
+ })
+ );
+ render( );
+ await waitFor(() => expect(mockGetDeployToolEnvStatusAction).toHaveBeenCalled());
+
+ const addButtons = screen.getAllByRole("button", { name: "Add…" });
+ fireEvent.click(addButtons[0]);
+
+ expect(await screen.findByText("Loading…")).toBeInTheDocument();
+
+ await waitFor(() => {
+ resolvePages!([]);
+ });
+
+ expect(await screen.findByText("No results.")).toBeInTheDocument();
+ });
+
+ it("shows a fetch error when the Pages lookup rejects", async () => {
+ mockListPagesAction.mockRejectedValueOnce(new Error("lookup failed"));
+ render( );
+ await waitFor(() => expect(mockGetDeployToolEnvStatusAction).toHaveBeenCalled());
+
+ const addButtons = screen.getAllByRole("button", { name: "Add…" });
+ fireEvent.click(addButtons[0]);
+
+ expect(await screen.findByText("lookup failed")).toBeInTheDocument();
+ });
+
+ it("shows a generic fetch error for a non-Error Pages lookup rejection", async () => {
+ mockListPagesAction.mockRejectedValueOnce("nope");
+ render( );
+ await waitFor(() => expect(mockGetDeployToolEnvStatusAction).toHaveBeenCalled());
+
+ const addButtons = screen.getAllByRole("button", { name: "Add…" });
+ fireEvent.click(addButtons[0]);
+
+ expect(await screen.findByText("Load failed")).toBeInTheDocument();
+ });
+
+ it("auto-selects the Administrators role and prevents its removal", async () => {
+ mockListRolesAction.mockResolvedValue(sampleRoles);
+ render( );
+ await waitFor(() => expect(mockGetDeployToolEnvStatusAction).toHaveBeenCalled());
+
+ await waitFor(() => {
+ expect(mockListRolesAction).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Administrators (#1)")).toBeInTheDocument();
+ });
+ // Locked chip has no remove button.
+ expect(
+ screen.queryByRole("button", { name: "Remove Administrators (#1)" })
+ ).not.toBeInTheDocument();
+ });
+
+ it("adds and removes a non-locked role", async () => {
+ mockListRolesAction.mockResolvedValue(sampleRoles);
+ render( );
+ await waitFor(() => expect(mockGetDeployToolEnvStatusAction).toHaveBeenCalled());
+
+ await waitFor(() => {
+ expect(screen.getByText("Administrators (#1)")).toBeInTheDocument();
+ });
+
+ const addButtons = screen.getAllByRole("button", { name: "Add…" });
+ fireEvent.click(addButtons[1]); // Roles popover
+
+ const staffOption = await screen.findByText("Staff (#2)");
+ fireEvent.click(staffOption);
+
+ await waitFor(() => {
+ expect(screen.getAllByText("Staff (#2)").length).toBeGreaterThan(0);
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Remove Staff (#2)" }));
+
+ await waitFor(() => {
+ expect(screen.queryByRole("button", { name: "Remove Staff (#2)" })).not.toBeInTheDocument();
+ });
+ });
+
+ it("dedupes role IDs before submitting", async () => {
+ mockListRolesAction.mockResolvedValue(sampleRoles);
+ mockDeployToolAction.mockResolvedValueOnce(sampleResult);
+ render( );
+ await waitFor(() => expect(mockGetDeployToolEnvStatusAction).toHaveBeenCalled());
+
+ await waitFor(() => {
+ expect(screen.getByText("Administrators (#1)")).toBeInTheDocument();
+ });
+
+ await fillRequiredFields();
+ fireEvent.click(screen.getByRole("button", { name: /deploy tool/i }));
+
+ await waitFor(() => {
+ expect(mockDeployToolAction).toHaveBeenCalledWith(
+ expect.objectContaining({ roleIds: [1] })
+ );
+ });
+ });
+
+ it("resets Launch Page when the route changes", async () => {
+ vi.stubEnv("NEXT_PUBLIC_PROD_URL", "https://tools.example.org");
+ mockUsePathname.mockReturnValue("/tools/one");
+ const { rerender } = render( );
+ await waitFor(() => expect(mockGetDeployToolEnvStatusAction).toHaveBeenCalled());
+
+ expect(
+ (screen.getByPlaceholderText(
+ "https://tools.example.org/tools/address-labels"
+ ) as HTMLInputElement).value
+ ).toBe("https://tools.example.org/tools/one");
+
+ mockUsePathname.mockReturnValue("/tools/two");
+ rerender( );
+
+ await waitFor(() => {
+ expect(
+ (screen.getByPlaceholderText(
+ "https://tools.example.org/tools/address-labels"
+ ) as HTMLInputElement).value
+ ).toBe("https://tools.example.org/tools/two");
+ });
+ });
+});
diff --git a/src/components/dev-panel/panels/params-panel.test.tsx b/src/components/dev-panel/panels/params-panel.test.tsx
new file mode 100644
index 0000000..bcb2431
--- /dev/null
+++ b/src/components/dev-panel/panels/params-panel.test.tsx
@@ -0,0 +1,92 @@
+import { describe, it, expect, afterEach } from "vitest";
+import { render, screen, cleanup } from "@testing-library/react";
+import { ParamsPanel } from "./params-panel";
+import type { ToolParams } from "@/lib/tool-params";
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("ParamsPanel", () => {
+ it("renders the no-parameters message when every param is undefined", () => {
+ render( );
+ expect(screen.getByText("Development Mode - No Parameters")).toBeInTheDocument();
+ });
+
+ it("renders param cards and marks new-record / edit-mode state", () => {
+ const params: ToolParams = { pageID: 292, recordID: -1 };
+ render( );
+
+ expect(screen.getByText("✓ New Record")).toBeInTheDocument();
+ expect(screen.getByText("○ Edit Mode")).toBeInTheDocument();
+ expect(screen.getByText("pageID")).toBeInTheDocument();
+ expect(screen.getByText("MP PageID")).toBeInTheDocument();
+ });
+
+ it("marks edit mode (not new record) for a positive recordID", () => {
+ const params: ToolParams = { recordID: 42 };
+ render( );
+
+ expect(screen.getByText("○ New Record")).toBeInTheDocument();
+ expect(screen.getByText("✓ Edit Mode")).toBeInTheDocument();
+ });
+
+ it("shows pageID alongside the page's Table_Name from pageData", () => {
+ const params: ToolParams = {
+ pageID: 292,
+ pageData: {
+ Page_ID: 292,
+ Display_Name: "Contacts",
+ Singular_Name: "Contact",
+ Table_Name: "Contacts",
+ Primary_Key: "Contact_ID",
+ Contact_ID_Field: "Contact_ID",
+ },
+ };
+ render( );
+
+ expect(screen.getByText("292 - Contacts")).toBeInTheDocument();
+ expect(screen.getByText("Contact_ID")).toBeInTheDocument();
+ expect(screen.getByText("Contact FK column for this page")).toBeInTheDocument();
+ });
+
+ it("skips rendering a card for pageData and recordDescription keys", () => {
+ const params: ToolParams = {
+ recordDescription: "Some description",
+ pageData: {
+ Page_ID: 1,
+ Display_Name: "X",
+ Singular_Name: "X",
+ Table_Name: "X",
+ Primary_Key: "X_ID",
+ },
+ };
+ render( );
+
+ expect(screen.queryByText("recordDescription")).not.toBeInTheDocument();
+ expect(screen.queryByText("pageData")).not.toBeInTheDocument();
+ });
+
+ it("shows 'undefined' placeholder for params without a value", () => {
+ const params: ToolParams = { q: undefined, pageID: 1 };
+ render( );
+
+ // q is explicitly present as a key with an undefined value.
+ expect(screen.getByText("q")).toBeInTheDocument();
+ });
+
+ it("renders a numeric param value directly (not string-wrapped)", () => {
+ const params: ToolParams = { s: 7 };
+ render( );
+
+ expect(screen.getByText("7")).toBeInTheDocument();
+ expect(screen.getByText("Selection ID")).toBeInTheDocument();
+ });
+
+ it("renders the raw JSON details for the params", () => {
+ const params: ToolParams = { pageID: 292 };
+ render( );
+
+ expect(screen.getByText(/View Raw JSON/)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/dev-panel/panels/require-dev-session.test.ts b/src/components/dev-panel/panels/require-dev-session.test.ts
new file mode 100644
index 0000000..4757450
--- /dev/null
+++ b/src/components/dev-panel/panels/require-dev-session.test.ts
@@ -0,0 +1,88 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+const mockGetSession = vi.hoisted(() => vi.fn());
+
+vi.mock('@/lib/auth', () => ({
+ auth: {
+ api: {
+ getSession: mockGetSession,
+ },
+ },
+}));
+
+vi.mock('next/headers', () => ({
+ headers: vi.fn().mockResolvedValue(new Headers()),
+}));
+
+import { requireDevSession } from './require-dev-session';
+
+const validSession = {
+ user: { id: 'internal-id', userGuid: '550e8400-e29b-41d4-a716-446655440000' },
+};
+
+/**
+ * This is a documented authorization carve-out (CLAUDE.md rule 12): the
+ * dev panel is gated on NODE_ENV + a valid session here, and the services
+ * it calls perform their own `AuthorizationService.requireSecurityRole`
+ * gate. Both the allow and deny paths are exercised below.
+ */
+describe('requireDevSession', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.stubEnv('NODE_ENV', 'development');
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('throws in production before checking the session at all', async () => {
+ vi.stubEnv('NODE_ENV', 'production');
+
+ await expect(requireDevSession('Deploy Tool')).rejects.toThrow(
+ 'Deploy Tool is not available in production.'
+ );
+ expect(mockGetSession).not.toHaveBeenCalled();
+ });
+
+ it('uses the default feature label "Dev panel" in the production error', async () => {
+ vi.stubEnv('NODE_ENV', 'production');
+
+ await expect(requireDevSession()).rejects.toThrow(
+ 'Dev panel is not available in production.'
+ );
+ });
+
+ it('throws Unauthorized when there is no session', async () => {
+ mockGetSession.mockResolvedValueOnce(null);
+
+ await expect(requireDevSession()).rejects.toThrow(
+ 'Unauthorized - Missing user session data'
+ );
+ });
+
+ it('throws Unauthorized when session has no user id', async () => {
+ mockGetSession.mockResolvedValueOnce({ user: {} });
+
+ await expect(requireDevSession()).rejects.toThrow(
+ 'Unauthorized - Missing user session data'
+ );
+ });
+
+ it('returns the session when NODE_ENV is not production and a session exists', async () => {
+ mockGetSession.mockResolvedValueOnce(validSession);
+
+ const result = await requireDevSession();
+
+ expect(result).toEqual(validSession);
+ });
+
+ it('allows non-production, non-development NODE_ENV values (e.g. test)', async () => {
+ vi.stubEnv('NODE_ENV', 'test');
+ mockGetSession.mockResolvedValueOnce(validSession);
+
+ const result = await requireDevSession('Dev panel');
+
+ expect(result).toEqual(validSession);
+ });
+});
diff --git a/src/components/dev-panel/panels/selection-panel.test.tsx b/src/components/dev-panel/panels/selection-panel.test.tsx
new file mode 100644
index 0000000..120035e
--- /dev/null
+++ b/src/components/dev-panel/panels/selection-panel.test.tsx
@@ -0,0 +1,135 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, cleanup, act, waitFor } from "@testing-library/react";
+
+const mockResolveSelection = vi.hoisted(() => vi.fn());
+
+vi.mock("./selection-actions", () => ({
+ resolveSelection: mockResolveSelection,
+}));
+
+import { SelectionPanel } from "./selection-panel";
+import type { ToolParams } from "@/lib/tool-params";
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+describe("SelectionPanel", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders nothing when there is no selection param", () => {
+ const params: ToolParams = {};
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ expect(mockResolveSelection).not.toHaveBeenCalled();
+ });
+
+ it("renders nothing when s is present but pageID is missing", () => {
+ const params: ToolParams = { s: 5 };
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("renders nothing when s is 0", () => {
+ const params: ToolParams = { s: 0, pageID: 292 };
+ const { container } = render( );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("shows a loading state, then the resolved selection", async () => {
+ let resolvePromise: (value: { recordIds: number[]; count: number }) => void;
+ mockResolveSelection.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolvePromise = resolve;
+ })
+ );
+
+ const params: ToolParams = { s: 5, pageID: 292 };
+ render( );
+
+ expect(screen.getByText("Development Mode - Loading Selection...")).toBeInTheDocument();
+
+ await act(async () => {
+ resolvePromise!({ recordIds: [1, 2, 3], count: 3 });
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Development Mode - Selection Details")).toBeInTheDocument();
+ });
+ expect(screen.getByText("3 records in selection")).toBeInTheDocument();
+ });
+
+ it("calls onRecordIdsResolved with the resolved record IDs", async () => {
+ mockResolveSelection.mockResolvedValueOnce({ recordIds: [10, 20], count: 2 });
+ const onRecordIdsResolved = vi.fn();
+
+ render( );
+
+ await waitFor(() => {
+ expect(onRecordIdsResolved).toHaveBeenCalledWith([10, 20]);
+ });
+ });
+
+ it("shows an error state when resolution fails", async () => {
+ mockResolveSelection.mockRejectedValueOnce(new Error("boom"));
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Development Mode - Selection Error")).toBeInTheDocument();
+ });
+ expect(screen.getByText("boom")).toBeInTheDocument();
+ });
+
+ it("shows a generic error message for a non-Error rejection", async () => {
+ mockResolveSelection.mockRejectedValueOnce("not an error object");
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Failed to resolve selection")).toBeInTheDocument();
+ });
+ });
+
+ it("shows page name and table from pageData, and truncates to +N more", async () => {
+ mockResolveSelection.mockResolvedValueOnce({
+ recordIds: [1, 2, 3, 4, 5, 6, 7],
+ count: 7,
+ });
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getAllByText("Contacts").length).toBe(2);
+ });
+ expect(screen.getByText("+2 more")).toBeInTheDocument();
+ });
+
+ it("falls back to 'Page N' and 'N/A' when pageData is missing", async () => {
+ mockResolveSelection.mockResolvedValueOnce({ recordIds: [1], count: 1 });
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Page 292")).toBeInTheDocument();
+ });
+ expect(screen.getByText("N/A")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/dev-panel/panels/user-tools-panel.test.tsx b/src/components/dev-panel/panels/user-tools-panel.test.tsx
new file mode 100644
index 0000000..a0491ab
--- /dev/null
+++ b/src/components/dev-panel/panels/user-tools-panel.test.tsx
@@ -0,0 +1,128 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, cleanup, waitFor } from "@testing-library/react";
+
+const mockGetUserTools = vi.hoisted(() => vi.fn());
+const mockUsePathname = vi.hoisted(() => vi.fn());
+
+vi.mock("./user-tools-actions", () => ({
+ getUserTools: mockGetUserTools,
+}));
+
+vi.mock("next/navigation", () => ({
+ usePathname: mockUsePathname,
+}));
+
+import { UserToolsPanel } from "./user-tools-panel";
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+});
+
+describe("UserToolsPanel", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUsePathname.mockReturnValue("/tools/address-labels");
+ vi.stubEnv("NEXT_PUBLIC_PROD_URL", "https://tools.example.org");
+ });
+
+ it("shows a loading state while fetching", () => {
+ mockGetUserTools.mockReturnValueOnce(new Promise(() => {}));
+ render( );
+ expect(screen.getByText("Development Mode - Loading User Tools")).toBeInTheDocument();
+ });
+
+ it("shows an error state when the fetch rejects", async () => {
+ mockGetUserTools.mockRejectedValueOnce(new Error("boom"));
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Development Mode - Error Loading User Tools")).toBeInTheDocument();
+ });
+ expect(screen.getByText("boom")).toBeInTheDocument();
+ });
+
+ it("shows a generic error message for a non-Error rejection", async () => {
+ mockGetUserTools.mockRejectedValueOnce("nope");
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Unknown error")).toBeInTheDocument();
+ });
+ });
+
+ it("shows a no-tools message when the tool list is empty", async () => {
+ mockGetUserTools.mockResolvedValueOnce([]);
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Development Mode - No Tools Found")).toBeInTheDocument();
+ });
+ });
+
+ it("shows a missing-env-var message when NEXT_PUBLIC_PROD_URL is unset", async () => {
+ vi.stubEnv("NEXT_PUBLIC_PROD_URL", "");
+ mockGetUserTools.mockResolvedValueOnce(["/tools/foo"]);
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText("Development Mode - Missing Environment Variable")).toBeInTheDocument();
+ });
+ expect(screen.getByText(/authorized tool paths: 1/)).toBeInTheDocument();
+ });
+
+ it("shows authorized state and calls onAuthorizationChange(true) when the prod URL matches a tool path", async () => {
+ mockGetUserTools.mockResolvedValueOnce([
+ "https://tools.example.org/tools/address-labels?pageID=292",
+ ]);
+ const onAuthorizationChange = vi.fn();
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Development Mode - Your Authorized Tools \(1\)/)).toBeInTheDocument();
+ });
+ expect(onAuthorizationChange).toHaveBeenCalledWith(true);
+ expect(
+ screen.getByText("https://tools.example.org/tools/address-labels?pageID=292")
+ ).toBeInTheDocument();
+ });
+
+ it("shows unauthorized state and calls onAuthorizationChange(false) when no tool path matches", async () => {
+ mockGetUserTools.mockResolvedValueOnce(["https://tools.example.org/tools/other"]);
+ const onAuthorizationChange = vi.fn();
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText(/NOT AUTHORIZED/)).toBeInTheDocument();
+ });
+ expect(onAuthorizationChange).toHaveBeenCalledWith(false);
+ expect(
+ screen.getByText(/No authorized tool path matches the production URL/)
+ ).toBeInTheDocument();
+ });
+
+ it("re-fetches when refreshKey changes", async () => {
+ mockGetUserTools.mockResolvedValue(["https://tools.example.org/tools/address-labels"]);
+ const { rerender } = render( );
+
+ await waitFor(() => {
+ expect(mockGetUserTools).toHaveBeenCalledTimes(1);
+ });
+
+ rerender( );
+
+ await waitFor(() => {
+ expect(mockGetUserTools).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it("shows the raw JSON of all authorized tool paths", async () => {
+ mockGetUserTools.mockResolvedValueOnce(["https://tools.example.org/tools/address-labels"]);
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText(/View all authorized tool paths/)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/field-management/field-order-editor.test.tsx b/src/components/field-management/field-order-editor.test.tsx
new file mode 100644
index 0000000..1db1dfb
--- /dev/null
+++ b/src/components/field-management/field-order-editor.test.tsx
@@ -0,0 +1,239 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { createRef } from 'react';
+import { render, screen, cleanup, fireEvent } from '@testing-library/react';
+import type { PageField, FieldOrderEditorHandle } from './types';
+
+const {
+ mockUseFieldOrderState,
+ mockBuildSavePayload,
+ mockMoveHiddenToOther,
+ mockHideAllSeparators,
+ mockAddGroup,
+ mockRemoveGroup,
+ mockUpdateField,
+ mockHandleDragStart,
+ mockHandleDragOver,
+ mockHandleDragEnd,
+} = vi.hoisted(() => ({
+ mockUseFieldOrderState: vi.fn(),
+ mockBuildSavePayload: vi.fn(),
+ mockMoveHiddenToOther: vi.fn(),
+ mockHideAllSeparators: vi.fn(),
+ mockAddGroup: vi.fn(),
+ mockRemoveGroup: vi.fn(),
+ mockUpdateField: vi.fn(),
+ mockHandleDragStart: vi.fn(),
+ mockHandleDragOver: vi.fn(),
+ mockHandleDragEnd: vi.fn(),
+}));
+
+vi.mock('./use-field-order-state', () => ({
+ useFieldOrderState: mockUseFieldOrderState,
+}));
+
+vi.mock('@dnd-kit/react', () => ({
+ DragDropProvider: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('./sortable-field-item', () => ({
+ SortableFieldItem: ({ field }: { field: PageField }) => (
+ {field.Field_Name}
+ ),
+}));
+
+vi.mock('./sortable-group', () => ({
+ SortableGroup: ({ groupName, isPinned, fieldIds }: { groupName: string; isPinned: boolean; fieldIds: number[] }) => (
+
+ {groupName} ({fieldIds.length})
+
+ ),
+}));
+
+vi.mock('./new-group-dialog', () => ({
+ NewGroupDialog: ({
+ open,
+ onCreateGroup,
+ existingGroupNames,
+ }: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onCreateGroup: (name: string) => void;
+ existingGroupNames: string[];
+ }) =>
+ open ? (
+
+ {existingGroupNames.join(',')}
+ onCreateGroup('New Group')}>confirm-create
+
+ ) : null,
+}));
+
+import { FieldOrderEditor } from './field-order-editor';
+
+function makeField(overrides: Partial = {}): PageField {
+ return {
+ Page_Field_ID: 1,
+ Page_ID: 1,
+ Field_Name: 'First_Name',
+ Group_Name: null,
+ View_Order: 1,
+ Required: false,
+ Hidden: false,
+ Default_Value: null,
+ Filter_Clause: null,
+ Depends_On_Field: null,
+ Field_Label: null,
+ Writing_Assistant_Enabled: false,
+ isSeparator: false,
+ ...overrides,
+ };
+}
+
+function defaultState(overrides: Record = {}) {
+ return {
+ groupedFields: { __flat__: [1] },
+ groupOrder: ['__flat__'],
+ fieldLookup: new Map([[1, makeField()]]),
+ isFlat: true,
+ isDirty: false,
+ handleDragStart: mockHandleDragStart,
+ handleDragOver: mockHandleDragOver,
+ handleDragEnd: mockHandleDragEnd,
+ addGroup: mockAddGroup,
+ removeGroup: mockRemoveGroup,
+ moveHiddenToOther: mockMoveHiddenToOther,
+ hideAllSeparators: mockHideAllSeparators,
+ updateField: mockUpdateField,
+ buildSavePayload: mockBuildSavePayload,
+ ...overrides,
+ };
+}
+
+describe('FieldOrderEditor', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseFieldOrderState.mockReturnValue(defaultState());
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('renders a flat list of fields with a field/fields count and no group count', () => {
+ render( );
+ expect(screen.getByTestId('field-1')).toHaveTextContent('First_Name');
+ expect(screen.getAllByText('1 field').length).toBeGreaterThan(0);
+ expect(screen.queryByText(/in \d+ groups/)).not.toBeInTheDocument();
+ });
+
+ it('filters out flat-mode field ids missing from fieldLookup', () => {
+ mockUseFieldOrderState.mockReturnValue(
+ defaultState({ groupedFields: { __flat__: [1, 999] }, fieldLookup: new Map([[1, makeField()]]) })
+ );
+ render( );
+ expect(screen.getByTestId('field-1')).toBeInTheDocument();
+ expect(screen.queryByTestId('field-999')).not.toBeInTheDocument();
+ });
+
+ it('renders SortableGroup per group in grouped mode, with count text and isPinned for "Other Fields"', () => {
+ mockUseFieldOrderState.mockReturnValue(
+ defaultState({
+ isFlat: false,
+ groupOrder: ['1 - General', '99 - Other Fields'],
+ groupedFields: { '1 - General': [1], '99 - Other Fields': [] },
+ })
+ );
+ render( );
+
+ expect(screen.getByTestId('group-1 - General')).toHaveAttribute('data-pinned', 'false');
+ expect(screen.getByTestId('group-99 - Other Fields')).toHaveAttribute('data-pinned', 'true');
+ expect(screen.getByText('1 field in 2 groups')).toBeInTheDocument();
+ });
+
+ it('pluralizes the total field count across all groups', () => {
+ mockUseFieldOrderState.mockReturnValue(
+ defaultState({
+ isFlat: false,
+ groupOrder: ['1 - General', '2 - Other'],
+ groupedFields: { '1 - General': [1, 2], '2 - Other': [3] },
+ fieldLookup: new Map([
+ [1, makeField({ Page_Field_ID: 1 })],
+ [2, makeField({ Page_Field_ID: 2 })],
+ [3, makeField({ Page_Field_ID: 3 })],
+ ]),
+ })
+ );
+ render( );
+ expect(screen.getByText('3 fields in 2 groups')).toBeInTheDocument();
+ });
+
+ it('opens the New Group dialog and forwards existing group names', () => {
+ mockUseFieldOrderState.mockReturnValue(
+ defaultState({ isFlat: false, groupOrder: ['1 - General'], groupedFields: { '1 - General': [] } })
+ );
+ render( );
+
+ expect(screen.queryByTestId('new-group-dialog')).not.toBeInTheDocument();
+ fireEvent.click(screen.getByText('New Group'));
+
+ expect(screen.getByTestId('new-group-dialog')).toBeInTheDocument();
+ expect(screen.getByTestId('existing-names')).toHaveTextContent('1 - General');
+ });
+
+ it('calls state.addGroup when a new group is confirmed via the dialog', () => {
+ render( );
+ fireEvent.click(screen.getByText('New Group'));
+ fireEvent.click(screen.getByText('confirm-create'));
+ expect(mockAddGroup).toHaveBeenCalledWith('New Group');
+ });
+
+ it('computes schemaRequiredFields from tableMetadata.Columns with IsRequired', () => {
+ render(
+
+ );
+ // Rendered via the mocked SortableFieldItem which does not display schemaRequired,
+ // so we assert indirectly: the editor renders without throwing and the field shows.
+ expect(screen.getByTestId('field-1')).toBeInTheDocument();
+ });
+
+ it('calls onDirtyChange whenever state.isDirty changes', () => {
+ const onDirtyChange = vi.fn();
+ mockUseFieldOrderState.mockReturnValue(defaultState({ isDirty: true }));
+ render( );
+ expect(onDirtyChange).toHaveBeenCalledWith(true);
+ });
+
+ it('exposes getSavePayload, moveHiddenToOther, and hideAllSeparators through the ref', () => {
+ const ref = createRef();
+ mockBuildSavePayload.mockReturnValue([{ Field_Name: 'A' }]);
+
+ render( );
+
+ expect(ref.current?.getSavePayload()).toEqual([{ Field_Name: 'A' }]);
+ ref.current?.moveHiddenToOther();
+ expect(mockMoveHiddenToOther).toHaveBeenCalledTimes(1);
+ ref.current?.hideAllSeparators();
+ expect(mockHideAllSeparators).toHaveBeenCalledTimes(1);
+ });
+
+ it('wires the DragDropProvider callbacks to the hook handlers', () => {
+ render( );
+ expect(screen.getByTestId('dnd-provider')).toBeInTheDocument();
+ // handleDragStart/Over/End are passed by reference to useFieldOrderState's
+ // return value and consumed by the (mocked) DragDropProvider; verifying the
+ // hook was invoked with the fields prop demonstrates the wiring compiles and runs.
+ expect(mockUseFieldOrderState).toHaveBeenCalledWith([]);
+ });
+});
diff --git a/src/components/field-management/new-group-dialog.test.tsx b/src/components/field-management/new-group-dialog.test.tsx
new file mode 100644
index 0000000..3271082
--- /dev/null
+++ b/src/components/field-management/new-group-dialog.test.tsx
@@ -0,0 +1,118 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, cleanup, fireEvent } from '@testing-library/react';
+import { NewGroupDialog } from './new-group-dialog';
+
+describe('NewGroupDialog', () => {
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('renders nothing when closed', () => {
+ render(
+
+ );
+ expect(screen.queryByText('New Group')).not.toBeInTheDocument();
+ });
+
+ it('renders the form when open', () => {
+ render(
+
+ );
+ expect(screen.getByText('New Group')).toBeInTheDocument();
+ expect(screen.getByLabelText('Group Name')).toBeInTheDocument();
+ });
+
+ it('shows a required error and does not call onCreateGroup when submitted empty', () => {
+ const onCreateGroup = vi.fn();
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Create Group' }));
+
+ expect(screen.getByText('Group name is required.')).toBeInTheDocument();
+ expect(onCreateGroup).not.toHaveBeenCalled();
+ });
+
+ it('treats a whitespace-only name as empty', () => {
+ render(
+
+ );
+
+ fireEvent.change(screen.getByLabelText('Group Name'), { target: { value: ' ' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Create Group' }));
+
+ expect(screen.getByText('Group name is required.')).toBeInTheDocument();
+ });
+
+ it('shows a duplicate-name error (case-insensitive) and does not call onCreateGroup', () => {
+ const onCreateGroup = vi.fn();
+ render(
+
+ );
+
+ fireEvent.change(screen.getByLabelText('Group Name'), { target: { value: '1 - general' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Create Group' }));
+
+ expect(screen.getByText('A group with this name already exists.')).toBeInTheDocument();
+ expect(onCreateGroup).not.toHaveBeenCalled();
+ });
+
+ it('trims the name, calls onCreateGroup, resets the form, and closes on valid submit', () => {
+ const onCreateGroup = vi.fn();
+ const onOpenChange = vi.fn();
+ render(
+
+ );
+
+ fireEvent.change(screen.getByLabelText('Group Name'), { target: { value: ' New Section ' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Create Group' }));
+
+ expect(onCreateGroup).toHaveBeenCalledWith('New Section');
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('clears the error as soon as the user types again', () => {
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Create Group' }));
+ expect(screen.getByText('Group name is required.')).toBeInTheDocument();
+
+ fireEvent.change(screen.getByLabelText('Group Name'), { target: { value: 'X' } });
+ expect(screen.queryByText('Group name is required.')).not.toBeInTheDocument();
+ });
+
+ it('resets name and error and calls onOpenChange(false) when Cancel is clicked', () => {
+ const onOpenChange = vi.fn();
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Create Group' }));
+ expect(screen.getByText('Group name is required.')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('resets name and error via handleOpenChange when Radix closes the dialog (e.g. Escape)', () => {
+ const onOpenChange = vi.fn();
+ render(
+
+ );
+
+ fireEvent.change(screen.getByLabelText('Group Name'), { target: { value: 'Partial' } });
+ fireEvent.keyDown(screen.getByLabelText('Group Name'), { key: 'Escape', code: 'Escape' });
+
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+});
diff --git a/src/components/field-management/page-search.test.tsx b/src/components/field-management/page-search.test.tsx
new file mode 100644
index 0000000..ea42033
--- /dev/null
+++ b/src/components/field-management/page-search.test.tsx
@@ -0,0 +1,145 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup, fireEvent, act, waitFor } from '@testing-library/react';
+
+const { mockFetchPages } = vi.hoisted(() => ({
+ mockFetchPages: vi.fn(),
+}));
+
+vi.mock('./actions', () => ({
+ fetchPages: mockFetchPages,
+}));
+
+import { PageSearch } from './page-search';
+import type { PageListItem } from './types';
+
+const contactsPage: PageListItem = { Page_ID: 292, Display_Name: 'Contacts', Table_Name: 'Contacts' };
+const donationsPage: PageListItem = { Page_ID: 293, Display_Name: 'Donations', Table_Name: 'Contributions' };
+
+describe('PageSearch', () => {
+ beforeEach(() => {
+ // jsdom does not implement scrollIntoView; cmdk calls it on selection change.
+ Element.prototype.scrollIntoView = vi.fn();
+ mockFetchPages.mockReset();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('shows "Loading pages..." and a disabled trigger while pages are being fetched', async () => {
+ let resolveFetch: (pages: PageListItem[]) => void = () => {};
+ mockFetchPages.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveFetch = resolve;
+ })
+ );
+
+ render( );
+
+ expect(screen.getByText('Loading pages...')).toBeInTheDocument();
+ expect(screen.getByRole('combobox')).toBeDisabled();
+
+ await act(async () => {
+ resolveFetch([contactsPage]);
+ });
+
+ expect(screen.queryByText('Loading pages...')).not.toBeInTheDocument();
+ });
+
+ it('shows "Select a page..." placeholder once loaded with no value', async () => {
+ mockFetchPages.mockResolvedValueOnce([]);
+ render( );
+
+ await waitFor(() => expect(screen.getByText('Select a page...')).toBeInTheDocument());
+ expect(screen.getByRole('combobox')).not.toBeDisabled();
+ });
+
+ it('shows the selected page Display_Name when value is set', async () => {
+ mockFetchPages.mockResolvedValueOnce([contactsPage]);
+ render( );
+
+ await waitFor(() => expect(mockFetchPages).toHaveBeenCalled());
+ expect(screen.getByText('Contacts')).toBeInTheDocument();
+ });
+
+ it('falls back to an empty page list when fetchPages rejects', async () => {
+ mockFetchPages.mockRejectedValueOnce(new Error('down'));
+ render( );
+
+ await waitFor(() => expect(screen.getByText('Select a page...')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('combobox'));
+ expect(screen.getByText('No pages found.')).toBeInTheDocument();
+ });
+
+ it('only fetches pages once even if effects were to re-run (fetchedRef guard)', async () => {
+ mockFetchPages.mockResolvedValueOnce([contactsPage]);
+ const { rerender } = render( );
+ await waitFor(() => expect(mockFetchPages).toHaveBeenCalledTimes(1));
+
+ rerender( );
+
+ expect(mockFetchPages).toHaveBeenCalledTimes(1);
+ });
+
+ it('lists fetched pages with Display_Name and Table_Name, and calls onSelect + closes on click', async () => {
+ const onSelect = vi.fn();
+ mockFetchPages.mockResolvedValueOnce([contactsPage, donationsPage]);
+ render( );
+
+ await waitFor(() => expect(screen.getByText('Select a page...')).toBeInTheDocument());
+ fireEvent.click(screen.getByRole('combobox'));
+
+ expect(screen.getByText('Donations')).toBeInTheDocument();
+ expect(screen.getByText('Contributions')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText('Donations'));
+
+ expect(onSelect).toHaveBeenCalledWith(donationsPage);
+ expect(screen.getByRole('combobox')).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('shows a check mark next to the currently-selected page', async () => {
+ mockFetchPages.mockResolvedValueOnce([contactsPage, donationsPage]);
+ render( );
+
+ await waitFor(() => expect(screen.getByText('Contacts')).toBeInTheDocument());
+ fireEvent.click(screen.getByRole('combobox'));
+
+ const items = screen.getAllByRole('option');
+ const contactsItem = items.find((item) => item.textContent?.includes('Contacts'));
+ const donationsItem = items.find((item) => item.textContent?.includes('Donations'));
+
+ expect(contactsItem?.querySelector('svg')).toHaveClass('opacity-100');
+ expect(donationsItem?.querySelector('svg')).toHaveClass('opacity-0');
+ });
+
+ it('resets the search-list scroll position on search input change', async () => {
+ vi.useFakeTimers();
+ try {
+ mockFetchPages.mockResolvedValueOnce([contactsPage, donationsPage]);
+ render( );
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+ fireEvent.click(screen.getByRole('combobox'));
+
+ const input = screen.getByPlaceholderText('Search pages...');
+ const scrollToSpy = vi.fn();
+ // cmdk's CommandList forwards the ref to a scrollable div.
+ const list = input.closest('[cmdk-root]')?.querySelector('[cmdk-list-sizer]')?.parentElement;
+ if (list) (list as HTMLDivElement).scrollTo = scrollToSpy;
+
+ fireEvent.change(input, { target: { value: 'Don' } });
+ act(() => {
+ vi.runOnlyPendingTimers();
+ });
+
+ expect(scrollToSpy).toHaveBeenCalledWith({ top: 0 });
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/src/components/field-management/sortable-field-item.test.tsx b/src/components/field-management/sortable-field-item.test.tsx
new file mode 100644
index 0000000..0dc0833
--- /dev/null
+++ b/src/components/field-management/sortable-field-item.test.tsx
@@ -0,0 +1,311 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup, fireEvent } from '@testing-library/react';
+import type { PageField } from './types';
+
+const { mockUseSortable } = vi.hoisted(() => ({
+ mockUseSortable: vi.fn(),
+}));
+
+vi.mock('@dnd-kit/react/sortable', () => ({
+ useSortable: mockUseSortable,
+}));
+
+import { SortableFieldItem } from './sortable-field-item';
+
+function makeField(overrides: Partial = {}): PageField {
+ return {
+ Page_Field_ID: 1,
+ Page_ID: 1,
+ Field_Name: 'First_Name',
+ Group_Name: '1 - General',
+ View_Order: 1,
+ Required: false,
+ Hidden: false,
+ Default_Value: null,
+ Filter_Clause: null,
+ Depends_On_Field: null,
+ Field_Label: null,
+ Writing_Assistant_Enabled: false,
+ isSeparator: false,
+ ...overrides,
+ };
+}
+
+describe('SortableFieldItem', () => {
+ beforeEach(() => {
+ mockUseSortable.mockReturnValue({ ref: vi.fn(), isDragging: false });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('calls useSortable with id/index/group/type derived from props', () => {
+ const field = makeField();
+ render(
+
+ );
+
+ expect(mockUseSortable).toHaveBeenCalledWith({
+ id: 1,
+ index: 2,
+ type: 'item',
+ accept: 'item',
+ group: '1 - General',
+ });
+ });
+
+ it('renders the field name and label', () => {
+ const field = makeField({ Field_Label: 'First Name' });
+ render(
+
+ );
+ expect(screen.getByText('First_Name')).toBeInTheDocument();
+ expect(screen.getByText('First Name')).toBeInTheDocument();
+ });
+
+ it('shows an em-dash when Field_Label is null', () => {
+ const field = makeField({ Field_Label: null });
+ render(
+
+ );
+ expect(screen.getByText('—')).toBeInTheDocument();
+ });
+
+ it('applies the dragging opacity/ring class when isDragging is true', () => {
+ mockUseSortable.mockReturnValue({ ref: vi.fn(), isDragging: true });
+ const field = makeField();
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toHaveClass('opacity-40', 'ring-2', 'ring-cyan-400');
+ });
+
+ it('renders separator styling and a Separator badge for a separator field', () => {
+ const field = makeField({ isSeparator: true, Field_Name: 'Sep_A' });
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toHaveClass('bg-slate-50', 'border-dashed');
+ expect(screen.getByText('Separator')).toBeInTheDocument();
+ });
+
+ it('shows a Required badge when schemaRequired is true, even if field.Required is false', () => {
+ const field = makeField({ Required: false });
+ render(
+
+ );
+ expect(screen.getByText('Required')).toBeInTheDocument();
+ });
+
+ it('shows a Required badge when field.Required is true', () => {
+ const field = makeField({ Required: true });
+ render(
+
+ );
+ expect(screen.getByText('Required')).toBeInTheDocument();
+ });
+
+ it('does not show a Required badge for a separator even if Required is true', () => {
+ const field = makeField({ isSeparator: true, Required: true });
+ render(
+
+ );
+ expect(screen.queryByText('Required')).not.toBeInTheDocument();
+ });
+
+ it('shows a Hidden badge when field.Hidden is true', () => {
+ const field = makeField({ Hidden: true });
+ render(
+
+ );
+ expect(screen.getByText('Hidden')).toBeInTheDocument();
+ });
+
+ it('toggles the expanded detail panel via the chevron button', () => {
+ const field = makeField();
+ render(
+
+ );
+ expect(screen.queryByLabelText('Field Label')).not.toBeInTheDocument();
+
+ const buttons = screen.getAllByRole('button');
+ fireEvent.click(buttons[0]);
+ expect(screen.getByLabelText('Field Label')).toBeInTheDocument();
+
+ fireEvent.click(buttons[0]);
+ expect(screen.queryByLabelText('Field Label')).not.toBeInTheDocument();
+ });
+
+ it('toggles expansion when the field name text is clicked', () => {
+ const field = makeField();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('First_Name'));
+ expect(screen.getByLabelText('Field Label')).toBeInTheDocument();
+ });
+
+ it('renders the reduced separator detail panel (label + hidden switch, no required/default/filter/depends)', () => {
+ const field = makeField({ isSeparator: true, Field_Name: 'Sep_A' });
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Sep_A'));
+
+ expect(screen.getByLabelText('Field Label')).toBeInTheDocument();
+ expect(screen.getByLabelText('Hidden')).toBeInTheDocument();
+ expect(screen.queryByLabelText('Default Value')).not.toBeInTheDocument();
+ expect(screen.queryByLabelText('Filter Clause')).not.toBeInTheDocument();
+ expect(screen.queryByLabelText('Depends On Field')).not.toBeInTheDocument();
+ expect(screen.queryByLabelText('Required')).not.toBeInTheDocument();
+ expect(screen.queryByLabelText('Writing Assistant')).not.toBeInTheDocument();
+ });
+
+ it('renders the full detail panel for a non-separator field', () => {
+ const field = makeField();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('First_Name'));
+
+ expect(screen.getByLabelText('Field Label')).toBeInTheDocument();
+ expect(screen.getByLabelText('Default Value')).toBeInTheDocument();
+ expect(screen.getByLabelText('Filter Clause')).toBeInTheDocument();
+ expect(screen.getByLabelText('Depends On Field')).toBeInTheDocument();
+ expect(screen.getByLabelText('Required')).toBeInTheDocument();
+ expect(screen.getByLabelText('Hidden')).toBeInTheDocument();
+ expect(screen.getByLabelText('Writing Assistant')).toBeInTheDocument();
+ });
+
+ it('shows the "(schema)" hint and disables the switch when schemaRequired is true', () => {
+ const field = makeField();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('First_Name'));
+
+ expect(screen.getByText('(schema)')).toBeInTheDocument();
+ expect(screen.getByLabelText(/^Required/)).toBeDisabled();
+ });
+
+ it('calls onUpdateField with a non-empty Field_Label as-is', () => {
+ const onUpdateField = vi.fn();
+ const field = makeField();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('First_Name'));
+
+ fireEvent.change(screen.getByLabelText('Field Label'), { target: { value: 'New Label' } });
+ expect(onUpdateField).toHaveBeenCalledWith(1, { Field_Label: 'New Label' });
+ });
+
+ it('converts an emptied Field_Label to null', () => {
+ // The inputs are controlled by the `field` prop, so re-emptying an
+ // already-changed DOM value needs a fresh render seeded with a non-empty
+ // value rather than a second fireEvent on the same uncontrolled node.
+ const onUpdateField = vi.fn();
+ const field = makeField({ Field_Label: 'Existing' });
+ render(
+
+ );
+ fireEvent.click(screen.getByText('First_Name'));
+
+ fireEvent.change(screen.getByLabelText('Field Label'), { target: { value: '' } });
+ expect(onUpdateField).toHaveBeenCalledWith(1, { Field_Label: null });
+ });
+
+ it('calls onUpdateField for Default_Value, Filter_Clause, and Depends_On_Field with non-empty text', () => {
+ const onUpdateField = vi.fn();
+ const field = makeField();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('First_Name'));
+
+ fireEvent.change(screen.getByLabelText('Default Value'), { target: { value: 'x' } });
+ expect(onUpdateField).toHaveBeenCalledWith(1, { Default_Value: 'x' });
+
+ fireEvent.change(screen.getByLabelText('Filter Clause'), { target: { value: 'y' } });
+ expect(onUpdateField).toHaveBeenCalledWith(1, { Filter_Clause: 'y' });
+
+ fireEvent.change(screen.getByLabelText('Depends On Field'), { target: { value: 'z' } });
+ expect(onUpdateField).toHaveBeenCalledWith(1, { Depends_On_Field: 'z' });
+ });
+
+ it('converts emptied Default_Value, Filter_Clause, and Depends_On_Field to null', () => {
+ const onUpdateField = vi.fn();
+ const field = makeField({
+ Default_Value: 'x',
+ Filter_Clause: 'y',
+ Depends_On_Field: 'z',
+ });
+ render(
+
+ );
+ fireEvent.click(screen.getByText('First_Name'));
+
+ fireEvent.change(screen.getByLabelText('Default Value'), { target: { value: '' } });
+ expect(onUpdateField).toHaveBeenCalledWith(1, { Default_Value: null });
+
+ fireEvent.change(screen.getByLabelText('Filter Clause'), { target: { value: '' } });
+ expect(onUpdateField).toHaveBeenCalledWith(1, { Filter_Clause: null });
+
+ fireEvent.change(screen.getByLabelText('Depends On Field'), { target: { value: '' } });
+ expect(onUpdateField).toHaveBeenCalledWith(1, { Depends_On_Field: null });
+ });
+
+ it('calls onUpdateField for Required, Hidden, and Writing_Assistant_Enabled switches', () => {
+ const onUpdateField = vi.fn();
+ const field = makeField();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('First_Name'));
+
+ fireEvent.click(screen.getByLabelText('Required'));
+ expect(onUpdateField).toHaveBeenCalledWith(1, { Required: true });
+
+ fireEvent.click(screen.getByLabelText('Hidden'));
+ expect(onUpdateField).toHaveBeenCalledWith(1, { Hidden: true });
+
+ fireEvent.click(screen.getByLabelText('Writing Assistant'));
+ expect(onUpdateField).toHaveBeenCalledWith(1, { Writing_Assistant_Enabled: true });
+ });
+
+ it('calls onUpdateField for Hidden on a separator row', () => {
+ const onUpdateField = vi.fn();
+ const field = makeField({ isSeparator: true, Field_Name: 'Sep_A' });
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Sep_A'));
+
+ fireEvent.click(screen.getByLabelText('Hidden'));
+ expect(onUpdateField).toHaveBeenCalledWith(field.Page_Field_ID, { Hidden: true });
+ });
+
+ it('calls onUpdateField for Field_Label on a separator row, converting empty to null', () => {
+ const onUpdateField = vi.fn();
+ const field = makeField({ isSeparator: true, Field_Name: 'Sep_A', Field_Label: 'Existing' });
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Sep_A'));
+
+ const labelInput = screen.getByLabelText('Field Label');
+ fireEvent.change(labelInput, { target: { value: '' } });
+ expect(onUpdateField).toHaveBeenCalledWith(field.Page_Field_ID, { Field_Label: null });
+ });
+
+ it('uses the field name as the Field_Label input placeholder', () => {
+ const field = makeField({ Field_Name: 'Last_Name', Field_Label: null });
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Last_Name'));
+ expect(screen.getByPlaceholderText('Last_Name')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/field-management/sortable-group.test.tsx b/src/components/field-management/sortable-group.test.tsx
new file mode 100644
index 0000000..cbed8e2
--- /dev/null
+++ b/src/components/field-management/sortable-group.test.tsx
@@ -0,0 +1,296 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup, fireEvent } from '@testing-library/react';
+import type { PageField } from './types';
+
+const { mockUseSortable } = vi.hoisted(() => ({
+ mockUseSortable: vi.fn(),
+}));
+
+vi.mock('@dnd-kit/react/sortable', () => ({
+ useSortable: mockUseSortable,
+}));
+
+vi.mock('@dnd-kit/abstract', () => ({
+ CollisionPriority: { Low: 'low', Normal: 'normal', High: 'high' },
+}));
+
+import { SortableGroup } from './sortable-group';
+
+function makeField(overrides: Partial = {}): PageField {
+ return {
+ Page_Field_ID: 1,
+ Page_ID: 1,
+ Field_Name: 'First_Name',
+ Group_Name: '1 - General',
+ View_Order: 1,
+ Required: false,
+ Hidden: false,
+ Default_Value: null,
+ Filter_Clause: null,
+ Depends_On_Field: null,
+ Field_Label: null,
+ Writing_Assistant_Enabled: false,
+ isSeparator: false,
+ ...overrides,
+ };
+}
+
+describe('SortableGroup', () => {
+ beforeEach(() => {
+ mockUseSortable.mockReturnValue({ ref: vi.fn(), isDragging: false });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('calls useSortable with id/index/type/accept/collisionPriority/disabled derived from props', () => {
+ render(
+
+ );
+
+ expect(mockUseSortable).toHaveBeenCalledWith({
+ id: '1 - General',
+ index: 0,
+ type: 'group',
+ accept: ['item', 'group'],
+ collisionPriority: 'low',
+ disabled: false,
+ });
+ });
+
+ it('disables the sortable and hides the grip handle when isPinned', () => {
+ render(
+
+ );
+
+ expect(mockUseSortable).toHaveBeenCalledWith(expect.objectContaining({ disabled: true }));
+ expect(document.querySelector('.lucide-grip-vertical')).not.toBeInTheDocument();
+ });
+
+ it('renders the group name and singular/plural field count', () => {
+ const { rerender } = render(
+
+ );
+ expect(screen.getByText('1 - General')).toBeInTheDocument();
+ expect(screen.getByText('1 field')).toBeInTheDocument();
+
+ rerender(
+
+ );
+ expect(screen.getByText('2 fields')).toBeInTheDocument();
+ });
+
+ it('applies the dragging ring class when isDragging is true', () => {
+ mockUseSortable.mockReturnValue({ ref: vi.fn(), isDragging: true });
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toHaveClass('opacity-40', 'ring-2', 'ring-cyan-400');
+ });
+
+ it('applies the pinned styling when isPinned and not dragging', () => {
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toHaveClass('border-gray-300', 'bg-gray-50');
+ });
+
+ it('shows the remove (trash) button only for an empty, non-pinned group, and it calls onRemove', () => {
+ const onRemove = vi.fn();
+ const { container } = render(
+
+ );
+
+ const trashButton = container.querySelector('.lucide-trash')?.closest('button');
+ expect(trashButton).not.toBeNull();
+ fireEvent.click(trashButton!);
+ expect(onRemove).toHaveBeenCalledWith('2 - Empty');
+ });
+
+ it('does not show the remove button for a non-empty group', () => {
+ const { container } = render(
+
+ );
+ expect(container.querySelector('.lucide-trash')).not.toBeInTheDocument();
+ });
+
+ it('does not show the remove button for an empty pinned group', () => {
+ const { container } = render(
+
+ );
+ expect(container.querySelector('.lucide-trash')).not.toBeInTheDocument();
+ });
+
+ it('shows the "Drag fields here" placeholder for an empty group', () => {
+ render(
+
+ );
+ expect(screen.getByText('Drag fields here')).toBeInTheDocument();
+ });
+
+ it('renders a SortableFieldItem per field id, skipping ids missing from fieldLookup', () => {
+ render(
+
+ );
+ expect(screen.getByText('First_Name')).toBeInTheDocument();
+ expect(screen.queryByText('Drag fields here')).not.toBeInTheDocument();
+ });
+
+ it('passes schemaRequired through to a field based on schemaRequiredFields', () => {
+ render(
+
+ );
+ expect(screen.getByText('Required')).toBeInTheDocument();
+ });
+
+ it('toggles collapsed state via the chevron button, hiding the fields area', () => {
+ const { container } = render(
+
+ );
+ expect(screen.getByText('First_Name')).toBeInTheDocument();
+
+ const chevronButton = container.querySelector('.lucide-chevron-down')!.closest('button')!;
+ fireEvent.click(chevronButton);
+ expect(screen.queryByText('First_Name')).not.toBeInTheDocument();
+
+ const collapsedChevronButton = container.querySelector('.lucide-chevron-right')!.closest('button')!;
+ fireEvent.click(collapsedChevronButton);
+ expect(screen.getByText('First_Name')).toBeInTheDocument();
+ });
+
+ it('toggles collapsed state via the group name click', () => {
+ render(
+
+ );
+ fireEvent.click(screen.getByText('1 - General'));
+ expect(screen.queryByText('First_Name')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/field-management/use-field-order-state.test.ts b/src/components/field-management/use-field-order-state.test.ts
index e700c48..54e97b6 100644
--- a/src/components/field-management/use-field-order-state.test.ts
+++ b/src/components/field-management/use-field-order-state.test.ts
@@ -1,16 +1,46 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
-import { useFieldOrderState } from './use-field-order-state';
import type { PageField } from './types';
const OTHER = '99 - Other Fields';
+const FLAT = '__flat__';
+
+// `move()` from @dnd-kit/helpers relies on internal dnd-kit manager/shape objects
+// that cannot be constructed as plain synthetic events in jsdom. We mock it with a
+// controllable implementation so we can drive the hook's own branching logic
+// (which group array gets mutated, in what order) without reimplementing dnd-kit's
+// collision/mutation algorithm.
+const mockMove = vi.hoisted(() => vi.fn());
+vi.mock('@dnd-kit/helpers', () => ({
+ move: mockMove,
+}));
+
+// `isSortable()` checks `instanceof SortableDroppable/SortableDraggable`, which are
+// real dnd-kit classes we cannot instantiate from a synthetic event. We mock it to
+// key off a plain marker property on our synthetic source objects instead.
+const mockIsSortable = vi.hoisted(() => vi.fn());
+vi.mock('@dnd-kit/react/sortable', () => ({
+ isSortable: mockIsSortable,
+}));
+
+import { useFieldOrderState } from './use-field-order-state';
+
+// The hook's handleDragOver/handleDragEnd parameter types are derived from real
+// @dnd-kit event types (Draggable/Droppable/DragOperationSnapshot), which carry
+// many internal fields we cannot construct in a synthetic test event. These
+// casts let us pass minimal `{ operation: { source, target } }` shapes that
+// exercise the hook's own branching without reimplementing dnd-kit's internals.
+
+function fakeDragEvent(event: unknown): any {
+ return event;
+}
function makeField(overrides: Partial & { Page_Field_ID: number; Field_Name: string }): PageField {
return {
Page_Field_ID: overrides.Page_Field_ID,
Page_ID: 1,
Field_Name: overrides.Field_Name,
- Group_Name: overrides.Group_Name ?? '1 - General',
+ Group_Name: 'Group_Name' in overrides ? overrides.Group_Name! : '1 - General',
View_Order: overrides.View_Order ?? 1,
Required: overrides.Required ?? false,
Hidden: overrides.Hidden ?? false,
@@ -23,6 +53,10 @@ function makeField(overrides: Partial & { Page_Field_ID: number; Fiel
};
}
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
describe('useFieldOrderState > hideAllSeparators', () => {
it('flips Hidden to true and moves separators to "99 - Other Fields"', () => {
const fields: PageField[] = [
@@ -109,3 +143,457 @@ describe('useFieldOrderState > hideAllSeparators', () => {
expect(sep?.Group_Name).toBe(OTHER);
});
});
+
+describe('useFieldOrderState > initialization', () => {
+ it('groups flat fields (no Group_Name) under the internal flat bucket, sorted by View_Order', () => {
+ const fields: PageField[] = [
+ makeField({ Page_Field_ID: 2, Field_Name: 'Second', Group_Name: null, View_Order: 2 }),
+ makeField({ Page_Field_ID: 1, Field_Name: 'First', Group_Name: null, View_Order: 1 }),
+ ];
+ const { result } = renderHook(() => useFieldOrderState(fields));
+
+ expect(result.current.isFlat).toBe(true);
+ expect(result.current.groupOrder).toEqual([FLAT]);
+ expect(result.current.groupedFields[FLAT]).toEqual([1, 2]);
+ });
+
+ it('groups fields by Group_Name, sorting groups numerically with "Other" pinned last', () => {
+ const fields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'A', Group_Name: '10 - Later', View_Order: 1 }),
+ makeField({ Page_Field_ID: 2, Field_Name: 'B', Group_Name: '2 - Earlier', View_Order: 1 }),
+ makeField({ Page_Field_ID: 3, Field_Name: 'C', Group_Name: null, View_Order: 1 }),
+ ];
+ const { result } = renderHook(() => useFieldOrderState(fields));
+
+ expect(result.current.isFlat).toBe(false);
+ expect(result.current.groupOrder).toEqual(['2 - Earlier', '10 - Later', OTHER]);
+ expect(result.current.groupedFields[OTHER]).toEqual([3]);
+ });
+});
+
+describe('useFieldOrderState > handleDragOver', () => {
+ const fields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'A', Group_Name: '1 - General', View_Order: 1 }),
+ makeField({ Page_Field_ID: 2, Field_Name: 'B', Group_Name: '1 - General', View_Order: 2 }),
+ ];
+
+ it('skips calling move() when the drag source is a group', () => {
+ const { result } = renderHook(() => useFieldOrderState(fields));
+ const before = result.current.groupedFields;
+
+ act(() => {
+ result.current.handleDragOver(fakeDragEvent({ operation: { source: { type: 'group' } } }));
+ });
+
+ expect(mockMove).not.toHaveBeenCalled();
+ expect(result.current.groupedFields).toBe(before);
+ });
+
+ it('delegates to move() and applies its result when the source is a field', () => {
+ const { result } = renderHook(() => useFieldOrderState(fields));
+ const moved = { '1 - General': [2, 1] };
+ mockMove.mockReturnValue(moved);
+
+ const event = { operation: { source: { type: 'field', id: 1 }, target: { id: 2 } } };
+ act(() => {
+ result.current.handleDragOver(fakeDragEvent(event));
+ });
+
+ expect(mockMove).toHaveBeenCalledWith({ '1 - General': [1, 2] }, event);
+ expect(result.current.groupedFields).toEqual(moved);
+ });
+});
+
+describe('useFieldOrderState > handleDragEnd', () => {
+ const fields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'A', Group_Name: '1 - General', View_Order: 1 }),
+ makeField({ Page_Field_ID: 2, Field_Name: 'B', Group_Name: '2 - Second', View_Order: 1 }),
+ ];
+
+ it('restores the pre-drag snapshot when the drag is canceled', () => {
+ const { result } = renderHook(() => useFieldOrderState(fields));
+
+ act(() => {
+ result.current.handleDragStart();
+ });
+ // Mutate state after the snapshot was taken, simulating an in-flight drag.
+ mockMove.mockReturnValue({ '1 - General': [], '2 - Second': [1, 2] });
+ act(() => {
+ result.current.handleDragOver(fakeDragEvent({ operation: { source: { type: 'field', id: 1 }, target: { id: 2 } } }));
+ });
+ expect(result.current.groupedFields).toEqual({ '1 - General': [], '2 - Second': [1, 2] });
+
+ act(() => {
+ result.current.handleDragEnd(fakeDragEvent({ canceled: true, operation: { source: { type: 'field' } } }));
+ });
+
+ expect(result.current.groupedFields).toEqual({ '1 - General': [1], '2 - Second': [2] });
+ expect(result.current.groupOrder).toEqual(['1 - General', '2 - Second']);
+ expect(result.current.isDirty).toBe(false);
+ });
+
+ it('reorders groupOrder and re-pins "Other Fields" last when a group handle is dropped', () => {
+ const groupedFields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'A', Group_Name: '1 - First', View_Order: 1 }),
+ makeField({ Page_Field_ID: 2, Field_Name: 'B', Group_Name: '2 - Second', View_Order: 1 }),
+ makeField({ Page_Field_ID: 3, Field_Name: 'C', Group_Name: null, View_Order: 1 }),
+ ];
+ const { result } = renderHook(() => useFieldOrderState(groupedFields));
+ expect(result.current.groupOrder).toEqual(['1 - First', '2 - Second', OTHER]);
+
+ mockIsSortable.mockReturnValue(true);
+ mockMove.mockReturnValue([OTHER, '1 - First', '2 - Second']);
+
+ act(() => {
+ result.current.handleDragEnd(fakeDragEvent({
+ canceled: false,
+ operation: { source: { type: 'group', id: OTHER }, target: { id: '1 - First' } },
+ }));
+ });
+
+ // "Other Fields" must be pulled back to the end even though move() put it first.
+ expect(result.current.groupOrder).toEqual(['1 - First', '2 - Second', OTHER]);
+ expect(result.current.isDirty).toBe(true);
+ });
+
+ it('leaves groupOrder untouched when isSortable() is false, even if source.type is "group"', () => {
+ const { result } = renderHook(() => useFieldOrderState(fields));
+ const before = result.current.groupOrder;
+ mockIsSortable.mockReturnValue(false);
+
+ act(() => {
+ result.current.handleDragEnd(fakeDragEvent({
+ canceled: false,
+ operation: { source: { type: 'group' }, target: { id: '2 - Second' } },
+ }));
+ });
+
+ expect(mockMove).not.toHaveBeenCalled();
+ expect(result.current.groupOrder).toBe(before);
+ expect(result.current.isDirty).toBe(true);
+ });
+
+ it('only marks dirty (no groupOrder change) when the source is a plain field, not a group', () => {
+ const { result } = renderHook(() => useFieldOrderState(fields));
+ const before = result.current.groupOrder;
+ mockIsSortable.mockReturnValue(true);
+
+ act(() => {
+ result.current.handleDragEnd(fakeDragEvent({
+ canceled: false,
+ operation: { source: { type: 'field' }, target: { id: 2 } },
+ }));
+ });
+
+ expect(mockMove).not.toHaveBeenCalled();
+ expect(result.current.groupOrder).toBe(before);
+ expect(result.current.isDirty).toBe(true);
+ });
+
+ it('does not re-splice when "Other Fields" is already last after the group move', () => {
+ const groupedFields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'A', Group_Name: '1 - First', View_Order: 1 }),
+ makeField({ Page_Field_ID: 2, Field_Name: 'B', Group_Name: null, View_Order: 1 }),
+ ];
+ const { result } = renderHook(() => useFieldOrderState(groupedFields));
+
+ mockIsSortable.mockReturnValue(true);
+ mockMove.mockReturnValue(['1 - First', OTHER]);
+
+ act(() => {
+ result.current.handleDragEnd(fakeDragEvent({
+ canceled: false,
+ operation: { source: { type: 'group' }, target: { id: '1 - First' } },
+ }));
+ });
+
+ expect(result.current.groupOrder).toEqual(['1 - First', OTHER]);
+ });
+});
+
+describe('useFieldOrderState > addGroup', () => {
+ it('transitions flat fields into a new group, moving all flat fields into "Other Fields"', () => {
+ const fields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'A', Group_Name: null, View_Order: 1 }),
+ makeField({ Page_Field_ID: 2, Field_Name: 'B', Group_Name: null, View_Order: 2 }),
+ ];
+ const { result } = renderHook(() => useFieldOrderState(fields));
+ expect(result.current.isFlat).toBe(true);
+
+ act(() => {
+ result.current.addGroup('1 - New Group');
+ });
+
+ expect(result.current.isFlat).toBe(false);
+ expect(result.current.groupedFields).toEqual({
+ '1 - New Group': [],
+ [OTHER]: [1, 2],
+ });
+ expect(result.current.groupOrder).toEqual(['1 - New Group', OTHER]);
+ expect(result.current.isDirty).toBe(true);
+ });
+
+ it('inserts a new group before "Other Fields" when already grouped', () => {
+ const fields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'A', Group_Name: '1 - First', View_Order: 1 }),
+ makeField({ Page_Field_ID: 2, Field_Name: 'B', Group_Name: null, View_Order: 1 }),
+ ];
+ const { result } = renderHook(() => useFieldOrderState(fields));
+
+ act(() => {
+ result.current.addGroup('2 - New');
+ });
+
+ expect(result.current.groupOrder).toEqual(['1 - First', '2 - New', OTHER]);
+ expect(result.current.groupedFields['2 - New']).toEqual([]);
+ });
+
+ it('appends the new group at the end when there is no "Other Fields" group yet', () => {
+ const fields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'A', Group_Name: '1 - First', View_Order: 1 }),
+ ];
+ const { result } = renderHook(() => useFieldOrderState(fields));
+
+ act(() => {
+ result.current.addGroup('2 - New');
+ });
+
+ expect(result.current.groupOrder).toEqual(['1 - First', '2 - New']);
+ });
+});
+
+describe('useFieldOrderState > removeGroup', () => {
+ it('removes an empty group from both groupedFields and groupOrder', () => {
+ const fields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'A', Group_Name: '1 - First', View_Order: 1 }),
+ ];
+ const { result } = renderHook(() => useFieldOrderState(fields));
+
+ act(() => {
+ result.current.addGroup('2 - Empty');
+ });
+ expect(result.current.groupedFields['2 - Empty']).toEqual([]);
+
+ act(() => {
+ result.current.removeGroup('2 - Empty');
+ });
+
+ expect(result.current.groupedFields['2 - Empty']).toBeUndefined();
+ expect(result.current.groupOrder).not.toContain('2 - Empty');
+ expect(result.current.isDirty).toBe(true);
+ });
+
+ it('is a no-op on groupedFields when the group still has fields, but still removes it from groupOrder', () => {
+ const fields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'A', Group_Name: '1 - First', View_Order: 1 }),
+ ];
+ const { result } = renderHook(() => useFieldOrderState(fields));
+
+ act(() => {
+ result.current.removeGroup('1 - First');
+ });
+
+ // Documented actual behavior: groupedFields keeps the non-empty group (guard
+ // in removeGroup refuses to drop its fields), but groupOrder unconditionally
+ // filters the name out regardless of that guard — see filed TODO
+ // 2026-09-13-removegroup-order-guard-mismatch.md.
+ expect(result.current.groupedFields['1 - First']).toEqual([1]);
+ expect(result.current.groupOrder).not.toContain('1 - First');
+
+ // Consequence: buildSavePayload iterates groupOrder, so field 1 is silently
+ // dropped from the save payload even though it still exists in groupedFields.
+ const payload = result.current.buildSavePayload();
+ expect(payload.find((p) => p.Field_Name === 'A')).toBeUndefined();
+ });
+
+ it('is a no-op when the group name does not exist in groupedFields at all', () => {
+ const fields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'A', Group_Name: '1 - First', View_Order: 1 }),
+ ];
+ const { result } = renderHook(() => useFieldOrderState(fields));
+ const before = result.current.groupedFields;
+
+ act(() => {
+ result.current.removeGroup('Never Existed');
+ });
+
+ expect(result.current.groupedFields).toEqual(before);
+ expect(result.current.isDirty).toBe(true);
+ });
+});
+
+describe('useFieldOrderState > moveHiddenToOther', () => {
+ it('moves hidden fields from every group into "Other Fields", appending after existing Other fields', () => {
+ const fields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'A', Group_Name: '1 - First', View_Order: 1, Hidden: false }),
+ makeField({ Page_Field_ID: 2, Field_Name: 'B', Group_Name: '1 - First', View_Order: 2, Hidden: true }),
+ makeField({ Page_Field_ID: 3, Field_Name: 'C', Group_Name: OTHER, View_Order: 3, Hidden: false }),
+ ];
+ const { result } = renderHook(() => useFieldOrderState(fields));
+
+ act(() => {
+ result.current.moveHiddenToOther();
+ });
+
+ expect(result.current.groupedFields['1 - First']).toEqual([1]);
+ expect(result.current.groupedFields[OTHER]).toEqual([3, 2]);
+ expect(result.current.groupOrder).toContain(OTHER);
+ expect(result.current.isDirty).toBe(true);
+ });
+
+ it('is a no-op when nothing is hidden', () => {
+ const fields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'A', Group_Name: '1 - First', View_Order: 1, Hidden: false }),
+ ];
+ const { result } = renderHook(() => useFieldOrderState(fields));
+ const before = result.current.groupedFields;
+
+ act(() => {
+ result.current.moveHiddenToOther();
+ });
+
+ expect(result.current.groupedFields).toBe(before);
+ expect(result.current.isDirty).toBe(true);
+ });
+
+ it('adds "Other Fields" to groupOrder if it was not already present', () => {
+ const fields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'A', Group_Name: null, View_Order: 1, Hidden: true }),
+ ];
+ const { result } = renderHook(() => useFieldOrderState(fields));
+ // Flat mode: groupOrder is [FLAT] and does not include OTHER yet.
+ expect(result.current.groupOrder).not.toContain(OTHER);
+
+ act(() => {
+ result.current.moveHiddenToOther();
+ });
+
+ expect(result.current.groupOrder).toContain(OTHER);
+ });
+});
+
+describe('useFieldOrderState > updateField', () => {
+ it('merges updates into the existing field and marks dirty', () => {
+ const fields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'A', Group_Name: '1 - First', View_Order: 1, Required: false }),
+ ];
+ const { result } = renderHook(() => useFieldOrderState(fields));
+
+ act(() => {
+ result.current.updateField(1, { Required: true });
+ });
+
+ expect(result.current.fieldLookup.get(1)?.Required).toBe(true);
+ expect(result.current.fieldLookup.get(1)?.Field_Name).toBe('A');
+ expect(result.current.isDirty).toBe(true);
+ });
+
+ it('no-ops when the field id is unknown, but still marks dirty', () => {
+ const fields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'A', Group_Name: '1 - First', View_Order: 1 }),
+ ];
+ const { result } = renderHook(() => useFieldOrderState(fields));
+ const before = result.current.fieldLookup;
+
+ act(() => {
+ result.current.updateField(999, { Required: true });
+ });
+
+ expect(result.current.fieldLookup).toBe(before);
+ expect(result.current.isDirty).toBe(true);
+ });
+});
+
+describe('useFieldOrderState > buildSavePayload', () => {
+ it('resolves group names to null for flat mode', () => {
+ const fields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'A', Group_Name: null, View_Order: 1 }),
+ ];
+ const { result } = renderHook(() => useFieldOrderState(fields));
+
+ const payload = result.current.buildSavePayload();
+ expect(payload).toEqual([
+ expect.objectContaining({ Field_Name: 'A', Group_Name: null, View_Order: 1 }),
+ ]);
+ });
+
+ it('renumbers named groups sequentially and keeps "Other Fields" as-is, skipping unknown field ids', () => {
+ const fields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'A', Group_Name: '5 - First', View_Order: 1 }),
+ makeField({ Page_Field_ID: 2, Field_Name: 'B', Group_Name: '10 - Second', View_Order: 1 }),
+ makeField({ Page_Field_ID: 3, Field_Name: 'C', Group_Name: OTHER, View_Order: 1 }),
+ ];
+ const { result } = renderHook(() => useFieldOrderState(fields));
+
+ const payload = result.current.buildSavePayload();
+ const byName = Object.fromEntries(payload.map((p) => [p.Field_Name, p]));
+
+ expect(byName['A'].Group_Name).toBe('1 - First');
+ expect(byName['B'].Group_Name).toBe('2 - Second');
+ expect(byName['C'].Group_Name).toBe(OTHER);
+ // View_Order increments continuously across groups regardless of renumbering.
+ expect(payload.map((p) => p.View_Order)).toEqual([1, 2, 3]);
+ });
+
+ it('falls back to an empty field list for a group present in groupOrder but absent from groupedFields', () => {
+ const fields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'A', Group_Name: '1 - First', View_Order: 1 }),
+ ];
+ const { result } = renderHook(() => useFieldOrderState(fields));
+
+ // Drive groupOrder to include a name with no corresponding groupedFields entry
+ // via the mocked move() used by the group-reorder path in handleDragEnd.
+ mockIsSortable.mockReturnValue(true);
+ mockMove.mockReturnValue(['1 - First', 'Ghost Group']);
+ act(() => {
+ result.current.handleDragEnd(fakeDragEvent({
+ canceled: false,
+ operation: { source: { type: 'group' }, target: { id: '1 - First' } },
+ }));
+ });
+ expect(result.current.groupOrder).toContain('Ghost Group');
+ expect(result.current.groupedFields['Ghost Group']).toBeUndefined();
+
+ const payload = result.current.buildSavePayload();
+ expect(payload).toHaveLength(1);
+ expect(payload[0].Field_Name).toBe('A');
+ });
+
+ it('skips a field id present in a group array but missing from fieldLookup', () => {
+ const fields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'A', Group_Name: '1 - First', View_Order: 1 }),
+ ];
+ const { result } = renderHook(() => useFieldOrderState(fields));
+
+ // Inject a dangling field id (999) into the group via the mocked move() used
+ // by the field-reorder path in handleDragOver.
+ mockMove.mockReturnValue({ '1 - First': [1, 999] });
+ act(() => {
+ result.current.handleDragOver(fakeDragEvent({ operation: { source: { type: 'field', id: 1 }, target: { id: 1 } } }));
+ });
+ expect(result.current.groupedFields['1 - First']).toEqual([1, 999]);
+
+ const payload = result.current.buildSavePayload();
+ expect(payload).toHaveLength(1);
+ expect(payload[0].Field_Name).toBe('A');
+ });
+});
+
+describe('useFieldOrderState > hideAllSeparators idempotency', () => {
+ it('leaves an already-hidden separator untouched on a second call', () => {
+ const fields: PageField[] = [
+ makeField({ Page_Field_ID: 1, Field_Name: 'Sep_A', Group_Name: '1 - General', View_Order: 1, isSeparator: true }),
+ ];
+ const { result } = renderHook(() => useFieldOrderState(fields));
+
+ act(() => {
+ result.current.hideAllSeparators();
+ });
+ expect(result.current.fieldLookup.get(1)?.Hidden).toBe(true);
+
+ act(() => {
+ result.current.hideAllSeparators();
+ });
+ expect(result.current.fieldLookup.get(1)?.Hidden).toBe(true);
+ });
+});
diff --git a/src/components/group-wizard/contact-search.test.tsx b/src/components/group-wizard/contact-search.test.tsx
new file mode 100644
index 0000000..bf8dbbd
--- /dev/null
+++ b/src/components/group-wizard/contact-search.test.tsx
@@ -0,0 +1,171 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
+
+const { mockSearchContacts } = vi.hoisted(() => ({
+ mockSearchContacts: vi.fn(),
+}));
+
+vi.mock('./actions', () => ({
+ searchContacts: mockSearchContacts,
+}));
+
+import { ContactSearch } from './contact-search';
+
+async function flush() {
+ await act(async () => {
+ await Promise.resolve();
+ });
+}
+
+async function advanceDebounce(ms = 300) {
+ await act(async () => {
+ vi.advanceTimersByTime(ms);
+ await Promise.resolve();
+ });
+}
+
+describe('ContactSearch', () => {
+ beforeEach(() => {
+ // jsdom does not implement scrollIntoView; cmdk calls it on selection change.
+ Element.prototype.scrollIntoView = vi.fn();
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+ mockSearchContacts.mockReset();
+ mockSearchContacts.mockResolvedValue([]);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it('shows the fixed placeholder when closed with no value', () => {
+ render( );
+ expect(screen.getByText('Search contacts...')).toBeInTheDocument();
+ });
+
+ it('shows the display name instead of the placeholder when value + displayName are set', () => {
+ render( );
+ expect(screen.getByText('Jane Doe')).toBeInTheDocument();
+ expect(screen.queryByText('Search contacts...')).not.toBeInTheDocument();
+ });
+
+ it('disables the trigger button when disabled prop is true', () => {
+ render( );
+ expect(screen.getByRole('combobox')).toBeDisabled();
+ });
+
+ it('shows the "type at least 2 characters" hint when opened with an empty query', () => {
+ render( );
+ fireEvent.click(screen.getByRole('combobox'));
+ expect(screen.getByText('Type at least 2 characters to search')).toBeInTheDocument();
+ });
+
+ it('doSearch short-circuits with empty results when the debounced term is still under 2 chars', async () => {
+ render( );
+ fireEvent.click(screen.getByRole('combobox'));
+ await advanceDebounce(300);
+ expect(mockSearchContacts).not.toHaveBeenCalled();
+ expect(screen.getByText('Type at least 2 characters to search')).toBeInTheDocument();
+ });
+
+ it('debounces a >=2-char query, shows "Searching...", then renders results', async () => {
+ let resolveSearch: (v: unknown) => void;
+ mockSearchContacts.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveSearch = resolve;
+ }),
+ );
+
+ render( );
+ fireEvent.click(screen.getByRole('combobox'));
+
+ const input = screen.getByPlaceholderText('Type a name...');
+ fireEvent.change(input, { target: { value: 'Ja' } });
+
+ await advanceDebounce(300);
+ expect(mockSearchContacts).toHaveBeenCalledWith('Ja');
+ expect(screen.getByText('Searching...')).toBeInTheDocument();
+
+ await act(async () => {
+ resolveSearch!([{ Contact_ID: 1, Display_Name: 'Jane Doe', Email_Address: 'jane@example.com' }]);
+ await Promise.resolve();
+ });
+
+ expect(screen.getByText('Jane Doe')).toBeInTheDocument();
+ expect(screen.getByText('jane@example.com')).toBeInTheDocument();
+ });
+
+ it('shows "No contacts found." when a >=2-char search resolves empty', async () => {
+ mockSearchContacts.mockResolvedValueOnce([]);
+ render( );
+ fireEvent.click(screen.getByRole('combobox'));
+
+ const input = screen.getByPlaceholderText('Type a name...');
+ fireEvent.change(input, { target: { value: 'Zz' } });
+ await advanceDebounce(300);
+
+ expect(screen.getByText('No contacts found.')).toBeInTheDocument();
+ });
+
+ it('recovers to empty results (no crash) when the search action rejects', async () => {
+ mockSearchContacts.mockRejectedValueOnce(new Error('network down'));
+ render( );
+ fireEvent.click(screen.getByRole('combobox'));
+
+ const input = screen.getByPlaceholderText('Type a name...');
+ fireEvent.change(input, { target: { value: 'Er' } });
+ await advanceDebounce(300);
+
+ expect(screen.queryByText('Searching...')).not.toBeInTheDocument();
+ expect(screen.getByText('No contacts found.')).toBeInTheDocument();
+ });
+
+ it('renders a result without an Email_Address with no subtitle line', async () => {
+ mockSearchContacts.mockResolvedValueOnce([{ Contact_ID: 2, Display_Name: 'No Email Guy', Email_Address: null }]);
+ render( );
+ fireEvent.click(screen.getByRole('combobox'));
+
+ const input = screen.getByPlaceholderText('Type a name...');
+ fireEvent.change(input, { target: { value: 'No' } });
+ await advanceDebounce(300);
+
+ expect(screen.getByText('No Email Guy')).toBeInTheDocument();
+ });
+
+ it('calls onSelect with id/name and closes the popover when a result is chosen', async () => {
+ const onSelect = vi.fn();
+ mockSearchContacts.mockResolvedValueOnce([
+ { Contact_ID: 3, Display_Name: 'Pick Me', Email_Address: 'pick@example.com' },
+ ]);
+ render( );
+ fireEvent.click(screen.getByRole('combobox'));
+
+ const input = screen.getByPlaceholderText('Type a name...');
+ fireEvent.change(input, { target: { value: 'Pi' } });
+ await advanceDebounce(300);
+
+ fireEvent.click(screen.getByText('Pick Me'));
+ await flush();
+
+ expect(onSelect).toHaveBeenCalledWith(3, 'Pick Me');
+ expect(screen.getByRole('combobox')).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('debounces rapid re-typing into a single call for the final term', async () => {
+ render( );
+ fireEvent.click(screen.getByRole('combobox'));
+ const input = screen.getByPlaceholderText('Type a name...');
+
+ fireEvent.change(input, { target: { value: 'Ja' } });
+ await act(async () => {
+ vi.advanceTimersByTime(100);
+ await Promise.resolve();
+ });
+ fireEvent.change(input, { target: { value: 'Jan' } });
+ await advanceDebounce(300);
+
+ expect(mockSearchContacts).toHaveBeenCalledTimes(1);
+ expect(mockSearchContacts).toHaveBeenCalledWith('Jan');
+ });
+});
diff --git a/src/components/group-wizard/group-search.test.tsx b/src/components/group-wizard/group-search.test.tsx
new file mode 100644
index 0000000..c0b6fe9
--- /dev/null
+++ b/src/components/group-wizard/group-search.test.tsx
@@ -0,0 +1,194 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
+
+const { mockSearchGroups } = vi.hoisted(() => ({
+ mockSearchGroups: vi.fn(),
+}));
+
+vi.mock('./actions', () => ({
+ searchGroups: mockSearchGroups,
+}));
+
+import { GroupSearch } from './group-search';
+
+async function flush() {
+ await act(async () => {
+ await Promise.resolve();
+ });
+}
+
+async function advanceDebounce(ms = 300) {
+ await act(async () => {
+ vi.advanceTimersByTime(ms);
+ await Promise.resolve();
+ });
+}
+
+describe('GroupSearch', () => {
+ beforeEach(() => {
+ // jsdom does not implement scrollIntoView; cmdk calls it on selection change.
+ Element.prototype.scrollIntoView = vi.fn();
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
+ mockSearchGroups.mockReset();
+ mockSearchGroups.mockResolvedValue([]);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it('shows the default placeholder when closed with no value', () => {
+ render( );
+ expect(screen.getByText('Search groups...')).toBeInTheDocument();
+ });
+
+ it('shows a custom placeholder when provided', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('Search promotion target group...')).toBeInTheDocument();
+ });
+
+ it('shows the display name instead of the placeholder when value + displayName are set', () => {
+ render( );
+ expect(screen.getByText('Youth Group')).toBeInTheDocument();
+ expect(screen.queryByText('Search groups...')).not.toBeInTheDocument();
+ });
+
+ it('disables the trigger button when disabled prop is true', () => {
+ render( );
+ expect(screen.getByRole('combobox')).toBeDisabled();
+ });
+
+ it('shows the "type at least 2 characters" hint when opened with an empty query', () => {
+ render( );
+ fireEvent.click(screen.getByRole('combobox'));
+ expect(screen.getByText('Type at least 2 characters to search')).toBeInTheDocument();
+ });
+
+ it('doSearch short-circuits with empty results when the debounced term is still under 2 chars', async () => {
+ render( );
+ fireEvent.click(screen.getByRole('combobox'));
+ await advanceDebounce(300);
+ expect(mockSearchGroups).not.toHaveBeenCalled();
+ expect(screen.getByText('Type at least 2 characters to search')).toBeInTheDocument();
+ });
+
+ it('debounces a >=2-char query, shows "Searching...", then renders results', async () => {
+ let resolveSearch: (v: unknown) => void;
+ mockSearchGroups.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveSearch = resolve;
+ }),
+ );
+
+ render( );
+ fireEvent.click(screen.getByRole('combobox'));
+
+ const input = screen.getByPlaceholderText('Type a group name...');
+ fireEvent.change(input, { target: { value: 'Yo' } });
+
+ await advanceDebounce(300);
+ expect(mockSearchGroups).toHaveBeenCalledWith('Yo');
+ expect(screen.getByText('Searching...')).toBeInTheDocument();
+
+ await act(async () => {
+ resolveSearch!([{ Group_ID: 1, Group_Name: 'Youth Group', Group_Type: 'Small Group' }]);
+ await Promise.resolve();
+ });
+
+ expect(screen.getByText('Youth Group')).toBeInTheDocument();
+ expect(screen.getByText('Small Group')).toBeInTheDocument();
+ });
+
+ it('shows "No groups found." when a >=2-char search resolves empty', async () => {
+ mockSearchGroups.mockResolvedValueOnce([]);
+ render( );
+ fireEvent.click(screen.getByRole('combobox'));
+
+ const input = screen.getByPlaceholderText('Type a group name...');
+ fireEvent.change(input, { target: { value: 'Zz' } });
+ await advanceDebounce(300);
+
+ expect(screen.getByText('No groups found.')).toBeInTheDocument();
+ });
+
+ it('recovers to empty results (no crash) when the search action rejects', async () => {
+ mockSearchGroups.mockRejectedValueOnce(new Error('network down'));
+ render( );
+ fireEvent.click(screen.getByRole('combobox'));
+
+ const input = screen.getByPlaceholderText('Type a group name...');
+ fireEvent.change(input, { target: { value: 'Er' } });
+ await advanceDebounce(300);
+
+ expect(screen.queryByText('Searching...')).not.toBeInTheDocument();
+ expect(screen.getByText('No groups found.')).toBeInTheDocument();
+ });
+
+ it('renders a result without a Group_Type with no subtitle line', async () => {
+ mockSearchGroups.mockResolvedValueOnce([{ Group_ID: 2, Group_Name: 'No Type Group', Group_Type: null }]);
+ render( );
+ fireEvent.click(screen.getByRole('combobox'));
+
+ const input = screen.getByPlaceholderText('Type a group name...');
+ fireEvent.change(input, { target: { value: 'No' } });
+ await advanceDebounce(300);
+
+ expect(screen.getByText('No Type Group')).toBeInTheDocument();
+ });
+
+ it('calls onSelect with id/name and closes the popover when a result is chosen', async () => {
+ const onSelect = vi.fn();
+ mockSearchGroups.mockResolvedValueOnce([{ Group_ID: 3, Group_Name: 'Pick Me', Group_Type: 'Type A' }]);
+ render( );
+ fireEvent.click(screen.getByRole('combobox'));
+
+ const input = screen.getByPlaceholderText('Type a group name...');
+ fireEvent.change(input, { target: { value: 'Pi' } });
+ await advanceDebounce(300);
+
+ fireEvent.click(screen.getByText('Pick Me'));
+ await flush();
+
+ expect(onSelect).toHaveBeenCalledWith(3, 'Pick Me');
+ expect(screen.getByRole('combobox')).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('shows "Clear selection" only when value is set, and clears on click', async () => {
+ const onSelect = vi.fn();
+ const { rerender } = render( );
+ fireEvent.click(screen.getByRole('combobox'));
+ expect(screen.queryByText('Clear selection')).not.toBeInTheDocument();
+
+ cleanup();
+ render( );
+ fireEvent.click(screen.getByRole('combobox'));
+ expect(screen.getByText('Clear selection')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText('Clear selection'));
+ await flush();
+ expect(onSelect).toHaveBeenCalledWith(null, '');
+ // silence unused rerender warning
+ void rerender;
+ });
+
+ it('debounces rapid re-typing into a single call for the final term', async () => {
+ render( );
+ fireEvent.click(screen.getByRole('combobox'));
+ const input = screen.getByPlaceholderText('Type a group name...');
+
+ fireEvent.change(input, { target: { value: 'Yo' } });
+ await act(async () => {
+ vi.advanceTimersByTime(100);
+ await Promise.resolve();
+ });
+ fireEvent.change(input, { target: { value: 'You' } });
+ await advanceDebounce(300);
+
+ expect(mockSearchGroups).toHaveBeenCalledTimes(1);
+ expect(mockSearchGroups).toHaveBeenCalledWith('You');
+ });
+});
diff --git a/src/components/group-wizard/step-attributes.test.tsx b/src/components/group-wizard/step-attributes.test.tsx
new file mode 100644
index 0000000..5dc1aaa
--- /dev/null
+++ b/src/components/group-wizard/step-attributes.test.tsx
@@ -0,0 +1,180 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useForm, FormProvider } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { StepAttributes } from './step-attributes';
+import { groupWizardSchema, GROUP_WIZARD_DEFAULTS, type GroupWizardFormData } from './schema';
+import type { GroupWizardLookups } from './types';
+
+beforeEach(() => {
+ Element.prototype.hasPointerCapture = Element.prototype.hasPointerCapture || (() => false);
+ Element.prototype.setPointerCapture = Element.prototype.setPointerCapture || (() => {});
+ Element.prototype.releasePointerCapture = Element.prototype.releasePointerCapture || (() => {});
+ Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {});
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+const LOOKUPS: GroupWizardLookups = {
+ groupTypes: [],
+ ministries: [],
+ congregations: [],
+ meetingDays: [],
+ meetingFrequencies: [],
+ meetingDurations: [],
+ lifeStages: [
+ { id: 1, name: 'Adult' },
+ { id: 2, name: 'Youth' },
+ ],
+ groupFocuses: [
+ { id: 1, name: 'Study' },
+ { id: 2, name: 'Support' },
+ ],
+ priorities: [],
+ rooms: [],
+ books: [
+ { id: 1, name: 'Book A' },
+ { id: 2, name: 'Book B' },
+ ],
+ smsNumbers: [
+ { id: 1, name: '555-0100' },
+ { id: 2, name: '555-0200' },
+ ],
+ groupEndedReasons: [],
+};
+
+function Harness({ overrides }: { overrides?: Partial }) {
+ const form = useForm({
+ resolver: zodResolver(groupWizardSchema),
+ defaultValues: { ...GROUP_WIZARD_DEFAULTS, ...overrides } as GroupWizardFormData,
+ mode: 'onTouched',
+ });
+ return (
+
+
+ {
+ void form.trigger(['Target_Size']);
+ }}
+ >
+ validate
+
+
+ );
+}
+
+function renderStep(overrides?: Partial) {
+ return render( );
+}
+
+async function selectOption(user: ReturnType, comboboxIndex: number, optionName: string) {
+ const comboboxes = screen.getAllByRole('combobox');
+ await user.click(comboboxes[comboboxIndex]);
+ const option = await screen.findByRole('option', { name: optionName });
+ await user.click(option);
+}
+
+/**
+ * These tests deliberately drive failure paths, and the code under test logs
+ * them on purpose. Silence the channel so a real, unexpected error still
+ * stands out in the runner output instead of drowning in expected noise.
+ * `mockImplementation` keeps the spy recording, so assertions on what was
+ * logged still work.
+ */
+beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+});
+
+describe('StepAttributes', () => {
+ it('renders all attribute fields', () => {
+ renderStep();
+ expect(screen.getByText('Group Attributes')).toBeInTheDocument();
+ expect(screen.getByLabelText(/Target Size/)).toBeInTheDocument();
+ expect(screen.getByText('Life Stage')).toBeInTheDocument();
+ expect(screen.getByText('Group Focus')).toBeInTheDocument();
+ expect(screen.getByText('Required Book')).toBeInTheDocument();
+ expect(screen.getByText('SMS Number')).toBeInTheDocument();
+ expect(screen.getByText('Group Is Full')).toBeInTheDocument();
+ });
+
+ it('enters a target size and converts it to a number', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ const input = screen.getByLabelText(/Target Size/);
+ await user.type(input, '12');
+ expect(input).toHaveValue(12);
+ });
+
+ it('clears the target size back to null', async () => {
+ const user = userEvent.setup();
+ renderStep({ Target_Size: 12 });
+ const input = screen.getByLabelText(/Target Size/);
+ await user.clear(input);
+ expect(input).toHaveValue(null);
+ });
+
+ it('rejects a non-positive Target_Size on validation', async () => {
+ const user = userEvent.setup();
+ renderStep({ Target_Size: 0 });
+ await user.click(screen.getByTestId('trigger-validate'));
+ expect(await screen.findByText('Must be a positive number')).toBeInTheDocument();
+ });
+
+ it('selects a life stage', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ await selectOption(user, 0, 'Youth');
+ expect(screen.getAllByRole('combobox')[0]).toHaveTextContent('Youth');
+ });
+
+ it('selects a group focus', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ await selectOption(user, 1, 'Support');
+ expect(screen.getAllByRole('combobox')[1]).toHaveTextContent('Support');
+ });
+
+ it('selects a required book', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ await selectOption(user, 2, 'Book B');
+ expect(screen.getAllByRole('combobox')[2]).toHaveTextContent('Book B');
+ });
+
+ it('selects an SMS number', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ await selectOption(user, 3, '555-0200');
+ expect(screen.getAllByRole('combobox')[3]).toHaveTextContent('555-0200');
+ });
+
+ it('toggles Group Is Full switch', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ const toggle = screen.getByRole('switch');
+ expect(toggle).toHaveAttribute('data-state', 'unchecked');
+ await user.click(toggle);
+ expect(toggle).toHaveAttribute('data-state', 'checked');
+ });
+
+ it('renders with Group_Is_Full already true', () => {
+ renderStep({ Group_Is_Full: true });
+ expect(screen.getByRole('switch')).toHaveAttribute('data-state', 'checked');
+ });
+
+ it('renders with pre-selected lookup values', () => {
+ renderStep({ Life_Stage_ID: 1, Group_Focus_ID: 2, Required_Book: 1, SMS_Number: 2 });
+ const comboboxes = screen.getAllByRole('combobox');
+ expect(comboboxes[0]).toHaveTextContent('Adult');
+ expect(comboboxes[1]).toHaveTextContent('Support');
+ expect(comboboxes[2]).toHaveTextContent('Book A');
+ expect(comboboxes[3]).toHaveTextContent('555-0200');
+ });
+});
diff --git a/src/components/group-wizard/step-attributes.tsx b/src/components/group-wizard/step-attributes.tsx
index 97132b0..f8dc1b1 100644
--- a/src/components/group-wizard/step-attributes.tsx
+++ b/src/components/group-wizard/step-attributes.tsx
@@ -67,7 +67,7 @@ export function StepAttributes({ lookups }: StepAttributesProps) {
Life Stage
field.onChange(val ? Number(val) : null)}
- value={field.value ? String(field.value) : undefined}
+ value={field.value ? String(field.value) : ""}
>
@@ -95,7 +95,7 @@ export function StepAttributes({ lookups }: StepAttributesProps) {
Group Focus
field.onChange(val ? Number(val) : null)}
- value={field.value ? String(field.value) : undefined}
+ value={field.value ? String(field.value) : ""}
>
@@ -123,7 +123,7 @@ export function StepAttributes({ lookups }: StepAttributesProps) {
Required Book
field.onChange(val ? Number(val) : null)}
- value={field.value ? String(field.value) : undefined}
+ value={field.value ? String(field.value) : ""}
>
@@ -151,7 +151,7 @@ export function StepAttributes({ lookups }: StepAttributesProps) {
SMS Number
field.onChange(val ? Number(val) : null)}
- value={field.value ? String(field.value) : undefined}
+ value={field.value ? String(field.value) : ""}
>
diff --git a/src/components/group-wizard/step-identity.test.tsx b/src/components/group-wizard/step-identity.test.tsx
new file mode 100644
index 0000000..52812b7
--- /dev/null
+++ b/src/components/group-wizard/step-identity.test.tsx
@@ -0,0 +1,189 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useForm, FormProvider } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { StepIdentity } from './step-identity';
+import { groupWizardSchema, GROUP_WIZARD_DEFAULTS, type GroupWizardFormData } from './schema';
+import type { GroupWizardLookups } from './types';
+
+// Radix Select relies on pointer-capture / scrollIntoView APIs jsdom does not
+// implement — stub them so the popover content actually opens in tests.
+beforeEach(() => {
+ Element.prototype.hasPointerCapture = Element.prototype.hasPointerCapture || (() => false);
+ Element.prototype.setPointerCapture = Element.prototype.setPointerCapture || (() => {});
+ Element.prototype.releasePointerCapture = Element.prototype.releasePointerCapture || (() => {});
+ Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {});
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+const LOOKUPS: GroupWizardLookups = {
+ groupTypes: [
+ { id: 1, name: 'Small Group' },
+ { id: 2, name: 'Class' },
+ ],
+ ministries: [],
+ congregations: [],
+ meetingDays: [],
+ meetingFrequencies: [],
+ meetingDurations: [],
+ lifeStages: [],
+ groupFocuses: [],
+ priorities: [],
+ rooms: [],
+ books: [],
+ smsNumbers: [],
+ groupEndedReasons: [
+ { id: 1, name: 'Moved' },
+ { id: 2, name: 'Disbanded' },
+ ],
+};
+
+function Harness({ overrides }: { overrides?: Partial }) {
+ const form = useForm({
+ resolver: zodResolver(groupWizardSchema),
+ defaultValues: { ...GROUP_WIZARD_DEFAULTS, ...overrides } as GroupWizardFormData,
+ mode: 'onTouched',
+ });
+ return (
+
+
+ {
+ void form.trigger(['Group_Name', 'Group_Type_ID', 'Start_Date']);
+ }}
+ >
+ validate
+
+
+ );
+}
+
+function renderStep(overrides?: Partial) {
+ return render( );
+}
+
+/**
+ * These tests deliberately drive failure paths, and the code under test logs
+ * them on purpose. Silence the channel so a real, unexpected error still
+ * stands out in the runner output instead of drowning in expected noise.
+ * `mockImplementation` keeps the spy recording, so assertions on what was
+ * logged still work.
+ */
+beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+});
+
+describe('StepIdentity', () => {
+ it('renders the core identity fields', () => {
+ renderStep();
+ expect(screen.getByText('Group Identity')).toBeInTheDocument();
+ expect(screen.getByLabelText(/Group Name/)).toBeInTheDocument();
+ expect(screen.getByText(/Group Type/)).toBeInTheDocument();
+ expect(screen.getByLabelText(/Start Date/)).toBeInTheDocument();
+ expect(screen.getByLabelText(/End Date/)).toBeInTheDocument();
+ expect(screen.getByLabelText(/Description/)).toBeInTheDocument();
+ });
+
+ it('does not render Reason_Ended when End_Date is empty', () => {
+ renderStep();
+ expect(screen.queryByText('Reason Ended')).not.toBeInTheDocument();
+ });
+
+ it('renders Reason_Ended once End_Date is set', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ const endDate = screen.getByLabelText(/End Date/);
+ await user.type(endDate, '2026-01-01');
+ expect(screen.getByText('Reason Ended')).toBeInTheDocument();
+ });
+
+ it('lets the user type a group name', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ const nameInput = screen.getByLabelText(/Group Name/);
+ await user.type(nameInput, 'Youth Group');
+ expect(nameInput).toHaveValue('Youth Group');
+ });
+
+ it('shows a validation error when Group_Name is empty and validated', async () => {
+ const user = userEvent.setup();
+ renderStep({ Group_Name: '', Start_Date: '2026-01-01', Group_Type_ID: 1 });
+ await user.click(screen.getByTestId('trigger-validate'));
+ expect(await screen.findByText('Group name is required')).toBeInTheDocument();
+ });
+
+ it('shows a validation error when Start_Date is empty and validated', async () => {
+ const user = userEvent.setup();
+ renderStep({ Group_Name: 'Valid', Start_Date: '', Group_Type_ID: 1 });
+ await user.click(screen.getByTestId('trigger-validate'));
+ expect(await screen.findByText('Start date is required')).toBeInTheDocument();
+ });
+
+ it('selects a Group Type from the dropdown', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ const trigger = screen.getByRole('combobox');
+ await user.click(trigger);
+ const option = await screen.findByRole('option', { name: 'Class' });
+ await user.click(option);
+ expect(screen.getByRole('combobox')).toHaveTextContent('Class');
+ });
+
+ it('selects a Reason Ended value once the field is shown', async () => {
+ const user = userEvent.setup();
+ renderStep({ End_Date: '2026-01-01' });
+ const comboboxes = screen.getAllByRole('combobox');
+ // Second combobox is Reason Ended (first is Group Type)
+ const reasonTrigger = comboboxes[1];
+ await user.click(reasonTrigger);
+ const option = await screen.findByRole('option', { name: 'Disbanded' });
+ await user.click(option);
+ expect(reasonTrigger).toHaveTextContent('Disbanded');
+ });
+
+ it('clears End_Date back to null when the input is cleared', async () => {
+ const user = userEvent.setup();
+ renderStep({ End_Date: '2026-01-01' });
+ const endDate = screen.getByLabelText(/End Date/);
+ await user.clear(endDate);
+ expect(screen.queryByText('Reason Ended')).not.toBeInTheDocument();
+ });
+
+ it('lets the user type a description', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ const desc = screen.getByLabelText(/Description/);
+ await user.type(desc, 'A weekly gathering');
+ expect(desc).toHaveValue('A weekly gathering');
+ });
+
+ it('clears the description back to null when emptied', async () => {
+ const user = userEvent.setup();
+ renderStep({ Description: 'Existing text' });
+ const desc = screen.getByLabelText(/Description/);
+ await user.clear(desc);
+ expect(desc).toHaveValue('');
+ });
+
+ it('renders with an existing Group_Type_ID selected', () => {
+ renderStep({ Group_Type_ID: 2 });
+ expect(screen.getByRole('combobox')).toHaveTextContent('Class');
+ });
+
+ it('uses within() to scope the identity heading section', () => {
+ renderStep();
+ const heading = screen.getByText('Group Identity').closest('div');
+ expect(heading).not.toBeNull();
+ if (heading) {
+ expect(within(heading).getByText('Group Identity')).toBeInTheDocument();
+ }
+ });
+});
diff --git a/src/components/group-wizard/step-identity.tsx b/src/components/group-wizard/step-identity.tsx
index 55f8fe3..bab1042 100644
--- a/src/components/group-wizard/step-identity.tsx
+++ b/src/components/group-wizard/step-identity.tsx
@@ -61,7 +61,7 @@ export function StepIdentity({ lookups }: StepIdentityProps) {
Group Type *
field.onChange(Number(val))}
- value={field.value ? String(field.value) : undefined}
+ value={field.value ? String(field.value) : ""}
>
@@ -125,7 +125,7 @@ export function StepIdentity({ lookups }: StepIdentityProps) {
Reason Ended
field.onChange(val ? Number(val) : null)}
- value={field.value ? String(field.value) : undefined}
+ value={field.value ? String(field.value) : ""}
>
diff --git a/src/components/group-wizard/step-meeting.test.tsx b/src/components/group-wizard/step-meeting.test.tsx
new file mode 100644
index 0000000..2e1627f
--- /dev/null
+++ b/src/components/group-wizard/step-meeting.test.tsx
@@ -0,0 +1,157 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useForm, FormProvider } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { StepMeeting } from './step-meeting';
+import { groupWizardSchema, GROUP_WIZARD_DEFAULTS, type GroupWizardFormData } from './schema';
+import type { GroupWizardLookups } from './types';
+
+beforeEach(() => {
+ Element.prototype.hasPointerCapture = Element.prototype.hasPointerCapture || (() => false);
+ Element.prototype.setPointerCapture = Element.prototype.setPointerCapture || (() => {});
+ Element.prototype.releasePointerCapture = Element.prototype.releasePointerCapture || (() => {});
+ Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {});
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+const LOOKUPS: GroupWizardLookups = {
+ groupTypes: [],
+ ministries: [],
+ congregations: [],
+ meetingDays: [
+ { id: 1, name: 'Sunday' },
+ { id: 2, name: 'Monday' },
+ ],
+ meetingFrequencies: [
+ { id: 1, name: 'Weekly' },
+ { id: 2, name: 'Biweekly' },
+ ],
+ meetingDurations: [
+ { id: 1, name: '1 hour' },
+ { id: 2, name: '2 hours' },
+ ],
+ lifeStages: [],
+ groupFocuses: [],
+ priorities: [],
+ rooms: [
+ { id: 1, name: 'Room A' },
+ { id: 2, name: 'Room B' },
+ ],
+ books: [],
+ smsNumbers: [],
+ groupEndedReasons: [],
+};
+
+function Harness({ overrides }: { overrides?: Partial }) {
+ const form = useForm({
+ resolver: zodResolver(groupWizardSchema),
+ defaultValues: { ...GROUP_WIZARD_DEFAULTS, ...overrides } as GroupWizardFormData,
+ mode: 'onTouched',
+ });
+ return (
+
+
+
+ );
+}
+
+function renderStep(overrides?: Partial) {
+ return render( );
+}
+
+async function selectOption(user: ReturnType, comboboxIndex: number, optionName: string) {
+ const comboboxes = screen.getAllByRole('combobox');
+ await user.click(comboboxes[comboboxIndex]);
+ const option = await screen.findByRole('option', { name: optionName });
+ await user.click(option);
+}
+
+describe('StepMeeting', () => {
+ it('renders all meeting fields', () => {
+ renderStep();
+ expect(screen.getByText('Meeting Schedule')).toBeInTheDocument();
+ expect(screen.getByText('Meeting Day')).toBeInTheDocument();
+ expect(screen.getByLabelText(/Meeting Time/)).toBeInTheDocument();
+ expect(screen.getByText('Meeting Frequency')).toBeInTheDocument();
+ expect(screen.getByText('Meeting Duration')).toBeInTheDocument();
+ expect(screen.getByLabelText(/Default Meeting Room/)).toBeInTheDocument();
+ expect(screen.getByLabelText(/Offsite Meeting Address/)).toBeInTheDocument();
+ expect(screen.getByText('Meets Online')).toBeInTheDocument();
+ });
+
+ it('selects a meeting day', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ await selectOption(user, 0, 'Monday');
+ expect(screen.getAllByRole('combobox')[0]).toHaveTextContent('Monday');
+ });
+
+ it('selects a meeting frequency', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ await selectOption(user, 1, 'Biweekly');
+ expect(screen.getAllByRole('combobox')[1]).toHaveTextContent('Biweekly');
+ });
+
+ it('selects a meeting duration', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ await selectOption(user, 2, '2 hours');
+ expect(screen.getAllByRole('combobox')[2]).toHaveTextContent('2 hours');
+ });
+
+ it('selects a default meeting room', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ await selectOption(user, 3, 'Room B');
+ expect(screen.getAllByRole('combobox')[3]).toHaveTextContent('Room B');
+ });
+
+ it('sets a meeting time', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ const time = screen.getByLabelText(/Meeting Time/);
+ await user.type(time, '09:30');
+ expect(time).toHaveValue('09:30');
+ });
+
+ it('enters a numeric offsite address and converts to a number', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ const addr = screen.getByLabelText(/Offsite Meeting Address/);
+ await user.type(addr, '55');
+ expect(addr).toHaveValue(55);
+ });
+
+ it('clears the offsite address back to empty/null', async () => {
+ const user = userEvent.setup();
+ renderStep({ Offsite_Meeting_Address: 55 });
+ const addr = screen.getByLabelText(/Offsite Meeting Address/);
+ await user.clear(addr);
+ expect(addr).toHaveValue(null);
+ });
+
+ it('toggles Meets Online switch on', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ const toggle = screen.getByRole('switch');
+ expect(toggle).toHaveAttribute('data-state', 'unchecked');
+ await user.click(toggle);
+ expect(toggle).toHaveAttribute('data-state', 'checked');
+ });
+
+ it('renders with a pre-set meeting time value', () => {
+ renderStep({ Meeting_Time: '14:00' });
+ expect(screen.getByLabelText(/Meeting Time/)).toHaveValue('14:00');
+ });
+
+ it('renders with Meets_Online already true', () => {
+ renderStep({ Meets_Online: true });
+ expect(screen.getByRole('switch')).toHaveAttribute('data-state', 'checked');
+ });
+});
diff --git a/src/components/group-wizard/step-meeting.tsx b/src/components/group-wizard/step-meeting.tsx
index 0cb7159..1902102 100644
--- a/src/components/group-wizard/step-meeting.tsx
+++ b/src/components/group-wizard/step-meeting.tsx
@@ -46,7 +46,7 @@ export function StepMeeting({ lookups }: StepMeetingProps) {
Meeting Day
field.onChange(val ? Number(val) : null)}
- value={field.value ? String(field.value) : undefined}
+ value={field.value ? String(field.value) : ""}
>
@@ -92,7 +92,7 @@ export function StepMeeting({ lookups }: StepMeetingProps) {
Meeting Frequency
field.onChange(val ? Number(val) : null)}
- value={field.value ? String(field.value) : undefined}
+ value={field.value ? String(field.value) : ""}
>
@@ -120,7 +120,7 @@ export function StepMeeting({ lookups }: StepMeetingProps) {
Meeting Duration
field.onChange(val ? Number(val) : null)}
- value={field.value ? String(field.value) : undefined}
+ value={field.value ? String(field.value) : ""}
>
@@ -148,7 +148,7 @@ export function StepMeeting({ lookups }: StepMeetingProps) {
Default Meeting Room
field.onChange(val ? Number(val) : null)}
- value={field.value ? String(field.value) : undefined}
+ value={field.value ? String(field.value) : ""}
>
diff --git a/src/components/group-wizard/step-organization.test.tsx b/src/components/group-wizard/step-organization.test.tsx
new file mode 100644
index 0000000..be607e9
--- /dev/null
+++ b/src/components/group-wizard/step-organization.test.tsx
@@ -0,0 +1,212 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useForm, FormProvider } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { groupWizardSchema, GROUP_WIZARD_DEFAULTS, type GroupWizardFormData } from './schema';
+import type { GroupWizardLookups } from './types';
+
+beforeEach(() => {
+ Element.prototype.hasPointerCapture = Element.prototype.hasPointerCapture || (() => false);
+ Element.prototype.setPointerCapture = Element.prototype.setPointerCapture || (() => {});
+ Element.prototype.releasePointerCapture = Element.prototype.releasePointerCapture || (() => {});
+ Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {});
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+// StepOrganization's own search UX (debounce, no-results, error states) is
+// covered by contact-search.test.tsx / group-search.test.tsx directly. Here we
+// only need stubs that let us drive the onSelect callbacks this step wires up.
+vi.mock('./contact-search', () => ({
+ ContactSearch: ({
+ onSelect,
+ displayName,
+ }: {
+ onSelect: (id: number, name: string) => void;
+ displayName?: string;
+ }) => (
+
+ {displayName ?? ''}
+ onSelect(42, 'Jane Doe')}>
+ pick-contact
+
+
+ ),
+}));
+
+vi.mock('./group-search', () => ({
+ GroupSearch: ({
+ onSelect,
+ displayName,
+ placeholder,
+ }: {
+ onSelect: (id: number | null, name: string) => void;
+ displayName?: string;
+ placeholder?: string;
+ }) => (
+
+ {displayName ?? ''}
+ onSelect(77, 'Parent Group Name')}>
+ pick-group
+
+ onSelect(null, '')}>
+ clear-group
+
+
+ ),
+}));
+
+import { StepOrganization } from './step-organization';
+
+const LOOKUPS: GroupWizardLookups = {
+ groupTypes: [],
+ ministries: [
+ { id: 1, name: 'Youth Ministry' },
+ { id: 2, name: 'Adult Ministry' },
+ ],
+ congregations: [
+ { id: 1, name: 'Main Campus' },
+ { id: 2, name: 'East Campus' },
+ ],
+ meetingDays: [],
+ meetingFrequencies: [],
+ meetingDurations: [],
+ lifeStages: [],
+ groupFocuses: [],
+ priorities: [
+ { id: 1, name: 'High' },
+ { id: 2, name: 'Low' },
+ ],
+ rooms: [],
+ books: [],
+ smsNumbers: [],
+ groupEndedReasons: [],
+};
+
+function Harness({
+ overrides,
+ onContactSelect = vi.fn(),
+ onGroupSelect = vi.fn(),
+ contactDisplayMap = new Map(),
+ groupDisplayMap = new Map(),
+}: {
+ overrides?: Partial;
+ onContactSelect?: (id: number, name: string) => void;
+ onGroupSelect?: (field: string, id: number | null, name: string) => void;
+ contactDisplayMap?: Map;
+ groupDisplayMap?: Map;
+}) {
+ const form = useForm({
+ resolver: zodResolver(groupWizardSchema),
+ defaultValues: { ...GROUP_WIZARD_DEFAULTS, ...overrides } as GroupWizardFormData,
+ mode: 'onTouched',
+ });
+ return (
+
+
+ {
+ void form.trigger(['Congregation_ID', 'Ministry_ID', 'Primary_Contact']);
+ }}
+ >
+ validate
+
+
+ );
+}
+
+function renderStep(props: Parameters[0] = {}) {
+ return render( );
+}
+
+async function selectOption(user: ReturnType, comboboxIndex: number, optionName: string) {
+ const comboboxes = screen.getAllByRole('combobox');
+ await user.click(comboboxes[comboboxIndex]);
+ const option = await screen.findByRole('option', { name: optionName });
+ await user.click(option);
+}
+
+describe('StepOrganization', () => {
+ it('renders all organization fields', () => {
+ renderStep();
+ expect(screen.getByText('Organization & People')).toBeInTheDocument();
+ expect(screen.getByText(/Congregation/)).toBeInTheDocument();
+ expect(screen.getByText(/Ministry/)).toBeInTheDocument();
+ expect(screen.getByText(/Primary Contact/)).toBeInTheDocument();
+ expect(screen.getByText('Parent Group')).toBeInTheDocument();
+ expect(screen.getByText('Priority')).toBeInTheDocument();
+ });
+
+ it('selects a congregation', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ await selectOption(user, 0, 'East Campus');
+ expect(screen.getAllByRole('combobox')[0]).toHaveTextContent('East Campus');
+ });
+
+ it('selects a ministry', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ await selectOption(user, 1, 'Adult Ministry');
+ expect(screen.getAllByRole('combobox')[1]).toHaveTextContent('Adult Ministry');
+ });
+
+ it('selects a priority', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ await selectOption(user, 2, 'High');
+ expect(screen.getAllByRole('combobox')[2]).toHaveTextContent('High');
+ });
+
+ it('picks a primary contact via ContactSearch and notifies the parent', async () => {
+ const user = userEvent.setup();
+ const onContactSelect = vi.fn();
+ renderStep({ onContactSelect });
+ await user.click(screen.getByText('pick-contact'));
+ expect(onContactSelect).toHaveBeenCalledWith(42, 'Jane Doe');
+ });
+
+ it('picks a parent group via GroupSearch and notifies the parent with the field name', async () => {
+ const user = userEvent.setup();
+ const onGroupSelect = vi.fn();
+ renderStep({ onGroupSelect });
+ const stub = screen.getByTestId('group-search-stub-Search parent group...');
+ await user.click(within(stub).getByText('pick-group'));
+ expect(onGroupSelect).toHaveBeenCalledWith('Parent_Group', 77, 'Parent Group Name');
+ });
+
+ it('clears the parent group selection', async () => {
+ const user = userEvent.setup();
+ const onGroupSelect = vi.fn();
+ renderStep({ onGroupSelect, overrides: { Parent_Group: 77 } });
+ const stub = screen.getByTestId('group-search-stub-Search parent group...');
+ await user.click(within(stub).getByText('clear-group'));
+ expect(onGroupSelect).toHaveBeenCalledWith('Parent_Group', null, '');
+ });
+
+ it('renders the contact display name from the map', () => {
+ renderStep({ contactDisplayMap: new Map([[42, 'Jane Doe']]), overrides: { Primary_Contact: 42 } });
+ expect(screen.getByTestId('contact-display-name')).toHaveTextContent('Jane Doe');
+ });
+
+ it('shows required-field validation errors when validated empty', async () => {
+ const user = userEvent.setup();
+ renderStep();
+ await user.click(screen.getByTestId('trigger-validate'));
+ expect(await screen.findByText('Congregation is required')).toBeInTheDocument();
+ expect(await screen.findByText('Ministry is required')).toBeInTheDocument();
+ expect(await screen.findByText('Primary contact is required')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/group-wizard/step-organization.tsx b/src/components/group-wizard/step-organization.tsx
index 191b35d..a180d34 100644
--- a/src/components/group-wizard/step-organization.tsx
+++ b/src/components/group-wizard/step-organization.tsx
@@ -56,7 +56,7 @@ export function StepOrganization({
Congregation *
field.onChange(Number(val))}
- value={field.value ? String(field.value) : undefined}
+ value={field.value ? String(field.value) : ""}
>
@@ -84,7 +84,7 @@ export function StepOrganization({
Ministry *
field.onChange(Number(val))}
- value={field.value ? String(field.value) : undefined}
+ value={field.value ? String(field.value) : ""}
>
@@ -159,7 +159,7 @@ export function StepOrganization({
Priority
field.onChange(val ? Number(val) : null)}
- value={field.value ? String(field.value) : undefined}
+ value={field.value ? String(field.value) : ""}
>
diff --git a/src/components/group-wizard/step-review.test.tsx b/src/components/group-wizard/step-review.test.tsx
new file mode 100644
index 0000000..25183ca
--- /dev/null
+++ b/src/components/group-wizard/step-review.test.tsx
@@ -0,0 +1,247 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, cleanup, fireEvent } from '@testing-library/react';
+import { useForm, FormProvider } from 'react-hook-form';
+import { GROUP_WIZARD_DEFAULTS, type GroupWizardFormData } from './schema';
+import type { GroupWizardLookups } from './types';
+import { StepReview } from './step-review';
+
+const LOOKUPS: GroupWizardLookups = {
+ groupTypes: [{ id: 1, name: 'Small Group' }],
+ ministries: [{ id: 1, name: 'Youth' }],
+ congregations: [{ id: 1, name: 'Main' }],
+ meetingDays: [{ id: 1, name: 'Monday' }],
+ meetingFrequencies: [{ id: 1, name: 'Weekly' }],
+ meetingDurations: [{ id: 1, name: '1 hour' }],
+ lifeStages: [{ id: 1, name: 'Adult' }],
+ groupFocuses: [{ id: 1, name: 'Bible Study' }],
+ priorities: [{ id: 1, name: 'High' }],
+ rooms: [{ id: 1, name: 'Room A' }],
+ books: [{ id: 1, name: 'Genesis' }],
+ smsNumbers: [{ id: 1, name: '555-1234' }],
+ groupEndedReasons: [{ id: 1, name: 'Completed' }],
+};
+
+const BASE_FORM: GroupWizardFormData = {
+ ...GROUP_WIZARD_DEFAULTS,
+ Group_Name: 'My Group',
+ Group_Type_ID: 1,
+ Start_Date: '2026-01-01',
+ Congregation_ID: 1,
+ Ministry_ID: 1,
+ Primary_Contact: 42,
+};
+
+function Harness({
+ defaultValues,
+ onEditStep = vi.fn(),
+ submitResult = null,
+ isEditMode = false,
+ onCreateAnother = vi.fn(),
+ onClose = vi.fn(),
+ contactDisplayMap = new Map([[42, 'Jane Doe']]),
+ groupDisplayMap = new Map(),
+}: {
+ defaultValues?: Partial;
+ onEditStep?: (step: number) => void;
+ submitResult?: { groupId: number; groupName: string } | null;
+ isEditMode?: boolean;
+ onCreateAnother?: () => void;
+ onClose?: () => void;
+ contactDisplayMap?: Map;
+ groupDisplayMap?: Map;
+}) {
+ const form = useForm({
+ defaultValues: { ...BASE_FORM, ...defaultValues },
+ });
+ return (
+
+
+
+ );
+}
+
+describe('StepReview', () => {
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ describe('submitResult (success) branch', () => {
+ it('renders "Group Created!" in create mode with Create Another button', () => {
+ const onCreateAnother = vi.fn();
+ const onClose = vi.fn();
+ render(
+ ,
+ );
+ expect(screen.getByText('Group Created!')).toBeInTheDocument();
+ expect(screen.getByText('New Group')).toBeInTheDocument();
+ const createAnotherBtn = screen.getByRole('button', { name: /create another/i });
+ fireEvent.click(createAnotherBtn);
+ expect(onCreateAnother).toHaveBeenCalledTimes(1);
+ fireEvent.click(screen.getByRole('button', { name: /close/i }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders "Group Updated!" in edit mode without Create Another button', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('Group Updated!')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /create another/i })).not.toBeInTheDocument();
+ });
+ });
+
+ describe('review table branch', () => {
+ it('renders required fields and hides optional falsy fields', () => {
+ render( );
+ expect(screen.getByText('My Group')).toBeInTheDocument();
+ expect(screen.getByText('Small Group')).toBeInTheDocument();
+ expect(screen.getByText('2026-01-01')).toBeInTheDocument();
+ // Optional fields not set should not render their labels
+ expect(screen.queryByText('End Date')).not.toBeInTheDocument();
+ expect(screen.queryByText('Reason Ended')).not.toBeInTheDocument();
+ expect(screen.queryByText('Description')).not.toBeInTheDocument();
+ expect(screen.queryByText('Parent Group')).not.toBeInTheDocument();
+ expect(screen.queryByText('Priority')).not.toBeInTheDocument();
+ expect(screen.queryByText('Room')).not.toBeInTheDocument();
+ expect(screen.queryByText('Offsite Address')).not.toBeInTheDocument();
+ expect(screen.queryByText('Target Size')).not.toBeInTheDocument();
+ expect(screen.queryByText('Life Stage')).not.toBeInTheDocument();
+ expect(screen.queryByText('Focus')).not.toBeInTheDocument();
+ expect(screen.queryByText('Required Book')).not.toBeInTheDocument();
+ expect(screen.queryByText('SMS Number')).not.toBeInTheDocument();
+ expect(screen.queryByText('Promote to Group')).not.toBeInTheDocument();
+ expect(screen.queryByText('Age to Promote')).not.toBeInTheDocument();
+ expect(screen.queryByText('Promotion Date')).not.toBeInTheDocument();
+ expect(screen.queryByText('Descended From')).not.toBeInTheDocument();
+ // primary contact resolved from display map
+ expect(screen.getByText('Jane Doe')).toBeInTheDocument();
+ // Meeting Day/Frequency/Duration resolve when unset via resolveLookup(null) -> "—"
+ expect(screen.getAllByText('No').length).toBeGreaterThan(0); // Meets Online / Group Is Full: No
+ });
+
+ it('renders all optional fields when populated', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('2026-06-01')).toBeInTheDocument();
+ expect(screen.getByText('Completed')).toBeInTheDocument();
+ expect(screen.getByText('A description')).toBeInTheDocument();
+ expect(screen.getByText('Parent Grp')).toBeInTheDocument();
+ expect(screen.getByText('High')).toBeInTheDocument();
+ expect(screen.getByText('Monday')).toBeInTheDocument();
+ expect(screen.getByText('10:00')).toBeInTheDocument();
+ expect(screen.getByText('Weekly')).toBeInTheDocument();
+ expect(screen.getByText('1 hour')).toBeInTheDocument();
+ expect(screen.getByText('Room A')).toBeInTheDocument();
+ expect(screen.getByText('Address ID: 77')).toBeInTheDocument();
+ expect(screen.getAllByText('Yes').length).toBeGreaterThan(0); // Meets Online / Group Is Full
+ expect(screen.getByText('12')).toBeInTheDocument();
+ expect(screen.getByText('Adult')).toBeInTheDocument();
+ expect(screen.getByText('Bible Study')).toBeInTheDocument();
+ expect(screen.getByText('Genesis')).toBeInTheDocument();
+ expect(screen.getByText('555-1234')).toBeInTheDocument();
+ expect(screen.getByText('Promo Grp')).toBeInTheDocument();
+ expect(screen.getByText('24 months')).toBeInTheDocument();
+ expect(screen.getByText('2026-07-01')).toBeInTheDocument();
+ expect(screen.getByText('Ancestor Grp')).toBeInTheDocument();
+ });
+
+ it('falls back to "ID: " when a lookup id is not found in the lookups array', () => {
+ render( );
+ expect(screen.getByText('ID: 999')).toBeInTheDocument();
+ });
+
+ it('falls back to "ID: " for primary contact / parent / promote group not in display map', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('ID: 42')).toBeInTheDocument(); // primary contact fallback
+ expect(screen.getByText('ID: 12345')).toBeInTheDocument();
+ expect(screen.getByText('ID: 54321')).toBeInTheDocument();
+ expect(screen.getByText('ID: 11111')).toBeInTheDocument();
+ });
+
+ it('shows "All defaults (off)" when every boolean setting is falsy', () => {
+ render( );
+ expect(screen.getByText('All defaults (off)')).toBeInTheDocument();
+ });
+
+ it('hides "All defaults (off)" and shows badges when some settings are on', () => {
+ render(
+ ,
+ );
+ expect(screen.queryByText('All defaults (off)')).not.toBeInTheDocument();
+ expect(screen.getByText('Available Online')).toBeInTheDocument();
+ expect(screen.getByText('Secure Check-in')).toBeInTheDocument();
+ // A falsy badge (Discussion) is not rendered
+ expect(screen.queryByText('Discussion')).not.toBeInTheDocument();
+ });
+
+ it('calls onEditStep with the section stepIndex when Edit is clicked', () => {
+ const onEditStep = vi.fn();
+ render( );
+ const editButtons = screen.getAllByRole('button', { name: /edit/i });
+ // Sections in order: Identity(0), Organization(1), Meeting(2), Attributes(3), Settings(4)
+ expect(editButtons).toHaveLength(5);
+ fireEvent.click(editButtons[0]);
+ expect(onEditStep).toHaveBeenCalledWith(0);
+ fireEvent.click(editButtons[2]);
+ expect(onEditStep).toHaveBeenCalledWith(2);
+ fireEvent.click(editButtons[4]);
+ expect(onEditStep).toHaveBeenCalledWith(4);
+ });
+ });
+});
diff --git a/src/components/group-wizard/step-settings.test.tsx b/src/components/group-wizard/step-settings.test.tsx
new file mode 100644
index 0000000..55f8602
--- /dev/null
+++ b/src/components/group-wizard/step-settings.test.tsx
@@ -0,0 +1,190 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, cleanup, fireEvent } from '@testing-library/react';
+import { useForm, FormProvider } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import {
+ groupWizardSchema,
+ GROUP_WIZARD_DEFAULTS,
+ type GroupWizardFormData,
+} from './schema';
+import type { GroupWizardLookups } from './types';
+
+const mockOnSelect = vi.hoisted(() => vi.fn());
+
+vi.mock('./group-search', () => ({
+ GroupSearch: ({
+ value,
+ displayName,
+ onSelect,
+ placeholder,
+ }: {
+ value: number | null | undefined;
+ displayName: string | undefined;
+ onSelect: (id: number | null, name: string) => void;
+ placeholder?: string;
+ }) => (
+
+ {value ?? 'none'}
+ {displayName ?? 'none'}
+ onSelect(999, 'Selected Group')}>
+ select-{placeholder}
+
+ onSelect(null, '')}>
+ clear-{placeholder}
+
+
+ ),
+}));
+
+import { StepSettings } from './step-settings';
+
+const LOOKUPS: GroupWizardLookups = {
+ groupTypes: [],
+ ministries: [],
+ congregations: [],
+ meetingDays: [],
+ meetingFrequencies: [],
+ meetingDurations: [],
+ lifeStages: [],
+ groupFocuses: [],
+ priorities: [],
+ rooms: [],
+ books: [],
+ smsNumbers: [],
+ groupEndedReasons: [],
+};
+
+function Harness({
+ groupDisplayMap = new Map(),
+ onGroupSelect = mockOnSelect,
+ defaultValues,
+}: {
+ groupDisplayMap?: Map;
+ onGroupSelect?: (field: string, id: number | null, name: string) => void;
+ defaultValues?: Partial;
+}) {
+ const form = useForm({
+ resolver: zodResolver(groupWizardSchema),
+ defaultValues: { ...GROUP_WIZARD_DEFAULTS, ...defaultValues },
+ });
+ return (
+
+
+
+ );
+}
+
+const SWITCH_LABELS = [
+ 'Available Online',
+ 'Available On App',
+ 'Enable Discussion',
+ 'Send Attendance Notification',
+ 'Send Service Notification',
+ 'Create Next Meeting',
+ 'Secure Check-in',
+ 'Suppress Nametag',
+ 'Suppress Care Note',
+ 'On Classroom Manager',
+ 'Promote Weekly',
+ 'Promote Participants Only',
+];
+
+describe('StepSettings', () => {
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('renders all switch rows unchecked by default', () => {
+ render( );
+ for (const label of SWITCH_LABELS) {
+ const row = screen.getByText(label).closest('div')?.parentElement;
+ expect(row).toBeTruthy();
+ }
+ const switches = screen.getAllByRole('switch');
+ expect(switches).toHaveLength(SWITCH_LABELS.length);
+ for (const sw of switches) {
+ expect(sw).toHaveAttribute('data-state', 'unchecked');
+ }
+ });
+
+ it('toggles every switch row on click', () => {
+ render( );
+ const switches = screen.getAllByRole('switch');
+ for (const sw of switches) {
+ fireEvent.click(sw);
+ expect(sw).toHaveAttribute('data-state', 'checked');
+ }
+ });
+
+ it('Promote_to_Group GroupSearch select updates field value and calls onGroupSelect', () => {
+ const onGroupSelect = vi.fn();
+ render( );
+ fireEvent.click(screen.getByText('select-Search promotion target group...'));
+ expect(onGroupSelect).toHaveBeenCalledWith('Promote_to_Group', 999, 'Selected Group');
+ });
+
+ it('Promote_to_Group GroupSearch clear passes null id', () => {
+ const onGroupSelect = vi.fn();
+ render( );
+ fireEvent.click(screen.getByText('clear-Search promotion target group...'));
+ expect(onGroupSelect).toHaveBeenCalledWith('Promote_to_Group', null, '');
+ });
+
+ it('Descended_From GroupSearch select updates field value and calls onGroupSelect', () => {
+ const onGroupSelect = vi.fn();
+ render( );
+ fireEvent.click(screen.getByText('select-Search original group...'));
+ expect(onGroupSelect).toHaveBeenCalledWith('Descended_From', 999, 'Selected Group');
+ });
+
+ it('passes displayName from groupDisplayMap for a populated Promote_to_Group value', () => {
+ const map = new Map([[42, 'Existing Promotion Group']]);
+ render( );
+ expect(
+ screen.getByTestId('group-search-display-Search promotion target group...').textContent,
+ ).toBe('Existing Promotion Group');
+ expect(
+ screen.getByTestId('group-search-value-Search promotion target group...').textContent,
+ ).toBe('42');
+ });
+
+ it('passes undefined displayName when Promote_to_Group value is falsy', () => {
+ render( );
+ expect(
+ screen.getByTestId('group-search-display-Search promotion target group...').textContent,
+ ).toBe('none');
+ });
+
+ it('Age_in_Months_to_Promote: typing a value sets a number, clearing sets null', () => {
+ render( );
+ const input = screen.getByPlaceholderText('e.g., 24') as HTMLInputElement;
+ fireEvent.change(input, { target: { value: '24' } });
+ expect(input.value).toBe('24');
+ fireEvent.change(input, { target: { value: '' } });
+ expect(input.value).toBe('');
+ });
+
+ it('Promotion_Date: typing a date sets value, clearing sets null', () => {
+ render( );
+ const dateInputs = document.querySelectorAll('input[type="date"]');
+ expect(dateInputs.length).toBe(1);
+ const input = dateInputs[0] as HTMLInputElement;
+ fireEvent.change(input, { target: { value: '2026-05-01' } });
+ expect(input.value).toBe('2026-05-01');
+ fireEvent.change(input, { target: { value: '' } });
+ expect(input.value).toBe('');
+ });
+
+ it('renders section headings', () => {
+ render( );
+ expect(screen.getByText('Settings & Promotion')).toBeInTheDocument();
+ expect(screen.getByText('Visibility & Communication')).toBeInTheDocument();
+ expect(screen.getByText('Check-in & Classroom')).toBeInTheDocument();
+ expect(screen.getByText('Promotion')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/group-wizard/wizard-navigation.test.tsx b/src/components/group-wizard/wizard-navigation.test.tsx
new file mode 100644
index 0000000..41a6230
--- /dev/null
+++ b/src/components/group-wizard/wizard-navigation.test.tsx
@@ -0,0 +1,112 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, cleanup, fireEvent } from '@testing-library/react';
+import { WizardNavigation } from './wizard-navigation';
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+function baseProps(overrides: Partial> = {}) {
+ return {
+ currentStep: 0,
+ totalSteps: 6,
+ onBack: vi.fn(),
+ onNext: vi.fn(),
+ onCancel: vi.fn(),
+ onSubmit: vi.fn(),
+ isSubmitting: false,
+ isEditMode: false,
+ ...overrides,
+ };
+}
+
+describe('WizardNavigation', () => {
+ it('hides Back button on the first step', () => {
+ render( );
+ expect(screen.queryByRole('button', { name: /back/i })).not.toBeInTheDocument();
+ });
+
+ it('shows Back button and calls onBack when not on the first step', () => {
+ const onBack = vi.fn();
+ render( );
+ const backBtn = screen.getByRole('button', { name: /back/i });
+ fireEvent.click(backBtn);
+ expect(onBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows Next button and calls onNext on a non-review step', () => {
+ const onNext = vi.fn();
+ render( );
+ const nextBtn = screen.getByRole('button', { name: /^next$/i });
+ fireEvent.click(nextBtn);
+ expect(onNext).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole('button', { name: /create group/i })).not.toBeInTheDocument();
+ });
+
+ it('shows "Create Group" submit button on the review step in create mode and calls onSubmit', () => {
+ const onSubmit = vi.fn();
+ render( );
+ const submitBtn = screen.getByRole('button', { name: /create group/i });
+ fireEvent.click(submitBtn);
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows "Save Changes" submit button on the review step in edit mode', () => {
+ render( );
+ expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
+ });
+
+ it('shows the spinner and "Creating..." text while submitting in create mode', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText(/creating\.\.\./i)).toBeInTheDocument();
+ });
+
+ it('shows the spinner and "Saving..." text while submitting in edit mode', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText(/saving\.\.\./i)).toBeInTheDocument();
+ });
+
+ it('disables Back and Cancel while submitting', () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole('button', { name: /back/i })).toBeDisabled();
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled();
+ });
+
+ it('enables Back and Cancel when not submitting', () => {
+ render( );
+ expect(screen.getByRole('button', { name: /back/i })).not.toBeDisabled();
+ expect(screen.getByRole('button', { name: /cancel/i })).not.toBeDisabled();
+ });
+
+ it('always renders Cancel and calls onCancel on click', () => {
+ const onCancel = vi.fn();
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it('disables the submit button on the review step while submitting', () => {
+ render(
+ ,
+ );
+ // The button now shows "Creating..." text; find it by role without name filter.
+ const buttons = screen.getAllByRole('button');
+ const submitBtn = buttons.find((b) => b.textContent?.match(/creating/i));
+ expect(submitBtn).toBeDisabled();
+ });
+});
diff --git a/src/components/group-wizard/wizard-stepper.test.tsx b/src/components/group-wizard/wizard-stepper.test.tsx
new file mode 100644
index 0000000..6ca944e
--- /dev/null
+++ b/src/components/group-wizard/wizard-stepper.test.tsx
@@ -0,0 +1,88 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';
+import { WizardStepper } from './wizard-stepper';
+import { WIZARD_STEPS } from './types';
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+describe('WizardStepper', () => {
+ it('calls onStepClick when a completed step button is clicked', () => {
+ const onStepClick = vi.fn();
+ render(
+ ,
+ );
+ // Step 0 ("Identity") is completed → clickable
+ const identityBtn = screen.getAllByRole('button').find((b) => b.textContent?.includes(WIZARD_STEPS[0].label));
+ expect(identityBtn).toBeDefined();
+ fireEvent.click(identityBtn!);
+ expect(onStepClick).toHaveBeenCalledWith(0);
+ });
+
+ it('does not call onStepClick for a future, non-completed step (not clickable)', () => {
+ const onStepClick = vi.fn();
+ render(
+ ,
+ );
+ // Step 3 ("Attributes") is neither completed nor before currentStep
+ const futureBtn = screen.getAllByRole('button').find((b) => b.textContent?.includes(WIZARD_STEPS[3].label));
+ expect(futureBtn).toBeDefined();
+ expect(futureBtn).toBeDisabled();
+ fireEvent.click(futureBtn!);
+ expect(onStepClick).not.toHaveBeenCalled();
+ });
+
+ it('calls onStepClick for a step before currentStep even if not marked completed', () => {
+ const onStepClick = vi.fn();
+ // currentStep is 3; step 1 is before currentStep but NOT in completedSteps.
+ render(
+ ,
+ );
+ const orgBtn = screen.getAllByRole('button').find((b) => b.textContent?.includes(WIZARD_STEPS[1].label));
+ expect(orgBtn).toBeDefined();
+ fireEvent.click(orgBtn!);
+ expect(onStepClick).toHaveBeenCalledWith(1);
+ });
+
+ it('renders the connector line styled for a completed segment vs an incomplete one', () => {
+ const { container } = render(
+ ,
+ );
+ const connectors = container.querySelectorAll('nav li > div');
+ expect(connectors.length).toBeGreaterThan(0);
+ // First connector (after completed step 0) should carry bg-primary
+ expect(connectors[0].className).toContain('bg-primary');
+ });
+
+ it('renders the current step as a ring-highlighted, non-completed circle', () => {
+ render( );
+ // Current step shows its 1-based index number, not a check mark
+ const currentBtn = screen.getAllByRole('button').find((b) => b.textContent?.includes(WIZARD_STEPS[1].label));
+ expect(currentBtn?.textContent).toContain('2');
+ });
+
+ it('renders the mobile stepper current label, step count, and progress width', () => {
+ const { container } = render(
+ ,
+ );
+ const mobile = container.querySelector('.md\\:hidden') as HTMLElement;
+ expect(mobile).toBeTruthy();
+ expect(within(mobile).getByText(WIZARD_STEPS[2].label)).toBeInTheDocument();
+ expect(within(mobile).getByText(`Step 3 of ${WIZARD_STEPS.length}`)).toBeInTheDocument();
+ expect(within(mobile).getByText(WIZARD_STEPS[2].description)).toBeInTheDocument();
+
+ const progressBar = container.querySelector('.md\\:hidden .bg-primary') as HTMLElement;
+ expect(progressBar).toBeTruthy();
+ expect(progressBar.style.width).toBe(`${((2 + 1) / WIZARD_STEPS.length) * 100}%`);
+ });
+
+ it('disables a button that is neither clickable nor current', () => {
+ render( );
+ const lastBtn = screen.getAllByRole('button').find((b) =>
+ b.textContent?.includes(WIZARD_STEPS[WIZARD_STEPS.length - 1].label),
+ );
+ expect(lastBtn).toBeDisabled();
+ });
+});
diff --git a/src/components/shared-actions/domain.test.ts b/src/components/shared-actions/domain.test.ts
new file mode 100644
index 0000000..81800f0
--- /dev/null
+++ b/src/components/shared-actions/domain.test.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+const { mockGetMpTimezone, mockGetInstance } = vi.hoisted(() => {
+ const getTz = vi.fn();
+ return {
+ mockGetMpTimezone: getTz,
+ mockGetInstance: vi.fn(() => ({ getMpTimezone: getTz })),
+ };
+});
+
+vi.mock('@/services/domainTimezoneService', () => ({
+ DomainTimezoneService: {
+ getInstance: mockGetInstance,
+ },
+}));
+
+import { getMpTimezone } from './domain';
+
+/**
+ * This action is one of the documented authorization carve-outs in CLAUDE.md
+ * rule 12: it returns a single domain-wide configuration string (the IANA
+ * time zone) and exposes no per-record data, so it is deliberately ungated.
+ */
+
+describe('getMpTimezone', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('returns the IANA zone from DomainTimezoneService', async () => {
+ mockGetMpTimezone.mockResolvedValueOnce('America/New_York');
+
+ await expect(getMpTimezone()).resolves.toBe('America/New_York');
+ });
+
+ it('resolves the service through its singleton accessor', async () => {
+ mockGetMpTimezone.mockResolvedValueOnce('UTC');
+
+ await getMpTimezone();
+
+ expect(mockGetInstance).toHaveBeenCalledTimes(1);
+ expect(mockGetMpTimezone).toHaveBeenCalledTimes(1);
+ });
+
+ it('propagates a lookup failure to the caller', async () => {
+ mockGetMpTimezone.mockRejectedValueOnce(new Error('domain lookup failed'));
+
+ await expect(getMpTimezone()).rejects.toThrow('domain lookup failed');
+ });
+});
diff --git a/src/components/template-editor/actions.test.ts b/src/components/template-editor/actions.test.ts
new file mode 100644
index 0000000..16a442d
--- /dev/null
+++ b/src/components/template-editor/actions.test.ts
@@ -0,0 +1,112 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+/**
+ * `compileMjml` gates through AuthorizationService.requireSecurityRole rather
+ * than a bare session check (CLAUDE.md rule 12). The default implementation
+ * resolves so happy-path tests don't need to think about auth; individual
+ * tests override it with a rejection to prove the gate actually blocks.
+ */
+const { mockRequireSecurityRole } = vi.hoisted(() => ({
+ mockRequireSecurityRole: vi.fn(),
+}));
+
+vi.mock('@/services/authorizationService', () => ({
+ AuthorizationService: {
+ getInstance: () => ({
+ requireSecurityRole: mockRequireSecurityRole,
+ }),
+ },
+}));
+
+const { mockMjml2html } = vi.hoisted(() => ({
+ mockMjml2html: vi.fn(),
+}));
+
+vi.mock('mjml', () => ({
+ default: mockMjml2html,
+}));
+
+import { compileMjml } from './actions';
+
+const VALID_MJML = 'Hi ';
+
+describe('compileMjml', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockRequireSecurityRole.mockResolvedValue(42);
+ mockMjml2html.mockResolvedValue({
+ html: 'compiled',
+ errors: [],
+ });
+ });
+
+ it('gates through AuthorizationService.requireSecurityRole for the dp_Tools table on read', async () => {
+ await compileMjml(VALID_MJML);
+
+ expect(mockRequireSecurityRole).toHaveBeenCalledWith({
+ table: 'dp_Tools',
+ operation: 'read',
+ });
+ });
+
+ it('propagates rejection when the caller is not authorized', async () => {
+ mockRequireSecurityRole.mockRejectedValueOnce(new Error('Not authorized'));
+
+ await expect(compileMjml(VALID_MJML)).rejects.toThrow('Not authorized');
+ expect(mockMjml2html).not.toHaveBeenCalled();
+ });
+
+ it('rejects an empty MJML source', async () => {
+ await expect(compileMjml('')).rejects.toThrow(/must be between 1 and/);
+ expect(mockMjml2html).not.toHaveBeenCalled();
+ });
+
+ it('rejects MJML source larger than the 500KB cap', async () => {
+ const oversized = 'a'.repeat(512_001);
+ await expect(compileMjml(oversized)).rejects.toThrow(/must be between 1 and/);
+ expect(mockMjml2html).not.toHaveBeenCalled();
+ });
+
+ it('accepts MJML source right at the 500KB cap', async () => {
+ const atCap = 'a'.repeat(512_000);
+ await expect(compileMjml(atCap)).resolves.toBeDefined();
+ expect(mockMjml2html).toHaveBeenCalledWith(atCap, {
+ validationLevel: 'soft',
+ minify: false,
+ });
+ });
+
+ it('returns the compiled html and maps empty errors', async () => {
+ const result = await compileMjml(VALID_MJML);
+
+ expect(result).toEqual({
+ html: 'compiled',
+ errors: [],
+ });
+ });
+
+ it('maps mjml compile errors into the MjmlCompileError shape', async () => {
+ mockMjml2html.mockResolvedValueOnce({
+ html: 'partial',
+ errors: [
+ {
+ line: 3,
+ message: 'Unknown tag',
+ tagName: 'mj-bogus',
+ formattedMessage: 'Line 3 of mj-bogus: Unknown tag',
+ },
+ ],
+ });
+
+ const result = await compileMjml(VALID_MJML);
+
+ expect(result.errors).toEqual([
+ {
+ line: 3,
+ message: 'Unknown tag',
+ tagName: 'mj-bogus',
+ formattedMessage: 'Line 3 of mj-bogus: Unknown tag',
+ },
+ ]);
+ });
+});
diff --git a/src/components/template-editor/editor-canvas.test.tsx b/src/components/template-editor/editor-canvas.test.tsx
new file mode 100644
index 0000000..91d939b
--- /dev/null
+++ b/src/components/template-editor/editor-canvas.test.tsx
@@ -0,0 +1,140 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, cleanup, screen } from '@testing-library/react';
+import type { Editor } from 'grapesjs';
+
+// EditorToolbar has its own dedicated test file — stub it here so
+// EditorCanvas tests focus on GjsEditor wiring (onReady setup, storage
+// gating for the default template) rather than toolbar internals.
+vi.mock('./editor-toolbar', () => ({
+ EditorToolbar: ({ onClose }: { onClose: () => void }) => (
+
+ close
+
+ ),
+}));
+
+vi.mock('grapesjs', () => ({
+ default: {},
+}));
+
+vi.mock('grapesjs-mjml', () => ({
+ default: {},
+}));
+
+// jsdom/vitest route real .css imports through the project's PostCSS/Tailwind
+// config, which isn't valid outside a Next.js build — stub both stylesheet
+// imports so only the component logic is under test.
+vi.mock('grapesjs/dist/css/grapes.min.css', () => ({}));
+vi.mock('@/styles/grapesjs-overrides.css', () => ({}));
+
+const { mockRegisterMergeFieldBlocks } = vi.hoisted(() => ({
+ mockRegisterMergeFieldBlocks: vi.fn(),
+}));
+
+vi.mock('./merge-fields', () => ({
+ registerMergeFieldBlocks: mockRegisterMergeFieldBlocks,
+}));
+
+const { _mockBlocksAdd, mockBlocksRemove, mockSetComponents, buildFakeEditor } = vi.hoisted(() => {
+ const _mockBlocksAdd = vi.fn();
+ const mockBlocksRemove = vi.fn();
+ const mockSetComponents = vi.fn();
+ const buildFakeEditor = () => ({
+ Blocks: { add: _mockBlocksAdd, remove: mockBlocksRemove },
+ setComponents: mockSetComponents,
+ });
+ return { _mockBlocksAdd, mockBlocksRemove, mockSetComponents, buildFakeEditor };
+});
+
+vi.mock('@grapesjs/react', () => ({
+ __esModule: true,
+ // Named `GjsEditorMock` rather than declared inline on `default:` so the
+ // react-hooks lint rule recognises it as a component and allows useEffect.
+ default: function GjsEditorMock({
+ onReady,
+ children,
+ className,
+ }: {
+ onReady?: (editor: Editor) => void;
+ children?: React.ReactNode;
+ className?: string;
+ }) {
+ const React = require('react');
+ React.useEffect(() => {
+ onReady?.(buildFakeEditor() as unknown as Editor);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+ return React.createElement('div', { className, 'data-testid': 'gjs-editor' }, children);
+ },
+ Canvas: ({ className }: { className?: string }) => (
+ // Real GrapesJS mounts its canvas iframe under a `.gjs-cv-canvas` node
+ // regardless of the className prop — replicate that class here so the
+ // component's `document.querySelector('.gjs-cv-canvas')` sizing logic
+ // has something to find, the same as it would in a real DOM.
+
+ ),
+ WithEditor: ({ children }: { children?: React.ReactNode }) => <>{children}>,
+}));
+
+import { EditorCanvas } from './editor-canvas';
+import { STORAGE_KEY, DEFAULT_MJML_TEMPLATE } from './grapes-config';
+
+describe('EditorCanvas', () => {
+ const onClose = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('renders the canvas and the (stubbed) toolbar', () => {
+ render( );
+ expect(screen.getByTestId('canvas')).toBeInTheDocument();
+ expect(screen.getByTestId('toolbar-close')).toBeInTheDocument();
+ });
+
+ it('registers merge field blocks and removes navbar blocks on ready', () => {
+ render( );
+
+ expect(mockRegisterMergeFieldBlocks).toHaveBeenCalledTimes(1);
+ expect(mockBlocksRemove).toHaveBeenCalledWith('mj-navbar');
+ expect(mockBlocksRemove).toHaveBeenCalledWith('mj-navbar-link');
+ });
+
+ it('loads the default MJML template when no saved state exists in localStorage', () => {
+ render( );
+ expect(mockSetComponents).toHaveBeenCalledWith(DEFAULT_MJML_TEMPLATE);
+ });
+
+ it('does not overwrite saved state when localStorage already has a draft', () => {
+ localStorage.setItem(STORAGE_KEY, '{"some":"state"}');
+ render( );
+ expect(mockSetComponents).not.toHaveBeenCalled();
+ });
+
+ it('tolerates localStorage being unavailable and still seeds the default template', () => {
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+ throw new Error('unavailable');
+ });
+ render( );
+ expect(mockSetComponents).toHaveBeenCalledWith(DEFAULT_MJML_TEMPLATE);
+ });
+
+ it('invokes the onEditorReady callback prop with the editor instance', () => {
+ const onEditorReady = vi.fn();
+ render( );
+ expect(onEditorReady).toHaveBeenCalledTimes(1);
+ expect(onEditorReady).toHaveBeenCalledWith(expect.objectContaining({ setComponents: mockSetComponents }));
+ });
+
+ it('passes onClose through to the toolbar', () => {
+ render( );
+ screen.getByTestId('toolbar-close').click();
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/components/template-editor/editor-code-dialog.test.tsx b/src/components/template-editor/editor-code-dialog.test.tsx
new file mode 100644
index 0000000..adb8f8d
--- /dev/null
+++ b/src/components/template-editor/editor-code-dialog.test.tsx
@@ -0,0 +1,144 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
+
+const { mockGetHtml, mockSetComponents, mockEditor } = vi.hoisted(() => {
+ const mockGetHtml = vi.fn();
+ const mockSetComponents = vi.fn();
+ // A single stable object reference — the component's effect depends on
+ // `editor` by identity, so a fresh object literal per render would retrigger
+ // the effect on every render and infinite-loop the test.
+ return {
+ mockGetHtml,
+ mockSetComponents,
+ mockEditor: { getHtml: mockGetHtml, setComponents: mockSetComponents },
+ };
+});
+
+vi.mock('@grapesjs/react', () => ({
+ useEditor: () => mockEditor,
+}));
+
+const { mockCompileMjml } = vi.hoisted(() => ({
+ mockCompileMjml: vi.fn(),
+}));
+
+vi.mock('./actions', () => ({
+ compileMjml: mockCompileMjml,
+}));
+
+const mockWriteText = vi.hoisted(() => vi.fn());
+
+import { EditorCodeDialog } from './editor-code-dialog';
+
+describe('EditorCodeDialog', () => {
+ const onOpenChange = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetHtml.mockReturnValue(' ');
+ mockCompileMjml.mockResolvedValue({ html: 'ok', errors: [] });
+ Object.assign(navigator, { clipboard: { writeText: mockWriteText } });
+ mockWriteText.mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('renders nothing when closed', () => {
+ render( );
+ expect(screen.queryByText('Code Editor')).not.toBeInTheDocument();
+ });
+
+ it('seeds the MJML textarea from editor.getHtml() when opened', () => {
+ render( );
+ expect(screen.getByDisplayValue(' ')).toBeInTheDocument();
+ });
+
+ it('applies edited MJML to the editor and closes the dialog', () => {
+ render( );
+
+ const textarea = screen.getByDisplayValue(' ');
+ fireEvent.change(textarea, { target: { value: 'edited ' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Apply to Editor' }));
+
+ expect(mockSetComponents).toHaveBeenCalledWith('edited ');
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('copies the MJML source to the clipboard and shows Copied! feedback', async () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Copy MJML' }));
+
+ expect(mockWriteText).toHaveBeenCalledWith(' ');
+ expect(await screen.findByText('Copied!')).toBeInTheDocument();
+ });
+
+ it('compiles HTML automatically on first switch to the HTML tab', async () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'HTML Preview' }));
+
+ expect(screen.getByText(/Compiling MJML/)).toBeInTheDocument();
+ await waitFor(() => expect(mockCompileMjml).toHaveBeenCalledWith(' '));
+ expect(await screen.findByDisplayValue('ok')).toBeInTheDocument();
+ });
+
+ it('does not recompile automatically the second time the HTML tab is opened', async () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'HTML Preview' }));
+ await screen.findByDisplayValue('ok');
+ expect(mockCompileMjml).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(screen.getByRole('button', { name: 'MJML Source' }));
+ fireEvent.click(screen.getByRole('button', { name: 'HTML Preview' }));
+
+ expect(mockCompileMjml).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows compile warnings returned alongside html', async () => {
+ mockCompileMjml.mockResolvedValueOnce({
+ html: 'partial',
+ errors: [{ line: 1, message: 'bad', tagName: 'mj-x', formattedMessage: 'Line 1: bad' }],
+ });
+
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'HTML Preview' }));
+
+ expect(await screen.findByText('Line 1: bad')).toBeInTheDocument();
+ });
+
+ it('shows an error message when compileMjml rejects', async () => {
+ mockCompileMjml.mockRejectedValueOnce(new Error('boom'));
+
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'HTML Preview' }));
+
+ expect(await screen.findByText('boom')).toBeInTheDocument();
+ });
+
+ it('falls back to a generic message when compileMjml throws a non-Error', async () => {
+ mockCompileMjml.mockRejectedValueOnce('nope');
+
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'HTML Preview' }));
+
+ expect(await screen.findByText('Compilation failed')).toBeInTheDocument();
+ });
+
+ it('recompile button re-invokes compileMjml and copy button copies html output', async () => {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'HTML Preview' }));
+ await screen.findByDisplayValue('ok');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Recompile' }));
+ await waitFor(() => expect(mockCompileMjml).toHaveBeenCalledTimes(2));
+
+ fireEvent.click(screen.getByRole('button', { name: 'Copy HTML' }));
+ expect(mockWriteText).toHaveBeenCalledWith('ok');
+ expect(await screen.findByText('Copied!')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/template-editor/editor-export-dialog.test.tsx b/src/components/template-editor/editor-export-dialog.test.tsx
new file mode 100644
index 0000000..38f84a3
--- /dev/null
+++ b/src/components/template-editor/editor-export-dialog.test.tsx
@@ -0,0 +1,153 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
+
+const { mockGetHtml, mockGetProjectData, mockEditor } = vi.hoisted(() => {
+ const mockGetHtml = vi.fn();
+ const mockGetProjectData = vi.fn();
+ // A single stable object reference — the component's effect depends on
+ // `editor` by identity, so a fresh object literal per render would retrigger
+ // the effect on every render and infinite-loop the test.
+ return {
+ mockGetHtml,
+ mockGetProjectData,
+ mockEditor: { getHtml: mockGetHtml, getProjectData: mockGetProjectData },
+ };
+});
+
+vi.mock('@grapesjs/react', () => ({
+ useEditor: () => mockEditor,
+}));
+
+const { mockCompileMjml } = vi.hoisted(() => ({
+ mockCompileMjml: vi.fn(),
+}));
+
+vi.mock('./actions', () => ({
+ compileMjml: mockCompileMjml,
+}));
+
+const mockWriteText = vi.hoisted(() => vi.fn());
+
+import { EditorExportDialog } from './editor-export-dialog';
+
+describe('EditorExportDialog', () => {
+ const onOpenChange = vi.fn();
+ let createObjectURL: ReturnType;
+ let revokeObjectURL: ReturnType;
+ let clickSpy: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetHtml.mockReturnValue(' ');
+ mockGetProjectData.mockReturnValue({ pages: [] });
+ mockCompileMjml.mockResolvedValue({ html: 'ok', errors: [] });
+ Object.assign(navigator, { clipboard: { writeText: mockWriteText } });
+ mockWriteText.mockResolvedValue(undefined);
+
+ createObjectURL = vi.fn().mockReturnValue('blob:mock-url');
+ revokeObjectURL = vi.fn();
+ // jsdom does not implement these.
+ (URL as unknown as { createObjectURL: typeof createObjectURL }).createObjectURL = createObjectURL;
+ (URL as unknown as { revokeObjectURL: typeof revokeObjectURL }).revokeObjectURL = revokeObjectURL;
+ clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('renders nothing when closed', () => {
+ render( );
+ expect(screen.queryByText('Export Template')).not.toBeInTheDocument();
+ });
+
+ it('seeds MJML and JSON state from the editor when opened', () => {
+ render( );
+
+ expect(screen.getByDisplayValue(' ')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'JSON' }));
+ const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
+ expect(textarea.value).toBe(JSON.stringify({ pages: [] }, null, 2));
+ });
+
+ it('compiles HTML automatically the first time the HTML tab is opened', async () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'HTML' }));
+ expect(screen.getByText('Compiling...')).toBeInTheDocument();
+
+ await waitFor(() => expect(mockCompileMjml).toHaveBeenCalledWith(' '));
+ expect(await screen.findByDisplayValue('ok')).toBeInTheDocument();
+ });
+
+ it('shows a generic message for non-Error rejections', async () => {
+ mockCompileMjml.mockRejectedValueOnce('nope');
+
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'HTML' }));
+
+ expect(await screen.findByText('Compilation failed')).toBeInTheDocument();
+ });
+
+ it('shows compile warnings returned alongside a successful compile', async () => {
+ mockCompileMjml.mockResolvedValueOnce({
+ html: 'partial',
+ errors: [{ line: 2, message: 'bad', tagName: 'mj-x', formattedMessage: 'Line 2: bad' }],
+ });
+
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'HTML' }));
+
+ expect(await screen.findByText('Line 2: bad')).toBeInTheDocument();
+ });
+
+ it('clicking the already-active MJML tab is a no-op that stays on MJML', () => {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'MJML' }));
+ expect(screen.getByDisplayValue(' ')).toBeInTheDocument();
+ });
+
+ it('copies MJML source to the clipboard', async () => {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'Copy MJML' }));
+
+ expect(mockWriteText).toHaveBeenCalledWith(' ');
+ expect(await screen.findByText('Copied!')).toBeInTheDocument();
+ });
+
+ it('recompile button is disabled while compiling and copy HTML disabled until output exists', async () => {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'HTML' }));
+
+ // While compiling, Recompile/Copy controls are not present (spinner shown instead).
+ expect(screen.queryByRole('button', { name: 'Recompile' })).not.toBeInTheDocument();
+
+ await screen.findByDisplayValue('ok');
+ fireEvent.click(screen.getByRole('button', { name: 'Copy HTML' }));
+ expect(mockWriteText).toHaveBeenCalledWith('ok');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Recompile' }));
+ await waitFor(() => expect(mockCompileMjml).toHaveBeenCalledTimes(2));
+ });
+
+ it('copies JSON state to the clipboard', async () => {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'JSON' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Copy JSON' }));
+
+ expect(mockWriteText).toHaveBeenCalledWith(JSON.stringify({ pages: [] }, null, 2));
+ expect(await screen.findByText('Copied!')).toBeInTheDocument();
+ });
+
+ it('downloads the JSON state as a file via an anchor click, then revokes the object URL', () => {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'JSON' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Download JSON' }));
+
+ expect(createObjectURL).toHaveBeenCalledTimes(1);
+ expect(clickSpy).toHaveBeenCalledTimes(1);
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
+ });
+});
diff --git a/src/components/template-editor/editor-import-dialog.test.tsx b/src/components/template-editor/editor-import-dialog.test.tsx
new file mode 100644
index 0000000..190ca47
--- /dev/null
+++ b/src/components/template-editor/editor-import-dialog.test.tsx
@@ -0,0 +1,106 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup, fireEvent } from '@testing-library/react';
+
+const { mockSetComponents, mockLoadData } = vi.hoisted(() => ({
+ mockSetComponents: vi.fn(),
+ mockLoadData: vi.fn(),
+}));
+
+vi.mock('@grapesjs/react', () => ({
+ useEditor: () => ({
+ setComponents: mockSetComponents,
+ loadData: mockLoadData,
+ }),
+}));
+
+import { EditorImportDialog } from './editor-import-dialog';
+
+describe('EditorImportDialog', () => {
+ const onOpenChange = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('renders nothing meaningful when closed', () => {
+ render( );
+ expect(screen.queryByText('Import Template')).not.toBeInTheDocument();
+ });
+
+ it('disables the Import button until source text is entered', () => {
+ render( );
+ expect(screen.getByRole('button', { name: 'Import' })).toBeDisabled();
+
+ fireEvent.change(screen.getByPlaceholderText(/mjml/i), { target: { value: ' ' } });
+ expect(screen.getByRole('button', { name: 'Import' })).not.toBeDisabled();
+ });
+
+ it('imports MJML source via editor.setComponents by default', () => {
+ render( );
+
+ fireEvent.change(screen.getByPlaceholderText(/mjml/i), { target: { value: ' ' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Import' }));
+
+ expect(mockSetComponents).toHaveBeenCalledWith(' ');
+ expect(mockLoadData).not.toHaveBeenCalled();
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('switches to JSON mode and loads valid JSON via editor.loadData', () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'JSON State' }));
+ const textarea = screen.getByPlaceholderText(/assets/i);
+ fireEvent.change(textarea, { target: { value: '{"assets":[]}' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Import' }));
+
+ expect(mockLoadData).toHaveBeenCalledWith({ assets: [] });
+ expect(mockSetComponents).not.toHaveBeenCalled();
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('shows an error and does not close when JSON is invalid', () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'JSON State' }));
+ fireEvent.change(screen.getByPlaceholderText(/assets/i), { target: { value: 'not json' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Import' }));
+
+ expect(screen.getByText(/Invalid JSON data/)).toBeInTheDocument();
+ expect(mockLoadData).not.toHaveBeenCalled();
+ expect(onOpenChange).not.toHaveBeenCalled();
+ });
+
+ it('does nothing when Import is clicked with only whitespace source', () => {
+ render( );
+
+ fireEvent.change(screen.getByPlaceholderText(/mjml/i), { target: { value: ' ' } });
+ // Button stays disabled for whitespace-only source.
+ expect(screen.getByRole('button', { name: 'Import' })).toBeDisabled();
+ });
+
+ it('switches from JSON mode back to MJML mode via the toggle', () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'JSON State' }));
+ fireEvent.click(screen.getByRole('button', { name: 'MJML Source' }));
+
+ fireEvent.change(screen.getByPlaceholderText(/mjml/i), { target: { value: ' ' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Import' }));
+
+ expect(mockSetComponents).toHaveBeenCalledWith(' ');
+ });
+
+ it('cancel closes without importing', () => {
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ expect(mockSetComponents).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/template-editor/editor-toolbar.test.tsx b/src/components/template-editor/editor-toolbar.test.tsx
new file mode 100644
index 0000000..43d3119
--- /dev/null
+++ b/src/components/template-editor/editor-toolbar.test.tsx
@@ -0,0 +1,327 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
+
+// Sub-dialogs / picker are exercised in their own test files — stub them here
+// so EditorToolbar tests focus on the toolbar's own wiring (undo/redo state,
+// device/panel toggles, and the Clear-canvas confirmation).
+vi.mock('./editor-code-dialog', () => ({
+ EditorCodeDialog: ({ open }: { open: boolean }) =>
+ open ?
: null,
+}));
+vi.mock('./editor-import-dialog', () => ({
+ EditorImportDialog: ({ open }: { open: boolean }) =>
+ open ?
: null,
+}));
+vi.mock('./editor-export-dialog', () => ({
+ EditorExportDialog: ({ open }: { open: boolean }) =>
+ open ?
: null,
+}));
+vi.mock('./merge-field-picker', () => ({
+ MergeFieldPicker: () =>
,
+}));
+
+const {
+ mockHasUndo,
+ mockHasRedo,
+ mockUndo,
+ mockRedo,
+ mockOn,
+ mockOff,
+ mockSetDevice,
+ mockDomComponentsClear,
+ mockCssComposerClear,
+ mockUndoManagerClear,
+ mockCommandsHas,
+ mockRunCommand,
+ mockStopCommand,
+ mockEditor,
+} = vi.hoisted(() => {
+ const mockHasUndo = vi.fn(() => false);
+ const mockHasRedo = vi.fn(() => false);
+ const mockUndo = vi.fn();
+ const mockRedo = vi.fn();
+ const mockOn = vi.fn();
+ const mockOff = vi.fn();
+ const mockSetDevice = vi.fn();
+ const mockDomComponentsClear = vi.fn();
+ const mockCssComposerClear = vi.fn();
+ const mockUndoManagerClear = vi.fn();
+ const mockCommandsHas = vi.fn(() => false);
+ const mockRunCommand = vi.fn();
+ const mockStopCommand = vi.fn();
+
+ // Stable object identity — EditorToolbar's effects depend on `editor` by
+ // reference, so recreating the object per useEditor() call would retrigger
+ // effects every render and infinite-loop the test.
+ const mockEditor = {
+ UndoManager: {
+ hasUndo: mockHasUndo,
+ hasRedo: mockHasRedo,
+ undo: mockUndo,
+ redo: mockRedo,
+ clear: mockUndoManagerClear,
+ },
+ on: mockOn,
+ off: mockOff,
+ setDevice: mockSetDevice,
+ DomComponents: { clear: mockDomComponentsClear },
+ CssComposer: { clear: mockCssComposerClear },
+ Commands: { has: mockCommandsHas },
+ runCommand: mockRunCommand,
+ stopCommand: mockStopCommand,
+ };
+
+ return {
+ mockHasUndo,
+ mockHasRedo,
+ mockUndo,
+ mockRedo,
+ mockOn,
+ mockOff,
+ mockSetDevice,
+ mockDomComponentsClear,
+ mockCssComposerClear,
+ mockUndoManagerClear,
+ mockCommandsHas,
+ mockRunCommand,
+ mockStopCommand,
+ mockEditor,
+ };
+});
+
+vi.mock('@grapesjs/react', () => ({
+ useEditor: () => mockEditor,
+}));
+
+import { EditorToolbar } from './editor-toolbar';
+
+/**
+ * `ToolbarButton` renders icon-only buttons: the tooltip text is not wired as
+ * an accessible name (Radix Tooltip only links `aria-describedby` while the
+ * tooltip is actually open), so `getByRole('button', { name })` cannot find
+ * them. Locate them instead by the lucide icon's stable CSS class
+ * (`lucide-`), which each toolbar button renders
+ * exactly one of.
+ */
+function getIconButton(iconClass: string): HTMLElement {
+ const button = screen
+ .getAllByRole('button')
+ .find((b) => b.querySelector(`svg.lucide-${iconClass}`));
+ if (!button) {
+ throw new Error(`No button found containing an svg.lucide-${iconClass}`);
+ }
+ return button;
+}
+
+describe('EditorToolbar', () => {
+ const onClose = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockHasUndo.mockReturnValue(false);
+ mockHasRedo.mockReturnValue(false);
+ mockCommandsHas.mockReturnValue(false);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('subscribes to change:changesCount on mount and unsubscribes on unmount', () => {
+ const { unmount } = render( );
+ expect(mockOn).toHaveBeenCalledWith('change:changesCount', expect.any(Function));
+
+ unmount();
+ expect(mockOff).toHaveBeenCalledWith('change:changesCount', expect.any(Function));
+ });
+
+ it('disables Undo/Redo buttons when the UndoManager reports none available', () => {
+ render( );
+ expect(getIconButton('undo-2')).toBeDisabled();
+ expect(getIconButton('redo-2')).toBeDisabled();
+ });
+
+ it('enables and wires Undo/Redo when the UndoManager reports availability', () => {
+ mockHasUndo.mockReturnValue(true);
+ mockHasRedo.mockReturnValue(true);
+ render( );
+
+ const undoBtn = getIconButton('undo-2');
+ const redoBtn = getIconButton('redo-2');
+ expect(undoBtn).not.toBeDisabled();
+ expect(redoBtn).not.toBeDisabled();
+
+ fireEvent.click(undoBtn);
+ expect(mockUndo).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(redoBtn);
+ expect(mockRedo).toHaveBeenCalledTimes(1);
+ });
+
+ it('re-syncs undo/redo state when the subscribed change event fires', () => {
+ render( );
+ expect(getIconButton('undo-2')).toBeDisabled();
+
+ mockHasUndo.mockReturnValue(true);
+ const onUpdate = mockOn.mock.calls.find(([event]) => event === 'change:changesCount')?.[1];
+ act(() => {
+ onUpdate?.();
+ });
+
+ expect(getIconButton('undo-2')).not.toBeDisabled();
+ });
+
+ it('switches active device and calls editor.setDevice', () => {
+ render( );
+
+ fireEvent.click(getIconButton('smartphone'));
+ expect(mockSetDevice).toHaveBeenCalledWith('Mobile');
+
+ fireEvent.click(getIconButton('monitor'));
+ expect(mockSetDevice).toHaveBeenCalledWith('Desktop');
+ });
+
+ it('resizes the views container and canvas elements when they exist in the DOM', () => {
+ // Simulate the real GrapesJS-rendered nodes the toolbar's effect looks
+ // up by class name via document.querySelector.
+ const viewsContainer = document.createElement('div');
+ viewsContainer.className = 'gjs-pn-views-container';
+ const canvas = document.createElement('div');
+ canvas.className = 'gjs-cv-canvas';
+ document.body.append(viewsContainer, canvas);
+
+ mockCommandsHas.mockReturnValue(true);
+ render( );
+ expect(viewsContainer.style.display).toBe('block');
+ expect(canvas.style.width).toBe('calc(100% - 240px)');
+
+ // Toggling the active panel closed hides the sidebar again.
+ fireEvent.click(getIconButton('layout-grid'));
+ expect(viewsContainer.style.display).toBe('none');
+ expect(canvas.style.width).toBe('100%');
+
+ viewsContainer.remove();
+ canvas.remove();
+ });
+
+ it('opens the Blocks panel command by default on mount when the command exists', () => {
+ mockCommandsHas.mockReturnValue(true);
+ render( );
+ expect(mockRunCommand).toHaveBeenCalledWith('core:open-blocks');
+ });
+
+ it('toggles a panel closed when its already-active button is clicked again', () => {
+ mockCommandsHas.mockReturnValue(true);
+ render( );
+ mockRunCommand.mockClear();
+ mockStopCommand.mockClear();
+
+ fireEvent.click(getIconButton('layout-grid'));
+
+ // Active panel becomes null: all panel-close commands run, none (re)open.
+ expect(mockStopCommand).toHaveBeenCalledWith('core:open-blocks');
+ expect(mockStopCommand).toHaveBeenCalledWith('core:open-layers');
+ expect(mockStopCommand).toHaveBeenCalledWith('core:open-styles-manager');
+ expect(mockRunCommand).not.toHaveBeenCalled();
+ });
+
+ it('switches to the Layers panel when its button is clicked', () => {
+ mockCommandsHas.mockReturnValue(true);
+ render( );
+ mockRunCommand.mockClear();
+
+ fireEvent.click(getIconButton('layers'));
+ expect(mockRunCommand).toHaveBeenCalledWith('core:open-layers');
+ });
+
+ it('switches to the Styles panel when its button is clicked', () => {
+ mockCommandsHas.mockReturnValue(true);
+ render( );
+ mockRunCommand.mockClear();
+
+ fireEvent.click(getIconButton('paintbrush'));
+ expect(mockRunCommand).toHaveBeenCalledWith('core:open-styles-manager');
+ });
+
+ it('does not run a panel command when the editor reports it does not exist', () => {
+ mockCommandsHas.mockReturnValue(false);
+ render( );
+
+ expect(mockRunCommand).not.toHaveBeenCalled();
+ expect(mockStopCommand).not.toHaveBeenCalled();
+ });
+
+ it('opens the code dialog from the toolbar button', () => {
+ render( );
+ fireEvent.click(getIconButton('code'));
+ expect(screen.getByTestId('code-dialog')).toBeInTheDocument();
+ });
+
+ it('opens the import dialog from the toolbar button', () => {
+ render( );
+ fireEvent.click(getIconButton('upload'));
+ expect(screen.getByTestId('import-dialog')).toBeInTheDocument();
+ });
+
+ it('opens the export dialog from the toolbar button', () => {
+ render( );
+ fireEvent.click(getIconButton('download'));
+ expect(screen.getByTestId('export-dialog')).toBeInTheDocument();
+ });
+
+ it('renders the merge field picker', () => {
+ render( );
+ expect(screen.getByTestId('merge-field-picker')).toBeInTheDocument();
+ });
+
+ it('calls onClose when the close button is clicked', () => {
+ render( );
+ fireEvent.click(getIconButton('x'));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('opens a confirmation dialog for the Clear-canvas button and does nothing until confirmed', () => {
+ render( );
+
+ fireEvent.click(getIconButton('trash-2'));
+
+ expect(screen.getByText('Clear canvas?')).toBeInTheDocument();
+ expect(mockDomComponentsClear).not.toHaveBeenCalled();
+ });
+
+ it('clears the canvas and removes the localStorage draft when confirmed', () => {
+ const removeItemSpy = vi.spyOn(Storage.prototype, 'removeItem');
+ render( );
+
+ fireEvent.click(getIconButton('trash-2'));
+ fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
+
+ expect(mockDomComponentsClear).toHaveBeenCalledTimes(1);
+ expect(mockCssComposerClear).toHaveBeenCalledTimes(1);
+ expect(mockUndoManagerClear).toHaveBeenCalledTimes(1);
+ expect(removeItemSpy).toHaveBeenCalledWith('mp-template-editor');
+ });
+
+ it('swallows a localStorage.removeItem failure during Clear', () => {
+ vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
+ throw new Error('unavailable');
+ });
+ render( );
+
+ fireEvent.click(getIconButton('trash-2'));
+
+ expect(() => fireEvent.click(screen.getByRole('button', { name: 'Clear' }))).not.toThrow();
+ expect(mockDomComponentsClear).toHaveBeenCalledTimes(1);
+ });
+
+ it('canceling the clear confirmation leaves the canvas untouched', () => {
+ render( );
+
+ fireEvent.click(getIconButton('trash-2'));
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+
+ expect(mockDomComponentsClear).not.toHaveBeenCalled();
+ expect(screen.queryByText('Clear canvas?')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/template-editor/grapes-config.test.ts b/src/components/template-editor/grapes-config.test.ts
new file mode 100644
index 0000000..5a4bcf4
--- /dev/null
+++ b/src/components/template-editor/grapes-config.test.ts
@@ -0,0 +1,51 @@
+import { describe, it, expect } from 'vitest';
+import { createEditorConfig, DEFAULT_MJML_TEMPLATE, STORAGE_KEY } from './grapes-config';
+
+describe('grapes-config', () => {
+ it('exports the shared storage key', () => {
+ expect(STORAGE_KEY).toBe('mp-template-editor');
+ });
+
+ it('exports a default MJML template that is well-formed enough to contain mjml/mj-body tags', () => {
+ expect(DEFAULT_MJML_TEMPLATE).toContain('');
+ expect(DEFAULT_MJML_TEMPLATE).toContain('');
+ });
+
+ it('createEditorConfig returns a config wired to the shared storage key', () => {
+ const config = createEditorConfig();
+
+ expect(config.fromElement).toBe(false);
+ expect(config.height).toBe('100%');
+ expect(config.storageManager).toMatchObject({
+ type: 'local',
+ autosave: true,
+ autoload: true,
+ stepsBeforeSave: 1,
+ options: {
+ local: {
+ key: STORAGE_KEY,
+ },
+ },
+ });
+ });
+
+ it('createEditorConfig includes Desktop and Mobile devices', () => {
+ const config = createEditorConfig();
+ const devices = config.deviceManager?.devices ?? [];
+ const names = devices.map((d) => d.name);
+ expect(names).toEqual(['Desktop', 'Mobile']);
+ });
+
+ it('createEditorConfig disables default panels (custom React toolbar is used instead)', () => {
+ const config = createEditorConfig();
+ expect(config.panels).toEqual({ defaults: [] });
+ });
+
+ it('createEditorConfig returns a fresh object on each call', () => {
+ const first = createEditorConfig();
+ const second = createEditorConfig();
+ expect(first).not.toBe(second);
+ expect(first).toEqual(second);
+ });
+});
diff --git a/src/components/template-editor/merge-field-picker.test.tsx b/src/components/template-editor/merge-field-picker.test.tsx
new file mode 100644
index 0000000..8542008
--- /dev/null
+++ b/src/components/template-editor/merge-field-picker.test.tsx
@@ -0,0 +1,136 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup, fireEvent } from '@testing-library/react';
+
+const { mockGetSelected, mockGetWrapper } = vi.hoisted(() => ({
+ mockGetSelected: vi.fn(),
+ mockGetWrapper: vi.fn(),
+}));
+
+vi.mock('@grapesjs/react', () => ({
+ useEditor: () => ({
+ getSelected: mockGetSelected,
+ getWrapper: mockGetWrapper,
+ }),
+}));
+
+import { MergeFieldPicker } from './merge-field-picker';
+
+describe('MergeFieldPicker', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetSelected.mockReturnValue(null);
+ mockGetWrapper.mockReturnValue(null);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ async function openPicker() {
+ render( );
+ const trigger = screen.getByRole('button');
+ fireEvent.click(trigger);
+ }
+
+ it('renders every category and field once opened', async () => {
+ await openPicker();
+
+ expect(await screen.findByText('Merge Fields')).toBeInTheDocument();
+ expect(screen.getByText('Contact')).toBeInTheDocument();
+ expect(screen.getByText('Household')).toBeInTheDocument();
+ expect(screen.getByText('Church')).toBeInTheDocument();
+ expect(screen.getByText('System')).toBeInTheDocument();
+ expect(screen.getByText('First Name')).toBeInTheDocument();
+ expect(screen.getByText('{{First_Name}}')).toBeInTheDocument();
+ });
+
+ it('appends the token to the selected mj-text component content instead of inserting a new block', async () => {
+ const set = vi.fn();
+ const selected = {
+ get: (key: string) => (key === 'type' ? 'mj-text' : key === 'content' ? 'Hi
' : undefined),
+ set,
+ };
+ mockGetSelected.mockReturnValue(selected);
+
+ await openPicker();
+ fireEvent.click(await screen.findByText('First Name'));
+
+ expect(set).toHaveBeenCalledWith('content', 'Hi
{{First_Name}}');
+ expect(mockGetWrapper).not.toHaveBeenCalled();
+ });
+
+ it('treats an empty existing content as empty string, not the literal "undefined"', async () => {
+ const set = vi.fn();
+ const selected = {
+ get: (key: string) => (key === 'type' ? 'mj-text' : key === 'content' ? undefined : undefined),
+ set,
+ };
+ mockGetSelected.mockReturnValue(selected);
+
+ await openPicker();
+ fireEvent.click(await screen.findByText('First Name'));
+
+ expect(set).toHaveBeenCalledWith('content', '{{First_Name}}');
+ });
+
+ it('inserts a new mj-section under the mj-body when nothing is selected', async () => {
+ const append = vi.fn();
+ const body = { append };
+ const wrapper = {
+ find: (selector: string) => (selector === 'mj-body' ? [body] : []),
+ };
+ mockGetSelected.mockReturnValue(null);
+ mockGetWrapper.mockReturnValue(wrapper);
+
+ await openPicker();
+ fireEvent.click(await screen.findByText('Unsubscribe Link'));
+
+ expect(append).toHaveBeenCalledWith({
+ type: 'mj-section',
+ components: [
+ {
+ type: 'mj-column',
+ components: [
+ {
+ type: 'mj-text',
+ content: '{{Unsubscribe_URL}}
',
+ },
+ ],
+ },
+ ],
+ });
+ });
+
+ it('falls back to appending directly on the wrapper when no mj-body is found', async () => {
+ const append = vi.fn();
+ const wrapper = {
+ find: () => [],
+ append,
+ };
+ mockGetSelected.mockReturnValue(null);
+ mockGetWrapper.mockReturnValue(wrapper);
+
+ await openPicker();
+ fireEvent.click(await screen.findByText('First Name'));
+
+ expect(append).toHaveBeenCalledTimes(1);
+ });
+
+ it('does nothing (no throw) when selected is not mj-text and no wrapper exists', async () => {
+ mockGetSelected.mockReturnValue(null);
+ mockGetWrapper.mockReturnValue(null);
+
+ await openPicker();
+ const field = await screen.findByText('First Name');
+ expect(() => fireEvent.click(field)).not.toThrow();
+ });
+
+ it('closes the popover after inserting a field', async () => {
+ await openPicker();
+ expect(await screen.findByText('Merge Fields')).toBeInTheDocument();
+ fireEvent.click(screen.getByText('First Name'));
+
+ expect(screen.queryByText('Merge Fields')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/template-editor/merge-fields.test.ts b/src/components/template-editor/merge-fields.test.ts
new file mode 100644
index 0000000..feaad17
--- /dev/null
+++ b/src/components/template-editor/merge-fields.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect, vi } from 'vitest';
+import {
+ MERGE_FIELDS,
+ MERGE_FIELD_CATEGORIES,
+ getFieldsByCategory,
+ registerMergeFieldBlocks,
+} from './merge-fields';
+import type { Editor } from 'grapesjs';
+
+describe('MERGE_FIELD_CATEGORIES', () => {
+ it('dedupes categories preserving first-seen order', () => {
+ expect(MERGE_FIELD_CATEGORIES).toEqual(['Contact', 'Household', 'Church', 'System']);
+ });
+
+ it('matches the set of categories present in MERGE_FIELDS', () => {
+ const categoriesInFields = new Set(MERGE_FIELDS.map((f) => f.category));
+ expect(new Set(MERGE_FIELD_CATEGORIES)).toEqual(categoriesInFields);
+ });
+});
+
+describe('getFieldsByCategory', () => {
+ it('returns only fields matching the given category', () => {
+ const contactFields = getFieldsByCategory('Contact');
+ expect(contactFields.length).toBeGreaterThan(0);
+ expect(contactFields.every((f) => f.category === 'Contact')).toBe(true);
+ expect(contactFields.map((f) => f.value)).toContain('{{First_Name}}');
+ });
+
+ it('returns an empty array for an unknown category', () => {
+ expect(getFieldsByCategory('Nonexistent')).toEqual([]);
+ });
+});
+
+describe('registerMergeFieldBlocks', () => {
+ it('calls editor.Blocks.add twice with the expected block ids and categories', () => {
+ const add = vi.fn();
+ const editor = { Blocks: { add } } as unknown as Editor;
+
+ registerMergeFieldBlocks(editor);
+
+ expect(add).toHaveBeenCalledTimes(2);
+ expect(add).toHaveBeenNthCalledWith(
+ 1,
+ 'merge-field-contact',
+ expect.objectContaining({
+ label: 'Contact Fields',
+ category: 'Merge Fields',
+ content: expect.objectContaining({ type: 'mj-text' }),
+ }),
+ );
+ expect(add).toHaveBeenNthCalledWith(
+ 2,
+ 'merge-field-unsubscribe',
+ expect.objectContaining({
+ label: 'Unsubscribe Link',
+ category: 'Merge Fields',
+ }),
+ );
+ });
+});
diff --git a/src/components/template-editor/template-editor-form.test.tsx b/src/components/template-editor/template-editor-form.test.tsx
new file mode 100644
index 0000000..c29d3d0
--- /dev/null
+++ b/src/components/template-editor/template-editor-form.test.tsx
@@ -0,0 +1,85 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+
+/**
+ * `next/dynamic` is mocked directly so both the resolved component and the
+ * `loading` fallback (`EditorSkeleton`) are independently testable and
+ * covered, without depending on the real dynamic-import/suspense timing.
+ */
+const { mockDynamic } = vi.hoisted(() => ({
+ mockDynamic: vi.fn(),
+}));
+
+vi.mock('next/dynamic', () => ({
+ default: mockDynamic,
+}));
+
+vi.mock('./editor-canvas', () => ({
+ EditorCanvas: ({ onClose }: { onClose: () => void }) => (
+
+ ),
+}));
+
+describe('TemplateEditorForm', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it('calls next/dynamic with a loader resolving to EditorCanvas and ssr disabled', async () => {
+ let capturedOptions: { ssr?: boolean; loading?: () => React.ReactNode } | undefined;
+ mockDynamic.mockImplementation((loader, options) => {
+ capturedOptions = options;
+ return function DynamicResult() {
+ return
;
+ };
+ });
+
+ const { TemplateEditorForm } = await import('./template-editor-form');
+ render( );
+
+ expect(mockDynamic).toHaveBeenCalledTimes(1);
+ expect(capturedOptions?.ssr).toBe(false);
+
+ const loaderFn = mockDynamic.mock.calls[0][0] as () => Promise<{ default: unknown }>;
+ const mod = await loaderFn();
+ const editorCanvasModule = await import('./editor-canvas');
+ expect(mod.default).toBe(editorCanvasModule.EditorCanvas);
+ });
+
+ it('renders the loading fallback (EditorSkeleton) via the loading option', async () => {
+ let capturedOptions: { loading?: () => React.ReactNode } | undefined;
+ mockDynamic.mockImplementation((loader, options) => {
+ capturedOptions = options;
+ return function DynamicResult() {
+ return
;
+ };
+ });
+
+ await import('./template-editor-form');
+ const loadingNode = capturedOptions?.loading?.();
+
+ // EditorSkeleton renders Skeleton placeholders inside a flex row plus a
+ // header skeleton — assert the structural shape rather than text, since
+ // Skeleton has no visible copy.
+ const { container } = render(<>{loadingNode}>);
+ expect(container.querySelectorAll('[class*="animate-pulse"]').length).toBeGreaterThan(0);
+ });
+
+ it('renders the resolved dynamic component (EditorCanvas) with onClose wired through', async () => {
+ const { EditorCanvas } = await import('./editor-canvas');
+ mockDynamic.mockImplementation(() => EditorCanvas);
+
+ const { TemplateEditorForm } = await import('./template-editor-form');
+ const onClose = vi.fn();
+ render( );
+
+ const canvas = await screen.findByTestId('editor-canvas');
+ canvas.click();
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/components/tool/tool-framework.test.tsx b/src/components/tool/tool-framework.test.tsx
new file mode 100644
index 0000000..c2096b6
--- /dev/null
+++ b/src/components/tool/tool-framework.test.tsx
@@ -0,0 +1,160 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+/**
+ * Tool framework tests — ToolContainer, ToolHeader, ToolFooter.
+ *
+ * These three compose every tool page, so their optional-prop branches
+ * (params -> DevPanel, infoContent -> tooltip, hideFooter -> no footer)
+ * decide what every tool renders. Each branch is pinned here.
+ */
+
+vi.mock('@/components/dev-panel', () => ({
+ DevPanel: ({ params }: { params: { pageID?: number } }) => (
+ dev-panel:{params.pageID}
+ ),
+}));
+
+import { ToolContainer } from './tool-container';
+import { ToolHeader } from './tool-header';
+import { ToolFooter } from './tool-footer';
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+describe('ToolFooter', () => {
+ it('renders default Close and Save labels', () => {
+ render( );
+
+ expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
+ });
+
+ it('honours custom labels', () => {
+ render( );
+
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument();
+ });
+
+ it('invokes onClose and onSave', async () => {
+ const onClose = vi.fn();
+ const onSave = vi.fn();
+ render( );
+
+ await userEvent.click(screen.getByRole('button', { name: 'Close' }));
+ await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onSave).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows "Saving..." and disables both buttons while saving', () => {
+ render( );
+
+ expect(screen.getByRole('button', { name: 'Saving...' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Close' })).toBeDisabled();
+ });
+
+ it('does not fire onSave while saving', async () => {
+ const onSave = vi.fn();
+ render( );
+
+ await userEvent.click(screen.getByRole('button', { name: 'Saving...' }));
+
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ it('renders footerExtra when supplied, and omits the slot when not', () => {
+ const { unmount } = render(extra-node} />);
+ expect(screen.getByText('extra-node')).toBeInTheDocument();
+ unmount();
+
+ render( );
+ expect(screen.queryByText('extra-node')).not.toBeInTheDocument();
+ });
+});
+
+describe('ToolHeader', () => {
+ it('renders the title as a level-1 heading', () => {
+ render( );
+
+ expect(screen.getByRole('heading', { level: 1, name: 'My Tool' })).toBeInTheDocument();
+ });
+
+ it('omits the info affordance when infoContent is absent', () => {
+ render( );
+
+ expect(screen.queryByRole('button', { name: 'Information' })).not.toBeInTheDocument();
+ });
+
+ it('renders a labelled info trigger when infoContent is supplied', () => {
+ render(help text} />);
+
+ expect(screen.getByRole('button', { name: 'Information' })).toBeInTheDocument();
+ });
+});
+
+describe('ToolContainer', () => {
+ const params = { pageID: 42 } as never;
+
+ it('renders children, header and footer by default', () => {
+ render(
+
+ body-content
+ ,
+ );
+
+ expect(screen.getByRole('heading', { level: 1, name: 'Container Tool' })).toBeInTheDocument();
+ expect(screen.getByText('body-content')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
+ });
+
+ it('omits the footer when hideFooter is set', () => {
+ render(
+
+ body-content
+ ,
+ );
+
+ expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument();
+ });
+
+ it('renders the DevPanel only when params are supplied', () => {
+ const { unmount } = render(
+
+ body
+ ,
+ );
+ expect(screen.queryByTestId('dev-panel')).not.toBeInTheDocument();
+ unmount();
+
+ render(
+
+ body
+ ,
+ );
+ expect(screen.getByTestId('dev-panel')).toHaveTextContent('dev-panel:42');
+ });
+
+ it('forwards footer props through to ToolFooter', async () => {
+ const onSave = vi.fn();
+ render(
+ footer-extra}
+ >
+ body
+ ,
+ );
+
+ expect(screen.getByText('footer-extra')).toBeInTheDocument();
+ await userEvent.click(screen.getByRole('button', { name: 'Persist' }));
+ expect(onSave).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/contexts/user-context.test.tsx b/src/contexts/user-context.test.tsx
index e799b11..ec171b1 100644
--- a/src/contexts/user-context.test.tsx
+++ b/src/contexts/user-context.test.tsx
@@ -143,3 +143,93 @@ describe('UserContext', () => {
});
});
});
+
+/**
+ * Branches the mount effect alone cannot reach.
+ *
+ * The effect only calls `loadUserProfile()` when a `userGuid` exists, so the
+ * guard clause inside that callback is reachable only through an explicit
+ * `refreshUserProfile()` — which is exactly what happens if a component
+ * refreshes while the session is still resolving or has gone stale.
+ */
+describe('UserProvider edge branches', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('clears the profile without calling the server when refreshed with no userGuid', async () => {
+ mockUseSession.mockReturnValue({
+ data: { user: { id: 'internal-id' } },
+ isPending: false,
+ });
+
+ const { result } = renderHook(() => useUser(), { wrapper: createWrapper() });
+
+ await act(async () => {
+ await result.current.refreshUserProfile();
+ });
+
+ expect(mockGetCurrentUserProfile).not.toHaveBeenCalled();
+ expect(result.current.userProfile).toBeNull();
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('stays in the loading state while the session is pending', () => {
+ mockUseSession.mockReturnValue({ data: undefined, isPending: true });
+
+ const { result } = renderHook(() => useUser(), { wrapper: createWrapper() });
+
+ expect(result.current.isLoading).toBe(true);
+ expect(mockGetCurrentUserProfile).not.toHaveBeenCalled();
+ });
+
+ it('wraps a non-Error rejection in a real Error', async () => {
+ mockUseSession.mockReturnValue({
+ data: { user: { id: 'internal-id', userGuid: 'guid-123' } },
+ isPending: false,
+ });
+ mockGetCurrentUserProfile.mockRejectedValueOnce('a bare string');
+
+ const { result } = renderHook(() => useUser(), { wrapper: createWrapper() });
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.error).toBeInstanceOf(Error);
+ expect(result.current.error?.message).toBe('Failed to load user profile');
+ expect(result.current.userProfile).toBeNull();
+ });
+
+ it('treats an undefined profile result as no profile', async () => {
+ mockUseSession.mockReturnValue({
+ data: { user: { id: 'internal-id', userGuid: 'guid-123' } },
+ isPending: false,
+ });
+ mockGetCurrentUserProfile.mockResolvedValueOnce(undefined);
+
+ const { result } = renderHook(() => useUser(), { wrapper: createWrapper() });
+
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+
+ expect(result.current.userProfile).toBeNull();
+ expect(result.current.error).toBeNull();
+ });
+
+ it('clears a previous error on a successful refresh', async () => {
+ mockUseSession.mockReturnValue({
+ data: { user: { id: 'internal-id', userGuid: 'guid-123' } },
+ isPending: false,
+ });
+ mockGetCurrentUserProfile.mockRejectedValueOnce(new Error('transient'));
+
+ const { result } = renderHook(() => useUser(), { wrapper: createWrapper() });
+ await waitFor(() => expect(result.current.error).not.toBeNull());
+
+ mockGetCurrentUserProfile.mockResolvedValueOnce({ Contact_ID: 7 });
+ await act(async () => {
+ await result.current.refreshUserProfile();
+ });
+
+ expect(result.current.error).toBeNull();
+ expect(result.current.userProfile).toEqual({ Contact_ID: 7 });
+ });
+});
diff --git a/src/lib/auth-client.test.ts b/src/lib/auth-client.test.ts
new file mode 100644
index 0000000..c9f230c
--- /dev/null
+++ b/src/lib/auth-client.test.ts
@@ -0,0 +1,35 @@
+import { describe, it, expect } from 'vitest';
+
+import { authClient } from './auth-client';
+
+/**
+ * Smoke coverage for the Better Auth browser client.
+ *
+ * Better Auth 1.7 removed `genericOAuthClient()`; the MP generic OAuth provider
+ * is now registered as a first-class social provider, so sign-in must go
+ * through `signIn.social({ provider: "ministryplatform" })` rather than the
+ * removed `signIn.oauth2`. These assertions pin the surface the app calls.
+ *
+ * The client is NOT mocked here: `better-auth/react` is a subpath export that
+ * Vitest's module registry does not intercept in this setup, and the real
+ * client constructs fine under jsdom. Constructing it for real is also the
+ * stronger test — it proves the plugin list in the source actually builds.
+ */
+
+describe('authClient', () => {
+ it('constructs without throwing at import time', () => {
+ expect(authClient).toBeDefined();
+ });
+
+ it('exposes signIn.social, the endpoint the MP provider uses', () => {
+ expect(authClient.signIn.social).toBeTypeOf('function');
+ });
+
+ it('exposes the customSession-backed useSession hook', () => {
+ expect(authClient.useSession).toBeDefined();
+ });
+
+ it('exposes signOut', () => {
+ expect(authClient.signOut).toBeTypeOf('function');
+ });
+});
diff --git a/src/lib/dto/address-label.dto.test.ts b/src/lib/dto/address-label.dto.test.ts
new file mode 100644
index 0000000..ba11d85
--- /dev/null
+++ b/src/lib/dto/address-label.dto.test.ts
@@ -0,0 +1,37 @@
+import { describe, it, expect } from 'vitest';
+
+import { SERVICE_TYPES } from './address-label.dto';
+
+/**
+ * SERVICE_TYPES feeds the USPS Service Type Identifier (STID) field of an
+ * Intelligent Mail barcode. A malformed id here produces a barcode the Postal
+ * Service rejects, so the shape is pinned rather than left to review.
+ */
+
+describe('SERVICE_TYPES', () => {
+ it('offers the five supported mail classes', () => {
+ expect(SERVICE_TYPES).toHaveLength(5);
+ });
+
+ it('uses exactly three digits for every service type id, as the IMb STID requires', () => {
+ for (const type of SERVICE_TYPES) {
+ expect(type.id).toMatch(/^\d{3}$/);
+ }
+ });
+
+ it('gives every entry a non-empty display name', () => {
+ for (const type of SERVICE_TYPES) {
+ expect(type.name.trim()).not.toBe('');
+ }
+ });
+
+ it('has no duplicate ids', () => {
+ const ids = SERVICE_TYPES.map((t) => t.id);
+
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it('includes First-Class Mail as service type 040', () => {
+ expect(SERVICE_TYPES).toContainEqual({ id: '040', name: 'First-Class Mail' });
+ });
+});
diff --git a/src/lib/dto/family.test.ts b/src/lib/dto/family.test.ts
new file mode 100644
index 0000000..489d77b
--- /dev/null
+++ b/src/lib/dto/family.test.ts
@@ -0,0 +1,107 @@
+import { describe, it, expect } from "vitest";
+import {
+ FamilyAddressSchema,
+ FamilyMemberParticipantSchema,
+ FamilyMemberSchema,
+ HouseholdSchema,
+ emptyAddress,
+ emptyMember,
+ emptyHousehold,
+ type FamilyDefaults,
+} from "./family";
+
+const defaults: FamilyDefaults = {
+ congregationId: 1,
+ sourceId: 18,
+ countryCode: "US",
+ state: "CA",
+ householdPositionId: 2,
+ participantTypeId: 4,
+ showEnvelopeNumbers: true,
+};
+
+describe("emptyAddress", () => {
+ it("returns a blank address shape", () => {
+ expect(emptyAddress()).toEqual({
+ addressId: 0,
+ addressLine1: null,
+ addressLine2: null,
+ city: null,
+ state: null,
+ region: null,
+ postalCode: "",
+ countryCode: null,
+ });
+ });
+
+ it("validates against FamilyAddressSchema", () => {
+ expect(() => FamilyAddressSchema.parse(emptyAddress())).not.toThrow();
+ });
+});
+
+describe("emptyMember", () => {
+ it("builds a member with the given contactId and lastName", () => {
+ const member = emptyMember(-1, "Smith", defaults);
+ expect(member.contactId).toBe(-1);
+ expect(member.lastName).toBe("Smith");
+ expect(member.firstName).toBe("");
+ expect(member.householdPositionId).toBe(defaults.householdPositionId);
+ expect(member.participant).toEqual({
+ participantId: 0,
+ participantTypeId: defaults.participantTypeId,
+ notes: null,
+ });
+ expect(member.isDonor).toBe(false);
+ expect(member.donorId).toBeNull();
+ expect(member.contactStatusId).toBe(1);
+ });
+
+ it("validates against FamilyMemberSchema", () => {
+ const member = emptyMember(-2, "Jones", defaults);
+ expect(() => FamilyMemberSchema.parse(member)).not.toThrow();
+ });
+});
+
+describe("emptyHousehold", () => {
+ it("builds a household with two blank members by default lastName", () => {
+ const household = emptyHousehold(defaults);
+ expect(household.householdId).toBe(0);
+ expect(household.householdName).toBe("");
+ expect(household.members).toHaveLength(2);
+ expect(household.members[0].contactId).toBe(-1);
+ expect(household.members[1].contactId).toBe(-2);
+ expect(household.address.countryCode).toBe(defaults.countryCode);
+ expect(household.address.state).toBe(defaults.state);
+ expect(household.alternateMailingAddress.countryCode).toBe(defaults.countryCode);
+ });
+
+ it("uses the provided lastName for householdName and members", () => {
+ const household = emptyHousehold(defaults, "Rivera");
+ expect(household.householdName).toBe("Rivera");
+ expect(household.members[0].lastName).toBe("Rivera");
+ expect(household.members[1].lastName).toBe("Rivera");
+ });
+
+ it("validates against HouseholdSchema", () => {
+ const household = emptyHousehold(defaults, "Rivera");
+ expect(() => HouseholdSchema.parse(household)).not.toThrow();
+ });
+});
+
+describe("FamilyMemberParticipantSchema", () => {
+ it("accepts a participant with optional notes omitted", () => {
+ const result = FamilyMemberParticipantSchema.safeParse({
+ participantId: 1,
+ participantTypeId: 2,
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it("rejects a non-integer participantId", () => {
+ const result = FamilyMemberParticipantSchema.safeParse({
+ participantId: 1.5,
+ participantTypeId: 2,
+ });
+ expect(result.success).toBe(false);
+ });
+});
diff --git a/src/lib/providers/google-places/provider.test.ts b/src/lib/providers/google-places/provider.test.ts
new file mode 100644
index 0000000..0c86725
--- /dev/null
+++ b/src/lib/providers/google-places/provider.test.ts
@@ -0,0 +1,219 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { GooglePlacesProvider } from "./provider";
+
+const mockFetch = vi.fn();
+
+describe("GooglePlacesProvider", () => {
+ beforeEach(() => {
+ vi.stubGlobal("fetch", mockFetch);
+ mockFetch.mockReset();
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ describe("constructor", () => {
+ it("throws when apiKey is empty", () => {
+ expect(() => new GooglePlacesProvider("")).toThrow(
+ "GooglePlacesProvider requires a non-empty API key",
+ );
+ });
+
+ it("constructs with a non-empty key", () => {
+ expect(() => new GooglePlacesProvider("key-123")).not.toThrow();
+ });
+ });
+
+ describe("autocomplete", () => {
+ it("returns [] when trimmed input is shorter than 3 chars", async () => {
+ const provider = new GooglePlacesProvider("key-123");
+ const result = await provider.autocomplete(" a ", "token-1");
+ expect(result).toEqual([]);
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+
+ it("calls the autocomplete endpoint and maps suggestions", async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ suggestions: [
+ {
+ placePrediction: {
+ placeId: "place-1",
+ text: { text: "123 Main St, Springfield" },
+ structuredFormat: {
+ mainText: { text: "123 Main St" },
+ secondaryText: { text: "Springfield" },
+ },
+ },
+ },
+ {
+ placePrediction: {
+ placeId: "place-2",
+ text: { text: "456 Oak Ave" },
+ // no structuredFormat
+ },
+ },
+ // missing placeId entirely -> filtered out
+ { placePrediction: { placeId: "" } },
+ {},
+ ],
+ }),
+ });
+
+ const provider = new GooglePlacesProvider("key-123");
+ const result = await provider.autocomplete("123 Main", "token-1");
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ "https://places.googleapis.com/v1/places:autocomplete",
+ expect.objectContaining({
+ method: "POST",
+ headers: expect.objectContaining({
+ "X-Goog-Api-Key": "key-123",
+ }),
+ }),
+ );
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
+ expect(body).toEqual({
+ input: "123 Main",
+ sessionToken: "token-1",
+ includedPrimaryTypes: ["street_address", "premise", "subpremise"],
+ });
+
+ expect(result).toEqual([
+ {
+ placeId: "place-1",
+ primary: "123 Main St",
+ secondary: "Springfield",
+ full: "123 Main St, Springfield",
+ },
+ {
+ placeId: "place-2",
+ primary: "456 Oak Ave",
+ secondary: "",
+ full: "456 Oak Ave",
+ },
+ ]);
+ });
+
+ it("returns [] when suggestions is missing entirely", async () => {
+ mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
+ const provider = new GooglePlacesProvider("key-123");
+ const result = await provider.autocomplete("123 Main", "token-1");
+ expect(result).toEqual([]);
+ });
+
+ it("throws with status and body text when the response is not ok", async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 429,
+ text: async () => "Rate limited",
+ });
+ const provider = new GooglePlacesProvider("key-123");
+ await expect(provider.autocomplete("123 Main", "token-1")).rejects.toThrow(
+ "Google Places autocomplete failed: 429 Rate limited",
+ );
+ });
+ });
+
+ describe("getPlaceDetails", () => {
+ it("requests details and maps address components", async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ id: "place-1",
+ formattedAddress: "123 Main St, Springfield, IL 62701, US",
+ addressComponents: [
+ { longText: "123", shortText: "123", types: ["street_number"] },
+ { longText: "Main St", shortText: "Main St", types: ["route"] },
+ { longText: "Springfield", shortText: "Springfield", types: ["locality"] },
+ { longText: "Illinois", shortText: "IL", types: ["administrative_area_level_1"] },
+ { longText: "62701", shortText: "62701", types: ["postal_code"] },
+ { longText: "United States", shortText: "US", types: ["country"] },
+ ],
+ }),
+ });
+
+ const provider = new GooglePlacesProvider("key-123");
+ const details = await provider.getPlaceDetails("place-1", "token-1");
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ "https://places.googleapis.com/v1/places/place-1?sessionToken=token-1",
+ expect.objectContaining({
+ method: "GET",
+ headers: expect.objectContaining({ "X-Goog-Api-Key": "key-123" }),
+ }),
+ );
+
+ expect(details).toEqual({
+ placeId: "place-1",
+ formattedAddress: "123 Main St, Springfield, IL 62701, US",
+ addressLine1: "123 Main St",
+ city: "Springfield",
+ state: "IL",
+ postalCode: "62701",
+ countryCode: "US",
+ });
+ });
+
+ it("falls back through city component types", async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ id: "place-2",
+ addressComponents: [
+ { longText: "Some Town", shortText: "Some Town", types: ["postal_town"] },
+ ],
+ }),
+ });
+ const provider = new GooglePlacesProvider("key-123");
+ const details = await provider.getPlaceDetails("place-2", "token-1");
+ expect(details.city).toBe("Some Town");
+ expect(details.formattedAddress).toBe("");
+ });
+
+ it("handles missing addressComponents entirely", async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ id: "place-3" }),
+ });
+ const provider = new GooglePlacesProvider("key-123");
+ const details = await provider.getPlaceDetails("place-3", "token-1");
+ expect(details).toEqual({
+ placeId: "place-3",
+ formattedAddress: "",
+ addressLine1: "",
+ city: "",
+ state: "",
+ postalCode: "",
+ countryCode: "",
+ });
+ });
+
+ it("throws with status and body text when the response is not ok", async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ text: async () => "Not found",
+ });
+ const provider = new GooglePlacesProvider("key-123");
+ await expect(provider.getPlaceDetails("bad-id", "token-1")).rejects.toThrow(
+ "Google Places details failed: 404 Not found",
+ );
+ });
+
+ it("encodes the placeId in the URL", async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ id: "place with spaces" }),
+ });
+ const provider = new GooglePlacesProvider("key-123");
+ await provider.getPlaceDetails("place with spaces", "token-1");
+ expect(mockFetch).toHaveBeenCalledWith(
+ "https://places.googleapis.com/v1/places/place%20with%20spaces?sessionToken=token-1",
+ expect.anything(),
+ );
+ });
+ });
+});
diff --git a/src/lib/providers/ministry-platform/client.test.ts b/src/lib/providers/ministry-platform/client.test.ts
index 4c5321e..4ae7f40 100644
--- a/src/lib/providers/ministry-platform/client.test.ts
+++ b/src/lib/providers/ministry-platform/client.test.ts
@@ -15,6 +15,18 @@ vi.mock('@/lib/providers/ministry-platform/auth/client-credentials', () => ({
getClientCredentialsToken: vi.fn(),
}));
+/**
+ * These tests deliberately drive failure paths, and the code under test logs
+ * them on purpose. Silence the channel so a real, unexpected error still
+ * stands out in the runner output instead of drowning in expected noise.
+ * `mockImplementation` keeps the spy recording, so assertions on what was
+ * logged still work.
+ */
+beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+});
+
describe('MinistryPlatformClient', () => {
let mockGetClientCredentialsToken: ReturnType;
diff --git a/src/lib/providers/ministry-platform/helper.test.ts b/src/lib/providers/ministry-platform/helper.test.ts
index fab6493..2feb593 100644
--- a/src/lib/providers/ministry-platform/helper.test.ts
+++ b/src/lib/providers/ministry-platform/helper.test.ts
@@ -1,6 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { z } from 'zod';
import { MPHelper, MPValidationError } from '@/lib/providers/ministry-platform/helper';
+import type {
+ CommunicationInfo,
+ FileUpdateParams,
+ FileUploadParams,
+ MessageInfo,
+} from '@/lib/providers/ministry-platform/types/provider.types';
/**
* MPHelper Tests
@@ -744,14 +750,17 @@ describe('MPHelper', () => {
describe('Communication Service Methods', () => {
it('should create communication without attachments', async () => {
- const communicationInfo = {
- Author_User_ID: 1,
+ const communicationInfo: CommunicationInfo = {
+ AuthorUserId: 1,
Subject: 'Test Subject',
Body: 'Test body
',
- Start_Date: '2024-01-01',
- From_Contact: 123,
- Reply_to_Contact: 123,
- To_Contact_List: '456,789',
+ StartDate: '2024-01-01',
+ FromContactId: 123,
+ ReplyToContactId: 123,
+ CommunicationType: 'Email',
+ Contacts: [456, 789],
+ IsBulkEmail: false,
+ SendToContactParents: false,
};
const createdCommunication = {
Communication_ID: 1,
@@ -766,14 +775,17 @@ describe('MPHelper', () => {
});
it('should create communication with attachments', async () => {
- const communicationInfo = {
- Author_User_ID: 1,
+ const communicationInfo: CommunicationInfo = {
+ AuthorUserId: 1,
Subject: 'Test with Attachment',
Body: 'See attached
',
- Start_Date: '2024-01-01',
- From_Contact: 123,
- Reply_to_Contact: 123,
- To_Contact_List: '456',
+ StartDate: '2024-01-01',
+ FromContactId: 123,
+ ReplyToContactId: 123,
+ CommunicationType: 'Email',
+ Contacts: [456],
+ IsBulkEmail: false,
+ SendToContactParents: false,
};
const mockFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
const createdCommunication = { Communication_ID: 1, ...communicationInfo };
@@ -810,9 +822,9 @@ describe('MPHelper', () => {
});
it('should send message without attachments', async () => {
- const messageInfo = {
- From: 'sender@example.com',
- To: 'recipient@example.com',
+ const messageInfo: MessageInfo = {
+ FromAddress: { Address: 'sender@example.com', DisplayName: 'Sender' },
+ ToAddresses: [{ Address: 'recipient@example.com', DisplayName: 'Recipient' }],
Subject: 'Test Message',
Body: 'Hello
',
};
@@ -826,9 +838,9 @@ describe('MPHelper', () => {
});
it('should send message with attachments', async () => {
- const messageInfo = {
- From: 'sender@example.com',
- To: 'recipient@example.com',
+ const messageInfo: MessageInfo = {
+ FromAddress: { Address: 'sender@example.com', DisplayName: 'Sender' },
+ ToAddresses: [{ Address: 'recipient@example.com', DisplayName: 'Recipient' }],
Subject: 'Test with Attachment',
Body: 'Please see attached
',
};
@@ -912,7 +924,7 @@ describe('MPHelper', () => {
it('should upload files with upload parameters', async () => {
const mockFile = new File(['data'], 'doc.pdf', { type: 'application/pdf' });
- const uploadParams = { IsDefault: true, Description: 'Main document' };
+ const uploadParams: FileUploadParams = { isDefaultImage: true, description: 'Main document' };
const uploadedFiles = [{ File_ID: 2, File_Name: 'doc.pdf' }];
mockUploadFiles.mockResolvedValueOnce(uploadedFiles);
@@ -930,7 +942,7 @@ describe('MPHelper', () => {
describe('updateFile', () => {
it('should update file metadata only', async () => {
- const updateParams = { Description: 'Updated description' };
+ const updateParams: FileUpdateParams = { description: 'Updated description' };
const updatedFile = { File_ID: 1, File_Name: 'photo.jpg', Description: 'Updated description' };
mockUpdateFile.mockResolvedValueOnce(updatedFile);
@@ -945,7 +957,7 @@ describe('MPHelper', () => {
it('should update file content and metadata', async () => {
const mockFile = new File(['new content'], 'updated.jpg', { type: 'image/jpeg' });
- const updateParams = { Description: 'New photo' };
+ const updateParams: FileUpdateParams = { description: 'New photo' };
const updatedFile = { File_ID: 1, File_Name: 'updated.jpg' };
mockUpdateFile.mockResolvedValueOnce(updatedFile);
diff --git a/src/lib/providers/ministry-platform/provider.test.ts b/src/lib/providers/ministry-platform/provider.test.ts
index cf4aaf9..3ab048e 100644
--- a/src/lib/providers/ministry-platform/provider.test.ts
+++ b/src/lib/providers/ministry-platform/provider.test.ts
@@ -102,6 +102,12 @@ vi.mock('./services', () => ({
}));
import { MinistryPlatformProvider } from './provider';
+import type {
+ CommunicationInfo,
+ FileUpdateParams,
+ FileUploadParams,
+ MessageInfo,
+} from './types/provider.types';
describe('MinistryPlatformProvider', () => {
beforeEach(() => {
@@ -375,14 +381,17 @@ describe('MinistryPlatformProvider', () => {
describe('Communication operations', () => {
it('should delegate createCommunication to CommunicationService', async () => {
- const comm = {
- Author_User_ID: 1,
+ const comm: CommunicationInfo = {
+ AuthorUserId: 1,
Subject: 'Hi',
Body: 'x
',
- Start_Date: '2026-01-01',
- From_Contact: 1,
- Reply_to_Contact: 1,
- To_Contact_List: '2',
+ StartDate: '2026-01-01',
+ FromContactId: 1,
+ ReplyToContactId: 1,
+ CommunicationType: 'Email',
+ Contacts: [2],
+ IsBulkEmail: false,
+ SendToContactParents: false,
};
const created = { Communication_ID: 10, ...comm };
mockCreateCommunication.mockResolvedValueOnce(created);
@@ -416,9 +425,9 @@ describe('MinistryPlatformProvider', () => {
});
it('should delegate sendMessage to CommunicationService', async () => {
- const message = {
- From: 'a@example.com',
- To: 'b@example.com',
+ const message: MessageInfo = {
+ FromAddress: { Address: 'a@example.com', DisplayName: 'A' },
+ ToAddresses: [{ Address: 'b@example.com', DisplayName: 'B' }],
Subject: 'Test',
Body: 'Hi
',
};
@@ -467,9 +476,10 @@ describe('MinistryPlatformProvider', () => {
mockUploadFiles.mockResolvedValueOnce(uploaded);
const provider = MinistryPlatformProvider.getInstance();
- const result = await provider.uploadFiles('Contacts', 1, [file], { IsDefault: true });
+ const uploadParams: FileUploadParams = { isDefaultImage: true };
+ const result = await provider.uploadFiles('Contacts', 1, [file], uploadParams);
- expect(mockUploadFiles).toHaveBeenCalledWith('Contacts', 1, [file], { IsDefault: true });
+ expect(mockUploadFiles).toHaveBeenCalledWith('Contacts', 1, [file], uploadParams);
expect(result).toEqual(uploaded);
});
@@ -479,9 +489,10 @@ describe('MinistryPlatformProvider', () => {
mockUpdateFile.mockResolvedValueOnce(updated);
const provider = MinistryPlatformProvider.getInstance();
- const result = await provider.updateFile(1, file, { Description: 'desc' });
+ const updateParams: FileUpdateParams = { description: 'desc' };
+ const result = await provider.updateFile(1, file, updateParams);
- expect(mockUpdateFile).toHaveBeenCalledWith(1, file, { Description: 'desc' });
+ expect(mockUpdateFile).toHaveBeenCalledWith(1, file, updateParams);
expect(result).toEqual(updated);
});
diff --git a/src/lib/providers/ministry-platform/services/communication.service.test.ts b/src/lib/providers/ministry-platform/services/communication.service.test.ts
index dc93a67..470cfd5 100644
--- a/src/lib/providers/ministry-platform/services/communication.service.test.ts
+++ b/src/lib/providers/ministry-platform/services/communication.service.test.ts
@@ -4,6 +4,18 @@ import type { MinistryPlatformClient } from '@/lib/providers/ministry-platform/c
import type { HttpClient } from '@/lib/providers/ministry-platform/utils/http-client';
import type { CommunicationInfo, MessageInfo } from '@/lib/providers/ministry-platform/types';
+/**
+ * These tests deliberately drive failure paths, and the code under test logs
+ * them on purpose. Silence the channel so a real, unexpected error still
+ * stands out in the runner output instead of drowning in expected noise.
+ * `mockImplementation` keeps the spy recording, so assertions on what was
+ * logged still work.
+ */
+beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+});
+
describe('CommunicationService', () => {
let service: CommunicationService;
let mockClient: MinistryPlatformClient;
diff --git a/src/lib/providers/ministry-platform/services/domain.service.test.ts b/src/lib/providers/ministry-platform/services/domain.service.test.ts
index 1758e9c..90ab7bd 100644
--- a/src/lib/providers/ministry-platform/services/domain.service.test.ts
+++ b/src/lib/providers/ministry-platform/services/domain.service.test.ts
@@ -4,6 +4,18 @@ import type { MinistryPlatformClient } from '@/lib/providers/ministry-platform/c
import type { HttpClient } from '@/lib/providers/ministry-platform/utils/http-client';
import type { DomainInfo } from '@/lib/providers/ministry-platform/types';
+/**
+ * These tests deliberately drive failure paths, and the code under test logs
+ * them on purpose. Silence the channel so a real, unexpected error still
+ * stands out in the runner output instead of drowning in expected noise.
+ * `mockImplementation` keeps the spy recording, so assertions on what was
+ * logged still work.
+ */
+beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+});
+
describe('DomainService', () => {
let service: DomainService;
let mockClient: MinistryPlatformClient;
diff --git a/src/lib/providers/ministry-platform/services/file.service.test.ts b/src/lib/providers/ministry-platform/services/file.service.test.ts
index 780d752..a1be7b6 100644
--- a/src/lib/providers/ministry-platform/services/file.service.test.ts
+++ b/src/lib/providers/ministry-platform/services/file.service.test.ts
@@ -3,6 +3,18 @@ import { FileService } from '@/lib/providers/ministry-platform/services/file.ser
import type { MinistryPlatformClient } from '@/lib/providers/ministry-platform/client';
import type { HttpClient } from '@/lib/providers/ministry-platform/utils/http-client';
+/**
+ * These tests deliberately drive failure paths, and the code under test logs
+ * them on purpose. Silence the channel so a real, unexpected error still
+ * stands out in the runner output instead of drowning in expected noise.
+ * `mockImplementation` keeps the spy recording, so assertions on what was
+ * logged still work.
+ */
+beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+});
+
describe('FileService', () => {
let service: FileService;
let mockClient: MinistryPlatformClient;
diff --git a/src/lib/providers/ministry-platform/services/metadata.service.test.ts b/src/lib/providers/ministry-platform/services/metadata.service.test.ts
index ce1173d..b7c547d 100644
--- a/src/lib/providers/ministry-platform/services/metadata.service.test.ts
+++ b/src/lib/providers/ministry-platform/services/metadata.service.test.ts
@@ -3,6 +3,18 @@ import { MetadataService } from '@/lib/providers/ministry-platform/services/meta
import type { MinistryPlatformClient } from '@/lib/providers/ministry-platform/client';
import type { HttpClient } from '@/lib/providers/ministry-platform/utils/http-client';
+/**
+ * These tests deliberately drive failure paths, and the code under test logs
+ * them on purpose. Silence the channel so a real, unexpected error still
+ * stands out in the runner output instead of drowning in expected noise.
+ * `mockImplementation` keeps the spy recording, so assertions on what was
+ * logged still work.
+ */
+beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+});
+
describe('MetadataService', () => {
let service: MetadataService;
let mockClient: MinistryPlatformClient;
diff --git a/src/lib/providers/ministry-platform/services/procedure.service.test.ts b/src/lib/providers/ministry-platform/services/procedure.service.test.ts
index 151ef66..d35156a 100644
--- a/src/lib/providers/ministry-platform/services/procedure.service.test.ts
+++ b/src/lib/providers/ministry-platform/services/procedure.service.test.ts
@@ -3,6 +3,18 @@ import { ProcedureService } from '@/lib/providers/ministry-platform/services/pro
import type { MinistryPlatformClient } from '@/lib/providers/ministry-platform/client';
import type { HttpClient } from '@/lib/providers/ministry-platform/utils/http-client';
+/**
+ * These tests deliberately drive failure paths, and the code under test logs
+ * them on purpose. Silence the channel so a real, unexpected error still
+ * stands out in the runner output instead of drowning in expected noise.
+ * `mockImplementation` keeps the spy recording, so assertions on what was
+ * logged still work.
+ */
+beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+});
+
describe('ProcedureService', () => {
let service: ProcedureService;
let mockClient: MinistryPlatformClient;
diff --git a/src/lib/providers/ministry-platform/services/table.service.test.ts b/src/lib/providers/ministry-platform/services/table.service.test.ts
index 99f531f..d0fb8c9 100644
--- a/src/lib/providers/ministry-platform/services/table.service.test.ts
+++ b/src/lib/providers/ministry-platform/services/table.service.test.ts
@@ -15,6 +15,18 @@ import type { HttpClient } from '@/lib/providers/ministry-platform/utils/http-cl
* - Error handling for all operations
*/
+/**
+ * These tests deliberately drive failure paths, and the code under test logs
+ * them on purpose. Silence the channel so a real, unexpected error still
+ * stands out in the runner output instead of drowning in expected noise.
+ * `mockImplementation` keeps the spy recording, so assertions on what was
+ * logged still work.
+ */
+beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+});
+
describe('TableService', () => {
let tableService: TableService;
let mockClient: MinistryPlatformClient;
diff --git a/src/lib/providers/ministry-platform/utils/http-client.test.ts b/src/lib/providers/ministry-platform/utils/http-client.test.ts
index b044d39..a872ce9 100644
--- a/src/lib/providers/ministry-platform/utils/http-client.test.ts
+++ b/src/lib/providers/ministry-platform/utils/http-client.test.ts
@@ -17,6 +17,18 @@ import { HttpClient } from '@/lib/providers/ministry-platform/utils/http-client'
const mockFetch = vi.fn();
global.fetch = mockFetch;
+/**
+ * These tests deliberately drive failure paths, and the code under test logs
+ * them on purpose. Silence the channel so a real, unexpected error still
+ * stands out in the runner output instead of drowning in expected noise.
+ * `mockImplementation` keeps the spy recording, so assertions on what was
+ * logged still work.
+ */
+beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+});
+
describe('HttpClient', () => {
const baseUrl = 'https://api.ministryplatform.com';
const mockToken = 'test-access-token-123';
diff --git a/src/proxy.test.ts b/src/proxy.test.ts
index d1e8074..c11efdc 100644
--- a/src/proxy.test.ts
+++ b/src/proxy.test.ts
@@ -58,6 +58,18 @@ function createMockRequest(pathname: string, baseUrl = 'http://localhost:3000')
} as unknown as NextRequest;
}
+/**
+ * These tests deliberately drive failure paths, and the code under test logs
+ * them on purpose. Silence the channel so a real, unexpected error still
+ * stands out in the runner output instead of drowning in expected noise.
+ * `mockImplementation` keeps the spy recording, so assertions on what was
+ * logged still work.
+ */
+beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+});
+
describe('proxy', () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/services/domainTimezoneService.test.ts b/src/services/domainTimezoneService.test.ts
index 1399c0f..4069eac 100644
--- a/src/services/domainTimezoneService.test.ts
+++ b/src/services/domainTimezoneService.test.ts
@@ -152,3 +152,52 @@ describe('DomainTimezoneService', () => {
});
});
});
+
+/**
+ * Remaining guard clauses.
+ *
+ * These are the fail-fast paths that keep a bad time zone or an unparseable MP
+ * datetime from silently drifting to the server's local zone — the exact class
+ * of bug CLAUDE.md rule 16 exists to prevent, so they are pinned explicitly.
+ */
+describe('DomainTimezoneService guard clauses', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('rejects a whitespace-only time zone identifier', () => {
+ expect(() => resolveIanaTimezone(' ')).toThrow('Time zone identifier is required');
+ });
+
+ it('rejects an unparseable MP datetime rather than returning Invalid Date', async () => {
+ const service = freshService();
+
+ await expect(service.parseMpDatetime('not-a-date')).rejects.toThrow(
+ /unable to parse "not-a-date"/,
+ );
+ });
+
+ it('parses a non-wall-clock but valid datetime through the Date fallback', async () => {
+ const service = freshService();
+
+ const parsed = await service.parseMpDatetime('2026-03-14T12:00:00Z');
+
+ expect(parsed.toISOString()).toBe('2026-03-14T12:00:00.000Z');
+ });
+
+ it('clearCache forces the next lookup to refetch domain info', async () => {
+ mockGetDomainInfo.mockResolvedValue({ TimeZoneName: 'Eastern Standard Time' });
+ const service = freshService();
+
+ await service.getMpTimezone();
+ expect(mockGetDomainInfo).toHaveBeenCalledTimes(1);
+
+ // Without clearCache the second call is served from cache.
+ await service.getMpTimezone();
+ expect(mockGetDomainInfo).toHaveBeenCalledTimes(1);
+
+ service.clearCache();
+ await service.getMpTimezone();
+ expect(mockGetDomainInfo).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/services/familyService.test.ts b/src/services/familyService.test.ts
new file mode 100644
index 0000000..908f1e9
--- /dev/null
+++ b/src/services/familyService.test.ts
@@ -0,0 +1,806 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { Household } from "@/lib/dto/family";
+
+const { mockGetTableRecords, mockCreateTableRecords, mockUpdateTableRecords, mockGetDomainInfo } =
+ vi.hoisted(() => ({
+ mockGetTableRecords: vi.fn(),
+ mockCreateTableRecords: vi.fn(),
+ mockUpdateTableRecords: vi.fn(),
+ mockGetDomainInfo: vi.fn(),
+ }));
+
+vi.mock("@/lib/providers/ministry-platform", () => ({
+ MPHelper: class {
+ getTableRecords = mockGetTableRecords;
+ createTableRecords = mockCreateTableRecords;
+ updateTableRecords = mockUpdateTableRecords;
+ getDomainInfo = mockGetDomainInfo;
+ },
+}));
+
+const { mockRequireSecurityRole } = vi.hoisted(() => ({
+ mockRequireSecurityRole: vi.fn(async () => 42),
+}));
+
+vi.mock("@/services/authorizationService", () => ({
+ AuthorizationService: {
+ getInstance: () => ({
+ requireSecurityRole: mockRequireSecurityRole,
+ }),
+ },
+}));
+
+import { FamilyService, PartialSaveError } from "./familyService";
+import { DomainTimezoneService } from "./domainTimezoneService";
+
+function makeHousehold(overrides: Partial = {}): Household {
+ return {
+ householdId: 0,
+ householdName: "Smith",
+ householdPhone: "",
+ congregationId: 1,
+ sourceId: 18,
+ address: {
+ addressId: 0,
+ addressLine1: "123 Main St",
+ addressLine2: null,
+ city: "Springfield",
+ state: "IL",
+ region: null,
+ postalCode: "62701",
+ countryCode: "US",
+ },
+ alternateMailingAddress: {
+ addressId: 0,
+ addressLine1: null,
+ addressLine2: null,
+ city: null,
+ state: null,
+ region: null,
+ postalCode: "",
+ countryCode: null,
+ },
+ seasonStart: null,
+ seasonEnd: null,
+ repeatsAnnually: false,
+ areHeadsMarried: false,
+ members: [],
+ ...overrides,
+ };
+}
+
+describe("FamilyService", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ (FamilyService as any).instance = undefined;
+
+ (DomainTimezoneService as any).instance = null;
+ mockRequireSecurityRole.mockResolvedValue(42);
+ mockGetDomainInfo.mockResolvedValue({ TimeZoneName: "UTC" });
+ });
+
+ it("is a singleton", async () => {
+ const instance1 = await FamilyService.getInstance();
+ const instance2 = await FamilyService.getInstance();
+ expect(instance1).toBe(instance2);
+ });
+
+ describe("searchContacts", () => {
+ it("authorizes a read against Contacts", async () => {
+ mockGetTableRecords.mockResolvedValueOnce([]);
+ const service = await FamilyService.getInstance();
+ await service.searchContacts("smith");
+ expect(mockRequireSecurityRole).toHaveBeenCalledWith({ table: "Contacts", operation: "read" });
+ });
+
+ it("returns [] without querying when trimmed term is shorter than 2 chars", async () => {
+ const service = await FamilyService.getInstance();
+ const result = await service.searchContacts(" a ");
+ expect(result).toEqual([]);
+ expect(mockGetTableRecords).not.toHaveBeenCalled();
+ });
+
+ it("escapes special characters and queries by Display_Name prefix", async () => {
+ mockGetTableRecords.mockResolvedValueOnce([]);
+ const service = await FamilyService.getInstance();
+ await service.searchContacts("O'Brien%_");
+ expect(mockGetTableRecords).toHaveBeenCalledWith(
+ expect.objectContaining({
+ table: "Contacts",
+ filter: "Display_Name LIKE 'O''Brien[%][_]%' AND Contact_Status_ID = 1",
+ }),
+ );
+ });
+
+ it("maps rows preferring Email_Address, falling back to the household address, then empty", async () => {
+ mockGetTableRecords.mockResolvedValueOnce([
+ { Contact_ID: 1, Display_Name: "Smith, John", Email_Address: "john@example.com" },
+ {
+ Contact_ID: 2,
+ Display_Name: "Smith, Jane",
+ Email_Address: null,
+ Household_ID_TABLE_Address_ID_TABLE_Address_Line_1: "123 Main St",
+ },
+ { Contact_ID: 3, Display_Name: "Smith, Joe", Email_Address: null },
+ ]);
+ const service = await FamilyService.getInstance();
+ const result = await service.searchContacts("smith");
+ expect(result).toEqual([
+ { contactId: 1, displayName: "Smith, John", detail: "john@example.com" },
+ { contactId: 2, displayName: "Smith, Jane", detail: "123 Main St" },
+ { contactId: 3, displayName: "Smith, Joe", detail: "" },
+ ]);
+ });
+ });
+
+ describe("resolveContactIdFromPage", () => {
+ it("authorizes a read against the given table", async () => {
+ mockGetTableRecords.mockResolvedValueOnce([{ Resolved_Contact_ID: 5 }]);
+ const service = await FamilyService.getInstance();
+ await service.resolveContactIdFromPage("Event_Participants", "Event_Participant_ID", 10, "Contact_ID");
+ expect(mockRequireSecurityRole).toHaveBeenCalledWith({
+ table: "Event_Participants",
+ operation: "read",
+ });
+ });
+
+ it("throws for a non-positive recordId", async () => {
+ const service = await FamilyService.getInstance();
+ await expect(
+ service.resolveContactIdFromPage("Event_Participants", "Event_Participant_ID", 0, "Contact_ID"),
+ ).rejects.toThrow("Expected positive integer");
+ expect(mockGetTableRecords).not.toHaveBeenCalled();
+ });
+
+ it("throws for an invalid primaryKey column name", async () => {
+ const service = await FamilyService.getInstance();
+ await expect(
+ service.resolveContactIdFromPage("Event_Participants", "bad; name", 10, "Contact_ID"),
+ ).rejects.toThrow("Invalid column name");
+ });
+
+ it("returns null without querying when contactIdField is blank", async () => {
+ const service = await FamilyService.getInstance();
+ const result = await service.resolveContactIdFromPage(
+ "Event_Participants",
+ "Event_Participant_ID",
+ 10,
+ " ",
+ );
+ expect(result).toBeNull();
+ expect(mockGetTableRecords).not.toHaveBeenCalled();
+ });
+
+ it("throws for an invalid contactIdField column name (no _TABLE)", async () => {
+ const service = await FamilyService.getInstance();
+ await expect(
+ service.resolveContactIdFromPage("Event_Participants", "Event_Participant_ID", 10, "bad; name"),
+ ).rejects.toThrow("Invalid column name");
+ });
+
+ it("uses the FK path directly when contactIdField traverses a _TABLE join", async () => {
+ mockGetTableRecords.mockResolvedValueOnce([{ Resolved_Contact_ID: 77 }]);
+ const service = await FamilyService.getInstance();
+ const result = await service.resolveContactIdFromPage(
+ "Event_Participants",
+ "Event_Participant_ID",
+ 10,
+ "Participant_ID_TABLE_Contact_ID",
+ );
+ expect(mockGetTableRecords).toHaveBeenCalledWith(
+ expect.objectContaining({
+ select: "Participant_ID_TABLE_Contact_ID AS Resolved_Contact_ID",
+ filter: "Event_Participant_ID = 10",
+ }),
+ );
+ expect(result).toBe(77);
+ });
+
+ it("returns null when the resolved id is 0", async () => {
+ mockGetTableRecords.mockResolvedValueOnce([{ Resolved_Contact_ID: 0 }]);
+ const service = await FamilyService.getInstance();
+ const result = await service.resolveContactIdFromPage(
+ "Event_Participants",
+ "Event_Participant_ID",
+ 10,
+ "Contact_ID",
+ );
+ expect(result).toBeNull();
+ });
+
+ it("returns null when no row is found", async () => {
+ mockGetTableRecords.mockResolvedValueOnce([]);
+ const service = await FamilyService.getInstance();
+ const result = await service.resolveContactIdFromPage(
+ "Event_Participants",
+ "Event_Participant_ID",
+ 10,
+ "Contact_ID",
+ );
+ expect(result).toBeNull();
+ });
+ });
+
+ describe("getHousehold", () => {
+ it("authorizes a read against Households", async () => {
+ mockGetTableRecords.mockResolvedValueOnce([]);
+ const service = await FamilyService.getInstance();
+ await service.getHousehold(1);
+ expect(mockRequireSecurityRole).toHaveBeenCalledWith({ table: "Households", operation: "read" });
+ });
+
+ it("throws for a non-positive contactId", async () => {
+ const service = await FamilyService.getInstance();
+ await expect(service.getHousehold(-1)).rejects.toThrow("Expected positive integer");
+ });
+
+ it("returns null when the contact has no Household_ID", async () => {
+ mockGetTableRecords.mockResolvedValueOnce([{ Contact_ID: 1, Household_ID: null }]);
+ const service = await FamilyService.getInstance();
+ const result = await service.getHousehold(1);
+ expect(result).toBeNull();
+ // Only the Contacts lookup ran — Promise.all for households/members never fired.
+ expect(mockGetTableRecords).toHaveBeenCalledTimes(1);
+ });
+
+ it("returns null when no contact row is found at all", async () => {
+ mockGetTableRecords.mockResolvedValueOnce([]);
+ const service = await FamilyService.getInstance();
+ const result = await service.getHousehold(1);
+ expect(result).toBeNull();
+ });
+
+ it("returns null when the household row itself is missing", async () => {
+ mockGetTableRecords
+ .mockResolvedValueOnce([{ Contact_ID: 1, Household_ID: 55 }])
+ .mockResolvedValueOnce([]) // households
+ .mockResolvedValueOnce([]); // members
+ const service = await FamilyService.getInstance();
+ const result = await service.getHousehold(1);
+ expect(result).toBeNull();
+ });
+
+ it("maps a full household with members, addresses, participant and donor", async () => {
+ mockGetTableRecords
+ .mockResolvedValueOnce([{ Contact_ID: 1, Household_ID: 55 }])
+ .mockResolvedValueOnce([
+ {
+ Household_ID: 55,
+ Household_Name: "Smith Family",
+ Home_Phone: "555-1234",
+ Congregation_ID: 2,
+ Household_Source_ID: 3,
+ Address_ID: 501,
+ Alternate_Mailing_Address: 502,
+ Season_Start: "2024-01-01",
+ Season_End: "0001-01-01T00:00:00",
+ Repeats_Annually: true,
+ Addr1_Line1: "123 Main St",
+ Addr1_Line2: "Apt 4",
+ Addr1_City: "Springfield",
+ Addr1_State: "IL",
+ Addr1_Postal: "62701",
+ Addr1_Country: "US",
+ Addr2_Line1: "PO Box 9",
+ Addr2_Line2: null,
+ Addr2_City: "Springfield",
+ Addr2_State: "IL",
+ Addr2_Postal: "62701",
+ Addr2_Country: "US",
+ },
+ ])
+ .mockResolvedValueOnce([
+ {
+ Contact_ID: 701,
+ Display_Name: "Smith, John",
+ First_Name: "John",
+ Middle_Name: null,
+ Maiden_Name: null,
+ Last_Name: "Smith",
+ Nickname: null,
+ Prefix_ID: null,
+ Suffix_ID: null,
+ Date_of_Birth: "1980-01-01",
+ Gender_ID: 1,
+ Marital_Status_ID: 2,
+ Mobile_Phone: "555-0000",
+ Email_Address: "john@example.com",
+ Bulk_Email_Opt_Out: false,
+ Contact_Status_ID: 1,
+ Primary_Language_ID: null,
+ Faith_Background_ID: null,
+ Household_Position_ID: 1,
+ Participant_Record: 801,
+ Donor_Record: 901,
+ Participant_Type_ID: 4,
+ Envelope_No: 1001,
+ },
+ {
+ Contact_ID: 702,
+ Display_Name: "Smith, Jane",
+ First_Name: "Jane",
+ Last_Name: "Smith",
+ Household_Position_ID: 2,
+ Participant_Record: null,
+ Donor_Record: null,
+ },
+ ]);
+
+ const service = await FamilyService.getInstance();
+ const result = await service.getHousehold(1);
+
+ expect(result).not.toBeNull();
+ expect(result!.householdId).toBe(55);
+ expect(result!.householdName).toBe("Smith Family");
+ expect(result!.householdPhone).toBe("555-1234");
+ expect(result!.congregationId).toBe(2);
+ expect(result!.sourceId).toBe(3);
+ expect(result!.seasonStart).toBe("2024-01-01");
+ expect(result!.seasonEnd).toBeNull(); // 0001-01-01 sentinel maps to null
+ expect(result!.repeatsAnnually).toBe(true);
+ expect(result!.areHeadsMarried).toBe(false);
+
+ expect(result!.address).toEqual({
+ addressId: 501,
+ addressLine1: "123 Main St",
+ addressLine2: "Apt 4",
+ city: "Springfield",
+ state: "IL",
+ region: null,
+ postalCode: "62701",
+ countryCode: "US",
+ });
+ expect(result!.alternateMailingAddress.addressId).toBe(502);
+
+ expect(result!.members).toHaveLength(2);
+ const [john, jane] = result!.members;
+ expect(john.contactId).toBe(701);
+ expect(john.birthDate).toBe("1980-01-01");
+ expect(john.participant).toEqual({
+ participantId: 801,
+ participantTypeId: 4,
+ notes: null,
+ });
+ expect(john.donorId).toBe(901);
+ expect(john.isDonor).toBe(true);
+ expect(john.envelopeNo).toBe(1001);
+
+ expect(jane.participant).toBeNull();
+ expect(jane.donorId).toBeNull();
+ expect(jane.isDonor).toBe(false);
+ expect(jane.birthDate).toBeNull();
+ expect(jane.firstName).toBe("Jane");
+ });
+ });
+
+ describe("getLookups", () => {
+ it("authorizes a read against Contacts and composes all lookup lists", async () => {
+ mockGetTableRecords
+ .mockResolvedValueOnce([{ Congregation_ID: 1, Congregation_Name: "Main Campus" }])
+ .mockResolvedValueOnce([{ Household_Source_ID: 1, Household_Source: "Web" }])
+ .mockResolvedValueOnce([{ Household_Position_ID: 1, Household_Position: "Head" }])
+ .mockResolvedValueOnce([{ Participant_Type_ID: 4, Participant_Type: "Member" }])
+ .mockResolvedValueOnce([{ Marital_Status_ID: 1, Marital_Status: "Single" }])
+ .mockResolvedValueOnce([{ Prefix_ID: 1, Prefix: "Mr." }])
+ .mockResolvedValueOnce([{ Suffix_ID: 1, Suffix: "Jr." }])
+ .mockResolvedValueOnce([{ Gender_ID: 1, Gender: "Male" }])
+ .mockResolvedValueOnce([{ Contact_Status_ID: 1, Contact_Status: "Active" }])
+ .mockResolvedValueOnce([{ Primary_Language_ID: 1, Primary_Language: "English" }])
+ .mockResolvedValueOnce([{ Faith_Background_ID: 1, Faith_Background: "Christian" }])
+ .mockResolvedValueOnce([
+ { Country_Code: "US", Country: "United States" },
+ { Country_Code: null, Country: "Bad Row" },
+ { Country_Code: "CA", Country: null },
+ ]);
+
+ const service = await FamilyService.getInstance();
+ const lookups = await service.getLookups();
+
+ expect(mockRequireSecurityRole).toHaveBeenCalledWith({ table: "Contacts", operation: "read" });
+ expect(lookups.congregations).toEqual([{ id: 1, name: "Main Campus" }]);
+ expect(lookups.sources).toEqual([{ id: 1, name: "Web" }]);
+ expect(lookups.householdPositions).toEqual([{ id: 1, name: "Head" }]);
+ expect(lookups.participantTypes).toEqual([{ id: 4, name: "Member" }]);
+ expect(lookups.maritalStatuses).toEqual([{ id: 1, name: "Single" }]);
+ expect(lookups.prefixes).toEqual([{ id: 1, name: "Mr." }]);
+ expect(lookups.suffixes).toEqual([{ id: 1, name: "Jr." }]);
+ expect(lookups.genders).toEqual([{ id: 1, name: "Male" }]);
+ expect(lookups.contactStatuses).toEqual([{ id: 1, name: "Active" }]);
+ expect(lookups.primaryLanguages).toEqual([{ id: 1, name: "English" }]);
+ expect(lookups.faithBackgrounds).toEqual([{ id: 1, name: "Christian" }]);
+ expect(lookups.countries).toEqual([{ code: "US", name: "United States" }]);
+ expect(lookups.states.length).toBeGreaterThan(0);
+ expect(lookups.states[0]).toEqual({ code: "AL", name: "Alabama" });
+ });
+ });
+
+ describe("getDefaults", () => {
+ it("returns a copy of the default family values", async () => {
+ const service = await FamilyService.getInstance();
+ const first = service.getDefaults();
+ first.congregationId = 999;
+ const second = service.getDefaults();
+ expect(second.congregationId).toBe(1);
+ expect(mockRequireSecurityRole).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("getNextEnvelopeNumber", () => {
+ it("authorizes a read against Contacts", async () => {
+ mockGetTableRecords.mockResolvedValueOnce([{ Highest: 100 }]);
+ const service = await FamilyService.getInstance();
+ await service.getNextEnvelopeNumber();
+ expect(mockRequireSecurityRole).toHaveBeenCalledWith({ table: "Contacts", operation: "read" });
+ });
+
+ it("returns 1 when there are no existing envelope numbers", async () => {
+ mockGetTableRecords.mockResolvedValueOnce([{ Highest: null }]);
+ const service = await FamilyService.getInstance();
+ const result = await service.getNextEnvelopeNumber();
+ expect(result).toBe(1);
+ });
+
+ it("returns 1 when no row is returned at all", async () => {
+ mockGetTableRecords.mockResolvedValueOnce([]);
+ const service = await FamilyService.getInstance();
+ const result = await service.getNextEnvelopeNumber();
+ expect(result).toBe(1);
+ });
+
+ it("returns highest + 1 when envelopes already exist", async () => {
+ mockGetTableRecords.mockResolvedValueOnce([{ Highest: 5000 }]);
+ const service = await FamilyService.getInstance();
+ const result = await service.getNextEnvelopeNumber();
+ expect(result).toBe(5001);
+ });
+ });
+
+ describe("saveHousehold", () => {
+ it("authorizes an update against Households before anything else", async () => {
+ mockRequireSecurityRole.mockRejectedValueOnce(new Error("Not authorized"));
+ const service = await FamilyService.getInstance();
+ await expect(service.saveHousehold(makeHousehold())).rejects.toThrow("Not authorized");
+ expect(mockCreateTableRecords).not.toHaveBeenCalled();
+ });
+
+ it("creates a new household, skips the blank alternate address, and creates a new member with a new participant and donor", async () => {
+ // 1. upsertAddress(main) -> create
+ mockCreateTableRecords
+ .mockResolvedValueOnce([{ Address_ID: 501 }]) // main address create
+ .mockResolvedValueOnce([{ Household_ID: 601 }]) // household create
+ .mockResolvedValueOnce([{ Contact_ID: 701 }]) // contact create
+ .mockResolvedValueOnce([{ Participant_ID: 801 }]) // participant create
+ .mockResolvedValueOnce([{ Donor_ID: 901 }]); // donor create
+
+ // Envelope conflict check -> no conflict
+ mockGetTableRecords.mockResolvedValueOnce([]);
+
+ const household = makeHousehold({
+ members: [
+ {
+ contactId: -1,
+ firstName: "John",
+ middleName: "",
+ maidenName: "",
+ lastName: "Smith",
+ nickname: "",
+ prefixId: 0,
+ suffixId: 0,
+ birthDate: null,
+ genderId: 0,
+ maritalStatusId: 0,
+ mobilePhone: "",
+ emailAddress: "",
+ bulkEmailOpt: false,
+ envelopeNo: 1001,
+ contactStatusId: 1,
+ primaryLanguageId: null,
+ faithBackgroundId: null,
+ householdPositionId: 1,
+ participant: { participantId: 0, participantTypeId: 4, notes: null },
+ donorId: null,
+ isDonor: true,
+ },
+ {
+ contactId: -2,
+ firstName: " ",
+ middleName: "",
+ maidenName: "",
+ lastName: "",
+ nickname: "",
+ prefixId: 0,
+ suffixId: 0,
+ birthDate: null,
+ genderId: 0,
+ maritalStatusId: 0,
+ mobilePhone: "",
+ emailAddress: "",
+ bulkEmailOpt: false,
+ envelopeNo: null,
+ contactStatusId: 1,
+ primaryLanguageId: null,
+ faithBackgroundId: null,
+ householdPositionId: 1,
+ participant: null,
+ donorId: null,
+ isDonor: false,
+ },
+ ],
+ });
+
+ const service = await FamilyService.getInstance();
+ const progress = await service.saveHousehold(household);
+
+ expect(progress.mainAddressId).toBe(501);
+ expect(progress.altAddressId).toBeNull();
+ expect(progress.householdId).toBe(601);
+ expect(progress.members).toHaveLength(1); // blank second member skipped
+
+ const saved = progress.members[0];
+ expect(saved.tempContactId).toBe(-1);
+ expect(saved.contactId).toBe(701);
+ expect(saved.participantId).toBe(801);
+ expect(saved.donorId).toBe(901);
+ expect(saved.envelopeNo).toBe(1001);
+ expect(saved.envelopeBumped).toBe(false);
+
+ // First create call was for the main address.
+ expect(mockCreateTableRecords.mock.calls[0][0]).toBe("Addresses");
+ expect(mockCreateTableRecords.mock.calls[1][0]).toBe("Households");
+ expect(mockCreateTableRecords.mock.calls[2][0]).toBe("Contacts");
+ expect(mockCreateTableRecords.mock.calls[3][0]).toBe("Participants");
+ expect(mockCreateTableRecords.mock.calls[4][0]).toBe("Donors");
+
+ // $userId comes solely from the authorization gate's return value.
+ for (const call of mockCreateTableRecords.mock.calls) {
+ expect(call[2]).toMatchObject({ $userId: 42 });
+ }
+
+ // Contact gets patched with the new Participant_Record and Donor_Record.
+ expect(mockUpdateTableRecords).toHaveBeenCalledWith(
+ "Contacts",
+ [{ Contact_ID: 701, Participant_Record: 801 }],
+ expect.objectContaining({ $userId: 42 }),
+ );
+ expect(mockUpdateTableRecords).toHaveBeenCalledWith(
+ "Contacts",
+ [{ Contact_ID: 701, Donor_Record: 901 }],
+ expect.objectContaining({ $userId: 42 }),
+ );
+ });
+
+ it("updates an existing household, address, member, participant and donor", async () => {
+ mockUpdateTableRecords.mockResolvedValue(undefined);
+ // Alternate address create (only new address in this scenario)
+ mockCreateTableRecords.mockResolvedValueOnce([{ Address_ID: 502 }]);
+ // Envelope conflict check (excluding existing donor) -> no conflict
+ mockGetTableRecords.mockResolvedValueOnce([]);
+
+ const household = makeHousehold({
+ householdId: 55,
+ address: {
+ addressId: 501,
+ addressLine1: "123 Main St",
+ addressLine2: null,
+ city: "Springfield",
+ state: "IL",
+ region: null,
+ postalCode: "62701",
+ countryCode: "US",
+ },
+ alternateMailingAddress: {
+ addressId: 0,
+ addressLine1: "PO Box 9",
+ addressLine2: null,
+ city: "Springfield",
+ state: "IL",
+ region: null,
+ postalCode: "62701",
+ countryCode: "US",
+ },
+ members: [
+ {
+ contactId: 701,
+ firstName: "John",
+ middleName: "",
+ maidenName: "",
+ lastName: "Smith",
+ nickname: "",
+ prefixId: 0,
+ suffixId: 0,
+ birthDate: "1980-05-01",
+ genderId: 1,
+ maritalStatusId: 1,
+ mobilePhone: "",
+ emailAddress: "",
+ bulkEmailOpt: false,
+ envelopeNo: 1002,
+ contactStatusId: 1,
+ primaryLanguageId: null,
+ faithBackgroundId: null,
+ householdPositionId: 1,
+ participant: { participantId: 801, participantTypeId: 4, notes: null },
+ donorId: 901,
+ isDonor: true,
+ },
+ ],
+ });
+
+ const service = await FamilyService.getInstance();
+ const progress = await service.saveHousehold(household);
+
+ expect(progress.mainAddressId).toBe(501); // existing, updated in place
+ expect(progress.altAddressId).toBe(502); // created
+ expect(progress.householdId).toBe(55);
+ expect(progress.members).toHaveLength(1);
+ expect(progress.members[0]).toEqual({
+ tempContactId: 701,
+ contactId: 701,
+ participantId: 801,
+ donorId: 901,
+ envelopeNo: 1002,
+ envelopeBumped: false,
+ });
+
+ expect(mockUpdateTableRecords).toHaveBeenCalledWith(
+ "Addresses",
+ [expect.objectContaining({ Address_ID: 501 })],
+ expect.objectContaining({ partial: true, $userId: 42 }),
+ );
+ expect(mockUpdateTableRecords).toHaveBeenCalledWith(
+ "Households",
+ [expect.objectContaining({ Household_ID: 55 })],
+ expect.objectContaining({ partial: true, $userId: 42 }),
+ );
+ expect(mockUpdateTableRecords).toHaveBeenCalledWith(
+ "Participants",
+ [{ Participant_ID: 801, Participant_Type_ID: 4 }],
+ expect.objectContaining({ $userId: 42 }),
+ );
+ expect(mockUpdateTableRecords).toHaveBeenCalledWith(
+ "Donors",
+ [{ Donor_ID: 901, Envelope_No: 1002 }],
+ expect.objectContaining({ $userId: 42 }),
+ );
+
+ const donorConflictCall = mockGetTableRecords.mock.calls.find(
+ (c) => c[0].table === "Donors" && c[0].select === "Donor_ID",
+ );
+ expect(donorConflictCall![0].filter).toBe("Envelope_No = 1002 AND Donor_ID <> 901");
+ });
+
+ it("bumps the envelope number when the requested one conflicts, and reports envelopeBumped", async () => {
+ mockCreateTableRecords
+ .mockResolvedValueOnce([{ Household_ID: 601 }])
+ .mockResolvedValueOnce([{ Contact_ID: 701 }])
+ .mockResolvedValueOnce([{ Donor_ID: 901 }]);
+
+ mockGetTableRecords
+ .mockResolvedValueOnce([{ Donor_ID: 55 }]) // conflict on requested 1001
+ .mockResolvedValueOnce([{ Highest: 1050 }]) // getNextEnvelopeNumber -> 1051
+ .mockResolvedValueOnce([]); // no conflict on 1051
+
+ const household = makeHousehold({
+ address: { ...makeHousehold().address, addressLine1: null, postalCode: "" }, // no address content
+ members: [
+ {
+ contactId: -1,
+ firstName: "John",
+ middleName: "",
+ maidenName: "",
+ lastName: "Smith",
+ nickname: "",
+ prefixId: 0,
+ suffixId: 0,
+ birthDate: null,
+ genderId: 0,
+ maritalStatusId: 0,
+ mobilePhone: "",
+ emailAddress: "",
+ bulkEmailOpt: false,
+ envelopeNo: 1001,
+ contactStatusId: 1,
+ primaryLanguageId: null,
+ faithBackgroundId: null,
+ householdPositionId: 1,
+ participant: null,
+ donorId: null,
+ isDonor: true,
+ },
+ ],
+ });
+
+ const service = await FamilyService.getInstance();
+ const progress = await service.saveHousehold(household);
+
+ expect(progress.mainAddressId).toBeNull(); // no address content -> no create call
+ expect(progress.members[0].envelopeNo).toBe(1051);
+ expect(progress.members[0].envelopeBumped).toBe(true);
+ });
+
+ it("throws after 5 failed attempts to find a unique envelope number, wrapped in PartialSaveError", async () => {
+ mockCreateTableRecords
+ .mockResolvedValueOnce([{ Household_ID: 601 }])
+ .mockResolvedValueOnce([{ Contact_ID: 701 }]);
+
+ // Every conflict check returns a conflict; every "next number" call returns the same value.
+ mockGetTableRecords.mockImplementation(async (query: { table: string; select: string }) => {
+ if (query.table === "Donors" && query.select === "Donor_ID") {
+ return [{ Donor_ID: 55 }];
+ }
+ if (query.table === "Donors" && query.select === "MAX(Envelope_No) AS Highest") {
+ return [{ Highest: 1000 }];
+ }
+ return [];
+ });
+
+ const household = makeHousehold({
+ address: { ...makeHousehold().address, addressLine1: null, postalCode: "" },
+ members: [
+ {
+ contactId: -1,
+ firstName: "John",
+ middleName: "",
+ maidenName: "",
+ lastName: "Smith",
+ nickname: "",
+ prefixId: 0,
+ suffixId: 0,
+ birthDate: null,
+ genderId: 0,
+ maritalStatusId: 0,
+ mobilePhone: "",
+ emailAddress: "",
+ bulkEmailOpt: false,
+ envelopeNo: 1001,
+ contactStatusId: 1,
+ primaryLanguageId: null,
+ faithBackgroundId: null,
+ householdPositionId: 1,
+ participant: null,
+ donorId: null,
+ isDonor: true,
+ },
+ ],
+ });
+
+ const service = await FamilyService.getInstance();
+ const error = await service.saveHousehold(household).catch((e) => e);
+
+ expect(error).toBeInstanceOf(PartialSaveError);
+ expect((error as PartialSaveError).message).toContain(
+ "Could not find an available envelope number after 5 attempts",
+ );
+ expect((error as PartialSaveError).progress.householdId).toBe(601);
+ });
+
+ it("wraps a failure creating the main address in a PartialSaveError with the progress so far", async () => {
+ mockCreateTableRecords.mockRejectedValueOnce(new Error("address insert failed"));
+
+ const service = await FamilyService.getInstance();
+ const error = await service.saveHousehold(makeHousehold()).catch((e) => e);
+
+ expect(error).toBeInstanceOf(PartialSaveError);
+ expect((error as PartialSaveError).message).toBe("address insert failed");
+ expect((error as PartialSaveError).progress).toEqual({
+ mainAddressId: null,
+ altAddressId: null,
+ householdId: null,
+ members: [],
+ });
+ expect((error as PartialSaveError).underlying).toBeInstanceOf(Error);
+ });
+
+ it("wraps a non-Error throw's String() representation as the message", async () => {
+ mockCreateTableRecords.mockRejectedValueOnce("raw string failure");
+
+ const service = await FamilyService.getInstance();
+ const error = await service.saveHousehold(makeHousehold()).catch((e) => e);
+
+ expect(error).toBeInstanceOf(PartialSaveError);
+ expect((error as PartialSaveError).message).toBe("raw string failure");
+ expect((error as PartialSaveError).underlying).toBe("raw string failure");
+ });
+ });
+});
diff --git a/src/services/googlePlacesService.test.ts b/src/services/googlePlacesService.test.ts
new file mode 100644
index 0000000..4fa9355
--- /dev/null
+++ b/src/services/googlePlacesService.test.ts
@@ -0,0 +1,146 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+const { mockGetTableRecords } = vi.hoisted(() => ({
+ mockGetTableRecords: vi.fn(),
+}));
+
+vi.mock("@/lib/providers/ministry-platform", () => ({
+ MPHelper: class {
+ getTableRecords = mockGetTableRecords;
+ },
+}));
+
+const { mockAutocomplete, mockGetPlaceDetails, MockGooglePlacesProvider } = vi.hoisted(() => {
+ const mockAutocomplete = vi.fn();
+ const mockGetPlaceDetails = vi.fn();
+ class MockGooglePlacesProvider {
+ apiKey: string;
+ constructor(apiKey: string) {
+ this.apiKey = apiKey;
+ }
+ autocomplete = mockAutocomplete;
+ getPlaceDetails = mockGetPlaceDetails;
+ }
+ return { mockAutocomplete, mockGetPlaceDetails, MockGooglePlacesProvider };
+});
+
+vi.mock("@/lib/providers/google-places", () => ({
+ GooglePlacesProvider: MockGooglePlacesProvider,
+}));
+
+import { GooglePlacesService } from "./googlePlacesService";
+
+describe("GooglePlacesService", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ (GooglePlacesService as any).instance = undefined;
+ vi.unstubAllEnvs();
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("is a singleton", async () => {
+ const instance1 = await GooglePlacesService.getInstance();
+ const instance2 = await GooglePlacesService.getInstance();
+ expect(instance1).toBe(instance2);
+ });
+
+ describe("resolveApiKey / isEnabled", () => {
+ it("resolves the key from MP configuration settings", async () => {
+ mockGetTableRecords.mockResolvedValueOnce([{ Value: "mp-key-123" }]);
+ const service = await GooglePlacesService.getInstance();
+ expect(await service.isEnabled()).toBe(true);
+ expect(mockGetTableRecords).toHaveBeenCalledWith(
+ expect.objectContaining({
+ table: "dp_Configuration_Settings",
+ filter: "Application_Code='COMMON' AND Key_Name='GoogleMapsAPIKey'",
+ }),
+ );
+ });
+
+ it("caches the resolved key so MP is queried only once", async () => {
+ mockGetTableRecords.mockResolvedValueOnce([{ Value: "mp-key-123" }]);
+ const service = await GooglePlacesService.getInstance();
+ await service.isEnabled();
+ await service.isEnabled();
+ expect(mockGetTableRecords).toHaveBeenCalledTimes(1);
+ });
+
+ it("falls back to the env var when MP has no value", async () => {
+ vi.stubEnv("GOOGLE_PLACES_API_KEY", "env-key-456");
+ mockGetTableRecords.mockResolvedValueOnce([{ Value: null }]);
+ const service = await GooglePlacesService.getInstance();
+ expect(await service.isEnabled()).toBe(true);
+ });
+
+ it("falls back to the env var when MP row Value is blank/whitespace", async () => {
+ vi.stubEnv("GOOGLE_PLACES_API_KEY", "env-key-456");
+ mockGetTableRecords.mockResolvedValueOnce([{ Value: " " }]);
+ const service = await GooglePlacesService.getInstance();
+ expect(await service.isEnabled()).toBe(true);
+ });
+
+ it("falls back to the env var when MP returns no rows", async () => {
+ vi.stubEnv("GOOGLE_PLACES_API_KEY", "env-key-456");
+ mockGetTableRecords.mockResolvedValueOnce([]);
+ const service = await GooglePlacesService.getInstance();
+ expect(await service.isEnabled()).toBe(true);
+ });
+
+ it("falls back to the env var when the MP lookup throws", async () => {
+ vi.stubEnv("GOOGLE_PLACES_API_KEY", "env-key-456");
+ mockGetTableRecords.mockRejectedValueOnce(new Error("table permission denied"));
+ const service = await GooglePlacesService.getInstance();
+ expect(await service.isEnabled()).toBe(true);
+ });
+
+ it("resolves to disabled (null) when neither MP nor env provide a key", async () => {
+ mockGetTableRecords.mockResolvedValueOnce([]);
+ const service = await GooglePlacesService.getInstance();
+ expect(await service.isEnabled()).toBe(false);
+ });
+
+ it("treats a blank env var as disabled", async () => {
+ vi.stubEnv("GOOGLE_PLACES_API_KEY", " ");
+ mockGetTableRecords.mockResolvedValueOnce([]);
+ const service = await GooglePlacesService.getInstance();
+ expect(await service.isEnabled()).toBe(false);
+ });
+ });
+
+ describe("getProvider / autocomplete / getPlaceDetails", () => {
+ it("throws a descriptive error when no key is configured", async () => {
+ mockGetTableRecords.mockResolvedValueOnce([]);
+ const service = await GooglePlacesService.getInstance();
+ await expect(service.autocomplete("123 Main", "token")).rejects.toThrow(
+ "Google Places API key is not configured",
+ );
+ });
+
+ it("creates the provider once and reuses it across calls", async () => {
+ mockGetTableRecords.mockResolvedValueOnce([{ Value: "mp-key-123" }]);
+ mockAutocomplete.mockResolvedValue([]);
+ mockGetPlaceDetails.mockResolvedValue({
+ placeId: "p1",
+ formattedAddress: "",
+ addressLine1: "",
+ city: "",
+ state: "",
+ postalCode: "",
+ countryCode: "",
+ });
+
+ const service = await GooglePlacesService.getInstance();
+ await service.autocomplete("123 Main", "token-1");
+ await service.getPlaceDetails("p1", "token-1");
+
+ expect(mockAutocomplete).toHaveBeenCalledWith("123 Main", "token-1");
+ expect(mockGetPlaceDetails).toHaveBeenCalledWith("p1", "token-1");
+ // Only resolved once thanks to caching of both the key and the provider.
+ expect(mockGetTableRecords).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/vitest.config.mts b/vitest.config.mts
new file mode 100644
index 0000000..833d073
--- /dev/null
+++ b/vitest.config.mts
@@ -0,0 +1,71 @@
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+import path from 'path';
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['./src/test-setup.ts'],
+ include: ['src/**/*.{test,spec}.{ts,tsx}'],
+ exclude: ['node_modules', '.next'],
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'json', 'html'],
+ // `include` is load-bearing. Without it the v8 provider only reports
+ // files that some test imported, so an untested file is invisible
+ // rather than counted as 0%. That masked ~1,600 uncovered statements
+ // and inflated the reported number from 48% to 83%.
+ //
+ // Note: this is the Vitest 5 spelling. The old `coverage.all: true`
+ // flag was REMOVED in Vitest 5 — setting it is a type error and does
+ // nothing. `include` is now the only way to widen the denominator.
+ include: ['src/**/*.{ts,tsx}'],
+ /**
+ * Statements and lines only, by deliberate choice.
+ *
+ * Branch and function coverage are noisier — a single added guard
+ * clause or a defensive `?? []` can drop branch coverage below a bar
+ * that nothing is actually wrong with, and a threshold people learn to
+ * override is worse than no threshold. Statements and lines move
+ * predictably with real test work.
+ *
+ * Set below the achieved figures (98.84% statements / 99.70% lines) so
+ * ordinary work has room, but above the 95% target so the suite cannot
+ * quietly slide back under it. Raise these when coverage rises; never
+ * lower them to make a red build green.
+ */
+ thresholds: {
+ statements: 97,
+ lines: 98,
+ },
+ // NOTE: directory exclusions must end in `**`. A bare trailing slash
+ // (e.g. 'src/components/ui/') matches nothing, so the files stay in the
+ // denominator — which is how the shadcn primitives were silently being
+ // counted before.
+ exclude: [
+ 'node_modules/**',
+ '.next/**',
+ 'src/test-setup.ts',
+ '**/*.d.ts',
+ '**/*.{test,spec}.{ts,tsx}',
+ 'src/lib/providers/ministry-platform/models/**', // Auto-generated files
+ 'src/lib/providers/ministry-platform/scripts/**', // Build-time CLI scripts
+ 'src/components/ui/**', // Vendored shadcn/ui primitives
+ '**/loading.tsx', // Declarative skeleton markup
+ 'src/app/layout.tsx', // Root font/metadata shell
+ 'src/app/(web)/layout.tsx', // Font/metadata shell
+ '**/index.ts', // Barrel re-exports
+ '**/types.ts', // Type-only modules
+ ],
+ },
+ },
+ resolve: {
+ alias: {
+ // `import.meta.dirname`, not `__dirname`: this file is ESM (.mts), and
+ // Vite's native config loader has no CommonJS globals.
+ '@': path.resolve(import.meta.dirname, './src'),
+ },
+ },
+});
diff --git a/vitest.config.ts b/vitest.config.ts
deleted file mode 100644
index 52a0268..0000000
--- a/vitest.config.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { defineConfig } from 'vitest/config';
-import react from '@vitejs/plugin-react';
-import path from 'path';
-
-export default defineConfig({
- plugins: [react()],
- test: {
- environment: 'jsdom',
- globals: true,
- setupFiles: ['./src/test-setup.ts'],
- include: ['src/**/*.{test,spec}.{ts,tsx}'],
- exclude: ['node_modules', '.next'],
- coverage: {
- provider: 'v8',
- reporter: ['text', 'json', 'html'],
- exclude: [
- 'node_modules/',
- '.next/',
- 'src/test-setup.ts',
- '**/*.d.ts',
- 'src/lib/providers/ministry-platform/models/', // Auto-generated files
- ],
- },
- },
- resolve: {
- alias: {
- '@': path.resolve(__dirname, './src'),
- },
- },
-});