修复 E2E 准备脚本:

package.json
test:e2e:prepare 现在是 migrate reset --force && prisma generate && seed
为 seed 运行时补充 JS Prisma client 生成器:
schema.prisma
修复 seed 在 ESM/CJS 下的 Prisma 导入兼容:
seed.mjs
修复 Jest 环境未加载 .env 导致连到 127.0.0.1 的问题:
e2e-app.helper.ts
修复夹具依赖“名称”导致被组织测试改名后失效的问题(改为按 seed openId 反查):
e2e-fixtures.helper.ts
修复组织测试的状态污染与清理逻辑,并收敛 afterAll 资源释放:
organization.e2e-spec.ts
e2e-context.helper.ts
This commit is contained in:
EL 2026-03-13 03:29:16 +08:00
parent b55e600c9c
commit 6ec8891be5
19 changed files with 5007 additions and 136 deletions

60
docs/e2e-testing.md Normal file
View File

@ -0,0 +1,60 @@
# E2E 接口测试说明
## 1. 目标
- 覆盖 `src/**/*controller.ts` 当前全部 30 个业务接口。
- 采用 `supertest + @nestjs/testing` 进行真实 HTTP E2E 测试。
- 测试前固定执行数据库重置与 seed确保结果可重复。
## 2. 风险提示
`pnpm test:e2e` 会执行:
1. `prisma migrate reset --force`
2. `node prisma/seed.mjs`
这会清空 `.env``DATABASE_URL` 指向数据库的全部数据,请仅在测试库执行。
## 3. 运行命令
```bash
pnpm test:e2e
```
仅重置数据库并注入 seed
```bash
pnpm test:e2e:prepare
```
监听模式:
```bash
pnpm test:e2e:watch
```
## 4. 种子账号(默认密码:`Seed@1234`
- 系统管理员:`13800001000`
- 院管(医院 A`13800001001`
- 主任(医院 A`13800001002`
- 组长(医院 A`13800001003`
- 医生(医院 A`13800001004`
- 工程师(医院 A`13800001005`
## 5. 用例结构
- `test/e2e/specs/auth.e2e-spec.ts`
- `test/e2e/specs/users.e2e-spec.ts`
- `test/e2e/specs/organization.e2e-spec.ts`
- `test/e2e/specs/tasks.e2e-spec.ts`
- `test/e2e/specs/patients.e2e-spec.ts`
## 6. 覆盖策略
- 受保护接口27 个):每个接口覆盖 6 角色访问结果 + 未登录 401。
- 非受保护接口3 个):每个接口至少 1 个成功 + 1 个失败。
- 关键行为额外覆盖:
- 任务状态机冲突409
- 患者 B 端角色可见性
- 组织域院管作用域限制与删除冲突

View File

@ -12,7 +12,10 @@
"start": "nest start", "start": "nest start",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main" "start:prod": "node dist/main",
"test:e2e:prepare": "pnpm prisma migrate reset --force && pnpm prisma generate && node prisma/seed.mjs",
"test:e2e": "pnpm test:e2e:prepare && NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --config ./test/jest-e2e.config.cjs --runInBand",
"test:e2e:watch": "NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --config ./test/jest-e2e.config.cjs --watch"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
@ -39,14 +42,17 @@
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^30.3.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prisma": "^7.4.2", "prisma": "^7.4.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.4.6",
"ts-loader": "^9.5.2", "ts-loader": "^9.5.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",

2374
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,11 @@ generator client {
output = "../src/generated/prisma" output = "../src/generated/prisma"
} }
// 兼容 seed 脚本在 Node.js 直接运行时使用 @prisma/client runtime。
generator seed_client {
provider = "prisma-client-js"
}
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
} }

View File

