// Regression guard for the actor-navigation path out of the // notifications list. // // The AppView guarantees `author_handle` is non-empty, but it does NOT // guarantee it's a real handle: for an author it has neither a // `profiles` row nor an indexed post for, it synthesises a placeholder // from the DID (`short_did_bare` in the AppView's `routes.rs`) — e.g. // `"did:plc:f5…"`, a truncated DID with an ellipsis. Navigating with // that string resolves to nothing, and `/api/profile/` answers // with a *synthetic empty profile* rather than an error, so the click // silently dead-ends on a blank page. // // The fix is to hand the navigation callback the real `author_did` // alongside the handle. These tests pin that: the callback must // receive the DID from the DTO, for both a real-handle author and a // placeholder-handle one. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mount, unmount, tick } from "svelte"; const fetchNotificationsMock = vi.fn(); const markNotificationsSeenMock = vi.fn(); vi.mock("../api/client", async () => { // Keep the real `notificationText` / `notificationIcon` — the row // copy is part of what we're rendering — and stub only the two // functions that would otherwise need a Tauri runtime. const actual = await vi.importActual("../api/client"); return { ...actual, fetchNotifications: (...args: unknown[]) => fetchNotificationsMock(...args), markNotificationsSeen: (...args: unknown[]) => markNotificationsSeenMock(...args), // Avatar resolves blobs through this; every fixture below has a // null avatar CID so it never fires, but stub it so a stray call // can't reach for the Tauri shell. fetchBlob: () => Promise.reject(new Error("no blob in test")), }; }); import NotificationsView from "./NotificationsView.svelte"; let target: HTMLDivElement; let app: ReturnType | null = null; beforeEach(() => { target = document.createElement("div"); document.body.appendChild(target); fetchNotificationsMock.mockReset(); markNotificationsSeenMock.mockReset(); markNotificationsSeenMock.mockResolvedValue({ ok: true, updated: 1 }); }); afterEach(() => { if (app) unmount(app); app = null; target.remove(); }); /// Let the component's load effect and its awaited promises settle. /// The mocked fetch resolves immediately, so a handful of microtask /// turns plus a Svelte flush is enough. async function flush(turns = 6) { for (let i = 0; i < turns; i++) { await Promise.resolve(); await tick(); } } function row(over: Record = {}) { return { id: 1, kind: "like", author_did: "did:plc:realauthor", author_handle: "alice.test", author_avatar_cid: null, subject_uri: "at://did:plc:me/app.twi.post/3k2", subject_text: "hello", created_at: "2026-09-09T10:00:00Z", indexed_at: "2026-09-09T10:00:01Z", read_at: null, ...over, }; } describe("NotificationsView actor navigation", () => { it("hands the callback the author DID, not just the handle", async () => { fetchNotificationsMock.mockResolvedValue({ notifications: [row()], cursor: null, }); const onActorClick = vi.fn(); app = mount(NotificationsView, { target, props: { did: "did:plc:me", on_actor_click: onActorClick }, }); await flush(); const handleBtn = target.querySelector("button.notifs__handle"); expect(handleBtn).not.toBeNull(); handleBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true })); await tick(); expect(onActorClick).toHaveBeenCalledTimes(1); expect(onActorClick).toHaveBeenCalledWith( "did:plc:realauthor", "alice.test", ); }); it("resolves an author whose handle is only a truncated-DID placeholder", async () => { // Exactly what the AppView returns for an author it has never // seen post and has no profile row for: `handle` is // `short_did_bare(author_did)`, which matches nothing on the way // back in. The DID beside it is real. fetchNotificationsMock.mockResolvedValue({ notifications: [ row({ id: 2, kind: "follow", author_did: "did:plc:f5abcdefghijklmnop", author_handle: "did:plc:f5a…", subject_uri: null, subject_text: undefined, }), ], cursor: null, }); const onActorClick = vi.fn(); app = mount(NotificationsView, { target, props: { did: "did:plc:me", on_actor_click: onActorClick }, }); await flush(); target .querySelector("button.notifs__handle")! .dispatchEvent(new MouseEvent("click", { bubbles: true })); await tick(); const [didArg, handleArg] = onActorClick.mock.calls[0]; // The DID must be the full, real one — never the truncated // display string. expect(didArg).toBe("did:plc:f5abcdefghijklmnop"); expect(didArg).not.toContain("…"); // The placeholder still travels as the label, which is fine — it // is not what the lookup keys off. expect(handleArg).toBe("did:plc:f5a…"); }); it("renders the kind copy and marks the first page seen at the top row", async () => { fetchNotificationsMock.mockResolvedValue({ notifications: [row({ kind: "follow", subject_uri: null })], cursor: null, }); const onSeen = vi.fn(); app = mount(NotificationsView, { target, props: { did: "did:plc:me", on_seen: onSeen }, }); await flush(); expect(target.textContent).toContain("folgt dir jetzt"); // The read watermark is the newest rendered row's `indexed_at` — // never null, so a notification landing mid-scroll survives. expect(markNotificationsSeenMock).toHaveBeenCalledWith( "did:plc:me", "2026-09-09T10:00:01Z", ); expect(onSeen).toHaveBeenCalledTimes(1); }); it("a follow row has no thread target, so its row button is inert", async () => { fetchNotificationsMock.mockResolvedValue({ notifications: [row({ kind: "follow", subject_uri: null })], cursor: null, }); const onThreadClick = vi.fn(); app = mount(NotificationsView, { target, props: { did: "did:plc:me", on_thread_click: onThreadClick }, }); await flush(); const rowBtn = target.querySelector("button.notifs__row"); expect(rowBtn?.disabled).toBe(true); rowBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true })); await tick(); expect(onThreadClick).not.toHaveBeenCalled(); }); it("shows actionable copy when the AppView rejects the session", async () => { // Since `/api/notifications` grew an auth guard, this is what a // rejected token looks like by the time it reaches the view: the // AppView's JSON body, wrapped by `appview_client.rs`'s // `status_error()` and stringified across the Tauri IPC boundary. // `safeInvoke` has already spent its one refresh attempt getting // here, so the only thing left to tell the user is "log in again" — // rendering the raw wire string would be accurate and useless. fetchNotificationsMock.mockRejectedValue( 'appview: notifications returned 401 Unauthorized: ' + '{"error":"TokenInvalid","message":"ExpiredSignature"}', ); app = mount(NotificationsView, { target, props: { did: "did:plc:me" }, }); await flush(); expect(target.textContent).toContain("bitte neu anmelden"); expect(target.textContent).not.toContain("TokenInvalid"); expect(target.textContent).not.toContain("401"); // A failed load must not leave the spinner up or ack a page it // never rendered. expect(markNotificationsSeenMock).not.toHaveBeenCalled(); }); it("still shows a server error verbatim — there's nothing better to say", async () => { fetchNotificationsMock.mockRejectedValue( "appview: notifications returned 500 Internal Server Error: db down", ); app = mount(NotificationsView, { target, props: { did: "did:plc:me" }, }); await flush(); expect(target.textContent).toContain("500"); expect(target.textContent).toContain("db down"); }); it("opens the thread for a row that has a subject", async () => { fetchNotificationsMock.mockResolvedValue({ notifications: [row()], cursor: null, }); const onThreadClick = vi.fn(); app = mount(NotificationsView, { target, props: { did: "did:plc:me", on_thread_click: onThreadClick }, }); await flush(); target .querySelector("button.notifs__row")! .dispatchEvent(new MouseEvent("click", { bubbles: true })); await tick(); expect(onThreadClick).toHaveBeenCalledWith( "at://did:plc:me/app.twi.post/3k2", ); }); });