Tier 1 — Unit Tests
Tier 1 is the smoke test layer. These tests run entirely in a Node.js process with no mocking, no database, and no DOM. They are the fastest tests in the suite and the first to write when adding a new pure function.
Running Tier 1 tests
npm run test:unit
This targets the following directories:
src/lib/schemas/
src/lib/utils/
src/lib/text/
src/lib/url/
src/lib/ou/
If the Vitest pipeline is newly configured, run test:unit first as a smoke test. A passing run with zero tests confirms that the alias resolver, JSX transform, and config are all wired correctly before writing any test logic.
Conventions
Environment directive
Every Tier 1 test file begins with:
// @vitest-environment node
This overrides the default jsdom environment configured in vitest.config.mjs. Node is faster and sufficient for pure functions that make no DOM calls.
File co-location
Test files live next to the source file they test:
src/lib/utils/isValueEmpty.js
src/lib/utils/isValueEmpty.test.js
What belongs in Tier 1
| Criterion | Required |
|---|---|
| Zero external API calls | ✅ |
| Zero database access | ✅ |
| Zero DOM / browser globals | ✅ |
No vi.mock() calls | ✅ |
| Function is exported | ✅ |
If a function requires mocking to test, it belongs in Tier 2 (server actions) or Tier 3 (services).
Test files
src/lib/utils/isValueEmpty.test.js
Tests the isValueEmpty utility that determines whether a value should be treated as empty in form and validation logic.
Key behaviours verified:
| Input | Expected |
|---|---|
null, undefined | true |
"", " " | true |
[] | true |
{} | true (empty object has no enumerable keys) |
new Map(), new Set() | true |
0, false | false (non-empty primitives) |
[1], { a: 1 } | false |
The JSDoc comment in the source states that {} returns false, but the implementation returns true. The test reflects actual behaviour.
src/lib/utils/getRoleFromControllers.test.js
Tests role derivation from the controllers array. The array uses 0 as the sentinel value for "self" (the current organization).
| Input | Role | Code |
|---|---|---|
[0] | Controller | 1 |
[] | Processor | 3 |
[1, 2] | Processor | 3 |
[0, 1] | Joint Controller | 2 |
| (no argument) | Controller (default [0]) | 1 |
src/lib/utils/findAttributeInRopa.test.js
Tests attribute search across a ROPA document's nested OU/activity structure. The internal normalizeText helper is tested indirectly through findAttributeInRopa.
Normalisation tested:
// Source value: "Données personnelles"
// Search text: "Donnees personnelles"
// → matches ✓
// Source value: "MARKETING"
// Search text: "marketing"
// → matches ✓
// Source value: "HR & Payroll"
// Search text: "HR Payroll" (special chars stripped)
// → matches ✓
Return shape on match:
{ exists: true, ouId: 10, activityId: 1 }
Return shape on miss or null input:
{ exists: false, ouId: null, activityId: null }
src/lib/utils/toPlain.test.js
Tests the deep serializer used to strip _id fields from Mongoose documents before passing data to Client Components.
toPlain({ _id: "root", child: { _id: "child-id", value: 1 } })
// → { child: { value: 1 } }
toPlain(null) // → null
toPlain(undefined) // → undefined
toPlain(42) // → 42
toPlain([{ _id: "1", name: "a" }, { _id: "2", name: "b" }])
// → [{ name: "a" }, { name: "b" }]
src/lib/utils/hasEmptyCompulsoryFields.test.js
Tests whether an activity is missing any of the 10 compulsory ROPA fields defined in src/constants/ropaAttributes.js.
activityName · purposeShort · purposeLong · legalbasis · dataCategories
datasubjectCategories · activityCategories · dataOrigin · timeLimit · securityLevel
Edge cases covered:
null/undefinedactivity →true- Field value is
""or" "→true - Field value is
[]→true(empty array) - Field value is
null→true - All 10 fields populated →
false
hasEmptyCompulsoryFields imports from @/constants. The test file lives in src/lib/utils/, not in src/constants/. Constants are treated as stable data — they have no test file of their own.
src/lib/text/getTextColor.test.js
Tests the YIQ brightness formula that picks black or white text for a given background hex colour.
YIQ formula:
yiq = (R × 299 + G × 587 + B × 114) / 1000
yiq ≥ 128 → black text (#000000)
yiq < 128 → white text (#FFFFFF)
| Background | YIQ | Text colour |
|---|---|---|
#FFFFFF (white) | 255 | #000000 |
#000000 (black) | 0 | #FFFFFF |
#FF0000 (red) | 76 | #FFFFFF |
#00FF00 (green) | 150 | #000000 |
#0000FF (blue) | 29 | #FFFFFF |
#FFFF00 (yellow) | 226 | #000000 |
Additional cases tested: 3-digit shorthand (#FFF), hex without # prefix, invalid input (fallback to #000000).
src/lib/text/getGoogleMapsUrl.test.js
Tests the Google Maps URL builder that joins address parts and URL-encodes the query.
getGoogleMapsUrl(null) // → null
getGoogleMapsUrl({}) // → null (no address parts)
getGoogleMapsUrl({ addressLine1: "Rue de l'Église", city: "Paris" })
// → "https://www.google.com/maps/search/?api=1&query=Rue%20de%20l'%C3%89glise%2C%20Paris"
Undefined fields are filtered before joining — the test verifies that "undefined" never appears in the output URL.
src/lib/url/toQueryString.test.js
Tests all four URL helpers exported from toQueryString.js.
The source file has "use client" at the top. This directive is a Next.js marker and has no effect in a Node.js test environment. URLSearchParams is globally available in Node 18+.
toQueryString
| Input | Output |
|---|---|
{} | "" |
{ a: "1" } | "?a=1" |
{ a: null } | "" |
{ a: undefined } | "" |
{ a: "" } | "" |
{ page: 2 } (number) | "?page=2" |
buildUrl
buildUrl("/home", { tab: "activities" }) // → "/home?tab=activities"
buildUrl(undefined, {}) // → "/"
mergeParams
mergeParams({ a: "old" }, { a: "new" }) // → { a: "new" } right overrides left
mergeParams({ a: "1" }, { a: null }) // → {} null removed
appendParams — convenience wrapper: buildUrl(path, mergeParams(base, extra)).
src/lib/ou/getOuColor.test.js
Tests color lookup for an Organisational Unit within a ROPA document.
- Returns
"#000000"(default) whenropaisnull, has noous, orouIdisnull. - Returns
"#000000"when the OU is found but has noouColorproperty. - Returns the stored
ouColorstring when the OU is found.
src/lib/ou/getOuName.test.js
Tests name lookup for an Organisational Unit. Uses optional chaining internally, so all null/undefined inputs are handled without throwing.
null/undefinedropa →""{}(noous) →""- Matching
ouId→ouNamestring - Non-matching
ouId→""
src/lib/schemas/contractSchema.test.js
Tests the Zod-based contract field validator and full-object validator. Error messages are i18n keys (e.g. "contractNameMin"), not translated strings.
validateContractField — tested fields:
| Field | Valid example | Error key |
|---|---|---|
contractName | "ValidName" (8+) | contractNameMin |
contractDescription | 20+ chars | contractDescriptionMin |
contractUrl | "" or "https://…" | invalidUrl |
contractExpirationDate | "2026-12-31" | contractExpirationRequired / contractExpirationInvalid |
| unknown field | any | null (no-op) |
validateContract — verifies the full object parser returns {} on success and a { fieldName: errorKey } map on failure. contractActivities is optional in the full-object schema (a contract can be saved without associated activities); when provided it still enforces the array type and minimum-1-item rule.
src/lib/schemas/organizationSchema.test.js
Tests field validation, address validation, and contact validation. Error values are i18n keys throughout.
validateOrganizationField
| Field | Boundary | Error key |
|---|---|---|
organizationName | < 2 chars | shortNameMin |
organizationName | > 25 chars | shortNameMax |
organizationWebsite | not a URL | invalidUrl |
organizationWebsite | "" | null (optional) |
validateAddress — additionally tests ISO 3166-1 alpha-2 country validation:
validateAddress({ ...address, country: "BEL" }) // → { country: "countryLength" }
validateAddress({ ...address, country: "XX" }) // → { country: "countryInvalid" }
validateAddress({ ...address, country: "BE" }) // → {}
validateContact — tests contactFirstname, contactLastname, and contactRole minimum length errors.
src/lib/schemas/importSchema.test.js
Tests the organizationCheck() function — the two-pass Zod + cross-document validator that gates all JSON import operations. Error messages are plain English strings (not i18n keys). 60 tests across 7 describe blocks.
Fixture approach: a single validEnvelope constant is deep-cloned via base() before each test case, then mutated to exercise one specific failure path. This keeps tests independent and makes the intended deviation obvious at a glance.
happy path — 8 tests
| Scenario | Assertion |
|---|---|
| Minimal valid envelope | { isValid: true } |
templates array omitted | still valid |
| Multiple locales (en + fr) | still valid |
| External org entity (ID > 0) | still valid |
| Activities with valid enum values | still valid |
| Base64-encoded logo | still valid |
null logo | still valid |
| Valid template document | still valid |
envelope — 7 tests
Verifies top-level structure: null / non-object input, wrong exportVersion, missing exportVersion, invalid exportedAt format, empty ropas[], missing organization.
organization fields — 11 tests
| Rule | Tested boundary |
|---|---|
shortName min | 1-char value |
shortName max | 33-char value |
isBlocked type | string "no" instead of boolean |
schemaVersion positive | value 0 |
highestOuId non-negative | value -1 |
organizations[] not empty | [] |
| Self-org (ID=0) required | all entities have ID > 0 |
Exactly one isDefault: true | none set / two set |
organization.ropas[] not empty | [] |
organization entities — 6 tests
organizationName / organizationNameLong minimum length, invalid hex color, valid hex color accepted, empty organizationContacts[], address missing city, address country not 2 uppercase letters.
contacts — 6 tests
Empty contactEmails[], invalid email address, multiple isPrimary emails, first name too short, role shorter than 3 chars, multiple isPrimary phone numbers.
ROPA documents — 7 tests
3-letter locale, uppercase locale, invalid legalbasis enum value, invalid dataCategories enum value, invalid securityLevel enum value, empty controllers[], empty ouName.
templates — 3 tests
Empty handlebars, invalid type enum value, invalid template locale.
cross-document checks — 12 tests
| Check | Test |
|---|---|
| Locale bijection | locale in org.ropas[] with no ROPA document |
| Locale bijection | ROPA document not declared in org.ropas[] |
| Duplicate locales | two ROPA documents with same locale |
orgShortName mismatch | ROPA document references wrong org |
orgShortName mismatch | template references wrong org |
Duplicate ouId | two OUs with the same ID in one ROPA |
Duplicate activityId | two activities with the same ID in one OU |
| Unknown controller ID | ID not present in organizations[] |
| Unknown processor ID | ID not present in organizations[] |
licenseEnd ≤ licenseStart | equal timestamps |
licenseEnd ≤ licenseStart | end before start |
| Valid external org as controller | passes when ID exists in organizations[] |
What is NOT in Tier 1
The following files in the same directories were excluded because they require mocking or external processes:
| File | Reason excluded |
|---|---|
src/lib/utils/logger.js | Wraps Pino — external library side-effects |
src/lib/utils/imageUtilities.js | Requires sharp (native binary) |
src/lib/utils/phoneUtilities.js | Wraps libphonenumber-js — integration concern |
src/lib/text/textTranslate.js | Calls Azure Translator API |
src/lib/text/renderRopa.js | Spawns Chromium via puppeteer-core |
src/lib/text/renderTemplate.js | Reads files from disk |
src/lib/ou/getOuLanguages.js | Queries MongoDB |
src/lib/html/* (generators) | Require DB + external APIs |
src/lib/url/getHostnameServer.js | Reads Next.js request headers |
These are tested at Tier 2 (server actions), Tier 3 (services), or Tier 4 (components) where mocking infrastructure is in place.
Adding a new Tier 1 test
- Verify the function has no imports that call APIs, read files, or access the DOM.
- Create
[name].test.jsnext to the source file. - Add
// @vitest-environment nodeas the first line. - Import
describe,it,expectfrom"vitest"(or rely onglobals: truein config). - Cover: happy path, each error branch, and boundary/edge cases (null, empty, limits).
- Run
npm run test:unitto confirm.
ESLint — unused destructured variables
When a test needs to omit a property from an object (e.g. to test validation without an optional field), the idiomatic pattern is a destructuring rest:
const { contractUrl: _contractUrl, ...withoutUrl } = validContract;
expect(validateContract(withoutUrl)).toEqual({});
ESLint's no-unused-vars rule is configured in eslint.config.mjs with:
"no-unused-vars": ["warn", { varsIgnorePattern: "^_", argsIgnorePattern: "^_" }]
Any variable or parameter whose name starts with _ is silently ignored. Use this prefix whenever you extract a property solely to exclude it from the rest spread — no eslint-disable comment needed.