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) => { const translations: Record = { "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(); 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(); 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( ); 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(); 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(); 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( ); 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(); 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(); 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(); 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( ); 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]"); }); });