Skip to content

Develop, validate, and test a JSON API

You will develop the Delivery Board JSON API behind four route contracts:

  • GET /api/health reports process availability;
  • GET /api/work-items returns the current collection;
  • GET /api/work-items/:itemId returns one known item or a specific not-found error; and
  • PATCH /api/work-items/:itemId/status accepts one validated status change.

The API will keep its data in process memory. It will validate request JSON as unknown, return deliberate status codes and stable JSON error shapes, and expose no raw internal error to the client. Eight Vitest and Supertest cases will exercise the Express application through its HTTP interface without using the normal port 3000.

An API is a contract between independently running software. A TypeScript type in the client does not cross the network with a request, and a TypeScript type in the server cannot make received JSON trustworthy. The server must validate the runtime value before it changes application state.

Good API tests operate through the same method, path, headers, body, status, and JSON boundaries that a client uses. They can prove selected server contracts without depending on a browser interface or a manually managed development port.

What you will practice

  • Design a small JSON API from observable request and response contracts before route implementation.
  • Choose GET for retrieval and PATCH for a partial status update.
  • Distinguish route parameters, request bodies, trusted internal values, and untrusted external values.
  • Narrow unknown JSON through object-shape, property, and union-value checks.
  • Use an in-memory store that protects its owned collection from external mutation.
  • Return deliberate 200, 400, 404, 413, and 500 JSON contracts.
  • Place normal middleware, routes, not-found handling, and error middleware in effective order.
  • Create a fresh Express application and fresh store state for each automated test.
  • Use Supertest to exercise the application without importing the normal network listener.
  • Prove one test is sensitive to an incorrectly accepted status value.
  • Explain which API and validation concepts transfer to PHP despite framework syntax differences.
  • New: API resource, representation, collection endpoint, item endpoint, route parameter, PATCH, request body, express.json, body-size limit, runtime parser, discriminated validation result, over-posting boundary, in-memory store factory, application factory, JSON error envelope, not-found middleware, error middleware, Supertest, Node test environment, isolated seed data, and sensitivity mutation.
  • Reused: WorkItem, WorkStatus, accepted fictional fixture, Express 5 application and listener separation, Node 24, NodeNext, type stripping, strict TypeScript, Vitest, npm scripts, HTTP method and path, status, headers, JSON, feature/express-api, complete client tests, source checks, and focused commits.

Starting point

Before you start

  • The server-foundation commit exists on a clean feature/express-api branch.
  • server/app.ts exports app, and server/index.ts imports it and owns the local listener.
  • GET /api/health returns the documented 200 JSON contract with Cache-Control no-store.
  • tsconfig.server.json checks server TypeScript with strict Node ESM and erasable-syntax rules.
  • The accepted client fixture has at least five fictional WorkItem records and no private data.
  • The existing client test suite, complete typecheck, lint, and production build pass before API changes.
  • No database, authentication system, account, secret, schema package, public server, or client fetch is required in this lesson.
Current state
The Express process exposes only health information. Work-item data still belongs to the React fixture, request bodies have no parsing or validation path, API errors are not consistently JSON, and no automated test exercises the server.
First action
Verify the clean server-foundation commit, add the four-route contract table to README.md, and write the eight API test names before installing Supertest or changing server source.
First checkpoint
A fresh createApp instance returns the copied server collection and one item through JSON, while unknown item IDs and routes return distinct 404 error codes.
Help trigger
Use the nearest recovery note or ask for help if client and server fixture responsibilities are unclear, request.body is treated as trusted, route order makes a valid route return 404, error middleware returns HTML, one test changes another test's data, Supertest opens the normal port, an HTTP status disagrees with the response body, a controlled wrong validator still passes the selected test, or a server change breaks an existing client check.

