Skip to main content

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

src/lib/mongoose/activityAdd.test.js
// @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

ConcernAssertion
Correct service calledexpect(serviceFn).toHaveBeenCalledWith(...)
cacheInvalidate called on successexpect(cacheInvalidate).toHaveBeenCalledWith(shortName, caller)
cacheInvalidate NOT called on errorexpect(cacheInvalidate).not.toHaveBeenCalled()
revalidatePath called on successexpect(revalidatePath).toHaveBeenCalledWith("/")
Response shape on successexpect(result).toEqual({ isError: false, data: ..., message: ... })
Response shape on errorexpect(result).toEqual({ isError: true, data: null, message: ... })

Test files

Activity actions

FileTestsKey assertions
activityAdd.test.js4ouId coerced to Number; cacheInvalidate called on success, not on service error or exception
activitySave.test.js2revalidatePath called only when !result.isError
activityDelete.test.js4Parallel delete across all ropas; stops on org error; stops on delete error; catches exceptions
activityActiveToggle.test.js5Parallel toggle; "active" vs "inactive" message; org error; toggle error; thrown exception
activityDefaultGet.test.js6activeLocale first; overrides applied; sorted fallback; org error; template error; exception
activityOuChange.test.js1Parallel move to new OU; revalidatePath called

Attribute actions

FileTestsKey assertions
attributeBooleanChange.test.js1Toggle called across all ropas with correct shape
attributeMultiValuedChange.test.js1Update called with newValues array; message includes joined values
attributeSingleValuedChange.test.js1Update called with newValue; message includes value
attributeTextChange.test.js1Update called with attributeValues as newValues; called for every ropa

Organization — defaults & entity

FileTestsKey assertions
organizationDefaultsGet.test.js4Success; special case "No default activity template" → {} not error; other error forwarded; exception caught
organizationDefaultsUpdate.test.js3Service called with { shortName, defaults }; cacheInvalidate on success; not called on error or exception
organizationEntityCreate.test.js5Base64 logo string converted to Buffer; null logo unchanged; cacheInvalidate on success; error forwarded; orgShortName prop overrides hostname
organizationEntityDelete.test.js3cacheInvalidate on success; not on error; orgShortName prop respected
organizationEntityUpdate.test.js5Same logo conversion as Create; cacheInvalidate on success; error forwarded; orgShortName prop
organizationEntityUsageGet.test.js2Direct pass-through to service; orgShortName prop respected
FileTestsKey assertions
organizationGet.test.js2Passes arg directly to service; returns result as-is
organizationLanguageGet.test.js4Env LOCALES parsed; empty LOCALES → error; org error; statuses (notConfigured / isConfigured / isDefault)
organizationLanguagesGet.test.js1Passes shortName + locales to service
organizationLanguageUpdate.test.js1Passes shortName + items to service
organizationLogoClear.test.js5Invalid ID → early error; org error; entity not found; logo cleared via updateOrganizationEntity; orgShortName prop

OU actions

FileTestsKey assertions
ouAdd.test.js5Transaction committed on success; cacheInvalidate called; aborted on org error; aborted on no ropas; aborted when findOneAndUpdate returns null; aborted on exception
ouDelete.test.js3Sequential deletion; org error returned early; stops on first ropa failure
ouGet.test.js3Parallel fetch; locale-keyed result; error on no ropa IDs; error on null data
ouUpdate.test.js3Filters ropas by available ouName locale; calls updateOuToRopa for each match; cacheInvalidate; error on no ropa IDs

Template actions

FileTestsKey assertions
organizationTemplateSave.test.js4Calls 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.

src/app/[locale]/[id]/pdf/route.test.js
// @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)

ConcernAssertion
URL validation failsexpect(res).toBe(errorResponse) (redirect returned directly)
No Turnstile tokenres.status === 200, content-type: text/html, generateTurnstilePage called
Missing site keyres.status === 500, error message matches /not configured/i
Invalid tokenres.status === 403, res.json().error defined
Missing secretres.status === 500, error message matches /temporarily unavailable/i
Activity not foundres.status === 404, error message matches
Generator errorres.status === 500
Successful PDFres.status === 200, content-type: application/pdf, filename in content-disposition
Successful RTFres.status === 200, content-type: application/rtf, attachment disposition
Browser closed on crashmockBrowser.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

FileTestsKey assertions
src/app/[locale]/[id]/pdf/route.test.js12URL 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.js13Same 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

FileReason
organizationTemplateCreate.jsDead code — not imported anywhere
organizationTemplateGet.jsReads template files from disk (fs) + complex multi-service orchestration
organizationTemplatePreviewGet.jsRequires Handlebars rendering + multi-service data fetch
organizationTemplateUpdate.jsImports from ./ barrel, creating circular mock difficulty
organizationLogoUpdate.jsRequires FormData, File, and sharp (native binary via checkImage)
organizationFsGet.jsStatic JSON import — no services to wire; trivial
organizationFromTemplateCreate.jsDeep multi-service orchestration; better tested at Tier 3 or E2E
ropaFsGet.jsReads from filesystem with simulated delay; no service wiring