205 lines
7.9 KiB
TypeScript
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();
|
|
});
|
|
}); |