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
262 lines
8.7 KiB
TypeScript
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",
|
|
);
|
|
});
|
|
});
|