[nexus] test(19-02): route-level integration tests for adapter-aware skill routes

- Add 18 supertest tests covering install/uninstall/rollback/group routes
- Verify 400 (missing agentId), 404 (unknown agent), 422 (unsupported adapter)
- Verify 403 for native skill deletion
- Verify hermes_local agents trigger syncHermesNativeSkills before list
- Verify group assign resolves dir from URL agentId param
- Fix wildcard route syntax from :skillId(*) to *skillId (Express 5 / path-to-regexp v8)
  resolves pre-existing TS errors for these routes too
This commit is contained in:
Mikkel Georgsen 2026-04-01 11:29:03 +02:00
parent 1cae145533
commit 12c3565a13
2 changed files with 387 additions and 4 deletions

View file

@ -0,0 +1,382 @@
/**
* Route-level integration tests for adapter-aware skill registry routes.
*
* Verifies that:
* - Install/uninstall/rollback routes accept agentId (not agentSkillsDir) and resolve paths server-side
* - Group assign/remove routes resolve dir from URL agentId param
* - 400 returned for missing agentId
* - 404 returned for unknown agentId
* - 422 returned for unsupported adapter
* - 403 returned for native skill delete
* - hermes_local agents get syncHermesNativeSkills called before listing
*/
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { skillRegistryRoutes } from "../routes/skill-registry.js";
import { skillGroupRoutes } from "../routes/skill-registry-groups.js";
import { errorHandler } from "../middleware/index.js";
// ---------------------------------------------------------------------------
// Hoisted mocks
// ---------------------------------------------------------------------------
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockResolveAdapterSkillConfig = vi.hoisted(() => vi.fn());
const mockSkillRegistryService = vi.hoisted(() => ({
list: vi.fn(),
getVersions: vi.fn(),
getById: vi.fn(),
install: vi.fn(),
uninstall: vi.fn(),
rollback: vi.fn(),
fetchAll: vi.fn(),
syncHermesNativeSkills: vi.fn(),
}));
const mockSkillGroupService = vi.hoisted(() => ({
listGroups: vi.fn(),
createGroup: vi.fn(),
getGroup: vi.fn(),
updateGroup: vi.fn(),
deleteGroup: vi.fn(),
listMembers: vi.fn(),
addMember: vi.fn(),
removeMember: vi.fn(),
exportGroup: vi.fn(),
importGroup: vi.fn(),
listAgentGroups: vi.fn(),
assignGroup: vi.fn(),
removeGroup: vi.fn(),
listAgentSkills: vi.fn(),
}));
const mockSkillRatingService = vi.hoisted(() => ({
rate: vi.fn(),
getRatings: vi.fn(),
}));
const mockGetSkillRegistryDb = vi.hoisted(() => vi.fn());
const mockAgentSkillsTable = vi.hoisted(() => ({ agentId: "agent_id", skillId: "skill_id" }));
// ---------------------------------------------------------------------------
// Module mocks
// ---------------------------------------------------------------------------
vi.mock("../services/agents.js", () => ({
agentService: () => mockAgentService,
}));
vi.mock("@paperclipai/adapter-utils", () => ({
resolveAdapterSkillConfig: mockResolveAdapterSkillConfig,
}));
vi.mock("../services/skill-registry.js", () => ({
skillRegistryService: () => mockSkillRegistryService,
}));
vi.mock("../services/skill-registry-groups.js", () => ({
skillGroupService: () => mockSkillGroupService,
}));
vi.mock("../services/skill-registry-ratings.js", () => ({
skillRatingService: () => mockSkillRatingService,
}));
vi.mock("../services/skill-registry-db.js", () => ({
getSkillRegistryDb: mockGetSkillRegistryDb,
}));
vi.mock("../services/skill-registry-schema.js", () => ({
agentSkills: mockAgentSkillsTable,
}));
// Mock drizzle-orm operators used in the DELETE route
vi.mock("drizzle-orm", async (importOriginal) => {
const actual = await importOriginal<typeof import("drizzle-orm")>();
return {
...actual,
};
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeAgent(adapterType: string) {
return {
id: "agent-123",
companyId: "company-1",
name: "Test Agent",
adapterType,
status: "active",
};
}
function makeDb() {
return {} as any;
}
function createApp() {
const app = express();
app.use(express.json());
// Simulate local_trusted board actor
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: true,
};
next();
});
const db = makeDb();
app.use(skillRegistryRoutes(db));
app.use(skillGroupRoutes(db));
app.use(errorHandler);
return app;
}
// Mock the DB returned by getSkillRegistryDb for native skill check
function setupRegistryDb(agentSkillRow?: { source: string } | null) {
const fakeDb = {
select: vi.fn(() => fakeDb),
from: vi.fn(() => fakeDb),
where: vi.fn(async () => agentSkillRow ? [agentSkillRow] : []),
};
mockGetSkillRegistryDb.mockResolvedValue(fakeDb as any);
return fakeDb;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("skill registry routes — adapter-aware install/uninstall", () => {
let app: ReturnType<typeof createApp>;
beforeEach(() => {
vi.clearAllMocks();
app = createApp();
// Default: known agent with supported adapter
mockAgentService.getById.mockResolvedValue(makeAgent("claude_local"));
mockResolveAdapterSkillConfig.mockReturnValue({
supportsInstall: true,
skillDir: "~/.claude/skills",
nativeSkillDir: null,
format: "markdown",
});
mockSkillRegistryService.install.mockResolvedValue({
type: "installed",
skillId: "hub/test-skill",
versionId: "v1",
targetDir: "/home/user/.claude/skills/test-skill",
});
mockSkillRegistryService.uninstall.mockResolvedValue(undefined);
mockSkillRegistryService.rollback.mockResolvedValue(undefined);
mockSkillRegistryService.syncHermesNativeSkills.mockResolvedValue(undefined);
mockSkillGroupService.assignGroup.mockResolvedValue({ groupId: "g1", agentId: "agent-123" });
mockSkillGroupService.removeGroup.mockResolvedValue(undefined);
mockSkillGroupService.listAgentSkills.mockResolvedValue([
{ skillId: "hub/test-skill", source: "managed", installedAt: 1234567890 },
]);
});
// --- Install ---
describe("POST /skill-registry/skills/:sourceId/:slug/install", () => {
it("returns 400 when agentId is missing", async () => {
const res = await request(app)
.post("/skill-registry/skills/hub/test-skill/install")
.send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/agentId/i);
});
it("returns 404 when agentId is unknown", async () => {
mockAgentService.getById.mockResolvedValue(null);
const res = await request(app)
.post("/skill-registry/skills/hub/test-skill/install")
.send({ agentId: "unknown-agent" });
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/Agent not found/i);
});
it("returns 422 when adapter does not support install", async () => {
mockResolveAdapterSkillConfig.mockReturnValue({
supportsInstall: false,
skillDir: null,
format: "none",
unsupportedReason: "Adapter does not support skill install",
});
const res = await request(app)
.post("/skill-registry/skills/hub/test-skill/install")
.send({ agentId: "agent-123" });
expect(res.status).toBe(422);
expect(res.body.error).toMatch(/does not support/i);
});
it("resolves the correct agentSkillsDir and installs when agentId is valid", async () => {
const res = await request(app)
.post("/skill-registry/skills/hub/test-skill/install")
.send({ agentId: "agent-123" });
expect(res.status).toBe(200);
expect(mockAgentService.getById).toHaveBeenCalledWith("agent-123");
expect(mockResolveAdapterSkillConfig).toHaveBeenCalledWith("claude_local");
expect(mockSkillRegistryService.install).toHaveBeenCalledWith(
"hub/test-skill",
expect.stringContaining(".claude/skills"),
);
});
});
// --- Uninstall ---
describe("DELETE /skill-registry/skills/:sourceId/:slug", () => {
it("returns 400 when agentId query param is missing", async () => {
setupRegistryDb(null);
const res = await request(app)
.delete("/skill-registry/skills/hub/test-skill");
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/agentId/i);
});
it("returns 403 when skill is native", async () => {
setupRegistryDb({ source: "native" });
const res = await request(app)
.delete("/skill-registry/skills/hub/test-skill")
.query({ agentId: "agent-123" });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/native/i);
});
it("resolves dir and uninstalls when agentId is valid and skill is managed", async () => {
setupRegistryDb({ source: "managed" });
const res = await request(app)
.delete("/skill-registry/skills/hub/test-skill")
.query({ agentId: "agent-123" });
expect(res.status).toBe(200);
expect(mockSkillRegistryService.uninstall).toHaveBeenCalledWith(
"hub/test-skill",
expect.stringContaining(".claude/skills"),
);
});
it("returns 404 when agentId is unknown", async () => {
setupRegistryDb(null);
mockAgentService.getById.mockResolvedValue(null);
const res = await request(app)
.delete("/skill-registry/skills/hub/test-skill")
.query({ agentId: "unknown-agent" });
expect(res.status).toBe(404);
});
});
// --- Rollback ---
describe("POST /skill-registry/skills/:sourceId/:slug/rollback", () => {
it("returns 400 when agentId is missing", async () => {
const res = await request(app)
.post("/skill-registry/skills/hub/test-skill/rollback")
.send({ versionId: "v1" });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/agentId/i);
});
it("returns 404 when agentId is unknown", async () => {
mockAgentService.getById.mockResolvedValue(null);
const res = await request(app)
.post("/skill-registry/skills/hub/test-skill/rollback")
.send({ versionId: "v1", agentId: "unknown-agent" });
expect(res.status).toBe(404);
});
it("resolves dir and rolls back when agentId is valid", async () => {
const res = await request(app)
.post("/skill-registry/skills/hub/test-skill/rollback")
.send({ versionId: "v1", agentId: "agent-123" });
expect(res.status).toBe(200);
expect(mockSkillRegistryService.rollback).toHaveBeenCalledWith(
"hub/test-skill",
"v1",
expect.stringContaining(".claude/skills"),
);
});
});
// --- Agent skills list with hermes sync ---
describe("GET /skill-registry/agents/:agentId/skills", () => {
it("returns skills with source field", async () => {
const res = await request(app)
.get("/skill-registry/agents/agent-123/skills");
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body[0]).toHaveProperty("source");
expect(res.body[0]).toHaveProperty("skillId");
expect(res.body[0]).toHaveProperty("installedAt");
});
it("calls syncHermesNativeSkills for hermes_local agents before listing", async () => {
mockAgentService.getById.mockResolvedValue(makeAgent("hermes_local"));
const res = await request(app)
.get("/skill-registry/agents/agent-123/skills");
expect(res.status).toBe(200);
expect(mockSkillRegistryService.syncHermesNativeSkills).toHaveBeenCalledWith("agent-123");
expect(mockSkillGroupService.listAgentSkills).toHaveBeenCalledWith("agent-123");
});
it("does not call syncHermesNativeSkills for non-hermes agents", async () => {
const res = await request(app)
.get("/skill-registry/agents/agent-123/skills");
expect(res.status).toBe(200);
expect(mockSkillRegistryService.syncHermesNativeSkills).not.toHaveBeenCalled();
});
it("returns 404 when agent is unknown", async () => {
mockAgentService.getById.mockResolvedValue(null);
const res = await request(app)
.get("/skill-registry/agents/unknown-agent/skills");
expect(res.status).toBe(404);
});
});
// --- Group assign/remove ---
describe("POST /skill-registry/agents/:agentId/groups", () => {
it("returns 400 when groupId is missing", async () => {
const res = await request(app)
.post("/skill-registry/agents/agent-123/groups")
.send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/groupId/i);
});
it("returns 404 when agentId is unknown", async () => {
mockAgentService.getById.mockResolvedValue(null);
const res = await request(app)
.post("/skill-registry/agents/unknown-agent/groups")
.send({ groupId: "g1" });
expect(res.status).toBe(404);
});
it("resolves dir from URL agentId and assigns group", async () => {
const res = await request(app)
.post("/skill-registry/agents/agent-123/groups")
.send({ groupId: "g1" });
expect(res.status).toBe(201);
expect(mockAgentService.getById).toHaveBeenCalledWith("agent-123");
expect(mockSkillGroupService.assignGroup).toHaveBeenCalledWith(
"g1",
"agent-123",
expect.stringContaining(".claude/skills"),
);
});
});
});

