Skip to content

Commit 89bda74

Browse files
Message service unit test (#71)
* Message test * refactor: improve message service tests with best practices --------- Co-authored-by: Kevin Rutledge <kevin.rutledge.89@gmail.com>
1 parent 2bc11e3 commit 89bda74

File tree

2 files changed

+320
-0
lines changed

2 files changed

+320
-0
lines changed

test/mocks/prisma.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,8 @@ beforeEach(() => {
1414
});
1515

1616
export const prismaMock = prisma as unknown as DeepMockProxy<PrismaClient>;
17+
18+
export function mockInteractiveTransaction() {
19+
prismaMock.$transaction.mockImplementation(((fn: (tx: typeof prismaMock) => Promise<unknown>) =>
20+
fn(prismaMock)) as never);
21+
}

test/services/message.test.ts

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
import { vi } from "vitest";
2+
import { prismaMock, mockInteractiveTransaction } from "../mocks/prisma";
3+
import { mockMembers } from "@/lib/mock-members";
4+
import { isQuietHours, validateSmsAllowed, sendGroupMessage, sendBlastMessage } from "@/services/message";
5+
import { AppError } from "@/utils/errors";
6+
import type { Message } from "@/generated/prisma/client";
7+
8+
// Test fixtures
9+
const testMessage: Message = {
10+
id: 1,
11+
groupId: 5,
12+
senderId: 100001,
13+
subject: "Test Group Message",
14+
body: "This is a test message for the group.",
15+
sentAt: new Date("2024-01-15T10:00:00Z"),
16+
emailCount: 3,
17+
smsCount: 0,
18+
failedCount: 0,
19+
isBlast: false,
20+
};
21+
22+
const testBlastMessage: Message = {
23+
id: 2,
24+
groupId: null,
25+
senderId: 100001,
26+
subject: "Test Blast Message",
27+
body: "This is a blast message to all members.",
28+
sentAt: new Date("2024-01-16T14:00:00Z"),
29+
emailCount: 389,
30+
smsCount: 0,
31+
failedCount: 0,
32+
isBlast: true,
33+
};
34+
35+
const envMock = vi.hoisted(() => ({
36+
SMS_ENABLED: false as boolean,
37+
FROM_EMAIL: "no-reply@prfc.coop",
38+
}));
39+
40+
// Mock modules
41+
vi.mock("@/services/contact-group", () => ({
42+
getGroupRecipients: vi.fn(),
43+
}));
44+
45+
vi.mock("@/services/email", () => ({
46+
sendGroupEmails: vi.fn(),
47+
}));
48+
49+
vi.mock("@/lib/api/member-api", () => ({
50+
getMemberDetails: vi.fn(),
51+
getAllActiveMemberIds: vi.fn(),
52+
}));
53+
54+
vi.mock("@/env", () => ({
55+
env: envMock,
56+
}));
57+
58+
import { getGroupRecipients } from "@/services/contact-group";
59+
import { sendGroupEmails } from "@/services/email";
60+
import { getMemberDetails, getAllActiveMemberIds } from "@/lib/api/member-api";
61+
62+
describe("isQuietHours", () => {
63+
beforeEach(() => {
64+
vi.useFakeTimers();
65+
});
66+
67+
afterEach(() => {
68+
vi.useRealTimers();
69+
});
70+
71+
it("returns true during quiet hours (9 PM Pacific)", () => {
72+
// 9 PM Pacific = 5 AM UTC next day (during PST)
73+
vi.setSystemTime(new Date("2024-01-15T05:00:00Z"));
74+
expect(isQuietHours()).toBe(true);
75+
});
76+
77+
it("returns false during business hours (10 AM Pacific)", () => {
78+
// 10 AM Pacific = 6 PM UTC (during PST)
79+
vi.setSystemTime(new Date("2024-01-15T18:00:00Z"));
80+
expect(isQuietHours()).toBe(false);
81+
});
82+
83+
it("returns false at exactly 8:00 AM Pacific (first non-quiet hour)", () => {
84+
// 8 AM Pacific = 4 PM UTC (during PST, UTC-8)
85+
vi.setSystemTime(new Date("2024-01-15T16:00:00Z"));
86+
expect(isQuietHours()).toBe(false);
87+
});
88+
89+
it("returns true at 7:59 AM Pacific (last quiet hour)", () => {
90+
// 7:59 AM Pacific = 3:59 PM UTC (during PST)
91+
vi.setSystemTime(new Date("2024-01-15T15:59:00Z"));
92+
expect(isQuietHours()).toBe(true);
93+
});
94+
95+
it("returns true at exactly 8:00 PM Pacific (first quiet hour)", () => {
96+
// 8 PM Pacific = 4 AM UTC next day (during PST)
97+
vi.setSystemTime(new Date("2024-01-16T04:00:00Z"));
98+
expect(isQuietHours()).toBe(true);
99+
});
100+
101+
it("returns false at 7:59 PM Pacific (last non-quiet hour)", () => {
102+
// 7:59 PM Pacific = 3:59 AM UTC next day (during PST)
103+
vi.setSystemTime(new Date("2024-01-16T03:59:00Z"));
104+
expect(isQuietHours()).toBe(false);
105+
});
106+
});
107+
108+
describe("validateSmsAllowed", () => {
109+
beforeEach(() => {
110+
vi.useFakeTimers();
111+
});
112+
113+
afterEach(() => {
114+
vi.useRealTimers();
115+
});
116+
117+
it("throws FORBIDDEN during quiet hours", () => {
118+
// 9 PM Pacific = 5 AM UTC next day (during PST)
119+
vi.setSystemTime(new Date("2024-01-15T05:00:00Z"));
120+
envMock.SMS_ENABLED = true;
121+
122+
expect.assertions(3);
123+
try {
124+
validateSmsAllowed();
125+
} catch (error) {
126+
expect(error).toBeInstanceOf(AppError);
127+
expect((error as AppError).code).toBe("FORBIDDEN");
128+
expect((error as AppError).context).toEqual({ reason: "QUIET_HOURS" });
129+
}
130+
});
131+
132+
it("throws FORBIDDEN when SMS_ENABLED=false", () => {
133+
// 10 AM Pacific = 6 PM UTC (during PST)
134+
vi.setSystemTime(new Date("2024-01-15T18:00:00Z"));
135+
envMock.SMS_ENABLED = false;
136+
137+
expect.assertions(3);
138+
try {
139+
validateSmsAllowed();
140+
} catch (error) {
141+
expect(error).toBeInstanceOf(AppError);
142+
expect((error as AppError).code).toBe("FORBIDDEN");
143+
expect((error as AppError).context).toEqual({ reason: "SMS_DISABLED" });
144+
}
145+
});
146+
});
147+
148+
describe("sendGroupMessage", () => {
149+
const testRecipients = [mockMembers[1], mockMembers[2], mockMembers[3]];
150+
const defaultInput = {
151+
groupId: 5,
152+
subject: "Test Subject",
153+
body: "Test Body",
154+
sendEmail: true,
155+
sendSms: false,
156+
};
157+
158+
beforeEach(() => {
159+
vi.clearAllMocks();
160+
envMock.SMS_ENABLED = false;
161+
mockInteractiveTransaction();
162+
});
163+
164+
it("creates Message record with correct data", async () => {
165+
vi.mocked(getGroupRecipients).mockResolvedValue([100002, 100003, 100004]);
166+
vi.mocked(getMemberDetails).mockResolvedValue(testRecipients);
167+
vi.mocked(sendGroupEmails).mockResolvedValue({ sent: 3, failed: 0, suppressed: 0 });
168+
prismaMock.message.create.mockResolvedValue({ ...testMessage, id: 1 });
169+
prismaMock.messageRecipient.createMany.mockResolvedValue({ count: 3 });
170+
prismaMock.messageRecipient.updateMany.mockResolvedValue({ count: 3 });
171+
prismaMock.message.update.mockResolvedValue(testMessage);
172+
173+
const result = await sendGroupMessage(defaultInput, 100001);
174+
175+
expect(result.messageId).toBe(1);
176+
expect(prismaMock.message.create).toHaveBeenCalledWith({
177+
data: {
178+
groupId: 5,
179+
senderId: 100001,
180+
subject: "Test Subject",
181+
body: "Test Body",
182+
emailCount: 3,
183+
smsCount: 0,
184+
failedCount: 0,
185+
isBlast: false,
186+
},
187+
});
188+
});
189+
190+
it("creates MessageRecipient records for email recipients", async () => {
191+
vi.mocked(getGroupRecipients).mockResolvedValue([100002, 100003, 100004]);
192+
vi.mocked(getMemberDetails).mockResolvedValue(testRecipients);
193+
vi.mocked(sendGroupEmails).mockResolvedValue({ sent: 3, failed: 0, suppressed: 0 });
194+
prismaMock.message.create.mockResolvedValue({ ...testMessage, id: 1 });
195+
prismaMock.messageRecipient.createMany.mockResolvedValue({ count: 3 });
196+
prismaMock.messageRecipient.updateMany.mockResolvedValue({ count: 3 });
197+
prismaMock.message.update.mockResolvedValue(testMessage);
198+
199+
await sendGroupMessage(defaultInput, 100001);
200+
201+
expect(prismaMock.messageRecipient.createMany).toHaveBeenCalledWith({
202+
data: [
203+
{ messageId: 1, memberId: 100002, channel: "email", status: "pending" },
204+
{ messageId: 1, memberId: 100003, channel: "email", status: "pending" },
205+
{ messageId: 1, memberId: 100004, channel: "email", status: "pending" },
206+
],
207+
});
208+
});
209+
210+
it("throws when no delivery method selected", async () => {
211+
await expect(sendGroupMessage({ ...defaultInput, sendEmail: false, sendSms: false }, 100001)).rejects.toMatchObject(
212+
{
213+
code: "VALIDATION_ERROR",
214+
},
215+
);
216+
});
217+
218+
it("throws when no recipients found", async () => {
219+
vi.mocked(getGroupRecipients).mockResolvedValue([]);
220+
221+
await expect(sendGroupMessage(defaultInput, 100001)).rejects.toMatchObject({
222+
code: "VALIDATION_ERROR",
223+
});
224+
});
225+
226+
it("updates failedCount when emails fail", async () => {
227+
vi.mocked(getGroupRecipients).mockResolvedValue([100002, 100003, 100004]);
228+
vi.mocked(getMemberDetails).mockResolvedValue(testRecipients);
229+
vi.mocked(sendGroupEmails).mockResolvedValue({ sent: 1, failed: 2, suppressed: 0 });
230+
prismaMock.message.create.mockResolvedValue({ ...testMessage, id: 1 });
231+
prismaMock.messageRecipient.createMany.mockResolvedValue({ count: 3 });
232+
prismaMock.messageRecipient.updateMany.mockResolvedValue({ count: 2 });
233+
prismaMock.message.update.mockResolvedValue({ ...testMessage, failedCount: 2 });
234+
235+
const result = await sendGroupMessage(defaultInput, 100001);
236+
237+
expect(prismaMock.message.update).toHaveBeenCalledWith({
238+
where: { id: 1 },
239+
data: { failedCount: 2 },
240+
});
241+
expect(result.failedCount).toBe(2);
242+
expect(result.emailCount).toBe(1);
243+
});
244+
});
245+
246+
describe("sendBlastMessage", () => {
247+
const allMemberIds = mockMembers.map((m) => m.ownerid);
248+
const defaultInput = {
249+
subject: "Blast Message",
250+
body: "This is a blast message",
251+
sendEmail: true,
252+
sendSms: false,
253+
confirmationText: "SEND TO ALL" as const,
254+
};
255+
256+
beforeEach(() => {
257+
vi.clearAllMocks();
258+
envMock.SMS_ENABLED = false;
259+
mockInteractiveTransaction();
260+
});
261+
262+
it("creates Message with isBlast=true and groupId=null", async () => {
263+
vi.mocked(getAllActiveMemberIds).mockResolvedValue(allMemberIds);
264+
vi.mocked(getMemberDetails).mockResolvedValue([...mockMembers]);
265+
vi.mocked(sendGroupEmails).mockResolvedValue({ sent: 389, failed: 0, suppressed: 0 });
266+
prismaMock.message.create.mockResolvedValue({ ...testBlastMessage, id: 2 });
267+
prismaMock.messageRecipient.createMany.mockResolvedValue({ count: 389 });
268+
prismaMock.messageRecipient.updateMany.mockResolvedValue({ count: 389 });
269+
prismaMock.message.update.mockResolvedValue(testBlastMessage);
270+
271+
const result = await sendBlastMessage(defaultInput, 100001);
272+
273+
expect(result.messageId).toBe(2);
274+
expect(prismaMock.message.create).toHaveBeenCalledWith({
275+
data: expect.objectContaining({
276+
isBlast: true,
277+
groupId: null,
278+
}),
279+
});
280+
});
281+
282+
it("sends to all active members", async () => {
283+
vi.mocked(getAllActiveMemberIds).mockResolvedValue(allMemberIds);
284+
vi.mocked(getMemberDetails).mockResolvedValue([...mockMembers]);
285+
vi.mocked(sendGroupEmails).mockResolvedValue({ sent: 389, failed: 0, suppressed: 0 });
286+
prismaMock.message.create.mockResolvedValue({ ...testBlastMessage, id: 2 });
287+
prismaMock.messageRecipient.createMany.mockResolvedValue({ count: 389 });
288+
prismaMock.messageRecipient.updateMany.mockResolvedValue({ count: 389 });
289+
prismaMock.message.update.mockResolvedValue(testBlastMessage);
290+
291+
await sendBlastMessage(defaultInput, 100001);
292+
293+
expect(getAllActiveMemberIds).toHaveBeenCalled();
294+
expect(getMemberDetails).toHaveBeenCalledWith(allMemberIds);
295+
});
296+
297+
it("updates failedCount when emails fail", async () => {
298+
vi.mocked(getAllActiveMemberIds).mockResolvedValue(allMemberIds);
299+
vi.mocked(getMemberDetails).mockResolvedValue([...mockMembers]);
300+
vi.mocked(sendGroupEmails).mockResolvedValue({ sent: 350, failed: 39, suppressed: 0 });
301+
prismaMock.message.create.mockResolvedValue({ ...testBlastMessage, id: 2 });
302+
prismaMock.messageRecipient.createMany.mockResolvedValue({ count: 389 });
303+
prismaMock.messageRecipient.updateMany.mockResolvedValue({ count: 39 });
304+
prismaMock.message.update.mockResolvedValue({ ...testBlastMessage, failedCount: 39 });
305+
306+
const result = await sendBlastMessage(defaultInput, 100001);
307+
308+
expect(prismaMock.message.update).toHaveBeenCalledWith({
309+
where: { id: 2 },
310+
data: { failedCount: 39 },
311+
});
312+
expect(result.failedCount).toBe(39);
313+
expect(result.emailCount).toBe(350);
314+
});
315+
});

0 commit comments

Comments
 (0)