diff --git a/src/modules/courses/admin-course.routes.ts b/src/modules/courses/admin-course.routes.ts index 543911c..fc0db46 100644 --- a/src/modules/courses/admin-course.routes.ts +++ b/src/modules/courses/admin-course.routes.ts @@ -569,12 +569,12 @@ export async function adminCourseRoutes(app: FastifyInstance): Promise { }, body: { type: "object", - required: ["title"], + required: ["title", "type", "content"], properties: { title: { type: "string", minLength: 1 }, - content: { type: "string" }, - contentType: { type: "string" }, - videoUrl: { type: "string" }, + type: { type: "string", enum: ["text", "video", "quiz", "exercise"] }, + content: { type: "object" }, + orderIndex: { type: "integer", minimum: 0 }, }, }, } as FastifySchema, diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 1f7af07..f53b17e 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -5,6 +5,7 @@ import { getRequestId } from "./request-context.js"; // that module imports this one (to log its own validation warnings/errors), // so importing config here would form a cycle — ESM leaves the not-yet- // initialized `config` export as undefined partway through that cycle, +// which crashed every request (and every test, since buildApp() builds a // which crashed every request (and every test that imports anything // touching this module) before config had finished evaluating. // which crashed every request (and every test, since buildApp() creates a diff --git a/tests/e2e/admin-badges.test.ts b/tests/e2e/admin-badges.test.ts new file mode 100644 index 0000000..773181c --- /dev/null +++ b/tests/e2e/admin-badges.test.ts @@ -0,0 +1,361 @@ +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, courses, enrollments, badges, userBadges } from "../../src/database/schema.js"; + +describe("Admin Badge Management API (#380)", () => { + let app: FastifyInstance; + + const adminUserId = "b1c2d3e4-1111-4ef8-bb6d-6bb9bd380d10"; + const adminStellarAddress = "GADMINBADGE00000000000000000000000000000000000000000000".slice( + 0, + 56, + ); + const nonAdminUserId = "b1c2d3e4-2222-4ef8-bb6d-6bb9bd380d10"; + const nonAdminStellarAddress = + "GNONADMINBADGE000000000000000000000000000000000000000000".slice(0, 56); + + const createdBadgeIds: string[] = []; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + + await db + .insert(users) + .values({ + id: adminUserId, + stellarAddress: adminStellarAddress, + displayName: "Badge Admin", + isAdmin: true, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: nonAdminUserId, + stellarAddress: nonAdminStellarAddress, + displayName: "Badge Non-Admin", + isAdmin: false, + }) + .onConflictDoNothing(); + }); + + afterAll(async () => { + for (const id of createdBadgeIds) { + await db.delete(userBadges).where(eq(userBadges.badgeId, id)); + await db.delete(badges).where(eq(badges.id, id)); + } + await db.delete(users).where(eq(users.id, adminUserId)); + await db.delete(users).where(eq(users.id, nonAdminUserId)); + await app.close(); + }); + + const adminToken = () => + app.jwt.sign({ sub: adminUserId, stellarAddress: adminStellarAddress }); + + const nonAdminToken = () => + app.jwt.sign({ sub: nonAdminUserId, stellarAddress: nonAdminStellarAddress }); + + describe("auth", () => { + it("should reject unauthenticated requests", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/badges", + }); + + 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/badges", + headers: { authorization: `Bearer ${nonAdminToken()}` }, + }); + + expect(response.statusCode).toBe(403); + }); + }); + + describe("badge CRUD", () => { + it("should create a badge", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/badges", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + name: "First Steps", + description: "Enroll in your first course", + iconUrl: "https://example.com/first-steps.png", + type: "enrollment", + criteria: { count: 1 }, + }, + }); + + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data.name).toBe("First Steps"); + expect(body.data.type).toBe("enrollment"); + createdBadgeIds.push(body.data.id); + }); + + it("should reject creation with a missing required field", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/badges", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { name: "Incomplete badge" }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should reject an invalid badge type", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/badges", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + name: "Bad Type", + description: "desc", + iconUrl: "https://example.com/icon.png", + type: "not_a_real_type", + criteria: { count: 1 }, + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should list all badges", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/badges", + 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); + expect(body.data.some((b: { id: string }) => createdBadgeIds.includes(b.id))).toBe(true); + }); + + it("should get a badge by id", async () => { + const [badge] = await db + .insert(badges) + .values({ + name: "Gettable Badge", + description: "desc", + iconUrl: "https://example.com/icon.png", + type: "enrollment", + criteria: { count: 1 }, + }) + .returning(); + createdBadgeIds.push(badge!.id); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/badges/${badge!.id}`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data.id).toBe(badge!.id); + }); + + it("should 404 for a non-existent badge id", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/badges/00000000-0000-0000-0000-000000000000", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should update a badge", async () => { + const [badge] = await db + .insert(badges) + .values({ + name: "Original Name", + description: "Original description", + iconUrl: "https://example.com/icon.png", + type: "enrollment", + criteria: { count: 1 }, + }) + .returning(); + createdBadgeIds.push(badge!.id); + + const response = await app.inject({ + method: "PUT", + url: `/api/v1/admin/badges/${badge!.id}`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { name: "Renamed Badge" }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data.name).toBe("Renamed Badge"); + expect(body.data.description).toBe("Original description"); + }); + + it("should 404 when updating a non-existent badge", async () => { + const response = await app.inject({ + method: "PUT", + url: "/api/v1/admin/badges/00000000-0000-0000-0000-000000000000", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { name: "Does not exist" }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should delete a badge", async () => { + const [badge] = await db + .insert(badges) + .values({ + name: "To Delete", + description: "desc", + iconUrl: "https://example.com/icon.png", + type: "enrollment", + criteria: { count: 1 }, + }) + .returning(); + + const response = await app.inject({ + method: "DELETE", + url: `/api/v1/admin/badges/${badge!.id}`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + + const remaining = await db.select().from(badges).where(eq(badges.id, badge!.id)); + expect(remaining.length).toBe(0); + }); + + it("should 404 when deleting a non-existent badge", async () => { + const response = await app.inject({ + method: "DELETE", + url: "/api/v1/admin/badges/00000000-0000-0000-0000-000000000000", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe("badge assignment and progress tracking", () => { + // Badges have no admin "assign" endpoint — they're evaluated and + // auto-awarded against a user's real activity on every call to + // GET /users/me/badges (badge.service.ts's evaluateUserBadges()), which + // is the actual assignment mechanism. Covering it here via the badge + // this suite creates through the admin API above, end to end. + const holderUserId = "b1c2d3e4-3333-4ef8-bb6d-6bb9bd380d10"; + const holderStellarAddress = + "GBADGEHOLDER0000000000000000000000000000000000000000000".slice(0, 56); + const courseId = "b1c2d3e4-4444-4ef8-bb6d-6bb9bd380d10"; + + const holderToken = () => + app.jwt.sign({ sub: holderUserId, stellarAddress: holderStellarAddress }); + + it("tracks progress toward an admin-created badge before it's earned", async () => { + await db + .insert(users) + .values({ + id: holderUserId, + stellarAddress: holderStellarAddress, + displayName: "Badge Holder", + }) + .onConflictDoNothing(); + + await db + .insert(courses) + .values({ + id: courseId, + title: "Badge Assignment Test Course", + description: "For #613 tests", + difficulty: "beginner", + isActive: true, + }) + .onConflictDoNothing(); + + const createResponse = await app.inject({ + method: "POST", + url: "/api/v1/admin/badges", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + name: "Enroll in 2 Courses", + description: "Enroll in at least 2 courses", + iconUrl: "https://example.com/icon.png", + type: "enrollment", + criteria: { count: 2 }, + }, + }); + const badgeId = JSON.parse(createResponse.payload).data.id; + createdBadgeIds.push(badgeId); + + await db + .insert(enrollments) + .values({ userId: holderUserId, courseId }) + .onConflictDoNothing(); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/users/me/badges", + headers: { authorization: `Bearer ${holderToken()}` }, + }); + + const body = JSON.parse(response.payload); + const found = body.data.unearned.find((b: { id: string }) => b.id === badgeId); + expect(found).toBeDefined(); + expect(found.progress.current).toBe(1); + expect(found.progress.target).toBe(2); + }); + + it("auto-assigns the badge once its criteria are met, and the admin API still reports it as a real badge", async () => { + const createResponse = await app.inject({ + method: "POST", + url: "/api/v1/admin/badges", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + name: "Enroll in 1 Course (assignment test)", + description: "Enroll in at least 1 course", + iconUrl: "https://example.com/icon.png", + type: "enrollment", + criteria: { count: 1 }, + }, + }); + const badgeId = JSON.parse(createResponse.payload).data.id; + createdBadgeIds.push(badgeId); + + const response = await app.inject({ + method: "GET", + url: "/api/v1/users/me/badges", + headers: { authorization: `Bearer ${holderToken()}` }, + }); + + const body = JSON.parse(response.payload); + const earned = body.data.earned.find((b: { id: string }) => b.id === badgeId); + expect(earned).toBeDefined(); + expect(earned.earnedAt).toBeDefined(); + + // The admin API's view of the same badge is unaffected by it having + // been earned by a user — it's still just the definition. + const adminGetResponse = await app.inject({ + method: "GET", + url: `/api/v1/admin/badges/${badgeId}`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + expect(adminGetResponse.statusCode).toBe(200); + expect(JSON.parse(adminGetResponse.payload).data.id).toBe(badgeId); + }); + }); +}); diff --git a/tests/e2e/admin-courses.test.ts b/tests/e2e/admin-courses.test.ts new file mode 100644 index 0000000..7c3e7d7 --- /dev/null +++ b/tests/e2e/admin-courses.test.ts @@ -0,0 +1,406 @@ +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, courses } from "../../src/database/schema.js"; + +describe("Admin Course Management API (#292)", () => { + let app: FastifyInstance; + + const adminUserId = "a1c2d3e4-1111-4ef8-bb6d-6bb9bd380c10"; + const adminStellarAddress = "GADMINCOURSE0000000000000000000000000000000000000000000".slice( + 0, + 56, + ); + const nonAdminUserId = "a1c2d3e4-2222-4ef8-bb6d-6bb9bd380c10"; + const nonAdminStellarAddress = + "GNONADMINCOURSE000000000000000000000000000000000000000000".slice(0, 56); + + const createdCourseIds: string[] = []; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + + await db + .insert(users) + .values({ + id: adminUserId, + stellarAddress: adminStellarAddress, + displayName: "Course Admin", + isAdmin: true, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: nonAdminUserId, + stellarAddress: nonAdminStellarAddress, + displayName: "Course Non-Admin", + isAdmin: false, + }) + .onConflictDoNothing(); + }); + + afterAll(async () => { + for (const id of createdCourseIds) { + await db.delete(courses).where(eq(courses.id, id)); + } + await db.delete(users).where(eq(users.id, adminUserId)); + await db.delete(users).where(eq(users.id, nonAdminUserId)); + await app.close(); + }); + + const adminToken = () => + app.jwt.sign({ sub: adminUserId, stellarAddress: adminStellarAddress }); + + const nonAdminToken = () => + app.jwt.sign({ sub: nonAdminUserId, stellarAddress: nonAdminStellarAddress }); + + describe("auth", () => { + it("should reject unauthenticated requests", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/courses", + payload: { title: "x", description: "x" }, + }); + + 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/courses", + headers: { authorization: `Bearer ${nonAdminToken()}` }, + payload: { title: "x", description: "x" }, + }); + + expect(response.statusCode).toBe(403); + }); + }); + + describe("course CRUD", () => { + it("should create a course", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/courses", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + title: "Intro to Stellar", + description: "Learn the basics of the Stellar network.", + difficulty: "beginner", + }, + }); + + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data.title).toBe("Intro to Stellar"); + expect(body.data.isActive).toBe(true); + createdCourseIds.push(body.data.id); + }); + + it("should reject creation with a missing required field", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/courses", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { title: "No description" }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should update a course", async () => { + const [course] = await db + .insert(courses) + .values({ title: "Original title", description: "Original description" }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "PUT", + url: `/api/v1/admin/courses/${course!.id}`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { title: "Updated title" }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data.title).toBe("Updated title"); + expect(body.data.description).toBe("Original description"); + }); + + it("should 404 when updating a non-existent course", async () => { + const response = await app.inject({ + method: "PUT", + url: "/api/v1/admin/courses/00000000-0000-0000-0000-000000000000", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { title: "Does not exist" }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should soft-delete a course", async () => { + const [course] = await db + .insert(courses) + .values({ title: "To delete", description: "Will be deactivated", isActive: true }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "DELETE", + url: `/api/v1/admin/courses/${course!.id}`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + + const [reloaded] = await db.select().from(courses).where(eq(courses.id, course!.id)); + expect(reloaded!.isActive).toBe(false); + }); + }); + + describe("module management", () => { + it("should create a module for a course", async () => { + const [course] = await db + .insert(courses) + .values({ title: "Module Test Course", description: "For module tests" }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/modules`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { title: "Module 1", description: "First module" }, + }); + + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data.title).toBe("Module 1"); + }); + + it("should update a module", async () => { + const [course] = await db + .insert(courses) + .values({ title: "Module Update Course", description: "For module update tests" }) + .returning(); + createdCourseIds.push(course!.id); + + const createResponse = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/modules`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { title: "Original Module", description: "desc" }, + }); + const moduleId = JSON.parse(createResponse.payload).data.id; + + const response = await app.inject({ + method: "PUT", + url: `/api/v1/admin/courses/${course!.id}/modules/${moduleId}`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { title: "Renamed Module" }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data.title).toBe("Renamed Module"); + }); + + it("should delete a module", async () => { + const [course] = await db + .insert(courses) + .values({ title: "Module Delete Course", description: "For module delete tests" }) + .returning(); + createdCourseIds.push(course!.id); + + const createResponse = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/modules`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { title: "Doomed Module", description: "desc" }, + }); + const moduleId = JSON.parse(createResponse.payload).data.id; + + const response = await app.inject({ + method: "DELETE", + url: `/api/v1/admin/courses/${course!.id}/modules/${moduleId}`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + }); + }); + + describe("content management", () => { + it("should create a text content item within a module", async () => { + const [course] = await db + .insert(courses) + .values({ title: "Content Test Course", description: "For content tests" }) + .returning(); + createdCourseIds.push(course!.id); + + const moduleResponse = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/modules`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { title: "Content Module", description: "desc" }, + }); + const moduleId = JSON.parse(moduleResponse.payload).data.id; + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + title: "Lesson text", + type: "text", + content: { body: "Welcome to the lesson." }, + }, + }); + + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data.title).toBe("Lesson text"); + }); + + it("should list content items for a module", async () => { + const [course] = await db + .insert(courses) + .values({ title: "Content List Course", description: "For content list tests" }) + .returning(); + createdCourseIds.push(course!.id); + + const moduleResponse = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/modules`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { title: "Listing Module", description: "desc" }, + }); + const moduleId = JSON.parse(moduleResponse.payload).data.id; + + await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + title: "Lesson text", + type: "text", + content: { body: "Content body." }, + }, + }); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.length).toBeGreaterThanOrEqual(1); + }); + + it("should delete a content item", async () => { + const [course] = await db + .insert(courses) + .values({ title: "Content Delete Course", description: "For content delete tests" }) + .returning(); + createdCourseIds.push(course!.id); + + const moduleResponse = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/modules`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { title: "Deletable Content Module", description: "desc" }, + }); + const moduleId = JSON.parse(moduleResponse.payload).data.id; + + const contentResponse = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + title: "Doomed content", + type: "text", + content: { body: "Will be deleted." }, + }, + }); + const contentId = JSON.parse(contentResponse.payload).data.id; + + const response = await app.inject({ + method: "DELETE", + url: `/api/v1/admin/courses/${course!.id}/modules/${moduleId}/content/${contentId}`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + }); + }); + + describe("course publishing", () => { + it("publish-check should report a brand-new course as not ready (no modules yet)", async () => { + const [course] = await db + .insert(courses) + .values({ title: "Unready Course", description: "Has no modules" }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/publish-check`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data.ready).toBe(false); + expect( + body.data.issues.some((issue: { field: string }) => issue.field === "modules"), + ).toBe(true); + }); + + it("should reject publishing a course that fails its readiness check", async () => { + const [course] = await db + .insert(courses) + .values({ title: "Cannot Publish Yet", description: "Has no modules" }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/publish`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should archive a course", async () => { + const [course] = await db + .insert(courses) + .values({ title: "To Archive", description: "Will be archived", isActive: true }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/archive`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const [reloaded] = await db.select().from(courses).where(eq(courses.id, course!.id)); + expect(reloaded!.isActive).toBe(false); + expect(reloaded!.archivedAt).not.toBeNull(); + }); + }); +}); diff --git a/tests/unit/middleware/deprecation.test.ts b/tests/unit/middleware/deprecation.test.ts new file mode 100644 index 0000000..ed4e21f --- /dev/null +++ b/tests/unit/middleware/deprecation.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi } from "vitest"; + +/** + * #583: deprecationHeader(version, sunsetDate) is a preHandler factory that + * attaches RFC 8594 Deprecation/Sunset/Link headers to a response. It is + * attached per-route by whoever wires up a deprecated endpoint; confirmed + * via a repo-wide search that no route currently does, so there is no + * "endpoint matching" logic to test beyond the factory itself — each call + * site decides which endpoint gets deprecated by whether it attaches this + * preHandler at all. + */ + +import { deprecationHeader } from "../../../src/middleware/deprecation.js"; + +function buildReply() { + const headers: Record = {}; + const reply = { + header: vi.fn((name: string, value: string) => { + headers[name] = value; + return reply; + }), + }; + return { reply, headers }; +} + +describe("deprecationHeader (#583)", () => { + it("sets the Deprecation header to 'true' (RFC 8594)", async () => { + const { reply, headers } = buildReply(); + const handler = deprecationHeader("1", "2026-12-31T00:00:00Z"); + + await handler( + {} as any, + reply as any, + ); + + expect(headers["Deprecation"]).toBe("true"); + }); + + it("sets the Sunset header as an HTTP-date (RFC 8594)", async () => { + const { reply, headers } = buildReply(); + const handler = deprecationHeader("1", "2026-12-31T00:00:00Z"); + + await handler({} as any, reply as any); + + expect(headers["Sunset"]).toBe(new Date("2026-12-31T00:00:00Z").toUTCString()); + // RFC 7231 HTTP-date format, e.g. "Thu, 31 Dec 2026 00:00:00 GMT" + expect(headers["Sunset"]).toMatch( + /^[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/ + ); + }); + + it("points the Link header's successor-version at version + 1", async () => { + const { reply, headers } = buildReply(); + const handler = deprecationHeader("1", "2026-12-31T00:00:00Z"); + + await handler({} as any, reply as any); + + expect(headers["Link"]).toBe('; rel="successor-version"'); + }); + + it("computes the correct successor version for a two-digit version string", async () => { + const { reply, headers } = buildReply(); + const handler = deprecationHeader("9", "2026-12-31T00:00:00Z"); + + await handler({} as any, reply as any); + + expect(headers["Link"]).toBe('; rel="successor-version"'); + }); + + it("produces a far-future Sunset header unchanged for any valid ISO date input", async () => { + const { reply, headers } = buildReply(); + const handler = deprecationHeader("2", "2030-01-15T12:30:00Z"); + + await handler({} as any, reply as any); + + expect(headers["Sunset"]).toBe(new Date("2030-01-15T12:30:00Z").toUTCString()); + }); + + it("sets all three headers on every call, independent of request contents", async () => { + const { reply, headers } = buildReply(); + const handler = deprecationHeader("1", "2026-06-01T00:00:00Z"); + + await handler({ method: "POST", url: "/api/v1/anything" } as any, reply as any); + + expect(Object.keys(headers).sort()).toEqual(["Deprecation", "Link", "Sunset"]); + }); +});