Skip to Content
Quality AssuranceTestsIntegrationsFactory API Endpoint Tests

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

We 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
KeyWhat it is
metadataMapThe metadata cache of that API. The factory loops on it, so this is what decides which entities get tested.
baseUrlComes from .env, one per API (TICKET_BASE_URL, CONFIG_BASE_URL, ACCOUNT_BASE_URL, PHPR_BASE_URL), with no trailing /.
apiVersionThe 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.
getTokenA function, not a token.
loginUrl, username, passwordThe older form of the same thing.
apiName, apiLabel, suiteTitle, testSuiteCodeOnly 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.

KeyWhat it is used for
entityNameThe 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, bodyModifiedThe POST and PUT/PATCH payloads used by the CRUD suite.
fullBody, fullBodyModifiedThe same with all the writable fields, not only the required ones.
sanitizedThe 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, swaggerQSUsed by the query-string suite to decide which filter to try on which property.
implementedInterfaces, treeViewableUsed 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_data has [moduleName, objectName, role_prefix, tableName, parent, implemented_interfaces, used_traits].
  • Every field has a fieldName and a type the generator knows.
  • Relation fields have targetEntity / targetEntityResourceName / targetEntityResourceIri.
  • Every apiFilters entry has .properties and an OrderFilter exists.
  • operations entries have .method and .url.
  • The property collections make sense together (required ⊆ fields, writable_required ⊆ writable and ⊆ 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.

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 using types / nullable / swaggerQS from 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: /status for routing and version, /status with OAuth, status/log-entries-lists/last, and the users/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.

Last updated on