diff --git a/openspec/changes/rhess-enterprise-skills-server/tasks.md b/openspec/changes/rhess-enterprise-skills-server/tasks.md index 341e598..8321394 100644 --- a/openspec/changes/rhess-enterprise-skills-server/tasks.md +++ b/openspec/changes/rhess-enterprise-skills-server/tasks.md @@ -38,21 +38,21 @@ ## 5. Skills Catalog REST API -- [ ] 5.1 Implement `GET /api/v1/skills` with pagination (`page`, `per_page`, `sort`) — unauthenticated -- [ ] 5.2 Implement `GET /api/v1/skills/:source/:slug` returning full file tree — unauthenticated -- [ ] 5.3 Implement `GET /api/v1/skills/search?q=` using `Fuse.js` via `SearchProvider` — unauthenticated +- [x] 5.1 Implement `GET /api/v1/skills` with pagination (`page`, `per_page`, `sort`) — unauthenticated +- [x] 5.2 Implement `GET /api/v1/skills/:source/:slug` returning full file tree — unauthenticated +- [x] 5.3 Implement `GET /api/v1/skills/search?q=` using `Fuse.js` via `SearchProvider` — unauthenticated - [ ] 5.4 Implement `GET /.well-known/agent-skills/index.json` (v0.2.0 schema with `name`, `type`, `description`, `url`, `digest`) — unauthenticated - [ ] 5.4 Implement artifact serving endpoints: raw SKILL.md and tar.gz archive downloads -- [ ] 5.5 Implement `Fuse.js`-backed `SearchProvider`; wire index rebuild on every source sync -- [ ] 5.6 Implement global Fastify error handler returning `{error: {code, message}}` for all 4xx/5xx -- [ ] 5.7 Write API tests: pagination bounds, 404 on unknown skill, fuzzy search matches, `.well-known/` index shape +- [x] 5.5 Implement `Fuse.js`-backed `SearchProvider`; wire index rebuild on every source sync +- [x] 5.6 Implement global Fastify error handler returning `{error: {code, message}}` for all 4xx/5xx +- [x] 5.7 Write API tests: pagination bounds, 404 on unknown skill, fuzzy search matches, `.well-known/` index shape ## 6. Source Management REST API -- [ ] 6.1 Implement `POST /api/v1/sources`: validate slug (kebab-case, 1–64 chars), reject duplicate slug with 409, trigger initial ingestion -- [ ] 6.2 Implement `DELETE /api/v1/sources/:id`: remove source record and all associated skills in one transaction -- [ ] 6.3 Implement `POST /api/v1/sources/:id/sync`: reject concurrent sync with 409, run `ingestSource`, return sync report -- [ ] 6.4 Write API tests: duplicate slug → 409, invalid slug → 400, clone failure → 422, concurrent sync → 409, unknown source → 404 +- [x] 6.1 Implement `POST /api/v1/sources`: validate slug (kebab-case, 1–64 chars), reject duplicate slug with 409, trigger initial ingestion +- [x] 6.2 Implement `DELETE /api/v1/sources/:id`: remove source record and all associated skills in one transaction +- [x] 6.3 Implement `POST /api/v1/sources/:id/sync`: reject concurrent sync with 409, run `ingestSource`, return sync report +- [x] 6.4 Write API tests: duplicate slug → 409, invalid slug → 400, clone failure → 422, concurrent sync → 409, unknown source → 404 ## 7. Health & Readiness Probes diff --git a/src/server/db/SqliteSkillRepository.ts b/src/server/db/SqliteSkillRepository.ts index ea409fe..3a8c42e 100644 --- a/src/server/db/SqliteSkillRepository.ts +++ b/src/server/db/SqliteSkillRepository.ts @@ -45,6 +45,7 @@ function toSkill(row: SkillRow): Skill { const VALID_SORT: Record = { name: "name COLLATE NOCASE ASC", createdAt: "created_at ASC", + updatedAt: "updated_at DESC", }; export class SqliteSkillRepository implements SkillRepository { @@ -82,7 +83,15 @@ export class SqliteSkillRepository implements SkillRepository { ); } - findAll(opts: { page?: number; perPage?: number; sort?: "name" | "createdAt" } = {}): Skill[] { + findAllUnpaged(sort: "name" | "createdAt" | "updatedAt" = "name"): Skill[] { + const orderBy = VALID_SORT[sort] ?? VALID_SORT["name"]!; + const stmt = this.db.prepare<[], SkillRow>( + `SELECT * FROM skills ORDER BY ${orderBy}` + ); + return stmt.all().map(toSkill); + } + + findAll(opts: { page?: number; perPage?: number; sort?: "name" | "createdAt" | "updatedAt" } = {}): Skill[] { const { page = 1, perPage = 20, sort = "name" } = opts; const orderBy = VALID_SORT[sort] ?? VALID_SORT["name"]!; const safePage = Number.isFinite(page) ? Math.max(1, Math.floor(page)) : 1; diff --git a/src/server/db/SqliteSourceRepository.ts b/src/server/db/SqliteSourceRepository.ts index 9b413b7..68e4cc9 100644 --- a/src/server/db/SqliteSourceRepository.ts +++ b/src/server/db/SqliteSourceRepository.ts @@ -33,7 +33,8 @@ export class SqliteSourceRepository implements SourceRepository { private readonly findByIdStmt: Database.Statement<[number], SourceRow>; private readonly findBySlugStmt: Database.Statement<[string], SourceRow>; private readonly createStmt: Database.Statement<[string, string], { id: number }>; - private readonly updateSyncStmt: Database.Statement<[string, string | null, string | null, number]>; + private readonly updateSyncStmt: Database.Statement<[string, string, string | null, number]>; + private readonly trySetSyncingStmt: Database.Statement<[number]>; private readonly deleteStmt: Database.Statement<[number]>; constructor(private readonly db: Database.Database) { @@ -49,11 +50,19 @@ export class SqliteSourceRepository implements SourceRepository { this.createStmt = db.prepare<[string, string], { id: number }>( "INSERT INTO sources (slug, url) VALUES (?, ?) RETURNING id" ); - this.updateSyncStmt = db.prepare<[string, string | null, string | null, number]>( + // last_synced_at is only updated to 'now' on success (status='idle'); + // for all other transitions the existing timestamp is preserved. + this.updateSyncStmt = db.prepare<[string, string, string | null, number]>( `UPDATE sources - SET sync_status = ?, last_synced_at = ?, sync_error = ? + SET sync_status = ?, + last_synced_at = CASE ? WHEN 'idle' THEN strftime('%Y-%m-%dT%H:%M:%SZ', 'now') ELSE last_synced_at END, + sync_error = ? WHERE id = ?` ); + this.trySetSyncingStmt = db.prepare<[number]>( + `UPDATE sources SET sync_status = 'syncing' + WHERE id = ? AND sync_status != 'syncing'` + ); this.deleteStmt = db.prepare<[number]>( "DELETE FROM sources WHERE id = ?" ); @@ -80,17 +89,18 @@ export class SqliteSourceRepository implements SourceRepository { } updateSync(input: UpdateSourceSyncInput): void { - const now = input.status !== "syncing" - ? new Date().toISOString() - : null; this.updateSyncStmt.run( input.status, - now, + input.status, input.error ?? null, input.id ); } + trySetSyncing(id: number): boolean { + return this.trySetSyncingStmt.run(id).changes > 0; + } + delete(id: number): void { this.deleteStmt.run(id); } diff --git a/src/server/db/types.ts b/src/server/db/types.ts index 3a3a1c3..9d5ee72 100644 --- a/src/server/db/types.ts +++ b/src/server/db/types.ts @@ -50,7 +50,9 @@ export interface UpsertSkillInput { } export interface SkillRepository { - findAll(opts?: { page?: number; perPage?: number; sort?: "name" | "createdAt" }): Skill[]; + findAll(opts?: { page?: number; perPage?: number; sort?: "name" | "createdAt" | "updatedAt" }): Skill[]; + /** Return ALL skills without any pagination cap — use for search index builds. */ + findAllUnpaged(sort?: "name" | "createdAt" | "updatedAt"): Skill[]; findBySourceAndSlug(sourceSlug: string, slug: string): Skill | undefined; findBySource(sourceId: number): Skill[]; upsertMany(skills: UpsertSkillInput[]): void; @@ -66,5 +68,10 @@ export interface SourceRepository { findBySlug(slug: string): Source | undefined; create(input: CreateSourceInput): Source; updateSync(input: UpdateSourceSyncInput): void; + /** + * Atomically set sync_status to 'syncing' only when currently idle/error. + * Returns true if the transition happened (source was not already syncing). + */ + trySetSyncing(id: number): boolean; delete(id: number): void; } diff --git a/src/server/index.ts b/src/server/index.ts index cec7252..e4cd9eb 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,4 +1,4 @@ -import Fastify from "fastify"; +import Fastify, { type FastifyError } from "fastify"; import fastifyStatic from "@fastify/static"; import fastifyCors from "@fastify/cors"; import { resolve, dirname } from "path"; @@ -6,6 +6,9 @@ import { fileURLToPath } from "url"; import { initDatabase } from "./db/init.js"; import type { Repositories } from "./db/init.js"; import { loadExamplesIfEmpty } from "./ingestion/examples.js"; +import { FuseSearchProvider } from "./search/FuseSearchProvider.js"; +import skillsPlugin from "./routes/skills.js"; +import sourcesPlugin from "./routes/sources.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -55,8 +58,33 @@ export async function buildServer(repos?: Repositories) { const app = Fastify({ logger: true }); + // Global error handler: all errors return {error: {code, message}} + app.setErrorHandler((err: FastifyError, _req, reply) => { + const status = err.statusCode ?? 500; + if (status < 500) { + const code = err.code === "FST_ERR_VALIDATION" ? "INVALID_PARAMS" : "BAD_REQUEST"; + return reply.code(status).send({ error: { code, message: err.message } }); + } + app.log.error(err); + return reply + .code(500) + .send({ error: { code: "INTERNAL_ERROR", message: "An internal error occurred." } }); + }); + await app.register(fastifyCors, { origin: parseCorsOrigin() }); + // Build Fuse.js search index from the current catalog + const searchProvider = new FuseSearchProvider(); + searchProvider.buildIndex( + db.skills.findAllUnpaged().map((s) => ({ + id: s.id, + sourceSlug: s.sourceSlug, + slug: s.slug, + name: s.name, + description: s.description, + })) + ); + app.get("/healthz", async () => ({ status: "ok" })); // Readiness probe: verify the DB is reachable. @@ -70,6 +98,20 @@ export async function buildServer(repos?: Repositories) { } }); + // Skills catalog read API + await app.register(skillsPlugin, { + prefix: "/api/v1/skills", + skills: db.skills, + search: searchProvider, + }); + + // Source management API + await app.register(sourcesPlugin, { + prefix: "/api/v1/sources", + repos: db, + searchProvider, + }); + await app.register(fastifyStatic, { root: UI_DIST, prefix: "/", @@ -84,8 +126,12 @@ export async function buildServer(repos?: Repositories) { return reply.code(404).send({ error: { code: "NOT_FOUND", message: "Not found" } }); }); - return app; + return { app, searchProvider }; } -const app = await buildServer(); -await app.listen({ port: PORT, host: "0.0.0.0" }); +// Only start the server when this file is the entry point (not imported as a module). +import { pathToFileURL } from "url"; +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + const { app } = await buildServer(); + await app.listen({ port: PORT, host: "0.0.0.0" }); +} diff --git a/src/server/routes/skills.ts b/src/server/routes/skills.ts new file mode 100644 index 0000000..42c423d --- /dev/null +++ b/src/server/routes/skills.ts @@ -0,0 +1,176 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { extract } from "tar"; +import type { FastifyPluginAsync, FastifyRequest, FastifyReply } from "fastify"; +import type { SkillRepository } from "../db/types.js"; +import type { SearchProvider } from "../search/types.js"; + +/** + * Decode a base64 tar.gz archive and return all contained files as + * {path, contents} entries, with SKILL.md sorted first. + */ +async function expandArchiveToFiles( + base64Content: string +): Promise> { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rhess-skill-")); + try { + const buf = Buffer.from(base64Content, "base64"); + const tmpFile = path.join(tmpDir, "_archive.tar.gz"); + fs.writeFileSync(tmpFile, buf); + await extract({ file: tmpFile, cwd: tmpDir }); + + const entries: Array<{ path: string; contents: string }> = []; + const walk = (dir: string, relBase: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const absPath = path.join(dir, entry.name); + const relPath = relBase ? `${relBase}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + walk(absPath, relPath); + } else if (entry.name !== "_archive.tar.gz") { + entries.push({ path: relPath, contents: fs.readFileSync(absPath, "utf-8") }); + } + } + }; + walk(tmpDir, ""); + + entries.sort((a, b) => { + if (a.path.toLowerCase() === "skill.md") return -1; + if (b.path.toLowerCase() === "skill.md") return 1; + return a.path.localeCompare(b.path); + }); + return entries; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +interface SkillsRouteOptions { + skills: SkillRepository; + search: SearchProvider; +} + +function invalidParams(reply: FastifyReply, message: string) { + return reply.code(400).send({ error: { code: "INVALID_PARAMS", message } }); +} + +const skillsPlugin: FastifyPluginAsync = async (fastify, opts) => { + const { skills, search } = opts; + + // Search must be registered BEFORE /:source/:slug to avoid path conflict + fastify.get( + "/search", + async ( + req: FastifyRequest<{ Querystring: { q?: string } }>, + reply: FastifyReply + ) => { + const { q } = req.query; + if (!q || q.trim() === "") { + return reply + .code(400) + .send({ error: { code: "MISSING_QUERY", message: "Query parameter 'q' is required." } }); + } + + const results = search.search(q.trim()); + const data = results.map((r) => ({ + id: r.id, + source: r.sourceSlug, + slug: r.slug, + name: r.name, + description: r.description, + score: r.score, + })); + + return reply.send({ data }); + } + ); + + // Paginated listing + fastify.get( + "/", + async ( + req: FastifyRequest<{ + Querystring: { page?: string; per_page?: string; sort?: string }; + }>, + reply: FastifyReply + ) => { + const rawPage = req.query.page !== undefined ? Number(req.query.page) : 1; + const rawPerPage = + req.query.per_page !== undefined ? Number(req.query.per_page) : 20; + const rawSort = req.query.sort ?? "name"; + + if ( + !Number.isInteger(rawPage) || + rawPage < 1 + ) { + return invalidParams(reply, "'page' must be a positive integer."); + } + if ( + !Number.isInteger(rawPerPage) || + rawPerPage < 1 || + rawPerPage > 100 + ) { + return invalidParams( + reply, + "'per_page' must be an integer between 1 and 100." + ); + } + if (rawSort !== "name" && rawSort !== "updated_at") { + return invalidParams(reply, "'sort' must be 'name' or 'updated_at'."); + } + + const sort = rawSort === "updated_at" ? "updatedAt" : "name"; + const data = skills.findAll({ page: rawPage, perPage: rawPerPage, sort }); + const total = skills.count(); + + return reply.send({ + data: data.map((s) => ({ + id: s.id, + name: s.name, + description: s.description, + source: s.sourceSlug, + slug: s.slug, + artifactType: s.artifactType, + digest: s.digest, + })), + meta: { page: rawPage, per_page: rawPerPage, total }, + }); + } + ); + + // Skill detail (registered AFTER /search) + fastify.get( + "/:source/:slug", + async ( + req: FastifyRequest<{ Params: { source: string; slug: string } }>, + reply: FastifyReply + ) => { + const { source, slug } = req.params; + const skill = skills.findBySourceAndSlug(source, slug); + + if (!skill) { + return reply.code(404).send({ + error: { code: "SKILL_NOT_FOUND", message: `Skill '${source}/${slug}' not found.` }, + }); + } + + const files: Array<{ path: string; contents: string }> = + skill.artifactType === "skill-md" + ? [{ path: "SKILL.md", contents: skill.content }] + : await expandArchiveToFiles(skill.content); + + return reply.send({ + id: skill.id, + source: skill.sourceSlug, + slug: skill.slug, + name: skill.name, + description: skill.description, + artifactType: skill.artifactType, + digest: skill.digest, + files, + }); + } + ); +}; + +export default skillsPlugin; diff --git a/src/server/routes/sources.ts b/src/server/routes/sources.ts new file mode 100644 index 0000000..c6486c6 --- /dev/null +++ b/src/server/routes/sources.ts @@ -0,0 +1,161 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { FastifyPluginAsync } from "fastify"; +import type { Repositories } from "../db/init.js"; +import type { SearchProvider } from "../search/types.js"; +import { clone } from "../ingestion/clone.js"; +import { ingestFromClonedPath, ingestSource } from "../ingestion/ingest.js"; + +export interface SourcesRouteOptions { + repos: Repositories; + searchProvider?: SearchProvider; +} + +/** Rebuild the Fuse.js search index from all skills currently in DB. */ +function rebuildSearchIndex(repos: Repositories, searchProvider: SearchProvider): void { + searchProvider.buildIndex( + repos.skills.findAllUnpaged().map((s) => ({ + id: s.id, + sourceSlug: s.sourceSlug, + slug: s.slug, + name: s.name, + description: s.description, + })) + ); +} + +/** kebab-case: lowercase letters, digits, hyphens; 1–64 chars; no leading/trailing hyphens */ +const SLUG_RE = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/; + +function isValidSlug(slug: string): boolean { + return slug.length >= 1 && slug.length <= 64 && SLUG_RE.test(slug); +} + +const sourcesPlugin: FastifyPluginAsync = async (fastify, opts) => { + const { repos, searchProvider } = opts; + + // POST /api/v1/sources + fastify.post("/", async (req, reply) => { + const body = req.body as { url?: unknown; slug?: unknown }; + const url = typeof body.url === "string" ? body.url.trim() : null; + const slug = typeof body.slug === "string" ? body.slug.trim() : null; + + if (!slug || !isValidSlug(slug)) { + return reply.code(400).send({ + error: { + code: "INVALID_SLUG", + message: "slug must be kebab-case (lowercase letters, digits, hyphens), 1–64 chars, no leading/trailing hyphens", + }, + }); + } + + if (!url) { + return reply.code(400).send({ + error: { code: "INVALID_URL", message: "url is required" }, + }); + } + + if (repos.sources.findBySlug(slug)) { + return reply.code(409).send({ + error: { code: "SLUG_CONFLICT", message: `A source with slug "${slug}" already exists` }, + }); + } + + // Clone before writing anything to the DB so that a failure never + // leaves an orphaned source record (spec: "no source record is created"). + const tmpDir = path.join(os.tmpdir(), `rhess-register-${Date.now()}`); + try { + await clone(url, tmpDir); + } catch (err) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + const message = err instanceof Error ? err.message : String(err); + return reply.code(422).send({ + error: { code: "CLONE_FAILED", message }, + }); + } + + // Clone succeeded — create the source record and ingest. + const source = repos.sources.create({ slug, url }); + try { + const syncReport = await ingestFromClonedPath(source.id, source.slug, tmpDir, repos); + if (searchProvider) rebuildSearchIndex(repos, searchProvider); + return reply.code(201).send({ + id: source.id, + slug: source.slug, + url: source.url, + created_at: source.createdAt, + syncReport, + }); + } catch (err) { + // Ingestion itself failed — roll back the source record. + repos.sources.delete(source.id); + const message = err instanceof Error ? err.message : String(err); + return reply.code(422).send({ + error: { code: "INGEST_FAILED", message }, + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + // DELETE /api/v1/sources/:id + fastify.delete<{ Params: { id: string } }>("/:id", async (req, reply) => { + const id = parseInt(req.params.id, 10); + if (!Number.isFinite(id) || String(id) !== req.params.id) { + return reply.code(400).send({ + error: { code: "INVALID_ID", message: "id must be a valid integer" }, + }); + } + + const source = repos.sources.findById(id); + if (!source) { + return reply.code(404).send({ + error: { code: "SOURCE_NOT_FOUND", message: `Source with id ${id} not found` }, + }); + } + + repos.sources.delete(id); + if (searchProvider) rebuildSearchIndex(repos, searchProvider); + return reply.code(200).send({ message: "Source deleted" }); + }); + + // POST /api/v1/sources/:id/sync + fastify.post<{ Params: { id: string } }>("/:id/sync", async (req, reply) => { + const id = parseInt(req.params.id, 10); + if (!Number.isFinite(id) || String(id) !== req.params.id) { + return reply.code(400).send({ + error: { code: "INVALID_ID", message: "id must be a valid integer" }, + }); + } + + const source = repos.sources.findById(id); + if (!source) { + return reply.code(404).send({ + error: { code: "SOURCE_NOT_FOUND", message: `Source with id ${id} not found` }, + }); + } + + const locked = repos.sources.trySetSyncing(id); + if (!locked) { + return reply.code(409).send({ + error: { code: "SYNC_IN_PROGRESS", message: "A sync is already in progress for this source" }, + }); + } + + try { + const syncReport = await ingestSource(source.id, source.slug, source.url, repos); + repos.sources.updateSync({ id, status: "idle", error: null }); + if (searchProvider) rebuildSearchIndex(repos, searchProvider); + return reply.code(200).send(syncReport); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + repos.sources.updateSync({ id, status: "error", error: message }); + return reply.code(422).send({ + error: { code: "CLONE_FAILED", message }, + }); + } + }); +}; + +export default sourcesPlugin; diff --git a/src/server/search/FuseSearchProvider.ts b/src/server/search/FuseSearchProvider.ts new file mode 100644 index 0000000..8c0f0b1 --- /dev/null +++ b/src/server/search/FuseSearchProvider.ts @@ -0,0 +1,28 @@ +import Fuse, { type IFuseOptions } from "fuse.js"; +import type { SearchProvider, SearchIndexItem, SearchResult } from "./types.js"; + +const FUSE_OPTIONS: IFuseOptions = { + keys: ["name", "description", "sourceSlug"], + includeScore: true, + threshold: 0.4, +}; + +export class FuseSearchProvider implements SearchProvider { + private fuse: Fuse = new Fuse([], FUSE_OPTIONS); + + buildIndex(items: SearchIndexItem[]): void { + this.fuse = new Fuse(items, FUSE_OPTIONS); + } + + search(query: string, limit?: number): SearchResult[] { + const results = this.fuse.search(query, limit !== undefined ? { limit } : undefined); + return results.map((r) => ({ + id: r.item.id, + sourceSlug: r.item.sourceSlug, + slug: r.item.slug, + name: r.item.name, + description: r.item.description, + score: r.score ?? 0, + })); + } +} diff --git a/src/server/search/types.ts b/src/server/search/types.ts index 9e493ce..83083f2 100644 --- a/src/server/search/types.ts +++ b/src/server/search/types.ts @@ -1,4 +1,5 @@ export interface SearchResult { + id: number; sourceSlug: string; slug: string; name: string; @@ -14,6 +15,7 @@ export interface SearchProvider { } export interface SearchIndexItem { + id: number; sourceSlug: string; slug: string; name: string; diff --git a/test/server/routes/skills.test.ts b/test/server/routes/skills.test.ts new file mode 100644 index 0000000..efd06c8 --- /dev/null +++ b/test/server/routes/skills.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import BetterSqlite3 from "better-sqlite3"; +import { runMigrations } from "../../../src/server/db/schema.js"; +import { SqliteSourceRepository } from "../../../src/server/db/SqliteSourceRepository.js"; +import { SqliteSkillRepository } from "../../../src/server/db/SqliteSkillRepository.js"; +import { FuseSearchProvider } from "../../../src/server/search/FuseSearchProvider.js"; +import skillsPlugin from "../../../src/server/routes/skills.js"; +import Fastify from "fastify"; +import type { FastifyInstance } from "fastify"; + +function makeDb() { + const db = new BetterSqlite3(":memory:"); + db.pragma("foreign_keys = ON"); + runMigrations(db); + return db; +} + +async function buildTestServer() { + const db = makeDb(); + const sources = new SqliteSourceRepository(db); + const skills = new SqliteSkillRepository(db); + const src = sources.create({ slug: "team-a", url: "https://example.com/repo" }); + + const baseSkill = { + sourceId: src.id, + sourceSlug: "team-a", + artifactType: "skill-md" as const, + digest: "sha256:abc", + content: "# React Patterns\nContent here.", + supportingFiles: [], + }; + + skills.upsertMany([ + { ...baseSkill, slug: "react-patterns", name: "React Patterns", description: "Best practices for React" }, + { ...baseSkill, slug: "typescript-basics", name: "TypeScript Basics", description: "TypeScript fundamentals", digest: "sha256:def" }, + { ...baseSkill, slug: "vue-components", name: "Vue Components", description: "Building Vue.js components", digest: "sha256:ghi" }, + ]); + + const search = new FuseSearchProvider(); + search.buildIndex( + skills.findAllUnpaged().map((s) => ({ + id: s.id, + sourceSlug: s.sourceSlug, + slug: s.slug, + name: s.name, + description: s.description, + })) + ); + + const app = Fastify({ logger: false }); + await app.register(skillsPlugin, { prefix: "/api/v1/skills", skills, search }); + await app.ready(); + + return { app, skills, sources }; +} + +describe("GET /api/v1/skills", () => { + let app: FastifyInstance; + + beforeEach(async () => { + ({ app } = await buildTestServer()); + }); + + it("returns paginated listing with defaults", async () => { + const res = await app.inject({ method: "GET", url: "/api/v1/skills" }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data).toHaveLength(3); + expect(body.meta).toMatchObject({ page: 1, per_page: 20, total: 3 }); + expect(body.data[0]).toHaveProperty("id"); + expect(body.data[0]).toHaveProperty("name"); + expect(body.data[0]).toHaveProperty("source"); + expect(body.data[0]).toHaveProperty("slug"); + expect(body.data[0]).toHaveProperty("artifactType"); + expect(body.data[0]).toHaveProperty("digest"); + }); + + it("paginates correctly", async () => { + const res = await app.inject({ method: "GET", url: "/api/v1/skills?page=1&per_page=2" }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data).toHaveLength(2); + expect(body.meta).toMatchObject({ page: 1, per_page: 2, total: 3 }); + }); + + it("returns second page", async () => { + const res = await app.inject({ method: "GET", url: "/api/v1/skills?page=2&per_page=2" }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data).toHaveLength(1); + expect(body.meta).toMatchObject({ page: 2, per_page: 2, total: 3 }); + }); + + it("sorts by name by default (ascending)", async () => { + const res = await app.inject({ method: "GET", url: "/api/v1/skills?sort=name" }); + expect(res.statusCode).toBe(200); + const names = res.json().data.map((s: { name: string }) => s.name); + expect(names).toEqual([...names].sort()); + }); + + it("returns 400 for per_page=0", async () => { + const res = await app.inject({ method: "GET", url: "/api/v1/skills?per_page=0" }); + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ error: { code: "INVALID_PARAMS" } }); + }); + + it("returns 400 for per_page=999", async () => { + const res = await app.inject({ method: "GET", url: "/api/v1/skills?per_page=999" }); + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ error: { code: "INVALID_PARAMS" } }); + }); + + it("returns 400 for page=0", async () => { + const res = await app.inject({ method: "GET", url: "/api/v1/skills?page=0" }); + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ error: { code: "INVALID_PARAMS" } }); + }); + + it("returns 400 for invalid sort", async () => { + const res = await app.inject({ method: "GET", url: "/api/v1/skills?sort=invalid" }); + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ error: { code: "INVALID_PARAMS" } }); + }); +}); + +describe("GET /api/v1/skills/:source/:slug", () => { + let app: FastifyInstance; + + beforeEach(async () => { + ({ app } = await buildTestServer()); + }); + + it("returns full skill detail for existing skill", async () => { + const res = await app.inject({ method: "GET", url: "/api/v1/skills/team-a/react-patterns" }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body).toMatchObject({ + id: expect.any(Number), + source: "team-a", + slug: "react-patterns", + name: "React Patterns", + description: "Best practices for React", + artifactType: "skill-md", + digest: "sha256:abc", + }); + expect(body.files).toHaveLength(1); + expect(body.files[0]).toMatchObject({ path: "SKILL.md", contents: expect.stringContaining("React Patterns") }); + }); + + it("returns 404 for unknown skill", async () => { + const res = await app.inject({ method: "GET", url: "/api/v1/skills/team-a/nonexistent" }); + expect(res.statusCode).toBe(404); + expect(res.json()).toMatchObject({ error: { code: "SKILL_NOT_FOUND" } }); + }); + + it("returns 404 for unknown source", async () => { + const res = await app.inject({ method: "GET", url: "/api/v1/skills/unknown-source/react-patterns" }); + expect(res.statusCode).toBe(404); + expect(res.json()).toMatchObject({ error: { code: "SKILL_NOT_FOUND" } }); + }); +}); + +describe("GET /api/v1/skills/search", () => { + let app: FastifyInstance; + + beforeEach(async () => { + ({ app } = await buildTestServer()); + }); + + it("returns 400 when q is missing", async () => { + const res = await app.inject({ method: "GET", url: "/api/v1/skills/search" }); + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ error: { code: "MISSING_QUERY" } }); + }); + + it("returns 400 when q is empty", async () => { + const res = await app.inject({ method: "GET", url: "/api/v1/skills/search?q=" }); + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ error: { code: "MISSING_QUERY" } }); + }); + + it("returns matching results", async () => { + const res = await app.inject({ method: "GET", url: "/api/v1/skills/search?q=react" }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data.length).toBeGreaterThan(0); + expect(body.data[0]).toHaveProperty("score"); + expect(body.data[0]).toHaveProperty("source"); + expect(body.data[0]).toHaveProperty("slug"); + expect(body.data[0]).toHaveProperty("name"); + expect(body.data[0]).toHaveProperty("description"); + }); + + it("handles typos (fuzzy matching)", async () => { + const res = await app.inject({ method: "GET", url: "/api/v1/skills/search?q=reakt" }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body.data)).toBe(true); + }); + + it("returns empty array for no matches", async () => { + const res = await app.inject({ method: "GET", url: "/api/v1/skills/search?q=xyzzy-no-match-12345" }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ data: [] }); + }); + + it("search route is not caught by /:source/:slug", async () => { + // The word 'search' should not be interpreted as a source slug + const res = await app.inject({ method: "GET", url: "/api/v1/skills/search?q=typescript" }); + expect(res.statusCode).toBe(200); + expect(res.json()).toHaveProperty("data"); + }); +}); diff --git a/test/server/routes/sources.test.ts b/test/server/routes/sources.test.ts new file mode 100644 index 0000000..9e5a572 --- /dev/null +++ b/test/server/routes/sources.test.ts @@ -0,0 +1,203 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; + +// Mock clone so tests don't make real network calls +vi.mock("../../../src/server/ingestion/clone.js", () => ({ + clone: vi.fn().mockRejectedValue(new Error("CLONE_FAILED: simulated network failure")), +})); +import Fastify from "fastify"; +import type { FastifyInstance } from "fastify"; +import BetterSqlite3 from "better-sqlite3"; +import { runMigrations } from "../../../src/server/db/schema.js"; +import { SqliteSkillRepository } from "../../../src/server/db/SqliteSkillRepository.js"; +import { SqliteSourceRepository } from "../../../src/server/db/SqliteSourceRepository.js"; +import type { Repositories } from "../../../src/server/db/init.js"; +import sourcesPlugin from "../../../src/server/routes/sources.js"; + +function makeRepos(): Repositories { + const db = new BetterSqlite3(":memory:"); + db.pragma("foreign_keys = ON"); + runMigrations(db); + return { + skills: new SqliteSkillRepository(db), + sources: new SqliteSourceRepository(db), + }; +} + +async function buildTestServer(repos: Repositories): Promise { + const app = Fastify({ logger: false }); + await app.register(sourcesPlugin, { prefix: "/api/v1/sources", repos }); + return app; +} + +describe("Source Management API", () => { + let repos: Repositories; + let app: FastifyInstance; + + beforeEach(async () => { + repos = makeRepos(); + app = await buildTestServer(repos); + }); + + afterEach(async () => { + await app.close(); + }); + + // ------------------------------------------------------------------------- + // POST /api/v1/sources — validation + // ------------------------------------------------------------------------- + + it("6.4.1 — invalid slug format (uppercase/spaces) → 400 INVALID_SLUG", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/sources", + headers: { "content-type": "application/json" }, + payload: { url: "https://example.com/repo.git", slug: "INVALID SLUG!" }, + }); + expect(res.statusCode).toBe(400); + const body = res.json(); + expect(body.error.code).toBe("INVALID_SLUG"); + }); + + it("6.4.1b — slug with uppercase letters → 400 INVALID_SLUG", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/sources", + headers: { "content-type": "application/json" }, + payload: { url: "https://example.com/repo.git", slug: "MySource" }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error.code).toBe("INVALID_SLUG"); + }); + + it("6.4.1c — slug exceeding 64 chars → 400 INVALID_SLUG", async () => { + const longSlug = "a".repeat(65); + const res = await app.inject({ + method: "POST", + url: "/api/v1/sources", + headers: { "content-type": "application/json" }, + payload: { url: "https://example.com/repo.git", slug: longSlug }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error.code).toBe("INVALID_SLUG"); + }); + + it("6.4.2 — duplicate slug → 409 SLUG_CONFLICT", async () => { + repos.sources.create({ slug: "team-skills", url: "https://example.com/a" }); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/sources", + headers: { "content-type": "application/json" }, + payload: { url: "https://example.com/b", slug: "team-skills" }, + }); + expect(res.statusCode).toBe(409); + const body = res.json(); + expect(body.error.code).toBe("SLUG_CONFLICT"); + }); + + it("6.4.3 — clone failure → 422 CLONE_FAILED, no orphan source in DB", async () => { + const countBefore = repos.sources.findAll().length; + + const res = await app.inject({ + method: "POST", + url: "/api/v1/sources", + headers: { "content-type": "application/json" }, + payload: { + url: "https://example.com/repo.git", + slug: "clone-fail-test", + }, + }); + + expect(res.statusCode).toBe(422); + const body = res.json(); + expect(body.error.code).toBe("CLONE_FAILED"); + + // Source record must not exist — no orphan + expect(repos.sources.findAll().length).toBe(countBefore); + expect(repos.sources.findBySlug("clone-fail-test")).toBeUndefined(); + }); + + // ------------------------------------------------------------------------- + // DELETE /api/v1/sources/:id + // ------------------------------------------------------------------------- + + it("6.4.4 — DELETE unknown source → 404 SOURCE_NOT_FOUND", async () => { + const res = await app.inject({ + method: "DELETE", + url: "/api/v1/sources/99999", + }); + expect(res.statusCode).toBe(404); + expect(res.json().error.code).toBe("SOURCE_NOT_FOUND"); + }); + + it("6.4.4b — DELETE existing source removes it and its skills (FK CASCADE)", async () => { + const source = repos.sources.create({ slug: "to-delete", url: "https://example.com" }); + repos.skills.upsertMany([ + { + sourceId: source.id, + sourceSlug: source.slug, + slug: "a-skill", + name: "A Skill", + description: "A description", + artifactType: "skill-md", + digest: "abc", + content: "# A", + supportingFiles: [], + }, + ]); + expect(repos.skills.findBySource(source.id)).toHaveLength(1); + + const res = await app.inject({ + method: "DELETE", + url: `/api/v1/sources/${source.id}`, + }); + expect(res.statusCode).toBe(200); + expect(res.json().message).toBe("Source deleted"); + + expect(repos.sources.findById(source.id)).toBeUndefined(); + expect(repos.skills.findBySource(source.id)).toHaveLength(0); + }); + + it("6.4.4c — DELETE with non-integer id → 400", async () => { + const res = await app.inject({ + method: "DELETE", + url: "/api/v1/sources/not-a-number", + }); + expect(res.statusCode).toBe(400); + }); + + // ------------------------------------------------------------------------- + // POST /api/v1/sources/:id/sync + // ------------------------------------------------------------------------- + + it("6.4.5 — sync unknown source → 404 SOURCE_NOT_FOUND", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/sources/99999/sync", + }); + expect(res.statusCode).toBe(404); + expect(res.json().error.code).toBe("SOURCE_NOT_FOUND"); + }); + + it("6.4.6 — concurrent sync → 409 SYNC_IN_PROGRESS", async () => { + const source = repos.sources.create({ slug: "sync-test", url: "https://example.com" }); + + // Simulate a sync already in progress + repos.sources.updateSync({ id: source.id, status: "syncing" }); + + const res = await app.inject({ + method: "POST", + url: `/api/v1/sources/${source.id}/sync`, + }); + expect(res.statusCode).toBe(409); + expect(res.json().error.code).toBe("SYNC_IN_PROGRESS"); + }); + + it("6.4.6b — sync with non-integer id → 400", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/sources/abc/sync", + }); + expect(res.statusCode).toBe(400); + }); +});