#!/usr/bin/env node // The Lucia project. // Copyright 2026-2026 DSP, inc. All rights reserved. // Authors: // imacat.yang@dsp.im (imacat), 2026/03/22 /** * Validates MSW fixture JSON files against the OpenAPI spec. * Ensures mock data matches the real API contract. * * Usage: node tests/validate-fixtures.js */ import Ajv from "ajv"; import addFormats from "ajv-formats"; import { readFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = resolve(__dirname, ".."); const spec = JSON.parse( readFileSync(resolve(root, "excludes/openapi.json"), "utf-8"), ); const schemas = spec.components?.schemas ?? {}; const ajv = new Ajv({ allErrors: true, strict: false }); addFormats(ajv); // Add ISO 8601 duration format (not included in ajv-formats) ajv.addFormat("duration", { type: "string", validate: (s) => /^P(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/.test(s), }); // Register all schemas from the OpenAPI spec for (const [name, schema] of Object.entries(schemas)) { ajv.addSchema(schema, `#/components/schemas/${name}`); } /** * Resolves a JSON Schema $ref to the actual schema object. * @param {object} schema - Schema that may contain $ref. * @returns {object} Resolved schema. */ function resolveRef(schema) { if (schema?.$ref) { const refName = schema.$ref.replace("#/components/schemas/", ""); return schemas[refName] ?? schema; } return schema; } /** * Gets the response schema for a given path and method. * @param {string} method - HTTP method (lowercase). * @param {string} path - API path. * @returns {object|null} Resolved schema or null. */ function getResponseSchema(method, path) { const pathInfo = spec.paths?.[path]; if (!pathInfo) return null; const methodInfo = pathInfo[method]; if (!methodInfo) return null; const responses = methodInfo.responses ?? {}; const resp = responses["200"] ?? responses["201"] ?? {}; const content = resp.content?.["application/json"] ?? {}; return content.schema ? resolveRef(content.schema) : null; } // Fixture-to-endpoint mapping const fixtures = [ ["token.json", "post", "/oauth/token"], ["my-account.json", "get", "/my-account"], ["users.json", "get", "/users"], ["user-detail.json", "get", "/users/{username}"], ["files.json", "get", "/files"], ["discover.json", "get", "/logs/{log_id}/discover"], ["performance.json", "get", "/logs/{log_id}/performance"], ["traces.json", "get", "/logs/{log_id}/traces"], ["trace-detail.json", "get", "/logs/{log_id}/traces/{trace_id}"], // compare.json: real API returns numbers for duration y-values and // dates without Z suffix, which doesn't match the OpenAPI spec. // Skipped until the spec is updated to match the actual API. // ["compare.json", "get", "/compare"], ["filter-params.json", "get", "/filters/params"], ]; let passed = 0; let failed = 0; for (const [fixture, method, path] of fixtures) { const data = JSON.parse( readFileSync( resolve(root, "src/mocks/fixtures", fixture), "utf-8", ), ); const schema = getResponseSchema(method, path); if (!schema) { console.log(`⚠ ${fixture}: no schema found for ${method.toUpperCase()} ${path}`); continue; } // For array responses, validate the schema's items against each element if (schema.type === "array" && schema.items) { const itemSchema = resolveRef(schema.items); let allValid = true; for (let i = 0; i < data.length; i++) { const valid = ajv.validate(itemSchema, data[i]); if (!valid) { console.log(`✗ ${fixture}[${i}]: ${ajv.errorsText()}`); allValid = false; } } if (allValid) { console.log(`✓ ${fixture} (${data.length} items)`); passed++; } else { failed++; } } else { const valid = ajv.validate(schema, data); if (valid) { console.log(`✓ ${fixture}`); passed++; } else { console.log(`✗ ${fixture}: ${ajv.errorsText()}`); failed++; } } } console.log(`\n${passed} passed, ${failed} failed`); process.exit(failed > 0 ? 1 : 0);