You have completed the lesson when:

  • work continues from the clean server-foundation commit on feature/express-api;
  • README documents the four required routes, request bodies, success responses, and error responses before implementation;
  • server/data/initial-work-items.ts contains the accepted fictional fixture under server ownership for the current migration stage;
  • the server data imports the existing WorkItem type without claiming that a shared type validates network JSON;
  • createWorkItemStore owns a copied collection and returns copied records from list, lookup, and update operations;
  • parseStatusUpdate starts with unknown, accepts only an object with one own status property, and accepts only planned, active, or done;
  • a reusable helper sends the documented JSON error envelope;
  • createApp creates a new Express application and store from supplied or default seed data;
  • every /api response uses Cache-Control: no-store, and X-Powered-By remains disabled;
  • express.json accepts JSON bodies up to 10kb before the routes read request.body;
  • GET /api/work-items returns status 200 and { "data": [...] };
  • GET /api/work-items/:itemId returns status 200 with one known item or status 404 with WORK_ITEM_NOT_FOUND;
  • PATCH /api/work-items/:itemId/status returns status 200 with the updated item, status 400 for an invalid body, or status 404 for an unknown item;
  • malformed JSON returns status 400 with MALFORMED_JSON;
  • a JSON body above 10 KB returns status 413 with PAYLOAD_TOO_LARGE;
  • every unmatched method and path returns status 404 with ROUTE_NOT_FOUND;
  • unexpected application errors return status 500 with INTERNAL_ERROR and no raw stack, source path, or exception message in the response;
  • supertest and @types/supertest are development dependencies;
  • the shared test setup resets React DOM state only when document exists, so jsdom client tests and Node API tests can share the configuration;
  • server/app.test.ts uses the Node environment, fresh test data, and a fresh createApp boundary;
  • eight API tests cover health, collection, known item, unknown item, valid persistent update, rejected update without mutation, malformed JSON, and unmatched route;
  • a controlled validator mistake causes the rejected-update test to fail with expected 400 but actual 200, and the valid validator is restored;
  • the complete client and API test suite, typecheck, lint, build, and manual HTTP checks pass; and
  • README records API contracts, state lifetime, test evidence, limits, and recovery before the focused API commit.

Run the accepted baseline from the repository root:

Verify the server-foundation baseline
git switch feature/express-api
git status
git log -1 --oneline
npm ci
npm test
npm run typecheck
npm run lint
npm run build

Stop if the branch is dirty or the current commit is not the verified server foundation. A new route must not hide an existing client, test, configuration, or build failure.

Add this route table to README:

Method and path Request body Success Expected client errors
GET /api/health None 200 and health object None for a valid request
GET /api/work-items None 200 and { "data": WorkItem[] } None for a valid request
GET /api/work-items/:itemId None 200 and { "data": WorkItem } 404 WORK_ITEM_NOT_FOUND
PATCH /api/work-items/:itemId/status { "status": WorkStatus } 200 and { "data": updated WorkItem } 400 INVALID_REQUEST_BODY or 404 WORK_ITEM_NOT_FOUND
Any unmatched method and path Not specified None 404 ROUTE_NOT_FOUND

All normal and error responses use JSON and Cache-Control: no-store. Body-parser failures add 400 MALFORMED_JSON and 413 PAYLOAD_TOO_LARGE. An unexpected server failure uses 500 INTERNAL_ERROR without exposing the internal error.

  • GET retrieves a representation and must not change the store.
  • PATCH changes one part of an existing resource: its status.
  • 200 OK means the requested representation or updated representation is in the body.
  • 400 Bad Request means the request body does not meet the route contract.
  • 404 Not Found means the selected resource or route does not exist for this method and path.
  • 413 Content Too Large means the parser rejected the body before route validation.
  • 500 Internal Server Error means the server could not fulfill a request because of an unexpected internal failure.

The API does not use 201 Created because it creates no resource. It does not use 204 No Content because successful updates return the current representation. It does not use 401 or 403 because authentication and authorization are outside this stage.

Write these outcomes in README or a temporary test plan:

  1. Health returns the stable JSON contract.
  2. Collection returns the supplied seed records.
  3. Known route ID returns one matching record.
  4. Unknown route ID returns the work-item error contract.
  5. Valid status update returns and retains the new value for a later request.
  6. Unsupported status returns 400 and leaves the record unchanged.
  7. Malformed JSON returns the parser error contract.
  8. Unmatched method and path return the route error contract.

Each name combines a scenario and an observable outcome. The tests do not inspect a private array or call a route handler directly.

Move a copy of the accepted fixture behind the server

Section titled “Move a copy of the accepted fixture behind the server”

Create the server folders and copy the current accepted fixture:

Create the server data boundary
New-Item -ItemType Directory -Force server/data
New-Item -ItemType Directory -Force server/http
New-Item -ItemType Directory -Force server/validation
Copy-Item src/data/sample-work-items.ts server/data/initial-work-items.ts

Open server/data/initial-work-items.ts and make two focused changes:

server/data/initial-work-items.ts — required boundary changes
import type { WorkItem } from '../../src/types/work-item.ts';
export const initialWorkItems: WorkItem[] = [
// Keep the complete accepted five-or-more-record fixture here.
];

Do not replace the array with the comment. Keep every accepted fictional record and change only the import path and export name.

This is a temporary migration step:

  • the server now owns the source that will back API responses;
  • the client still imports its original fixture until the next lesson connects it;
  • the two arrays must match at this checkpoint so the network migration does not also change product content; and
  • the client fixture will stop being a runtime source after API consumption works.

