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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user