/** * Anti-regression: every Loop V2 swagger write endpoint maps to a * registered MCP tool whose source file references the path's stable * prefix. Catches three drift modes pre-publish: * * 1. Loop adds a new POST/PUT/PATCH/DELETE → no mapping → test fails * and names the orphan endpoint. * 2. A tool's `server.tool("…")` registration is renamed or removed → * the name lookup against `index.ts` source fails. * 3. A tool's source file is refactored such that the swagger path * family no longer appears literally → the path-fragment lookup * against the source file fails. * * The vendored snapshot at `loop-swagger.snapshot.json` was fetched on * 2026-04-28 from `https://api.loop.software/swagger/v2/swagger.json` * (32 write endpoints across 8 tags). Refresh by re-downloading and * extending ENDPOINT_MAP — the test is the single source of truth for * what coverage means. */ import { describe, expect, it } from 'vitest'; import { readFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_SRC = resolve(__dirname, '..'); const SWAGGER = JSON.parse( readFileSync(resolve(__dirname, 'loop-swagger.snapshot.json'), 'utf-8'), ) as { paths: Record> }; const WRITE_METHODS = ['post', 'put', 'patch', 'delete'] as const; interface EndpointMapping { method: typeof WRITE_METHODS[number]; path: string; tool: string; source: string; fragment: string; } // One entry per swagger write op. `fragment` is the literal substring // (typically the stable prefix before any `${...}` template variable) // that must appear in the source file — the path's family signature. const ENDPOINT_MAP: EndpointMapping[] = [ // Customer (1) { method: 'post', path: '/customer/preferences/{personCode}', tool: 'loop-customer-preferences', source: 'tools/customer-preferences.ts', fragment: '/customer/preferences/' }, // Feedback (2) { method: 'post', path: '/feedback/residential/sales/viewings/{id}/feedback', tool: 'loop-feedback-submit', source: 'tools/feedback.ts', fragment: '/feedback/residential/' }, { method: 'post', path: '/feedback/residential/lettings/viewings/{id}/feedback', tool: 'loop-feedback-submit', source: 'tools/feedback.ts', fragment: '/feedback/residential/' }, // Marketing — match-batch PUT (2) { method: 'put', path: '/marketing/matching/other-matches', tool: 'loop-marketing-match-batch', source: 'tools/marketing-match-batch.ts', fragment: '/matching/other-matches' }, { method: 'put', path: '/marketing/rentals/matching/other-matches', tool: 'loop-marketing-match-batch', source: 'tools/marketing-match-batch.ts', fragment: '/matching/other-matches' }, // Marketing — match-request POST × {sales,lettings} × {viewing,information,callback} (6) { method: 'post', path: '/marketing/matching/{id}/viewing', tool: 'loop-marketing-match-request', source: 'tools/marketing-match-request.ts', fragment: '/matching/' }, { method: 'post', path: '/marketing/rentals/matching/{id}/viewing', tool: 'loop-marketing-match-request', source: 'tools/marketing-match-request.ts', fragment: '/marketing/rentals' }, { method: 'post', path: '/marketing/matching/{id}/information', tool: 'loop-marketing-match-request', source: 'tools/marketing-match-request.ts', fragment: '/matching/' }, { method: 'post', path: '/marketing/rentals/matching/{id}/information', tool: 'loop-marketing-match-request', source: 'tools/marketing-match-request.ts', fragment: '/marketing/rentals' }, { method: 'post', path: '/marketing/matching/{id}/callback', tool: 'loop-marketing-match-request', source: 'tools/marketing-match-request.ts', fragment: '/matching/' }, { method: 'post', path: '/marketing/rentals/matching/{id}/callback', tool: 'loop-marketing-match-request', source: 'tools/marketing-match-request.ts', fragment: '/marketing/rentals' }, // Marketing — seller enquiry (1) { method: 'post', path: '/marketing/enquiries/seller', tool: 'loop-marketing-enquiry', source: 'tools/marketing-enquiry.ts', fragment: '/marketing/enquiries/seller' }, // Marketing — auto-responder (3) — Task 103 split into loop-auto-responder { method: 'put', path: '/marketing/enquiries/auto-responder/{id}/answers/{key}', tool: 'loop-auto-responder', source: 'tools/auto-responder.ts', fragment: '/marketing/enquiries/auto-responder/' }, { method: 'put', path: '/marketing/enquiries/auto-responder/{id}/details/{key}', tool: 'loop-auto-responder', source: 'tools/auto-responder.ts', fragment: '/marketing/enquiries/auto-responder/' }, { method: 'put', path: '/marketing/enquiries/auto-responder/{id}/refer/{key}', tool: 'loop-auto-responder', source: 'tools/auto-responder.ts', fragment: '/marketing/enquiries/auto-responder/' }, // Property intent (6) — Task 103 split loop-property-request into three intent-clear tools { method: 'post', path: '/property/residential/sales/{id}/viewing', tool: 'loop-property-viewing', source: 'tools/property-intent.ts', fragment: '/property/residential/' }, { method: 'post', path: '/property/residential/sales/{id}/call-back', tool: 'loop-property-callback', source: 'tools/property-intent.ts', fragment: '/property/residential/' }, { method: 'post', path: '/property/residential/sales/{id}/information', tool: 'loop-property-information', source: 'tools/property-intent.ts', fragment: '/property/residential/' }, { method: 'post', path: '/property/residential/lettings/{id}/viewing', tool: 'loop-property-viewing', source: 'tools/property-intent.ts', fragment: '/property/residential/' }, { method: 'post', path: '/property/residential/lettings/{id}/call-back', tool: 'loop-property-callback', source: 'tools/property-intent.ts', fragment: '/property/residential/' }, { method: 'post', path: '/property/residential/lettings/{id}/information', tool: 'loop-property-information', source: 'tools/property-intent.ts', fragment: '/property/residential/' }, // Supplier (3) { method: 'post', path: '/supplier/maintenance/{code}/{jobid}/complete', tool: 'loop-supplier', source: 'tools/supplier.ts', fragment: '/supplier/maintenance/' }, { method: 'put', path: '/supplier/maintenance/{code}/{quoteid}/quote-for-job', tool: 'loop-supplier', source: 'tools/supplier.ts', fragment: '/quote-for-job' }, { method: 'post', path: '/supplier/board-contractor/{code}/{jobid}/complete', tool: 'loop-supplier', source: 'tools/supplier.ts', fragment: '/supplier/board-contractor/' }, // Viewing — sales (4) { method: 'post', path: '/residential/sales/viewings', tool: 'loop-viewing-create', source: 'tools/viewing-create.ts', fragment: '/residential/' }, { method: 'post', path: '/residential/sales/viewings/{id}/buyer-feedback', tool: 'loop-viewing-update', source: 'tools/viewing-update.ts', fragment: '/residential/' }, { method: 'post', path: '/residential/sales/viewings/{id}/note', tool: 'loop-viewing-update', source: 'tools/viewing-update.ts', fragment: '/residential/' }, { method: 'post', path: '/residential/sales/viewings/{id}/seller-feedback', tool: 'loop-viewing-update', source: 'tools/viewing-update.ts', fragment: '/residential/' }, // RentalViewing — lettings (4) { method: 'post', path: '/residential/lettings/viewings', tool: 'loop-viewing-create', source: 'tools/viewing-create.ts', fragment: '/residential/' }, { method: 'post', path: '/residential/lettings/viewings/{id}/renter-feedback', tool: 'loop-viewing-update', source: 'tools/viewing-update.ts', fragment: '/residential/' }, { method: 'post', path: '/residential/lettings/viewings/{id}/note', tool: 'loop-viewing-update', source: 'tools/viewing-update.ts', fragment: '/residential/' }, { method: 'post', path: '/residential/lettings/viewings/{id}/landlord-feedback', tool: 'loop-viewing-update', source: 'tools/viewing-update.ts', fragment: '/residential/' }, ]; // Lazily cache source files so multiple per-endpoint tests don't re-read. const sourceCache = new Map(); function readSource(rel: string): string { let cached = sourceCache.get(rel); if (cached) return cached; cached = readFileSync(resolve(PLUGIN_SRC, rel), 'utf-8'); sourceCache.set(rel, cached); return cached; } const indexSrc = readSource('index.ts'); describe('Loop swagger write coverage', () => { // 1. Every write op in the snapshot has a mapping (catches new endpoints). it('every swagger write op has a mapping in ENDPOINT_MAP', () => { const swaggerOps: { method: string; path: string }[] = []; for (const [path, item] of Object.entries(SWAGGER.paths)) { for (const m of WRITE_METHODS) { if (item[m]) swaggerOps.push({ method: m, path }); } } const mapped = new Set(ENDPOINT_MAP.map(e => `${e.method.toUpperCase()} ${e.path}`)); const orphans = swaggerOps.filter(o => !mapped.has(`${o.method.toUpperCase()} ${o.path}`)); if (orphans.length > 0) { const lines = orphans.map(o => ` ${o.method.toUpperCase()} ${o.path}`).join('\n'); throw new Error( `${orphans.length} swagger write endpoint(s) lack a mapping in ENDPOINT_MAP — Loop has added new write ops since the last snapshot:\n${lines}\n\nUpdate ENDPOINT_MAP to map each new endpoint to its tool, source, and path fragment.`, ); } expect(swaggerOps.length).toBe(ENDPOINT_MAP.length); }); // 2. Every mapping's tool is registered in index.ts (catches deregistration). // Match by quoted tool name in proximity to a `server.tool(` call — // each registration is `server.tool(\n "", ...)` or single-line. // A tool name that appears only in a comment or unrelated string doesn't // match because we require it to be the first non-whitespace argument // of a server.tool( invocation within ~32 chars. it.each(ENDPOINT_MAP)('$method $path → tool $tool registered in index.ts', ({ tool }) => { const registrationRe = new RegExp(`server\\.tool\\(\\s*"${tool.replace(/[.*+?^${}()|[\\\]\\\\]/g, '\\\\$&')}"`); const isRegistered = registrationRe.test(indexSrc); expect(isRegistered, `tool "${tool}" not registered as server.tool("${tool}", ...) in src/index.ts`).toBe(true); }); // 3. Every mapping's path fragment appears in the named source file // (catches refactor that drops the path family). it.each(ENDPOINT_MAP)('$method $path → fragment "$fragment" present in $source', ({ source, fragment }) => { const src = readSource(source); expect( src.includes(fragment), `expected fragment "${fragment}" in ${source} (path family for the swagger op)`, ).toBe(true); }); // 4. Reverse coverage: every distinct tool in the mapping appears // registered (catches dead mappings). it('every tool referenced by ENDPOINT_MAP is registered', () => { const tools = new Set(ENDPOINT_MAP.map(e => e.tool)); const missing: string[] = []; for (const tool of tools) { if (!indexSrc.includes(`"${tool}"`)) missing.push(tool); } expect(missing).toEqual([]); }); });