C端 my-lifecycle 由跨院多患者聚合改为单患者返回,结构统一为 patient + lifecycle 生命周期事件移除事件内重复 patient 字段,减少冗余 B端患者生命周期接口同步采用 patient + lifecycle 结构 新增并接入生命周期 Swagger 响应模型,补齐接口文档 更新 auth/patients/frontend 集成文档说明 增加 e2e:多患者冲突、C端/B端新返回结构、权限失败场景
360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
import request from 'supertest';
|
||
import { Role } from '../../../src/generated/prisma/enums.js';
|
||
import {
|
||
E2E_SEED_CREDENTIALS,
|
||
E2E_SEED_PASSWORD,
|
||
} from '../fixtures/e2e-roles.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';
|
||
import { buildMiniAppMockPayload } from '../helpers/e2e-miniapp-auth.helper.js';
|
||
|
||
describe('AuthController (e2e)', () => {
|
||
let ctx: E2EContext;
|
||
|
||
beforeAll(async () => {
|
||
ctx = await createE2EContext();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await closeE2EContext(ctx);
|
||
});
|
||
|
||
describe('POST /auth/system-admin', () => {
|
||
it('成功:创建系统管理员账号', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/auth/system-admin')
|
||
.send({
|
||
name: uniqueSeedValue('Auth 系统管理员'),
|
||
phone: uniquePhone(),
|
||
password: 'Seed@1234',
|
||
openId: uniqueSeedValue('auth-system-admin-openid'),
|
||
systemAdminBootstrapKey: process.env.SYSTEM_ADMIN_BOOTSTRAP_KEY,
|
||
});
|
||
|
||
expectSuccessEnvelope(response, 201);
|
||
expect(response.body.data.role).toBe(Role.SYSTEM_ADMIN);
|
||
});
|
||
|
||
it('失败:参数不合法返回 400', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/auth/system-admin')
|
||
.send({
|
||
name: 'bad-system-admin',
|
||
phone: '13800009999',
|
||
password: '123',
|
||
systemAdminBootstrapKey: process.env.SYSTEM_ADMIN_BOOTSTRAP_KEY,
|
||
});
|
||
|
||
expectErrorEnvelope(response, 400, '密码长度至少 8 位');
|
||
});
|
||
});
|
||
|
||
describe('POST /auth/login', () => {
|
||
it('成功:院内账号可使用手机号密码登录', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/auth/login')
|
||
.send({
|
||
phone: E2E_SEED_CREDENTIALS[Role.DOCTOR].phone,
|
||
password: E2E_SEED_PASSWORD,
|
||
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('成功:同手机号命中多个账号时先返回候选,再确认登录', async () => {
|
||
const sharedPhone = uniquePhone();
|
||
const firstUser = await request(ctx.app.getHttpServer())
|
||
.post('/users')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||
.send({
|
||
name: uniqueSeedValue('Password 多账号医生'),
|
||
phone: sharedPhone,
|
||
password: 'Seed@1234',
|
||
role: Role.DOCTOR,
|
||
hospitalId: ctx.fixtures.hospitalAId,
|
||
departmentId: ctx.fixtures.departmentA1Id,
|
||
groupId: ctx.fixtures.groupA1Id,
|
||
});
|
||
expectSuccessEnvelope(firstUser, 201);
|
||
|
||
const secondUser = await request(ctx.app.getHttpServer())
|
||
.post('/users')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||
.send({
|
||
name: uniqueSeedValue('Password 多账号工程师'),
|
||
phone: sharedPhone,
|
||
password: 'Seed@1234',
|
||
role: Role.ENGINEER,
|
||
hospitalId: ctx.fixtures.hospitalAId,
|
||
});
|
||
expectSuccessEnvelope(secondUser, 201);
|
||
|
||
const firstStage = await request(ctx.app.getHttpServer())
|
||
.post('/auth/login')
|
||
.send({
|
||
phone: sharedPhone,
|
||
password: 'Seed@1234',
|
||
});
|
||
|
||
expectSuccessEnvelope(firstStage, 201);
|
||
expect(firstStage.body.data.needSelect).toBe(true);
|
||
expect(firstStage.body.data.accounts).toHaveLength(2);
|
||
|
||
const confirmResponse = await request(ctx.app.getHttpServer())
|
||
.post('/auth/login/confirm')
|
||
.send({
|
||
loginTicket: firstStage.body.data.loginTicket,
|
||
userId: firstUser.body.data.id,
|
||
});
|
||
|
||
expectSuccessEnvelope(confirmResponse, 201);
|
||
expect(confirmResponse.body.data.user.id).toBe(firstUser.body.data.id);
|
||
});
|
||
|
||
it('失败:密码错误返回 401', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/auth/login')
|
||
.send({
|
||
phone: E2E_SEED_CREDENTIALS[Role.DOCTOR].phone,
|
||
password: 'Wrong@1234',
|
||
role: Role.DOCTOR,
|
||
hospitalId: ctx.fixtures.hospitalAId,
|
||
});
|
||
|
||
expectErrorEnvelope(response, 401, '手机号、密码或角色不匹配');
|
||
});
|
||
});
|
||
|
||
describe('POST /auth/miniapp/b/phone-login', () => {
|
||
it('成功:单院内账号手机号可直接登录', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/auth/miniapp/b/phone-login')
|
||
.send(buildMiniAppMockPayload('13800001004', 'seed-doctor-a-openid'));
|
||
|
||
expectSuccessEnvelope(response, 201);
|
||
expect(response.body.data.accessToken).toEqual(expect.any(String));
|
||
expect(response.body.data.actor.role).toBe(Role.DOCTOR);
|
||
});
|
||
|
||
it('成功:同手机号多账号时先返回候选,再确认登录', async () => {
|
||
const sharedPhone = uniquePhone();
|
||
const firstUser = await request(ctx.app.getHttpServer())
|
||
.post('/users')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||
.send({
|
||
name: uniqueSeedValue('MiniApp 多账号医生'),
|
||
phone: sharedPhone,
|
||
role: Role.DOCTOR,
|
||
hospitalId: ctx.fixtures.hospitalAId,
|
||
departmentId: ctx.fixtures.departmentA1Id,
|
||
groupId: ctx.fixtures.groupA1Id,
|
||
});
|
||
expectSuccessEnvelope(firstUser, 201);
|
||
|
||
const secondUser = await request(ctx.app.getHttpServer())
|
||
.post('/users')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||
.send({
|
||
name: uniqueSeedValue('MiniApp 多账号工程师'),
|
||
phone: sharedPhone,
|
||
role: Role.ENGINEER,
|
||
hospitalId: ctx.fixtures.hospitalAId,
|
||
});
|
||
expectSuccessEnvelope(secondUser, 201);
|
||
|
||
const firstStage = await request(ctx.app.getHttpServer())
|
||
.post('/auth/miniapp/b/phone-login')
|
||
.send(
|
||
buildMiniAppMockPayload(sharedPhone, uniqueSeedValue('multi-openid')),
|
||
);
|
||
|
||
expectSuccessEnvelope(firstStage, 201);
|
||
expect(firstStage.body.data.needSelect).toBe(true);
|
||
expect(firstStage.body.data.accounts).toHaveLength(2);
|
||
|
||
const confirmResponse = await request(ctx.app.getHttpServer())
|
||
.post('/auth/miniapp/b/phone-login/confirm')
|
||
.send({
|
||
loginTicket: firstStage.body.data.loginTicket,
|
||
userId: firstUser.body.data.id,
|
||
});
|
||
|
||
expectSuccessEnvelope(confirmResponse, 201);
|
||
expect(confirmResponse.body.data.user.id).toBe(firstUser.body.data.id);
|
||
});
|
||
|
||
it('成功:同一微信 openId 可切换绑定多个院内账号', async () => {
|
||
const sharedPhone = uniquePhone();
|
||
const sharedOpenId = uniqueSeedValue('shared-openid');
|
||
const firstUser = await request(ctx.app.getHttpServer())
|
||
.post('/users')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||
.send({
|
||
name: uniqueSeedValue('MiniApp 切换账号医生'),
|
||
phone: sharedPhone,
|
||
role: Role.DOCTOR,
|
||
hospitalId: ctx.fixtures.hospitalAId,
|
||
departmentId: ctx.fixtures.departmentA1Id,
|
||
groupId: ctx.fixtures.groupA1Id,
|
||
});
|
||
expectSuccessEnvelope(firstUser, 201);
|
||
|
||
const secondUser = await request(ctx.app.getHttpServer())
|
||
.post('/users')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||
.send({
|
||
name: uniqueSeedValue('MiniApp 切换账号工程师'),
|
||
phone: sharedPhone,
|
||
role: Role.ENGINEER,
|
||
hospitalId: ctx.fixtures.hospitalAId,
|
||
});
|
||
expectSuccessEnvelope(secondUser, 201);
|
||
|
||
const firstStageForDoctor = await request(ctx.app.getHttpServer())
|
||
.post('/auth/miniapp/b/phone-login')
|
||
.send(buildMiniAppMockPayload(sharedPhone, sharedOpenId));
|
||
expectSuccessEnvelope(firstStageForDoctor, 201);
|
||
|
||
const doctorConfirm = await request(ctx.app.getHttpServer())
|
||
.post('/auth/miniapp/b/phone-login/confirm')
|
||
.send({
|
||
loginTicket: firstStageForDoctor.body.data.loginTicket,
|
||
userId: firstUser.body.data.id,
|
||
});
|
||
expectSuccessEnvelope(doctorConfirm, 201);
|
||
|
||
const firstStageForEngineer = await request(ctx.app.getHttpServer())
|
||
.post('/auth/miniapp/b/phone-login')
|
||
.send(buildMiniAppMockPayload(sharedPhone, sharedOpenId));
|
||
expectSuccessEnvelope(firstStageForEngineer, 201);
|
||
|
||
const engineerConfirm = await request(ctx.app.getHttpServer())
|
||
.post('/auth/miniapp/b/phone-login/confirm')
|
||
.send({
|
||
loginTicket: firstStageForEngineer.body.data.loginTicket,
|
||
userId: secondUser.body.data.id,
|
||
});
|
||
expectSuccessEnvelope(engineerConfirm, 201);
|
||
|
||
const users = await ctx.prisma.user.findMany({
|
||
where: {
|
||
id: {
|
||
in: [firstUser.body.data.id, secondUser.body.data.id],
|
||
},
|
||
},
|
||
select: {
|
||
id: true,
|
||
openId: true,
|
||
},
|
||
orderBy: { id: 'asc' },
|
||
});
|
||
|
||
expect(users).toEqual([
|
||
{ id: firstUser.body.data.id, openId: sharedOpenId },
|
||
{ id: secondUser.body.data.id, openId: sharedOpenId },
|
||
]);
|
||
});
|
||
|
||
it('失败:手机号未匹配院内账号返回 404', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/auth/miniapp/b/phone-login')
|
||
.send(
|
||
buildMiniAppMockPayload(
|
||
uniquePhone(),
|
||
uniqueSeedValue('no-user-openid'),
|
||
),
|
||
);
|
||
|
||
expectErrorEnvelope(response, 404, '手机号未匹配到院内账号');
|
||
});
|
||
});
|
||
|
||
describe('POST /auth/miniapp/c/phone-login', () => {
|
||
it('成功:手机号唯一关联患者时可创建 C 端账号并返回 token', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/auth/miniapp/c/phone-login')
|
||
.send(buildMiniAppMockPayload('13800002002', 'seed-family-a2-openid'));
|
||
|
||
expectSuccessEnvelope(response, 201);
|
||
expect(response.body.data.accessToken).toEqual(expect.any(String));
|
||
expect(response.body.data.familyAccount.phone).toBe('13800002002');
|
||
});
|
||
|
||
it('失败:手机号未关联患者档案返回 404', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/auth/miniapp/c/phone-login')
|
||
.send(
|
||
buildMiniAppMockPayload(
|
||
uniquePhone(),
|
||
uniqueSeedValue('family-openid'),
|
||
),
|
||
);
|
||
|
||
expectErrorEnvelope(response, 404, '当前手机号未关联患者档案');
|
||
});
|
||
|
||
it('失败:手机号关联多份患者档案返回 409', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/auth/miniapp/c/phone-login')
|
||
.send(buildMiniAppMockPayload('13800002001', 'seed-family-a1-openid'));
|
||
|
||
expectErrorEnvelope(
|
||
response,
|
||
409,
|
||
'当前手机号关联了多份患者档案,请联系管理员处理',
|
||
);
|
||
});
|
||
});
|
||
|
||
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'),
|
||
});
|
||
});
|
||
});
|
||
});
|