Files
maarcadetweet/crates/tauri-app/src/lib/components/NotificationsView.test.ts
T
tomdeboneandClaude Opus 5 9ee717bbc7 fix(tauri-app): Token an die AppView senden — und die Erneuerung reparieren
Die vier viewer-bezogenen AppView-Aufrufe (Timeline, Notifications,
Count, Seen) senden jetzt das Access-JWT. Ohne Session gibt es einen
sprechenden Fehler statt eines leeren Bearer-Headers.

Dabei kam heraus, dass die automatische Token-Erneuerung noch nie
funktioniert hat: isTokenInvalid() stieg mit `typeof e !== "object"`
sofort aus, aber Tauri lehnt bei Commands mit Result<T, String> mit
einem blanken String ab — der Zweig war seit seiner Einführung tot.
Belegt per Mutationstest: mit der alten Zeile fallen acht der neuen
Tests um. Die Prüfung liest den Fehlertext jetzt über einen Helfer,
der Strings und Objekte behandelt.

Dazu: der Badge-Poll bricht ab, wenn die Erneuerung endgültig
scheitert, statt weiter gegen einen 401 zu laufen. 503 AuthUnavailable
gilt dabei bewusst nicht als Auth-Fehler — die PDS kann kurz weg sein,
der Poll soll das überdauern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 23:03:12 +02:00

262 lines
8.7 KiB
TypeScript

// 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/<handle>` 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<typeof import("../api/client")>("../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<typeof mount> | 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<string, unknown> = {}) {
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<HTMLButtonElement>("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",
);
});
});