import 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";
import type { Fetch } from "#lib/fetch.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: Fetch) => createBobbinClient({ serviceUrl: "https://bobbin.test", fetch });
it("returns the record bobbin has", async () => {
const fetch = vi.fn<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"]);
});
});