/** * E2E tests for the bulk marks CSV import endpoint. * * Target: POST /api/marks/bulk * Caller: teacher demo account (`teacher@school.com / teacher123`) * is the production happy path, but for setup convenience these * tests use the admin account so we can also create the * dependent course + assignment rows through the API. The * marks endpoint allows admin and teacher equally. * Header: assignment_id,student_id,score,feedback * * Three specs: * 1. Happy path — 30 valid rows produce 30 inserted (0 errored). * 2. One invalid row — 30 rows, one references a missing student; * 29 inserted, 1 errored. * 3. Non-CSV file — a PNG is rejected by the mime allowlist with 400. * * Test data setup: a fresh course + assignment is created per run; * student IDs come from GET /api/users?role=student. The * marks.controller validates both columns with explicit header + per-row * error reporting (added in ops/sqlite-backup 2026-07-18). * * Pre-reqs (same as fixes.spec.ts): * - server on http://localhost:3001 with seeded subjects + students * - client on http://localhost:3000 (not strictly needed for these * specs since we go through the API directly, but playwright.config * uses it for the baseURL fallback) */ import { test, expect, request as pwRequest } from '@playwright/test'; const API = 'http://localhost:3001'; const TEACHER = { email: 'teacher@school.com', password: 'teacher123' }; const ADMIN = { email: 'admin@school.com', password: 'admin123' }; /** Login and return a request context bound to the API with the JWT in the * default Authorization header. */ async function loginAs(creds: { email: string; password: string }) { const ctx = await pwRequest.newContext({ baseURL: API }); const res = await ctx.post('/api/auth/login', { data: creds }); expect(res.status(), `login as ${creds.email}`).toBe(200); const body = await res.json(); const token = body.token as string; // Re-issue a context that always sends the bearer token. const authed = await pwRequest.newContext({ baseURL: API, extraHTTPHeaders: { Authorization: `Bearer ${token}` }, }); await ctx.dispose(); return { ctx: authed, token, user: body.user }; } /** Pick an existing course_id from GET /api/courses. The assignments * controller only validates that course_id references a row in subjects * (the FK), so any seeded course is fine. */ async function pickCourseId(ctx: any): Promise { const res = await ctx.get('/api/courses'); expect(res.status(), 'GET /api/courses').toBe(200); const arr = await res.json(); expect(Array.isArray(arr)).toBe(true); expect(arr.length).toBeGreaterThan(0); return arr[0].id as number; } /** Pick the first N distinct student IDs. Returns numeric IDs that * the marks controller will accept. */ async function pickStudentIds(ctx: any, n: number): Promise { const res = await ctx.get('/api/users', { params: { role: 'student', limit: String(n) } }); expect(res.status(), 'GET /api/users?role=student').toBe(200); const body = await res.json(); const users = body.users || body; expect(Array.isArray(users)).toBe(true); expect(users.length).toBeGreaterThanOrEqual(n); return users.slice(0, n).map((u: any) => u.id as number); } /** Create a fresh assignment so the upload counts as inserts (not updates). */ async function createAssignment(ctx: any, courseId: number): Promise { const res = await ctx.post('/api/assignments', { data: { course_id: courseId, title: `E2E bulk-marks ${new Date().toISOString()}`, description: 'Created by client/e2e/bulk-marks.spec.ts', max_score: 100, submission_type: 'file', }, }); expect(res.status(), 'POST /api/assignments').toBe(201); const body = await res.json(); expect(typeof body.id).toBe('number'); return body.id as number; } function buildCsv(rows: Array<{ assignment_id: number; student_id: number; score: number; feedback: string }>): Buffer { const lines = ['assignment_id,student_id,score,feedback']; for (const r of rows) { lines.push(`${r.assignment_id},${r.student_id},${r.score},"${r.feedback.replace(/"/g, '""')}"`); } return Buffer.from(lines.join('\n'), 'utf-8'); } /** Build the per-test setup the three specs share: admin login, fresh * assignment against an existing course, and a list of student IDs. */ async function setupTestData() { const { ctx } = await loginAs(ADMIN); const courseId = await pickCourseId(ctx); const assignmentId = await createAssignment(ctx, courseId); const studentIds = await pickStudentIds(ctx, 30); return { ctx, assignmentId, studentIds }; } test.describe('bulk marks — POST /api/marks/bulk', () => { test('1. happy path: 30 valid rows → 30 inserted', async () => { const { ctx, assignmentId, studentIds } = await setupTestData(); const csv = buildCsv(studentIds.map((sid, i) => ({ assignment_id: assignmentId, student_id: sid, score: 50 + (i % 51), // 50..100 feedback: `bulk-marks spec 1 row ${i + 1}`, }))); const res = await ctx.post('/api/marks/bulk', { multipart: { file: { name: 'marks.csv', mimeType: 'text/csv', buffer: csv, }, }, }); expect(res.status(), 'bulk upload status').toBe(200); const body = await res.json(); expect(body.total_rows).toBe(30); expect(body.inserted).toBe(30); expect(body.updated).toBe(0); expect(Array.isArray(body.errors)).toBe(true); expect(body.errors.length).toBe(0); await ctx.dispose(); }); test('2. one invalid row: 30 rows → 29 inserted, 1 errored', async () => { const { ctx, assignmentId, studentIds } = await setupTestData(); // Replace student_id for one row with a non-existent user id. // The controller validates "student exists and role=student" before // counting it as an insert; this row should land in errors[]. const BAD_STUDENT_ID = 9_999_999; const rows = studentIds.map((sid, i) => ({ assignment_id: assignmentId, student_id: i === 7 ? BAD_STUDENT_ID : sid, score: 60, feedback: i === 7 ? 'this row references a missing student' : `spec 2 row ${i + 1}`, })); const csv = buildCsv(rows); const res = await ctx.post('/api/marks/bulk', { multipart: { file: { name: 'marks-with-one-bad-row.csv', mimeType: 'text/csv', buffer: csv, }, }, }); expect(res.status(), 'bulk upload status').toBe(200); const body = await res.json(); expect(body.total_rows).toBe(30); expect(body.inserted).toBe(29); expect(body.updated).toBe(0); expect(Array.isArray(body.errors)).toBe(true); expect(body.errors.length).toBe(1); // The reported row number should be 9 (header is row 1, bad row is index 8). expect(body.errors[0].row).toBe(9); expect(String(body.errors[0].reason)).toMatch(/student_id 9999999 not found/i); await ctx.dispose(); }); test('3. non-CSV file → 400', async () => { const { ctx } = await loginAs(TEACHER); // A minimal 1x1 PNG. mime type is image/png — outside the controller's // allowlist of text/csv, application/csv, application/vnd.ms-excel, // text/plain, application/octet-stream, ''. const png = Buffer.from( '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489' + '0000000d49444154789c636000010000050001a5f645400000000049454e44ae426082', 'hex' ); const res = await ctx.post('/api/marks/bulk', { multipart: { file: { name: 'not-a-csv.png', mimeType: 'image/png', buffer: png, }, }, }); expect(res.status(), 'non-CSV upload status').toBe(400); const body = await res.json(); expect(String(body.error || '')).toMatch(/unsupported mime type/i); await ctx.dispose(); }); });