import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createBobbinClient } from "$lib/api/client"; import { awaitIndexedOrSaved } from "$lib/api/awaitRecord"; import { putProfile, uploadProfileAvatar } from "$lib/api/profile"; import { formFromProfile, loadProfile, profileFromForm, saveAvatar, writeProfile } from "$lib/api/profileSettings"; import type { ProfileRecord } from "$lib/api/records"; vi.mock("$lib/api/profile", () => ({ PROFILE_COLLECTION: "sh.tangled.actor.profile", putProfile: vi.fn(), uploadProfileAvatar: vi.fn() })); vi.mock("$lib/api/awaitRecord", () => ({ awaitIndexedOrSaved: vi.fn() })); const agent = { sub: "did:plc:me" } as unknown as OAuthUserAgent; const bobbin = createBobbinClient({ serviceUrl: "https://bobbin.test" }); const written = { uri: "at://did:plc:me/sh.tangled.actor.profile/self", cid: "bafynew" }; const cid = "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a"; const avatar: NonNullable = { $type: "blob", ref: { $link: cid }, mimeType: "image/png", size: 12 }; const stored: ProfileRecord = { $type: "sh.tangled.actor.profile", bluesky: true, isOrganization: true, description: "Social coding.", pronouns: "they/them", location: "Helsinki", links: ["https://tangled.org/"], pinnedRepositories: ["did:plc:repo"], avatar }; beforeEach(() => { vi.resetAllMocks(); vi.mocked(putProfile).mockResolvedValue(written as never); }); describe("loadProfile", () => { const ctx = (fetch: typeof globalThis.fetch) => createBobbinClient({ serviceUrl: "https://bobbin.test", fetch }); it("returns the record bobbin has", async () => { const fetch = vi.fn(async () => Response.json({ uri: "at://did:plc:me/sh.tangled.actor.profile/self", cid, value: stored }) ); await expect(loadProfile(ctx(fetch), "did:plc:me")).resolves.toEqual(stored); const url = new URL(String(fetch.mock.calls[0][0])); expect(url.pathname).toBe("/xrpc/sh.tangled.actor.getProfile"); expect(url.searchParams.get("actor")).toBe("at://did:plc:me/sh.tangled.actor.profile/self"); }); it("treats a missing record as empty and anything else as an error", async () => { await expect( loadProfile( ctx(async () => Response.json({ error: "NotFound", message: "" }, { status: 404 })), "did:plc:me" ) ).resolves.toBeNull(); await expect( loadProfile( ctx(async () => Response.json({ error: "UpstreamFailure", message: "" }, { status: 502 }) ), "did:plc:me" ) ).rejects.toThrow(); }); }); describe("formFromProfile", () => { it("fills the form from the record and pads the link slots", () => { expect(formFromProfile(stored)).toEqual({ description: "Social coding.", pronouns: "they/them", location: "Helsinki", links: ["https://tangled.org/", "", ""], bluesky: true, isOrganization: true }); }); it("starts empty without a record", () => { expect(formFromProfile(null)).toEqual({ description: "", pronouns: "", location: "", links: ["", "", ""], bluesky: false, isOrganization: false }); }); }); describe("profileFromForm", () => { it("keeps the fields the form does not edit and drops blanks", () => { const record = profileFromForm( { description: " Updated. ", pronouns: "", location: " ", links: ["tangled.org", "", " "], bluesky: false, isOrganization: true }, stored ); expect(record).toEqual({ $type: "sh.tangled.actor.profile", bluesky: false, isOrganization: true, description: "Updated.", links: ["https://tangled.org/"], pinnedRepositories: ["did:plc:repo"], avatar }); }); it("only writes the organization flag when it is set", () => { expect(profileFromForm(formFromProfile(null), null)).toEqual({ $type: "sh.tangled.actor.profile", bluesky: false }); expect(profileFromForm({ ...formFromProfile(null), isOrganization: true }, null)).toEqual({ $type: "sh.tangled.actor.profile", bluesky: false, isOrganization: true }); }); it("rejects links that are not http urls", () => { expect(() => profileFromForm({ ...formFromProfile(null), links: ["ftp://x.example"] }, null) ).toThrow("Links must use http or https."); }); }); describe("writeProfile", () => { it("waits for bobbin to index the written revision", async () => { await expect(writeProfile(bobbin, agent, stored)).resolves.toEqual(stored); expect(putProfile).toHaveBeenCalledWith(agent, stored); expect(awaitIndexedOrSaved).toHaveBeenCalledWith(bobbin, written); }); it("surfaces an indexing failure", async () => { vi.mocked(awaitIndexedOrSaved).mockRejectedValue( new Error("Saved, but the appview has not caught up yet") ); await expect(writeProfile(bobbin, agent, stored)).rejects.toThrow("has not caught up"); }); }); describe("saveAvatar", () => { it("uploads the image and writes it into the current record", async () => { const image = new Blob(["png"], { type: "image/png" }); vi.mocked(uploadProfileAvatar).mockResolvedValue(avatar); const result = await saveAvatar(bobbin, agent, image, { ...stored, avatar: undefined }); expect(uploadProfileAvatar).toHaveBeenCalledWith(agent, image); expect(putProfile).toHaveBeenCalledWith(agent, { ...stored, avatar }); expect(result).toEqual({ ...stored, avatar }); }); it("removes the avatar without uploading", async () => { await saveAvatar(bobbin, agent, null, stored); expect(uploadProfileAvatar).not.toHaveBeenCalled(); const { avatar: _avatar, ...withoutAvatar } = stored; expect(putProfile).toHaveBeenCalledWith(agent, withoutAvatar); }); });