From ec92b0300611200d76ad77a4c2872f60513d6889 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 17:20:01 +0200 Subject: [PATCH] test(mentor): cover multi-mentor stacking + change-request procedures (PR8 Task 10) - multi-mentor-assignment.test.ts: stacking, P2002 dup-pair, per-team email idempotency via notificationSentAt, requestChange/list/resolve auth + conflict semantics - mentor-file-scope.test.ts: schema invariant (projectId required, dropping the originating assignment leaves the file in place via SetNull) Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/integration/mentor-file-scope.test.ts | 138 ++++++ tests/unit/multi-mentor-assignment.test.ts | 509 ++++++++++++++++++++ 2 files changed, 647 insertions(+) create mode 100644 tests/integration/mentor-file-scope.test.ts create mode 100644 tests/unit/multi-mentor-assignment.test.ts diff --git a/tests/integration/mentor-file-scope.test.ts b/tests/integration/mentor-file-scope.test.ts new file mode 100644 index 0000000..e8bda3f --- /dev/null +++ b/tests/integration/mentor-file-scope.test.ts @@ -0,0 +1,138 @@ +/** + * PR8 — MentorFile schema invariant check + * + * The actual data migration (backfill of MentorFile.projectId from the + * originating MentorAssignment.projectId) was verified against the May 7 + * production database dump in Task 2 of PR8. This file is a complementary + * schema-invariant check that runs against the current dev DB: + * + * 1. MentorFile.projectId is now a required column (Prisma validation fails + * when omitted). + * 2. Files are scoped to the project, not to a single MentorAssignment — + * deleting the originating assignment leaves the file in place with + * mentorAssignmentId set to NULL (FK SetNull) and projectId unchanged. + * This is what enables team-wide file visibility across co-mentors. + */ + +import { afterAll, describe, expect, it } from 'vitest' +import { prisma } from '../setup' +import { + createTestUser, + createTestProgram, + createTestProject, + cleanupTestData, + uid, +} from '../helpers' + +describe('MentorFile scope invariants (PR8 schema)', () => { + const programIds: string[] = [] + const userIds: string[] = [] + const mentorFileIds: string[] = [] + + afterAll(async () => { + if (mentorFileIds.length > 0) { + await prisma.mentorFile.deleteMany({ where: { id: { in: mentorFileIds } } }) + } + for (const programId of programIds) { + await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } }) + await prisma.mentorFile.deleteMany({ where: { project: { programId } } }) + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + it('MentorFile.projectId matches MentorAssignment.projectId when created via the workspace path', async () => { + const program = await createTestProgram({ name: `mfscope-match-${uid()}` }) + programIds.push(program.id) + const project = await createTestProject(program.id, { title: 'Scope Match' }) + const mentor = await createTestUser('MENTOR') + userIds.push(mentor.id) + + const assignment = await prisma.mentorAssignment.create({ + data: { + projectId: project.id, + mentorId: mentor.id, + method: 'MANUAL', + workspaceEnabled: true, + }, + }) + + const file = await prisma.mentorFile.create({ + data: { + projectId: project.id, + mentorAssignmentId: assignment.id, + uploadedByUserId: mentor.id, + fileName: 'invariant.pdf', + mimeType: 'application/pdf', + size: 1024, + bucket: 'mopc-files', + objectKey: `Scope_Match/mentorship/${Date.now()}-invariant.pdf`, + }, + }) + mentorFileIds.push(file.id) + + expect(file.projectId).toBe(assignment.projectId) + }) + + it('creating a MentorFile without a projectId is rejected by Prisma', async () => { + const program = await createTestProgram({ name: `mfscope-noproj-${uid()}` }) + programIds.push(program.id) + const mentor = await createTestUser('MENTOR') + userIds.push(mentor.id) + + // `projectId` is required in the schema — Prisma should reject this. + // Cast away the type for the deliberate omission. + await expect( + prisma.mentorFile.create({ + data: { + uploadedByUserId: mentor.id, + fileName: 'no-project.pdf', + mimeType: 'application/pdf', + size: 10, + bucket: 'mopc-files', + objectKey: 'orphan/no-project.pdf', + } as unknown as Parameters[0]['data'], + }), + ).rejects.toThrow() + }) + + it('dropping the originating MentorAssignment leaves the MentorFile in place (SetNull)', async () => { + const program = await createTestProgram({ name: `mfscope-setnull-${uid()}` }) + programIds.push(program.id) + const project = await createTestProject(program.id, { title: 'SetNull Project' }) + const mentor = await createTestUser('MENTOR') + userIds.push(mentor.id) + + const assignment = await prisma.mentorAssignment.create({ + data: { + projectId: project.id, + mentorId: mentor.id, + method: 'MANUAL', + workspaceEnabled: true, + }, + }) + + const file = await prisma.mentorFile.create({ + data: { + projectId: project.id, + mentorAssignmentId: assignment.id, + uploadedByUserId: mentor.id, + fileName: 'survives-drop.pdf', + mimeType: 'application/pdf', + size: 2048, + bucket: 'mopc-files', + objectKey: `SetNull_Project/mentorship/${Date.now()}-survives.pdf`, + }, + }) + mentorFileIds.push(file.id) + + await prisma.mentorAssignment.delete({ where: { id: assignment.id } }) + + const after = await prisma.mentorFile.findUnique({ where: { id: file.id } }) + expect(after).not.toBeNull() + expect(after?.mentorAssignmentId).toBeNull() + expect(after?.projectId).toBe(project.id) + }) +}) diff --git a/tests/unit/multi-mentor-assignment.test.ts b/tests/unit/multi-mentor-assignment.test.ts new file mode 100644 index 0000000..bae675c --- /dev/null +++ b/tests/unit/multi-mentor-assignment.test.ts @@ -0,0 +1,509 @@ +/** + * PR8 — Multi-mentor stacking + change-request procedures + * + * Covers the API surface added by PR8 Tasks 4 + 6: + * - mentor.assign: per-team stacking, P2002 on duplicate (projectId, mentorId), + * idempotent per-row email notification (via MentorAssignment.notificationSentAt), + * re-assignment after drop creates a new row and re-fires the email. + * - mentor.requestChange: auth (team-member or admin), validation, single open + * request per (user, project), target-assignment cross-project guard. + * - mentor.listChangeRequests: admin-only, PENDING-first ordering. + * - mentor.resolveChangeRequest: admin-only, BAD_REQUEST on already-resolved. + */ + +import { afterAll, describe, expect, it } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestUser, + createTestProgram, + createTestProject, + cleanupTestData, + uid, +} from '../helpers' +import { mentorRouter } from '../../src/server/routers/mentor' +import type { UserRole } from '@prisma/client' + +async function createUserWithRoles( + primaryRole: UserRole, + rolesArray: UserRole[], + overrides: { name?: string; expertiseTags?: string[] } = {}, +) { + const id = uid('user') + return prisma.user.create({ + data: { + id, + email: `${id}@test.local`, + name: overrides.name ?? `Test ${primaryRole}`, + role: primaryRole, + roles: rolesArray, + status: 'ACTIVE', + expertiseTags: overrides.expertiseTags ?? [], + }, + }) +} + +describe('mentor.assign — stacking + per-team email idempotency', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + for (const programId of programIds) { + await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } }) + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + it('stacks two different mentors on the same project (both rows active)', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `assign-stack-${uid()}` }) + programIds.push(program.id) + const project = await createTestProject(program.id, { title: 'Stacking Project' }) + + const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M1' }) + const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'M2' }) + userIds.push(m1.id, m2.id) + + const caller = createCaller(mentorRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const a1 = await caller.assign({ projectId: project.id, mentorId: m1.id }) + const a2 = await caller.assign({ projectId: project.id, mentorId: m2.id }) + + expect(a1.id).not.toBe(a2.id) + expect(a1.mentorId).toBe(m1.id) + expect(a2.mentorId).toBe(m2.id) + + const rows = await prisma.mentorAssignment.findMany({ + where: { projectId: project.id }, + orderBy: { assignedAt: 'asc' }, + }) + expect(rows).toHaveLength(2) + expect(rows.every((r) => r.droppedAt === null)).toBe(true) + }) + + it('rejects duplicate (projectId, mentorId) pair with CONFLICT', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `assign-dup-${uid()}` }) + programIds.push(program.id) + const project = await createTestProject(program.id, { title: 'Dup Project' }) + const mentor = await createUserWithRoles('MENTOR', ['MENTOR']) + userIds.push(mentor.id) + + const caller = createCaller(mentorRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + await caller.assign({ projectId: project.id, mentorId: mentor.id }) + await expect( + caller.assign({ projectId: project.id, mentorId: mentor.id }), + ).rejects.toThrow(/already assigned/i) + }) + + it('stamps notificationSentAt on first assignment; fires fresh email when same mentor is added to a different project', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `assign-email-${uid()}` }) + programIds.push(program.id) + const project1 = await createTestProject(program.id, { title: 'Project Alpha' }) + const project2 = await createTestProject(program.id, { title: 'Project Beta' }) + const mentor = await createUserWithRoles('MENTOR', ['MENTOR']) + userIds.push(mentor.id) + + const caller = createCaller(mentorRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + const a1 = await caller.assign({ projectId: project1.id, mentorId: mentor.id }) + const a2 = await caller.assign({ projectId: project2.id, mentorId: mentor.id }) + + // assign() returns the row before the post-write stamp; re-read for the + // current value. + const row1 = await prisma.mentorAssignment.findUnique({ where: { id: a1.id } }) + const row2 = await prisma.mentorAssignment.findUnique({ where: { id: a2.id } }) + + expect(row1?.notificationSentAt).not.toBeNull() + expect(row2?.notificationSentAt).not.toBeNull() + // Each row carries its own timestamp — they're independent. + expect(row1?.id).not.toBe(row2?.id) + }) + + it('stamps notificationSentAt independently for each co-mentor on the same project', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `assign-comentor-${uid()}` }) + programIds.push(program.id) + const project = await createTestProject(program.id, { title: 'Co-mentor Project' }) + const m1 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-1' }) + const m2 = await createUserWithRoles('MENTOR', ['MENTOR'], { name: 'Co-2' }) + userIds.push(m1.id, m2.id) + + const caller = createCaller(mentorRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const a1 = await caller.assign({ projectId: project.id, mentorId: m1.id }) + const a2 = await caller.assign({ projectId: project.id, mentorId: m2.id }) + + const row1 = await prisma.mentorAssignment.findUnique({ where: { id: a1.id } }) + const row2 = await prisma.mentorAssignment.findUnique({ where: { id: a2.id } }) + + expect(row1?.notificationSentAt).not.toBeNull() + expect(row2?.notificationSentAt).not.toBeNull() + }) + + it('after a mentor is dropped (assignment row deleted), re-assigning creates a fresh row with a new notificationSentAt', async () => { + const admin = await createTestUser('SUPER_ADMIN') + userIds.push(admin.id) + const program = await createTestProgram({ name: `assign-redrop-${uid()}` }) + programIds.push(program.id) + const project = await createTestProject(program.id, { title: 'Re-assign Project' }) + const mentor = await createUserWithRoles('MENTOR', ['MENTOR']) + userIds.push(mentor.id) + + const caller = createCaller(mentorRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + const a1 = await caller.assign({ projectId: project.id, mentorId: mentor.id }) + const stamp1 = ( + await prisma.mentorAssignment.findUnique({ where: { id: a1.id } }) + )?.notificationSentAt + expect(stamp1).not.toBeNull() + + // Hard-delete the first row (simulates a "fully dropped → repository clean" + // state — the unique constraint also blocks any re-assign while the row + // exists, so the row must go away). + await prisma.mentorAssignment.delete({ where: { id: a1.id } }) + + // Re-assign: new row, new notificationSentAt stamp. + const a2 = await caller.assign({ projectId: project.id, mentorId: mentor.id }) + const stamp2 = ( + await prisma.mentorAssignment.findUnique({ where: { id: a2.id } }) + )?.notificationSentAt + + expect(a2.id).not.toBe(a1.id) + expect(stamp2).not.toBeNull() + }) +}) + +describe('mentor.requestChange / listChangeRequests / resolveChangeRequest', () => { + const programIds: string[] = [] + const userIds: string[] = [] + + afterAll(async () => { + for (const programId of programIds) { + await prisma.mentorChangeRequest.deleteMany({ where: { project: { programId } } }) + await prisma.mentorAssignment.deleteMany({ where: { project: { programId } } }) + await prisma.teamMember.deleteMany({ where: { project: { programId } } }) + await cleanupTestData(programId, []) + } + if (userIds.length > 0) { + await prisma.user.deleteMany({ where: { id: { in: userIds } } }) + } + }) + + /** + * Builds a project with a LEAD team member (applicant), an unrelated + * non-team-member (applicant), and an admin. Returns the IDs. + */ + async function setupProjectWithTeam(label: string) { + const admin = await createTestUser('SUPER_ADMIN', { name: `Admin ${label}` }) + userIds.push(admin.id) + const program = await createTestProgram({ name: `${label}-${uid()}` }) + programIds.push(program.id) + const project = await createTestProject(program.id, { title: `Project ${label}` }) + const teamMember = await createUserWithRoles('APPLICANT', ['APPLICANT'], { + name: `Team ${label}`, + }) + const outsider = await createUserWithRoles('APPLICANT', ['APPLICANT'], { + name: `Outsider ${label}`, + }) + userIds.push(teamMember.id, outsider.id) + await prisma.teamMember.create({ + data: { + projectId: project.id, + userId: teamMember.id, + role: 'LEAD', + }, + }) + return { admin, program, project, teamMember, outsider } + } + + it('team member can open a change request (PENDING)', async () => { + const { project, teamMember } = await setupProjectWithTeam('rc-teamok') + const caller = createCaller(mentorRouter, { + id: teamMember.id, + email: teamMember.email, + role: 'APPLICANT', + }) + const created = await caller.requestChange({ + projectId: project.id, + reason: 'We would like a mentor with deeper marine biology experience.', + }) + expect(created.status).toBe('PENDING') + + const persisted = await prisma.mentorChangeRequest.findUnique({ + where: { id: created.id }, + }) + expect(persisted?.requestedByUserId).toBe(teamMember.id) + expect(persisted?.projectId).toBe(project.id) + }) + + it('non-team-member non-admin is rejected with FORBIDDEN', async () => { + const { project, outsider } = await setupProjectWithTeam('rc-outsider') + const caller = createCaller(mentorRouter, { + id: outsider.id, + email: outsider.email, + role: 'APPLICANT', + }) + await expect( + caller.requestChange({ + projectId: project.id, + reason: 'I have no business asking for this.', + }), + ).rejects.toThrow(/FORBIDDEN|not a member/i) + }) + + it('admin (no team membership) can open a change request', async () => { + const { admin, project } = await setupProjectWithTeam('rc-admin') + const caller = createCaller(mentorRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const created = await caller.requestChange({ + projectId: project.id, + reason: 'Admin-initiated mentor swap due to internal escalation.', + }) + expect(created.status).toBe('PENDING') + }) + + it('reason < 10 chars is rejected (Zod validation)', async () => { + const { project, teamMember } = await setupProjectWithTeam('rc-short') + const caller = createCaller(mentorRouter, { + id: teamMember.id, + email: teamMember.email, + role: 'APPLICANT', + }) + await expect( + caller.requestChange({ projectId: project.id, reason: 'too short' }), + ).rejects.toThrow() + }) + + it('opening a second request while the first is still PENDING throws CONFLICT', async () => { + const { project, teamMember } = await setupProjectWithTeam('rc-conflict') + const caller = createCaller(mentorRouter, { + id: teamMember.id, + email: teamMember.email, + role: 'APPLICANT', + }) + await caller.requestChange({ + projectId: project.id, + reason: 'First request — still pending please review.', + }) + await expect( + caller.requestChange({ + projectId: project.id, + reason: 'Second request while first is open.', + }), + ).rejects.toThrow(/already.*open|CONFLICT/i) + }) + + it('after the first request is resolved, the same user can open a new one', async () => { + const { admin, project, teamMember } = await setupProjectWithTeam('rc-reopen') + const teamCaller = createCaller(mentorRouter, { + id: teamMember.id, + email: teamMember.email, + role: 'APPLICANT', + }) + const adminCaller = createCaller(mentorRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + const first = await teamCaller.requestChange({ + projectId: project.id, + reason: 'First request — please address my concerns.', + }) + await adminCaller.resolveChangeRequest({ + id: first.id, + status: 'RESOLVED', + resolutionNote: 'Mentor swapped.', + }) + + const second = await teamCaller.requestChange({ + projectId: project.id, + reason: 'Second request — new concern after resolution.', + }) + expect(second.status).toBe('PENDING') + expect(second.id).not.toBe(first.id) + }) + + it('targetAssignmentId belonging to a different project is rejected with BAD_REQUEST', async () => { + const { admin, project: projectA, teamMember } = await setupProjectWithTeam('rc-crossproj') + // Make a second project + mentor assignment NOT on the requester's project. + const otherProgram = await createTestProgram({ name: `rc-other-${uid()}` }) + programIds.push(otherProgram.id) + const otherProject = await createTestProject(otherProgram.id, { title: 'Other proj' }) + const mentor = await createUserWithRoles('MENTOR', ['MENTOR']) + userIds.push(mentor.id) + const foreignAssignment = await prisma.mentorAssignment.create({ + data: { + projectId: otherProject.id, + mentorId: mentor.id, + method: 'MANUAL', + assignedBy: admin.id, + }, + }) + + const caller = createCaller(mentorRouter, { + id: teamMember.id, + email: teamMember.email, + role: 'APPLICANT', + }) + await expect( + caller.requestChange({ + projectId: projectA.id, + targetAssignmentId: foreignAssignment.id, + reason: 'Trying to point at a foreign assignment row.', + }), + ).rejects.toThrow(/does not belong|BAD_REQUEST/i) + }) + + it('listChangeRequests is FORBIDDEN for applicant', async () => { + const { project, teamMember } = await setupProjectWithTeam('rc-list-forbidden') + const teamCaller = createCaller(mentorRouter, { + id: teamMember.id, + email: teamMember.email, + role: 'APPLICANT', + }) + await teamCaller.requestChange({ + projectId: project.id, + reason: 'A real request, but list should still be admin-only.', + }) + await expect(teamCaller.listChangeRequests({})).rejects.toThrow() + }) + + it('listChangeRequests returns PENDING rows before non-PENDING rows', async () => { + const { admin, project, teamMember } = await setupProjectWithTeam('rc-list-order') + const teamCaller = createCaller(mentorRouter, { + id: teamMember.id, + email: teamMember.email, + role: 'APPLICANT', + }) + const adminCaller = createCaller(mentorRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + // Create two requests: open one, resolve it; open a second one (still PENDING). + const resolvedReq = await teamCaller.requestChange({ + projectId: project.id, + reason: 'Will be resolved before the second request opens.', + }) + await adminCaller.resolveChangeRequest({ + id: resolvedReq.id, + status: 'RESOLVED', + }) + const pendingReq = await teamCaller.requestChange({ + projectId: project.id, + reason: 'Still pending — should be listed first.', + }) + + const rows = (await adminCaller.listChangeRequests({ projectId: project.id })) as Array<{ + id: string + status: string + }> + const ids = rows.map((r) => r.id) + // PENDING must come before RESOLVED in the listing. + expect(ids.indexOf(pendingReq.id)).toBeLessThan(ids.indexOf(resolvedReq.id)) + expect(rows[0].status).toBe('PENDING') + }) + + it('resolveChangeRequest sets resolvedBy/resolvedAt/resolutionNote', async () => { + const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve') + const teamCaller = createCaller(mentorRouter, { + id: teamMember.id, + email: teamMember.email, + role: 'APPLICANT', + }) + const adminCaller = createCaller(mentorRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + + const req = await teamCaller.requestChange({ + projectId: project.id, + reason: 'Please resolve this request.', + }) + const result = await adminCaller.resolveChangeRequest({ + id: req.id, + status: 'RESOLVED', + resolutionNote: 'Replacement mentor assigned.', + }) + expect(result.status).toBe('RESOLVED') + expect(result.resolvedByUserId).toBe(admin.id) + expect(result.resolvedAt).not.toBeNull() + expect(result.resolutionNote).toBe('Replacement mentor assigned.') + }) + + it('resolveChangeRequest by non-admin is FORBIDDEN', async () => { + const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve-forbid') + const teamCaller = createCaller(mentorRouter, { + id: teamMember.id, + email: teamMember.email, + role: 'APPLICANT', + }) + const adminCaller = createCaller(mentorRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const req = await adminCaller.requestChange({ + projectId: project.id, + reason: 'Admin opens, applicant should not resolve.', + }) + await expect( + teamCaller.resolveChangeRequest({ id: req.id, status: 'RESOLVED' }), + ).rejects.toThrow() + }) + + it('resolveChangeRequest on an already-resolved request throws BAD_REQUEST', async () => { + const { admin, project, teamMember } = await setupProjectWithTeam('rc-resolve-twice') + const teamCaller = createCaller(mentorRouter, { + id: teamMember.id, + email: teamMember.email, + role: 'APPLICANT', + }) + const adminCaller = createCaller(mentorRouter, { + id: admin.id, + email: admin.email, + role: 'SUPER_ADMIN', + }) + const req = await teamCaller.requestChange({ + projectId: project.id, + reason: 'Will resolve, then try to resolve again.', + }) + await adminCaller.resolveChangeRequest({ id: req.id, status: 'RESOLVED' }) + await expect( + adminCaller.resolveChangeRequest({ id: req.id, status: 'DISMISSED' }), + ).rejects.toThrow(/already resolved|BAD_REQUEST/i) + }) +})