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
This commit is contained in:
tomdebone
2026-09-09 23:03:12 +02:00
co-authored by Claude Opus 5
parent ac18ff7a16
commit 9ee717bbc7
7 changed files with 903 additions and 105 deletions
@@ -194,6 +194,48 @@ describe("NotificationsView actor navigation", () => {
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()],