Files
Jyotisha/frontend/tests/feedback-complaints-20260930.test.tsx
T

187 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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\);/);
});