Files
SpaceWanderer/lib/openapi.test.ts
T
2026-10-04 03:22:51 +00:00

239 lines
15 KiB
TypeScript

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<string, unknown>; 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<string, unknown>; 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<string, unknown>; 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<string, unknown>; 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<string, unknown>; 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<string, unknown>; 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');
// R-P1: the response object is camelCase (traitGood/traitBad), and since the skill-
// progression work the member also carries its four fixed-order skill levels + title.
const member = openApiSpec.components.schemas.CrewMember as { properties: Record<string, unknown>; required: string[] };
expect(Object.keys(member.properties)).toEqual(['id', 'name', 'role', 'traitGood', 'traitBad', 'origin', 'skills', 'title']);
expect(member.required).toEqual(['id', 'name', 'role', 'traitGood', 'traitBad', 'origin', 'skills', 'title']);
expect((member.properties.skills as { items: { required: string[] } }).items.required).toEqual(['skill', 'level']);
expect((member.properties.title as { nullable: boolean }).nullable).toBe(true);
const exploreResult = openApiSpec.components.schemas.ExploreResult as { allOf: unknown[] };
const extra = (exploreResult.allOf[1] as { properties: Record<string, unknown> }).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<string, unknown>; 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<string, unknown>; 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<string, unknown>; 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<string, unknown>; 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<string, unknown> }).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');
});
});