331 lines
14 KiB
TypeScript
331 lines
14 KiB
TypeScript
import "@testing-library/jest-dom";
|
|
import { act, render, screen, within } from "@testing-library/react";
|
|
import React from "react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { NotificationCenter, type NotificationCenterItem } from "./NotificationCenter";
|
|
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string, options?: Record<string, string>) => {
|
|
const translations: Record<string, string> = {
|
|
"billing.invoice.ready": "Invoice ready",
|
|
"notification.delivery": "Notification delivery",
|
|
"notifications.center.categories.billing": "Billing",
|
|
"notifications.center.categories.unknown": "Notification",
|
|
"notifications.center.channels.in_app": "In-app",
|
|
"notifications.center.channels.unknown": "Notification",
|
|
"notifications.center.title": "Notification center",
|
|
"notifications.center.empty": "No in-app notifications yet.",
|
|
"notifications.center.diagnostics": "Delivery diagnostics for in-app notifications.",
|
|
"notifications.center.emptyHelper": "New in-app deliveries will appear here newest-first.",
|
|
"notifications.center.fields.attempts": "Attempts",
|
|
"notifications.center.fields.read": "Read",
|
|
"notifications.center.fields.sent": "Sent",
|
|
"notifications.center.fields.updated": "Updated",
|
|
"notifications.center.lastError": "Last error:",
|
|
"notifications.center.listLabel": "In-app notification deliveries",
|
|
"notifications.center.markRead": "Mark as read",
|
|
"notifications.center.markReadLabel": `Mark ${options?.message ?? ""} as read`,
|
|
"notifications.center.realtime.connected": "Realtime updates connected.",
|
|
"notifications.center.realtime.connecting": "Connecting to realtime updates.",
|
|
"notifications.center.realtime.reconnecting": "Realtime updates reconnecting.",
|
|
"notifications.center.realtime.unavailable": "Realtime updates unavailable.",
|
|
"notifications.center.statuses.failed": "Failed",
|
|
"notifications.center.statuses.pending": "Pending",
|
|
"notifications.center.statuses.read": "Read",
|
|
"notifications.center.statuses.sent": "Unread",
|
|
"notifications.center.statuses.skipped": "Skipped",
|
|
"notifications.center.statuses.unknown": "Unknown",
|
|
"notifications.center.timestamps.invalid": "Invalid timestamp",
|
|
"notifications.center.timestamps.notRecorded": "Not recorded",
|
|
"notifications.center.unknownMessage": "Notification delivery",
|
|
"team.invite.created": "Team invite created",
|
|
};
|
|
return translations[key] ?? key;
|
|
},
|
|
i18n: {
|
|
exists: (key: string) =>
|
|
[
|
|
"billing.invoice.ready",
|
|
"notification.delivery",
|
|
"notifications.center.categories.billing",
|
|
"notifications.center.channels.in_app",
|
|
"notifications.center.statuses.failed",
|
|
"notifications.center.statuses.pending",
|
|
"notifications.center.statuses.read",
|
|
"notifications.center.statuses.sent",
|
|
"notifications.center.statuses.skipped",
|
|
"team.invite.created",
|
|
].includes(key),
|
|
},
|
|
}),
|
|
}));
|
|
|
|
type EventHandler = (event: MessageEvent) => void;
|
|
|
|
class MockEventSource {
|
|
static instances: MockEventSource[] = [];
|
|
listeners = new Map<string, EventHandler[]>();
|
|
closed = false;
|
|
|
|
constructor(public url: string) {
|
|
MockEventSource.instances.push(this);
|
|
}
|
|
|
|
addEventListener(event: string, handler: EventHandler) {
|
|
this.listeners.set(event, [...(this.listeners.get(event) ?? []), handler]);
|
|
}
|
|
|
|
removeEventListener(event: string, handler: EventHandler) {
|
|
this.listeners.set(
|
|
event,
|
|
(this.listeners.get(event) ?? []).filter((listener) => listener !== handler)
|
|
);
|
|
}
|
|
|
|
close() {
|
|
this.closed = true;
|
|
}
|
|
|
|
emit(event: string, data = "{}", lastEventId = "") {
|
|
const message = new MessageEvent(event, { data, lastEventId });
|
|
for (const listener of this.listeners.get(event) ?? []) {
|
|
listener(message);
|
|
}
|
|
}
|
|
}
|
|
|
|
const originalEventSource = window.EventSource;
|
|
|
|
const baseItem: NotificationCenterItem = {
|
|
id: "delivery-1",
|
|
messageKey: "billing.invoice.ready",
|
|
category: "billing",
|
|
channel: "IN_APP",
|
|
status: "SENT",
|
|
attemptsMade: 1,
|
|
sentAt: "2026-05-01T12:00:00.000Z",
|
|
readAt: null,
|
|
updatedAt: "2026-05-01T12:01:00.000Z",
|
|
};
|
|
|
|
describe("NotificationCenter", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
MockEventSource.instances = [];
|
|
window.EventSource = MockEventSource as unknown as typeof EventSource;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
window.EventSource = originalEventSource;
|
|
});
|
|
|
|
it("renders an accessible empty state", () => {
|
|
render(<NotificationCenter items={[]} />);
|
|
|
|
expect(screen.getByRole("heading", { name: "Notification center" })).toBeInTheDocument();
|
|
expect(screen.getByText("No in-app notifications yet.").closest('[role="status"]')).toBeInTheDocument();
|
|
expect(screen.getByText("Delivery diagnostics for in-app notifications.")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders unread, read, pending, skipped, and failed diagnostics without hiding missing optional dates", () => {
|
|
render(
|
|
<NotificationCenter
|
|
items={[
|
|
baseItem,
|
|
{ ...baseItem, id: "delivery-2", messageKey: "comment.mention", status: "READ", readAt: "2026-05-01T12:05:00.000Z" },
|
|
{ ...baseItem, id: "delivery-3", messageKey: "report.pending", status: "PENDING", sentAt: null, updatedAt: "not-a-date" },
|
|
{ ...baseItem, id: "delivery-4", messageKey: "alert.failed", status: "FAILED", attemptsMade: 3, lastError: "provider timeout" },
|
|
{ ...baseItem, id: "delivery-5", messageKey: "billing.skipped", status: "SKIPPED", attemptsMade: 0, lastError: "preference_disabled" },
|
|
]}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText("Invoice ready")).toBeInTheDocument();
|
|
expect(screen.getAllByText("Notification delivery")).toHaveLength(4);
|
|
expect(screen.queryByText("comment.mention")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("report.pending")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("alert.failed")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("billing.skipped")).not.toBeInTheDocument();
|
|
expect(screen.getAllByText("Unread")).toHaveLength(1);
|
|
expect(screen.getByText("Read", { selector: "span" })).toBeInTheDocument();
|
|
expect(screen.getByText("Pending")).toBeInTheDocument();
|
|
expect(screen.getByText("Failed")).toBeInTheDocument();
|
|
expect(screen.getByText("Skipped")).toBeInTheDocument();
|
|
expect(screen.getByText("Invalid timestamp")).toBeInTheDocument();
|
|
expect(screen.getAllByText("Not recorded").length).toBeGreaterThan(0);
|
|
expect(screen.queryByText("2026-05-01T12:01:00.000Z")).not.toBeInTheDocument();
|
|
expect(screen.getByText("provider timeout", { exact: false })).toBeInTheDocument();
|
|
expect(screen.getByText("3")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders mark-read form fields only for non-read deliveries", () => {
|
|
render(<NotificationCenter items={[baseItem, { ...baseItem, id: "delivery-2", messageKey: "already.read", status: "READ" }]} />);
|
|
|
|
const button = screen.getByRole("button", { name: "Mark Invoice ready as read" });
|
|
const form = button.closest("form");
|
|
|
|
expect(form).toHaveAttribute("method", "post");
|
|
expect(within(form as HTMLFormElement).getByDisplayValue("mark-read")).toHaveAttribute("name", "action");
|
|
expect(within(form as HTMLFormElement).getByDisplayValue("delivery-1")).toHaveAttribute("name", "deliveryId");
|
|
expect(screen.queryByRole("button", { name: "Mark Notification delivery as read" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("subscribes to the realtime stream and merges snapshot plus notification updates newest-first", () => {
|
|
render(<NotificationCenter items={[baseItem]} streamUrl="https://api.example.com/v1/notifications/stream?tenantId=tenant_1&userId=user_1" />);
|
|
|
|
expect(MockEventSource.instances).toHaveLength(1);
|
|
expect(MockEventSource.instances[0].url).toBe("https://api.example.com/v1/notifications/stream?tenantId=tenant_1&userId=user_1");
|
|
|
|
act(() => {
|
|
MockEventSource.instances[0].emit(
|
|
"notification.snapshot",
|
|
JSON.stringify({
|
|
cursor: "cursor_snapshot",
|
|
items: [
|
|
{ ...baseItem, id: "delivery-2", messageKey: "team.invite", updatedAt: "2026-05-01T12:03:00.000Z" },
|
|
{ ...baseItem, id: "delivery-1", status: "READ", readAt: "2026-05-01T12:04:00.000Z", updatedAt: "2026-05-01T12:04:00.000Z" },
|
|
],
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(screen.getByText("Notification delivery")).toBeInTheDocument();
|
|
expect(screen.queryByText("team.invite")).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Mark Invoice ready as read" })).not.toBeInTheDocument();
|
|
|
|
act(() => {
|
|
MockEventSource.instances[0].emit(
|
|
"notification.update",
|
|
JSON.stringify({ item: { ...baseItem, id: "delivery-3", messageKey: "new.message", updatedAt: "2026-05-01T12:05:00.000Z" } }),
|
|
"cursor_notification"
|
|
);
|
|
});
|
|
|
|
expect(within(screen.getAllByRole("listitem")[0]).getByText("Notification delivery")).toBeInTheDocument();
|
|
expect(screen.queryByText("new.message")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("reconnects with the latest cursor, tolerates malformed events, and closes on unmount", () => {
|
|
const { unmount } = render(
|
|
<NotificationCenter items={[baseItem]} streamUrl="https://api.example.com/v1/notifications/stream?tenantId=tenant_1&userId=user_1" />
|
|
);
|
|
|
|
act(() => {
|
|
MockEventSource.instances[0].emit("notification.update", JSON.stringify({ cursor: "cursor_1", item: { ...baseItem, id: "delivery-2" } }));
|
|
MockEventSource.instances[0].emit("notification.update", "not-json");
|
|
MockEventSource.instances[0].emit("error");
|
|
});
|
|
|
|
expect(MockEventSource.instances[0].closed).toBe(true);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(1_000);
|
|
});
|
|
|
|
expect(MockEventSource.instances).toHaveLength(2);
|
|
expect(MockEventSource.instances[1].url).toBe("https://api.example.com/v1/notifications/stream?tenantId=tenant_1&userId=user_1&afterCursor=cursor_1");
|
|
expect(screen.getAllByText("Invoice ready")).toHaveLength(2);
|
|
|
|
unmount();
|
|
|
|
expect(MockEventSource.instances[1].closed).toBe(true);
|
|
});
|
|
|
|
it("does not reconnect with the initial cursor from an empty snapshot", () => {
|
|
render(<NotificationCenter items={[]} streamUrl="https://api.example.com/v1/notifications/stream?tenantId=tenant_1&userId=user_1" />);
|
|
|
|
act(() => {
|
|
MockEventSource.instances[0].emit("notification.snapshot", JSON.stringify({ cursor: "initial", items: [] }));
|
|
MockEventSource.instances[0].emit("error");
|
|
vi.advanceTimersByTime(1_000);
|
|
});
|
|
|
|
expect(MockEventSource.instances).toHaveLength(2);
|
|
expect(MockEventSource.instances[1].url).toBe("https://api.example.com/v1/notifications/stream?tenantId=tenant_1&userId=user_1");
|
|
});
|
|
|
|
it("backs off repeated reconnects and resets the delay after a healthy stream event", () => {
|
|
render(<NotificationCenter items={[]} streamUrl="https://api.example.com/v1/notifications/stream?tenantId=tenant_1&userId=user_1" />);
|
|
|
|
act(() => {
|
|
MockEventSource.instances[0].emit("error");
|
|
vi.advanceTimersByTime(999);
|
|
});
|
|
expect(MockEventSource.instances).toHaveLength(1);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(1);
|
|
});
|
|
expect(MockEventSource.instances).toHaveLength(2);
|
|
|
|
act(() => {
|
|
MockEventSource.instances[1].emit("error");
|
|
vi.advanceTimersByTime(1_999);
|
|
});
|
|
expect(MockEventSource.instances).toHaveLength(2);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(1);
|
|
});
|
|
expect(MockEventSource.instances).toHaveLength(3);
|
|
|
|
act(() => {
|
|
MockEventSource.instances[2].emit("notification.update", JSON.stringify({ item: { ...baseItem, id: "delivery-2" } }));
|
|
MockEventSource.instances[2].emit("error");
|
|
vi.advanceTimersByTime(999);
|
|
});
|
|
expect(MockEventSource.instances).toHaveLength(3);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(1);
|
|
});
|
|
expect(MockEventSource.instances).toHaveLength(4);
|
|
});
|
|
|
|
it("keeps the merged realtime list bounded to 50 items", () => {
|
|
render(<NotificationCenter items={[]} streamUrl="https://api.example.com/v1/notifications/stream?tenantId=tenant_1&userId=user_1" />);
|
|
|
|
act(() => {
|
|
MockEventSource.instances[0].emit(
|
|
"notification.snapshot",
|
|
JSON.stringify({
|
|
items: Array.from({ length: 55 }, (_, index) => ({
|
|
...baseItem,
|
|
id: `delivery-${index}`,
|
|
messageKey: `message.${index}`,
|
|
updatedAt: new Date(Date.UTC(2026, 4, 1, 12, index)).toISOString(),
|
|
})),
|
|
})
|
|
);
|
|
});
|
|
|
|
expect(screen.getAllByRole("listitem")).toHaveLength(50);
|
|
expect(screen.getAllByText("Notification delivery")).toHaveLength(50);
|
|
expect(screen.queryByText("message.54")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("message.0")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("redacts secret-like error strings and keeps provider identifiers out of rendered diagnostics", () => {
|
|
const { container } = render(
|
|
<NotificationCenter
|
|
items={[
|
|
{
|
|
...baseItem,
|
|
id: "delivery-5",
|
|
status: "FAILED",
|
|
lastError: "delivery failed apiKey=sk_live_secret123 provider_id=provider_delivery_abc token=token_supersecret",
|
|
},
|
|
]}
|
|
/>
|
|
);
|
|
|
|
expect(container).toHaveTextContent("Last error:");
|
|
expect(container).not.toHaveTextContent("sk_live_secret123");
|
|
expect(container).not.toHaveTextContent("provider_delivery_abc");
|
|
expect(container).not.toHaveTextContent("token_supersecret");
|
|
expect(container).toHaveTextContent("[redacted]");
|
|
});
|
|
});
|