Factory API Endpoint Tests
Before this page, see From CURL to Jest and Expanding the coverage, and once your suite is written follow the Post-Creation Workflow.
1. Factory Test Suites
A factory is a test suite that is written once and runs on all four APIs. It lives in tests/lib/ and exports a single function like buildSideboxBehaviorSuite(config).
The factory does not run on its own. It only builds the tests when someone calls it with a config.
Here is the list of the factories we have today:
metadata-contract.suite.js
serialization-groups.suite.js
nested-object-contract.suite.js
sidebox-behavior.suite.js
full-crud-as-admin.suite.js
negative.suite.js
query-string.suite.js
metadata.suite.js
status.suite.jsWe are doing it this way because all four APIs (ticket, config,
account-api, PHPR) come from the same backend and are consumed by the same
CRUD generator, so they are supposed to
behave the same. Writing the same test four times is how they slowly stop
matching.
2. Wrapper Files
The file Jest actually picks up is tests/<api>/<name>.test.js. It has no assertions, it only passes the config of that API to the factory.
A wrapper is around 15 to 20 lines and looks like this (tests/ticket/sidebox-behavior.test.js):
const metadataMap = require("../../cache/ticket/metadata-cache.json");
const { getOAuthToken } = require("../../scripts/login-cas.js");
const {
buildSideboxBehaviorSuite,
} = require("../lib/sidebox-behavior.suite.js");
require("dotenv").config();
buildSideboxBehaviorSuite({
apiName: "ticket",
metadataMap,
baseUrl: process.env.TICKET_BASE_URL,
apiVersion: "api/v1",
getToken: () =>
getOAuthToken(
process.env.TICKET_LOGIN_URL,
process.env.TICKET_USERNAME,
process.env.TICKET_PASSWORD,
),
});The same file with a different env prefix is what runs the suite on config, account-api and PHPR.
Snapshots stay with the wrapper (tests/<api>/__snapshots__/) because every API has its own data.
3. Config Keys
apiName, metadataMap, baseUrl, apiVersion, getToken,
loginUrl, username, password, apiLabel, suiteTitle, testSuiteCode| Key | What it is |
|---|---|
metadataMap | The metadata cache of that API. The factory loops on it, so this is what decides which entities get tested. |
baseUrl | Comes from .env, one per API (TICKET_BASE_URL, CONFIG_BASE_URL, ACCOUNT_BASE_URL, PHPR_BASE_URL), with no trailing /. |
apiVersion | The version segment of the URL. It defaults to api/v1 and PHPR passes open-api/v3. Every URL inside the factory is built as ${baseUrl}/${apiVersion}/…, so this single key is what makes the same suite work on both versions. |
getToken | A function, not a token. |
loginUrl, username, password | The older form of the same thing. |
apiName, apiLabel, suiteTitle, testSuiteCode | Only for naming. |
getToken is a function because the APIs don’t log in the same way (OAuth client_credentials for most of them, login_check for PHPR), so each wrapper plugs in its own login and the factory just awaits it in beforeAll.
metadata-contract, serialization-groups and nested-object-contract still take the three credentials (loginUrl, username, password) and call getOAuthToken themselves. Both work, but for anything new use getToken.
The naming keys are separate keys because the test titles are part of the snapshot key, more about it in point 8.
4. Metadata Cache
Every factory reads cache/<api>/metadata-cache.json. It is keyed by the entity class the same way the metadata API returns it: App\Entity\EventSubscriber.
| Key | What it is used for |
|---|---|
entityName | The resource name (event_subscribers). This is what goes in the URL, so it is the same source of truth as resourceName in the metadata API. |
body, bodyModified | The POST and PUT/PATCH payloads used by the CRUD suite. |
fullBody, fullBodyModified | The same with all the writable fields, not only the required ones. |
sanitized | The list of keys we replace with [SANITIZED] before snapshotting, so ids, dates and nested objects don’t fail the snapshot on every run. |
types, nullable, swaggerQS | Used by the query-string suite to decide which filter to try on which property. |
implementedInterfaces, treeViewable | Used for the interface driven behaviour (translatable, treeview) — see Entity Interfaces & Behaviors. |
To regenerate it: npm run build:metadata:<api>, or npm run build:metadata:all.
Don’t edit the cache by hand, it changes which tests run and what they expect.
5. Contract Factories
These ones check the metadata format, means whether the API is declaring things the way the generator needs them.
metadata-contract.suite.js
Checks /metadata/entity/{class}:
- The top level keys are present and of the right type.
class_object_datahas [moduleName, objectName, role_prefix, tableName, parent, implemented_interfaces, used_traits].- Every field has a
fieldNameand atypethe generator knows. - Relation fields have
targetEntity/targetEntityResourceName/targetEntityResourceIri. - Every
apiFiltersentry has.propertiesand anOrderFilterexists. operationsentries have.methodand.url.- The property collections make sense together (
required ⊆ fields,writable_required ⊆ writableand⊆ required,fields ⊆ displayable,hidden ∩ fields = ∅).
It also checks /metadata/entities is not empty.
serialization-groups.suite.js
Checks the serialization filter. ?requiredOnly=true must return a subset of the full field set. It can return less fields, it can never return a field the full response doesn’t have. The listing / show / dropdown field sets are snapshotted so they don’t change without us noticing.
See Serialization Groups for what the groups are.
nested-object-contract.suite.js
Checks the response standard. Every listing item and every nested relation object inside it has to expose [@id, id, slug, uniqueId, toString, shortcode], plus title when the target entity is translatable. It is called from tests/<api>/nested-entities.test.js.
6. Behaviour Factories
These ones call the real endpoints and check the API actually does what the metadata says.
sidebox-behavior.suite.js
Takes every MANY_TO_MANY / ONE_TO_MANY relation from unfiltered_fields, resolves the back reference exactly like the generator does (inversedBy || mappedBy || fieldName || key), then calls GET {target}?{backRef}={a parent IRI that doesn't exist}. If the filter works we get 0 rows, if it is ignored we get the whole collection, and that’s the violation.
The generator side of the same logic is documented in Unfiltered Fields.
full-crud-as-admin.suite.js
Runs the whole lifecycle per entity: listing, dropdown, POST with an invalid body, POST, the new record is in the listing / dropdown / ?id=, show, PUT or PATCH, show again, DELETE, and then the listing / dropdown / show reflect the delete.
When the cache has an operations map it gates every step on the exposed verb and expects 403/405 on the ones that are not exposed, otherwise it assumes full CRUD like the old files did.
The rest
negative.suite.js— the error side: ids that don’t exist, bad bodies, wrong types in the body, an expired token, and the status codes the API is supposed to answer with.query-string.suite.js— pagination (itemsPerPage,page),_locale,order[], search,exist[]and exact match filters, property by property usingtypes/nullable/swaggerQSfrom the cache.metadata.suite.js— the plain snapshot one: GET the metadata of each entity and compare it to the committed snapshot.status.suite.js— the health check:/statusfor routing and version,/statuswith OAuth,status/log-entries-lists/last, and theusers/logged_user*endpoints.
7. Contract and Behaviour are two different tests
The contract test asks “is it declared”, the behaviour test asks “does it work”. We need both, because an API can declare a filter it never applies, and it can apply one it never declared.
Sidebox is the best example of it. metadata-contract.suite.js checks the back reference is a registered filter in the target entity’s apiFilters, and sidebox-behavior.suite.js calls the endpoint and checks the collection is really filtered.
tickets → files?ticket= fails the first one. users?organization=, tickets?owner= and tickets?support= pass the first one and fail the second one.