Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
187 lines
12 KiB
TypeScript
187 lines
12 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
import React from "react";
|
||
import { toast } from "sonner";
|
||
|
||
import { FeedbackDialog } from "../src/components/feedback-dialog";
|
||
import { LEGAL_ENTITY } from "../src/lib/legal-entity";
|
||
import { answerHash, feedbackKeyFor, loadReplyRatings, parseFeedbackKey, persistReplyRating } from "../src/lib/reply-ratings";
|
||
import {
|
||
FEEDBACK_RATE_LIMITED_COPY,
|
||
FEEDBACK_RESPONSE_WORKING_DAYS,
|
||
feedbackBodyProblem,
|
||
feedbackRpcErrorStatus,
|
||
feedbackSubmissionSchema,
|
||
parseFeedbackFilter,
|
||
} from "../src/lib/user-feedback";
|
||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
|
||
|
||
// 反馈与投诉 + 回复评价 persistence (compliance round 2026-09-30).
|
||
Object.assign(globalThis, { React });
|
||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||
const SESSION = "11111111-1111-4111-8111-111111111111";
|
||
const migration = read("../supabase/migrations/20260930030000_user_feedback.sql");
|
||
|
||
test("feedback validation: type required, 10–2000 characters, contact optional", () => {
|
||
assert.equal(feedbackBodyProblem("太短了"), "请至少写 10 个字,说清楚发生了什么。");
|
||
assert.equal(feedbackBodyProblem("报告里的第三章有一段内容看起来不对劲"), null);
|
||
assert.equal(feedbackBodyProblem("字".repeat(2001)), "最多 2000 个字。");
|
||
assert.equal(feedbackSubmissionSchema.safeParse({ type: "problem", body: "短" }).success, false);
|
||
assert.equal(feedbackSubmissionSchema.safeParse({ type: "nope", body: "这是一段足够长的反馈内容" }).success, false);
|
||
const ok = feedbackSubmissionSchema.parse({ type: "content_report", body: " 这是一段足够长的反馈内容 ", contact: " " });
|
||
assert.equal(ok.body, "这是一段足够长的反馈内容");
|
||
assert.equal(ok.contact, undefined);
|
||
assert.equal(feedbackSubmissionSchema.safeParse({ type: "problem", body: "这是一段足够长的反馈内容", sessionId: "not-a-uuid" }).success, false);
|
||
});
|
||
|
||
test("database errors map to friendly answers; the hourly limit is a 429", () => {
|
||
assert.deepEqual(feedbackRpcErrorStatus("ERROR: feedback_rate_limited"), { status: 429, error: FEEDBACK_RATE_LIMITED_COPY });
|
||
assert.equal(feedbackRpcErrorStatus("feedback_session_not_owned").status, 400);
|
||
assert.equal(feedbackRpcErrorStatus("connection reset").status, 503);
|
||
assert.equal(feedbackRpcErrorStatus(undefined).status, 503);
|
||
});
|
||
|
||
test("the database enforces five per hour, session ownership, and has no direct table access", () => {
|
||
assert.match(migration, /interval '1 hour'/);
|
||
assert.match(migration, /if v_recent >= 5 then\s+raise exception 'feedback_rate_limited'/);
|
||
assert.match(migration, /feedback_session_not_owned/);
|
||
assert.match(migration, /rating_session_not_owned/);
|
||
assert.match(migration, /revoke all on table public\.user_feedback from public, anon, authenticated, service_role;/);
|
||
assert.match(migration, /revoke all on table public\.reply_ratings from public, anon, authenticated, service_role;/);
|
||
assert.match(migration, /grant execute on function public\.submit_user_feedback\(uuid, text, text, text, uuid\) to service_role;/);
|
||
assert.doesNotMatch(migration, /\b(drop table|alter table public\.(?!user_feedback|reply_ratings))/i, "additive only");
|
||
// Only the session id is stored, never conversation text.
|
||
const table = migration.slice(migration.indexOf("create table if not exists public.user_feedback"), migration.indexOf("create index if not exists user_feedback_user_created_idx"));
|
||
assert.doesNotMatch(table, /message|transcript|text\[\]/);
|
||
});
|
||
|
||
test("admin access: new permissions, role grants, permission-checked functions and an audit row", () => {
|
||
assert.match(read("../src/lib/admin/auth-policy.ts"), /"support\.feedback\.read",\n "support\.feedback\.write",/);
|
||
assert.match(read("../src/lib/admin/providers.ts"), /feedback: \{ read: "support\.feedback\.read", write: "support\.feedback\.write" \}/);
|
||
assert.match(migration, /\('support', 'support\.feedback\.read'\), \('support', 'support\.feedback\.write'\)/);
|
||
assert.match(migration, /\('auditor', 'support\.feedback\.read'\)\n\)/);
|
||
assert.match(migration, /admin_has_permission\(p_actor_user_id, 'support\.feedback\.read'\)/);
|
||
assert.match(migration, /admin_has_permission\(p_actor_user_id, 'support\.feedback\.write'\)/);
|
||
assert.match(migration, /'user_feedback\.update', 'user_feedback', p_feedback_id/);
|
||
const route = read("../src/app/api/admin/feedback/route.ts");
|
||
assert.match(route, /requirePermission\("support\.feedback\.read"\)/);
|
||
assert.match(route, /requireAdminMutation\(request, "support\.feedback\.write"\)/);
|
||
assert.deepEqual(parseFeedbackFilter("type:content_report"), { type: "content_report", status: null });
|
||
assert.deepEqual(parseFeedbackFilter("resolved"), { type: null, status: "resolved" });
|
||
assert.deepEqual(parseFeedbackFilter("type:drop table"), { type: null, status: null });
|
||
// New content reports sort first.
|
||
assert.match(migration, /order by \(f\.feedback_type = 'content_report' and f\.status = 'new'\) desc, f\.created_at desc/);
|
||
});
|
||
|
||
test("the user API signs in first and writes only through submit_user_feedback", () => {
|
||
const route = read("../src/app/api/feedback/route.ts");
|
||
assert.ok(route.indexOf("auth.getUser()") < route.indexOf("feedbackSubmissionSchema.safeParse"));
|
||
assert.match(route, /status: 401/);
|
||
assert.match(route, /service\.rpc\("submit_user_feedback"/);
|
||
assert.doesNotMatch(route, /\.from\("user_feedback"\)/);
|
||
});
|
||
|
||
type Harness = ReturnType<typeof createClientLifecycleHarness>;
|
||
const find = (h: Harness, predicate: (node: ReturnType<Harness["elements"]>[number]) => boolean) => h.elements().find(predicate);
|
||
|
||
test("the dialog validates, submits, shows the promised reply time and the contact email", async () => {
|
||
const calls: { url: string; body: Record<string, unknown> }[] = [];
|
||
const originalFetch = globalThis.fetch;
|
||
globalThis.fetch = (async (url: string, init?: RequestInit) => {
|
||
calls.push({ url, body: JSON.parse(String(init?.body ?? "{}")) });
|
||
return new Response(JSON.stringify({ id: "x" }), { status: 201 });
|
||
}) as typeof fetch;
|
||
const h = createClientLifecycleHarness();
|
||
const errorBefore = toast.error;
|
||
const errors: string[] = [];
|
||
toast.error = (message) => { errors.push(String(message)); return 1; };
|
||
try {
|
||
await h.render(<FeedbackDialog accountEmail="user@example.test" sessionId={SESSION} onClose={() => {}} />);
|
||
const form = find(h, (node) => node.tagName === "FORM")!;
|
||
await h.event(form, "onSubmit");
|
||
// 原值: 表单里 role="alert" 一行「请至少写 10 个字,说清楚发生了什么。」
|
||
// 新值: 同一句是 error toast,表单里没有 alert 行
|
||
// 原因: 2026-09-30 产品「报错和提示不要占页面布局」
|
||
assert.deepEqual(errors, ["请至少写 10 个字,说清楚发生了什么。"]);
|
||
assert.equal(find(h, (node) => node.getAttribute("role") === "alert"), undefined);
|
||
assert.equal(calls.length, 0);
|
||
await h.event(find(h, (node) => node.getAttribute("role") === "radio" && node.text === "内容举报")!);
|
||
await h.event(find(h, (node) => node.tagName === "TEXTAREA")!, "onChange", { target: { value: "回答里有一段话让我很不舒服,希望处理" } });
|
||
await h.event(find(h, (node) => node.tagName === "INPUT" && node.props.type === "checkbox")!, "onChange", { target: { checked: true } });
|
||
await h.event(find(h, (node) => node.tagName === "FORM")!, "onSubmit");
|
||
await h.idle();
|
||
assert.equal(calls.length, 1);
|
||
assert.equal(calls[0]!.url, "/api/feedback");
|
||
assert.deepEqual(calls[0]!.body, { type: "content_report", body: "回答里有一段话让我很不舒服,希望处理", contact: "user@example.test", sessionId: SESSION });
|
||
assert.ok(h.container.text.includes(`已收到,我们会在 ${FEEDBACK_RESPONSE_WORKING_DAYS} 个工作日内处理。`));
|
||
assert.ok(h.container.text.includes(LEGAL_ENTITY.contactEmail));
|
||
assert.deepEqual(h.errors, []);
|
||
} finally {
|
||
toast.error = errorBefore;
|
||
globalThis.fetch = originalFetch;
|
||
await h.close();
|
||
}
|
||
});
|
||
|
||
test("the dialog shows the rate-limit message and offers no attach box without a saved conversation", async () => {
|
||
const originalFetch = globalThis.fetch;
|
||
globalThis.fetch = (async () => new Response(JSON.stringify({ error: FEEDBACK_RATE_LIMITED_COPY }), { status: 429 })) as unknown as typeof fetch;
|
||
const h = createClientLifecycleHarness();
|
||
const errorBefore = toast.error;
|
||
const errors: string[] = [];
|
||
toast.error = (message) => { errors.push(String(message)); return 1; };
|
||
try {
|
||
await h.render(<FeedbackDialog accountEmail={null} sessionId={null} onClose={() => {}} />);
|
||
assert.equal(find(h, (node) => node.tagName === "INPUT" && node.props.type === "checkbox"), undefined);
|
||
await h.event(find(h, (node) => node.tagName === "TEXTAREA")!, "onChange", { target: { value: "扣点好像扣了两次,想确认一下" } });
|
||
await h.event(find(h, (node) => node.tagName === "FORM")!, "onSubmit");
|
||
await h.idle();
|
||
// 原值: 表单里 role="alert" 的文字等于 FEEDBACK_RATE_LIMITED_COPY
|
||
// 新值: 同一句是 error toast
|
||
// 原因: 2026-09-30 产品「报错和提示不要占页面布局」
|
||
assert.deepEqual(errors, [FEEDBACK_RATE_LIMITED_COPY]);
|
||
} finally {
|
||
toast.error = errorBefore;
|
||
globalThis.fetch = originalFetch;
|
||
await h.close();
|
||
}
|
||
});
|
||
|
||
test("the account menu has exactly one feedback entry and the sidebar owns the dialog", () => {
|
||
const sidebar = read("../src/components/app-sidebar.tsx");
|
||
assert.equal((sidebar.match(/<span>反馈与投诉<\/span>/g) ?? []).length, 1);
|
||
assert.match(sidebar, /<FeedbackDialog accountEmail=\{account\.email\} sessionId=\{activeSessionId\}/);
|
||
});
|
||
|
||
test("reply ratings: keyed by session and position, saved on toggle, restored only onto the same text", async () => {
|
||
assert.deepEqual(parseFeedbackKey(`${SESSION}:message-3`), { sessionId: SESSION, messageIndex: 3 });
|
||
assert.equal(parseFeedbackKey("local-draft:message-3"), null, "unsaved conversations are not persisted");
|
||
assert.match(await answerHash("你好"), /^[a-f0-9]{16}$/);
|
||
|
||
const calls: { method: string; body?: Record<string, unknown> }[] = [];
|
||
const originalFetch = globalThis.fetch;
|
||
const hashA = await answerHash("第一版回答");
|
||
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
|
||
calls.push({ method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined });
|
||
return new Response(JSON.stringify({ ratings: [
|
||
{ messageIndex: 1, rating: "up", answerSha256: hashA },
|
||
{ messageIndex: 3, rating: "down", answerSha256: hashA },
|
||
] }), { status: 200 });
|
||
}) as typeof fetch;
|
||
try {
|
||
await persistReplyRating(`${SESSION}:message-1`, "up", "第一版回答");
|
||
await persistReplyRating(`${SESSION}:message-1`, undefined, "第一版回答");
|
||
assert.deepEqual(calls.map((call) => [call.method, call.body?.rating]), [["PUT", "up"], ["PUT", null]]);
|
||
const restored = await loadReplyRatings(SESSION, ["", "第一版回答", "", "重新生成后的另一版"]);
|
||
assert.deepEqual(restored, { [feedbackKeyFor(SESSION, 1)]: "up" }, "index 3 was regenerated, so its old rating is not shown");
|
||
} finally {
|
||
globalThis.fetch = originalFetch;
|
||
}
|
||
// 原值: persistReplyRating(feedbackKey, toggleChatMessageFeedback(...), message.text) inline.
|
||
// 新值: the toggled value is named `next` once and saved; the same value also opens 不满意原因.
|
||
// 原因: reply-quality round 2026-09-30 needs the toggled rating twice; what is saved is unchanged.
|
||
assert.match(read("../src/components/chat-transcript.tsx"), /const next = toggleChatMessageFeedback\(messageFeedback\[feedbackKey\], requested\);\s+void persistReplyRating\(feedbackKey, next, message\.text\);/);
|
||
assert.match(read("../src/app/(app)/page.tsx"), /useReplyRatingsSync\(activeSession, setMessageFeedback\);/);
|
||
});
|