diff --git a/apps/self-hosted/hosting/api/src/routes/internal.test.ts b/apps/self-hosted/hosting/api/src/routes/internal.test.ts index 936f453df9..94ebc634ed 100644 --- a/apps/self-hosted/hosting/api/src/routes/internal.test.ts +++ b/apps/self-hosted/hosting/api/src/routes/internal.test.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({ transaction: vi.fn(), getByUsername: vi.fn(), generateConfigFile: vi.fn(), + publishConfigFile: vi.fn(), auditLog: vi.fn(), buildConfig: vi.fn(), getBlogUrl: vi.fn(), @@ -40,6 +41,7 @@ vi.mock('../services/tenant-service', () => ({ vi.mock('../services/config-service', () => ({ ConfigService: { generateConfigFile: mocks.generateConfigFile, + publishConfigFile: mocks.publishConfigFile, }, })); @@ -78,6 +80,7 @@ describe('POST /activate config publication', () => { subscriptionStatus: 'active', }); mocks.generateConfigFile.mockReset().mockResolvedValue('/configs/alice.json'); + mocks.publishConfigFile.mockReset().mockResolvedValue(undefined); mocks.auditLog.mockReset(); }); @@ -180,6 +183,7 @@ describe('internal endpoint audit trail', () => { subscriptionStatus: 'active', }); mocks.generateConfigFile.mockReset().mockResolvedValue('/configs/alice.json'); + mocks.publishConfigFile.mockReset().mockResolvedValue(undefined); mocks.buildConfig.mockReset().mockResolvedValue({ version: 1 }); mocks.getBlogUrl.mockReset().mockReturnValue('https://alice.blogs.ecency.com'); mocks.isDomainClaimed.mockReset().mockResolvedValue(false); @@ -524,11 +528,20 @@ describe('internal endpoint audit trail', () => { const created = await post('/claim-blog', { username: 'alice' }); expect(created.status).toBe(200); + // The flag the claiming UI distinguishes on: an existing tenant comes + // back unchanged with none of the customization applied, and the UI must + // not present that as a fresh provision. + expect(await created.json()).toMatchObject({ created: true }); expect(mocks.auditLog.mock.calls[0][0]).toMatchObject({ tenantId: 'tenant-9', eventType: 'tenant.pro_blog_claimed', eventData: { username: 'alice', created: true, subscriptionStatus: 'active' }, }); + // Published BY USERNAME (a locked re-read), never from the + // transaction-returned row: that snapshot can overwrite a newer config + // another writer committed between the claim's commit and this publish. + expect(mocks.publishConfigFile).toHaveBeenCalledWith('alice'); + expect(mocks.generateConfigFile).not.toHaveBeenCalled(); mocks.auditLog.mockReset(); mocks.transaction.mockResolvedValueOnce({ created: false, row }); @@ -536,9 +549,64 @@ describe('internal endpoint audit trail', () => { const existing = await post('/claim-blog', { username: 'alice' }); expect(existing.status).toBe(200); + expect(await existing.json()).toMatchObject({ created: false }); expect(mocks.auditLog.mock.calls[0][0]).toMatchObject({ eventType: 'tenant.pro_blog_claimed', eventData: { created: false }, }); }); + + it('passes the customize step through to the claimed config', async () => { + // The claim carries the same customization the paid signup does; a Pro + // claimant must not be locked to a default-looking instance. + const row = { + id: 'tenant-9', + username: 'alice', + owner: 'alice', + subscription_status: 'active', + subscription_plan: 'standard', + subscription_started_at: null, + subscription_expires_at: null, + custom_domain: null, + custom_domain_verified: false, + custom_domain_verified_at: null, + config: {}, + created_at: '2026-07-27T10:25:13.000Z', + updated_at: '2026-07-27T10:25:13.000Z', + }; + mocks.transaction.mockResolvedValueOnce({ created: true, row }); + + const response = await post('/claim-blog', { + username: 'alice', + title: 'Alice writes', + styleTemplate: 'journal', + accent: '#9c4a1e', + fontPreset: 'editorial', + }); + + expect(response.status).toBe(200); + expect(mocks.buildConfig).toHaveBeenCalledWith('alice', { + title: 'Alice writes', + description: undefined, + styleTemplate: 'journal', + accent: '#9c4a1e', + fontPreset: 'editorial', + }); + }); + + it('rejects customization that fails the public rosters instead of dropping it', async () => { + // Silently ignoring a chosen template would report a successful claim that + // looks nothing like what the claimant picked. Same validation surface as + // the public create path. + for (const body of [ + { username: 'alice', styleTemplate: 'no-such-template' }, + { username: 'alice', accent: 'red' }, + { username: 'alice', fontPreset: 'comic-sans' }, + ]) { + const response = await post('/claim-blog', body); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: 'invalid_request' }); + } + expect(mocks.buildConfig).not.toHaveBeenCalled(); + }); }); diff --git a/apps/self-hosted/hosting/api/src/routes/internal.ts b/apps/self-hosted/hosting/api/src/routes/internal.ts index ae4c50a470..ec133a1915 100644 --- a/apps/self-hosted/hosting/api/src/routes/internal.ts +++ b/apps/self-hosted/hosting/api/src/routes/internal.ts @@ -21,6 +21,8 @@ import { reconcileHivesignerClientIds } from '../services/hivesigner-registry'; import { AuditService, parseClientIp } from '../services/audit-service'; import { mapTenantFromDb, type Tenant } from '../types'; import { addVerifiedDomainOrigin } from '../utils/cors-domains'; +import { ACCENT_HEX_PATTERN, FONT_PRESET_KEYS } from '../appearance'; +import { STYLE_TEMPLATES } from '../style-templates'; export const internalRoutes = new Hono(); @@ -484,9 +486,41 @@ internalRoutes.post('/claim-blog', async (c) => { const title = typeof body?.title === 'string' ? body.title.slice(0, 100) : undefined; const description = typeof body?.description === 'string' ? body.description.slice(0, 500) : undefined; + // The claim carries the same customize step as the paid signup, validated + // against the same rosters the public create path enforces (routes/tenants.ts). + // Fail closed on junk rather than dropping it: silently ignoring a chosen + // template would report a successful claim that looks nothing like the + // preview the claimant picked. + const styleTemplate = + typeof body?.styleTemplate === 'string' ? body.styleTemplate : undefined; + if ( + styleTemplate !== undefined && + !(STYLE_TEMPLATES as readonly string[]).includes(styleTemplate) + ) { + return c.json({ error: 'invalid_request' }, 400); + } + const accent = typeof body?.accent === 'string' ? body.accent : undefined; + if (accent !== undefined && !ACCENT_HEX_PATTERN.test(accent)) { + return c.json({ error: 'invalid_request' }, 400); + } + const fontPreset = + typeof body?.fontPreset === 'string' ? body.fontPreset : undefined; + if ( + fontPreset !== undefined && + !(FONT_PRESET_KEYS as readonly string[]).includes(fontPreset) + ) { + return c.json({ error: 'invalid_request' }, 400); + } + try { // Build config outside the transaction (pure, no I/O). - const config = await TenantService.buildConfig(username, { title, description }); + const config = await TenantService.buildConfig(username, { + title, + description, + styleTemplate, + accent, + fontPreset, + }); const result = await db.transaction<{ created: boolean; row: any }>(async (client) => { // Try to create; ON CONFLICT means the tenant already exists. DO UPDATE revives a row the @@ -557,10 +591,13 @@ internalRoutes.post('/claim-blog', async (c) => { const tenant = mapTenantFromDb(result.row); - // Generate the config file for a freshly-created tenant (non-critical, outside the tx). + // Publish the config for a freshly-created tenant (non-critical, outside + // the tx). By username, not the transaction-returned row: publishing a + // pre-commit snapshot can overwrite a newer config another writer + // committed in between; publishConfigFile re-reads under the tenant lock. if (result.created) { try { - await ConfigService.generateConfigFile(tenant); + await ConfigService.publishConfigFile(username); } catch (err) { console.error(`[internal/claim-blog] config generation failed for ${username}:`, err); } @@ -576,6 +613,11 @@ internalRoutes.post('/claim-blog', async (c) => { }); return c.json({ + // Surfaced so the claiming UI can tell a fresh provision from an + // existing tenant returned unchanged: the latter applied NONE of the + // customization the claimant may have filled in, and reporting it as a + // plain success would claim otherwise. + created: result.created, tenant: { username: tenant.username, blogUrl: TenantService.getBlogUrl(tenant), diff --git a/apps/web/src/app/api/hosting/claim-blog/route.ts b/apps/web/src/app/api/hosting/claim-blog/route.ts index 5ba37fe7ad..b28dbceef6 100644 --- a/apps/web/src/app/api/hosting/claim-blog/route.ts +++ b/apps/web/src/app/api/hosting/claim-blog/route.ts @@ -42,13 +42,21 @@ export async function POST(request: NextRequest) { const title = typeof body.title === "string" ? body.title : undefined; const description = typeof body.description === "string" ? body.description : undefined; + // The customize step from the paid signup applies to the claim too. Passed + // through as-is; the hosting service validates them against its rosters. + const styleTemplate = typeof body.styleTemplate === "string" ? body.styleTemplate : undefined; + const accent = typeof body.accent === "string" ? body.accent : undefined; + const fontPreset = typeof body.fontPreset === "string" ? body.fontPreset : undefined; let upstream: Response; try { upstream = await callHostingInternal("/v1/internal/claim-blog", secret, { username, title, - description + description, + styleTemplate, + accent, + fontPreset }); } catch { return Response.json({ error: "Hosting service unavailable" }, { status: 502 }); diff --git a/apps/web/src/features/hosting-signup/hosting-api.ts b/apps/web/src/features/hosting-signup/hosting-api.ts index 25b95c7df8..1849d31269 100644 --- a/apps/web/src/features/hosting-signup/hosting-api.ts +++ b/apps/web/src/features/hosting-signup/hosting-api.ts @@ -49,6 +49,13 @@ export interface HostingTemplate { */ export const ACCENT_HEX_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; +/** + * Client-side mirror of the hosting API's font preset roster + * (hosting/api/src/appearance.ts FONT_PRESET_KEYS), shared by every surface + * that offers the appearance step so the option lists cannot drift apart. + */ +export const FONT_PRESETS = ["classic", "editorial", "modern", "technical", "system"] as const; + export interface CreateTenantResult { tenant: { username: string; subscriptionStatus: string; blogUrl: string }; paymentInstructions: { to: string; amount: string; memo: string; note?: string }; diff --git a/apps/web/src/features/hosting-signup/hosting-signup.tsx b/apps/web/src/features/hosting-signup/hosting-signup.tsx index 145e1dd519..d8ee4da578 100644 --- a/apps/web/src/features/hosting-signup/hosting-signup.tsx +++ b/apps/web/src/features/hosting-signup/hosting-signup.tsx @@ -17,6 +17,7 @@ import { hostingProSkuForMonths, isValidCommunityId, ACCENT_HEX_PATTERN, + FONT_PRESETS, HOSTING_CUSTOM_DOMAIN_MONTHLY_USD, type HostingPaymentMethods, type HostingTemplate @@ -49,7 +50,6 @@ type InstanceType = "blog" | "community"; const TERMS = [1, 3, 6, 12]; /** Font pairing keys the hosting API accepts; labels live in i18n. */ -const FONT_PRESETS = ["classic", "editorial", "modern", "technical", "system"] as const; /** localStorage key for an in-progress customization, so an abandoned tab resumes. */ const customizeDraftKey = (name: string) => `ecency:hosting:customize:${name}`; diff --git a/apps/web/src/features/i18n/locales/en-US.json b/apps/web/src/features/i18n/locales/en-US.json index 1db6025bcd..f41c5469f0 100644 --- a/apps/web/src/features/i18n/locales/en-US.json +++ b/apps/web/src/features/i18n/locales/en-US.json @@ -127,6 +127,9 @@ "not-pro": "Ecency Pro membership is required to claim a free blog.", "unavailable": "Blog hosting is not available right now.", "claimed-title": "Your blog is ready", + "already-title": "Your blog is already set up", + "already-note": "Claiming again does not change its settings. Manage the title, look and domain from Your hosted sites.", + "manage-link": "Go to Your hosted sites", "custom-domain-upsell": "Want your own domain like blog.yoursite.com? Add a custom domain for $3/mo.", "add-custom-domain": "Add a custom domain" }, diff --git a/apps/web/src/features/pro/pro-blog-claim.tsx b/apps/web/src/features/pro/pro-blog-claim.tsx index af662c8f02..c9a1ee3ccf 100644 --- a/apps/web/src/features/pro/pro-blog-claim.tsx +++ b/apps/web/src/features/pro/pro-blog-claim.tsx @@ -1,17 +1,40 @@ "use client"; import { getAccessToken } from "@/utils"; +import { getAccountFullQueryOptions } from "@ecency/sdk"; +import { useQuery } from "@tanstack/react-query"; import { Alert } from "@ui/alert"; import { Button } from "@ui/button"; +import { FormControl } from "@ui/input"; import i18next from "i18next"; import Link from "next/link"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { AccentPicker } from "../hosting-signup/accent-picker"; +import { + ACCENT_HEX_PATTERN, + FONT_PRESETS, + hostingApi, + type HostingTemplate +} from "../hosting-signup/hosting-api"; +import { TemplatePicker } from "../hosting-signup/template-picker"; const BASE_DOMAIN = "blogs.ecency.com"; interface Props { /** The authenticated Ecency Pro member. Its HiveSigner token authorizes the claim. */ username: string; + /** + * How long the requests the claim gates on (the template catalog and the + * existence probe) may keep it waiting before they degrade: the catalog to + * an ordinary load failure, the probe to claimable. A connection that + * neither resolves nor rejects must not disable claiming forever. + */ + settleTimeoutMs?: number; +} + +/** What the mount probe and a raced claim know about an existing blog. */ +interface ExistingBlog { + blogUrl?: string; } /** @@ -19,14 +42,144 @@ interface Props { * {username}.blogs.ecency.com; this action idempotently activates it via the web proxy * (/api/hosting/claim-blog), then links to the blog and the Custom domain upgrade. The proxy * re-checks Pro membership server-side, so this is safe even if rendered for a non-member. + * + * The claim passes through the same customize step as the paid signup: template, accent, fonts + * and an identity prefilled from the member's profile, so a claimed blog starts out looking like + * its owner rather than like the default template. */ -export function ProBlogClaim({ username }: Props) { +export function ProBlogClaim({ username, settleTimeoutMs = 10_000 }: Props) { const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [blogUrl, setBlogUrl] = useState(""); + // 'pending' while the mount probe runs; an ExistingBlog replaces the whole + // form (the claim would return it unchanged, applying none of the fields); + // null means claimable. + const [existing, setExisting] = useState("pending"); + + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [styleTemplate, setStyleTemplate] = useState(null); + const [accent, setAccent] = useState(null); + const [accentInput, setAccentInput] = useState(""); + const [fontPreset, setFontPreset] = useState(null); + const [templates, setTemplates] = useState(null); + const [templatesFailed, setTemplatesFailed] = useState(false); const subdomain = `${username}.${BASE_DOMAIN}`; + // The field is mid-edit and unusable: neither empty (template default) nor + // a committed valid hex. + const accentPending = + accentInput.trim().length > 0 && !ACCENT_HEX_PATTERN.test(accentInput.trim()); + + // The template catalog, like the paid signup: a load failure must not + // block the claim, the blog just starts on the default look. Bounded: the + // claim button waits for the catalog to settle, so a request that neither + // resolves nor rejects times out into the same failure state instead of + // disabling the claim forever. First outcome wins; a late arrival after + // the timeout is ignored rather than un-failing a form the member may + // already be reading. + useEffect(() => { + let cancelled = false; + let settled = false; + const timer = setTimeout(() => { + if (!cancelled && !settled) { + settled = true; + setTemplatesFailed(true); + } + }, settleTimeoutMs); + hostingApi + .templates() + .then((r) => { + if (!cancelled && !settled) { + settled = true; + // An empty roster would render a blank picker; the failure message + // (with the claim still allowed) is the honest state for it. + if (r.templates.length > 0) setTemplates(r.templates); + else setTemplatesFailed(true); + } + }) + .catch(() => { + if (!cancelled && !settled) { + settled = true; + setTemplatesFailed(true); + } + }) + .finally(() => clearTimeout(timer)); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [settleTimeoutMs]); + + // Whether this member's blog already exists: the claim endpoint returns an + // existing live tenant UNCHANGED, so presenting editable customization for + // it would report success while applying nothing. An existing blog swaps + // the form for a manage pointer; only an unknown name (or an abandoned + // reservation, which a claim revives) is claimable. Fail open on probe + // errors: the form still works and the claim itself answers honestly. + useEffect(() => { + let cancelled = false; + let settled = false; + // Bounded like the catalog: this probe also gates the claim button, so a + // stalled request fails open to claimable instead of disabling claiming + // forever. The race that lets through (a blog that does exist) is safe: + // the endpoint returns it unchanged with created: false and the claim + // shows the already-exists state. + const timer = setTimeout(() => { + if (!cancelled && !settled) { + settled = true; + setExisting(null); + } + }, settleTimeoutMs); + hostingApi + .tenant(username) + .then((t) => { + if (cancelled || settled) return; + settled = true; + setExisting( + t.subscriptionStatus === "abandoned" ? null : { blogUrl: t.blogUrl } + ); + }) + .catch(() => { + if (!cancelled && !settled) { + settled = true; + setExisting(null); + } + }) + .finally(() => clearTimeout(timer)); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [username, settleTimeoutMs]); + + // Identity prefill from the member's profile, once. The claimant is fixed + // (their own account), so the signup's name-change bookkeeping is not + // needed here; empty fields are simply seeded and stay editable. + const { data: prefillAccount, isFetched: prefillSettled } = useQuery( + getAccountFullQueryOptions(username) + ); + + // The claim is one-shot (an existing live tenant is returned unchanged), so + // a click before the catalog, the profile prefill and the existence probe + // SETTLE would lock in a default-looking config the claimant never saw + // coming. Failures still settle: a dead catalog or profile degrades to + // claiming without them, and the catalog settles by timeout at the latest. + const customizeSettled = + (templates !== null || templatesFailed) && prefillSettled && existing !== "pending"; + + const prefilledRef = useRef(false); + useEffect(() => { + if (prefilledRef.current || !prefillAccount) return; + prefilledRef.current = true; + const profile = ( + prefillAccount as { profile?: { name?: unknown; about?: unknown } } | undefined + )?.profile; + if (profile?.name) setTitle((prev) => prev || String(profile.name).slice(0, 100)); + if (profile?.about) setDescription((prev) => prev || String(profile.about).slice(0, 500)); + }, [prefillAccount]); + const claim = useCallback(async () => { setError(""); const code = getAccessToken(username) ?? ""; @@ -39,7 +192,14 @@ export function ProBlogClaim({ username }: Props) { const r = await fetch("/api/hosting/claim-blog", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code }) + body: JSON.stringify({ + code, + title: title.trim() || undefined, + description: description.trim() || undefined, + styleTemplate: styleTemplate ?? undefined, + accent: accent ?? undefined, + fontPreset: fontPreset ?? undefined + }) }); const data = await r.json().catch(() => ({})); if (!r.ok) { @@ -48,13 +208,47 @@ export function ProBlogClaim({ username }: Props) { else setError(data?.error || i18next.t("pro-blog.claim-failed")); return; } + // A raced claim (the blog appeared between the probe and the click) is + // returned unchanged with none of the customization applied; showing + // the plain success would claim otherwise. + if (data?.created === false) { + setExisting({ blogUrl: data?.tenant?.blogUrl || `https://${subdomain}` }); + return; + } setBlogUrl(data?.tenant?.blogUrl || `https://${subdomain}`); } catch { setError(i18next.t("pro-blog.claim-failed")); } finally { setBusy(false); } - }, [username, subdomain]); + }, [username, subdomain, title, description, styleTemplate, accent, fontPreset]); + + // Already set up: the claim would return this blog unchanged, so instead of + // a form whose every field would be silently discarded, point at the blog + // and at the manage panel where settings can actually be changed. + if (existing && existing !== "pending") { + return ( + +
+ {i18next.t("pro-blog.already-title")} + {existing.blogUrl && ( + + {existing.blogUrl} + + )} +

{i18next.t("pro-blog.already-note")}

+ + {i18next.t("pro-blog.manage-link")} + +
+
+ ); + } if (blogUrl) { return ( @@ -74,13 +268,78 @@ export function ProBlogClaim({ username }: Props) { } return ( -
+
{i18next.t("pro-blog.title")}

{i18next.t("pro-blog.includes", { subdomain })}

+ +

{i18next.t("hosting.customize-hint")}

+ + + + + + { + setAccentInput(raw); + const trimmed = raw.trim(); + if (!trimmed) setAccent(null); + else if (ACCENT_HEX_PATTERN.test(trimmed)) setAccent(trimmed); + }} + onPick={(hex) => { + setAccent(hex); + setAccentInput(hex ?? ""); + }} + /> + + + + + + setTitle(e.target.value)} + placeholder={i18next.t("hosting.blog-title-placeholder")} + /> + + setDescription(e.target.value)} + placeholder={i18next.t("hosting.blog-desc-placeholder")} + /> + {error && {error}} -
diff --git a/apps/web/src/specs/features/pro/pro-blog-claim.spec.tsx b/apps/web/src/specs/features/pro/pro-blog-claim.spec.tsx new file mode 100644 index 0000000000..dad3aaad3e --- /dev/null +++ b/apps/web/src/specs/features/pro/pro-blog-claim.spec.tsx @@ -0,0 +1,222 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen, fireEvent, waitFor } from "@testing-library/react"; +import { renderWithQueryClient } from "@/specs/test-utils"; + +// The Pro free-blog claim passes through the same customize step as the paid +// signup: template, accent, fonts and an identity prefilled from the profile. +// These specs pin the claim payload, since a field silently dropped here is a +// claimant staring at a default-looking blog they thought they had styled. + +const mocks = vi.hoisted(() => ({ + accessToken: "tok-alice" as string | undefined, + templates: vi.fn(), + tenant: vi.fn() +})); + +vi.mock("@/utils", () => ({ + getAccessToken: () => mocks.accessToken, + random: vi.fn() +})); + +vi.mock("@/features/hosting-signup/hosting-api", async () => { + const actual = await vi.importActual("@/features/hosting-signup/hosting-api"); + return { + ...actual, + hostingApi: { ...actual.hostingApi, templates: mocks.templates, tenant: mocks.tenant } + }; +}); + +import { ProBlogClaim } from "@/features/pro/pro-blog-claim"; +import { getAccountFullQueryOptions, QueryKeys } from "@ecency/sdk"; + +const TEMPLATES = [ + { + id: "medium", + name: "Medium", + tagline: "Clean", + isDefault: true, + colors: { background: "#fff", surface: "#fafafa", accent: "#111", text: "#111" }, + headingStyle: "serif" + }, + { + id: "journal", + name: "Journal", + tagline: "Ink on paper", + isDefault: false, + colors: { background: "#faf8f4", surface: "#f2efe8", accent: "#9c4a1e", text: "#221d17" }, + headingStyle: "serif" + } +]; + +describe("ProBlogClaim customize step", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.accessToken = "tok-alice"; + mocks.templates.mockResolvedValue({ templates: TEMPLATES }); + // No blog yet: the probe 404s, so the customize form renders. + mocks.tenant.mockRejectedValue(new Error("Tenant not found")); + vi.mocked(getAccountFullQueryOptions as any).mockImplementation(() => ({ + queryKey: QueryKeys.accounts.full("alice"), + queryFn: async () => ({ + profile: { name: "Alice in Chains", about: "Notes from the chain" } + }) + })); + fetchSpy = vi.spyOn(globalThis, "fetch" as any).mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + created: true, + tenant: { blogUrl: "https://alice.blogs.ecency.com" } + }) + } as any); + }); + + it("prefills identity from the profile and sends the chosen customization", async () => { + renderWithQueryClient(); + + // Identity prefilled from the profile once it loads. + await waitFor(() => + expect(screen.getByDisplayValue("Alice in Chains")).toBeTruthy() + ); + expect(screen.getByDisplayValue("Notes from the chain")).toBeTruthy(); + + // Pick a template card, a quick-pick accent and a font preset. + fireEvent.click(await screen.findByRole("radio", { name: /Journal/ })); + fireEvent.click(screen.getByRole("button", { name: "#0066cc" })); + fireEvent.change(screen.getByLabelText("hosting.fonts-label"), { + target: { value: "editorial" } + }); + + fireEvent.click(screen.getByRole("button", { name: "pro-blog.claim" })); + + await waitFor(() => expect(fetchSpy).toHaveBeenCalled()); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe("/api/hosting/claim-blog"); + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + code: "tok-alice", + title: "Alice in Chains", + description: "Notes from the chain", + styleTemplate: "journal", + accent: "#0066cc", + fontPreset: "editorial" + }); + + // Success shows the claimed blog link. + await screen.findByText("pro-blog.claimed-title"); + }); + + it("shows the manage pointer instead of the form when the blog already exists", async () => { + // The claim endpoint returns an existing live tenant UNCHANGED, so a form + // whose every field would be silently discarded must not render at all. + mocks.tenant.mockResolvedValue({ + username: "alice", + subscriptionStatus: "active", + blogUrl: "https://alice.blogs.ecency.com" + }); + renderWithQueryClient(); + + await screen.findByText("pro-blog.already-title"); + expect(screen.queryByRole("button", { name: "pro-blog.claim" })).toBeNull(); + expect(screen.getByText("https://alice.blogs.ecency.com")).toBeTruthy(); + }); + + it("reports a raced claim as already existing, never as an applied customization", async () => { + // The blog appeared between the probe and the click (another tab): the + // endpoint returns it unchanged and flags created: false. + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + created: false, + tenant: { blogUrl: "https://alice.blogs.ecency.com" } + }) + } as any); + renderWithQueryClient(); + await waitFor(() => + expect(screen.getByDisplayValue("Alice in Chains")).toBeTruthy() + ); + const button = screen.getByRole("button", { + name: "pro-blog.claim" + }) as HTMLButtonElement; + await waitFor(() => expect(button.disabled).toBe(false)); + fireEvent.click(button); + + await screen.findByText("pro-blog.already-title"); + expect(screen.queryByText("pro-blog.claimed-title")).toBeNull(); + }); + + it("degrades a stalled catalog to a failure instead of disabling the claim forever", async () => { + mocks.templates.mockReturnValue(new Promise(() => {})); + renderWithQueryClient(); + + await screen.findByText("hosting.template-load-failed"); + const button = screen.getByRole("button", { + name: "pro-blog.claim" + }) as HTMLButtonElement; + await waitFor(() => expect(button.disabled).toBe(false)); + }); + + it("treats an empty template roster as a load failure, not a blank picker", async () => { + mocks.templates.mockResolvedValue({ templates: [] }); + renderWithQueryClient(); + + await screen.findByText("hosting.template-load-failed"); + const button = (await screen.findByRole("button", { + name: "pro-blog.claim" + })) as HTMLButtonElement; + await waitFor(() => expect(button.disabled).toBe(false)); + }); + + it("fails a stalled existence probe open to claimable", async () => { + // The probe gates the claim the same way the catalog does, so it gets + // the same bound. Letting a real-but-slow existing blog through is safe: + // the endpoint answers created: false and the already-exists state shows. + mocks.tenant.mockReturnValue(new Promise(() => {})); + renderWithQueryClient(); + + const button = (await screen.findByRole("button", { + name: "pro-blog.claim" + })) as HTMLButtonElement; + await waitFor(() => expect(button.disabled).toBe(false)); + }); + + it("blocks the claim until the catalog and prefill settle", async () => { + // The claim is one-shot on the hosting side (an existing live tenant is + // returned unchanged), so a quick click before the customization data + // arrives would permanently lock in a default-looking config. + mocks.templates.mockReturnValue(new Promise(() => {})); + renderWithQueryClient(); + + await waitFor(() => + expect(screen.getByDisplayValue("Alice in Chains")).toBeTruthy() + ); + const button = screen.getByRole("button", { + name: "pro-blog.claim" + }) as HTMLButtonElement; + expect(button.disabled).toBe(true); + fireEvent.click(button); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("still claims with no customization when the catalog fails to load", async () => { + mocks.templates.mockRejectedValue(new Error("down")); + vi.mocked(getAccountFullQueryOptions as any).mockImplementation(() => ({ + queryKey: QueryKeys.accounts.full("alice"), + queryFn: async () => ({ profile: {} }) + })); + renderWithQueryClient(); + + await screen.findByText("hosting.template-load-failed"); + fireEvent.click(screen.getByRole("button", { name: "pro-blog.claim" })); + + await waitFor(() => expect(fetchSpy).toHaveBeenCalled()); + const [, init] = fetchSpy.mock.calls[0]; + // Nothing chosen and nothing prefilled: the payload carries only the code, + // so the claim degrades to exactly the pre-customize behavior. + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + code: "tok-alice" + }); + }); +}); diff --git a/apps/web/src/specs/setup-any-spec.ts b/apps/web/src/specs/setup-any-spec.ts index c25b8fc4fb..bcde6ae212 100644 --- a/apps/web/src/specs/setup-any-spec.ts +++ b/apps/web/src/specs/setup-any-spec.ts @@ -110,7 +110,8 @@ vi.mock("@ecency/sdk", async () => ({ // Only the key builders the web app reaches for directly. Pure string arrays, so a // partial stand-in is safe; add more branches as consumers need them. QueryKeys: { - quests: { status: (username?: string) => ["quests", "status", username] } + quests: { status: (username?: string) => ["quests", "status", username] }, + accounts: { full: (username?: string) => ["get-account-full", username] } }, getSpotlightsQueryOptions: vi.fn(() => ({ queryKey: ["notifications", "spotlights"],