From df1d43ffba60a96f80f374d7ae17a5d569b4837f Mon Sep 17 00:00:00 2001 From: Chibuikem Michael Ilonze Date: Wed, 21 Oct 2026 23:09:52 +0100 Subject: [PATCH] test(e2e): add admin course archive, publish, clone, and import test suites Closes #653, Closes #652, Closes #651, Closes #650 --- tests/e2e/admin-course-archive.test.ts | 318 +++++++++++++++++++++++++ tests/e2e/admin-course-clone.test.ts | 239 +++++++++++++++++++ tests/e2e/admin-course-import.test.ts | 287 ++++++++++++++++++++++ tests/e2e/admin-course-publish.test.ts | 287 ++++++++++++++++++++++ 4 files changed, 1131 insertions(+) create mode 100644 tests/e2e/admin-course-archive.test.ts create mode 100644 tests/e2e/admin-course-clone.test.ts create mode 100644 tests/e2e/admin-course-import.test.ts create mode 100644 tests/e2e/admin-course-publish.test.ts diff --git a/tests/e2e/admin-course-archive.test.ts b/tests/e2e/admin-course-archive.test.ts new file mode 100644 index 0000000..ae931e4 --- /dev/null +++ b/tests/e2e/admin-course-archive.test.ts @@ -0,0 +1,318 @@ +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 Archive E2E API (#653)", () => { + let app: FastifyInstance; + + const adminUserId = "c1a2d3e4-1111-4ef8-bb6d-6bb9bd380c10"; + const adminStellarAddress = "GADMINARCHIVE000000000000000000000000000000000000000000".slice( + 0, + 56, + ); + const nonAdminUserId = "c1a2d3e4-2222-4ef8-bb6d-6bb9bd380c10"; + const nonAdminStellarAddress = + "GNONADMINARCHIVE000000000000000000000000000000000000000".slice(0, 56); + + const learnerUserId = "c1a2d3e4-3333-4ef8-bb6d-6bb9bd380c10"; + const learnerStellarAddress = + "GLEARNERARCHIVE0000000000000000000000000000000000000000".slice(0, 56); + + const createdCourseIds: string[] = []; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + + await db + .insert(users) + .values({ + id: adminUserId, + stellarAddress: adminStellarAddress, + displayName: "Archive Admin", + isAdmin: true, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: nonAdminUserId, + stellarAddress: nonAdminStellarAddress, + displayName: "Archive Non-Admin", + isAdmin: false, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: learnerUserId, + stellarAddress: learnerStellarAddress, + displayName: "Archive Learner", + isAdmin: false, + }) + .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, adminUserId)); + await db.delete(users).where(eq(users.id, nonAdminUserId)); + await db.delete(users).where(eq(users.id, learnerUserId)); + await app.close(); + }); + + const adminToken = () => + app.jwt.sign({ sub: adminUserId, stellarAddress: adminStellarAddress }); + + const nonAdminToken = () => + app.jwt.sign({ sub: nonAdminUserId, stellarAddress: nonAdminStellarAddress }); + + const learnerToken = () => + app.jwt.sign({ sub: learnerUserId, stellarAddress: learnerStellarAddress }); + + describe("Authentication & Authorization", () => { + it("should reject unauthenticated requests with 401", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/courses/00000000-0000-0000-0000-000000000000/archive", + }); + + 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/00000000-0000-0000-0000-000000000000/archive", + headers: { authorization: `Bearer ${nonAdminToken()}` }, + }); + + expect(response.statusCode).toBe(403); + }); + + it("should return 400 for invalid UUID format", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/courses/not-a-valid-uuid/archive", + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should return 404 when archiving a non-existent course", async () => { + const nonExistentId = "ffffffff-ffff-4fff-bfff-ffffffffffff"; + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${nonExistentId}/archive`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe("Course Archiving & Unarchiving State Lifecycle", () => { + it("should archive an active course and set archivedAt timestamp", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Course to Archive", + description: "Active course testing archive state transition", + difficulty: "beginner", + isActive: true, + isDraft: false, + }) + .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 body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.message).toBe("Course archived"); + + // Verify DB record + const [updated] = await db + .select() + .from(courses) + .where(eq(courses.id, course!.id)); + + expect(updated?.isActive).toBe(false); + expect(updated?.archivedAt).not.toBeNull(); + }); + + it("should keep original archivedAt timestamp when archiving an already archived course", async () => { + const originalArchivedDate = new Date(Date.now() - 3600000); // 1 hour ago + const [course] = await db + .insert(courses) + .values({ + title: "Already Archived Course", + description: "Idempotent archive timestamp verification", + difficulty: "intermediate", + isActive: false, + archivedAt: originalArchivedDate, + }) + .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 [updated] = await db + .select() + .from(courses) + .where(eq(courses.id, course!.id)); + + // Timestamp should remain unchanged + expect(updated?.isActive).toBe(false); + expect(updated?.archivedAt?.toISOString()).toBe(originalArchivedDate.toISOString()); + }); + + it("should allow unarchiving / activating the course via update", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Archived Course to Unarchive", + description: "Testing unarchive / reactivation via update", + difficulty: "advanced", + isActive: false, + archivedAt: new Date(), + }) + .returning(); + createdCourseIds.push(course!.id); + + // Admin updates course to active + const response = await app.inject({ + method: "PUT", + url: `/api/v1/admin/courses/${course!.id}`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + isActive: true, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data.isActive).toBe(true); + + const [updated] = await db + .select() + .from(courses) + .where(eq(courses.id, course!.id)); + + expect(updated?.isActive).toBe(true); + }); + }); + + describe("Listing and Visibility Behavior", () => { + it("should exclude archived course from public course listings", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Archived Hidden From Public", + description: "This course should not appear in public list", + difficulty: "beginner", + isActive: false, + archivedAt: new Date(), + }) + .returning(); + createdCourseIds.push(course!.id); + + const listResponse = await app.inject({ + method: "GET", + url: "/api/v1/courses", + }); + + expect(listResponse.statusCode).toBe(200); + const listBody = JSON.parse(listResponse.payload); + const courseInList = listBody.data?.courses?.some( + (c: { id: string }) => c.id === course!.id, + ); + expect(courseInList).toBe(false); + }); + + it("should preserve enrolled learner access to an archived course", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Archived Enrolled Course", + description: "Enrolled users retain access", + difficulty: "beginner", + isActive: false, + archivedAt: new Date(), + }) + .returning(); + createdCourseIds.push(course!.id); + + // Enroll learner + await db + .insert(enrollments) + .values({ + userId: learnerUserId, + courseId: course!.id, + progress: 25, + }) + .onConflictDoNothing(); + + // Learner requests course detail + const detailResponse = await app.inject({ + method: "GET", + url: `/api/v1/courses/${course!.id}`, + headers: { authorization: `Bearer ${learnerToken()}` }, + }); + + expect(detailResponse.statusCode).toBe(200); + const body = JSON.parse(detailResponse.payload); + expect(body.success).toBe(true); + expect(body.data.id).toBe(course!.id); + expect(body.data.title).toBe("Archived Enrolled Course"); + }); + + it("should allow admin to inspect archived course in admin view", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Archived Admin Viewable", + description: "Admins can view archived courses", + difficulty: "intermediate", + isActive: false, + archivedAt: new Date(), + }) + .returning(); + createdCourseIds.push(course!.id); + + const adminResponse = await app.inject({ + method: "GET", + url: `/api/v1/admin/courses/${course!.id}`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(adminResponse.statusCode).toBe(200); + const body = JSON.parse(adminResponse.payload); + expect(body.success).toBe(true); + expect(body.data.id).toBe(course!.id); + expect(body.data.isActive).toBe(false); + expect(body.data.archivedAt).not.toBeNull(); + }); + }); +}); diff --git a/tests/e2e/admin-course-clone.test.ts b/tests/e2e/admin-course-clone.test.ts new file mode 100644 index 0000000..422a8ec --- /dev/null +++ b/tests/e2e/admin-course-clone.test.ts @@ -0,0 +1,239 @@ +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, moduleContent, quizzes } from "../../src/database/schema.js"; + +describe("Admin Course Clone E2E API (#651)", () => { + let app: FastifyInstance; + + const adminUserId = "k1c2d3e4-1111-4ef8-bb6d-6bb9bd380c10"; + const adminStellarAddress = "GADMINCLONE00000000000000000000000000000000000000000000".slice( + 0, + 56, + ); + const nonAdminUserId = "k1c2d3e4-2222-4ef8-bb6d-6bb9bd380c10"; + const nonAdminStellarAddress = + "GNONADMINCLONE000000000000000000000000000000000000000000".slice(0, 56); + + const createdCourseIds: string[] = []; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + + await db + .insert(users) + .values({ + id: adminUserId, + stellarAddress: adminStellarAddress, + displayName: "Clone Admin", + isAdmin: true, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: nonAdminUserId, + stellarAddress: nonAdminStellarAddress, + displayName: "Clone Non-Admin", + isAdmin: false, + }) + .onConflictDoNothing(); + }); + + afterAll(async () => { + for (const id of createdCourseIds) { + await db.delete(quizzes).where(eq(quizzes.courseId, id)); + await db.delete(moduleContent).where(eq(moduleContent.courseId, id)); + 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("Authentication & Authorization", () => { + it("should reject unauthenticated clone requests with 401", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/courses/00000000-0000-0000-0000-000000000000/clone", + payload: { title: "Cloned Course" }, + }); + + 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/00000000-0000-0000-0000-000000000000/clone", + headers: { authorization: `Bearer ${nonAdminToken()}` }, + payload: { title: "Cloned Course" }, + }); + + expect(response.statusCode).toBe(403); + }); + + it("should return 404 when attempting to clone a non-existent course", async () => { + const nonExistentId = "ffffffff-ffff-4fff-bfff-ffffffffffff"; + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${nonExistentId}/clone`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { title: "Cloned Course" }, + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe("Course Cloning, Module and Content Copying (#651 / #378)", () => { + it("should clone a course with modules, content items, and quizzes into a draft", async () => { + const sourceModuleId = "src-mod-101"; + + // 1. Create source course with modules + const [sourceCourse] = await db + .insert(courses) + .values({ + title: "Original Master Course", + description: "Full course with modules, lessons, and quiz", + difficulty: "intermediate", + tags: ["stellar", "blockchain"], + modules: [ + { + id: sourceModuleId, + title: "Source Module 1", + description: "Detailed source module description", + order: 0, + }, + ], + courseModules: [ + { + id: sourceModuleId, + title: "Source Module 1", + description: "Detailed source module description", + estimatedDurationMinutes: 45, + }, + ], + isActive: true, + isDraft: false, + }) + .returning(); + createdCourseIds.push(sourceCourse!.id); + + // 2. Add module content item + await db.insert(moduleContent).values({ + courseId: sourceCourse!.id, + moduleId: sourceModuleId, + title: "Lesson Text 1", + type: "text", + content: { body: "Original source text lesson" }, + orderIndex: 0, + }); + + // 3. Add quiz for the module + await db.insert(quizzes).values({ + courseId: sourceCourse!.id, + moduleId: sourceModuleId, + questions: [ + { + id: "q1", + text: "What consensus does Stellar use?", + options: ["SCP", "PoW", "PoS"], + correctIndex: 0, + }, + ], + }); + + // 4. Request course clone with custom title + const customTitle = "Cloned Stellar Mastery Course"; + const cloneResponse = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${sourceCourse!.id}/clone`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: { + title: customTitle, + }, + }); + + expect(cloneResponse.statusCode).toBe(200); + const cloneBody = JSON.parse(cloneResponse.payload); + expect(cloneBody.success).toBe(true); + + const clonedCourse = cloneBody.data; + expect(clonedCourse.id).toBeDefined(); + expect(clonedCourse.id).not.toBe(sourceCourse!.id); + expect(clonedCourse.title).toBe(customTitle); + expect(clonedCourse.isDraft).toBe(true); + expect(clonedCourse.isActive).toBe(false); + + createdCourseIds.push(clonedCourse.id); + + // 5. Verify module copying and remapped IDs + expect(clonedCourse.modules).toHaveLength(1); + const clonedModule = clonedCourse.modules[0]; + expect(clonedModule.id).not.toBe(sourceModuleId); + expect(clonedModule.title).toBe("Source Module 1"); + + // 6. Verify module content copied and remapped to new course and module ID + const clonedContentRows = await db + .select() + .from(moduleContent) + .where(eq(moduleContent.courseId, clonedCourse.id)); + + expect(clonedContentRows).toHaveLength(1); + expect(clonedContentRows[0]?.courseId).toBe(clonedCourse.id); + expect(clonedContentRows[0]?.moduleId).toBe(clonedModule.id); + expect(clonedContentRows[0]?.title).toBe("Lesson Text 1"); + + // 7. Verify quiz copied and remapped + const clonedQuizzes = await db + .select() + .from(quizzes) + .where(eq(quizzes.courseId, clonedCourse.id)); + + expect(clonedQuizzes).toHaveLength(1); + expect(clonedQuizzes[0]?.courseId).toBe(clonedCourse.id); + expect(clonedQuizzes[0]?.moduleId).toBe(clonedModule.id); + }); + + it("should generate default clone title when title is omitted", async () => { + const [sourceCourse] = await db + .insert(courses) + .values({ + title: "Standalone Course", + description: "Testing default title suffix", + difficulty: "beginner", + modules: [], + courseModules: [], + isActive: true, + isDraft: false, + }) + .returning(); + createdCourseIds.push(sourceCourse!.id); + + const cloneResponse = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${sourceCourse!.id}/clone`, + headers: { authorization: `Bearer ${adminToken()}` }, + payload: {}, + }); + + expect(cloneResponse.statusCode).toBe(200); + const cloneBody = JSON.parse(cloneResponse.payload); + expect(cloneBody.success).toBe(true); + expect(cloneBody.data.title).toBe("Standalone Course (Clone)"); + + createdCourseIds.push(cloneBody.data.id); + }); + }); +}); diff --git a/tests/e2e/admin-course-import.test.ts b/tests/e2e/admin-course-import.test.ts new file mode 100644 index 0000000..181f195 --- /dev/null +++ b/tests/e2e/admin-course-import.test.ts @@ -0,0 +1,287 @@ +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 Import E2E API (#650)", () => { + let app: FastifyInstance; + + const adminUserId = "m1c2d3e4-1111-4ef8-bb6d-6bb9bd380c10"; + const adminStellarAddress = "GADMINIMPORT0000000000000000000000000000000000000000000".slice( + 0, + 56, + ); + const nonAdminUserId = "m1c2d3e4-2222-4ef8-bb6d-6bb9bd380c10"; + const nonAdminStellarAddress = + "GNONADMINIMPORT0000000000000000000000000000000000000000".slice(0, 56); + + const createdCourseIds: string[] = []; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + + await db + .insert(users) + .values({ + id: adminUserId, + stellarAddress: adminStellarAddress, + displayName: "Import Admin", + isAdmin: true, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: nonAdminUserId, + stellarAddress: nonAdminStellarAddress, + displayName: "Import 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 }); + + function createMultipartPayload( + fileContent: string, + filename = "course.json", + contentType = "application/json", + ) { + const boundary = "----WebKitFormBoundaryImportCourseTest123"; + const payload = [ + `--${boundary}`, + `Content-Disposition: form-data; name="file"; filename="${filename}"`, + `Content-Type: ${contentType}`, + "", + fileContent, + `--${boundary}--`, + "", + ].join("\r\n"); + + return { + contentType: `multipart/form-data; boundary=${boundary}`, + payload, + }; + } + + describe("Authentication & Authorization", () => { + it("should reject unauthenticated requests with 401", async () => { + const { contentType, payload } = createMultipartPayload( + JSON.stringify({ title: "Unauth Course", description: "Desc" }), + ); + + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/courses/import", + headers: { "content-type": contentType }, + payload, + }); + + expect(response.statusCode).toBe(401); + }); + + it("should reject non-admin users with 403", async () => { + const { contentType, payload } = createMultipartPayload( + JSON.stringify({ title: "Non-Admin Course", description: "Desc" }), + ); + + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/courses/import", + headers: { + authorization: `Bearer ${nonAdminToken()}`, + "content-type": contentType, + }, + payload, + }); + + expect(response.statusCode).toBe(403); + }); + }); + + describe("Request Format & Error Handling", () => { + it("should reject requests that are not multipart/form-data with 400", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/courses/import", + headers: { + authorization: `Bearer ${adminToken()}`, + "content-type": "application/json", + }, + payload: { title: "Direct JSON Course", description: "Desc" }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.payload); + expect(body.error).toBeDefined(); + }); + + it("should reject multipart requests without a file field with 400", async () => { + const boundary = "----WebKitFormBoundaryEmptyField"; + const emptyPayload = [ + `--${boundary}`, + 'Content-Disposition: form-data; name="otherField"', + "", + "some value", + `--${boundary}--`, + "", + ].join("\r\n"); + + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/courses/import", + headers: { + authorization: `Bearer ${adminToken()}`, + "content-type": `multipart/form-data; boundary=${boundary}`, + }, + payload: emptyPayload, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should reject malformed non-JSON file content with 400", async () => { + const { contentType, payload } = createMultipartPayload( + "INVALID NOT JSON DATA {{{", + "corrupt.json", + ); + + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/courses/import", + headers: { + authorization: `Bearer ${adminToken()}`, + "content-type": contentType, + }, + payload, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.payload); + expect(body.error).toBeDefined(); + }); + }); + + describe("JSON Validation & Course Import Execution (#650 / #366)", () => { + it("should reject JSON missing required course fields (e.g. title or description)", async () => { + const invalidCourseJson = { + difficulty: "beginner", + tags: ["stellar"], + // Missing title and description + }; + + const { contentType, payload } = createMultipartPayload( + JSON.stringify(invalidCourseJson), + ); + + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/courses/import", + headers: { + authorization: `Bearer ${adminToken()}`, + "content-type": contentType, + }, + payload, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.payload); + expect(body.error).toBeDefined(); + }); + + it("should reject JSON with invalid difficulty enum", async () => { + const invalidDifficultyJson = { + title: "Invalid Difficulty Course", + description: "Valid description", + difficulty: "expert", // Not in ["beginner", "intermediate", "advanced"] + }; + + const { contentType, payload } = createMultipartPayload( + JSON.stringify(invalidDifficultyJson), + ); + + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/courses/import", + headers: { + authorization: `Bearer ${adminToken()}`, + "content-type": contentType, + }, + payload, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should successfully import a valid course with nested modules", async () => { + const validImportJson = { + title: "Imported Stellar Smart Contracts", + description: "Learn how to build Soroban smart contracts on Stellar", + difficulty: "intermediate", + tags: ["soroban", "rust", "stellar"], + modules: [ + { + title: "Environment Setup", + description: "Install Rust and Soroban CLI", + order: 0, + }, + { + title: "Contract Syntax", + description: "Writing and building your first contract", + order: 1, + }, + ], + }; + + const { contentType, payload } = createMultipartPayload( + JSON.stringify(validImportJson), + ); + + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/courses/import", + headers: { + authorization: `Bearer ${adminToken()}`, + "content-type": contentType, + }, + payload, + }); + + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.payload); + expect(body.success).toBe(true); + expect(body.data.courseId).toBeDefined(); + expect(body.data.modulesCreated).toBe(2); + + createdCourseIds.push(body.data.courseId); + + // Verify DB record + const [importedCourse] = await db + .select() + .from(courses) + .where(eq(courses.id, body.data.courseId)); + + expect(importedCourse).toBeDefined(); + expect(importedCourse?.title).toBe("Imported Stellar Smart Contracts"); + expect(importedCourse?.difficulty).toBe("intermediate"); + expect(importedCourse?.modules).toHaveLength(2); + }); + }); +}); diff --git a/tests/e2e/admin-course-publish.test.ts b/tests/e2e/admin-course-publish.test.ts new file mode 100644 index 0000000..7ece478 --- /dev/null +++ b/tests/e2e/admin-course-publish.test.ts @@ -0,0 +1,287 @@ +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, quizzes } from "../../src/database/schema.js"; + +describe("Admin Course Publish E2E API (#652)", () => { + let app: FastifyInstance; + + const adminUserId = "p1c2d3e4-1111-4ef8-bb6d-6bb9bd380c10"; + const adminStellarAddress = "GADMINPUBLISH000000000000000000000000000000000000000000".slice( + 0, + 56, + ); + const nonAdminUserId = "p1c2d3e4-2222-4ef8-bb6d-6bb9bd380c10"; + const nonAdminStellarAddress = + "GNONADMINPUBLISH000000000000000000000000000000000000000".slice(0, 56); + + const createdCourseIds: string[] = []; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + + await db + .insert(users) + .values({ + id: adminUserId, + stellarAddress: adminStellarAddress, + displayName: "Publish Admin", + isAdmin: true, + }) + .onConflictDoNothing(); + + await db + .insert(users) + .values({ + id: nonAdminUserId, + stellarAddress: nonAdminStellarAddress, + displayName: "Publish Non-Admin", + isAdmin: false, + }) + .onConflictDoNothing(); + }); + + afterAll(async () => { + for (const id of createdCourseIds) { + await db.delete(quizzes).where(eq(quizzes.courseId, id)); + 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("Authentication & Authorization", () => { + it("should reject unauthenticated requests to publish with 401", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/courses/00000000-0000-0000-0000-000000000000/publish", + }); + + 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/00000000-0000-0000-0000-000000000000/publish", + headers: { authorization: `Bearer ${nonAdminToken()}` }, + }); + + expect(response.statusCode).toBe(403); + }); + + it("should reject non-admin users from publish-check with 403", async () => { + const response = await app.inject({ + method: "POST", + url: "/api/v1/admin/courses/00000000-0000-0000-0000-000000000000/publish-check", + headers: { authorization: `Bearer ${nonAdminToken()}` }, + }); + + expect(response.statusCode).toBe(403); + }); + + it("should return 404 when publishing non-existent course", async () => { + const nonExistentId = "ffffffff-ffff-4fff-bfff-ffffffffffff"; + const response = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${nonExistentId}/publish`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe("Publish Checks & Validation (#384 / #652)", () => { + it("should report blocking issues when course has no modules", async () => { + const [course] = await db + .insert(courses) + .values({ + title: "Course Without Modules", + description: "Testing readiness check with zero modules", + difficulty: "beginner", + modules: [], + courseModules: [], + isDraft: true, + isActive: false, + }) + .returning(); + createdCourseIds.push(course!.id); + + // Check publish readiness + const checkResponse = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/publish-check`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(checkResponse.statusCode).toBe(200); + const checkBody = JSON.parse(checkResponse.payload); + expect(checkBody.data.ready).toBe(false); + expect(checkBody.data.score).toBeLessThan(100); + + const blockingIssues = checkBody.data.issues.filter( + (i: { severity: string }) => i.severity === "blocking", + ); + expect(blockingIssues.length).toBeGreaterThanOrEqual(1); + + // Directly attempting to publish should be rejected with 400 validation error + const publishResponse = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/publish`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(publishResponse.statusCode).toBe(400); + const publishBody = JSON.parse(publishResponse.payload); + expect(publishBody.error).toBeDefined(); + }); + + it("should report blocking issues when a module is missing a quiz", async () => { + const moduleId = "m1-no-quiz"; + const [course] = await db + .insert(courses) + .values({ + title: "Course Missing Quiz", + description: "Has module but no corresponding quiz", + difficulty: "intermediate", + modules: [ + { id: moduleId, title: "Module Without Quiz", description: "desc", order: 0 }, + ], + courseModules: [ + { id: moduleId, title: "Module Without Quiz", description: "desc" }, + ], + isDraft: true, + isActive: false, + }) + .returning(); + createdCourseIds.push(course!.id); + + const checkResponse = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/publish-check`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(checkResponse.statusCode).toBe(200); + const checkBody = JSON.parse(checkResponse.payload); + expect(checkBody.data.ready).toBe(false); + expect( + checkBody.data.issues.some((issue: { code: string }) => issue.code === "module_missing_quiz"), + ).toBe(true); + + const publishResponse = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/publish`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(publishResponse.statusCode).toBe(400); + }); + }); + + describe("Draft to Active Publish Workflow", () => { + it("should successfully publish course when all requirements are satisfied", async () => { + const moduleId = "mod-ready-1"; + const [course] = await db + .insert(courses) + .values({ + title: "Complete Publishable Course", + description: "Full course with module and quiz", + difficulty: "beginner", + modules: [ + { + id: moduleId, + title: "Module 1", + description: "Complete module content", + order: 0, + }, + ], + courseModules: [ + { + id: moduleId, + title: "Module 1", + description: "Complete module content", + }, + ], + isDraft: true, + isActive: false, + archivedAt: null, + }) + .returning(); + createdCourseIds.push(course!.id); + + // Insert valid non-empty quiz for the module + await db.insert(quizzes).values({ + courseId: course!.id, + moduleId, + questions: [ + { + id: "q1", + text: "What is Stellar?", + options: ["A decentralized payment network", "A centralized database"], + correctIndex: 0, + }, + ], + }); + + // 1. Verify publish-check passes with 100 score + const checkResponse = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/publish-check`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(checkResponse.statusCode).toBe(200); + const checkBody = JSON.parse(checkResponse.payload); + expect(checkBody.data.ready).toBe(true); + expect(checkBody.data.score).toBe(100); + expect(checkBody.data.issues).toHaveLength(0); + + // 2. Publish the course + const publishResponse = await app.inject({ + method: "POST", + url: `/api/v1/admin/courses/${course!.id}/publish`, + headers: { authorization: `Bearer ${adminToken()}` }, + }); + + expect(publishResponse.statusCode).toBe(200); + const publishBody = JSON.parse(publishResponse.payload); + expect(publishBody.success).toBe(true); + expect(publishBody.data.isActive).toBe(true); + expect(publishBody.data.isDraft).toBe(false); + + // 3. Verify DB record state transition + const [updatedCourse] = await db + .select() + .from(courses) + .where(eq(courses.id, course!.id)); + + expect(updatedCourse?.isActive).toBe(true); + expect(updatedCourse?.isDraft).toBe(false); + expect(updatedCourse?.archivedAt).toBeNull(); + + // 4. Verify course appears in public listing + const publicResponse = await app.inject({ + method: "GET", + url: "/api/v1/courses", + }); + + expect(publicResponse.statusCode).toBe(200); + const publicBody = JSON.parse(publicResponse.payload); + expect( + publicBody.data?.courses?.some((c: { id: string }) => c.id === course!.id), + ).toBe(true); + }); + }); +});