@ -1,10 +1,10 @@
import 'dotenv/config'; import 'dotenv/config';
import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaPg } from '@prisma/adapter-pg';
import { hash } from 'bcrypt'; import { hash } from 'bcrypt';
import { PrismaClient } from '../src/generated/prisma/client.js'; import prismaClientPackage from '@prisma/client';
import { DeviceStatus, Role } from '../src/generated/prisma/enums.js';
const { DeviceStatus, PrismaClient, Role, TaskStatus } = prismaClientPackage;
// Keep the seed executable with the same pg driver adapter used by PrismaService.
const connectionString = process.env.DATABASE_URL; const connectionString = process.env.DATABASE_URL;
if (!connectionString) { if (!connectionString) {
throw new Error('DATABASE_URL is required to run seed'); throw new Error('DATABASE_URL is required to run seed');
@ -14,177 +14,424 @@ const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString }), adapter: new PrismaPg({ connectionString }),
}); });
async function main() { const SEED_PASSWORD_PLAIN = 'Seed@1234';
// Default seed login password (plain): Seed@1234
const seedPasswordHash = await hash('Seed@1234', 12);
// Seed a baseline organization tree for local/demo usage. async function ensureHospital(name) {
const hospital = return (
(await prisma.hospital.findFirst({ where: { name: 'Demo Hospital' } })) ?? (await prisma.hospital.findFirst({ where: { name } })) ??
(await prisma.hospital.create({ prisma.hospital.create({ data: { name } })
data: { name: 'Demo Hospital' }, );
})); }
const department = async function ensureDepartment(hospitalId, name) {
return (
(await prisma.department.findFirst({ (await prisma.department.findFirst({
where: { where: { hospitalId, name },
hospitalId: hospital.id,
name: 'Neurosurgery',
},
})) ?? })) ??
(await prisma.department.create({ prisma.department.create({
data: { data: { hospitalId, name },
hospitalId: hospital.id, })
name: 'Neurosurgery', );
}, }
}));
const group = async function ensureGroup(departmentId, name) {
return (
(await prisma.group.findFirst({ (await prisma.group.findFirst({
where: { where: { departmentId, name },
departmentId: department.id,
name: 'Shift-A',
},
})) ?? })) ??
(await prisma.group.create({ prisma.group.create({
data: { data: { departmentId, name },
departmentId: department.id, })
name: 'Shift-A', );
}, }
}));
// Use openId as idempotent unique key for seeded users. async function upsertUserByOpenId(openId, data) {
const systemAdmin = await prisma.user.upsert({ return prisma.user.upsert({
where: { openId: 'seed-system-admin-openid' }, where: { openId },
update: { update: data,
name: 'System Admin', create: {
phone: '13800000000', ...data,
openId,
},
});
}
async function ensurePatient({
hospitalId,
doctorId,
name,
phone,
idCardHash,
}) {
const existing = await prisma.patient.findFirst({
where: {
hospitalId,
phone,
idCardHash,
},
});
if (existing) {
if (existing.doctorId !== doctorId || existing.name !== name) {
return prisma.patient.update({
where: { id: existing.id },
data: { doctorId, name },
});
}
return existing;
}
return prisma.patient.create({
data: {
hospitalId,
doctorId,
name,
phone,
idCardHash,
},
});
}
async function main() {
const seedPasswordHash = await hash(SEED_PASSWORD_PLAIN, 12);
const hospitalA = await ensureHospital('Seed Hospital A');
const hospitalB = await ensureHospital('Seed Hospital B');
const departmentA1 = await ensureDepartment(hospitalA.id, 'Neurosurgery-A1');
const departmentA2 = await ensureDepartment(hospitalA.id, 'Cardiology-A2');
const departmentB1 = await ensureDepartment(hospitalB.id, 'Neurosurgery-B1');
const groupA1 = await ensureGroup(departmentA1.id, 'Shift-A1');
const groupA2 = await ensureGroup(departmentA2.id, 'Shift-A2');
const groupB1 = await ensureGroup(departmentB1.id, 'Shift-B1');
const systemAdmin = await upsertUserByOpenId('seed-system-admin-openid', {
name: 'Seed System Admin',
phone: '13800001000',
passwordHash: seedPasswordHash, passwordHash: seedPasswordHash,
role: Role.SYSTEM_ADMIN, role: Role.SYSTEM_ADMIN,
hospitalId: null, hospitalId: null,
departmentId: null, departmentId: null,
groupId: null, groupId: null,
},
create: {
name: 'System Admin',
phone: '13800000000',
passwordHash: seedPasswordHash,
openId: 'seed-system-admin-openid',
role: Role.SYSTEM_ADMIN,
},
}); });
await prisma.user.upsert({ const hospitalAdminA = await upsertUserByOpenId(
where: { openId: 'seed-hospital-admin-openid' }, 'seed-hospital-admin-a-openid',
update: { {
name: 'Hospital Admin', name: 'Seed Hospital Admin A',
phone: '13800000001', phone: '13800001001',
passwordHash: seedPasswordHash, passwordHash: seedPasswordHash,
role: Role.HOSPITAL_ADMIN, role: Role.HOSPITAL_ADMIN,
hospitalId: hospital.id, hospitalId: hospitalA.id,
departmentId: department.id,
groupId: group.id,
},
create: {
name: 'Hospital Admin',
phone: '13800000001',
passwordHash: seedPasswordHash,
openId: 'seed-hospital-admin-openid',
role: Role.HOSPITAL_ADMIN,
hospitalId: hospital.id,
departmentId: department.id,
groupId: group.id,
},
});
const doctor = await prisma.user.upsert({
where: { openId: 'seed-doctor-openid' },
update: {
name: 'Doctor Demo',
phone: '13800000002',
passwordHash: seedPasswordHash,
role: Role.DOCTOR,
hospitalId: hospital.id,
departmentId: department.id,
groupId: group.id,
},
create: {
name: 'Doctor Demo',
phone: '13800000002',
passwordHash: seedPasswordHash,
openId: 'seed-doctor-openid',
role: Role.DOCTOR,
hospitalId: hospital.id,
departmentId: department.id,
groupId: group.id,
},
});
await prisma.user.upsert({
where: { openId: 'seed-engineer-openid' },
update: {
name: 'Engineer Demo',
phone: '13800000009',
passwordHash: seedPasswordHash,
role: Role.ENGINEER,
hospitalId: hospital.id,
departmentId: null, departmentId: null,
groupId: null, groupId: null,
}, },
create: { );
name: 'Engineer Demo',
phone: '13800000009', await upsertUserByOpenId('seed-hospital-admin-b-openid', {
name: 'Seed Hospital Admin B',
phone: '13800001101',
passwordHash: seedPasswordHash,
role: Role.HOSPITAL_ADMIN,
hospitalId: hospitalB.id,
departmentId: null,
groupId: null,
});
const directorA = await upsertUserByOpenId('seed-director-a-openid', {
name: 'Seed Director A',
phone: '13800001002',
passwordHash: seedPasswordHash,
role: Role.DIRECTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: null,
});
const leaderA = await upsertUserByOpenId('seed-leader-a-openid', {
name: 'Seed Leader A',
phone: '13800001003',
passwordHash: seedPasswordHash,
role: Role.LEADER,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: groupA1.id,
});
const doctorA = await upsertUserByOpenId('seed-doctor-a-openid', {
name: 'Seed Doctor A',
phone: '13800001004',
passwordHash: seedPasswordHash,
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: groupA1.id,
});
const doctorA2 = await upsertUserByOpenId('seed-doctor-a2-openid', {
name: 'Seed Doctor A2',
phone: '13800001204',
passwordHash: seedPasswordHash,
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: groupA1.id,
});
const doctorA3 = await upsertUserByOpenId('seed-doctor-a3-openid', {
name: 'Seed Doctor A3',
phone: '13800001304',
passwordHash: seedPasswordHash,
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA2.id,
groupId: groupA2.id,
});
const doctorB = await upsertUserByOpenId('seed-doctor-b-openid', {
name: 'Seed Doctor B',
phone: '13800001104',
passwordHash: seedPasswordHash,
role: Role.DOCTOR,
hospitalId: hospitalB.id,
departmentId: departmentB1.id,
groupId: groupB1.id,
});
const engineerA = await upsertUserByOpenId('seed-engineer-a-openid', {
name: 'Seed Engineer A',
phone: '13800001005',
passwordHash: seedPasswordHash, passwordHash: seedPasswordHash,
openId: 'seed-engineer-openid',
role: Role.ENGINEER, role: Role.ENGINEER,
hospitalId: hospital.id, hospitalId: hospitalA.id,
departmentId: null,
groupId: null,
});
const engineerB = await upsertUserByOpenId('seed-engineer-b-openid', {
name: 'Seed Engineer B',
phone: '13800001105',
passwordHash: seedPasswordHash,
role: Role.ENGINEER,
hospitalId: hospitalB.id,
departmentId: null,
groupId: null,
});
const patientA1 = await ensurePatient({
hospitalId: hospitalA.id,
doctorId: doctorA.id,
name: 'Seed Patient A1',
phone: '13800002001',
idCardHash: 'seed-id-card-cross-hospital',
});
const patientA2 = await ensurePatient({
hospitalId: hospitalA.id,
doctorId: doctorA2.id,
name: 'Seed Patient A2',
phone: '13800002002',
idCardHash: 'seed-id-card-a2',
});
const patientA3 = await ensurePatient({
hospitalId: hospitalA.id,
doctorId: doctorA3.id,
name: 'Seed Patient A3',
phone: '13800002003',
idCardHash: 'seed-id-card-a3',
});
const patientB1 = await ensurePatient({
hospitalId: hospitalB.id,
doctorId: doctorB.id,
name: 'Seed Patient B1',
phone: '13800002001',
idCardHash: 'seed-id-card-cross-hospital',
});
const deviceA1 = await prisma.device.upsert({
where: { snCode: 'SEED-SN-A-001' },
update: {
patientId: patientA1.id,
currentPressure: 118,
status: DeviceStatus.ACTIVE,
},
create: {
snCode: 'SEED-SN-A-001',
patientId: patientA1.id,
currentPressure: 118,
status: DeviceStatus.ACTIVE,
}, },
}); });
const patient = const deviceA2 = await prisma.device.upsert({
(await prisma.patient.findFirst({ where: { snCode: 'SEED-SN-A-002' },
where: {
hospitalId: hospital.id,
phone: '13800000003',
idCardHash: 'seed-id-card-hash',
},
})) ??
(await prisma.patient.create({
data: {
hospitalId: hospital.id,
doctorId: doctor.id,
name: 'Patient Demo',
phone: '13800000003',
idCardHash: 'seed-id-card-hash',
},
}));
await prisma.device.upsert({
where: { snCode: 'SEED-SN-001' },
update: { update: {
patientId: patient.id, patientId: patientA2.id,
currentPressure: 110, currentPressure: 112,
status: DeviceStatus.ACTIVE, status: DeviceStatus.ACTIVE,
}, },
create: { create: {
snCode: 'SEED-SN-001', snCode: 'SEED-SN-A-002',
patientId: patient.id, patientId: patientA2.id,
currentPressure: 110, currentPressure: 112,
status: DeviceStatus.ACTIVE, status: DeviceStatus.ACTIVE,
}, },
}); });
await prisma.device.upsert({
where: { snCode: 'SEED-SN-A-003' },
update: {
patientId: patientA3.id,
currentPressure: 109,
status: DeviceStatus.ACTIVE,
},
create: {
snCode: 'SEED-SN-A-003',
patientId: patientA3.id,
currentPressure: 109,
status: DeviceStatus.ACTIVE,
},
});
const deviceB1 = await prisma.device.upsert({
where: { snCode: 'SEED-SN-B-001' },
update: {
patientId: patientB1.id,
currentPressure: 121,
status: DeviceStatus.ACTIVE,
},
create: {
snCode: 'SEED-SN-B-001',
patientId: patientB1.id,
currentPressure: 121,
status: DeviceStatus.ACTIVE,
},
});
await prisma.device.upsert({
where: { snCode: 'SEED-SN-A-004' },
update: {
patientId: patientA1.id,
currentPressure: 130,
status: DeviceStatus.INACTIVE,
},
create: {
snCode: 'SEED-SN-A-004',
patientId: patientA1.id,
currentPressure: 130,
status: DeviceStatus.INACTIVE,
},
});
// 清理与种子设备关联的历史任务,保证 seed 可重复执行且生命周期夹具稳定。
const seedTaskItems = await prisma.taskItem.findMany({
where: {
deviceId: {
in: [deviceA1.id, deviceB1.id],
},
},
select: { taskId: true },
});
const seedTaskIds = Array.from(
new Set(seedTaskItems.map((item) => item.taskId)),
);
if (seedTaskIds.length > 0) {
await prisma.task.deleteMany({
where: {
id: {
in: seedTaskIds,
},
},
});
}
const lifecycleTaskA = await prisma.task.create({
data: {
status: TaskStatus.COMPLETED,
creatorId: doctorA.id,
engineerId: engineerA.id,
hospitalId: hospitalA.id,
items: {
create: [
{
deviceId: deviceA1.id,
oldPressure: 118,
targetPressure: 120,
},
],
},
},
include: { items: true },
});
const lifecycleTaskB = await prisma.task.create({
data: {
status: TaskStatus.PENDING,
creatorId: doctorB.id,
engineerId: engineerB.id,
hospitalId: hospitalB.id,
items: {
create: [
{
deviceId: deviceB1.id,
oldPressure: 121,
targetPressure: 119,
},
],
},
},
include: { items: true },
});
console.log( console.log(
JSON.stringify( JSON.stringify(
{ {
ok: true, ok: true,
hospitalId: hospital.id, seedPasswordPlain: SEED_PASSWORD_PLAIN,
departmentId: department.id, hospitals: {
groupId: group.id, hospitalAId: hospitalA.id,
hospitalBId: hospitalB.id,
},
departments: {
departmentA1Id: departmentA1.id,
departmentA2Id: departmentA2.id,
departmentB1Id: departmentB1.id,
},
groups: {
groupA1Id: groupA1.id,
groupA2Id: groupA2.id,
groupB1Id: groupB1.id,
},
users: {
systemAdminId: systemAdmin.id, systemAdminId: systemAdmin.id,
doctorId: doctor.id, hospitalAdminAId: hospitalAdminA.id,
patientId: patient.id, directorAId: directorA.id,
seedPasswordPlain: 'Seed@1234', leaderAId: leaderA.id,
doctorAId: doctorA.id,
doctorA2Id: doctorA2.id,
doctorA3Id: doctorA3.id,
doctorBId: doctorB.id,
engineerAId: engineerA.id,
engineerBId: engineerB.id,
},
patients: {
patientA1Id: patientA1.id,
patientA2Id: patientA2.id,
patientA3Id: patientA3.id,
patientB1Id: patientB1.id,
},
devices: {
deviceA1Id: deviceA1.id,
deviceA2Id: deviceA2.id,
deviceB1Id: deviceB1.id,
},
tasks: {
lifecycleTaskAId: lifecycleTaskA.id,
lifecycleTaskBId: lifecycleTaskB.id,
},
}, },
null, null,
2, 2,

View File

@ -0,0 +1,59 @@
import { Role } from '../../../src/generated/prisma/enums.js';
export const E2E_SEED_PASSWORD = 'Seed@1234';
export const E2E_ROLE_LIST = [
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
Role.ENGINEER,
] as const;
export type E2ERole = (typeof E2E_ROLE_LIST)[number];
export interface E2ESeedCredential {
role: E2ERole;
phone: string;
password: string;
hospitalId?: number;
}
export const E2E_SEED_CREDENTIALS: Record<E2ERole, E2ESeedCredential> = {
[Role.SYSTEM_ADMIN]: {
role: Role.SYSTEM_ADMIN,
phone: '13800001000',
password: E2E_SEED_PASSWORD,
},
[Role.HOSPITAL_ADMIN]: {
role: Role.HOSPITAL_ADMIN,
phone: '13800001001',
password: E2E_SEED_PASSWORD,
hospitalId: 1,
},
[Role.DIRECTOR]: {
role: Role.DIRECTOR,
phone: '13800001002',
password: E2E_SEED_PASSWORD,
hospitalId: 1,
},
[Role.LEADER]: {
role: Role.LEADER,
phone: '13800001003',
password: E2E_SEED_PASSWORD,
hospitalId: 1,
},
[Role.DOCTOR]: {
role: Role.DOCTOR,
phone: '13800001004',
password: E2E_SEED_PASSWORD,
hospitalId: 1,
},
[Role.ENGINEER]: {
role: Role.ENGINEER,
phone: '13800001005',
password: E2E_SEED_PASSWORD,
hospitalId: 1,
},
};

View File

@ -0,0 +1,41 @@
import 'dotenv/config';
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { AppModule } from '../../../src/app.module.js';
import { HttpExceptionFilter } from '../../../src/common/http-exception.filter.js';
import { MESSAGES } from '../../../src/common/messages.js';
import { ResponseEnvelopeInterceptor } from '../../../src/common/response-envelope.interceptor.js';
export async function createE2eApp(): Promise<INestApplication> {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
const app = moduleRef.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
exceptionFactory: (errors) => {
const messages = errors
.flatMap((error) => Object.values(error.constraints ?? {}))
.filter((item): item is string => Boolean(item));
return new BadRequestException(
messages.length > 0
? messages.join('')
: MESSAGES.DEFAULT_BAD_REQUEST,
);
},
}),
);
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
await app.init();
return app;
}

View File

@ -0,0 +1,47 @@
import type { INestApplication } from '@nestjs/common';
import request from 'supertest';
import {
E2E_ROLE_LIST,
type E2ERole,
E2E_SEED_CREDENTIALS,
} from '../fixtures/e2e-roles.js';
import { expectSuccessEnvelope } from './e2e-http.helper.js';
export type E2EAccessTokenMap = Record<E2ERole, string>;
export async function loginAsRole(
app: INestApplication,
role: E2ERole,
): Promise<string> {
const credential = E2E_SEED_CREDENTIALS[role];
const payload: Record<string, unknown> = {
phone: credential.phone,
password: credential.password,
role: credential.role,
};
if (credential.hospitalId != null) {
payload.hospitalId = credential.hospitalId;
}
const response = await request(app.getHttpServer())
.post('/auth/login')
.send(payload);
expectSuccessEnvelope(response, 201);
expect(response.body.data?.accessToken).toEqual(expect.any(String));
return response.body.data.accessToken as string;
}
export async function loginAllRoles(
app: INestApplication,
): Promise<E2EAccessTokenMap> {
const tokenEntries = await Promise.all(
E2E_ROLE_LIST.map(
async (role) => [role, await loginAsRole(app, role)] as const,
),
);
return Object.fromEntries(tokenEntries) as E2EAccessTokenMap;
}

View File

@ -0,0 +1,38 @@
import type { INestApplication } from '@nestjs/common';
import { PrismaService } from '../../../src/prisma.service.js';
import { loginAllRoles, type E2EAccessTokenMap } from './e2e-auth.helper.js';
import { createE2eApp } from './e2e-app.helper.js';
import {
loadSeedFixtures,
type E2ESeedFixtures,
} from './e2e-fixtures.helper.js';
export interface E2EContext {
app: INestApplication;
prisma: PrismaService;
tokens: E2EAccessTokenMap;
fixtures: E2ESeedFixtures;
}
export async function createE2EContext(): Promise<E2EContext> {
const app = await createE2eApp();
const prisma = app.get(PrismaService);
const fixtures = await loadSeedFixtures(prisma);
const tokens = await loginAllRoles(app);
return {
app,
prisma,
fixtures,
tokens,
};
}
export async function closeE2EContext(ctx?: E2EContext) {
if (!ctx) {
return;
}
await ctx.prisma.$disconnect();
await ctx.app.close();
}

View File

@ -0,0 +1,195 @@
import { NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../../src/prisma.service.js';
export interface E2ESeedFixtures {
hospitalAId: number;
hospitalBId: number;
departmentA1Id: number;
departmentA2Id: number;
departmentB1Id: number;
groupA1Id: number;
groupA2Id: number;
groupB1Id: number;
users: {
systemAdminId: number;
hospitalAdminAId: number;
directorAId: number;
leaderAId: number;
doctorAId: number;
doctorA2Id: number;
doctorA3Id: number;
doctorBId: number;
engineerAId: number;
engineerBId: number;
};
patients: {
patientA1Id: number;
patientA2Id: number;
patientA3Id: number;
patientB1Id: number;
};
devices: {
deviceA1Id: number;
deviceA2Id: number;
deviceA3Id: number;
deviceA4InactiveId: number;
deviceB1Id: number;
};
}
interface SeedUserScope {
id: number;
hospitalId: number | null;
departmentId: number | null;
groupId: number | null;
}
async function requireUserScope(
prisma: PrismaService,
openId: string,
): Promise<SeedUserScope> {
const user = await prisma.user.findUnique({
where: { openId },
select: {
id: true,
hospitalId: true,
departmentId: true,
groupId: true,
},
});
if (!user) {
throw new NotFoundException(`Seed user not found: ${openId}`);
}
return user;
}
async function requireDeviceId(
prisma: PrismaService,
snCode: string,
): Promise<number> {
const device = await prisma.device.findUnique({
where: { snCode },
select: { id: true },
});
if (!device) {
throw new NotFoundException(`Seed device not found: ${snCode}`);
}
return device.id;
}
async function requirePatientId(
prisma: PrismaService,
hospitalId: number,
phone: string,
idCardHash: string,
): Promise<number> {
const patient = await prisma.patient.findFirst({
where: { hospitalId, phone, idCardHash },
select: { id: true },
});
if (!patient) {
throw new NotFoundException(
`Seed patient not found: ${phone}/${idCardHash}`,
);
}
return patient.id;
}
export async function loadSeedFixtures(
prisma: PrismaService,
): Promise<E2ESeedFixtures> {
const systemAdmin = await requireUserScope(
prisma,
'seed-system-admin-openid',
);
const hospitalAdminA = await requireUserScope(
prisma,
'seed-hospital-admin-a-openid',
);
const directorA = await requireUserScope(prisma, 'seed-director-a-openid');
const leaderA = await requireUserScope(prisma, 'seed-leader-a-openid');
const doctorA = await requireUserScope(prisma, 'seed-doctor-a-openid');
const doctorA2 = await requireUserScope(prisma, 'seed-doctor-a2-openid');
const doctorA3 = await requireUserScope(prisma, 'seed-doctor-a3-openid');
const doctorB = await requireUserScope(prisma, 'seed-doctor-b-openid');
const engineerA = await requireUserScope(prisma, 'seed-engineer-a-openid');
const engineerB = await requireUserScope(prisma, 'seed-engineer-b-openid');
const hospitalAId = hospitalAdminA.hospitalId;
const hospitalBId = doctorB.hospitalId;
const departmentA1Id = doctorA.departmentId;
const departmentA2Id = doctorA3.departmentId;
const departmentB1Id = doctorB.departmentId;
const groupA1Id = doctorA.groupId;
const groupA2Id = doctorA3.groupId;
const groupB1Id = doctorB.groupId;
if (
hospitalAId == null ||
hospitalBId == null ||
departmentA1Id == null ||
departmentA2Id == null ||
departmentB1Id == null ||
groupA1Id == null ||
groupA2Id == null ||
groupB1Id == null
) {
throw new NotFoundException('Seed user scope is incomplete');
}
return {
hospitalAId,
hospitalBId,
departmentA1Id,
departmentA2Id,
departmentB1Id,
groupA1Id,
groupA2Id,
groupB1Id,
users: {
systemAdminId: systemAdmin.id,
hospitalAdminAId: hospitalAdminA.id,
directorAId: directorA.id,
leaderAId: leaderA.id,
doctorAId: doctorA.id,
doctorA2Id: doctorA2.id,
doctorA3Id: doctorA3.id,
doctorBId: doctorB.id,
engineerAId: engineerA.id,
engineerBId: engineerB.id,
},
patients: {
patientA1Id: await requirePatientId(
prisma,
hospitalAId,
'13800002001',
'seed-id-card-cross-hospital',
),
patientA2Id: await requirePatientId(
prisma,
hospitalAId,
'13800002002',
'seed-id-card-a2',
),
patientA3Id: await requirePatientId(
prisma,
hospitalAId,
'13800002003',
'seed-id-card-a3',
),
patientB1Id: await requirePatientId(
prisma,
hospitalBId,
'13800002001',
'seed-id-card-cross-hospital',
),
},
devices: {
deviceA1Id: await requireDeviceId(prisma, 'SEED-SN-A-001'),
deviceA2Id: await requireDeviceId(prisma, 'SEED-SN-A-002'),
deviceA3Id: await requireDeviceId(prisma, 'SEED-SN-A-003'),
deviceA4InactiveId: await requireDeviceId(prisma, 'SEED-SN-A-004'),
deviceB1Id: await requireDeviceId(prisma, 'SEED-SN-B-001'),
},
};
}

View File

@ -0,0 +1,37 @@
import type { Response } from 'supertest';
export function expectSuccessEnvelope(response: Response, status: number) {
expect(response.status).toBe(status);
expect(response.body).toEqual(
expect.objectContaining({
code: 0,
msg: '成功',
}),
);
expect(response.body).toHaveProperty('data');
}
export function expectErrorEnvelope(
response: Response,
status: number,
messageIncludes?: string,
) {
expect(response.status).toBe(status);
expect(response.body.code).toBe(status);
expect(response.body.data).toBeNull();
if (messageIncludes) {
expect(String(response.body.msg)).toContain(messageIncludes);
}
}
export function uniqueSeedValue(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export function uniquePhone(): string {
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`
.replace(/\D/g, '')
.slice(-10);
return `1${suffix.padStart(10, '0')}`.slice(0, 11);
}

View File

@ -0,0 +1,32 @@
import type { Response } from 'supertest';
import { E2E_ROLE_LIST, type E2ERole } from '../fixtures/e2e-roles.js';
import type { E2EAccessTokenMap } from './e2e-auth.helper.js';
interface RoleMatrixCase {
name: string;
tokens: E2EAccessTokenMap;
expectedStatusByRole: Record<E2ERole, number>;
sendAsRole: (role: E2ERole, token: string) => Promise<Response>;
sendWithoutToken: () => Promise<Response>;
expectedStatusWithoutToken?: number;
}
export async function assertRoleMatrix(matrixCase: RoleMatrixCase) {
for (const role of E2E_ROLE_LIST) {
const response = await matrixCase.sendAsRole(role, matrixCase.tokens[role]);
const expectedStatus = matrixCase.expectedStatusByRole[role];
const isSuccess = expectedStatus >= 200 && expectedStatus < 300;
expect(response.status).toBe(expectedStatus);
expect(response.body.code).toBe(isSuccess ? 0 : expectedStatus);
}
const unauthorizedResponse = await matrixCase.sendWithoutToken();
const unauthorizedStatus = matrixCase.expectedStatusWithoutToken ?? 401;
expect(unauthorizedResponse.status).toBe(unauthorizedStatus);
expect(unauthorizedResponse.body.code).toBe(
unauthorizedStatus >= 200 && unauthorizedStatus < 300
? 0
: unauthorizedStatus,
);
}

View File

@ -0,0 +1,129 @@
import request from 'supertest';
import { Role } from '../../../src/generated/prisma/enums.js';
import {
closeE2EContext,
createE2EContext,
type E2EContext,
} from '../helpers/e2e-context.helper.js';
import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
import {
expectErrorEnvelope,
expectSuccessEnvelope,
uniquePhone,
uniqueSeedValue,
} from '../helpers/e2e-http.helper.js';
describe('AuthController (e2e)', () => {
let ctx: E2EContext;
beforeAll(async () => {
ctx = await createE2EContext();
});
afterAll(async () => {
await closeE2EContext(ctx);
});
describe('POST /auth/register', () => {
it('成功:注册医生账号', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/auth/register')
.send({
name: uniqueSeedValue('Auth 注册医生'),
phone: uniquePhone(),
password: 'Seed@1234',
role: Role.DOCTOR,
hospitalId: ctx.fixtures.hospitalAId,
departmentId: ctx.fixtures.departmentA1Id,
groupId: ctx.fixtures.groupA1Id,
openId: uniqueSeedValue('auth-register-openid'),
});
expectSuccessEnvelope(response, 201);
expect(response.body.data.role).toBe(Role.DOCTOR);
});
it('失败:参数不合法返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/auth/register')
.send({
name: 'bad-register',
phone: '13800009999',
password: '123',
role: Role.DOCTOR,
hospitalId: ctx.fixtures.hospitalAId,
departmentId: ctx.fixtures.departmentA1Id,
groupId: ctx.fixtures.groupA1Id,
});
expectErrorEnvelope(response, 400, 'password 长度至少 8 位');
});
});
describe('POST /auth/login', () => {
it('成功seed 账号登录并拿到 token', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/auth/login')
.send({
phone: '13800001004',
password: 'Seed@1234',
role: Role.DOCTOR,
hospitalId: ctx.fixtures.hospitalAId,
});
expectSuccessEnvelope(response, 201);
expect(response.body.data.accessToken).toEqual(expect.any(String));
expect(response.body.data.actor.role).toBe(Role.DOCTOR);
});
it('失败:密码错误返回 401', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/auth/login')
.send({
phone: '13800001004',
password: 'Seed@12345',
role: Role.DOCTOR,
hospitalId: ctx.fixtures.hospitalAId,
});
expectErrorEnvelope(response, 401, '手机号、角色或密码错误');
});
});
describe('GET /auth/me', () => {
it('成功:已登录用户可读取当前信息', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/auth/me')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.role).toBe(Role.DOCTOR);
});
it('失败:未登录返回 401', async () => {
const response = await request(ctx.app.getHttpServer()).get('/auth/me');
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
});
it('角色矩阵6 角色都可访问,未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /auth/me role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 200,
[Role.LEADER]: 200,
[Role.DOCTOR]: 200,
[Role.ENGINEER]: 200,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.get('/auth/me')
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get('/auth/me'),
});
});
});
});

View File

@ -0,0 +1,729 @@
import request from 'supertest';
import { Role } from '../../../src/generated/prisma/enums.js';
import {
closeE2EContext,
createE2EContext,
type E2EContext,
} from '../helpers/e2e-context.helper.js';
import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
import {
expectErrorEnvelope,
expectSuccessEnvelope,
uniqueSeedValue,
} from '../helpers/e2e-http.helper.js';
describe('Organization Controllers (e2e)', () => {
let ctx: E2EContext;
beforeAll(async () => {
ctx = await createE2EContext();
});
afterAll(async () => {
await closeE2EContext(ctx);
});
describe('HospitalsController', () => {
describe('POST /b/organization/hospitals', () => {
it('成功SYSTEM_ADMIN 可创建医院', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/organization/hospitals')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({ name: uniqueSeedValue('组织-医院') });
expectSuccessEnvelope(response, 201);
expect(response.body.data.name).toContain('组织-医院');
});
it('失败:非系统管理员创建返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/organization/hospitals')
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: uniqueSeedValue('组织-医院-失败') });
expectErrorEnvelope(response, 403, '无权限执行当前操作');
});
it('角色矩阵:仅 SYSTEM_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/organization/hospitals role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 400,
[Role.HOSPITAL_ADMIN]: 403,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.post('/b/organization/hospitals')
.set('Authorization', `Bearer ${token}`)
.send({}),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.post('/b/organization/hospitals')
.send({}),
});
});
});
describe('GET /b/organization/hospitals', () => {
it('成功SYSTEM_ADMIN 可查询医院列表', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/b/organization/hospitals')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data).toHaveProperty('list');
});
it('失败:未登录返回 401', async () => {
const response = await request(ctx.app.getHttpServer()).get(
'/b/organization/hospitals',
);
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /b/organization/hospitals role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.get('/b/organization/hospitals')
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get('/b/organization/hospitals'),
});
});
});
describe('GET /b/organization/hospitals/:id', () => {
it('成功HOSPITAL_ADMIN 可查询本院详情', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/b/organization/hospitals/${ctx.fixtures.hospitalAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.id).toBe(ctx.fixtures.hospitalAId);
});
it('失败HOSPITAL_ADMIN 查询他院返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/b/organization/hospitals/${ctx.fixtures.hospitalBId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /b/organization/hospitals/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.get(`/b/organization/hospitals/${ctx.fixtures.hospitalAId}`)
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get(
`/b/organization/hospitals/${ctx.fixtures.hospitalAId}`,
),
});
});
});
describe('PATCH /b/organization/hospitals/:id', () => {
it('成功HOSPITAL_ADMIN 可更新本院名称', async () => {
const originalName = 'Seed Hospital A';
const nextName = uniqueSeedValue('医院更新');
const response = await request(ctx.app.getHttpServer())
.patch(`/b/organization/hospitals/${ctx.fixtures.hospitalAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: nextName });
expectSuccessEnvelope(response, 200);
const rollbackResponse = await request(ctx.app.getHttpServer())
.patch(`/b/organization/hospitals/${ctx.fixtures.hospitalAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: originalName });
expectSuccessEnvelope(rollbackResponse, 200);
});
it('失败HOSPITAL_ADMIN 更新他院返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.patch(`/b/organization/hospitals/${ctx.fixtures.hospitalBId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: uniqueSeedValue('跨院更新失败') });
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'PATCH /b/organization/hospitals/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 404,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.patch('/b/organization/hospitals/99999999')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'matrix-hospital-patch' }),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.patch('/b/organization/hospitals/99999999')
.send({ name: 'matrix-hospital-patch' }),
});
});
});
describe('DELETE /b/organization/hospitals/:id', () => {
it('成功SYSTEM_ADMIN 可删除空医院', async () => {
const createResponse = await request(ctx.app.getHttpServer())
.post('/b/organization/hospitals')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({ name: uniqueSeedValue('医院待删') });
expectSuccessEnvelope(createResponse, 201);
const targetId = createResponse.body.data.id as number;
const deleteResponse = await request(ctx.app.getHttpServer())
.delete(`/b/organization/hospitals/${targetId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(deleteResponse, 200);
expect(deleteResponse.body.data.id).toBe(targetId);
});
it('失败HOSPITAL_ADMIN 删除医院返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.delete(`/b/organization/hospitals/${ctx.fixtures.hospitalAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectErrorEnvelope(response, 403, '无权限执行当前操作');
});
it('角色矩阵:仅 SYSTEM_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'DELETE /b/organization/hospitals/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 403,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.delete('/b/organization/hospitals/99999999')
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).delete(
'/b/organization/hospitals/99999999',
),
});
});
});
});
describe('DepartmentsController', () => {
describe('POST /b/organization/departments', () => {
it('成功HOSPITAL_ADMIN 可在本院创建科室', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/organization/departments')
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({
name: uniqueSeedValue('组织-科室'),
hospitalId: ctx.fixtures.hospitalAId,
});
expectSuccessEnvelope(response, 201);
});
it('失败HOSPITAL_ADMIN 跨院创建返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/organization/departments')
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({
name: uniqueSeedValue('组织-跨院科室失败'),
hospitalId: ctx.fixtures.hospitalBId,
});
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/organization/departments role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 400,
[Role.HOSPITAL_ADMIN]: 400,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.post('/b/organization/departments')
.set('Authorization', `Bearer ${token}`)
.send({}),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.post('/b/organization/departments')
.send({}),
});
});
});
describe('GET /b/organization/departments', () => {
it('成功HOSPITAL_ADMIN 可查询本院科室列表', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/b/organization/departments')
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data).toHaveProperty('list');
});
it('失败:未登录返回 401', async () => {
const response = await request(ctx.app.getHttpServer()).get(
'/b/organization/departments',
);
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /b/organization/departments role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.get('/b/organization/departments')
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get('/b/organization/departments'),
});
});
});
describe('GET /b/organization/departments/:id', () => {
it('成功SYSTEM_ADMIN 可查询科室详情', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/b/organization/departments/${ctx.fixtures.departmentA1Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.id).toBe(ctx.fixtures.departmentA1Id);
});
it('失败HOSPITAL_ADMIN 查询他院科室返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/b/organization/departments/${ctx.fixtures.departmentB1Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /b/organization/departments/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.get(`/b/organization/departments/${ctx.fixtures.departmentA1Id}`)
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get(
`/b/organization/departments/${ctx.fixtures.departmentA1Id}`,
),
});
});
});
describe('PATCH /b/organization/departments/:id', () => {
it('成功HOSPITAL_ADMIN 可更新本院科室', async () => {
const originalName = 'Cardiology-A2';
const nextName = uniqueSeedValue('科室更新');
const response = await request(ctx.app.getHttpServer())
.patch(`/b/organization/departments/${ctx.fixtures.departmentA2Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: nextName });
expectSuccessEnvelope(response, 200);
const rollbackResponse = await request(ctx.app.getHttpServer())
.patch(`/b/organization/departments/${ctx.fixtures.departmentA2Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: originalName });
expectSuccessEnvelope(rollbackResponse, 200);
});
it('失败HOSPITAL_ADMIN 更新他院科室返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.patch(`/b/organization/departments/${ctx.fixtures.departmentB1Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: uniqueSeedValue('跨院科室更新失败') });
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'PATCH /b/organization/departments/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 404,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.patch('/b/organization/departments/99999999')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'matrix-department-patch' }),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.patch('/b/organization/departments/99999999')
.send({ name: 'matrix-department-patch' }),
});
});
});
describe('DELETE /b/organization/departments/:id', () => {
it('成功SYSTEM_ADMIN 可删除无关联科室', async () => {
const createResponse = await request(ctx.app.getHttpServer())
.post('/b/organization/departments')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({
name: uniqueSeedValue('科室待删'),
hospitalId: ctx.fixtures.hospitalAId,
});
expectSuccessEnvelope(createResponse, 201);
const targetId = createResponse.body.data.id as number;
const deleteResponse = await request(ctx.app.getHttpServer())
.delete(`/b/organization/departments/${targetId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(deleteResponse, 200);
});
it('失败:存在关联数据删除返回 409', async () => {
const response = await request(ctx.app.getHttpServer())
.delete(`/b/organization/departments/${ctx.fixtures.departmentA1Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectErrorEnvelope(response, 409, '存在关联数据,无法删除');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'DELETE /b/organization/departments/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 404,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.delete('/b/organization/departments/99999999')
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).delete(
'/b/organization/departments/99999999',
),
});
});
});
});
describe('GroupsController', () => {
describe('POST /b/organization/groups', () => {
it('成功HOSPITAL_ADMIN 可创建小组', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/organization/groups')
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({
name: uniqueSeedValue('组织-小组'),
departmentId: ctx.fixtures.departmentA1Id,
});
expectSuccessEnvelope(response, 201);
});
it('失败HOSPITAL_ADMIN 跨院创建小组返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/organization/groups')
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({
name: uniqueSeedValue('组织-跨院小组失败'),
departmentId: ctx.fixtures.departmentB1Id,
});
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/organization/groups role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 400,
[Role.HOSPITAL_ADMIN]: 400,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.post('/b/organization/groups')
.set('Authorization', `Bearer ${token}`)
.send({}),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.post('/b/organization/groups')
.send({}),
});
});
});
describe('GET /b/organization/groups', () => {
it('成功SYSTEM_ADMIN 可查询小组列表', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/b/organization/groups')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data).toHaveProperty('list');
});
it('失败:未登录返回 401', async () => {
const response = await request(ctx.app.getHttpServer()).get(
'/b/organization/groups',
);
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /b/organization/groups role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.get('/b/organization/groups')
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get('/b/organization/groups'),
});
});
});
describe('GET /b/organization/groups/:id', () => {
it('成功HOSPITAL_ADMIN 可查询本院小组详情', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/b/organization/groups/${ctx.fixtures.groupA1Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.id).toBe(ctx.fixtures.groupA1Id);
});
it('失败HOSPITAL_ADMIN 查询他院小组返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/b/organization/groups/${ctx.fixtures.groupB1Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /b/organization/groups/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.get(`/b/organization/groups/${ctx.fixtures.groupA1Id}`)
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get(
`/b/organization/groups/${ctx.fixtures.groupA1Id}`,
),
});
});
});
describe('PATCH /b/organization/groups/:id', () => {
it('成功HOSPITAL_ADMIN 可更新本院小组', async () => {
const originalName = 'Shift-A2';
const nextName = uniqueSeedValue('小组更新');
const response = await request(ctx.app.getHttpServer())
.patch(`/b/organization/groups/${ctx.fixtures.groupA2Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: nextName });
expectSuccessEnvelope(response, 200);
const rollbackResponse = await request(ctx.app.getHttpServer())
.patch(`/b/organization/groups/${ctx.fixtures.groupA2Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: originalName });
expectSuccessEnvelope(rollbackResponse, 200);
});
it('失败HOSPITAL_ADMIN 更新他院小组返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.patch(`/b/organization/groups/${ctx.fixtures.groupB1Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: uniqueSeedValue('跨院小组更新失败') });
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'PATCH /b/organization/groups/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 404,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.patch('/b/organization/groups/99999999')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'matrix-group-patch' }),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.patch('/b/organization/groups/99999999')
.send({ name: 'matrix-group-patch' }),
});
});
});
describe('DELETE /b/organization/groups/:id', () => {
it('成功SYSTEM_ADMIN 可删除无关联小组', async () => {
const createResponse = await request(ctx.app.getHttpServer())
.post('/b/organization/groups')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({
name: uniqueSeedValue('小组待删'),
departmentId: ctx.fixtures.departmentA1Id,
});
expectSuccessEnvelope(createResponse, 201);
const targetId = createResponse.body.data.id as number;
const deleteResponse = await request(ctx.app.getHttpServer())
.delete(`/b/organization/groups/${targetId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(deleteResponse, 200);
});
it('失败HOSPITAL_ADMIN 删除他院小组返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.delete(`/b/organization/groups/${ctx.fixtures.groupB1Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'DELETE /b/organization/groups/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 404,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.delete('/b/organization/groups/99999999')
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).delete(
'/b/organization/groups/99999999',
),
});
});
});
});
});

