Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/components/notifications/NotificationCenter.test.tsx
T

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]");
});
});