2bec205a30
- Bell icon in icon registry (Phosphor Bell) - NotificationProvider context with polling (15s unread / 30s list) - useNotifications() hook with optimistic updates - NotificationCenter component: bell + badge + dropdown panel - NotificationItem component: severity icon, title, relative time, actions - AppShell integration: mount in header-actions, hidden on mobile - CSS styles: dropdown, items, unread/read states, empty state - formatRelativeTime utility (custom, no new deps) - 25 frontend tests (9 hook + 6 item + 10 center) Quality gates: vitest 25 passed, tsc clean, eslint clean
105 lines
2.5 KiB
TypeScript
105 lines
2.5 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
|
import { NotificationItem } from "./notification-item";
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
const makeNotification = (overrides?: Record<string, unknown>) => ({
|
|
id: "1",
|
|
user_id: "user-1",
|
|
category: "instance",
|
|
severity: "info" as const,
|
|
title: "Container started",
|
|
message: null,
|
|
source_type: null,
|
|
source_id: null,
|
|
metadata: {},
|
|
read_at: null,
|
|
dismissed_at: null,
|
|
created_at: "2026-05-29T10:00:00Z",
|
|
...overrides,
|
|
});
|
|
|
|
describe("NotificationItem", () => {
|
|
it("displays title and relative time", () => {
|
|
render(
|
|
<NotificationItem
|
|
notification={makeNotification()}
|
|
onMarkRead={vi.fn()}
|
|
onDismiss={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Container started")).toBeInTheDocument();
|
|
expect(screen.getByText(/ago|just now/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("applies unread styling when read_at is null", () => {
|
|
render(
|
|
<NotificationItem
|
|
notification={makeNotification({ read_at: null })}
|
|
onMarkRead={vi.fn()}
|
|
onDismiss={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const row = screen.getByRole("listitem");
|
|
expect(row.className).toContain("notification-item--unread");
|
|
});
|
|
|
|
it("applies read styling when read_at is set", () => {
|
|
render(
|
|
<NotificationItem
|
|
notification={makeNotification({ read_at: "2026-05-29T10:01:00Z" })}
|
|
onMarkRead={vi.fn()}
|
|
onDismiss={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const row = screen.getByRole("listitem");
|
|
expect(row.className).toContain("notification-item--read");
|
|
});
|
|
|
|
it("calls onMarkRead when mark read clicked", () => {
|
|
const onMarkRead = vi.fn();
|
|
render(
|
|
<NotificationItem
|
|
notification={makeNotification()}
|
|
onMarkRead={onMarkRead}
|
|
onDismiss={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /mark read/i }));
|
|
expect(onMarkRead).toHaveBeenCalledWith("1");
|
|
});
|
|
|
|
it("calls onDismiss when dismiss clicked", () => {
|
|
const onDismiss = vi.fn();
|
|
render(
|
|
<NotificationItem
|
|
notification={makeNotification()}
|
|
onMarkRead={vi.fn()}
|
|
onDismiss={onDismiss}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /dismiss/i }));
|
|
expect(onDismiss).toHaveBeenCalledWith("1");
|
|
});
|
|
|
|
it("displays severity icon", () => {
|
|
render(
|
|
<NotificationItem
|
|
notification={makeNotification({ severity: "error" })}
|
|
onMarkRead={vi.fn()}
|
|
onDismiss={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByRole("img", { hidden: true })).toBeInTheDocument();
|
|
});
|
|
});
|