import pkg from '@/package.json'; import { describe, expect, it } from 'vitest'; import { openApiSpec } from './openapi'; describe('OpenAPI spec', () => { it('documents every public endpoint', () => { expect(Object.keys(openApiSpec.paths).sort()).toEqual([ '/api/admin/tables', '/api/admin/tables/{name}/rows', '/api/crew', '/api/events', '/api/explore', '/api/jump', '/api/missions', '/api/missions/{id}/accept', '/api/research', '/api/ship', '/api/ship/build', '/api/ship/expand', '/api/ship/upgrade', '/api/system', '/api/tech', '/api/turn', '/api/version', '/api/world', ]); }); it('describes the ship endpoints as ShipState in and out, with error responses on the actions', () => { const ship = openApiSpec.paths['/api/ship'].get.responses[200].content['application/json'].schema; expect(ship.$ref).toBe('#/components/schemas/ShipState'); const system = openApiSpec.components.schemas.ShipSystem.properties; expect(Object.keys(system)).toEqual(['id', 'type', 'level', 'capacity', 'built']); for (const [path, request] of [ ['/api/ship/upgrade', '#/components/schemas/UpgradeRequest'], ['/api/ship/expand', '#/components/schemas/ExpandRequest'], ['/api/ship/build', '#/components/schemas/BuildRequest'], ] as const) { const post = openApiSpec.paths[path].post; expect(post.requestBody.content['application/json'].schema.$ref).toBe(request); expect(post.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/ShipState'); for (const status of ['400', '404', '409', '422'] as const) { expect(post.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); } } // The queued-ship-work requests: the action target plus the crew team (lead required, // helpers optional) — the same team fields as JumpRequest / ExploreRequest. const upgradeReq = openApiSpec.components.schemas.UpgradeRequest as { properties: Record; required: string[] }; expect(Object.keys(upgradeReq.properties)).toEqual(['systemId', 'leadCrewId', 'helperCrewIds']); expect(upgradeReq.required).toEqual(['systemId', 'leadCrewId']); expect((upgradeReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer'); const expandReq = openApiSpec.components.schemas.ExpandRequest as { properties: Record; required: string[] }; expect(Object.keys(expandReq.properties)).toEqual(['systemId', 'leadCrewId', 'helperCrewIds']); expect(expandReq.required).toEqual(['systemId', 'leadCrewId']); expect((expandReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer'); const buildReq = openApiSpec.components.schemas.BuildRequest as { properties: Record; required: string[] }; expect(Object.keys(buildReq.properties)).toEqual(['type', 'leadCrewId', 'helperCrewIds']); expect(buildReq.required).toEqual(['type', 'leadCrewId']); expect((buildReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer'); }); it('describes the tech endpoints: GET /api/tech → Tech[], POST /api/research with error responses', () => { const tech = openApiSpec.paths['/api/tech'].get.responses[200].content['application/json'].schema; expect(tech.type).toBe('array'); expect((tech.items as { $ref: string }).$ref).toBe('#/components/schemas/Tech'); const techProps = openApiSpec.components.schemas.Tech.properties; expect(Object.keys(techProps)).toEqual(['id', 'slug', 'name', 'costKind', 'costQty', 'prereqs', 'effect', 'state', 'missingPrereqs']); expect((techProps.state as { enum: string[] }).enum).toEqual(['RESEARCHED', 'AVAILABLE', 'LOCKED']); const post = openApiSpec.paths['/api/research'].post; expect(post.requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/ResearchRequest'); expect(post.responses[200].content['application/json'].schema.items.$ref).toBe('#/components/schemas/Tech'); for (const status of ['400', '404', '409', '422'] as const) { expect(post.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); } // The queued-research request: the tech target plus the crew team (lead required, helpers optional). const researchReq = openApiSpec.components.schemas.ResearchRequest as { properties: Record; required: string[] }; expect(Object.keys(researchReq.properties)).toEqual(['techId', 'leadCrewId', 'helperCrewIds']); expect(researchReq.required).toEqual(['techId', 'leadCrewId']); expect((researchReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer'); }); it('describes the mission endpoints: GET /api/missions → Mission[], POST accept with error responses', () => { const missions = openApiSpec.paths['/api/missions'].get.responses[200].content['application/json'].schema; expect(missions.type).toBe('array'); expect((missions.items as { $ref: string }).$ref).toBe('#/components/schemas/Mission'); const props = openApiSpec.components.schemas.Mission.properties; expect(Object.keys(props)).toEqual(['id', 'type', 'description', 'targetSystemId', 'conditionKind', 'progressTarget', 'progress', 'rewardKind', 'rewardQty', 'state']); expect((props.type as { enum: string[] }).enum).toEqual(['DELIVER', 'EXPLORE', 'FIND']); expect((props.state as { enum: string[] }).enum).toEqual(['AVAILABLE', 'ACTIVE', 'DONE']); const post = openApiSpec.paths['/api/missions/{id}/accept'].post; expect(post.responses[200].content['application/json'].schema.items.$ref).toBe('#/components/schemas/Mission'); for (const status of ['400', '404', '409'] as const) { expect(post.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); } }); it('describes GET /api/events → Event[] with a limit query param and a 400 error response', () => { const events = openApiSpec.paths['/api/events'].get.responses[200].content['application/json'].schema; expect(events.type).toBe('array'); expect((events.items as { $ref: string }).$ref).toBe('#/components/schemas/Event'); const props = openApiSpec.components.schemas.Event.properties; expect(Object.keys(props)).toEqual(['id', 'turn', 'type', 'message', 'createdAt']); expect((props.type as { enum: string[] }).enum).toEqual(['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission', 'level_up']); const get = openApiSpec.paths['/api/events'].get; const limit = (get.parameters ?? []).find((p) => p.name === 'limit'); expect(limit?.in).toBe('query'); expect(limit?.required).toBe(false); expect(get.responses['400'].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); }); it('describes /api/crew as the CrewState { crew, tasks } object and crewFound on ExploreResult', () => { const crew = openApiSpec.paths['/api/crew'].get.responses[200].content['application/json'].schema; expect(crew.$ref).toBe('#/components/schemas/CrewState'); const crewState = openApiSpec.components.schemas.CrewState as { properties: Record; required: string[] }; expect(Object.keys(crewState.properties)).toEqual(['crew', 'tasks']); expect(crewState.required).toEqual(['crew', 'tasks']); expect((crewState.properties.crew as { items: { $ref: string } }).items.$ref).toBe('#/components/schemas/CrewMember'); expect((crewState.properties.tasks as { items: { $ref: string } }).items.$ref).toBe('#/components/schemas/TaskDto'); const taskDto = openApiSpec.components.schemas.TaskDto as { properties: Record; required: string[] }; expect(Object.keys(taskDto.properties)).toEqual(['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining', 'status', 'leadCrewId', 'helperCrewIds']); expect(taskDto.required).toEqual(['id', 'type', 'targetLabel', 'leadName', 'helperNames', 'remaining', 'status', 'leadCrewId', 'helperCrewIds']); expect((taskDto.properties.type as { enum: string[] }).enum).toEqual(['JUMP', 'EXPLORE', 'SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD', 'RESEARCH']); expect((taskDto.properties.status as { enum: string[] }).enum).toEqual(['ACTIVE', 'PENDING']); expect((taskDto.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer'); const member = openApiSpec.components.schemas.CrewMember.properties; expect(Object.keys(member)).toEqual(['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin']); const exploreResult = openApiSpec.components.schemas.ExploreResult as { allOf: unknown[] }; const extra = (exploreResult.allOf[1] as { properties: Record }).properties; expect(Object.keys(extra)).toEqual(['outcome', 'crewFound']); }); it('describes the system endpoints as SystemState in and out', () => { const system = openApiSpec.paths['/api/system'].get.responses[200].content['application/json'].schema; expect(system.$ref).toBe('#/components/schemas/SystemState'); // The snapshot carries the active + pending task queue. const systemState = openApiSpec.components.schemas.SystemState as { properties: Record; required: string[] }; expect(systemState.required).toEqual(['systemId', 'systemName', 'planets', 'inventory', 'turn', 'tasks']); expect((systemState.properties.tasks as { items: { $ref: string } }).items.$ref).toBe('#/components/schemas/TaskDto'); const explore = openApiSpec.paths['/api/explore'].post; expect(explore.requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/ExploreRequest'); expect(explore.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/ExploreResult'); // ExploreResult extends SystemState with the scan outcome. const exploreResult = openApiSpec.components.schemas.ExploreResult as { allOf: { $ref?: string }[] }; expect(exploreResult.allOf[0].$ref).toBe('#/components/schemas/SystemState'); for (const status of ['400', '404', '409'] as const) { expect(explore.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); } // The queued-scan request: the scan target plus the crew team (lead required, helpers optional). const exploreReq = openApiSpec.components.schemas.ExploreRequest as { properties: Record; required: string[] }; expect(Object.keys(exploreReq.properties)).toEqual(['planetId', 'leadCrewId', 'helperCrewIds']); expect(exploreReq.required).toEqual(['planetId', 'leadCrewId']); expect((exploreReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer'); }); it('describes the world endpoints as WorldState in and out', () => { const world = openApiSpec.paths['/api/world'].get.responses[200].content['application/json'].schema; expect(world.$ref).toBe('#/components/schemas/WorldState'); // The snapshot carries the active + pending task queue. const worldState = openApiSpec.components.schemas.WorldState as { properties: Record; required: string[] }; expect(worldState.required).toEqual(['systems', 'edges', 'turn', 'tasks']); expect((worldState.properties.tasks as { items: { $ref: string } }).items.$ref).toBe('#/components/schemas/TaskDto'); const jump = openApiSpec.paths['/api/jump'].post; expect(jump.requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/JumpRequest'); expect(jump.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/WorldState'); for (const status of ['400', '404', '409'] as const) { expect(jump.responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); } // The queued-jump request: the jump target plus the crew team (lead required, helpers optional). const jumpReq = openApiSpec.components.schemas.JumpRequest as { properties: Record; required: string[] }; expect(Object.keys(jumpReq.properties)).toEqual(['systemId', 'leadCrewId', 'helperCrewIds']); expect(jumpReq.required).toEqual(['systemId', 'leadCrewId']); expect((jumpReq.properties.helperCrewIds as { items: { type: string } }).items.type).toBe('integer'); }); it('describes the turn endpoint: POST /api/turn with no body → WorldState + tasks, 500 on internal error', () => { const post = openApiSpec.paths['/api/turn'].post; expect(post).not.toHaveProperty('requestBody'); expect(post.responses[200].content['application/json'].schema.$ref).toBe('#/components/schemas/TurnResult'); expect(post.responses[500].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); // TurnResult extends WorldState with the task queue after the tick. const turnResult = openApiSpec.components.schemas.TurnResult as { allOf: unknown[] }; expect((turnResult.allOf[0] as { $ref: string }).$ref).toBe('#/components/schemas/WorldState'); const extra = (turnResult.allOf[1] as { properties: Record }).properties; expect(Object.keys(extra)).toEqual(['tasks']); const props = openApiSpec.components.schemas.Task.properties; expect(Object.keys(props)).toEqual(['id', 'saveId', 'type', 'payload', 'leadCrewId', 'helpers', 'duration', 'remaining', 'status', 'queuedAtTurn']); expect((props.type as { enum: string[] }).enum).toEqual(['JUMP', 'EXPLORE', 'SHIP_UPGRADE', 'SHIP_EXPAND', 'SHIP_BUILD', 'RESEARCH']); expect((props.status as { enum: string[] }).enum).toEqual(['ACTIVE', 'PENDING', 'DONE']); expect((props.helpers as { items: { type: string } }).items.type).toBe('integer'); }); it('keeps info.version in sync with package.json', () => { expect(openApiSpec.info.title).toBe('SpaceWanderer 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/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 appVersion = openApiSpec.components.schemas.AppVersion.properties; expect(Object.keys(appVersion)).toEqual(['version']); expect(appVersion.version.type).toBe('string'); }); });