geocrop-platform./apps/nextgen/client/e2e/bulk-marks.spec.ts

205 lines
7.9 KiB
TypeScript

/**
* 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<number> {
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<number[]> {
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<number> {
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();
});
});