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) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-05-22 17:20:01 +02:00
parent 349671f37c
commit ec92b03006
2 changed files with 647 additions and 0 deletions

View File

@@ -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<typeof prisma.mentorFile.create>[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)
})
})

View File

@@ -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)
})
})