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', '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', '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', 'weightKg', // v0.27.0 (ISS-045) — package weight in kg ]); 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)[p]?.post ?? {}) as { responses: Record; requestBody?: { required?: boolean; content: Record }; 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 }; 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'); }); });