Co-authored-by: Cursor <cursoragent@cursor.com>master
parent
4ef21f41db
commit
ced49c80e4
@ -0,0 +1,390 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
FLOCK_ADDITIONAL_SERVICES,
|
||||
FLOCK_DEFAULT_VEHICLES,
|
||||
FLOCK_FREIGHT_CLASSES,
|
||||
FLOCK_LOCATION_TYPES,
|
||||
FLOCK_PACKAGING_TYPES,
|
||||
FLOCK_VEHICLE_TYPES,
|
||||
getFlockLocationAccessorials,
|
||||
mapFlockLoggedInToApiInput,
|
||||
validateFlockLoggedInCargoLine,
|
||||
type FlockLoggedInQuotePayload,
|
||||
} from "@/components/flock/flock-logged-in-quote-form";
|
||||
import { FLOCK_VALIDATION_MESSAGES } from "@/lib/constants/flock-limits";
|
||||
|
||||
describe("flock logged-in quote form constants", () => {
|
||||
it("地点类型恰 12 项且无虚构 Grocery/Prison 等", () => {
|
||||
const ids = FLOCK_LOCATION_TYPES.map((x) => x.id);
|
||||
expect(ids).toHaveLength(12);
|
||||
expect(ids).toEqual(
|
||||
expect.arrayContaining([
|
||||
"business_with_dock",
|
||||
"business_without_dock",
|
||||
"limited_school",
|
||||
"limited_airport",
|
||||
"limited_military",
|
||||
"trade_show",
|
||||
"residential",
|
||||
"limited_other",
|
||||
]),
|
||||
);
|
||||
expect(ids).not.toContain("limited_grocery");
|
||||
expect(ids).not.toContain("limited_mini_storage");
|
||||
expect(ids).not.toContain("limited_prison");
|
||||
expect(ids).not.toContain("limited_utility");
|
||||
});
|
||||
|
||||
it("地点类型含 Business / School / Airport / Trade Show 等", () => {
|
||||
const ids = FLOCK_LOCATION_TYPES.map((x) => x.id);
|
||||
expect(ids).toEqual(
|
||||
expect.arrayContaining([
|
||||
"business_with_dock",
|
||||
"business_without_dock",
|
||||
"limited_school",
|
||||
"limited_airport",
|
||||
"trade_show",
|
||||
"residential",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("调度选项按总重 >5000 动态启用", async () => {
|
||||
const mod = await import("@/components/flock/flock-logged-in-quote-form");
|
||||
expect(mod.flockSchedulingWeightLockedHint()).toBe(
|
||||
"整票总重须大于 5000 lb 才可选用",
|
||||
);
|
||||
expect(
|
||||
mod.isFlockSchedulingOptionLocked(4000, true),
|
||||
).toBe(true);
|
||||
expect(
|
||||
mod.isFlockSchedulingOptionLocked(5001, true),
|
||||
).toBe(false);
|
||||
expect(
|
||||
mod.isFlockSchedulingOptionLocked(4000, false),
|
||||
).toBe(false);
|
||||
expect(
|
||||
mod.FLOCK_PICKUP_SERVICES.find((s) => s.id === "during_window")
|
||||
?.weightLocked,
|
||||
).toBe(true);
|
||||
expect(
|
||||
mod.FLOCK_PICKUP_SERVICES.find((s) => s.id === "standard_fcfs")
|
||||
?.weightLocked,
|
||||
).toBe(false);
|
||||
expect(
|
||||
mod.FLOCK_DELIVERY_SERVICES.find((s) => s.id === "need_appointment")
|
||||
?.weightLocked,
|
||||
).toBe(false);
|
||||
expect(
|
||||
mod.FLOCK_DELIVERY_SERVICES.find((s) => s.id === "must_arrive_by")
|
||||
?.weightLocked,
|
||||
).toBe(true);
|
||||
expect(mod.FLOCK_SCHEDULING_TIME_OPTIONS[0]).toBe("12:00 am");
|
||||
expect(
|
||||
mod.FLOCK_PICKUP_SERVICES.find((s) => s.id === "during_window")?.detail,
|
||||
).toContain("预设时段");
|
||||
});
|
||||
|
||||
it("mapFlockLoggedInToApiInput 透传调度子字段", () => {
|
||||
const payload: FlockLoggedInQuotePayload = {
|
||||
mode: "quick",
|
||||
pickupDate: "2026-07-16",
|
||||
pickupZip: "60611",
|
||||
pickupType: "business_with_dock",
|
||||
pickupLiftgate: false,
|
||||
pickupInside: false,
|
||||
pickupPalletJack: false,
|
||||
deliveryZip: "78701",
|
||||
deliveryType: "business_without_dock",
|
||||
deliveryLiftgate: false,
|
||||
deliveryInside: false,
|
||||
deliveryPalletJack: false,
|
||||
items: [
|
||||
{
|
||||
quantity: 4,
|
||||
packagingType: "pallets_48x40",
|
||||
lengthIn: 48,
|
||||
widthIn: 40,
|
||||
heightIn: 48,
|
||||
totalWeightLb: 6000,
|
||||
freightClass: "100",
|
||||
description: "papers",
|
||||
stackable: true,
|
||||
turnable: false,
|
||||
},
|
||||
],
|
||||
additionalServices: [],
|
||||
vehicleTypes: ["dry_van"],
|
||||
pickupService: "during_window",
|
||||
deliveryService: "must_arrive_by",
|
||||
pickupWindowStartTime: "8:00 am",
|
||||
pickupWindowEndTime: "5:00 pm",
|
||||
deliveryMustArriveByDate: "2026-07-20",
|
||||
callForDeliveryAppointment: true,
|
||||
callBeforePickup: false,
|
||||
callBeforeDelivery: false,
|
||||
additionalInsurance: false,
|
||||
};
|
||||
const mapped = mapFlockLoggedInToApiInput(payload);
|
||||
expect(mapped.pickup_service).toBe("during_window");
|
||||
expect(mapped.pickup_window_start_time).toBe("8:00 am");
|
||||
expect(mapped.pickup_window_end_time).toBe("5:00 pm");
|
||||
expect(mapped.delivery_service).toBe("must_arrive_by");
|
||||
expect(mapped.delivery_must_arrive_by_date).toBe("2026-07-20");
|
||||
expect(mapped.call_for_delivery_appointment).toBe(true);
|
||||
});
|
||||
|
||||
it("mapFlockLoggedInToApiInput 总重 ≤5000 时强制关闭提货前致电", () => {
|
||||
const payload: FlockLoggedInQuotePayload = {
|
||||
mode: "quick",
|
||||
pickupDate: "2026-07-16",
|
||||
pickupZip: "60611",
|
||||
pickupType: "business_with_dock",
|
||||
pickupLiftgate: false,
|
||||
pickupInside: false,
|
||||
pickupPalletJack: false,
|
||||
deliveryZip: "78701",
|
||||
deliveryType: "business_without_dock",
|
||||
deliveryLiftgate: false,
|
||||
deliveryInside: false,
|
||||
deliveryPalletJack: false,
|
||||
items: [
|
||||
{
|
||||
quantity: 4,
|
||||
packagingType: "pallets_48x40",
|
||||
lengthIn: 48,
|
||||
widthIn: 40,
|
||||
heightIn: 48,
|
||||
totalWeightLb: 1000,
|
||||
freightClass: "100",
|
||||
description: "papers",
|
||||
stackable: true,
|
||||
turnable: false,
|
||||
},
|
||||
],
|
||||
additionalServices: [],
|
||||
vehicleTypes: ["dry_van"],
|
||||
pickupService: "standard_fcfs",
|
||||
deliveryService: "standard_fcfs",
|
||||
callBeforePickup: true,
|
||||
callBeforeDelivery: false,
|
||||
additionalInsurance: false,
|
||||
};
|
||||
expect(mapFlockLoggedInToApiInput(payload).call_before_pickup).toBe(false);
|
||||
payload.items[0]!.totalWeightLb = 6000;
|
||||
expect(mapFlockLoggedInToApiInput(payload).call_before_pickup).toBe(true);
|
||||
});
|
||||
|
||||
it("各地点类型附加选项矩阵对齐官网截图", () => {
|
||||
expect(getFlockLocationAccessorials("business_without_dock")).toEqual({
|
||||
liftgate: true,
|
||||
inside: true,
|
||||
palletJack: true,
|
||||
});
|
||||
expect(getFlockLocationAccessorials("residential")).toEqual({
|
||||
liftgate: true,
|
||||
inside: true,
|
||||
palletJack: false,
|
||||
});
|
||||
expect(getFlockLocationAccessorials("limited_construction")).toEqual({
|
||||
liftgate: true,
|
||||
inside: false,
|
||||
palletJack: true,
|
||||
});
|
||||
expect(getFlockLocationAccessorials("trade_show")).toEqual({
|
||||
liftgate: false,
|
||||
inside: false,
|
||||
palletJack: true,
|
||||
});
|
||||
expect(getFlockLocationAccessorials("limited_worship")).toEqual({
|
||||
liftgate: true,
|
||||
inside: true,
|
||||
palletJack: false,
|
||||
});
|
||||
expect(getFlockLocationAccessorials("business_with_dock")).toEqual({
|
||||
liftgate: false,
|
||||
inside: false,
|
||||
palletJack: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("官网提示文案中文齐全", async () => {
|
||||
const tips = await import("@/components/flock/flock-logged-in-quote-form");
|
||||
expect(tips.FLOCK_LOCATION_TIP_ITEMS).toHaveLength(7);
|
||||
expect(tips.FLOCK_ADDITIONAL_SERVICE_TIP_ITEMS).toHaveLength(5);
|
||||
expect(tips.FLOCK_VEHICLE_TIP_ITEMS).toHaveLength(4);
|
||||
expect(tips.FLOCK_FREIGHT_CLASS_TIP).toContain("件数");
|
||||
expect(tips.FLOCK_INSURANCE_YES_TIP).toContain("责任限额");
|
||||
expect(tips.FLOCK_NMFC_NOTICE).toContain("NMFC");
|
||||
});
|
||||
|
||||
it("货物尺寸与重量硬限同 FLOCK_LIMITS", () => {
|
||||
const ok = validateFlockLoggedInCargoLine({
|
||||
quantity: 2,
|
||||
lengthIn: 48,
|
||||
widthIn: 40,
|
||||
heightIn: 48,
|
||||
totalWeightLb: 1000,
|
||||
description: "ok",
|
||||
});
|
||||
expect(ok).toEqual({});
|
||||
|
||||
expect(
|
||||
validateFlockLoggedInCargoLine({
|
||||
quantity: 1,
|
||||
lengthIn: 637,
|
||||
widthIn: 40,
|
||||
heightIn: 48,
|
||||
totalWeightLb: 100,
|
||||
description: "x",
|
||||
}).len,
|
||||
).toBe(FLOCK_VALIDATION_MESSAGES.length);
|
||||
|
||||
expect(
|
||||
validateFlockLoggedInCargoLine({
|
||||
quantity: 1,
|
||||
lengthIn: 48,
|
||||
widthIn: 103,
|
||||
heightIn: 48,
|
||||
totalWeightLb: 100,
|
||||
description: "x",
|
||||
}).wid,
|
||||
).toBe(FLOCK_VALIDATION_MESSAGES.width);
|
||||
|
||||
expect(
|
||||
validateFlockLoggedInCargoLine({
|
||||
quantity: 1,
|
||||
lengthIn: 48,
|
||||
widthIn: 40,
|
||||
heightIn: 109,
|
||||
totalWeightLb: 100,
|
||||
description: "x",
|
||||
}).hei,
|
||||
).toBe(FLOCK_VALIDATION_MESSAGES.height);
|
||||
|
||||
expect(
|
||||
validateFlockLoggedInCargoLine({
|
||||
quantity: 1,
|
||||
lengthIn: 48,
|
||||
widthIn: 40,
|
||||
heightIn: 48,
|
||||
totalWeightLb: 45_001,
|
||||
description: "x",
|
||||
}).wt,
|
||||
).toBe(FLOCK_VALIDATION_MESSAGES.totalWeight);
|
||||
});
|
||||
|
||||
it("包装类型含标准托盘尺寸", () => {
|
||||
const ids = FLOCK_PACKAGING_TYPES.map((x) => x.id);
|
||||
expect(ids).toEqual(
|
||||
expect.arrayContaining([
|
||||
"pallets_48x40",
|
||||
"pallets_48x48",
|
||||
"pallets_60x48",
|
||||
"pallets_custom",
|
||||
]),
|
||||
);
|
||||
const p48 = FLOCK_PACKAGING_TYPES.find((p) => p.id === "pallets_48x40");
|
||||
expect(p48?.length).toBe(48);
|
||||
expect(p48?.width).toBe(40);
|
||||
});
|
||||
|
||||
it("货运等级含密度计算与数值档", () => {
|
||||
expect(FLOCK_FREIGHT_CLASSES[0]).toBe("density");
|
||||
expect(FLOCK_FREIGHT_CLASSES).toEqual(
|
||||
expect.arrayContaining(["50", "70", "100", "500"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("附加服务与车型对齐截图", () => {
|
||||
expect(FLOCK_ADDITIONAL_SERVICES.map((s) => s.id)).toEqual([
|
||||
"blind_shipment",
|
||||
"food_grade",
|
||||
"load_to_ride",
|
||||
"temperature_control",
|
||||
"unloading",
|
||||
]);
|
||||
expect(FLOCK_VEHICLE_TYPES.map((v) => v.id)).toEqual([
|
||||
"box_truck",
|
||||
"dry_van",
|
||||
"refrigerated",
|
||||
"sprinter",
|
||||
]);
|
||||
expect([...FLOCK_DEFAULT_VEHICLES]).toEqual([
|
||||
"box_truck",
|
||||
"dry_van",
|
||||
"refrigerated",
|
||||
]);
|
||||
});
|
||||
|
||||
it("mapFlockLoggedInToApiInput 汇总首行尺寸与多行数量/重量", () => {
|
||||
const payload: FlockLoggedInQuotePayload = {
|
||||
mode: "quick",
|
||||
pickupDate: "2026-07-16",
|
||||
pickupZip: "60611",
|
||||
pickupType: "business_with_dock",
|
||||
pickupLiftgate: false,
|
||||
pickupInside: false,
|
||||
pickupPalletJack: false,
|
||||
deliveryZip: "78701",
|
||||
deliveryType: "business_without_dock",
|
||||
deliveryLiftgate: true,
|
||||
deliveryInside: false,
|
||||
deliveryPalletJack: false,
|
||||
items: [
|
||||
{
|
||||
quantity: 2,
|
||||
packagingType: "pallets_48x40",
|
||||
lengthIn: 48,
|
||||
widthIn: 40,
|
||||
heightIn: 50,
|
||||
totalWeightLb: 500,
|
||||
freightClass: "100",
|
||||
description: "papers",
|
||||
stackable: true,
|
||||
turnable: false,
|
||||
},
|
||||
{
|
||||
quantity: 1,
|
||||
packagingType: "boxes",
|
||||
lengthIn: 20,
|
||||
widthIn: 20,
|
||||
heightIn: 20,
|
||||
totalWeightLb: 100,
|
||||
freightClass: "70",
|
||||
description: "parts",
|
||||
stackable: false,
|
||||
turnable: false,
|
||||
},
|
||||
],
|
||||
additionalServices: ["blind_shipment"],
|
||||
vehicleTypes: ["box_truck", "dry_van"],
|
||||
pickupService: "standard_fcfs",
|
||||
deliveryService: "standard_fcfs",
|
||||
callBeforePickup: false,
|
||||
callBeforeDelivery: false,
|
||||
additionalInsurance: false,
|
||||
};
|
||||
const mapped = mapFlockLoggedInToApiInput(payload);
|
||||
expect(mapped.pickup_date).toBe("07/16/2026");
|
||||
expect(mapped.pickup_zip).toBe("60611");
|
||||
expect(mapped.delivery_zip).toBe("78701");
|
||||
expect(mapped.pallet_count).toBe(3);
|
||||
expect(mapped.total_weight).toEqual({ value: 600, unit: "lb" });
|
||||
expect(mapped.dimensions).toEqual({
|
||||
length: 48,
|
||||
width: 40,
|
||||
height: 50,
|
||||
unit: "in",
|
||||
});
|
||||
expect(mapped.form_mode).toBe("logged_in_quick");
|
||||
expect(mapped.packaging_type).toBe("pallets_48x40");
|
||||
expect(mapped.freight_class).toBe("100");
|
||||
expect(mapped.description).toBe("papers");
|
||||
expect(mapped.stackable).toBe(true);
|
||||
expect(mapped.additional_services).toEqual(["blind_shipment"]);
|
||||
expect(mapped.vehicle_types).toEqual(["box_truck", "dry_van"]);
|
||||
expect(mapped.delivery_liftgate).toBe(true);
|
||||
expect(mapped.pickup_liftgate).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MS_CARGO_TYPES,
|
||||
MS_DEFAULT_READY_TIME,
|
||||
MS_DELIVERY_ACCESSORIALS,
|
||||
MS_PICKUP_ACCESSORIALS,
|
||||
MS_READY_TIMES,
|
||||
} from "@/components/mothership/mothership-logged-in-shipment-form";
|
||||
|
||||
describe("mothership logged-in shipment form constants", () => {
|
||||
it("提货附加服务含官网录制项", () => {
|
||||
const ids = MS_PICKUP_ACCESSORIALS.map((x) => x.id);
|
||||
expect(ids).toEqual(
|
||||
expect.arrayContaining([
|
||||
"cfs",
|
||||
"liftgate",
|
||||
"limitedAccess",
|
||||
"inside",
|
||||
"residential",
|
||||
"tradeshow",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("送货附加服务含 Amazon 预约等录制项", () => {
|
||||
const ids = MS_DELIVERY_ACCESSORIALS.map((x) => x.id);
|
||||
expect(ids).toEqual(
|
||||
expect.arrayContaining([
|
||||
"fbaAppointment",
|
||||
"appointment",
|
||||
"cfs",
|
||||
"liftgate",
|
||||
"limitedAccess",
|
||||
"inside",
|
||||
"residential",
|
||||
"tradeshow",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("货物类型对齐官网完整列表", () => {
|
||||
expect(MS_CARGO_TYPES.map((x) => x.id)).toEqual([
|
||||
"pallet",
|
||||
"box",
|
||||
"crate",
|
||||
"piece",
|
||||
"bale",
|
||||
"bucket",
|
||||
"carton",
|
||||
"case",
|
||||
"coil",
|
||||
"cylinder",
|
||||
"drum",
|
||||
"pail",
|
||||
"reel",
|
||||
"roll",
|
||||
"skid",
|
||||
"tote",
|
||||
"tube",
|
||||
]);
|
||||
});
|
||||
|
||||
it("可提货时刻为 24 个整点且有序无重复", () => {
|
||||
expect(MS_READY_TIMES).toHaveLength(24);
|
||||
expect(new Set(MS_READY_TIMES).size).toBe(24);
|
||||
expect(MS_READY_TIMES[0]).toBe("12:00 AM");
|
||||
expect(MS_READY_TIMES[11]).toBe("11:00 AM");
|
||||
expect(MS_READY_TIMES[12]).toBe("12:00 PM");
|
||||
expect(MS_READY_TIMES[15]).toBe("3:00 PM");
|
||||
expect(MS_READY_TIMES[23]).toBe("11:00 PM");
|
||||
expect(MS_DEFAULT_READY_TIME).toBe("11:00 AM");
|
||||
expect(MS_READY_TIMES).toContain(MS_DEFAULT_READY_TIME);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
QUOTE_ACCENT,
|
||||
QUOTE_REQUIRED,
|
||||
QUOTE_SECTION,
|
||||
QUOTE_TITLE,
|
||||
quoteAddRowCls,
|
||||
quoteCtaCls,
|
||||
quoteInputCls,
|
||||
} from "@/components/quote/quote-form-chrome";
|
||||
|
||||
describe("quote-form-chrome tokens", () => {
|
||||
it("局部主色与标题色对齐视觉规范", () => {
|
||||
expect(QUOTE_ACCENT).toBe("#1890FF");
|
||||
expect(QUOTE_TITLE).toBe("#1F2937");
|
||||
expect(QUOTE_SECTION).toBe("#4B5563");
|
||||
expect(QUOTE_REQUIRED).toBe("#EF4444");
|
||||
});
|
||||
|
||||
it("输入/CTA/添加行 class 含关键样式关键字", () => {
|
||||
expect(quoteInputCls).toContain("h-10");
|
||||
expect(quoteInputCls).toContain("px-3");
|
||||
expect(quoteInputCls).toContain("#1890FF");
|
||||
expect(quoteCtaCls).toContain("min-w-[48px]");
|
||||
expect(quoteCtaCls).toContain("bg-[#1890FF]");
|
||||
expect(quoteAddRowCls).toContain("hover:-translate-y-0.5");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,28 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resolveStorageStatePath } from "@/lib/axel/session";
|
||||
import { runWithMothershipLoginContext } from "@/lib/rpa/mothership-login-context";
|
||||
|
||||
const TMP = path.join(process.cwd(), ".rpa", "_tmp-logged-in-storage-test.json");
|
||||
|
||||
describe("resolveStorageStatePath 登录态优先", () => {
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(TMP)) fs.unlinkSync(TMP);
|
||||
delete process.env.RPA_LOGGED_IN_STORAGE_STATE_PATH;
|
||||
});
|
||||
|
||||
it("有登录凭据且 logged-in storage 存在时使用登录态路径", async () => {
|
||||
process.env.RPA_LOGGED_IN_STORAGE_STATE_PATH = TMP;
|
||||
fs.mkdirSync(path.dirname(TMP), { recursive: true });
|
||||
fs.writeFileSync(TMP, JSON.stringify({ cookies: [], origins: [] }), "utf8");
|
||||
|
||||
await runWithMothershipLoginContext(
|
||||
{ email: "a@b.com", password: "x" },
|
||||
"customer",
|
||||
async () => {
|
||||
expect(resolveStorageStatePath()).toBe(TMP);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FLOCK_LIMITS } from "@/lib/constants/flock-limits";
|
||||
import { isFlockRpaEnabled } from "@/lib/flock/env";
|
||||
|
||||
describe("flock limits & env", () => {
|
||||
it("硬限与 PRD §14.3 一致", () => {
|
||||
expect(FLOCK_LIMITS.palletCount).toEqual({ min: 4, max: 20 });
|
||||
expect(FLOCK_LIMITS.totalWeightLbMax).toBe(45_000);
|
||||
expect(FLOCK_LIMITS.dimIn).toEqual({
|
||||
lengthMax: 636,
|
||||
widthMax: 102,
|
||||
heightMax: 108,
|
||||
min: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("Mock 模式下视为启用", () => {
|
||||
const prevMock = process.env.RPA_MOCK_MODE;
|
||||
const prevEnabled = process.env.FLOCK_RPA_ENABLED;
|
||||
const prevNode = process.env.NODE_ENV;
|
||||
process.env.RPA_MOCK_MODE = "true";
|
||||
process.env.FLOCK_RPA_ENABLED = "false";
|
||||
process.env.NODE_ENV = "production";
|
||||
expect(isFlockRpaEnabled()).toBe(true);
|
||||
process.env.RPA_MOCK_MODE = prevMock;
|
||||
process.env.FLOCK_RPA_ENABLED = prevEnabled;
|
||||
process.env.NODE_ENV = prevNode;
|
||||
});
|
||||
|
||||
it("development 且未显式关闭时默认启用", () => {
|
||||
const prevEnabled = process.env.FLOCK_RPA_ENABLED;
|
||||
const prevNode = process.env.NODE_ENV;
|
||||
delete process.env.FLOCK_RPA_ENABLED;
|
||||
process.env.NODE_ENV = "development";
|
||||
expect(isFlockRpaEnabled()).toBe(true);
|
||||
process.env.FLOCK_RPA_ENABLED = "false";
|
||||
expect(isFlockRpaEnabled()).toBe(false);
|
||||
process.env.FLOCK_RPA_ENABLED = prevEnabled;
|
||||
process.env.NODE_ENV = prevNode;
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isEmbedHostedShell,
|
||||
parseEmbedSsoParams,
|
||||
} from "@/lib/embed/sso-params";
|
||||
|
||||
describe("parseEmbedSsoParams", () => {
|
||||
it("解析 login_type=api_key + api_key", () => {
|
||||
const p = new URLSearchParams(
|
||||
"login_type=api_key&api_key=chj_demo_token",
|
||||
);
|
||||
expect(parseEmbedSsoParams(p)).toEqual({
|
||||
mode: "api_key",
|
||||
apiKey: "chj_demo_token",
|
||||
});
|
||||
});
|
||||
|
||||
it("解析短别名 type=key + key=", () => {
|
||||
const p = new URLSearchParams("type=api_key&key=demo-host-token");
|
||||
expect(parseEmbedSsoParams(p)).toEqual({
|
||||
mode: "api_key",
|
||||
apiKey: "demo-host-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("仅有 key 无 type 时默认 api_key", () => {
|
||||
const p = new URLSearchParams("api_key=abc");
|
||||
expect(parseEmbedSsoParams(p)).toEqual({ mode: "api_key", apiKey: "abc" });
|
||||
});
|
||||
|
||||
it("解析账号密码", () => {
|
||||
const p = new URLSearchParams(
|
||||
"login_type=password&customer_id=CUST_001&password=secret",
|
||||
);
|
||||
expect(parseEmbedSsoParams(p)).toEqual({
|
||||
mode: "password",
|
||||
customerId: "CUST_001",
|
||||
password: "secret",
|
||||
});
|
||||
});
|
||||
|
||||
it("缺字段 → none", () => {
|
||||
expect(parseEmbedSsoParams(new URLSearchParams("login_type=api_key"))).toEqual(
|
||||
{ mode: "none" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEmbedHostedShell", () => {
|
||||
it("embed=1 或带 SSO 凭证视为托管壳", () => {
|
||||
expect(isEmbedHostedShell(new URLSearchParams("embed=1"))).toBe(true);
|
||||
expect(
|
||||
isEmbedHostedShell(new URLSearchParams("api_key=x")),
|
||||
).toBe(true);
|
||||
expect(isEmbedHostedShell(new URLSearchParams(""))).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildFlockQuotesApiRequest } from "@/lib/flock/quote-payload";
|
||||
import { mapFlockFulfillmentOptionsToLines } from "@/lib/flock/map-fulfillment-options";
|
||||
import type { FlockQuoteInput } from "@/workers/rpa/flock/types";
|
||||
|
||||
const sampleInput: FlockQuoteInput = {
|
||||
pickupDate: "07/15/2026",
|
||||
pickupZip: "90001",
|
||||
deliveryZip: "75201",
|
||||
palletCount: 6,
|
||||
totalWeightLb: 10_000,
|
||||
lengthIn: 48,
|
||||
widthIn: 40,
|
||||
heightIn: 48,
|
||||
};
|
||||
|
||||
describe("buildFlockQuotesApiRequest", () => {
|
||||
it("对齐抓包契约字段", () => {
|
||||
const body = buildFlockQuotesApiRequest(sampleInput);
|
||||
expect(body.shipment.pickupDate).toBe("2026-07-15");
|
||||
expect(body.shipment.originPostalCode).toBe("90001");
|
||||
expect(body.shipment.destinationPostalCode).toBe("75201");
|
||||
expect(body.shipment.items[0]).toMatchObject({
|
||||
quantity: 6,
|
||||
weightLbs: 10000,
|
||||
lengthIn: 48,
|
||||
freightClass: "CLASS_60",
|
||||
packagingType: "PALLET_OTHER",
|
||||
});
|
||||
expect(body.isEdiRequote).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapFlockFulfillmentOptionsToLines", () => {
|
||||
it("映射 GUARANTEED_HUBLESS 与 STANDARD 最低价", () => {
|
||||
const { quotes, reference } = mapFlockFulfillmentOptionsToLines({
|
||||
referenceNumber: "FFW-TEST",
|
||||
fulfillmentOptions: [
|
||||
{
|
||||
fulfillmentCategory: "STANDARD",
|
||||
rateUsd: "1512.67",
|
||||
transitTimeDaysMin: 3,
|
||||
transitTimeDaysMax: 7,
|
||||
carrierName: "A",
|
||||
},
|
||||
{
|
||||
fulfillmentCategory: "STANDARD",
|
||||
rateUsd: "1410.09",
|
||||
transitTimeDaysMin: 3,
|
||||
transitTimeDaysMax: 5,
|
||||
carrierName: "Roadrunner",
|
||||
},
|
||||
{
|
||||
fulfillmentCategory: "GUARANTEED_HUBLESS",
|
||||
rateUsd: "2091.00",
|
||||
transitTimeDaysMin: 4,
|
||||
transitTimeDaysMax: 4,
|
||||
transitSupportedOnWeekendsAndHolidays: true,
|
||||
carrierName: "Flock Freight Estimate",
|
||||
},
|
||||
{
|
||||
fulfillmentCategory: "GUARANTEED_HUBLESS",
|
||||
rateUsd: "1970.00",
|
||||
transitTimeDaysMin: 4,
|
||||
transitTimeDaysMax: 4,
|
||||
transitSupportedOnWeekendsAndHolidays: true,
|
||||
carrierName: "Flock Freight Estimate",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(reference).toBe("FFW-TEST");
|
||||
expect(quotes).toHaveLength(2);
|
||||
const direct = quotes.find((q) => q.tier === "flock_direct");
|
||||
const standard = quotes.find((q) => q.tier === "standard");
|
||||
expect(direct?.totalUsd).toBe(1970);
|
||||
expect(direct?.label).toBe("FlockDirect®");
|
||||
expect(standard?.totalUsd).toBe(1410.09);
|
||||
expect(standard?.transitDays).toBe("3-5");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildFlockAliasedEmail,
|
||||
createFreshFlockTestAccount,
|
||||
createRandomQqEmail,
|
||||
createVerifiedQqAliasedEmail,
|
||||
parseFlockEnvPool,
|
||||
} from "@/lib/flock/fresh-account";
|
||||
|
||||
describe("parseFlockEnvPool", () => {
|
||||
it("解析逗号与换行分隔", () => {
|
||||
expect(parseFlockEnvPool("a@b.com, c@d.com\ne@f.com")).toEqual([
|
||||
"a@b.com",
|
||||
"c@d.com",
|
||||
"e@f.com",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRandomQqEmail", () => {
|
||||
it("1/2/3 开头 + 9 位数字 @qq.com", () => {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
const email = createRandomQqEmail();
|
||||
expect(email).toMatch(/^[123]\d{9}@qq\.com$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createVerifiedQqAliasedEmail", () => {
|
||||
it("已验证 QQ + 短别名", () => {
|
||||
const email = createVerifiedQqAliasedEmail();
|
||||
expect(email).toMatch(/^3457189488\+[a-f0-9]{6}@qq\.com$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFlockAliasedEmail", () => {
|
||||
it("Gmail 使用 + 子地址", () => {
|
||||
const email = buildFlockAliasedEmail("user@gmail.com", "abc123");
|
||||
expect(email).toMatch(/^user\+[a-z0-9]+@gmail\.com$/);
|
||||
});
|
||||
|
||||
it("QQ 基址使用短 + 别名", () => {
|
||||
const email = buildFlockAliasedEmail("3457189488@qq.com", "x1y2z3");
|
||||
expect(email).toMatch(/^3457189488\+[a-z0-9]+@qq\.com$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFreshFlockTestAccount", () => {
|
||||
it("默认使用已验证 QQ 短别名(非虚构号码)", () => {
|
||||
const prev = {
|
||||
pool: process.env.FLOCK_EMAIL_POOL,
|
||||
alias: process.env.FLOCK_EMAIL_ALIAS_BASE,
|
||||
test: process.env.FLOCK_TEST_EMAIL,
|
||||
mode: process.env.FLOCK_EMAIL_MODE,
|
||||
};
|
||||
delete process.env.FLOCK_EMAIL_POOL;
|
||||
delete process.env.FLOCK_EMAIL_ALIAS_BASE;
|
||||
delete process.env.FLOCK_TEST_EMAIL;
|
||||
delete process.env.FLOCK_EMAIL_MODE;
|
||||
|
||||
const a = createFreshFlockTestAccount();
|
||||
expect(a.email).toMatch(/^3457189488\+[a-z0-9]+@qq\.com$/);
|
||||
|
||||
process.env.FLOCK_EMAIL_POOL = prev.pool;
|
||||
process.env.FLOCK_EMAIL_ALIAS_BASE = prev.alias;
|
||||
process.env.FLOCK_TEST_EMAIL = prev.test;
|
||||
process.env.FLOCK_EMAIL_MODE = prev.mode;
|
||||
});
|
||||
|
||||
it("使用邮箱池时不重复编造域名", () => {
|
||||
const prev = process.env.FLOCK_EMAIL_POOL;
|
||||
process.env.FLOCK_EMAIL_POOL = "one@gmail.com,two@outlook.com";
|
||||
const a = createFreshFlockTestAccount();
|
||||
expect(["one@gmail.com", "two@outlook.com"]).toContain(a.email);
|
||||
process.env.FLOCK_EMAIL_POOL = prev;
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
analyzeFlockHarEntries,
|
||||
scoreFlockQuoteCandidate,
|
||||
verdictFromCandidates,
|
||||
type FlockHarEntry,
|
||||
} from "@/lib/flock/har-analyze";
|
||||
|
||||
describe("flock har-analyze", () => {
|
||||
it("高分匹配类似 axel 的 quote JSON", () => {
|
||||
const score = scoreFlockQuoteCandidate({
|
||||
url: "https://api.flockfreight.com/v1/quote",
|
||||
method: "POST",
|
||||
status: 200,
|
||||
mimeType: "application/json",
|
||||
responseText: JSON.stringify({
|
||||
rates: [
|
||||
{ label: "FlockDirect", price: 2300 },
|
||||
{ label: "Standard", price: 800 },
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(score).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
|
||||
it("过滤静态与埋点", () => {
|
||||
expect(
|
||||
scoreFlockQuoteCandidate({
|
||||
url: "https://www.googletagmanager.com/gtm.js",
|
||||
method: "GET",
|
||||
status: 200,
|
||||
mimeType: "application/javascript",
|
||||
responseText: "gtm",
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("从 HAR entries 选出候选并裁决", () => {
|
||||
const entries: FlockHarEntry[] = [
|
||||
{
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://app.flockfreight.com/api/quotes",
|
||||
},
|
||||
response: {
|
||||
status: 200,
|
||||
content: {
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify({
|
||||
flockDirect: { total: 1999 },
|
||||
standard: { total: 777 },
|
||||
reference: "FRG-1",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
request: {
|
||||
method: "GET",
|
||||
url: "https://cdn.example.com/app.js",
|
||||
},
|
||||
response: { status: 200, content: { mimeType: "application/javascript" } },
|
||||
},
|
||||
];
|
||||
const cands = analyzeFlockHarEntries(entries);
|
||||
expect(cands.length).toBeGreaterThanOrEqual(1);
|
||||
expect(verdictFromCandidates(cands)).toBe("stable_json_quote_api_found");
|
||||
});
|
||||
|
||||
it("无候选时裁决为 dom_only", () => {
|
||||
expect(verdictFromCandidates([])).toBe("no_quote_json_api_dom_only");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
evaluateFlockOptionCompat,
|
||||
flockOptionHasBlock,
|
||||
isFlockReeferOnlyWithoutTemp,
|
||||
isFlockSprinterOnly,
|
||||
isFlockTempWithoutReefer,
|
||||
} from "@/lib/flock/option-compat";
|
||||
|
||||
describe("Flock 车型×温控", () => {
|
||||
it("仅冷藏无温控 → block", () => {
|
||||
expect(
|
||||
isFlockReeferOnlyWithoutTemp({
|
||||
vehicleTypes: ["refrigerated"],
|
||||
additionalServices: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
const issues = evaluateFlockOptionCompat({
|
||||
pickupLocationType: "business_with_dock",
|
||||
deliveryLocationType: "business_with_dock",
|
||||
packagingTypes: ["pallets_48x40"],
|
||||
vehicleTypes: ["refrigerated"],
|
||||
additionalServices: [],
|
||||
});
|
||||
expect(flockOptionHasBlock(issues)).toBe(true);
|
||||
});
|
||||
|
||||
it("温控无冷藏车 → block", () => {
|
||||
expect(
|
||||
isFlockTempWithoutReefer({
|
||||
vehicleTypes: ["box_truck"],
|
||||
additionalServices: ["temperature_control"],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("冷藏+温控 → 通过", () => {
|
||||
expect(
|
||||
isFlockReeferOnlyWithoutTemp({
|
||||
vehicleTypes: ["refrigerated"],
|
||||
additionalServices: ["temperature_control"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("仅 sprinter → warn", () => {
|
||||
expect(isFlockSprinterOnly(["sprinter"])).toBe(true);
|
||||
const issues = evaluateFlockOptionCompat({
|
||||
pickupLocationType: "business_with_dock",
|
||||
deliveryLocationType: "business_with_dock",
|
||||
packagingTypes: ["boxes"],
|
||||
vehicleTypes: ["sprinter"],
|
||||
additionalServices: [],
|
||||
});
|
||||
expect(issues.some((i) => i.code === "flock_sprinter_only")).toBe(true);
|
||||
});
|
||||
|
||||
it("施工工地 / units 包装 → warn", () => {
|
||||
const issues = evaluateFlockOptionCompat({
|
||||
pickupLocationType: "business_with_dock",
|
||||
deliveryLocationType: "limited_construction",
|
||||
packagingTypes: ["units"],
|
||||
vehicleTypes: ["box_truck", "dry_van", "refrigerated"],
|
||||
additionalServices: [],
|
||||
});
|
||||
expect(flockOptionHasBlock(issues)).toBe(false);
|
||||
expect(issues.some((i) => i.code.includes("construction"))).toBe(true);
|
||||
expect(issues.some((i) => i.code.includes("units"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
defaultFlockPickupDateIso,
|
||||
flockPickupDateValidationMessageFromDisplay,
|
||||
flockPickupDateValidationMessageFromIso,
|
||||
isFlockPickupDateOnOrAfterToday,
|
||||
minFlockPickupDateIso,
|
||||
} from "@/lib/flock/pickup-date";
|
||||
|
||||
describe("flock pickup date", () => {
|
||||
it("今天及以后合法", () => {
|
||||
const today = minFlockPickupDateIso();
|
||||
expect(isFlockPickupDateOnOrAfterToday(today)).toBe(true);
|
||||
expect(flockPickupDateValidationMessageFromIso(today)).toBeNull();
|
||||
expect(
|
||||
flockPickupDateValidationMessageFromIso(defaultFlockPickupDateIso()),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("早于今天拒绝", () => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 1);
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const iso = `${yyyy}-${mm}-${dd}`;
|
||||
expect(isFlockPickupDateOnOrAfterToday(iso)).toBe(false);
|
||||
expect(flockPickupDateValidationMessageFromIso(iso)).toMatch(/不能早于今天/);
|
||||
expect(
|
||||
flockPickupDateValidationMessageFromDisplay(`${mm}/${dd}/${yyyy}`),
|
||||
).toMatch(/不能早于今天/);
|
||||
});
|
||||
|
||||
it("周六周日拒绝", () => {
|
||||
expect(flockPickupDateValidationMessageFromIso("2026-07-18")).toMatch(
|
||||
/周六或周日/,
|
||||
);
|
||||
expect(flockPickupDateValidationMessageFromDisplay("07/19/2026")).toMatch(
|
||||
/周六或周日/,
|
||||
);
|
||||
expect(flockPickupDateValidationMessageFromIso("2026-07-20")).toBeNull();
|
||||
});
|
||||
|
||||
it("默认值为工作日", () => {
|
||||
expect(flockPickupDateValidationMessageFromIso(defaultFlockPickupDateIso())).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { qqDnaEmailPool } from "@/lib/flock/qqdna-email-pool";
|
||||
import { createFreshFlockTestAccount } from "@/lib/flock/fresh-account";
|
||||
|
||||
describe("qqDnaEmailPool", () => {
|
||||
it("全部为合法长度 QQ@qq.com", () => {
|
||||
const pool = qqDnaEmailPool();
|
||||
expect(pool.length).toBeGreaterThan(50);
|
||||
for (const email of pool) {
|
||||
expect(email).toMatch(/^\d{5,11}@qq\.com$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("FLOCK_EMAIL_MODE=qqdna", () => {
|
||||
it("轮询取 qqdna 池", () => {
|
||||
const prev = process.env.FLOCK_EMAIL_MODE;
|
||||
process.env.FLOCK_EMAIL_MODE = "qqdna";
|
||||
delete process.env.FLOCK_EMAIL_POOL;
|
||||
const a = createFreshFlockTestAccount();
|
||||
const b = createFreshFlockTestAccount();
|
||||
expect(a.email).toMatch(/^\d{5,11}@qq\.com$/);
|
||||
expect(b.email).toMatch(/^\d{5,11}@qq\.com$/);
|
||||
expect(a.email).not.toBe(b.email);
|
||||
process.env.FLOCK_EMAIL_MODE = prev;
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasFlockRegistrationOverride,
|
||||
resolveFlockQuoteAccount,
|
||||
} from "@/lib/flock/resolve-account";
|
||||
import type { FlockQuoteInput } from "@/workers/rpa/flock/types";
|
||||
|
||||
function baseInput(
|
||||
registration?: FlockQuoteInput["registration"],
|
||||
): FlockQuoteInput {
|
||||
return {
|
||||
pickupDate: "07/14/2026",
|
||||
pickupZip: "90001",
|
||||
deliveryZip: "75201",
|
||||
palletCount: 6,
|
||||
totalWeightLb: 3500,
|
||||
lengthIn: 48,
|
||||
widthIn: 40,
|
||||
heightIn: 48,
|
||||
registration,
|
||||
};
|
||||
}
|
||||
|
||||
describe("hasFlockRegistrationOverride", () => {
|
||||
it("空对象视为未填写", () => {
|
||||
expect(hasFlockRegistrationOverride({})).toBe(false);
|
||||
expect(hasFlockRegistrationOverride(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("任一字段有值即为自填", () => {
|
||||
expect(hasFlockRegistrationOverride({ email: "a@b.com" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFlockQuoteAccount", () => {
|
||||
it("用户邮箱覆盖系统随机", () => {
|
||||
const account = resolveFlockQuoteAccount(
|
||||
baseInput({ email: "user@example.com", phone: "7536407420" }),
|
||||
);
|
||||
expect(account.email).toBe("user@example.com");
|
||||
expect(account.phone).toBe("7536407420");
|
||||
expect(account.firstName.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildFlockSavedFreightPreset,
|
||||
filterFlockSavedFreight,
|
||||
flockSavedFreightStorageKey,
|
||||
parseFlockSavedFreightList,
|
||||
removeFlockSavedFreight,
|
||||
upsertFlockSavedFreight,
|
||||
type FlockSavedFreightPreset,
|
||||
} from "@/lib/flock/saved-freight-presets";
|
||||
|
||||
const baseSource = {
|
||||
description: "家电木托",
|
||||
quantity: "4",
|
||||
packagingType: "pallets_48x40",
|
||||
lengthIn: "48",
|
||||
widthIn: "40",
|
||||
heightIn: "48",
|
||||
totalWeightLb: "1200",
|
||||
freightClass: "70",
|
||||
stackable: true,
|
||||
turnable: false,
|
||||
};
|
||||
|
||||
function preset(partial: Partial<FlockSavedFreightPreset>): FlockSavedFreightPreset {
|
||||
return {
|
||||
id: "id-1",
|
||||
name: "家电",
|
||||
description: "家电木托",
|
||||
quantity: "4",
|
||||
packagingType: "pallets_48x40",
|
||||
lengthIn: "48",
|
||||
widthIn: "40",
|
||||
heightIn: "48",
|
||||
totalWeightLb: "1200",
|
||||
freightClass: "70",
|
||||
stackable: true,
|
||||
turnable: false,
|
||||
updatedAt: "2026-07-15T00:00:00.000Z",
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("saved-freight-presets", () => {
|
||||
it("storage key 按客户隔离", () => {
|
||||
expect(flockSavedFreightStorageKey("CUST_001")).toContain("CUST_001");
|
||||
expect(flockSavedFreightStorageKey(" ")).toContain("anon");
|
||||
});
|
||||
|
||||
it("build 用描述作默认名称", () => {
|
||||
const p = buildFlockSavedFreightPreset(baseSource, "");
|
||||
expect(p?.name).toBe("家电木托");
|
||||
expect(p?.description).toBe("家电木托");
|
||||
});
|
||||
|
||||
it("空描述且空名称拒绝", () => {
|
||||
expect(
|
||||
buildFlockSavedFreightPreset({ ...baseSource, description: "" }, " "),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("同名覆盖且新条目置顶", () => {
|
||||
const a = preset({ id: "a", name: "木托A" });
|
||||
const b = preset({ id: "b", name: "木托B" });
|
||||
const next = upsertFlockSavedFreight(
|
||||
[a, b],
|
||||
preset({ id: "c", name: "木托A", description: "新描述" }),
|
||||
);
|
||||
expect(next).toHaveLength(2);
|
||||
expect(next[0]?.description).toBe("新描述");
|
||||
expect(next[0]?.name).toBe("木托A");
|
||||
});
|
||||
|
||||
it("按名称或描述过滤", () => {
|
||||
const list = [
|
||||
preset({ id: "1", name: "家电托", description: "电视" }),
|
||||
preset({ id: "2", name: "建材", description: "瓷砖" }),
|
||||
];
|
||||
expect(filterFlockSavedFreight(list, "瓷")).toHaveLength(1);
|
||||
expect(filterFlockSavedFreight(list, "家电")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("删除与解析容错", () => {
|
||||
const list = [preset({ id: "x" }), preset({ id: "y", name: "Y" })];
|
||||
expect(removeFlockSavedFreight(list, "x")).toHaveLength(1);
|
||||
expect(parseFlockSavedFreightList("not-json")).toEqual([]);
|
||||
expect(parseFlockSavedFreightList('[{"id":"1","name":"n","description":"d"}]')).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,205 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildQuoteRequestBodyFromLoggedIn,
|
||||
resolveLoggedInPalletCount,
|
||||
} from "@/lib/frontend/mothership-logged-in-quote-body";
|
||||
import type { MothershipLoggedInShipmentPayload } from "@/components/mothership/mothership-logged-in-shipment-form";
|
||||
import type { MothershipAddressCandidate } from "@/lib/frontend/types";
|
||||
|
||||
const pickup: MothershipAddressCandidate = {
|
||||
option_id: "p1",
|
||||
display_label: "A, Fairhope, AL",
|
||||
formatted_address: "A, Fairhope, AL",
|
||||
street: "A",
|
||||
city: "Fairhope",
|
||||
state: "AL",
|
||||
zip: "36532",
|
||||
};
|
||||
|
||||
const delivery: MothershipAddressCandidate = {
|
||||
option_id: "d1",
|
||||
display_label: "B, Brea, CA",
|
||||
formatted_address: "B, Brea, CA",
|
||||
street: "B",
|
||||
city: "Brea",
|
||||
state: "CA",
|
||||
zip: "92821",
|
||||
};
|
||||
|
||||
function payload(
|
||||
cargo: MothershipLoggedInShipmentPayload["cargo"],
|
||||
): MothershipLoggedInShipmentPayload {
|
||||
return {
|
||||
pickupQuery: pickup.display_label,
|
||||
deliveryQuery: delivery.display_label,
|
||||
pickupConfirmed: pickup,
|
||||
deliveryConfirmed: delivery,
|
||||
pickupAccessorials: [],
|
||||
deliveryAccessorials: [],
|
||||
readyDate: "2026-07-16",
|
||||
readyTime: "12:00 PM",
|
||||
timezone: "GMT+8",
|
||||
cargo,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mothership-logged-in-quote-body", () => {
|
||||
it("多行托盘 quantity 合计", () => {
|
||||
expect(
|
||||
resolveLoggedInPalletCount(
|
||||
payload([
|
||||
{
|
||||
cargoType: "pallet",
|
||||
quantity: 2,
|
||||
weightLb: 250,
|
||||
lengthIn: 48,
|
||||
widthIn: 48,
|
||||
heightIn: 48,
|
||||
},
|
||||
{
|
||||
cargoType: "pallet",
|
||||
quantity: 3,
|
||||
weightLb: 100,
|
||||
lengthIn: 40,
|
||||
widthIn: 40,
|
||||
heightIn: 40,
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toBe(5);
|
||||
});
|
||||
|
||||
it("询价体重取首行单件重量", () => {
|
||||
const body = buildQuoteRequestBodyFromLoggedIn(
|
||||
payload([
|
||||
{
|
||||
cargoType: "pallet",
|
||||
quantity: 2,
|
||||
weightLb: 250,
|
||||
lengthIn: 48,
|
||||
widthIn: 48,
|
||||
heightIn: 48,
|
||||
},
|
||||
]),
|
||||
"CUST_001",
|
||||
);
|
||||
expect(body.weight.value).toBe(250);
|
||||
expect(body.pallet_count).toBe(2);
|
||||
});
|
||||
|
||||
it("透传附加服务与可提货时间", () => {
|
||||
const base = payload([
|
||||
{
|
||||
cargoType: "pallet",
|
||||
quantity: 1,
|
||||
weightLb: 100,
|
||||
lengthIn: 48,
|
||||
widthIn: 40,
|
||||
heightIn: 48,
|
||||
},
|
||||
]);
|
||||
const body = buildQuoteRequestBodyFromLoggedIn(
|
||||
{
|
||||
...base,
|
||||
pickupAccessorials: ["liftgate", "residential"],
|
||||
deliveryAccessorials: ["appointment"],
|
||||
readyDate: "2026-07-16",
|
||||
readyTime: "3:00 PM",
|
||||
},
|
||||
"CUST_001",
|
||||
);
|
||||
expect(body.pickup_accessorials).toEqual(["liftgate", "residential"]);
|
||||
expect(body.delivery_accessorials).toEqual(["appointment"]);
|
||||
expect(body.ready_date).toBe("2026-07-16");
|
||||
expect(body.ready_time).toBe("3:00 PM");
|
||||
});
|
||||
|
||||
it("透传多行 cargo_lines", () => {
|
||||
const body = buildQuoteRequestBodyFromLoggedIn(
|
||||
payload([
|
||||
{
|
||||
cargoType: "pallet",
|
||||
quantity: 2,
|
||||
weightLb: 250,
|
||||
lengthIn: 48,
|
||||
widthIn: 40,
|
||||
heightIn: 48,
|
||||
},
|
||||
{
|
||||
cargoType: "box",
|
||||
quantity: 2,
|
||||
weightLb: 250,
|
||||
lengthIn: 50,
|
||||
widthIn: 40,
|
||||
heightIn: 50,
|
||||
},
|
||||
]),
|
||||
"CUST_001",
|
||||
);
|
||||
expect(body.cargo_lines).toHaveLength(2);
|
||||
expect(body.cargo_lines?.[1]?.cargo_type).toBe("box");
|
||||
expect(body.pallet_count).toBe(4);
|
||||
});
|
||||
|
||||
it("透传二级 details 字段", () => {
|
||||
const body = buildQuoteRequestBodyFromLoggedIn(
|
||||
payload([
|
||||
{
|
||||
cargoType: "pallet",
|
||||
quantity: 1,
|
||||
weightLb: 100,
|
||||
lengthIn: 48,
|
||||
widthIn: 40,
|
||||
heightIn: 48,
|
||||
},
|
||||
]),
|
||||
"CUST_001",
|
||||
{
|
||||
pickup: {
|
||||
companyName: "Pickup Co",
|
||||
suite: "Ste 100",
|
||||
contactFirst: "A",
|
||||
contactLast: "B",
|
||||
contactEmail: "pickup@example.com",
|
||||
contactPhone: "5551112222",
|
||||
reference: "REF-1",
|
||||
notes: "note",
|
||||
opensAt: "8:00 AM",
|
||||
closesAt: "5:00 PM",
|
||||
},
|
||||
delivery: {
|
||||
companyName: "Delivery Co",
|
||||
suite: "",
|
||||
contactFirst: "C",
|
||||
contactLast: "D",
|
||||
contactEmail: "delivery@example.com",
|
||||
contactPhone: "5553334444",
|
||||
reference: "",
|
||||
notes: "",
|
||||
opensAt: "9:00 AM",
|
||||
closesAt: "6:00 PM",
|
||||
},
|
||||
requestDeliveryAppointment: true,
|
||||
fbaNumber: "FBA-123",
|
||||
fbaPoNumber: "PO-456",
|
||||
cargo: [
|
||||
{
|
||||
pieceCountType: "Pieces",
|
||||
pieceCountQty: 10,
|
||||
description: "General freight",
|
||||
nmfc: "",
|
||||
hazmat: false,
|
||||
alcohol: false,
|
||||
tobacco: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(body.mothership_details?.pickup.company_name).toBe("Pickup Co");
|
||||
expect(body.mothership_details?.delivery.contact_email).toBe(
|
||||
"delivery@example.com",
|
||||
);
|
||||
expect(body.mothership_details?.request_delivery_appointment).toBe(true);
|
||||
expect(body.mothership_details?.cargo?.[0]?.piece_count_type).toBe("Pieces");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isMothershipWeekendIso,
|
||||
isMothershipWeightEachAllowed,
|
||||
normalizeMothershipReadyDateIso,
|
||||
snapMothershipReadyDateToWeekday,
|
||||
} from "@/lib/mothership/logged-in-constraints";
|
||||
|
||||
describe("mothership logged-in constraints", () => {
|
||||
it("识别周末", () => {
|
||||
expect(isMothershipWeekendIso("2026-07-18")).toBe(true); // Sat
|
||||
expect(isMothershipWeekendIso("2026-07-19")).toBe(true); // Sun
|
||||
expect(isMothershipWeekendIso("2026-07-20")).toBe(false); // Mon
|
||||
});
|
||||
|
||||
it("周末拨到周一", () => {
|
||||
expect(snapMothershipReadyDateToWeekday("2026-07-18")).toBe("2026-07-20");
|
||||
expect(snapMothershipReadyDateToWeekday("2026-07-19")).toBe("2026-07-20");
|
||||
expect(snapMothershipReadyDateToWeekday("2026-07-20")).toBe("2026-07-20");
|
||||
});
|
||||
|
||||
it("单件重量 ≤5000", () => {
|
||||
expect(isMothershipWeightEachAllowed(5000)).toBe(true);
|
||||
expect(isMothershipWeightEachAllowed(5000.01)).toBe(false);
|
||||
expect(isMothershipWeightEachAllowed(50)).toBe(true);
|
||||
});
|
||||
|
||||
it("normalize 周末日期", () => {
|
||||
expect(normalizeMothershipReadyDateIso("2026-07-19")).toBe("2026-07-20");
|
||||
expect(normalizeMothershipReadyDateIso("2026-07-20")).toBe("2026-07-20");
|
||||
expect(normalizeMothershipReadyDateIso(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
evaluateMothershipAccessorialCompat,
|
||||
mothershipAccessorialHasBlock,
|
||||
toggleMothershipAccessorial,
|
||||
} from "@/lib/mothership/option-compat";
|
||||
|
||||
describe("toggleMothershipAccessorial", () => {
|
||||
it("禁止提货勾选住宅", () => {
|
||||
const r = toggleMothershipAccessorial({
|
||||
side: "pickup",
|
||||
selected: ["liftgate"],
|
||||
id: "residential",
|
||||
});
|
||||
expect(r.applied).toBe(false);
|
||||
expect(r.next).toEqual(["liftgate"]);
|
||||
expect(r.message).toMatch(/不支持住宅/);
|
||||
});
|
||||
|
||||
it("预约与 Amazon 预约互斥:勾选后者去掉前者", () => {
|
||||
const r = toggleMothershipAccessorial({
|
||||
side: "delivery",
|
||||
selected: ["appointment"],
|
||||
id: "fbaAppointment",
|
||||
});
|
||||
expect(r.applied).toBe(true);
|
||||
expect(r.next).toContain("fbaAppointment");
|
||||
expect(r.next).not.toContain("appointment");
|
||||
expect(r.message).toMatch(/不能同时/);
|
||||
});
|
||||
|
||||
it("住宅与展会互斥", () => {
|
||||
const r = toggleMothershipAccessorial({
|
||||
side: "delivery",
|
||||
selected: ["residential"],
|
||||
id: "tradeshow",
|
||||
});
|
||||
expect(r.next).toEqual(["tradeshow"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateMothershipAccessorialCompat", () => {
|
||||
it("提货已含住宅 → block", () => {
|
||||
const issues = evaluateMothershipAccessorialCompat({
|
||||
side: "pickup",
|
||||
selected: ["residential"],
|
||||
});
|
||||
expect(mothershipAccessorialHasBlock(issues)).toBe(true);
|
||||
});
|
||||
|
||||
it("住宅派送无尾板 → warn", () => {
|
||||
const issues = evaluateMothershipAccessorialCompat({
|
||||
side: "delivery",
|
||||
selected: ["residential"],
|
||||
});
|
||||
expect(issues.some((i) => i.code === "ms_residential_need_liftgate")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildMothershipWeekdayGrid,
|
||||
isMothershipDateSelectable,
|
||||
} from "@/lib/mothership/weekday-date-picker";
|
||||
|
||||
describe("weekday-date-picker", () => {
|
||||
it("周六日不可选", () => {
|
||||
expect(isMothershipDateSelectable("2026-07-18", "2026-07-01")).toBe(false);
|
||||
expect(isMothershipDateSelectable("2026-07-19", "2026-07-01")).toBe(false);
|
||||
expect(isMothershipDateSelectable("2026-07-20", "2026-07-01")).toBe(true);
|
||||
});
|
||||
|
||||
it("网格周末格子 disabled", () => {
|
||||
const cells = buildMothershipWeekdayGrid(2026, 6, "2026-07-20", "2026-07-01");
|
||||
const sat = cells.find((c) => c.inMonth && c.iso === "2026-07-18");
|
||||
const sun = cells.find((c) => c.inMonth && c.iso === "2026-07-19");
|
||||
const mon = cells.find((c) => c.inMonth && c.iso === "2026-07-20");
|
||||
expect(sat?.disabled).toBe(true);
|
||||
expect(sat?.isWeekend).toBe(true);
|
||||
expect(sun?.disabled).toBe(true);
|
||||
expect(mon?.disabled).toBe(false);
|
||||
expect(mon?.isSelected).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
priority1CustomerDisplayUsd,
|
||||
priority1HasMarkup,
|
||||
} from "@/lib/priority1/display-price";
|
||||
import type { Priority1DisplayLine } from "@/modules/priority1/quote-storage";
|
||||
|
||||
function line(partial: Partial<Priority1DisplayLine>): Priority1DisplayLine {
|
||||
return {
|
||||
rank: 1,
|
||||
carrier: "Test",
|
||||
carrierCode: "TST",
|
||||
totalUsd: 100,
|
||||
transitDays: 3,
|
||||
deliveryDate: null,
|
||||
expirationDate: null,
|
||||
serviceLevel: "Standard",
|
||||
quoteId: null,
|
||||
caboUrl: null,
|
||||
source: "visible-dom",
|
||||
markup_amount: 0,
|
||||
final_total_usd: 100,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("priority1 display price", () => {
|
||||
it("shows final_total_usd when markup applied", () => {
|
||||
const row = line({ totalUsd: 428.5, markup_amount: 42.85, final_total_usd: 471.35 });
|
||||
expect(priority1CustomerDisplayUsd(row)).toBe(471.35);
|
||||
expect(priority1HasMarkup(row)).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to totalUsd when final is zero", () => {
|
||||
const row = line({ totalUsd: 200, markup_amount: 0, final_total_usd: 0 });
|
||||
expect(priority1CustomerDisplayUsd(row)).toBe(200);
|
||||
expect(priority1HasMarkup(row)).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EMPTY_PRIORITY1_SIMULATOR_FORM } from "@/lib/priority1/empty-simulator-form";
|
||||
|
||||
describe("EMPTY_PRIORITY1_SIMULATOR_FORM", () => {
|
||||
it("does not prefill zip, date, email, phone or cargo fields", () => {
|
||||
expect(EMPTY_PRIORITY1_SIMULATOR_FORM.originZip).toBe("");
|
||||
expect(EMPTY_PRIORITY1_SIMULATOR_FORM.destinationZip).toBe("");
|
||||
expect(EMPTY_PRIORITY1_SIMULATOR_FORM.pickupDate).toBe("");
|
||||
expect(EMPTY_PRIORITY1_SIMULATOR_FORM.email).toBe("");
|
||||
expect(EMPTY_PRIORITY1_SIMULATOR_FORM.phone).toBe("");
|
||||
expect(EMPTY_PRIORITY1_SIMULATOR_FORM.weightLb).toBe("");
|
||||
expect(EMPTY_PRIORITY1_SIMULATOR_FORM.commodity).toBe("");
|
||||
expect(EMPTY_PRIORITY1_SIMULATOR_FORM.shipmentFrequency).toBe("");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RpaError } from "@/modules/rpa/errors";
|
||||
import {
|
||||
isDirectErrorFatalWithoutWidget,
|
||||
shouldFallbackToWidgetAfterDirectError,
|
||||
} from "@/lib/rpa/direct-quote-fallback";
|
||||
|
||||
describe("direct-quote-fallback", () => {
|
||||
it("CARRIER_NO_CAPACITY 且 Widget fallback 开启时应回退浏览器", () => {
|
||||
vi.stubEnv("RPA_DISABLE_WIDGET_QUOTE_FALLBACK", "");
|
||||
const err = new RpaError(
|
||||
"CARRIER_NO_CAPACITY",
|
||||
"MotherShip 该线路暂无可用报价,请调整地址或货物后重试",
|
||||
{ retryable: false },
|
||||
);
|
||||
expect(shouldFallbackToWidgetAfterDirectError(err)).toBe(true);
|
||||
expect(isDirectErrorFatalWithoutWidget(err)).toBe(false);
|
||||
});
|
||||
|
||||
it("RPA_DISABLE_WIDGET_QUOTE_FALLBACK=true 时 direct 无运力仍 fatal", () => {
|
||||
vi.stubEnv("RPA_DISABLE_WIDGET_QUOTE_FALLBACK", "true");
|
||||
const err = new RpaError("CARRIER_NO_CAPACITY", "无运力", {
|
||||
retryable: false,
|
||||
});
|
||||
expect(shouldFallbackToWidgetAfterDirectError(err)).toBe(false);
|
||||
expect(isDirectErrorFatalWithoutWidget(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("STRUCT_CHANGE 仍禁止 Widget 回退", () => {
|
||||
vi.stubEnv("RPA_DISABLE_WIDGET_QUOTE_FALLBACK", "");
|
||||
const err = new RpaError("STRUCT_CHANGE", "结构变更", { retryable: false });
|
||||
expect(isDirectErrorFatalWithoutWidget(err)).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,35 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getEffectiveMothershipLogin,
|
||||
getMothershipLoginSource,
|
||||
hasEffectiveMothershipLogin,
|
||||
runWithMothershipLoginContext,
|
||||
} from "@/lib/rpa/mothership-login-context";
|
||||
|
||||
describe("mothership login context", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.MOTHERSHIP_EMAIL;
|
||||
delete process.env.MOTHERSHIP_PASSWORD;
|
||||
});
|
||||
|
||||
it("客户凭据优先于环境变量", async () => {
|
||||
process.env.MOTHERSHIP_EMAIL = "env@example.com";
|
||||
process.env.MOTHERSHIP_PASSWORD = "env-pass";
|
||||
await runWithMothershipLoginContext(
|
||||
{ email: "cust@example.com", password: "cust-pass" },
|
||||
"customer",
|
||||
async () => {
|
||||
expect(hasEffectiveMothershipLogin()).toBe(true);
|
||||
expect(getMothershipLoginSource()).toBe("customer");
|
||||
expect(getEffectiveMothershipLogin()?.email).toBe("cust@example.com");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("无 ALS 时回退 env", () => {
|
||||
process.env.MOTHERSHIP_EMAIL = "env@example.com";
|
||||
process.env.MOTHERSHIP_PASSWORD = "env-pass";
|
||||
expect(hasEffectiveMothershipLogin()).toBe(true);
|
||||
expect(getMothershipLoginSource()).toBe("env");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatExternalSiteNavigationError,
|
||||
isTransientNavigationError,
|
||||
} from "@/lib/rpa/page-goto";
|
||||
|
||||
describe("page-goto resilience", () => {
|
||||
it("detects connection reset as transient", () => {
|
||||
expect(
|
||||
isTransientNavigationError(
|
||||
new Error(
|
||||
"page.goto: net::ERR_CONNECTION_RESET at https://www.priority1.com/",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("formats user-facing Priority1 navigation error", () => {
|
||||
const msg = formatExternalSiteNavigationError(
|
||||
"Priority1 官网",
|
||||
new Error("page.goto: net::ERR_CONNECTION_RESET"),
|
||||
);
|
||||
expect(msg).toContain("连接被重置");
|
||||
expect(msg).toContain("网络与代理");
|
||||
expect(msg).not.toMatch(/ERR_|page\.goto|RPA_PROXY/i);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,36 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getMasterLoginPassword,
|
||||
isMasterLoginPassword,
|
||||
} from "@/modules/auth/master-password";
|
||||
|
||||
describe("master-password", () => {
|
||||
const prev = process.env.MASTER_LOGIN_PASSWORD;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.MASTER_LOGIN_PASSWORD = "test-master-secret";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (prev === undefined) {
|
||||
delete process.env.MASTER_LOGIN_PASSWORD;
|
||||
} else {
|
||||
process.env.MASTER_LOGIN_PASSWORD = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it("读取环境变量", () => {
|
||||
expect(getMasterLoginPassword()).toBe("test-master-secret");
|
||||
});
|
||||
|
||||
it("匹配万能密码", () => {
|
||||
expect(isMasterLoginPassword("test-master-secret")).toBe(true);
|
||||
expect(isMasterLoginPassword("wrong")).toBe(false);
|
||||
});
|
||||
|
||||
it("未配置时禁用", () => {
|
||||
delete process.env.MASTER_LOGIN_PASSWORD;
|
||||
expect(getMasterLoginPassword()).toBeNull();
|
||||
expect(isMasterLoginPassword("anything")).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,80 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
decryptSecret,
|
||||
encryptSecret,
|
||||
} from "@/modules/customer/token-crypto";
|
||||
import {
|
||||
getFlockLoginSource,
|
||||
hasEffectiveFlockLogin,
|
||||
runWithFlockLoginContext,
|
||||
} from "@/lib/flock/login-context";
|
||||
import { serializeProviderCredentialAdminView } from "@/modules/customer/provider-credentials";
|
||||
|
||||
describe("encryptSecret / decryptSecret", () => {
|
||||
it("往返一致", () => {
|
||||
const plain = "daetrDG#%%^Ydad12";
|
||||
expect(decryptSecret(encryptSecret(plain))).toBe(plain);
|
||||
});
|
||||
|
||||
it("空/非法密文返回 null", () => {
|
||||
expect(decryptSecret(null)).toBeNull();
|
||||
expect(decryptSecret("")).toBeNull();
|
||||
expect(decryptSecret("@@@")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeProviderCredentialAdminView", () => {
|
||||
it("回传完整邮箱与明文密码", () => {
|
||||
const password = "Secret#1";
|
||||
const view = serializeProviderCredentialAdminView({
|
||||
provider: "flock",
|
||||
loginEmail: "ops@example.com",
|
||||
passwordCiphertext: encryptSecret(password),
|
||||
updatedAt: new Date("2026-07-15T00:00:00.000Z"),
|
||||
isDeleted: false,
|
||||
});
|
||||
expect(view.email).toBe("ops@example.com");
|
||||
expect(view.password).toBe(password);
|
||||
expect(view.has_password).toBe(true);
|
||||
});
|
||||
|
||||
it("已删除不回传账密", () => {
|
||||
const view = serializeProviderCredentialAdminView({
|
||||
provider: "mothership",
|
||||
loginEmail: "ops@example.com",
|
||||
passwordCiphertext: encryptSecret("x"),
|
||||
updatedAt: new Date(),
|
||||
isDeleted: true,
|
||||
});
|
||||
expect(view.email).toBeNull();
|
||||
expect(view.password).toBeNull();
|
||||
expect(view.has_password).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flock login context", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.FLOCK_LOGIN_EMAIL;
|
||||
delete process.env.FLOCK_LOGIN_PASSWORD;
|
||||
});
|
||||
|
||||
it("客户凭据优先于环境变量", async () => {
|
||||
process.env.FLOCK_LOGIN_EMAIL = "env@example.com";
|
||||
process.env.FLOCK_LOGIN_PASSWORD = "env-pass";
|
||||
await runWithFlockLoginContext(
|
||||
{ email: "cust@example.com", password: "cust-pass" },
|
||||
"customer",
|
||||
async () => {
|
||||
expect(hasEffectiveFlockLogin()).toBe(true);
|
||||
expect(getFlockLoginSource()).toBe("customer");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("无 ALS 时回退 env", async () => {
|
||||
process.env.FLOCK_LOGIN_EMAIL = "env@example.com";
|
||||
process.env.FLOCK_LOGIN_PASSWORD = "env-pass";
|
||||
expect(hasEffectiveFlockLogin()).toBe(true);
|
||||
expect(getFlockLoginSource()).toBe("env");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyMarkupToFlockLines,
|
||||
buildFlockStoredQuotes,
|
||||
parseFlockStoredQuotes,
|
||||
} from "@/modules/flock/quote-storage";
|
||||
import type { FlockQuoteLine } from "@/workers/rpa/flock/types";
|
||||
|
||||
const lines: FlockQuoteLine[] = [
|
||||
{
|
||||
tier: "flock_direct",
|
||||
serviceLevel: "guaranteed",
|
||||
rateOption: "fastest",
|
||||
carrier: "Flock Freight",
|
||||
label: "FlockDirect®",
|
||||
totalUsd: 1000,
|
||||
transitDays: "4",
|
||||
transitDescription: "4 days",
|
||||
},
|
||||
{
|
||||
tier: "standard",
|
||||
serviceLevel: "standard",
|
||||
rateOption: "lowest",
|
||||
carrier: "Flock Freight",
|
||||
label: "Standard",
|
||||
totalUsd: 500,
|
||||
transitDays: "3-4",
|
||||
transitDescription: "3-4 days",
|
||||
},
|
||||
];
|
||||
|
||||
describe("flock quote storage", () => {
|
||||
it("按百分比加价", () => {
|
||||
const marked = applyMarkupToFlockLines(lines, {
|
||||
type: "percent",
|
||||
percent: 10,
|
||||
fixedAmount: 0,
|
||||
});
|
||||
expect(marked[0]!.final_total_usd).toBe(1100);
|
||||
expect(marked[1]!.final_total_usd).toBe(550);
|
||||
});
|
||||
|
||||
it("序列化与解析", () => {
|
||||
const stored = buildFlockStoredQuotes(
|
||||
lines,
|
||||
{ type: "percent", percent: 0, fixedAmount: 0 },
|
||||
"FRG-TEST",
|
||||
);
|
||||
expect(stored.provider).toBe("flock");
|
||||
expect(parseFlockStoredQuotes(stored)?.reference).toBe("FRG-TEST");
|
||||
expect(parseFlockStoredQuotes({ provider: "priority1", lines: [] })).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyMarkupToPriority1Lines } from "@/modules/priority1/quote-storage";
|
||||
import type { Priority1QuoteLine } from "@/workers/rpa/priority1/quote-extract";
|
||||
|
||||
const sampleLine: Priority1QuoteLine = {
|
||||
rank: 1,
|
||||
carrier: "XPO",
|
||||
carrierCode: "XPOL",
|
||||
totalUsd: 500,
|
||||
transitDays: 5,
|
||||
deliveryDate: null,
|
||||
expirationDate: null,
|
||||
serviceLevel: "Standard",
|
||||
quoteId: null,
|
||||
caboUrl: null,
|
||||
source: "visible-dom",
|
||||
};
|
||||
|
||||
describe("applyMarkupToPriority1Lines", () => {
|
||||
it("applies percent markup per customer rule", () => {
|
||||
const marked = applyMarkupToPriority1Lines([sampleLine], {
|
||||
type: "percent",
|
||||
percent: 10,
|
||||
fixedAmount: 0,
|
||||
});
|
||||
expect(marked[0]!.markup_amount).toBe(50);
|
||||
expect(marked[0]!.final_total_usd).toBe(550);
|
||||
});
|
||||
|
||||
it("applies fixed markup per line", () => {
|
||||
const marked = applyMarkupToPriority1Lines([sampleLine], {
|
||||
type: "fixed",
|
||||
percent: 0,
|
||||
fixedAmount: 25,
|
||||
});
|
||||
expect(marked[0]!.markup_amount).toBe(25);
|
||||
expect(marked[0]!.final_total_usd).toBe(525);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildFlockBatchCases,
|
||||
classifyFlockFailure,
|
||||
randomCargo,
|
||||
} from "@/scripts/batch-flock-stress";
|
||||
import {
|
||||
FLOCK_LIMITS,
|
||||
fitsFlockTrailerCargo,
|
||||
} from "@/lib/constants/flock-limits";
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
return () => {
|
||||
let t = (seed += 0x6d2b79f5);
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildFlockBatchCases", () => {
|
||||
it("生成样例且 ZIP/货物均在 Flock 合法范围(含线性英尺)", () => {
|
||||
const cases = buildFlockBatchCases(20, 42);
|
||||
expect(cases).toHaveLength(20);
|
||||
const zips = new Set([
|
||||
"02109",
|
||||
"94102",
|
||||
"60611",
|
||||
"78701",
|
||||
"21230",
|
||||
"19106",
|
||||
"15222",
|
||||
"30326",
|
||||
"90212",
|
||||
"90248",
|
||||
]);
|
||||
for (const c of cases) {
|
||||
expect(zips.has(c.pickupZip)).toBe(true);
|
||||
expect(zips.has(c.deliveryZip)).toBe(true);
|
||||
expect(c.pickupZip).not.toBe(c.deliveryZip);
|
||||
expect(c.palletCount).toBeGreaterThanOrEqual(FLOCK_LIMITS.palletCount.min);
|
||||
expect(c.palletCount).toBeLessThanOrEqual(FLOCK_LIMITS.palletCount.max);
|
||||
expect(c.totalWeightLb).toBeLessThanOrEqual(FLOCK_LIMITS.totalWeightLbMax);
|
||||
expect(c.lengthIn).toBeLessThanOrEqual(FLOCK_LIMITS.dimIn.lengthMax);
|
||||
expect(c.widthIn).toBeLessThanOrEqual(FLOCK_LIMITS.dimIn.widthMax);
|
||||
expect(c.heightIn).toBeLessThanOrEqual(FLOCK_LIMITS.dimIn.heightMax);
|
||||
expect(
|
||||
fitsFlockTrailerCargo({
|
||||
palletCount: c.palletCount,
|
||||
lengthIn: c.lengthIn,
|
||||
widthIn: c.widthIn,
|
||||
}),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("randomCargo", () => {
|
||||
it("不生成触发 Linear feet 超限的组合", () => {
|
||||
const rng = mulberry32(99);
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
const c = randomCargo(rng);
|
||||
expect(fitsFlockTrailerCargo(c)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyFlockFailure", () => {
|
||||
it("识别限流", () => {
|
||||
expect(classifyFlockFailure("Too many quote requests, try again later")).toBe(
|
||||
"rate_limit",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FLOCK_LOGGED_IN_X20_CASES } from "@/scripts/diag-flock-logged-in-x20";
|
||||
import { fitsFlockTrailerCargo } from "@/lib/constants/flock-limits";
|
||||
|
||||
describe("FLOCK_LOGGED_IN_X20_CASES", () => {
|
||||
it("恰好 20 组且 ZIP/货物合法", () => {
|
||||
expect(FLOCK_LOGGED_IN_X20_CASES).toHaveLength(20);
|
||||
const names = new Set<string>();
|
||||
for (const c of FLOCK_LOGGED_IN_X20_CASES) {
|
||||
expect(c.pickupZip).toMatch(/^\d{5}$/);
|
||||
expect(c.deliveryZip).toMatch(/^\d{5}$/);
|
||||
expect(c.pickupZip).not.toBe(c.deliveryZip);
|
||||
expect(c.palletCount).toBeGreaterThanOrEqual(1);
|
||||
expect(c.totalWeightLb).toBeLessThanOrEqual(45_000);
|
||||
expect(
|
||||
fitsFlockTrailerCargo({
|
||||
palletCount: c.palletCount,
|
||||
lengthIn: c.lengthIn,
|
||||
widthIn: c.widthIn,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(names.has(c.name)).toBe(false);
|
||||
names.add(c.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,118 @@
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_FLOCK_QUOTE_URL,
|
||||
formatFlockRecordingChecklist,
|
||||
} from "@/workers/rpa/flock/demo-input";
|
||||
import {
|
||||
formatFlockLoggedInRecordingChecklist,
|
||||
parseFlockRecordArgs,
|
||||
resolveFlockOutputBasename,
|
||||
resolveFlockStoragePath,
|
||||
} from "@/scripts/record-flock";
|
||||
|
||||
const SH_PATH = join(process.cwd(), "scripts", "record-flock.sh");
|
||||
const TS_PATH = join(process.cwd(), "scripts", "record-flock.ts");
|
||||
const VISUAL_PATH = join(process.cwd(), "scripts", "visual-flock-human-assist.ts");
|
||||
const SOP_PATH = join(process.cwd(), "docs", "rpa-flock-logged-in-sop.md");
|
||||
|
||||
describe("record-flock scripts", () => {
|
||||
it("record-flock.sh 存在且包含 codegen + logged-in", () => {
|
||||
expect(existsSync(SH_PATH)).toBe(true);
|
||||
const content = readFileSync(SH_PATH, "utf8");
|
||||
expect(content).toContain("codegen");
|
||||
expect(content).toContain("get-a-quote");
|
||||
expect(content).toContain("--logged-in");
|
||||
expect(content).toContain("flock-logged-in");
|
||||
});
|
||||
|
||||
it("record-flock.ts 存在且包含 headed 与 canonical 路径", () => {
|
||||
expect(existsSync(TS_PATH)).toBe(true);
|
||||
const content = readFileSync(TS_PATH, "utf8");
|
||||
expect(content).toContain("codegen");
|
||||
expect(content).toContain("FLOCK_CANONICAL_PATHS");
|
||||
expect(content).toContain("DEFAULT_FLOCK_QUOTE_URL");
|
||||
expect(content).toContain("--logged-in");
|
||||
expect(content).toContain("flock-logged-in-");
|
||||
expect(content).toContain("flock-logged-in-storage.json");
|
||||
});
|
||||
|
||||
it("登录后 SOP 存在", () => {
|
||||
expect(existsSync(SOP_PATH)).toBe(true);
|
||||
const content = readFileSync(SOP_PATH, "utf8");
|
||||
expect(content).toContain("flock-logged-in-");
|
||||
expect(content).toContain("Let's build a quote");
|
||||
});
|
||||
|
||||
it("visual-flock-human-assist 含分步 pause", () => {
|
||||
expect(existsSync(VISUAL_PATH)).toBe(true);
|
||||
const content = readFileSync(VISUAL_PATH, "utf8");
|
||||
expect(content).toContain("page.pause");
|
||||
expect(content).toContain("flock-human-");
|
||||
});
|
||||
|
||||
it("录制清单包含关键步骤", () => {
|
||||
const text = formatFlockRecordingChecklist({
|
||||
pickupDateDisplay: "07/14/2026",
|
||||
pickupZip: "90001",
|
||||
deliveryZip: "75201",
|
||||
palletCount: 6,
|
||||
totalWeightLb: 3500,
|
||||
lengthIn: 48,
|
||||
widthIn: 40,
|
||||
heightIn: 48,
|
||||
});
|
||||
expect(text).toContain("Next");
|
||||
expect(text).toContain("Create your free account");
|
||||
expect(text).toContain("Complete your order");
|
||||
});
|
||||
|
||||
it("登录后清单提示手动粘贴账密与 Generate my quote", () => {
|
||||
const text = formatFlockLoggedInRecordingChecklist();
|
||||
expect(text).toContain("FLOCK_LOGIN_EMAIL");
|
||||
expect(text).toContain("Generate my quote");
|
||||
expect(text).toContain("90001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFlockRecordArgs / storage 隔离", () => {
|
||||
it("默认匿名;--logged-in / --login 切换模式", () => {
|
||||
expect(parseFlockRecordArgs(["node", "record-flock.ts"]).mode).toBe(
|
||||
"anonymous",
|
||||
);
|
||||
expect(
|
||||
parseFlockRecordArgs(["node", "record-flock.ts", "--logged-in"]).mode,
|
||||
).toBe("logged-in");
|
||||
expect(
|
||||
parseFlockRecordArgs(["node", "record-flock.ts", "--login"]).mode,
|
||||
).toBe("logged-in");
|
||||
});
|
||||
|
||||
it("解析起始 URL 与 help / list", () => {
|
||||
const withUrl = parseFlockRecordArgs([
|
||||
"node",
|
||||
"x",
|
||||
"--logged-in",
|
||||
"https://app.flockfreight.com/login",
|
||||
]);
|
||||
expect(withUrl.mode).toBe("logged-in");
|
||||
expect(withUrl.startUrl).toBe("https://app.flockfreight.com/login");
|
||||
expect(parseFlockRecordArgs(["node", "x", "-h"]).help).toBe(true);
|
||||
expect(parseFlockRecordArgs(["node", "x", "list"]).list).toBe(true);
|
||||
});
|
||||
|
||||
it("登录态产物前缀与 storage 路径隔离", () => {
|
||||
expect(resolveFlockOutputBasename("logged-in", "20260715-120000")).toBe(
|
||||
"flock-logged-in-20260715-120000.js",
|
||||
);
|
||||
expect(resolveFlockOutputBasename("anonymous", "20260715-120000")).toBe(
|
||||
"flock-manual-20260715-120000.js",
|
||||
);
|
||||
expect(resolveFlockStoragePath("logged-in")).toContain(
|
||||
"flock-logged-in-storage.json",
|
||||
);
|
||||
expect(resolveFlockStoragePath("anonymous")).toContain("flock-storage.json");
|
||||
expect(DEFAULT_FLOCK_QUOTE_URL).toContain("get-a-quote");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateMotherShipAddressChipLines } from "@/workers/rpa/address-chip-visual";
|
||||
|
||||
describe("evaluateMotherShipAddressChipLines", () => {
|
||||
it("截图态:门牌行 + City, ST 两行 → 已确认", () => {
|
||||
const lines = [
|
||||
"Where to?",
|
||||
"1234 Warehouse Street",
|
||||
"Los Angeles, CA",
|
||||
"Deliver to",
|
||||
"Freight details",
|
||||
];
|
||||
const probe = evaluateMotherShipAddressChipLines(
|
||||
lines,
|
||||
"1234",
|
||||
"Los Angeles",
|
||||
);
|
||||
expect(probe.committed).toBe(true);
|
||||
expect(probe.streetLine).toBe("1234 Warehouse Street");
|
||||
expect(probe.cityLine).toBe("Los Angeles, CA");
|
||||
});
|
||||
|
||||
it("打字态:整段查询在同一行 → 未确认", () => {
|
||||
const lines = [
|
||||
"1234 Warehouse Street, Los Angeles, California",
|
||||
"Deliver to",
|
||||
];
|
||||
expect(
|
||||
evaluateMotherShipAddressChipLines(lines, "1234", "Los Angeles")
|
||||
.committed,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("仅 City, ST 无门牌行 → 未确认", () => {
|
||||
const lines = ["Los Angeles, CA", "Deliver to"];
|
||||
expect(
|
||||
evaluateMotherShipAddressChipLines(lines, "1234", "Los Angeles")
|
||||
.committed,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import type { Page } from "playwright";
|
||||
|
||||
vi.mock("@/lib/rpa/env", () => ({
|
||||
isRpaAddressRushMode: vi.fn(() => false),
|
||||
isRpaBlindAddressFlow: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("@/workers/rpa/cargo-lock", () => ({
|
||||
dismissCargoPopoverOnly: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/workers/rpa/page-prep", () => ({
|
||||
scrollQuoteWidgetIntoView: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rpa/selector-specs", () => ({
|
||||
clickFirstVisibleFromSpecs: vi.fn(async () => false),
|
||||
getSelectorSpecs: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
function mockPage(innerText: string, buttonVisible = true): Page {
|
||||
const click = vi.fn(async () => undefined);
|
||||
const button = {
|
||||
isVisible: vi.fn(async () => buttonVisible),
|
||||
isDisabled: vi.fn(async () => false),
|
||||
scrollIntoViewIfNeeded: vi.fn(async () => undefined),
|
||||
click,
|
||||
};
|
||||
const roleButton = {
|
||||
first: vi.fn(() => button),
|
||||
};
|
||||
const widget = {
|
||||
innerText: vi.fn(async () => innerText),
|
||||
getByRole: vi.fn(() => roleButton),
|
||||
locator: vi.fn(() => ({
|
||||
filter: vi.fn(() => ({
|
||||
first: vi.fn(() => button),
|
||||
})),
|
||||
first: vi.fn(() => button),
|
||||
})),
|
||||
};
|
||||
return {
|
||||
locator: vi.fn(() => widget),
|
||||
} as unknown as Page;
|
||||
}
|
||||
|
||||
describe("clickWidgetQuoteSubmitButton", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("表单未就绪时非 blind 模式不点击", async () => {
|
||||
const { clickWidgetQuoteSubmitButton } = await import(
|
||||
"@/workers/rpa/quote-capture/click-widget-quote-submit"
|
||||
);
|
||||
const page = mockPage("Freight details only");
|
||||
const clicked = await clickWidgetQuoteSubmitButton(page);
|
||||
expect(clicked).toBe(false);
|
||||
});
|
||||
|
||||
it("blind 模式表单检测失败仍尝试点击", async () => {
|
||||
const env = await import("@/lib/rpa/env");
|
||||
vi.mocked(env.isRpaBlindAddressFlow).mockReturnValue(true);
|
||||
const { clickWidgetQuoteSubmitButton } = await import(
|
||||
"@/workers/rpa/quote-capture/click-widget-quote-submit"
|
||||
);
|
||||
const page = mockPage("Freight details only");
|
||||
const clicked = await clickWidgetQuoteSubmitButton(page);
|
||||
expect(clicked).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isFlockLoggedInQuoteForm,
|
||||
selectFlockComboboxOption,
|
||||
} from "@/workers/rpa/flock/logged-in-form";
|
||||
|
||||
function mockLocator(opts: {
|
||||
count?: number;
|
||||
visible?: boolean;
|
||||
click?: () => Promise<void>;
|
||||
}) {
|
||||
const click = opts.click ?? (async () => undefined);
|
||||
return {
|
||||
first: () => mockLocator(opts),
|
||||
count: async () => opts.count ?? 0,
|
||||
isVisible: async () => opts.visible ?? false,
|
||||
click,
|
||||
waitFor: async () => undefined,
|
||||
fill: async () => undefined,
|
||||
getAttribute: async () => null,
|
||||
scrollIntoViewIfNeeded: async () => undefined,
|
||||
page: () => ({ isClosed: () => false }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("flock logged-in form", () => {
|
||||
it("识别 Let's build a quote 为登录后表单", async () => {
|
||||
const heading = mockLocator({ count: 1, visible: true });
|
||||
const page = {
|
||||
getByTestId: () => mockLocator({ count: 0 }),
|
||||
getByRole: (role: string) => {
|
||||
if (role === "heading") return heading;
|
||||
return mockLocator({ count: 0 });
|
||||
},
|
||||
getByText: () => mockLocator({ count: 0 }),
|
||||
};
|
||||
expect(await isFlockLoggedInQuoteForm(page as never)).toBe(true);
|
||||
});
|
||||
|
||||
it("combobox 按候选选 option", async () => {
|
||||
const clicks: string[] = [];
|
||||
const option70 = mockLocator({
|
||||
count: 1,
|
||||
visible: true,
|
||||
click: async () => {
|
||||
clicks.push("70");
|
||||
},
|
||||
});
|
||||
const combo = mockLocator({
|
||||
count: 1,
|
||||
visible: true,
|
||||
click: async () => {
|
||||
clicks.push("open");
|
||||
},
|
||||
});
|
||||
const page = {
|
||||
getByRole: (role: string, opts?: { name?: RegExp | string }) => {
|
||||
if (role === "combobox") return combo;
|
||||
if (role === "button") return mockLocator({ count: 0 });
|
||||
if (role === "option") {
|
||||
const name = String(opts?.name ?? "");
|
||||
if (name.includes("70") || name === "70") return option70;
|
||||
return mockLocator({ count: 0 });
|
||||
}
|
||||
return mockLocator({ count: 0 });
|
||||
},
|
||||
waitForTimeout: async () => undefined,
|
||||
keyboard: { press: async () => undefined },
|
||||
};
|
||||
|
||||
const ok = await selectFlockComboboxOption(
|
||||
page as never,
|
||||
/Freight Class/i,
|
||||
["70", "50"],
|
||||
);
|
||||
expect(ok).toBe(true);
|
||||
expect(clicks).toContain("open");
|
||||
expect(clicks).toContain("70");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,91 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getFlockLoginUrl,
|
||||
hasFlockLoginCredentials,
|
||||
isFlockFreshSessionPerQuote,
|
||||
isFlockRandomAccountPerQuote,
|
||||
shouldSaveFlockStorageState,
|
||||
} from "@/lib/flock/env";
|
||||
import {
|
||||
flockLoginRegistrationBlockedMessage,
|
||||
isFlockLoginPage,
|
||||
resetFlockLoginSessionFlagForTests,
|
||||
} from "@/workers/rpa/flock/login";
|
||||
import { shouldRotateFlockSession } from "@/workers/rpa/flock/worker-session";
|
||||
|
||||
describe("Flock login credentials env", () => {
|
||||
const prev = {
|
||||
email: process.env.FLOCK_LOGIN_EMAIL,
|
||||
password: process.env.FLOCK_LOGIN_PASSWORD,
|
||||
fresh: process.env.FLOCK_FRESH_SESSION_PER_QUOTE,
|
||||
random: process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE,
|
||||
loginUrl: process.env.FLOCK_LOGIN_URL,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
process.env.FLOCK_LOGIN_EMAIL = prev.email;
|
||||
process.env.FLOCK_LOGIN_PASSWORD = prev.password;
|
||||
process.env.FLOCK_FRESH_SESSION_PER_QUOTE = prev.fresh;
|
||||
process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE = prev.random;
|
||||
process.env.FLOCK_LOGIN_URL = prev.loginUrl;
|
||||
});
|
||||
|
||||
it("无凭据时 hasCredentials=false", () => {
|
||||
delete process.env.FLOCK_LOGIN_EMAIL;
|
||||
delete process.env.FLOCK_LOGIN_PASSWORD;
|
||||
expect(hasFlockLoginCredentials()).toBe(false);
|
||||
});
|
||||
|
||||
it("成对凭据时开启登录门禁并关闭 fresh/random", () => {
|
||||
process.env.FLOCK_LOGIN_EMAIL = "user@example.com";
|
||||
process.env.FLOCK_LOGIN_PASSWORD = "secret";
|
||||
process.env.FLOCK_FRESH_SESSION_PER_QUOTE = "true";
|
||||
process.env.FLOCK_RANDOM_ACCOUNT_PER_QUOTE = "true";
|
||||
expect(hasFlockLoginCredentials()).toBe(true);
|
||||
expect(isFlockFreshSessionPerQuote()).toBe(false);
|
||||
expect(isFlockRandomAccountPerQuote()).toBe(false);
|
||||
expect(shouldSaveFlockStorageState()).toBe(true);
|
||||
});
|
||||
|
||||
it("默认登录 URL", () => {
|
||||
delete process.env.FLOCK_LOGIN_URL;
|
||||
expect(getFlockLoginUrl()).toContain("flockfreight.com/login");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFlockLoginPage", () => {
|
||||
it("识别 login URL", () => {
|
||||
expect(
|
||||
isFlockLoginPage({
|
||||
url: () => "https://app.flockfreight.com/login",
|
||||
} as never),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isFlockLoginPage({
|
||||
url: () => "https://app.flockfreight.com/get-a-quote",
|
||||
} as never),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("login mode session rotation", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.FLOCK_LOGIN_EMAIL;
|
||||
delete process.env.FLOCK_LOGIN_PASSWORD;
|
||||
resetFlockLoginSessionFlagForTests();
|
||||
});
|
||||
|
||||
it("有登录凭据时禁止换号", () => {
|
||||
process.env.FLOCK_LOGIN_EMAIL = "a@b.com";
|
||||
process.env.FLOCK_LOGIN_PASSWORD = "x";
|
||||
expect(
|
||||
shouldRotateFlockSession(
|
||||
"FLOCK_ACCOUNT_REJECTED: We had an issue creating your account",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("注册页阻断文案", () => {
|
||||
expect(flockLoginRegistrationBlockedMessage()).toContain("登录凭据");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
flockLocatorCount,
|
||||
flockPageClosedMessage,
|
||||
isFlockPageAlive,
|
||||
isFlockPageClosedError,
|
||||
} from "@/workers/rpa/flock/page-alive";
|
||||
|
||||
describe("flock page-alive", () => {
|
||||
it("识别 Target closed / locator.count 关窗错误", () => {
|
||||
expect(
|
||||
isFlockPageClosedError(
|
||||
new Error("locator.count: Target page, context or browser has been closed"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(isFlockPageClosedError(new Error("other"))).toBe(false);
|
||||
});
|
||||
|
||||
it("关窗文案可读", () => {
|
||||
expect(flockPageClosedMessage("填表")).toContain("BROWSER_CLOSED");
|
||||
expect(flockPageClosedMessage("填表")).toContain("填表");
|
||||
});
|
||||
|
||||
it("isFlockPageAlive null 为 false", () => {
|
||||
expect(isFlockPageAlive(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("flockLocatorCount 在 page.isClosed 时返回 0", async () => {
|
||||
const loc = {
|
||||
page: () => ({ isClosed: () => true }),
|
||||
count: async () => {
|
||||
throw new Error("should not call");
|
||||
},
|
||||
};
|
||||
expect(await flockLocatorCount(loc as never)).toBe(0);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractFlockPendingReference,
|
||||
formatFlockRatesPendingMessage,
|
||||
isFlockRatesPendingText,
|
||||
parseFlockMoney,
|
||||
parseFlockTransit,
|
||||
} from "@/workers/rpa/flock/quote-extract";
|
||||
import { isFlockNonRetryableFailure } from "@/lib/flock/exclusive-lock";
|
||||
|
||||
describe("parseFlockMoney", () => {
|
||||
it("解析 Prices from 文案", () => {
|
||||
expect(parseFlockMoney("Prices from$1,925Transit(incl. weekends)4 days")).toBe(
|
||||
1925,
|
||||
);
|
||||
expect(parseFlockMoney("Prices from $1,187")).toBe(1187);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFlockTransit", () => {
|
||||
it("解析含周末时效", () => {
|
||||
const r = parseFlockTransit(
|
||||
"Prices from$1,925Transit(incl. weekends)4 daysGuaranteed",
|
||||
);
|
||||
expect(r.transitDays).toBe("4");
|
||||
});
|
||||
|
||||
it("解析工作日预估", () => {
|
||||
const r = parseFlockTransit(
|
||||
"Prices from$1,187Transit(business days only)Contact Rep",
|
||||
);
|
||||
expect(r.transitDescription).toMatch(/business days only|工作日/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Flock rates pending 页", () => {
|
||||
const sample =
|
||||
"#NCC-ZHZ4 pricing options\nExpires Jul 17, 2026\nHold tight, we are still looking for the best rates!\nWe should have a rate for you in about 15 minutes.";
|
||||
|
||||
it("识别 Hold tight 待价文案", () => {
|
||||
expect(isFlockRatesPendingText(sample)).toBe(true);
|
||||
expect(isFlockRatesPendingText("Prices from $100")).toBe(false);
|
||||
});
|
||||
|
||||
it("从标题抽取参考编号", () => {
|
||||
expect(extractFlockPendingReference(sample)).toBe("NCC-ZHZ4");
|
||||
});
|
||||
|
||||
it("待价结果禁止 BullMQ 重跑", () => {
|
||||
const msg = formatFlockRatesPendingMessage("NCC-ZHZ4");
|
||||
expect(msg).toMatch(/FLOCK_RATES_PENDING/);
|
||||
expect(msg).toContain("NCC-ZHZ4");
|
||||
expect(isFlockNonRetryableFailure(msg)).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
describeFlockPagePhase,
|
||||
isFlockQuoteLimitText,
|
||||
} from "@/workers/rpa/flock/page-state";
|
||||
import { readFlockStallMs } from "@/workers/rpa/flock/stall-watch";
|
||||
|
||||
describe("isFlockQuoteLimitText", () => {
|
||||
it("识别 Thank you for your interest", () => {
|
||||
expect(isFlockQuoteLimitText("Thank you for your interest in Flock")).toBe(true);
|
||||
expect(describeFlockPagePhase("Thank you for your interest")).toBe("quote_limit");
|
||||
});
|
||||
|
||||
it("识别中文上限", () => {
|
||||
expect(isFlockQuoteLimitText("获取报价达到上限")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readFlockStallMs", () => {
|
||||
it("默认 20s", () => {
|
||||
const prev = process.env.FLOCK_STALL_MS;
|
||||
delete process.env.FLOCK_STALL_MS;
|
||||
expect(readFlockStallMs()).toBe(20_000);
|
||||
process.env.FLOCK_STALL_MS = prev;
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatWidgetQuoteTierLabel,
|
||||
mergeWidgetTabQuotePrice,
|
||||
} from "@/workers/rpa/quote-capture/widget-truck-tab-quotes";
|
||||
import { validateQuoteSchema } from "@/workers/rpa/quote-capture/quote-schema-validator";
|
||||
import type { QuoteItem } from "@/modules/providers/quote-provider";
|
||||
|
||||
function item(serviceLevel: string, rateOption: string, rawTotal: number): QuoteItem {
|
||||
return {
|
||||
serviceLevel,
|
||||
rateOption,
|
||||
carrier: "Mothership",
|
||||
transitDays: "—",
|
||||
transitDescription: "—",
|
||||
rawFreight: rawTotal,
|
||||
surcharges: 0,
|
||||
rawTotal,
|
||||
};
|
||||
}
|
||||
|
||||
describe("formatWidgetQuoteTierLabel", () => {
|
||||
it("Shared truck · Best value 文案", () => {
|
||||
expect(formatWidgetQuoteTierLabel(item("standard", "bestValue", 81.71))).toBe(
|
||||
"standard/bestValue (Shared truck · Best value)",
|
||||
);
|
||||
});
|
||||
|
||||
it("Dedicated truck 文案", () => {
|
||||
expect(formatWidgetQuoteTierLabel(item("dedicated", "bestValue", 246.11))).toBe(
|
||||
"dedicated/bestValue (Dedicated truck)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeWidgetTabQuotePrice", () => {
|
||||
it("Tab 价覆盖金额但保留 axel 时效", () => {
|
||||
const axel: QuoteItem = {
|
||||
...item("standard", "bestValue", 80),
|
||||
transitDays: "3 business days",
|
||||
transitDescription: "3 个工作日",
|
||||
carrier: "Mothership Carrier",
|
||||
};
|
||||
const tab = item("standard", "bestValue", 81.71);
|
||||
const merged = mergeWidgetTabQuotePrice(axel, tab);
|
||||
const dedicated = mergeWidgetTabQuotePrice(
|
||||
undefined,
|
||||
item("dedicated", "bestValue", 246.11),
|
||||
);
|
||||
expect(merged.rawTotal).toBe(81.71);
|
||||
expect(merged.transitDays).toBe("3 business days");
|
||||
expect(dedicated.transitDays).toBe("待确认");
|
||||
expect(validateQuoteSchema([merged, dedicated])).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,58 @@
|
||||
import { assertCustomerMatch, parseServiceAuth } from "@/lib/api/auth-context";
|
||||
import { enforceRateLimits } from "@/lib/api/rate-limit";
|
||||
import { resolveAxelMothershipSuggest } from "@/lib/axel/candidates";
|
||||
import { mothershipSuggestBodySchema } from "@/modules/address/validation";
|
||||
import { fail, ok } from "@/lib/response";
|
||||
import { AuthError } from "@/modules/auth/errors";
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
/** POST /api/addresses/mothership-suggest — 单 query 地址联想(登录后一级表单) */
|
||||
export async function POST(request: Request) {
|
||||
let auth;
|
||||
try {
|
||||
auth = await parseServiceAuth(request);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return fail(error.code, error.message, error.httpStatus);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const rateLimited = await enforceRateLimits(request, auth.customerId);
|
||||
if (rateLimited) {
|
||||
return rateLimited;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
|
||||
}
|
||||
|
||||
const parsed = mothershipSuggestBodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
const message = parsed.error.issues[0]?.message ?? "地址参数无效";
|
||||
return fail("VALIDATION_FAILED", message, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
assertCustomerMatch(auth, parsed.data.customer_id);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return fail(error.code, error.message, error.httpStatus);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const candidates = await resolveAxelMothershipSuggest(parsed.data.query);
|
||||
return ok({ candidates });
|
||||
} catch (error) {
|
||||
console.error("[mothership-suggest]", error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : "地址联想服务异常";
|
||||
return fail("INTERNAL_ERROR", message, 503);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,117 @@
|
||||
import { parseAdminAuth } from "@/lib/api/admin-auth-context";
|
||||
import { fail, ok } from "@/lib/response";
|
||||
import { writeAudit } from "@/modules/audit/service";
|
||||
import { AuthError } from "@/modules/auth/errors";
|
||||
import {
|
||||
hasCustomerProviderCredential,
|
||||
isProviderCredentialProvider,
|
||||
listCustomerProviderCredentialsForAdmin,
|
||||
upsertCustomerProviderCredential,
|
||||
} from "@/modules/customer/provider-credentials";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ customer_id: string }>;
|
||||
};
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
let auth;
|
||||
try {
|
||||
auth = parseAdminAuth(request);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return fail(error.code, error.message, error.httpStatus);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { customer_id: customerId } = await context.params;
|
||||
const list = await listCustomerProviderCredentialsForAdmin(customerId);
|
||||
await writeAudit(
|
||||
"customer:provider_credential_view",
|
||||
auth.userId,
|
||||
customerId,
|
||||
{ providers: list.filter((x) => x.has_password).map((x) => x.provider) },
|
||||
);
|
||||
return ok({ customer_id: customerId, list });
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, context: RouteContext) {
|
||||
let auth;
|
||||
try {
|
||||
auth = parseAdminAuth(request);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return fail(error.code, error.message, error.httpStatus);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { customer_id: customerId } = await context.params;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
|
||||
}
|
||||
|
||||
const payload = body as {
|
||||
provider?: unknown;
|
||||
email?: unknown;
|
||||
password?: unknown;
|
||||
clear?: unknown;
|
||||
};
|
||||
|
||||
if (!isProviderCredentialProvider(payload.provider)) {
|
||||
return fail("VALIDATION_FAILED", "provider 须为 mothership 或 flock", 400);
|
||||
}
|
||||
|
||||
// 产品约束:查价网站账密仅支持新建客户时首次写入,禁止事后修改/清除
|
||||
if (payload.clear === true) {
|
||||
return fail(
|
||||
"VALIDATION_FAILED",
|
||||
"不支持清除查价网站账密;如需更换请新建客户并重新绑定",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof payload.email !== "string") {
|
||||
return fail("VALIDATION_FAILED", "登录邮箱无效", 400);
|
||||
}
|
||||
const password =
|
||||
typeof payload.password === "string" ? payload.password : undefined;
|
||||
|
||||
try {
|
||||
if (await hasCustomerProviderCredential(customerId, payload.provider)) {
|
||||
return fail(
|
||||
"VALIDATION_FAILED",
|
||||
"该承运商账密已绑定,不支持修改;仅可在管理端查看",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const saved = await upsertCustomerProviderCredential(
|
||||
customerId,
|
||||
payload.provider,
|
||||
{
|
||||
email: payload.email,
|
||||
password,
|
||||
updatedBy: auth.userId,
|
||||
},
|
||||
);
|
||||
await writeAudit(
|
||||
"customer:provider_credential_upsert",
|
||||
auth.userId,
|
||||
customerId,
|
||||
{
|
||||
provider: payload.provider,
|
||||
email: saved.email,
|
||||
password_updated: Boolean(password?.trim()),
|
||||
},
|
||||
);
|
||||
return ok(saved);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "保存失败";
|
||||
return fail("VALIDATION_FAILED", message, 400);
|
||||
}
|
||||
}
|
||||
@ -1,16 +1,15 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { ok } from "@/lib/response";
|
||||
import { EMBED_DEMO_COOKIE } from "@/modules/embed-demo/auth";
|
||||
import {
|
||||
EMBED_DEMO_COOKIE,
|
||||
embedDemoCookieOptions,
|
||||
} from "@/modules/embed-demo/auth";
|
||||
|
||||
/** POST /api/embed-demo/logout */
|
||||
export async function POST() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(EMBED_DEMO_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
...embedDemoCookieOptions(0),
|
||||
});
|
||||
return ok({ logged_out: true });
|
||||
}
|
||||
|
||||
@ -0,0 +1,69 @@
|
||||
import { assertCustomerMatch, parseServiceAuth } from "@/lib/api/auth-context";
|
||||
import { enforceRateLimits } from "@/lib/api/rate-limit";
|
||||
import { fail, ok } from "@/lib/response";
|
||||
import { AuthError } from "@/modules/auth/errors";
|
||||
import {
|
||||
FlockEnqueueError,
|
||||
submitFlockQuote,
|
||||
} from "@/modules/flock/orchestrator";
|
||||
import { ValidationError, QuoteIdConflictError } from "@/modules/quote/types";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let auth;
|
||||
try {
|
||||
auth = await parseServiceAuth(request);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return fail(error.code, error.message, error.httpStatus);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const rateLimited = await enforceRateLimits(request, auth.customerId);
|
||||
if (rateLimited) return rateLimited;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
|
||||
}
|
||||
|
||||
const bodyCustomerId =
|
||||
typeof body === "object" &&
|
||||
body !== null &&
|
||||
"customer_id" in body &&
|
||||
typeof (body as { customer_id: unknown }).customer_id === "string"
|
||||
? (body as { customer_id: string }).customer_id
|
||||
: null;
|
||||
|
||||
if (!bodyCustomerId) {
|
||||
return fail("VALIDATION_FAILED", "请填写客户标识", 400);
|
||||
}
|
||||
|
||||
try {
|
||||
assertCustomerMatch(auth, bodyCustomerId);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return fail(error.code, error.message, error.httpStatus);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await submitFlockQuote(body);
|
||||
return ok(result);
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
return fail("VALIDATION_FAILED", error.message, 400);
|
||||
}
|
||||
if (error instanceof QuoteIdConflictError) {
|
||||
return fail(error.code, error.message, 500);
|
||||
}
|
||||
if (error instanceof FlockEnqueueError) {
|
||||
return fail(error.code, error.message, 503);
|
||||
}
|
||||
console.error("[POST /api/flock/quotes] 异常:", error);
|
||||
return fail("INTERNAL_ERROR", "服务异常,请稍后重试", 500);
|
||||
}
|
||||
}
|
||||
@ -1,69 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { EmbeddedQuoteWidget } from "@/components/embed/embedded-quote-widget";
|
||||
import { EmbedDemoLogin } from "@/components/embed/embed-demo-login";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { SecondaryButton } from "@/components/ui/primary-button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
embedDemoLogout,
|
||||
type EmbedDemoSession,
|
||||
} from "@/lib/frontend/api-client";
|
||||
import { EmbedDemoClient } from "@/app/embed-demo/embed-demo-client";
|
||||
|
||||
export default function EmbedDemoPage() {
|
||||
const [booting, setBooting] = useState(true);
|
||||
const [session, setSession] = useState<EmbedDemoSession | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await embedDemoLogout("");
|
||||
setBooting(false);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await embedDemoLogout("");
|
||||
setSession(null);
|
||||
};
|
||||
|
||||
if (booting) {
|
||||
return (
|
||||
<AppLayout title="宿主嵌入演示">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<AppLayout title="宿主嵌入演示">
|
||||
<EmbedDemoLogin
|
||||
onSuccess={(data) => {
|
||||
setSession(data);
|
||||
}}
|
||||
/>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout title="宿主嵌入演示">
|
||||
<Card className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-text-secondary">当前登录客户</p>
|
||||
<p className="text-lg font-semibold">{session.customer_id}</p>
|
||||
</div>
|
||||
<SecondaryButton onClick={() => void handleLogout()}>
|
||||
退出登录
|
||||
</SecondaryButton>
|
||||
</Card>
|
||||
|
||||
<p className="mb-4 text-sm text-text-secondary">
|
||||
模拟宿主系统内嵌查价组件。询价结果将按当前客户的加价规则计算最终价格。
|
||||
</p>
|
||||
<EmbeddedQuoteWidget customerId={session.customer_id} apiBaseUrl="" />
|
||||
</AppLayout>
|
||||
<Suspense
|
||||
fallback={
|
||||
<AppLayout title="宿主嵌入演示">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</AppLayout>
|
||||
}
|
||||
>
|
||||
<EmbedDemoClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { CaretDown, CaretLeft, CaretRight } from "@phosphor-icons/react";
|
||||
import {
|
||||
minMothershipReadyDateIso,
|
||||
parseIsoDateLocal,
|
||||
} from "@/lib/mothership/logged-in-constraints";
|
||||
import {
|
||||
buildMothershipWeekdayGrid,
|
||||
formatMothershipWeekdayDateLabel,
|
||||
monthLabelZh,
|
||||
} from "@/lib/mothership/weekday-date-picker";
|
||||
import { quoteInputCls } from "@/components/quote/quote-form-chrome";
|
||||
|
||||
const WEEKDAYS_ZH = ["日", "一", "二", "三", "四", "五", "六"] as const;
|
||||
|
||||
export type MothershipWeekdayDatePickerProps = {
|
||||
value: string;
|
||||
onChange: (iso: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
minIso?: string;
|
||||
};
|
||||
|
||||
export function MothershipWeekdayDatePicker({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
className,
|
||||
minIso = minMothershipReadyDateIso(),
|
||||
}: MothershipWeekdayDatePickerProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const listId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchor = parseIsoDateLocal(value) ?? parseIsoDateLocal(minIso)!;
|
||||
const [viewYear, setViewYear] = useState(anchor.getFullYear());
|
||||
const [viewMonth, setViewMonth] = useState(anchor.getMonth());
|
||||
|
||||
useEffect(() => {
|
||||
const d = parseIsoDateLocal(value);
|
||||
if (!d) return;
|
||||
setViewYear(d.getFullYear());
|
||||
setViewMonth(d.getMonth());
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open]);
|
||||
|
||||
const cells = useMemo(
|
||||
() => buildMothershipWeekdayGrid(viewYear, viewMonth, value, minIso),
|
||||
[viewYear, viewMonth, value, minIso],
|
||||
);
|
||||
|
||||
const shiftMonth = (delta: number) => {
|
||||
const d = new Date(viewYear, viewMonth + delta, 1, 12, 0, 0);
|
||||
setViewYear(d.getFullYear());
|
||||
setViewMonth(d.getMonth());
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
id={listId}
|
||||
disabled={disabled}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={className ?? quoteInputCls + " flex items-center justify-between text-left"}
|
||||
>
|
||||
<span>{formatMothershipWeekdayDateLabel(value)}</span>
|
||||
<CaretDown size={16} className="shrink-0 text-text-secondary" />
|
||||
</button>
|
||||
{open && !disabled && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-labelledby={listId}
|
||||
className="absolute z-30 mt-1 w-[280px] rounded-lg border border-border bg-surface p-3 shadow-modal"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="上一月"
|
||||
className="rounded p-1 text-text-secondary hover:bg-bg"
|
||||
onClick={() => shiftMonth(-1)}
|
||||
>
|
||||
<CaretLeft size={16} />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{monthLabelZh(viewYear, viewMonth)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="下一月"
|
||||
className="rounded p-1 text-text-secondary hover:bg-bg"
|
||||
onClick={() => shiftMonth(1)}
|
||||
>
|
||||
<CaretRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1 text-center text-[11px] text-text-secondary">
|
||||
{WEEKDAYS_ZH.map((w) => (
|
||||
<div key={w} className="py-1 font-medium">
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-7 gap-1">
|
||||
{cells.map((cell) => {
|
||||
const base =
|
||||
"flex h-9 w-9 items-center justify-center rounded-full text-sm transition-colors";
|
||||
let cls = base;
|
||||
if (!cell.inMonth) {
|
||||
cls += " invisible pointer-events-none";
|
||||
} else if (cell.disabled) {
|
||||
cls += " cursor-not-allowed text-text-disabled opacity-40";
|
||||
} else if (cell.isSelected) {
|
||||
cls +=
|
||||
" bg-[#1890FF] font-semibold text-white hover:bg-[#1580E0]";
|
||||
} else {
|
||||
cls +=
|
||||
" text-text-primary hover:bg-[#1890FF]/10 hover:text-[#1890FF]";
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={cell.iso + String(cell.inMonth)}
|
||||
type="button"
|
||||
disabled={cell.disabled || !cell.inMonth}
|
||||
aria-label={cell.iso}
|
||||
aria-selected={cell.isSelected}
|
||||
className={cls}
|
||||
onClick={() => {
|
||||
onChange(cell.iso);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{cell.day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-text-secondary">
|
||||
周六、周日不可选
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue