240 lines
8.5 KiB
TypeScript
240 lines
8.5 KiB
TypeScript
import { isErr, isOk } from '@common/result';
|
|
import { MAX_PAGE_SIZE } from '@common/pagination';
|
|
|
|
import {
|
|
INSUFFICIENT_DATA,
|
|
ModuleSource,
|
|
} from '@modules/competency-graph';
|
|
import { AchievementType } from '@modules/learning-space';
|
|
import {
|
|
SkillDefinitionService,
|
|
createBuiltinSkillDefinitions,
|
|
} from '@modules/skill-center';
|
|
import {
|
|
Action,
|
|
Purpose,
|
|
ResourceType,
|
|
Role,
|
|
SensitiveFieldCategory,
|
|
User,
|
|
} from '@modules/compliance';
|
|
|
|
import {
|
|
IntegrationHarness,
|
|
buildReferenceItem,
|
|
makeIntegrationHarness,
|
|
} from './integration-harness';
|
|
|
|
/**
|
|
* 端到端集成:核心不变量贯通触达(任务 14;确认 Property 1-8 的核心不变量在装配后成立)。
|
|
*
|
|
* 说明:Property 1-8 的完整属性测试分散在各模块的 `*.property.spec.ts` /
|
|
* `*.spec.ts` 中(fast-check 驱动),由全量测试套件统一运行。本文件不重复属性测试,
|
|
* 而是以集成装配的真实服务对每个 Property 所在领域做一次"核心不变量"冒烟断言,
|
|
* 确认底座装配正确、跨模块共享时各不变量仍然成立。
|
|
*/
|
|
describe('集成:核心不变量贯通触达(Property 1-8 冒烟确认)', () => {
|
|
let h: IntegrationHarness;
|
|
const studentId = 'stu-invariant-1';
|
|
|
|
beforeEach(async () => {
|
|
h = await makeIntegrationHarness();
|
|
});
|
|
|
|
it('Property 1(技能定义序列化往返一致性):内置技能定义往返等价', () => {
|
|
const codec = new SkillDefinitionService();
|
|
for (const def of createBuiltinSkillDefinitions()) {
|
|
const roundTripped = codec.deserialize(codec.serialize(def));
|
|
expect(codec.equivalent(def, roundTripped)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('Property 2(能力标签映射有效性):已定义标签映射成功、未定义标签拒绝且不创建记录', async () => {
|
|
// 已定义标签 → 成功。
|
|
const okResult = await h.mappingService.mapData(
|
|
ModuleSource.LearningSpace,
|
|
{ studentId, referenceId: 'ref-ok', score: 60 },
|
|
['tag.knowledge.basic'],
|
|
);
|
|
expect(isOk(okResult)).toBe(true);
|
|
|
|
// 未定义标签 → 拒绝、不创建映射记录(需求 14.3)。
|
|
const before = (await h.mappingService.listMappings(studentId)).length;
|
|
const badResult = await h.mappingService.mapData(
|
|
ModuleSource.LearningSpace,
|
|
{ studentId, referenceId: 'ref-bad' },
|
|
['tag.does-not-exist'],
|
|
);
|
|
expect(isErr(badResult)).toBe(true);
|
|
const after = (await h.mappingService.listMappings(studentId)).length;
|
|
expect(after).toBe(before);
|
|
});
|
|
|
|
it('Property 3(画像维度分值有界性):每个维度为 [0,100] 数值或数据不足标记', async () => {
|
|
await h.mappingService.mapData(
|
|
ModuleSource.AIEvaluationEngine,
|
|
{ studentId, referenceId: 'ref-k', score: 150 }, // 越界 → clamp
|
|
['tag.knowledge.basic'],
|
|
);
|
|
const profile = await h.profile.generateProfile(studentId);
|
|
expect(isOk(profile)).toBe(true);
|
|
if (!isOk(profile)) {
|
|
return;
|
|
}
|
|
for (const dim of profile.value.dimensions) {
|
|
if (dim.score === INSUFFICIENT_DATA) {
|
|
expect(dim.traceability).toEqual([]);
|
|
} else {
|
|
expect(typeof dim.score).toBe('number');
|
|
expect(dim.score as number).toBeGreaterThanOrEqual(0);
|
|
expect(dim.score as number).toBeLessThanOrEqual(100);
|
|
}
|
|
}
|
|
// 越界分值被 clamp 到 100。
|
|
const knowledge = profile.value.dimensions.find(
|
|
(d) => d.dimensionName === '知识掌握',
|
|
)!;
|
|
expect(knowledge.score).toBe(100);
|
|
});
|
|
|
|
it('Property 4(授权强制不变量):本人完整、非本人无授权脱敏、授权后释放、撤销后失效', async () => {
|
|
const owner: User = { id: studentId, role: Role.Student };
|
|
const mentor: User = { id: 'mentor-inv-1', role: Role.Mentor };
|
|
const profile = {
|
|
studentId,
|
|
fields: [
|
|
{ key: 'dimension.knowledge', value: 80, sensitive: false },
|
|
{
|
|
key: 'dimension.clinical',
|
|
value: 70,
|
|
sensitive: true,
|
|
category: SensitiveFieldCategory.MedicalRecord,
|
|
},
|
|
],
|
|
};
|
|
|
|
// 本人 → 完整。
|
|
const ownerView = await h.consent.resolveProfileView(
|
|
owner,
|
|
profile,
|
|
Purpose.CareerGuidance,
|
|
);
|
|
expect(ownerView.fullAccess).toBe(true);
|
|
expect(ownerView.redactedFieldKeys).toEqual([]);
|
|
|
|
// 非本人无授权 → 敏感字段脱敏。
|
|
const noAuth = await h.consent.resolveProfileView(
|
|
mentor,
|
|
profile,
|
|
Purpose.MentorReview,
|
|
);
|
|
expect(noAuth.fullAccess).toBe(false);
|
|
expect(noAuth.redactedFieldKeys).toContain('dimension.clinical');
|
|
|
|
// 授权后 → 敏感字段释放。
|
|
const now = new Date();
|
|
const grant = await h.consent.grantConsent({
|
|
studentId,
|
|
scope: [SensitiveFieldCategory.MedicalRecord],
|
|
purpose: Purpose.MentorReview,
|
|
validFrom: new Date(now.getTime() - 1000),
|
|
validUntil: new Date(now.getTime() + 60_000),
|
|
});
|
|
expect(isOk(grant)).toBe(true);
|
|
if (!isOk(grant)) {
|
|
return;
|
|
}
|
|
const authed = await h.consent.resolveProfileView(
|
|
mentor,
|
|
profile,
|
|
Purpose.MentorReview,
|
|
);
|
|
expect(authed.redactedFieldKeys).not.toContain('dimension.clinical');
|
|
|
|
// 撤销后 → 立即按未授权处理(需求 7.8)。
|
|
await h.consent.revokeConsent(studentId, grant.value.id);
|
|
const afterRevoke = await h.consent.resolveProfileView(
|
|
mentor,
|
|
profile,
|
|
Purpose.MentorReview,
|
|
);
|
|
expect(afterRevoke.redactedFieldKeys).toContain('dimension.clinical');
|
|
});
|
|
|
|
it('Property 5(引用可追溯不变量):引用输出仅含可追溯结论,无法溯源不作为引用输出', async () => {
|
|
const summary = await h.research.summarize([
|
|
buildReferenceItem({ id: 'ref-traceable' }),
|
|
]);
|
|
expect(isOk(summary)).toBe(true);
|
|
if (!isOk(summary)) {
|
|
return;
|
|
}
|
|
for (const conclusion of summary.value.citationOutput) {
|
|
expect(conclusion.verified).toBe(true);
|
|
expect(conclusion.citations.length).toBeGreaterThanOrEqual(1);
|
|
}
|
|
// 未验证结论绝不出现在引用输出中。
|
|
const unverified = summary.value.conclusions.filter((c) => !c.verified);
|
|
for (const c of unverified) {
|
|
expect(summary.value.citationOutput).not.toContain(c);
|
|
}
|
|
});
|
|
|
|
it('Property 6(权限判定一致性):允许 ⟺ 操作在角色范围内;越权必记录拒绝', async () => {
|
|
const admin: User = { id: 'admin-inv-1', role: Role.Administrator };
|
|
const student: User = { id: 'stu-inv-2', role: Role.Student };
|
|
|
|
// 管理员可配置技能定义(范围内)。
|
|
const adminDecision = await h.compliance.checkAccess(
|
|
admin,
|
|
{ resourceType: ResourceType.SkillDefinition, identifier: 's-1' },
|
|
Action.Configure,
|
|
);
|
|
expect(adminDecision.allowed).toBe(true);
|
|
|
|
// 学生删除审计日志(越权)→ 拒绝并记录。
|
|
const studentDecision = await h.compliance.checkAccess(
|
|
student,
|
|
{ resourceType: ResourceType.AuditLog, identifier: 'log-1' },
|
|
Action.Delete,
|
|
);
|
|
expect(studentDecision.allowed).toBe(false);
|
|
expect(studentDecision.denialEventId).toBeTruthy();
|
|
const denials = await h.compliance.listDenialEventsByActor(student.id);
|
|
expect(denials.length).toBe(1);
|
|
});
|
|
|
|
it('Property 7(错误路径数据保全):非法成果新增被拒且不创建任何记录', async () => {
|
|
const before = await h.learningSpaceRepo.countAchievements();
|
|
// 缺少必填元数据(标题为空)→ 拒绝(需求 1.6)。
|
|
const result = await h.learningSpace.addAchievement(studentId, {
|
|
type: AchievementType.CourseRecord,
|
|
title: ' ',
|
|
occurredAt: new Date('2024-10-01T00:00:00Z'),
|
|
});
|
|
expect(isErr(result)).toBe(true);
|
|
// 数据保全:无任何记录被创建。
|
|
expect(await h.learningSpaceRepo.countAchievements()).toBe(before);
|
|
});
|
|
|
|
it('Property 8(分页边界不变量):单页结果数量不超过 50', async () => {
|
|
// 新增 55 条成果。
|
|
for (let i = 0; i < 55; i += 1) {
|
|
const result = await h.learningSpace.addAchievement(studentId, {
|
|
type: AchievementType.CourseRecord,
|
|
title: `课程记录 ${i + 1}`,
|
|
occurredAt: new Date('2024-10-01T00:00:00Z'),
|
|
});
|
|
expect(isOk(result)).toBe(true);
|
|
}
|
|
// 请求 pageSize 超过上界 → 被钳制到 MAX_PAGE_SIZE。
|
|
const page = await h.learningSpace.listAchievements(
|
|
studentId,
|
|
{},
|
|
{ page: 1, pageSize: 1000 },
|
|
);
|
|
expect(page.items.length).toBeLessThanOrEqual(MAX_PAGE_SIZE);
|
|
expect(page.items.length).toBe(MAX_PAGE_SIZE);
|
|
});
|
|
});
|