Files
Flight-Ops/lib/openapi.test.ts
T
2026-09-24 09:24:31 +00:00

300 lines
15 KiB
TypeScript

import pkg from '@/package.json';
import { describe, expect, it } from 'vitest';
import { openApiSpec } from './openapi';
describe('OpenAPI spec', () => {
it('documents exactly the seventeen public endpoints (6 read + 8 player actions + 2 auth + 1 admin CRUD)', () => {
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
'/api/admin/tables', // v0.71.0 — admin panel
'/api/admin/tables/{name}/rows', // v0.71.0 — GET+PUT+POST+DELETE on one path
'/api/airports',
'/api/events', // v0.57.0 (ISS-129) — SSE state stream
'/api/login', // v0.71.0 — admin panel auth
'/api/logout', // v0.71.0
'/api/market/buy-plane',
'/api/market/sell-plane',
'/api/me', // v0.71.0
'/api/packages/{id}/abandon',
'/api/packages/{id}/load',
'/api/planes/{planeId}/fly',
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
'/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 packages', () => {
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',
'catalog',
'fuelPricePerL',
'packages',
'planes',
'sellFraction',
'simState',
]);
expect(openApiSpec.components.schemas.StateSnapshot.required.sort()).toEqual([
'account',
'catalog',
'fuelPricePerL',
'packages',
'planes',
'sellFraction',
'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.packages.items.$ref).toBe('#/components/schemas/PackageState');
expect(snap.catalog.items.$ref).toBe('#/components/schemas/CatalogEntry');
expect(snap.fuelPricePerL.type).toBe('number');
expect(snap.sellFraction.type).toBe('number');
const catalog = openApiSpec.components.schemas.CatalogEntry.properties;
expect(Object.keys(catalog).sort()).toEqual([
'burnPerKm',
'emptyWeightKg', // v0.16.0 weighted burn model input
'label',
'maxFuel',
'maxPayloadKg', // v0.27.0 (ISS-045) — replaces the v0.12.0 slot capacity
'model',
'price',
'speedKmh',
]);
for (const key of ['burnPerKm', 'emptyWeightKg', 'maxFuel', 'maxPayloadKg', 'price', 'speedKmh'] as const) {
expect(catalog[key].type, `CatalogEntry.${key}`).toBe('number');
}
expect(catalog.model.type).toBe('string');
expect(catalog.label.type).toBe('string');
const plane = openApiSpec.components.schemas.PlaneState.properties;
expect(Object.keys(plane).sort()).toEqual([
'departureSimTime',
'destIcao',
'destName',
'fuel',
'health', // v0.39.0 (ISS-069) — structural health in [0, 100]
'id',
'lat',
'locationIcao',
'locationName',
'lon',
'maxFuel',
'model',
'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started
'repairUntilSimTime', // v0.39.0 (ISS-069) — active repair gate; null when not repairing
'status',
]);
const pkg = openApiSpec.components.schemas.PackageState.properties;
expect(Object.keys(pkg).sort()).toEqual([
'cargoCount',
'cargoType',
'createdSimTime',
'deadlineSimTime',
'destIcao',
'destName',
'distanceKm', // v0.48.0 (ISS-095) — great-circle leg distance in km
'expiresRealMs', // v0.65.0 — wall-clock expiry for open jobs; null otherwise
'id',
'originIcao',
'originName',
'planeId',
'reward',
'special', // v0.37.0 (ISS-073) — special status from the specials registry
'status',
'weightKg', // v0.27.0 (ISS-045) — package weight in kg
]);
// ISS-117 (v0.52.0): the snapshot carries only the game clock — time controls are gone.
const sim = openApiSpec.components.schemas.SimState.properties;
expect(Object.keys(sim).sort()).toEqual(['clockMs']);
const packageStatus = openApiSpec.components.schemas.PackageState.properties.status;
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'loaded', 'open']);
});
it('documents the eight 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?: { required?: boolean; 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/packages/{id}/load',
'/api/packages/{id}/abandon',
'/api/planes/{planeId}/fly',
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
'/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043)
'/api/market/buy-plane',
'/api/market/sell-plane',
'/api/planes/{planeId}/repair', // v0.39.0 (ISS-069)
]) {
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/packages/{id}/load')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/packages/{id}/abandon')).toEqual(['404', '409']);
expect(errorCodes('/api/planes/{planeId}/fly')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/queue')).toEqual(['400', '404', '409']); // v0.34.0 (ISS-065) — 409 on duplicate leg / active-leg re-queue
expect(errorCodes('/api/planes/{planeId}/queue/cancel')).toEqual(['400', '404']); // v0.25.0
expect(errorCodes('/api/market/buy-plane')).toEqual(['400', '409']);
expect(errorCodes('/api/market/sell-plane')).toEqual(['400', '404', '409']);
expect(errorCodes('/api/planes/{planeId}/repair')).toEqual(['404', '409']); // v0.39.0 (ISS-069) — no body, so no 400
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon and repair take none.
expect(post('/api/packages/{id}/load').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/LoadPackageRequest',
);
expect(post('/api/planes/{planeId}/fly').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/FlyPlaneRequest',
);
expect(post('/api/planes/{planeId}/queue').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/QueueLegRequest', // v0.25.0 (ISS-043)
);
expect(post('/api/planes/{planeId}/queue/cancel').requestBody?.content['application/json'].schema.$ref).toBe(
'#/components/schemas/CancelQueuedLegRequest', // v0.25.0 (ISS-043)
);
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/packages/{id}/abandon').requestBody).toBeUndefined();
expect(post('/api/planes/{planeId}/repair').requestBody).toBeUndefined(); // v0.39.0 (ISS-069) — no body, like abandon
// Path parameters are declared on the parameterized routes.
const paramNames = (p: string) => post(p).parameters?.map((x) => x.name);
expect(paramNames('/api/packages/{id}/load')).toEqual(['id']);
expect(paramNames('/api/packages/{id}/abandon')).toEqual(['id']);
expect(paramNames('/api/planes/{planeId}/fly')).toEqual(['planeId']);
expect(paramNames('/api/planes/{planeId}/queue')).toEqual(['planeId']); // v0.25.0 (ISS-043)
expect(paramNames('/api/planes/{planeId}/queue/cancel')).toEqual(['planeId']); // v0.25.0 (ISS-043)
expect(paramNames('/api/planes/{planeId}/repair')).toEqual(['planeId']); // v0.39.0 (ISS-069)
// Request schema shapes.
const load = openApiSpec.components.schemas.LoadPackageRequest;
expect(load.required).toEqual(['planeId']);
expect(load.properties.planeId.type).toBe('string');
const fly = openApiSpec.components.schemas.FlyPlaneRequest;
expect(fly.required).toEqual(['destIcao']);
expect(fly.properties.destIcao.type).toBe('string');
// v0.25.0 (ISS-043): the queue request schemas mirror fly's destIcao shape.
const queueLeg = openApiSpec.components.schemas.QueueLegRequest;
expect(queueLeg.required).toEqual(['destIcao']);
expect(queueLeg.properties.destIcao.type).toBe('string');
const cancelQueuedLeg = openApiSpec.components.schemas.CancelQueuedLegRequest;
expect(cancelQueuedLeg.required).toEqual(['index']);
expect(cancelQueuedLeg.properties.index.type).toBe('integer');
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');
});
it('documents the v0.71.0 admin-panel endpoints: auth + guarded DB CRUD', () => {
const paths = openApiSpec.paths as Record<string, Record<string, { responses: Record<string, unknown>; requestBody?: unknown; parameters?: Array<{ name: string }> }>>;
// Error codes per endpoint (401/403 on every admin route — the guard runs before input).
const codes = (p: string, m: string) => Object.keys(paths[p][m].responses).sort();
expect(codes('/api/login', 'post')).toEqual(['200', '400', '401']);
expect(codes('/api/logout', 'post')).toEqual(['200', '401']);
expect(codes('/api/me', 'get')).toEqual(['200', '401']);
expect(codes('/api/admin/tables', 'get')).toEqual(['200', '401', '403']);
for (const m of ['get', 'put', 'post', 'delete'] as const) {
expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('401');
expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('403');
expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('404');
}
// Success shapes.
const ok = (p: string, m: string) => paths[p][m].responses['200'] as { content: Record<string, { schema: unknown }> };
expect((ok('/api/login', 'post').content['application/json'].schema as { $ref: string }).$ref).toBe('#/components/schemas/LoginResponse');
expect((ok('/api/me', 'get').content['application/json'].schema as { $ref: string }).$ref).toBe('#/components/schemas/SessionUser');
const tables = ok('/api/admin/tables', 'get').content['application/json'].schema as { type: string; items: { $ref: string } };
expect(tables.type).toBe('array');
expect(tables.items.$ref).toBe('#/components/schemas/AdminTable');
const rows = ok('/api/admin/tables/{name}/rows', 'get').content['application/json'].schema as { $ref: string };
expect(rows.$ref).toBe('#/components/schemas/AdminRowsResponse');
// Request bodies + path parameter.
const bodyRef = (p: string, m: string) =>
((paths[p][m].requestBody ?? {}) as { content?: Record<string, { schema: { $ref: string } }> }).content?.['application/json']
?.schema.$ref;
expect(bodyRef('/api/login', 'post')).toBe('#/components/schemas/LoginRequest');
expect(bodyRef('/api/admin/tables/{name}/rows', 'put')).toBe('#/components/schemas/UpdateRowRequest');
expect(bodyRef('/api/admin/tables/{name}/rows', 'post')).toBe('#/components/schemas/InsertRowRequest');
for (const m of ['get', 'put', 'post', 'delete'] as const) {
expect(paths['/api/admin/tables/{name}/rows'][m].parameters?.map((x) => x.name)).toContain('name');
}
// Request schema shapes.
const login = openApiSpec.components.schemas.LoginRequest;
expect(login.required).toEqual(['username', 'password']);
expect(login.properties.username.type).toBe('string');
expect(login.properties.password.type).toBe('string');
const sessionUser = openApiSpec.components.schemas.SessionUser;
expect(sessionUser.required.sort()).toEqual(['admin', 'username']);
expect(sessionUser.properties.admin.type).toBe('boolean');
const updateRow = openApiSpec.components.schemas.UpdateRowRequest;
expect(updateRow.required).toEqual(['rowid', 'values']);
expect(updateRow.properties.rowid.type).toBe('integer');
});
});