diff --git a/tests/e2e/admin-course-analytics.test.ts b/tests/e2e/admin-course-analytics.test.ts new file mode 100644 index 0000000..59bd0e6 --- /dev/null +++ b/tests/e2e/admin-course-analytics.test.ts @@ -0,0 +1,425 @@ +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 } from "../../src/database/schema.js"; + +describe("Admin Course Analytics API (#654)", () => { + let app: FastifyInstance; + + const adminUserId = "b1c2d3e4-1111-4ef8-bb6d-6bb9bd380b54"; + const adminStellarAddress = + "GADMINANALYTICS00000000000000000000000000000000000000000".slice(0, 56); + const nonAdminUserId = "b1c2d3e4-2222-4ef8-bb6d-6bb9bd380b54"; + const nonAdminStellarAddress = + "GNONADMINANALYTICS000000000000000000000000000000000000000".slice(0, 56); + + const enrolledUserAId = "b1c2d3e4-3333-4ef8-bb6d-6bb9bd380b54"; + const enrolledUserAStellar = + "GANALYTICSUSERAAA0000000000000000000000000000000000000000".slice(0, 56); + + const createdCourseIds: string[] = []; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + + await db + .insert(users) + .values({ + id: adminUserId, + stellarAddress: adminStellarAddress, + displayName: "Analytics Admin", + isAdmin: true, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: nonAdminUserId, + stellarAddress: nonAdminStellarAddress, + displayName: "Analytics Non-Admin", + isAdmin: false, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: enrolledUserAId, + stellarAddress: enrolledUserAStellar, + displayName: "Analytics Enrolled User A", + }) + .onConflictDoNothing(); + }); + + afterAll(async () => { + for (const id of createdCourseIds) { + await db.delete(enrollments).where(eq(enrollments.courseId, id)); + await db.delete(courses).where(eq(courses.id, id)); + } + await db.delete(users).where(eq(users.id, enrolledUserAId)); + 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 analytics requests with 401", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Analytics Auth Course", + description: "For auth tests", + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/analytics`, + }); + + expect(response.statusCode).toBe(401); + }); + + it("should reject non-admin analytics requests with 403", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Analytics Non-Admin Course", + description: "For 403 tests", + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/analytics`, + headers: { authorization: `Bearer ${nonAdminToken()}` }, + }); + + expect(response.statusCode).toBe(403); + }); + }); + + describe("course analytics endpoint", () => { + it("should return analytics data for a valid course", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Analytics Test Course", + description: "For analytics tests", + isActive: true, + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/analytics`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data).toBeDefined(); + }); + + it("should return 404 for a non-existent course", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/courses/00000000-0000-0000-0000-000000000000/analytics", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should include enrollment trend data in the analytics response", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Enrollment Trends Analytics Course", + description: "For trend tests", + isActive: true, + }) + .returning(); + createdCourseIds.push(course!.id); + + await db + .insert(enrollments) + .values({ userId: enrolledUserAId, courseId: course!.id }) + .onConflictDoNothing(); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/analytics`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data).toHaveProperty("totalEnrollments"); + }); + + it("should return zero enrollments for a brand-new course", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Zero Enrollments Analytics Course", + description: "For zero count tests", + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/analytics`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data.totalEnrollments).toBe(0); + }); + + it("should return 400 for an invalid course ID format", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/courses/not-a-uuid/analytics", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe("enrollment trends", () => { + it("should return enrollment trends with default range and granularity", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Trends Default Course", + description: "For default trend tests", + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/enrollment-trends`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data).toBeDefined(); + }); + + it("should return trends for 7d range with daily granularity", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Trends 7d Daily Course", + description: "For 7d daily trends", + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/enrollment-trends?range=7d&granularity=daily`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data).toBeDefined(); + }); + + it("should return trends for 30d range with weekly granularity", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Trends 30d Weekly Course", + description: "For 30d weekly trends", + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/enrollment-trends?range=30d&granularity=weekly`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data).toBeDefined(); + }); + + it("should return trends for 90d range with monthly granularity", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Trends 90d Monthly Course", + description: "For 90d monthly trends", + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/enrollment-trends?range=90d&granularity=monthly`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + }); + + it("should reject an invalid range parameter", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Trends Invalid Range Course", + description: "For invalid range tests", + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/enrollment-trends?range=1y`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should reject an invalid granularity parameter", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Trends Invalid Granularity Course", + description: "For invalid granularity", + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/enrollment-trends?granularity=hourly`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe("module engagement metrics", () => { + it("should return engagement metrics including completion rate", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Engagement Metrics Course", + description: "For engagement tests", + isActive: true, + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/engagement`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data).toHaveProperty("completionRate"); + }); + + it("should include per-module drop-off data in engagement response", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Dropoff Metrics Course", + description: "For drop-off tests", + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/engagement`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data).toHaveProperty("moduleDropOff"); + }); + + it("should reject unauthenticated engagement requests with 401", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Engagement Auth Course", + description: "For auth tests", + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/engagement`, + }); + + expect(response.statusCode).toBe(401); + }); + + it("should reject non-admin engagement requests with 403", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Engagement Non-Admin Course", + description: "For 403 tests", + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/engagement`, + headers: { authorization: `Bearer ${nonAdminToken()}` }, + }); + + expect(response.statusCode).toBe(403); + }); + + it("should return 404 for a non-existent course engagement", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/courses/00000000-0000-0000-0000-000000000000/engagement", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + }); +}); diff --git a/tests/e2e/admin-course-content.test.ts b/tests/e2e/admin-course-content.test.ts new file mode 100644 index 0000000..5ce6fa2 --- /dev/null +++ b/tests/e2e/admin-course-content.test.ts @@ -0,0 +1,434 @@ +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 Content Management API (#655)", () => { + let app: FastifyInstance; + + const adminUserId = "f1c2d3e4-1111-4ef8-bb6d-6bb9bd380f55"; + const adminStellarAddress = + "GADMINCONTENT00000000000000000000000000000000000000000000".slice(0, 56); + const nonAdminUserId = "f1c2d3e4-2222-4ef8-bb6d-6bb9bd380f55"; + const nonAdminStellarAddress = + "GNONADMINCONTENT0000000000000000000000000000000000000000".slice(0, 56); + + const createdCourseIds: string[] = []; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + + await db + .insert(users) + .values({ + id: adminUserId, + stellarAddress: adminStellarAddress, + displayName: "Content Admin", + isAdmin: true, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: nonAdminUserId, + stellarAddress: nonAdminStellarAddress, + displayName: "Content 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, + }); + + /** Helper: creates a course and a single module, returns { courseId, moduleId } */ + const setupCourseAndModule = async (titleSuffix: string) => { + const [course] = await db + .insert(courses) + .values({ + title: `Content Test Course ${titleSuffix}`, + description: "For content management 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: `Module for ${titleSuffix}`, description: "desc" }, + }); + const moduleId = JSON.parse(moduleResponse.payload).data.id; + + return { courseId: course!.id, moduleId }; + }; + + describe("auth", () => { + it("should reject unauthenticated content creation with 401", async () => { + const { courseId, moduleId } = await setupCourseAndModule("AuthCheck"); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content`, + payload: { + title: "Unauthorized", + type: "text", + content: { body: "x" }, + }, + }); + + expect(response.statusCode).toBe(401); + }); + + it("should reject non-admin content creation with 403", async () => { + const { courseId, moduleId } = + await setupCourseAndModule("NonAdminCheck"); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${nonAdminToken()}` }, + payload: { title: "Forbidden", type: "text", content: { body: "x" } }, + }); + + expect(response.statusCode).toBe(403); + }); + }); + + describe("content CRUD", () => { + it("should create a text content item", async () => { + const { courseId, moduleId } = await setupCourseAndModule("CreateText"); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + title: "Text Lesson", + type: "text", + content: { body: "This is the lesson body." }, + }, + }); + + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data.title).toBe("Text Lesson"); + expect(body.data.type).toBe("text"); + }); + + it("should create a video content item", async () => { + const { courseId, moduleId } = await setupCourseAndModule("CreateVideo"); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + title: "Video Lesson", + type: "video", + content: { url: "https://example.com/video.mp4" }, + }, + }); + + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.payload); + expect(body.data.type).toBe("video"); + }); + + it("should create a quiz content item", async () => { + const { courseId, moduleId } = await setupCourseAndModule("CreateQuiz"); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + title: "Quiz Content", + type: "quiz", + content: { quizId: "some-quiz-ref" }, + }, + }); + + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.payload); + expect(body.data.type).toBe("quiz"); + }); + + it("should create an exercise content item", async () => { + const { courseId, moduleId } = + await setupCourseAndModule("CreateExercise"); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + title: "Exercise Content", + type: "exercise", + content: { instructions: "Do this exercise." }, + }, + }); + + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.payload); + expect(body.data.type).toBe("exercise"); + }); + + it("should reject creation with an invalid content type", async () => { + const { courseId, moduleId } = await setupCourseAndModule("InvalidType"); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + title: "Invalid Type", + type: "markdown", + content: { body: "x" }, + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should reject creation with missing required fields", async () => { + const { courseId, moduleId } = + await setupCourseAndModule("MissingFields"); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { title: "No type or content" }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should list content items for a module", async () => { + const { courseId, moduleId } = await setupCourseAndModule("ListContent"); + + await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + title: "Item One", + type: "text", + content: { body: "First." }, + }, + }); + await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + title: "Item Two", + type: "text", + content: { body: "Second." }, + }, + }); + + const listResponse = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(listResponse.statusCode).toBe(200); + const body = JSON.parse(listResponse.payload); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.length).toBeGreaterThanOrEqual(2); + }); + + it("should return an empty list for a module with no content", async () => { + const { courseId, moduleId } = await setupCourseAndModule("EmptyContent"); + + const listResponse = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(listResponse.statusCode).toBe(200); + const body = JSON.parse(listResponse.payload); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data).toHaveLength(0); + }); + + it("should update a content item's title", async () => { + const { courseId, moduleId } = + await setupCourseAndModule("UpdateContent"); + + const createResponse = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + title: "Original Title", + type: "text", + content: { body: "Original body." }, + }, + }); + const contentId = JSON.parse(createResponse.payload).data.id; + + const updateResponse = await app.inject({ + method: "PUT", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content/${contentId}`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { title: "Updated Title" }, + }); + + expect(updateResponse.statusCode).toBe(200); + const body = JSON.parse(updateResponse.payload); + expect(body.data.title).toBe("Updated Title"); + }); + + it("should update a content item's body", async () => { + const { courseId, moduleId } = + await setupCourseAndModule("UpdateContentBody"); + + const createResponse = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + title: "Body Update Lesson", + type: "text", + content: { body: "Old body." }, + }, + }); + const contentId = JSON.parse(createResponse.payload).data.id; + + const updateResponse = await app.inject({ + method: "PUT", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content/${contentId}`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { content: { body: "New body." } }, + }); + + expect(updateResponse.statusCode).toBe(200); + }); + + it("should delete a content item", async () => { + const { courseId, moduleId } = + await setupCourseAndModule("DeleteContent"); + + const createResponse = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + title: "Doomed Content", + type: "text", + content: { body: "Will be deleted." }, + }, + }); + const contentId = JSON.parse(createResponse.payload).data.id; + + const deleteResponse = await app.inject({ + method: "DELETE", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content/${contentId}`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(deleteResponse.statusCode).toBe(200); + }); + + it("should return 404 when deleting a non-existent content item", async () => { + const { courseId, moduleId } = + await setupCourseAndModule("DeleteNotFound"); + + const response = await app.inject({ + method: "DELETE", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content/00000000-0000-0000-0000-000000000000`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe("content reordering", () => { + it("should reorder content items within a module atomically", async () => { + const { courseId, moduleId } = + await setupCourseAndModule("ReorderContent"); + + const item1Response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + title: "Content Item 1", + type: "text", + content: { body: "First item." }, + }, + }); + const contentId1 = JSON.parse(item1Response.payload).data.id; + + const item2Response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + title: "Content Item 2", + type: "text", + content: { body: "Second item." }, + }, + }); + const contentId2 = JSON.parse(item2Response.payload).data.id; + + const reorderResponse = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content/reorder`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { contentIds: [contentId2, contentId1] }, + }); + + expect(reorderResponse.statusCode).toBe(200); + const body = JSON.parse(reorderResponse.payload); + expect(body.success).toBe(true); + }); + + it("should create content with an explicit orderIndex", async () => { + const { courseId, moduleId } = + await setupCourseAndModule("OrderIndexContent"); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${courseId}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + title: "Ordered Content", + type: "text", + content: { body: "At position 2." }, + orderIndex: 2, + }, + }); + + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.payload); + expect(body.data.title).toBe("Ordered Content"); + }); + }); +}); diff --git a/tests/e2e/admin-course-enrolled-users.test.ts b/tests/e2e/admin-course-enrolled-users.test.ts new file mode 100644 index 0000000..f47a643 --- /dev/null +++ b/tests/e2e/admin-course-enrolled-users.test.ts @@ -0,0 +1,367 @@ +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 } from "../../src/database/schema.js"; + +describe("Admin Course Enrolled Users API (#657)", () => { + let app: FastifyInstance; + + const adminUserId = "d1c2d3e4-1111-4ef8-bb6d-6bb9bd380d57"; + const adminStellarAddress = + "GADMINCEUSER000000000000000000000000000000000000000000000".slice(0, 56); + const nonAdminUserId = "d1c2d3e4-2222-4ef8-bb6d-6bb9bd380d57"; + const nonAdminStellarAddress = + "GNONADMINCEUSER00000000000000000000000000000000000000000".slice(0, 56); + + const enrolledUserAId = "d1c2d3e4-3333-4ef8-bb6d-6bb9bd380d57"; + const enrolledUserAStellar = + "GENROLLEDUSERAA0000000000000000000000000000000000000000".slice(0, 56); + const enrolledUserBId = "d1c2d3e4-4444-4ef8-bb6d-6bb9bd380d57"; + const enrolledUserBStellar = + "GENROLLEDUSERBBB0000000000000000000000000000000000000000".slice(0, 56); + + const createdCourseIds: string[] = []; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + + await db + .insert(users) + .values({ + id: adminUserId, + stellarAddress: adminStellarAddress, + displayName: "Enrolled Users Admin", + isAdmin: true, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: nonAdminUserId, + stellarAddress: nonAdminStellarAddress, + displayName: "Enrolled Users Non-Admin", + isAdmin: false, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values([ + { + id: enrolledUserAId, + stellarAddress: enrolledUserAStellar, + displayName: "Enrolled User A", + }, + { + id: enrolledUserBId, + stellarAddress: enrolledUserBStellar, + displayName: "Enrolled User B", + }, + ]) + .onConflictDoNothing(); + }); + + afterAll(async () => { + for (const id of createdCourseIds) { + await db.delete(enrollments).where(eq(enrollments.courseId, id)); + await db.delete(courses).where(eq(courses.id, id)); + } + await db.delete(users).where(eq(users.id, enrolledUserAId)); + await db.delete(users).where(eq(users.id, enrolledUserBId)); + 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 with 401", async () => { + const [course] = await db + .insert(courses) + .values({ title: "Auth Test Course EU", description: "For auth tests" }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/enrolled-users`, + }); + + expect(response.statusCode).toBe(401); + }); + + it("should reject non-admin users with 403", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Non-Admin Test Course EU", + description: "For 403 tests", + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/enrolled-users`, + headers: { authorization: `Bearer ${nonAdminToken()}` }, + }); + + expect(response.statusCode).toBe(403); + }); + }); + + describe("listing enrolled users", () => { + it("should return an empty list for a course with no enrollments", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "No Enrollments Course", + description: "Zero enrolled", + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/enrolled-users`, + 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.users)).toBe(true); + expect(body.data.total).toBe(0); + expect(body.data.users).toHaveLength(0); + }); + + it("should list enrolled users with quiz progress fields", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Enrolled Users List Course", + description: "For listing tests", + }) + .returning(); + createdCourseIds.push(course!.id); + + await db + .insert(enrollments) + .values({ userId: enrolledUserAId, courseId: course!.id }) + .onConflictDoNothing(); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/enrolled-users`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data.total).toBe(1); + expect(body.data.users).toHaveLength(1); + + const user = body.data.users[0]; + expect(user.userId).toBe(enrolledUserAId); + expect(user).toHaveProperty("quizCount"); + expect(user).toHaveProperty("averageScore"); + }); + + it("should list multiple enrolled users", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Multi Enrolled Users Course", + description: "For multi-user listing", + }) + .returning(); + createdCourseIds.push(course!.id); + + await db + .insert(enrollments) + .values([ + { userId: enrolledUserAId, courseId: course!.id }, + { userId: enrolledUserBId, courseId: course!.id }, + ]) + .onConflictDoNothing(); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/enrolled-users`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data.total).toBe(2); + expect(body.data.users).toHaveLength(2); + }); + + it("should return 404 for a non-existent course", async () => { + const response = await app.inject({ + method: "GET", + url: "/api/v1/admin/courses/00000000-0000-0000-0000-000000000000/enrolled-users", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe("pagination", () => { + it("should paginate enrolled users with page and limit query params", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Pagination Test Course EU", + description: "For pagination tests", + }) + .returning(); + createdCourseIds.push(course!.id); + + await db + .insert(enrollments) + .values([ + { userId: enrolledUserAId, courseId: course!.id }, + { userId: enrolledUserBId, courseId: course!.id }, + ]) + .onConflictDoNothing(); + + const page1 = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/enrolled-users?page=1&limit=1`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(page1.statusCode).toBe(200); + const body1 = JSON.parse(page1.payload); + expect(body1.data.total).toBe(2); + expect(body1.data.users).toHaveLength(1); + + const page2 = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/enrolled-users?page=2&limit=1`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(page2.statusCode).toBe(200); + const body2 = JSON.parse(page2.payload); + expect(body2.data.total).toBe(2); + expect(body2.data.users).toHaveLength(1); + + // The two pages should return different users + expect(body1.data.users[0].userId).not.toBe(body2.data.users[0].userId); + }); + + it("should use default pagination when no params are given", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Default Pagination Course EU", + description: "For default pagination", + }) + .returning(); + createdCourseIds.push(course!.id); + + await db + .insert(enrollments) + .values({ userId: enrolledUserAId, courseId: course!.id }) + .onConflictDoNothing(); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/enrolled-users`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.data).toHaveProperty("users"); + expect(body.data).toHaveProperty("total"); + }); + + it("should reject invalid pagination params (page=0)", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Invalid Pagination Course EU", + description: "For invalid params", + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/enrolled-users?page=0`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should reject limit exceeding maximum (limit=51)", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Limit Exceed Course EU", + description: "For limit exceed tests", + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/enrolled-users?limit=51`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe("progress tracking", () => { + it("should include quizCount of 0 and null averageScore for users with no quiz submissions", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "No Quizzes Course EU", + description: "For no-quiz progress tests", + }) + .returning(); + createdCourseIds.push(course!.id); + + await db + .insert(enrollments) + .values({ userId: enrolledUserBId, courseId: course!.id }) + .onConflictDoNothing(); + + const response = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/enrolled-users`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + const user = body.data.users.find( + (u: { userId: string }) => u.userId === enrolledUserBId, + ); + expect(user).toBeDefined(); + expect(user.quizCount).toBe(0); + expect(user.averageScore).toBeNull(); + }); + }); +}); diff --git a/tests/e2e/admin-course-modules.test.ts b/tests/e2e/admin-course-modules.test.ts new file mode 100644 index 0000000..cb5efed --- /dev/null +++ b/tests/e2e/admin-course-modules.test.ts @@ -0,0 +1,411 @@ +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 Module Management API (#656)", () => { + let app: FastifyInstance; + + const adminUserId = "e1c2d3e4-1111-4ef8-bb6d-6bb9bd380e56"; + const adminStellarAddress = + "GADMINMODULE000000000000000000000000000000000000000000000".slice(0, 56); + const nonAdminUserId = "e1c2d3e4-2222-4ef8-bb6d-6bb9bd380e56"; + const nonAdminStellarAddress = + "GNONADMINMODULE000000000000000000000000000000000000000000".slice(0, 56); + + const createdCourseIds: string[] = []; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + + await db + .insert(users) + .values({ + id: adminUserId, + stellarAddress: adminStellarAddress, + displayName: "Module Admin", + isAdmin: true, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: nonAdminUserId, + stellarAddress: nonAdminStellarAddress, + displayName: "Module 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 with 401", async () => { + const [course] = await db + .insert(courses) + .values({ title: "Auth Module Course", description: "For auth tests" }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/modules`, + payload: { title: "Unauthorized Module" }, + }); + + expect(response.statusCode).toBe(401); + }); + + it("should reject non-admin users with 403", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Non-Admin Module Course", + description: "For 403 tests", + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/modules`, + headers: { authorization: `Bearer ${nonAdminToken()}` }, + payload: { title: "Forbidden Module" }, + }); + + expect(response.statusCode).toBe(403); + }); + }); + + describe("module CRUD", () => { + it("should create a module for a course", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Create Module Course", + description: "For create 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 Alpha", description: "First test module" }, + }); + + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data.title).toBe("Module Alpha"); + }); + + it("should create a module with an optional order field", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Ordered Module Course", + description: "For order 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: "Ordered Module", + description: "With explicit order", + order: 0, + }, + }); + + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.payload); + expect(body.data.title).toBe("Ordered Module"); + }); + + it("should reject module creation without a title", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "No Title Module Course", + description: "For validation 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: { description: "Missing title" }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should return 404 when creating a module for a non-existent course", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/courses/00000000-0000-0000-0000-000000000000/modules", + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { title: "Ghost Module" }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should update a module's title", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Update Module Course", + description: "For update module 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 Title", description: "desc" }, + }); + const moduleId = JSON.parse(createResponse.payload).data.id; + + const updateResponse = await app.inject({ + method: "PUT", + url: `/api/v1/admin/courses/${course!.id}/modules/${moduleId}`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { title: "Updated Module Title" }, + }); + + expect(updateResponse.statusCode).toBe(200); + const body = JSON.parse(updateResponse.payload); + expect(body.data.title).toBe("Updated Module Title"); + }); + + it("should update a module's description", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Update Desc Module Course", + description: "For desc 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: "Desc Module", description: "Original description" }, + }); + const moduleId = JSON.parse(createResponse.payload).data.id; + + const updateResponse = await app.inject({ + method: "PUT", + url: `/api/v1/admin/courses/${course!.id}/modules/${moduleId}`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { description: "New description" }, + }); + + expect(updateResponse.statusCode).toBe(200); + const body = JSON.parse(updateResponse.payload); + expect(body.data.description).toBe("New description"); + }); + + it("should delete a module", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Delete Module Course", + description: "For delete module 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: "Module To Delete", description: "Will be removed" }, + }); + const moduleId = JSON.parse(createResponse.payload).data.id; + + const deleteResponse = await app.inject({ + method: "DELETE", + url: `/api/v1/admin/courses/${course!.id}/modules/${moduleId}`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(deleteResponse.statusCode).toBe(200); + }); + + it("should return 404 when deleting a non-existent module", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Ghost Delete Module Course", + description: "For 404 delete tests", + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "DELETE", + url: `/api/v1/admin/courses/${course!.id}/modules/nonexistent-module-id`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe("module reordering", () => { + it("should reorder modules atomically", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Reorder Modules Course", + description: "For reorder tests", + }) + .returning(); + createdCourseIds.push(course!.id); + + const mod1Response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/modules`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { title: "Module One", description: "First" }, + }); + const moduleOneId = JSON.parse(mod1Response.payload).data.id; + + const mod2Response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/modules`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { title: "Module Two", description: "Second" }, + }); + const moduleTwoId = JSON.parse(mod2Response.payload).data.id; + + const reorderResponse = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/modules/reorder`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { moduleIds: [moduleTwoId, moduleOneId] }, + }); + + expect(reorderResponse.statusCode).toBe(200); + const body = JSON.parse(reorderResponse.payload); + expect(body.success).toBe(true); + }); + + it("should reject reorder with an empty moduleIds array", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Empty Reorder Course", + description: "For empty reorder tests", + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/modules/reorder`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { moduleIds: [] }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should reject reorder with a missing moduleIds field", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Missing ModuleIds Course", + description: "For missing field tests", + }) + .returning(); + createdCourseIds.push(course!.id); + + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/modules/reorder`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: {}, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe("module content listing", () => { + it("should list content items within a module", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Module Content List Course", + description: "For content listing", + }) + .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 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: "A content item", + type: "text", + content: { body: "Hello from module listing test." }, + }, + }); + + const listResponse = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}/modules/${moduleId}/content`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(listResponse.statusCode).toBe(200); + const body = JSON.parse(listResponse.payload); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.length).toBeGreaterThanOrEqual(1); + }); + }); +});