Files
EpicNext-Cms/src/app/api/notifications/route.test.ts
T
Simo 1eee6661f9
CI / check (push) Failing after 1m18s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped
feat(notifications): add account-scoped inbox and persistent preferences
2026-09-13 17:59:22 +02:00

118 lines
3.7 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
auth: vi.fn(),
feed: vi.fn(),
mark: vi.fn(),
preferences: vi.fn(),
}));
vi.mock("@/lib/auth", () => ({ auth: mocks.auth }));
vi.mock("@/features/notifications/server", () => ({ notifications: mocks }));
vi.mock("@/lib/rate-limit", () => ({ rateLimit: async () => ({ ok: true }) }));
import { GET, POST } from "./route";
function request(body: unknown, origin = "https://hotel.test") {
return new Request("https://hotel.test/api/notifications", {
method: "POST",
headers: { origin, "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
mocks.auth.mockResolvedValue({ user: { id: "7" } });
mocks.feed.mockResolvedValue({ items: [] });
mocks.mark.mockResolvedValue(true);
});
describe("notification route authorization", () => {
it("rejects anonymous reads and writes without accessing data", async () => {
mocks.auth.mockResolvedValue(null);
expect(
(await GET(new Request("https://hotel.test/api/notifications"))).status,
).toBe(401);
expect(
(await POST(request({ action: "read", key: "support:1" }))).status,
).toBe(401);
expect(mocks.feed).not.toHaveBeenCalled();
expect(mocks.mark).not.toHaveBeenCalled();
});
it("uses the session identity even when a query supplies another account", async () => {
await GET(
new Request("https://hotel.test/api/notifications?userId=99&page=2"),
);
expect(mocks.feed).toHaveBeenCalledWith(7, 2);
});
it("rejects cross-origin mutations", async () => {
expect(
(
await POST(
request(
{ action: "read", key: "support:1" },
"https://attacker.test",
),
)
).status,
).toBe(403);
expect(mocks.mark).not.toHaveBeenCalled();
});
it("rejects an injected account in writes", async () => {
expect(
(await POST(request({ action: "read", key: "support:1", userId: 99 })))
.status,
).toBe(400);
expect(mocks.mark).not.toHaveBeenCalled();
});
it("rejects unavailable and other account notifications", async () => {
mocks.mark.mockResolvedValue(false);
expect(
(await POST(request({ action: "read", key: "support:1" }))).status,
).toBe(404);
expect(mocks.mark).toHaveBeenCalledWith(7, "support:1");
});
it("persists category preferences under the authenticated account", async () => {
const preferences = { support: false, friends: true, events: false };
expect(
(await POST(request({ action: "preferences", preferences }))).status,
).toBe(200);
expect(mocks.preferences).toHaveBeenCalledWith(7, preferences);
});
it("does not turn an infrastructure failure into an empty feed", async () => {
mocks.feed.mockRejectedValue(new Error("Database unavailable"));
expect(
(await GET(new Request("https://hotel.test/api/notifications"))).status,
).toBe(503);
});
});
it("returns a failure when saving read state or preferences fails", async () => {
mocks.mark.mockRejectedValue(new Error("Database unavailable"));
expect(
(await POST(request({ action: "read", key: "support:1" }))).status,
).toBe(503);
mocks.preferences.mockRejectedValue(new Error("Database unavailable"));
expect(
(
await POST(
request({
action: "preferences",
preferences: { support: true, friends: false, events: true },
}),
)
).status,
).toBe(503);
});
it("rejects incomplete and foreign-account preferences", async () => {
for (const body of [
{ action: "preferences", preferences: { support: false } },
{
action: "preferences",
userId: 99,
preferences: { support: false, friends: true, events: true },
},
])
expect((await POST(request(body))).status).toBe(400);
expect(mocks.preferences).not.toHaveBeenCalled();
});