The server imports the existing type to prevent an avoidable second WorkItem declaration. That static relationship exists inside the repository. It does not travel in JSON and cannot validate a response received by the future client.

Keep persistence outside the lesson boundary

Section titled “Keep persistence outside the lesson boundary”

The store will live inside one createApp result. Updates remain available to later requests handled by that application instance. They disappear when the process restarts or a new application instance is created.

This in-memory lifetime is intentional. It lets the lesson focus on routes, runtime validation, status codes, errors, and tests. A database would add storage schema, connection, migration, failure, test isolation, and deployment responsibilities that are not required by the curriculum goal.

Create a store that owns and copies its data

Section titled “Create a store that owns and copies its data”

Create server/work-item-store.ts:

server/work-item-store.ts
import type { WorkItem, WorkStatus } from '../src/types/work-item.ts';
function copyWorkItem(item: WorkItem): WorkItem {
return {
...item,
labels: [...item.labels],
};
}
export function createWorkItemStore(initialItems: readonly WorkItem[]) {
let items = initialItems.map(copyWorkItem);
return {
list(): WorkItem[] {
return items.map(copyWorkItem);
},
findById(id: string): WorkItem | undefined {
const item = items.find((candidate) => candidate.id === id);
return item === undefined ? undefined : copyWorkItem(item);
},
updateStatus(id: string, status: WorkStatus): WorkItem | undefined {
const currentItem = items.find((candidate) => candidate.id === id);
if (currentItem === undefined) {
return undefined;
}
const updatedItem: WorkItem = {
...currentItem,
status,
};
items = items.map((item) => (item.id === id ? updatedItem : item));
return copyWorkItem(updatedItem);
},
};
}

The store owns its array. It copies every seed item and nested labels array. It also returns copies. A route or test therefore cannot change store state by mutating a returned object.

updateStatus replaces one record and the containing array. This follows the immutable update model used in React, but for a different reason: the store protects its ownership boundary and makes changes explicit. The server could use controlled internal mutation, but it must not leak a mutable reference to callers.

Check the store through its public methods

Section titled “Check the store through its public methods”

Do not add a separate store test in the required path. The API suite will exercise list, lookup, and update through HTTP. You can still inspect the ownership model before routes exist:

  1. initialItems enters the factory as read-only input.
  2. items is a private closure value.
  3. list returns every current record as a copy.
  4. findById returns one copy or undefined.
  5. updateStatus returns the updated copy or undefined.

No caller can replace items or read it directly.

Create server/validation/status-update.ts:

