/** * A refused sharing action says what happened, stays on screen, and can be * tried again. * * ## The state this file exists to prevent * * Three of the four mutations on the shared-access panel had no failure * rendering at all. `accept`, `revoke` and `renounce` fired, the request came * back 409 or 410 or never left the machine, and the panel did nothing: the * dialog had already closed, the list did not change, and the only signal was * the absence of one. The two refusals that matter most were exactly the two * that arrived silently — an invitation that had lapsed, and the last Guardian * being told they may not walk away from a profile that would then have nobody * looking after it. Both are recoverable, and neither was recoverable if * nobody knew it had happened. * * ## Why the failure lives on the mutation and not in a `useState` * * A local `useState` set from `onError` is a second copy of a fact TanStack * already holds, and the copy is the thing that goes stale: a re-render from * an unrelated invalidation clears it, or a second failure overwrites the * first row's message onto another row. `isError` / `error` / `variables` * survive on the mutation until something explicitly resets it, and * `variables` is what says WHICH row failed. That is also what makes this file * possible — the panel's failure states are reachable from a static render, * with no click, because they are props rather than the result of an * interaction. * * ## What a static render can and cannot prove * * It proves the alert exists, is announced, is addressed to the right row, * carries content contrast rather than muted meta, sits beside a retry control * and leaves the row's own affordances in place. It does NOT prove the retry * button re-fires the mutation with the same argument — there is no click * here. That claim is the browser journey's, and the pure resolver below is * where the mapping from a refusal to a sentence is actually pinned. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; import { I18nProvider } from "@/lib/i18n/context"; import { ApiError } from "@/lib/api/api-fetch"; import type { GrantList, GrantRow } from "@/lib/queries/use-account-grants"; const grantsRef: { value: GrantList } = { value: grantList() }; function grantList(partial: Partial = {}): GrantList { return { given: [], received: [], retentionDays: 365, ...partial }; } /** A mutation as the panel sees one: idle unless a case says otherwise. */ interface FakeMutation { mutate: ReturnType; isPending: boolean; isError: boolean; error: unknown; variables: string | undefined; } function idle(): FakeMutation { return { mutate: vi.fn(), isPending: false, isError: false, error: null, variables: undefined, }; } function failed(error: unknown, grantId: string): FakeMutation { return { ...idle(), isError: true, error, variables: grantId }; } const acceptRef = { value: idle() }; const revokeRef = { value: idle() }; const renounceRef = { value: idle() }; vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useAccountGrants: () => ({ data: grantsRef.value, isLoading: false, isError: false, refetch: vi.fn(), }), useAcceptGrant: () => acceptRef.value, useRevokeGrant: () => revokeRef.value, useRenounceGrant: () => renounceRef.value, }; }); vi.mock("@/hooks/use-account-switch", () => ({ useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }), })); import { grantActionErrorKey, type GrantAction, } from "@/components/settings/access/grant-action-error"; import { inviteErrorKey } from "@/components/settings/access/grant-invite-card"; import { GrantsGivenCard } from "@/components/settings/access/grants-given-card"; import { GrantsReceivedCard } from "@/components/settings/access/grants-received-card"; function render(node: React.ReactNode): string { return renderToStaticMarkup( {node}, ); } const BASE: GrantRow = { id: "g1", account: { id: "acct-1", username: "housemate", displayName: "Jo" }, access: "MANAGE", scope: null, state: "ACTIVE", invitedAt: "2026-01-02T10:00:00.000Z", acceptedAt: "2026-01-02T11:00:00.000Z", expiresAt: null, lastUsedAt: null, revokedAt: null, revokedBy: null, }; const PENDING: GrantRow = { ...BASE, state: "PENDING", acceptedAt: null }; function apiError(status: number, errorCode?: string): ApiError { return new ApiError("refused", status, errorCode ? { errorCode } : undefined); } function reset() { acceptRef.value = idle(); revokeRef.value = idle(); renounceRef.value = idle(); grantsRef.value = grantList(); } describe("what a refused sharing action says", () => { const cases: Array<{ action: GrantAction; err: unknown; key: string; why: string; }> = [ { action: "accept", err: apiError(410, "sharing.accept.expired"), key: "recordSharing.actionError.acceptExpired", why: "the invitation lapsed — the next step is to ask for a new one", }, { action: "accept", err: apiError(410, "sharing.accept.revoked"), key: "recordSharing.actionError.acceptWithdrawn", why: "withdrawn is a different fact from lapsed, and has no next step", }, { action: "accept", err: apiError(409, "sharing.accept.not_pending"), key: "recordSharing.actionError.acceptNotPending", why: "already accepted, most likely in another tab", }, { action: "accept", err: apiError(404, "sharing.accept.not_found"), key: "recordSharing.actionError.acceptNotFound", why: "the row is gone", }, { action: "accept", err: apiError(429), key: "recordSharing.actionError.rateLimited", why: "waiting is the recovery, so it must not read as a failure", }, { action: "revoke", err: apiError(409, "managed_profile.guardian.required"), key: "recordSharing.actionError.lastGuardian", why: "the refusal a managed profile depends on, and it names a way out", }, { action: "renounce", err: apiError(409, "managed_profile.guardian.required"), key: "recordSharing.actionError.lastGuardian", why: "the same refusal reached from the delegate's side", }, { action: "revoke", err: apiError(409, "sharing.revoke.already_ended"), key: "recordSharing.actionError.alreadyEnded", why: "nothing to recover — the access is already closed", }, { action: "renounce", err: apiError(409, "sharing.renounce.already_ended"), key: "recordSharing.actionError.alreadyEnded", why: "same, from the other side", }, { action: "revoke", err: apiError(404, "sharing.revoke.not_found"), key: "recordSharing.actionError.notFound", why: "the row is gone", }, { action: "deleteProfile", err: apiError(404, "managed_profile.not_found"), key: "recordSharing.actionError.profileNotFound", why: "the only refusal the delete route classifies, and it covers three situations the server will not tell apart", }, { action: "removeGuardian", err: apiError(404, "managed_profile.not_found"), key: "recordSharing.actionError.profileNotFound", why: "the same refusal reached from the guardian-removal route", }, { action: "removeGuardian", err: apiError(409, "managed_profile.guardian.required"), key: "recordSharing.actionError.lastGuardian", why: "the floor, still enforced by the server after the card has hidden the control", }, { action: "deleteProfile", err: apiError(500), key: "recordSharing.actionError.deleteProfileFailed", why: "anything the delete route does not classify becomes the generic envelope and must not borrow a diagnosis", }, { action: "deleteProfile", err: apiError(401, "auth.stepup.required"), key: "recordSharing.managed.errorStepUp", why: "the step-up window lapsed while the confirm dialog was open — the likeliest refusal of the two acts that carry a gate", }, { action: "removeGuardian", err: apiError(401, "auth.stepup.required"), key: "recordSharing.managed.errorStepUp", why: "the same gate on the other confirmed act", }, { action: "deleteProfile", err: apiError(401, "auth.stepup.mfa_not_enrolled"), key: "recordSharing.managed.errorMfaRequired", why: "no second factor at all: 'confirm your second factor' is a dead end, and enrolling is the only way through", }, { action: "accept", err: apiError(401), key: "recordSharing.actionError.acceptFailed", why: "a 401 on a route with NO step-up gate is a lapsed session, and must not tell somebody to confirm a factor", }, { action: "renounce", err: apiError(401), key: "recordSharing.actionError.renounceFailed", why: "same, from the delegate's side", }, { action: "removeGuardian", err: new TypeError("Failed to fetch"), key: "recordSharing.actionError.offline", why: "never reached the server, so the guardian still has access and the row is unchanged", }, { action: "accept", err: new TypeError("Failed to fetch"), key: "recordSharing.actionError.offline", why: "never reached the server — retrying is the whole recovery", }, { action: "revoke", err: new TypeError("Failed to fetch"), key: "recordSharing.actionError.offline", why: "same, and the row is unchanged because nothing was sent", }, { action: "renounce", err: new TypeError("Failed to fetch"), key: "recordSharing.actionError.offline", why: "same", }, { action: "accept", err: apiError(500), key: "recordSharing.actionError.acceptFailed", why: "an unclassified failure still says which act failed", }, { action: "revoke", err: apiError(500), key: "recordSharing.actionError.revokeFailed", why: "same", }, { action: "renounce", err: apiError(500), key: "recordSharing.actionError.renounceFailed", why: "same", }, ]; for (const { action, err, key, why } of cases) { it(`${action}: ${why}`, () => { expect(grantActionErrorKey(err, action)).toBe(key); }); } it("tells the refusals apart rather than collapsing them", () => { // The failure this table is written against is one message for // everything. Distinct codes must produce distinct sentences, and the // count is asserted so a resolver that returned one key for all of them // fails here rather than passing every row above by accident. const distinct = new Set( cases.map(({ err, action }) => grantActionErrorKey(err, action)), ); expect(distinct.size).toBeGreaterThan(8); }); }); describe("a refused invitation keeps what was typed", () => { const INVITE_CARD = readFileSync( join(process.cwd(), "src/components/settings/access/grant-invite-card.tsx"), "utf8", ); it("separates a request that never arrived from one the server refused", () => { // "The invitation could not be sent" invites somebody to change the form, // and after a dropped connection the form is fine. expect(inviteErrorKey(new TypeError("Failed to fetch"))).toBe( "recordSharing.invite.errorOffline", ); expect(inviteErrorKey(apiError(404))).toBe( "recordSharing.invite.errorNoAccount", ); expect(inviteErrorKey(apiError(409, "sharing.invite.duplicate"))).toBe( "recordSharing.invite.errorDuplicate", ); }); it("clears the form only where the invitation actually went", () => { // A static render cannot press the button, so the claim is made about the // code path instead: every field reset lives inside `onSuccess`. A reset // that migrated into `onError` — or out of the callbacks entirely — would // wipe a typed identifier and eight ticked sections on a failure the // person is being asked to retry. const start = INVITE_CARD.indexOf("onSuccess:"); const split = INVITE_CARD.indexOf("onError:", start); // The options object ends at the first ` },` after the error arm, so // the slice stops at the mutation rather than running into the JSX below, // where a `setNarrowed(false)` / `setNarrowed(true)` on a radio would // satisfy the matcher for the wrong reason. The success reset returns the // scope to unset (`null`), which the JSX never writes. const end = INVITE_CARD.indexOf("\n },", split); const onSuccess = INVITE_CARD.slice(start, split); const onError = INVITE_CARD.slice(split, end); // Positive controls: both slices really contain the callback they name. expect(onSuccess).toContain("setInvited"); expect(onError).toContain("inviteErrorKey"); for (const reset of [ 'setIdentifier("")', 'setExpiresOn("")', 'setAccess("READ")', "setNarrowed(null)", "setSections([])", ]) { expect(onSuccess, reset).toContain(reset); expect(onError, reset).not.toContain(reset); } }); }); describe("the refusal stays on screen, addressed to its own row", () => { it("announces a refused acceptance and leaves the invitation acceptable", () => { reset(); grantsRef.value = grantList({ received: [PENDING] }); acceptRef.value = failed(apiError(410, "sharing.accept.expired"), "g1"); const html = render(); // Announced, not merely printed. expect(html).toContain('data-slot="grant-action-error"'); expect(html).toContain('role="alert"'); expect(html).toContain("That invitation has lapsed"); // Addressed to the row it belongs to, so a second invitation below it // does not wear somebody else's failure. expect(html).toContain('data-grant-error-for="g1"'); // The state survives: the row is still there and still offers the act. expect(html).toContain('data-grant-id="g1"'); expect(html).toContain('data-slot="grant-accept"'); // And there is a way to try again. expect(html).toContain('data-slot="grant-action-retry"'); }); it("does not paint the failure onto a row that did not fail", () => { reset(); grantsRef.value = grantList({ received: [PENDING, { ...PENDING, id: "g2" }], }); acceptRef.value = failed(apiError(410, "sharing.accept.expired"), "g1"); const html = render(); expect(html.match(/data-slot="grant-action-error"/g)).toHaveLength(1); expect(html).toContain('data-grant-error-for="g1"'); expect(html).not.toContain('data-grant-error-for="g2"'); }); it("names the last-Guardian refusal and the way out of it", () => { reset(); grantsRef.value = grantList({ given: [BASE] }); revokeRef.value = failed( apiError(409, "managed_profile.guardian.required"), "g1", ); const html = render(); expect(html).toContain('data-slot="grant-action-error"'); // The sentence has to carry the recovery. "Could not end access" is true // and useless; the owner needs to know a second guardian is the fix. expect(html).toContain("Add another guardian first"); // The row keeps its control, so the owner can act on the advice and // return to the same button. expect(html).toContain('data-slot="grant-revoke"'); }); it("reaches the same refusal from the delegate's side", () => { reset(); grantsRef.value = grantList({ received: [BASE] }); renounceRef.value = failed( apiError(409, "managed_profile.guardian.required"), "g1", ); const html = render(); expect(html).toContain('data-slot="grant-action-error"'); expect(html).toContain("Add another guardian first"); expect(html).toContain('data-slot="grant-renounce"'); }); it("keeps material copy in content contrast, not muted meta", () => { reset(); grantsRef.value = grantList({ received: [PENDING] }); acceptRef.value = failed(new TypeError("Failed to fetch"), "g1"); const html = render(); const alert = /<[^>]*data-slot="grant-action-error"[^>]*>/.exec(html)?.[0]; expect(alert).toBeDefined(); // A refusal somebody has to act on is content. UI-STANDARDS §3 reserves // muted for meta, and a sentence nobody reads is a sentence nobody acts // on. expect(alert).toContain("text-destructive"); expect(alert).not.toContain("text-muted-foreground"); }); it("shows nothing at all while every mutation is idle", () => { reset(); grantsRef.value = grantList({ given: [BASE], received: [PENDING] }); expect(render()).not.toContain( 'data-slot="grant-action-error"', ); expect(render()).not.toContain( 'data-slot="grant-action-error"', ); }); });