Files
headquarter/apps/web/src/components/event-toast-bridge.test.tsx
T
alex 19242b4152 feat: notification center toast coordination (PR-4)
- EventToastBridge checks notification_toast_level and notification_mute_categories
- toast-rules.ts: event-to-category/severity mapping functions
- Settings page: notification preferences section (toast level dropdown, mute checkboxes)
- Settings API types extended with notification preference fields
- 17 frontend tests (toast-rules + bridge)
- Preference hierarchy: mute categories → toast level → show/hide

Quality gates: vitest 17 passed, tsc clean, eslint clean
2026-05-29 13:52:49 +02:00

221 lines
6.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act } from "@testing-library/react";
import { EventToastBridge } from "./event-toast-bridge";
import { useEventContext } from "../state/events";
import { getUserConfig } from "../api/settings";
import { handleEventToast } from "./toast-rules";
import type { InstanceEventPayload } from "../types/events";
vi.mock("../state/events", () => ({
useEventContext: vi.fn(),
}));
vi.mock("../api/settings", () => ({
getUserConfig: vi.fn(),
}));
vi.mock("./toast-rules", async (importOriginal) => {
const actual = await importOriginal<typeof import("./toast-rules")>();
return {
...actual,
handleEventToast: vi.fn(),
clearToastDedup: vi.fn(),
};
});
const mockedUseEventContext = vi.mocked(useEventContext);
const mockedGetUserConfig = vi.mocked(getUserConfig);
const mockedHandleEventToast = vi.mocked(handleEventToast);
function makeEvent(
eventType: string,
overrides?: Partial<InstanceEventPayload>,
): InstanceEventPayload {
return {
event: eventType,
instance_id: "i-1",
status: undefined,
message: undefined,
metadata: {},
timestamp: "2026-05-29T10:00:00Z",
correlation_id: "c1",
...overrides,
};
}
async function flushPromises() {
await act(async () => {
await Promise.resolve();
});
}
describe("EventToastBridge preference checks", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedUseEventContext.mockReturnValue({
events: [],
connected: false,
reconnectCount: 0,
});
mockedGetUserConfig.mockResolvedValue({
theme: "system",
default_editor: null,
git_user_name: null,
git_user_email: null,
last_session_id: null,
notification_toast_level: "all",
notification_mute_categories: [],
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("shows toast when level is all and category not muted", async () => {
const event = makeEvent("instance.started");
mockedUseEventContext.mockReturnValue({
events: [event],
connected: false,
reconnectCount: 0,
});
render(<EventToastBridge />);
await flushPromises();
expect(mockedHandleEventToast).toHaveBeenCalledWith(event);
});
it("suppresses toast when level is none", async () => {
mockedGetUserConfig.mockResolvedValue({
notification_toast_level: "none",
notification_mute_categories: [],
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
const event = makeEvent("instance.started");
mockedUseEventContext.mockReturnValue({
events: [event],
connected: false,
reconnectCount: 0,
});
render(<EventToastBridge />);
await flushPromises();
expect(mockedHandleEventToast).not.toHaveBeenCalled();
});
it("suppresses info toast when level is errors", async () => {
mockedGetUserConfig.mockResolvedValue({
notification_toast_level: "errors",
notification_mute_categories: [],
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
const event = makeEvent("instance.started");
mockedUseEventContext.mockReturnValue({
events: [event],
connected: false,
reconnectCount: 0,
});
render(<EventToastBridge />);
await flushPromises();
expect(mockedHandleEventToast).not.toHaveBeenCalled();
});
it("shows error toast when level is errors", async () => {
mockedGetUserConfig.mockResolvedValue({
notification_toast_level: "errors",
notification_mute_categories: [],
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
const event = makeEvent("instance.error");
mockedUseEventContext.mockReturnValue({
events: [event],
connected: false,
reconnectCount: 0,
});
render(<EventToastBridge />);
await flushPromises();
expect(mockedHandleEventToast).toHaveBeenCalledWith(event);
});
it("suppresses toast when category is muted", async () => {
mockedGetUserConfig.mockResolvedValue({
notification_toast_level: "all",
notification_mute_categories: ["instance"],
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
const event = makeEvent("instance.started");
mockedUseEventContext.mockReturnValue({
events: [event],
connected: false,
reconnectCount: 0,
});
render(<EventToastBridge />);
await flushPromises();
expect(mockedHandleEventToast).not.toHaveBeenCalled();
});
it("applies preference change immediately via custom event", async () => {
const event1 = makeEvent("instance.started");
mockedUseEventContext.mockReturnValue({
events: [event1],
connected: false,
reconnectCount: 0,
});
const { rerender } = render(<EventToastBridge />);
await flushPromises();
expect(mockedHandleEventToast).toHaveBeenCalledTimes(1);
act(() => {
window.dispatchEvent(
new CustomEvent("userconfig:updated", {
detail: { notification_toast_level: "none" },
}),
);
});
const event2 = makeEvent("instance.started");
mockedUseEventContext.mockReturnValue({
events: [event1, event2],
connected: false,
reconnectCount: 0,
});
rerender(<EventToastBridge />);
await flushPromises();
expect(mockedHandleEventToast).toHaveBeenCalledTimes(1);
});
it("muted category overrides all level", async () => {
mockedGetUserConfig.mockResolvedValue({
notification_toast_level: "all",
notification_mute_categories: ["instance"],
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
const event = makeEvent("instance.error");
mockedUseEventContext.mockReturnValue({
events: [event],
connected: false,
reconnectCount: 0,
});
render(<EventToastBridge />);
await flushPromises();
expect(mockedHandleEventToast).not.toHaveBeenCalled();
});
it("deduplication still works with preferences", async () => {
const event = makeEvent("instance.started");
mockedUseEventContext.mockReturnValue({
events: [event, event],
connected: false,
reconnectCount: 0,
});
render(<EventToastBridge />);
await flushPromises();
expect(mockedHandleEventToast).toHaveBeenCalledTimes(1);
});
it("unmapped event defaults to system/info and shows when level is all", async () => {
const event = makeEvent("system.announcement");
mockedUseEventContext.mockReturnValue({
events: [event],
connected: false,
reconnectCount: 0,
});
render(<EventToastBridge />);
await flushPromises();
expect(mockedHandleEventToast).toHaveBeenCalledWith(event);
});
});