server/validation/status-update.ts
import type { WorkStatus } from '../../src/types/work-item.ts';
const workStatuses = [
'planned',
'active',
'done',
] as const satisfies readonly WorkStatus[];
type ValidationResult<T> =
| { ok: true; value: T }
| { ok: false; issue: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function isWorkStatus(value: unknown): value is WorkStatus {
return (
typeof value === 'string' &&
workStatuses.some((status) => status === value)
);
}
export function parseStatusUpdate(
input: unknown,
): ValidationResult<WorkStatus> {
if (!isRecord(input)) {
return { ok: false, issue: 'The body must be a JSON object.' };
}
const keys = Object.keys(input);
if (keys.length !== 1 || !Object.hasOwn(input, 'status')) {
return {
ok: false,
issue: 'The body must contain only the status property.',
};
}
if (!isWorkStatus(input.status)) {
return {
ok: false,
issue: 'status must be planned, active, or done.',
};
}
return { ok: true, value: input.status };
}

The parser rejects:

  • undefined, null, arrays, strings, numbers, and booleans;
  • objects without an own status property;
  • objects with extra properties;
  • non-string status values; and
  • strings outside the WorkStatus union.

Rejecting extra properties creates a narrow update contract. A client cannot send title, labels, or another property to this status-only route and assume the server ignores it. This reduces accidental over-posting.

The result is a discriminated union. After if (!result.ok), TypeScript knows that result.issue exists. In the success branch, it knows that result.value is WorkStatus.

This would be unsafe:

Incorrect — assertion skips runtime evidence
const body = request.body as { status: WorkStatus };
store.updateStatus(request.params.itemId, body.status);

An assertion changes what the type checker permits. It does not inspect the received bytes or parsed value. A request can still contain { "status": "blocked" }, { "status": 12 }, an array, or no body.

PHP has the same boundary. A PHP type declaration does not make decoded request JSON valid. A PHP handler or framework validator must still check that the decoded value has the required shape and allowed status before it updates trusted state.

Checkpoint: Server data and status input have explicit owners

What now works
The server owns a temporary copy of the accepted fixture, the store copies values across its boundary, and the status parser converts unknown input into either one valid WorkStatus or one focused issue.
Files changed
server/data/initial-work-items.ts, server/work-item-store.ts, server/validation/status-update.ts
What remains
Create stable error JSON, assemble the application factory, and register the route contracts in effective order.
Next action
Create server/http/api-error.ts with the allowed error codes and response helper.
If it does not work
Trace one value from request bytes to parsed unknown, validation result, store input, copied output, and JSON response before changing a type.

Create server/http/api-error.ts:

server/http/api-error.ts
import type { Response } from 'express';
export type ApiErrorCode =
| 'INVALID_REQUEST_BODY'
| 'MALFORMED_JSON'
| 'PAYLOAD_TOO_LARGE'
| 'ROUTE_NOT_FOUND'
| 'WORK_ITEM_NOT_FOUND'
| 'INTERNAL_ERROR';
export function sendApiError(
response: Response,
status: 400 | 404 | 413 | 500,
code: ApiErrorCode,
message: string,
): void {
response.status(status).json({
error: {
code,
message,
},
});
}

Every error body now has one stable shape:

API error envelope
{
"error": {
"code": "WORK_ITEM_NOT_FOUND",
"message": "No work item has ID WI-999."
}
}

The code is stable program data. The message is concise human-readable context. A future client can select behavior from the HTTP status and code without parsing English text.

Do not put a stack trace, local path, package version, request body, credential, or private record in the response. The server can record protected operational detail when a real deployment defines an authorized logging process. This local lesson logs only an unexpected error object to the server terminal.

Assemble a fresh application in effective order

Section titled “Assemble a fresh application in effective order”

Replace server/app.ts with the complete application factory:

server/app.ts
import express, {
type Express,
type NextFunction,
type Request,
type Response,
} from 'express';
import type { WorkItem } from '../src/types/work-item.ts';
import { initialWorkItems } from './data/initial-work-items.ts';
import { sendApiError } from './http/api-error.ts';
import { parseStatusUpdate } from './validation/status-update.ts';
import { createWorkItemStore } from './work-item-store.ts';
function hasErrorType(
error: unknown,
type: string,
): error is Error & { type: string } {
return error instanceof Error && 'type' in error && error.type === type;
}
export function createApp(
seedItems: readonly WorkItem[] = initialWorkItems,
): Express {
const app: Express = express();
const store = createWorkItemStore(seedItems);
app.disable('x-powered-by');
app.use('/api', (_request, response, next) => {
response.set('Cache-Control', 'no-store');
next();
});
app.use(express.json({ limit: '10kb', strict: true }));
app.get('/api/health', (_request: Request, response: Response) => {
response.status(200).json({
status: 'ok',
service: 'delivery-board-api',
});
});
app.get('/api/work-items', (_request, response) => {
response.status(200).json({ data: store.list() });
});
app.get('/api/work-items/:itemId', (request, response) => {
const item = store.findById(request.params.itemId);
if (item === undefined) {
sendApiError(
response,
404,
'WORK_ITEM_NOT_FOUND',
`No work item has ID ${request.params.itemId}.`,
);
return;
}
response.status(200).json({ data: item });
});
app.patch('/api/work-items/:itemId/status', (request, response) => {
const body: unknown = request.body;
const result = parseStatusUpdate(body);
if (!result.ok) {
sendApiError(response, 400, 'INVALID_REQUEST_BODY', result.issue);
return;
}
const updatedItem = store.updateStatus(
request.params.itemId,
result.value,
);
if (updatedItem === undefined) {
sendApiError(
response,
404,
'WORK_ITEM_NOT_FOUND',
`No work item has ID ${request.params.itemId}.`,
);
return;
}
response.status(200).json({ data: updatedItem });
});
app.use((_request, response) => {
sendApiError(
response,
404,
'ROUTE_NOT_FOUND',
'No API route matches this method and path.',
);
});
app.use(
(
error: unknown,
_request: Request,
response: Response,
_next: NextFunction,
) => {
if (hasErrorType(error, 'entity.parse.failed')) {
sendApiError(
response,
400,
'MALFORMED_JSON',
'The JSON body is not valid.',
);
return;
}
if (hasErrorType(error, 'entity.too.large')) {
sendApiError(
response,
413,
'PAYLOAD_TOO_LARGE',
'The JSON body exceeds 10 KB.',
);
return;
}
console.error('Unhandled API error', error);
sendApiError(
response,
500,
'INTERNAL_ERROR',
'The server could not complete the request.',
);
},
);
return app;
}
export const app = createApp();

Keep server/index.ts unchanged. It imports the default app, validates PORT, and opens the normal local listener. Tests import createApp instead.

Express processes middleware and routes in registration order:

  1. Disable X-Powered-By for the application.
  2. Add Cache-Control: no-store to paths under /api.
  3. Parse matching JSON request bodies with a 10kb limit.
  4. Try the specific health and work-item routes.
  5. Convert every still-unmatched method and path into JSON 404.
  6. Handle errors raised by the parser or later middleware.

If the not-found middleware appears before the work-item routes, it ends every request with 404 before a valid handler can run. If error middleware does not have four parameters, Express treats it as normal middleware instead of an error handler.

Express populates request.body only when the request content type matches its JSON parser. The runtime value is controlled by the requester. This assignment makes the boundary explicit:

const body: unknown = request.body;
const result = parseStatusUpdate(body);

The parser must succeed before the store receives a status. Missing content type, missing body, arrays, extra properties, wrong primitive types, and unsupported strings all reach a controlled 400 result instead of a type assertion or crash.

Distinguish item not found from route not found

Section titled “Distinguish item not found from route not found”

These are different contracts:

  • GET /api/work-items/WI-999 matches a real route, but its selected resource does not exist. It returns WORK_ITEM_NOT_FOUND.
  • POST /api/missing matches no registered method and path. It returns ROUTE_NOT_FOUND.

Both use HTTP 404, while the JSON code preserves the difference for the client and test suite.

The final error middleware recognizes parser error types and maps them to client errors. Every other error receives a generic 500 body. The original error stays in the server terminal and does not enter the response.

This handler is a last boundary, not permission to ignore known failure cases. Invalid input and missing resources have specific paths before it.

Run the source checks:

Check the API source
npm run typecheck
npm run lint

Resolve the first diagnostic before starting the server. Do not add an assertion to silence an unknown input error.

Checkpoint: The complete API returns deliberate JSON contracts

What now works
Fresh application instances own fresh stores; valid collection, item, and status requests succeed; invalid bodies, missing items, unmatched routes, parser failures, and unexpected failures have bounded JSON responses.
Files changed
server/app.ts, server/http/api-error.ts, server/work-item-store.ts, server/validation/status-update.ts
What remains
Install the HTTP test adapter, automate eight API contracts, prove test sensitivity, and repeat manual HTTP checks.
Next action
Install supertest and @types/supertest as development dependencies.
If it does not work
Check middleware order, method and path, parser content type, validation branch, store result, selected status, and error middleware in that order.

Test the HTTP contract without the normal listener

Section titled “Test the HTTP contract without the normal listener”

Install Supertest and its declarations:

Install the API test adapter
npm install --save-dev supertest @types/supertest
npm ls supertest @types/supertest vitest

Vitest already belongs to the project. Supertest accepts the Express application function, creates a temporary test server on an available port, sends a real HTTP request through it, and closes that test boundary. It does not import server/index.ts or compete for the normal 3000 listener.

Make the shared setup safe in both environments

Section titled “Make the shared setup safe in both environments”

The existing src/test/setup.ts runs before every test file. Its React cleanup and title reset require a document, but the API file deliberately uses the Node environment. Update the callback while keeping the same imports:

src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
if (typeof document === 'undefined') {
return;
}
cleanup();
document.title = '';
});

