Files
maarcadetweet/crates/tauri-app/src/lib/api/notifications.test.ts
T
tomdeboneandEifelduds f281914899 feat(tauri-app): Notifications-View mit Badge, Follower-Listen, DID-Navigation
Bindet die neuen AppView-Endpoints an: sechs IPC-Commands
(fetch_notifications, notification_count, mark_notifications_seen,
fetch_followers, fetch_following, fetch_thread) plus profile_get_by_did,
dazu die TS-Gegenstücke.

* NotificationsView: Liste mit Icon/Text je Art, Avatar, Vorschau des
  subject_text, Cursor-Pagination; Klick auf eine Zeile mit Subject öffnet
  den Thread. Beim Öffnen wird mit dem indexed_at der obersten Zeile als
  Wasserzeichen quittiert.
* NavRail: Eintrag mit Unread-Badge, gepollt im vorhandenen 5s-Timer und
  über den bestehenden Teardown-Pfad abgeräumt; der Poll pausiert, solange
  die Liste offen ist.
* ProfileView: Follower- und Following-Zahlen sind jetzt Buttons und öffnen
  die jeweilige Liste inline.

Navigiert wird über die DID, nicht über den Handle: für Actors, die weder
profiles noch posts kennen, liefert die AppView einen abgeschnittenen
Platzhalter im handle-Feld ("did:plc:abcd…"), und ein Klick darauf landete
in einem synthetischen Leerprofil. Die echte DID steht im DTO und wird jetzt
explizit durchgereicht — keine Heuristik auf das Platzhalter-Format.

Dabei aufgefallen und mitgefixt: ProfileView lud nur in onMount, obwohl die
Komponente bei Profil-zu-Profil-Navigation gemountet bleibt — Posts und
Zahlen des vorherigen Nutzers wären unter dem neuen Namen stehengeblieben.
Jetzt ein auf (did, handle) gekeyter Effekt.

Das Thread-Overlay hing im else-Zweig der leeren Timeline und wäre aus dem
Notifications-View unsichtbar gewesen; es ist jetzt ein Snippet, das beide
Views rendern.

Co-Authored-By: Eifelduds <noreply@eifelduds.de>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
2026-09-09 21:37:00 +02:00

369 lines
12 KiB
TypeScript

