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 { 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(/报价单不存在/); }); });