100 lines
5.1 KiB
TypeScript
100 lines
5.1 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { test } from "node:test";
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { DatabaseSync } from "node:sqlite";
|
|
import { allDocuments, documentHistory, importDocuments, readDocument, saveDocument,
|
|
type DocInput, type DocumentExport } from "../src/lib/docs";
|
|
|
|
const input: DocInput = { slug: "endor/example", sourceSlug: "00-example", title: "Example",
|
|
category: "Endor", collection: "Endor 文档", group: "讨论", description: "Example description",
|
|
draft: true, featured: false, content: "# Example\n\nOriginal body" };
|
|
|
|
function isolated(run: (directory: string) => void) {
|
|
const directory = mkdtempSync(join(tmpdir(), "tabularium-docs-"));
|
|
const previous = process.env.AUTH_CONFIG_FILE;
|
|
process.env.AUTH_CONFIG_FILE = join(directory, "config.json");
|
|
try { run(directory); } finally {
|
|
if (previous === undefined) delete process.env.AUTH_CONFIG_FILE;
|
|
else process.env.AUTH_CONFIG_FILE = previous;
|
|
rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function bundle(documents: DocInput[]): DocumentExport {
|
|
return { format: "tabularium-documents-v1", documents: documents.map((doc) => ({ ...doc, revision: 1 })) };
|
|
}
|
|
|
|
test("writes and metadata persist across independent connections; no repository seed", () => isolated(() => {
|
|
assert.deepEqual(allDocuments(), []);
|
|
const saved = saveDocument(input, 0);
|
|
assert.deepEqual(readDocument(input.slug), saved);
|
|
assert.deepEqual(readDocument(input.sourceSlug), saved);
|
|
const changed = saveDocument({ ...input, content: "# Revised", collection: "新合集", group: "新目录" }, 1);
|
|
assert.equal(changed.revision, 2);
|
|
assert.equal(allDocuments()[0].collection, "新合集");
|
|
assert.deepEqual(documentHistory(input.sourceSlug).map((doc) => doc.content), [input.content, "# Revised"]);
|
|
}));
|
|
|
|
test("optimistic concurrency prevents lost updates and requires explicit revision", () => isolated(() => {
|
|
saveDocument(input, 0);
|
|
assert.throws(() => saveDocument({ ...input, content: "Lost" }, 0), /conflict/);
|
|
assert.throws(() => saveDocument(input, NaN), /expected revision/);
|
|
assert.equal(readDocument(input.slug)?.content, input.content);
|
|
assert.equal(documentHistory(input.sourceSlug).length, 1);
|
|
}));
|
|
|
|
test("renames preserve aliases and alias collisions roll back document and revision", () => isolated(() => {
|
|
saveDocument(input, 0);
|
|
saveDocument({ ...input, slug: "endor/renamed" }, 1);
|
|
assert.equal(readDocument(input.slug)?.slug, "endor/renamed");
|
|
assert.throws(() => saveDocument({ ...input, sourceSlug: "another", slug: input.slug }, 0), /collision/);
|
|
assert.equal(allDocuments().length, 1);
|
|
assert.equal(readDocument("another"), null);
|
|
assert.equal(documentHistory(input.sourceSlug).length, 2);
|
|
}));
|
|
|
|
test("bootstrap is atomic and repeat imports never replace DB edits", () => isolated(() => {
|
|
const snapshot = bundle([input]);
|
|
assert.equal(importDocuments(snapshot), 1);
|
|
saveDocument({ ...input, content: "Written directly to DB" }, 1);
|
|
assert.equal(importDocuments(snapshot), 0);
|
|
assert.equal(importDocuments(bundle([{ ...input, content: "Stale release" }]), true), 0);
|
|
assert.throws(() => importDocuments(bundle([{ ...input, content: "Other" }])), /empty/);
|
|
assert.equal(readDocument(input.slug)?.content, "Written directly to DB");
|
|
}));
|
|
|
|
test("colliding bootstrap leaves no partial rows and can be retried", () => isolated(() => {
|
|
assert.throws(() => importDocuments(bundle([input, { ...input, sourceSlug: "other" }])));
|
|
assert.deepEqual(allDocuments(), []);
|
|
assert.equal(importDocuments(bundle([input])), 1);
|
|
}));
|
|
|
|
test("migration preserves unrelated authentication and research tables", () => isolated((directory) => {
|
|
const db = new DatabaseSync(join(directory, "login-state.sqlite"));
|
|
db.exec("CREATE TABLE login_state(value TEXT); INSERT INTO login_state VALUES ('preserved'); CREATE TABLE research_datasets(value TEXT); INSERT INTO research_datasets VALUES ('research');");
|
|
db.close();
|
|
importDocuments(bundle([input]));
|
|
const reopened = new DatabaseSync(join(directory, "login-state.sqlite"));
|
|
assert.equal(reopened.prepare("SELECT value FROM login_state").get()?.value, "preserved");
|
|
assert.equal(reopened.prepare("SELECT value FROM research_datasets").get()?.value, "research");
|
|
assert.deepEqual(reopened.prepare("PRAGMA foreign_key_check").all(), []);
|
|
reopened.close();
|
|
}));
|
|
|
|
test("invalid content cannot enter the database", () => isolated(() => {
|
|
for (const invalid of [ { ...input, slug: "../private" }, { ...input, slug: "endor/01-example" },
|
|
{ ...input, content: "" }, { ...input, category: "" } ])
|
|
assert.throws(() => saveDocument(invalid, 0));
|
|
assert.deepEqual(allDocuments(), []);
|
|
assert.equal(readDocument("../private"), null);
|
|
}));
|
|
|
|
test("missing database configuration fails closed", () => {
|
|
const previous = process.env.AUTH_CONFIG_FILE;
|
|
delete process.env.AUTH_CONFIG_FILE;
|
|
try { assert.throws(() => allDocuments(), /not configured/); }
|
|
finally { if (previous !== undefined) process.env.AUTH_CONFIG_FILE = previous; }
|
|
});
|