Files
Flight-Ops/lib/openapi.test.ts
T

192 lines
8.3 KiB
TypeScript

import pkg from '@/package.json';
import { describe, expect, it } from 'vitest';
import { openApiSpec } from './openapi';
describe('OpenAPI spec', () => {
it('documents exactly the nine public endpoints (4 read + 5 player actions)', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
'/api/airports',
'/api/contracts/{id}/accept',
'/api/market/buy-plane',
'/api/market/sell-plane',
'/api/planes/{planeId}/dispatch',
'/api/planes/{planeId}/refuel',
'/api/sim',
'/api/state',
'/api/version',
]);
});
it('keeps info.version in sync with package.json', () => {
expect(openApiSpec.info.title).toBe('Flight Ops API');
expect(openApiSpec.info.version).toBe(pkg.version);
});
it('uses an empty server url so "try it" targets the current origin', () => {
expect(openApiSpec.servers).toEqual([{ url: '' }]);
});
it('describes /api/airports as a GeoJSON FeatureCollection of airport features', () => {
const schema = openApiSpec.paths['/api/airports'].get.responses[200].content['application/json'].schema;
expect(schema.$ref).toBe('#/components/schemas/AirportFeatureCollection');
const fc = openApiSpec.components.schemas.AirportFeatureCollection.properties;
expect(fc.features.items.$ref).toBe('#/components/schemas/AirportFeature');
const feature = openApiSpec.components.schemas.AirportFeature.properties;
expect(feature.geometry.$ref).toBe('#/components/schemas/PointGeometry');
expect(feature.properties.$ref).toBe('#/components/schemas/AirportProperties');
const props = openApiSpec.components.schemas.AirportProperties.properties;
expect(Object.keys(props).sort()).toEqual(['icao', 'lat', 'lon', 'name']);
expect(props.name.type).toBe('string');
expect(props.icao.type).toBe('string');
expect(props.lat.type).toBe('number');
expect(props.lon.type).toBe('number');
const coords = openApiSpec.components.schemas.PointGeometry.properties.coordinates;
expect(coords.items.type).toBe('number');
});
it('describes /api/version as { version } with a string version', () => {
const schema = openApiSpec.paths['/api/version'].get.responses[200].content['application/json'].schema;
expect(schema.$ref).toBe('#/components/schemas/AppVersion');
const props = openApiSpec.components.schemas.AppVersion.properties;
expect(Object.keys(props)).toEqual(['version']);
expect(props.version.type).toBe('string');
});
it('describes /api/state as a StateSnapshot with account, simState, planes and contracts', () => {
const schema = openApiSpec.paths['/api/state'].get.responses[200].content['application/json'].schema;
expect(schema.$ref).toBe('#/components/schemas/StateSnapshot');
const snap = openApiSpec.components.schemas.StateSnapshot.properties;
expect(Object.keys(snap).sort()).toEqual(['account', 'contracts', 'planes', 'simState']);
expect(snap.account.$ref).toBe('#/components/schemas/AccountState');
expect(snap.simState.$ref).toBe('#/components/schemas/SimState');
expect(snap.planes.items.$ref).toBe('#/components/schemas/PlaneState');
expect(snap.contracts.items.$ref).toBe('#/components/schemas/ContractState');
const plane = openApiSpec.components.schemas.PlaneState.properties;
expect(Object.keys(plane).sort()).toEqual([
'contractId',
'departureSimTime',
'fuel',
'id',
'lat',
'locationIcao',
'locationName',
'lon',
'maxFuel',
'model',
'status',
]);
const contract = openApiSpec.components.schemas.ContractState.properties;
expect(Object.keys(contract).sort()).toEqual([
'cargoCount',
'cargoType',
'createdSimTime',
'deadlineSimTime',
'destIcao',
'destName',
'id',
'originIcao',
'originName',
'planeId',
'reward',
'status',
]);
const sim = openApiSpec.components.schemas.SimState.properties;
expect(Object.keys(sim).sort()).toEqual(['clockMs', 'paused', 'timeScale']);
const contractStatus = openApiSpec.components.schemas.ContractState.properties.status;
expect(contractStatus.enum.sort()).toEqual(['accepted', 'completed', 'expired', 'open']);
});
it('describes POST /api/sim as SimControlsRequest → SimState with a 400 error shape', () => {
const post = openApiSpec.paths['/api/sim'].post;
expect(post.operationId).toBe('setSimControls');
const requestSchema = post.requestBody.content['application/json'].schema;
expect(requestSchema.$ref).toBe('#/components/schemas/SimControlsRequest');
const ok = post.responses[200].content['application/json'].schema;
expect(ok.$ref).toBe('#/components/schemas/SimState');
const bad = post.responses[400].content['application/json'].schema;
expect(bad.$ref).toBe('#/components/schemas/Error');
const controls = openApiSpec.components.schemas.SimControlsRequest.properties;
expect(Object.keys(controls).sort()).toEqual(['paused', 'timeScale']);
expect(controls.timeScale.type).toBe('number');
expect(controls.timeScale.minimum).toBe(1);
expect(controls.timeScale.maximum).toBe(64);
expect(controls.paused.type).toBe('boolean');
const error = openApiSpec.components.schemas.Error.properties;
expect(Object.keys(error)).toEqual(['error']);
});
it('documents the five action endpoints: snapshot on 200, Error on their 4xx paths', () => {
// The spec object is a literal type; these helpers view it as plain JSON.
const post = (p: string) =>
((openApiSpec.paths as Record<string, { post?: unknown }>)[p]?.post ?? {}) as {
responses: Record<string, unknown>;
requestBody?: { content: Record<string, { schema: { $ref: string } }> };
parameters?: Array<{ name: string }>;
};
// Every action returns the full StateSnapshot on success (response convention).
for (const p of [
'/api/contracts/{id}/accept',
'/api/planes/{planeId}/dispatch',
'/api/market/buy-plane',
'/api/market/sell-plane',
'/api/planes/{planeId}/refuel',
]) {
const ok = post(p).responses['200'] as { content: Record<string, { schema: { $ref: string } }> };
expect(ok.content['application/json'].schema.$ref).toBe('#/components/schemas/StateSnapshot');
}
// Error shapes per endpoint.
const errorCodes = (p: string) => Object.keys(post(p).responses).filter((k) => k !== '200').sort();
expect(errorCodes('/api/contracts/{id}/accept')).toEqual(['404', '409']);
expect(errorCodes('/api/planes/{planeId}/dispatch')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/refuel')).toEqual(['404', '409']);
// Request bodies: dispatch/buy/sell take their dedicated schemas; accept/refuel take none.
expect(post('/api/planes/{planeId}/dispatch').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/DispatchRequest',
);
expect(post('/api/market/buy-plane').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/BuyPlaneRequest',
);
expect(post('/api/market/sell-plane').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/SellPlaneRequest',
);
expect(post('/api/contracts/{id}/accept').requestBody).toBeUndefined();
expect(post('/api/planes/{planeId}/refuel').requestBody).toBeUndefined();
// Path parameters are declared on the parameterized routes.
const paramNames = (p: string) => post(p).parameters?.map((x) => x.name);
expect(paramNames('/api/contracts/{id}/accept')).toEqual(['id']);
expect(paramNames('/api/planes/{planeId}/dispatch')).toEqual(['planeId']);
expect(paramNames('/api/planes/{planeId}/refuel')).toEqual(['planeId']);
// Request schema shapes.
const dispatch = openApiSpec.components.schemas.DispatchRequest;
expect(dispatch.required).toEqual(['contractId']);
expect(dispatch.properties.contractId.type).toBe('string');
const buy = openApiSpec.components.schemas.BuyPlaneRequest;
expect(buy.required).toEqual(['model']);
expect(buy.properties.model.type).toBe('string');
const sell = openApiSpec.components.schemas.SellPlaneRequest;
expect(sell.required).toEqual(['planeId']);
expect(sell.properties.planeId.type).toBe('string');
});
});