import { describe, expect, it, vi, type Mock } from "vitest";
import {
beginSignup,
completeSignup,
getSignup,
resendSignup,
SignupError
} from "#lib/api/signup.ts";
import type { Fetch } from "#lib/fetch.ts";
const calledInit = (mock: Mock<Fetch>) => mock.mock.calls[0][1] as RequestInit;
describe("beginSignup", () => {
it("posts email and turnstile token to the beginSignup nsid without auth", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(new Response("", { status: 200 }));
await beginSignup("https://deliberi.test", "alice@example.com", "turnstile-123", fetchMock);
expect(fetchMock).toHaveBeenCalledTimes(1);
const url = new URL(String(fetchMock.mock.calls[0][0]));
expect(url.origin).toBe("https://deliberi.test");
expect(url.pathname).toBe("/xrpc/org.tangled.temp.account.beginSignup");
expect(calledInit(fetchMock).method).toBe("POST");
expect(JSON.parse(String(calledInit(fetchMock).body))).toEqual({
email: "alice@example.com",
turnstileToken: "turnstile-123"
});
expect(calledInit(fetchMock).headers).not.toHaveProperty("authorization");
});
it("surfaces the server's error message", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(
new Response(
JSON.stringify({
error: "EmailAlreadyRegistered",
message: "an account already exists for this email"
}),
{
status: 409,
headers: { "content-type": "application/json" }
}
)
);
await expect(
beginSignup("https://deliberi.test", "a@b.co", "t", fetchMock)
).rejects.toThrow("An account already exists for this email.");
});
it("falls back to the status when the body is not an xrpc error", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(new Response("nope", { status: 409 }));
await expect(
beginSignup("https://deliberi.test", "a@b.co", "t", fetchMock)
).rejects.toThrow("Unable to send the link (HTTP 409). Try again in a moment.");
});
});
describe("resendSignup", () => {
it("posts the email to the resendSignup nsid without auth", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(new Response("", { status: 200 }));
await resendSignup("https://deliberi.test", "alice@example.com", fetchMock);
expect(fetchMock).toHaveBeenCalledTimes(1);
const url = new URL(String(fetchMock.mock.calls[0][0]));
expect(url.origin).toBe("https://deliberi.test");
expect(url.pathname).toBe("/xrpc/org.tangled.temp.account.resendSignup");
expect(calledInit(fetchMock).method).toBe("POST");
expect(JSON.parse(String(calledInit(fetchMock).body))).toEqual({
email: "alice@example.com"
});
expect(calledInit(fetchMock).headers).not.toHaveProperty("authorization");
});
it("surfaces the server's message when nothing is pending", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(
new Response(
JSON.stringify({
error: "NoPendingSignup",
message: "no link is waiting for this email; sign up again"
}),
{
status: 404,
headers: { "content-type": "application/json" }
}
)
);
await expect(resendSignup("https://deliberi.test", "a@b.co", fetchMock)).rejects.toThrow(
"No link is waiting for this email; sign up again."
);
});
});
describe("getSignup", () => {
it("queries the token and returns the signup's email", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(
new Response(JSON.stringify({ email: "alice@example.com" }), {
status: 200,
headers: { "content-type": "application/json" }
})
);
await expect(getSignup("https://deliberi.test", "tok-123", fetchMock)).resolves.toEqual({
email: "alice@example.com"
});
const url = new URL(String(fetchMock.mock.calls[0][0]));
expect(url.pathname).toBe("/xrpc/org.tangled.temp.account.getSignup");
expect(url.searchParams.get("token")).toBe("tok-123");
});
it("returns the pds the account will live on", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(
new Response(JSON.stringify({ email: "alice@example.com", pds: "https://pds.test" }), {
status: 200,
headers: { "content-type": "application/json" }
})
);
await expect(getSignup("https://deliberi.test", "tok-123", fetchMock)).resolves.toEqual({
email: "alice@example.com",
pds: "https://pds.test"
});
});
it("reports a dead link with its code", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(
new Response(
JSON.stringify({
error: "InvalidCode",
message: "invalid or expired verification link"
}),
{ status: 400, headers: { "content-type": "application/json" } }
)
);
await expect(getSignup("https://deliberi.test", "bad", fetchMock)).rejects.toMatchObject({
code: "InvalidCode"
});
});
it("rejects a response without an email", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(new Response("{}", { status: 200 }));
await expect(getSignup("https://deliberi.test", "tok", fetchMock)).rejects.toBeInstanceOf(
SignupError
);
});
});
describe("completeSignup", () => {
it("posts token, username, and password and parses did/handle", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(
new Response(JSON.stringify({ did: "did:plc:x", handle: "alice.tngl.sh" }), {
status: 200,
headers: { "content-type": "application/json" }
})
);
const out = await completeSignup(
"https://deliberi.test",
{ token: "tok-123", username: "alice", password: "password1234" },
fetchMock
);
expect(out).toEqual({ did: "did:plc:x", handle: "alice.tngl.sh" });
const url = new URL(String(fetchMock.mock.calls[0][0]));
expect(url.pathname).toBe("/xrpc/org.tangled.temp.account.completeSignup");
expect(calledInit(fetchMock).method).toBe("POST");
expect(JSON.parse(String(calledInit(fetchMock).body))).toEqual({
token: "tok-123",
username: "alice",
password: "password1234"
});
expect(calledInit(fetchMock).headers).not.toHaveProperty("authorization");
});
it("sends the oauth request and returns the pds's authorization", async () => {
const authorization = { code: "cod-1", state: "sid-1", iss: "https://pds.test" };
const fetchMock = vi
.fn<Fetch>()
.mockResolvedValue(
new Response(
JSON.stringify({ did: "did:plc:x", handle: "alice.tngl.sh", authorization }),
{ status: 200, headers: { "content-type": "application/json" } }
)
);
const out = await completeSignup(
"https://deliberi.test",
{ token: "tok", username: "alice", password: "password1234", requestUri: "urn:req" },
fetchMock
);
expect(out).toEqual({ did: "did:plc:x", handle: "alice.tngl.sh", authorization });
expect(JSON.parse(String(calledInit(fetchMock).body))).toMatchObject({
requestUri: "urn:req"
});
});
it("drops a malformed authorization so the page signs in through the pds", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(
new Response(
JSON.stringify({
did: "did:plc:x",
handle: "alice.tngl.sh",
authorization: { code: "c" }
}),
{ status: 200, headers: { "content-type": "application/json" } }
)
);
await expect(
completeSignup(
"https://deliberi.test",
{
token: "tok",
username: "alice",
password: "password1234",
requestUri: "urn:req"
},
fetchMock
)
).resolves.toEqual({ did: "did:plc:x", handle: "alice.tngl.sh" });
});
it("surfaces the server's error message", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(
new Response(
JSON.stringify({
error: "InvalidCode",
message: "invalid or expired verification link"
}),
{
status: 400,
headers: { "content-type": "application/json" }
}
)
);
await expect(
completeSignup(
"https://deliberi.test",
{ token: "bad", username: "alice", password: "password1234" },
fetchMock
)
).rejects.toThrow("Invalid or expired verification link.");
});
it("keeps the service's error code", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(
new Response(
JSON.stringify({
error: "UsernameNotAvailable",
message: "this username is already taken"
}),
{
status: 400,
headers: { "content-type": "application/json" }
}
)
);
const failure = completeSignup(
"https://deliberi.test",
{ token: "tok", username: "alice", password: "password1234" },
fetchMock
);
await expect(failure).rejects.toBeInstanceOf(SignupError);
await expect(failure).rejects.toMatchObject({ code: "UsernameNotAvailable" });
});
it("falls back to the status when the body is not an xrpc error", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(new Response("bad", { status: 400 }));
await expect(
completeSignup(
"https://deliberi.test",
{ token: "bad", username: "alice", password: "password1234" },
fetchMock
)
).rejects.toThrow("Unable to create the account (HTTP 400). Try again in a moment.");
});
it("replaces server failures with a readable sentence", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(
new Response(
JSON.stringify({
error: "InternalServerError",
message: "internal server error"
}),
{ status: 500, headers: { "content-type": "application/json" } }
)
);
await expect(
beginSignup("https://deliberi.test", "a@b.co", "token", fetchMock)
).rejects.toThrow("Unable to reach Tangled. Try again in a moment.");
});
it("surfaces the error code when the payload omits the message", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(
new Response(JSON.stringify({ error: "InvalidCode" }), {
status: 400,
headers: { "content-type": "application/json" }
})
);
await expect(
completeSignup(
"https://deliberi.test",
{ token: "bad", username: "alice", password: "password1234" },
fetchMock
)
).rejects.toThrow("InvalidCode");
});
it("rejects a 2xx payload missing the handle", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(
new Response(JSON.stringify({ did: "did:plc:x" }), {
status: 200,
headers: { "content-type": "application/json" }
})
);
await expect(
completeSignup(
"https://deliberi.test",
{ token: "tok", username: "alice", password: "password1234" },
fetchMock
)
).rejects.toThrow("Unable to create the account. Try again in a moment.");
});
it("rejects an empty 2xx body instead of leaking a parse error", async () => {
const fetchMock = vi.fn<Fetch>().mockResolvedValue(new Response("", { status: 200 }));
await expect(
completeSignup(
"https://deliberi.test",
{ token: "tok", username: "alice", password: "password1234" },
fetchMock
)
).rejects.toThrow("Unable to create the account. Try again in a moment.");
});
});