Skip to main content

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/
Start here

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

CriterionRequired
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:

InputExpected
null, undefinedtrue
"", " "true
[]true
{}true (empty object has no enumerable keys)
new Map(), new Set()true
0, falsefalse (non-empty primitives)
[1], { a: 1 }false
Difference from JSDoc

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).

InputRoleCode
[0]Controller1
[]Processor3
[1, 2]Processor3
[0, 1]Joint Controller2
(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:

accent and case insensitivity
// 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.

_id stripped at every nesting level
toPlain({ _id: "root", child: { _id: "child-id", value: 1 } })
// → { child: { value: 1 } }
primitives and nullish pass through unchanged
toPlain(null) // → null
toPlain(undefined) // → undefined
toPlain(42) // → 42
_id stripped inside arrays
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 / undefined activity → true
  • Field value is "" or " "true
  • Field value is []true (empty array)
  • Field value is nulltrue
  • All 10 fields populated → false
Dependency

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)
BackgroundYIQText 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.

null / empty inputs
getGoogleMapsUrl(null) // → null
getGoogleMapsUrl({}) // → null (no address parts)
special character encoding
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.

"use client" directive

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

InputOutput
{}""
{ 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) when ropa is null, has no ous, or ouId is null.
  • Returns "#000000" when the OU is found but has no ouColor property.
  • Returns the stored ouColor string 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 / undefined ropa → ""
  • {} (no ous) → ""
  • Matching ouIdouName string
  • 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:

FieldValid exampleError key
contractName"ValidName" (8+)contractNameMin
contractDescription20+ charscontractDescriptionMin
contractUrl"" or "https://…"invalidUrl
contractExpirationDate"2026-12-31"contractExpirationRequired / contractExpirationInvalid
unknown fieldanynull (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

FieldBoundaryError key
organizationName< 2 charsshortNameMin
organizationName> 25 charsshortNameMax
organizationWebsitenot a URLinvalidUrl
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

ScenarioAssertion
Minimal valid envelope{ isValid: true }
templates array omittedstill valid
Multiple locales (en + fr)still valid
External org entity (ID > 0)still valid
Activities with valid enum valuesstill valid
Base64-encoded logostill valid
null logostill valid
Valid template documentstill 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

RuleTested boundary
shortName min1-char value
shortName max33-char value
isBlocked typestring "no" instead of boolean
schemaVersion positivevalue 0
highestOuId non-negativevalue -1
organizations[] not empty[]
Self-org (ID=0) requiredall entities have ID > 0
Exactly one isDefault: truenone 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

CheckTest
Locale bijectionlocale in org.ropas[] with no ROPA document
Locale bijectionROPA document not declared in org.ropas[]
Duplicate localestwo ROPA documents with same locale
orgShortName mismatchROPA document references wrong org
orgShortName mismatchtemplate references wrong org
Duplicate ouIdtwo OUs with the same ID in one ROPA
Duplicate activityIdtwo activities with the same ID in one OU
Unknown controller IDID not present in organizations[]
Unknown processor IDID not present in organizations[]
licenseEndlicenseStartequal timestamps
licenseEndlicenseStartend before start
Valid external org as controllerpasses 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:

FileReason excluded
src/lib/utils/logger.jsWraps Pino — external library side-effects
src/lib/utils/imageUtilities.jsRequires sharp (native binary)
src/lib/utils/phoneUtilities.jsWraps libphonenumber-js — integration concern
src/lib/text/textTranslate.jsCalls Azure Translator API
src/lib/text/renderRopa.jsSpawns Chromium via puppeteer-core
src/lib/text/renderTemplate.jsReads files from disk
src/lib/ou/getOuLanguages.jsQueries MongoDB
src/lib/html/* (generators)Require DB + external APIs
src/lib/url/getHostnameServer.jsReads 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

  1. Verify the function has no imports that call APIs, read files, or access the DOM.
  2. Create [name].test.js next to the source file.
  3. Add // @vitest-environment node as the first line.
  4. Import describe, it, expect from "vitest" (or rely on globals: true in config).
  5. Cover: happy path, each error branch, and boundary/edge cases (null, empty, limits).
  6. Run npm run test:unit to 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.