feat: let guests publish a CommunityRule and claim it on this browser later

Finalize no longer requires a magic link. Guest rows stay off the catalog until sign-in on the same browser attaches ownership, and the login modal kebab no longer acts as a second close.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilallo
2026-08-26 11:01:10 -06:00
co-authored by Cursor
parent bf4020005a
commit 950b3224ac
23 changed files with 689 additions and 31 deletions
+126
View File
@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const cookieGetMock = vi.fn();
const cookieSetMock = vi.fn();
const updateManyMock = vi.fn();
const getSessionPepperMock = vi.fn();
const hashRuleClaimTokenMock = vi.fn();
vi.mock("next/headers", () => ({
cookies: async () => ({
get: cookieGetMock,
set: cookieSetMock,
}),
}));
vi.mock("../../lib/server/db", () => ({
prisma: {
publishedRule: {
updateMany: (...args: unknown[]) => updateManyMock(...args),
},
},
}));
vi.mock("../../lib/server/env", () => ({
getSessionPepper: () => getSessionPepperMock(),
}));
vi.mock("../../lib/server/hash", () => ({
hashRuleClaimToken: (...args: unknown[]) => hashRuleClaimTokenMock(...args),
}));
vi.mock("../../lib/logger", () => ({
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import {
claimGuestRulesForUser,
issueGuestRuleClaimCookie,
} from "../../lib/server/guestRuleClaim";
beforeEach(() => {
cookieGetMock.mockReset();
cookieSetMock.mockReset();
updateManyMock.mockReset();
getSessionPepperMock.mockReset();
hashRuleClaimTokenMock.mockReset();
getSessionPepperMock.mockReturnValue("pepper");
hashRuleClaimTokenMock.mockImplementation((token: string) => `h-${token}`);
});
describe("issueGuestRuleClaimCookie", () => {
it("stores the token in an httpOnly cookie", async () => {
cookieGetMock.mockReturnValue(undefined);
await issueGuestRuleClaimCookie("token-aaaaaaaaaaaaaaaa");
expect(cookieSetMock).toHaveBeenCalledWith(
"cr_rule_claim",
JSON.stringify(["token-aaaaaaaaaaaaaaaa"]),
expect.objectContaining({
httpOnly: true,
path: "/",
sameSite: "lax",
}),
);
});
it("appends without duplicating", async () => {
cookieGetMock.mockReturnValue({
value: JSON.stringify(["token-aaaaaaaaaaaaaaaa"]),
});
await issueGuestRuleClaimCookie("token-bbbbbbbbbbbbbbbb");
expect(cookieSetMock).toHaveBeenCalledWith(
"cr_rule_claim",
JSON.stringify(["token-aaaaaaaaaaaaaaaa", "token-bbbbbbbbbbbbbbbb"]),
expect.any(Object),
);
});
});
describe("claimGuestRulesForUser", () => {
it("returns 0 when the cookie is empty", async () => {
cookieGetMock.mockReturnValue(undefined);
const claimed = await claimGuestRulesForUser("user-1");
expect(claimed).toBe(0);
expect(updateManyMock).not.toHaveBeenCalled();
});
it("claims matching ownerless rows and clears the cookie", async () => {
cookieGetMock.mockReturnValue({
value: JSON.stringify(["token-aaaaaaaaaaaaaaaa"]),
});
updateManyMock.mockResolvedValueOnce({ count: 1 });
const claimed = await claimGuestRulesForUser("user-1");
expect(claimed).toBe(1);
expect(hashRuleClaimTokenMock).toHaveBeenCalledWith(
"token-aaaaaaaaaaaaaaaa",
"pepper",
);
expect(updateManyMock).toHaveBeenCalledWith({
where: { claimTokenHash: "h-token-aaaaaaaaaaaaaaaa", userId: null },
data: { userId: "user-1", claimTokenHash: null },
});
expect(cookieSetMock).toHaveBeenCalledWith(
"cr_rule_claim",
"",
expect.objectContaining({ maxAge: 0 }),
);
});
it("does not attach a rule that already has an owner", async () => {
cookieGetMock.mockReturnValue({
value: JSON.stringify(["token-aaaaaaaaaaaaaaaa"]),
});
updateManyMock.mockResolvedValueOnce({ count: 0 });
const claimed = await claimGuestRulesForUser("user-1");
expect(claimed).toBe(0);
expect(cookieSetMock).toHaveBeenCalledWith(
"cr_rule_claim",
"",
expect.objectContaining({ maxAge: 0 }),
);
});
});