Client tests still receive jsdom, clean their rendered React tree, and reset the title. A Node test has no document, so the callback returns before it touches DOM state. This guard does not create a simulated DOM for the server.

Create server/app.test.ts:

server/app.test.ts
// @vitest-environment node
import request from 'supertest';
import { describe, expect, it } from 'vitest';
import type { WorkItem } from '../src/types/work-item.ts';
import { createApp } from './app.ts';
const seedItems: WorkItem[] = [
{
id: 'WI-101',
title: 'Verify the API contract',
description: 'Check the response through its public HTTP boundary.',
status: 'planned',
labels: ['api', 'quality'],
},
{
id: 'WI-102',
title: 'Document runtime validation',
description: 'Record why external JSON starts as unknown.',
status: 'active',
labels: ['api', 'documentation'],
},
];
describe('Delivery Board API', () => {
it('returns the health contract as JSON', async () => {
const response = await request(createApp(seedItems))
.get('/api/health')
.expect('Content-Type', /json/)
.expect('Cache-Control', 'no-store')
.expect(200);
expect(response.body).toEqual({
status: 'ok',
service: 'delivery-board-api',
});
});
it('returns the current work-item collection', async () => {
const response = await request(createApp(seedItems))
.get('/api/work-items')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toEqual({ data: seedItems });
});
it('returns one known work item by route ID', async () => {
const response = await request(createApp(seedItems))
.get('/api/work-items/WI-101')
.expect(200);
expect(response.body.data).toEqual(seedItems[0]);
});
it('returns a JSON not-found error for an unknown work item', async () => {
const response = await request(createApp(seedItems))
.get('/api/work-items/WI-999')
.expect('Content-Type', /json/)
.expect(404);
expect(response.body).toEqual({
error: {
code: 'WORK_ITEM_NOT_FOUND',
message: 'No work item has ID WI-999.',
},
});
});
it('updates one valid status and keeps it for the next request', async () => {
const app = createApp(seedItems);
const updateResponse = await request(app)
.patch('/api/work-items/WI-101/status')
.send({ status: 'active' })
.expect(200);
expect(updateResponse.body.data.status).toBe('active');
const followUpResponse = await request(app)
.get('/api/work-items/WI-101')
.expect(200);
expect(followUpResponse.body.data.status).toBe('active');
});
it('rejects an unsupported status without changing the item', async () => {
const app = createApp(seedItems);
const errorResponse = await request(app)
.patch('/api/work-items/WI-101/status')
.send({ status: 'blocked' })
.expect(400);
expect(errorResponse.body.error.code).toBe('INVALID_REQUEST_BODY');
const followUpResponse = await request(app)
.get('/api/work-items/WI-101')
.expect(200);
expect(followUpResponse.body.data.status).toBe('planned');
});
it('returns a JSON error for malformed JSON', async () => {
const response = await request(createApp(seedItems))
.patch('/api/work-items/WI-101/status')
.set('Content-Type', 'application/json')
.send('{"status":')
.expect(400);
expect(response.body.error.code).toBe('MALFORMED_JSON');
});
it('returns a JSON error for an unmatched method and path', async () => {
const response = await request(createApp(seedItems))
.post('/api/missing')
.expect('Content-Type', /json/)
.expect(404);
expect(response.body.error.code).toBe('ROUTE_NOT_FOUND');
});
});

