diff --git a/desktop/src/shared/api/onboardingAccountStubs.test.mjs b/desktop/src/shared/api/onboardingAccountStubs.test.mjs new file mode 100644 index 000000000..d49ad1ae1 --- /dev/null +++ b/desktop/src/shared/api/onboardingAccountStubs.test.mjs @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + connectInvitedCommunity, + createCommunity, + isValidEmail, + listInvitedCommunities, + logIn, + MIN_PASSWORD_LENGTH, + OnboardingAccountError, + sendCommunityInvites, + signUp, +} from "./onboardingAccountStubs.ts"; + +test("isValidEmail accepts plausible addresses and rejects junk", () => { + assert.equal(isValidEmail("cynthia@example.com"), true); + assert.equal(isValidEmail(" padded@example.com "), true); + assert.equal(isValidEmail("no-at-sign.example.com"), false); + assert.equal(isValidEmail("missing@tld"), false); + assert.equal(isValidEmail("spaces in@example.com"), false); + assert.equal(isValidEmail(""), false); +}); + +test("signUp resolves for a valid email and strong-enough password", async () => { + const result = await signUp({ + email: "cynthia@example.com", + password: "a".repeat(MIN_PASSWORD_LENGTH), + }); + assert.deepEqual(result, { ok: true }); +}); + +test("signUp rejects an invalid email with a stable error code", async () => { + await assert.rejects( + signUp({ email: "not-an-email", password: "long-enough-password" }), + (error) => + error instanceof OnboardingAccountError && error.code === "email_invalid", + ); +}); + +test("signUp rejects a short password with password_weak", async () => { + await assert.rejects( + signUp({ + email: "cynthia@example.com", + password: "a".repeat(MIN_PASSWORD_LENGTH - 1), + }), + (error) => + error instanceof OnboardingAccountError && error.code === "password_weak", + ); +}); + +test("logIn resolves for a plausible credential shape", async () => { + assert.deepEqual( + await logIn({ email: "cynthia@example.com", password: "hunter22" }), + { ok: true }, + ); +}); + +test("logIn rejects bad shapes with credentials_invalid", async () => { + for (const attempt of [ + { email: "not-an-email", password: "hunter22" }, + { email: "cynthia@example.com", password: "" }, + ]) { + await assert.rejects( + logIn(attempt), + (error) => + error instanceof OnboardingAccountError && + error.code === "credentials_invalid", + ); + } +}); + +test("listInvitedCommunities returns fixture invites with the fields the hub renders", async () => { + const invites = await listInvitedCommunities(); + assert.ok(invites.length >= 1); + for (const invite of invites) { + assert.equal(typeof invite.inviteId, "string"); + assert.equal(typeof invite.name, "string"); + assert.equal(typeof invite.host, "string"); + assert.match(invite.relayWsUrl, /^wss?:\/\//); + } +}); + +test("connectInvitedCommunity resolves a known invite and rejects an unknown one", async () => { + const [invite] = await listInvitedCommunities(); + const result = await connectInvitedCommunity(invite.inviteId); + assert.equal(result.relayWsUrl, invite.relayWsUrl); + + await assert.rejects( + connectInvitedCommunity("definitely-not-an-invite"), + (error) => error instanceof OnboardingAccountError, + ); +}); + +test("createCommunity slugs the relay host from the name", async () => { + const created = await createCommunity({ + name: "The Land of Ooo", + description: "A magical workspace", + }); + assert.equal(created.name, "The Land of Ooo"); + assert.equal( + created.relayWsUrl, + "wss://the-land-of-ooo.communities.buzz.xyz", + ); +}); + +test("createCommunity rejects an empty name", async () => { + await assert.rejects( + createCommunity({ name: " ", description: "" }), + (error) => error instanceof OnboardingAccountError, + ); +}); + +test("sendCommunityInvites counts only valid emails", async () => { + const result = await sendCommunityInvites({ + emails: ["kalvin@example.com", "not-an-email", "wes@example.com"], + }); + assert.deepEqual(result, { sent: 2 }); +}); diff --git a/desktop/src/shared/api/onboardingAccountStubs.ts b/desktop/src/shared/api/onboardingAccountStubs.ts new file mode 100644 index 000000000..b7611877d --- /dev/null +++ b/desktop/src/shared/api/onboardingAccountStubs.ts @@ -0,0 +1,176 @@ +/** + * Frontend-only stubs for the onboarding v2 "standard flow" backend seams. + * + * The signup-first onboarding (see PLANS/ONBOARDING_V2_STANDARD_FLOW.md in the + * workspace) needs endpoints that do not exist on the relay yet: email/password + * accounts, invitee-bound community invites, in-app community creation, and + * email invites. This module is the typed contract for those endpoints; every + * function currently resolves fixture data after a short delay so the whole + * flow is walkable in the UI with zero backend changes. + * + * Engineers: replace the bodies (not the signatures) when the real endpoints + * land. Error cases are expressed as thrown `OnboardingAccountError`s with + * stable `code` values the UI already branches on. + */ + +export type OnboardingAccountErrorCode = + | "email_invalid" + | "email_taken" + | "password_weak" + | "credentials_invalid" + | "community_name_taken" + | "network"; + +export class OnboardingAccountError extends Error { + readonly code: OnboardingAccountErrorCode; + + constructor(code: OnboardingAccountErrorCode, message: string) { + super(message); + this.name = "OnboardingAccountError"; + this.code = code; + } +} + +export type InvitedCommunity = { + /** Opaque invite identifier used to claim the invite. */ + inviteId: string; + /** Human-readable community name, e.g. "The Land of Ooo". */ + name: string; + /** Community hostname shown under the name in the hub list. */ + host: string; + /** Relay websocket URL to connect to on accept. */ + relayWsUrl: string; +}; + +export type CreatedCommunity = { + name: string; + relayWsUrl: string; +}; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +/** Minimum password length enforced client-side until the backend owns policy. */ +export const MIN_PASSWORD_LENGTH = 8; + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Client-side email shape check shared by the signup and login forms. */ +export function isValidEmail(email: string): boolean { + return EMAIL_PATTERN.test(email.trim()); +} + +/** + * Create an email/password account. In the real flow this pairs the account + * with the freshly generated identity key; the stub only validates shape. + */ +export async function signUp({ + email, + password, +}: { + email: string; + password: string; +}): Promise<{ ok: true }> { + await delay(400); + if (!isValidEmail(email)) { + throw new OnboardingAccountError( + "email_invalid", + "Enter a valid email address.", + ); + } + if (password.length < MIN_PASSWORD_LENGTH) { + throw new OnboardingAccountError( + "password_weak", + `Password must be at least ${MIN_PASSWORD_LENGTH} characters.`, + ); + } + return { ok: true }; +} + +/** Log in a returning email/password user. Stub accepts any valid shape. */ +export async function logIn({ + email, + password, +}: { + email: string; + password: string; +}): Promise<{ ok: true }> { + await delay(400); + if (!isValidEmail(email) || password.length === 0) { + throw new OnboardingAccountError( + "credentials_invalid", + "Email or password is incorrect.", + ); + } + return { ok: true }; +} + +/** + * Communities the current account has been invited to. Invites are bound to + * the invitee server-side, so onboarding can list them without a pasted link. + */ +export async function listInvitedCommunities(): Promise { + await delay(300); + return [ + { + inviteId: "stub-invite-1", + name: "The Land of Ooo", + host: "land-of-ooo.communities.buzz.xyz", + relayWsUrl: "wss://land-of-ooo.communities.buzz.xyz", + }, + ]; +} + +/** Accept an invitee-bound invite and return the relay to connect to. */ +export async function connectInvitedCommunity( + inviteId: string, +): Promise<{ relayWsUrl: string }> { + await delay(300); + const invites = await listInvitedCommunities(); + const invite = invites.find((candidate) => candidate.inviteId === inviteId); + if (!invite) { + throw new OnboardingAccountError("network", "Invite is no longer valid."); + } + return { relayWsUrl: invite.relayWsUrl }; +} + +/** Create a new hosted community from inside onboarding. */ +export async function createCommunity({ + name, + description, + avatarUrl, +}: { + name: string; + description: string; + avatarUrl?: string; +}): Promise { + await delay(500); + void description; + void avatarUrl; + const trimmed = name.trim(); + if (!trimmed) { + throw new OnboardingAccountError( + "community_name_taken", + "Enter a community name.", + ); + } + const slug = trimmed + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return { + name: trimmed, + relayWsUrl: `wss://${slug || "community"}.communities.buzz.xyz`, + }; +} + +/** Send email invites to join the newly created community. */ +export async function sendCommunityInvites({ + emails, +}: { + emails: string[]; +}): Promise<{ sent: number }> { + await delay(300); + const valid = emails.filter((email) => isValidEmail(email)); + return { sent: valid.length }; +}