You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
chajia/__tests__/modules/flock/flock-hold-user-journeys.te...

288 lines
8.8 KiB

This file contains ambiguous Unicode characters!

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

/**
* 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);
});
});