// Unit tests for the notification / actor-list half of `client.ts`.
//
// Same setup as `client.test.ts`: `@tauri-apps/api/core` is mocked so
// no Tauri shell is needed, and every assertion is about the exact
// command name + argument bag we hand the Rust IPC layer. That
// argument bag is the contract — Tauri's `invoke` serialises camelCase
// JS keys to the snake_case Rust command parameters, so a typo here
// surfaces at runtime as "command not found" or a null argument, not
// at compile time.
//
// Covered:
// * `fetchNotifications` — command name, args, wire→object mapping
// (snake_case fields pass through verbatim), cursor pagination;
// * `notificationCount` — unwraps `{ count }` to a number;
// * `markNotificationsSeen` — sends the `seenAt` watermark;
// * `notificationText` / `notificationIcon` — the kind→copy map,
// including the fallback for an unknown kind;
// * `fetchFollowers` / `fetchFollowing` — distinct commands, same
// shape.
//
// Run with:
// npx vitest run src/lib/api/notifications.test.ts
import { beforeEach, describe, expect, it, vi } from "vitest";
const invokeMock = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => invokeMock(...args),
isTauri: () => true,
}));
beforeEach(() => {
invokeMock.mockReset();
});
/// A full-fat wire row, exactly as `NotificationItem` serialises it
/// server-side (snake_case, `read_at` present even when null).
function wireNotification(over: Record<string, unknown> = {}) {
return {
id: 1,
kind: "like",
author_did: "did:plc:alice",
author_handle: "alice.test",
author_display_name: "Alice",
author_avatar_cid: "bafyavatar",
subject_uri: "at://did:plc:me/app.twi.post/3k2",
subject_text: "hello world",
created_at: "2026-09-09T10:00:00Z",
indexed_at: "2026-09-09T10:00:01Z",
read_at: null,
...over,
};
}
describe("fetchNotifications", () => {
it("invokes fetch_notifications and maps the wire rows verbatim", async () => {
const { fetchNotifications } = await import("./client");
invokeMock.mockResolvedValueOnce({
notifications: [wireNotification()],
cursor: "cur1",
});
const r = await fetchNotifications("did:plc:me");
expect(invokeMock).toHaveBeenCalledTimes(1);
expect(invokeMock).toHaveBeenCalledWith("fetch_notifications", {
did: "did:plc:me",
cursor: null,
limit: 30,
});
expect(r.cursor).toBe("cur1");
expect(r.notifications).toHaveLength(1);
const n = r.notifications[0];
expect(n.id).toBe(1);
expect(n.kind).toBe("like");
expect(n.author_handle).toBe("alice.test");
expect(n.author_display_name).toBe("Alice");
expect(n.author_avatar_cid).toBe("bafyavatar");
expect(n.subject_uri).toBe("at://did:plc:me/app.twi.post/3k2");
expect(n.subject_text).toBe("hello world");
expect(n.indexed_at).toBe("2026-09-09T10:00:01Z");
// `read_at: null` is the unread marker the badge keys off — it
// must survive as null, not become undefined.
expect(n.read_at).toBeNull();
});
it("forwards the cursor and limit for a follow-up page", async () => {
const { fetchNotifications } = await import("./client");
invokeMock.mockResolvedValueOnce({ notifications: [], cursor: null });
const r = await fetchNotifications("did:plc:me", "cur1", 50);
expect(invokeMock).toHaveBeenCalledWith("fetch_notifications", {
did: "did:plc:me",
cursor: "cur1",
limit: 50,
});
// A null cursor is the documented end-of-list sentinel.
expect(r.cursor).toBeNull();
});
it("handles a follow row (no subject) without inventing fields", async () => {
const { fetchNotifications } = await import("./client");
invokeMock.mockResolvedValueOnce({
notifications: [
{
id: 7,
kind: "follow",
author_did: "did:plc:bob",
author_handle: "bob.test",
subject_uri: null,
created_at: "2026-09-09T09:00:00Z",
indexed_at: "2026-09-09T09:00:01Z",
read_at: "2026-09-09T09:30:00Z",
},
],
cursor: null,
});
const [n] = (await fetchNotifications("did:plc:me")).notifications;
expect(n.kind).toBe("follow");
expect(n.subject_uri).toBeNull();
expect(n.subject_text).toBeUndefined();
expect(n.author_display_name).toBeUndefined();
expect(n.read_at).toBe("2026-09-09T09:30:00Z");
});
it("propagates a Tauri-side error verbatim", async () => {
const { fetchNotifications } = await import("./client");
invokeMock.mockRejectedValueOnce(
new Error("appview: notifications returned 400: did is required"),
);
await expect(fetchNotifications("")).rejects.toThrow(
/notifications returned 400/,
);
});
});
describe("notificationCount", () => {
it("unwraps the {count} envelope to a plain number", async () => {
const { notificationCount } = await import("./client");
invokeMock.mockResolvedValueOnce({ count: 12 });
const n = await notificationCount("did:plc:me");
expect(n).toBe(12);
expect(invokeMock).toHaveBeenCalledWith("notification_count", {
did: "did:plc:me",
});
});
it("returns 0 for a fully-read inbox", async () => {
const { notificationCount } = await import("./client");
invokeMock.mockResolvedValueOnce({ count: 0 });
await expect(notificationCount("did:plc:me")).resolves.toBe(0);
});
});
describe("markNotificationsSeen", () => {
it("sends the seenAt watermark", async () => {
const { markNotificationsSeen } = await import("./client");
invokeMock.mockResolvedValueOnce({ ok: true, updated: 4 });
const r = await markNotificationsSeen(
"did:plc:me",
"2026-09-09T10:00:01Z",
);
expect(r).toEqual({ ok: true, updated: 4 });
expect(invokeMock).toHaveBeenCalledWith("mark_notifications_seen", {
did: "did:plc:me",
seenAt: "2026-09-09T10:00:01Z",
});
});
it("omitting the watermark sends null (mark everything read)", async () => {
const { markNotificationsSeen } = await import("./client");
invokeMock.mockResolvedValueOnce({ ok: true, updated: 0 });
await markNotificationsSeen("did:plc:me");
expect(invokeMock).toHaveBeenCalledWith("mark_notifications_seen", {
did: "did:plc:me",
seenAt: null,
});
});
});
describe("notificationText / notificationIcon", () => {
it("maps each kind to its German line", async () => {
const { notificationText } = await import("./client");
expect(notificationText("like")).toBe("hat deinen Post geliked");
expect(notificationText("repost")).toBe("hat repostet");
expect(notificationText("follow")).toBe("folgt dir jetzt");
expect(notificationText("reply")).toBe("hat geantwortet");
});
it("falls back rather than rendering an empty line for an unknown kind", async () => {
const { notificationText } = await import("./client");
expect(notificationText("quote")).toBe("hat interagiert");
expect(notificationText("")).toBe("hat interagiert");
});
it("gives every known kind its own icon glyph", async () => {
const { notificationIcon } = await import("./client");
const icons = ["like", "repost", "follow", "reply"].map(notificationIcon);
expect(new Set(icons).size).toBe(4);
expect(icons.every((i) => i.length > 0)).toBe(true);
expect(notificationIcon("quote")).toBe("•");
});
});
describe("fetchFollowers / fetchFollowing", () => {
const wireProfile = {
did: "did:plc:carol",
handle: "carol.test",
display_name: "Carol",
avatar_cid: "bafycarol",
};
it("fetchFollowers hits the followers command", async () => {
const { fetchFollowers } = await import("./client");
invokeMock.mockResolvedValueOnce({
profiles: [wireProfile],
cursor: null,
});
const r = await fetchFollowers("did:plc:me");
expect(invokeMock).toHaveBeenCalledWith("fetch_followers", {
did: "did:plc:me",
cursor: null,
limit: 30,
});
expect(r.profiles[0].handle).toBe("carol.test");
expect(r.profiles[0].display_name).toBe("Carol");
expect(r.cursor).toBeNull();
});
it("fetchFollowing hits the following command and pages", async () => {
const { fetchFollowing } = await import("./client");
invokeMock.mockResolvedValueOnce({
profiles: [wireProfile],
cursor: "cur2",
});
const r = await fetchFollowing("did:plc:me", "cur1", 10);
expect(invokeMock).toHaveBeenCalledWith("fetch_following", {
did: "did:plc:me",
cursor: "cur1",
limit: 10,
});
expect(r.cursor).toBe("cur2");
});
});
describe("fetchProfileByDid", () => {
it("invokes profile_get_by_did with the DID", async () => {
const { fetchProfileByDid } = await import("./client");
invokeMock.mockResolvedValueOnce({
did: "did:plc:f5abcdefghijklmnop",
handle: "alice.test",
posts: [],
followers: 2,
following: 3,
post_count: 0,
});
const p = await fetchProfileByDid("did:plc:f5abcdefghijklmnop");
expect(invokeMock).toHaveBeenCalledWith("profile_get_by_did", {
did: "did:plc:f5abcdefghijklmnop",
});
expect(p.did).toBe("did:plc:f5abcdefghijklmnop");
expect(p.followers).toBe(2);
});
it("resolves an actor whose only known handle is a truncated-DID placeholder", async () => {
// The dead end this endpoint exists to avoid: the AppView hands
// us `handle: "did:plc:f5a…"` (from `short_did_bare`) for an actor
// it has no profile row or indexed post for. Feeding that back
// into the *handle* lookup matches nothing and the server answers
// 200 with a synthetic empty profile — `did: ""`, zero counts —
// so the UI shows a blank page instead of an error. The DID
// lookup resolves the real row.
const { fetchProfile, fetchProfileByDid } = await import("./client");
const placeholderHandle = "did:plc:f5a…";
const realDid = "did:plc:f5abcdefghijklmnop";
// 1. What the handle path actually gets back today.
invokeMock.mockResolvedValueOnce({
did: "",
handle: placeholderHandle,
posts: [],
followers: 0,
following: 0,
post_count: 0,
});
const viaHandle = await fetchProfile(placeholderHandle);
expect(viaHandle.did).toBe("");
expect(viaHandle.post_count).toBe(0);
// 2. What the DID path gets back — the real profile.
invokeMock.mockResolvedValueOnce({
did: realDid,
handle: "alice.test",
posts: [],
followers: 5,
following: 1,
post_count: 12,
});
const viaDid = await fetchProfileByDid(realDid);
expect(viaDid.did).toBe(realDid);
expect(viaDid.handle).toBe("alice.test");
expect(viaDid.post_count).toBe(12);
// The two calls must be distinct commands — a shared one would
// reintroduce the ambiguity.
expect(invokeMock.mock.calls.map((c) => c[0])).toEqual([
"profile_get",
"profile_get_by_did",
]);
});
});
describe("fetchThread", () => {
it("invokes fetch_thread with the viewer DID", async () => {
const { fetchThread } = await import("./client");
invokeMock.mockResolvedValueOnce({
post: null,
parents: [],
root: null,
replies: [],
});
const r = await fetchThread("at://did:plc:me/app.twi.post/3k2", "did:plc:me");
expect(invokeMock).toHaveBeenCalledWith("fetch_thread", {
uri: "at://did:plc:me/app.twi.post/3k2",
viewerDid: "did:plc:me",
});
// `post: null` is the "not in our index" sentinel — one field
// check is enough to render "post not found".
expect(r.post).toBeNull();
expect(r.parents).toEqual([]);
expect(r.replies).toEqual([]);
});
it("defaults the viewer DID to null", async () => {
const { fetchThread } = await import("./client");
invokeMock.mockResolvedValueOnce({
post: null,
parents: [],
root: null,
replies: [],
});
await fetchThread("at://did:plc:me/app.twi.post/3k2");
expect(invokeMock).toHaveBeenCalledWith("fetch_thread", {
uri: "at://did:plc:me/app.twi.post/3k2",
viewerDid: null,
});
});
});