diff --git a/app/(app)/create/PostLoginDraftTransfer.tsx b/app/(app)/create/PostLoginDraftTransfer.tsx index 2d80360..caf09cd 100644 --- a/app/(app)/create/PostLoginDraftTransfer.tsx +++ b/app/(app)/create/PostLoginDraftTransfer.tsx @@ -16,6 +16,7 @@ import messages from "../../../messages/en/index"; import Alert from "../../components/modals/Alert"; import { isBackendSyncEnabled } from "../../../lib/create/backendSyncEnabled"; +import { flushPendingCommunityAvatar } from "../../../lib/create/flushPendingCommunityAvatar"; function buildPayloadWithStep( base: CreateFlowState, @@ -111,8 +112,15 @@ export function PostLoginDraftTransfer({ return; } + const flushed = await flushPendingCommunityAvatar(); + if (cancelled) return; + const transferred = + flushed.status === "uploaded" + ? { ...payload, communityAvatarUrl: flushed.url } + : payload; + if (isBackendSyncEnabled() && createFlowStateHasKeys(local)) { - const saveResult = await saveDraftToServer(payload); + const saveResult = await saveDraftToServer(transferred); if (cancelled) return; if (saveResult.ok === false) { @@ -127,8 +135,23 @@ export function PostLoginDraftTransfer({ } } - clearAnonymousCreateFlowStorage(); - replaceState(payload); + clearAnonymousCreateFlowStorage({ + preservePendingAvatar: flushed.status === "failed", + }); + replaceState((prev) => { + const fromPayload = + typeof transferred.communityAvatarUrl === "string" + ? transferred.communityAvatarUrl.trim() + : ""; + const fromPrev = + typeof prev.communityAvatarUrl === "string" + ? prev.communityAvatarUrl.trim() + : ""; + const communityAvatarUrl = fromPayload || fromPrev; + return communityAvatarUrl + ? { ...transferred, communityAvatarUrl } + : transferred; + }); if (pathname) { const params = new URLSearchParams(searchParams.toString()); diff --git a/app/(app)/create/components/CreateFlowPendingAvatarFlush.tsx b/app/(app)/create/components/CreateFlowPendingAvatarFlush.tsx index d8eb262..2735d7a 100644 --- a/app/(app)/create/components/CreateFlowPendingAvatarFlush.tsx +++ b/app/(app)/create/components/CreateFlowPendingAvatarFlush.tsx @@ -1,19 +1,16 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { useCreateFlow } from "../context/CreateFlowContext"; -import { - CreateFlowUploadValidationError, - uploadCreateFlowFile, -} from "../../../../lib/create/uploadToServer"; -import { - clearPendingCommunityAvatarFile, - readPendingCommunityAvatarFile, -} from "../../../../lib/create/pendingCommunityAvatarUpload"; +import { flushPendingCommunityAvatar } from "../../../../lib/create/flushPendingCommunityAvatar"; + +/** Background tries after a transient upload failure. The file stays staged. */ +const MAX_FLUSH_ATTEMPTS = 4; /** * After sign-in, uploads a community avatar staged in IndexedDB (anonymous pick) - * and writes `communityAvatarUrl` on success. + * and writes `communityAvatarUrl` on success. A transient failure retries with + * backoff while this layout stays mounted. */ export function CreateFlowPendingAvatarFlush({ sessionUser, @@ -23,7 +20,8 @@ export function CreateFlowPendingAvatarFlush({ sessionResolved: boolean; }) { const { state, updateState } = useCreateFlow(); - /** One successful flush per signed-in user id (survives React StrictMode remounts). */ + const [attempt, setAttempt] = useState(0); + /** One successful flush per signed-in user id (survives the effect re-running). */ const lastFlushedUserIdRef = useRef(null); const hasServerAvatar = typeof state.communityAvatarUrl === "string" && @@ -33,29 +31,31 @@ export function CreateFlowPendingAvatarFlush({ if (!sessionResolved || !sessionUser) return; if (lastFlushedUserIdRef.current === sessionUser.id) return; if (hasServerAvatar) return; + if (attempt >= MAX_FLUSH_ATTEMPTS) return; let cancelled = false; + let timer: ReturnType | undefined; void (async () => { - const file = await readPendingCommunityAvatarFile(); - if (cancelled || !file) return; - try { - const { url } = await uploadCreateFlowFile(file, "communityAvatar"); - if (cancelled) return; - await clearPendingCommunityAvatarFile(); - updateState({ communityAvatarUrl: url }); + const result = await flushPendingCommunityAvatar(); + if (cancelled) return; + if (result.status === "uploaded") { + updateState({ communityAvatarUrl: result.url }); lastFlushedUserIdRef.current = sessionUser.id; - } catch (err) { - if (err instanceof CreateFlowUploadValidationError) { - await clearPendingCommunityAvatarFile(); - } - // Leave a transient (auth / UPLOAD_ROOT) failure in place to retry. + return; } + if (result.status !== "failed") return; + if (attempt + 1 >= MAX_FLUSH_ATTEMPTS) return; + const delay = Math.min(20_000, 5_000 * 2 ** attempt); + timer = setTimeout(() => { + if (!cancelled) setAttempt((current) => current + 1); + }, delay); })(); return () => { cancelled = true; + if (timer) clearTimeout(timer); }; - }, [hasServerAvatar, sessionResolved, sessionUser, updateState]); + }, [attempt, hasServerAvatar, sessionResolved, sessionUser, updateState]); return null; } diff --git a/app/(app)/create/hooks/useCommunityAvatarDisplayUrl.ts b/app/(app)/create/hooks/useCommunityAvatarDisplayUrl.ts new file mode 100644 index 0000000..c920a6c --- /dev/null +++ b/app/(app)/create/hooks/useCommunityAvatarDisplayUrl.ts @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { readPendingCommunityAvatarFile } from "../../../../lib/create/pendingCommunityAvatarUpload"; + +function trimmedAvatarUrl(url: string | null): string | null { + if (typeof url !== "string") return null; + const trimmed = url.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +/** + * Logo src for review cards. Uses the uploaded URL when the draft has one, + * otherwise the guest file staged in IndexedDB. + * + * @param serverUrl `communityAvatarUrl` from create-flow state, if any. + * @returns A URL the Rule card can render, or `null` when there is no logo. + * + * @example + * const logoUrl = useCommunityAvatarDisplayUrl(state.communityAvatarUrl ?? null); + */ +export function useCommunityAvatarDisplayUrl( + serverUrl: string | null | undefined, +): string | null { + const resolvedServerUrl = trimmedAvatarUrl(serverUrl ?? null); + const [objectUrl, setObjectUrl] = useState(null); + + useEffect(() => { + if (resolvedServerUrl) { + setObjectUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + return; + } + + let cancelled = false; + let created: string | null = null; + void (async () => { + try { + const file = await readPendingCommunityAvatarFile(); + if (cancelled || !file) return; + created = URL.createObjectURL(file); + if (cancelled) { + URL.revokeObjectURL(created); + created = null; + return; + } + setObjectUrl((prev) => { + if (prev && prev !== created) URL.revokeObjectURL(prev); + return created; + }); + } catch { + // IndexedDB missing or blocked: the card keeps the placeholder. + } + })(); + + return () => { + cancelled = true; + if (created) URL.revokeObjectURL(created); + }; + }, [resolvedServerUrl]); + + return resolvedServerUrl ?? objectUrl; +} diff --git a/app/(app)/create/hooks/useCreateFlowExit.ts b/app/(app)/create/hooks/useCreateFlowExit.ts index b70d19a..9677020 100644 --- a/app/(app)/create/hooks/useCreateFlowExit.ts +++ b/app/(app)/create/hooks/useCreateFlowExit.ts @@ -3,6 +3,7 @@ import { useCallback } from "react"; import type { CreateFlowState, CreateFlowStep } from "../types"; import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload"; +import { stateWithPendingCommunityAvatar } from "../../../../lib/create/flushPendingCommunityAvatar"; import { saveDraftToServer, updatePublishedRule } from "../../../../lib/create/api"; import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule"; import { isBackendSyncEnabled } from "../../../../lib/create/backendSyncEnabled"; @@ -47,12 +48,18 @@ export function useCreateFlowExit({ } if (saveDraft && isBackendSyncEnabled()) { + const prepared = await stateWithPendingCommunityAvatar(state); + if (prepared.ok === false) { + setDraftSaveBannerMessage?.(messages.create.upload.errors.generic); + return; + } + const draftState = prepared.state; const editingId = - typeof state.editingPublishedRuleId === "string" - ? state.editingPublishedRuleId.trim() + typeof draftState.editingPublishedRuleId === "string" + ? draftState.editingPublishedRuleId.trim() : ""; if (editingId.length > 0) { - const payloadResult = buildPublishPayload(state); + const payloadResult = buildPublishPayload(draftState); if (payloadResult.ok === false) { setDraftSaveBannerMessage?.( payloadResult.error === "missingCommunityName" @@ -82,7 +89,7 @@ export function useCreateFlowExit({ } } else { const payload: CreateFlowState = { - ...state, + ...draftState, ...(currentStep ? { currentStep } : {}), }; const result = await saveDraftToServer(payload); diff --git a/app/(app)/create/hooks/useCreateFlowFinalize.ts b/app/(app)/create/hooks/useCreateFlowFinalize.ts index 52a007c..53bec38 100644 --- a/app/(app)/create/hooks/useCreateFlowFinalize.ts +++ b/app/(app)/create/hooks/useCreateFlowFinalize.ts @@ -2,6 +2,7 @@ import { useCallback, useState } from "react"; import { buildPublishPayload } from "../../../../lib/create/buildPublishPayload"; +import { stateWithPendingCommunityAvatar } from "../../../../lib/create/flushPendingCommunityAvatar"; import { publishRule, updatePublishedRule } from "../../../../lib/create/api"; import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule"; import { writePendingKeepRuleLogin } from "../../../../lib/create/pendingKeepRuleLogin"; @@ -63,8 +64,19 @@ export function useCreateFlowFinalize({ if (sessionUser === undefined) return; setPublishBannerMessage(null); - const payloadResult = buildPublishPayload(state); + setIsPublishing(true); + const prepared = await stateWithPendingCommunityAvatar(state); + if (prepared.ok === false) { + setIsPublishing(false); + setPublishBannerMessage(messages.create.upload.errors.generic); + return; + } + if (prepared.state.communityAvatarUrl !== state.communityAvatarUrl) { + updateState({ communityAvatarUrl: prepared.state.communityAvatarUrl }); + } + const payloadResult = buildPublishPayload(prepared.state); if (payloadResult.ok === false) { + setIsPublishing(false); setPublishBannerMessage( payloadResult.error === "missingCommunityName" ? messages.create.reviewAndComplete.publish.missingCommunityName @@ -73,7 +85,6 @@ export function useCreateFlowFinalize({ return; } const { title, summary, document: ruleDocument } = payloadResult; - setIsPublishing(true); const editingId = typeof state.editingPublishedRuleId === "string" diff --git a/app/(app)/create/screens/review/CommunityReviewScreen.tsx b/app/(app)/create/screens/review/CommunityReviewScreen.tsx index 6c1095f..8aff914 100644 --- a/app/(app)/create/screens/review/CommunityReviewScreen.tsx +++ b/app/(app)/create/screens/review/CommunityReviewScreen.tsx @@ -8,6 +8,7 @@ import InlineTextButton from "../../../../components/buttons/InlineTextButton"; import { useMessages, useTranslation } from "../../../../contexts/MessagesContext"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; import { useCreateFlow } from "../../context/CreateFlowContext"; +import { useCommunityAvatarDisplayUrl } from "../../hooks/useCommunityAvatarDisplayUrl"; import { useCreateFlowLgUp } from "../../hooks/useCreateFlowLgUp"; import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; import { @@ -148,11 +149,7 @@ export function CommunityReviewScreen() { ? state.communityContext.trim() : undefined; - const avatarUrl = - typeof state.communityAvatarUrl === "string" && - state.communityAvatarUrl.trim().length > 0 - ? state.communityAvatarUrl.trim() - : null; + const avatarUrl = useCommunityAvatarDisplayUrl(state.communityAvatarUrl); const structure = messages.create.community.communityStructure; const sizes = messages.create.community.communitySize.communitySizes; diff --git a/app/(app)/create/screens/review/FinalReviewScreen.tsx b/app/(app)/create/screens/review/FinalReviewScreen.tsx index d677ebf..7f2bcc1 100644 --- a/app/(app)/create/screens/review/FinalReviewScreen.tsx +++ b/app/(app)/create/screens/review/FinalReviewScreen.tsx @@ -28,6 +28,7 @@ import { } from "../../components/FinalReviewChipEditModal"; import { FinalReviewCommunityContextEditModal } from "../../components/FinalReviewCommunityContextEditModal"; import { FinalReviewTitleEditModal } from "../../components/FinalReviewTitleEditModal"; +import { useCommunityAvatarDisplayUrl } from "../../hooks/useCommunityAvatarDisplayUrl"; import { usePreviewSurfaceColor } from "../../hooks/usePreviewSurfaceColor"; import { useCreateFlowNavigation } from "../../hooks/useCreateFlowNavigation"; import { createFlowStepForFacetGroup } from "../../utils/facetGroupToCreateFlowStep"; @@ -251,11 +252,7 @@ export function FinalReviewScreen({ const descriptionEmptyHint = variant === "editPublished" ? t("communityContextEditModal.emptyHint") : undefined; - const avatarUrl = - typeof state.communityAvatarUrl === "string" && - state.communityAvatarUrl.trim().length > 0 - ? state.communityAvatarUrl.trim() - : null; + const avatarUrl = useCommunityAvatarDisplayUrl(state.communityAvatarUrl); const storedFallback = typeof state.previewSurfaceColor === "string" ? state.previewSurfaceColor diff --git a/app/(app)/create/utils/anonymousDraftStorage.ts b/app/(app)/create/utils/anonymousDraftStorage.ts index 397e363..627e014 100644 --- a/app/(app)/create/utils/anonymousDraftStorage.ts +++ b/app/(app)/create/utils/anonymousDraftStorage.ts @@ -1,4 +1,5 @@ import type { CreateFlowState } from "../types"; +import { forgetFlushedCommunityAvatarUrl } from "../../../../lib/create/flushPendingCommunityAvatar"; import { migrateLegacyCreateFlowState } from "../../../../lib/create/migrateLegacyCreateFlowState"; import { clearPendingCommunityAvatarFile } from "../../../../lib/create/pendingCommunityAvatarUpload"; @@ -47,7 +48,10 @@ export function writeAnonymousCreateFlowState(value: CreateFlowState): void { } } -export function clearAnonymousCreateFlowStorage(): void { +export function clearAnonymousCreateFlowStorage(options?: { + /** Keep the staged logo so a failed upload can retry. */ + preservePendingAvatar?: boolean; +}): void { if (typeof window === "undefined") return; try { window.localStorage.removeItem(CREATE_FLOW_ANONYMOUS_KEY); @@ -55,6 +59,8 @@ export function clearAnonymousCreateFlowStorage(): void { } catch { // ignore } + if (options?.preservePendingAvatar) return; + forgetFlushedCommunityAvatarUrl(); void clearPendingCommunityAvatarFile(); } diff --git a/app/api/uploads/route.ts b/app/api/uploads/route.ts index 286187f..8494801 100644 --- a/app/api/uploads/route.ts +++ b/app/api/uploads/route.ts @@ -31,6 +31,17 @@ function isPurpose(x: string): x is CreateFlowUploadPurpose { return x === "communityAvatar" || x === "customMethodAttachment"; } +function clientIp(request: NextRequest): string { + return ( + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + request.headers.get("x-real-ip") ?? + "unknown" + ); +} + +/** Same spacing as a signed-in upload, keyed by IP so a guest cannot fill the disk. */ +const ANONYMOUS_COMMUNITY_AVATAR_MIN_INTERVAL_MS = 5_000; + function messageForValidationReason( reason: CreateFlowUploadValidationReason, ): string { @@ -54,7 +65,19 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => { } const user = await getSessionUser(); - if (!user) { + + let formData: FormData | null = null; + try { + formData = await request.formData(); + } catch { + formData = null; + } + + const purposeRaw = formData?.get("purpose"); + // A guest publishes before they have a session. Only the community logo + // can be stored without one; attachments stay signed-in. A body that + // cannot be parsed is not a logo upload. + if (!user && purposeRaw !== "communityAvatar") { return unauthorized(); } @@ -64,15 +87,7 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => { ); } - const rl = rateLimitKey(`upload:${user.id}`, 5_000); - if (rl.ok === false) { - return rateLimited(rl.retryAfterMs); - } - - let formData: FormData; - try { - formData = await request.formData(); - } catch { + if (!formData) { return errorJson( "payload_too_large", "Upload body is too large or malformed.", @@ -80,9 +95,18 @@ export const POST = apiRoute("uploads.post", async (request: NextRequest) => { ); } - const purposeRaw = formData.get("purpose"); const file = asUploadedBlob(formData.get("file")); + const rl = user + ? rateLimitKey(`upload:${user.id}`, 5_000) + : rateLimitKey( + `upload-anon:${clientIp(request)}`, + ANONYMOUS_COMMUNITY_AVATAR_MIN_INTERVAL_MS, + ); + if (rl.ok === false) { + return rateLimited(rl.retryAfterMs); + } + if (typeof purposeRaw !== "string" || !isPurpose(purposeRaw)) { return errorJson( "validation_error", diff --git a/app/components/cards/Rule/Rule.view.tsx b/app/components/cards/Rule/Rule.view.tsx index c2323d7..687cc41 100644 --- a/app/components/cards/Rule/Rule.view.tsx +++ b/app/components/cards/Rule/Rule.view.tsx @@ -163,14 +163,16 @@ export function RuleView({ // Render logo/icon const renderLogo = () => { if (logoUrl) { - // Check if it's a localhost URL or external URL that needs regular img tag - const isLocalhost = + // next/image cannot load blob previews or localhost optimizer targets. + const useNativeImage = + logoUrl.startsWith("blob:") || + logoUrl.startsWith("data:") || logoUrl.startsWith("http://localhost") || logoUrl.startsWith("https://localhost"); const containerClass = `${logoContainerClass} relative overflow-hidden rounded-full bg-[var(--color-surface-invert-brand-teal)]`; - if (isLocalhost) { + if (useNativeImage) { return (
{/* eslint-disable-next-line @next/next/no-img-element */} diff --git a/lib/create/flushPendingCommunityAvatar.ts b/lib/create/flushPendingCommunityAvatar.ts new file mode 100644 index 0000000..d10ad2e --- /dev/null +++ b/lib/create/flushPendingCommunityAvatar.ts @@ -0,0 +1,97 @@ +import type { CreateFlowState } from "../../app/(app)/create/types"; +import { CreateFlowUploadValidationError } from "./createFlowUploadValidation"; +import { + clearPendingCommunityAvatarFile, + readPendingCommunityAvatarFile, +} from "./pendingCommunityAvatarUpload"; +import { uploadCreateFlowFile } from "./uploadToServer"; + +export type FlushPendingCommunityAvatarResult = + | { status: "none" } + | { status: "uploaded"; url: string } + | { status: "invalid" } + | { status: "failed" }; + +/** + * URL from the last successful flush. The IndexedDB file is removed on + * success, so a second caller (publish, sign-in transfer) can still apply + * the same URL if React state has not caught up yet. + * Cleared when anonymous draft storage is wiped. + */ +let lastUploadedUrl: string | null = null; +let inFlight: Promise | null = null; + +export function forgetFlushedCommunityAvatarUrl(): void { + lastUploadedUrl = null; +} + +async function runFlush(): Promise { + let file: File | null; + try { + file = await readPendingCommunityAvatarFile(); + } catch { + return lastUploadedUrl + ? { status: "uploaded", url: lastUploadedUrl } + : { status: "none" }; + } + if (!file) { + return lastUploadedUrl + ? { status: "uploaded", url: lastUploadedUrl } + : { status: "none" }; + } + + try { + const { url } = await uploadCreateFlowFile(file, "communityAvatar"); + lastUploadedUrl = url; + await clearPendingCommunityAvatarFile(); + return { status: "uploaded", url }; + } catch (err) { + if (err instanceof CreateFlowUploadValidationError) { + await clearPendingCommunityAvatarFile(); + return { status: "invalid" }; + } + return { status: "failed" }; + } +} + +/** + * Upload the staged community logo, once, even if review and sign-in both ask. + * A validation failure drops the file. A transient failure leaves it in place. + */ +export function flushPendingCommunityAvatar(): Promise { + if (!inFlight) { + inFlight = runFlush().finally(() => { + inFlight = null; + }); + } + return inFlight; +} + +/** + * Copy a staged logo onto `state` as `communityAvatarUrl` when upload works. + * `upload_failed` means the file is still staged and must not be published over. + */ +export async function stateWithPendingCommunityAvatar( + state: CreateFlowState, +): Promise< + | { ok: true; state: CreateFlowState } + | { ok: false; reason: "upload_failed" } +> { + const existing = + typeof state.communityAvatarUrl === "string" + ? state.communityAvatarUrl.trim() + : ""; + if (existing.length > 0) return { ok: true, state }; + + const flushed = await flushPendingCommunityAvatar(); + if (flushed.status === "uploaded") { + return { + ok: true, + state: { ...state, communityAvatarUrl: flushed.url }, + }; + } + if (flushed.status === "failed") { + return { ok: false, reason: "upload_failed" }; + } + return { ok: true, state }; +} diff --git a/lib/create/uploadToServer.ts b/lib/create/uploadToServer.ts index b5c423b..9c50167 100644 --- a/lib/create/uploadToServer.ts +++ b/lib/create/uploadToServer.ts @@ -28,8 +28,8 @@ function reasonFromUnknown(value: unknown): CreateFlowUploadValidationReason | n } /** - * Authenticated multipart upload to `POST /api/uploads`. - * Caller must have a session cookie (same-origin fetch). + * Multipart upload to `POST /api/uploads`. + * `communityAvatar` is accepted for a guest; other purposes need a session. */ export async function uploadCreateFlowFile( file: File, diff --git a/tests/components/CreateFlowPendingAvatarFlush.test.tsx b/tests/components/CreateFlowPendingAvatarFlush.test.tsx new file mode 100644 index 0000000..8bff520 --- /dev/null +++ b/tests/components/CreateFlowPendingAvatarFlush.test.tsx @@ -0,0 +1,61 @@ +import { act } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CreateFlowPendingAvatarFlush } from "../../app/(app)/create/components/CreateFlowPendingAvatarFlush"; +import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext"; +import { flushPendingCommunityAvatar } from "../../lib/create/flushPendingCommunityAvatar"; +import { + renderWithProviders as render, + screen, +} from "../utils/test-utils"; +import "@testing-library/jest-dom/vitest"; + +vi.mock("../../lib/create/flushPendingCommunityAvatar", () => ({ + flushPendingCommunityAvatar: vi.fn(), +})); + +function AvatarProbe() { + const { state } = useCreateFlow(); + return
{state.communityAvatarUrl ?? ""}
; +} + +describe("CreateFlowPendingAvatarFlush", () => { + afterEach(() => { + vi.useRealTimers(); + vi.mocked(flushPendingCommunityAvatar).mockReset(); + }); + + it("retries a transient upload and then stores the url", async () => { + vi.useFakeTimers(); + vi.mocked(flushPendingCommunityAvatar) + .mockResolvedValueOnce({ status: "failed" }) + .mockResolvedValueOnce({ + status: "uploaded", + url: "/api/uploads/retry", + }); + + render( + <> + + + , + ); + + await act(async () => { + await Promise.resolve(); + }); + expect(flushPendingCommunityAvatar).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("avatar")).toHaveTextContent(""); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + + expect(flushPendingCommunityAvatar).toHaveBeenCalledTimes(2); + expect(screen.getByTestId("avatar")).toHaveTextContent( + "/api/uploads/retry", + ); + }); +}); diff --git a/tests/components/PostLoginDraftTransfer.test.tsx b/tests/components/PostLoginDraftTransfer.test.tsx new file mode 100644 index 0000000..3df8e78 --- /dev/null +++ b/tests/components/PostLoginDraftTransfer.test.tsx @@ -0,0 +1,120 @@ +import { useSearchParams } from "next/navigation"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext"; +import { PostLoginDraftTransfer } from "../../app/(app)/create/PostLoginDraftTransfer"; +import { + CREATE_FLOW_ANONYMOUS_KEY, + CREATE_FLOW_TRANSFER_PENDING_KEY, +} from "../../app/(app)/create/utils/anonymousDraftStorage"; +import { saveDraftToServer } from "../../lib/create/api"; +import { flushPendingCommunityAvatar } from "../../lib/create/flushPendingCommunityAvatar"; +import { clearPendingCommunityAvatarFile } from "../../lib/create/pendingCommunityAvatarUpload"; +import { + renderWithProviders as render, + screen, + waitFor, +} from "../utils/test-utils"; +import "@testing-library/jest-dom/vitest"; + +vi.mock("../../lib/create/api", () => ({ + fetchDraftFromServer: vi.fn().mockResolvedValue(null), + saveDraftToServer: vi.fn().mockResolvedValue({ ok: true }), +})); + +vi.mock("../../lib/create/flushPendingCommunityAvatar", () => ({ + flushPendingCommunityAvatar: vi.fn(), + forgetFlushedCommunityAvatarUrl: vi.fn(), +})); + +vi.mock("../../lib/create/pendingCommunityAvatarUpload", () => ({ + clearPendingCommunityAvatarFile: vi.fn().mockResolvedValue(undefined), + readPendingCommunityAvatarFile: vi.fn().mockResolvedValue(null), + storePendingCommunityAvatarFile: vi.fn(), +})); + +function mockSearchParams(value: string) { + vi.mocked(useSearchParams).mockReturnValue( + new URLSearchParams(value) as NonNullable< + ReturnType + >, + ); +} + +function StateProbe() { + const { state } = useCreateFlow(); + return ( +
+ {state.title ?? ""} + {state.communityAvatarUrl ?? ""} +
+ ); +} + +describe("PostLoginDraftTransfer", () => { + beforeEach(() => { + localStorage.clear(); + localStorage.setItem( + CREATE_FLOW_ANONYMOUS_KEY, + JSON.stringify({ title: "Garden Club" }), + ); + localStorage.setItem(CREATE_FLOW_TRANSFER_PENDING_KEY, "1"); + mockSearchParams("syncDraft=1"); + vi.mocked(flushPendingCommunityAvatar).mockReset(); + vi.mocked(saveDraftToServer).mockClear(); + vi.mocked(clearPendingCommunityAvatarFile).mockClear(); + }); + + it("uploads the staged logo before clearing the anonymous draft", async () => { + let draftStillPresent = false; + vi.mocked(flushPendingCommunityAvatar).mockImplementation(async () => { + draftStillPresent = + localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY) != null; + return { status: "uploaded", url: "/api/uploads/from-guest" }; + }); + + render( + <> + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("avatar")).toHaveTextContent( + "/api/uploads/from-guest", + ); + }); + expect(draftStillPresent).toBe(true); + expect(localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull(); + expect(saveDraftToServer).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Garden Club", + communityAvatarUrl: "/api/uploads/from-guest", + }), + ); + expect(clearPendingCommunityAvatarFile).toHaveBeenCalled(); + }); + + it("keeps the staged file when the upload fails", async () => { + vi.mocked(flushPendingCommunityAvatar).mockResolvedValue({ + status: "failed", + }); + + render( + <> + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("title")).toHaveTextContent("Garden Club"); + }); + expect(clearPendingCommunityAvatarFile).not.toHaveBeenCalled(); + expect(screen.getByTestId("avatar")).toHaveTextContent(""); + }); +}); diff --git a/tests/components/ReviewPage.test.tsx b/tests/components/ReviewPage.test.tsx index 41d548c..6bf7ad1 100644 --- a/tests/components/ReviewPage.test.tsx +++ b/tests/components/ReviewPage.test.tsx @@ -1,4 +1,4 @@ -import { beforeEach, describe, it, expect } from "vitest"; +import { beforeEach, describe, it, expect, vi } from "vitest"; import React, { useEffect } from "react"; import { renderWithProviders as render, @@ -9,6 +9,13 @@ import "@testing-library/jest-dom/vitest"; import { CommunityReviewScreen } from "../../app/(app)/create/screens/review/CommunityReviewScreen"; import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext"; import { testRouter } from "../mocks/navigation"; +import { readPendingCommunityAvatarFile } from "../../lib/create/pendingCommunityAvatarUpload"; + +vi.mock("../../lib/create/pendingCommunityAvatarUpload", () => ({ + readPendingCommunityAvatarFile: vi.fn().mockResolvedValue(null), + storePendingCommunityAvatarFile: vi.fn(), + clearPendingCommunityAvatarFile: vi.fn(), +})); function ReviewWithTitle({ title }: { title: string }) { const { state, updateState } = useCreateFlow(); @@ -43,6 +50,8 @@ describe("CommunityReviewScreen", () => { beforeEach(() => { testRouter.replace.mockReset(); testRouter.push.mockReset(); + vi.mocked(readPendingCommunityAvatarFile).mockReset(); + vi.mocked(readPendingCommunityAvatarFile).mockResolvedValue(null); }); it("renders without crashing", () => { @@ -89,6 +98,33 @@ describe("CommunityReviewScreen", () => { expect(screen.getByText("Garden Club")).toBeInTheDocument(); }); + it("shows a guest logo staged in the browser on the rule card", async () => { + const pending = new File([new Uint8Array([1, 2, 3])], "logo.png", { + type: "image/png", + }); + vi.mocked(readPendingCommunityAvatarFile).mockResolvedValue(pending); + Object.defineProperty(URL, "createObjectURL", { + value: vi.fn(() => "blob:pending-avatar"), + writable: true, + configurable: true, + }); + Object.defineProperty(URL, "revokeObjectURL", { + value: vi.fn(), + writable: true, + configurable: true, + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole("img", { name: "Garden Club" })).toHaveAttribute( + "src", + "blob:pending-avatar", + ); + }); + expect(readPendingCommunityAvatarFile).toHaveBeenCalled(); + }); + it("omits the Rule description when the user has not entered community context", () => { render(); expect( diff --git a/tests/unit/anonymousDraftStorage.test.ts b/tests/unit/anonymousDraftStorage.test.ts new file mode 100644 index 0000000..a96a293 --- /dev/null +++ b/tests/unit/anonymousDraftStorage.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const clearPendingCommunityAvatarFile = vi.fn(); +const forgetFlushedCommunityAvatarUrl = vi.fn(); + +vi.mock("../../lib/create/pendingCommunityAvatarUpload", () => ({ + clearPendingCommunityAvatarFile: () => clearPendingCommunityAvatarFile(), +})); + +vi.mock("../../lib/create/flushPendingCommunityAvatar", () => ({ + forgetFlushedCommunityAvatarUrl: () => forgetFlushedCommunityAvatarUrl(), +})); + +import { + CREATE_FLOW_ANONYMOUS_KEY, + CREATE_FLOW_TRANSFER_PENDING_KEY, + clearAnonymousCreateFlowStorage, +} from "../../app/(app)/create/utils/anonymousDraftStorage"; + +describe("clearAnonymousCreateFlowStorage", () => { + beforeEach(() => { + localStorage.clear(); + clearPendingCommunityAvatarFile.mockReset(); + forgetFlushedCommunityAvatarUrl.mockReset(); + localStorage.setItem(CREATE_FLOW_ANONYMOUS_KEY, "{\"title\":\"Garden\"}"); + localStorage.setItem(CREATE_FLOW_TRANSFER_PENDING_KEY, "1"); + }); + + it("removes the draft and the staged logo", () => { + clearAnonymousCreateFlowStorage(); + expect(localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull(); + expect(localStorage.getItem(CREATE_FLOW_TRANSFER_PENDING_KEY)).toBeNull(); + expect(clearPendingCommunityAvatarFile).toHaveBeenCalledTimes(1); + expect(forgetFlushedCommunityAvatarUrl).toHaveBeenCalledTimes(1); + }); + + it("keeps the staged logo when a transfer upload still needs to retry", () => { + clearAnonymousCreateFlowStorage({ preservePendingAvatar: true }); + expect(localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull(); + expect(clearPendingCommunityAvatarFile).not.toHaveBeenCalled(); + expect(forgetFlushedCommunityAvatarUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/flushPendingCommunityAvatar.test.ts b/tests/unit/flushPendingCommunityAvatar.test.ts new file mode 100644 index 0000000..d04e4f4 --- /dev/null +++ b/tests/unit/flushPendingCommunityAvatar.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CreateFlowUploadValidationError } from "../../lib/create/createFlowUploadValidation"; + +const readPendingCommunityAvatarFile = vi.fn(); +const clearPendingCommunityAvatarFile = vi.fn(); +const uploadCreateFlowFile = vi.fn(); + +vi.mock("../../lib/create/pendingCommunityAvatarUpload", () => ({ + readPendingCommunityAvatarFile: (...args: unknown[]) => + readPendingCommunityAvatarFile(...args), + clearPendingCommunityAvatarFile: (...args: unknown[]) => + clearPendingCommunityAvatarFile(...args), +})); + +vi.mock("../../lib/create/uploadToServer", () => ({ + uploadCreateFlowFile: (...args: unknown[]) => uploadCreateFlowFile(...args), +})); + +import { + flushPendingCommunityAvatar, + forgetFlushedCommunityAvatarUrl, + stateWithPendingCommunityAvatar, +} from "../../lib/create/flushPendingCommunityAvatar"; + +const pending = new File([new Uint8Array([1, 2, 3])], "logo.png", { + type: "image/png", +}); + +describe("flushPendingCommunityAvatar", () => { + beforeEach(() => { + readPendingCommunityAvatarFile.mockReset(); + clearPendingCommunityAvatarFile.mockReset(); + uploadCreateFlowFile.mockReset(); + clearPendingCommunityAvatarFile.mockResolvedValue(undefined); + forgetFlushedCommunityAvatarUrl(); + }); + + it("does nothing when no file is staged", async () => { + readPendingCommunityAvatarFile.mockResolvedValue(null); + await expect(flushPendingCommunityAvatar()).resolves.toEqual({ + status: "none", + }); + expect(uploadCreateFlowFile).not.toHaveBeenCalled(); + }); + + it("uploads once and remembers the url after the file is cleared", async () => { + readPendingCommunityAvatarFile + .mockResolvedValueOnce(pending) + .mockResolvedValue(null); + uploadCreateFlowFile.mockResolvedValue({ url: "/api/uploads/logo" }); + + const [first, second] = await Promise.all([ + flushPendingCommunityAvatar(), + flushPendingCommunityAvatar(), + ]); + + expect(first).toEqual({ status: "uploaded", url: "/api/uploads/logo" }); + expect(second).toEqual(first); + expect(uploadCreateFlowFile).toHaveBeenCalledTimes(1); + expect(clearPendingCommunityAvatarFile).toHaveBeenCalledTimes(1); + + await expect(flushPendingCommunityAvatar()).resolves.toEqual({ + status: "uploaded", + url: "/api/uploads/logo", + }); + expect(uploadCreateFlowFile).toHaveBeenCalledTimes(1); + }); + + it("drops an invalid file and leaves a transient failure staged", async () => { + readPendingCommunityAvatarFile.mockResolvedValue(pending); + uploadCreateFlowFile.mockRejectedValueOnce( + new CreateFlowUploadValidationError("tooLarge"), + ); + + await expect(flushPendingCommunityAvatar()).resolves.toEqual({ + status: "invalid", + }); + expect(clearPendingCommunityAvatarFile).toHaveBeenCalledTimes(1); + + uploadCreateFlowFile.mockRejectedValueOnce(new Error("network")); + await expect(flushPendingCommunityAvatar()).resolves.toEqual({ + status: "failed", + }); + expect(clearPendingCommunityAvatarFile).toHaveBeenCalledTimes(1); + }); + + it("copies a successful upload onto publish state and blocks on failure", async () => { + readPendingCommunityAvatarFile.mockResolvedValue(pending); + uploadCreateFlowFile.mockResolvedValue({ url: "/api/uploads/logo" }); + + await expect( + stateWithPendingCommunityAvatar({ title: "Garden" }), + ).resolves.toEqual({ + ok: true, + state: { title: "Garden", communityAvatarUrl: "/api/uploads/logo" }, + }); + + forgetFlushedCommunityAvatarUrl(); + uploadCreateFlowFile.mockRejectedValueOnce(new Error("network")); + await expect( + stateWithPendingCommunityAvatar({ + title: "Garden", + communityAvatarFileName: "logo.png", + }), + ).resolves.toEqual({ ok: false, reason: "upload_failed" }); + }); +}); diff --git a/tests/unit/hooks/useCreateFlowFinalize.test.tsx b/tests/unit/hooks/useCreateFlowFinalize.test.tsx index 7a18b7f..126555d 100644 --- a/tests/unit/hooks/useCreateFlowFinalize.test.tsx +++ b/tests/unit/hooks/useCreateFlowFinalize.test.tsx @@ -3,6 +3,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { CreateFlowState } from "../../../app/(app)/create/types"; import { useCreateFlowFinalize } from "../../../app/(app)/create/hooks/useCreateFlowFinalize"; import { publishRule, updatePublishedRule } from "../../../lib/create/api"; +import { buildPublishPayload } from "../../../lib/create/buildPublishPayload"; +import { stateWithPendingCommunityAvatar } from "../../../lib/create/flushPendingCommunityAvatar"; +import messages from "../../../messages/en/index"; import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule"; import { CREATE_FLOW_PENDING_KEEP_RULE_LOGIN_KEY } from "../../../lib/create/pendingKeepRuleLogin"; import { @@ -24,6 +27,13 @@ vi.mock("../../../lib/create/api", () => ({ updatePublishedRule: vi.fn(), })); +vi.mock("../../../lib/create/flushPendingCommunityAvatar", () => ({ + stateWithPendingCommunityAvatar: vi.fn(async (state: CreateFlowState) => ({ + ok: true as const, + state, + })), +})); + vi.mock("../../../lib/create/lastPublishedRule", () => ({ writeLastPublishedRule: vi.fn(), })); @@ -41,6 +51,10 @@ describe("useCreateFlowFinalize", () => { vi.mocked(publishRule).mockReset(); vi.mocked(updatePublishedRule).mockReset(); vi.mocked(writeLastPublishedRule).mockReset(); + vi.mocked(stateWithPendingCommunityAvatar).mockReset(); + vi.mocked(stateWithPendingCommunityAvatar).mockImplementation( + async (state) => ({ ok: true as const, state }), + ); router.push.mockReset(); updateState.mockReset(); openLogin.mockReset(); @@ -148,6 +162,82 @@ describe("useCreateFlowFinalize", () => { ); }); + it("publishes a guest logo that was only staged in the browser", async () => { + vi.mocked(publishRule).mockResolvedValue({ + ok: true, + id: "guest-rule-id", + title: "Published title", + }); + vi.mocked(stateWithPendingCommunityAvatar).mockResolvedValue({ + ok: true, + state: { + communityAvatarFileName: "logo.png", + communityAvatarUrl: "/api/uploads/guest-logo", + }, + }); + vi.mocked(buildPublishPayload).mockImplementationOnce((draft) => ({ + ok: true as const, + title: "Published title", + summary: "Published summary", + document: { + communityAvatarUrl: draft.communityAvatarUrl, + }, + })); + + const { result } = renderHook(() => + useCreateFlowFinalize({ + state: { communityAvatarFileName: "logo.png" }, + router, + openLogin, + updateState, + loginReturnPath: "/create/final-review", + sessionUser: null, + onGuestPublished, + }), + ); + + await act(async () => { + await result.current.finalize(); + }); + + expect(publishRule).toHaveBeenCalledWith({ + title: "Published title", + summary: "Published summary", + document: { communityAvatarUrl: "/api/uploads/guest-logo" }, + }); + expect(updateState).toHaveBeenCalledWith({ + communityAvatarUrl: "/api/uploads/guest-logo", + }); + }); + + it("does not publish when the staged logo fails to upload", async () => { + vi.mocked(stateWithPendingCommunityAvatar).mockResolvedValue({ + ok: false, + reason: "upload_failed", + }); + + const { result } = renderHook(() => + useCreateFlowFinalize({ + state: { communityAvatarFileName: "logo.png" }, + router, + openLogin, + updateState, + loginReturnPath: "/create/final-review", + sessionUser: null, + }), + ); + + await act(async () => { + await result.current.finalize(); + }); + + expect(publishRule).not.toHaveBeenCalled(); + expect(result.current.publishBannerMessage).toBe( + messages.create.upload.errors.generic, + ); + expect(result.current.isPublishing).toBe(false); + }); + it("hints to claim later when a guest publishes without stakeholder emails", async () => { vi.mocked(publishRule).mockResolvedValue({ ok: true, diff --git a/tests/unit/uploadsPostRoute.test.ts b/tests/unit/uploadsPostRoute.test.ts index 16a42ba..ca4a669 100644 --- a/tests/unit/uploadsPostRoute.test.ts +++ b/tests/unit/uploadsPostRoute.test.ts @@ -80,6 +80,39 @@ describe("POST /api/uploads", () => { expect(res.status).toBe(401); }); + it("returns 401 when a guest uploads an attachment", async () => { + getSessionUserMock.mockResolvedValueOnce(null); + const res = await POST( + multipartRequest({ + purpose: "customMethodAttachment", + fileName: "notes.pdf", + fileContent: "hello", + contentType: "application/pdf", + }), + undefined, + ); + expect(res.status).toBe(401); + }); + + it("lets a guest submit a community logo and then validates it", async () => { + getSessionUserMock.mockResolvedValueOnce(null); + const res = await POST( + multipartRequest({ + purpose: "communityAvatar", + fileName: "empty.png", + fileContent: "", + }), + undefined, + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { + error: { code: string }; + details?: { reason?: string }; + }; + expect(body.error.code).toBe("validation_error"); + expect(body.details?.reason).toBe("empty"); + }); + it("returns 500 when UPLOAD_ROOT is unset", async () => { getUploadRootFromEnvMock.mockReturnValueOnce(null); const res = await POST(