View File

@ -0,0 +1,185 @@
import request from 'supertest';
import { Role } from '../../../src/generated/prisma/enums.js';
import {
closeE2EContext,
createE2EContext,
type E2EContext,
} from '../helpers/e2e-context.helper.js';
import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
import {
expectErrorEnvelope,
expectSuccessEnvelope,
} from '../helpers/e2e-http.helper.js';
describe('Patients Controllers (e2e)', () => {
let ctx: E2EContext;
beforeAll(async () => {
ctx = await createE2EContext();
});
afterAll(async () => {
await closeE2EContext(ctx);
});
describe('GET /b/patients', () => {
it('成功:按角色返回正确可见性范围', async () => {
const systemAdminResponse = await request(ctx.app.getHttpServer())
.get('/b/patients')
.query({ hospitalId: ctx.fixtures.hospitalAId })
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(systemAdminResponse, 200);
const systemPatientIds = (
systemAdminResponse.body.data as Array<{ id: number }>
).map((item) => item.id);
expect(systemPatientIds).toEqual(
expect.arrayContaining([
ctx.fixtures.patients.patientA1Id,
ctx.fixtures.patients.patientA2Id,
ctx.fixtures.patients.patientA3Id,
]),
);
const hospitalAdminResponse = await request(ctx.app.getHttpServer())
.get('/b/patients')
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectSuccessEnvelope(hospitalAdminResponse, 200);
const hospitalPatientIds = (
hospitalAdminResponse.body.data as Array<{ id: number }>
).map((item) => item.id);
expect(hospitalPatientIds).toEqual(
expect.arrayContaining([
ctx.fixtures.patients.patientA1Id,
ctx.fixtures.patients.patientA2Id,
ctx.fixtures.patients.patientA3Id,
]),
);
const directorResponse = await request(ctx.app.getHttpServer())
.get('/b/patients')
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
expectSuccessEnvelope(directorResponse, 200);
const directorPatientIds = (
directorResponse.body.data as Array<{ id: number }>
).map((item) => item.id);
expect(directorPatientIds).toEqual(
expect.arrayContaining([
ctx.fixtures.patients.patientA1Id,
ctx.fixtures.patients.patientA2Id,
]),
);
expect(directorPatientIds).not.toContain(
ctx.fixtures.patients.patientA3Id,
);
const leaderResponse = await request(ctx.app.getHttpServer())
.get('/b/patients')
.set('Authorization', `Bearer ${ctx.tokens[Role.LEADER]}`);
expectSuccessEnvelope(leaderResponse, 200);
const leaderPatientIds = (
leaderResponse.body.data as Array<{ id: number }>
).map((item) => item.id);
expect(leaderPatientIds).toEqual(
expect.arrayContaining([
ctx.fixtures.patients.patientA1Id,
ctx.fixtures.patients.patientA2Id,
]),
);
expect(leaderPatientIds).not.toContain(ctx.fixtures.patients.patientA3Id);
const doctorResponse = await request(ctx.app.getHttpServer())
.get('/b/patients')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
expectSuccessEnvelope(doctorResponse, 200);
const doctorPatientIds = (
doctorResponse.body.data as Array<{ id: number }>
).map((item) => item.id);
expect(doctorPatientIds).toContain(ctx.fixtures.patients.patientA1Id);
expect(doctorPatientIds).not.toContain(ctx.fixtures.patients.patientA2Id);
expect(doctorPatientIds).not.toContain(ctx.fixtures.patients.patientA3Id);
const engineerResponse = await request(ctx.app.getHttpServer())
.get('/b/patients')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`);
expectErrorEnvelope(engineerResponse, 403, '无权限执行当前操作');
});
it('失败SYSTEM_ADMIN 不传 hospitalId 返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/b/patients')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectErrorEnvelope(
response,
400,
'系统管理员查询必须显式传入 hospitalId',
);
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER/DOCTOR 可访问ENGINEER 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /b/patients role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 200,
[Role.LEADER]: 200,
[Role.DOCTOR]: 200,
[Role.ENGINEER]: 403,
},
sendAsRole: async (role, token) => {
const req = request(ctx.app.getHttpServer())
.get('/b/patients')
.set('Authorization', `Bearer ${token}`);
if (role === Role.SYSTEM_ADMIN) {
req.query({ hospitalId: ctx.fixtures.hospitalAId });
}
return req;
},
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get('/b/patients'),
});
});
});
describe('GET /c/patients/lifecycle', () => {
it('成功:可按 phone + idCardHash 查询跨院生命周期', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/c/patients/lifecycle')
.query({
phone: '13800002001',
idCardHash: 'seed-id-card-cross-hospital',
});
expectSuccessEnvelope(response, 200);
expect(response.body.data.phone).toBe('13800002001');
expect(response.body.data.idCardHash).toBe('seed-id-card-cross-hospital');
expect(response.body.data.patientCount).toBeGreaterThanOrEqual(2);
expect(Array.isArray(response.body.data.lifecycle)).toBe(true);
});
it('失败:参数缺失返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/c/patients/lifecycle')
.query({
phone: '13800002001',
});
expectErrorEnvelope(response, 400, 'idCardHash 必须是字符串');
});
it('失败:不存在患者返回 404', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/c/patients/lifecycle')
.query({
phone: '13800009999',
idCardHash: 'not-exists-idcard-hash',
});
expectErrorEnvelope(response, 404, '未找到匹配的患者档案');
});
});
});

View File

@ -0,0 +1,325 @@
import request from 'supertest';
import { Role, TaskStatus } from '../../../src/generated/prisma/enums.js';
import {
closeE2EContext,
createE2EContext,
type E2EContext,
} from '../helpers/e2e-context.helper.js';
import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
import {
expectErrorEnvelope,
expectSuccessEnvelope,
} from '../helpers/e2e-http.helper.js';
describe('BTasksController (e2e)', () => {
let ctx: E2EContext;
beforeAll(async () => {
ctx = await createE2EContext();
});
afterAll(async () => {
await closeE2EContext(ctx);
});
async function publishPendingTask(deviceId: number, targetPressure: number) {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
items: [
{
deviceId,
targetPressure,
},
],
});
expectSuccessEnvelope(response, 201);
return response.body.data as { id: number; status: TaskStatus };
}
describe('POST /b/tasks/publish', () => {
it('成功DOCTOR 可发布任务', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
engineerId: ctx.fixtures.users.engineerAId,
items: [
{
deviceId: ctx.fixtures.devices.deviceA2Id,
targetPressure: 126,
},
],
});
expectSuccessEnvelope(response, 201);
expect(response.body.data.status).toBe(TaskStatus.PENDING);
});
it('失败:发布跨院设备任务返回 404', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
items: [
{
deviceId: ctx.fixtures.devices.deviceB1Id,
targetPressure: 120,
},
],
});
expectErrorEnvelope(response, 404, '存在设备不在当前医院或设备不存在');
});
it('角色矩阵:仅 DOCTOR 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/tasks/publish role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 403,
[Role.HOSPITAL_ADMIN]: 403,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 400,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${token}`)
.send({}),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).post('/b/tasks/publish').send({}),
});
});
});
describe('POST /b/tasks/accept', () => {
it('成功ENGINEER 可接收待处理任务', async () => {
const task = await publishPendingTask(
ctx.fixtures.devices.deviceA2Id,
127,
);
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(response, 201);
expect(response.body.data.status).toBe(TaskStatus.ACCEPTED);
expect(response.body.data.engineerId).toBe(
ctx.fixtures.users.engineerAId,
);
});
it('失败:接收不存在任务返回 404', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: 99999999 });
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
});
it('状态机失败:重复接收返回 409', async () => {
const task = await publishPendingTask(
ctx.fixtures.devices.deviceA3Id,
122,
);
const firstAccept = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(firstAccept, 201);
const secondAccept = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectErrorEnvelope(secondAccept, 409, '仅待接收任务可执行接收');
});
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/tasks/accept role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 403,
[Role.HOSPITAL_ADMIN]: 403,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 404,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${token}`)
.send({ taskId: 99999999 }),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.send({ taskId: 99999999 }),
});
});
});
describe('POST /b/tasks/complete', () => {
it('成功ENGINEER 完成已接收任务并同步设备压力', async () => {
const targetPressure = 135;
const task = await publishPendingTask(
ctx.fixtures.devices.deviceA1Id,
targetPressure,
);
const acceptResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(acceptResponse, 201);
const completeResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(completeResponse, 201);
expect(completeResponse.body.data.status).toBe(TaskStatus.COMPLETED);
const device = await ctx.prisma.device.findUnique({
where: { id: ctx.fixtures.devices.deviceA1Id },
select: { currentPressure: true },
});
expect(device?.currentPressure).toBe(targetPressure);
});
it('失败:完成不存在任务返回 404', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: 99999999 });
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
});
it('状态机失败:未接收任务直接完成返回 409', async () => {
const task = await publishPendingTask(
ctx.fixtures.devices.deviceA2Id,
124,
);
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectErrorEnvelope(response, 409, '仅已接收任务可执行完成');
});
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/tasks/complete role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 403,
[Role.HOSPITAL_ADMIN]: 403,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 404,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${token}`)
.send({ taskId: 99999999 }),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.send({ taskId: 99999999 }),
});
});
});
describe('POST /b/tasks/cancel', () => {
it('成功DOCTOR 可取消自己创建的任务', async () => {
const task = await publishPendingTask(
ctx.fixtures.devices.deviceA3Id,
120,
);
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/cancel')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(response, 201);
expect(response.body.data.status).toBe(TaskStatus.CANCELLED);
});
it('失败:取消不存在任务返回 404', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/cancel')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({ taskId: 99999999 });
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
});
it('状态机失败:已完成任务不可取消返回 409', async () => {
const task = await publishPendingTask(
ctx.fixtures.devices.deviceA2Id,
123,
);
const acceptResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(acceptResponse, 201);
const completeResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(completeResponse, 201);
const cancelResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/cancel')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({ taskId: task.id });
expectErrorEnvelope(cancelResponse, 409, '仅待接收/已接收任务可取消');
});
it('角色矩阵:仅 DOCTOR 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/tasks/cancel role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 403,
[Role.HOSPITAL_ADMIN]: 403,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 404,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.post('/b/tasks/cancel')
.set('Authorization', `Bearer ${token}`)
.send({ taskId: 99999999 }),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.post('/b/tasks/cancel')
.send({ taskId: 99999999 }),
});
});
});
});

