CI / build + unit + e2e (pull_request) Failing after 6m37s
- planes.queue TEXT NOT NULL DEFAULT '[]' — ordered JSON array of not-yet-started
leg ICACs; active leg stays destIcao + departureSimTime. DDL + guarded ALTER
migration for existing DBs; seed sets queue: [] explicitly.
- deliverPackages() auto-chains on arrival: with a non-empty queue and fuel
covering the first queued leg (same requiredFuelFor rule as /fly), the plane
immediately starts that leg from the arrival airport (stays 'flying',
departureSimTime = arrival instant, queue shortened). Short tank → grounds
safely with the queue kept intact; empty queue → unchanged grounding. Cargo
delivery for the reached airport runs first and is unaffected. Ordering kept:
delivery still runs before tickOnce's pause check, so a chained plane counts
as still flying — no premature last-plane pause (ISS-041 preserved).
- New endpoints via performAction(): POST /api/planes/{planeId}/queue {destIcao}
(append; works grounded or flying; 400 bad body/unknown ICAO, 404 unknown
plane) and POST /api/planes/{planeId}/queue/cancel {index} (remove queued leg
by index; active in-flight leg never affected; 400 non-integer/out-of-range,
404 unknown plane). Both return the full state snapshot.
- PlaneState.queue: string[] in /api/state (null/malformed-safe parse helper
shared with the engine); OpenAPI updated (PlaneState.queue, QueueLegRequest,
CancelQueuedLegRequest, both paths) and openapi.test.ts expectations extended.
- Tests: auto-chain block in engine.test.ts (chain / short-fuel ground / empty
queue) + chained-plane-no-pause regression guard; queueLeg/cancelQueuedLeg
blocks in actions.test.ts; type-compat queue: [] fixtures in deriveEvents,
right-pane, mapData and seed tests.
v0.25.0 — part 1 (backend) of ISS-043; UI/map rendering lands in a follow-up.
272 lines
12 KiB
TypeScript
272 lines
12 KiB
TypeScript
import pkg from '@/package.json';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { openApiSpec } from './openapi';
|
|
|
|
describe('OpenAPI spec', () => {
|
|
it('documents exactly the twelve public endpoints (4 read + 8 player actions)', () => {
|
|
expect(Object.keys(openApiSpec.paths).sort()).toEqual([
|
|
'/api/airports',
|
|
'/api/market/buy-plane',
|
|
'/api/market/sell-plane',
|
|
'/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}/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 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',
|
|
'capacity',
|
|
'emptyWeightKg', // v0.16.0 weighted burn model input
|
|
'label',
|
|
'maxFuel',
|
|
'model',
|
|
'price',
|
|
'speedKmh',
|
|
]);
|
|
for (const key of ['burnPerKm', 'emptyWeightKg', 'maxFuel', 'price', 'speedKmh'] as const) {
|
|
expect(catalog[key].type, `CatalogEntry.${key}`).toBe('number');
|
|
}
|
|
expect(catalog.capacity.type).toBe('integer'); // v0.12.0 slot capacity
|
|
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',
|
|
'id',
|
|
'lat',
|
|
'locationIcao',
|
|
'locationName',
|
|
'lon',
|
|
'maxFuel',
|
|
'model',
|
|
'queue', // v0.25.0 (ISS-043) — upcoming legs not yet started
|
|
'status',
|
|
]);
|
|
|
|
const pkg = openApiSpec.components.schemas.PackageState.properties;
|
|
expect(Object.keys(pkg).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 packageStatus = openApiSpec.components.schemas.PackageState.properties.status;
|
|
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'loaded', '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(1000);
|
|
expect(controls.paused.type).toBe('boolean');
|
|
|
|
const error = openApiSpec.components.schemas.Error.properties;
|
|
expect(Object.keys(error)).toEqual(['error']);
|
|
});
|
|
|
|
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}/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/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']); // v0.25.0 — no 409: queueing works grounded or flying
|
|
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}/refuel')).toEqual(['400', '404', '409']); // v0.16.0 optional body
|
|
|
|
// Request bodies: load/fly/buy/sell take their dedicated schemas; abandon takes none;
|
|
// refuel's is OPTIONAL (v0.16.0) — no body still means "fill to full".
|
|
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();
|
|
const refuelBody = post('/api/planes/{planeId}/refuel').requestBody;
|
|
expect(refuelBody?.content['application/json'].schema.$ref).toBe('#/components/schemas/RefuelRequest');
|
|
expect(refuelBody?.required).toBe(false); // optional — no body still means "fill to full"
|
|
|
|
// 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}/refuel')).toEqual(['planeId']);
|
|
|
|
// 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');
|
|
|
|
// RefuelRequest (v0.16.0): both fields optional — no body / {} means "full".
|
|
const refuel = openApiSpec.components.schemas.RefuelRequest;
|
|
expect((refuel as { required?: string[] }).required ?? []).toEqual([]);
|
|
expect(((refuel.properties.mode) as { enum: string[] }).enum.sort()).toEqual(['forFlight', 'full']);
|
|
expect(refuel.properties.packageId.type).toBe('string');
|
|
});
|
|
});
|