feat: T4 wave 6 - reminders and morning digest, next steps UI, warnings and notifications UI
- In-app reminders: task due (skips rescheduled tasks, catch-up wording) and a per-user morning digest step in the daily job - Frontend: "Als Nächstes" on the colony page, "Heute" on the dashboard, inspection-due card, season chip, apiary overview fields, cleanup sheet for ended colonies - Frontend: plausibility warnings that stay until dismissed, withdrawal spans in the calendar, notification bell with categories and links, notification settings in the profile - ARCHITECTURE.md: T4a sections 11.1-11.10, assumptions 31-41, handler table and daily job step order Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
ef33e5549b
commit
32c61c8e14
77 files changed
+5309
-142
No files matched your search
@@ -0,0 +1,164 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { NotificationBell } from "./notification-bell";
|
||||
import {
|
||||
fetchNotifications,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
} from "@/lib/notifications/api";
|
||||
import type { Notification } from "@/lib/notifications/types";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
vi.mock("@/lib/notifications/api", () => ({
|
||||
fetchNotifications: vi.fn(),
|
||||
markNotificationRead: vi.fn(),
|
||||
markAllNotificationsRead: vi.fn(),
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||
|
||||
function notification(overrides: Partial<Notification>): Notification {
|
||||
return {
|
||||
id: "n-1",
|
||||
priority: "WARNUNG",
|
||||
category: "WITHDRAWAL",
|
||||
message: "Wartezeit-Konflikt bei B3",
|
||||
relatedType: null,
|
||||
relatedId: null,
|
||||
link: null,
|
||||
read: false,
|
||||
createdAt: "2026-08-01T10:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderBell() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<NotificationBell />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
let invalidate: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
describe("NotificationBell", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(markAllNotificationsRead).mockReset().mockResolvedValue({ updated: 2 });
|
||||
vi.mocked(markNotificationRead).mockReset().mockResolvedValue({} as never);
|
||||
vi.mocked(toast.error).mockReset();
|
||||
vi.mocked(toast.success).mockReset();
|
||||
});
|
||||
|
||||
it("„Alle gelesen“ calls read-all and a notification with link navigates there", async () => {
|
||||
vi.mocked(fetchNotifications).mockResolvedValue([
|
||||
notification({ id: "n-1", link: "/harvests/h-1" }),
|
||||
notification({ id: "n-2", message: "Ohne Ziel", relatedType: "Colony", relatedId: "c-9" }),
|
||||
]);
|
||||
renderBell();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Benachrichtigungen" }));
|
||||
|
||||
const linked = await screen.findByText("Wartezeit-Konflikt bei B3");
|
||||
expect(linked.closest("a")).toHaveAttribute("href", "/harvests/h-1");
|
||||
expect(screen.getByText("Ohne Ziel").closest("a")).toHaveAttribute("href", "/colonies/c-9");
|
||||
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: /Alle gelesen/ }));
|
||||
await waitFor(() => expect(markAllNotificationsRead).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("ignores a link that is not an internal path", async () => {
|
||||
vi.mocked(fetchNotifications).mockResolvedValue([
|
||||
notification({ link: "//evil.example/x" }),
|
||||
notification({ id: "n-2", message: "Schema", link: "https://evil.example" }),
|
||||
]);
|
||||
renderBell();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Benachrichtigungen" }));
|
||||
expect((await screen.findByText("Wartezeit-Konflikt bei B3")).closest("a")).toBeNull();
|
||||
expect(screen.getByText("Schema").closest("a")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides „Alle gelesen“ when nothing is unread and names the category in words", async () => {
|
||||
vi.mocked(fetchNotifications).mockResolvedValue([notification({ read: true })]);
|
||||
renderBell();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Benachrichtigungen" }));
|
||||
expect(await screen.findByText("Wartezeit")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: /Alle gelesen/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking an unread notification marks it read and its link navigates", async () => {
|
||||
vi.mocked(fetchNotifications).mockResolvedValue([notification({ id: "n-7", link: "/harvests/h-7" })]);
|
||||
renderBell();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ }));
|
||||
const text = await screen.findByText("Wartezeit-Konflikt bei B3");
|
||||
expect(text.closest("a")).toHaveAttribute("href", "/harvests/h-7");
|
||||
await userEvent.click(text);
|
||||
await waitFor(() => expect(markNotificationRead).toHaveBeenCalled());
|
||||
expect(vi.mocked(markNotificationRead).mock.calls[0][0]).toBe("n-7");
|
||||
});
|
||||
|
||||
it("a 404 on mark-read refreshes the list without an error toast", async () => {
|
||||
vi.mocked(fetchNotifications).mockResolvedValue([notification({})]);
|
||||
vi.mocked(markNotificationRead).mockRejectedValue(new ApiError(404, "Not found"));
|
||||
renderBell();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ }));
|
||||
await userEvent.click(await screen.findByText("Wartezeit-Konflikt bei B3"));
|
||||
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ["notifications"] }));
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("another mark-read error shows one toast with a fixed id", async () => {
|
||||
vi.mocked(fetchNotifications).mockResolvedValue([notification({})]);
|
||||
vi.mocked(markNotificationRead).mockRejectedValue(new ApiError(500, "boom"));
|
||||
renderBell();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ }));
|
||||
await userEvent.click(await screen.findByText("Wartezeit-Konflikt bei B3"));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(toast.error).mock.calls[0][1]).toEqual({ id: "notification-read-error" });
|
||||
});
|
||||
|
||||
it("the badge is announced with the unread count, the digit is hidden from readers", async () => {
|
||||
vi.mocked(fetchNotifications).mockResolvedValue([notification({}), notification({ id: "n-2" })]);
|
||||
renderBell();
|
||||
expect(await screen.findByText("2 ungelesene Benachrichtigungen")).toHaveClass("sr-only");
|
||||
expect(screen.getByText("2", { selector: "[aria-hidden='true']" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("read-all confirms with a toast and refreshes", async () => {
|
||||
vi.mocked(fetchNotifications).mockResolvedValue([notification({})]);
|
||||
renderBell();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ }));
|
||||
await userEvent.click(await screen.findByRole("menuitem", { name: /Alle gelesen/ }));
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Alle als gelesen markiert."));
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["notifications"] });
|
||||
});
|
||||
|
||||
it("read-all refreshes the list on failure too", async () => {
|
||||
vi.mocked(fetchNotifications).mockResolvedValue([notification({})]);
|
||||
vi.mocked(markAllNotificationsRead).mockRejectedValue(new ApiError(500, "x"));
|
||||
renderBell();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ }));
|
||||
await userEvent.click(await screen.findByRole("menuitem", { name: /Alle gelesen/ }));
|
||||
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ["notifications"] }));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/harvests/h-1?tab=a&x=1", true],
|
||||
["/\t/x", false],
|
||||
["//evil", false],
|
||||
["/\\evil", false],
|
||||
["https://evil.example", false],
|
||||
["/a b", false],
|
||||
[`/${"a".repeat(300)}`, false],
|
||||
])("link %j is internal: %s", async (link, internal) => {
|
||||
vi.mocked(fetchNotifications).mockResolvedValue([notification({ link })]);
|
||||
renderBell();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Benachrichtigungen/ }));
|
||||
const anchor = (await screen.findByText("Wartezeit-Konflikt bei B3")).closest("a");
|
||||
if (internal) expect(anchor).toHaveAttribute("href", link);
|
||||
else expect(anchor).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user