import { SearchStudentsTool } from './search-students.tool'; import { CaslAbilityFactory } from '../../authorization/casl-ability.factory'; import { StudentAccessScopeFactory } from '../../students/student-access-scope.factory'; import { AgentToolContextFactory } from '../agent-tool.types'; import type { AgentToolContext } from '../agent-tool.types'; import type { AuthenticatedUser } from '../../authorization'; const abilityFactory = new CaslAbilityFactory(); const scopeFactory = new StudentAccessScopeFactory(abilityFactory); function makeCtx( overrides: Partial & { id: number; username: string }, ): AgentToolContext { const user: AuthenticatedUser = { id: overrides.id, username: overrides.username, permissions: overrides.permissions ?? [], isSuperAdmin: overrides.isSuperAdmin ?? false, roles: overrides.roles ?? [], }; return AgentToolContextFactory.fromAuthenticatedUser(user); } const studentViewerCtx = makeCtx({ id: 2, username: 'teacher', permissions: ['student:view'] }); const noPermCtx = makeCtx({ id: 3, username: 'guest', permissions: [] }); const superAdminCtx = makeCtx({ id: 1, username: 'admin', isSuperAdmin: true }); const classEditorCtx = makeCtx({ id: 4, username: 'class_editor', permissions: ['student:view', 'class:edit'], }); function makeTool(svcOverride?: { agentSearchStudents: jest.Mock }): SearchStudentsTool { const svc = svcOverride ?? { agentSearchStudents: jest.fn().mockResolvedValue([]) }; return new SearchStudentsTool(svc as never, scopeFactory); } describe('SearchStudentsTool', () => { // ----------------------------------------------------------------------- // Tool metadata // ----------------------------------------------------------------------- it('has name "search_students"', () => { const tool = makeTool(); expect(tool.name).toBe('search_students'); }); it('requires permission "student:view"', () => { const tool = makeTool(); expect(tool.requiredPermission).toBe('student:view'); }); // ----------------------------------------------------------------------- // Input validation // ----------------------------------------------------------------------- describe('validate', () => { it('accepts valid input with keyword', () => { const tool = makeTool(); const result = tool.validate({ keyword: '张三' }); expect(result.ok).toBe(true); if (result.ok) expect(result.value.keyword).toBe('张三'); }); it('accepts valid input with classId', () => { const tool = makeTool(); const result = tool.validate({ classId: 5 }); expect(result.ok).toBe(true); if (result.ok) expect(result.value.classId).toBe(5); }); it('accepts valid input with organizationId', () => { const tool = makeTool(); const result = tool.validate({ organizationId: 10 }); expect(result.ok).toBe(true); if (result.ok) expect(result.value.organizationId).toBe(10); }); it('accepts valid input with limit', () => { const tool = makeTool(); const result = tool.validate({ limit: 30 }); expect(result.ok).toBe(true); if (result.ok) expect(result.value.limit).toBe(30); }); it('rejects userId', () => { const tool = makeTool(); const result = tool.validate({ userId: 999 }); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toContain('userId'); }); it('rejects isSuperAdmin', () => { const tool = makeTool(); const result = tool.validate({ isSuperAdmin: true }); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toContain('isSuperAdmin'); }); it('rejects permissions', () => { const tool = makeTool(); const result = tool.validate({ permissions: ['student:delete'] }); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toContain('permissions'); }); it('rejects roles', () => { const tool = makeTool(); const result = tool.validate({ roles: ['admin'] }); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toContain('roles'); }); it('rejects ability', () => { const tool = makeTool(); const result = tool.validate({ ability: {} }); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toContain('ability'); }); it('rejects unknown fields to match additionalProperties false', () => { const tool = makeTool(); const result = tool.validate({ debug: true }); expect(result.ok).toBe(false); }); it('rejects limit above the advertised maximum', () => { const tool = makeTool(); const result = tool.validate({ limit: 51 }); expect(result.ok).toBe(false); }); it('rejects non-integer classId', () => { const tool = makeTool(); const result = tool.validate({ classId: 'abc' }); expect(result.ok).toBe(false); }); it('rejects non-integer organizationId', () => { const tool = makeTool(); const result = tool.validate({ organizationId: 1.5 }); expect(result.ok).toBe(false); }); }); // ----------------------------------------------------------------------- // P2-2: Scope construction via StudentAccessScopeFactory // ----------------------------------------------------------------------- describe('P2-2: scope construction', () => { it('super admin uses manageAll scope', async () => { const mockSvc = { agentSearchStudents: jest.fn().mockResolvedValue([]) }; const tool = makeTool(mockSvc); await tool.execute({}, superAdminCtx); expect(mockSvc.agentSearchStudents).toHaveBeenCalledWith({ type: 'manageAll' }, {}); }); it('non-admin uses teacher scope with userId', async () => { const mockSvc = { agentSearchStudents: jest.fn().mockResolvedValue([]) }; const tool = makeTool(mockSvc); await tool.execute({ keyword: 'test' }, studentViewerCtx); expect(mockSvc.agentSearchStudents).toHaveBeenCalledWith( { type: 'teacher', userId: 2 }, { keyword: 'test' }, ); }); it('class:edit permission grants manageAll scope (not teacher)', async () => { const mockSvc = { agentSearchStudents: jest.fn().mockResolvedValue([]) }; const tool = makeTool(mockSvc); await tool.execute({}, classEditorCtx); expect(mockSvc.agentSearchStudents).toHaveBeenCalledWith({ type: 'manageAll' }, {}); }); }); });