Tier 3 — Service Layer Tests
Tier 3 tests verify that service functions in src/services/ produce correct results against a real MongoDB schema. Each test runs against a live MongoMemoryReplSet instance — no query mocking. The goal is to catch projection errors, schema validation failures, transaction rollbacks, and off-by-one bugs in array mutations that mock-based tests would miss.
Running Tier 3 tests
npm run test:services
This uses a dedicated Vitest config (vitest.services.config.mjs) and targets src/services/.
Infrastructure
Dedicated Vitest config
Tier 3 uses vitest.services.config.mjs rather than the default vitest.config.mjs. The key differences:
| Setting | Value | Reason |
|---|---|---|
pool | "forks" | Each test file runs in its own Node.js process, preventing mongoose singleton conflicts across files |
hookTimeout | 60000 | MongoMemoryReplSet.create() can take >10 s on first run (binary download + replica-set election) |
env.MONGODB_URI | dummy localhost value | Prevents the service connectDB() helper from blocking on startup; patched in testSetup.js |
server.deps.inline | ["next-intl"] | Forces next-intl through Vite's bundler so resolve.alias can intercept its internal next/navigation import |
resolve.alias["next/navigation"] | stub file | next-intl (transitively imported via @/lib/utils) cannot resolve next/navigation in a plain Node.js environment |
src/services/testSetup.js
Exports three lifecycle helpers used in every test file:
import { startDb, stopDb, clearDb } from "./testSetup";
beforeAll(startDb);
afterAll(stopDb);
beforeEach(clearDb);
startDb
- Disconnects mongoose if it already has an open connection (Vitest module loading can connect to the dummy
MONGODB_URIas a side effect of importing service files). - Creates a single-node
MongoMemoryReplSet. A replica set is required — standalone MongoDB does not support multi-document transactions (session.withTransaction). - Connects mongoose to the replica set URI.
- Patches
globalThis._mongooseCache(the module-level cache insidesrc/services/mongodb.js) so thatconnectDB()inside services short-circuits and returns the already-established in-memory connection instead of trying to reconnect.
stopDb — disconnects mongoose and stops the replica set.
clearDb — calls deleteMany({}) on every collection so each test starts with an empty database.
Why pool: "forks" is required
Each test file calls startDb / stopDb, which starts and stops MongoMemoryReplSet and connects/disconnects the mongoose singleton. Vitest's default thread pool shares the Node.js process — and thus the mongoose singleton — across all files running in parallel. This causes openUri() conflicts ("Can't call openUri() on an active connection with different connection strings") and "Topology is closed" errors on the second file to run. forks mode gives each file an isolated process.
Mock setup
Tier 3 test files mock only the Next.js integration points. The database layer is real.
// @vitest-environment node
import { vi, describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
vi.mock("next/cache", () => ({ revalidateTag: vi.fn(), revalidatePath: vi.fn() }));
vi.mock("@/lib/cache", () => ({ withCache: (fn) => fn, cacheInvalidate: vi.fn() }));
vi.mock("@/lib/url/getHostnameServer", () => ({ default: vi.fn(async () => "test-org") }));
organizationService.test.js adds two more mocks:
// organizationService calls translateRopaDocument when cloning ROPAs
vi.mock("@/lib/text", () => ({ translateRopaDocument: vi.fn(async (doc) => doc) }));
// organizationService calls getOrgMembership (from @clerk/nextjs) for authorization;
// mock it to bypass the server-only Clerk import chain in tests
vi.mock("@/lib/auth/requireOrgAccess", () => ({ getOrgMembership: vi.fn(async () => ({ role: "org:admin" })) }));
What to assert
Every service function returns { isError, data, message }. Tests assert all three paths:
| Path | Pattern |
|---|---|
| Success | expect(result.isError).toBe(false) + shape/value assertions on result.data |
| Expected error | expect(result.isError).toBe(true) + optional expect(result.message).toMatch(...) |
| Invalid input | expect(result.isError).toBe(true) with a predictable message (e.g. "Invalid Ropa ID") |
Test files
ropaService.test.js — 25 tests (1 skipped)
describe block | Tests | Key assertions |
|---|---|---|
createRopaFromJson | 5 | Returns ObjectId; creates with OUs; isError:true when orgShortName / locale / ous missing or invalid |
getRopaByIdRO | 3 | Correct shape returned; isError:true for invalid ObjectId; isError:true for valid-but-missing ID |
getRopaByIdAndUpdateRW | 3 | Applies update and returns updated doc; isError:true for invalid / non-existent ID |
runMutatorOnRopaRW | 4 | Persists when mutator returns { save: true }; discards when mutator returns without save; error string from mutator → isError:true; non-existent ROPA → isError:true |
deleteRopaById | 2 | Deleted doc returned; non-existent → isError:true |
deleteRopasByShortName | 3 | Returns deletion count; 0 when no match; empty shortName → isError:true |
addActivityToOu | 2 | Activity pushed into OU's array; non-existent OU → isError:true |
deleteActivityFromRopa | 3 (1 skipped) | Activity removed from OU; invalid ROPA ID → isError:true; skipped: "not found" detection unreliable on MongoDB 7.x |
Skipped test note: deleteActivityFromRopa > returns isError:true when the activity is not found is skipped because MongoDB 7.x reports modifiedCount: 1 for $pull with the $[] positional-all operator even when no nested element is removed. The service relies on modifiedCount === 0 to detect a missing activity, so this detection is not reliable on MongoDB 7.x.
organizationService.test.js — 26 tests
describe block | Tests | Key assertions |
|---|---|---|
createOrganization | 3 | Saved doc returned; missing shortName → error; duplicate shortName → error with message "Organization short name already exists" |
getOrganizationByShortNameRO | 3 | Found by shortName; not found → isError:true; empty shortName → "Invalid short name" |
getRopaIdsRO | 3 | Empty array when no ROPAs; returns ropa refs with locale; unknown org → isError:true |
deleteOrganizationByShortName | 3 | Deleted doc returned; not found → isError:false with not-found message; empty shortName → isError:true |
createOrganizationEntity | 3 | Auto-incremented organizationId; ID increments per entity; unknown org → isError:true |
updateOrganizationEntity | 3 | Updated fields returned; non-existent ID → isError:true; non-numeric organizationId → isError:true |
deleteOrganizationEntity | 3 | Deleted entity returned; self-org (ID=0) → "Cannot delete self-organization"; non-existent ID → isError:true |
organizationEntityUsageRO | 2 | { isUsed: false, asController: [], asProcessor: [] } when unused; unknown org → isError:true |
updateOrganizationDefaults + getDefaultActivityRO | 3 | Round-trip: save + retrieve defaults; null defaults → isError:true; unknown org → isError:true |
Fixture note: createOrganization requires a fully-populated SELF_ENTITY (the organizationId: 0 entry in the organizations array). Required fields: organizationId, organizationName, organizationNameLong, organizationPostalAddress (with addressLine1, city, postalCode, country), and organizationContacts.
ouService.test.js — 17 tests
describe block | Tests | Key assertions |
|---|---|---|
getOuFromRopaRO | 3 | OU returned without _id; non-existent ouId → not-found error; invalid ROPA ID → "Invalid Ropa ID" |
countOusRO | 3 | Returns correct count; 0 for ROPA with no OUs; invalid ROPA ID → isError:true |
getOuFromActivityIdRO | 3 | Returns OU containing the given activityId; no match → not-found error; invalid ROPA ID → isError:true |
addOuToRopa | 2 | OU appended with correct ouName and ouId; invalid ROPA ID → isError:true |
updateOuToRopa | 2 | ouName and ouColor updated on target OU; invalid ROPA ID → isError:true |
deleteOuFromRopa | 2 | OU removed; remaining OUs intact; invalid ROPA ID → isError:true |
deleteOu | 2 | OU removed from all ROPAs across all locales; unknown org → isError:true |
deleteOu setup: requires a complete org document (created with createOrganization) that has ropas array entries referencing real ROPA ObjectIds. This is needed so getOrganizationByShortNameRO can resolve the ropa refs that deleteOu iterates over.
activityService.test.js — 23 tests
describe block | Tests | Key assertions |
|---|---|---|
getActivityRO | 3 | Returns { activity, ou } pair; not found → "No activity found"; invalid ROPA ID → "Ropa ID is not a valid ObjectId" |
getActivityByIdRO | 3 | Returns activity by activityId; not found → error; invalid ROPA ID → isError:true |
moveActivityToNewOu | 3 | Activity moves from source OU to target OU; old OU not found → error; activity not in old OU → error |
toggleActivityActive | 4 | Cannot activate when compulsory fields are empty; activates when all filled; deactivates active activity; not found → error |
toggleBooleanAttribute | 4 | Toggles false → true and true → false; non-boolean attribute → "not a boolean"; not found → error |
updateSingleValuedAttribute | 3 | Scalar attribute updated; non-existent attribute → "does not exist"; not found → error |
updateMultiValuedAttribute | 3 | Array attribute replaced; non-array attribute → "not multi-valued"; not found → error |
Fixtures: Two activity factories are used:
makeActivity(id, overrides)— minimal activity; compulsory fields (legalbasis,dataCategories, etc.) are absent. Used where activation must fail.makeFullActivity(id, overrides)— all 10 compulsory fields populated. Required fortoggleActivityActivesuccess tests.
contractService.test.js — 28 tests
describe block | Tests | Key assertions |
|---|---|---|
createContractRW | 2 | Returns doc with all fields + _id as string; optional fields default to "" / [] |
getContractsByActivityIdRO | 2 | Returns all contracts whose activityIds includes the target; empty array when none match; _id is string on every returned doc |
getContractsByPartnerIdRO | 2 | Returns all contracts whose partnerIds includes the target; empty array when none match |
updateContractRW | 3 | Updates scalar and array fields; invalid ObjectId → isError:true with message; valid-but-missing ID → "Contract not found" |
deleteContractRW | 3 | Deletes by ID, returns data:null; invalid ObjectId → isError:true; missing ID → "Contract not found" |
deleteContractsByShortNameRW | 3 | Deletes all contracts for an org and returns deletedCount; 0 when org has no contracts; empty shortName → isError:true |
contractActivityLinkRW | 2 | $addToSet appends activityId while preserving existing ones; invalid ObjectId → isError:true |
contractActivityUnlinkRW | 2 | $pull removes the target activityId without touching others; invalid ObjectId → isError:true |
contractPartnerLinkRW | 2 | $addToSet appends partnerId; invalid ObjectId → isError:true |
contractPartnerUnlinkRW | 3 | $pull removes the target partnerId; last-partner guard → isError:true; invalid ObjectId → isError:true |
removeActivityFromAllContractsRW | 2 | Removes activityId from all matching contracts, returns correct modifiedCount; 0 when no contract matched |
removePartnerFromAllContractsRW | 2 | Removes partnerId from all matching contracts, returns correct modifiedCount; 0 when no contract matched |
Mocks: standard Tier 3 set (next/cache, @/lib/cache, @/lib/url/getHostnameServer). No @/lib/text mock needed — contractService does not call translateRopaDocument.
No ROPA / org fixture required. Contracts are self-contained; the seedContract helper calls createContractRW directly. Tests are isolated by clearDb (which deletes all collections including contracts).
Known limitations
deleteActivityFromRopa not-found detection (MongoDB 7.x)
deleteActivityFromRopa uses $pull with the $[] positional-all operator to remove an activity from all OUs in a ROPA:
{ $pull: { "ous.$[].activities": { activityId } } }
On MongoDB 7.x, this operation reports modifiedCount: 1 even when no element is removed from any nested array (the document is considered "matched and processed" regardless). The service cannot distinguish "activity removed" from "activity not found" using modifiedCount alone. The corresponding test is marked it.skip with this explanation.
exportService.test.js — 13 tests
Tests exportOrganization(shortName) which assembles a full org snapshot (organization + ROPA documents + templates) as a plain JSON object.
Mock setup: only the standard pair (next/cache, @/lib/cache). exportService has no auth or text-translation dependencies.
Fixtures: three helpers seed the database directly via Mongoose model constructors (not service functions, since export is read-only and has no corresponding create service):
makeOrg(shortName, ropaId?)— creates anOrganizationModeldocument with a self-entity, optional ROPA reference, and realistic field values.makeRopa(shortName?)— creates aRopaModeldocument with one OU containing one activity ("Payroll",controllers: [0]).makeTemplate(orgId, shortName?)— creates aTemplateModeldocument of type"ropaFrontPage".
describe block | Tests | Key assertions |
|---|---|---|
| success | 12 | Payload shape; exportVersion: 1; ISO exportedAt; correct shortName; _id stripped from org, ROPAs, templates; ropaId stripped from org.ropas[]; orgId stripped from templates; multi-locale ROPA count; OU/activity data preserved; template content preserved; logo base64-encoded; cross-org isolation |
| errors | 3 | Non-existent org → isError:true + /not found/i; empty shortName → isError:true; null shortName → isError:true |
Logo encoding test: seeds an org whose self-entity has organizationLogo: Buffer.from("fake-logo-bytes"), then asserts the exported value is a base64 string that decodes back to the original bytes.
Cross-org isolation test: seeds two orgs (org-a, org-b), exports org-a, and asserts that every entry in data.ropas and data.templates has orgShortName === "org-a".
importService.test.js — 17 tests
Tests importOrganization(json) which orchestrates ROPA creation → org creation → template creation from a JSON export envelope.
Mock setup: full set — next/cache, @/lib/cache, @/lib/url/getHostnameServer, @/lib/text (translateRopaDocument), and @/lib/auth/requireOrgAccess (getOrgMembership). The last two are required because importService delegates to createOrganizationFromJson (in organizationService), which imports these at module level.
Fixture: makeEnvelope(shortName, overrides) builds a complete valid import envelope with one locale ("en"), one OU ("HR"), one activity ("Payroll", controllers: [0]), and no templates. Tests that need templates or multiple locales extend the base envelope inline.
describe block | Tests | Key assertions |
|---|---|---|
| success | 9 | isError:false + { shortName, orgId } returned; org exists in DB; ROPA documents created for each locale; ropaId in org.ropas[] points to created ROPA _id; OU/activity data preserved; templates created in DB + org.templates[] patched with new templateId; base64 logo decoded to Buffer; org metadata preserved (licenseStart, licenseEnd, schemaVersion); multiple org entities including external orgs |
| shortName conflict | 2 | Second import with same shortName → isError:false (replace behavior); new org _id differs from original (confirms delete + recreate) |
| invalid envelope | 6 | null input; wrong exportVersion; missing shortName; empty ropas[]; missing self-org (ID=0); locale declared in org.ropas[] but no matching ROPA document |
No-DB-write assertion: the last invalid-envelope test calls importOrganization with exportVersion: 99, then queries OrganizationModel.findOne({ shortName: "should-not-exist" }) and asserts the result is null. This confirms the organizationCheck gate prevents any writes before any service function is called.
ropaId linking test: after a successful import, fetches both the created org and the created ROPA from the DB and asserts org.ropas[0].ropaId.toString() === ropa._id.toString().
Template linking test: imports an envelope with one template of type "ropaFrontPage", then:
- Fetches the
TemplateModeldoc and assertstypeandhandlebarsare correct. - Fetches the org and asserts
org.templates[0].templateId.toString() === tpl._id.toString().
Adding a new Tier 3 test
- Create
src/services/[name]Service.test.js. - Add
// @vitest-environment nodeas the first line. - Apply the standard mock block (see above). Add
@/lib/textmock if the service callstranslateRopaDocument. Add@/lib/auth/requireOrgAccessmock if the service callsgetOrgMembership(any service usingrunMutatorOnOrgRWdoes — otherwise@clerk/nextjspulls inserver-onlyand the test fails to import). - Import
startDb,stopDb,clearDbfrom./testSetupand wire them tobeforeAll/afterAll/beforeEach. - Seed data using other services (e.g.
createRopaFromJson,createOrganization) — not by inserting raw documents. - Assert
{ isError, data, message }for every path: success, expected error, and invalid input. - Run
npm run test:servicesto confirm.