nexus/server/src/__tests__/skill-registry-schema.test.ts
Mikkel Georgsen cf58f09085 feat(09-01): install @libsql/client, schema, DB init, path helpers
- Install @libsql/client@^0.17.2 to server package
- Create skill-registry-schema.ts with 4 sqliteTable definitions (skills, skillVersions, skillFiles, communityRatings)
- Create skill-registry-db.ts with lazy singleton getSkillRegistryDb() and resetSkillRegistryDb()
- Add resolveSkillRegistryDbPath() and resolveSkillCacheDir() to home-paths.ts
- Add skill-registry-schema.test.ts with 8 passing tests (TDD green)
2026-04-04 03:55:42 +00:00

136 lines
5.5 KiB
TypeScript

import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
// We reset the singleton between tests by calling resetSkillRegistryDb
// and redirecting PAPERCLIP_HOME to an isolated temp dir
describe("skill-registry-schema", () => {
let tmpDir: string;
let originalPaperclipHome: string | undefined;
beforeEach(async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), "skill-registry-test-"));
originalPaperclipHome = process.env.PAPERCLIP_HOME;
process.env.PAPERCLIP_HOME = tmpDir;
});
afterEach(async () => {
// Reset the DB singleton so next test gets a fresh instance
const { resetSkillRegistryDb } = await import("../services/skill-registry-db.js");
resetSkillRegistryDb();
if (originalPaperclipHome === undefined) {
delete process.env.PAPERCLIP_HOME;
} else {
process.env.PAPERCLIP_HOME = originalPaperclipHome;
}
await rm(tmpDir, { recursive: true, force: true });
});
it("Test 1: getSkillRegistryDb() creates registry.db at the resolved path and returns a drizzle instance", async () => {
const { getSkillRegistryDb } = await import("../services/skill-registry-db.js");
const db = await getSkillRegistryDb();
expect(db).toBeDefined();
expect(typeof db.select).toBe("function");
expect(typeof db.insert).toBe("function");
});
it("Test 2: skills table has correct columns", async () => {
const { skills } = await import("../services/skill-registry-schema.js");
const cols = Object.keys(skills);
// Check the table has the expected name
expect((skills as any)[Symbol.for("drizzle:Name") as any] ?? skills._.name).toBeDefined();
const colNames = Object.keys(skills);
// Drizzle table object has column accessors
expect(skills.id).toBeDefined();
expect(skills.sourceId).toBeDefined();
expect(skills.name).toBeDefined();
expect(skills.description).toBeDefined();
expect(skills.sourceUrl).toBeDefined();
expect(skills.activeVersionId).toBeDefined();
expect(skills.removedAt).toBeDefined();
expect(skills.createdAt).toBeDefined();
expect(skills.updatedAt).toBeDefined();
});
it("Test 3: skill_versions table has correct columns", async () => {
const { skillVersions } = await import("../services/skill-registry-schema.js");
expect(skillVersions.id).toBeDefined();
expect(skillVersions.skillId).toBeDefined();
expect(skillVersions.version).toBeDefined();
expect(skillVersions.fetchedAt).toBeDefined();
expect(skillVersions.cacheDir).toBeDefined();
});
it("Test 4: skill_files table has correct columns", async () => {
const { skillFiles } = await import("../services/skill-registry-schema.js");
expect(skillFiles.id).toBeDefined();
expect(skillFiles.versionId).toBeDefined();
expect(skillFiles.path).toBeDefined();
expect(skillFiles.kind).toBeDefined();
expect(skillFiles.sizeBytes).toBeDefined();
});
it("Test 5: community_ratings table has correct columns", async () => {
const { communityRatings } = await import("../services/skill-registry-schema.js");
expect(communityRatings.id).toBeDefined();
expect(communityRatings.skillId).toBeDefined();
expect(communityRatings.fetchedAt).toBeDefined();
expect(communityRatings.averageRating).toBeDefined();
expect(communityRatings.ratingCount).toBeDefined();
expect(communityRatings.source).toBeDefined();
});
it("Test 6: soft-delete — inserting a skill and setting removed_at keeps the row queryable with a WHERE filter", async () => {
const { getSkillRegistryDb } = await import("../services/skill-registry-db.js");
const { skills } = await import("../services/skill-registry-schema.js");
const { eq, isNull, isNotNull } = await import("drizzle-orm");
const db = await getSkillRegistryDb();
const now = Date.now();
await db.insert(skills).values({
id: "test-skill-1",
sourceId: "src-1",
name: "Test Skill",
description: "A test skill",
sourceUrl: null,
activeVersionId: null,
removedAt: null,
createdAt: now,
updatedAt: now,
});
// Before soft-delete: row is visible
const before = await db.select().from(skills).where(isNull(skills.removedAt));
expect(before.length).toBe(1);
// Apply soft-delete
await db.update(skills).set({ removedAt: now + 1000 }).where(eq(skills.id, "test-skill-1"));
// After soft-delete: not visible via active filter
const activeAfter = await db.select().from(skills).where(isNull(skills.removedAt));
expect(activeAfter.length).toBe(0);
// But still visible via removed filter
const removedAfter = await db.select().from(skills).where(isNotNull(skills.removedAt));
expect(removedAfter.length).toBe(1);
expect(removedAfter[0]!.id).toBe("test-skill-1");
});
it("Test 7: resolveSkillRegistryDbPath() returns path ending in skills/registry.db under instance root", async () => {
const { resolveSkillRegistryDbPath } = await import("../home-paths.js");
const dbPath = resolveSkillRegistryDbPath();
expect(dbPath).toMatch(/skills[/\\]registry\.db$/);
expect(dbPath.startsWith(tmpDir)).toBe(true);
});
it("Test 8: resolveSkillCacheDir returns path ending in skills/cache/<skillId>/<versionId>", async () => {
const { resolveSkillCacheDir } = await import("../home-paths.js");
const cacheDir = resolveSkillCacheDir("my-skill", "abc123");
expect(cacheDir).toMatch(/skills[/\\]cache[/\\]my-skill[/\\]abc123$/);
expect(cacheDir.startsWith(tmpDir)).toBe(true);
});
});