From 97d2ce583517fbc20c6ac538a7d99ada5d207945 Mon Sep 17 00:00:00 2001 From: Mawuli Ejere Date: Sat, 24 Oct 2026 20:36:28 +0100 Subject: [PATCH 1/4] fix: add missing authGuard to admin webhook routes; add E2E coverage (#615) webhook.routes.ts registered only adminGuard on this router, never authGuard. adminGuard reads request.authUser.isAdmin directly, and only authGuard sets authUser, so every single request to any admin webhook endpoint (authenticated or not, admin or not) threw "Cannot read properties of undefined (reading 'isAdmin')" and 500'd, instead of the clean 401/403 every other admin router (admin-users, dashboard) returns. Confirmed pre-existing via git stash against the unmodified upstream tree, and consistent with this repo's own recent CI runs on main, which have been failing. Fixed by adding the missing authGuard onRequest hook, matching the other admin routers exactly. Added tests/e2e/admin-webhooks.test.ts covering CRUD operations, secret rotation (confirms the secret is never returned from any read endpoint, changes value on each rotation, and persists to the DB), delivery-attempt listing (including the cursor-based pagination path added in #482), and stats computation. All 29 tests pass; 0 new failures introduced elsewhere (same 40 pre-existing repo-wide build errors and 36 pre-existing failing e2e suites before and after, confirmed via git stash comparison). The existing tests/e2e/webhooks.test.ts (a different, non-admin-scoped file, out of scope for #615) improves from 3/17 to 11/17 passing as a side effect, since it was hedging its assertions across multiple status codes to tolerate this exact bug. --- src/modules/admin/webhook.routes.ts | 10 +- tests/e2e/admin-webhooks.test.ts | 620 ++++++++++++++++++++++++++++ 2 files changed, 628 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/admin-webhooks.test.ts diff --git a/src/modules/admin/webhook.routes.ts b/src/modules/admin/webhook.routes.ts index 1ed330f..d1ee2ba 100644 --- a/src/modules/admin/webhook.routes.ts +++ b/src/modules/admin/webhook.routes.ts @@ -1,6 +1,6 @@ import type { FastifyInstance, FastifySchema } from "fastify"; import { webhookController } from "./webhook.controller.js"; -import { adminGuard } from "../../middleware/auth.js"; +import { authGuard, adminGuard } from "../../middleware/auth.js"; import { validate } from "../../middleware/validation.js"; import { createWebhookSchema, @@ -15,7 +15,13 @@ const idParamSchema = z.object({ }); export async function webhookRoutes(app: FastifyInstance): Promise { - app.addHook("onRequest", adminGuard); + // adminGuard reads request.authUser, which only authGuard sets. Every + // other admin router (admin-users, dashboard) registers both; this one + // registered only adminGuard, so request.authUser was always undefined + // and every request here (admin or not) 500'd on `authUser.isAdmin` + // instead of cleanly rejecting with 401/403 (#615). + app.addHook("onRequest", authGuard); + app.addHook("preHandler", adminGuard); app.post<{ Body: import("./webhook.types.js").CreateWebhookBody }>( "/", diff --git a/tests/e2e/admin-webhooks.test.ts b/tests/e2e/admin-webhooks.test.ts new file mode 100644 index 0000000..2ca1e72 --- /dev/null +++ b/tests/e2e/admin-webhooks.test.ts @@ -0,0 +1,620 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { eq } from "drizzle-orm"; +import { buildApp } from "../../src/server.js"; +import { db } from "../../src/config/database.js"; +import { users, webhooks, webhookAttempts } from "../../src/database/schema.js"; + +describe("Admin Webhook Management API (#615)", () => { + let app: FastifyInstance; + + const adminUserId = "f1a2d3e4-1111-4ef8-bb6d-6bb9bd380d99"; + const adminStellarAddress = + "GWEBHOOKADMIN0000000000000000000000000000000000000000000"; + const nonAdminUserId = "f1a2d3e4-2222-4ef8-bb6d-6bb9bd380d98"; + const nonAdminStellarAddress = + "GWEBHOOKNONADMIN0000000000000000000000000000000000000000"; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + + await db + .insert(users) + .values({ + id: adminUserId, + stellarAddress: adminStellarAddress, + displayName: "Webhook Admin", + isAdmin: true, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: nonAdminUserId, + stellarAddress: nonAdminStellarAddress, + displayName: "Webhook Non-Admin", + isAdmin: false, + }) + .onConflictDoNothing(); + }); + + afterAll(async () => { + await db.delete(users).where(eq(users.id, adminUserId)); + await db.delete(users).where(eq(users.id, nonAdminUserId)); + await app.close(); + }); + + // Every webhook this file creates is cleared before each test (cascades + // to webhookAttempts via onDelete: "cascade"), so list/pagination/stats + // assertions never see leftovers from a previous test. + beforeEach(async () => { + await db.delete(webhooks); + }); + + const adminToken = () => + app.jwt.sign({ sub: adminUserId, stellarAddress: adminStellarAddress }); + + const nonAdminToken = () => + app.jwt.sign({ + sub: nonAdminUserId, + stellarAddress: nonAdminStellarAddress, + }); + + async function createWebhookDirect( + overrides: Partial<{ url: string; events: string[]; active: boolean }> = {}, + ): Promise { + const [row] = await db + .insert(webhooks) + .values({ + url: overrides.url ?? "https://example.com/webhook", + events: overrides.events ?? ["enrollment.created"], + secret: "test-secret-0000000000000000000000000000000000000000000000", + active: overrides.active ?? true, + }) + .returning({ id: webhooks.id }); + return row.id; + } + + describe("POST /api/v1/admin/webhooks", () => { + it("should reject unauthenticated requests", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/webhooks", + payload: { url: "https://example.com/webhook", events: ["enrollment.created"] }, + }); + + // Regression (#615): webhook.routes.ts previously registered only + // adminGuard on this router, never authGuard. adminGuard reads + // request.authUser.isAdmin directly, and only authGuard sets + // authUser, so EVERY request here (admin or not, authenticated or + // not) 500'd with "Cannot read properties of undefined (reading + // 'isAdmin')" instead of the admin-users/dashboard routers' clean + // 401/403. Fixed by adding the missing authGuard onRequest hook. + expect(response.statusCode).toBe(401); + }); + + it("should reject non-admin users with 403", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/webhooks", + headers: { authorization: `Bearer ${nonAdminToken()}` }, + payload: { url: "https://example.com/webhook", events: ["enrollment.created"] }, + }); + + expect(response.statusCode).toBe(403); + }); + + it("should create a webhook and never return the secret in the response", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/webhooks", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + url: "https://example.com/webhook", + events: ["enrollment.created", "quiz.passed"], + }, + }); + + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data.url).toBe("https://example.com/webhook"); + expect(body.data.events).toEqual(["enrollment.created", "quiz.passed"]); + expect(body.data.active).toBe(true); + expect(body.data.secret).toBeUndefined(); + + const [row] = await db + .select() + .from(webhooks) + .where(eq(webhooks.id, body.data.id)); + expect(row?.secret).toBeDefined(); + expect(row?.secret.length).toBeGreaterThan(0); + }); + + it("should reject a webhook with no events", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/webhooks", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { url: "https://example.com/webhook", events: [] }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should reject an invalid URL", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/webhooks", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { url: "not-a-url", events: ["enrollment.created"] }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should reject a webhook targeting a loopback/private address (SSRF guard, #487)", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/webhooks", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { url: "http://localhost:3000/webhook", events: ["enrollment.created"] }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe("GET /api/v1/admin/webhooks", () => { + it("should reject non-admin users with 403", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/webhooks", + headers: { authorization: `Bearer ${nonAdminToken()}` }, + }); + + expect(response.statusCode).toBe(403); + }); + + it("should list webhooks, newest first, with pagination", async () => { + await createWebhookDirect({ url: "https://example.com/first" }); + await createWebhookDirect({ url: "https://example.com/second" }); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/webhooks", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data).toHaveLength(2); + expect(body.pagination.total).toBe(2); + // No secret anywhere in the listing either. + for (const webhook of body.data) { + expect(webhook.secret).toBeUndefined(); + } + }); + + it("should respect page and limit query params", async () => { + await createWebhookDirect({ url: "https://example.com/a" }); + await createWebhookDirect({ url: "https://example.com/b" }); + await createWebhookDirect({ url: "https://example.com/c" }); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/webhooks?page=2&limit=2", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data).toHaveLength(1); + expect(body.pagination.page).toBe(2); + expect(body.pagination.limit).toBe(2); + }); + }); + + describe("GET /api/v1/admin/webhooks/:id", () => { + it("should get a single webhook", async () => { + const id = await createWebhookDirect({ url: "https://example.com/single" }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/webhooks/${id}`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data.id).toBe(id); + expect(body.data.url).toBe("https://example.com/single"); + }); + + it("should 404 for a non-existent webhook", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/webhooks/00000000-0000-0000-0000-000000000000", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should reject a malformed webhook id", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/webhooks/not-a-uuid", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe("PUT /api/v1/admin/webhooks/:id (CRUD: update)", () => { + it("should update the url, events, and active flag independently", async () => { + const id = await createWebhookDirect({ + url: "https://example.com/original", + events: ["enrollment.created"], + active: true, + }); + + const response = await app.inject({ + method: "PUT", + url: `/api/v1/admin/webhooks/${id}`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { active: false }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data.active).toBe(false); + // Fields not included in the PUT body must be left untouched. + expect(body.data.url).toBe("https://example.com/original"); + expect(body.data.events).toEqual(["enrollment.created"]); + }); + + it("should update the url and events together", async () => { + const id = await createWebhookDirect({ url: "https://example.com/original" }); + + const response = await app.inject({ + method: "PUT", + url: `/api/v1/admin/webhooks/${id}`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + url: "https://example.com/updated", + events: ["quiz.passed", "credential.minted"], + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data.url).toBe("https://example.com/updated"); + expect(body.data.events).toEqual(["quiz.passed", "credential.minted"]); + }); + + it("should 404 when updating a non-existent webhook", async () => { + const response = await app.inject({ + method: "PUT", + url: "/api/v1/admin/webhooks/00000000-0000-0000-0000-000000000000", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { active: false }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should reject an update with an invalid URL", async () => { + const id = await createWebhookDirect(); + + const response = await app.inject({ + method: "PUT", + url: `/api/v1/admin/webhooks/${id}`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { url: "not-a-url" }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe("DELETE /api/v1/admin/webhooks/:id (CRUD: delete)", () => { + it("should delete a webhook", async () => { + const id = await createWebhookDirect(); + + const response = await app.inject({ + method: "DELETE", + url: `/api/v1/admin/webhooks/${id}`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + + const [row] = await db.select().from(webhooks).where(eq(webhooks.id, id)); + expect(row).toBeUndefined(); + }); + + it("should 404 when deleting a non-existent webhook", async () => { + const response = await app.inject({ + method: "DELETE", + url: "/api/v1/admin/webhooks/00000000-0000-0000-0000-000000000000", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe("POST /api/v1/admin/webhooks/:id/rotate-secret", () => { + it("should reject non-admin users with 403", async () => { + const id = await createWebhookDirect(); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/webhooks/${id}/rotate-secret`, + headers: { authorization: `Bearer ${nonAdminToken()}` }, + }); + + expect(response.statusCode).toBe(403); + }); + + it("should rotate the secret and return the new value exactly once", async () => { + const id = await createWebhookDirect(); + const [before] = await db.select().from(webhooks).where(eq(webhooks.id, id)); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/webhooks/${id}/rotate-secret`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(typeof body.data.secret).toBe("string"); + expect(body.data.secret.length).toBeGreaterThan(0); + expect(body.data.secret).not.toBe(before?.secret); + + const [after] = await db.select().from(webhooks).where(eq(webhooks.id, id)); + expect(after?.secret).toBe(body.data.secret); + + // A follow-up GET on the webhook must never leak the (new) secret. + const getResponse = await app.inject({ + method: "GET", + url: `/api/v1/admin/webhooks/${id}`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + expect(JSON.parse(getResponse.payload).data.secret).toBeUndefined(); + }); + + it("should produce a different secret on each successive rotation", async () => { + const id = await createWebhookDirect(); + + const first = await app.inject({ + method: "POST", + url: `/api/v1/admin/webhooks/${id}/rotate-secret`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + const second = await app.inject({ + method: "POST", + url: `/api/v1/admin/webhooks/${id}/rotate-secret`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(JSON.parse(first.payload).data.secret).not.toBe( + JSON.parse(second.payload).data.secret, + ); + }); + + it("should 404 when rotating the secret of a non-existent webhook", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/webhooks/00000000-0000-0000-0000-000000000000/rotate-secret", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe("GET /api/v1/admin/webhooks/:id/attempts", () => { + it("should 404 for a non-existent webhook", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/webhooks/00000000-0000-0000-0000-000000000000/attempts", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should list attempts for a webhook, newest first", async () => { + const id = await createWebhookDirect(); + await db.insert(webhookAttempts).values({ + webhookId: id, + event: "enrollment.created", + payload: { foo: "bar" }, + statusCode: 200, + succeededAt: new Date(), + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + const [secondAttempt] = await db + .insert(webhookAttempts) + .values({ + webhookId: id, + event: "quiz.passed", + payload: { foo: "baz" }, + statusCode: 500, + errorMessage: "Connection refused", + failedAt: new Date(), + }) + .returning({ id: webhookAttempts.id }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/webhooks/${id}/attempts`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data).toHaveLength(2); + expect(body.data[0].id).toBe(secondAttempt.id); + expect(body.data[0].statusCode).toBe(500); + expect(body.data[0].errorMessage).toBe("Connection refused"); + expect(body.pagination.total).toBe(2); + }); + + it("should only return attempts for the requested webhook, not another one's", async () => { + const id1 = await createWebhookDirect({ url: "https://example.com/one" }); + const id2 = await createWebhookDirect({ url: "https://example.com/two" }); + await db.insert(webhookAttempts).values({ + webhookId: id1, + event: "enrollment.created", + payload: {}, + statusCode: 200, + succeededAt: new Date(), + }); + await db.insert(webhookAttempts).values({ + webhookId: id2, + event: "enrollment.created", + payload: {}, + statusCode: 200, + succeededAt: new Date(), + }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/webhooks/${id1}/attempts`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + const body = JSON.parse(response.payload); + expect(body.data).toHaveLength(1); + expect(body.data[0].webhookId).toBe(id1); + }); + + it("should support cursor-based pagination via nextCursor (#482)", async () => { + const id = await createWebhookDirect(); + for (let i = 0; i < 3; i++) { + await db.insert(webhookAttempts).values({ + webhookId: id, + event: "enrollment.created", + payload: { i }, + statusCode: 200, + succeededAt: new Date(), + }); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + const firstPage = await app.inject({ + method: "GET", + url: `/api/v1/admin/webhooks/${id}/attempts?limit=2`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + const firstBody = JSON.parse(firstPage.payload); + expect(firstBody.data).toHaveLength(2); + expect(firstBody.pagination.nextCursor).not.toBeNull(); + + const secondPage = await app.inject({ + method: "GET", + url: `/api/v1/admin/webhooks/${id}/attempts?limit=2&cursor=${firstBody.pagination.nextCursor}`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + const secondBody = JSON.parse(secondPage.payload); + expect(secondBody.data).toHaveLength(1); + expect(secondBody.pagination.nextCursor).toBeNull(); + + // No overlap between the two pages. + const firstIds = firstBody.data.map((a: { id: string }) => a.id); + const secondIds = secondBody.data.map((a: { id: string }) => a.id); + expect(firstIds.some((id: string) => secondIds.includes(id))).toBe(false); + }); + }); + + describe("GET /api/v1/admin/webhooks/:id/stats", () => { + it("should 404 for a non-existent webhook", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/webhooks/00000000-0000-0000-0000-000000000000/stats", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should return zeroed stats for a webhook with no attempts", async () => { + const id = await createWebhookDirect(); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/webhooks/${id}/stats`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data).toEqual({ + webhookId: id, + totalAttempts: 0, + succeeded: 0, + failed: 0, + pending: 0, + successRate: 0, + }); + }); + + it("should compute succeeded/failed/pending counts and successRate correctly", async () => { + const id = await createWebhookDirect(); + // 2 succeeded, 1 failed, 1 pending (neither succeededAt nor failedAt set). + await db.insert(webhookAttempts).values([ + { + webhookId: id, + event: "enrollment.created", + payload: {}, + statusCode: 200, + succeededAt: new Date(), + }, + { + webhookId: id, + event: "quiz.passed", + payload: {}, + statusCode: 200, + succeededAt: new Date(), + }, + { + webhookId: id, + event: "quiz.failed", + payload: {}, + statusCode: 500, + failedAt: new Date(), + }, + { + webhookId: id, + event: "credential.minted", + payload: {}, + }, + ]); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/webhooks/${id}/stats`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data.totalAttempts).toBe(4); + expect(body.data.succeeded).toBe(2); + expect(body.data.failed).toBe(1); + expect(body.data.pending).toBe(1); + expect(body.data.successRate).toBe(50); + }); + }); +}); From a71709b2a3c375c7de05c84e5946ac1b8d76917f Mon Sep 17 00:00:00 2001 From: Mawuli Ejere Date: Sat, 24 Oct 2026 20:36:56 +0100 Subject: [PATCH 2/4] test: expand admin-users E2E coverage for ban, credits, and activity (#616) Adds E2E coverage for the three entrypoints #616 named that this file didn't yet exercise: ban (confirms the reason is persisted and the banned user's own JWT is rejected with 403 on the very next request, #347), credits grant/deduct (confirms the before/after balance in the response matches the DB, and that a deduction exceeding the balance is rejected with VALIDATION_ERROR and leaves the balance unchanged), and the user activity feed (confirms an enrollment surfaces as an activity entry with the right courseId). #616 also asks for unban coverage, but no unban/reinstate entrypoint exists anywhere in this codebase (confirmed by searching the whole repo for "unban" and for any route that clears bannedAt): the ban is one-way as shipped. Added a test that documents this gap explicitly (asserts the route 404s) rather than silently dropping that half of the issue's scope; it will start failing, as a signal to replace it with real coverage, the moment an unban route is added. --- tests/e2e/admin-users.test.ts | 335 +++++++++++++++++++++++++++++++++- 1 file changed, 334 insertions(+), 1 deletion(-) diff --git a/tests/e2e/admin-users.test.ts b/tests/e2e/admin-users.test.ts index 24a0dac..f84d036 100644 --- a/tests/e2e/admin-users.test.ts +++ b/tests/e2e/admin-users.test.ts @@ -3,7 +3,7 @@ import type { FastifyInstance } from "fastify"; import { eq } from "drizzle-orm"; import { buildApp } from "../../src/server.js"; import { db } from "../../src/config/database.js"; -import { users } from "../../src/database/schema.js"; +import { users, enrollments, courses } from "../../src/database/schema.js"; describe("Admin Users API (#288)", () => { let app: FastifyInstance; @@ -14,6 +14,19 @@ describe("Admin Users API (#288)", () => { const nonAdminUserId = "c1a2d3e4-2222-4ef8-bb6d-6bb9bd380a98"; const nonAdminStellarAddress = "GNONADMIN00000000000000000000000000000000000000000000000"; + // Separate target users for the destructive ban/credit tests, so they + // don't mutate (and leave side effects on) the shared nonAdminUserId + // fixture the listing/search tests above also read. + const banTargetUserId = "c1a2d3e4-3333-4ef8-bb6d-6bb9bd380a97"; + const banTargetStellarAddress = + "GBANTARGET0000000000000000000000000000000000000000000000"; + const creditTargetUserId = "c1a2d3e4-4444-4ef8-bb6d-6bb9bd380a96"; + const creditTargetStellarAddress = + "GCREDITTARGET0000000000000000000000000000000000000000000"; + const activityUserId = "c1a2d3e4-5555-4ef8-bb6d-6bb9bd380a95"; + const activityStellarAddress = + "GACTIVITYUSER0000000000000000000000000000000000000000000"; + const activityCourseId = "c1a2d3e4-6666-4ef8-bb6d-6bb9bd380a94"; beforeAll(async () => { app = await buildApp(); @@ -38,9 +51,63 @@ describe("Admin Users API (#288)", () => { isAdmin: false, }) .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: banTargetUserId, + stellarAddress: banTargetStellarAddress, + displayName: "Ban Target", + isAdmin: false, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: creditTargetUserId, + stellarAddress: creditTargetStellarAddress, + displayName: "Credit Target", + isAdmin: false, + credits: 100, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: activityUserId, + stellarAddress: activityStellarAddress, + displayName: "Activity User", + isAdmin: false, + }) + .onConflictDoNothing(); + + await db + .insert(courses) + .values({ + id: activityCourseId, + title: "Activity Test Course", + description: "Course fixture for the admin activity feed test", + difficulty: "beginner", + }) + .onConflictDoNothing(); + + await db + .insert(enrollments) + .values({ + userId: activityUserId, + courseId: activityCourseId, + }) + .onConflictDoNothing(); }); afterAll(async () => { + await db.delete(enrollments).where(eq(enrollments.userId, activityUserId)); + await db.delete(courses).where(eq(courses.id, activityCourseId)); + await db.delete(users).where(eq(users.id, activityUserId)); + await db.delete(users).where(eq(users.id, creditTargetUserId)); + await db.delete(users).where(eq(users.id, banTargetUserId)); await db.delete(users).where(eq(users.id, adminUserId)); await db.delete(users).where(eq(users.id, nonAdminUserId)); await app.close(); @@ -176,4 +243,270 @@ describe("Admin Users API (#288)", () => { expect(response.statusCode).toBe(400); }); }); + + describe("POST /api/v1/admin/users/:id/ban (#616)", () => { + it("should reject unauthenticated requests", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${banTargetUserId}/ban`, + payload: { reason: "Terms of service violation" }, + }); + + expect(response.statusCode).toBe(401); + }); + + it("should reject non-admin users with 403", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${banTargetUserId}/ban`, + headers: { authorization: `Bearer ${nonAdminToken()}` }, + payload: { reason: "Terms of service violation" }, + }); + + expect(response.statusCode).toBe(403); + }); + + it("should reject a missing reason", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${banTargetUserId}/ban`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: {}, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should 404 for a non-existent user", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/users/00000000-0000-0000-0000-000000000000/ban", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { reason: "Terms of service violation" }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should ban a user, persist the reason, and reject their subsequent authenticated requests with 403", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${banTargetUserId}/ban`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { reason: "Terms of service violation" }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + + const [banned] = await db + .select() + .from(users) + .where(eq(users.id, banTargetUserId)); + expect(banned?.bannedAt).not.toBeNull(); + expect(banned?.banReason).toBe("Terms of service violation"); + + // The banned user's own token must now be rejected (authGuard, #347): + // a ban takes effect on the next request without needing to revoke + // the JWT itself. + const bannedUserToken = app.jwt.sign({ + sub: banTargetUserId, + stellarAddress: banTargetStellarAddress, + }); + const selfResponse = await app.inject({ + method: "GET", + url: "/api/v1/users/me", + headers: { authorization: `Bearer ${bannedUserToken}` }, + }); + expect(selfResponse.statusCode).toBe(403); + }); + + // Issue #616 asks for ban/unban coverage, but no unban/reinstate + // entrypoint exists anywhere in the codebase (confirmed by searching + // the whole repo for "unban" and for any route that clears bannedAt). + // This test documents that gap explicitly rather than silently + // omitting it: it will start failing (in a good way) the moment an + // unban route is added, which is the signal to replace it with real + // coverage instead of deleting it. + it("has no unban/reinstate entrypoint yet (tracks the gap in #616's ban/unban scope)", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${banTargetUserId}/unban`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe("POST /api/v1/admin/users/:id/credits/grant and /deduct (#386, #616)", () => { + it("should reject unauthenticated grant requests", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${creditTargetUserId}/credits/grant`, + payload: { amount: 50, reason: "Promotion" }, + }); + + expect(response.statusCode).toBe(401); + }); + + it("should reject non-admin grant requests with 403", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${creditTargetUserId}/credits/grant`, + headers: { authorization: `Bearer ${nonAdminToken()}` }, + payload: { amount: 50, reason: "Promotion" }, + }); + + expect(response.statusCode).toBe(403); + }); + + it("should grant credits and return the balance before and after", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${creditTargetUserId}/credits/grant`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { amount: 50, reason: "Promotion", reference: "PROMO2026" }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data.creditsBefore).toBe(100); + expect(body.data.creditsAfter).toBe(150); + expect(body.data.reference).toBe("PROMO2026"); + + const [row] = await db + .select() + .from(users) + .where(eq(users.id, creditTargetUserId)); + expect(row?.credits).toBe(150); + }); + + it("should reject a grant with a non-positive amount", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${creditTargetUserId}/credits/grant`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { amount: 0, reason: "Promotion" }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should reject a grant with a missing reason", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${creditTargetUserId}/credits/grant`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { amount: 10 }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should 404 a grant for a non-existent user", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/users/00000000-0000-0000-0000-000000000000/credits/grant", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { amount: 10, reason: "Promotion" }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should deduct credits and return the balance before and after", async () => { + // Balance is 150 after the grant test above (same fixture user, + // sequential within this file). + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${creditTargetUserId}/credits/deduct`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { amount: 30, reason: "Penalty" }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data.creditsBefore).toBe(150); + expect(body.data.creditsAfter).toBe(120); + + const [row] = await db + .select() + .from(users) + .where(eq(users.id, creditTargetUserId)); + expect(row?.credits).toBe(120); + }); + + it("should reject a deduction that would make the balance negative", async () => { + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/users/${creditTargetUserId}/credits/deduct`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { amount: 999999, reason: "Penalty" }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.payload); + expect(body.error).toBe("VALIDATION_ERROR"); + + // The balance must be unchanged after the rejected deduction. + const [row] = await db + .select() + .from(users) + .where(eq(users.id, creditTargetUserId)); + expect(row?.credits).toBe(120); + }); + }); + + describe("GET /api/v1/admin/users/:id/activity (#346, #616)", () => { + it("should reject unauthenticated requests", async () => { + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/users/${activityUserId}/activity`, + }); + + expect(response.statusCode).toBe(401); + }); + + it("should reject non-admin users with 403", async () => { + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/users/${activityUserId}/activity`, + headers: { authorization: `Bearer ${nonAdminToken()}` }, + }); + + expect(response.statusCode).toBe(403); + }); + + it("should 404 for a non-existent user", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/users/00000000-0000-0000-0000-000000000000/activity", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should return the user's enrollment as an activity entry", async () => { + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/users/${activityUserId}/activity`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(Array.isArray(body.data)).toBe(true); + const enrollmentActivity = body.data.find( + (a: { type: string }) => a.type === "enrollment", + ); + expect(enrollmentActivity).toBeDefined(); + expect(enrollmentActivity.details.courseId).toBe(activityCourseId); + }); + }); }); From 3921bbb1594c5a8526f1ad8f9980a4532cd4fa82 Mon Sep 17 00:00:00 2001 From: Mawuli Ejere Date: Sat, 24 Oct 2026 20:37:05 +0100 Subject: [PATCH 3/4] test: add admin dashboard E2E coverage (#618) Covers the dashboard's one endpoint end to end: auth/admin rejection, the full stats shape (totals, new-user windows, quiz completion rate, credentials, rewards claimed), the week-over-week trend metrics, and caching behavior specifically, since that was named explicitly in scope. The caching tests go past "a cache key exists" to verify the actual behavior that matters: a second request within the 5-minute TTL returns the byte-identical cached response even after new data is written directly to the DB in between, and clearing the cache entry makes the very next request pick up that new data. Both are driven against the real Redis instance the app itself talks to, not a reimplementation of the cache logic. --- tests/e2e/admin-dashboard.test.ts | 227 ++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 tests/e2e/admin-dashboard.test.ts diff --git a/tests/e2e/admin-dashboard.test.ts b/tests/e2e/admin-dashboard.test.ts new file mode 100644 index 0000000..2c1612f --- /dev/null +++ b/tests/e2e/admin-dashboard.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { eq } from "drizzle-orm"; +import { buildApp } from "../../src/server.js"; +import { db } from "../../src/config/database.js"; +import { users } from "../../src/database/schema.js"; +import { redis } from "../../src/config/redis.js"; +import { cacheKey } from "../../src/cache/index.js"; + +describe("Admin Dashboard API (#367, #618)", () => { + let app: FastifyInstance; + + const adminUserId = "d1a2d3e4-1111-4ef8-bb6d-6bb9bd380b99"; + const adminStellarAddress = + "GDASHADMIN0000000000000000000000000000000000000000000000"; + const nonAdminUserId = "d1a2d3e4-2222-4ef8-bb6d-6bb9bd380b98"; + const nonAdminStellarAddress = + "GDASHNONADMIN000000000000000000000000000000000000000000"; + + const dashboardCacheKey = cacheKey("admin", "dashboard-stats"); + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + + await db + .insert(users) + .values({ + id: adminUserId, + stellarAddress: adminStellarAddress, + displayName: "Dashboard Admin", + isAdmin: true, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: nonAdminUserId, + stellarAddress: nonAdminStellarAddress, + displayName: "Dashboard Non-Admin", + isAdmin: false, + }) + .onConflictDoNothing(); + }); + + afterAll(async () => { + await db.delete(users).where(eq(users.id, adminUserId)); + await db.delete(users).where(eq(users.id, nonAdminUserId)); + await redis.del(dashboardCacheKey); + await app.close(); + }); + + const adminToken = () => + app.jwt.sign({ sub: adminUserId, stellarAddress: adminStellarAddress }); + + const nonAdminToken = () => + app.jwt.sign({ + sub: nonAdminUserId, + stellarAddress: nonAdminStellarAddress, + }); + + describe("GET /api/v1/admin/dashboard", () => { + it("should reject unauthenticated requests", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/dashboard", + }); + + expect(response.statusCode).toBe(401); + const body = JSON.parse(response.payload); + expect(body.error).toBe("UNAUTHORIZED"); + }); + + it("should reject non-admin users with 403", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/dashboard", + headers: { authorization: `Bearer ${nonAdminToken()}` }, + }); + + expect(response.statusCode).toBe(403); + const body = JSON.parse(response.payload); + expect(body.error).toBe("FORBIDDEN"); + }); + + it("should return the full stats shape for admins", async () => { + await redis.del(dashboardCacheKey); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/dashboard", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + + const stats = body.data; + expect(typeof stats.totalUsers).toBe("number"); + expect(typeof stats.newUsersToday).toBe("number"); + expect(typeof stats.newUsersThisWeek).toBe("number"); + expect(typeof stats.newUsersThisMonth).toBe("number"); + expect(typeof stats.totalEnrollments).toBe("number"); + expect(typeof stats.quizCompletionRate).toBe("number"); + expect(typeof stats.totalCredentials).toBe("number"); + expect(typeof stats.totalRewardsClaimed).toBe("number"); + expect(typeof stats.generatedAt).toBe("string"); + + // The two seed users above must be reflected in the admin-visible + // total, since the count query filters only on deletedAt. + expect(stats.totalUsers).toBeGreaterThanOrEqual(2); + }); + + it("should include week-over-week trend metrics with current/previous/changePercent", async () => { + await redis.del(dashboardCacheKey); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/dashboard", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const { trends } = JSON.parse(response.payload).data; + + for (const metric of [trends.newUsers, trends.enrollments]) { + expect(typeof metric.current).toBe("number"); + expect(typeof metric.previous).toBe("number"); + expect( + metric.changePercent === null || typeof metric.changePercent === "number", + ).toBe(true); + } + }); + + it("should cache the response in Redis under the documented key", async () => { + await redis.del(dashboardCacheKey); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/dashboard", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + + const cached = await redis.get(dashboardCacheKey); + expect(cached).not.toBeNull(); + const cachedStats = JSON.parse(cached as string); + expect(cachedStats.generatedAt).toBe( + JSON.parse(response.payload).data.generatedAt, + ); + }); + + it("should serve the identical cached response on a second request within the TTL, even after new data is written", async () => { + await redis.del(dashboardCacheKey); + + const first = await app.inject({ + method: "GET", + url: "/api/v1/admin/dashboard", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + const firstBody = JSON.parse(first.payload); + + // Write a brand-new user after the first request. If the endpoint + // were not caching, this would change totalUsers on the next call. + const extraUserId = "d1a2d3e4-3333-4ef8-bb6d-6bb9bd380b97"; + await db.insert(users).values({ + id: extraUserId, + stellarAddress: + "GDASHEXTRAUSER0000000000000000000000000000000000000000", + displayName: "Extra Dashboard User", + isAdmin: false, + }); + + try { + const second = await app.inject({ + method: "GET", + url: "/api/v1/admin/dashboard", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + const secondBody = JSON.parse(second.payload); + + expect(secondBody.data.totalUsers).toBe(firstBody.data.totalUsers); + expect(secondBody.data.generatedAt).toBe(firstBody.data.generatedAt); + } finally { + await db.delete(users).where(eq(users.id, extraUserId)); + } + }); + + it("should recompute (and reflect new data) once the cache entry is cleared", async () => { + await redis.del(dashboardCacheKey); + + const before = await app.inject({ + method: "GET", + url: "/api/v1/admin/dashboard", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + const beforeTotal = JSON.parse(before.payload).data.totalUsers; + + const extraUserId = "d1a2d3e4-4444-4ef8-bb6d-6bb9bd380b96"; + await db.insert(users).values({ + id: extraUserId, + stellarAddress: + "GDASHEXTRAUSER20000000000000000000000000000000000000000", + displayName: "Another Extra Dashboard User", + isAdmin: false, + }); + + try { + await redis.del(dashboardCacheKey); + + const after = await app.inject({ + method: "GET", + url: "/api/v1/admin/dashboard", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + const afterTotal = JSON.parse(after.payload).data.totalUsers; + + expect(afterTotal).toBe(beforeTotal + 1); + } finally { + await db.delete(users).where(eq(users.id, extraUserId)); + } + }); + }); +}); From d82d85d8b3d2b92fcd577857e62df63f62b0633c Mon Sep 17 00:00:00 2001 From: Mawuli Ejere Date: Sat, 24 Oct 2026 20:37:12 +0100 Subject: [PATCH 4/4] test: add notification endpoints E2E coverage (#619) Covers all three entrypoints mounted under /api/v1/users/me/notifications (wired into user.routes.ts, not a dedicated notifications.routes.ts): listing (ordering, per-user isolation, pagination, the unreadCount field staying correct independent of the current page), marking a single notification read (including the 404 when a user tries to mark another user's notification, confirmed to leave that row genuinely untouched in the DB, not just hidden in the response), and mark-all-read (confirms the returned count, that it only touches the authenticated user's own rows, and the 0 case when nothing is unread). --- tests/e2e/notifications.test.ts | 325 ++++++++++++++++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 tests/e2e/notifications.test.ts diff --git a/tests/e2e/notifications.test.ts b/tests/e2e/notifications.test.ts new file mode 100644 index 0000000..057fba3 --- /dev/null +++ b/tests/e2e/notifications.test.ts @@ -0,0 +1,325 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { eq } from "drizzle-orm"; +import { buildApp } from "../../src/server.js"; +import { db } from "../../src/config/database.js"; +import { users, notifications } from "../../src/database/schema.js"; + +describe("Notifications API (#619)", () => { + let app: FastifyInstance; + + const userId = "e1a2d3e4-1111-4ef8-bb6d-6bb9bd380c99"; + const userStellarAddress = + "GNOTIFYUSER00000000000000000000000000000000000000000000"; + const otherUserId = "e1a2d3e4-2222-4ef8-bb6d-6bb9bd380c98"; + const otherUserStellarAddress = + "GNOTIFYOTHER0000000000000000000000000000000000000000000"; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + + await db + .insert(users) + .values({ + id: userId, + stellarAddress: userStellarAddress, + displayName: "Notify User", + isAdmin: false, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: otherUserId, + stellarAddress: otherUserStellarAddress, + displayName: "Other Notify User", + isAdmin: false, + }) + .onConflictDoNothing(); + }); + + afterAll(async () => { + await db.delete(users).where(eq(users.id, userId)); + await db.delete(users).where(eq(users.id, otherUserId)); + await app.close(); + }); + + // Every notification row this file creates is cleared before each test, + // so list/count/unread-count assertions never see leftovers from a + // previous test in this same file. + beforeEach(async () => { + await db.delete(notifications).where(eq(notifications.userId, userId)); + await db.delete(notifications).where(eq(notifications.userId, otherUserId)); + }); + + const token = () => + app.jwt.sign({ sub: userId, stellarAddress: userStellarAddress }); + + const otherToken = () => + app.jwt.sign({ sub: otherUserId, stellarAddress: otherUserStellarAddress }); + + async function seedNotification( + targetUserId: string, + overrides: Partial<{ type: string; title: string; message: string; read: boolean }> = {}, + ): Promise { + const [row] = await db + .insert(notifications) + .values({ + userId: targetUserId, + type: overrides.type ?? "badge_earned", + title: overrides.title ?? "You earned a badge!", + message: overrides.message ?? "Congratulations on your progress.", + read: overrides.read ?? false, + }) + .returning({ id: notifications.id }); + return row.id; + } + + describe("GET /api/v1/users/me/notifications", () => { + it("should reject unauthenticated requests", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/users/me/notifications", + }); + + expect(response.statusCode).toBe(401); + }); + + it("should return an empty page with unreadCount 0 for a user with no notifications", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/users/me/notifications", + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data).toEqual([]); + expect(body.pagination.total).toBe(0); + expect(body.unreadCount).toBe(0); + }); + + it("should list the authenticated user's notifications, newest first", async () => { + const firstId = await seedNotification(userId, { title: "First" }); + await new Promise((resolve) => setTimeout(resolve, 10)); + const secondId = await seedNotification(userId, { title: "Second" }); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/users/me/notifications", + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data).toHaveLength(2); + expect(body.data[0].id).toBe(secondId); + expect(body.data[1].id).toBe(firstId); + expect(body.pagination.total).toBe(2); + }); + + it("should only return the authenticated user's own notifications, never another user's", async () => { + await seedNotification(userId, { title: "Mine" }); + await seedNotification(otherUserId, { title: "Not mine" }); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/users/me/notifications", + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data).toHaveLength(1); + expect(body.data[0].title).toBe("Mine"); + }); + + it("should report unreadCount independent of the current page", async () => { + await seedNotification(userId, { read: true }); + await seedNotification(userId, { read: false }); + await seedNotification(userId, { read: false }); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/users/me/notifications?page=1&limit=1", + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data).toHaveLength(1); + expect(body.pagination.total).toBe(3); + expect(body.unreadCount).toBe(2); + }); + + it("should respect page and limit query params", async () => { + for (let i = 0; i < 3; i++) { + await seedNotification(userId, { title: `Notification ${i}` }); + } + + const response = await app.inject({ + method: "GET", + url: "/api/v1/users/me/notifications?page=2&limit=2", + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data).toHaveLength(1); + expect(body.pagination.page).toBe(2); + expect(body.pagination.limit).toBe(2); + }); + + it("should reject invalid limit values", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/users/me/notifications?limit=0", + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe("PUT /api/v1/users/me/notifications/:id/read", () => { + it("should reject unauthenticated requests", async () => { + const id = await seedNotification(userId); + + const response = await app.inject({ + method: "PUT", + url: `/api/v1/users/me/notifications/${id}/read`, + }); + + expect(response.statusCode).toBe(401); + }); + + it("should mark a single notification as read", async () => { + const id = await seedNotification(userId, { read: false }); + + const response = await app.inject({ + method: "PUT", + url: `/api/v1/users/me/notifications/${id}/read`, + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data.id).toBe(id); + expect(body.data.read).toBe(true); + + const [row] = await db + .select() + .from(notifications) + .where(eq(notifications.id, id)); + expect(row?.read).toBe(true); + }); + + it("should 404 when marking another user's notification as read", async () => { + const otherNotificationId = await seedNotification(otherUserId); + + const response = await app.inject({ + method: "PUT", + url: `/api/v1/users/me/notifications/${otherNotificationId}/read`, + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(404); + + // Confirm it genuinely wasn't touched, not just that the response hid it. + const [row] = await db + .select() + .from(notifications) + .where(eq(notifications.id, otherNotificationId)); + expect(row?.read).toBe(false); + }); + + it("should 404 for a non-existent notification id", async () => { + const response = await app.inject({ + method: "PUT", + url: "/api/v1/users/me/notifications/00000000-0000-0000-0000-000000000000/read", + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should reject a malformed notification id", async () => { + const response = await app.inject({ + method: "PUT", + url: "/api/v1/users/me/notifications/not-a-uuid/read", + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe("PUT /api/v1/users/me/notifications/read-all", () => { + it("should reject unauthenticated requests", async () => { + const response = await app.inject({ + method: "PUT", + url: "/api/v1/users/me/notifications/read-all", + }); + + expect(response.statusCode).toBe(401); + }); + + it("should mark every unread notification as read and report the count", async () => { + await seedNotification(userId, { read: false }); + await seedNotification(userId, { read: false }); + await seedNotification(userId, { read: true }); + + const response = await app.inject({ + method: "PUT", + url: "/api/v1/users/me/notifications/read-all", + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data.count).toBe(2); + + const unreadResponse = await app.inject({ + method: "GET", + url: "/api/v1/users/me/notifications", + headers: { authorization: `Bearer ${token()}` }, + }); + expect(JSON.parse(unreadResponse.payload).unreadCount).toBe(0); + }); + + it("should only affect the authenticated user's own notifications", async () => { + await seedNotification(userId, { read: false }); + await seedNotification(otherUserId, { read: false }); + + await app.inject({ + method: "PUT", + url: "/api/v1/users/me/notifications/read-all", + headers: { authorization: `Bearer ${token()}` }, + }); + + const otherResponse = await app.inject({ + method: "GET", + url: "/api/v1/users/me/notifications", + headers: { authorization: `Bearer ${otherToken()}` }, + }); + expect(JSON.parse(otherResponse.payload).unreadCount).toBe(1); + }); + + it("should return count 0 when there are no unread notifications", async () => { + const response = await app.inject({ + method: "PUT", + url: "/api/v1/users/me/notifications/read-all", + headers: { authorization: `Bearer ${token()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data.count).toBe(0); + }); + }); +});