View File

@ -0,0 +1,332 @@
import request from 'supertest';
import { Role } from '../../../src/generated/prisma/enums.js';
import {
closeE2EContext,
createE2EContext,
type E2EContext,
} from '../helpers/e2e-context.helper.js';
import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
import {
expectErrorEnvelope,
expectSuccessEnvelope,
uniquePhone,
uniqueSeedValue,
} from '../helpers/e2e-http.helper.js';
describe('UsersController + BUsersController (e2e)', () => {
let ctx: E2EContext;
beforeAll(async () => {
ctx = await createE2EContext();
});
afterAll(async () => {
await closeE2EContext(ctx);
});
async function createDoctorUser(token: string) {
const response = await request(ctx.app.getHttpServer())
.post('/users')
.set('Authorization', `Bearer ${token}`)
.send({
name: uniqueSeedValue('用户-医生'),
phone: uniquePhone(),
password: 'Seed@1234',
role: Role.DOCTOR,
hospitalId: ctx.fixtures.hospitalAId,
departmentId: ctx.fixtures.departmentA1Id,
groupId: ctx.fixtures.groupA1Id,
openId: uniqueSeedValue('users-doctor-openid'),
});
expectSuccessEnvelope(response, 201);
return response.body.data as { id: number; name: string };
}
async function createEngineerUser(token: string) {
const response = await request(ctx.app.getHttpServer())
.post('/users')
.set('Authorization', `Bearer ${token}`)
.send({
name: uniqueSeedValue('用户-工程师'),
phone: uniquePhone(),
password: 'Seed@1234',
role: Role.ENGINEER,
hospitalId: ctx.fixtures.hospitalAId,
openId: uniqueSeedValue('users-engineer-openid'),
});
expectSuccessEnvelope(response, 201);
return response.body.data as { id: number; name: string };
}
describe('POST /users', () => {
it('成功SYSTEM_ADMIN 可创建用户', async () => {
await createDoctorUser(ctx.tokens[Role.SYSTEM_ADMIN]);
});
it('失败:参数校验失败返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/users')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({
name: 'bad-user',
phone: '123',
password: 'short',
role: Role.DOCTOR,
hospitalId: ctx.fixtures.hospitalAId,
departmentId: ctx.fixtures.departmentA1Id,
groupId: ctx.fixtures.groupA1Id,
});
expectErrorEnvelope(response, 400, 'phone 必须是合法手机号');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /users role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 400,
[Role.HOSPITAL_ADMIN]: 400,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.post('/users')
.set('Authorization', `Bearer ${token}`)
.send({}),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).post('/users').send({}),
});
});
});
describe('GET /users', () => {
it('成功SYSTEM_ADMIN 可查询用户列表', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/users')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(response, 200);
expect(Array.isArray(response.body.data)).toBe(true);
});
it('失败:未登录返回 401', async () => {
const response = await request(ctx.app.getHttpServer()).get('/users');
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /users role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.get('/users')
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get('/users'),
});
});
});
describe('GET /users/:id', () => {
it('成功SYSTEM_ADMIN 可查询用户详情', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/users/${ctx.fixtures.users.doctorAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId);
});
it('失败:查询不存在用户返回 404', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/users/99999999')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectErrorEnvelope(response, 404, '用户不存在');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /users/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.get(`/users/${ctx.fixtures.users.doctorAId}`)
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get(
`/users/${ctx.fixtures.users.doctorAId}`,
),
});
});
});
describe('PATCH /users/:id', () => {
it('成功SYSTEM_ADMIN 可更新用户姓名', async () => {
const created = await createDoctorUser(ctx.tokens[Role.SYSTEM_ADMIN]);
const nextName = uniqueSeedValue('更新后医生名');
const response = await request(ctx.app.getHttpServer())
.patch(`/users/${created.id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({ name: nextName });
expectSuccessEnvelope(response, 200);
expect(response.body.data.name).toBe(nextName);
});
it('失败:非医生调整科室/小组返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.patch(`/users/${ctx.fixtures.users.engineerAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({
departmentId: ctx.fixtures.departmentA1Id,
groupId: ctx.fixtures.groupA1Id,
});
expectErrorEnvelope(response, 400, '仅医生允许调整科室/小组归属');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'PATCH /users/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 404,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.patch('/users/99999999')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'matrix-patch' }),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.patch('/users/99999999')
.send({ name: 'matrix-patch' }),
});
});
});
describe('DELETE /users/:id', () => {
it('成功SYSTEM_ADMIN 可删除用户', async () => {
const created = await createEngineerUser(ctx.tokens[Role.SYSTEM_ADMIN]);
const response = await request(ctx.app.getHttpServer())
.delete(`/users/${created.id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.id).toBe(created.id);
});
it('失败HOSPITAL_ADMIN 无法删除返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.delete(`/users/${ctx.fixtures.users.doctorAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectErrorEnvelope(response, 403, '无权限执行当前操作');
});
it('角色矩阵:仅 SYSTEM_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'DELETE /users/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 403,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.delete('/users/99999999')
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).delete('/users/99999999'),
});
});
});
describe('PATCH /b/users/:id/assign-engineer-hospital', () => {
it('成功SYSTEM_ADMIN 可绑定工程师医院', async () => {
const response = await request(ctx.app.getHttpServer())
.patch(
`/b/users/${ctx.fixtures.users.engineerAId}/assign-engineer-hospital`,
)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({ hospitalId: ctx.fixtures.hospitalAId });
expectSuccessEnvelope(response, 200);
expect(response.body.data.hospitalId).toBe(ctx.fixtures.hospitalAId);
expect(response.body.data.role).toBe(Role.ENGINEER);
});
it('失败:目标用户不是工程师返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.patch(
`/b/users/${ctx.fixtures.users.doctorAId}/assign-engineer-hospital`,
)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({ hospitalId: ctx.fixtures.hospitalAId });
expectErrorEnvelope(response, 400, '目标用户不是工程师');
});
it('角色矩阵:仅 SYSTEM_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'PATCH /b/users/:id/assign-engineer-hospital role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 400,
[Role.HOSPITAL_ADMIN]: 403,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.patch(
`/b/users/${ctx.fixtures.users.engineerAId}/assign-engineer-hospital`,
)
.set('Authorization', `Bearer ${token}`)
.send({}),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.patch(
`/b/users/${ctx.fixtures.users.engineerAId}/assign-engineer-hospital`,
)
.send({}),
});
});
});
});

20
test/jest-e2e.config.cjs Normal file
View File

@ -0,0 +1,20 @@
module.exports = {
rootDir: '../',
testEnvironment: 'node',
moduleFileExtensions: ['js', 'json', 'ts'],
testRegex: 'test/e2e/specs/.*\\.e2e-spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': [
'ts-jest',
{
useESM: true,
tsconfig: '<rootDir>/test/tsconfig.e2e.json',
},
],
},
extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
maxWorkers: 1,
};

10
test/tsconfig.e2e.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"types": ["node", "jest"],
"noEmit": true
},
"include": ["./e2e/**/*.ts", "../src/**/*.ts"]
}