- 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>
510 lines
18 KiB
TypeScript
510 lines
18 KiB
TypeScript
/**
|
|
* 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)
|
|
})
|
|
})
|