hn-hlw-service/hlw/automation/index.test.js
2026-07-31 16:22:09 +08:00

452 lines
12 KiB
JavaScript

const { ObjectId } = require("mongodb");
const {
AUTOMATION_TYPES,
COLLECTION,
TASK_STATUS,
buildAutoPrescriptionParams,
calculateDueAt,
claimNextTask,
enqueueTask,
processClaimedTask,
reconcileAutomationTasks,
} = require("./index");
function createCollectionDb(collections) {
return {
collection: jest.fn((name) => {
if (!collections[name]) {
throw new Error(`unexpected collection: ${name}`);
}
return collections[name];
}),
};
}
describe("automation task scheduling", () => {
afterEach(() => {
jest.restoreAllMocks();
});
test("uses the configured delay and respects firstSubmitRxIntervel", () => {
jest.spyOn(Date, "now").mockReturnValue(1_000_000);
const config = {
autoOpenRx: true,
autoOpenRxDelayMinSeconds: 20,
autoOpenRxDelayMaxSeconds: 40,
firstSubmitRxIntervel: 60,
};
expect(
calculateDueAt(AUTOMATION_TYPES.OPEN_RX, config, 1_000_000, () => 0)
).toBe(1_060_000);
expect(
calculateDueAt(
AUTOMATION_TYPES.OPEN_RX,
{ ...config, firstSubmitRxIntervel: 10 },
1_000_000,
() => 0.999999
)
).toBe(1_040_000);
});
test("creates a task idempotently through a unique task key", async () => {
const updateOne = jest
.fn()
.mockResolvedValueOnce({ modifiedCount: 0 })
.mockResolvedValueOnce({ upsertedCount: 1 });
const db = createCollectionDb({
"hlw-config": {
findOne: jest.fn().mockResolvedValue({
autoAcceptOrder: true,
autoAcceptDelayMinSeconds: 2,
autoAcceptDelayMaxSeconds: 5,
}),
},
[COLLECTION]: { updateOne },
});
const result = await enqueueTask(db, {
type: AUTOMATION_TYPES.ACCEPT,
corpId: "corp-1",
orderId: "order-1",
baseTime: Date.now(),
expiresAt: Date.now() + 60_000,
});
expect(result).toMatchObject({
success: true,
created: true,
taskKey: "AUTO_ACCEPT:order-1",
});
expect(updateOne).toHaveBeenLastCalledWith(
{ taskKey: "AUTO_ACCEPT:order-1" },
expect.objectContaining({
$setOnInsert: expect.objectContaining({
status: TASK_STATUS.PENDING,
attempts: 0,
}),
}),
{ upsert: true }
);
});
test("reactivates a task cancelled by a previously disabled switch", async () => {
const updateOne = jest.fn().mockResolvedValue({ modifiedCount: 1 });
const db = createCollectionDb({
"hlw-config": {
findOne: jest.fn().mockResolvedValue({ autoAcceptOrder: true }),
},
[COLLECTION]: { updateOne },
});
const result = await enqueueTask(db, {
type: AUTOMATION_TYPES.ACCEPT,
corpId: "corp-1",
orderId: "order-1",
});
expect(result).toMatchObject({
success: true,
created: true,
reactivated: true,
});
expect(updateOne).toHaveBeenCalledTimes(1);
expect(updateOne).toHaveBeenCalledWith(
{
taskKey: "AUTO_ACCEPT:order-1",
status: TASK_STATUS.CANCELLED,
},
expect.objectContaining({
$set: expect.objectContaining({ status: TASK_STATUS.PENDING }),
$unset: { completedAt: "", result: "" },
})
);
});
test("treats a concurrent unique-key insert as idempotent success", async () => {
const duplicateError = Object.assign(new Error("duplicate key"), {
code: 11000,
});
const updateOne = jest
.fn()
.mockResolvedValueOnce({ modifiedCount: 0 })
.mockRejectedValueOnce(duplicateError);
const db = createCollectionDb({
"hlw-config": {
findOne: jest.fn().mockResolvedValue({ autoPassRx: true }),
},
[COLLECTION]: { updateOne },
});
await expect(
enqueueTask(db, {
type: AUTOMATION_TYPES.PASS_RX,
corpId: "corp-1",
orderId: "order-1",
rxId: new ObjectId().toString(),
})
).resolves.toMatchObject({
success: true,
created: false,
});
});
test("claims due and expired-lease tasks with one atomic update", async () => {
const claimed = {
_id: new ObjectId(),
taskKey: "AUTO_ACCEPT:order-1",
status: TASK_STATUS.RUNNING,
};
const findOneAndUpdate = jest.fn().mockResolvedValue(claimed);
const db = createCollectionDb({
[COLLECTION]: { findOneAndUpdate },
});
await expect(claimNextTask(db, 10_000)).resolves.toBe(claimed);
expect(findOneAndUpdate).toHaveBeenCalledWith(
{
$or: [
{
status: TASK_STATUS.PENDING,
nextRunAt: { $lte: 10_000 },
},
{
status: TASK_STATUS.RUNNING,
leaseUntil: { $lte: 10_000 },
},
],
},
expect.objectContaining({
$set: expect.objectContaining({
status: TASK_STATUS.RUNNING,
leaseOwner: expect.any(String),
}),
$inc: { attempts: 1 },
}),
{
sort: { nextRunAt: 1, createdAt: 1 },
returnDocument: "after",
}
);
});
test("cancels a claimed task when its switch has been turned off", async () => {
const updateOne = jest.fn().mockResolvedValue({ modifiedCount: 1 });
const db = createCollectionDb({
"hlw-config": {
findOne: jest.fn().mockResolvedValue({ autoAcceptOrder: false }),
},
[COLLECTION]: { updateOne },
});
const task = {
_id: new ObjectId(),
type: AUTOMATION_TYPES.ACCEPT,
corpId: "corp-1",
orderId: "order-1",
attempts: 1,
expiresAt: Date.now() + 60_000,
};
await processClaimedTask(db, task);
expect(updateOne).toHaveBeenCalledWith(
expect.objectContaining({
_id: task._id,
status: TASK_STATUS.RUNNING,
leaseOwner: expect.any(String),
}),
expect.objectContaining({
$set: expect.objectContaining({
status: TASK_STATUS.CANCELLED,
completedAt: expect.any(Number),
}),
})
);
});
test("reconciliation recreates a missing task after service restart", async () => {
jest.spyOn(Date, "now").mockReturnValue(2_000_000);
const taskUpdateOne = jest
.fn()
.mockResolvedValueOnce({ modifiedCount: 0 })
.mockResolvedValueOnce({ upsertedCount: 1 });
const order = {
orderId: "order-recovery",
corpId: "corp-1",
orderSource: "ALIPAY_MINI",
orderStatus: "pending",
createTime: 1_990_000,
expireTime: 2_060_000,
};
const db = createCollectionDb({
"consult-order": {
find: jest.fn().mockReturnValue({
toArray: jest.fn().mockResolvedValue([order]),
}),
findOne: jest.fn().mockResolvedValue({ _id: new ObjectId() }),
},
"hlw-config": {
find: jest.fn().mockReturnValue({
toArray: jest
.fn()
.mockResolvedValue([{ corpId: "corp-1", autoAcceptOrder: true }]),
}),
findOne: jest
.fn()
.mockResolvedValue({ corpId: "corp-1", autoAcceptOrder: true }),
},
"diagnostic-record": {
find: jest.fn().mockReturnValue({
toArray: jest.fn().mockResolvedValue([]),
}),
},
[COLLECTION]: { updateOne: taskUpdateOne },
});
await reconcileAutomationTasks(db);
expect(taskUpdateOne).toHaveBeenLastCalledWith(
{ taskKey: "AUTO_ACCEPT:order-recovery" },
expect.objectContaining({
$setOnInsert: expect.objectContaining({
orderId: "order-recovery",
status: TASK_STATUS.PENDING,
}),
}),
{ upsert: true }
);
});
test("retries automatic audit while the assigned pharmacist is offline", async () => {
const rxId = new ObjectId();
const taskUpdateOne = jest.fn().mockResolvedValue({ modifiedCount: 1 });
const db = createCollectionDb({
"hlw-config": {
findOne: jest.fn().mockResolvedValue({ autoPassRx: true }),
},
"diagnostic-record": {
findOne: jest.fn().mockResolvedValue({
_id: rxId,
orderId: "order-1",
status: "INIT",
pharmacistNo: "P001",
}),
},
"hlw-doctor": {
findOne: jest.fn().mockResolvedValue({
doctorNo: "P001",
onlineStatus: "offline",
}),
},
[COLLECTION]: { updateOne: taskUpdateOne },
});
const task = {
_id: new ObjectId(),
type: AUTOMATION_TYPES.PASS_RX,
corpId: "corp-1",
orderId: "order-1",
rxId: rxId.toString(),
attempts: 1,
expiresAt: Date.now() + 60_000,
};
await processClaimedTask(db, task);
expect(taskUpdateOne).toHaveBeenCalledWith(
expect.objectContaining({
_id: task._id,
status: TASK_STATUS.RUNNING,
}),
expect.objectContaining({
$set: expect.objectContaining({
status: TASK_STATUS.PENDING,
nextRunAt: expect.any(Number),
lastError: "审方药师暂不在线",
}),
})
);
});
});
describe("automatic prescription assembly", () => {
test("reloads master data and produces the existing prescription contract", async () => {
const drugId = new ObjectId();
const findConfig = jest.fn((query) => {
if (query.group) {
return {
toArray: jest.fn().mockResolvedValue([
{
key: "store-medicine-dosage-unit",
list: [{ code: "MG", name: "mg" }],
},
{
key: "store-medicine-frequence",
list: [{ code: "BID", name: "每日两次" }],
},
{
key: "store-medicine-administration",
list: [{ code: "PO", name: "口服" }],
},
{
key: "medicine-package-unit",
list: [{ code: "盒", name: "盒" }],
},
]),
};
}
throw new Error("unexpected config find");
});
const db = createCollectionDb({
"hlw-doctor": {
findOne: jest.fn().mockResolvedValue({
doctorNo: "D001",
signatureUrl: "doctor-sign",
onlineStatus: "online",
}),
},
"hlw-diagnosis": {
find: jest.fn().mockReturnValue({
toArray: jest
.fn()
.mockResolvedValue([{ code: "J00", name: "普通感冒" }]),
}),
},
"drug-info": {
find: jest.fn().mockReturnValue({
toArray: jest.fn().mockResolvedValue([
{
_id: drugId,
onSale: true,
name: "测试药品",
erpId: "ERP-1",
insurance_code: "MED-1",
product_id: "P-1",
dosage_form: "片剂",
days: 3,
dosage_unit: "mg",
administration_method: "口服",
freq: "每日两次",
unit: "盒",
specification: "10mg*10片",
package_amount: 10,
recommended_quantity: 1,
},
]),
}),
},
"hlw-config": {
find: findConfig,
findOne: jest
.fn()
.mockResolvedValue({ medicinePurchaseRxDuration: 30 }),
},
});
const order = {
corpId: "corp-1",
orderId: "order-1",
orderSource: "ALIPAY_MINI",
consultType: "storeMedicinePurchase",
doctorCode: "D001",
doctorName: "医生甲",
patientId: "P001",
name: "患者甲",
diseases: ["普通感冒"],
description: "鼻塞流涕",
pastHistoryStr: "两天前起病",
drugs: [
{
_id: drugId.toString(),
dosage: 10,
dosage_unit: "mg",
quantity: 1,
usageName: "口服",
frequencyName: "每日两次",
unit: "盒",
},
],
expireTime: Date.now() + 60_000,
};
const params = await buildAutoPrescriptionParams(db, order);
expect(params).toMatchObject({
complaint: "普通感冒 鼻塞流涕",
presentIllness: "两天前起病",
doctorCAUserId: "doctor-sign",
prescriptionType: "storeMedicinePurchase",
diagnosisList: [{ code: "J00", name: "普通感冒", desc: "" }],
drugs: [
expect.objectContaining({
_id: drugId.toString(),
erpId: "ERP-1",
dosage_unit_code: "MG",
frequencyCode: "BID",
usageCode: "PO",
quantity: 1,
days: 3,
}),
],
});
});
});