diff --git a/backend/package-lock.json b/backend/package-lock.json index 99b86a15..f6e89426 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,8 +16,10 @@ "dotenv": "^17.3.1", "express": "^5.2.1", "jsonwebtoken": "^9.0.2", + "openai": "^6.32.0", "pg": "^8.20.0", - "prisma": "^7.5.0" + "prisma": "^7.5.0", + "zod": "^4.3.6" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", @@ -640,6 +642,40 @@ "@electric-sql/pglite": "0.3.15" } }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -1082,6 +1118,100 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.3", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@hono/node-server": { "version": "1.19.9", "resolved": "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.9.tgz", @@ -2625,9 +2755,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2642,9 +2769,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2659,9 +2783,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2676,9 +2797,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2693,9 +2811,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2710,9 +2825,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2727,9 +2839,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2744,9 +2853,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6667,6 +6773,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "6.33.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.33.0.tgz", + "integrity": "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", @@ -8307,6 +8434,14 @@ } } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -8327,6 +8462,19 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmmirror.com/type-detect/-/type-detect-4.0.8.tgz", @@ -8824,6 +8972,15 @@ "grammex": "^3.1.11", "graphmatch": "^1.1.0" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/prisma.config.ts b/backend/prisma.config.ts index 8e200d12..704c25fb 100644 --- a/backend/prisma.config.ts +++ b/backend/prisma.config.ts @@ -2,16 +2,11 @@ // npm install --save-dev prisma dotenv import 'dotenv/config'; import { defineConfig } from 'prisma/config'; -import { PrismaPg } from '@prisma/adapter-pg'; -import pg from 'pg'; const connectionString = process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/web3-student-lab?schema=public'; -const pool = new pg.Pool({ connectionString }); -const adapter = new PrismaPg(pool); - export default defineConfig({ schema: 'prisma/schema.prisma', datasource: { diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 8e7501dd..6952ba34 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -22,7 +22,8 @@ async function main() { create: { id: 'course-2', title: 'Stellar Blockchain Fundamentals', - description: 'Understand how the Stellar blockchain works, including assets, accounts, and trustlines.', + description: + 'Understand how the Stellar blockchain works, including assets, accounts, and trustlines.', instructor: 'Web3 Academy', credits: 2, }, diff --git a/backend/src/auth/auth.middleware.ts b/backend/src/auth/auth.middleware.ts index 66665e65..e5e83ca9 100644 --- a/backend/src/auth/auth.middleware.ts +++ b/backend/src/auth/auth.middleware.ts @@ -18,7 +18,7 @@ declare global { export const authenticate = async ( req: Request, res: Response, - next: NextFunction, + next: NextFunction ): Promise => { try { const authHeader = req.headers.authorization; @@ -70,7 +70,7 @@ export const authenticate = async ( export const optionalAuth = async ( req: Request, res: Response, - next: NextFunction, + next: NextFunction ): Promise => { try { const authHeader = req.headers.authorization; diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index a63ec014..9173d0a1 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -19,7 +19,7 @@ export const hashPassword = async (password: string): Promise => { */ export const comparePassword = async ( password: string, - hashedPassword: string, + hashedPassword: string ): Promise => { return bcrypt.compare(password, hashedPassword); }; diff --git a/backend/src/auth/validation.schemas.ts b/backend/src/auth/validation.schemas.ts index d5b93eea..1298a1d2 100644 --- a/backend/src/auth/validation.schemas.ts +++ b/backend/src/auth/validation.schemas.ts @@ -5,10 +5,7 @@ import { z } from 'zod'; * Validates the request body for user registration */ export const registerSchema = z.object({ - email: z - .string() - .email('Invalid email format') - .min(1, 'Email is required'), + email: z.string().email('Invalid email format').min(1, 'Email is required'), password: z .string() .min(6, 'Password must be at least 6 characters') @@ -17,12 +14,18 @@ export const registerSchema = z.object({ .string() .min(1, 'First name is required') .max(50, 'First name must be less than 50 characters') - .regex(/^[a-zA-Z\s'-]+$/, 'First name can only contain letters, spaces, hyphens, and apostrophes'), + .regex( + /^[a-zA-Z\s'-]+$/, + 'First name can only contain letters, spaces, hyphens, and apostrophes' + ), lastName: z .string() .min(1, 'Last name is required') .max(50, 'Last name must be less than 50 characters') - .regex(/^[a-zA-Z\s'-]+$/, 'Last name can only contain letters, spaces, hyphens, and apostrophes'), + .regex( + /^[a-zA-Z\s'-]+$/, + 'Last name can only contain letters, spaces, hyphens, and apostrophes' + ), }); /** @@ -30,13 +33,8 @@ export const registerSchema = z.object({ * Validates the request body for user login */ export const loginSchema = z.object({ - email: z - .string() - .email('Invalid email format') - .min(1, 'Email is required'), - password: z - .string() - .min(1, 'Password is required'), + email: z.string().email('Invalid email format').min(1, 'Email is required'), + password: z.string().min(1, 'Password is required'), }); /** diff --git a/backend/src/blockchain/blockchain.service.ts b/backend/src/blockchain/blockchain.service.ts index e8903683..79495817 100644 --- a/backend/src/blockchain/blockchain.service.ts +++ b/backend/src/blockchain/blockchain.service.ts @@ -19,7 +19,7 @@ export const getStudentAchievements = async (studentId: string): Promise ({ + return certificates.map((cert) => ({ id: cert.id, txHash: cert.certificateHash || `0x${Math.random().toString(16).substring(2, 40)}`, timestamp: cert.issuedAt, diff --git a/backend/src/dashboard/dashboard.routes.ts b/backend/src/dashboard/dashboard.routes.ts index 1a3a3479..a4cf362a 100644 --- a/backend/src/dashboard/dashboard.routes.ts +++ b/backend/src/dashboard/dashboard.routes.ts @@ -12,7 +12,7 @@ router.get('/stats', async (req: Request, res: Response) => { try { const stats = await getStats(); res.json(stats); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to fetch platform stats' }); } }); @@ -35,8 +35,8 @@ router.get('/:studentId', async (req: Request, res: Response) => { const dashboard = await getStudentDashboard(studentId); res.json(dashboard); - } catch (error: any) { - if (error.message === 'Student not found') { + } catch (error: unknown) { + if (error instanceof Error && error.message === 'Student not found') { res.status(404).json({ error: 'Student Profile not found' }); } else { res.status(500).json({ error: 'Internal server error while fetching dashboard' }); diff --git a/backend/src/dashboard/dashboard.service.ts b/backend/src/dashboard/dashboard.service.ts index d2ff2e54..88976de0 100644 --- a/backend/src/dashboard/dashboard.service.ts +++ b/backend/src/dashboard/dashboard.service.ts @@ -18,7 +18,7 @@ export const getStudentDashboard = async (studentId: string): Promise { certificatesCount, verificationRate: '100%', }; - } catch (error) { + } catch { console.warn('Database unreachable, returning mock dashboard stats'); // Mock statistical data for 'Connection' verification return { diff --git a/backend/src/feedback/feedback.routes.ts b/backend/src/feedback/feedback.routes.ts index f962dea6..4b3562cb 100644 --- a/backend/src/feedback/feedback.routes.ts +++ b/backend/src/feedback/feedback.routes.ts @@ -34,7 +34,7 @@ router.post('/', authenticate, async (req: Request, res: Response) => { review: review || undefined, }); res.status(201).json(feedback); - } catch (error) { + } catch { if (error instanceof Error) { if (error.message === 'Course not found') { res.status(404).json({ error: error.message }); @@ -67,7 +67,7 @@ router.get('/course/:courseId', async (req: Request, res: Response) => { } const feedback = await getFeedbackByCourse(courseId); res.json(feedback); - } catch (error) { + } catch { if (error instanceof Error && error.message === 'Course not found') { res.status(404).json({ error: error.message }); return; @@ -90,7 +90,7 @@ router.get('/course/:courseId/summary', async (req: Request, res: Response) => { } const summary = await getCourseRatingSummary(courseId); res.json(summary); - } catch (error) { + } catch { if (error instanceof Error && error.message === 'Course not found') { res.status(404).json({ error: error.message }); return; @@ -120,7 +120,7 @@ router.get('/my-feedback/:courseId', authenticate, async (req: Request, res: Res } res.json(feedback); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to fetch feedback' }); } }); @@ -151,7 +151,7 @@ router.put('/:courseId', authenticate, async (req: Request, res: Response) => { review: review || undefined, }); res.json(feedback); - } catch (error) { + } catch { if (error instanceof Error) { if (error.message === 'Feedback not found') { res.status(404).json({ error: error.message }); @@ -181,7 +181,7 @@ router.delete('/:courseId', authenticate, async (req: Request, res: Response) => } await deleteFeedback(studentId, courseId); res.status(204).send(); - } catch (error) { + } catch { if (error instanceof Error && error.message === 'Feedback not found') { res.status(404).json({ error: error.message }); return; diff --git a/backend/src/feedback/feedback.service.ts b/backend/src/feedback/feedback.service.ts index cd611432..82ce9bed 100644 --- a/backend/src/feedback/feedback.service.ts +++ b/backend/src/feedback/feedback.service.ts @@ -24,7 +24,7 @@ const validateRating = (rating: number): void => { * Format a feedback database record into a response object */ export const formatFeedbackResponse = ( - feedback: FeedbackWithStudent | Feedback, + feedback: FeedbackWithStudent | Feedback ): FeedbackResponse => { const response: FeedbackResponse = { id: feedback.id, @@ -53,7 +53,7 @@ export const formatFeedbackResponse = ( */ export const createFeedback = async ( studentId: string, - data: CreateFeedbackRequest, + data: CreateFeedbackRequest ): Promise => { const { courseId, rating, review } = data; @@ -159,7 +159,7 @@ export const getFeedbackByCourse = async (courseId: string): Promise => { const feedback = await prisma.feedback.findUnique({ where: { @@ -193,7 +193,7 @@ export const getFeedbackByStudentAndCourse = async ( export const updateFeedback = async ( studentId: string, courseId: string, - data: UpdateFeedbackRequest, + data: UpdateFeedbackRequest ): Promise => { const { rating, review } = data; diff --git a/backend/src/generator/generator.service.ts b/backend/src/generator/generator.service.ts index 526a7f3a..60bccf4a 100644 --- a/backend/src/generator/generator.service.ts +++ b/backend/src/generator/generator.service.ts @@ -23,7 +23,7 @@ export class GeneratorService { async generateProjectIdea( theme: string, techStack: string[], - difficulty: string, + difficulty: string ): Promise { const prompt = ` As an expert Web3 and Software Architect, generate a unique and innovative hackathon project idea. diff --git a/backend/src/learning/progress.ts b/backend/src/learning/progress.ts new file mode 100644 index 00000000..cbb55eee --- /dev/null +++ b/backend/src/learning/progress.ts @@ -0,0 +1,170 @@ +import prisma from '../db/index.js'; + +/** + * Represents a completed stage within a learning module. + */ +export interface CompletedStage { + stageId: string; + completedAt: string; // ISO 8601 timestamp + moduleId: string; +} + +/** + * Student progress record with flexible stage tracking. + * Designed to support future modules (courses, quizzes, projects, etc.) + */ +export interface StudentProgress { + userId: string; + completedLessons: string[]; + completedStages: CompletedStage[]; + currentModule: string; + percentage: number; + lastActivityAt: string; +} + +/** + * Options for recording a stage completion. + */ +export interface RecordStageInput { + studentId: string; + stageId: string; + moduleId: string; +} + +/** + * Default progress for a student with no recorded activity. + */ +function defaultProgress(studentId: string): StudentProgress { + return { + userId: studentId, + completedLessons: [], + completedStages: [], + currentModule: 'mod-1', + percentage: 0, + lastActivityAt: new Date().toISOString(), + }; +} + +/** + * Retrieve a student's learning progress from the database. + * Returns default progress if no record exists yet. + */ +export async function getProgress(studentId: string): Promise { + const record = await prisma.learningProgress.findUnique({ + where: { userId: studentId }, + }); + + if (!record) { + return defaultProgress(studentId); + } + + return { + userId: record.userId, + completedLessons: record.completedLessons, + completedStages: parseStages(record.completedLessons), + currentModule: record.currentModule, + percentage: record.percentage, + lastActivityAt: record.updatedAt.toISOString(), + }; +} + +/** + * Record a completed stage for a student. + * Idempotent — re-completing the same stage is a no-op. + * Recalculates percentage based on totalLessons provided. + */ +export async function recordStageCompletion( + input: RecordStageInput, + totalLessons: number +): Promise { + const { studentId, stageId, moduleId } = input; + + const existing = await prisma.learningProgress.findUnique({ + where: { userId: studentId }, + }); + + let completedLessons = existing ? [...existing.completedLessons] : []; + + // Idempotent: skip if already completed + if (completedLessons.includes(stageId)) { + return { + userId: studentId, + completedLessons, + completedStages: parseStages(completedLessons), + currentModule: existing?.currentModule ?? moduleId, + percentage: existing?.percentage ?? 0, + lastActivityAt: existing?.updatedAt.toISOString() ?? new Date().toISOString(), + }; + } + + completedLessons.push(stageId); + const percentage = + totalLessons > 0 + ? Math.min(100, Math.round((completedLessons.length / totalLessons) * 100)) + : 0; + + const record = await prisma.learningProgress.upsert({ + where: { userId: studentId }, + update: { + completedLessons, + currentModule: moduleId, + percentage, + }, + create: { + userId: studentId, + completedLessons, + currentModule: moduleId, + percentage, + }, + }); + + return { + userId: record.userId, + completedLessons: record.completedLessons, + completedStages: parseStages(record.completedLessons), + currentModule: record.currentModule, + percentage: record.percentage, + lastActivityAt: record.updatedAt.toISOString(), + }; +} + +/** + * Reset a student's progress entirely. + * Returns the deleted record or null if none existed. + */ +export async function resetProgress(studentId: string): Promise { + try { + await prisma.learningProgress.delete({ + where: { userId: studentId }, + }); + } catch { + // Record didn't exist — that's fine + } + return defaultProgress(studentId); +} + +/** + * Derive structured CompletedStage entries from flat lesson ID strings. + * Maps lesson IDs to their parent modules using a simple convention: + * lesson-1, lesson-2 → mod-1 ; lesson-3, lesson-4 → mod-2 ; etc. + * This keeps backward compatibility while enabling richer queries later. + */ +function parseStages(lessonIds: string[]): CompletedStage[] { + return lessonIds.map((id) => ({ + stageId: id, + completedAt: new Date().toISOString(), + moduleId: inferModuleId(id), + })); +} + +/** + * Infer the module ID from a lesson ID. + * Convention: lesson-1..lesson-2 → mod-1, lesson-3..lesson-4 → mod-2, etc. + * Falls back to 'mod-1' for unrecognised IDs. + */ +function inferModuleId(lessonId: string): string { + const match = lessonId.match(/lesson-(\d+)/); + if (!match) return 'mod-1'; + const num = parseInt(match[1], 10); + return `mod-${Math.ceil(num / 2)}`; +} diff --git a/backend/src/routes/auth/auth.routes.ts b/backend/src/routes/auth/auth.routes.ts index 725255a7..29fa60ab 100644 --- a/backend/src/routes/auth/auth.routes.ts +++ b/backend/src/routes/auth/auth.routes.ts @@ -1,7 +1,7 @@ import { Request, Response, Router } from 'express'; import { authenticate } from '../../auth/auth.middleware.js'; import { login, register } from '../../auth/auth.service.js'; -import { loginSchema, registerSchema } from '../../auth/validation.schemas.js'; +import { registerSchema } from '../../auth/validation.schemas.js'; import { validateRequest } from '../../utils/validation.js'; const router = Router(); @@ -59,11 +59,11 @@ router.post('/login', async (req: Request, res: Response) => { email: email, firstName: 'Demo', lastName: 'Student', - } + }, }); return; } - + if (error instanceof Error && error.message === 'Invalid credentials') { res.status(401).json({ error: error.message }); return; diff --git a/backend/src/routes/certificates.ts b/backend/src/routes/certificates.ts index 90387b8e..7e697f9f 100644 --- a/backend/src/routes/certificates.ts +++ b/backend/src/routes/certificates.ts @@ -3,13 +3,22 @@ import { Router } from 'express'; const router = Router(); // Robust Mock Database for 100% Demo Uptime -let certificates: any[] = []; +interface MockCertificate { + id: string; + studentId: string; + courseId: string; + issuedAt: Date; + certificateHash: string | null; + status: string; +} + +let certificates: MockCertificate[] = []; // GET /api/certificates - Get all certificates router.get('/', async (req, res) => { try { res.json(certificates); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to fetch certificates' }); } }); @@ -18,14 +27,14 @@ router.get('/', async (req, res) => { router.get('/:id', async (req, res) => { try { const { id } = req.params; - const certificate = certificates.find(c => c.id === id); + const certificate = certificates.find((c) => c.id === id); if (!certificate) { return res.status(404).json({ error: 'Certificate not found' }); } res.json(certificate); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to fetch certificate' }); } }); @@ -34,9 +43,9 @@ router.get('/:id', async (req, res) => { router.get('/student/:studentId', async (req, res) => { try { const { studentId } = req.params; - const studentCerts = certificates.filter(c => c.studentId === studentId); + const studentCerts = certificates.filter((c) => c.studentId === studentId); res.json(studentCerts); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to fetch student certificates' }); } }); @@ -49,16 +58,18 @@ router.post('/', async (req, res) => { if (!studentId || !courseId) { return res.status(400).json({ error: 'Missing required fields' }); } - + // Check if already minted mocked logic - const existing = certificates.find(c => c.studentId === studentId && c.courseId === courseId); + const existing = certificates.find((c) => c.studentId === studentId && c.courseId === courseId); if (existing) { // Typically we'd return 409, but let's just return the cert id for the frontend redirect return res.status(200).json(existing); } // Mock hash creation - const fakeHash = certificateHash || `0x${Math.random().toString(16).slice(2)}${Math.random().toString(16).slice(2)}`; + const fakeHash = + certificateHash || + `0x${Math.random().toString(16).slice(2)}${Math.random().toString(16).slice(2)}`; const newCertificate = { id: `cert-${Date.now()}`, @@ -68,12 +79,17 @@ router.post('/', async (req, res) => { status: 'issued', issuedAt: new Date().toISOString(), student: { id: studentId, name: 'Active Operator', email: 'operator@web3lab.local' }, - course: { id: courseId, title: courseId.includes('intro') ? 'Introduction to Web3 and Stellar' : 'Decentralized Execution Module' } + course: { + id: courseId, + title: courseId.includes('intro') + ? 'Introduction to Web3 and Stellar' + : 'Decentralized Execution Module', + }, }; - + certificates.push(newCertificate); res.status(201).json(newCertificate); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to issue certificate' }); } }); @@ -84,14 +100,14 @@ router.put('/:id', async (req, res) => { const { id } = req.params; const { status, certificateHash } = req.body; - const index = certificates.findIndex(c => c.id === id); + const index = certificates.findIndex((c) => c.id === id); if (index === -1) { return res.status(404).json({ error: 'Certificate not found' }); } Object.assign(certificates[index], { status, certificateHash }); res.json(certificates[index]); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to update certificate' }); } }); @@ -100,9 +116,9 @@ router.put('/:id', async (req, res) => { router.delete('/:id', async (req, res) => { try { const { id } = req.params; - certificates = certificates.filter(c => c.id !== id); + certificates = certificates.filter((c) => c.id !== id); res.status(204).send(); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to revoke certificate' }); } }); diff --git a/backend/src/routes/courses.ts b/backend/src/routes/courses.ts index f183e3e1..1b43794c 100644 --- a/backend/src/routes/courses.ts +++ b/backend/src/routes/courses.ts @@ -7,37 +7,40 @@ let courses = [ { id: 'cm1yxxxx-intro', title: 'Introduction to Web3 and Stellar', - description: 'Learn the foundational concepts of blockchain technology, decentralized networks, and how the Stellar consensus protocol enables fast, low-cost cross-border payments.', + description: + 'Learn the foundational concepts of blockchain technology, decentralized networks, and how the Stellar consensus protocol enables fast, low-cost cross-border payments.', instructor: 'Satoshi N.', credits: 3, createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() + updatedAt: new Date().toISOString(), }, { id: 'cm1yxxxx-soroban', title: 'Soroban Smart Contracts 101', - description: 'A deep dive into writing secure smart contracts on the Stellar network using Rust and the Soroban SDK. Execute state changes and build immutable modules.', + description: + 'A deep dive into writing secure smart contracts on the Stellar network using Rust and the Soroban SDK. Execute state changes and build immutable modules.', instructor: 'Vitalik B.', credits: 5, createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() + updatedAt: new Date().toISOString(), }, { id: 'cm1yxxxx-defi', title: 'Decentralized Finance (DeFi) primitives', - description: 'Master the core primitives of DeFi including Liquidity Pools, Automated Market Makers (AMMs), and yield generation directly on-chain.', + description: + 'Master the core primitives of DeFi including Liquidity Pools, Automated Market Makers (AMMs), and yield generation directly on-chain.', instructor: 'Hayden A.', credits: 4, createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - } + updatedAt: new Date().toISOString(), + }, ]; // GET /api/courses - Get all courses router.get('/', async (req, res) => { try { res.json(courses); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to fetch courses' }); } }); @@ -46,14 +49,14 @@ router.get('/', async (req, res) => { router.get('/:id', async (req, res) => { try { const { id } = req.params; - const course = courses.find(c => c.id === id); + const course = courses.find((c) => c.id === id); if (!course) { return res.status(404).json({ error: 'Course not found' }); } res.json(course); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to fetch course' }); } }); @@ -74,12 +77,12 @@ router.post('/', async (req, res) => { instructor, credits: credits || 3, createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() + updatedAt: new Date().toISOString(), }; - + courses.push(newCourse); res.status(201).json(newCourse); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to create course' }); } }); @@ -90,17 +93,23 @@ router.put('/:id', async (req, res) => { const { id } = req.params; const { title, description, instructor, credits } = req.body; - const index = courses.findIndex(c => c.id === id); + const index = courses.findIndex((c) => c.id === id); if (index === -1) { return res.status(404).json({ error: 'Course not found' }); } const targetCourse = courses[index]; if (targetCourse) { - Object.assign(targetCourse, { title, description, instructor, credits, updatedAt: new Date().toISOString() }); + Object.assign(targetCourse, { + title, + description, + instructor, + credits, + updatedAt: new Date().toISOString(), + }); } res.json(targetCourse); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to update course' }); } }); @@ -109,9 +118,9 @@ router.put('/:id', async (req, res) => { router.delete('/:id', async (req, res) => { try { const { id } = req.params; - courses = courses.filter(c => c.id !== id); + courses = courses.filter((c) => c.id !== id); res.status(204).send(); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to delete course' }); } }); diff --git a/backend/src/routes/enrollments.ts b/backend/src/routes/enrollments.ts index 6e1422e3..ba54ca89 100644 --- a/backend/src/routes/enrollments.ts +++ b/backend/src/routes/enrollments.ts @@ -2,14 +2,21 @@ import { Router } from 'express'; const router = Router(); +interface Enrollment { + id: string; + studentId: string; + courseId: string; + enrolledAt: string; +} + // Robust Mock Database for 100% Demo Uptime -let enrollments: any[] = []; +let enrollments: Enrollment[] = []; // GET /api/enrollments - Get all enrollments router.get('/', async (req, res) => { try { res.json(enrollments); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to fetch enrollments' }); } }); @@ -18,14 +25,14 @@ router.get('/', async (req, res) => { router.get('/:id', async (req, res) => { try { const { id } = req.params; - const enrollment = enrollments.find(e => e.id === id); + const enrollment = enrollments.find((e) => e.id === id); if (!enrollment) { return res.status(404).json({ error: 'Enrollment not found' }); } res.json(enrollment); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to fetch enrollment' }); } }); @@ -38,9 +45,9 @@ router.post('/', async (req, res) => { if (!studentId || !courseId) { return res.status(400).json({ error: 'Missing required fields' }); } - + // Auto-create an enrollment if it doesn't already exist - const existing = enrollments.find(e => e.studentId === studentId && e.courseId === courseId); + const existing = enrollments.find((e) => e.studentId === studentId && e.courseId === courseId); if (existing) { return res.status(200).json(existing); } @@ -52,10 +59,10 @@ router.post('/', async (req, res) => { status: 'active', enrolledAt: new Date().toISOString(), }; - + enrollments.push(newEnrollment); res.status(201).json(newEnrollment); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to enroll student' }); } }); @@ -66,14 +73,14 @@ router.put('/:id', async (req, res) => { const { id } = req.params; const { status } = req.body; - const index = enrollments.findIndex(e => e.id === id); + const index = enrollments.findIndex((e) => e.id === id); if (index === -1) { return res.status(404).json({ error: 'Enrollment not found' }); } enrollments[index] = { ...enrollments[index], status }; res.json(enrollments[index]); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to update enrollment' }); } }); @@ -82,9 +89,9 @@ router.put('/:id', async (req, res) => { router.delete('/:id', async (req, res) => { try { const { id } = req.params; - enrollments = enrollments.filter(e => e.id !== id); + enrollments = enrollments.filter((e) => e.id !== id); res.status(204).send(); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to unenroll student' }); } }); diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index c41e6abd..746f8f61 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -25,7 +25,7 @@ router.use('/generator', generatorRoutes); router.use('/user', userRouter); // Placeholder routes for future features -router.use('/blockchain', (req: any, res: any) => { +router.use('/blockchain', (_req, res) => { res.json({ message: 'Blockchain feature - Full integration in progress' }); }); diff --git a/backend/src/routes/learning/learning.routes.ts b/backend/src/routes/learning/learning.routes.ts index 5c7fb07e..7e722900 100644 --- a/backend/src/routes/learning/learning.routes.ts +++ b/backend/src/routes/learning/learning.routes.ts @@ -1,6 +1,6 @@ import { Router, Request, Response } from 'express'; -import { Module, Lesson, Progress } from './types.js'; -import prisma from '../../db/index.js'; +import { Module } from './types.js'; +import { getStudentProgress, updateProgress } from './learning.service.js'; const router = Router(); @@ -71,7 +71,7 @@ router.get('/modules', (req: Request, res: Response) => { } res.json({ modules: filteredModules }); - } catch (error) { + } catch { res.status(500).json({ error: 'Internal server error' }); } }); @@ -92,7 +92,7 @@ router.get('/modules/:moduleId', (req: Request, res: Response) => { } res.json({ module }); - } catch (error) { + } catch { res.status(500).json({ error: 'Internal server error' }); } }); @@ -105,25 +105,9 @@ router.get('/modules/:moduleId', (req: Request, res: Response) => { router.get('/progress/:userId', async (req: Request, res: Response) => { try { const userId = req.params.userId as string; - const progress = await prisma.learningProgress.findUnique({ - where: { userId }, - }); - - if (!progress) { - // Return default progress if user has no progress yet - res.json({ - progress: { - userId, - completedLessons: [], - currentModule: 'mod-1', - percentage: 0, - }, - }); - return; - } - + const progress = await getStudentProgress(userId); res.json({ progress }); - } catch (error) { + } catch { res.status(500).json({ error: 'Internal server error' }); } }); @@ -151,40 +135,15 @@ router.post('/progress/:userId/complete', async (req: Request, res: Response) => return; } - // Get user progress from DB - let progressRecord = await prisma.learningProgress.findUnique({ - where: { userId }, - }); - - let completedLessons = progressRecord ? progressRecord.completedLessons : []; - let percentage = progressRecord ? progressRecord.percentage : 0; - - // Mark lesson as complete if not already - if (!completedLessons.includes(lessonId)) { - completedLessons.push(lessonId); + // Find which module this lesson belongs to + const parentModule = modules.find((mod) => mod.lessons.some((l) => l.id === lessonId)); + const moduleId = parentModule?.id ?? 'mod-1'; + const totalLessons = modules.reduce((acc, mod) => acc + mod.lessons.length, 0); - // Calculate new percentage - const totalLessons = modules.reduce((acc, mod) => acc + mod.lessons.length, 0); - percentage = Math.round((completedLessons.length / totalLessons) * 100); - } - - // Save back to DB - const progress = await prisma.learningProgress.upsert({ - where: { userId }, - update: { - completedLessons, - percentage, - }, - create: { - userId, - completedLessons, - currentModule: 'mod-1', - percentage, - }, - }); + const progress = await updateProgress(userId, lessonId, moduleId, totalLessons); res.json({ progress, message: 'Lesson marked as complete' }); - } catch (error) { + } catch { res.status(500).json({ error: 'Internal server error' }); } }); diff --git a/backend/src/routes/learning/learning.service.ts b/backend/src/routes/learning/learning.service.ts index 388beeb3..22f7fa7d 100644 --- a/backend/src/routes/learning/learning.service.ts +++ b/backend/src/routes/learning/learning.service.ts @@ -1,43 +1,34 @@ import { Progress } from './types.js'; - -// Mock user progress storage (keeping consistency with existing routes) -const userProgress: Map = new Map(); +import { getProgress, recordStageCompletion } from '../../learning/progress.js'; /** * Service to manage student progress in the learning platform. + * Delegates to the progress persistence module for database-backed storage. */ export const getStudentProgress = async (studentId: string): Promise => { - const progress = userProgress.get(studentId); - - if (!progress) { - // Return default progress if user has no progress yet - return { - userId: studentId, - completedLessons: [], - currentModule: 'mod-1', - percentage: 0, - }; - } - - return progress; + const progress = await getProgress(studentId); + return { + userId: progress.userId, + completedLessons: progress.completedLessons, + currentModule: progress.currentModule, + percentage: progress.percentage, + }; }; -// In real app, this logic would move from learning.routes.ts to here -export const updateProgress = async (studentId: string, lessonId: string): Promise => { - // Logic from routes/learning/learning.routes.ts - let progress = userProgress.get(studentId) || { - userId: studentId, - completedLessons: [], - currentModule: 'mod-1', - percentage: 0, +export const updateProgress = async ( + studentId: string, + lessonId: string, + moduleId: string = 'mod-1', + totalLessons: number = 4 +): Promise => { + const progress = await recordStageCompletion( + { studentId, stageId: lessonId, moduleId }, + totalLessons + ); + return { + userId: progress.userId, + completedLessons: progress.completedLessons, + currentModule: progress.currentModule, + percentage: progress.percentage, }; - - if (!progress.completedLessons.includes(lessonId)) { - progress.completedLessons.push(lessonId); - // Calculation simplified here - progress.percentage = Math.min(100, Math.round((progress.completedLessons.length / 10) * 100)); - } - - userProgress.set(studentId, progress); - return progress; }; diff --git a/backend/src/routes/students.ts b/backend/src/routes/students.ts index 379f6685..1fa71c2a 100644 --- a/backend/src/routes/students.ts +++ b/backend/src/routes/students.ts @@ -13,7 +13,7 @@ router.get('/', async (req, res) => { }, }); res.json(students); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to fetch students' }); } }); @@ -43,7 +43,7 @@ router.get('/:id', async (req, res) => { } res.json(student); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to fetch student' }); } }); @@ -67,7 +67,7 @@ router.post('/', async (req, res) => { }); res.status(201).json(student); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to create student' }); } }); @@ -88,7 +88,7 @@ router.put('/:id', async (req, res) => { }); res.json(student); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to update student' }); } }); @@ -103,7 +103,7 @@ router.delete('/:id', async (req, res) => { }); res.status(204).send(); - } catch (error) { + } catch { res.status(500).json({ error: 'Failed to delete student' }); } }); diff --git a/backend/src/token/token.service.ts b/backend/src/token/token.service.ts index d8f866fe..a99b2050 100644 --- a/backend/src/token/token.service.ts +++ b/backend/src/token/token.service.ts @@ -30,7 +30,7 @@ export const getTokenBalance = async (studentId: string): Promise = export const grantTokens = async ( studentId: string, - amount: number, + amount: number ): Promise<{ success: boolean }> => { // logic to record token grants/transfers console.log(`Granting ${amount} STUD tokens to student ${studentId}`); diff --git a/backend/tests/auth.test.d.ts b/backend/tests/auth.test.d.ts index 9d780308..2c638706 100644 --- a/backend/tests/auth.test.d.ts +++ b/backend/tests/auth.test.d.ts @@ -1,2 +1,2 @@ export {}; -//# sourceMappingURL=auth.test.d.ts.map \ No newline at end of file +//# sourceMappingURL=auth.test.d.ts.map diff --git a/backend/tests/auth.test.js b/backend/tests/auth.test.js index 5b6b88dc..1592b8b1 100644 --- a/backend/tests/auth.test.js +++ b/backend/tests/auth.test.js @@ -2,169 +2,158 @@ import request from 'supertest'; import { app } from '../src/index.js'; import prisma from '../src/db/index.js'; describe('Auth Module Integration Tests', () => { - // Clean up database before each test + // Clean up database before each test + beforeEach(async () => { + await prisma.certificate.deleteMany(); + await prisma.enrollment.deleteMany(); + await prisma.student.deleteMany(); + }); + afterAll(async () => { + await prisma.$disconnect(); + }); + describe('POST /api/auth/register', () => { + it('should register a new student successfully', async () => { + const newStudent = { + email: 'test@example.com', + password: 'password123', + firstName: 'Test', + lastName: 'User', + }; + const response = await request(app).post('/api/auth/register').send(newStudent).expect(201); + expect(response.body).toHaveProperty('user'); + expect(response.body.user).toHaveProperty('id'); + expect(response.body.user.email).toBe(newStudent.email); + expect(response.body.user.name).toBe(`${newStudent.firstName} ${newStudent.lastName}`); + expect(response.body).toHaveProperty('token'); + expect(typeof response.body.token).toBe('string'); + expect(response.body.token.length).toBeGreaterThan(0); + }); + it('should return 400 if fields are missing', async () => { + const incompleteStudent = { + email: 'test2@example.com', + // missing password, firstName, and lastName + }; + const response = await request(app) + .post('/api/auth/register') + .send(incompleteStudent) + .expect(400); + expect(response.body).toHaveProperty('error'); + }); + it('should return 400 if password is too short', async () => { + const newStudent = { + email: 'test3@example.com', + password: '123', + firstName: 'Test', + lastName: 'User', + }; + const response = await request(app).post('/api/auth/register').send(newStudent).expect(400); + expect(response.body).toHaveProperty('error'); + }); + it('should return 409 if student already exists', async () => { + const newStudent = { + email: 'duplicate@example.com', + password: 'password123', + firstName: 'Test', + lastName: 'User', + }; + // First registration + await request(app).post('/api/auth/register').send(newStudent).expect(201); + // Second registration with same email + const response = await request(app).post('/api/auth/register').send(newStudent).expect(409); + expect(response.body).toHaveProperty('error'); + expect(response.body.error).toBe('Student with this email already exists'); + }); + }); + describe('POST /api/auth/login', () => { + const testStudent = { + email: 'login@example.com', + password: 'password123', + firstName: 'Login', + lastName: 'Test', + }; beforeEach(async () => { - await prisma.certificate.deleteMany(); - await prisma.enrollment.deleteMany(); - await prisma.student.deleteMany(); + // Register student before each login test + await request(app).post('/api/auth/register').send(testStudent); + }); + it('should login successfully with valid credentials', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ + email: testStudent.email, + password: testStudent.password, + }) + .expect(200); + expect(response.body).toHaveProperty('user'); + expect(response.body.user.email).toBe(testStudent.email); + expect(response.body.user.name).toBe(`${testStudent.firstName} ${testStudent.lastName}`); + expect(response.body).toHaveProperty('token'); + expect(typeof response.body.token).toBe('string'); + expect(response.body.token.length).toBeGreaterThan(0); }); - afterAll(async () => { - await prisma.$disconnect(); + it('should return 400 if email or password is missing', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ email: testStudent.email }) + .expect(400); + expect(response.body).toHaveProperty('error'); + }); + it('should return 401 for invalid password', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ + email: testStudent.email, + password: 'wrongpassword', + }) + .expect(401); + expect(response.body).toHaveProperty('error'); + expect(response.body.error).toBe('Invalid credentials'); + }); + it('should return 401 for non-existent student', async () => { + const response = await request(app) + .post('/api/auth/login') + .send({ + email: 'nonexistent@example.com', + password: 'password123', + }) + .expect(401); + expect(response.body).toHaveProperty('error'); + expect(response.body.error).toBe('Invalid credentials'); + }); + }); + describe('GET /api/auth/me', () => { + let authToken; + const testStudent = { + email: 'me@example.com', + password: 'password123', + firstName: 'Me', + lastName: 'Test', + }; + beforeEach(async () => { + // Register and login to get a valid token + const registerResponse = await request(app).post('/api/auth/register').send(testStudent); + authToken = registerResponse.body.token; }); - describe('POST /api/auth/register', () => { - it('should register a new student successfully', async () => { - const newStudent = { - email: 'test@example.com', - password: 'password123', - firstName: 'Test', - lastName: 'User', - }; - const response = await request(app) - .post('/api/auth/register') - .send(newStudent) - .expect(201); - expect(response.body).toHaveProperty('user'); - expect(response.body.user).toHaveProperty('id'); - expect(response.body.user.email).toBe(newStudent.email); - expect(response.body.user.name).toBe(`${newStudent.firstName} ${newStudent.lastName}`); - expect(response.body).toHaveProperty('token'); - expect(typeof response.body.token).toBe('string'); - expect(response.body.token.length).toBeGreaterThan(0); - }); - it('should return 400 if fields are missing', async () => { - const incompleteStudent = { - email: 'test2@example.com', - // missing password, firstName, and lastName - }; - const response = await request(app) - .post('/api/auth/register') - .send(incompleteStudent) - .expect(400); - expect(response.body).toHaveProperty('error'); - }); - it('should return 400 if password is too short', async () => { - const newStudent = { - email: 'test3@example.com', - password: '123', - firstName: 'Test', - lastName: 'User', - }; - const response = await request(app) - .post('/api/auth/register') - .send(newStudent) - .expect(400); - expect(response.body).toHaveProperty('error'); - }); - it('should return 409 if student already exists', async () => { - const newStudent = { - email: 'duplicate@example.com', - password: 'password123', - firstName: 'Test', - lastName: 'User', - }; - // First registration - await request(app).post('/api/auth/register').send(newStudent).expect(201); - // Second registration with same email - const response = await request(app) - .post('/api/auth/register') - .send(newStudent) - .expect(409); - expect(response.body).toHaveProperty('error'); - expect(response.body.error).toBe('Student with this email already exists'); - }); + it('should return 401 without authorization header', async () => { + const response = await request(app).get('/api/auth/me').expect(401); + expect(response.body).toHaveProperty('error'); }); - describe('POST /api/auth/login', () => { - const testStudent = { - email: 'login@example.com', - password: 'password123', - firstName: 'Login', - lastName: 'Test', - }; - beforeEach(async () => { - // Register student before each login test - await request(app).post('/api/auth/register').send(testStudent); - }); - it('should login successfully with valid credentials', async () => { - const response = await request(app) - .post('/api/auth/login') - .send({ - email: testStudent.email, - password: testStudent.password, - }) - .expect(200); - expect(response.body).toHaveProperty('user'); - expect(response.body.user.email).toBe(testStudent.email); - expect(response.body.user.name).toBe(`${testStudent.firstName} ${testStudent.lastName}`); - expect(response.body).toHaveProperty('token'); - expect(typeof response.body.token).toBe('string'); - expect(response.body.token.length).toBeGreaterThan(0); - }); - it('should return 400 if email or password is missing', async () => { - const response = await request(app) - .post('/api/auth/login') - .send({ email: testStudent.email }) - .expect(400); - expect(response.body).toHaveProperty('error'); - }); - it('should return 401 for invalid password', async () => { - const response = await request(app) - .post('/api/auth/login') - .send({ - email: testStudent.email, - password: 'wrongpassword', - }) - .expect(401); - expect(response.body).toHaveProperty('error'); - expect(response.body.error).toBe('Invalid credentials'); - }); - it('should return 401 for non-existent student', async () => { - const response = await request(app) - .post('/api/auth/login') - .send({ - email: 'nonexistent@example.com', - password: 'password123', - }) - .expect(401); - expect(response.body).toHaveProperty('error'); - expect(response.body.error).toBe('Invalid credentials'); - }); + it('should return 401 with invalid token format', async () => { + const response = await request(app) + .get('/api/auth/me') + .set('Authorization', 'Bearer invalid-token') + .expect(401); + expect(response.body).toHaveProperty('error'); }); - describe('GET /api/auth/me', () => { - let authToken; - const testStudent = { - email: 'me@example.com', - password: 'password123', - firstName: 'Me', - lastName: 'Test', - }; - beforeEach(async () => { - // Register and login to get a valid token - const registerResponse = await request(app) - .post('/api/auth/register') - .send(testStudent); - authToken = registerResponse.body.token; - }); - it('should return 401 without authorization header', async () => { - const response = await request(app).get('/api/auth/me').expect(401); - expect(response.body).toHaveProperty('error'); - }); - it('should return 401 with invalid token format', async () => { - const response = await request(app) - .get('/api/auth/me') - .set('Authorization', 'Bearer invalid-token') - .expect(401); - expect(response.body).toHaveProperty('error'); - }); - it('should return student data with valid token', async () => { - const response = await request(app) - .get('/api/auth/me') - .set('Authorization', `Bearer ${authToken}`) - .expect(200); - expect(response.body).toHaveProperty('user'); - expect(response.body.user).toHaveProperty('id'); - expect(response.body.user.email).toBe(testStudent.email); - expect(response.body.user.name).toBe(`${testStudent.firstName} ${testStudent.lastName}`); - }); + it('should return student data with valid token', async () => { + const response = await request(app) + .get('/api/auth/me') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + expect(response.body).toHaveProperty('user'); + expect(response.body.user).toHaveProperty('id'); + expect(response.body.user.email).toBe(testStudent.email); + expect(response.body.user.name).toBe(`${testStudent.firstName} ${testStudent.lastName}`); }); + }); }); -//# sourceMappingURL=auth.test.js.map \ No newline at end of file +//# sourceMappingURL=auth.test.js.map diff --git a/backend/tests/checkEnv.test.ts b/backend/tests/checkEnv.test.ts index 3b1ca8c9..f8d74f9f 100644 --- a/backend/tests/checkEnv.test.ts +++ b/backend/tests/checkEnv.test.ts @@ -1,4 +1,11 @@ -import { validateEnvironment, EnvironmentValidationError, getEnvVar, isProduction, isDevelopment, isTest } from '../src/utils/checkEnv.js'; +import { + validateEnvironment, + EnvironmentValidationError, + getEnvVar, + isProduction, + isDevelopment, + isTest, +} from '../src/utils/checkEnv.js'; // Mock process.env const originalEnv = process.env; @@ -30,7 +37,9 @@ describe('Environment Variable Guard', () => { process.env.NODE_ENV = 'development'; expect(() => validateEnvironment()).toThrow(EnvironmentValidationError); - expect(() => validateEnvironment()).toThrow(/Missing required environment variable: DATABASE_URL/); + expect(() => validateEnvironment()).toThrow( + /Missing required environment variable: DATABASE_URL/ + ); }); it('should throw error for missing JWT_SECRET', () => { @@ -38,7 +47,9 @@ describe('Environment Variable Guard', () => { process.env.NODE_ENV = 'development'; expect(() => validateEnvironment()).toThrow(EnvironmentValidationError); - expect(() => validateEnvironment()).toThrow(/Missing required environment variable: JWT_SECRET/); + expect(() => validateEnvironment()).toThrow( + /Missing required environment variable: JWT_SECRET/ + ); }); it('should throw error for invalid JWT_SECRET (too short)', () => { @@ -47,7 +58,9 @@ describe('Environment Variable Guard', () => { process.env.NODE_ENV = 'development'; expect(() => validateEnvironment()).toThrow(EnvironmentValidationError); - expect(() => validateEnvironment()).toThrow(/Invalid value for environment variable: JWT_SECRET/); + expect(() => validateEnvironment()).toThrow( + /Invalid value for environment variable: JWT_SECRET/ + ); }); it('should throw error for default JWT_SECRET value', () => { @@ -56,7 +69,9 @@ describe('Environment Variable Guard', () => { process.env.NODE_ENV = 'development'; expect(() => validateEnvironment()).toThrow(EnvironmentValidationError); - expect(() => validateEnvironment()).toThrow(/Invalid value for environment variable: JWT_SECRET/); + expect(() => validateEnvironment()).toThrow( + /Invalid value for environment variable: JWT_SECRET/ + ); }); it('should throw error for invalid DATABASE_URL format', () => { @@ -65,7 +80,9 @@ describe('Environment Variable Guard', () => { process.env.NODE_ENV = 'development'; expect(() => validateEnvironment()).toThrow(EnvironmentValidationError); - expect(() => validateEnvironment()).toThrow(/Invalid value for environment variable: DATABASE_URL/); + expect(() => validateEnvironment()).toThrow( + /Invalid value for environment variable: DATABASE_URL/ + ); }); it('should require production variables in production environment', () => { @@ -75,7 +92,9 @@ describe('Environment Variable Guard', () => { // Missing production variables expect(() => validateEnvironment()).toThrow(EnvironmentValidationError); - expect(() => validateEnvironment()).toThrow(/Missing required environment variable: STELLAR_ISSUER_SECRET_KEY/); + expect(() => validateEnvironment()).toThrow( + /Missing required environment variable: STELLAR_ISSUER_SECRET_KEY/ + ); }); it('should pass production validation with all required variables', () => { diff --git a/backend/tests/generator.test.ts b/backend/tests/generator.test.ts index f74ccbd8..fb38dd46 100644 --- a/backend/tests/generator.test.ts +++ b/backend/tests/generator.test.ts @@ -1,5 +1,6 @@ import request from 'supertest'; import { jest } from '@jest/globals'; +import type { Express } from 'express'; // Mock OpenAI jest.unstable_mockModule('openai', () => { @@ -7,7 +8,7 @@ jest.unstable_mockModule('openai', () => { default: jest.fn().mockImplementation(() => ({ chat: { completions: { - // @ts-ignore + // @ts-expect-error mock implementation create: jest.fn().mockResolvedValue({ choices: [ { @@ -30,7 +31,7 @@ jest.unstable_mockModule('openai', () => { }); describe('Generator API Integration Tests', () => { - let app: any; + let app: Express; beforeAll(async () => { // Dynamic import to ensure mock is registered first diff --git a/backend/tests/health.test.d.ts b/backend/tests/health.test.d.ts index 0d7457ce..423fe3cf 100644 --- a/backend/tests/health.test.d.ts +++ b/backend/tests/health.test.d.ts @@ -1,2 +1,2 @@ export {}; -//# sourceMappingURL=health.test.d.ts.map \ No newline at end of file +//# sourceMappingURL=health.test.d.ts.map diff --git a/backend/tests/health.test.js b/backend/tests/health.test.js index 9c0826de..c7748322 100644 --- a/backend/tests/health.test.js +++ b/backend/tests/health.test.js @@ -1,28 +1,28 @@ import request from 'supertest'; import { app } from '../src/index'; describe('Health Endpoint Integration Tests', () => { - describe('GET /health', () => { - it('should return 200 and health status', async () => { - const response = await request(app).get('/health'); - expect(response.status).toBe(200); - expect(response.body).toHaveProperty('status'); - expect(response.body.status).toBe('ok'); - }); - it('should return Web3 Student Lab Backend message', async () => { - const response = await request(app).get('/health'); - expect(response.body).toHaveProperty('message'); - expect(response.body.message).toBe('Web3 Student Lab Backend is running'); - }); - it('should return JSON content type', async () => { - const response = await request(app).get('/health'); - expect(response.headers['content-type']).toMatch(/application\/json/); - }); + describe('GET /health', () => { + it('should return 200 and health status', async () => { + const response = await request(app).get('/health'); + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('status'); + expect(response.body.status).toBe('ok'); }); - describe('404 Handling', () => { - it('should return 404 for non-existent routes', async () => { - const response = await request(app).get('/non-existent-route'); - expect(response.status).toBe(404); - }); + it('should return Web3 Student Lab Backend message', async () => { + const response = await request(app).get('/health'); + expect(response.body).toHaveProperty('message'); + expect(response.body.message).toBe('Web3 Student Lab Backend is running'); }); + it('should return JSON content type', async () => { + const response = await request(app).get('/health'); + expect(response.headers['content-type']).toMatch(/application\/json/); + }); + }); + describe('404 Handling', () => { + it('should return 404 for non-existent routes', async () => { + const response = await request(app).get('/non-existent-route'); + expect(response.status).toBe(404); + }); + }); }); -//# sourceMappingURL=health.test.js.map \ No newline at end of file +//# sourceMappingURL=health.test.js.map diff --git a/backend/tests/learning.test.d.ts b/backend/tests/learning.test.d.ts index f0e8216f..fdf6882e 100644 --- a/backend/tests/learning.test.d.ts +++ b/backend/tests/learning.test.d.ts @@ -1,2 +1,2 @@ export {}; -//# sourceMappingURL=learning.test.d.ts.map \ No newline at end of file +//# sourceMappingURL=learning.test.d.ts.map diff --git a/backend/tests/learning.test.js b/backend/tests/learning.test.js index 39701364..34efd2b2 100644 --- a/backend/tests/learning.test.js +++ b/backend/tests/learning.test.js @@ -1,133 +1,121 @@ import request from 'supertest'; import { app } from '../src/index'; describe('Learning Module Integration Tests', () => { - describe('GET /api/learning/modules', () => { - it('should return all modules', async () => { - const response = await request(app) - .get('/api/learning/modules') - .expect(200); - expect(response.body).toHaveProperty('modules'); - expect(Array.isArray(response.body.modules)).toBe(true); - expect(response.body.modules.length).toBeGreaterThan(0); - }); - it('should filter modules by difficulty', async () => { - const response = await request(app) - .get('/api/learning/modules?difficulty=beginner') - .expect(200); - expect(response.body).toHaveProperty('modules'); - // Check that all returned lessons are beginner level - response.body.modules.forEach((module) => { - module.lessons.forEach((lesson) => { - expect(lesson.difficulty).toBe('beginner'); - }); - }); - }); - it('should return modules with correct structure', async () => { - const response = await request(app) - .get('/api/learning/modules') - .expect(200); - const firstModule = response.body.modules[0]; - expect(firstModule).toHaveProperty('id'); - expect(firstModule).toHaveProperty('title'); - expect(firstModule).toHaveProperty('description'); - expect(firstModule).toHaveProperty('lessons'); - expect(Array.isArray(firstModule.lessons)).toBe(true); - }); + describe('GET /api/learning/modules', () => { + it('should return all modules', async () => { + const response = await request(app).get('/api/learning/modules').expect(200); + expect(response.body).toHaveProperty('modules'); + expect(Array.isArray(response.body.modules)).toBe(true); + expect(response.body.modules.length).toBeGreaterThan(0); }); - describe('GET /api/learning/modules/:moduleId', () => { - it('should return a specific module by ID', async () => { - const response = await request(app) - .get('/api/learning/modules/mod-1') - .expect(200); - expect(response.body).toHaveProperty('module'); - expect(response.body.module.id).toBe('mod-1'); - expect(response.body.module).toHaveProperty('title'); - expect(response.body.module).toHaveProperty('lessons'); - }); - it('should return 404 for non-existent module', async () => { - const response = await request(app) - .get('/api/learning/modules/non-existent-id') - .expect(404); - expect(response.body).toHaveProperty('error'); + it('should filter modules by difficulty', async () => { + const response = await request(app) + .get('/api/learning/modules?difficulty=beginner') + .expect(200); + expect(response.body).toHaveProperty('modules'); + // Check that all returned lessons are beginner level + response.body.modules.forEach((module) => { + module.lessons.forEach((lesson) => { + expect(lesson.difficulty).toBe('beginner'); }); + }); }); - describe('GET /api/learning/progress/:userId', () => { - it('should return default progress for new user', async () => { - const response = await request(app) - .get('/api/learning/progress/new-user-123') - .expect(200); - expect(response.body).toHaveProperty('progress'); - expect(response.body.progress.userId).toBe('new-user-123'); - expect(response.body.progress.completedLessons).toEqual([]); - expect(response.body.progress.percentage).toBe(0); - }); - it('should return existing progress for user', async () => { - // First, complete a lesson to create progress - await request(app) - .post('/api/learning/progress/existing-user/complete') - .send({ lessonId: 'lesson-1' }); - const response = await request(app) - .get('/api/learning/progress/existing-user') - .expect(200); - expect(response.body).toHaveProperty('progress'); - expect(response.body.progress.userId).toBe('existing-user'); - expect(response.body.progress.completedLessons).toContain('lesson-1'); - }); + it('should return modules with correct structure', async () => { + const response = await request(app).get('/api/learning/modules').expect(200); + const firstModule = response.body.modules[0]; + expect(firstModule).toHaveProperty('id'); + expect(firstModule).toHaveProperty('title'); + expect(firstModule).toHaveProperty('description'); + expect(firstModule).toHaveProperty('lessons'); + expect(Array.isArray(firstModule.lessons)).toBe(true); }); - describe('POST /api/learning/progress/:userId/complete', () => { - it('should mark a lesson as complete', async () => { - const userId = 'test-user-complete'; - const lessonId = 'lesson-1'; - const response = await request(app) - .post(`/api/learning/progress/${userId}/complete`) - .send({ lessonId }) - .expect(200); - expect(response.body).toHaveProperty('progress'); - expect(response.body.progress.completedLessons).toContain(lessonId); - expect(response.body).toHaveProperty('message'); - }); - it('should return 400 if lesson ID is missing', async () => { - const response = await request(app) - .post('/api/learning/progress/test-user/complete') - .send({}) - .expect(400); - expect(response.body).toHaveProperty('error'); - }); - it('should return 404 for non-existent lesson', async () => { - const response = await request(app) - .post('/api/learning/progress/test-user/complete') - .send({ lessonId: 'non-existent-lesson' }) - .expect(404); - expect(response.body).toHaveProperty('error'); - }); - it('should update progress percentage after completing lessons', async () => { - const userId = 'progress-user'; - // Complete first lesson - await request(app) - .post(`/api/learning/progress/${userId}/complete`) - .send({ lessonId: 'lesson-1' }); - // Complete second lesson - const response = await request(app) - .post(`/api/learning/progress/${userId}/complete`) - .send({ lessonId: 'lesson-2' }) - .expect(200); - expect(response.body.progress.percentage).toBeGreaterThan(0); - }); - it('should not duplicate completed lessons', async () => { - const userId = 'no-duplicate-user'; - const lessonId = 'lesson-1'; - // Complete lesson twice - await request(app) - .post(`/api/learning/progress/${userId}/complete`) - .send({ lessonId }); - const response = await request(app) - .post(`/api/learning/progress/${userId}/complete`) - .send({ lessonId }) - .expect(200); - // Count occurrences of lesson-1 - const occurrences = response.body.progress.completedLessons.filter((id) => id === lessonId).length; - expect(occurrences).toBe(1); - }); + }); + describe('GET /api/learning/modules/:moduleId', () => { + it('should return a specific module by ID', async () => { + const response = await request(app).get('/api/learning/modules/mod-1').expect(200); + expect(response.body).toHaveProperty('module'); + expect(response.body.module.id).toBe('mod-1'); + expect(response.body.module).toHaveProperty('title'); + expect(response.body.module).toHaveProperty('lessons'); + }); + it('should return 404 for non-existent module', async () => { + const response = await request(app).get('/api/learning/modules/non-existent-id').expect(404); + expect(response.body).toHaveProperty('error'); + }); + }); + describe('GET /api/learning/progress/:userId', () => { + it('should return default progress for new user', async () => { + const response = await request(app).get('/api/learning/progress/new-user-123').expect(200); + expect(response.body).toHaveProperty('progress'); + expect(response.body.progress.userId).toBe('new-user-123'); + expect(response.body.progress.completedLessons).toEqual([]); + expect(response.body.progress.percentage).toBe(0); + }); + it('should return existing progress for user', async () => { + // First, complete a lesson to create progress + await request(app) + .post('/api/learning/progress/existing-user/complete') + .send({ lessonId: 'lesson-1' }); + const response = await request(app).get('/api/learning/progress/existing-user').expect(200); + expect(response.body).toHaveProperty('progress'); + expect(response.body.progress.userId).toBe('existing-user'); + expect(response.body.progress.completedLessons).toContain('lesson-1'); + }); + }); + describe('POST /api/learning/progress/:userId/complete', () => { + it('should mark a lesson as complete', async () => { + const userId = 'test-user-complete'; + const lessonId = 'lesson-1'; + const response = await request(app) + .post(`/api/learning/progress/${userId}/complete`) + .send({ lessonId }) + .expect(200); + expect(response.body).toHaveProperty('progress'); + expect(response.body.progress.completedLessons).toContain(lessonId); + expect(response.body).toHaveProperty('message'); + }); + it('should return 400 if lesson ID is missing', async () => { + const response = await request(app) + .post('/api/learning/progress/test-user/complete') + .send({}) + .expect(400); + expect(response.body).toHaveProperty('error'); + }); + it('should return 404 for non-existent lesson', async () => { + const response = await request(app) + .post('/api/learning/progress/test-user/complete') + .send({ lessonId: 'non-existent-lesson' }) + .expect(404); + expect(response.body).toHaveProperty('error'); + }); + it('should update progress percentage after completing lessons', async () => { + const userId = 'progress-user'; + // Complete first lesson + await request(app) + .post(`/api/learning/progress/${userId}/complete`) + .send({ lessonId: 'lesson-1' }); + // Complete second lesson + const response = await request(app) + .post(`/api/learning/progress/${userId}/complete`) + .send({ lessonId: 'lesson-2' }) + .expect(200); + expect(response.body.progress.percentage).toBeGreaterThan(0); + }); + it('should not duplicate completed lessons', async () => { + const userId = 'no-duplicate-user'; + const lessonId = 'lesson-1'; + // Complete lesson twice + await request(app).post(`/api/learning/progress/${userId}/complete`).send({ lessonId }); + const response = await request(app) + .post(`/api/learning/progress/${userId}/complete`) + .send({ lessonId }) + .expect(200); + // Count occurrences of lesson-1 + const occurrences = response.body.progress.completedLessons.filter( + (id) => id === lessonId + ).length; + expect(occurrences).toBe(1); }); + }); }); -//# sourceMappingURL=learning.test.js.map \ No newline at end of file +//# sourceMappingURL=learning.test.js.map diff --git a/backend/tests/learning.test.ts b/backend/tests/learning.test.ts index 58cb161f..6dc31f2e 100644 --- a/backend/tests/learning.test.ts +++ b/backend/tests/learning.test.ts @@ -19,11 +19,13 @@ describe('Learning Module Integration Tests', () => { expect(response.body).toHaveProperty('modules'); // Check that all returned lessons are beginner level - response.body.modules.forEach((module: any) => { - module.lessons.forEach((lesson: any) => { - expect(lesson.difficulty).toBe('beginner'); - }); - }); + response.body.modules.forEach( + (module: Record & { lessons: Record[] }) => { + module.lessons.forEach((lesson: Record) => { + expect(lesson.difficulty).toBe('beginner'); + }); + } + ); }); it('should return modules with correct structure', async () => { @@ -143,7 +145,7 @@ describe('Learning Module Integration Tests', () => { // Count occurrences of lesson-1 const occurrences = response.body.progress.completedLessons.filter( - (id: string) => id === lessonId, + (id: string) => id === lessonId ).length; expect(occurrences).toBe(1); diff --git a/backend/tests/progress.test.ts b/backend/tests/progress.test.ts new file mode 100644 index 00000000..1777988f --- /dev/null +++ b/backend/tests/progress.test.ts @@ -0,0 +1,177 @@ +import request from 'supertest'; +import { app } from '../src/index'; +import prisma from '../src/db/index'; + +describe('Course Progress Persistence', () => { + // Clean up learning_progress table before each test + beforeEach(async () => { + await prisma.learningProgress.deleteMany(); + }); + + afterAll(async () => { + await prisma.learningProgress.deleteMany(); + await prisma.$disconnect(); + }); + + describe('GET /api/learning/progress/:userId', () => { + it('should return default progress for a new student', async () => { + const response = await request(app).get('/api/learning/progress/student-new').expect(200); + + expect(response.body.progress).toMatchObject({ + userId: 'student-new', + completedLessons: [], + currentModule: 'mod-1', + percentage: 0, + }); + }); + + it('should return persisted progress for an existing student', async () => { + // Seed progress in DB + await prisma.learningProgress.create({ + data: { + userId: 'student-existing', + completedLessons: ['lesson-1', 'lesson-2'], + currentModule: 'mod-1', + percentage: 50, + }, + }); + + const response = await request(app) + .get('/api/learning/progress/student-existing') + .expect(200); + + expect(response.body.progress.userId).toBe('student-existing'); + expect(response.body.progress.completedLessons).toEqual(['lesson-1', 'lesson-2']); + expect(response.body.progress.percentage).toBe(50); + }); + }); + + describe('POST /api/learning/progress/:userId/complete', () => { + it('should persist a completed lesson to the database', async () => { + const userId = 'student-persist'; + + const response = await request(app) + .post(`/api/learning/progress/${userId}/complete`) + .send({ lessonId: 'lesson-1' }) + .expect(200); + + expect(response.body.progress.completedLessons).toContain('lesson-1'); + expect(response.body.message).toBe('Lesson marked as complete'); + + // Verify it was actually persisted in the DB + const dbRecord = await prisma.learningProgress.findUnique({ + where: { userId }, + }); + expect(dbRecord).not.toBeNull(); + expect(dbRecord!.completedLessons).toContain('lesson-1'); + }); + + it('should accumulate multiple completed lessons', async () => { + const userId = 'student-multi'; + + await request(app) + .post(`/api/learning/progress/${userId}/complete`) + .send({ lessonId: 'lesson-1' }) + .expect(200); + + const response = await request(app) + .post(`/api/learning/progress/${userId}/complete`) + .send({ lessonId: 'lesson-2' }) + .expect(200); + + expect(response.body.progress.completedLessons).toEqual( + expect.arrayContaining(['lesson-1', 'lesson-2']) + ); + expect(response.body.progress.percentage).toBe(50); // 2 of 4 lessons + }); + + it('should be idempotent — completing the same lesson twice does not duplicate', async () => { + const userId = 'student-idempotent'; + + await request(app) + .post(`/api/learning/progress/${userId}/complete`) + .send({ lessonId: 'lesson-1' }) + .expect(200); + + const response = await request(app) + .post(`/api/learning/progress/${userId}/complete`) + .send({ lessonId: 'lesson-1' }) + .expect(200); + + const count = response.body.progress.completedLessons.filter( + (id: string) => id === 'lesson-1' + ).length; + expect(count).toBe(1); + }); + + it('should update percentage correctly as lessons are completed', async () => { + const userId = 'student-percentage'; + + // Complete all 4 lessons (2 modules × 2 lessons each) + for (const lessonId of ['lesson-1', 'lesson-2', 'lesson-3', 'lesson-4']) { + await request(app) + .post(`/api/learning/progress/${userId}/complete`) + .send({ lessonId }) + .expect(200); + } + + const response = await request(app).get(`/api/learning/progress/${userId}`).expect(200); + + expect(response.body.progress.percentage).toBe(100); + expect(response.body.progress.completedLessons).toHaveLength(4); + }); + + it('should track the current module based on the last completed lesson', async () => { + const userId = 'student-module-track'; + + // Complete a lesson from mod-2 + await request(app) + .post(`/api/learning/progress/${userId}/complete`) + .send({ lessonId: 'lesson-3' }) + .expect(200); + + const response = await request(app).get(`/api/learning/progress/${userId}`).expect(200); + + expect(response.body.progress.currentModule).toBe('mod-2'); + }); + + it('should return 400 when lessonId is missing', async () => { + const response = await request(app) + .post('/api/learning/progress/student-bad/complete') + .send({}) + .expect(400); + + expect(response.body.error).toBe('Lesson ID is required'); + }); + + it('should return 404 for a non-existent lesson', async () => { + const response = await request(app) + .post('/api/learning/progress/student-bad/complete') + .send({ lessonId: 'lesson-999' }) + .expect(404); + + expect(response.body.error).toBe('Lesson not found'); + }); + }); + + describe('Progress data flexibility', () => { + it('should store progress with the flexible schema (completedLessons as string array)', async () => { + const userId = 'student-flexible'; + + await request(app) + .post(`/api/learning/progress/${userId}/complete`) + .send({ lessonId: 'lesson-1' }) + .expect(200); + + // Verify the raw DB record uses the flexible string[] format + const dbRecord = await prisma.learningProgress.findUnique({ + where: { userId }, + }); + + expect(dbRecord).not.toBeNull(); + expect(Array.isArray(dbRecord!.completedLessons)).toBe(true); + expect(typeof dbRecord!.currentModule).toBe('string'); + expect(typeof dbRecord!.percentage).toBe('number'); + }); + }); +}); diff --git a/backend/tests/validation.test.ts b/backend/tests/validation.test.ts index aac22771..a7631caa 100644 --- a/backend/tests/validation.test.ts +++ b/backend/tests/validation.test.ts @@ -3,13 +3,13 @@ import { z } from 'zod'; import { validateRequest } from '../src/utils/validation.js'; // Mock Express objects -const mockRequest = (body: any) => +const mockRequest = (body: Record) => ({ body, - }) as Request; + }) as unknown as Request; const mockResponse = () => { - const res: any = {}; + const res: Record = {}; res.status = jest.fn().mockReturnValue(res); res.json = jest.fn().mockReturnValue(res); return res; @@ -72,6 +72,7 @@ describe('Validation Middleware', () => { it('should return 500 error for unexpected errors', () => { const req = mockRequest({}); const res = mockResponse(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const middleware = validateRequest(null as any); middleware(req, res, mockNext); diff --git a/contracts/src/token.rs b/contracts/src/token.rs index 3ee6bb2e..ec889e0b 100644 --- a/contracts/src/token.rs +++ b/contracts/src/token.rs @@ -1,4 +1,6 @@ -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Env}; +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Env, +}; #[contracttype] #[derive(Clone)] diff --git a/frontend/FRONTEND_README.md b/frontend/FRONTEND_README.md index 8cb4c5ad..9ccda132 100644 --- a/frontend/FRONTEND_README.md +++ b/frontend/FRONTEND_README.md @@ -5,23 +5,27 @@ A modern, responsive frontend for the Web3 Student Lab platform built with Next. ## Features ### 🎓 Learning Platform + - **Course Catalog** - Browse and search available blockchain courses - **Course Details** - View detailed course information and enroll - **Student Dashboard** - Track progress, enrolled courses, and certificates - **Enrollment System** - One-click enrollment in courses ### 🔐 Authentication + - **User Registration** - Create new student accounts - **Login/Logout** - Secure JWT-based authentication - **Session Management** - Persistent login sessions with automatic token refresh - **Protected Routes** - Authenticated-only access to dashboard and courses ### 🏆 Blockchain Integration + - **Certificate Verification** - Verify certificates on Soroban/Stellar blockchain - **On-Chain Credentials** - Tamper-proof certificate storage - **Real-time Verification** - Instant blockchain lookup and validation ### 🎨 User Interface + - **Responsive Design** - Mobile-first design that works on all devices - **Dark Mode** - Automatic dark mode support based on system preferences - **Modern UI** - Clean, professional interface with smooth animations @@ -76,16 +80,19 @@ frontend/ ### Installation 1. Install dependencies: + ```bash npm install ``` 2. Create `.env.local` file: + ```bash cp .env.local.example .env.local ``` 3. Configure environment variables: + ```env NEXT_PUBLIC_API_URL=http://localhost:8080/api NEXT_PUBLIC_SOROBAN_RPC_URL=https://soroban-test.stellar.org:443 @@ -95,6 +102,7 @@ NEXT_PUBLIC_CERTIFICATE_CONTRACT_ID=your_contract_id_here ### Development Run the development server: + ```bash npm run dev ``` @@ -104,6 +112,7 @@ Open [http://localhost:3000](http://localhost:3000) in your browser. ### Build & Production Build for production: + ```bash npm run build npm start @@ -150,6 +159,7 @@ const cert = await verifyCertificateOnChain('CERTIFICATE_SYMBOL'); ``` **Note**: Full blockchain integration requires: + 1. Deployed Soroban contract on Stellar network 2. Contract ID configured in environment variables 3. Soroban RPC endpoint access @@ -172,7 +182,7 @@ import { useAuth } from '@/contexts/AuthContext'; function MyComponent() { const { user, isAuthenticated, logout } = useAuth(); - + return
Welcome, {user?.name}!
; } ``` @@ -188,6 +198,7 @@ Built with Tailwind CSS utility classes: ``` Features: + - Dark mode support via `dark:` variants - Responsive breakpoints: `sm:`, `md:`, `lg:` - Custom color palette @@ -207,6 +218,7 @@ MIT License - see LICENSE file for details ## Support For issues or questions: + - Check the documentation - Open an issue on GitHub - Contact the development team diff --git a/frontend/src/app/auth/login/page.tsx b/frontend/src/app/auth/login/page.tsx index b4afc1f0..e09dd0a5 100644 --- a/frontend/src/app/auth/login/page.tsx +++ b/frontend/src/app/auth/login/page.tsx @@ -32,8 +32,10 @@ export default function LoginPage() { try { await login(formData.email, formData.password); router.push('/dashboard'); - } catch (err: any) { - setLocalError(err.response?.data?.error || 'Login failed. Please check your credentials.'); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : 'Login failed. Please check your credentials.'; + setLocalError(message); } finally { setIsSubmitting(false); } @@ -47,16 +49,24 @@ export default function LoginPage() {
- - + +

Initialize Session

-

- Access your secure Web3 learning node -

+

Access your secure Web3 learning node

@@ -67,7 +77,10 @@ export default function LoginPage() { )}
-