mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
The Admin Console only ever saw the community the desktop happened to be connected to. With #8011's community reads, the console can now browse every community on the relay and, for signed staff, moderate any of them by name without switching connections. 🤖 ## What changes - **Communities tab.** Lists every live community on the relay, searchable by host prefix with "Load more" paging. The connected community is pinned at the top by exact host match. Report rows get a community badge that opens that community. - **Community page.** Reports, Restrictions, Members and Actions, each scoped to the community on the page. The Actions form has no host field: it always acts on the page's community, and the TypeScript host normalization is gone from the Actions tab. - **Native reads** (`admin_list_communities`, `admin_search_members`, `admin_get_member`, `admin_get_event`). Every request validates the explicit community host and NIP-98-signs the real method and URL. Failures come back as a structured error (`status`, `bodyComplete`, `bodyEmpty`, `code`), so the UI can tell an older relay's empty 404, a coded `event_not_found` and a transport failure apart, and never says "not found" unless the relay did. - **Explicit-host lifts.** `admin_lift_restriction` sends the page's community host, not the active relay's. It freezes the relay and signer the intent was reviewed under, and refuses to send if either has changed. - **Pending actions survive navigation.** A panel-level `DirectActionsProvider` holds the frozen intent, so switching communities or tabs mid-confirm doesn't lose it. Only Discard drops it. A signer change remounts the controller and drops it. - **Fenced lookups.** Member and event lookups are keyed by origin, signer, community, target and a generation counter, so a late answer for one community can't unlock Review in another. - **Shared member picker** (`MemberSearchResult`, also used by Add Member), showing the truncated npub so members with the same name can be told apart. Delete accepts a note link and previews the message before Review. Relay staff are blocked before Review. - **Disabled admin auth is read-only.** With `BUZZ_ADMIN_AUTH=disabled` the Communities tab and community pages still render, and report badges still open them, but every ban, timeout, delete, add-member and lift control is disabled. The settings badge says the console is read-only and that acting needs `nip98`. Staffing stays hidden. - #7904's protections in the Actions tab are kept: frozen intent, `requestId`, Retry, `notSent` and the key-backup guard. - **Readable load errors.** Admin list and detail loads show the relay's error message (for example "request is not authorized"), never the raw JSON envelope. - **Failed kicks reload the report.** A resolve that fails with `enforcement_failed` reloads the report, so its failed action and "Cancel & reopen" appear immediately. - **Key backups never leave in a search.** Every admin API request URL passes the NIP-49 key-backup guard before it is signed or sent: community and legacy reads, mutations, the connection probe (before its first, unsigned request) and feedback-attachment fetches. The community and member search boxes, including the directory's Load more, refuse a pasted `nsec` or `ncryptsec` anywhere in the input without calling the relay. - **`just admin-seed` for multi-community testing.** Besides the local community, it seeds `beta.localhost:3000`, `gamma.localhost:3000` and 50 filler communities, with named members, a ban and a timeout in each named community, a report with Kick available, and a message for the delete preview. Rerunning it restores all of that to the starting state. It refuses, writing nothing, while a moderation action is unfinished on a seeded report. - The bodyless `GET /events/{id}` preview is added to the events-URL egress inventory. It sends no body, so it carries no key material. ## Tests Native (Rust): - `empty_404_and_405_read_as_complete_and_empty`, `coded_404_carries_the_relay_code`, `truncated_and_over_cap_bodies_are_incomplete`, `transport_failure_has_no_status`: the structured read error tells an older relay, a coded refusal, a cut-off body and a transport failure apart. - `a_401_retry_is_signed_by_the_same_keys`, `lift_signs_the_send_and_401_retry_with_the_checked_keys`: the 401 retry never switches keys. - `read_urls_validate_the_explicit_host_and_encode_the_query`, `lift_url_sends_the_page_host_not_the_active_relay`, `list_url_carries_host_and_cursor_and_refuses_a_changed_relay`, `lift_refuses_before_sending_on_signer_change_or_bad_host`: requests target the explicit host and refuse to send after a relay or signer change. jsdom: - `actions-pending-nav`: a pending intent survives a community change, and only Discard drops it. - `actions-fenced-lookup`, `fenced-load`: a late lookup for an old key never shows under, or unlocks, the new one. - `actions-same-ids`: the same pubkey and event id are read separately in each community. - `actions-community-change-drops-member`, `actions-page-host`, `actions-success`, `actions-identity`: the form acts only in the page's community, and confirm sends the frozen intent. - `actions-preview-errors`, `actions-delete-link`, `actions-staff-target`, `actions-disabled-auth`: preview errors, delete by note link, the staff block, and community pages rendering read-only with every action control disabled under disabled admin auth. - `communities-directory`, `communities-unsupported`, `community-badge`, `community-badge-disabled-auth`: the directory, older relays, and report badges, which open community pages under disabled admin auth too. - `restrictions-lift-signer-change`. The existing restriction tests now run on the community page and use `admin_lift_restriction`. Mutation checks: removing the pending-navigation guard turns `actions-pending-nav` red, and dropping the host from the fence key turns `actions-fenced-lookup`, `actions-same-ids` and `fenced-load` red. Ungating restriction lifts on `canMutate` turns `actions-disabled-auth` red. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
906 lines
30 KiB
JavaScript
906 lines
30 KiB
JavaScript
/**
|
|
* Feedback tab behavior tests for AdminConsolePanel. Covers feedback detail
|
|
* rendering, community grouping, status (honest and read-only), severed
|
|
* community, list refetch on back-nav, and canMutate gates.
|
|
*/
|
|
import assert from "node:assert/strict";
|
|
import { afterEach, test } from "node:test";
|
|
import {
|
|
act,
|
|
fireEvent,
|
|
setIpcHandler,
|
|
resetTestState,
|
|
mountPanel,
|
|
settle,
|
|
CM_ORIGIN,
|
|
CM_PUBKEY,
|
|
capturedToasts,
|
|
capturedErrorToasts,
|
|
makeCmFalseFeedback,
|
|
} from "./adminConsolePanelTestHelpers.jsdom.mjs";
|
|
|
|
afterEach(resetTestState);
|
|
|
|
// ── Feedback fixture builder ──────────────────────────────────────────────────
|
|
//
|
|
// Builds a matched (summary, detail) pair with explicit overrides for the
|
|
// fields that vary across feedback test scenarios. Reload handlers, IDs,
|
|
// counters, and per-test assertions stay test-local.
|
|
|
|
function makeFeedbackFixtures({
|
|
id = "00000000-0000-0000-0000-000000000099",
|
|
communityId = "comm-1",
|
|
communityHost = "relay.example.com",
|
|
submitterPubkey = "sub-fixture",
|
|
category = "bug",
|
|
bodySummary = "Fixture feedback summary",
|
|
body = "Fixture feedback full body",
|
|
status = "new",
|
|
eventId = "feedevent-fixture",
|
|
eventCreatedAt = "2024-06-01T09:00:00Z",
|
|
receivedAt = "2024-06-01T09:00:00Z",
|
|
} = {}) {
|
|
const summary = {
|
|
id,
|
|
communityId,
|
|
communityHost,
|
|
submitterPubkey,
|
|
category,
|
|
bodySummary,
|
|
status,
|
|
receivedAt,
|
|
};
|
|
const detail = {
|
|
id,
|
|
communityId,
|
|
communityHost,
|
|
eventId,
|
|
submitterPubkey,
|
|
category,
|
|
body,
|
|
status,
|
|
tags: [],
|
|
eventCreatedAt,
|
|
receivedAt,
|
|
};
|
|
return { summary, detail };
|
|
}
|
|
|
|
test("feedback-detail-renders-structured-fields: FeedbackDetail shows field layout, not raw JSON", async () => {
|
|
// Verifies item 3: the feedback detail view renders data-testid='feedback-detail-fields'.
|
|
// Lives here (jsdom) because tab switching and item navigation require fireEvent.click.
|
|
//
|
|
// Mutation evidence: revert FeedbackFields → <pre>{JSON.stringify(...)}</pre>
|
|
// → this test goes red ("feedback-detail-fields element must render").
|
|
|
|
const origin = "https://admin.example.com";
|
|
const pubkey = "6".repeat(64);
|
|
|
|
// Summary shape returned by GET /admin/feedback (FeedbackSummary wire type).
|
|
const feedbackSummary = {
|
|
id: "00000000-0000-0000-0000-000000000011",
|
|
communityId: "00000000-0000-0000-0000-000000000022",
|
|
communityHost: "relay.example.com",
|
|
submitterPubkey: "submitter001pubkey",
|
|
category: "bug",
|
|
bodySummary: "App crashes on startup",
|
|
status: "new",
|
|
receivedAt: "2024-05-01T09:00:05Z",
|
|
};
|
|
|
|
// Full AdminFeedbackDto shape returned by GET /admin/feedback/:id.
|
|
const feedbackDetail = {
|
|
id: "00000000-0000-0000-0000-000000000011",
|
|
communityId: "00000000-0000-0000-0000-000000000022",
|
|
communityHost: "relay.example.com",
|
|
eventId: "feedevent001",
|
|
submitterPubkey: "submitter001pubkey",
|
|
category: "bug",
|
|
body: "App crashes on startup — full detail body text",
|
|
status: "new",
|
|
tags: [],
|
|
eventCreatedAt: "2024-05-01T09:00:00Z",
|
|
receivedAt: "2024-05-01T09:00:05Z",
|
|
};
|
|
|
|
setIpcHandler("admin_list_reports", () => Promise.resolve([]));
|
|
setIpcHandler("admin_list_feedback", () =>
|
|
Promise.resolve([feedbackSummary]),
|
|
);
|
|
setIpcHandler("admin_get_feedback", () => Promise.resolve(feedbackDetail));
|
|
|
|
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
|
|
await doRender();
|
|
await settle(30);
|
|
|
|
// Click the Feedback tab.
|
|
const feedbackTab = container.querySelector(
|
|
"[data-testid='admin-tab-feedback']",
|
|
);
|
|
assert.ok(feedbackTab, "Feedback tab must be present");
|
|
await act(async () => {
|
|
fireEvent.click(feedbackTab);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
});
|
|
|
|
await settle(30);
|
|
|
|
// Pre-navigation: list row shows the summary body text (bodySummary rendered).
|
|
// Mutation seam: render `body` instead of `bodySummary` → red because summary
|
|
// fixture has no `body` field → row title is blank.
|
|
const listText = container.textContent ?? "";
|
|
assert.ok(
|
|
listText.includes("App crashes on startup"),
|
|
`list row must show bodySummary before navigation; got: ${listText.slice(0, 400)}`,
|
|
);
|
|
|
|
// Navigate into the feedback detail — click the first non-tab button.
|
|
const allButtons = container.querySelectorAll("button");
|
|
for (const btn of allButtons) {
|
|
const testid = btn.getAttribute("data-testid") ?? "";
|
|
if (testid.match(/^(admin-tab|community-badge)/)) continue;
|
|
await act(async () => {
|
|
fireEvent.click(btn);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
});
|
|
break;
|
|
}
|
|
|
|
await settle(30);
|
|
|
|
const fields = container.querySelector(
|
|
"[data-testid='feedback-detail-fields']",
|
|
);
|
|
assert.ok(
|
|
fields !== null,
|
|
"feedback-detail-fields element must render — JSON dump not replaced",
|
|
);
|
|
|
|
const text = container.textContent ?? "";
|
|
assert.ok(
|
|
!text.includes('"body":'),
|
|
`raw JSON must not be rendered in feedback detail; got: ${text.slice(0, 400)}`,
|
|
);
|
|
|
|
// Real DTO fields must render.
|
|
assert.ok(
|
|
text.includes("submitter001pubkey"),
|
|
`submitterPubkey must render; got: ${text.slice(0, 600)}`,
|
|
);
|
|
assert.ok(
|
|
text.includes("bug"),
|
|
`category must render; got: ${text.slice(0, 600)}`,
|
|
);
|
|
assert.ok(
|
|
text.includes("App crashes on startup"),
|
|
`body must render; got: ${text.slice(0, 600)}`,
|
|
);
|
|
|
|
// Fake fields must NOT appear.
|
|
assert.ok(
|
|
!text.includes("appVersion"),
|
|
`invented 'appVersion' field must not render; got: ${text.slice(0, 400)}`,
|
|
);
|
|
assert.ok(
|
|
!text.includes("authorPubkey"),
|
|
`invented 'authorPubkey' field must not render; got: ${text.slice(0, 400)}`,
|
|
);
|
|
|
|
// Relative timestamp: formatTimestamp output must match "Xm/h/d ago (...)" shape.
|
|
// The fixture receivedAt is far in the past, so it will be "Nd ago (...)".
|
|
assert.ok(
|
|
/\d+[mhd] ago \(/.test(text) || text.includes("just now ("),
|
|
`relative timestamp must render in "Nm/h/d ago (...)" format; got: ${text.slice(0, 600)}`,
|
|
);
|
|
|
|
await unmount();
|
|
});
|
|
|
|
// ── NIP-11 auto-discovery ─────────────────────────────────────────────────
|
|
|
|
test("feedback-grouped-by-community: multi-community feedback renders per-community headings", async () => {
|
|
// Same grouping contract for the Feedback tab.
|
|
//
|
|
// Mutation evidence: revert FeedbackTab to a flat <ul> → group headings
|
|
// vanish and this test goes red.
|
|
|
|
const origin = "https://admin.example.com";
|
|
const pubkey = "b8".repeat(32);
|
|
|
|
const feedback = [
|
|
{
|
|
id: "00000000-0000-0000-0000-0000000000b1",
|
|
communityId: "comm-1",
|
|
communityHost: "alpha.example.com",
|
|
submitterPubkey: "sub1",
|
|
category: "bug",
|
|
bodySummary: "Alpha feedback body",
|
|
status: "new",
|
|
receivedAt: "2024-06-01T09:00:00Z",
|
|
},
|
|
{
|
|
id: "00000000-0000-0000-0000-0000000000b2",
|
|
communityId: "comm-2",
|
|
communityHost: "beta.example.com",
|
|
submitterPubkey: "sub2",
|
|
category: "idea",
|
|
bodySummary: "Beta feedback body",
|
|
status: "new",
|
|
receivedAt: "2024-06-02T09:00:00Z",
|
|
},
|
|
];
|
|
|
|
setIpcHandler("admin_list_reports", () => Promise.resolve([]));
|
|
setIpcHandler("admin_list_feedback", () => Promise.resolve(feedback));
|
|
|
|
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
|
|
await doRender();
|
|
await settle(30);
|
|
|
|
// Switch to the Feedback tab.
|
|
const feedbackTab = container.querySelector(
|
|
"[data-testid='admin-tab-feedback']",
|
|
);
|
|
assert.ok(feedbackTab, "Feedback tab must be present");
|
|
await act(async () => {
|
|
fireEvent.click(feedbackTab);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
});
|
|
await settle(30);
|
|
|
|
const hosts = Array.from(
|
|
container.querySelectorAll(
|
|
"[data-testid='community-group-host'] [data-testid='community-badge-host']",
|
|
),
|
|
).map((el) => el.textContent);
|
|
assert.deepEqual(
|
|
hosts,
|
|
["alpha.example.com", "beta.example.com"],
|
|
`feedback group headings must show each community host; got: ${JSON.stringify(hosts)}`,
|
|
);
|
|
|
|
await unmount();
|
|
});
|
|
|
|
test("feedback-status-honest: a reviewed detail reports reviewed, never defaulting to new", async () => {
|
|
// Thufir finding 5 (desktop half): `status` is a required wire field. A
|
|
// reviewed/archived entry must render its real status after reload, not be
|
|
// silently presented as "new". The status control must also initialize its
|
|
// selected state from the server value.
|
|
//
|
|
// Mutation evidence: reinstate `detailState.data.status ?? "new"` in
|
|
// FeedbackDetail → a reviewed entry would still show, but re-adding the
|
|
// absent-defaulting cast and feeding an entry with no status would present
|
|
// it as new; here we assert the reviewed value round-trips and its button
|
|
// is the active (default-variant) one.
|
|
|
|
const origin = "https://admin.example.com";
|
|
const pubkey = "d5".repeat(32);
|
|
|
|
const { summary: reviewedSummary, detail: reviewedDetail } =
|
|
makeFeedbackFixtures({
|
|
id: "00000000-0000-0000-0000-0000000000d5",
|
|
communityId: "comm-1",
|
|
communityHost: "alpha.example.com",
|
|
submitterPubkey: "sub-reviewed",
|
|
category: "bug",
|
|
bodySummary: "Already-triaged feedback",
|
|
body: "Already-triaged feedback full body",
|
|
status: "reviewed",
|
|
eventId: "revevent",
|
|
receivedAt: "2024-06-01T09:00:00Z",
|
|
eventCreatedAt: "2024-06-01T09:00:00Z",
|
|
});
|
|
|
|
setIpcHandler("admin_list_reports", () => Promise.resolve([]));
|
|
setIpcHandler("admin_list_feedback", () =>
|
|
Promise.resolve([reviewedSummary]),
|
|
);
|
|
setIpcHandler("admin_get_feedback", () => Promise.resolve(reviewedDetail));
|
|
|
|
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
|
|
await doRender();
|
|
await settle(30);
|
|
|
|
// Switch to the Feedback tab.
|
|
const feedbackTab = container.querySelector(
|
|
"[data-testid='admin-tab-feedback']",
|
|
);
|
|
assert.ok(feedbackTab, "Feedback tab must be present");
|
|
await act(async () => {
|
|
fireEvent.click(feedbackTab);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
});
|
|
await settle(30);
|
|
|
|
// List row shows the "reviewed" badge (status !== "new").
|
|
const listText = container.textContent ?? "";
|
|
assert.ok(
|
|
listText.includes("reviewed"),
|
|
`list row must show the reviewed badge; got: ${listText.slice(0, 400)}`,
|
|
);
|
|
|
|
// Navigate into the feedback detail.
|
|
const listRow = Array.from(container.querySelectorAll("button")).find(
|
|
(btn) =>
|
|
!(btn.getAttribute("data-testid") ?? "").match(
|
|
/^(admin-tab|community-badge)/,
|
|
) && btn.textContent?.includes("Already-triaged feedback"),
|
|
);
|
|
assert.ok(listRow, "feedback list row must be present");
|
|
await act(async () => {
|
|
fireEvent.click(listRow);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
});
|
|
await settle(30);
|
|
|
|
// The status control initializes from the server value: the "reviewed"
|
|
// button is the active (default-variant) selection, not "new".
|
|
const control = container.querySelector(
|
|
"[data-testid='feedback-status-control']",
|
|
);
|
|
assert.ok(control, "feedback status control must render");
|
|
const reviewedBtn = container.querySelector(
|
|
"[data-testid='feedback-status-btn-reviewed']",
|
|
);
|
|
const newBtn = container.querySelector(
|
|
"[data-testid='feedback-status-btn-new']",
|
|
);
|
|
assert.ok(reviewedBtn && newBtn, "status buttons must render");
|
|
// The active status is styled with a ring highlight (see FeedbackStatusControl).
|
|
assert.ok(
|
|
(reviewedBtn.className ?? "").includes("ring-2"),
|
|
`the reviewed button must be marked active; got className: ${reviewedBtn.className}`,
|
|
);
|
|
assert.ok(
|
|
!(newBtn.className ?? "").includes("ring-2"),
|
|
`the new button must NOT be active for a reviewed entry; got className: ${newBtn.className}`,
|
|
);
|
|
|
|
// P2-2: semantic contract — aria-pressed must reflect the selected status,
|
|
// not just the visual ring class. Fails if aria-pressed is removed from
|
|
// FeedbackStatusControl's Button props.
|
|
assert.equal(
|
|
reviewedBtn.getAttribute("aria-pressed"),
|
|
"true",
|
|
"the active status button must have aria-pressed=true",
|
|
);
|
|
assert.equal(
|
|
newBtn.getAttribute("aria-pressed"),
|
|
"false",
|
|
"an inactive status button must have aria-pressed=false",
|
|
);
|
|
|
|
await unmount();
|
|
});
|
|
|
|
test("feedback-severed-community: a purged-source feedback row renders in list and detail without its community", async () => {
|
|
// Item 5 (desktop): feedback whose source community was purged carries a
|
|
// null communityId/communityHost (tenant provenance severed, row retained as
|
|
// operator evidence). The list must still render it (grouped under a
|
|
// "source community removed" bucket) and the detail must show em-dashes for
|
|
// the absent community fields — never crash on the null.
|
|
//
|
|
// Mutation evidence: narrow AdminFeedbackDto.communityId back to `string` →
|
|
// typecheck breaks; restore the `communityId: string` grouping constraint →
|
|
// the null key throws in groupByCommunity.
|
|
|
|
const origin = "https://admin.example.com";
|
|
const pubkey = "e8".repeat(32);
|
|
|
|
const { summary: severedSummary, detail: severedDetail } =
|
|
makeFeedbackFixtures({
|
|
id: "00000000-0000-0000-0000-0000000000e8",
|
|
communityId: null,
|
|
communityHost: null,
|
|
submitterPubkey: "sub-severed",
|
|
category: "bug",
|
|
bodySummary: "Feedback from a since-purged community",
|
|
body: "Feedback from a since-purged community — full body",
|
|
status: "new",
|
|
eventId: "sevevent",
|
|
eventCreatedAt: "2024-06-01T09:00:00Z",
|
|
receivedAt: "2024-06-01T09:00:00Z",
|
|
});
|
|
|
|
setIpcHandler("admin_list_reports", () => Promise.resolve([]));
|
|
setIpcHandler("admin_list_feedback", () => Promise.resolve([severedSummary]));
|
|
setIpcHandler("admin_get_feedback", () => Promise.resolve(severedDetail));
|
|
|
|
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
|
|
await doRender();
|
|
await settle(30);
|
|
|
|
const feedbackTab = container.querySelector(
|
|
"[data-testid='admin-tab-feedback']",
|
|
);
|
|
assert.ok(feedbackTab, "Feedback tab must be present");
|
|
await act(async () => {
|
|
fireEvent.click(feedbackTab);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
});
|
|
await settle(30);
|
|
|
|
// The severed row still renders in the list (did not throw / vanish).
|
|
const listRow = Array.from(container.querySelectorAll("button")).find(
|
|
(btn) =>
|
|
!(btn.getAttribute("data-testid") ?? "").match(
|
|
/^(admin-tab|community-badge)/,
|
|
) && btn.textContent?.includes("since-purged community"),
|
|
);
|
|
assert.ok(listRow, "the severed feedback row must render in the list");
|
|
|
|
await act(async () => {
|
|
fireEvent.click(listRow);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
});
|
|
await settle(30);
|
|
|
|
// Detail renders; the community fields show the em-dash placeholder.
|
|
const fields = container.querySelector(
|
|
"[data-testid='feedback-detail-fields']",
|
|
);
|
|
assert.ok(fields, "feedback detail must render for a severed row");
|
|
assert.ok(
|
|
(fields.textContent ?? "").includes("—"),
|
|
`absent community fields must render as em-dash; got: ${fields.textContent}`,
|
|
);
|
|
|
|
await unmount();
|
|
});
|
|
|
|
// ── D3a: kick suppressed when the report carries no channel ────────────────
|
|
|
|
test("feedback-list-refetches-on-back-after-mutation: changing status then navigating back shows fresh list status", async () => {
|
|
// Same fence for the Feedback tab: a status change in the detail bumps the
|
|
// FeedbackTab list generation so back-nav refetches.
|
|
//
|
|
// Mutation evidence: drop the FeedbackDetail onMutated → setListGen wiring →
|
|
// admin_list_feedback is called once and the second-call assertion goes red.
|
|
|
|
const origin = "https://admin.example.com";
|
|
const pubkey = "d6".repeat(32);
|
|
|
|
const summary = {
|
|
id: "00000000-0000-0000-0000-0000000000d6",
|
|
communityId: "00000000-0000-0000-0000-000000000022",
|
|
communityHost: "relay.example.com",
|
|
submitterPubkey: "submitter",
|
|
category: "bug",
|
|
bodySummary: "App crashes on startup",
|
|
status: "new",
|
|
receivedAt: "2024-05-01T09:00:05Z",
|
|
};
|
|
const detail = {
|
|
id: summary.id,
|
|
communityId: summary.communityId,
|
|
communityHost: summary.communityHost,
|
|
eventId: "feedevent",
|
|
submitterPubkey: summary.submitterPubkey,
|
|
category: "bug",
|
|
body: "App crashes on startup — full detail",
|
|
status: "new",
|
|
tags: [],
|
|
eventCreatedAt: "2024-05-01T09:00:00Z",
|
|
receivedAt: "2024-05-01T09:00:05Z",
|
|
};
|
|
|
|
let listCalls = 0;
|
|
setIpcHandler("admin_list_reports", () => Promise.resolve([]));
|
|
setIpcHandler("admin_list_feedback", () => {
|
|
listCalls += 1;
|
|
return Promise.resolve([
|
|
{ ...summary, status: listCalls === 1 ? "new" : "reviewed" },
|
|
]);
|
|
});
|
|
setIpcHandler("admin_get_feedback", () => Promise.resolve(detail));
|
|
setIpcHandler("admin_patch_feedback", () =>
|
|
Promise.resolve({ status: "reviewed" }),
|
|
);
|
|
|
|
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
|
|
await doRender();
|
|
await settle(30);
|
|
|
|
// Switch to the Feedback tab.
|
|
const feedbackTab = container.querySelector(
|
|
"[data-testid='admin-tab-feedback']",
|
|
);
|
|
assert.ok(feedbackTab, "Feedback tab must be present");
|
|
await act(async () => {
|
|
fireEvent.click(feedbackTab);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
});
|
|
await settle(20);
|
|
assert.equal(listCalls, 1, "feedback list is fetched once on tab open");
|
|
|
|
// Open the first feedback row.
|
|
const row = Array.from(container.querySelectorAll("button")).find(
|
|
(b) =>
|
|
!(b.getAttribute("data-testid") ?? "").match(
|
|
/^(admin-tab|community-badge)/,
|
|
) && b.textContent?.includes("App crashes"),
|
|
);
|
|
assert.ok(row, "feedback row must be present");
|
|
await act(async () => {
|
|
fireEvent.click(row);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
});
|
|
await settle(20);
|
|
|
|
// Mark reviewed.
|
|
const reviewedBtn = container.querySelector(
|
|
"[data-testid='feedback-status-btn-reviewed']",
|
|
);
|
|
assert.ok(reviewedBtn, "reviewed status button must be present");
|
|
await act(async () => {
|
|
fireEvent.click(reviewedBtn);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
});
|
|
await settle(20);
|
|
|
|
// Navigate back to the feedback list.
|
|
const backBtn = Array.from(container.querySelectorAll("button")).find((b) =>
|
|
b.textContent?.includes("Back to feedback"),
|
|
);
|
|
assert.ok(backBtn, "back-to-feedback button must be present");
|
|
await act(async () => {
|
|
fireEvent.click(backBtn);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
});
|
|
await settle(20);
|
|
|
|
assert.ok(
|
|
listCalls >= 2,
|
|
`the feedback list must refetch after back-nav following a status change; listCalls=${listCalls}`,
|
|
);
|
|
assert.ok(
|
|
(container.textContent ?? "").includes("reviewed"),
|
|
`the refetched feedback list must show the updated status; got: ${(container.textContent ?? "").slice(0, 400)}`,
|
|
);
|
|
|
|
await unmount();
|
|
});
|
|
|
|
test("canMutate-false-feedback: feedback-status-control absent in disabled mode", async () => {
|
|
// Mutation: remove {canMutate && …} guard on feedback status control → control renders → RED.
|
|
const { feedbackSummary, feedbackDetail } = makeCmFalseFeedback();
|
|
setIpcHandler("admin_list_reports", () => Promise.resolve([]));
|
|
setIpcHandler("admin_list_feedback", () =>
|
|
Promise.resolve([feedbackSummary]),
|
|
);
|
|
setIpcHandler("admin_get_feedback", () => Promise.resolve(feedbackDetail));
|
|
const { container, doRender, unmount } = mountPanel({
|
|
origin: CM_ORIGIN,
|
|
pubkey: CM_PUBKEY,
|
|
canMutate: false,
|
|
});
|
|
try {
|
|
await doRender();
|
|
await settle(30);
|
|
const feedbackTab = container.querySelector(
|
|
"[data-testid='admin-tab-feedback']",
|
|
);
|
|
assert.ok(feedbackTab, "Feedback tab must be present");
|
|
await act(async () => {
|
|
fireEvent.click(feedbackTab);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
});
|
|
await settle(30);
|
|
const listBtns = Array.from(container.querySelectorAll("button")).filter(
|
|
(b) =>
|
|
!(b.getAttribute("data-testid") ?? "").match(
|
|
/^(admin-tab|community-badge)/,
|
|
),
|
|
);
|
|
assert.ok(listBtns.length > 0, "feedback list item must be present");
|
|
await act(async () => {
|
|
fireEvent.click(listBtns[0]);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
});
|
|
await settle(30);
|
|
assert.equal(
|
|
container.querySelector("[data-testid='feedback-status-control']"),
|
|
null,
|
|
"feedback-status-control must be absent when canMutate=false",
|
|
);
|
|
} finally {
|
|
await unmount();
|
|
}
|
|
});
|
|
|
|
test("feedback-status-readonly: read-only detail shows status badge, no status-control, no PATCH", async () => {
|
|
const origin = "https://admin-readonly.example.com";
|
|
const pubkey = "55".repeat(32);
|
|
const feedbackId = "00000000-0000-0000-0000-000000000055";
|
|
|
|
const patchCalls = [];
|
|
setIpcHandler("admin_list_reports", () => Promise.resolve([]));
|
|
setIpcHandler("admin_list_feedback", () =>
|
|
Promise.resolve([
|
|
{
|
|
id: feedbackId,
|
|
communityId: "comm-1",
|
|
communityHost: "relay.example.com",
|
|
submitterPubkey: "sub055",
|
|
category: null,
|
|
bodySummary: "read-only feedback item",
|
|
receivedAt: "2024-01-01T00:00:00Z",
|
|
status: "reviewed",
|
|
},
|
|
]),
|
|
);
|
|
setIpcHandler("admin_get_feedback", () =>
|
|
Promise.resolve({
|
|
id: feedbackId,
|
|
communityId: "comm-1",
|
|
communityHost: "relay.example.com",
|
|
eventId: "fev055",
|
|
submitterPubkey: "sub055",
|
|
category: null,
|
|
body: "read-only feedback full body",
|
|
status: "reviewed",
|
|
tags: [],
|
|
eventCreatedAt: "2024-01-01T00:00:00Z",
|
|
receivedAt: "2024-01-01T00:00:00Z",
|
|
}),
|
|
);
|
|
setIpcHandler("admin_patch_feedback", (args) => {
|
|
patchCalls.push(args);
|
|
return Promise.resolve();
|
|
});
|
|
|
|
const { container, doRender, unmount } = mountPanel({
|
|
origin,
|
|
pubkey,
|
|
canMutate: false,
|
|
});
|
|
await doRender();
|
|
await settle(30);
|
|
|
|
try {
|
|
// Navigate to Feedback tab.
|
|
const feedbackTab = container.querySelector(
|
|
"[data-testid='admin-tab-feedback']",
|
|
);
|
|
assert.ok(feedbackTab, "Feedback tab must be present");
|
|
await act(async () => {
|
|
fireEvent.click(feedbackTab);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
});
|
|
await settle(30);
|
|
|
|
// Click the feedback list item to open detail.
|
|
const listBtns = Array.from(container.querySelectorAll("button")).filter(
|
|
(b) =>
|
|
!(b.getAttribute("data-testid") ?? "").match(
|
|
/^(admin-tab|community-badge)/,
|
|
),
|
|
);
|
|
assert.ok(listBtns.length > 0, "feedback list item must be present");
|
|
await act(async () => {
|
|
fireEvent.click(listBtns[0]);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
});
|
|
await settle(30);
|
|
|
|
// feedback-status-control must be absent (no mutation affordance).
|
|
const ctrl = container.querySelector(
|
|
"[data-testid='feedback-status-control']",
|
|
);
|
|
assert.equal(
|
|
ctrl,
|
|
null,
|
|
"feedback-status-control must be absent when canMutate=false",
|
|
);
|
|
|
|
// feedback-status-readonly must be present with the server status.
|
|
const readonlyBadge = container.querySelector(
|
|
"[data-testid='feedback-status-readonly']",
|
|
);
|
|
assert.ok(
|
|
readonlyBadge !== null,
|
|
"feedback-status-readonly must be present in read-only detail",
|
|
);
|
|
assert.ok(
|
|
readonlyBadge.textContent.includes("reviewed"),
|
|
`feedback-status-readonly must show server status "reviewed"; got: ${readonlyBadge.textContent}`,
|
|
);
|
|
|
|
// No PATCH must have been issued.
|
|
assert.equal(
|
|
patchCalls.length,
|
|
0,
|
|
"admin_patch_feedback must not be called in read-only mode",
|
|
);
|
|
} finally {
|
|
await unmount();
|
|
}
|
|
});
|
|
|
|
// ── Item 5: non-image attachment uses native save dialog ──────────────────
|
|
|
|
test("non-image-attachment-save-button: a non-image attachment shows a Save button and calls admin_save_attachment", async () => {
|
|
// Non-image attachments must use the native save-dialog path
|
|
// (admin_save_attachment Tauri command) rather than <a download href={blob:}>,
|
|
// which is a WKWebView no-op.
|
|
|
|
const origin = CM_ORIGIN;
|
|
const pubkey = CM_PUBKEY;
|
|
const sha256 = "a1b2c3d4e5f67890".repeat(4); // 64 hex chars
|
|
const imetaTag = [
|
|
"imeta",
|
|
`url https://${origin}/attachments/${sha256}`,
|
|
"m application/pdf",
|
|
`x ${sha256}`,
|
|
"size 2048",
|
|
];
|
|
|
|
const { summary, detail } = makeFeedbackFixtures({
|
|
id: "00000000-0000-0000-0000-000000000fa1",
|
|
bodySummary: "Attachment test feedback",
|
|
body: "Attachment test feedback full body",
|
|
});
|
|
const detailWithAttachment = { ...detail, tags: [imetaTag] };
|
|
|
|
setIpcHandler("admin_list_reports", () => Promise.resolve([]));
|
|
setIpcHandler("admin_list_feedback", () => Promise.resolve([summary]));
|
|
setIpcHandler("admin_get_feedback", () =>
|
|
Promise.resolve(detailWithAttachment),
|
|
);
|
|
|
|
let saveArgs = null;
|
|
let saveResult = true;
|
|
setIpcHandler("admin_save_attachment", (args) => {
|
|
saveArgs = args;
|
|
return Promise.resolve(saveResult);
|
|
});
|
|
|
|
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
|
|
try {
|
|
await doRender();
|
|
await settle(30);
|
|
|
|
// Open Feedback tab.
|
|
const feedbackTab = container.querySelector(
|
|
"[data-testid='admin-tab-feedback']",
|
|
);
|
|
assert.ok(feedbackTab, "Feedback tab must be present");
|
|
await act(async () => {
|
|
fireEvent.click(feedbackTab);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
});
|
|
await settle(30);
|
|
|
|
// Navigate to feedback detail.
|
|
const allButtons = Array.from(container.querySelectorAll("button"));
|
|
const itemBtn = allButtons.find(
|
|
(b) =>
|
|
!(b.getAttribute("data-testid") ?? "").match(
|
|
/^(admin-tab|community-badge)/,
|
|
),
|
|
);
|
|
assert.ok(itemBtn, "feedback item button must exist");
|
|
await act(async () => {
|
|
fireEvent.click(itemBtn);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
});
|
|
await settle(30);
|
|
|
|
// The attachment section must be visible.
|
|
const attachmentSection = container.querySelector("h4");
|
|
assert.ok(
|
|
attachmentSection?.textContent?.includes("Attachment"),
|
|
`Attachments section must render; got: ${container.textContent?.slice(0, 200)}`,
|
|
);
|
|
|
|
// There must be a "Save attachment" button (not an <a> element).
|
|
const saveBtns = Array.from(container.querySelectorAll("button")).filter(
|
|
(b) => (b.textContent ?? "").includes("Save attachment"),
|
|
);
|
|
assert.ok(
|
|
saveBtns.length > 0,
|
|
`"Save attachment" button must render for a non-image attachment; got: ${container.textContent?.slice(0, 400)}`,
|
|
);
|
|
|
|
// Save is the only action: no View/preview control for non-images.
|
|
assert.ok(
|
|
!(container.textContent ?? "").includes("View attachment"),
|
|
"non-image attachments must not offer a View action",
|
|
);
|
|
|
|
// No <a download> anchor must exist for non-image attachments.
|
|
const anchors = container.querySelectorAll("a[download]");
|
|
assert.equal(
|
|
anchors.length,
|
|
0,
|
|
"no <a download> anchor must exist — non-image attachments use the native save dialog",
|
|
);
|
|
|
|
// Click Save attachment — must call admin_save_attachment with correct params.
|
|
await act(async () => {
|
|
fireEvent.click(saveBtns[0]);
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
});
|
|
|
|
assert.ok(saveArgs !== null, "admin_save_attachment must have been called");
|
|
assert.equal(
|
|
saveArgs.sha256,
|
|
sha256,
|
|
`save args must include the correct sha256; got: ${JSON.stringify(saveArgs)}`,
|
|
);
|
|
assert.equal(
|
|
saveArgs.expectedMime,
|
|
"application/pdf",
|
|
`save args must include the correct MIME type; got: ${JSON.stringify(saveArgs)}`,
|
|
);
|
|
assert.equal(
|
|
saveArgs.expectedSize,
|
|
2048,
|
|
`save args must include the correct size; got: ${JSON.stringify(saveArgs)}`,
|
|
);
|
|
|
|
// A written file (`true`) shows exactly one success toast.
|
|
assert.deepEqual(capturedToasts, ["Attachment saved"]);
|
|
|
|
// A cancelled dialog (`false`) shows nothing: no toast, no error.
|
|
saveResult = false;
|
|
const saveBtnAfter = Array.from(container.querySelectorAll("button")).find(
|
|
(b) => (b.textContent ?? "").includes("Save attachment"),
|
|
);
|
|
assert.ok(saveBtnAfter, "Save button must re-enable after a save");
|
|
await act(async () => {
|
|
fireEvent.click(saveBtnAfter);
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
});
|
|
assert.deepEqual(
|
|
capturedToasts,
|
|
["Attachment saved"],
|
|
"cancel must not toast",
|
|
);
|
|
assert.deepEqual(capturedErrorToasts, [], "cancel must not error-toast");
|
|
assert.ok(
|
|
Array.from(container.querySelectorAll("button")).some((b) =>
|
|
(b.textContent ?? "").includes("Save attachment"),
|
|
),
|
|
`cancel must leave the Save control, not an error; got: ${container.textContent?.slice(0, 400)}`,
|
|
);
|
|
|
|
// A rejected save keeps Save reachable, shows the error beside it,
|
|
// and a retry clears the error and succeeds.
|
|
saveResult = "rejected";
|
|
setIpcHandler("admin_save_attachment", (args) => {
|
|
saveArgs = args;
|
|
return saveResult === "rejected"
|
|
? Promise.reject("admin_attachment_network_error")
|
|
: Promise.resolve(saveResult);
|
|
});
|
|
const findSave = () =>
|
|
Array.from(container.querySelectorAll("button")).find((b) =>
|
|
(b.textContent ?? "").includes("Save attachment"),
|
|
);
|
|
await act(async () => {
|
|
fireEvent.click(findSave());
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
});
|
|
assert.ok(
|
|
(container.textContent ?? "").includes("admin_attachment_network_error"),
|
|
`rejection must show the save error; got: ${container.textContent?.slice(0, 400)}`,
|
|
);
|
|
const retryBtn = findSave();
|
|
assert.ok(retryBtn, "Save must stay visible after a rejected save");
|
|
saveResult = true;
|
|
await act(async () => {
|
|
fireEvent.click(retryBtn);
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
});
|
|
assert.ok(
|
|
!(container.textContent ?? "").includes("admin_attachment_network_error"),
|
|
"retry must clear the save error",
|
|
);
|
|
assert.deepEqual(capturedToasts, ["Attachment saved", "Attachment saved"]);
|
|
} finally {
|
|
await unmount();
|
|
}
|
|
});
|