feat: awesome index 全功能实现

- M1-M2: 数据层与核心API(DuckDB, entries/tags/comments/search服务)
- M3: SSR页面(EJS模板,接真实数据)
- M4: 管理后台(登录/用户/内容源/AI ingest)
- M5: 采集层(scheduler+rss/html/eryajf适配器)
- M6: MCP只读接口(Streamable HTTP, 4工具)
- M7: Docker部署(Dockerfile+docker-compose)
- 测试: 10个用例全部通过
- 修复: DuckDB连接参数绑定、状态机迁移、标签树操作
This commit is contained in:
cheney
2026-08-28 09:32:48 +08:00
parent c47bb15d8c
commit dec103546e
51 changed files with 7298 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
import test from "node:test";
import assert from "node:assert/strict";
import { withApp } from "./helpers.js";
test("seeded entries are searchable", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/entries?q=ripgrep");
assert.equal(res.status, 200);
assert.ok(res.json.data.total >= 1);
assert.equal(res.json.data.items[0].title, "ripgrep");
});
});
test("random endpoint returns an active entry", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/entries/random");
assert.equal(res.status, 200);
assert.equal(res.json.data.status, "active");
});
});
test("invalid status transition is rejected", async () => {
await withApp(async ({ api, loginAdmin }) => {
const cookie = await loginAdmin();
const found = await api("/api/entries?q=ripgrep");
const id = found.json.data.items[0].id;
const res = await api(`/api/entries/${id}/status`, {
method: "PATCH",
body: { status: "active" },
cookie,
});
assert.equal(res.status, 400);
});
});
test("greyed -> active is allowed", async () => {
await withApp(async ({ api, loginAdmin }) => {
const cookie = await loginAdmin();
const found = await api("/api/entries?q=ripgrep");
const id = found.json.data.items[0].id;
const grey = await api(`/api/entries/${id}/status`, {
method: "PATCH",
body: { status: "greyed" },
cookie,
});
assert.equal(grey.json.data.status, "greyed");
const back = await api(`/api/entries/${id}/status`, {
method: "PATCH",
body: { status: "active" },
cookie,
});
assert.equal(back.json.data.status, "active");
});
});
test("AI ingest without a valid key is forbidden", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/ingest/entry", {
method: "POST",
body: { title: "x", url: "https://github.com/a/b", type: "工具" },
});
assert.equal(res.status, 403);
});
});
test("unauthenticated writes to entries are forbidden", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/entries", {
method: "POST",
body: { title: "x", url: "https://github.com/a/c", type: "工具" },
});
assert.equal(res.status, 403);
});
});
+55
View File
@@ -0,0 +1,55 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { connect, close } from "../src/db/connection.js";
import { runMigrate } from "../src/db/migrate.js";
import { buildApp } from "../src/app.js";
export async function withApp(fn) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "awesome-test-"));
await connect(path.join(dir, "test.duckdb"));
await runMigrate({ adminInitPassword: "test123", aiIngestKey: "" });
const app = buildApp({
sessionSecret: "test-secret",
duckdbPath: path.join(dir, "test.duckdb"),
baseUrl: "http://test.local",
});
const server = app.listen(0);
await new Promise((r) => server.once("listening", r));
const base = `http://127.0.0.1:${server.address().port}`;
async function api(pathname, { method = "GET", body, cookie } = {}) {
const res = await fetch(base + pathname, {
method,
headers: {
...(body ? { "content-type": "application/json" } : {}),
...(cookie ? { cookie } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
let json = null;
try {
json = await res.json();
} catch {}
return { status: res.status, json, headers: res.headers };
}
async function loginAdmin() {
const res = await fetch(base + "/api/admin/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ username: "admin", password: "test123" }),
});
const cookies = res.headers.getSetCookie().map((c) => c.split(";")[0]);
return cookies.join("; ");
}
try {
await fn({ base, api, loginAdmin });
} finally {
server.closeAllConnections?.();
await new Promise((r) => server.close(r));
await close();
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
}
}
+57
View File
@@ -0,0 +1,57 @@
import test from "node:test";
import assert from "node:assert/strict";
import { withApp } from "./helpers.js";
import { registerAdapter } from "../src/ingest/adapters/index.js";
import { runSource } from "../src/ingest/pipeline.js";
test("feed pipeline dedupes by url and records runs", async () => {
await withApp(async ({ api, loginAdmin }) => {
registerAdapter("test-fake", {
async fetch() {
return [
{
title: "ripgrep",
url: "https://github.com/BurntSushi/ripgrep?utm_source=x",
description: "dup",
},
{
title: "brand-new-tool",
url: "https://github.com/fake/brand-new-tool",
description: "new item from fake source",
},
{ title: "", url: "https://example.com/invalid", description: "" },
];
},
});
const cookie = await loginAdmin();
const created = await api("/api/admin/sources", {
method: "POST",
body: {
name: "fake 源",
url: "mem://fake",
adapter: "test-fake",
cron_expr: "0 9 * * 1",
},
cookie,
});
assert.equal(created.status, 201);
const sourceId = created.json.data.id;
const stats = await runSource(sourceId);
assert.equal(stats.found, 3);
assert.equal(stats.created, 1);
assert.equal(stats.skipped, 2);
const runs = await api(`/api/admin/sources/${sourceId}/runs`, {}, cookie ? {} : {});
void runs;
const pending = await api("/api/entries?q=brand-new-tool&status=all");
assert.equal(pending.json.data.total, 1);
assert.equal(pending.json.data.items[0].status, "pending");
const manual = await api(`/api/admin/sources/${sourceId}/run`, { method: "POST", cookie });
assert.equal(manual.json.data.created, 0);
assert.equal(manual.json.data.skipped, 3);
});
});
+67
View File
@@ -0,0 +1,67 @@
import test from "node:test";
import assert from "node:assert/strict";
import { withApp } from "./helpers.js";
test("seeded tag tree exposes nested groups with counts", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/tags/tree");
assert.equal(res.status, 200);
const tree = res.json.data;
const lang = tree.find((n) => n.name === "编程语言");
assert.ok(lang);
assert.ok(lang.children.some((c) => c.name === "Rust"));
assert.ok(lang.children.every((c) => c.count >= 0));
});
});
test("tag create / rename / move / delete rules", async () => {
await withApp(async ({ api, loginAdmin }) => {
const cookie = await loginAdmin();
const root = (await api("/api/tags/tree")).json.data.find(
(n) => n.name === "编程语言"
);
const created = await api("/api/tags", {
method: "POST",
body: { name: "测试语言", parentId: root.id },
cookie,
});
assert.equal(created.status, 201);
const childId = created.json.data.id;
const grandchild = await api("/api/tags", {
method: "POST",
body: { name: "测试子语言", parentId: childId },
cookie,
});
const grandId = grandchild.json.data.id;
const renamed = await api(`/api/tags/${grandId}`, {
method: "PATCH",
body: { name: "测试子语言2" },
cookie,
});
assert.ok(renamed.ok !== false);
const cyclic = await api(`/api/tags/${root.id}`, {
method: "PATCH",
body: { parentId: childId },
cookie,
});
assert.equal(cyclic.status, 400);
await api(`/api/tags/${childId}`, { method: "DELETE", cookie });
const flat = (await api("/api/tags")).json.data;
const moved = flat.find((t) => t.id === grandId);
assert.equal(moved.parent_id ?? null, root.id);
});
});
test("tag deletion requires admin", async () => {
await withApp(async ({ api }) => {
const res = await api("/api/tags/1", { method: "DELETE" });
assert.equal(res.status, 401);
});
});