Show invited people the Rule, let them copy its cards for their community, and replace an expired invitation link.

This commit is contained in:
adilallo committed 2026-11-06 09:16:06 -07:00
1 parent 727a92a9ac
commit 3c984e00c5
37 files changed
+1962 -19

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import {
findCopiedNameHit,
stripCopiedRuleDocument,
} from "../../lib/create/copiedRuleDocument";
describe("stripCopiedRuleDocument", () => {
it("drops name, description, photo, and save-email", () => {
const out = stripCopiedRuleDocument({
title: "Juniper Street Food Share",
communityContext: "A neighborhood share.",
communityAvatarUrl: "https://example.test/a.png",
communityAvatarFileName: "a.png",
communityLocation: "Boulder",
communitySaveEmail: "a@example.com",
sections: [
{
categoryName: "Values",
entries: [{ title: "Care", body: "We care." }],
},
],
});
expect(out.title).toBeUndefined();
expect(out.communityContext).toBeUndefined();
expect(out.communityAvatarUrl).toBeUndefined();
expect(out.communityAvatarFileName).toBeUndefined();
expect(out.communityLocation).toBeUndefined();
expect(out.communitySaveEmail).toBeUndefined();
expect(out.sections).toEqual([
{
categoryName: "Values",
entries: [{ title: "Care", body: "We care." }],
},
]);
});
});
describe("findCopiedNameHit", () => {
it("returns the first entry that still names the original group", () => {
expect(
findCopiedNameHit(
{
sections: [
{
categoryName: "Values",
entries: [
{
title: "Solidarity",
body: "Juniper Street Food Share members share food.",
},
],
},
],
},
"Juniper Street Food Share",
),
).toEqual({
categoryName: "Values",
entryTitle: "Solidarity",
});
});
it("returns null when the name is gone", () => {
expect(
findCopiedNameHit(
{
sections: [
{
categoryName: "Values",
entries: [{ title: "Care", body: "We care." }],
},
],
},
"Juniper Street Food Share",
),
).toBeNull();
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import {
inviteExpiredPath,
invitedRulePath,
} from "../../lib/create/invitePaths";
describe("invitedRulePath", () => {
it("encodes the rule id", () => {
expect(invitedRulePath("ab/c")).toBe("/invite/ab%2Fc");
});
it("appends copy=1", () => {
expect(invitedRulePath("rule-1", { copy: true })).toBe(
"/invite/rule-1?copy=1",
);
});
});
describe("inviteExpiredPath", () => {
it("omits the token when missing", () => {
expect(inviteExpiredPath()).toBe("/invite/expired");
});
it("encodes the token", () => {
expect(inviteExpiredPath("a+b")).toBe("/invite/expired?token=a%2Bb");
});
});
@@ -6,6 +6,8 @@ describe("isChromelessNavigationPath", () => {
["/create", true],
["/create/completed", true],
["/login", true],
["/invite/expired", true],
["/invite/rule-1", true],
["/use-cases/mutual-aid-colorado/rule", true],
["/use-cases/food-not-bombs/rule/", true],
["/", false],
+24
View File
@@ -4,6 +4,7 @@ import {
communityInviteInitials,
communityPossessive,
inviteRuleCountsLabel,
inviteRuleCountsSpoken,
} from "../../lib/create/ruleInviteCardMeta";
describe("communityInviteInitials", () => {
@@ -61,3 +62,26 @@ describe("inviteRuleCountsLabel", () => {
).toBe("3 VALUES AND 2 CARDS");
});
});
describe("inviteRuleCountsSpoken", () => {
it("uses lowercase words", () => {
expect(
inviteRuleCountsSpoken({
sections: [
{
categoryName: "Values",
entries: [
{ title: "Care", body: "We care." },
{ title: "Share", body: "We share." },
{ title: "Listen", body: "We listen." },
],
},
{
categoryName: "Communication",
entries: [{ title: "Open channel", body: "How we talk." }],
},
],
}),
).toBe("three values and one card");
});
});
@@ -0,0 +1,112 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const findUniqueMock = vi.fn();
const updateMock = vi.fn();
const sendInviteMock = vi.fn();
const getSessionPepperMock = vi.fn(() => "pepper");
const rateLimitKeyMock = vi.fn(
(
_key: string,
_minIntervalMs: number,
): { ok: true } | { ok: false; retryAfterMs: number } => ({ ok: true }),
);
vi.mock("../../lib/server/env", () => ({
isDatabaseConfigured: () => true,
getSessionPepper: () => getSessionPepperMock(),
}));
vi.mock("../../lib/server/db", () => ({
prisma: {
ruleStakeholder: {
findUnique: (...args: unknown[]) => findUniqueMock(...args),
update: (...args: unknown[]) => updateMock(...args),
},
},
}));
vi.mock("../../lib/server/hash", () => ({
hashSessionToken: (token: string) => `hash-${token}`,
newSessionToken: () => "new-token",
}));
vi.mock("../../lib/server/mail", () => ({
sendRuleStakeholderInviteEmail: (...args: unknown[]) =>
sendInviteMock(...args),
}));
vi.mock("../../lib/server/rateLimit", () => ({
rateLimitKey: (key: string, minIntervalMs: number) =>
rateLimitKeyMock(key, minIntervalMs),
}));
import { POST } from "../../app/api/invites/rule-stakeholder/resend/route";
beforeEach(() => {
findUniqueMock.mockReset();
updateMock.mockReset();
sendInviteMock.mockReset();
getSessionPepperMock.mockReset();
rateLimitKeyMock.mockReset();
getSessionPepperMock.mockReturnValue("pepper");
rateLimitKeyMock.mockReturnValue({ ok: true as const });
updateMock.mockResolvedValue({});
sendInviteMock.mockResolvedValue(undefined);
});
describe("POST /api/invites/rule-stakeholder/resend", () => {
it("returns ok without sending when the token does not match", async () => {
findUniqueMock.mockResolvedValueOnce(null);
const res = await POST(
new NextRequest("https://x.test/api/invites/rule-stakeholder/resend", {
method: "POST",
body: JSON.stringify({
token: "a".repeat(12),
email: "inv@example.com",
}),
headers: { "content-type": "application/json" },
}),
undefined,
);
expect(res.status).toBe(200);
expect(sendInviteMock).not.toHaveBeenCalled();
});
it("rotates the token and sends mail when email matches", async () => {
findUniqueMock.mockResolvedValueOnce({
id: "st-1",
email: "inv@example.com",
inviteTokenHash: "hash-old",
inviteExpiresAt: new Date(Date.now() - 60_000),
invitedBy: { email: "owner@example.com" },
rule: {
title: "Juniper Street Food Share",
summary: null,
document: {},
user: { email: "owner@example.com" },
},
});
const res = await POST(
new NextRequest("https://x.test/api/invites/rule-stakeholder/resend", {
method: "POST",
body: JSON.stringify({
token: "a".repeat(12),
email: "inv@example.com",
}),
headers: { "content-type": "application/json" },
}),
undefined,
);
expect(res.status).toBe(200);
expect(updateMock).toHaveBeenCalled();
expect(sendInviteMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "inv@example.com",
communityName: "Juniper Street Food Share",
inviterEmail: "owner@example.com",
}),
);
});
});
@@ -70,7 +70,7 @@ describe("GET /api/invites/rule-stakeholder/verify", () => {
expect(res.status).toBeGreaterThanOrEqual(300);
expect(res.status).toBeLessThan(400);
expect(res.headers.get("location")).toContain("/rules/rule-1");
expect(res.headers.get("location")).toContain("/invite/rule-1");
expect(setCookieMock).toHaveBeenCalled();
});
@@ -97,4 +97,50 @@ describe("GET /api/invites/rule-stakeholder/verify", () => {
);
expect(upsertMock).not.toHaveBeenCalled();
});
it("redirects expired tokens to the expired invitation page", async () => {
getSessionUserMock.mockResolvedValue(null);
findUniqueMock.mockResolvedValue({
id: "st1",
email: "inv@example.com",
ruleId: "rule-1",
inviteExpiresAt: new Date(Date.now() - 60_000),
});
const token = "z".repeat(12);
const res = await GET(
new NextRequest(
`https://x.test/api/invites/rule-stakeholder/verify?token=${token}`,
),
);
expect(res.headers.get("location")).toContain("/invite/expired");
expect(res.headers.get("location")).toContain(`token=${token}`);
expect(upsertMock).not.toHaveBeenCalled();
});
it("keeps copy=1 on the invited view after a valid token", async () => {
getSessionUserMock.mockResolvedValue(null);
findUniqueMock.mockResolvedValue({
id: "st1",
email: "inv@example.com",
ruleId: "rule-1",
inviteExpiresAt: new Date(Date.now() + 60_000),
});
upsertMock.mockResolvedValue({ id: "u1", email: "inv@example.com" });
updateMock.mockResolvedValue({});
createSessionMock.mockResolvedValue({
token: "sess",
expiresAt: new Date(),
});
const res = await GET(
new NextRequest(
`https://x.test/api/invites/rule-stakeholder/verify?token=${"x".repeat(12)}&copy=1`,
),
);
expect(res.headers.get("location")).toContain("/invite/rule-1");
expect(res.headers.get("location")).toContain("copy=1");
});
});
+145
View File
@@ -0,0 +1,145 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const findFirstStakeholderMock = vi.fn();
const createRuleMock = vi.fn();
const getSessionUserMock = vi.fn();
const rateLimitKeyMock = vi.fn(
(
_key: string,
_minIntervalMs: number,
): { ok: true } | { ok: false; retryAfterMs: number } => ({ ok: true }),
);
vi.mock("../../lib/server/env", () => ({
isDatabaseConfigured: () => true,
}));
vi.mock("../../lib/server/session", () => ({
getSessionUser: () => getSessionUserMock(),
}));
vi.mock("../../lib/server/rateLimit", () => ({
rateLimitKey: (key: string, minIntervalMs: number) =>
rateLimitKeyMock(key, minIntervalMs),
}));
vi.mock("../../lib/server/db", () => ({
prisma: {
ruleStakeholder: {
findFirst: (...args: unknown[]) => findFirstStakeholderMock(...args),
},
publishedRule: {
create: (...args: unknown[]) => createRuleMock(...args),
},
},
}));
import { POST } from "../../app/api/rules/[id]/make-copy/route";
const viewer = { id: "user-2", email: "reader@example.com" };
const routeCtx = { params: Promise.resolve({ id: "rule-1" }) };
beforeEach(() => {
findFirstStakeholderMock.mockReset();
createRuleMock.mockReset();
getSessionUserMock.mockReset();
rateLimitKeyMock.mockReset();
rateLimitKeyMock.mockReturnValue({ ok: true as const });
getSessionUserMock.mockResolvedValue(viewer);
});
describe("POST /api/rules/[id]/make-copy", () => {
it("returns 401 when unauthenticated", async () => {
getSessionUserMock.mockResolvedValueOnce(null);
const res = await POST(
new NextRequest("https://x.test/api/rules/rule-1/make-copy", {
method: "POST",
body: JSON.stringify({ title: "New group" }),
headers: { "content-type": "application/json" },
}),
routeCtx,
);
expect(res.status).toBe(401);
});
it("returns 404 when the viewer is not an invited reader", async () => {
findFirstStakeholderMock.mockResolvedValueOnce(null);
const res = await POST(
new NextRequest("https://x.test/api/rules/rule-1/make-copy", {
method: "POST",
body: JSON.stringify({ title: "New group" }),
headers: { "content-type": "application/json" },
}),
routeCtx,
);
expect(res.status).toBe(404);
expect(createRuleMock).not.toHaveBeenCalled();
});
it("copies cards and text, not the original name or description", async () => {
findFirstStakeholderMock.mockResolvedValueOnce({
id: "st-1",
rule: {
id: "rule-1",
title: "Juniper Street Food Share",
userId: "owner-1",
document: {
title: "Juniper Street Food Share",
communityContext: "A neighborhood share.",
communityAvatarUrl: "https://example.test/a.png",
sections: [
{
categoryName: "Values",
entries: [
{
title: "Care",
body: "Juniper Street Food Share cares.",
},
],
},
],
},
},
});
createRuleMock.mockResolvedValueOnce({
id: "rule-copy",
title: "New group",
summary: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
});
const res = await POST(
new NextRequest("https://x.test/api/rules/rule-1/make-copy", {
method: "POST",
body: JSON.stringify({ title: "New group" }),
headers: { "content-type": "application/json" },
}),
routeCtx,
);
expect(res.status).toBe(200);
expect(createRuleMock).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: viewer.id,
title: "New group",
summary: null,
document: expect.not.objectContaining({
title: "Juniper Street Food Share",
communityContext: "A neighborhood share.",
communityAvatarUrl: "https://example.test/a.png",
}),
}),
});
const body = (await res.json()) as {
rule: {
id: string;
copiedFromTitle: string;
copiedNameHit: { entryTitle: string } | null;
};
};
expect(body.rule.id).toBe("rule-copy");
expect(body.rule.copiedFromTitle).toBe("Juniper Street Food Share");
expect(body.rule.copiedNameHit?.entryTitle).toBe("Care");
});
});