You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

702 lines
20 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { beforeEach, describe, expect, it, vi } from "vitest";
import {
MS_REFINE_DECISION_MS,
MS_REFINE_MSG,
MS_REFINE_TOTAL_MS,
buildMsRefineHoldState,
formatMsRefineRemainingLabel,
isMsRefineHoldExpired,
shouldAutoDeclineRefineHold,
toMsRefineHoldPublic,
} from "@/lib/constants/ms-refine-hold";
import {
collectMsDetailsRefineInvalidFields,
collectMsDetailsRefineMissing,
snapMsReadyTimeToOpensAt,
validateMsDetailsForRefine,
} from "@/lib/mothership/ms-details-refine-validation";
import type { MothershipLoggedInDetailsPayload } from "@/components/mothership/mothership-logged-in-details-form";
const SESSION = "00000000-0000-4000-8000-000000000001";
function fullDetails(
patch?: Partial<MothershipLoggedInDetailsPayload>,
): MothershipLoggedInDetailsPayload {
return {
pickup: {
companyName: "Acme Pickup",
suite: "",
contactFirst: "A",
contactLast: "B",
contactEmail: "pickup@acme.com",
contactPhone: "555-1111",
reference: "",
notes: "",
opensAt: "8:00 AM",
closesAt: "5:00 PM",
},
delivery: {
companyName: "Acme Delivery",
suite: "",
contactFirst: "C",
contactLast: "D",
contactEmail: "delivery@acme.com",
contactPhone: "555-2222",
reference: "",
notes: "",
opensAt: "9:00 AM",
closesAt: "5:00 PM",
},
requestDeliveryAppointment: false,
fbaNumber: "",
fbaPoNumber: "",
cargo: [
{
pieceCountType: "Cartons",
pieceCountQty: 2,
description: "General freight",
nmfc: "",
hazmat: false,
alcohol: false,
tobacco: false,
},
],
...patch,
};
}
describe("ms refine hold — 无客户时限逼迫", () => {
it("构建决策窗与总会话窗(默认 1 小时)", () => {
const now = 1_700_000_000_000;
const s = buildMsRefineHoldState({
quoteId: "q1",
sessionId: SESSION,
customerId: "CUST_001",
nowMs: now,
});
expect(s.decision_deadline_ms).toBe(now + MS_REFINE_DECISION_MS);
expect(s.total_deadline_ms).toBe(now + MS_REFINE_TOTAL_MS);
expect(MS_REFINE_TOTAL_MS).toBe(3_600_000);
});
it("不再因决策窗自动放弃", () => {
const now = 1_000;
const s = buildMsRefineHoldState({
quoteId: "q1",
sessionId: SESSION,
customerId: "CUST_001",
nowMs: now,
});
expect(shouldAutoDeclineRefineHold(s, now + MS_REFINE_DECISION_MS - 1)).toBe(
false,
);
expect(shouldAutoDeclineRefineHold(s, now + MS_REFINE_DECISION_MS)).toBe(
false,
);
expect(
shouldAutoDeclineRefineHold(
{ ...s, status: "filling" },
now + MS_REFINE_DECISION_MS + 10_000,
),
).toBe(false);
});
it("总会话窗边界(内部资源回收)", () => {
const now = 1_000;
const s = buildMsRefineHoldState({
quoteId: "q1",
sessionId: SESSION,
customerId: "CUST_001",
nowMs: now,
});
expect(isMsRefineHoldExpired(s, now + MS_REFINE_TOTAL_MS - 1)).toBe(false);
expect(isMsRefineHoldExpired(s, now + MS_REFINE_TOTAL_MS)).toBe(true);
});
it("倒计时文案:秒 / 分秒 / 归零", () => {
const now = 10_000;
expect(formatMsRefineRemainingLabel(now + 45_000, now)).toBe("45秒");
expect(formatMsRefineRemainingLabel(now + 65_000, now)).toBe("1分05秒");
expect(formatMsRefineRemainingLabel(now - 1, now)).toBe("0秒");
});
it("客户可见文案不含超时/二级/官网敏感词", () => {
expect(MS_REFINE_MSG.decisionTimeout).toBe("请补充必要信息后重新获取报价");
expect(MS_REFINE_MSG.totalTimeout).toBe("请补充必要信息后重新获取报价");
expect(MS_REFINE_MSG.totalTimeoutApi).not.toMatch(/超时|二级|5 分钟/);
expect(MS_REFINE_MSG.decisionTimeoutApi).not.toMatch(/超时|二级/);
});
it("public.available:filling/refining 始终 true;awaiting 在总会话内可用", () => {
const now = 5_000;
const s = buildMsRefineHoldState({
quoteId: "q1",
sessionId: SESSION,
customerId: "CUST_001",
nowMs: now,
});
expect(toMsRefineHoldPublic(s, now + 1_000).available).toBe(true);
expect(
toMsRefineHoldPublic(s, now + MS_REFINE_DECISION_MS + 1).available,
).toBe(false);
const filling = { ...s, status: "filling" as const };
expect(
toMsRefineHoldPublic(filling, now + MS_REFINE_DECISION_MS + 1).available,
).toBe(true);
expect(
toMsRefineHoldPublic(filling, now + MS_REFINE_TOTAL_MS).available,
).toBe(true);
});
});
describe("ms details refine — 必填预防", () => {
it("完整填写通过", () => {
expect(validateMsDetailsForRefine(fullDetails()).ok).toBe(true);
});
it("空公司名/邮箱/电话/营业时间 → 列出缺失项", () => {
const missing = collectMsDetailsRefineMissing(
fullDetails({
pickup: {
...fullDetails().pickup,
companyName: " ",
contactEmail: "",
contactPhone: "",
opensAt: "",
closesAt: "",
},
}),
);
expect(missing.some((m) => m.includes("提货") && m.includes("公司名"))).toBe(
true,
);
expect(missing.some((m) => m.includes("邮箱"))).toBe(true);
expect(missing.some((m) => m.includes("电话"))).toBe(true);
expect(missing.some((m) => m.includes("开门"))).toBe(true);
});
it("邮箱格式无效 → 明确提示", () => {
const missing = collectMsDetailsRefineMissing(
fullDetails({
delivery: { ...fullDetails().delivery, contactEmail: "not-an-email" },
}),
);
expect(missing.some((m) => m.includes("邮箱格式无效"))).toBe(true);
});
it("无货描 → 拦截;件数类型/数量为空不拦截", () => {
const r = validateMsDetailsForRefine(
fullDetails({
cargo: [
{
pieceCountType: "",
pieceCountQty: 0,
description: "",
nmfc: "",
hazmat: false,
alcohol: false,
tobacco: false,
},
],
}),
);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.message).toMatch(/信息不完整/);
expect(r.missing.some((m) => m.includes("货物描述"))).toBe(true);
expect(r.missing.some((m) => m.includes("件数类型"))).toBe(false);
expect(r.missing.some((m) => m.includes("件数数量"))).toBe(false);
}
});
it("有货描、无件数类型 → 通过(官网件数类型非必填)", () => {
expect(
validateMsDetailsForRefine(
fullDetails({
cargo: [
{
pieceCountType: "",
pieceCountQty: 0,
description: "Rugs",
nmfc: "",
hazmat: false,
alcohol: false,
tobacco: false,
},
],
}),
).ok,
).toBe(true);
});
it("地址空白 → 拦截", () => {
const missing = collectMsDetailsRefineMissing(fullDetails(), {
pickupAddress: " ",
deliveryAddress: "789 Elm",
});
expect(missing.some((m) => m.includes("提货") && m.includes("地址"))).toBe(
true,
);
expect(missing.some((m) => m.includes("送货") && m.includes("地址"))).toBe(
false,
);
});
it("提货与送货邮箱相同 → 拦截", () => {
const missing = collectMsDetailsRefineMissing(
fullDetails({
delivery: {
...fullDetails().delivery,
contactEmail: "pickup@acme.com",
},
}),
);
expect(missing.some((m) => m.includes("邮箱不能相同"))).toBe(true);
});
it("提货与送货邮箱大小写不同但相同 → 拦截", () => {
const missing = collectMsDetailsRefineMissing(
fullDetails({
pickup: {
...fullDetails().pickup,
contactEmail: "Same@Acme.com",
},
delivery: {
...fullDetails().delivery,
contactEmail: "same@acme.com",
},
}),
);
expect(missing.some((m) => m.includes("邮箱不能相同"))).toBe(true);
});
it("货运就绪时刻早于提货开门 → 拦截", () => {
const missing = collectMsDetailsRefineMissing(
fullDetails({
pickup: {
...fullDetails().pickup,
opensAt: "9:00 AM",
closesAt: "5:00 PM",
},
}),
{ readyTime: "7:00 AM" },
);
expect(missing.some((m) => m.includes("就绪时刻不能早于"))).toBe(true);
});
it("货运就绪时刻不早于开门 → 通过", () => {
expect(
validateMsDetailsForRefine(
fullDetails({
pickup: {
...fullDetails().pickup,
opensAt: "8:00 AM",
closesAt: "5:00 PM",
},
}),
{ readyTime: "11:00 AM" },
).ok,
).toBe(true);
});
it("开门不早于关门 → 拦截", () => {
const missing = collectMsDetailsRefineMissing(
fullDetails({
pickup: {
...fullDetails().pickup,
opensAt: "5:00 PM",
closesAt: "8:00 AM",
},
}),
);
expect(missing.some((m) => m.includes("开门时间须早于关门时间"))).toBe(
true,
);
});
it("snapMsReadyTimeToOpensAt 拨到开门整点", () => {
expect(snapMsReadyTimeToOpensAt("7:00 AM", "9:00 AM")).toBe("9:00 AM");
expect(snapMsReadyTimeToOpensAt("11:00 AM", "9:00 AM")).toBe("11:00 AM");
});
it("字段键覆盖空公司名与邮箱", () => {
const keys = collectMsDetailsRefineInvalidFields(
fullDetails({
pickup: {
...fullDetails().pickup,
companyName: "",
contactEmail: "bad",
},
}),
);
expect(keys.has("pickup.companyName")).toBe(true);
expect(keys.has("pickup.contactEmail")).toBe(true);
});
});
vi.mock("@/lib/prisma", () => ({
prisma: {
quoteRecord: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/modules/address/parked-session-queue", () => ({
requestReleaseParkedSession: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/mothership/refine-queue", () => ({
enqueueMsRefineDetailsJob: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/quote/rpa-queue", () => ({
enqueueQuoteJob: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/mothership/dashboard-direct-quote", () => ({
fetchMothershipLoggedInDirectQuote: vi.fn(),
isMsDashboardDirectQuoteEnabled: vi.fn(() => true),
}));
vi.mock("@/lib/rpa/env", () => ({
isInlineDirectQuoteEnabled: vi.fn(() => true),
}));
vi.mock("@/lib/rpa/mothership-login-context", () => ({
runWithMothershipLoginContext: vi.fn((_login, _src, fn: () => unknown) => fn()),
}));
vi.mock("@/modules/customer/provider-credentials", () => ({
getCustomerProviderLogin: vi.fn().mockResolvedValue({
email: "ms@example.com",
password: "x",
}),
}));
vi.mock("@/modules/mothership/refine-apply-quotes", () => ({
applyMsRefineQuotes: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/mothership/refine-hold-store", () => ({
assertMsRefineHoldUsable: vi.fn(),
clearMsRefineHold: vi.fn().mockResolvedValue(undefined),
readMsRefineHold: vi.fn(),
updateMsRefineHoldStatus: vi.fn().mockResolvedValue(null),
}));
import {
continueMsRefineHold,
declineMsRefineHold,
submitMsRefineDetails,
} from "@/modules/mothership/refine-service";
import {
assertMsRefineHoldUsable,
clearMsRefineHold,
readMsRefineHold,
updateMsRefineHoldStatus,
} from "@/lib/mothership/refine-hold-store";
import { requestReleaseParkedSession } from "@/modules/address/parked-session-queue";
import { enqueueMsRefineDetailsJob } from "@/modules/mothership/refine-queue";
import { enqueueQuoteJob } from "@/modules/quote/rpa-queue";
import { prisma } from "@/lib/prisma";
import { fetchMothershipLoggedInDirectQuote } from "@/lib/mothership/dashboard-direct-quote";
import { applyMsRefineQuotes } from "@/modules/mothership/refine-apply-quotes";
import {
MOTHERSHIP_DELIVERY_CONFIRMED,
MOTHERSHIP_PICKUP_CONFIRMED,
} from "@/__tests__/fixtures/mothership-address";
describe("refine-service — 用户路径", () => {
const quoteId = "Q_TEST_1";
const customerId = "CUST_001";
const now = Date.now();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetchMothershipLoggedInDirectQuote).mockRejectedValue(
new Error("direct-disabled-in-test"),
);
});
it("decline:释放驻留并清 hold", async () => {
vi.mocked(readMsRefineHold).mockResolvedValue(
buildMsRefineHoldState({
quoteId,
sessionId: SESSION,
customerId,
nowMs: now,
}),
);
const r = await declineMsRefineHold({
customerId,
quoteId,
sessionId: SESSION,
});
expect(r.released).toBe(true);
expect(updateMsRefineHoldStatus).toHaveBeenCalledWith(SESSION, "declined");
expect(requestReleaseParkedSession).toHaveBeenCalledWith(SESSION);
expect(clearMsRefineHold).toHaveBeenCalledWith(SESSION);
});
it("decline:客户不匹配 → 拒绝", async () => {
vi.mocked(readMsRefineHold).mockResolvedValue(
buildMsRefineHoldState({
quoteId,
sessionId: SESSION,
customerId: "OTHER",
nowMs: now,
}),
);
await expect(
declineMsRefineHold({ customerId, quoteId, sessionId: SESSION }),
).rejects.toThrow(/无权/);
});
it("continue:awaiting → filling", async () => {
vi.mocked(readMsRefineHold).mockResolvedValue(
buildMsRefineHoldState({
quoteId,
sessionId: SESSION,
customerId,
nowMs: now,
}),
);
const r = await continueMsRefineHold({
customerId,
quoteId,
sessionId: SESSION,
});
expect(r.status).toBe("filling");
expect(updateMsRefineHoldStatus).toHaveBeenCalledWith(SESSION, "filling");
});
it("continue:决策窗已过仍可进入 filling(不再自动超时放弃)", async () => {
const s = buildMsRefineHoldState({
quoteId,
sessionId: SESSION,
customerId,
nowMs: now - MS_REFINE_DECISION_MS - 1,
});
vi.mocked(readMsRefineHold).mockResolvedValue(s);
const r = await continueMsRefineHold({
customerId,
quoteId,
sessionId: SESSION,
});
expect(r.status).toBe("filling");
expect(requestReleaseParkedSession).not.toHaveBeenCalled();
});
it("submit:会话窗已过 → 完整重查(不抛超时文案)", async () => {
const s = {
...buildMsRefineHoldState({
quoteId,
sessionId: SESSION,
customerId,
nowMs: now - MS_REFINE_TOTAL_MS - 1,
}),
status: "filling" as const,
};
vi.mocked(readMsRefineHold).mockResolvedValue(s);
vi.mocked(prisma.quoteRecord.findUnique).mockResolvedValue({
quoteId,
customerId,
requestId: "550e8400-e29b-41d4-a716-446655440000",
businessCustomerId: null,
pickupJson: {
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
place_id: "ChIJ_pickup",
formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001, USA",
selected_from_suggestions: true,
...MOTHERSHIP_PICKUP_CONFIRMED,
},
deliveryJson: {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
place_id: "ChIJ_delivery",
formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA",
selected_from_suggestions: true,
...MOTHERSHIP_DELIVERY_CONFIRMED,
},
weightLb: 500,
dimLIn: 48,
dimWIn: 40,
dimHIn: 48,
palletCount: 2,
cargoType: "general_freight",
} as never);
vi.mocked(prisma.quoteRecord.update).mockResolvedValue({} as never);
const r = await submitMsRefineDetails({
customerId,
quoteId,
sessionId: SESSION,
mothershipDetails: {
pickup: { company_name: "Acme", contact_email: "a@b.com" },
},
});
expect(r).toEqual({ quote_id: quoteId, status: "processing" });
expect(enqueueQuoteJob).toHaveBeenCalled();
expect(enqueueMsRefineDetailsJob).not.toHaveBeenCalled();
});
it("submit:filling 态入队刷价", async () => {
const s = {
...buildMsRefineHoldState({
quoteId,
sessionId: SESSION,
customerId,
nowMs: now,
}),
status: "filling" as const,
};
vi.mocked(assertMsRefineHoldUsable).mockResolvedValue(s);
vi.mocked(readMsRefineHold).mockResolvedValue(s);
vi.mocked(prisma.quoteRecord.findUnique).mockResolvedValue({
quoteId,
customerId,
requestId: "550e8400-e29b-41d4-a716-446655440000",
pickupJson: {
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
place_id: "ChIJ_pickup",
formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001, USA",
selected_from_suggestions: true,
...MOTHERSHIP_PICKUP_CONFIRMED,
},
deliveryJson: {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
place_id: "ChIJ_delivery",
formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA",
selected_from_suggestions: true,
...MOTHERSHIP_DELIVERY_CONFIRMED,
},
weightLb: 500,
dimLIn: 48,
dimWIn: 40,
dimHIn: 48,
palletCount: 2,
cargoType: "general_freight",
} as never);
vi.mocked(prisma.quoteRecord.update).mockResolvedValue({} as never);
const r = await submitMsRefineDetails({
customerId,
quoteId,
sessionId: SESSION,
mothershipDetails: {
pickup: { company_name: "Acme", contact_email: "a@b.com" },
},
});
expect(r).toEqual({ quote_id: quoteId, status: "processing" });
expect(updateMsRefineHoldStatus).toHaveBeenCalledWith(SESSION, "refining");
expect(enqueueMsRefineDetailsJob).toHaveBeenCalled();
});
it("submit:Direct 成功则跳过队列", async () => {
const s = {
...buildMsRefineHoldState({
quoteId,
sessionId: SESSION,
customerId,
nowMs: now,
}),
status: "filling" as const,
};
vi.mocked(assertMsRefineHoldUsable).mockResolvedValue(s);
vi.mocked(readMsRefineHold).mockResolvedValue(s);
vi.mocked(prisma.quoteRecord.findUnique).mockResolvedValue({
quoteId,
customerId,
requestId: "550e8400-e29b-41d4-a716-446655440000",
pickupJson: {
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
place_id: "ChIJ_pickup",
formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001, USA",
selected_from_suggestions: true,
...MOTHERSHIP_PICKUP_CONFIRMED,
},
deliveryJson: {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
place_id: "ChIJ_delivery",
formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA",
selected_from_suggestions: true,
...MOTHERSHIP_DELIVERY_CONFIRMED,
},
weightLb: 500,
dimLIn: 48,
dimWIn: 40,
dimHIn: 48,
palletCount: 2,
cargoType: "general_freight",
} as never);
vi.mocked(prisma.quoteRecord.update).mockResolvedValue({} as never);
vi.mocked(fetchMothershipLoggedInDirectQuote).mockResolvedValue([
{
serviceLevel: "standard",
rateOption: "bestValue",
carrier: "XPO Logistics Direct",
transitDays: "3",
transitDescription: "3 business days",
rawFreight: 400,
surcharges: 0,
rawTotal: 400,
},
]);
const r = await submitMsRefineDetails({
customerId,
quoteId,
sessionId: SESSION,
mothershipDetails: {
pickup: { company_name: "Acme", contact_email: "a@b.com" },
},
});
expect(r).toEqual({ quote_id: quoteId, status: "processing" });
expect(applyMsRefineQuotes).toHaveBeenCalled();
expect(enqueueMsRefineDetailsJob).not.toHaveBeenCalled();
expect(requestReleaseParkedSession).toHaveBeenCalled();
});
it("submit:报价单不属于客户 → 拒绝", async () => {
vi.mocked(readMsRefineHold).mockResolvedValue({
...buildMsRefineHoldState({
quoteId,
sessionId: SESSION,
customerId,
nowMs: now,
}),
status: "filling",
});
vi.mocked(prisma.quoteRecord.findUnique).mockResolvedValue({
quoteId,
customerId: "OTHER",
} as never);
await expect(
submitMsRefineDetails({
customerId,
quoteId,
sessionId: SESSION,
}),
).rejects.toThrow(/报价单不存在/);
});
});