View file

@ -148,10 +148,10 @@ export function skillGroupRoutes(db: Db): Router {
}
});
router.delete("/skill-registry/groups/:groupId/members/:skillId(*)", async (req, res) => {
router.delete("/skill-registry/groups/:groupId/members/*skillId", async (req, res) => {
assertBoard(req);
try {
const skillId = req.params.skillId;
const skillId = (req.params as any).skillId as string;
await svc.removeMember(req.params.groupId, skillId);
res.status(204).end();
} catch (err) {
@ -202,11 +202,12 @@ export function skillGroupRoutes(db: Db): Router {
});
// Remove a group from an agent — resolves skill dir from agentId URL param server-side
router.delete("/skill-registry/agents/:agentId/groups/:groupId(*)", async (req, res) => {
router.delete("/skill-registry/agents/:agentId/groups/*groupId", async (req, res) => {
assertBoard(req);
try {
const groupId = (req.params as any).groupId as string;
const agentSkillsDir = await resolveSkillsDirForAgent(db, req.params.agentId);
await svc.removeGroup(req.params.groupId, req.params.agentId, agentSkillsDir);
await svc.removeGroup(groupId, req.params.agentId, agentSkillsDir);
res.status(204).end();
} catch (err: any) {
const status = err.status ?? 500;