The file-level environment comment is required because the existing React tests use jsdom. This API file needs Node network and process behavior, not a simulated browser document.

Most tests call createApp(seedItems) once. The two update tests keep one application instance for two requests because they need to prove state across requests.

The next test still creates another application and store from the unchanged seed fixture. Test order cannot determine its result. Do not reset a private array from test code.

The test fixture is deliberately smaller than the product fixture. It contains only the records needed to expose the selected API contracts. Product data changes must not rewrite every API test expectation.

Run the API boundary first:

Run only the API tests
npm test -- server/app.test.ts

Expected API result: one file and eight passing tests.

Then run every client and server test:

Run the complete test portfolio
npm test

The total depends on whether Stage 2 added tests beyond its eight required cases. The important result is that every existing client test and all eight new API tests pass in one deterministic run.

server/app.test.ts imports createApp from app.ts. It does not import index.ts. Supertest manages a temporary listening boundary for each request and closes it.

If tests report that port 3000 is occupied or stay open after completion, inspect the imports. The test must not import a module that calls app.listen during evaluation.

The suite proves selected behavior through Express’s HTTP boundary:

  • route method and path matching;
  • status and selected headers;
  • JSON serialization and parsing;
  • route parameter selection;
  • validation and error codes;
  • state retained by one application instance; and
  • isolation between fresh application instances.

It does not prove public-network configuration, TLS, reverse-proxy behavior, database durability, concurrent update policy, authentication, client rendering, browser focus, or deployment safety.

Checkpoint: Eight API contracts pass through the HTTP boundary

What now works
Supertest exercises fresh Express applications in the Node environment; success, validation, update, parser, item-not-found, and route-not-found cases produce their documented status and JSON results.
Files changed
server/app.test.ts, package.json, package-lock.json, Complete Vitest output
What remains
Prove one validator test detects a plausible defect, inspect the normal local server manually, and finish repository evidence.
Next action
Make the controlled blocked-status validator change and run only the rejected-update test.
If it does not work
Confirm Node test environment, createApp import, fresh seed input, request method and path, expected status, and first response difference before changing application source.

Prove that the validator test can detect a defect

Section titled “Prove that the validator test can detect a defect”

Open server/validation/status-update.ts. Temporarily change only the return condition in isWorkStatus:

Controlled wrong validator — do not commit
return (
typeof value === 'string' &&
(workStatuses.some((status) => status === value) || value === 'blocked')
);

Run only the selected test:

Run the validator sensitivity check
npm test -- server/app.test.ts -t "rejects an unsupported status"

The test must fail at the HTTP boundary with evidence equivalent to:

