|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
import { POST } from "@/app/api/quotes/route";
|
|
|
|
|
|
vi.mock("@/modules/cache/redis-cache", () => ({
|
|
|
getL2: vi.fn(),
|
|
|
setL2: vi.fn(),
|
|
|
setL3: vi.fn(),
|
|
|
}));
|
|
|
|
|
|
vi.mock("@/modules/quote/idempotency", () => ({
|
|
|
checkIdempotency: vi.fn(),
|
|
|
saveIdempotency: vi.fn(),
|
|
|
}));
|
|
|
|
|
|
vi.mock("@/modules/quote/quote-id", () => ({
|
|
|
generateQuoteId: vi.fn(),
|
|
|
handleQuoteIdConflict: vi.fn(),
|
|
|
isQuoteIdUniqueConflict: vi.fn(),
|
|
|
}));
|
|
|
|
|
|
vi.mock("@/modules/quote/rpa-queue", () => ({
|
|
|
enqueueQuoteJob: vi.fn(),
|
|
|
}));
|
|
|
|
|
|
vi.mock("@/modules/metrics/collector", () => ({
|
|
|
safeRecord: vi.fn(),
|
|
|
recordPostTotal: vi.fn().mockResolvedValue(undefined),
|
|
|
recordCacheHit: vi.fn().mockResolvedValue(undefined),
|
|
|
recordPostDone: vi.fn().mockResolvedValue(undefined),
|
|
|
recordRpaTriggered: vi.fn().mockResolvedValue(undefined),
|
|
|
markQuoteStarted: vi.fn().mockResolvedValue(undefined),
|
|
|
}));
|
|
|
|
|
|
vi.mock("@/lib/api/rate-limit", () => ({
|
|
|
enforceRateLimits: vi.fn().mockResolvedValue(null),
|
|
|
}));
|
|
|
|
|
|
vi.mock("@/lib/prisma", () => ({
|
|
|
prisma: {
|
|
|
quoteRecord: { create: vi.fn() },
|
|
|
markupConfig: { findFirst: vi.fn().mockResolvedValue(null) },
|
|
|
},
|
|
|
}));
|
|
|
|
|
|
import { getL2 } from "@/modules/cache/redis-cache";
|
|
|
import { checkIdempotency } from "@/modules/quote/idempotency";
|
|
|
import { generateQuoteId } from "@/modules/quote/quote-id";
|
|
|
import { enqueueQuoteJob } from "@/modules/quote/rpa-queue";
|
|
|
import { prisma } from "@/lib/prisma";
|
|
|
import { resetServiceTokenCache } from "@/modules/auth/service-token";
|
|
|
import { buildCargoHash, serializeAddress } from "@/modules/quote/cargo-hash";
|
|
|
import {
|
|
|
MOTHERSHIP_DELIVERY_CONFIRMED,
|
|
|
MOTHERSHIP_PICKUP_CONFIRMED,
|
|
|
} from "@/__tests__/fixtures/mothership-address";
|
|
|
|
|
|
const SERVICE_HEADERS = {
|
|
|
"x-auth-type": "service",
|
|
|
"x-customer-id": "CUST_001",
|
|
|
"x-permissions": "pricing:markup:write",
|
|
|
};
|
|
|
|
|
|
const VALID_BODY = {
|
|
|
request_id: "550e8400-e29b-41d4-a716-446655440000",
|
|
|
customer_id: "CUST_001",
|
|
|
pickup_address: {
|
|
|
street: "1234 Warehouse Blvd",
|
|
|
city: "Los Angeles",
|
|
|
state: "CA",
|
|
|
zip: "90001",
|
|
|
place_id: "ChIJ_pickup",
|
|
|
formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001, USA",
|
|
|
selected_from_suggestions: true,
|
|
|
...MOTHERSHIP_PICKUP_CONFIRMED,
|
|
|
},
|
|
|
delivery_address: {
|
|
|
street: "5678 Distribution Dr",
|
|
|
city: "Dallas",
|
|
|
state: "TX",
|
|
|
zip: "75201",
|
|
|
place_id: "ChIJ_delivery",
|
|
|
formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA",
|
|
|
selected_from_suggestions: true,
|
|
|
...MOTHERSHIP_DELIVERY_CONFIRMED,
|
|
|
},
|
|
|
weight: { value: 500, unit: "lb" },
|
|
|
dimensions: { length: 48, width: 40, height: 48, unit: "in" },
|
|
|
pallet_count: 2,
|
|
|
cargo_type: "general_freight",
|
|
|
};
|
|
|
|
|
|
const FOUR_TIERS = [
|
|
|
{
|
|
|
service_level: "standard",
|
|
|
rate_option: "lowest",
|
|
|
raw_freight: 320,
|
|
|
surcharges: 0,
|
|
|
raw_total: 320,
|
|
|
carrier: "M",
|
|
|
transit_days: "5",
|
|
|
transit_description: "x",
|
|
|
},
|
|
|
{
|
|
|
service_level: "standard",
|
|
|
rate_option: "fastest",
|
|
|
raw_freight: 365,
|
|
|
surcharges: 0,
|
|
|
raw_total: 365,
|
|
|
carrier: "M",
|
|
|
transit_days: "3",
|
|
|
transit_description: "x",
|
|
|
},
|
|
|
{
|
|
|
service_level: "guaranteed",
|
|
|
rate_option: "lowest",
|
|
|
raw_freight: 380,
|
|
|
surcharges: 0,
|
|
|
raw_total: 380,
|
|
|
carrier: "M",
|
|
|
transit_days: "4",
|
|
|
transit_description: "x",
|
|
|
},
|
|
|
{
|
|
|
service_level: "guaranteed",
|
|
|
rate_option: "fastest",
|
|
|
raw_freight: 410,
|
|
|
surcharges: 0,
|
|
|
raw_total: 410,
|
|
|
carrier: "M",
|
|
|
transit_days: "3",
|
|
|
transit_description: "x",
|
|
|
},
|
|
|
];
|
|
|
|
|
|
beforeEach(() => {
|
|
|
vi.clearAllMocks();
|
|
|
resetServiceTokenCache();
|
|
|
process.env.HOST_SERVICE_TOKENS = JSON.stringify({
|
|
|
"demo-host-token": {
|
|
|
customerId: "CUST_001",
|
|
|
permissions: ["pricing:markup:write"],
|
|
|
},
|
|
|
});
|
|
|
vi.mocked(checkIdempotency).mockResolvedValue({ hit: false });
|
|
|
vi.mocked(getL2).mockResolvedValue(null);
|
|
|
vi.mocked(generateQuoteId).mockResolvedValue("QTE_INT_0001");
|
|
|
vi.mocked(prisma.quoteRecord.create).mockResolvedValue({} as never);
|
|
|
vi.mocked(enqueueQuoteJob).mockResolvedValue(undefined);
|
|
|
});
|
|
|
|
|
|
describe("POST /api/quotes 集成(task-085)", () => {
|
|
|
it("TC-101:首次询价 → processing + 入队 RPA", async () => {
|
|
|
const response = await POST(
|
|
|
new Request("http://localhost/api/quotes", {
|
|
|
method: "POST",
|
|
|
headers: { "Content-Type": "application/json", ...SERVICE_HEADERS },
|
|
|
body: JSON.stringify(VALID_BODY),
|
|
|
}),
|
|
|
);
|
|
|
const json = await response.json();
|
|
|
|
|
|
expect(response.status).toBe(200);
|
|
|
expect(json.data.status).toBe("processing");
|
|
|
expect(enqueueQuoteJob).toHaveBeenCalled();
|
|
|
});
|
|
|
|
|
|
it("TC-104:L2 命中 → done + source=cache", async () => {
|
|
|
vi.mocked(getL2).mockResolvedValue({ quotes: FOUR_TIERS });
|
|
|
|
|
|
const response = await POST(
|
|
|
new Request("http://localhost/api/quotes", {
|
|
|
method: "POST",
|
|
|
headers: { "Content-Type": "application/json", ...SERVICE_HEADERS },
|
|
|
body: JSON.stringify(VALID_BODY),
|
|
|
}),
|
|
|
);
|
|
|
const json = await response.json();
|
|
|
|
|
|
expect(json.data.status).toBe("done");
|
|
|
expect(json.data.source_type).toBe("cache");
|
|
|
expect(enqueueQuoteJob).not.toHaveBeenCalled();
|
|
|
});
|
|
|
|
|
|
it("TC-105:L1 幂等 → 返回原 quote_id", async () => {
|
|
|
vi.mocked(checkIdempotency).mockResolvedValue({
|
|
|
hit: true,
|
|
|
quoteId: "QTE_EXISTING",
|
|
|
response: {
|
|
|
quote_id: "QTE_EXISTING",
|
|
|
status: "done",
|
|
|
source_type: "rpa",
|
|
|
},
|
|
|
});
|
|
|
|
|
|
const response = await POST(
|
|
|
new Request("http://localhost/api/quotes", {
|
|
|
method: "POST",
|
|
|
headers: { "Content-Type": "application/json", ...SERVICE_HEADERS },
|
|
|
body: JSON.stringify(VALID_BODY),
|
|
|
}),
|
|
|
);
|
|
|
const json = await response.json();
|
|
|
|
|
|
expect(json.data.quote_id).toBe("QTE_EXISTING");
|
|
|
expect(prisma.quoteRecord.create).not.toHaveBeenCalled();
|
|
|
});
|
|
|
|
|
|
it("TC-106/107:相同货物 hash 一致;service_level 不影响 hash", () => {
|
|
|
const pickup = serializeAddress({
|
|
|
street: "1234 Warehouse Blvd",
|
|
|
city: "Los Angeles",
|
|
|
state: "CA",
|
|
|
zip: "90001",
|
|
|
place_id: "ChIJ_pickup",
|
|
|
});
|
|
|
const delivery = serializeAddress({
|
|
|
street: "5678 Distribution Dr",
|
|
|
city: "Dallas",
|
|
|
state: "TX",
|
|
|
zip: "75201",
|
|
|
place_id: "ChIJ_delivery",
|
|
|
});
|
|
|
const base = {
|
|
|
pickupSerialized: pickup,
|
|
|
deliverySerialized: delivery,
|
|
|
weightLb: 500,
|
|
|
dimLIn: 48,
|
|
|
dimWIn: 40,
|
|
|
dimHIn: 48,
|
|
|
palletCount: 2,
|
|
|
cargoType: "general_freight",
|
|
|
};
|
|
|
const h1 = buildCargoHash(base);
|
|
|
const h2 = buildCargoHash({ ...base, cargoType: "general_freight" });
|
|
|
expect(h1).toBe(h2);
|
|
|
expect(h1).toHaveLength(32);
|
|
|
});
|
|
|
});
|