import { describe, expect, it, vi, type Mock } from "vitest";
import type { OAuthUserAgent } from "@atcute/oauth-browser-client";
import {
addEmail,
deleteEmail,
listEmails,
markPrimary,
removeEmailRow,
setPrimaryEmail,
verifyEmailByToken,
type ListEmailItem
} from "#lib/api/emails.ts";
import { createDeliberiClient } from "#lib/api/deliberi.ts";
import type { Fetch } from "#lib/fetch.ts";
vi.mock("#lib/auth/agent.ts", () => ({
mintServiceAuth: vi.fn(async () => "test-token"),
serviceDidForHost: (host: string) => `did:web:${host}`
}));
const row = (overrides: Partial<ListEmailItem> = {}): ListEmailItem => ({
address: "alice@example.com",
verified: true,
primary: false,
createdAt: "2026-01-01T00:00:00.000Z",
...overrides
});
const makeCtx = (fetchMock: Fetch) =>
createDeliberiClient({
deliberiUrl: "https://deliberi.test",
agent: {} as unknown as OAuthUserAgent,
fetch: fetchMock
});
const calledInit = (mock: Mock<Fetch>) => mock.mock.calls[0][1] as RequestInit;
const jsonResponse = (body: unknown): Response =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" }
});
describe("listEmails", () => {
it("gets the listEmails nsid and parses the email shape", async () => {
const item = row({ address: "bob@example.com", verified: false, primary: true });
const fetchMock = vi.fn<Fetch>().mockResolvedValue(jsonResponse({ emails: [item] }));
const out = await listEmails(makeCtx(fetchMock));
expect(new URL(String(fetchMock.mock.calls[0][0])).pathname).toBe(
"/xrpc/org.tangled.temp.account.listEmails"
);
expect(out.emails).toEqual([item]);
});
});
describe.each([
["addEmail", "addEmail", { email: "a@b.co" }],
["deleteEmail", "deleteEmail", { email: "a@b.co" }],
["setPrimaryEmail", "setPrimaryEmail", { email: "a@b.co" }]
] as const)("%s", (_name, nsid, body) => {
it(`posts ${JSON.stringify(body)} to org.tangled.temp.account.${nsid}`, async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(new Response("", { status: 200 }));
const ctx = makeCtx(fetchMock);
if (nsid === "addEmail") await addEmail(ctx, body.email);
else if (nsid === "deleteEmail") await deleteEmail(ctx, body.email);
else await setPrimaryEmail(ctx, body.email);
expect(fetchMock).toHaveBeenCalledTimes(1);
const url = new URL(String(fetchMock.mock.calls[0][0]));
expect(url.pathname).toBe(`/xrpc/org.tangled.temp.account.${nsid}`);
expect(url.origin).toBe("https://deliberi.test");
expect(calledInit(fetchMock).method).toBe("POST");
expect(JSON.parse(String(calledInit(fetchMock).body))).toEqual(body);
});
});
describe("verifyEmailByToken", () => {
it("posts the token to the open verifyEmail endpoint without auth", async () => {
const fetchMock = vi
.fn<Fetch>()
.mockResolvedValue(jsonResponse({ email: "alice@example.com" }));
const out = await verifyEmailByToken("https://deliberi.test", "abc123", fetchMock);
expect(out.email).toBe("alice@example.com");
expect(fetchMock).toHaveBeenCalledTimes(1);
const url = new URL(String(fetchMock.mock.calls[0][0]));
expect(url.pathname).toBe("/xrpc/org.tangled.temp.account.verifyEmail");
expect(calledInit(fetchMock).method).toBe("POST");
expect(JSON.parse(String(calledInit(fetchMock).body))).toEqual({ token: "abc123" });
expect(calledInit(fetchMock).headers).not.toHaveProperty("authorization");
});
it("classifies a 400 as an invalid link", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(new Response("nope", { status: 400 }));
await expect(
verifyEmailByToken("https://deliberi.test", "bad", fetchMock)
).rejects.toMatchObject({ kind: "invalid" });
});
it("classifies a 5xx as an unavailable service", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(new Response("boom", { status: 500 }));
await expect(
verifyEmailByToken("https://deliberi.test", "bad", fetchMock)
).rejects.toMatchObject({ kind: "unavailable" });
});
});
describe("markPrimary", () => {
it("moves the flag onto the target and clears the rest", () => {
const rows = [row({ address: "a@x.co", primary: true }), row({ address: "b@x.co" })];
expect(markPrimary(rows, "b@x.co")).toEqual([
expect.objectContaining({ address: "a@x.co", primary: false }),
expect.objectContaining({ address: "b@x.co", primary: true })
]);
});
});
describe("removeEmailRow", () => {
it("drops the deleted address and keeps every other row", () => {
const rows = [row({ address: "a@x.co" }), row({ address: "b@x.co" })];
expect(removeEmailRow(rows, "a@x.co").map((r) => r.address)).toEqual(["b@x.co"]);
});
});