tyt-api-nest/test/e2e/specs/tasks.e2e-spec.ts
2026-03-24 20:09:20 +08:00

756 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import sharp from 'sharp';
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,
uniquePhone,
uniqueSeedValue,
} from '../helpers/e2e-http.helper.js';
import { loginByMiniApp } from '../helpers/e2e-miniapp-auth.helper.js';
function uniqueIdCard() {
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`
.replace(/\D/g, '')
.slice(-4);
return `11010119990101${suffix.padStart(4, '0')}`;
}
describe('BTasksController (e2e)', () => {
let ctx: E2EContext;
let samplePngBuffer: Buffer;
let doctorBToken = '';
let doctorA2Token = '';
beforeAll(async () => {
ctx = await createE2EContext();
samplePngBuffer = await sharp({
create: {
width: 32,
height: 32,
channels: 3,
background: { r: 42, g: 78, b: 126 },
},
})
.png()
.toBuffer();
doctorBToken = await loginByUser(
ctx.fixtures.users.doctorBId,
Role.DOCTOR,
ctx.fixtures.hospitalBId,
);
doctorA2Token = await loginByUser(
ctx.fixtures.users.doctorA2Id,
Role.DOCTOR,
ctx.fixtures.hospitalAId,
);
});
afterAll(async () => {
await closeE2EContext(ctx);
});
async function publishPendingTask(
deviceId: number,
targetPressure: string,
actorToken = ctx.tokens[Role.DOCTOR],
) {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${actorToken}`)
.send({
items: [
{
deviceId,
targetPressure,
},
],
});
expectSuccessEnvelope(response, 201);
return response.body.data as {
id: number;
status: TaskStatus;
engineerId: number | null;
hospitalId: number;
};
}
async function acceptPendingTask(
taskId: number,
actorToken = ctx.tokens[Role.ENGINEER],
) {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${actorToken}`)
.send({ taskId });
expectSuccessEnvelope(response, 201);
return response.body.data as {
id: number;
status: TaskStatus;
engineerId: number;
};
}
async function loginByUser(userId: number, role: Role, hospitalId: number) {
const user = await ctx.prisma.user.findUnique({
where: { id: userId },
select: { phone: true, openId: true },
});
expect(user?.phone).toBeTruthy();
expect(user?.openId).toBeTruthy();
return loginByMiniApp(ctx.app.getHttpServer(), {
phone: user!.phone,
openId: user!.openId!,
role,
hospitalId,
userId,
});
}
async function createAdjustableDevices(options?: {
actorToken?: string;
doctorId?: number;
count?: number;
patientName?: string;
projectName?: string;
}) {
const {
actorToken = ctx.tokens[Role.DOCTOR],
doctorId = ctx.fixtures.users.doctorAId,
count = 1,
patientName = '调压测试患者',
projectName = '调压测试项目',
} = options ?? {};
const response = await request(ctx.app.getHttpServer())
.post('/b/patients')
.set('Authorization', `Bearer ${actorToken}`)
.send({
name: `${patientName}-${uniqueSeedValue('name')}`,
inpatientNo: uniqueSeedValue('zyh'),
projectName,
phone: uniquePhone(),
idCard: uniqueIdCard(),
doctorId,
initialSurgery: {
surgeryDate: '2026-03-20T08:00:00.000Z',
surgeryName: '调压测试手术',
preOpPressure: 18,
primaryDisease: '交通性脑积水',
hydrocephalusTypes: ['交通性'],
devices: Array.from({ length: count }, (_, index) => ({
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
shuntMode: 'VPS',
proximalPunctureAreas: [index % 2 === 0 ? '额角' : '枕角'],
valvePlacementSites: [index % 2 === 0 ? '耳后' : '胸前'],
distalShuntDirection: '腹腔',
initialPressure: index % 2 === 0 ? '1' : '1.5',
implantNotes: uniqueSeedValue(`task-device-${index + 1}`),
})),
},
});
expectSuccessEnvelope(response, 201);
return response.body.data.devices as Array<{ id: number }>;
}
async function uploadEngineerProof(actorToken = ctx.tokens[Role.ENGINEER]) {
const response = await request(ctx.app.getHttpServer())
.post('/b/uploads')
.set('Authorization', `Bearer ${actorToken}`)
.attach('file', samplePngBuffer, {
filename: 'task-proof.png',
contentType: 'image/png',
});
expectSuccessEnvelope(response, 201);
return response.body.data as {
id: number;
type: 'IMAGE' | 'VIDEO';
url: string;
originalName?: string;
fileName?: string;
};
}
function buildCompletionPayload(
taskId: number,
asset: Awaited<ReturnType<typeof uploadEngineerProof>>,
) {
return {
taskId,
completionMaterials: [
{
assetId: asset.id,
type: asset.type,
url: asset.url,
name: asset.originalName || asset.fileName || '调压完成照片',
},
],
};
}
describe('GET /b/tasks/engineers', () => {
it('成功DOCTOR 可查看本院可选工程师', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/b/tasks/engineers')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
expectSuccessEnvelope(response, 200);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: ctx.fixtures.users.engineerAId,
}),
]),
);
});
it('成功SYSTEM_ADMIN 可按医院筛选工程师', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/b/tasks/engineers')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.query({ hospitalId: ctx.fixtures.hospitalBId });
expectSuccessEnvelope(response, 200);
expect(response.body.data).toEqual([
expect.objectContaining({
id: ctx.fixtures.users.engineerBId,
hospitalId: ctx.fixtures.hospitalBId,
}),
]);
});
it('失败hospitalId 非法返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/b/tasks/engineers')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.query({ hospitalId: 0 });
expectErrorEnvelope(response, 400, 'hospitalId 必须大于 0');
});
});
describe('GET /b/tasks', () => {
it('成功SYSTEM_ADMIN 可查看跨医院调压记录', async () => {
const [deviceA] = await createAdjustableDevices();
const [deviceB] = await createAdjustableDevices({
actorToken: doctorBToken,
doctorId: ctx.fixtures.users.doctorBId,
patientName: 'B院调压患者',
});
await publishPendingTask(
deviceA.id,
'1.5',
ctx.tokens[Role.SYSTEM_ADMIN],
);
await publishPendingTask(
deviceB.id,
'1.5',
ctx.tokens[Role.SYSTEM_ADMIN],
);
const response = await request(ctx.app.getHttpServer())
.get('/b/tasks')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.query({ page: 1, pageSize: 20 });
expectSuccessEnvelope(response, 200);
expect(Array.isArray(response.body.data.list)).toBe(true);
expect(response.body.data.total).toBeGreaterThan(0);
expect(
response.body.data.list.every(
(item: {
creator?: { id?: number; name?: string };
engineer?: { id?: number; name?: string } | null;
patient?: { phone?: string };
patientPhone?: string;
}) =>
Number.isInteger(item.creator?.id) &&
Boolean(item.creator?.name) &&
Boolean(item.patient?.phone) &&
Boolean(item.patientPhone),
),
).toBe(true);
const hospitalIds = Array.from(
new Set(
response.body.data.list
.map((item: { hospital?: { id?: number } }) => item.hospital?.id)
.filter(Boolean),
),
);
expect(hospitalIds).toEqual(
expect.arrayContaining([
ctx.fixtures.hospitalAId,
ctx.fixtures.hospitalBId,
]),
);
});
it('成功DOCTOR 仅可查看本人患者调压记录', async () => {
const keywordPrefix = uniqueSeedValue('scope-doctor');
const [selfDevice] = await createAdjustableDevices({
actorToken: ctx.tokens[Role.DOCTOR],
doctorId: ctx.fixtures.users.doctorAId,
patientName: `${keywordPrefix}-self`,
});
const [peerDevice] = await createAdjustableDevices({
actorToken: doctorA2Token,
doctorId: ctx.fixtures.users.doctorA2Id,
patientName: `${keywordPrefix}-peer`,
});
await publishPendingTask(
selfDevice.id,
'1.5',
ctx.tokens[Role.SYSTEM_ADMIN],
);
await publishPendingTask(
peerDevice.id,
'1.5',
ctx.tokens[Role.SYSTEM_ADMIN],
);
const response = await request(ctx.app.getHttpServer())
.get('/b/tasks')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.query({ page: 1, pageSize: 50, keyword: keywordPrefix });
expectSuccessEnvelope(response, 200);
expect(Array.isArray(response.body.data.list)).toBe(true);
expect(response.body.data.total).toBeGreaterThan(0);
expect(
response.body.data.list.every(
(item: {
hospital?: { id?: number };
patient?: { name?: string };
}) =>
item.hospital?.id === ctx.fixtures.hospitalAId &&
item.patient?.name?.includes(`${keywordPrefix}-self`),
),
).toBe(true);
expect(
response.body.data.list.some((item: { patient?: { name?: string } }) =>
item.patient?.name?.includes(`${keywordPrefix}-peer`),
),
).toBe(false);
});
it('失败hospitalId 非法返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/b/tasks')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.query({ hospitalId: 0 });
expectErrorEnvelope(response, 400, 'hospitalId 必须大于 0');
});
});
describe('POST /b/tasks/publish', () => {
it('成功DOCTOR 发布任务后进入待接收状态', async () => {
const [device] = await createAdjustableDevices();
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
items: [
{
deviceId: device.id,
targetPressure: '1.5',
},
],
});
expectSuccessEnvelope(response, 201);
expect(response.body.data.status).toBe(TaskStatus.PENDING);
expect(response.body.data.engineerId).toBeNull();
});
it('成功SYSTEM_ADMIN 可按设备自动归院发布任务', async () => {
const [device] = await createAdjustableDevices();
const task = await publishPendingTask(
device.id,
'1.5',
ctx.tokens[Role.SYSTEM_ADMIN],
);
expect(task.status).toBe(TaskStatus.PENDING);
expect(task.hospitalId).toBe(ctx.fixtures.hospitalAId);
});
it('失败:可调压设备使用非法挡位返回 400', async () => {
const [device] = await createAdjustableDevices();
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
items: [
{
deviceId: device.id,
targetPressure: '2',
},
],
});
expectErrorEnvelope(response, 400, '压力值不在该植入物配置的挡位范围内');
});
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: '1.5',
},
],
});
expectErrorEnvelope(response, 404, '存在设备不在当前医院或设备不存在');
});
it('失败:已有待处理任务的设备不可重复发布', async () => {
const [device] = await createAdjustableDevices();
await publishPendingTask(device.id, '1.5');
const duplicateResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
items: [
{
deviceId: device.id,
targetPressure: '1',
},
],
});
expectErrorEnvelope(duplicateResponse, 409, '该设备已有待处理调压任务');
});
it('成功:同一患者另一台设备无任务时仍可发布', async () => {
const createdDevices = await createAdjustableDevices({ count: 2 });
expect(createdDevices).toHaveLength(2);
await publishPendingTask(createdDevices[0].id, '1.5');
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
items: [
{
deviceId: createdDevices[1].id,
targetPressure: '1',
},
],
});
expectSuccessEnvelope(response, 201);
expect(response.body.data.status).toBe(TaskStatus.PENDING);
});
it('角色矩阵:管理员/DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/tasks/publish role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 400,
[Role.HOSPITAL_ADMIN]: 400,
[Role.DIRECTOR]: 400,
[Role.LEADER]: 400,
[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 [device] = await createAdjustableDevices();
const task = await publishPendingTask(device.id, '1.5');
const acceptedTask = await acceptPendingTask(task.id);
expect(acceptedTask.status).toBe(TaskStatus.ACCEPTED);
expect(acceptedTask.engineerId).toBe(ctx.fixtures.users.engineerAId);
});
it('失败:跨院工程师不能接收任务', async () => {
const [device] = await createAdjustableDevices();
const task = await publishPendingTask(device.id, '1.5');
const engineerB = await ctx.prisma.user.findUnique({
where: { id: ctx.fixtures.users.engineerBId },
select: { phone: true },
});
expect(engineerB?.phone).toBeTruthy();
const loginResponse = await request(ctx.app.getHttpServer())
.post('/auth/miniapp/b/phone-login')
.send({
loginCode: `mock-login:${encodeURIComponent('seed-engineer-b-openid')}`,
phoneCode: `mock-phone:${engineerB?.phone}`,
});
expectSuccessEnvelope(loginResponse, 201);
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${loginResponse.body.data.accessToken}`)
.send({ taskId: task.id });
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
});
it('状态机失败:已被接收的任务不可再次接收', async () => {
const [device] = await createAdjustableDevices();
const task = await publishPendingTask(device.id, '1.5');
await acceptPendingTask(task.id);
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.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/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 = '1.5';
const [device] = await createAdjustableDevices();
const task = await publishPendingTask(device.id, targetPressure);
await acceptPendingTask(task.id);
const proof = await uploadEngineerProof();
const completeResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send(buildCompletionPayload(task.id, proof));
expectSuccessEnvelope(completeResponse, 201);
expect(completeResponse.body.data.status).toBe(TaskStatus.COMPLETED);
expect(completeResponse.body.data.completionMaterials).toEqual([
expect.objectContaining({
assetId: proof.id,
type: 'IMAGE',
url: proof.url,
}),
]);
const updatedDevice = await ctx.prisma.device.findUnique({
where: { id: device.id },
select: { currentPressure: true },
});
expect(updatedDevice?.currentPressure).toBe(targetPressure);
});
it('失败:完成不存在任务返回 404', async () => {
const proof = await uploadEngineerProof();
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send(buildCompletionPayload(99999999, proof));
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
});
it('失败:未上传完成凭证返回 400', async () => {
const [device] = await createAdjustableDevices();
const task = await publishPendingTask(device.id, '1.5');
await acceptPendingTask(task.id);
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({
taskId: task.id,
completionMaterials: [],
});
expectErrorEnvelope(response, 400, 'completionMaterials 至少上传 1 项');
});
it('状态机失败:重复完成返回 409', async () => {
const [device] = await createAdjustableDevices();
const task = await publishPendingTask(device.id, '1');
await acceptPendingTask(task.id);
const proof = await uploadEngineerProof();
const firstComplete = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send(buildCompletionPayload(task.id, proof));
expectSuccessEnvelope(firstComplete, 201);
const secondComplete = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send(buildCompletionPayload(task.id, proof));
expectErrorEnvelope(secondComplete, 409, '仅已接收任务可执行完成');
});
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403未登录 401', async () => {
const proof = await uploadEngineerProof();
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(buildCompletionPayload(99999999, proof)),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.send(buildCompletionPayload(99999999, proof)),
});
});
});
describe('POST /b/tasks/cancel', () => {
it('成功DOCTOR 可取消自己创建的待接收任务', async () => {
const [device] = await createAdjustableDevices();
const task = await publishPendingTask(device.id, '1.5');
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('成功ENGINEER 可取消接收并退回待接收状态', async () => {
const [device] = await createAdjustableDevices();
const task = await publishPendingTask(device.id, '1.5');
await acceptPendingTask(task.id);
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/cancel')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id, reason: '患者临时取消' });
expectSuccessEnvelope(response, 201);
expect(response.body.data.status).toBe(TaskStatus.PENDING);
expect(response.body.data.engineerId).toBeNull();
const secondAcceptResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(secondAcceptResponse, 201);
expect(secondAcceptResponse.body.data.status).toBe(TaskStatus.ACCEPTED);
});
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 [device] = await createAdjustableDevices();
const task = await publishPendingTask(device.id, '1.5');
await acceptPendingTask(task.id);
const proof = await uploadEngineerProof();
const completeResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send(buildCompletionPayload(task.id, proof));
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/DIRECTOR/LEADER 可进入业务,其余角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/tasks/cancel role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 404,
[Role.DIRECTOR]: 404,
[Role.LEADER]: 404,
[Role.DOCTOR]: 404,
[Role.ENGINEER]: 404,
},
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 }),
});
});
});
});