// 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 = {}) { 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, }); }); });