Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263import type { OAuthUserAgent } from "@atcute/oauth-browser-client";import { beforeEach, describe, expect, it, vi } from "vitest";import { createBobbinClient } from "#lib/api/client.ts";import { awaitIndexedOrSaved } from "#lib/api/awaitRecord.ts";import { putProfile, uploadProfileAvatar } from "#lib/api/profile.ts";import { compactLinks, formFromProfile, loadProfile, profileFromForm, saveAvatar, visibleLinkCount, writeProfile} from "#lib/api/profileSettings.ts";import type { ProfileRecord } from "#lib/api/records.ts";
vi.mock("#lib/api/profile.ts", () => ({ PROFILE_COLLECTION: "org.tangled.actor.profile", putProfile: vi.fn(), uploadProfileAvatar: vi.fn()}));vi.mock("#lib/api/awaitRecord.ts", () => ({ 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/org.tangled.actor.profile/self", cid: "bafynew" };const cid = "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a";const avatar: NonNullable<ProfileRecord["avatar"]> = { $type: "blob", ref: { $link: cid }, mimeType: "image/png", size: 12};
const stored: ProfileRecord = { $type: "org.tangled.actor.profile", 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<typeof globalThis.fetch>(async () => Response.json({ uri: "at://did:plc:me/org.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/org.tangled.actor.getProfile"); expect(url.searchParams.get("actor")).toBe( "at://did:plc:me/org.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/", "", "", "", ""], pinned: ["did:plc:repo"], isOrganization: true }); });
it("lists a pin once when the record repeats it", () => { const repeated = { ...stored, pinnedRepositories: ["did:plc:a", "did:plc:b", "did:plc:a"] } as ProfileRecord; expect(formFromProfile(repeated).pinned).toEqual(["did:plc:a", "did:plc:b"]); });
it("starts empty without a record", () => { expect(formFromProfile(null)).toEqual({ description: "", pronouns: "", location: "", links: ["", "", "", "", ""], pinned: [], 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", "", " "], pinned: ["did:plc:repo"], isOrganization: true }, stored );
expect(record).toEqual({ $type: "org.tangled.actor.profile", isOrganization: true, description: "Updated.", links: ["https://tangled.org/"], pinnedRepositories: ["did:plc:repo"], avatar }); });
it("writes the pinned repositories in the order the form has them", () => { const pinned = ["did:plc:b", "did:plc:a"] as const; const form = { ...formFromProfile(stored), pinned: [...pinned] }; expect(profileFromForm(form, stored).pinnedRepositories).toEqual([ "did:plc:b", "did:plc:a" ]); });
it("drops the field when nothing is pinned", () => { const form = { ...formFromProfile(stored), pinned: [] }; expect(profileFromForm(form, stored)).not.toHaveProperty("pinnedRepositories"); });
it("only writes the organization flag when it is set", () => { expect(profileFromForm(formFromProfile(null), null)).toEqual({ $type: "org.tangled.actor.profile" }); expect(profileFromForm({ ...formFromProfile(null), isOrganization: true }, null)).toEqual({ $type: "org.tangled.actor.profile", 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."); });
it("rejects links without a full domain", () => { expect(() => profileFromForm({ ...formFromProfile(null), links: ["settings"] }, null) ).toThrow("Links need a full domain, like example.com."); });
it("rejects text that cannot become a url", () => { expect(() => profileFromForm({ ...formFromProfile(null), links: ["not a url at all"] }, null) ).toThrow("Enter a valid URL, like example.com."); });});
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); });});
describe("visibleLinkCount", () => { it("shows one empty row when nothing is filled", () => { expect(visibleLinkCount(["", "", "", "", ""])).toBe(1); });
it("shows the filled rows plus the next empty one", () => { expect(visibleLinkCount(["a.example", "", "", "", ""])).toBe(2); expect(visibleLinkCount(["a.example", "b.example", "", "", ""])).toBe(3); });
it("keeps rows that sit before the last filled one", () => { expect(visibleLinkCount(["a.example", "", "c.example", "", ""])).toBe(4); });
it("treats whitespace as empty", () => { expect(visibleLinkCount(["a.example", " ", "", "", ""])).toBe(2); });
it("never exceeds the slots", () => { expect(visibleLinkCount(["a", "b", "c", "d", "e"])).toBe(5); expect(visibleLinkCount(["a", "b", "c", "d", "e"], 3)).toBe(3); });});
describe("compactLinks", () => { it("moves filled links up and pads back to the slot count", () => { expect(compactLinks(["a.example", "", " ", "d.example", ""])).toEqual([ "a.example", "d.example", "", "", "" ]); });
it("leaves a full list alone", () => { expect(compactLinks(["a", "b", "c", "d", "e"])).toEqual(["a", "b", "c", "d", "e"]); });});