Skip to main content

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:

SettingValueReason
pool"forks"Each test file runs in its own Node.js process, preventing mongoose singleton conflicts across files
hookTimeout60000MongoMemoryReplSet.create() can take >10 s on first run (binary download + replica-set election)
env.MONGODB_URIdummy localhost valuePrevents 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 filenext-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

  1. Disconnects mongoose if it already has an open connection (Vitest module loading can connect to the dummy MONGODB_URI as a side effect of importing service files).
  2. Creates a single-node MongoMemoryReplSet. A replica set is required — standalone MongoDB does not support multi-document transactions (session.withTransaction).
  3. Connects mongoose to the replica set URI.
  4. Patches globalThis._mongooseCache (the module-level cache inside src/services/mongodb.js) so that connectDB() 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:

PathPattern
Successexpect(result.isError).toBe(false) + shape/value assertions on result.data
Expected errorexpect(result.isError).toBe(true) + optional expect(result.message).toMatch(...)
Invalid inputexpect(result.isError).toBe(true) with a predictable message (e.g. "Invalid Ropa ID")

Test files

ropaService.test.js — 25 tests (1 skipped)

describe blockTestsKey assertions
createRopaFromJson5Returns ObjectId; creates with OUs; isError:true when orgShortName / locale / ous missing or invalid
getRopaByIdRO3Correct shape returned; isError:true for invalid ObjectId; isError:true for valid-but-missing ID
getRopaByIdAndUpdateRW3Applies update and returns updated doc; isError:true for invalid / non-existent ID
runMutatorOnRopaRW4Persists when mutator returns { save: true }; discards when mutator returns without save; error string from mutator → isError:true; non-existent ROPA → isError:true
deleteRopaById2Deleted doc returned; non-existent → isError:true
deleteRopasByShortName3Returns deletion count; 0 when no match; empty shortNameisError:true
addActivityToOu2Activity pushed into OU's array; non-existent OU → isError:true
deleteActivityFromRopa3 (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 blockTestsKey assertions
createOrganization3Saved doc returned; missing shortName → error; duplicate shortName → error with message "Organization short name already exists"
getOrganizationByShortNameRO3Found by shortName; not found → isError:true; empty shortName"Invalid short name"
getRopaIdsRO3Empty array when no ROPAs; returns ropa refs with locale; unknown org → isError:true
deleteOrganizationByShortName3Deleted doc returned; not found → isError:false with not-found message; empty shortNameisError:true
createOrganizationEntity3Auto-incremented organizationId; ID increments per entity; unknown org → isError:true
updateOrganizationEntity3Updated fields returned; non-existent ID → isError:true; non-numeric organizationIdisError:true
deleteOrganizationEntity3Deleted entity returned; self-org (ID=0) → "Cannot delete self-organization"; non-existent ID → isError:true
organizationEntityUsageRO2{ isUsed: false, asController: [], asProcessor: [] } when unused; unknown org → isError:true
updateOrganizationDefaults + getDefaultActivityRO3Round-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 blockTestsKey assertions
getOuFromRopaRO3OU returned without _id; non-existent ouId → not-found error; invalid ROPA ID → "Invalid Ropa ID"
countOusRO3Returns correct count; 0 for ROPA with no OUs; invalid ROPA ID → isError:true
getOuFromActivityIdRO3Returns OU containing the given activityId; no match → not-found error; invalid ROPA ID → isError:true
addOuToRopa2OU appended with correct ouName and ouId; invalid ROPA ID → isError:true
updateOuToRopa2ouName and ouColor updated on target OU; invalid ROPA ID → isError:true
deleteOuFromRopa2OU removed; remaining OUs intact; invalid ROPA ID → isError:true
deleteOu2OU 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 blockTestsKey assertions
getActivityRO3Returns { activity, ou } pair; not found → "No activity found"; invalid ROPA ID → "Ropa ID is not a valid ObjectId"
getActivityByIdRO3Returns activity by activityId; not found → error; invalid ROPA ID → isError:true
moveActivityToNewOu3Activity moves from source OU to target OU; old OU not found → error; activity not in old OU → error
toggleActivityActive4Cannot activate when compulsory fields are empty; activates when all filled; deactivates active activity; not found → error
toggleBooleanAttribute4Toggles false → true and true → false; non-boolean attribute → "not a boolean"; not found → error
updateSingleValuedAttribute3Scalar attribute updated; non-existent attribute → "does not exist"; not found → error
updateMultiValuedAttribute3Array 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 for toggleActivityActive success tests.

contractService.test.js — 28 tests

describe blockTestsKey assertions
createContractRW2Returns doc with all fields + _id as string; optional fields default to "" / []
getContractsByActivityIdRO2Returns all contracts whose activityIds includes the target; empty array when none match; _id is string on every returned doc
getContractsByPartnerIdRO2Returns all contracts whose partnerIds includes the target; empty array when none match
updateContractRW3Updates scalar and array fields; invalid ObjectId → isError:true with message; valid-but-missing ID → "Contract not found"
deleteContractRW3Deletes by ID, returns data:null; invalid ObjectId → isError:true; missing ID → "Contract not found"
deleteContractsByShortNameRW3Deletes all contracts for an org and returns deletedCount; 0 when org has no contracts; empty shortNameisError:true
contractActivityLinkRW2$addToSet appends activityId while preserving existing ones; invalid ObjectId → isError:true
contractActivityUnlinkRW2$pull removes the target activityId without touching others; invalid ObjectId → isError:true
contractPartnerLinkRW2$addToSet appends partnerId; invalid ObjectId → isError:true
contractPartnerUnlinkRW3$pull removes the target partnerId; last-partner guard → isError:true; invalid ObjectId → isError:true
removeActivityFromAllContractsRW2Removes activityId from all matching contracts, returns correct modifiedCount; 0 when no contract matched
removePartnerFromAllContractsRW2Removes 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 an OrganizationModel document with a self-entity, optional ROPA reference, and realistic field values.
  • makeRopa(shortName?) — creates a RopaModel document with one OU containing one activity ("Payroll", controllers: [0]).
  • makeTemplate(orgId, shortName?) — creates a TemplateModel document of type "ropaFrontPage".
describe blockTestsKey assertions
success12Payload 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
errors3Non-existent org → isError:true + /not found/i; empty shortNameisError: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 blockTestsKey assertions
success9isError: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 conflict2Second import with same shortNameisError:false (replace behavior); new org _id differs from original (confirms delete + recreate)
invalid envelope6null 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:

  1. Fetches the TemplateModel doc and asserts type and handlebars are correct.
  2. Fetches the org and asserts org.templates[0].templateId.toString() === tpl._id.toString().

Adding a new Tier 3 test

  1. Create src/services/[name]Service.test.js.
  2. Add // @vitest-environment node as the first line.
  3. Apply the standard mock block (see above). Add @/lib/text mock if the service calls translateRopaDocument. Add @/lib/auth/requireOrgAccess mock if the service calls getOrgMembership (any service using runMutatorOnOrgRW does — otherwise @clerk/nextjs pulls in server-only and the test fails to import).
  4. Import startDb, stopDb, clearDb from ./testSetup and wire them to beforeAll/afterAll/beforeEach.
  5. Seed data using other services (e.g. createRopaFromJson, createOrganization) — not by inserting raw documents.
  6. Assert { isError, data, message } for every path: success, expected error, and invalid input.
  7. Run npm run test:services to confirm.