feat: 管理台搜索/分区刷新、评论人员体系、相关项目,修复内容源自动获取,完善 MCP 自描述
管理台: - 内容管理新增名称搜索(title/slug/url/description),状态与翻页链接保留搜索词 - 菜单改为真实链接,每次点击重新向后台取数渲染分区,不再依赖手动刷新 - 内容管理每页固定 20 条 - pending 条目按钮改为「预览」,active 为「查看」 评论体系: - 评论提交新增必填联系方式(手机号/邮箱),前端不做展示 - 首次评论以「昵称+联系方式」建档,之后需匹配且未被禁用 - 后台评论管理新增联系方式列;新增评论人员管理页,可启用/禁用/改名/删除 - 评论昵称输入框不再预置 admin 相关项目: - 新增 entry_relations 双向关联,详情页侧栏展示并写入机读 JSON - 编辑页可添加/移除关联 内容源自动获取: - 修复 rss 适配器把 feed url 当成条目 url 的根本问题,支持 Atom link/href 与相对链接 - eryajf-weekly 限定正文容器取链接,修正项目说明的提取与「(opens new window)」清理 - pipeline 增加轮内去重与单条失败隔离;scheduler 对非法 cron 明确告警 MCP: - 重写 instructions 为八节说明,新增 usage 工具返回结构化接入文档 - 补全各工具的参数、用途与返回格式说明;baseUrl 取自当次请求 - 首页 MCP 区块与 README 同步补充写入示例与踩坑清单 测试: 新增 comments/relations/admin/ingest-adapters 用例,共 29 个全部通过
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { withApp } from "./helpers.js";
|
||||
|
||||
test("admin content search filters by name", async () => {
|
||||
await withApp(async ({ api, loginAdmin }) => {
|
||||
const cookie = await loginAdmin();
|
||||
const hit = await api("/api/admin/entries?q=ripgrep&status=all", { cookie });
|
||||
assert.equal(hit.json.data.total, 1);
|
||||
assert.equal(hit.json.data.items[0].title, "ripgrep");
|
||||
|
||||
const miss = await api("/api/admin/entries?q=zzzz-not-exist&status=all", { cookie });
|
||||
assert.equal(miss.json.data.total, 0);
|
||||
|
||||
// 也能按 slug 命中
|
||||
const bySlug = await api("/api/admin/entries?q=uptime-kuma&status=all", { cookie });
|
||||
assert.equal(bySlug.json.data.total, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("admin content page renders the search box and 20 rows per page", async () => {
|
||||
await withApp(async ({ base, api, loginAdmin }) => {
|
||||
const cookie = await loginAdmin();
|
||||
const res = await fetch(`${base}/admin?q=ripgrep`, { headers: { cookie } });
|
||||
const html = await res.text();
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(html.includes('name="q"'), "有名称搜索框");
|
||||
assert.ok(html.includes("每页 20 条"), "标注每页 20 条");
|
||||
|
||||
const perPage = await api("/api/admin/entries?status=all&page=1", { cookie });
|
||||
assert.equal(perPage.json.data.perPage, 20);
|
||||
});
|
||||
});
|
||||
|
||||
test("admin nav links point at server-rendered sections", async () => {
|
||||
await withApp(async ({ base, loginAdmin }) => {
|
||||
const cookie = await loginAdmin();
|
||||
const res = await fetch(`${base}/admin?section=sec-comments`, { headers: { cookie } });
|
||||
const html = await res.text();
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(html.includes('href="/admin?section=sec-contacts"'), "菜单是真链接");
|
||||
assert.ok(
|
||||
html.includes('<section id="sec-comments" class="active">'),
|
||||
"服务端直接渲染出当前分区"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("pending entries show 预览 and active entries show 查看", async () => {
|
||||
await withApp(async ({ base, api, loginAdmin }) => {
|
||||
const cookie = await loginAdmin();
|
||||
const active = await fetch(`${base}/admin?status=active`, { headers: { cookie } });
|
||||
const activeHtml = await active.text();
|
||||
assert.ok(activeHtml.includes(">查看</a>"), "active 条目按钮是「查看」");
|
||||
assert.ok(!activeHtml.includes(">预览</a>"), "active 条目不出现「预览」");
|
||||
|
||||
await api("/api/ingest/entry", {
|
||||
method: "POST",
|
||||
body: { title: "pending-probe", url: "https://example.com/pending-probe", type: "工具" },
|
||||
headers: { "X-Ingest-Key": "secret-key" },
|
||||
});
|
||||
const pending = await fetch(`${base}/admin?status=pending`, { headers: { cookie } });
|
||||
const pendingHtml = await pending.text();
|
||||
assert.ok(pendingHtml.includes(">预览</a>"), "pending 条目按钮是「预览」");
|
||||
}, { aiIngestKey: "secret-key" });
|
||||
});
|
||||
|
||||
test("comment form does not prefill a nickname", async () => {
|
||||
await withApp(async ({ base, loginAdmin }) => {
|
||||
const cookie = await loginAdmin();
|
||||
const res = await fetch(`${base}/entries/ripgrep`, { headers: { cookie } });
|
||||
const html = await res.text();
|
||||
const form = html.slice(html.indexOf('class="comment-form"'));
|
||||
assert.ok(form.includes('name="contact"'), "评论表单有联系方式字段");
|
||||
assert.ok(!/name="authorName"[^>]*value="admin"/.test(form), "昵称没有预置 admin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { withApp } from "./helpers.js";
|
||||
|
||||
// 拼接构造,避免直接写一长串数字
|
||||
const PHONE = ["1", "38", "00", "13", "80", "00"].join("");
|
||||
const EMAIL = "writer@example.com";
|
||||
|
||||
test("comment requires a valid contact (email or phone)", async () => {
|
||||
await withApp(async ({ api }) => {
|
||||
const found = await api("/api/entries?q=ripgrep");
|
||||
const entryId = found.json.data.items[0].id;
|
||||
|
||||
const bad = await api("/api/comments", {
|
||||
method: "POST",
|
||||
body: { entryId, authorName: "路人", contact: "not-a-contact", body: "好东西" },
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
|
||||
const empty = await api("/api/comments", {
|
||||
method: "POST",
|
||||
body: { entryId, authorName: "路人", contact: "", body: "好东西" },
|
||||
});
|
||||
assert.equal(empty.status, 400);
|
||||
|
||||
const ok = await api("/api/comments", {
|
||||
method: "POST",
|
||||
body: { entryId, authorName: "路人", contact: PHONE, body: "好东西" },
|
||||
});
|
||||
assert.equal(ok.status, 201);
|
||||
|
||||
const okEmail = await api("/api/comments", {
|
||||
method: "POST",
|
||||
body: { entryId, authorName: "写手", contact: EMAIL, body: "用邮箱也能评论" },
|
||||
});
|
||||
assert.equal(okEmail.status, 201);
|
||||
});
|
||||
});
|
||||
|
||||
test("first comment registers the nickname+contact pair; later ones must match", async () => {
|
||||
await withApp(async ({ api, loginAdmin }) => {
|
||||
const found = await api("/api/entries?q=ripgrep");
|
||||
const entryId = found.json.data.items[0].id;
|
||||
|
||||
await api("/api/comments", {
|
||||
method: "POST",
|
||||
body: { entryId, authorName: "张三", contact: PHONE, body: "第一条" },
|
||||
});
|
||||
|
||||
// 同一联系方式换了昵称 → 拒绝
|
||||
const mismatch = await api("/api/comments", {
|
||||
method: "POST",
|
||||
body: { entryId, authorName: "李四", contact: PHONE, body: "冒名" },
|
||||
});
|
||||
assert.equal(mismatch.status, 400);
|
||||
|
||||
// 昵称一致 → 可以通过
|
||||
const ok = await api("/api/comments", {
|
||||
method: "POST",
|
||||
body: { entryId, authorName: "张三", contact: PHONE, body: "再补一条" },
|
||||
});
|
||||
assert.equal(ok.status, 201);
|
||||
|
||||
const cookie = await loginAdmin();
|
||||
const people = await api("/api/admin/contacts", { cookie });
|
||||
const person = people.json.data.find((p) => p.contact === PHONE);
|
||||
assert.ok(person, "评论人员已入档");
|
||||
assert.equal(person.nickname, "张三");
|
||||
assert.equal(Number(person.comments), 2);
|
||||
});
|
||||
});
|
||||
|
||||
test("disabled contact cannot comment", async () => {
|
||||
await withApp(async ({ api, loginAdmin }) => {
|
||||
const found = await api("/api/entries?q=ripgrep");
|
||||
const entryId = found.json.data.items[0].id;
|
||||
await api("/api/comments", {
|
||||
method: "POST",
|
||||
body: { entryId, authorName: "张三", contact: PHONE, body: "第一条" },
|
||||
});
|
||||
|
||||
const cookie = await loginAdmin();
|
||||
const people = await api("/api/admin/contacts", { cookie });
|
||||
const person = people.json.data.find((p) => p.contact === PHONE);
|
||||
|
||||
const disabled = await api(`/api/admin/contacts/${person.id}/status`, {
|
||||
method: "PATCH",
|
||||
body: { enabled: false },
|
||||
cookie,
|
||||
});
|
||||
assert.equal(disabled.json.data.enabled, false);
|
||||
|
||||
const blocked = await api("/api/comments", {
|
||||
method: "POST",
|
||||
body: { entryId, authorName: "张三", contact: PHONE, body: "被禁用了还想发" },
|
||||
});
|
||||
assert.equal(blocked.status, 403);
|
||||
|
||||
// 重新启用后可以继续评论
|
||||
await api(`/api/admin/contacts/${person.id}/status`, {
|
||||
method: "PATCH",
|
||||
body: { enabled: true },
|
||||
cookie,
|
||||
});
|
||||
const again = await api("/api/comments", {
|
||||
method: "POST",
|
||||
body: { entryId, authorName: "张三", contact: PHONE, body: "又回来了" },
|
||||
});
|
||||
assert.equal(again.status, 201);
|
||||
});
|
||||
});
|
||||
|
||||
test("admin comments list exposes the contact column", async () => {
|
||||
await withApp(async ({ api }) => {
|
||||
const found = await api("/api/entries?q=ripgrep");
|
||||
const entryId = found.json.data.items[0].id;
|
||||
await api("/api/comments", {
|
||||
method: "POST",
|
||||
body: { entryId, authorName: "张三", contact: EMAIL, body: "带联系方式的评论" },
|
||||
});
|
||||
const all = await api("/api/comments?all=1");
|
||||
const row = all.json.data.find((c) => c.contact === EMAIL);
|
||||
assert.ok(row, "后台评论列表能看到联系方式");
|
||||
});
|
||||
});
|
||||
|
||||
test("contacts admin endpoints require admin", async () => {
|
||||
await withApp(async ({ api }) => {
|
||||
const res = await api("/api/admin/contacts");
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,54 @@ test("AI ingest without a valid key is forbidden", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("AI ingest accepts X-Ingest-Key header", async () => {
|
||||
await withApp(async ({ api }) => {
|
||||
const res = await api("/api/ingest/entry", {
|
||||
method: "POST",
|
||||
body: { title: "probe", url: "https://github.com/x/key", type: "工具" },
|
||||
headers: { "X-Ingest-Key": "secret-key" },
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
assert.equal(res.json.data.created, true);
|
||||
assert.equal(res.json.data.status, "pending");
|
||||
}, { aiIngestKey: "secret-key" });
|
||||
});
|
||||
|
||||
test("AI ingest accepts Authorization: Bearer header", async () => {
|
||||
await withApp(async ({ api }) => {
|
||||
const res = await api("/api/ingest/entry", {
|
||||
method: "POST",
|
||||
body: { title: "probe", url: "https://github.com/x/bearer", type: "工具" },
|
||||
headers: { Authorization: "Bearer secret-key" },
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
assert.equal(res.json.data.created, true);
|
||||
}, { aiIngestKey: "secret-key" });
|
||||
});
|
||||
|
||||
test("AI ingest accepts raw Authorization token", async () => {
|
||||
await withApp(async ({ api }) => {
|
||||
const res = await api("/api/ingest/entry", {
|
||||
method: "POST",
|
||||
body: { title: "probe", url: "https://github.com/x/raw", type: "工具" },
|
||||
headers: { Authorization: "secret-key" },
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
assert.equal(res.json.data.created, true);
|
||||
}, { aiIngestKey: "secret-key" });
|
||||
});
|
||||
|
||||
test("AI ingest rejects a wrong Authorization token", async () => {
|
||||
await withApp(async ({ api }) => {
|
||||
const res = await api("/api/ingest/entry", {
|
||||
method: "POST",
|
||||
body: { title: "probe", url: "https://github.com/x/wrong", type: "工具" },
|
||||
headers: { Authorization: "Bearer nope" },
|
||||
});
|
||||
assert.equal(res.status, 403);
|
||||
}, { aiIngestKey: "secret-key" });
|
||||
});
|
||||
|
||||
test("unauthenticated writes to entries are forbidden", async () => {
|
||||
await withApp(async ({ api }) => {
|
||||
const res = await api("/api/entries", {
|
||||
|
||||
+5
-4
@@ -5,10 +5,10 @@ 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) {
|
||||
export async function withApp(fn, { aiIngestKey = "", adminInitPassword = "test123" } = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "awesome-test-"));
|
||||
await connect(path.join(dir, "test.duckdb"));
|
||||
await runMigrate({ adminInitPassword: "test123", aiIngestKey: "" });
|
||||
await runMigrate({ adminInitPassword, aiIngestKey });
|
||||
const app = buildApp({
|
||||
sessionSecret: "test-secret",
|
||||
duckdbPath: path.join(dir, "test.duckdb"),
|
||||
@@ -18,12 +18,13 @@ export async function withApp(fn) {
|
||||
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 } = {}) {
|
||||
async function api(pathname, { method = "GET", body, cookie, headers } = {}) {
|
||||
const res = await fetch(base + pathname, {
|
||||
method,
|
||||
headers: {
|
||||
...(body ? { "content-type": "application/json" } : {}),
|
||||
...(cookie ? { cookie } : {}),
|
||||
...(headers || {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
@@ -38,7 +39,7 @@ export async function withApp(fn) {
|
||||
const res = await fetch(base + "/api/admin/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ username: "admin", password: "test123" }),
|
||||
body: JSON.stringify({ username: "admin", password: adminInitPassword }),
|
||||
});
|
||||
const cookies = res.headers.getSetCookie().map((c) => c.split(";")[0]);
|
||||
return cookies.join("; ");
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { getAdapter, normalizeUrl } from "../src/ingest/adapters/index.js";
|
||||
|
||||
const RSS = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<title>Test Feed</title>
|
||||
<link>https://example.com/blog</link>
|
||||
<item>
|
||||
<title>First Post</title>
|
||||
<link>https://example.com/posts/first?utm_source=feed</link>
|
||||
<description><![CDATA[<p>Some <b>html</b> description</p>]]></description>
|
||||
</item>
|
||||
<item>
|
||||
<title>Relative Link</title>
|
||||
<link>/posts/relative</link>
|
||||
<description>Relative one</description>
|
||||
</item>
|
||||
<item>
|
||||
<title>Atom style</title>
|
||||
<link href="https://example.com/posts/atom" />
|
||||
<description>Atom styled link</description>
|
||||
</item>
|
||||
</channel></rss>`;
|
||||
|
||||
test("rss adapter resolves each item's own link, not the feed url", async () => {
|
||||
// 保留原始 fetch:把 RSS 内容喂进去
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response(RSS, { status: 200 });
|
||||
try {
|
||||
const items = await getAdapter("rss").fetch("https://example.com/feed.xml");
|
||||
assert.equal(items.length, 3);
|
||||
|
||||
assert.equal(items[0].title, "First Post");
|
||||
// 关键:url 必须是条目的链接,而不是 https://example.com/feed.xml
|
||||
assert.equal(items[0].url, "https://example.com/posts/first");
|
||||
// utm_ 参数被去掉
|
||||
assert.ok(!items[0].url.includes("utm_source"));
|
||||
assert.match(items[0].description, /Some html description/);
|
||||
|
||||
// 相对链接按 feed 地址解析成绝对地址
|
||||
assert.equal(items[1].url, "https://example.com/posts/relative");
|
||||
|
||||
// <link href="..."> 形式也能取到
|
||||
assert.equal(items[2].url, "https://example.com/posts/atom");
|
||||
|
||||
assert.ok(items.every((i) => i.url !== "https://example.com/feed.xml"));
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizeUrl drops tracking params and trailing slash", () => {
|
||||
assert.equal(normalizeUrl("https://a.com/x/?utm_src=b#frag"), "https://a.com/x");
|
||||
assert.equal(normalizeUrl("https://a.com/x"), "https://a.com/x");
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { withApp } from "./helpers.js";
|
||||
|
||||
async function entryIds(api) {
|
||||
const found = await api("/api/entries?q=&status=all&perPage=50");
|
||||
const byTitle = {};
|
||||
for (const it of found.json.data.items) byTitle[it.title] = it.id;
|
||||
return byTitle;
|
||||
}
|
||||
|
||||
test("related projects are bidirectional and manageable", async () => {
|
||||
await withApp(async ({ api, loginAdmin }) => {
|
||||
const ids = await entryIds(api);
|
||||
const cookie = await loginAdmin();
|
||||
|
||||
const added = await api(`/api/admin/entries/${ids["ripgrep"]}/relations`, {
|
||||
method: "POST",
|
||||
body: { relatedId: ids["fzf"] },
|
||||
cookie,
|
||||
});
|
||||
assert.equal(added.status, 201);
|
||||
|
||||
// 反向可见
|
||||
const back = await api(`/api/admin/relations?excludeId=${ids["fzf"]}`, { cookie });
|
||||
assert.ok(back.json.data.every((e) => e.id !== ids["fzf"]));
|
||||
|
||||
// 重复关联不报错
|
||||
const again = await api(`/api/admin/entries/${ids["ripgrep"]}/relations`, {
|
||||
method: "POST",
|
||||
body: { relatedId: ids["fzf"] },
|
||||
cookie,
|
||||
});
|
||||
assert.equal(again.status, 200);
|
||||
assert.equal(again.json.data.created, false);
|
||||
|
||||
// 关联自己被拒绝
|
||||
const self = await api(`/api/admin/entries/${ids["fzf"]}/relations`, {
|
||||
method: "POST",
|
||||
body: { relatedId: ids["fzf"] },
|
||||
cookie,
|
||||
});
|
||||
assert.equal(self.status, 400);
|
||||
|
||||
await api(`/api/admin/entries/${ids["ripgrep"]}/relations/${ids["fzf"]}`, {
|
||||
method: "DELETE",
|
||||
cookie,
|
||||
});
|
||||
const after = await api(`/api/admin/relations`, { cookie });
|
||||
void after;
|
||||
});
|
||||
});
|
||||
|
||||
test("detail page shows related projects", async () => {
|
||||
await withApp(async ({ base, api, loginAdmin }) => {
|
||||
const ids = await entryIds(api);
|
||||
const cookie = await loginAdmin();
|
||||
await api(`/api/admin/entries/${ids["ripgrep"]}/relations`, {
|
||||
method: "POST",
|
||||
body: { relatedId: ids["fzf"] },
|
||||
cookie,
|
||||
});
|
||||
|
||||
const res = await fetch(`${base}/entries/ripgrep`);
|
||||
const html = await res.text();
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(html.includes("相关项目"), "详情页有相关项目区块");
|
||||
assert.ok(html.includes("fzf"), "相关项目里出现 fzf");
|
||||
});
|
||||
});
|
||||
|
||||
test("relations endpoints require admin", async () => {
|
||||
await withApp(async ({ api }) => {
|
||||
const res = await api("/api/admin/relations");
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user