expected 400 "Bad Request", got 200 "OK"

This is the intended reason: the wrong type guard lets blocked reach the store and success response. The type checker can still accept the incorrect predicate logic, so the behavior test provides different evidence.

Restore the valid condition immediately:

Restored validator
return (
typeof value === 'string' &&
workStatuses.some((status) => status === value)
);

Run all tests:

Confirm the restored test portfolio
npm test

Do not commit the deliberate defect, its failed output, or a temporary skip. Record the scenario and restored passing result in README.

Automated tests use the application factory. Manual checks prove that server/index.ts, process configuration, and the normal listener still connect to the same application.

Start the server in Terminal 1:

Terminal 1 — start the complete API
npm run dev:server

Use Terminal 2 for these requests.

Get the product collection
curl.exe -i http://127.0.0.1:3000/api/work-items

Confirm status 200, JSON content type, Cache-Control: no-store, and the complete server-owned fictional collection.

Use one ID from your accepted fixture:

Compare known and unknown resources
curl.exe -i http://127.0.0.1:3000/api/work-items/WI-001
curl.exe -i http://127.0.0.1:3000/api/work-items/WI-999

The first request must return its exact item with 200. The second must return 404 and WORK_ITEM_NOT_FOUND. If your accepted fixture uses different IDs, replace WI-001 with one real ID and keep WI-999 only when it is absent.

In PowerShell, use single quotes around the JSON text so the inner double quotes reach curl unchanged:

Send a valid partial update
curl.exe -i -X PATCH -H "Content-Type: application/json" -d '{"status":"active"}' http://127.0.0.1:3000/api/work-items/WI-001/status
curl.exe -i http://127.0.0.1:3000/api/work-items/WI-001

The update must return 200 and the updated item. The following GET must show the same status because the running application retains its store.

Inspect two body failures
curl.exe -i -X PATCH -H "Content-Type: application/json" -d '{"status":"blocked"}' http://127.0.0.1:3000/api/work-items/WI-001/status
curl.exe -i -X PATCH -H "Content-Type: application/json" -d '{"status":' http://127.0.0.1:3000/api/work-items/WI-001/status

The unsupported value must return 400 INVALID_REQUEST_BODY. The incomplete JSON must return 400 MALFORMED_JSON. Repeat GET and confirm that the invalid requests did not create blocked state.

Stop Terminal 1 with Ctrl+C. Start npm run start:server again and retrieve the same item. Its status must return to the value in server/data/initial-work-items.ts.

This reset is a documented limit, not a defect in the lesson result. Do not add a file write, local storage, or database to hide it.

Stop the server with Ctrl+C after the final request. Confirm that the local URL no longer accepts a connection.

Update README with:

  1. the complete route contract table;
  2. success and error JSON envelopes;
  3. method and status-code decisions;
  4. the server-owned fixture and temporary client-copy boundary;
  5. the in-memory store lifetime and restart reset;
  6. the runtime validation rules and rejection of extra properties;
  7. the 10kb body limit;
  8. middleware and error-handler order;
  9. the API-test dependency and Node environment;
  10. the document guard that lets jsdom client files and Node API files share setup;
  11. all eight API test outcomes;
  12. the controlled validator failure and restored result;
  13. manual collection, item, update, invalid-body, malformed-body, and restart evidence;
  14. exact latest test, typecheck, lint, and build results;
  15. PHP transfer: decoded request data still requires runtime validation and deliberate status and JSON responses; and
  16. current limits: duplicate migration fixture, memory-only data, one-process state, no concurrency policy, client fetch, database, authentication, authorization, public exposure, or deployment.

Run the final gate with no server process active:

Verify the API lesson result
npm test
npm run typecheck
npm run lint
npm run build
git status --short
git diff --check
git diff

Inspect the diff. Confirm that no blocked validator, it.skip, it.only, raw private data, generated output, or running-process artifact remains.

Create one focused lesson commit:

Commit the validated API
git add package.json package-lock.json README.md
git add src/test/setup.ts
git add server/app.ts server/app.test.ts
git add server/data/initial-work-items.ts server/work-item-store.ts
git add server/http/api-error.ts server/validation/status-update.ts
git diff --cached --check
git diff --cached
git commit -m "feat: add validated work-item API"
git status

Keep the branch local and clean. The API-consumption lesson will connect React before Stage 3 review and integration.

Self-check

