Capture current Ansible control plane state
Commit the accumulated infrastructure work that was living only in the working tree: monitoring stack, emergency access/bot, gyro allocator, grimmory, adguard, backup audit and the OpenCode agent definitions. Also ignore Python bytecode, local archives and Nix/direnv artifacts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTocXkGUUazHdKKd3r9k71
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { GrimmoryClient, query, unwrap } from "../src/client.js";
|
||||
|
||||
test("unwrap handles Grimmory API response envelope", () => {
|
||||
assert.deepEqual(unwrap({ status: 200, data: { id: 7 } }), { id: 7 });
|
||||
assert.deepEqual(unwrap([1, 2]), [1, 2]);
|
||||
});
|
||||
|
||||
test("query repeats array values and skips empty values", () => {
|
||||
assert.equal(
|
||||
query({ page: 0, status: ["READING", "READ"], search: "" }),
|
||||
"page=0&status=READING&status=READ",
|
||||
);
|
||||
});
|
||||
|
||||
test("libraries falls back to the current user on the v3.2.4 endpoint bug", async () => {
|
||||
const responses = [
|
||||
new Response(JSON.stringify({ message: "broken" }), { status: 500 }),
|
||||
new Response(JSON.stringify({ data: { assignedLibraries: [{ id: 3 }] } })),
|
||||
];
|
||||
const client = new GrimmoryClient({
|
||||
baseUrl: "https://example.test",
|
||||
username: "ada",
|
||||
passwordFile: "/unused",
|
||||
fetchImpl: async () => responses.shift(),
|
||||
});
|
||||
client.accessToken = "test";
|
||||
assert.deepEqual(await client.libraries(), [{ id: 3 }]);
|
||||
});
|
||||
|
||||
test("springPage supports the nested Spring page metadata", async () => {
|
||||
let request = 0;
|
||||
const client = new GrimmoryClient({
|
||||
baseUrl: "https://example.test",
|
||||
username: "ada",
|
||||
passwordFile: "/unused",
|
||||
fetchImpl: async () => {
|
||||
const page = request++;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
content: page === 0 ? Array.from({ length: 100 }, (_, id) => id) : [100],
|
||||
page: { number: page, totalPages: 2 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
client.accessToken = "test";
|
||||
assert.equal((await client.springPage("/sessions")).length, 101);
|
||||
assert.equal(request, 2);
|
||||
});
|
||||
|
||||
test("allBooks follows nested pagination and rejects missing metadata", async () => {
|
||||
let request = 0;
|
||||
const client = new GrimmoryClient({
|
||||
baseUrl: "https://example.test",
|
||||
username: "ada",
|
||||
passwordFile: "/unused",
|
||||
fetchImpl: async () => {
|
||||
const page = request++;
|
||||
return new Response(JSON.stringify({ data: {
|
||||
content: page === 0 ? Array.from({ length: 50 }, (_, id) => ({ id })) : [{ id: 50 }],
|
||||
page: { number: page, totalPages: 2 },
|
||||
} }));
|
||||
},
|
||||
});
|
||||
client.accessToken = "test";
|
||||
assert.equal((await client.allBooks()).length, 51);
|
||||
assert.equal(request, 2);
|
||||
});
|
||||
|
||||
test("concurrent first requests share one login", async () => {
|
||||
let logins = 0;
|
||||
const client = new GrimmoryClient({
|
||||
baseUrl: "https://example.test",
|
||||
username: "ada",
|
||||
passwordFile: "/unused",
|
||||
fetchImpl: async () => new Response(JSON.stringify({ data: { ok: true } })),
|
||||
});
|
||||
client.login = async () => {
|
||||
logins += 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
client.accessToken = "test";
|
||||
};
|
||||
await Promise.all([client.request("/one"), client.request("/two")]);
|
||||
assert.equal(logins, 1);
|
||||
});
|
||||
|
||||
test("springPage rejects missing pagination metadata on a full page", async () => {
|
||||
const client = new GrimmoryClient({
|
||||
baseUrl: "https://example.test",
|
||||
username: "ada",
|
||||
passwordFile: "/unused",
|
||||
fetchImpl: async () => new Response(JSON.stringify({ data: {
|
||||
content: Array.from({ length: 100 }, (_, id) => ({ id })),
|
||||
} })),
|
||||
});
|
||||
client.accessToken = "test";
|
||||
await assert.rejects(() => client.springPage("/sessions"), /no pagination metadata/);
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chmod, mkdir, mkdtemp, readFile, readdir, symlink, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
ObsidianSync,
|
||||
extractUserSection,
|
||||
progressPercent,
|
||||
renderBookNote,
|
||||
sanitizeFilename,
|
||||
} from "../src/obsidian.js";
|
||||
|
||||
const bundle = {
|
||||
book: {
|
||||
id: 42,
|
||||
libraryId: 1,
|
||||
title: "Test Book",
|
||||
readStatus: "READING",
|
||||
personalRating: 4,
|
||||
metadata: {
|
||||
title: "Test Book",
|
||||
authors: ["Ada Author"],
|
||||
categories: ["Science"],
|
||||
tags: ["AI"],
|
||||
moods: [],
|
||||
pageCount: 300,
|
||||
description: "Description",
|
||||
},
|
||||
shelves: [{ name: "Reading" }],
|
||||
},
|
||||
progress: { readProgress: 0.425, koreaderProgress: { percentage: 42.5 } },
|
||||
notebook: [{ type: "annotation", text: "Quote", note: "Comment" }],
|
||||
annotations: [],
|
||||
bookmarks: [],
|
||||
notes: [],
|
||||
cfiNotes: [],
|
||||
reviews: [],
|
||||
sessions: [],
|
||||
};
|
||||
|
||||
test("render creates a study note with normalized progress", () => {
|
||||
const note = renderBookNote(bundle, "99 System/Export/Grimmory/Covers/42.jpg");
|
||||
assert.match(note, /grimmory_id: 42/);
|
||||
assert.match(note, /status: active/);
|
||||
assert.match(note, /grimmory_progress: 42.5/);
|
||||
assert.match(note, /> Quote/);
|
||||
});
|
||||
|
||||
test("user section survives extraction", () => {
|
||||
const note = renderBookNote(bundle);
|
||||
const edited = note.replace("## Связанные заметки", "## Связанные заметки\n\n- [[AI]]");
|
||||
assert.match(extractUserSection(edited), /\[\[AI\]\]/);
|
||||
});
|
||||
|
||||
test("filename and progress normalization are safe", () => {
|
||||
assert.equal(sanitizeFilename('A/B: C?'), "A-B- C-");
|
||||
assert.equal(progressPercent(0.5), 50);
|
||||
assert.equal(progressPercent(75), 75);
|
||||
});
|
||||
|
||||
test("syncBook creates one note in the configured vault directory", async () => {
|
||||
const { vault, sync } = await tempSync();
|
||||
|
||||
const relative = await sync.syncBook(bundle);
|
||||
const files = await readdir(path.join(vault, "Books"));
|
||||
const content = await readFile(path.join(vault, relative), "utf8");
|
||||
|
||||
assert.equal(relative, "Books/Test Book — grimmory-42.md");
|
||||
assert.deepEqual(files, ["Test Book — grimmory-42.md"]);
|
||||
assert.match(content, /grimmory_id: 42/);
|
||||
assert.match(content, /<!-- grimmory:user:start -->/);
|
||||
});
|
||||
|
||||
test("syncBook preserves the user section and renames title changes without duplicates", async () => {
|
||||
const { vault, sync } = await tempSync();
|
||||
const first = await sync.syncBook({
|
||||
...bundle,
|
||||
book: { ...bundle.book, title: "Old Title", metadata: { ...bundle.book.metadata, title: "Old Title" } },
|
||||
});
|
||||
const edited = (await readFile(path.join(vault, first), "utf8")).replace(
|
||||
"## Связанные заметки",
|
||||
"## Связанные заметки\n\n- [[User Link]]",
|
||||
);
|
||||
await writeFile(path.join(vault, first), edited);
|
||||
|
||||
const second = await sync.syncBook({
|
||||
...bundle,
|
||||
book: { ...bundle.book, title: "New Title", metadata: { ...bundle.book.metadata, title: "New Title" } },
|
||||
});
|
||||
const files = await readdir(path.join(vault, "Books"));
|
||||
const content = await readFile(path.join(vault, second), "utf8");
|
||||
|
||||
assert.equal(second, "Books/New Title — grimmory-42.md");
|
||||
assert.deepEqual(files, ["New Title — grimmory-42.md"]);
|
||||
assert.match(content, /\[\[User Link\]\]/);
|
||||
});
|
||||
|
||||
test("saveCover writes only under the configured covers directory", async () => {
|
||||
const { vault, sync } = await tempSync();
|
||||
const client = {
|
||||
cover: async () =>
|
||||
new Response(Uint8Array.from([0xff, 0xd8, 0xff]), {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
};
|
||||
|
||||
const relative = await sync.saveCover(client, 42);
|
||||
const bytes = await readFile(path.join(vault, relative));
|
||||
|
||||
assert.equal(relative, "Covers/42.jpg");
|
||||
assert.deepEqual([...bytes], [0xff, 0xd8, 0xff]);
|
||||
assert.throws(
|
||||
() => new ObsidianSync({ vaultPath: vault, booksDir: "Books", coversDir: "../Covers", indexScript: "index.py" }),
|
||||
/Path escapes Obsidian vault/,
|
||||
);
|
||||
});
|
||||
|
||||
test("syncBook ignores body IDs and refuses duplicate frontmatter IDs", async () => {
|
||||
const { vault, sync } = await tempSync();
|
||||
await mkdir(path.join(vault, "Books"), { recursive: true });
|
||||
await writeFile(path.join(vault, "Books/unrelated.md"), "Body mentions\ngrimmory_id: 42\n");
|
||||
await sync.syncBook(bundle);
|
||||
assert.equal((await readdir(path.join(vault, "Books"))).length, 2);
|
||||
|
||||
await writeFile(
|
||||
path.join(vault, "Books/duplicate.md"),
|
||||
"---\ntitle: duplicate\ngrimmory_id: 42\n---\n",
|
||||
);
|
||||
await assert.rejects(() => sync.syncBook(bundle), /Duplicate Obsidian notes/);
|
||||
});
|
||||
|
||||
test("syncBook refuses to overwrite a generated note with broken user markers", async () => {
|
||||
const { vault, sync } = await tempSync();
|
||||
const relative = await sync.syncBook(bundle);
|
||||
const file = path.join(vault, relative);
|
||||
const broken = (await readFile(file, "utf8")).replace("<!-- grimmory:user:end -->", "");
|
||||
await writeFile(file, broken);
|
||||
await assert.rejects(() => sync.syncBook(bundle), /invalid user markers/);
|
||||
assert.equal(await readFile(file, "utf8"), broken);
|
||||
});
|
||||
|
||||
test("syncBook rejects malformed or repeated frontmatter IDs", async () => {
|
||||
for (const marker of ["grimmory_id: 42 # comment", "grimmory_id: 42\ngrimmory_id: 42"]) {
|
||||
const { vault, sync } = await tempSync();
|
||||
await mkdir(path.join(vault, "Books"), { recursive: true });
|
||||
await writeFile(path.join(vault, "Books/bad.md"), `---\ntitle: Bad\n${marker}\n---\n`);
|
||||
await assert.rejects(() => sync.syncBook(bundle), /Ambiguous or malformed/);
|
||||
}
|
||||
});
|
||||
|
||||
test("saveCover rejects a covers-directory symlink outside the vault", async () => {
|
||||
const { vault, sync } = await tempSync();
|
||||
const outside = await mkdtemp(path.join(os.tmpdir(), "grimmory-cover-outside-"));
|
||||
await symlink(outside, path.join(vault, "Covers"));
|
||||
let requested = false;
|
||||
await assert.rejects(
|
||||
() => sync.saveCover({ cover: async () => { requested = true; } }, 42),
|
||||
/Resolved path escapes Obsidian vault/,
|
||||
);
|
||||
assert.equal(requested, false);
|
||||
});
|
||||
|
||||
test("rebuildIndex invokes the configured script inside the temporary vault", async () => {
|
||||
const { vault, sync } = await tempSync();
|
||||
await writeFile(
|
||||
path.join(vault, "index.py"),
|
||||
[
|
||||
"import os, pathlib, sys",
|
||||
"pathlib.Path('index-called.txt').write_text(sys.argv[1] + '\\n' + os.getcwd())",
|
||||
"print('rebuilt')",
|
||||
].join("\n"),
|
||||
);
|
||||
await chmod(path.join(vault, "index.py"), 0o700);
|
||||
|
||||
assert.equal(await sync.rebuildIndex(), "rebuilt");
|
||||
const marker = await readFile(path.join(vault, "index-called.txt"), "utf8");
|
||||
assert.equal(marker, `rebuild\n${vault}`);
|
||||
});
|
||||
|
||||
async function tempSync() {
|
||||
const vault = await mkdtemp(path.join(os.tmpdir(), "grimmory-obsidian-test-"));
|
||||
return {
|
||||
vault,
|
||||
sync: new ObsidianSync({
|
||||
vaultPath: vault,
|
||||
booksDir: "Books",
|
||||
coversDir: "Covers",
|
||||
indexScript: "index.py",
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
test("MCP server exposes the read and manual sync tools", async () => {
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [path.join(projectRoot, "src/server.js")],
|
||||
env: { ...process.env },
|
||||
});
|
||||
const client = new Client({ name: "grimmory-test", version: "0.1.0" });
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const { tools } = await client.listTools();
|
||||
const names = tools.map((tool) => tool.name);
|
||||
assert.ok(names.includes("get_book"));
|
||||
assert.ok(names.includes("sync_all_to_obsidian"));
|
||||
assert.equal(names.length, 10);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user