mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(onboarding): add stubbed account API contract for onboarding v2
Typed frontend seams for the signup-first onboarding flow's backend endpoints that do not exist yet: email/password signup + login, invitee-bound community invites, in-app community creation, and email invites. Every function resolves fixture data so the whole flow is walkable in the UI with zero backend changes; error cases use stable OnboardingAccountError codes the UI can branch on. Engineers replace the bodies (not the signatures) when the real endpoints land. PR 1 of the onboarding v2 sequence. Co-authored-by: npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 <29b529ff43be424db89f51bff58a38cf86942acbffff98a14ce50b38c2a57d8f@buzz.block.builderlab.xyz> Signed-off-by: npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 <29b529ff43be424db89f51bff58a38cf86942acbffff98a14ce50b38c2a57d8f@buzz.block.builderlab.xyz>
This commit is contained in:
parent
c96ae0ea97
commit
747720cff9
@@ -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 });
|
||||
});
|
||||
@@ -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<void>((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<InvitedCommunity[]> {
|
||||
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<CreatedCommunity> {
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user