sendou.ink/app/features/notifications/core/notify.server.test.ts
2026-07-28 17:13:02 +03:00

422 lines
10 KiB
TypeScript

import { beforeEach, describe, expect, test, vi } from "vitest";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { APP_ICON_URL } from "~/utils/urls";
import * as NotificationRepository from "../NotificationRepository.server";
import { clearSentNotificationsForTesting, notify } from "./notify.server";
const users = UserFactory.pool();
const { mockSendNotification, mockWebPushEnabled } = vi.hoisted(() => ({
mockSendNotification: vi.fn(),
mockWebPushEnabled: { value: false },
}));
vi.mock("./webPush.server", () => ({
get webPushEnabled() {
return mockWebPushEnabled.value;
},
default: {
sendNotification: mockSendNotification,
},
}));
describe("notify()", () => {
beforeEach(async () => {
await users.create(20);
clearSentNotificationsForTesting();
});
test("different recipients receive same notification", async () => {
await notify({
userIds: [users.id(1), users.id(2)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUsername: "alice" },
},
});
await notify({
userIds: [users.id(3), users.id(4)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUsername: "alice" },
},
});
const user1Notifications = await NotificationRepository.findByUserId(
users.id(1),
);
const user2Notifications = await NotificationRepository.findByUserId(
users.id(2),
);
const user3Notifications = await NotificationRepository.findByUserId(
users.id(3),
);
const user4Notifications = await NotificationRepository.findByUserId(
users.id(4),
);
expect(user1Notifications).toHaveLength(1);
expect(user2Notifications).toHaveLength(1);
expect(user3Notifications).toHaveLength(1);
expect(user4Notifications).toHaveLength(1);
expect(user1Notifications[0].type).toBe("SCRIM_NEW_REQUEST");
expect(user1Notifications[0].meta).toEqual({ fromUsername: "alice" });
});
test("same recipients and notification deduplicates", async () => {
await notify({
userIds: [users.id(5), users.id(6)],
notification: {
type: "BADGE_ADDED",
meta: { badgeName: "Test", badgeId: 1 },
},
});
await notify({
userIds: [users.id(5), users.id(6)],
notification: {
type: "BADGE_ADDED",
meta: { badgeName: "Test", badgeId: 1 },
},
});
const user5Notifications = await NotificationRepository.findByUserId(
users.id(5),
);
const user6Notifications = await NotificationRepository.findByUserId(
users.id(6),
);
expect(user5Notifications).toHaveLength(1);
expect(user6Notifications).toHaveLength(1);
});
test("user ID order doesn't affect deduplication", async () => {
await notify({
userIds: [users.id(7), users.id(8), users.id(9)],
notification: {
type: "SEASON_STARTED",
meta: { seasonNth: 1 },
},
});
await notify({
userIds: [users.id(9), users.id(7), users.id(8)],
notification: {
type: "SEASON_STARTED",
meta: { seasonNth: 1 },
},
});
const user7Notifications = await NotificationRepository.findByUserId(
users.id(7),
);
const user8Notifications = await NotificationRepository.findByUserId(
users.id(8),
);
const user9Notifications = await NotificationRepository.findByUserId(
users.id(9),
);
expect(user7Notifications).toHaveLength(1);
expect(user8Notifications).toHaveLength(1);
expect(user9Notifications).toHaveLength(1);
});
test("bulk notifications (>10 users) bypass deduplication", async () => {
const userIds = users.ids(11);
await notify({
userIds,
notification: {
type: "TO_CHECK_IN_OPENED",
meta: { tournamentId: 1, tournamentName: "Test Tournament" },
},
});
await notify({
userIds,
notification: {
type: "TO_CHECK_IN_OPENED",
meta: { tournamentId: 1, tournamentName: "Test Tournament" },
},
});
const user1Notifications = await NotificationRepository.findByUserId(
users.id(1),
);
const user11Notifications = await NotificationRepository.findByUserId(
users.id(11),
);
expect(user1Notifications).toHaveLength(2);
expect(user11Notifications).toHaveLength(2);
});
test("different notification types don't deduplicate", async () => {
await notify({
userIds: [users.id(10), users.id(11)],
notification: {
type: "SCRIM_SCHEDULED",
meta: { id: 1, opponentTeamName: "Alpha" },
},
});
await notify({
userIds: [users.id(10), users.id(11)],
notification: {
type: "SCRIM_CANCELED",
meta: { id: 1, opponentTeamName: "Alpha" },
},
});
const user10Notifications = await NotificationRepository.findByUserId(
users.id(10),
);
const user11Notifications = await NotificationRepository.findByUserId(
users.id(11),
);
expect(user10Notifications).toHaveLength(2);
expect(user11Notifications).toHaveLength(2);
const types = user10Notifications.map((n) => n.type).sort();
expect(types).toEqual(["SCRIM_CANCELED", "SCRIM_SCHEDULED"]);
});
test("different notification meta don't deduplicate", async () => {
await notify({
userIds: [users.id(12), users.id(13)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUsername: "bob" },
},
});
await notify({
userIds: [users.id(12), users.id(13)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUsername: "charlie" },
},
});
const user12Notifications = await NotificationRepository.findByUserId(
users.id(12),
);
const user13Notifications = await NotificationRepository.findByUserId(
users.id(13),
);
expect(user12Notifications).toHaveLength(2);
expect(user13Notifications).toHaveLength(2);
const metas = user12Notifications.map((n) => n.meta);
expect(metas).toContainEqual({ fromUsername: "bob" });
expect(metas).toContainEqual({ fromUsername: "charlie" });
});
test("duplicate user IDs in input array are deduplicated", async () => {
await notify({
userIds: [
users.id(14),
users.id(14),
users.id(15),
users.id(15),
users.id(15),
],
notification: {
type: "PLUS_VOTING_STARTED",
meta: { seasonNth: 2 },
},
});
const user14Notifications = await NotificationRepository.findByUserId(
users.id(14),
);
const user15Notifications = await NotificationRepository.findByUserId(
users.id(15),
);
expect(user14Notifications).toHaveLength(1);
expect(user15Notifications).toHaveLength(1);
});
});
describe("notify() - web push notifications", () => {
beforeEach(async () => {
await users.create(20);
clearSentNotificationsForTesting();
mockSendNotification.mockClear();
mockWebPushEnabled.value = false;
});
test("sends web push notification when user has subscription", async () => {
const mockSubscription = {
endpoint: "https://fcm.googleapis.com/fcm/send/test",
keys: {
auth: "test-auth-key",
p256dh: "test-p256dh-key",
},
};
vi.spyOn(
NotificationRepository,
"findAllSubscriptionsByUserIds",
).mockResolvedValue([
{
id: 1,
subscription: mockSubscription,
},
]);
mockWebPushEnabled.value = true;
await notify({
userIds: [users.id(1)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUsername: "alice" },
},
});
expect(mockSendNotification).toHaveBeenCalledTimes(1);
expect(mockSendNotification).toHaveBeenCalledWith(
mockSubscription,
expect.any(String),
{ urgency: "high" },
);
const callArgs = mockSendNotification.mock.calls[0][1];
const payload = JSON.parse(callArgs);
expect(payload.title).toBe("New Scrim Request");
expect(payload.body).toBe("alice requested a scrim");
expect(payload.data.url).toBe("/scrims");
expect(payload.icon).toBe(APP_ICON_URL);
});
test("sends web push to multiple subscriptions", async () => {
const mockSubscription1 = {
endpoint: "https://fcm.googleapis.com/fcm/send/test1",
keys: {
auth: "test-auth-key-1",
p256dh: "test-p256dh-key-1",
},
};
const mockSubscription2 = {
endpoint: "https://fcm.googleapis.com/fcm/send/test2",
keys: {
auth: "test-auth-key-2",
p256dh: "test-p256dh-key-2",
},
};
vi.spyOn(
NotificationRepository,
"findAllSubscriptionsByUserIds",
).mockResolvedValue([
{
id: 1,
subscription: mockSubscription1,
},
{
id: 2,
subscription: mockSubscription2,
},
]);
mockWebPushEnabled.value = true;
await notify({
userIds: [users.id(1), users.id(2)],
notification: {
type: "BADGE_ADDED",
meta: { badgeName: "Test", badgeId: 1 },
},
});
expect(mockSendNotification).toHaveBeenCalledTimes(2);
expect(mockSendNotification).toHaveBeenCalledWith(
mockSubscription1,
expect.any(String),
{ urgency: "normal" },
);
expect(mockSendNotification).toHaveBeenCalledWith(
mockSubscription2,
expect.any(String),
{ urgency: "normal" },
);
});
test("does not send web push when webPushEnabled is false", async () => {
const mockSubscription = {
endpoint: "https://fcm.googleapis.com/fcm/send/test",
keys: {
auth: "test-auth-key",
p256dh: "test-p256dh-key",
},
};
vi.spyOn(
NotificationRepository,
"findAllSubscriptionsByUserIds",
).mockResolvedValue([
{
id: 1,
subscription: mockSubscription,
},
]);
await notify({
userIds: [users.id(1)],
notification: {
type: "SCRIM_NEW_REQUEST",
meta: { fromUsername: "alice" },
},
});
expect(mockSendNotification).not.toHaveBeenCalled();
});
test("includes opponent team name for scrim notifications", async () => {
const mockSubscription = {
endpoint: "https://fcm.googleapis.com/fcm/send/test",
keys: {
auth: "test-auth-key",
p256dh: "test-p256dh-key",
},
};
vi.spyOn(
NotificationRepository,
"findAllSubscriptionsByUserIds",
).mockResolvedValue([
{
id: 1,
subscription: mockSubscription,
},
]);
mockWebPushEnabled.value = true;
await notify({
userIds: [users.id(1)],
notification: {
type: "SCRIM_SCHEDULED",
meta: { id: 1, opponentTeamName: "Sendou's pickup" },
},
});
expect(mockSendNotification).toHaveBeenCalledTimes(1);
const callArgs = mockSendNotification.mock.calls[0][1];
const payload = JSON.parse(callArgs);
expect(payload.title).toBe("Scrim Scheduled");
expect(payload.body).toBe("New scrim scheduled vs. Sendou's pickup");
});
});