feat: add tenant student operations

This commit is contained in:
Codex
2026-06-29 01:16:25 +08:00
parent 61240c5833
commit 726d090a8e
12 changed files with 908 additions and 24 deletions

View File

@@ -2762,6 +2762,222 @@ async function testTenantClassStudentScopes() {
assert.ok(auditLogs.items?.some(item => item.action === 'tenant.class_member.upserted'), 'audit logs should include class member upsert');
}
async function testTenantStudentOperations() {
let bulkStudentUserId = '';
const bulk = await request('/api/tenant-admin/students/bulk-upsert', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
students: [
{
username: 'integration_bulk_student',
phone: '13800000018',
name: 'Integration Bulk Student',
regionId: ids.region,
legacyUserId: 'legacy-bulk-student',
status: 'active',
stats: { source: 'integration' },
},
{
username: 'integration_bulk_missing_region',
phone: '13800000019',
name: 'Integration Bulk Missing Region',
regionId: '00000000-0000-0000-0000-ffffffffffff',
},
],
},
});
assert.equal(bulk.total, 2, 'bulk student upsert should report total');
assert.equal(bulk.successCount, 1, 'bulk student upsert should import valid student');
assert.equal(bulk.errorCount, 1, 'bulk student upsert should report invalid rows');
bulkStudentUserId = bulk.items?.find(item => item.status === 'active')?.userId || '';
assert.ok(bulkStudentUserId, 'bulk result should include created student id');
assert.ok(bulk.errors?.some(item => item.code === 'REGION_NOT_FOUND'), 'bulk result should include row-level validation error');
const bulkLimitDenied = await request('/api/tenant-admin/students/bulk-upsert', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
students: Array.from({ length: 201 }, (_, index) => ({
username: `too_many_${index}`,
phone: `13988${String(index).padStart(6, '0')}`,
})),
},
expectStatus: 413,
});
assert.ok(
['BULK_LIMIT_EXCEEDED', 'JSON_BODY_TOO_LARGE'].includes(bulkLimitDenied.code),
'bulk student upsert should enforce item or body limit',
);
const bulkAssign = await request('/api/tenant-admin/classes/members/bulk-assign', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
classId: ids.tenantClass,
assignments: [
{ userId: bulkStudentUserId, memberType: 'student', status: 'active' },
{ userId: '00000000-0000-0000-0000-ffffffffffff', memberType: 'student' },
],
},
});
assert.equal(bulkAssign.total, 2, 'bulk class assign should report total');
assert.equal(bulkAssign.successCount, 1, 'bulk class assign should assign valid student');
assert.equal(bulkAssign.errorCount, 1, 'bulk class assign should report invalid rows');
const adminClassStudents = await request('/api/tenant-admin/students', {
userId: TENANT_ADMIN_USER_ID,
query: { classId: ids.tenantClass, keyword: 'Bulk Student' },
});
assert.ok(adminClassStudents.items?.some(item => item.userId === bulkStudentUserId), 'bulk assigned student should appear in class student list');
const disabled = await request('/api/tenant-admin/students/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
userId: bulkStudentUserId,
status: 'disabled',
reason: 'integration-test',
},
});
assert.equal(disabled.item?.status, 'disabled', 'tenant admin should disable student membership');
const disabledStudents = await request('/api/tenant-admin/students', {
userId: TENANT_ADMIN_USER_ID,
query: { status: 'disabled', keyword: 'Bulk Student' },
});
assert.ok(disabledStudents.items?.some(item => item.userId === bulkStudentUserId), 'disabled student should be queryable by status');
const restored = await request('/api/tenant-admin/students/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
userId: bulkStudentUserId,
status: 'active',
reason: 'restore-integration-test',
},
});
assert.equal(restored.item?.status, 'active', 'tenant admin should restore student membership');
const teacherNote = await request('/api/tenant-admin/students/notes', {
userId: TENANT_TEACHER_USER_ID,
method: 'PUT',
body: {
studentUserId: USER_ID,
noteType: 'learning',
content: '该学生本周错题复习需要跟进。',
visibility: 'class_staff',
isPinned: true,
metadata: { source: 'integration' },
},
});
assert.equal(teacherNote.item?.noteType, 'learning', 'teacher should create note for scoped student');
const teacherNotes = await request('/api/tenant-admin/students/notes', {
userId: TENANT_TEACHER_USER_ID,
query: { studentUserId: USER_ID },
});
assert.ok(teacherNotes.items?.some(item => item.id === teacherNote.item.id), 'teacher should list scoped student notes');
const teacherOtherNoteDenied = await request('/api/tenant-admin/students/notes', {
userId: TENANT_TEACHER_USER_ID,
method: 'PUT',
body: {
studentUserId: SECOND_STUDENT_USER_ID,
noteType: 'learning',
content: '不应允许教师给非负责学生写备注。',
},
expectStatus: 403,
});
assert.equal(teacherOtherNoteDenied.code, 'STUDENT_SCOPE_REQUIRED', 'teacher should not write note for unscoped student');
const followup = await request('/api/tenant-admin/students/followups', {
userId: TENANT_TEACHER_USER_ID,
method: 'PUT',
body: {
studentUserId: USER_ID,
assignedToUserId: TENANT_TEACHER_USER_ID,
classId: ids.tenantClass,
title: '错题复盘督导',
description: '提醒学生完成本周错题复盘。',
followupType: 'learning',
priority: 'high',
status: 'open',
dueAt: '2026-07-01T10:00:00.000Z',
},
});
assert.equal(followup.item?.status, 'open', 'teacher should create follow-up for scoped student');
const teacherFollowups = await request('/api/tenant-admin/students/followups', {
userId: TENANT_TEACHER_USER_ID,
query: { studentUserId: USER_ID, status: 'open' },
});
assert.ok(teacherFollowups.items?.some(item => item.id === followup.item.id), 'teacher should list scoped follow-ups');
const visibleFollowup = teacherFollowups.items?.find(item => item.id === followup.item.id);
assert.equal(visibleFollowup?.studentPhone, null, 'teacher follow-up list should mask student phone');
const completedFollowup = await request('/api/tenant-admin/students/followups', {
userId: TENANT_TEACHER_USER_ID,
method: 'PUT',
body: {
id: followup.item.id,
studentUserId: USER_ID,
assignedToUserId: TENANT_TEACHER_USER_ID,
classId: ids.tenantClass,
title: '错题复盘督导',
followupType: 'learning',
priority: 'high',
status: 'done',
},
});
assert.equal(completedFollowup.item?.status, 'done', 'teacher should complete scoped follow-up');
assert.equal(completedFollowup.item?.completedBy, TENANT_TEACHER_USER_ID, 'completed follow-up should record completer');
const teacherOtherFollowupDenied = await request('/api/tenant-admin/students/followups', {
userId: TENANT_TEACHER_USER_ID,
method: 'PUT',
body: {
studentUserId: SECOND_STUDENT_USER_ID,
classId: ids.tenantClassOther,
title: '不应创建',
followupType: 'learning',
},
expectStatus: 403,
});
assert.equal(teacherOtherFollowupDenied.code, 'STUDENT_SCOPE_REQUIRED', 'teacher should not create follow-up for unscoped student');
const studentStatusDenied = await request('/api/tenant-admin/students/status', {
userId: TENANT_TEACHER_USER_ID,
method: 'POST',
body: {
userId: USER_ID,
status: 'disabled',
},
expectStatus: 403,
});
assert.equal(studentStatusDenied.code, 'TENANT_PERMISSION_REQUIRED', 'teacher should not disable students without status permission');
const partnerBulkDenied = await request('/api/tenant-admin/students/bulk-upsert', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
students: [{ userId: USER_ID, name: 'cross tenant denied' }],
},
expectStatus: 403,
});
assert.equal(partnerBulkDenied.code, 'TENANT_ADMIN_REQUIRED', 'student bulk import must be tenant isolated');
const auditLogs = await request('/api/tenant-admin/audit-logs', {
userId: TENANT_ADMIN_USER_ID,
query: { action: 'tenant.student', limit: 100 },
});
assert.ok(auditLogs.items?.some(item => item.action === 'tenant.students.bulk_upserted'), 'audit logs should include bulk student upsert');
assert.ok(auditLogs.items?.some(item => item.action === 'tenant.student.status_updated'), 'audit logs should include student status update');
assert.ok(auditLogs.items?.some(item => item.action === 'tenant.student_note.upserted'), 'audit logs should include student note upsert');
assert.ok(auditLogs.items?.some(item => item.action === 'tenant.student_followup.upserted'), 'audit logs should include student follow-up upsert');
}
async function testReferralAndCrmGrowth() {
const salesMember = await request('/api/tenant-admin/members', {
userId: TENANT_ADMIN_USER_ID,
@@ -2958,6 +3174,7 @@ async function main() {
await check('tenant admin operations', testTenantAdminOps);
await check('tenant member permissions and audit', testTenantMemberPermissionsAndAudit);
await check('tenant class and student scopes', testTenantClassStudentScopes);
await check('tenant student operations', testTenantStudentOperations);
await check('referral and CRM growth', testReferralAndCrmGrowth);
console.log('API integration tests complete.');