Complete these checks against the required result.

  1. Confirm that the lesson started from the clean server-foundation commit on feature/express-api.
  2. Open README and trace each of the four required methods and paths to its request, success, and error contracts.
  3. Explain why GET retrieves data while PATCH changes only the selected status field.
  4. Explain why the API returns 200 with a representation rather than 204 after the update.
  5. Confirm that the server fixture contains the accepted fictional records and the client copy remains only for the next migration step.
  6. Explain why importing WorkItem avoids a duplicate declaration but does not validate network JSON.
  7. Point to where the store copies the seed, nested labels, listed records, found record, and updated record.
  8. Confirm that one createApp call owns one store and that a new createApp call starts from a fresh copied seed.
  9. Trace an unknown request body through isRecord, own-key inspection, isWorkStatus, and the validation result union.
  10. Confirm that arrays, null, missing status, extra properties, wrong primitive values, and blocked are rejected.
  11. Explain why a type assertion would skip the runtime check rather than perform it.
  12. Point to the stable error code and human-readable message in the JSON error envelope.
  13. Confirm that no error response includes a stack trace, local source path, raw request body, or exception message.
  14. Read server/app.ts in registration order and explain what each middleware or route can do before the next one runs.
  15. Confirm that every /api response uses no-store and does not expose X-Powered-By.
  16. Confirm that express.json has a 10kb limit and appears before the PATCH route.
  17. Trace a known GET, unknown GET, valid PATCH, invalid PATCH, malformed JSON, oversized JSON, unmatched route, and unexpected error to the selected status and code.
  18. Confirm that supertest and @types/supertest are development dependencies.
  19. Explain why the API test file selects the Node environment while React tests continue to use jsdom.
  20. Confirm that shared setup checks for document before React cleanup and title reset.
  21. Confirm that tests import createApp rather than the listening process entry.
  22. Run the API file and confirm one file and eight passing tests.
  23. Run the complete portfolio and confirm every existing client test and every API test passes.
  24. Perform the controlled blocked-status validator change, confirm expected 400 versus actual 200, restore the valid guard, and rerun every test.
  25. Start the normal server and verify collection, known item, missing item, update, invalid input, malformed JSON, and restart reset through curl.
  26. Stop the exact server process and confirm the local URL no longer accepts a connection.
  27. Run typecheck, lint, and build after the complete test suite.
  28. Confirm that README records contracts, state lifetime, evidence, recovery, PHP transfer, and current limits.
  29. Inspect the staged diff, create the focused API commit, and confirm a clean feature branch.

Explanation: validate at every runtime boundary

Section titled “Explanation: validate at every runtime boundary”

The same WorkItem declaration supports internal client and server source, but the HTTP boundary still carries bytes. The sender can be an older client, a different program, a manual request, a modified browser, or invalid input. The server must convert parsed unknown data into trusted application values.

This lesson uses a small manual parser because the accepted body has one field and three values. The steps remain visible:

  1. establish object shape;
  2. restrict own properties;
  3. narrow the selected property;
  4. return a typed success or explicit failure; and
  5. change state only after success.

A schema library can reduce repetition for larger contracts, but it does not remove the need to design the contract, choose status codes, protect error output, test invalid cases, or understand the generated result. No schema dependency is required here.

The application factory and store factory solve another boundary: test isolation. Each test can use specific seed data and an independent state lifetime. The normal app export still gives server/index.ts one default application for local use.

API tests complement the React suite:

  • React tests prove selected rendered behavior in jsdom.
  • API tests prove selected HTTP behavior in the Node environment.
  • Manual local checks prove that the normal process entry and listener connect.
  • Type checking, linting, and building answer separate static and production questions.

No one result replaces the others.

The Express implementation uses app.get, app.patch, middleware, request.params, request.body, and response.status().json(). A PHP project can express the same responsibilities through a framework router and request or response objects, or through lower-level PHP and web-server configuration.

The transferable sequence is:

  1. match an HTTP method and path;
  2. read route and body input;
  3. treat decoded input as untrusted;
  4. validate shape and allowed values;
  5. perform the permitted state operation;
  6. select a deliberate status and response representation; and
  7. test success and failure through HTTP.

Modern PHP can declare parameter and return types, but those declarations do not prove that decoded JSON has the required properties and values. Runtime validation remains a separate job in both stacks.

The four routes, validation, stable errors, eight tests, manual checks, documentation, and focused commit complete this lesson. Keep extension work separate from the required evidence.

The required result is safely paused when all eight API tests and all existing client tests pass, the wrong validator has been restored, manual requests match the contracts, the restart resets memory as documented, no server process remains active, typecheck, lint, and build pass, the API commit exists, and git status is clean on feature/express-api.

Continue to Connect a React application to an API. That lesson will treat every response body as external runtime data and will replace the client’s fixture ownership with explicit loading, success, empty, failure, retry, and status-update behavior.