import { StudentsService } from './students.service'; import { StudentAccessScope } from './student-access-scope'; // --------------------------------------------------------------------------- // Mock helpers — simulate TypeORM QueryBuilder with raw-column naming // --------------------------------------------------------------------------- interface QbMock extends Record { select: jest.Mock; distinct: jest.Mock; leftJoin: jest.Mock; innerJoin: jest.Mock; where: jest.Mock; andWhere: jest.Mock; orderBy: jest.Mock; take: jest.Mock; getRawMany: jest.Mock; getRawOne: jest.Mock; setParameter: jest.Mock; } function makeQb(rawMany: unknown[] = [], rawOne: unknown | null = null): QbMock { const qb: QbMock = { select: jest.fn().mockReturnThis(), distinct: jest.fn().mockReturnThis(), leftJoin: jest.fn().mockReturnThis(), innerJoin: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), take: jest.fn().mockReturnThis(), setParameter: jest.fn().mockReturnThis(), getRawMany: jest.fn().mockResolvedValue(rawMany), getRawOne: jest.fn().mockResolvedValue(rawOne), }; return qb; } /** StudentsService constructor args in order. */ function makeService( studentRawMany: unknown[] = [], studentRawOne: unknown | null = null, classStudentRawMany: unknown[] = [], innerJoinOnStudentQb?: (qb: QbMock) => void, ): { service: StudentsService; studentQb: QbMock; classStudentQb: QbMock; } { const studentQb = makeQb(studentRawMany, studentRawOne ?? studentRawMany[0] ?? null); const classStudentQb = makeQb(classStudentRawMany); if (innerJoinOnStudentQb) innerJoinOnStudentQb(studentQb); const studentRepo = { createQueryBuilder: jest.fn().mockReturnValue(studentQb) }; const classStudentRepo = { createQueryBuilder: jest.fn().mockReturnValue(classStudentQb) }; const service = new StudentsService( studentRepo as never, classStudentRepo as never, {} as never, // classRepo {} as never, // attendanceRepo {} as never, // classTeacherRepo {} as never, // organizationRepo {} as never, // profileRepo {} as never, // enrollmentRepo {} as never, // examScoreRepo {} as never, // learningRecordRepo {} as never, // resultRepo ); return { service, studentQb, classStudentQb }; } const manageAll: StudentAccessScope = { type: 'manageAll' }; const teacher: StudentAccessScope = { type: 'teacher', userId: 42 }; // TypeORM getRawMany/getRawOne uses snake_case column aliases const sampleRaw = { student_id: 1, student_name: '张三', student_student_no: 'S001', student_gender: '男', student_status: 'active', student_organization_id: 10, organization_name: '杭州校区', }; const expectedOutput = { id: 1, name: '张三', studentNo: 'S001', gender: '男', status: 'active', organizationId: 10, organizationName: '杭州校区', classIds: [5], }; // classStudentRepo raw aliases: cs.studentId → cs_student_id, cs.classId → cs_class_id const classStudentRaw = [ { cs_student_id: 1, cs_class_id: 5 }, ]; // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('StudentsService — agent-safe query APIs', () => { // ----------------------------------------------------------------------- // agentSearchStudents — scope enforcement in SQL // ----------------------------------------------------------------------- describe('agentSearchStudents — scope enforcement in SQL', () => { it('manageAll scope does NOT add teacher/class restrictions', async () => { const { service, studentQb } = makeService([sampleRaw], null, classStudentRaw); await service.agentSearchStudents(manageAll, {}); const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' '); expect(innerJoinArgs).not.toContain('class_teacher'); expect(innerJoinArgs).not.toContain('scopeTeacherUserId'); }); it('teacher scope adds class_student join with class_teacher subquery', async () => { const { service, studentQb } = makeService([], null, classStudentRaw); await service.agentSearchStudents(teacher, {}); const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' '); expect(innerJoinArgs).toContain('class_teacher'); expect(innerJoinArgs).toContain('scopeTeacherUserId'); }); it('teacher scope returns empty array when no students match', async () => { const { service } = makeService([], null, []); const result = await service.agentSearchStudents(teacher, {}); expect(result).toEqual([]); }); }); // ----------------------------------------------------------------------- // agentSearchStudents — classId intersection // ----------------------------------------------------------------------- describe('agentSearchStudents — classId intersection', () => { it('classId triggers class_student join for manageAll', async () => { const { service, studentQb } = makeService([sampleRaw], null, classStudentRaw); await service.agentSearchStudents(manageAll, { classId: 5 }); const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' '); expect(innerJoinArgs).toContain('class_student'); expect(innerJoinArgs).toContain('scopeClassId'); }); it('classId ANDs with teacher scope (intersection, not widening)', async () => { const { service, studentQb } = makeService([sampleRaw], null, classStudentRaw); await service.agentSearchStudents(teacher, { classId: 3 }); const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' '); expect(innerJoinArgs).toContain('class_teacher'); expect(innerJoinArgs).toContain('scopeTeacherUserId'); // classId adds an andWhere, not replacing the teacher join const andWhereArgs = studentQb.andWhere.mock.calls.flat().join(' '); expect(andWhereArgs).toContain('scopeClassId'); }); }); // ----------------------------------------------------------------------- // agentSearchStudents — field whitelist // ----------------------------------------------------------------------- describe('agentSearchStudents — field whitelist', () => { it('select list excludes sensitive fields', async () => { const { service, studentQb } = makeService([sampleRaw], null, classStudentRaw); await service.agentSearchStudents(manageAll, {}); const selectCalls = studentQb.select.mock.calls.flat().join(' '); const forbidden = ['phone', 'idNumber', 'emergencyContact', 'emergencyPhone']; for (const field of forbidden) { expect(selectCalls).not.toContain(field); } }); it('maps raw columns to formatted output', async () => { const { service } = makeService([sampleRaw], null, classStudentRaw); const result = await service.agentSearchStudents(manageAll, {}); expect(result).toEqual([expectedOutput]); }); it('output never contains phone/idNumber', async () => { const { service } = makeService([{ ...sampleRaw, student_phone: '13800138000' }], null, classStudentRaw); const result = await service.agentSearchStudents(manageAll, {}); if (result.length > 0) { const keys = Object.keys(result[0]); expect(keys).not.toContain('phone'); expect(keys).not.toContain('idNumber'); } }); }); // ----------------------------------------------------------------------- // agentSearchStudents — limit // ----------------------------------------------------------------------- describe('agentSearchStudents — limit', () => { it('clamps limit to max 50', async () => { const { service, studentQb } = makeService([], null, []); await service.agentSearchStudents(manageAll, { limit: 200 }); expect(studentQb.take).toHaveBeenCalledWith(50); }); it('clamps limit to min 1', async () => { const { service, studentQb } = makeService([], null, []); await service.agentSearchStudents(manageAll, { limit: 0 }); expect(studentQb.take).toHaveBeenCalledWith(1); }); it('defaults limit to 20 when not specified', async () => { const { service, studentQb } = makeService([], null, []); await service.agentSearchStudents(manageAll, {}); expect(studentQb.take).toHaveBeenCalledWith(20); }); }); // ----------------------------------------------------------------------- // agentSearchStudents — filters // ----------------------------------------------------------------------- describe('agentSearchStudents — filters', () => { it('keyword goes to SQL WHERE', async () => { const { service, studentQb } = makeService([], null, []); await service.agentSearchStudents(manageAll, { keyword: '张三' }); const andWhereStr = JSON.stringify(studentQb.andWhere.mock.calls); expect(andWhereStr).toContain('LIKE'); expect(andWhereStr).toContain('keyword'); }); it('organizationId goes to SQL WHERE', async () => { const { service, studentQb } = makeService([], null, []); await service.agentSearchStudents(manageAll, { organizationId: 10 }); const andWhereStr = JSON.stringify(studentQb.andWhere.mock.calls); expect(andWhereStr).toContain('orgId'); }); }); // ----------------------------------------------------------------------- // agentGetStudentBasic // ----------------------------------------------------------------------- describe('agentGetStudentBasic', () => { it('returns formatted result for student in scope', async () => { // getRawOne returns first element const { service } = makeService([sampleRaw], sampleRaw, classStudentRaw); const result = await service.agentGetStudentBasic(manageAll, 1); expect(result).toEqual(expectedOutput); }); it('returns null when student not in scope', async () => { const { service } = makeService([], null, []); const result = await service.agentGetStudentBasic(teacher, 999); expect(result).toBeNull(); }); it('enforces teacher scope at SQL level via subquery', async () => { const { service, studentQb } = makeService([sampleRaw], sampleRaw, []); await service.agentGetStudentBasic(teacher, 1); const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' '); expect(innerJoinArgs).toContain('class_student'); expect(innerJoinArgs).toContain('class_teacher'); }); it('output whitelist excludes sensitive fields', async () => { const { service } = makeService([sampleRaw], sampleRaw, classStudentRaw); const result = await service.agentGetStudentBasic(manageAll, 1); const keys = Object.keys(result!); expect(keys).not.toContain('phone'); expect(keys).not.toContain('idNumber'); expect(keys).not.toContain('emergencyContact'); expect(keys).not.toContain('emergencyPhone'); }); }); // ----------------------------------------------------------------------- // P1-3: classIds scope enforcement (second query re-applies teacher filter) // ----------------------------------------------------------------------- describe('P1-3: classIds second query enforces teacher scope', () => { it('agentSearchStudents teacher scope: second class query includes teacher filter', async () => { const { service, classStudentQb } = makeService([sampleRaw], null, classStudentRaw); await service.agentSearchStudents(teacher, {}); const andWhereCalls = classStudentQb.andWhere.mock.calls.flat().join(' '); expect(andWhereCalls).toContain('class_teacher'); expect(andWhereCalls).toContain(':scopeTeacherUserId'); }); it('agentSearchStudents manageAll scope: second class query has NO teacher filter', async () => { const { service, classStudentQb } = makeService([sampleRaw], null, classStudentRaw); await service.agentSearchStudents(manageAll, {}); const andWhereCalls = classStudentQb.andWhere.mock.calls.flat().join(' '); expect(andWhereCalls).not.toContain('class_teacher'); }); it('agentGetStudentBasic teacher scope: second class query includes teacher filter', async () => { const { service, classStudentQb } = makeService([], sampleRaw, classStudentRaw); await service.agentGetStudentBasic(teacher, 1); const andWhereCalls = classStudentQb.andWhere.mock.calls.flat().join(' '); expect(andWhereCalls).toContain('class_teacher'); expect(andWhereCalls).toContain(':scopeTeacherUserId'); }); it('agentGetStudentBasic manageAll: second class query has NO teacher filter', async () => { const { service, classStudentQb } = makeService([], sampleRaw, classStudentRaw); await service.agentGetStudentBasic(manageAll, 1); const andWhereCalls = classStudentQb.andWhere.mock.calls.flat().join(' '); expect(andWhereCalls).not.toContain('class_teacher'); }); }); // ----------------------------------------------------------------------- // P2-3: DISTINCT to prevent duplicate students from teacher multi-class join // ----------------------------------------------------------------------- describe('P2-3: DISTINCT in main student query', () => { it('agentSearchStudents teacher scope calls distinct(true)', async () => { const { service, studentQb } = makeService([], null, []); await service.agentSearchStudents(teacher, {}); expect(studentQb.distinct).toHaveBeenCalledWith(true); }); it('agentSearchStudents manageAll scope also calls distinct(true)', async () => { const { service, studentQb } = makeService([], null, []); await service.agentSearchStudents(manageAll, {}); expect(studentQb.distinct).toHaveBeenCalledWith(true); }); }); });