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', 'catalog', 'contracts', 'fuelPricePerL', 'planes', 'sellFraction', 'simState', ]); expect(openApiSpec.components.schemas.StateSnapshot.required.sort()).toEqual([ 'account', 'catalog', 'contracts', 'fuelPricePerL', '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.contracts.items.$ref).toBe('#/components/schemas/ContractState'); 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', 'label', 'maxFuel', 'model', 'price', 'speedKmh']); for (const key of ['burnPerKm', 'maxFuel', '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([ '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)[p]?.post ?? {}) as { responses: Record; requestBody?: { content: Record }; 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 }; 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'); }); });