|
|
/**
|
|
|
* Flock 登录态 hold + 选档结账:用户旅程防护清单(QA)
|
|
|
* 覆盖:超时提醒、必填缺失、未选档、未解锁二级、非法输入
|
|
|
*/
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
import {
|
|
|
FLOCK_HOLD_DECISION_MS,
|
|
|
FLOCK_HOLD_MSG,
|
|
|
FLOCK_HOLD_TOTAL_MS,
|
|
|
buildFlockQuoteHoldState,
|
|
|
formatFlockHoldRemainingLabel,
|
|
|
isFlockQuoteHoldExpired,
|
|
|
shouldAutoDeclineFlockHold,
|
|
|
toFlockQuoteHoldPublic,
|
|
|
} from "@/lib/constants/flock-quote-hold";
|
|
|
import { validateFlockCheckoutSelection } from "@/lib/flock/flock-checkout-selection";
|
|
|
import {
|
|
|
emptyFlockCheckoutDetailsDraft,
|
|
|
flockDetailsHasErrors,
|
|
|
validateFlockCheckoutDetailsDraft,
|
|
|
} from "@/lib/flock/flock-checkout-details-rules";
|
|
|
import { flockCheckoutSchema } from "@/modules/flock/checkout-validation";
|
|
|
import { flockHoldContinueSchema } from "@/modules/flock/hold-validation";
|
|
|
|
|
|
/** 模拟 UI 结账前置门禁(与 widget 文案对齐) */
|
|
|
function gateCheckoutUi(input: {
|
|
|
holdAccepted: boolean;
|
|
|
selectedTier: "flock_direct" | "standard" | null;
|
|
|
selectedFlexibility: "2_day" | "1_day" | "none" | null;
|
|
|
detailsOk: boolean;
|
|
|
}): string | null {
|
|
|
if (!input.holdAccepted) {
|
|
|
return "请先在确认窗口选择「继续填写」,再完善二级信息并下单";
|
|
|
}
|
|
|
if (!input.selectedTier) {
|
|
|
return "请先选择服务档与灵活性报价";
|
|
|
}
|
|
|
if (!input.selectedFlexibility) {
|
|
|
return "请先选择服务档与灵活性报价";
|
|
|
}
|
|
|
if (!input.detailsOk) {
|
|
|
return "请先完善左侧结账详情";
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
describe("用户旅程 · 60s / 5min 超时提醒", () => {
|
|
|
it("决策窗内:提示剩余秒数;到期 auto-decline", () => {
|
|
|
const now = 10_000;
|
|
|
const s = buildFlockQuoteHoldState({
|
|
|
quoteId: "q1",
|
|
|
sessionId: "flock_q1",
|
|
|
customerId: "C1",
|
|
|
nowMs: now,
|
|
|
});
|
|
|
expect(formatFlockHoldRemainingLabel(s.decision_deadline_ms, now)).toBe(
|
|
|
"1分00秒",
|
|
|
);
|
|
|
expect(shouldAutoDeclineFlockHold(s, now + FLOCK_HOLD_DECISION_MS - 1)).toBe(
|
|
|
false,
|
|
|
);
|
|
|
expect(shouldAutoDeclineFlockHold(s, now + FLOCK_HOLD_DECISION_MS)).toBe(
|
|
|
true,
|
|
|
);
|
|
|
expect(FLOCK_HOLD_MSG.decisionTimeout).toMatch(/初步报价/);
|
|
|
expect(FLOCK_HOLD_MSG.decisionTimeoutApi).toMatch(/重新询价/);
|
|
|
});
|
|
|
|
|
|
it("继续填写后:决策窗到期不再 auto-decline;5min 仍硬停", () => {
|
|
|
const now = 1_000;
|
|
|
const s = buildFlockQuoteHoldState({
|
|
|
quoteId: "q1",
|
|
|
sessionId: "flock_q1",
|
|
|
customerId: "C1",
|
|
|
nowMs: now,
|
|
|
});
|
|
|
const filling = { ...s, status: "filling" as const };
|
|
|
expect(
|
|
|
shouldAutoDeclineFlockHold(filling, now + FLOCK_HOLD_DECISION_MS + 10_000),
|
|
|
).toBe(false);
|
|
|
expect(toFlockQuoteHoldPublic(filling, now + 90_000).available).toBe(true);
|
|
|
expect(isFlockQuoteHoldExpired(filling, now + FLOCK_HOLD_TOTAL_MS)).toBe(
|
|
|
true,
|
|
|
);
|
|
|
expect(FLOCK_HOLD_MSG.totalTimeoutApi).toMatch(/5 分钟/);
|
|
|
expect(FLOCK_HOLD_MSG.totalTimeout).toMatch(/保留/);
|
|
|
});
|
|
|
|
|
|
it("GET 侧 available:决策窗过后对 awaiting 变 false,避免继续操作假象", () => {
|
|
|
const now = 5_000;
|
|
|
const s = buildFlockQuoteHoldState({
|
|
|
quoteId: "q1",
|
|
|
sessionId: "flock_q1",
|
|
|
customerId: "C1",
|
|
|
nowMs: now,
|
|
|
});
|
|
|
expect(toFlockQuoteHoldPublic(s, now).available).toBe(true);
|
|
|
expect(
|
|
|
toFlockQuoteHoldPublic(s, now + FLOCK_HOLD_DECISION_MS).available,
|
|
|
).toBe(false);
|
|
|
});
|
|
|
});
|
|
|
|
|
|
describe("用户旅程 · 选档 / 灵活价缺失提醒", () => {
|
|
|
it("未选服务档", () => {
|
|
|
expect(
|
|
|
validateFlockCheckoutSelection({
|
|
|
preferredTier: "express" as "standard",
|
|
|
preferredFlexibility: "2_day",
|
|
|
}),
|
|
|
).toMatch(/FlockDirect|Standard/);
|
|
|
});
|
|
|
|
|
|
it("未选灵活性 → 明确中文", () => {
|
|
|
expect(
|
|
|
validateFlockCheckoutSelection({ preferredTier: "flock_direct" }),
|
|
|
).toMatch(/灵活性/);
|
|
|
expect(
|
|
|
validateFlockCheckoutSelection({
|
|
|
preferredTier: "standard",
|
|
|
preferredFlexibility: null,
|
|
|
}),
|
|
|
).toMatch(/灵活性/);
|
|
|
});
|
|
|
|
|
|
it("非法灵活性 key", () => {
|
|
|
expect(
|
|
|
validateFlockCheckoutSelection({
|
|
|
preferredTier: "standard",
|
|
|
preferredFlexibility: "3_day" as "none",
|
|
|
}),
|
|
|
).toMatch(/有效的灵活性/);
|
|
|
});
|
|
|
|
|
|
it("完整选档通过", () => {
|
|
|
expect(
|
|
|
validateFlockCheckoutSelection({
|
|
|
preferredTier: "flock_direct",
|
|
|
preferredFlexibility: "2_day",
|
|
|
}),
|
|
|
).toBeNull();
|
|
|
});
|
|
|
|
|
|
it("API schema:缺 preferred_flexibility → 失败", () => {
|
|
|
const r = flockCheckoutSchema.safeParse({
|
|
|
customer_id: "C1",
|
|
|
quote_id: "q1",
|
|
|
preferred_tier: "standard",
|
|
|
});
|
|
|
expect(r.success).toBe(false);
|
|
|
if (!r.success) {
|
|
|
expect(r.error.issues[0]?.message).toMatch(/灵活性/);
|
|
|
}
|
|
|
});
|
|
|
});
|
|
|
|
|
|
describe("用户旅程 · 二级详情必填缺失提醒", () => {
|
|
|
it("空表单:提货/送货公司名、地址、联系人、邮箱、电话均有错", () => {
|
|
|
const errors = validateFlockCheckoutDetailsDraft(
|
|
|
emptyFlockCheckoutDetailsDraft(),
|
|
|
);
|
|
|
expect(flockDetailsHasErrors(errors)).toBe(true);
|
|
|
expect(errors.pickup?.company_name).toBeTruthy();
|
|
|
expect(errors.pickup?.contact_name).toBeTruthy();
|
|
|
expect(errors.pickup?.contact_phone).toBeTruthy();
|
|
|
expect(errors.pickup?.contact_email).toBeTruthy();
|
|
|
expect(errors.delivery?.company_name).toBeTruthy();
|
|
|
expect(errors.delivery?.address1).toBeTruthy();
|
|
|
expect(errors.delivery?.city).toBeTruthy();
|
|
|
expect(errors.delivery?.state).toBeTruthy();
|
|
|
expect(errors.delivery?.zip).toBeTruthy();
|
|
|
expect(errors.delivery?.contact_email).toBeTruthy();
|
|
|
});
|
|
|
|
|
|
it("use_billing 时仍要求提货联系人;送货地址仍必填", () => {
|
|
|
const draft = emptyFlockCheckoutDetailsDraft();
|
|
|
draft.use_billing_for_pickup = true;
|
|
|
draft.pickup.contact_name = "";
|
|
|
draft.pickup.contact_phone = "";
|
|
|
draft.pickup.contact_email = "";
|
|
|
const errors = validateFlockCheckoutDetailsDraft(draft);
|
|
|
expect(errors.pickup?.contact_name).toBeTruthy();
|
|
|
expect(errors.delivery?.address1).toBeTruthy();
|
|
|
});
|
|
|
|
|
|
it("邮箱 / 电话格式错误有中文提示", () => {
|
|
|
const draft = emptyFlockCheckoutDetailsDraft();
|
|
|
draft.use_billing_for_pickup = true;
|
|
|
draft.pickup = {
|
|
|
...draft.pickup,
|
|
|
company_name: "A",
|
|
|
contact_name: "Alice",
|
|
|
contact_phone: "555-123-4567",
|
|
|
contact_email: "not-an-email",
|
|
|
opens_at: "9:00 AM",
|
|
|
closes_at: "5:00 PM",
|
|
|
};
|
|
|
draft.delivery = {
|
|
|
company_name: "B",
|
|
|
address1: "1 Main",
|
|
|
address2: "",
|
|
|
city: "Austin",
|
|
|
state: "TX",
|
|
|
zip: "78701",
|
|
|
contact_name: "Bob",
|
|
|
contact_phone: "123",
|
|
|
contact_email: "bob@",
|
|
|
opens_at: "9:00 AM",
|
|
|
closes_at: "5:00 PM",
|
|
|
};
|
|
|
const errors = validateFlockCheckoutDetailsDraft(draft);
|
|
|
expect(errors.pickup?.contact_email).toBeTruthy();
|
|
|
expect(errors.pickup?.contact_phone).toBeTruthy();
|
|
|
expect(errors.delivery?.contact_phone).toBeTruthy();
|
|
|
expect(errors.delivery?.contact_email).toBeTruthy();
|
|
|
});
|
|
|
|
|
|
it("widget 表单失败时应展示统一汇总文案(契约)", () => {
|
|
|
// 与 flock-logged-in-details-form validateAndGetDetails 一致
|
|
|
expect("请先修正结账详情中的必填项与格式错误").toMatch(/必填|格式/);
|
|
|
});
|
|
|
});
|
|
|
|
|
|
describe("用户旅程 · 结账门禁顺序(预防跳步)", () => {
|
|
|
it("未点继续填写 → 拦截", () => {
|
|
|
expect(
|
|
|
gateCheckoutUi({
|
|
|
holdAccepted: false,
|
|
|
selectedTier: "flock_direct",
|
|
|
selectedFlexibility: "2_day",
|
|
|
detailsOk: true,
|
|
|
}),
|
|
|
).toMatch(/继续填写/);
|
|
|
});
|
|
|
|
|
|
it("未选档 / 未选灵活 → 拦截", () => {
|
|
|
expect(
|
|
|
gateCheckoutUi({
|
|
|
holdAccepted: true,
|
|
|
selectedTier: null,
|
|
|
selectedFlexibility: null,
|
|
|
detailsOk: true,
|
|
|
}),
|
|
|
).toMatch(/灵活性|服务档/);
|
|
|
});
|
|
|
|
|
|
it("详情未过校验 → 拦截", () => {
|
|
|
expect(
|
|
|
gateCheckoutUi({
|
|
|
holdAccepted: true,
|
|
|
selectedTier: "standard",
|
|
|
selectedFlexibility: "none",
|
|
|
detailsOk: false,
|
|
|
}),
|
|
|
).toMatch(/结账详情/);
|
|
|
});
|
|
|
|
|
|
it("全部就绪 → 放行", () => {
|
|
|
expect(
|
|
|
gateCheckoutUi({
|
|
|
holdAccepted: true,
|
|
|
selectedTier: "flock_direct",
|
|
|
selectedFlexibility: "1_day",
|
|
|
detailsOk: true,
|
|
|
}),
|
|
|
).toBeNull();
|
|
|
});
|
|
|
});
|
|
|
|
|
|
describe("用户旅程 · hold API 入参防护", () => {
|
|
|
it("continue schema:缺 quote_session_id", () => {
|
|
|
const r = flockHoldContinueSchema.safeParse({
|
|
|
customer_id: "C1",
|
|
|
quote_id: "q1",
|
|
|
});
|
|
|
expect(r.success).toBe(false);
|
|
|
});
|
|
|
|
|
|
it("continue schema:完整通过", () => {
|
|
|
const r = flockHoldContinueSchema.safeParse({
|
|
|
customer_id: "C1",
|
|
|
quote_id: "q1",
|
|
|
quote_session_id: "flock_q1",
|
|
|
});
|
|
|
expect(r.success).toBe(true);
|
|
|
});
|
|
|
});
|