415 lines
18 KiB
JavaScript
415 lines
18 KiB
JavaScript
const assert = require("assert");
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
const Database = require("better-sqlite3");
|
|
const { PRESERVED_PATHS } = require("../../../src/services/update-repository");
|
|
const {
|
|
accessForUser,
|
|
createEntry,
|
|
ensureTables,
|
|
getEntryBySlug,
|
|
grantPermission,
|
|
listEntries,
|
|
listPermissions,
|
|
listVersions,
|
|
revokePermission,
|
|
restoreVersion,
|
|
searchForAi,
|
|
setEntryWorkflow,
|
|
updateEntry
|
|
} = require("../backend/okf_store");
|
|
const {
|
|
ensureKnowledgeDirs,
|
|
getCommunityKnowledgeFile,
|
|
listCommunityKnowledgeFiles,
|
|
loadKnowledgeEntries,
|
|
listKnowledgePlaceholders,
|
|
saveCorrectionKnowledgeFile,
|
|
saveCommunityKnowledgeFile,
|
|
migrateSingleBracePlaceholders,
|
|
searchFileKnowledge
|
|
} = require("../backend/file_knowledge");
|
|
const { generateKnowledgeFiles } = require("../backend/generate_knowledge");
|
|
const { renderMarkdown } = require("../backend/markdown");
|
|
|
|
const db = new Database(":memory:");
|
|
db.exec(`
|
|
CREATE TABLE user_profiles (
|
|
id TEXT PRIMARY KEY,
|
|
internal_username TEXT NOT NULL UNIQUE,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
`);
|
|
ensureTables(db);
|
|
|
|
const now = Date.now();
|
|
db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("admin-1", "Admin", now, now);
|
|
db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("mod-1", "Mod", now, now);
|
|
db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("user-1", "User", now, now);
|
|
db.prepare("INSERT INTO user_profiles (id, internal_username, created_at, updated_at) VALUES (?, ?, ?, ?)").run("editor-1", "Editor", now, now);
|
|
|
|
const admin = { id: "admin-1", isAdmin: true, isMod: true };
|
|
const mod = { id: "mod-1", isMod: true };
|
|
const user = { id: "user-1" };
|
|
const editor = { id: "editor-1" };
|
|
|
|
assert(PRESERVED_PATHS.includes("knowledge/community"));
|
|
assert(PRESERVED_PATHS.includes("knowledge/corrections"));
|
|
const adminTemplate = fs.readFileSync(path.join(__dirname, "..", "views", "admin.ejs"), "utf8");
|
|
assert(adminTemplate.includes("General OKF"));
|
|
assert(adminTemplate.includes("Community OKF"));
|
|
assert(adminTemplate.includes("System-generated OKF"));
|
|
assert(adminTemplate.includes("tab=general"));
|
|
assert(adminTemplate.includes("tab=community"));
|
|
assert(adminTemplate.includes("tab=system"));
|
|
assert(adminTemplate.includes("selectedSystemFile"));
|
|
assert(adminTemplate.includes("System-generated OKF") && !adminTemplate.includes("Save system file"));
|
|
assert(adminTemplate.includes("data-okf-file-list"));
|
|
assert(adminTemplate.includes("data-okf-file-search"));
|
|
assert(adminTemplate.includes('data-okf-file-filter="category"'));
|
|
assert(adminTemplate.includes("No community OKF files match these filters."));
|
|
assert(adminTemplate.includes("No system-generated OKF files match these filters."));
|
|
const okfPluginSource = fs.readFileSync(path.join(__dirname, "..", "index.js"), "utf8");
|
|
assert(okfPluginSource.includes("preferSpecificKnowledgeChunks"));
|
|
assert(okfPluginSource.includes("trimForContext"));
|
|
assert(okfPluginSource.includes("remaining = 4200"));
|
|
|
|
grantPermission(db, { user_id: "editor-1", level: "edit", notes: "Trusted writer" }, admin);
|
|
assert.equal(accessForUser(db, editor).canEdit, true);
|
|
assert.equal(listPermissions(db).filter((grant) => !grant.revoked_at).length, 1);
|
|
|
|
const entry = createEntry(db, {
|
|
title: "Currency basics",
|
|
slug: "currency-basics",
|
|
category: "Community",
|
|
tags: "currency, coins",
|
|
aliases: "How do coins, bonuses, and payouts work?\nWhat is currency?",
|
|
summary: "Explains the community currency.",
|
|
user_markdown: "Users earn **coins** through activity.",
|
|
moderator_markdown: "Moderators may help users with missing transaction context.",
|
|
admin_markdown: "Admin-only economy configuration details.",
|
|
ai_facts_markdown: "The primary community currency is coins.",
|
|
source_links: "/commands",
|
|
visibility: "mod",
|
|
status: "published",
|
|
review_state: "approved"
|
|
}, admin);
|
|
|
|
assert.equal(entry.slug, "currency-basics");
|
|
assert.deepEqual(entry.aliases, ["How do coins, bonuses, and payouts work?", "What is currency?"]);
|
|
assert.equal(listEntries(db, {}, user).length, 0);
|
|
assert.equal(listEntries(db, {}, mod).length, 1);
|
|
assert.equal(getEntryBySlug(db, "currency-basics", user), null);
|
|
assert.equal(getEntryBySlug(db, "currency-basics", mod).moderator_markdown.includes("Moderators"), true);
|
|
assert.equal(getEntryBySlug(db, "currency-basics", admin).admin_markdown.includes("Admin-only"), true);
|
|
assert(searchForAi(db, "currency", mod).some((item) => item.facts.includes("Moderators")));
|
|
assert.equal(searchForAi(db, "currency", user).length, 0);
|
|
|
|
const publicEntry = createEntry(db, {
|
|
title: "Public help",
|
|
slug: "public-help",
|
|
category: "Support",
|
|
summary: "Public support answer.",
|
|
user_markdown: "Everyone can read this.",
|
|
admin_markdown: "Admin-only implementation notes.",
|
|
ai_facts_markdown: "Admin AI fact.",
|
|
visibility: "user",
|
|
status: "published",
|
|
review_state: "approved"
|
|
}, admin);
|
|
const publicUserContext = searchForAi(db, "public", user);
|
|
const publicAdminContext = searchForAi(db, "public", admin);
|
|
assert(publicUserContext.some((item) => item.id === publicEntry.id));
|
|
assert.equal(publicUserContext.some((item) => item.facts.includes("Admin-only")), false);
|
|
assert(publicAdminContext.some((item) => item.facts.includes("Admin-only implementation notes.")));
|
|
assert(publicAdminContext.some((item) => item.facts.includes("Admin AI fact.")));
|
|
|
|
const proposed = createEntry(db, {
|
|
title: "Draft from editor",
|
|
slug: "draft-from-editor",
|
|
summary: "Editor draft",
|
|
user_markdown: "Draft content."
|
|
}, editor);
|
|
assert.equal(proposed.status, "draft");
|
|
assert.equal(proposed.review_state, "review_pending");
|
|
assert.equal(listEntries(db, {}, user).some((item) => item.slug === "draft-from-editor"), false);
|
|
assert.equal(listEntries(db, {}, editor, { management: true }).some((item) => item.slug === "draft-from-editor"), true);
|
|
|
|
updateEntry(db, "draft-from-editor", {
|
|
title: "Draft from editor",
|
|
slug: "draft-from-editor",
|
|
summary: "Updated editor draft",
|
|
user_markdown: "Updated draft content.",
|
|
change_note: "Editor update."
|
|
}, editor);
|
|
assert.equal(listVersions(db, proposed.id).length, 2);
|
|
|
|
const restored = restoreVersion(db, proposed.id, 1, admin, "Test restore.");
|
|
assert.equal(restored.summary, "Editor draft");
|
|
assert.equal(restored.user_markdown, "Draft content.");
|
|
assert.equal(listVersions(db, proposed.id).length, 3);
|
|
assert.equal(searchForAi(db, "draft", admin).length, 0);
|
|
|
|
setEntryWorkflow(db, "draft-from-editor", "review", admin);
|
|
setEntryWorkflow(db, "draft-from-editor", "publish", admin);
|
|
assert.equal(getEntryBySlug(db, "draft-from-editor", user).summary, "Editor draft");
|
|
|
|
assert(searchForAi(db, "currency", mod).some((item) => item.slug === "currency-basics"));
|
|
assert.equal(searchForAi(db, "admin-only economy", user).length, 0);
|
|
assert(renderMarkdown("<script>alert(1)</script> **safe**").includes("<script>"));
|
|
assert.equal(renderMarkdown("[bad](javascript:alert(1))").includes("javascript:"), false);
|
|
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-okf-"));
|
|
ensureKnowledgeDirs(tempRoot);
|
|
const commandsKnowledgePath = path.join(tempRoot, "knowledge", "core", "commands.md");
|
|
const currencyKnowledgePath = path.join(tempRoot, "knowledge", "community", "currency.md");
|
|
const correctionKnowledgePath = path.join(tempRoot, "knowledge", "corrections", "currency.md");
|
|
fs.writeFileSync(commandsKnowledgePath, [
|
|
"---",
|
|
"id: core.commands",
|
|
"title: Core commands",
|
|
"scope: core",
|
|
"status: active",
|
|
"priority: 1",
|
|
"visibility: user",
|
|
"tags: commands, help",
|
|
"generated: true",
|
|
"editable: false",
|
|
"---",
|
|
"# Commands",
|
|
"Use !help to list available commands.",
|
|
"The public currency is {{community.currency.primary_name}}.",
|
|
"The admin-only reference is {{community.admin-secret.secret_value}}."
|
|
].join("\n"));
|
|
fs.writeFileSync(currencyKnowledgePath, [
|
|
"---",
|
|
"id: community.currency",
|
|
"title: Community currency",
|
|
"scope: community",
|
|
"status: active",
|
|
"priority: 5",
|
|
"visibility: user",
|
|
"tags: currency, coins",
|
|
"primary_name: coins",
|
|
"---",
|
|
"# Coins",
|
|
"The community currency is coins, not points."
|
|
].join("\n"));
|
|
fs.writeFileSync(correctionKnowledgePath, [
|
|
"---",
|
|
"id: correction.currency-name",
|
|
"title: Currency correction",
|
|
"scope: corrections",
|
|
"status: active",
|
|
"priority: 10",
|
|
"visibility: user",
|
|
"tags: currency",
|
|
"---",
|
|
"# Currency name",
|
|
"Always call the currency coins."
|
|
].join("\n"));
|
|
fs.writeFileSync(path.join(tempRoot, "knowledge", "community", "admin-secret.md"), [
|
|
"---",
|
|
"id: community.admin-secret",
|
|
"title: Admin secret",
|
|
"scope: community",
|
|
"status: active",
|
|
"visibility: admin",
|
|
"secret_value: hidden-admin-context",
|
|
"---",
|
|
"# Secret",
|
|
"Only admins should retrieve this."
|
|
].join("\n"));
|
|
fs.writeFileSync(path.join(tempRoot, "knowledge", "community", "people.md"), [
|
|
"---",
|
|
"id: community.people",
|
|
"title: Community people",
|
|
"scope: community",
|
|
"status: active",
|
|
"priority: 20",
|
|
"visibility: user",
|
|
"tags: people, identity, ookamikuntv, jenni",
|
|
"---",
|
|
"# Community people",
|
|
"## OokamiKunTV",
|
|
"OokamiKunTV is a known community contact in Lumi's local community knowledge."
|
|
].join("\n"));
|
|
|
|
const fileEntries = loadKnowledgeEntries(tempRoot);
|
|
assert.equal(fileEntries.length, 5);
|
|
assert(fileEntries.every((item) => item.chunks.length >= 1));
|
|
const userPlaceholders = listKnowledgePlaceholders({ user, rootDir: tempRoot, includeHidden: true });
|
|
assert(userPlaceholders.includes("{{community.currency.primary_name}}"));
|
|
assert.equal(userPlaceholders.includes("{{community.currency.scope}}"), false);
|
|
assert.equal(userPlaceholders.includes("{{plugins.plugin-throne-wishlist.scope}}"), false);
|
|
assert.equal(userPlaceholders.includes("{{community.admin-secret.secret_value}}"), false);
|
|
const adminPlaceholders = listKnowledgePlaceholders({ user: admin, rootDir: tempRoot, includeHidden: true });
|
|
assert(adminPlaceholders.includes("{{community.admin-secret.secret_value}}"));
|
|
const currencyResults = searchFileKnowledge({ query: "currency coins", user, rootDir: tempRoot, limit: 5 });
|
|
assert.equal(currencyResults[0].id, "correction.currency-name");
|
|
assert(currencyResults[0].source_metadata.path.endsWith("corrections/currency.md"));
|
|
assert(currencyResults[0].source_metadata.heading);
|
|
assert(Number.isFinite(currencyResults[0].source_metadata.score));
|
|
const identityResults = searchFileKnowledge({ query: "Who is OokamiKunTV?", user, rootDir: tempRoot, limit: 5 });
|
|
assert.equal(identityResults[0].id, "community.people");
|
|
assert(identityResults[0].facts.includes("OokamiKunTV is a known community contact"));
|
|
const commandUserResult = searchFileKnowledge({ query: "commands", user, rootDir: tempRoot, limit: 5 })[0];
|
|
assert(commandUserResult.facts.includes("The public currency is coins."));
|
|
assert(commandUserResult.facts.includes("[missing OKF reference]"));
|
|
const commandAdminResult = searchFileKnowledge({ query: "commands", user: admin, rootDir: tempRoot, limit: 5 })[0];
|
|
assert(commandAdminResult.facts.includes("The admin-only reference is hidden-admin-context."));
|
|
assert.equal(searchFileKnowledge({ query: "secret", user, rootDir: tempRoot, limit: 5 }).length, 0);
|
|
assert.equal(searchFileKnowledge({ query: "secret", user: admin, rootDir: tempRoot, limit: 5 })[0].id, "community.admin-secret");
|
|
const savedCommunity = saveCommunityKnowledgeFile(tempRoot, {
|
|
title: "Community roles",
|
|
slug: "community-roles",
|
|
id: "community.roles",
|
|
category: "Community",
|
|
visibility: "user",
|
|
status: "active",
|
|
priority: "7",
|
|
tags: "roles, community",
|
|
body: "# Roles\nCommunity roles are documented here."
|
|
});
|
|
assert.equal(savedCommunity.id, "community.roles");
|
|
assert.equal(getCommunityKnowledgeFile(tempRoot, "community-roles").title, "Community roles");
|
|
assert(listCommunityKnowledgeFiles(tempRoot).some((item) => item.slug === "community-roles"));
|
|
const renamedCommunity = saveCommunityKnowledgeFile(tempRoot, {
|
|
existing_slug: "community-roles",
|
|
title: "Community role guide",
|
|
slug: "role-guide",
|
|
id: "community.roles",
|
|
category: "Community",
|
|
visibility: "user",
|
|
status: "active",
|
|
tags: "roles",
|
|
body: "# Roles\nRenamed community role guide."
|
|
});
|
|
assert.equal(renamedCommunity.slug, "role-guide");
|
|
assert(fs.existsSync(path.join(tempRoot, "knowledge", "community", "role-guide.md")));
|
|
assert.equal(fs.existsSync(path.join(tempRoot, "knowledge", "community", "community-roles.md")), false);
|
|
fs.writeFileSync(path.join(tempRoot, "knowledge", "community", "generated.md"), [
|
|
"---",
|
|
"id: community.generated",
|
|
"title: Generated community file",
|
|
"scope: community",
|
|
"generated: true",
|
|
"editable: false",
|
|
"---",
|
|
"# Generated"
|
|
].join("\n"));
|
|
assert.throws(() => saveCommunityKnowledgeFile(tempRoot, {
|
|
existing_slug: "community-generated",
|
|
title: "Blocked",
|
|
body: "# Blocked"
|
|
}), /not editable/);
|
|
const savedCorrection = saveCorrectionKnowledgeFile(tempRoot, {
|
|
title: "Feedback correction",
|
|
slug: "feedback-correction",
|
|
source_feedback_id: "feedback-1",
|
|
source_feedback_url: "/admin/feedback?feedback=feedback-1",
|
|
visibility: "user",
|
|
priority: "120",
|
|
body: "# Feedback correction\nUse the corrected behavior from reviewed feedback."
|
|
});
|
|
assert.equal(savedCorrection.id, "correction.feedback-correction");
|
|
assert.equal(savedCorrection.path, "knowledge/corrections/feedback-correction.md");
|
|
assert.equal(savedCorrection.frontmatter.source_feedback_id, "feedback-1");
|
|
assert.equal(searchFileKnowledge({ query: "corrected behavior", user, rootDir: tempRoot, limit: 5 })[0].id, "correction.feedback-correction");
|
|
fs.writeFileSync(currencyKnowledgePath, [
|
|
"---",
|
|
"id: community.currency",
|
|
"title: Community currency",
|
|
"scope: community",
|
|
"status: active",
|
|
"priority: 5",
|
|
"visibility: user",
|
|
"tags: currency, coins",
|
|
"primary_name: gold stars",
|
|
"---",
|
|
"# Coins",
|
|
"The community currency is gold stars, not points."
|
|
].join("\n"));
|
|
assert(searchFileKnowledge({ query: "commands", user, rootDir: tempRoot, limit: 5 })[0].facts.includes("The public currency is gold stars."));
|
|
fs.rmSync(correctionKnowledgePath, { force: true });
|
|
assert.equal(searchFileKnowledge({ query: "currency coins", user, rootDir: tempRoot, limit: 5 }).some((item) => item.id === "correction.currency-name"), false);
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
|
|
const migrationRoot = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-okf-migrate-"));
|
|
ensureKnowledgeDirs(migrationRoot);
|
|
const legacyPlaceholderPath = path.join(migrationRoot, "knowledge", "community", "legacy.md");
|
|
fs.writeFileSync(legacyPlaceholderPath, [
|
|
"---",
|
|
"id: community.legacy",
|
|
"title: Legacy placeholder",
|
|
"scope: community",
|
|
"status: active",
|
|
"visibility: user",
|
|
"---",
|
|
"# Legacy",
|
|
"The old token is {community.currency.primary_name}.",
|
|
"The new token stays {{community.currency.primary_name}}."
|
|
].join("\n"));
|
|
const migratedPlaceholders = migrateSingleBracePlaceholders(migrationRoot);
|
|
assert.equal(migratedPlaceholders.changed, 1);
|
|
const migratedPlaceholderContent = fs.readFileSync(legacyPlaceholderPath, "utf8");
|
|
assert(migratedPlaceholderContent.includes("The old token is {{community.currency.primary_name}}."));
|
|
assert(migratedPlaceholderContent.includes("The new token stays {{community.currency.primary_name}}."));
|
|
fs.rmSync(migrationRoot, { recursive: true, force: true });
|
|
|
|
const generatedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-okf-generated-"));
|
|
fs.mkdirSync(path.join(generatedRoot, "plugins", "test-plugin"), { recursive: true });
|
|
fs.writeFileSync(path.join(generatedRoot, "package.json"), JSON.stringify({
|
|
name: "lumi-test",
|
|
version: "1.2.3",
|
|
description: "Generated test core."
|
|
}, null, 2));
|
|
fs.writeFileSync(path.join(generatedRoot, "README.md"), "# Lumi Test\n\nGenerated README.");
|
|
fs.mkdirSync(path.join(generatedRoot, "src", "web"), { recursive: true });
|
|
fs.writeFileSync(path.join(generatedRoot, "src", "web", "server.js"), "app.get('/admin/test', handler);\napp.post('/api/test', handler);\n");
|
|
fs.writeFileSync(path.join(generatedRoot, "plugins", "test-plugin", "plugin.json"), JSON.stringify({
|
|
id: "test-plugin",
|
|
name: "Test Plugin",
|
|
version: "0.0.1",
|
|
description: "Generated plugin knowledge."
|
|
}, null, 2));
|
|
fs.writeFileSync(path.join(generatedRoot, "plugins", "test-plugin", "index.js"), [
|
|
"router.get('/settings', handler);",
|
|
"commandRouter.registerCommands('test-plugin', [{ trigger: 'test', description: 'Test command.' }]);"
|
|
].join("\n"));
|
|
const generated = generateKnowledgeFiles(generatedRoot);
|
|
const normalizedGenerated = generated.map((file) => file.replace(/\\/g, "/"));
|
|
assert(normalizedGenerated.some((file) => file.endsWith("knowledge/core/lumi-core.md")));
|
|
assert(normalizedGenerated.some((file) => file.endsWith("knowledge/plugins/test-plugin.md")));
|
|
const generatedCore = fs.readFileSync(path.join(generatedRoot, "knowledge", "core", "lumi-core.md"), "utf8");
|
|
assert(generatedCore.includes("GET /admin/test"));
|
|
assert(generatedCore.includes("## Route Reference"));
|
|
assert(generatedCore.includes("### GET /admin/test"));
|
|
assert(generatedCore.includes("Response format:"));
|
|
const generatedPlugin = fs.readFileSync(path.join(generatedRoot, "knowledge", "plugins", "test-plugin.md"), "utf8");
|
|
assert(generatedPlugin.includes("Test Plugin"));
|
|
assert(generatedPlugin.includes("### GET /plugins/test-plugin/settings"));
|
|
assert(generatedPlugin.includes("Inputs:"));
|
|
assert(generatedPlugin.includes("test"));
|
|
fs.writeFileSync(path.join(generatedRoot, "knowledge", "plugins", "test-plugin.md"), [
|
|
"---",
|
|
"id: plugin.test-plugin",
|
|
"title: Manual Test Plugin",
|
|
"generated: false",
|
|
"editable: true",
|
|
"---",
|
|
"# Manual"
|
|
].join("\n"));
|
|
generateKnowledgeFiles(generatedRoot);
|
|
assert(fs.readFileSync(path.join(generatedRoot, "knowledge", "plugins", "test-plugin.md"), "utf8").includes("Manual Test Plugin"));
|
|
fs.rmSync(generatedRoot, { recursive: true, force: true });
|
|
|
|
const grant = listPermissions(db).find((row) => row.user_id === "editor-1" && !row.revoked_at);
|
|
revokePermission(db, grant.id, admin);
|
|
assert.equal(accessForUser(db, editor).canEdit, false);
|
|
|
|
db.close();
|
|
console.log("OKF plugin verification passed.");
|