Keep a guest community logo on review and on the published rule.

Sign-in was clearing the staged file before it uploaded, and review only read the server URL, so a chosen logo disappeared after the upload step.
This commit is contained in:
adilallo committed 2026-11-05 20:02:40 -07:00
1 parent 2348a9e94f
commit 8212423ec3
19 files changed
+780 -61

No files matched your search

+43
View File
@@ -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();
});
});
@@ -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" });
});
});
@@ -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,
+33
View File
@@ -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(