Keep a published rule when some invitation emails already went out, and only set Reply-To when the inviter address is a mailbox.

This commit is contained in:
adilallo committed 2026-11-06 09:22:06 -07:00
1 parent 3c984e00c5
commit ab6a0c807d
7 files changed
+161 -10

No files matched your search

@@ -1,13 +1,47 @@
import { describe, expect, it } from "vitest";
import {
RULE_STAKEHOLDER_INVITE_LOGO_PATH,
RULE_STAKEHOLDER_INVITER_FALLBACK,
buildRuleStakeholderInviteMail,
ruleStakeholderInviteCopyUrl,
stakeholderInviteInviterLabel,
stakeholderInviteReplyTo,
} from "../../lib/server/ruleStakeholderInviteMail";
const VERIFY_URL =
"https://communityrule.info/api/invites/rule-stakeholder/verify?token=abc";
describe("stakeholder invite reply address", () => {
it("uses a mailbox as Reply-To and keeps a missing address out of the header", () => {
expect(stakeholderInviteReplyTo(" Maya@Example.org ")).toBe(
"maya@example.org",
);
expect(stakeholderInviteReplyTo(RULE_STAKEHOLDER_INVITER_FALLBACK)).toBe(
undefined,
);
expect(stakeholderInviteReplyTo("CommunityRule")).toBe(undefined);
});
it("names a missing inviter without turning the label into an address", () => {
expect(stakeholderInviteInviterLabel("")).toBe(
RULE_STAKEHOLDER_INVITER_FALLBACK,
);
expect(stakeholderInviteInviterLabel("Maya@Example.org")).toBe(
"maya@example.org",
);
const mail = buildRuleStakeholderInviteMail({
to: "rosa@example.org",
verifyUrl: VERIFY_URL,
origin: "https://communityrule.info",
communityName: "Juniper Street Food Share",
inviterEmail: RULE_STAKEHOLDER_INVITER_FALLBACK,
});
expect(mail.text).toContain(
`${RULE_STAKEHOLDER_INVITER_FALLBACK} sent the invitation.`,
);
});
});
describe("ruleStakeholderInviteCopyUrl", () => {
it("adds copy=1 to the invitation link", () => {
expect(ruleStakeholderInviteCopyUrl(VERIFY_URL)).toBe(
@@ -41,6 +41,7 @@ vi.mock("../../lib/server/rateLimit", () => ({
rateLimitKeyMock(key, minIntervalMs),
}));
import { RULE_STAKEHOLDER_INVITER_FALLBACK } from "../../lib/server/ruleStakeholderInviteMail";
import { POST } from "../../app/api/invites/rule-stakeholder/resend/route";
beforeEach(() => {
@@ -109,4 +110,39 @@ describe("POST /api/invites/rule-stakeholder/resend", () => {
}),
);
});
it("does not use a fake mailbox when the inviter and owner have no email", async () => {
findUniqueMock.mockResolvedValueOnce({
id: "st-1",
email: "inv@example.com",
inviteTokenHash: "hash-old",
inviteExpiresAt: new Date(Date.now() - 60_000),
invitedBy: null,
rule: {
title: "Juniper Street Food Share",
summary: null,
document: {},
user: { email: 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).toHaveBeenCalledWith(
expect.objectContaining({
inviterEmail: RULE_STAKEHOLDER_INVITER_FALLBACK,
}),
);
});
});
+49
View File
@@ -202,6 +202,55 @@ describe("POST /api/rules", () => {
});
});
it("keeps the rule when a later invite send fails after one succeeded", async () => {
const created = {
id: "rule-partial",
title: "Published title",
summary: null,
createdAt: new Date("2026-01-02T00:00:00.000Z"),
};
transactionMock.mockImplementation(
async (
fn: (tx: {
publishedRule: { create: typeof vi.fn };
ruleStakeholder: { create: typeof vi.fn };
}) => Promise<unknown>,
) => {
const tx = {
publishedRule: {
create: vi.fn().mockResolvedValue(created),
},
ruleStakeholder: {
create: vi.fn().mockResolvedValue({}),
},
};
return fn(tx);
},
);
sendInviteMock
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error("smtp down"));
const res = await POST(
new NextRequest("https://x.test/api/rules", {
method: "POST",
body: JSON.stringify({
title: "Published title",
summary: null,
document: {},
stakeholderEmails: ["first@example.com", "second@example.com"],
}),
}),
undefined,
);
expect(res.status).toBe(200);
expect(sendInviteMock).toHaveBeenCalledTimes(2);
expect(publishedRuleDeleteMock).not.toHaveBeenCalled();
const body = (await res.json()) as { rule: { id: string } };
expect(body.rule.id).toBe("rule-partial");
});
it("creates an unlisted rule when there is no session", async () => {
getSessionUserMock.mockResolvedValueOnce(null);
publishedRuleCreateMock.mockResolvedValueOnce({