Tier 2 — Server Action & Route Handler Tests
Tier 2 tests cover two kinds of server-side files:
Server actions (src/lib/mongoose/) are thin wrappers that resolve the org shortname, call a service function, conditionally invalidate the cache, and return a { isError, data, message } response.
Route handlers (src/app/**/route.js) are Next.js API endpoints that orchestrate HTTP-level logic — URL validation, Turnstile token gating, calling HTML generators, launching headless browsers (PDF), converting HTML to RTF, and returning the correct HTTP response shape and headers.
Both types run in Node.js with no real browser and no MongoDB. All external dependencies are mocked.
Running Tier 2 tests
npm run test:actions
This targets src/lib/mongoose/ and runs in Node.js environment (no browser, no MongoDB).
Mock setup
Every Tier 2 test file begins with // @vitest-environment node and applies the following canonical mocks.
Standard mock block
// @vitest-environment node
import { vi, describe, it, expect, beforeEach } from "vitest";
vi.mock("@/services");
vi.mock("@/lib/cache/cacheInvalidate", () => ({ cacheInvalidate: vi.fn() }));
vi.mock("@/lib/url/getHostnameServer", () => ({ default: vi.fn(() => "test-org") }));
When revalidatePath is used instead of cacheInvalidate
Some actions (e.g. activityDelete, activitySave) call revalidatePath("/") from next/cache directly instead of going through cacheInvalidate. Add:
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
When @/lib/cache barrel is imported
ouAdd and ouUpdate import cacheInvalidate from @/lib/cache (the barrel), not from @/lib/cache/cacheInvalidate. The barrel also exports withCache, which is used by the services layer at module load time, so both must be included in the mock:
vi.mock("@/lib/cache", () => ({ cacheInvalidate: vi.fn(), withCache: (fn) => fn }));
When logger is used
organizationDefaultsUpdate and organizationTemplateSave import logger from @/lib/utils. Mock the module to avoid Pino side-effects in tests:
vi.mock("@/lib/utils", () => ({
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() },
}));
When mongoose.startSession is used directly (ouAdd)
ouAdd calls mongoose.startSession() directly to run operations in a transaction. Use importOriginal to preserve real mongoose (Schema, model, models are needed by the models layer) and only override startSession. Combine with an explicit @/services factory — auto-mocking @/services would cause Vitest to load the actual service files, which cascade into model files that call mongoose at import time:
const mockSession = vi.hoisted(() => ({
startTransaction: vi.fn(),
abortTransaction: vi.fn().mockResolvedValue(undefined),
commitTransaction: vi.fn().mockResolvedValue(undefined),
endSession: vi.fn(),
}));
vi.mock("mongoose", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
default: { ...actual.default, startSession: vi.fn().mockResolvedValue(mockSession) },
};
});
// Explicit factory — prevents Vitest loading actual service code (which imports
// model files that call mongoose.Schema at import time).
vi.mock("@/services", () => ({
getOrganizationByShortNameRO: vi.fn(),
addOuToRopa: vi.fn(),
}));
vi.mock("@/models", () => ({
OrganizationModel: { findOneAndUpdate: vi.fn() },
}));
What to assert
| Concern | Assertion |
|---|---|
| Correct service called | expect(serviceFn).toHaveBeenCalledWith(...) |
cacheInvalidate called on success | expect(cacheInvalidate).toHaveBeenCalledWith(shortName, caller) |
cacheInvalidate NOT called on error | expect(cacheInvalidate).not.toHaveBeenCalled() |
revalidatePath called on success | expect(revalidatePath).toHaveBeenCalledWith("/") |
| Response shape on success | expect(result).toEqual({ isError: false, data: ..., message: ... }) |
| Response shape on error | expect(result).toEqual({ isError: true, data: null, message: ... }) |
Test files
Activity actions
| File | Tests | Key assertions |
|---|---|---|
activityAdd.test.js | 4 | ouId coerced to Number; cacheInvalidate called on success, not on service error or exception |
activitySave.test.js | 2 | revalidatePath called only when !result.isError |
activityDelete.test.js | 4 | Parallel delete across all ropas; stops on org error; stops on delete error; catches exceptions |
activityActiveToggle.test.js | 5 | Parallel toggle; "active" vs "inactive" message; org error; toggle error; thrown exception |
activityDefaultGet.test.js | 6 | activeLocale first; overrides applied; sorted fallback; org error; template error; exception |
activityOuChange.test.js | 1 | Parallel move to new OU; revalidatePath called |
Attribute actions
| File | Tests | Key assertions |
|---|---|---|
attributeBooleanChange.test.js | 1 | Toggle called across all ropas with correct shape |
attributeMultiValuedChange.test.js | 1 | Update called with newValues array; message includes joined values |
attributeSingleValuedChange.test.js | 1 | Update called with newValue; message includes value |
attributeTextChange.test.js | 1 | Update called with attributeValues as newValues; called for every ropa |
Organization — defaults & entity
| File | Tests | Key assertions |
|---|---|---|
organizationDefaultsGet.test.js | 4 | Success; special case "No default activity template" → {} not error; other error forwarded; exception caught |
organizationDefaultsUpdate.test.js | 3 | Service called with { shortName, defaults }; cacheInvalidate on success; not called on error or exception |
organizationEntityCreate.test.js | 5 | Base64 logo string converted to Buffer; null logo unchanged; cacheInvalidate on success; error forwarded; orgShortName prop overrides hostname |
organizationEntityDelete.test.js | 3 | cacheInvalidate on success; not on error; orgShortName prop respected |
organizationEntityUpdate.test.js | 5 | Same logo conversion as Create; cacheInvalidate on success; error forwarded; orgShortName prop |
organizationEntityUsageGet.test.js | 2 | Direct pass-through to service; orgShortName prop respected |
Organization — language & logo
| File | Tests | Key assertions |
|---|---|---|
organizationGet.test.js | 2 | Passes arg directly to service; returns result as-is |
organizationLanguageGet.test.js | 4 | Env LOCALES parsed; empty LOCALES → error; org error; statuses (notConfigured / isConfigured / isDefault) |
organizationLanguagesGet.test.js | 1 | Passes shortName + locales to service |
organizationLanguageUpdate.test.js | 1 | Passes shortName + items to service |
organizationLogoClear.test.js | 5 | Invalid ID → early error; org error; entity not found; logo cleared via updateOrganizationEntity; orgShortName prop |
OU actions
| File | Tests | Key assertions |
|---|---|---|
ouAdd.test.js | 5 | Transaction committed on success; cacheInvalidate called; aborted on org error; aborted on no ropas; aborted when findOneAndUpdate returns null; aborted on exception |
ouDelete.test.js | 3 | Sequential deletion; org error returned early; stops on first ropa failure |
ouGet.test.js | 3 | Parallel fetch; locale-keyed result; error on no ropa IDs; error on null data |
ouUpdate.test.js | 3 | Filters ropas by available ouName locale; calls updateOuToRopa for each match; cacheInvalidate; error on no ropa IDs |
Template actions
| File | Tests | Key assertions |
|---|---|---|
organizationTemplateSave.test.js | 4 | Calls updateTemplateRW when template exists; calls createTemplateRW when not; org error; exception caught |
Patterns discovered during implementation
vi.mock("@/services") auto-mock caveat
When auto-mocking @/services, Vitest loads the actual barrel file to determine its export shape. This cascades into ropaService.js → src/models/ropa.js → mongoose, causing mongoose named exports (Schema, model, models) to be called at import time. If mongoose is also mocked, those exports must be present.
Workaround A (used for ouAdd.test.js): provide an explicit vi.mock("@/services", () => ({ ... })) factory listing only the service functions the action under test actually calls. This prevents Vitest from loading the actual service files.
Workaround B (for all other test files): do NOT mock mongoose. The auto-mock of @/services is the only place where mongoose's named exports are required, and without a mongoose mock they remain real — no actual DB connection is made because the service functions themselves are replaced by vi.fn().
@/lib/cache vs @/lib/cache/cacheInvalidate
Two distinct import paths are used across the codebase:
- Most actions:
import { cacheInvalidate } from "@/lib/cache/cacheInvalidate"→ mock@/lib/cache/cacheInvalidate ouAdd,ouUpdate:import { cacheInvalidate } from "@/lib/cache"→ mock@/lib/cache
The barrel @/lib/cache also exports withCache, which services use at import time. Always include it in the mock:
vi.mock("@/lib/cache", () => ({ cacheInvalidate: vi.fn(), withCache: (fn) => fn }));
revalidatePath vs cacheInvalidate
Several actions bypass cacheInvalidate and call revalidatePath("/") directly from next/cache. In those tests, mock next/cache and assert revalidatePath, not cacheInvalidate.
Route handler tests
Route handlers in src/app/**/route.js follow a different pattern from server actions but live in the same Tier 2 bucket: they run in Node.js, mock all external I/O, and assert HTTP-level behavior.
Mock setup (pdf/rtf route handlers)
All vi.mock() calls must be at the top level of the file (not inside beforeEach). Vitest hoists them — any reference to a module-scope variable inside a factory will be undefined at hoist time.
// @vitest-environment node
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
// All mocks at top level — no external variable references in factories
vi.mock("@/lib/url/urlValidateServer", () => ({ default: vi.fn() }));
vi.mock("@/lib/security", () => ({
extractTurnstileToken: vi.fn(),
verifyTurnstileToken: vi.fn(),
}));
vi.mock("@/lib/html", () => ({
generateActivityInformation: vi.fn(),
generateActivityDeclaration: vi.fn(),
generateTurnstilePage: vi.fn(),
}));
vi.mock("@/lib/utils", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
logError: vi.fn(),
}));
// Heavy native dependencies mocked with minimal stubs
vi.mock("@sparticuz/chromium", () => ({
default: { executablePath: vi.fn().mockResolvedValue("/usr/bin/chromium"), args: [] },
}));
vi.mock("playwright-core", () => ({
chromium: { launch: vi.fn() },
}));
vi.mock("pdf-lib", () => ({
PDFDocument: { load: vi.fn() },
}));
After the top-level mocks, import the mocked modules normally and configure return values in beforeEach using vi.mocked():
import urlValidateServer from "@/lib/url/urlValidateServer";
import { chromium as playwrightChromium } from "playwright-core";
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(urlValidateServer).mockResolvedValue({ isValid: true, errorResponse: null });
vi.mocked(playwrightChromium.launch).mockResolvedValue({
newPage: vi.fn().mockResolvedValue({
setContent: vi.fn(),
pdf: vi.fn().mockResolvedValue(Buffer.from("fake-pdf")),
}),
close: vi.fn(),
});
// ...
});
What to assert (route handlers)
| Concern | Assertion |
|---|---|
| URL validation fails | expect(res).toBe(errorResponse) (redirect returned directly) |
| No Turnstile token | res.status === 200, content-type: text/html, generateTurnstilePage called |
| Missing site key | res.status === 500, error message matches /not configured/i |
| Invalid token | res.status === 403, res.json().error defined |
| Missing secret | res.status === 500, error message matches /temporarily unavailable/i |
| Activity not found | res.status === 404, error message matches |
| Generator error | res.status === 500 |
| Successful PDF | res.status === 200, content-type: application/pdf, filename in content-disposition |
| Successful RTF | res.status === 200, content-type: application/rtf, attachment disposition |
| Browser closed on crash | mockBrowser.close called even when page.pdf() rejects |
PDF feature flag caveat
pdfFeatureEnabled is a module-level constant evaluated at import time:
const pdfFeatureEnabled =
process.env.NODE_ENV !== "production" ||
process.env.ENABLE_SERVER_PDF === "true";
The 503 branch cannot be triggered in tests without vi.resetModules() + dynamic re-import, because NODE_ENV is always "test" when Vitest runs. The logic is simple enough to be covered by code review rather than a test.
Route handler test files
| File | Tests | Key assertions |
|---|---|---|
src/app/[locale]/[id]/pdf/route.test.js | 12 | URL validation redirect; Turnstile HTML page (info & declaration); missing site key 500; invalid token 403; missing secret 500; activity not found 404; generator error 500; PDF headers + filename; declaration PDF headers + filename; browser closes on crash; PDF metadata set |
src/app/[locale]/[id]/rtf/route.test.js | 13 | Same Turnstile paths; activity not found 404; generator error 500; RTF headers + filename (info & declaration); html2rtfBuffer called with correct article title; unexpected throw → 500 |
Files not covered at Tier 2
| File | Reason |
|---|---|
organizationTemplateCreate.js | Dead code — not imported anywhere |
organizationTemplateGet.js | Reads template files from disk (fs) + complex multi-service orchestration |
organizationTemplatePreviewGet.js | Requires Handlebars rendering + multi-service data fetch |
organizationTemplateUpdate.js | Imports from ./ barrel, creating circular mock difficulty |
organizationLogoUpdate.js | Requires FormData, File, and sharp (native binary via checkImage) |
organizationFsGet.js | Static JSON import — no services to wire; trivial |
organizationFromTemplateCreate.js | Deep multi-service orchestration; better tested at Tier 3 or E2E |
ropaFsGet.js | Reads from filesystem with simulated delay; no service wiring |