commit
360c2c2d71
@ -0,0 +1,70 @@
|
||||
---
|
||||
description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria.
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Karpathy behavioral guidelines
|
||||
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
|
||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
## 1. Think Before Coding
|
||||
|
||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||
|
||||
Before implementing:
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them - don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
## 2. Simplicity First
|
||||
|
||||
**Minimum code that solves the problem. Nothing speculative.**
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
## 3. Surgical Changes
|
||||
|
||||
**Touch only what you must. Clean up only your own mess.**
|
||||
|
||||
When editing existing code:
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it - don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
## 4. Goal-Driven Execution
|
||||
|
||||
**Define success criteria. Loop until verified.**
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
```
|
||||
1. [Step] → verify: [check]
|
||||
2. [Step] → verify: [check]
|
||||
3. [Step] → verify: [check]
|
||||
```
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
|
||||
---
|
||||
|
||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
.mypy_cache
|
||||
*.md
|
||||
docs
|
||||
.cursor
|
||||
cursor_project_rules
|
||||
scripts
|
||||
@ -0,0 +1,44 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop, "feature/**"]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Validate Prisma schema
|
||||
run: npm run db:validate
|
||||
|
||||
- name: Lint (tsc)
|
||||
run: npm run lint
|
||||
|
||||
- name: Unit tests
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Integration tests
|
||||
run: npm run test:integration
|
||||
env:
|
||||
JWT_SECRET: ci-test-jwt-secret-32chars-min!!
|
||||
HOST_SERVICE_TOKENS: '{"demo-host-token":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}}'
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
JWT_SECRET: ci-test-jwt-secret-32chars-min!!
|
||||
HOST_SERVICE_TOKENS: '{"demo-host-token":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}}'
|
||||
@ -0,0 +1,144 @@
|
||||
name: E2E Nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
|
||||
- name: E2E tests
|
||||
run: npm run test:e2e
|
||||
env:
|
||||
CI: true
|
||||
JWT_SECRET: ci-test-jwt-secret-32chars-min!!
|
||||
HOST_SERVICE_TOKENS: '{"demo-host-token":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}}'
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
go-no-go:
|
||||
runs-on: ubuntu-latest
|
||||
needs: e2e
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Go-No-Go check (mock)
|
||||
run: npm run go-no-go -- --mock
|
||||
env:
|
||||
RPA_MOCK_MODE: "true"
|
||||
|
||||
probe:
|
||||
runs-on: ubuntu-latest
|
||||
needs: e2e
|
||||
if: always()
|
||||
timeout-minutes: 45
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers (probe)
|
||||
run: npx playwright install chromium --with-deps
|
||||
|
||||
- name: RPA probe 3/3 (optional)
|
||||
run: bash scripts/ci/run-probe-nightly.sh
|
||||
env:
|
||||
MOTHERSHIP_USERNAME: ${{ secrets.MOTHERSHIP_USERNAME }}
|
||||
MOTHERSHIP_PASSWORD: ${{ secrets.MOTHERSHIP_PASSWORD }}
|
||||
MOTHERSHIP_QUOTE_URLS: ${{ secrets.MOTHERSHIP_QUOTE_URLS }}
|
||||
PROBE_NIGHTLY_LOG: probe-nightly.log
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: probe-nightly-log
|
||||
path: probe-nightly.log
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
load:
|
||||
runs-on: ubuntu-latest
|
||||
needs: e2e
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install k6
|
||||
run: |
|
||||
sudo gpg -k
|
||||
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
|
||||
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install k6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Start app (mock mode)
|
||||
run: |
|
||||
npm run build
|
||||
npm run start &
|
||||
sleep 15
|
||||
env:
|
||||
JWT_SECRET: ci-test-jwt-secret-32chars-min!!
|
||||
HOST_SERVICE_TOKENS: '{"demo-host-token":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}}'
|
||||
RPA_MOCK_MODE: "true"
|
||||
DATABASE_URL: mysql://root:root@127.0.0.1:3306/chajia
|
||||
REDIS_URL: redis://127.0.0.1:6379
|
||||
|
||||
- name: k6 rate-limit (smoke)
|
||||
continue-on-error: true
|
||||
run: k6 run --vus 1 --iterations 5 load-tests/rate-limit.js
|
||||
env:
|
||||
BASE_URL: http://localhost:3000
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: k6-report
|
||||
path: load-tests/
|
||||
retention-days: 7
|
||||
@ -0,0 +1,31 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
CMD ["node", "server.js"]
|
||||
@ -0,0 +1,13 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["npm", "run", "worker:rpa"]
|
||||
@ -0,0 +1,111 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GET } from "@/app/api/admin/markup-configs/route";
|
||||
import { PUT } from "@/app/api/admin/markup-configs/[customer_id]/route";
|
||||
import { resetServiceTokenCache } from "@/modules/auth/service-token";
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
markupConfig: {
|
||||
findMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const ADMIN_HEADERS = {
|
||||
authorization: "Bearer admin-jwt",
|
||||
"x-auth-type": "admin",
|
||||
"x-user-id": "admin_demo",
|
||||
"x-user-role": "admin",
|
||||
};
|
||||
|
||||
describe("admin markup-configs API", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetServiceTokenCache();
|
||||
process.env.HOST_SERVICE_TOKENS = JSON.stringify({
|
||||
"demo-host-token": {
|
||||
customerId: "CUST_001",
|
||||
permissions: ["pricing:markup:write"],
|
||||
},
|
||||
});
|
||||
process.env.CUSTOMER_REGISTRY = "CUST_002,CUST_003";
|
||||
});
|
||||
|
||||
it("GET 返回注册客户合并配置", async () => {
|
||||
vi.mocked(prisma.markupConfig.findMany).mockResolvedValue([
|
||||
{
|
||||
customerId: "CUST_002",
|
||||
markupType: "percent",
|
||||
markupPercent: 10,
|
||||
markupFixedAmount: null,
|
||||
operatorId: "admin_demo",
|
||||
remark: null,
|
||||
updatedAt: new Date("2026-06-15T10:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/admin/markup-configs?page=1&size=10", {
|
||||
headers: ADMIN_HEADERS,
|
||||
}),
|
||||
);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.code).toBe(0);
|
||||
expect(body.data.total).toBe(3);
|
||||
expect(body.data.list[1].customer_id).toBe("CUST_002");
|
||||
expect(body.data.list[1].markup_percent).toBe(10);
|
||||
});
|
||||
|
||||
it("PUT 固定金额加价成功", async () => {
|
||||
vi.mocked(prisma.markupConfig.upsert).mockResolvedValue({
|
||||
customerId: "CUST_001",
|
||||
markupType: "fixed",
|
||||
markupPercent: 0,
|
||||
markupFixedAmount: 25,
|
||||
operatorId: "admin_demo",
|
||||
remark: "测试",
|
||||
updatedAt: new Date("2026-06-24T10:00:00Z"),
|
||||
} as never);
|
||||
|
||||
const res = await PUT(
|
||||
new Request("http://localhost/api/admin/markup-configs/CUST_001", {
|
||||
method: "PUT",
|
||||
headers: { ...ADMIN_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
markup_type: "fixed",
|
||||
markup_fixed_amount: 25,
|
||||
remark: "测试",
|
||||
}),
|
||||
}),
|
||||
{ params: Promise.resolve({ customer_id: "CUST_001" }) },
|
||||
);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.markup_type).toBe("fixed");
|
||||
expect(body.data.markup_fixed_amount).toBe(25);
|
||||
});
|
||||
|
||||
it("PUT 未知客户 400", async () => {
|
||||
const res = await PUT(
|
||||
new Request("http://localhost/api/admin/markup-configs/CUST_999", {
|
||||
method: "PUT",
|
||||
headers: { ...ADMIN_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ markup_percent: 5 }),
|
||||
}),
|
||||
{ params: Promise.resolve({ customer_id: "CUST_999" }) },
|
||||
);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(body.message).toBe("客户不存在");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,139 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GET as listAlerts } from "@/app/api/alerts/route";
|
||||
import { POST as resolveAlert } from "@/app/api/alerts/[id]/resolve/route";
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
alertLog: {
|
||||
count: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const ADMIN_HEADERS = {
|
||||
"x-auth-type": "admin",
|
||||
"x-user-id": "U_admin_demo",
|
||||
"x-user-role": "admin",
|
||||
};
|
||||
|
||||
const SERVICE_HEADERS = {
|
||||
"x-auth-type": "service",
|
||||
"x-customer-id": "CUST_001",
|
||||
"x-permissions": "",
|
||||
};
|
||||
|
||||
const SAMPLE_ALERT = {
|
||||
id: BigInt(1),
|
||||
alertType: "RPA_FAILED",
|
||||
quoteId: "QTE_001",
|
||||
cargoHash: "abc123",
|
||||
detailJson: { reason: "timeout" },
|
||||
status: "open",
|
||||
resolverId: null,
|
||||
resolvedAt: null,
|
||||
createdAt: new Date("2026-06-16T08:00:00Z"),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/alerts", () => {
|
||||
it("admin + type 筛选 → 200", async () => {
|
||||
vi.mocked(prisma.alertLog.count).mockResolvedValue(1);
|
||||
vi.mocked(prisma.alertLog.findMany).mockResolvedValue([SAMPLE_ALERT as never]);
|
||||
|
||||
const response = await listAlerts(
|
||||
new Request(
|
||||
"http://localhost/api/alerts?page=1&size=20&type=RPA_FAILED&status=open",
|
||||
{ headers: ADMIN_HEADERS },
|
||||
),
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.code).toBe(0);
|
||||
expect(json.data.list).toHaveLength(1);
|
||||
expect(json.data.list[0].alert_type).toBe("RPA_FAILED");
|
||||
expect(prisma.alertLog.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { alertType: "RPA_FAILED", status: "open" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("缺分页 → 400", async () => {
|
||||
const response = await listAlerts(
|
||||
new Request("http://localhost/api/alerts", { headers: ADMIN_HEADERS }),
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("宿主 token → 403", async () => {
|
||||
const response = await listAlerts(
|
||||
new Request("http://localhost/api/alerts?page=1&size=20", {
|
||||
headers: SERVICE_HEADERS,
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/alerts/{id}/resolve", () => {
|
||||
it("admin 处理 → 200 + resolved", async () => {
|
||||
vi.mocked(prisma.alertLog.findUnique).mockResolvedValue(
|
||||
SAMPLE_ALERT as never,
|
||||
);
|
||||
vi.mocked(prisma.alertLog.update).mockResolvedValue({
|
||||
...SAMPLE_ALERT,
|
||||
status: "resolved",
|
||||
resolverId: "U_admin_demo",
|
||||
resolvedAt: new Date("2026-06-16T09:00:00Z"),
|
||||
} as never);
|
||||
|
||||
const response = await resolveAlert(
|
||||
new Request("http://localhost/api/alerts/1/resolve", {
|
||||
method: "POST",
|
||||
headers: { ...ADMIN_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ resolver_id: "U_admin_demo" }),
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "1" }) },
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.data.status).toBe("resolved");
|
||||
expect(json.data.resolver_id).toBe("U_admin_demo");
|
||||
});
|
||||
|
||||
it("不存在 id → 404", async () => {
|
||||
vi.mocked(prisma.alertLog.findUnique).mockResolvedValue(null);
|
||||
|
||||
const response = await resolveAlert(
|
||||
new Request("http://localhost/api/alerts/999/resolve", {
|
||||
method: "POST",
|
||||
headers: ADMIN_HEADERS,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "999" }) },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("非 admin → 403", async () => {
|
||||
const response = await resolveAlert(
|
||||
new Request("http://localhost/api/alerts/1/resolve", {
|
||||
method: "POST",
|
||||
headers: SERVICE_HEADERS,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "1" }) },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GET as getDashboard } from "@/app/api/metrics/dashboard/route";
|
||||
|
||||
vi.mock("@/modules/metrics/collector", () => ({
|
||||
getMetricsSnapshot: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/workers/rpa/queue", () => ({
|
||||
getQueueDepth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
alertLog: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { getMetricsSnapshot } from "@/modules/metrics/collector";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getQueueDepth } from "@/workers/rpa/queue";
|
||||
import { METRIC_COUNTER } from "@/lib/constants/metrics";
|
||||
|
||||
const ADMIN_HEADERS = {
|
||||
"x-auth-type": "admin",
|
||||
"x-user-id": "U_admin_demo",
|
||||
"x-user-role": "admin",
|
||||
};
|
||||
|
||||
const SERVICE_HEADERS = {
|
||||
"x-auth-type": "service",
|
||||
"x-customer-id": "CUST_001",
|
||||
"x-permissions": "",
|
||||
};
|
||||
|
||||
const SAMPLE_SNAPSHOT = {
|
||||
api_success_rate: 99.5,
|
||||
rpa_success_rate: 85,
|
||||
realtime_rate: 90,
|
||||
cache_hit_rate: 92,
|
||||
p95_latency_ms: 12_000,
|
||||
latency_sample_count: 100,
|
||||
counters: {
|
||||
[METRIC_COUNTER.POST_TOTAL]: 1000,
|
||||
[METRIC_COUNTER.POST_DONE]: 995,
|
||||
[METRIC_COUNTER.CACHE_HIT_L1]: 200,
|
||||
[METRIC_COUNTER.CACHE_HIT_L2]: 720,
|
||||
[METRIC_COUNTER.RPA_TRIGGERED]: 80,
|
||||
[METRIC_COUNTER.RPA_DONE]: 68,
|
||||
[METRIC_COUNTER.DONE_TOTAL]: 995,
|
||||
[METRIC_COUNTER.DONE_REALTIME]: 896,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/metrics/dashboard", () => {
|
||||
it("admin → 200 + GD-4 四指标", async () => {
|
||||
vi.mocked(getMetricsSnapshot).mockResolvedValue(SAMPLE_SNAPSHOT);
|
||||
vi.mocked(getQueueDepth).mockResolvedValue(3);
|
||||
vi.mocked(prisma.alertLog.count).mockResolvedValue(5);
|
||||
|
||||
const response = await getDashboard(
|
||||
new Request("http://localhost/api/metrics/dashboard", {
|
||||
headers: ADMIN_HEADERS,
|
||||
}),
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.code).toBe(0);
|
||||
expect(json.data.api_success_rate).toBe(99.5);
|
||||
expect(json.data.rpa_success_rate).toBe(85);
|
||||
expect(json.data.realtime_rate).toBe(90);
|
||||
expect(json.data.cache_hit_rate).toBe(92);
|
||||
expect(json.data.p95_latency_ms).toBe(12_000);
|
||||
expect(json.data.queue_depth).toBe(3);
|
||||
expect(json.data.open_alerts_count).toBe(5);
|
||||
});
|
||||
|
||||
it("非 admin → 403", async () => {
|
||||
const response = await getDashboard(
|
||||
new Request("http://localhost/api/metrics/dashboard", {
|
||||
headers: SERVICE_HEADERS,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(getMetricsSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,46 @@
|
||||
/** 阶段0 录证 axel/quote 响应精简 fixture */
|
||||
export const AXEL_QUOTE_BODY = {
|
||||
data: {
|
||||
rates: {
|
||||
standard: {
|
||||
lowest: {
|
||||
price: 423.49,
|
||||
finalPrice: 428.48,
|
||||
days: 8,
|
||||
serviceLevel: "standard",
|
||||
serviceType: "lowest",
|
||||
serviceCommitmentDescription: "Standard",
|
||||
},
|
||||
fastest: {
|
||||
price: 662.08,
|
||||
finalPrice: 667.07,
|
||||
days: 3,
|
||||
serviceLevel: "standard",
|
||||
serviceType: "fastest",
|
||||
serviceCommitmentDescription: "LTL Standard",
|
||||
},
|
||||
bestValue: {
|
||||
price: 576.24,
|
||||
days: 6,
|
||||
serviceLevel: "standard",
|
||||
serviceType: "bestValue",
|
||||
},
|
||||
},
|
||||
guaranteed: {
|
||||
bestValue: {
|
||||
price: 809.16,
|
||||
finalPrice: 814.15,
|
||||
days: 3,
|
||||
serviceLevel: "guaranteed",
|
||||
serviceType: "bestValue",
|
||||
serviceCommitmentDescription: "Urgent Care (Guaranteed by 5pm)",
|
||||
},
|
||||
},
|
||||
},
|
||||
availableRates: {},
|
||||
},
|
||||
key: "quote_test",
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
export const AXEL_QUOTE_URL = "https://services.mothership.com/axel/quote";
|
||||
@ -0,0 +1,12 @@
|
||||
/** 测试用:用户已确认的 MotherShip 精确地址字段 */
|
||||
export const MOTHERSHIP_PICKUP_CONFIRMED = {
|
||||
mothership_option_id: "ms_test_pickup",
|
||||
mothership_display_label: "1234 Warehouse Blvd, Los Angeles, CA 90001",
|
||||
selected_from_mothership: true as const,
|
||||
};
|
||||
|
||||
export const MOTHERSHIP_DELIVERY_CONFIRMED = {
|
||||
mothership_option_id: "ms_test_delivery",
|
||||
mothership_display_label: "5678 Distribution Dr, Dallas, TX 75201",
|
||||
selected_from_mothership: true as const,
|
||||
};
|
||||
@ -0,0 +1,105 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { pollQuoteUntilDone } from "@/hooks/use-quote-polling";
|
||||
import {
|
||||
POLL_INTERVAL_MS,
|
||||
POLL_TIMEOUT_MS,
|
||||
} from "@/lib/frontend/constants";
|
||||
import type { QuoteDetail } from "@/lib/frontend/types";
|
||||
|
||||
function processingQuote(): QuoteDetail {
|
||||
return {
|
||||
quote_id: "QTE_test",
|
||||
request_id: "req-1",
|
||||
status: "processing",
|
||||
currency: "USD",
|
||||
};
|
||||
}
|
||||
|
||||
function doneQuote(): QuoteDetail {
|
||||
return {
|
||||
quote_id: "QTE_test",
|
||||
request_id: "req-1",
|
||||
status: "done",
|
||||
currency: "USD",
|
||||
is_realtime: true,
|
||||
quotes: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("pollQuoteUntilDone", () => {
|
||||
it("前 3 次网络失败第 4 次成功 → done", async () => {
|
||||
let calls = 0;
|
||||
const result = await pollQuoteUntilDone(async () => {
|
||||
calls += 1;
|
||||
if (calls < 4) {
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, data: doneQuote() };
|
||||
});
|
||||
expect(result.type).toBe("done");
|
||||
if (result.type === "done") {
|
||||
expect(result.quote.status).toBe("done");
|
||||
}
|
||||
expect(calls).toBe(4);
|
||||
});
|
||||
|
||||
it("超过 POLL_TIMEOUT_MS 仍 processing → timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const promise = pollQuoteUntilDone(async () => ({
|
||||
ok: true,
|
||||
data: processingQuote(),
|
||||
}));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
POLL_TIMEOUT_MS + POLL_INTERVAL_MS,
|
||||
);
|
||||
const result = await promise;
|
||||
expect(result.type).toBe("timeout");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("failed 状态 → error 含可读文案", async () => {
|
||||
const result = await pollQuoteUntilDone(async () => ({
|
||||
ok: true,
|
||||
data: {
|
||||
...doneQuote(),
|
||||
status: "failed",
|
||||
error_code: "ADDRESS_SUGGESTION_NOT_FOUND",
|
||||
error_message: "未找到用户确认的 MotherShip 地址",
|
||||
},
|
||||
}));
|
||||
expect(result.type).toBe("error");
|
||||
if (result.type === "error") {
|
||||
expect(result.message).toBe("未找到用户确认的 MotherShip 地址");
|
||||
}
|
||||
});
|
||||
|
||||
it("processing 超过 10s 后使用更短轮询间隔", async () => {
|
||||
vi.useFakeTimers();
|
||||
let calls = 0;
|
||||
const intervals: number[] = [];
|
||||
let last = Date.now();
|
||||
|
||||
const promise = pollQuoteUntilDone(async () => {
|
||||
const now = Date.now();
|
||||
if (calls > 0) {
|
||||
intervals.push(now - last);
|
||||
}
|
||||
last = now;
|
||||
calls += 1;
|
||||
if (calls < 7) {
|
||||
return { ok: true, data: processingQuote() };
|
||||
}
|
||||
return { ok: true, data: doneQuote() };
|
||||
});
|
||||
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
const result = await promise;
|
||||
expect(result.type).toBe("done");
|
||||
expect(intervals.some((gap) => gap <= 800)).toBe(true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pickBestSearchCandidate } from "@/lib/axel/pick-candidate";
|
||||
import { buildAxelShipmentPayload } from "@/lib/axel/shipment";
|
||||
import { parseQuoteCliArgs } from "@/lib/axel/parse-cli-args";
|
||||
|
||||
describe("parseQuoteCliArgs", () => {
|
||||
it("解析必填参数", () => {
|
||||
const args = parseQuoteCliArgs([
|
||||
"--origin",
|
||||
"3131 Western Ave, Seattle, WA",
|
||||
"--dest",
|
||||
"123 Main St, Los Angeles, CA",
|
||||
"--pallets",
|
||||
"2",
|
||||
"--weight",
|
||||
"500",
|
||||
]);
|
||||
expect(args.origin).toContain("Seattle");
|
||||
expect(args.pallets).toBe(2);
|
||||
expect(args.weightLb).toBe(500);
|
||||
});
|
||||
|
||||
it("缺少 origin 时抛错", () => {
|
||||
expect(() =>
|
||||
parseQuoteCliArgs(["--dest", "x", "--pallets", "1", "--weight", "100"]),
|
||||
).toThrow(/--origin/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickBestSearchCandidate", () => {
|
||||
it("优先匹配门牌号", () => {
|
||||
const picked = pickBestSearchCandidate("3131 Western Ave, Seattle, WA", [
|
||||
{
|
||||
placeId: "ChIJ-other",
|
||||
description: "Western Avenue, Seattle, WA, USA",
|
||||
mainText: "Western Avenue",
|
||||
},
|
||||
{
|
||||
placeId: "ChIJ-good",
|
||||
description: "3131 Western Avenue, Seattle, WA, USA",
|
||||
mainText: "3131 Western Avenue",
|
||||
},
|
||||
]);
|
||||
expect(picked?.placeId).toBe("ChIJ-good");
|
||||
});
|
||||
|
||||
it("门牌号查询不选泛化 route", () => {
|
||||
const picked = pickBestSearchCandidate(
|
||||
"123 Main St, Los Angeles, CA 90001",
|
||||
[
|
||||
{
|
||||
placeId: "ChIJ-good",
|
||||
description: "123 Main Street, Los Angeles, CA, USA",
|
||||
mainText: "123 Main Street",
|
||||
},
|
||||
{
|
||||
placeId: "Eh1-route",
|
||||
description: "Main St, Los Angeles, CA, USA",
|
||||
mainText: "Main St",
|
||||
types: ["geocode", "route"],
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(picked?.placeId).toBe("ChIJ-good");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAxelShipmentPayload", () => {
|
||||
it("生成 shipment 含 cargo 与 autocompleteResult", () => {
|
||||
const shipment = buildAxelShipmentPayload({
|
||||
pickup: { placeId: "ChIJ-pickup", street: "1 Main" },
|
||||
delivery: { placeId: "ChIJ-delivery", street: "2 Oak" },
|
||||
pickupDescription: "1 Main St, Seattle, WA",
|
||||
deliveryDescription: "2 Oak Ave, Miami, FL",
|
||||
cargo: {
|
||||
palletCount: 2,
|
||||
weightLb: 500,
|
||||
lengthIn: 48,
|
||||
widthIn: 40,
|
||||
heightIn: 45,
|
||||
},
|
||||
});
|
||||
expect(shipment.pickupDate).toMatch(/T19:00:00\.000Z$/);
|
||||
const pickup = shipment.pickupLocation as Record<string, unknown>;
|
||||
expect(pickup.autocompleteResult).toBeTruthy();
|
||||
expect(Object.keys(shipment.cargo as object)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAddressDisplayLabel } from "@/lib/frontend/format-address";
|
||||
|
||||
describe("formatAddressDisplayLabel", () => {
|
||||
it("优先使用 street/city/state 组合,不含邮编", () => {
|
||||
expect(
|
||||
formatAddressDisplayLabel({
|
||||
street: "1234 Warehouse Blvd",
|
||||
city: "Los Angeles",
|
||||
state: "CA",
|
||||
formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001",
|
||||
}),
|
||||
).toBe("1234 Warehouse Blvd, Los Angeles, CA");
|
||||
});
|
||||
|
||||
it("从 formatted_address 剥离邮编与 USA", () => {
|
||||
expect(
|
||||
formatAddressDisplayLabel({
|
||||
formatted_address: "5678 Distribution Dr, Dallas, TX 75201, USA",
|
||||
}),
|
||||
).toBe("5678 Distribution Dr, Dallas, TX");
|
||||
});
|
||||
|
||||
it("MotherShip 确认后展示与弹窗一致的 display_label", () => {
|
||||
expect(
|
||||
formatAddressDisplayLabel({
|
||||
street: "3131 Western Ave",
|
||||
city: "Seattle",
|
||||
state: "WA",
|
||||
selected_from_mothership: true,
|
||||
mothership_display_label:
|
||||
"3131 Western Avenue, Seattle, WA, USA",
|
||||
}),
|
||||
).toBe("3131 Western Avenue, Seattle, WA");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { executeWithGatewayRetry } from "@/lib/gateway-retry";
|
||||
|
||||
describe("lib/gateway-retry", () => {
|
||||
it("首次 503 第二次 200 返回 200", async () => {
|
||||
const handler = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ status: 503, body: { error: "down" } })
|
||||
.mockResolvedValueOnce({ status: 200, body: { ok: true } });
|
||||
|
||||
const result = await executeWithGatewayRetry(handler);
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toEqual({ ok: true });
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("连续 503 返回 INTERNAL_ERROR", async () => {
|
||||
const handler = vi.fn().mockResolvedValue({ status: 503, body: null });
|
||||
|
||||
const result = await executeWithGatewayRetry(handler);
|
||||
expect(result.status).toBe(503);
|
||||
expect(result.body).toEqual({
|
||||
code: "INTERNAL_ERROR",
|
||||
message: "服务暂时不可用,请稍后重试",
|
||||
data: null,
|
||||
});
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("首次 200 不重试", async () => {
|
||||
const handler = vi.fn().mockResolvedValue({ status: 200, body: { ok: true } });
|
||||
|
||||
const result = await executeWithGatewayRetry(handler);
|
||||
expect(result.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fail, ok } from "@/lib/response";
|
||||
|
||||
describe("lib/response", () => {
|
||||
it("ok 返回 code=0、message=ok、data", async () => {
|
||||
const res = ok({ quote_id: "Q001" });
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
code: 0,
|
||||
message: "ok",
|
||||
data: { quote_id: "Q001" },
|
||||
});
|
||||
expect(res.headers.get("Cache-Control")).toBe("no-store");
|
||||
});
|
||||
|
||||
it("fail 返回 error code、message、data=null 及 HTTP 状态码", async () => {
|
||||
const res = fail("VALIDATION_FAILED", "请填写重量", 400);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(body).toEqual({
|
||||
code: "VALIDATION_FAILED",
|
||||
message: "请填写重量",
|
||||
data: null,
|
||||
});
|
||||
expect(res.headers.get("Cache-Control")).toBe("no-store");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,24 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
humanPacingDelayMs,
|
||||
RPA_HUMAN_PACING_MAX_MS,
|
||||
RPA_HUMAN_PACING_MIN_MS,
|
||||
} from "@/lib/rpa/human-pacing";
|
||||
|
||||
describe("human-pacing", () => {
|
||||
it("默认延迟在 100~300ms", () => {
|
||||
vi.stubEnv("RPA_HUMAN_PACING", "");
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
const ms = humanPacingDelayMs();
|
||||
expect(ms).toBeGreaterThanOrEqual(RPA_HUMAN_PACING_MIN_MS);
|
||||
expect(ms).toBeLessThanOrEqual(RPA_HUMAN_PACING_MAX_MS);
|
||||
}
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("RPA_HUMAN_PACING=false 时返回 0", () => {
|
||||
vi.stubEnv("RPA_HUMAN_PACING", "false");
|
||||
expect(humanPacingDelayMs()).toBe(0);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pageLocator } from "@/lib/rpa/locator";
|
||||
|
||||
describe("pageLocator", () => {
|
||||
it("解析 role= 规格", () => {
|
||||
const calls: unknown[] = [];
|
||||
const page = {
|
||||
getByRole: (role: string, opts?: { name?: string }) => {
|
||||
calls.push([role, opts]);
|
||||
return { _type: "locator" };
|
||||
},
|
||||
locator: () => ({ _type: "css" }),
|
||||
getByText: () => ({ _type: "text" }),
|
||||
getByPlaceholder: () => ({ _type: "ph" }),
|
||||
} as unknown as Parameters<typeof pageLocator>[0];
|
||||
|
||||
pageLocator(page, 'role=button[name="获取货运报价"]');
|
||||
expect(calls[0]).toEqual(["button", { name: "获取货运报价" }]);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,95 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const connect = vi.fn().mockResolvedValue(undefined);
|
||||
const quit = vi.fn().mockResolvedValue(undefined);
|
||||
const setex = vi.fn().mockResolvedValue("OK");
|
||||
const get = vi.fn().mockResolvedValue(null);
|
||||
|
||||
vi.mock("ioredis", () => ({
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
connect,
|
||||
quit,
|
||||
setex,
|
||||
get,
|
||||
})),
|
||||
}));
|
||||
|
||||
import {
|
||||
resolveQuoteSessionStoragePath,
|
||||
saveQuoteSessionFromContext,
|
||||
} from "@/lib/rpa/quote-session-store";
|
||||
|
||||
describe("quote-session-store", () => {
|
||||
const prevCwd = process.cwd();
|
||||
const prevRedisUrl = process.env.REDIS_URL;
|
||||
let tmpDir = "";
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(prevCwd);
|
||||
if (prevRedisUrl === undefined) {
|
||||
delete process.env.REDIS_URL;
|
||||
} else {
|
||||
process.env.REDIS_URL = prevRedisUrl;
|
||||
}
|
||||
if (tmpDir) {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
tmpDir = "";
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("saveQuoteSessionFromContext 写入本地文件并返回 sessionId", async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "quote-session-"));
|
||||
process.chdir(tmpDir);
|
||||
process.env.REDIS_URL = "redis://127.0.0.1:6379";
|
||||
|
||||
const storageState = vi.fn().mockImplementation(async ({ path: outPath }: { path: string }) => {
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
fs.writeFileSync(outPath, "{}", "utf8");
|
||||
});
|
||||
const sessionId = await saveQuoteSessionFromContext(
|
||||
{ storageState } as never,
|
||||
{
|
||||
pickup: {
|
||||
street: "123 Main",
|
||||
city: "LA",
|
||||
state: "CA",
|
||||
zip: "90001",
|
||||
},
|
||||
delivery: {
|
||||
street: "456 Oak",
|
||||
city: "Dallas",
|
||||
state: "TX",
|
||||
zip: "75201",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(sessionId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
expect(
|
||||
fs.existsSync(path.join(tmpDir, ".rpa", "sessions", `${sessionId}.json`)),
|
||||
).toBe(true);
|
||||
expect(setex).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolveQuoteSessionStoragePath Redis 失败时回退本地文件", async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "quote-session-"));
|
||||
process.chdir(tmpDir);
|
||||
process.env.REDIS_URL = "redis://127.0.0.1:6379";
|
||||
connect.mockRejectedValueOnce(new Error("redis down"));
|
||||
|
||||
const sessionId = "11111111-1111-4111-8111-111111111111";
|
||||
const sessionsDir = path.join(tmpDir, ".rpa", "sessions");
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
const file = path.join(sessionsDir, `${sessionId}.json`);
|
||||
fs.writeFileSync(file, "{}", "utf8");
|
||||
|
||||
const resolved = await resolveQuoteSessionStoragePath(sessionId);
|
||||
expect(resolved).toBe(file);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getSelectorSpecs,
|
||||
parseSelectorSpecs,
|
||||
} from "@/lib/rpa/selector-specs";
|
||||
|
||||
describe("selector-specs", () => {
|
||||
it("parseSelectorSpecs 支持逗号分隔双语", () => {
|
||||
expect(parseSelectorSpecs("text=Pick up from,text=从何处取货")).toEqual([
|
||||
"text=Pick up from",
|
||||
"text=从何处取货",
|
||||
]);
|
||||
});
|
||||
|
||||
it("getSelectorSpecs 合并 env 与内置回退", () => {
|
||||
process.env.RPA_SELECTOR_PICKUP_SECTION = "text=Custom pickup";
|
||||
const specs = getSelectorSpecs("RPA_SELECTOR_PICKUP_SECTION");
|
||||
expect(specs[0]).toBe("text=Custom pickup");
|
||||
expect(specs.some((s) => s.includes("Pick up from"))).toBe(true);
|
||||
delete process.env.RPA_SELECTOR_PICKUP_SECTION;
|
||||
});
|
||||
|
||||
it("英文 street selector 在内置回退中", () => {
|
||||
const specs = getSelectorSpecs("RPA_SELECTOR_PICKUP_STREET");
|
||||
expect(specs.some((s) => s.includes("Search address"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { assertRpaSelectorsEnv, getSelector } from "@/lib/rpa/selectors";
|
||||
|
||||
const REQUIRED = {
|
||||
RPA_SELECTOR_PICKUP_STREET: "role=textbox",
|
||||
RPA_SELECTOR_DELIVERY_STREET: "role=textbox",
|
||||
RPA_SELECTOR_ADDRESS_SUGGESTION: ".pac-item",
|
||||
RPA_SELECTOR_SUBMIT: "role=button",
|
||||
RPA_SELECTOR_QUOTE_WIDGET: "#react-quoter-landing-plugin",
|
||||
} as const;
|
||||
|
||||
describe("assertRpaSelectorsEnv", () => {
|
||||
it("FASTEST 留空时仍可通过启动校验", () => {
|
||||
vi.stubEnv("RPA_MOCK_MODE", "false");
|
||||
for (const [key, value] of Object.entries(REQUIRED)) {
|
||||
vi.stubEnv(key, value);
|
||||
}
|
||||
vi.stubEnv("RPA_SELECTOR_FASTEST_OPTION", "");
|
||||
expect(() => assertRpaSelectorsEnv()).not.toThrow();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("env 为空时使用内置双语回退", () => {
|
||||
vi.stubEnv("RPA_MOCK_MODE", "false");
|
||||
for (const key of Object.keys(REQUIRED)) {
|
||||
vi.stubEnv(key, "");
|
||||
}
|
||||
vi.stubEnv("RPA_SELECTOR_FASTEST_OPTION", "");
|
||||
expect(() => assertRpaSelectorsEnv()).not.toThrow();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("RPA_SELECTOR_QUOTE_WIDGET 显式空串时 getSelector 走内置回退,禁止空 selector", () => {
|
||||
vi.stubEnv("RPA_MOCK_MODE", "false");
|
||||
for (const [key, value] of Object.entries(REQUIRED)) {
|
||||
vi.stubEnv(key, value);
|
||||
}
|
||||
vi.stubEnv("RPA_SELECTOR_QUOTE_WIDGET", "");
|
||||
expect(getSelector("RPA_SELECTOR_QUOTE_WIDGET")).toBe(
|
||||
"#react-quoter-landing-plugin",
|
||||
);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,41 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("storage-state", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir && fs.existsSync(tmpDir)) {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("invalidateStorageState 删除 state 文件", async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-state-"));
|
||||
const stateFile = path.join(tmpDir, "state.json");
|
||||
fs.writeFileSync(stateFile, "{}");
|
||||
process.env.RPA_STORAGE_STATE_PATH = stateFile;
|
||||
|
||||
const { invalidateStorageState, storageStateExists } = await import(
|
||||
"@/lib/rpa/storage-state"
|
||||
);
|
||||
expect(storageStateExists()).toBe(true);
|
||||
invalidateStorageState();
|
||||
expect(storageStateExists()).toBe(false);
|
||||
});
|
||||
|
||||
it("getStorageStateForContext 存在时返回 storageState", async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-state-"));
|
||||
const stateFile = path.join(tmpDir, "state.json");
|
||||
fs.writeFileSync(stateFile, '{"cookies":[]}');
|
||||
process.env.RPA_STORAGE_STATE_PATH = stateFile;
|
||||
|
||||
const { getStorageStateForContext } = await import(
|
||||
"@/lib/rpa/storage-state"
|
||||
);
|
||||
expect(getStorageStateForContext()).toEqual({ storageState: stateFile });
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,25 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/workers/rpa/worker-state", () => ({
|
||||
listWorkerHeartbeats: vi.fn(),
|
||||
}));
|
||||
|
||||
import { isAnyRpaWorkerHealthy } from "@/lib/rpa/worker-health";
|
||||
import { listWorkerHeartbeats } from "@/workers/rpa/worker-state";
|
||||
|
||||
describe("isAnyRpaWorkerHealthy", () => {
|
||||
it("存在近期心跳时返回 true", async () => {
|
||||
vi.mocked(listWorkerHeartbeats).mockResolvedValue([
|
||||
{ workerId: "w1", lastSeenMs: Date.now() - 5_000 },
|
||||
]);
|
||||
await expect(isAnyRpaWorkerHealthy()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("无心跳或 Redis 异常时返回 false", async () => {
|
||||
vi.mocked(listWorkerHeartbeats).mockResolvedValue([]);
|
||||
await expect(isAnyRpaWorkerHealthy()).resolves.toBe(false);
|
||||
|
||||
vi.mocked(listWorkerHeartbeats).mockRejectedValue(new Error("redis down"));
|
||||
await expect(isAnyRpaWorkerHealthy()).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { listMockMothershipCandidates } from "@/modules/address/candidate-catalog";
|
||||
|
||||
describe("listMockMothershipCandidates", () => {
|
||||
it("Dallas 75201 返回 4 个 MotherShip 精确坐标", () => {
|
||||
const list = listMockMothershipCandidates({
|
||||
street: "5678 Distribution Dr",
|
||||
city: "Dallas",
|
||||
state: "TX",
|
||||
zip: "75201",
|
||||
});
|
||||
expect(list).toHaveLength(4);
|
||||
expect(list.map((c) => c.display_label)).toContain(
|
||||
"5678 Distribution Dr, Dallas, TX 75201",
|
||||
);
|
||||
});
|
||||
|
||||
it("唯一候选时长度为 1", () => {
|
||||
const list = listMockMothershipCandidates({
|
||||
street: "99 Unknown Rd",
|
||||
city: "Austin",
|
||||
state: "TX",
|
||||
zip: "78701",
|
||||
});
|
||||
expect(list).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,99 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/rpa/worker-health", () => ({
|
||||
isAnyRpaWorkerHealthy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rpa/spawn-address-candidates", () => ({
|
||||
spawnFetchMothershipAddressCandidates: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/workers/rpa/queue", () => ({
|
||||
addAddressCandidatesJob: vi.fn(),
|
||||
}));
|
||||
|
||||
const redisGet = vi.fn();
|
||||
|
||||
vi.mock("@/lib/redis", () => ({
|
||||
ensureRedisReady: vi.fn().mockResolvedValue(undefined),
|
||||
getRedis: vi.fn(() => ({
|
||||
setex: vi.fn().mockResolvedValue("OK"),
|
||||
get: redisGet,
|
||||
})),
|
||||
resetRedisClient: vi.fn(),
|
||||
}));
|
||||
|
||||
import { spawnFetchMothershipAddressCandidates } from "@/lib/rpa/spawn-address-candidates";
|
||||
import { isAnyRpaWorkerHealthy } from "@/lib/rpa/worker-health";
|
||||
import { requestMothershipCandidatesViaWorker } from "@/modules/address/candidates-queue";
|
||||
import { addAddressCandidatesJob } from "@/workers/rpa/queue";
|
||||
|
||||
const input = {
|
||||
pickup: {
|
||||
street: "1234 Warehouse Blvd",
|
||||
city: "Los Angeles",
|
||||
state: "CA",
|
||||
zip: "90001",
|
||||
},
|
||||
delivery: {
|
||||
street: "5678 Distribution Dr",
|
||||
city: "Dallas",
|
||||
state: "TX",
|
||||
zip: "75201",
|
||||
},
|
||||
};
|
||||
|
||||
const mockResult = {
|
||||
pickup_candidates: [{ display_label: "A", option_id: "1" }],
|
||||
delivery_candidates: [{ display_label: "B", option_id: "2" }],
|
||||
requires_selection: false,
|
||||
};
|
||||
|
||||
describe("requestMothershipCandidatesViaWorker", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
redisGet.mockReset();
|
||||
delete process.env.RPA_QUEUE_ENABLED;
|
||||
});
|
||||
|
||||
it("Worker 无心跳时默认走 spawn", async () => {
|
||||
delete process.env.RPA_PARKED_SESSION;
|
||||
vi.mocked(isAnyRpaWorkerHealthy).mockResolvedValue(false);
|
||||
vi.mocked(spawnFetchMothershipAddressCandidates).mockResolvedValue(
|
||||
mockResult,
|
||||
);
|
||||
|
||||
const result = await requestMothershipCandidatesViaWorker(input);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(spawnFetchMothershipAddressCandidates).toHaveBeenCalledWith(input);
|
||||
});
|
||||
|
||||
it("Worker 无心跳且 RPA_PARKED_SESSION=true 时抛错", async () => {
|
||||
process.env.RPA_QUEUE_ENABLED = "true";
|
||||
process.env.RPA_PARKED_SESSION = "true";
|
||||
vi.mocked(isAnyRpaWorkerHealthy).mockResolvedValue(false);
|
||||
|
||||
await expect(requestMothershipCandidatesViaWorker(input)).rejects.toMatchObject(
|
||||
{ code: "PAGE_LOAD_TIMEOUT" },
|
||||
);
|
||||
expect(spawnFetchMothershipAddressCandidates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Worker 有心跳时只走队列等待结果,不 spawn", async () => {
|
||||
process.env.RPA_QUEUE_ENABLED = "true";
|
||||
vi.mocked(isAnyRpaWorkerHealthy).mockResolvedValue(true);
|
||||
vi.mocked(addAddressCandidatesJob).mockResolvedValue(undefined);
|
||||
redisGet
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({ ok: true, data: mockResult }),
|
||||
);
|
||||
|
||||
const result = await requestMothershipCandidatesViaWorker(input);
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(addAddressCandidatesJob).toHaveBeenCalled();
|
||||
expect(spawnFetchMothershipAddressCandidates).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/axel/candidates", () => ({
|
||||
resolveAxelMothershipCandidates: vi.fn(),
|
||||
}));
|
||||
|
||||
import { resolveMothershipCandidates } from "@/modules/address/mothership-candidates";
|
||||
import { resolveAxelMothershipCandidates } from "@/lib/axel/candidates";
|
||||
|
||||
describe("resolveMothershipCandidates", () => {
|
||||
const input = {
|
||||
pickup: {
|
||||
street: "5678 Distribution Dr",
|
||||
city: "Dallas",
|
||||
state: "TX",
|
||||
zip: "75201",
|
||||
},
|
||||
delivery: {
|
||||
street: "1234 Warehouse Blvd",
|
||||
city: "Los Angeles",
|
||||
state: "CA",
|
||||
zip: "90001",
|
||||
},
|
||||
};
|
||||
|
||||
it("非 mock 模式统一走 axel search", async () => {
|
||||
process.env.RPA_MOCK_MODE = "false";
|
||||
vi.mocked(resolveAxelMothershipCandidates).mockResolvedValue({
|
||||
pickup_candidates: [
|
||||
{
|
||||
option_id: "ChIJpickup",
|
||||
display_label: "5678 Distribution Dr, Dallas, TX 75201",
|
||||
formatted_address: "5678 Distribution Dr, Dallas, TX 75201",
|
||||
street: "5678 Distribution Dr",
|
||||
city: "Dallas",
|
||||
state: "TX",
|
||||
zip: "75201",
|
||||
},
|
||||
],
|
||||
delivery_candidates: [
|
||||
{
|
||||
option_id: "ChIJdelivery",
|
||||
display_label: "1234 Warehouse Blvd, Los Angeles, CA 90001",
|
||||
formatted_address: "1234 Warehouse Blvd, Los Angeles, CA 90001",
|
||||
street: "1234 Warehouse Blvd",
|
||||
city: "Los Angeles",
|
||||
state: "CA",
|
||||
zip: "90001",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await resolveMothershipCandidates(input);
|
||||
expect(resolveAxelMothershipCandidates).toHaveBeenCalledWith(input);
|
||||
expect(result.pickup_candidates[0].option_id).toBe("ChIJpickup");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,114 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
quoteCacheMeta: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
alertLog: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: prismaMock }));
|
||||
|
||||
import { detectDeviation } from "@/modules/alert/deviation-detector";
|
||||
import type { RawQuoteTier } from "@/modules/pricing/engine";
|
||||
|
||||
const fourTiers = (): RawQuoteTier[] => [
|
||||
{
|
||||
service_level: "standard",
|
||||
rate_option: "lowest",
|
||||
carrier: "A",
|
||||
transit_days: "3",
|
||||
transit_description: "3d",
|
||||
raw_freight: 320,
|
||||
surcharges: 0,
|
||||
raw_total: 320,
|
||||
},
|
||||
{
|
||||
service_level: "standard",
|
||||
rate_option: "fastest",
|
||||
carrier: "A",
|
||||
transit_days: "2",
|
||||
transit_description: "2d",
|
||||
raw_freight: 380,
|
||||
surcharges: 0,
|
||||
raw_total: 380,
|
||||
},
|
||||
{
|
||||
service_level: "guaranteed",
|
||||
rate_option: "lowest",
|
||||
carrier: "B",
|
||||
transit_days: "3",
|
||||
transit_description: "3d",
|
||||
raw_freight: 420,
|
||||
surcharges: 0,
|
||||
raw_total: 420,
|
||||
},
|
||||
{
|
||||
service_level: "guaranteed",
|
||||
rate_option: "fastest",
|
||||
carrier: "B",
|
||||
transit_days: "2",
|
||||
transit_description: "2d",
|
||||
raw_freight: 480,
|
||||
surcharges: 0,
|
||||
raw_total: 480,
|
||||
},
|
||||
];
|
||||
|
||||
describe("detectDeviation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("TC-212 首次询价不触发偏差预警", async () => {
|
||||
prismaMock.quoteCacheMeta.findUnique.mockResolvedValue(null);
|
||||
prismaMock.quoteCacheMeta.create.mockResolvedValue({});
|
||||
|
||||
await detectDeviation("hash1", fourTiers(), "QTE001");
|
||||
|
||||
expect(prismaMock.alertLog.create).not.toHaveBeenCalled();
|
||||
expect(prismaMock.quoteCacheMeta.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("TC-210 偏差≥5% 写 PRICE_DEVIATION", async () => {
|
||||
prismaMock.quoteCacheMeta.findUnique.mockResolvedValue({
|
||||
cargoHash: "hash1",
|
||||
rawTotalStandard: 352,
|
||||
});
|
||||
prismaMock.quoteCacheMeta.update.mockResolvedValue({});
|
||||
prismaMock.alertLog.create.mockResolvedValue({});
|
||||
|
||||
const quotes = fourTiers();
|
||||
quotes[0].raw_total = 370;
|
||||
quotes[0].raw_freight = 370;
|
||||
|
||||
await detectDeviation("hash1", quotes, "QTE002");
|
||||
|
||||
expect(prismaMock.alertLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ alertType: "PRICE_DEVIATION" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("TC-211 偏差4.9% 不触发", async () => {
|
||||
prismaMock.quoteCacheMeta.findUnique.mockResolvedValue({
|
||||
cargoHash: "hash1",
|
||||
rawTotalStandard: 352,
|
||||
});
|
||||
prismaMock.quoteCacheMeta.update.mockResolvedValue({});
|
||||
|
||||
const quotes = fourTiers();
|
||||
quotes[0].raw_total = 369.25;
|
||||
quotes[0].raw_freight = 369.25;
|
||||
|
||||
await detectDeviation("hash1", quotes, "QTE003");
|
||||
|
||||
expect(prismaMock.alertLog.create).not.toHaveBeenCalled();
|
||||
expect(prismaMock.quoteCacheMeta.update).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,47 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
alertLog: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: prismaMock }));
|
||||
|
||||
import { writeAlert } from "@/modules/alert/service";
|
||||
|
||||
describe("writeAlert", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
prismaMock.alertLog.create.mockResolvedValue({ id: BigInt(1) });
|
||||
});
|
||||
|
||||
it("写入 alert_log 成功", async () => {
|
||||
await writeAlert("RPA_FAILED", {
|
||||
quoteId: "QTE_001",
|
||||
cargoHash: "abc123",
|
||||
detail: { reason: "timeout" },
|
||||
});
|
||||
|
||||
expect(prismaMock.alertLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
alertType: "RPA_FAILED",
|
||||
quoteId: "QTE_001",
|
||||
cargoHash: "abc123",
|
||||
status: "open",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("DB 写失败不抛异常", async () => {
|
||||
prismaMock.alertLog.create.mockRejectedValue(new Error("db down"));
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await expect(
|
||||
writeAlert("SECURITY", { detail: { reason: "test" } }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,65 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
auditLog: { create: vi.fn() },
|
||||
alertLog: { create: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: prismaMock }));
|
||||
|
||||
import { recordSecurityEvent, writeAudit } from "@/modules/audit/service";
|
||||
|
||||
describe("writeAudit", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
prismaMock.auditLog.create.mockResolvedValue({ id: BigInt(1) });
|
||||
prismaMock.alertLog.create.mockResolvedValue({ id: BigInt(1) });
|
||||
});
|
||||
|
||||
it("写入 audit_log 成功", async () => {
|
||||
await writeAudit("FORBIDDEN_ACCESS", "CUST_001", "QTE_B", {
|
||||
owner: "CUST_B",
|
||||
});
|
||||
|
||||
expect(prismaMock.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "FORBIDDEN_ACCESS",
|
||||
actorId: "CUST_001",
|
||||
resource: "QTE_B",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("DB 写失败不抛异常", async () => {
|
||||
prismaMock.auditLog.create.mockRejectedValue(new Error("db down"));
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await expect(
|
||||
writeAudit("TEST", "actor", "resource"),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordSecurityEvent", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
prismaMock.auditLog.create.mockResolvedValue({ id: BigInt(1) });
|
||||
prismaMock.alertLog.create.mockResolvedValue({ id: BigInt(1) });
|
||||
});
|
||||
|
||||
it("同时写 audit + SECURITY 预警", async () => {
|
||||
await recordSecurityEvent(
|
||||
"SECURITY_VALIDATION_BLOCKED",
|
||||
"CUST_001",
|
||||
"POST /api/quotes",
|
||||
{ message: "injection" },
|
||||
);
|
||||
|
||||
expect(prismaMock.auditLog.create).toHaveBeenCalled();
|
||||
expect(prismaMock.alertLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ alertType: "SECURITY" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,46 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
decodeToken,
|
||||
signToken,
|
||||
verifyToken,
|
||||
} from "@/modules/auth/jwt";
|
||||
|
||||
describe("modules/auth/jwt", () => {
|
||||
beforeEach(() => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-unit-tests";
|
||||
});
|
||||
|
||||
it("签发后校验通过", async () => {
|
||||
const token = await signToken({ sub: "ADMIN_001", role: "admin" });
|
||||
const payload = await verifyToken(token);
|
||||
expect(payload).toEqual({ sub: "ADMIN_001", role: "admin" });
|
||||
});
|
||||
|
||||
it("篡改 token 后校验失败", async () => {
|
||||
const token = await signToken({ sub: "ADMIN_001", role: "admin" });
|
||||
const tampered = `${token.slice(0, -4)}xxxx`;
|
||||
await expect(verifyToken(tampered)).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
});
|
||||
|
||||
it("过期 token 校验失败", async () => {
|
||||
const { SignJWT } = await import("jose");
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
const expired = await new SignJWT({ role: "admin" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject("ADMIN_001")
|
||||
.setIssuedAt(Math.floor(Date.now() / 1000) - 7200)
|
||||
.setExpirationTime(Math.floor(Date.now() / 1000) - 3600)
|
||||
.sign(secret);
|
||||
|
||||
await expect(verifyToken(expired)).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
});
|
||||
|
||||
it("decodeToken 可解析有效载荷", async () => {
|
||||
const token = await signToken({ sub: "ADMIN_001", role: "admin" });
|
||||
expect(decodeToken(token)).toEqual({ sub: "ADMIN_001", role: "admin" });
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { requirePermission } from "@/modules/auth/rbac";
|
||||
|
||||
describe("modules/auth/rbac", () => {
|
||||
const adminAuth = {
|
||||
type: "admin" as const,
|
||||
userId: "ADMIN_001",
|
||||
role: "admin" as const,
|
||||
};
|
||||
|
||||
const hostAuth = {
|
||||
type: "service" as const,
|
||||
customerId: "CUST_001",
|
||||
permissions: ["pricing:markup:write" as const],
|
||||
};
|
||||
|
||||
it("admin + alert:read 通过", () => {
|
||||
expect(() => requirePermission(adminAuth, "alert:read")).not.toThrow();
|
||||
});
|
||||
|
||||
it("admin + rpa:operate 通过", () => {
|
||||
expect(() => requirePermission(adminAuth, "rpa:operate")).not.toThrow();
|
||||
});
|
||||
|
||||
it("宿主 token + alert:read 拒绝", () => {
|
||||
expect(() => requirePermission(hostAuth, "alert:read")).toThrow(
|
||||
expect.objectContaining({ code: "FORBIDDEN" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("宿主 token + pricing:markup:write 通过", () => {
|
||||
expect(() =>
|
||||
requirePermission(hostAuth, "pricing:markup:write"),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,42 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
resetServiceTokenCache,
|
||||
verifyServiceToken,
|
||||
} from "@/modules/auth/service-token";
|
||||
|
||||
const DEMO_TOKENS = JSON.stringify({
|
||||
"demo-host-token": {
|
||||
customerId: "CUST_001",
|
||||
permissions: ["pricing:markup:write"],
|
||||
},
|
||||
});
|
||||
|
||||
describe("modules/auth/service-token", () => {
|
||||
beforeEach(() => {
|
||||
process.env.HOST_SERVICE_TOKENS = DEMO_TOKENS;
|
||||
resetServiceTokenCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.HOST_SERVICE_TOKENS;
|
||||
resetServiceTokenCache();
|
||||
});
|
||||
|
||||
it("正确 token + 匹配 customer_id 通过", () => {
|
||||
const result = verifyServiceToken("demo-host-token", "CUST_001");
|
||||
expect(result.customerId).toBe("CUST_001");
|
||||
expect(result.permissions).toContain("pricing:markup:write");
|
||||
});
|
||||
|
||||
it("customer_id 不匹配返回 403", () => {
|
||||
expect(() => verifyServiceToken("demo-host-token", "CUST_002")).toThrow(
|
||||
expect.objectContaining({ code: "FORBIDDEN" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("无效 token 返回 401", () => {
|
||||
expect(() => verifyServiceToken("invalid-token", "CUST_001")).toThrow(
|
||||
expect.objectContaining({ code: "UNAUTHORIZED" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,232 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
L1_TTL_SECONDS,
|
||||
L2_TTL_BASE_SECONDS,
|
||||
L2_TTL_JITTER_MAX_SECONDS,
|
||||
L3_TTL_SECONDS,
|
||||
LOCK_TTL_SECONDS,
|
||||
} from "@/lib/constants/cache";
|
||||
|
||||
type RedisMock = {
|
||||
store: Map<string, { value: string; expireAt?: number }>;
|
||||
get: ReturnType<typeof vi.fn>;
|
||||
set: ReturnType<typeof vi.fn>;
|
||||
del: ReturnType<typeof vi.fn>;
|
||||
exists: ReturnType<typeof vi.fn>;
|
||||
incr: ReturnType<typeof vi.fn>;
|
||||
expire: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createRedisMock(): RedisMock {
|
||||
const store = new Map<string, { value: string; expireAt?: number }>();
|
||||
|
||||
const isExpired = (key: string): boolean => {
|
||||
const entry = store.get(key);
|
||||
if (!entry?.expireAt) {
|
||||
return false;
|
||||
}
|
||||
return Date.now() > entry.expireAt;
|
||||
};
|
||||
|
||||
return {
|
||||
store,
|
||||
get: vi.fn(async (key: string) => {
|
||||
if (isExpired(key)) {
|
||||
store.delete(key);
|
||||
return null;
|
||||
}
|
||||
return store.get(key)?.value ?? null;
|
||||
}),
|
||||
set: vi.fn(
|
||||
async (
|
||||
key: string,
|
||||
value: string,
|
||||
...args: (string | number)[]
|
||||
): Promise<string | null> => {
|
||||
const nx = args.includes("NX");
|
||||
if (nx && store.has(key) && !isExpired(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let ttlSeconds: number | undefined;
|
||||
const exIndex = args.indexOf("EX");
|
||||
if (exIndex >= 0) {
|
||||
ttlSeconds = Number(args[exIndex + 1]);
|
||||
}
|
||||
|
||||
store.set(key, {
|
||||
value,
|
||||
expireAt: ttlSeconds ? Date.now() + ttlSeconds * 1000 : undefined,
|
||||
});
|
||||
return "OK";
|
||||
},
|
||||
),
|
||||
del: vi.fn(async (key: string) => {
|
||||
store.delete(key);
|
||||
return 1;
|
||||
}),
|
||||
exists: vi.fn(async (key: string) => {
|
||||
if (isExpired(key)) {
|
||||
store.delete(key);
|
||||
return 0;
|
||||
}
|
||||
return store.has(key) ? 1 : 0;
|
||||
}),
|
||||
incr: vi.fn(async (key: string) => {
|
||||
const current = store.get(key);
|
||||
const next = current ? Number(current.value) + 1 : 1;
|
||||
store.set(key, { value: String(next) });
|
||||
return next;
|
||||
}),
|
||||
expire: vi.fn(async (key: string, seconds: number) => {
|
||||
const entry = store.get(key);
|
||||
if (!entry) {
|
||||
return 0;
|
||||
}
|
||||
entry.expireAt = Date.now() + seconds * 1000;
|
||||
return 1;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
let redisMock: RedisMock;
|
||||
|
||||
vi.mock("@/lib/redis", () => ({
|
||||
getRedis: () => redisMock,
|
||||
}));
|
||||
|
||||
describe("modules/cache/redis-cache", () => {
|
||||
beforeEach(async () => {
|
||||
redisMock = createRedisMock();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
async function loadCache() {
|
||||
return import("@/modules/cache/redis-cache");
|
||||
}
|
||||
|
||||
it("L1 setL1 SETNX 不覆盖 + TTL 86400", async () => {
|
||||
const cache = await loadCache();
|
||||
const payload = { quote_id: "Q1", status: "done" };
|
||||
|
||||
expect(await cache.setL1("req-1", payload)).toBe(true);
|
||||
expect(await cache.setL1("req-1", { quote_id: "Q2" })).toBe(false);
|
||||
expect(await cache.getL1("req-1")).toEqual(payload);
|
||||
|
||||
const [, , , ttl] = redisMock.set.mock.calls[0];
|
||||
expect(ttl).toBe(L1_TTL_SECONDS);
|
||||
});
|
||||
|
||||
it("L2 TTL 在 180~210s 之间", async () => {
|
||||
const cache = await loadCache();
|
||||
const ttls = new Set<number>();
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
redisMock.store.clear();
|
||||
const ttl = await cache.setL2("hash-1", { quotes: [] });
|
||||
ttls.add(ttl);
|
||||
}
|
||||
|
||||
for (const ttl of ttls) {
|
||||
expect(ttl).toBeGreaterThanOrEqual(L2_TTL_BASE_SECONDS);
|
||||
expect(ttl).toBeLessThanOrEqual(
|
||||
L2_TTL_BASE_SECONDS + L2_TTL_JITTER_MAX_SECONDS,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("L3 TTL 1800s", async () => {
|
||||
const cache = await loadCache();
|
||||
await cache.setL3("hash-1", { quotes: [] });
|
||||
|
||||
const [, , , ttl] = redisMock.set.mock.calls[0];
|
||||
expect(ttl).toBe(L3_TTL_SECONDS);
|
||||
});
|
||||
|
||||
it("acquireLock SETNX TTL 5s", async () => {
|
||||
const cache = await loadCache();
|
||||
|
||||
expect(await cache.acquireLock("hash-1")).toBe(true);
|
||||
expect(await cache.acquireLock("hash-1")).toBe(false);
|
||||
|
||||
const [, , , ttl] = redisMock.set.mock.calls[0];
|
||||
expect(ttl).toBe(LOCK_TTL_SECONDS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modules/cache/rate-limiter", () => {
|
||||
beforeEach(async () => {
|
||||
redisMock = createRedisMock();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("60 次通过,第 61 次 RATE_LIMITED", async () => {
|
||||
const { checkRateLimit } = await import("@/modules/cache/rate-limiter");
|
||||
|
||||
for (let i = 0; i < 60; i++) {
|
||||
expect(await checkRateLimit("CUST_001")).toEqual({ allowed: true });
|
||||
}
|
||||
|
||||
expect(await checkRateLimit("CUST_001")).toEqual({
|
||||
allowed: false,
|
||||
errorCode: "RATE_LIMITED",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("modules/cache/ip-rate-limiter", () => {
|
||||
beforeEach(async () => {
|
||||
redisMock = createRedisMock();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("超 1000 次封禁 IP", async () => {
|
||||
const { checkIpRateLimit } = await import(
|
||||
"@/modules/cache/ip-rate-limiter"
|
||||
);
|
||||
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
expect(await checkIpRateLimit("1.2.3.4")).toEqual({ allowed: true });
|
||||
}
|
||||
|
||||
const blocked = await checkIpRateLimit("1.2.3.4");
|
||||
expect(blocked).toEqual({
|
||||
allowed: false,
|
||||
errorCode: "RATE_LIMITED",
|
||||
banned: true,
|
||||
});
|
||||
|
||||
expect(await checkIpRateLimit("1.2.3.4")).toEqual({
|
||||
allowed: false,
|
||||
errorCode: "RATE_LIMITED",
|
||||
banned: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("modules/cache/circuit-breaker", () => {
|
||||
beforeEach(async () => {
|
||||
redisMock = createRedisMock();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("连续 3 次失败熔断", async () => {
|
||||
const breaker = await import("@/modules/cache/circuit-breaker");
|
||||
|
||||
expect(await breaker.isCircuitOpen()).toBe(false);
|
||||
await breaker.recordRpaFailure();
|
||||
await breaker.recordRpaFailure();
|
||||
expect(await breaker.isCircuitOpen()).toBe(false);
|
||||
await breaker.recordRpaFailure();
|
||||
expect(await breaker.isCircuitOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("closeCircuit 清除熔断状态", async () => {
|
||||
const breaker = await import("@/modules/cache/circuit-breaker");
|
||||
|
||||
await breaker.openCircuit();
|
||||
expect(await breaker.isCircuitOpen()).toBe(true);
|
||||
await breaker.closeCircuit();
|
||||
expect(await breaker.isCircuitOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseMarkupInput } from "@/modules/pricing/markup-validation";
|
||||
|
||||
describe("parseMarkupInput", () => {
|
||||
it("默认百分比模式", () => {
|
||||
const result = parseMarkupInput({ markup_percent: 10 });
|
||||
expect(result).toEqual({
|
||||
markupType: "percent",
|
||||
markupPercent: 10,
|
||||
markupFixedAmount: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("固定金额模式", () => {
|
||||
const result = parseMarkupInput({
|
||||
markup_type: "fixed",
|
||||
markup_fixed_amount: 50.5,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
markupType: "fixed",
|
||||
markupPercent: 0,
|
||||
markupFixedAmount: 50.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("比例超过 30% 拒绝", () => {
|
||||
const result = parseMarkupInput({ markup_percent: 30.1 });
|
||||
expect(result).toMatchObject({
|
||||
code: "PERCENT_TOO_HIGH",
|
||||
message: "加价比例不能超过 30%",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getConfidenceScore } from "@/modules/quote/confidence";
|
||||
|
||||
describe("getConfidenceScore", () => {
|
||||
it("source=rpa → 0.95", () => {
|
||||
expect(getConfidenceScore("rpa")).toBe(0.95);
|
||||
});
|
||||
|
||||
it("source=cache → 0.95", () => {
|
||||
expect(getConfidenceScore("cache")).toBe(0.95);
|
||||
});
|
||||
|
||||
it("source=stale → 0.70", () => {
|
||||
expect(getConfidenceScore("stale")).toBe(0.7);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
shouldConsumeRpaRetry,
|
||||
} from "@/modules/quote/fallback-orchestrator";
|
||||
import { RpaError } from "@/modules/rpa/errors";
|
||||
|
||||
describe("fallback entry errors (task-129)", () => {
|
||||
it("QUOTE_ENTRY_UNAVAILABLE 不消耗重试配额", () => {
|
||||
expect(
|
||||
shouldConsumeRpaRetry(
|
||||
new RpaError("QUOTE_ENTRY_UNAVAILABLE", "入口不可用", {
|
||||
retryable: false,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ADDRESS_SUGGESTION_NOT_FOUND 不消耗重试配额", () => {
|
||||
expect(
|
||||
shouldConsumeRpaRetry(
|
||||
new RpaError("ADDRESS_SUGGESTION_NOT_FOUND", "联想失败", {
|
||||
retryable: false,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("PAGE_LOAD_TIMEOUT 仍可重试", () => {
|
||||
expect(
|
||||
shouldConsumeRpaRetry(
|
||||
new RpaError("PAGE_LOAD_TIMEOUT", "超时"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,108 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
checkIdempotency,
|
||||
saveIdempotency,
|
||||
} from "@/modules/quote/idempotency";
|
||||
|
||||
vi.mock("@/modules/cache/redis-cache", () => ({
|
||||
getL1: vi.fn(),
|
||||
setL1: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
idempotencyRecord: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
quoteRecord: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { getL1, setL1 } from "@/modules/cache/redis-cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const mockedGetL1 = vi.mocked(getL1);
|
||||
const mockedSetL1 = vi.mocked(setL1);
|
||||
const mockedFindUnique = vi.mocked(prisma.idempotencyRecord.findUnique);
|
||||
const mockedCreate = vi.mocked(prisma.idempotencyRecord.create);
|
||||
const mockedQuoteFind = vi.mocked(prisma.quoteRecord.findUnique);
|
||||
|
||||
describe("checkIdempotency", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("L1 命中 → 返回 quote_id", async () => {
|
||||
mockedGetL1.mockResolvedValue({
|
||||
quoteId: "QTE_20260616_0001",
|
||||
response: { quote_id: "QTE_20260616_0001", status: "done" },
|
||||
});
|
||||
|
||||
const result = await checkIdempotency("550e8400-e29b-41d4-a716-446655440000");
|
||||
expect(result.hit).toBe(true);
|
||||
if (result.hit) {
|
||||
expect(result.quoteId).toBe("QTE_20260616_0001");
|
||||
}
|
||||
});
|
||||
|
||||
it("L1 未命中、DB 命中 → 回填 L1", async () => {
|
||||
mockedGetL1.mockResolvedValue(null);
|
||||
mockedFindUnique.mockResolvedValue({
|
||||
quoteId: "QTE_20260616_0002",
|
||||
} as never);
|
||||
mockedQuoteFind.mockResolvedValue({
|
||||
quoteId: "QTE_20260616_0002",
|
||||
status: "done",
|
||||
} as never);
|
||||
|
||||
const result = await checkIdempotency("550e8400-e29b-41d4-a716-446655440000");
|
||||
expect(result.hit).toBe(true);
|
||||
expect(mockedSetL1).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("均未命中 → hit=false", async () => {
|
||||
mockedGetL1.mockResolvedValue(null);
|
||||
mockedFindUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await checkIdempotency("550e8400-e29b-41d4-a716-446655440000");
|
||||
expect(result.hit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveIdempotency", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedCreate.mockResolvedValue({} as never);
|
||||
mockedSetL1.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("写入 DB + Redis", async () => {
|
||||
await saveIdempotency(
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"CUST_001",
|
||||
"QTE_20260616_0001",
|
||||
{ quote_id: "QTE_20260616_0001", status: "done" },
|
||||
);
|
||||
expect(mockedCreate).toHaveBeenCalled();
|
||||
expect(mockedSetL1).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("P2002 冲突 → 静默返回", async () => {
|
||||
mockedCreate.mockRejectedValue({ code: "P2002" });
|
||||
mockedFindUnique.mockResolvedValue({
|
||||
quoteId: "QTE_20260616_0001",
|
||||
} as never);
|
||||
|
||||
await expect(
|
||||
saveIdempotency(
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"CUST_001",
|
||||
"QTE_20260616_0001",
|
||||
{},
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertFourTiers,
|
||||
normalizeToFourTiers,
|
||||
} from "@/modules/quote/quote-completeness";
|
||||
|
||||
const FOUR_TIERS = [
|
||||
{ service_level: "standard", rate_option: "lowest" },
|
||||
{ service_level: "standard", rate_option: "fastest" },
|
||||
{ service_level: "guaranteed", rate_option: "lowest" },
|
||||
{ service_level: "guaranteed", rate_option: "fastest" },
|
||||
];
|
||||
|
||||
describe("assertFourTiers", () => {
|
||||
it("4 档 → 通过", () => {
|
||||
expect(() => assertFourTiers(FOUR_TIERS)).not.toThrow();
|
||||
});
|
||||
|
||||
it("仅 2 档 lowest → normalize 后通过", () => {
|
||||
expect(() =>
|
||||
assertFourTiers([
|
||||
{ service_level: "standard", rate_option: "lowest" },
|
||||
{ service_level: "guaranteed", rate_option: "lowest" },
|
||||
]),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("缺 guaranteed 整档 → 仅含 standard/lowest 仍通过", () => {
|
||||
expect(() =>
|
||||
assertFourTiers(
|
||||
FOUR_TIERS.filter((t) => t.service_level !== "guaranteed"),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("有 guaranteed/lowest 无 fastest → 通过", () => {
|
||||
const incomplete = FOUR_TIERS.filter(
|
||||
(t) => !(t.service_level === "guaranteed" && t.rate_option === "fastest"),
|
||||
);
|
||||
expect(() => assertFourTiers(incomplete)).not.toThrow();
|
||||
});
|
||||
|
||||
it("含 bestValue 扩展档 → 保留 API 返回的全部档位", () => {
|
||||
const six = [
|
||||
...FOUR_TIERS,
|
||||
{ service_level: "standard", rate_option: "bestValue" },
|
||||
{ service_level: "guaranteed", rate_option: "bestValue" },
|
||||
];
|
||||
const normalized = normalizeToFourTiers(six);
|
||||
expect(normalized).toHaveLength(6);
|
||||
expect(() => assertFourTiers(six)).not.toThrow();
|
||||
});
|
||||
|
||||
it("2+1 动态档位全部保留", () => {
|
||||
const tiers = [
|
||||
{ service_level: "standard", rate_option: "lowest" },
|
||||
{ service_level: "standard", rate_option: "fastest" },
|
||||
{ service_level: "guaranteed", rate_option: "lowest" },
|
||||
];
|
||||
expect(normalizeToFourTiers(tiers)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("3+3 动态档位全部保留", () => {
|
||||
const tiers = [
|
||||
{ service_level: "standard", rate_option: "lowest" },
|
||||
{ service_level: "standard", rate_option: "fastest" },
|
||||
{ service_level: "standard", rate_option: "bestValue" },
|
||||
{ service_level: "guaranteed", rate_option: "lowest" },
|
||||
{ service_level: "guaranteed", rate_option: "fastest" },
|
||||
{ service_level: "guaranteed", rate_option: "bestValue" },
|
||||
];
|
||||
expect(normalizeToFourTiers(tiers)).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RpaError } from "@/modules/rpa/errors";
|
||||
import {
|
||||
formatQuoteErrorMessage,
|
||||
resolveQuoteFailureCode,
|
||||
resolveQuoteFailureMessage,
|
||||
} from "@/modules/quote/quote-error-messages";
|
||||
|
||||
describe("quote-error-messages", () => {
|
||||
it("RpaError 保留细分 error_code", () => {
|
||||
expect(
|
||||
resolveQuoteFailureCode(
|
||||
new RpaError("ADDRESS_SUGGESTION_NOT_FOUND", "未找到地址"),
|
||||
),
|
||||
).toBe("ADDRESS_SUGGESTION_NOT_FOUND");
|
||||
});
|
||||
|
||||
it("RpaError 使用原始 message 作为用户文案", () => {
|
||||
const msg = resolveQuoteFailureMessage(
|
||||
new RpaError("STRUCT_CHANGE", "login 页且无 Mothership 凭据"),
|
||||
"QUOTE_ENTRY_UNAVAILABLE",
|
||||
);
|
||||
expect(msg).toBe("login 页且无 Mothership 凭据");
|
||||
});
|
||||
|
||||
it("formatQuoteErrorMessage 优先 storedMessage", () => {
|
||||
expect(
|
||||
formatQuoteErrorMessage("QUOTE_UNAVAILABLE", "自定义原因"),
|
||||
).toBe("自定义原因");
|
||||
});
|
||||
|
||||
it("formatQuoteErrorMessage 按 code 回退", () => {
|
||||
expect(formatQuoteErrorMessage("QUOTE_TIMEOUT")).toContain("420 秒");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
generateQuoteId,
|
||||
handleQuoteIdConflict,
|
||||
isQuoteIdUniqueConflict,
|
||||
} from "@/modules/quote/quote-id";
|
||||
import { QuoteIdConflictError } from "@/modules/quote/types";
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
quoteRecord: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const mockedFindFirst = vi.mocked(prisma.quoteRecord.findFirst);
|
||||
const mockedAuditCreate = vi.mocked(prisma.auditLog.create);
|
||||
|
||||
describe("generateQuoteId", () => {
|
||||
beforeEach(() => {
|
||||
mockedFindFirst.mockReset();
|
||||
});
|
||||
|
||||
it("首日生成 _0001", async () => {
|
||||
mockedFindFirst.mockResolvedValue(null);
|
||||
const id = await generateQuoteId();
|
||||
expect(id).toMatch(/^QTE_\d{8}_0001$/);
|
||||
});
|
||||
|
||||
it("递增序号", async () => {
|
||||
mockedFindFirst.mockResolvedValue({
|
||||
quoteId: "QTE_20260616_0005",
|
||||
} as never);
|
||||
const id = await generateQuoteId();
|
||||
expect(id).toMatch(/_0006$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isQuoteIdUniqueConflict", () => {
|
||||
it("P2002 quote_id → true", () => {
|
||||
expect(
|
||||
isQuoteIdUniqueConflict({
|
||||
code: "P2002",
|
||||
meta: { target: ["quote_id"] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("其他错误 → false", () => {
|
||||
expect(isQuoteIdUniqueConflict({ code: "P2002", meta: { target: ["email"] } })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleQuoteIdConflict", () => {
|
||||
it("写 audit 并抛 QuoteIdConflictError", async () => {
|
||||
mockedAuditCreate.mockResolvedValue({} as never);
|
||||
await expect(
|
||||
handleQuoteIdConflict("QTE_20260616_0001", "CUST_001"),
|
||||
).rejects.toThrow(QuoteIdConflictError);
|
||||
expect(mockedAuditCreate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRunPaths } from "@/scripts/human-commit-baseline/paths";
|
||||
|
||||
describe("human-commit-baseline paths", () => {
|
||||
it("为 runId 生成完整产物路径", () => {
|
||||
const p = createRunPaths("abc12345");
|
||||
expect(p.root).toContain("human-commit-baseline");
|
||||
expect(p.root).toContain("abc12345");
|
||||
expect(p.har).toContain("baseline.har");
|
||||
expect(p.trace).toContain("trace.zip");
|
||||
expect(p.triggerAfterPickup).toContain("after-pickup");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
diffHumanRpaNetwork,
|
||||
summarizeNetwork,
|
||||
type NetworkEvent,
|
||||
} from "@/scripts/human-commit-baseline/network-diff";
|
||||
|
||||
const humanSample: NetworkEvent[] = [
|
||||
{
|
||||
ts: 100,
|
||||
iso: "",
|
||||
phase: "page_loaded",
|
||||
kind: "request",
|
||||
method: "POST",
|
||||
url: "https://services.mothership.com/axel/location/search",
|
||||
isLocationSearch: true,
|
||||
},
|
||||
{
|
||||
ts: 200,
|
||||
iso: "",
|
||||
phase: "page_loaded",
|
||||
kind: "response",
|
||||
method: "POST",
|
||||
url: "https://services.mothership.com/axel/location/search",
|
||||
status: 200,
|
||||
isLocationSearch: true,
|
||||
},
|
||||
{
|
||||
ts: 900,
|
||||
iso: "",
|
||||
phase: "page_loaded",
|
||||
kind: "request",
|
||||
method: "GET",
|
||||
url: "https://services.mothership.com/axel/location/place/ChIJabc",
|
||||
isPlaceCommit: true,
|
||||
},
|
||||
{
|
||||
ts: 1100,
|
||||
iso: "",
|
||||
phase: "page_loaded",
|
||||
kind: "response",
|
||||
method: "GET",
|
||||
url: "https://services.mothership.com/axel/location/place/ChIJabc",
|
||||
status: 200,
|
||||
isPlaceCommit: true,
|
||||
},
|
||||
];
|
||||
|
||||
const rpaMissingPlace: NetworkEvent[] = [
|
||||
{
|
||||
ts: 100,
|
||||
iso: "",
|
||||
phase: "rpa_pickup",
|
||||
kind: "request",
|
||||
method: "POST",
|
||||
url: "https://services.mothership.com/axel/location/search",
|
||||
isLocationSearch: true,
|
||||
},
|
||||
{
|
||||
ts: 200,
|
||||
iso: "",
|
||||
phase: "rpa_pickup",
|
||||
kind: "response",
|
||||
method: "POST",
|
||||
url: "https://services.mothership.com/axel/location/search",
|
||||
status: 200,
|
||||
isLocationSearch: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe("network-diff", () => {
|
||||
it("真人 sample 识别 search→place 完整链", () => {
|
||||
const s = summarizeNetwork(humanSample, "human");
|
||||
expect(s.placeGet200).toBe(1);
|
||||
expect(s.chains[0]?.complete).toBe(true);
|
||||
expect(s.chains[0]?.gapSearchResponseToPlaceRequestMs).toBe(700);
|
||||
});
|
||||
|
||||
it("RPA 缺 place 时 verdict=RPA_MISSING_PLACE", () => {
|
||||
const humanFull = [...humanSample, ...humanSample.map((e, i) => ({ ...e, ts: e.ts + 2000 + i }))];
|
||||
const d = diffHumanRpaNetwork(humanFull, rpaMissingPlace, "human1", "rpa1");
|
||||
expect(d.verdict).toBe("RPA_MISSING_PLACE");
|
||||
expect(d.gaps.some((g) => g.includes("place"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
diffAxelTraces,
|
||||
normalizeAxelSequence,
|
||||
type AxelTraceEvent,
|
||||
} from "../../../scripts/lib/axel-event-tracer";
|
||||
|
||||
describe("axel-event-tracer", () => {
|
||||
it("diff 识别 RPA 缺失 place commit", () => {
|
||||
const manual: AxelTraceEvent[] = [
|
||||
{
|
||||
at: 1,
|
||||
phase: "manual-pickup",
|
||||
method: "POST",
|
||||
url: "https://services.mothership.com/axel/location/search",
|
||||
status: 200,
|
||||
kind: "search",
|
||||
searchPlaceIds: ["ChIJ_manual"],
|
||||
},
|
||||
{
|
||||
at: 2,
|
||||
phase: "manual-pickup",
|
||||
method: "GET",
|
||||
url: "https://services.mothership.com/axel/location/place/ChIJ_manual",
|
||||
status: 200,
|
||||
kind: "place",
|
||||
placeId: "ChIJ_manual",
|
||||
},
|
||||
];
|
||||
const rpa: AxelTraceEvent[] = [
|
||||
{
|
||||
at: 1,
|
||||
phase: "rpa-fillAddress",
|
||||
method: "POST",
|
||||
url: "https://services.mothership.com/axel/location/search",
|
||||
status: 200,
|
||||
kind: "search",
|
||||
searchPlaceIds: ["ChIJ_manual"],
|
||||
},
|
||||
];
|
||||
const diff = diffAxelTraces(manual, rpa);
|
||||
expect(diff.verdict).toBe("RPA_MISSING_PLACE_COMMIT");
|
||||
expect(diff.manualPlaceCommits).toEqual(["ChIJ_manual"]);
|
||||
expect(diff.rpaPlaceCommits).toEqual([]);
|
||||
expect(normalizeAxelSequence(manual).some((l) => l.includes("GET place/ChIJ_manual"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
countAxelQuoteTiers,
|
||||
extractAxelQuoteContract,
|
||||
} from "@/scripts/phase0/lib/axel-quote-contract";
|
||||
import { pickBestQuoteEntry, scoreQuoteBody } from "@/scripts/phase0/lib/har-parse";
|
||||
import { buildQuoteContractSchema } from "@/scripts/phase0/lib/schema-infer";
|
||||
|
||||
const AXEL_SAMPLE = {
|
||||
data: {
|
||||
rates: {
|
||||
standard: {
|
||||
lowest: { price: 100, days: 5, serviceType: "lowest" },
|
||||
fastest: { price: 200, days: 2, serviceType: "fastest" },
|
||||
bestValue: { price: 150, days: 3, serviceType: "bestValue" },
|
||||
},
|
||||
guaranteed: {
|
||||
bestValue: { price: 250, days: 2, serviceType: "bestValue" },
|
||||
},
|
||||
},
|
||||
availableRates: {
|
||||
rate_a: { price: 100, serviceLevel: "standard", serviceType: "lowest" },
|
||||
rate_b: { price: 200, serviceLevel: "standard", serviceType: "fastest" },
|
||||
rate_c: { price: 110, serviceLevel: "guaranteed", serviceType: "lowest" },
|
||||
rate_d: { price: 220, serviceLevel: "guaranteed", serviceType: "fastest" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("phase0 axel-quote-contract", () => {
|
||||
it("识别 data.rates 四档与 availableRates", () => {
|
||||
const c = extractAxelQuoteContract(AXEL_SAMPLE);
|
||||
expect(c.fourTierCoverage.standardLowest).toBe(true);
|
||||
expect(c.fourTierCoverage.standardFastest).toBe(true);
|
||||
expect(c.availableRateCount).toBe(4);
|
||||
expect(countAxelQuoteTiers(AXEL_SAMPLE).pricedLeafCount).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase0 har-parse", () => {
|
||||
it("pickBestQuoteEntry 识别 axel/quote 响应", () => {
|
||||
const har = {
|
||||
log: {
|
||||
entries: [
|
||||
{
|
||||
request: {
|
||||
url: "https://services.mothership.com/axel/quote",
|
||||
method: "POST",
|
||||
},
|
||||
response: {
|
||||
status: 200,
|
||||
content: {
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify(AXEL_SAMPLE),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const picked = pickBestQuoteEntry(har.log!.entries!);
|
||||
expect(picked).not.toBeNull();
|
||||
expect(scoreQuoteBody(picked!.body)).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase0 schema-infer", () => {
|
||||
it("buildQuoteContractSchema 产出含 body 与 sourceHar", () => {
|
||||
const doc = buildQuoteContractSchema({
|
||||
body: AXEL_SAMPLE,
|
||||
harEntry: {
|
||||
url: "https://services.mothership.com/axel/quote",
|
||||
method: "POST",
|
||||
status: 200,
|
||||
},
|
||||
});
|
||||
expect(doc.$schema).toContain("json-schema.org");
|
||||
expect(doc.sourceHar.url).toContain("axel/quote");
|
||||
expect(doc.body.type).toBe("object");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pickBestMothershipCandidate } from "@/scripts/lib/pick-mothership-candidate";
|
||||
|
||||
describe("pickBestMothershipCandidate", () => {
|
||||
it("优先匹配门牌号与城市,惩罚 Bridge 歧义", () => {
|
||||
const picked = pickBestMothershipCandidate(
|
||||
[
|
||||
{
|
||||
option_id: "ChIJ-bridge",
|
||||
display_label: "1234 Market Street Bridge, Philadelphia, PA, USA",
|
||||
formatted_address: "1234 Market Street Bridge, Philadelphia, PA, USA",
|
||||
street: "1234 Market Street Bridge",
|
||||
city: "Philadelphia",
|
||||
state: "PA",
|
||||
zip: "",
|
||||
},
|
||||
{
|
||||
option_id: "ChIJ-good",
|
||||
display_label: "1234 Market Street, Philadelphia, PA, USA",
|
||||
formatted_address: "1234 Market Street, Philadelphia, PA, USA",
|
||||
street: "1234 Market Street",
|
||||
city: "Philadelphia",
|
||||
state: "PA",
|
||||
zip: "",
|
||||
},
|
||||
],
|
||||
{ street: "1234 Market Street", city: "Philadelphia", state: "PA" },
|
||||
);
|
||||
expect(picked?.option_id).toBe("ChIJ-good");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,46 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SCRIPT_PATH = join(process.cwd(), "scripts", "record-mothership.sh");
|
||||
|
||||
describe("record-mothership.sh (task-121)", () => {
|
||||
const content = readFileSync(SCRIPT_PATH, "utf8");
|
||||
|
||||
it("存在且包含 headed 与 codegen 启动", () => {
|
||||
expect(content).toContain("RPA_HEADLESS=false");
|
||||
expect(content).toContain("playwright");
|
||||
expect(content).toContain("codegen");
|
||||
});
|
||||
|
||||
it("禁止 GD-15 黑名单 URL", () => {
|
||||
expect(content).toContain("dashboard.mothership.com");
|
||||
expect(content).toContain("www.mothership.com/quote");
|
||||
expect(content).toContain("check_url");
|
||||
});
|
||||
|
||||
it("产出录制脚本与 storageState 路径", () => {
|
||||
expect(content).toContain(".rpa/recordings");
|
||||
expect(content).toContain("mothership-storage.json");
|
||||
expect(content).toContain("--save-storage");
|
||||
});
|
||||
});
|
||||
|
||||
const TS_SCRIPT_PATH = join(process.cwd(), "scripts", "record-mothership.ts");
|
||||
|
||||
describe("record-mothership.ts (Windows 跨平台)", () => {
|
||||
const content = readFileSync(TS_SCRIPT_PATH, "utf8");
|
||||
|
||||
it("存在且包含 headed codegen 与 GD-15 校验", () => {
|
||||
expect(content).toContain('RPA_HEADLESS: "false"');
|
||||
expect(content).toContain("playwright");
|
||||
expect(content).toContain("codegen");
|
||||
expect(content).toContain("isForbiddenQuoteUrl");
|
||||
});
|
||||
|
||||
it("产出录制脚本与 storageState 路径", () => {
|
||||
expect(content).toContain(".rpa/recordings");
|
||||
expect(content).toContain("mothership-storage.json");
|
||||
expect(content).toContain("--save-storage");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AddressCommitStateMachine } from "@/workers/rpa/address-adapter/commit-state-machine";
|
||||
import { formatDiagnosticsFailure } from "@/workers/rpa/address-adapter/diagnostics";
|
||||
import { getRpaAddressMode } from "@/lib/rpa/address-mode";
|
||||
|
||||
describe("AddressCommitStateMachine", () => {
|
||||
it("COMMITTED 可进入下游", () => {
|
||||
const sm = new AddressCommitStateMachine();
|
||||
sm.transition("COMMITTED");
|
||||
expect(sm.canProceedToCargo()).toBe(true);
|
||||
expect(() => sm.assertCommittedForDownstream("pickup")).not.toThrow();
|
||||
});
|
||||
|
||||
it("FAILED 禁止进入 cargo", () => {
|
||||
const sm = new AddressCommitStateMachine();
|
||||
sm.transition("FAILED");
|
||||
expect(sm.canProceedToCargo()).toBe(false);
|
||||
expect(() => sm.assertCommittedForDownstream("pickup")).toThrow(/COMMITTED/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDiagnosticsFailure", () => {
|
||||
it("区分 no_place_request", () => {
|
||||
const msg = formatDiagnosticsFailure({
|
||||
address: "123 Main",
|
||||
placeId: "ChIJ_x",
|
||||
method: "keyboard",
|
||||
events: [],
|
||||
placeRequestObserved: false,
|
||||
status: "no_place_request",
|
||||
commitState: "FAILED",
|
||||
});
|
||||
expect(msg).toContain("未发起 GET place 请求");
|
||||
expect(msg).toContain("selection event");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRpaAddressMode", () => {
|
||||
it("默认 real", () => {
|
||||
const prev = process.env.RPA_ADDRESS_MODE;
|
||||
delete process.env.RPA_ADDRESS_MODE;
|
||||
expect(getRpaAddressMode()).toBe("real");
|
||||
process.env.RPA_ADDRESS_MODE = prev;
|
||||
});
|
||||
|
||||
it("mock 显式启用", () => {
|
||||
const prev = process.env.RPA_ADDRESS_MODE;
|
||||
process.env.RPA_ADDRESS_MODE = "mock";
|
||||
expect(getRpaAddressMode()).toBe("mock");
|
||||
process.env.RPA_ADDRESS_MODE = prev;
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,225 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import type { Page } from "playwright";
|
||||
import type { QuoteRequest } from "@/modules/providers/quote-provider";
|
||||
import { fillAddressStep } from "@/workers/rpa/kernel/steps/address-step";
|
||||
import type { RPAContext } from "@/workers/rpa/kernel/context";
|
||||
|
||||
const mockSelectAddress = vi.fn();
|
||||
const mockWaitForCommit = vi.fn();
|
||||
const mockGetDiagnostics = vi.fn();
|
||||
const mockGetCommitState = vi.fn();
|
||||
|
||||
vi.mock("@/workers/rpa/address-adapter", () => ({
|
||||
createAddressAdapter: vi.fn(() => ({
|
||||
selectAddress: mockSelectAddress,
|
||||
waitForCommit: mockWaitForCommit,
|
||||
getCommittedAddress: vi.fn(),
|
||||
getDiagnostics: mockGetDiagnostics,
|
||||
getCommitState: mockGetCommitState,
|
||||
})),
|
||||
formatDiagnosticsFailure: vi.fn((d) => d.status ?? "failed"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rpa/address-mode", () => ({
|
||||
isRpaAddressMockMode: vi.fn().mockReturnValue(false),
|
||||
getRpaAddressMode: vi.fn().mockReturnValue("real"),
|
||||
}));
|
||||
|
||||
vi.mock("@/workers/rpa/address-commit", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/workers/rpa/address-commit")>();
|
||||
return {
|
||||
...actual,
|
||||
blurAddressEditing: vi.fn().mockResolvedValue(undefined),
|
||||
dismissAddressSuggestionDropdown: vi.fn().mockResolvedValue(undefined),
|
||||
settlePickupBeforeDelivery: vi.fn().mockResolvedValue(undefined),
|
||||
assertDualAddressStable: vi.fn().mockResolvedValue(undefined),
|
||||
ensureDualAddressCommitted: vi.fn().mockResolvedValue(undefined),
|
||||
isPickupReadyForDeliveryInput: vi.fn().mockResolvedValue(true),
|
||||
lockPickupIntoChip: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/workers/rpa/address-suggestions", () => ({
|
||||
isAddressSuggestionDropdownOpen: vi.fn().mockResolvedValue(false),
|
||||
getAddressConfirmationSurfaceText: vi.fn().mockResolvedValue(""),
|
||||
}));
|
||||
|
||||
vi.mock("@/workers/rpa/address-side", () => ({
|
||||
openAddressSide: vi.fn().mockResolvedValue(undefined),
|
||||
visibleStreetInput: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/workers/rpa/cargo-lock", () => ({
|
||||
settleAddressFieldsForCargo: vi.fn().mockResolvedValue(undefined),
|
||||
assertBothAddressesLocked: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/workers/rpa/kernel/step-guard", () => ({
|
||||
runKernelSubStep: vi.fn(
|
||||
(_step: string, _label: string, fn: () => Promise<unknown>) => fn(),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/workers/rpa/page-prep", () => ({
|
||||
stabilizeQuoteLandingPage: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { openAddressSide } from "@/workers/rpa/address-side";
|
||||
|
||||
function baseRequest(overrides?: Partial<QuoteRequest>): QuoteRequest {
|
||||
return {
|
||||
cargoHash: "hash",
|
||||
quoteSessionId: undefined,
|
||||
pickup: {
|
||||
street: "1234 Warehouse Street",
|
||||
city: "Farmers Branch",
|
||||
state: "TX",
|
||||
zip: "75234",
|
||||
placeId: "p1",
|
||||
formattedAddress: "1234 Warehouse Street Farmers Branch TX",
|
||||
selectedFromSuggestions: true,
|
||||
mothershipOptionId: "o1",
|
||||
mothershipDisplayLabel: "1234 Warehouse Road Farmers Branch TX",
|
||||
selectedFromMothership: true,
|
||||
},
|
||||
delivery: {
|
||||
street: "5678 Distribution Way",
|
||||
city: "Fort Worth",
|
||||
state: "TX",
|
||||
zip: "76106",
|
||||
placeId: "d1",
|
||||
formattedAddress: "5678 Distribution Way Fort Worth TX",
|
||||
selectedFromSuggestions: true,
|
||||
mothershipOptionId: "o2",
|
||||
mothershipDisplayLabel: "5678 Distribution Drive Fort Worth TX",
|
||||
selectedFromMothership: true,
|
||||
},
|
||||
weightLb: 500,
|
||||
dimsIn: { l: 48, w: 40, h: 48 },
|
||||
palletCount: 2,
|
||||
cargoType: "general_freight",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockCtx(widgetText = ""): RPAContext {
|
||||
const page = {
|
||||
keyboard: { press: vi.fn().mockResolvedValue(undefined) },
|
||||
waitForTimeout: vi.fn().mockResolvedValue(undefined),
|
||||
locator: vi.fn().mockReturnValue({
|
||||
innerText: vi.fn().mockResolvedValue(widgetText),
|
||||
}),
|
||||
url: vi.fn().mockReturnValue("https://example.com"),
|
||||
} as unknown as Page;
|
||||
|
||||
return {
|
||||
page,
|
||||
selectors: {
|
||||
resolve: vi.fn().mockReturnValue("#widget"),
|
||||
specs: vi.fn().mockReturnValue([]),
|
||||
widget: vi.fn(),
|
||||
},
|
||||
exec: { evaluateOnLocator: vi.fn() },
|
||||
runtime: { evaluateOnLocator: vi.fn() },
|
||||
getWidgetScrollAt: vi.fn().mockReturnValue(0),
|
||||
setWidgetScrollAt: vi.fn(),
|
||||
state: {
|
||||
markAddressSide: vi.fn(),
|
||||
settleAddressEditing: vi.fn().mockResolvedValue(undefined),
|
||||
axelPlaceLocations: { pickup: { placeId: "ChIJ_test" } },
|
||||
},
|
||||
} as unknown as RPAContext;
|
||||
}
|
||||
|
||||
describe("fillAddressStep", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSelectAddress.mockImplementation(async (params) => {
|
||||
params.ctx.state.axelPlaceLocations[params.side] = {
|
||||
placeId: "ChIJ_test",
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
placeId: "ChIJ_test",
|
||||
formattedAddress: "test",
|
||||
committed: true,
|
||||
};
|
||||
});
|
||||
mockWaitForCommit.mockResolvedValue({
|
||||
success: true,
|
||||
placeId: "ChIJ_test",
|
||||
formattedAddress: "test",
|
||||
committed: true,
|
||||
});
|
||||
mockGetCommitState.mockReturnValue("COMMITTED");
|
||||
mockGetDiagnostics.mockReturnValue({
|
||||
address: "test",
|
||||
placeId: "ChIJ_test",
|
||||
method: "mothership",
|
||||
events: [],
|
||||
placeRequestObserved: true,
|
||||
status: "ok",
|
||||
commitState: "COMMITTED",
|
||||
});
|
||||
});
|
||||
|
||||
it("有 quote_session 时仍走完整填表(禁止跳过)", async () => {
|
||||
const ctx = mockCtx(
|
||||
"1234 Warehouse Road Farmers Branch TX 5678 Distribution Drive Fort Worth TX",
|
||||
);
|
||||
const loc = {
|
||||
isEditable: vi.fn().mockResolvedValue(true),
|
||||
waitFor: vi.fn().mockResolvedValue(undefined),
|
||||
click: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const { visibleStreetInput } = await import("@/workers/rpa/address-side");
|
||||
vi.mocked(visibleStreetInput).mockResolvedValue(loc as never);
|
||||
|
||||
await fillAddressStep(
|
||||
ctx,
|
||||
baseRequest({ quoteSessionId: "session-uuid" }),
|
||||
);
|
||||
|
||||
expect(openAddressSide).toHaveBeenCalled();
|
||||
expect(mockSelectAddress).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("无 quote_session 时走完整填表", async () => {
|
||||
const ctx = mockCtx(
|
||||
"1234 Warehouse Road Farmers Branch TX 5678 Distribution Drive Fort Worth TX",
|
||||
);
|
||||
const loc = {
|
||||
isEditable: vi.fn().mockResolvedValue(true),
|
||||
waitFor: vi.fn().mockResolvedValue(undefined),
|
||||
click: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const { visibleStreetInput } = await import("@/workers/rpa/address-side");
|
||||
vi.mocked(visibleStreetInput).mockResolvedValue(loc as never);
|
||||
|
||||
await fillAddressStep(ctx, baseRequest());
|
||||
|
||||
expect(openAddressSide).toHaveBeenCalled();
|
||||
expect(mockSelectAddress).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("widget 未体现确认地址时抛错", async () => {
|
||||
const { assertDualAddressStable } = await import(
|
||||
"@/workers/rpa/address-commit"
|
||||
);
|
||||
vi.mocked(assertDualAddressStable).mockRejectedValueOnce({
|
||||
code: "ADDRESS_NOT_CONFIRMED",
|
||||
});
|
||||
const ctx = mockCtx("999 Wrong Street Fort Worth TX");
|
||||
const loc = {
|
||||
isEditable: vi.fn().mockResolvedValue(true),
|
||||
waitFor: vi.fn().mockResolvedValue(undefined),
|
||||
click: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const { visibleStreetInput } = await import("@/workers/rpa/address-side");
|
||||
vi.mocked(visibleStreetInput).mockResolvedValue(loc as never);
|
||||
|
||||
await expect(fillAddressStep(ctx, baseRequest())).rejects.toMatchObject({
|
||||
code: "ADDRESS_NOT_CONFIRMED",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Page } from "playwright";
|
||||
import { AxelSelectionBridge } from "@/workers/rpa/axel-selection-bridge";
|
||||
import { commitViaAxelPlacePrefetch } from "@/workers/rpa/axel-place-prefetch";
|
||||
|
||||
describe("commitViaAxelPlacePrefetch", () => {
|
||||
it("GET 200 但 fill 回退不合成 commit", async () => {
|
||||
const bridge = new AxelSelectionBridge();
|
||||
const page = {
|
||||
context: () => ({
|
||||
cookies: vi.fn().mockResolvedValue([]),
|
||||
request: {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
status: () => 200,
|
||||
ok: () => true,
|
||||
json: async () => ({
|
||||
placeId: "ChIJ_test_place_id_00",
|
||||
city: "LA",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
evaluate: vi.fn().mockResolvedValue(false),
|
||||
keyboard: { press: vi.fn().mockResolvedValue(undefined) },
|
||||
waitForTimeout: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as Page;
|
||||
const input = {
|
||||
click: vi.fn().mockResolvedValue(undefined),
|
||||
fill: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const result = await commitViaAxelPlacePrefetch(
|
||||
page,
|
||||
bridge,
|
||||
"ChIJ_test_place_id_00",
|
||||
"1234 Warehouse Street, Los Angeles, CA, USA",
|
||||
input as never,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.location).toBeTruthy();
|
||||
expect(bridge.hasPlaceCommit("ChIJ_test_place_id_00")).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AxelSelectionBridge, formatPlaceCommitFailure, normalizeAxelPlaceId } from "@/workers/rpa/axel-selection-bridge";
|
||||
|
||||
describe("AxelSelectionBridge", () => {
|
||||
it("resolveSelection 从 googlePlacesResults 绑定 placeId", () => {
|
||||
const bridge = new AxelSelectionBridge();
|
||||
bridge.ingestSearchResults({
|
||||
googlePlacesResults: [
|
||||
{
|
||||
description: "1234 Warehouse Street, Los Angeles, CA 90001, USA",
|
||||
mainText: "1234 Warehouse Street",
|
||||
placeId: "ChIJ_test_la",
|
||||
secondaryText: "Los Angeles, CA 90001, USA",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const binding = bridge.resolveSelection(
|
||||
"1234 Warehouse StreetLos Angeles, CA",
|
||||
{
|
||||
street: "1234 Warehouse Street",
|
||||
city: "Los Angeles",
|
||||
state: "CA",
|
||||
zip: "90001",
|
||||
},
|
||||
);
|
||||
|
||||
expect(binding).toEqual({
|
||||
label: "1234 Warehouse StreetLos Angeles, CA",
|
||||
placeId: "ChIJ_test_la",
|
||||
description: "1234 Warehouse Street, Los Angeles, CA 90001, USA",
|
||||
});
|
||||
});
|
||||
|
||||
it("记录 place/{id} 200 为 commit", () => {
|
||||
const bridge = new AxelSelectionBridge();
|
||||
bridge.recordPlaceCommit("ChIJ_commit_ok");
|
||||
|
||||
expect(bridge.hasPlaceCommit("ChIJ_commit_ok")).toBe(true);
|
||||
expect(bridge.getObservedPlaceCommitIds()).toContain("ChIJ_commit_ok");
|
||||
});
|
||||
|
||||
it("normalizeAxelPlaceId 从 base64 复合串提取 ChIJ", () => {
|
||||
const raw =
|
||||
"EisxMjM0IFdhcmVob3VzZSBTdHJlZXQsIExvcyBBbmdlbGVzLCBDQSwgVVNBIjESLwoUChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol";
|
||||
expect(normalizeAxelPlaceId(raw)).toBe("ChIJywDT6CXGwoARuoz5HIJe20g");
|
||||
});
|
||||
|
||||
it("normalizeAxelPlaceId 截断 ChIJ 后粘连 base64 尾巴", () => {
|
||||
const composite =
|
||||
"ChIJywDT6CXGwoARuoz5HIJe20gQ0gkqFAoSCSEaJHUmxsKAEY2YXFohyZol";
|
||||
expect(normalizeAxelPlaceId(composite)).toBe("ChIJywDT6CXGwoARuoz5HIJe20g");
|
||||
});
|
||||
|
||||
it("resolveSelection 有门牌 query 时优先带门牌的 placeId", () => {
|
||||
const bridge = new AxelSelectionBridge();
|
||||
bridge.ingestSearchResults({
|
||||
googlePlacesResults: [
|
||||
{
|
||||
description: "Distribution Way, Farmers Branch, TX, USA",
|
||||
mainText: "Distribution Way",
|
||||
placeId: "ChIJ_generic_way",
|
||||
secondaryText: "Farmers Branch, TX, USA",
|
||||
},
|
||||
{
|
||||
description: "5678 Distribution Way, Farmers Branch, TX, USA",
|
||||
mainText: "5678 Distribution Way",
|
||||
placeId: "ChIJ_numbered_way",
|
||||
secondaryText: "Farmers Branch, TX, USA",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const binding = bridge.resolveSelection("Distribution WayFarmers Branch, TX", {
|
||||
street: "5678 Distribution Way",
|
||||
city: "Farmers Branch",
|
||||
state: "TX",
|
||||
zip: "",
|
||||
});
|
||||
|
||||
expect(binding?.placeId).toBe("ChIJ_numbered_way");
|
||||
});
|
||||
|
||||
it("无 search 结果时 resolveSelection 返回 null", () => {
|
||||
const bridge = new AxelSelectionBridge();
|
||||
expect(
|
||||
bridge.resolveSelection("1234 Warehouse StreetLos Angeles, CA"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("formatPlaceCommitFailure 区分无请求与 HTTP 错误", () => {
|
||||
expect(
|
||||
formatPlaceCommitFailure("ChIJ_expected", ["keyboard-bound-0"], {
|
||||
committedPlaceIds: [],
|
||||
placeNetworkLog: [],
|
||||
}),
|
||||
).toContain("未发起 GET place 请求");
|
||||
|
||||
expect(
|
||||
formatPlaceCommitFailure("ChIJ_expected", ["pac-item-0"], {
|
||||
committedPlaceIds: [],
|
||||
placeNetworkLog: [{ placeId: "ChIJ_other", status: 404, url: "", at: 0 }],
|
||||
}),
|
||||
).toContain("placeId 不匹配");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BROWSER_FETCH_AND_INJECT_PLACE } from "@/workers/rpa/fix-place/browser-place-commit";
|
||||
|
||||
describe("browser-place-commit", () => {
|
||||
it("BROWSER_FETCH_AND_INJECT_PLACE 为 Playwright 可执行 async 函数体", () => {
|
||||
expect(BROWSER_FETCH_AND_INJECT_PLACE.trim().startsWith("async (arg)")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(BROWSER_FETCH_AND_INJECT_PLACE).not.toContain("(async function");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
evaluateAddressesLockedForSubmit,
|
||||
evaluateCargoCommitted,
|
||||
type CargoCommitProbe,
|
||||
} from "@/workers/rpa/cargo-lock";
|
||||
|
||||
describe("cargo commit barrier", () => {
|
||||
const committed: CargoCommitProbe = {
|
||||
hasSummaryChip: true,
|
||||
visibleEditableCargoInputs: 0,
|
||||
editorOpen: false,
|
||||
internalPalletCount: 2,
|
||||
internalWeight: 227,
|
||||
widgetTextPreview: "2 托盘s",
|
||||
};
|
||||
|
||||
it("summary + 无 editable input + internal pallet → committed", () => {
|
||||
expect(evaluateCargoCommitted(committed, 2)).toBe(true);
|
||||
});
|
||||
|
||||
it("内联步输入仍可见但摘要完整 → committed", () => {
|
||||
expect(
|
||||
evaluateCargoCommitted(
|
||||
{
|
||||
hasSummaryChip: true,
|
||||
visibleEditableCargoInputs: 5,
|
||||
editorOpen: true,
|
||||
internalPalletCount: 2,
|
||||
internalWeight: 226,
|
||||
widgetTextPreview:
|
||||
"2 托盘s 40W, x 48L, x 48H, x 226Lbs 货运详情",
|
||||
},
|
||||
2,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("摘要不完整且仍有 editable → 未 committed", () => {
|
||||
expect(
|
||||
evaluateCargoCommitted(
|
||||
{
|
||||
hasSummaryChip: false,
|
||||
visibleEditableCargoInputs: 2,
|
||||
editorOpen: true,
|
||||
internalPalletCount: null,
|
||||
internalWeight: null,
|
||||
widgetTextPreview: "货运详情",
|
||||
},
|
||||
2,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("摘要含托盘+尺寸但无重量 → 未 committed", () => {
|
||||
expect(
|
||||
evaluateCargoCommitted(
|
||||
{
|
||||
hasSummaryChip: true,
|
||||
visibleEditableCargoInputs: 0,
|
||||
editorOpen: true,
|
||||
internalPalletCount: 2,
|
||||
internalWeight: null,
|
||||
widgetTextPreview: "2 托盘s 40W, x 48L, x 48H, x -Lbs",
|
||||
},
|
||||
2,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("摘要含 226Lbs 无空格 → committed", () => {
|
||||
expect(
|
||||
evaluateCargoCommitted(
|
||||
{
|
||||
hasSummaryChip: true,
|
||||
visibleEditableCargoInputs: 0,
|
||||
editorOpen: false,
|
||||
internalPalletCount: 2,
|
||||
internalWeight: 226,
|
||||
widgetTextPreview: "2 托盘s 40W, x 48L, x 48H, x 226Lbs",
|
||||
},
|
||||
2,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("editor 打开且仍有 editable → 未 committed", () => {
|
||||
expect(
|
||||
evaluateCargoCommitted(
|
||||
{ ...committed, editorOpen: true, visibleEditableCargoInputs: 2 },
|
||||
2,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("摘要重量与请求单托不符 → 未 committed", () => {
|
||||
expect(
|
||||
evaluateCargoCommitted(
|
||||
{
|
||||
hasSummaryChip: true,
|
||||
visibleEditableCargoInputs: 0,
|
||||
editorOpen: false,
|
||||
internalPalletCount: 2,
|
||||
internalWeight: 2296,
|
||||
widgetTextPreview: "2 托盘s 40W, x 48L, x 48H, x 2296Lbs",
|
||||
},
|
||||
{
|
||||
palletCount: 2,
|
||||
weight: {
|
||||
displayValue: "500",
|
||||
unit: "lb",
|
||||
weightLbPerPallet: 500,
|
||||
palletCount: 2,
|
||||
},
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("address lock barrier", () => {
|
||||
it("双州码 → locked", () => {
|
||||
expect(
|
||||
evaluateAddressesLockedForSubmit(
|
||||
"1234 Warehouse St, Los Angeles, CA 90021 → 5678 Distribution Way, Dallas, TX 75234",
|
||||
false,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("单州码 → 未 locked", () => {
|
||||
expect(
|
||||
evaluateAddressesLockedForSubmit("Los Angeles, CA 90021 货运详情", false),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("地址搜索仍可编辑 → 未 locked", () => {
|
||||
expect(
|
||||
evaluateAddressesLockedForSubmit(
|
||||
"1234 Warehouse St, CA 90021 5678 Distribution Way, TX 75234",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { widgetHasFreightSummary } from "@/workers/rpa/cargo-reset";
|
||||
|
||||
describe("widgetHasFreightSummary", () => {
|
||||
it("含托盘+尺寸摘要 → true", () => {
|
||||
expect(
|
||||
widgetHasFreightSummary(
|
||||
"2 托盘s 40W, x 48L, x 44H, x 2079Lbs",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("仅地址无货物 → false", () => {
|
||||
expect(
|
||||
widgetHasFreightSummary("Denver, CO Charlotte, NC 4800 South Race Street"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DUAL_ADDRESS_PATCH_INIT } from "@/workers/rpa/inject/dual-address-patch-init";
|
||||
import { isDualAddressPatchEnabled } from "@/lib/rpa/env";
|
||||
|
||||
describe("dual-address-patch", () => {
|
||||
it("init script 包含 vault API 标识", () => {
|
||||
expect(DUAL_ADDRESS_PATCH_INIT).toContain("__rpaDualAddressPatchInstalled");
|
||||
expect(DUAL_ADDRESS_PATCH_INIT).toContain("__rpaDualAddressApi");
|
||||
expect(DUAL_ADDRESS_PATCH_INIT).toContain("preservePickup");
|
||||
});
|
||||
|
||||
it("显式 RPA_DUAL_ADDRESS_PATCH=true 时启用 patch", () => {
|
||||
const prev = process.env.RPA_DUAL_ADDRESS_PATCH;
|
||||
const prevQuote = process.env.RPA_QUOTE_MODE;
|
||||
process.env.RPA_DUAL_ADDRESS_PATCH = "true";
|
||||
process.env.RPA_QUOTE_MODE = "widget";
|
||||
expect(isDualAddressPatchEnabled()).toBe(true);
|
||||
process.env.RPA_DUAL_ADDRESS_PATCH = "false";
|
||||
expect(isDualAddressPatchEnabled()).toBe(false);
|
||||
process.env.RPA_DUAL_ADDRESS_PATCH = prev ?? "";
|
||||
process.env.RPA_QUOTE_MODE = prevQuote ?? "";
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWeightDisplayForUnit,
|
||||
inferWeightUnitFromFieldHint,
|
||||
} from "@/workers/rpa/cargo-weight";
|
||||
import { classifyCargoFieldHint } from "@/workers/rpa/fill-cargo-in-drawer";
|
||||
|
||||
describe("classifyCargoFieldHint", () => {
|
||||
it("识别托数/重量/尺寸", () => {
|
||||
expect(classifyCargoFieldHint("How many pallets?")).toBe("pallet");
|
||||
expect(classifyCargoFieldHint("Weight (lbs)")).toBe("weight");
|
||||
expect(classifyCargoFieldHint("Weight (kg)")).toBe("weight");
|
||||
expect(classifyCargoFieldHint("重量(公斤)")).toBe("weight");
|
||||
expect(classifyCargoFieldHint("Length")).toBe("length");
|
||||
expect(classifyCargoFieldHint("Width")).toBe("width");
|
||||
expect(classifyCargoFieldHint("Height")).toBe("height");
|
||||
expect(classifyCargoFieldHint("需要几个托盘?")).toBe("pallet");
|
||||
});
|
||||
|
||||
it("kg placeholder 仅换算 kg 字段", () => {
|
||||
const display = buildWeightDisplayForUnit(
|
||||
503,
|
||||
inferWeightUnitFromFieldHint("Weight (kg)"),
|
||||
);
|
||||
expect(display.unit).toBe("kg");
|
||||
expect(display.displayValue).not.toBe("503");
|
||||
const lbs = buildWeightDisplayForUnit(
|
||||
503,
|
||||
inferWeightUnitFromFieldHint("Weight (lbs)"),
|
||||
);
|
||||
expect(lbs.displayValue).toBe("503");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CHECK_PICKUP_IN_REACT_STATE,
|
||||
INJECT_REACT_STATE,
|
||||
INVOKE_CAPTURED_PLACE_CHANGED,
|
||||
TRIGGER_GOOGLE_PLACE_CHANGED,
|
||||
} from "@/workers/rpa/fix-place/browser-eval-scripts";
|
||||
|
||||
describe("fix-place/browser-eval-scripts", () => {
|
||||
it("脚本为纯字符串,不含 esbuild helper 引用", () => {
|
||||
const scripts = [
|
||||
TRIGGER_GOOGLE_PLACE_CHANGED,
|
||||
INVOKE_CAPTURED_PLACE_CHANGED,
|
||||
CHECK_PICKUP_IN_REACT_STATE,
|
||||
INJECT_REACT_STATE,
|
||||
];
|
||||
for (const script of scripts) {
|
||||
expect(script).not.toMatch(/__name|__assign|__awaiter|__spread/);
|
||||
expect(typeof script).toBe("string");
|
||||
expect(script.length).toBeGreaterThan(10);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RESOLVE_CLICK_TARGET } from "@/workers/rpa/fix-place/cdp-click";
|
||||
|
||||
describe("cdp-click", () => {
|
||||
it("RESOLVE_CLICK_TARGET 为纯字符串脚本", () => {
|
||||
expect(typeof RESOLVE_CLICK_TARGET).toBe("string");
|
||||
expect(RESOLVE_CLICK_TARGET).toContain("getBoundingClientRect");
|
||||
expect(RESOLVE_CLICK_TARGET).not.toMatch(/__name|__assign/);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
PLACE_DIAG_COLLECT_SNAPSHOT,
|
||||
PLACE_DIAG_SET_MARK,
|
||||
P0_PLACE_DIAG_INIT,
|
||||
P2_FETCH_HIJACK_INIT,
|
||||
} from "@/workers/rpa/fix-place/place-diagnostic-init";
|
||||
import { interpretPlaceDiagnostic } from "@/workers/rpa/fix-place/place-diagnostic-probe";
|
||||
|
||||
describe("place-diagnostic-init", () => {
|
||||
it("P0/P2 脚本为纯字符串", () => {
|
||||
for (const script of [
|
||||
P0_PLACE_DIAG_INIT,
|
||||
P2_FETCH_HIJACK_INIT,
|
||||
PLACE_DIAG_SET_MARK,
|
||||
PLACE_DIAG_COLLECT_SNAPSHOT,
|
||||
]) {
|
||||
expect(typeof script).toBe("string");
|
||||
expect(script).not.toMatch(/__name|__assign/);
|
||||
}
|
||||
expect(P0_PLACE_DIAG_INIT).toContain("maps.event.addListener");
|
||||
expect(P0_PLACE_DIAG_INIT).toContain("fingerprintHandler");
|
||||
expect(P2_FETCH_HIJACK_INIT).toContain("axel/location/place");
|
||||
});
|
||||
});
|
||||
|
||||
describe("interpretPlaceDiagnostic", () => {
|
||||
it("无监听器时提示 input gm 定位", () => {
|
||||
const lines = interpretPlaceDiagnostic(
|
||||
{
|
||||
ok: true,
|
||||
topology: { instanceCount: 0, instances: [], inputProbes: [], monkeyPatchInstances: 0, capturedHandlers: 0 },
|
||||
listeners: { total: 0, anyHasFetch: false, anyHasAxelPlace: false, rows: [] },
|
||||
handlerEvents: [],
|
||||
fetchLog: [],
|
||||
},
|
||||
"test",
|
||||
);
|
||||
expect(lines.some((l) => l.includes("无任何 place_changed 监听器"))).toBe(true);
|
||||
});
|
||||
|
||||
it("有 handler 无 fetch 时提示 P2", () => {
|
||||
const lines = interpretPlaceDiagnostic(
|
||||
{
|
||||
ok: true,
|
||||
topology: { instanceCount: 1, instances: [], inputProbes: [], monkeyPatchInstances: 1, capturedHandlers: 1 },
|
||||
listeners: { total: 1, anyHasFetch: false, anyHasAxelPlace: false, rows: [] },
|
||||
handlerEvents: [{ type: "place_changed_handler", instanceId: "ac-1", getPlaceBefore: { place_id: "ChIJ_test", hasGeometry: true } }],
|
||||
fetchLog: [],
|
||||
},
|
||||
"test",
|
||||
);
|
||||
expect(lines.some((l) => l.includes("P2 无 place fetch"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findGoldStandardTargets,
|
||||
loadAllGoldStandardTargets,
|
||||
scoreGoldTextMatch,
|
||||
} from "@/workers/rpa/gold-standard-targets";
|
||||
|
||||
describe("gold-standard-targets", () => {
|
||||
it("loadAllGoldStandardTargets 读取录证金样本", () => {
|
||||
const targets = loadAllGoldStandardTargets();
|
||||
expect(targets.length).toBeGreaterThan(0);
|
||||
expect(targets[0]?.textPreview.length).toBeGreaterThan(12);
|
||||
});
|
||||
|
||||
it("scoreGoldTextMatch 对完全匹配给高分", () => {
|
||||
const text =
|
||||
"La Concha Key West, Autograph Collection, Duval Street, Key West, Florida, USA";
|
||||
const score = scoreGoldTextMatch(text, {
|
||||
street: "Duval Street",
|
||||
city: "Key West",
|
||||
state: "FL",
|
||||
zip: "",
|
||||
}, text);
|
||||
expect(score).toBeGreaterThanOrEqual(85);
|
||||
});
|
||||
|
||||
it("scoreGoldTextMatch 拒绝仅城市相同的不同街道", () => {
|
||||
const score = scoreGoldTextMatch(
|
||||
"1234 Warehouse Street, Los Angeles, CA, USA",
|
||||
{
|
||||
street: "1234 Warehouse Street",
|
||||
city: "Los Angeles",
|
||||
state: "CA",
|
||||
zip: "",
|
||||
},
|
||||
"Hollywood BoulevardLos Angeles, California, USA",
|
||||
);
|
||||
expect(score).toBe(0);
|
||||
});
|
||||
|
||||
it("findGoldStandardTargets 按 description 匹配 Key West 金样本", () => {
|
||||
const matches = findGoldStandardTargets(
|
||||
"La Concha Key West, Autograph Collection, Duval Street, Key West, Florida, USA",
|
||||
{
|
||||
street: "Duval Street",
|
||||
city: "Key West",
|
||||
state: "FL",
|
||||
zip: "",
|
||||
},
|
||||
"pickup",
|
||||
40,
|
||||
);
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
expect(matches[0]?.textPreview).toMatch(/Key West|Duval/i);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
COLLECT_CLIENT_STATE,
|
||||
FILL_CARGO_FIELD,
|
||||
PROBE_CARGO_UI,
|
||||
WAIT_CARGO_INTERACTIVE,
|
||||
} from "@/workers/rpa/kernel/browser-scripts";
|
||||
|
||||
describe("kernel/browser-scripts re-exports", () => {
|
||||
it("脚本为纯字符串,不含 TS helper 引用", () => {
|
||||
const scripts = [
|
||||
PROBE_CARGO_UI,
|
||||
WAIT_CARGO_INTERACTIVE,
|
||||
FILL_CARGO_FIELD,
|
||||
COLLECT_CLIENT_STATE,
|
||||
];
|
||||
for (const script of scripts) {
|
||||
expect(script).not.toMatch(/__name|__assign|__awaiter|__spread/);
|
||||
expect(typeof script).toBe("string");
|
||||
expect(script.length).toBeGreaterThan(10);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SelectorGraph } from "@/workers/rpa/kernel/selector-graph";
|
||||
import { RPAContext } from "@/workers/rpa/kernel/context";
|
||||
import { isCargoStateReady } from "@/workers/rpa/kernel/state-engine";
|
||||
|
||||
function mockPage(): object {
|
||||
return {
|
||||
locator: () => ({
|
||||
filter: () => ({ nth: () => ({}) }),
|
||||
first: () => ({}),
|
||||
}),
|
||||
waitForTimeout: async () => undefined,
|
||||
url: () => "https://example.com",
|
||||
};
|
||||
}
|
||||
|
||||
describe("SelectorGraph", () => {
|
||||
it("resolve widget selector", () => {
|
||||
const graph = new SelectorGraph();
|
||||
expect(graph.resolve("RPA_SELECTOR_QUOTE_WIDGET")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("specs 永不为 undefined", () => {
|
||||
const graph = new SelectorGraph();
|
||||
expect(graph.specs("NOT_A_REAL_KEY")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RPAContext", () => {
|
||||
it("scroll 时间戳存于实例", () => {
|
||||
const ctx = new RPAContext(mockPage() as never);
|
||||
expect(ctx.getWidgetScrollAt()).toBe(0);
|
||||
ctx.setWidgetScrollAt(123);
|
||||
expect(ctx.getWidgetScrollAt()).toBe(123);
|
||||
});
|
||||
|
||||
it("runtime 与 exec 同源", () => {
|
||||
const ctx = new RPAContext(mockPage() as never);
|
||||
expect(ctx.runtime).toBe(ctx.exec);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCargoStateReady", () => {
|
||||
it("托盘 interactive → ready", () => {
|
||||
expect(
|
||||
isCargoStateReady({
|
||||
phase: "interactive",
|
||||
mountedCargoFields: [{ placeholder: "需要几个托盘?", ariaLabel: null }],
|
||||
interactiveCargoFields: [
|
||||
{ placeholder: "需要几个托盘?", ariaLabel: null },
|
||||
],
|
||||
blockedCargoFields: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeCargoEvidence } from "@/workers/rpa/kernel/normalize";
|
||||
|
||||
describe("normalizeCargoEvidence", () => {
|
||||
it("undefined → 空 evidence", () => {
|
||||
expect(normalizeCargoEvidence(undefined).phase).toBe("no_cargo_inputs");
|
||||
expect(normalizeCargoEvidence(undefined).mountedCargoFields).toEqual([]);
|
||||
});
|
||||
|
||||
it("数组字段 undefined → []", () => {
|
||||
const evidence = normalizeCargoEvidence({
|
||||
phase: "hydrating",
|
||||
mountedCargoFields: undefined as never,
|
||||
interactiveCargoFields: undefined as never,
|
||||
blockedCargoFields: undefined as never,
|
||||
});
|
||||
expect(evidence.mountedCargoFields).toEqual([]);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDescriptionProbes,
|
||||
labelsMatch,
|
||||
suggestionLabelConflicts,
|
||||
} from "@/workers/rpa/mothership-styled-suggestion-click";
|
||||
|
||||
describe("mothership-styled-locator", () => {
|
||||
it("buildDescriptionProbes 生成多层文本探针", () => {
|
||||
const probes = buildDescriptionProbes(
|
||||
"Hollywood Boulevard, Los Angeles, California, USA",
|
||||
{
|
||||
street: "Hollywood Boulevard",
|
||||
city: "Los Angeles",
|
||||
state: "CA",
|
||||
zip: "",
|
||||
},
|
||||
);
|
||||
expect(probes).toContain("Hollywood Boulevard, Los Angeles, CA");
|
||||
expect(probes.some((p) => p.includes("Hollywood"))).toBe(true);
|
||||
});
|
||||
|
||||
it("labelsMatch 支持包含匹配与门牌号", () => {
|
||||
expect(
|
||||
labelsMatch(
|
||||
"1234 Warehouse Street, Los Angeles, CA, USA",
|
||||
"1234 Warehouse Street Los Angeles CA",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("labelsMatch 拒绝 Bridge 误匹配", () => {
|
||||
expect(
|
||||
labelsMatch(
|
||||
"1234 Market Street Bridge, Philadelphia, PA, USA",
|
||||
"1234 Market Street, Philadelphia, PA, USA",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("suggestionLabelConflicts 识别 Bridge 歧义", () => {
|
||||
expect(
|
||||
suggestionLabelConflicts(
|
||||
"800 Hennepin Avenue Bridge, Minneapolis, MN, USA",
|
||||
{ street: "800 Hennepin Avenue", city: "Minneapolis", state: "MN", zip: "" },
|
||||
"800 Hennepin Avenue, Minneapolis, MN, USA",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,32 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Page } from "playwright";
|
||||
|
||||
const calls: string[] = [];
|
||||
|
||||
vi.mock("@/workers/rpa/kernel/steps/cargo-step", () => ({
|
||||
fillCargoStep: vi.fn(async () => {
|
||||
calls.push("cargo-step");
|
||||
}),
|
||||
}));
|
||||
|
||||
import { fillCargo } from "@/workers/rpa/mothership";
|
||||
import { fillCargoStep } from "@/workers/rpa/kernel/steps/cargo-step";
|
||||
|
||||
const probeReq = {
|
||||
cargoHash: "t",
|
||||
pickup: {} as never,
|
||||
delivery: {} as never,
|
||||
weightLb: 500,
|
||||
dimsIn: { l: 48, w: 40, h: 48 },
|
||||
palletCount: 2,
|
||||
cargoType: "general_freight",
|
||||
};
|
||||
|
||||
describe("fillCargo thin wrapper", () => {
|
||||
it("委托 kernel fillCargoStep", async () => {
|
||||
calls.length = 0;
|
||||
await fillCargo({} as Page, probeReq);
|
||||
expect(calls).toEqual(["cargo-step"]);
|
||||
expect(fillCargoStep).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue