test(order): verify live import template directories without business writes
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# 后端脚本管理与全量 HTTP 验收索引
|
||||
|
||||
`client-import-template-test-env.mjs` 为批量导入模板只读 TEST 检查,默认 `--plan` 零网络。执行参数沿用 `--execute --environment=TEST --run-id=<新批次>`,另传 `--python=<load_workspace_dependencies返回的Python路径>`;只使用显式 TEST 验收配置。内部 `client-import-template-inspect.py` 下载模板并在内存用 bundled openpyxl 读取,不写 Excel;与完整启用仓库分页、各国内仓渠道并集、当前客户取货/私人地址逐项对照,同时检查命名范围、隐藏选项和 50 行下拉。只登录/退出可用 POST,不导入或创建业务数据,不保存原始模板和客户地址;报告独占 `reports/client-template-<run-id>/`,失败保留。对应 `.test.py` 在内存构造最小 OOXML 检查缺项、额外项、错范围和重复名称;真实第二客户负向隔离及 Excel 桌面操作另验。
|
||||
|
||||
`client-workbench-test-env.mjs` 是客户订单工作台只读 TEST 分页验收入口,默认 `--plan` 零网络。Firstleg/Order 新工作台接口部署后,以显式 `node --env-file=<用户指定目录的test-acceptance.env> scripts/client-workbench-test-env.mjs --execute --environment=TEST --run-id=<新批次>` 执行;独立客户登录及退出是唯一 POST,不创建订单、不写 SQL、不调用会补建预入库记录的旧草稿列表。要求稳定样本超过 200 条,遍历完整工作台及独立统一订单集合,核对完整正式单集合、费用/草稿待办、报关队列分页数量、21页切片、渠道筛选和伪造客户参数。报关业务资格、第二客户负向权限、页面及写后刷新仍需另验。报告独占 `reports/client-workbench-<run-id>/`,请求前持久记录脱敏意图,失败保留且结束退出自己的会话;不保存凭据或原始客户明细。`client-workbench-test-env.test.mjs` 用故障响应验证拒绝截断、重复、错页和客户泄漏,不能替代真实 TEST 结果。
|
||||
|
||||
`fleet-operations-test-env.mjs` 是车次运营统计独立只读TEST入口。显式环境文件及`--ready-commit=a6fd0ba3`之外,必须提供`--fixture=reports/<C批次>/handover-fixture.json --fixture-sha256=<文件SHA256> --marker=<该轮marker> --run-id=<独立批次>`;校验本轮真实DEPART和已核平车次成本,不能借历史车次或从统计响应反推期望。独立登录,只读该车次/历史/运营统计及导出,禁止造数、业务写入和里程更正;登录/退出为唯一POST。报告`reports/fleet-operations-<run-id>/`独占且请求前落盘,原币成本CNY100.03与USD13.07分开;失败保留证据、禁止覆盖重跑,结束退出独立会话。来源断言和权限由正式入口及`fleet-operations-test-env-contract.test.mjs`共同约束,离线测试不证明真实统计已通过。
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Read-only TEST template audit. Credentials and workbook bytes stay in memory."""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from openpyxl import load_workbook
|
||||
|
||||
|
||||
def require(value, message):
|
||||
if not value:
|
||||
raise AssertionError(message)
|
||||
|
||||
|
||||
def labels(values, labeler):
|
||||
result = []
|
||||
for item in values:
|
||||
base = labeler(item)
|
||||
label, n = base, 2
|
||||
while label in result:
|
||||
label = f"{base}(同名选项 {n})"
|
||||
n += 1
|
||||
result.append(label)
|
||||
return result
|
||||
|
||||
|
||||
def label(name, code):
|
||||
return code if not name or name.lower() == code.lower() else f"{name}|{code}"
|
||||
|
||||
|
||||
def compare_options(actual, expected):
|
||||
return {"expectedCount": len(expected), "actualCount": len(actual),
|
||||
"missingCount": len(set(expected) - set(actual)),
|
||||
"extraCount": len(set(actual) - set(expected)),
|
||||
"duplicateCount": len(actual) - len(set(actual)),
|
||||
"passed": sorted(actual) == sorted(expected),
|
||||
"actualSha256": hashlib.sha256(json.dumps(sorted(actual), ensure_ascii=False).encode()).hexdigest()}
|
||||
|
||||
|
||||
def inspect_template(content, expected):
|
||||
book = load_workbook(io.BytesIO(content), data_only=False)
|
||||
try:
|
||||
require(set(book.sheetnames) == {"订单导入", "选项数据", "填写说明"}, "Unexpected workbook sheets")
|
||||
require(book["选项数据"].sheet_state == "hidden", "Options sheet must be hidden")
|
||||
sheet = book["订单导入"]
|
||||
headers = {str(cell.value).removesuffix(" *"): cell.column for cell in sheet[2] if cell.value}
|
||||
checks = {}
|
||||
for title, values in expected.items():
|
||||
require(title in headers, "Missing expected header")
|
||||
col = headers[title]
|
||||
validations = [v for v in sheet.data_validations.dataValidation
|
||||
if any(r.min_col <= col <= r.max_col and r.min_row <= 3 <= r.max_row for r in v.sqref.ranges)]
|
||||
if not values:
|
||||
require(not validations, "Empty customer directory unexpectedly has choices")
|
||||
checks[title] = compare_options([], [])
|
||||
continue
|
||||
require(len(validations) == 1, "Missing or overlapping dropdown validation")
|
||||
validation = validations[0]
|
||||
require(validation.type == "list" and validation.showErrorMessage, "Dropdown must reject invalid choices")
|
||||
require(any(r.min_row == 3 and r.max_row == 52 and r.min_col == col and r.max_col == col
|
||||
for r in validation.sqref.ranges), "Dropdown must cover all 50 input rows")
|
||||
name = book.defined_names.get(validation.formula1)
|
||||
require(name is not None, "Dropdown name does not resolve")
|
||||
destinations = list(name.destinations)
|
||||
require(len(destinations) == 1 and destinations[0][0] == "选项数据", "Invalid dropdown destination")
|
||||
actual = [cell.value for row in book[destinations[0][0]][destinations[0][1]] for cell in row]
|
||||
require(all(isinstance(v, str) and v for v in actual), "Dropdown contains blank or invalid entries")
|
||||
checks[title] = compare_options(actual, values)
|
||||
return {"checks": checks, "passed": all(v["passed"] for v in checks.values()),
|
||||
"workbookSha256": hashlib.sha256(content).hexdigest(), "headerCount": len(headers),
|
||||
"dropdowns": len(sheet.data_validations.dataValidation)}
|
||||
finally:
|
||||
book.close()
|
||||
|
||||
|
||||
def main(config):
|
||||
require(config.get("environment") == "TEST" and re.fullmatch(r"[A-Za-z0-9_-]{1,60}", config.get("runId", "")), "Explicit TEST run required")
|
||||
directory = Path(__file__).resolve().parent.parent / "reports" / ("client-template-" + config["runId"])
|
||||
directory.mkdir() # Never overwrite an earlier run.
|
||||
report_path = directory / "report.json"
|
||||
report = {"startedAt": datetime.now(timezone.utc).isoformat(), "readOnlyBusiness": True,
|
||||
"success": False, "sessionClosed": False, "requests": []}
|
||||
token, refresh = "", ""
|
||||
|
||||
def save():
|
||||
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
def request(method, path, body=None, binary=False):
|
||||
require(method == "GET" or method == "POST" and path in ["/auth/login", "/auth/logout"], "Business writes forbidden")
|
||||
parsed = urllib.parse.urlsplit(path)
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
record = {"method": method, "path": parsed.path, "outcome": "PENDING",
|
||||
"page": query.get("page"), "size": query.get("size")}
|
||||
report["requests"].append(record)
|
||||
save()
|
||||
headers = {"Accept": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = "Bearer " + token
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(config["base"] + path, method=method, headers=headers,
|
||||
data=json.dumps(body).encode() if body is not None else None)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
data = response.read()
|
||||
record.update(outcome="RECEIVED", http=response.status,
|
||||
requestId=response.headers.get("X-Request-ID"), traceId=response.headers.get("X-Trace-ID"))
|
||||
if binary:
|
||||
require(data[:2] == b"PK", "Template response is not XLSX")
|
||||
record["sha256"] = hashlib.sha256(data).hexdigest()
|
||||
save()
|
||||
return data
|
||||
payload = json.loads(data)
|
||||
record.update(code=payload.get("code"), requestId=payload.get("requestId") or record["requestId"],
|
||||
traceId=payload.get("traceId") or record["traceId"])
|
||||
save()
|
||||
require(str(payload.get("code")) == "200", "API business response rejected")
|
||||
return payload["data"]
|
||||
except urllib.error.HTTPError as error:
|
||||
record.update(outcome="RECEIVED", http=error.code)
|
||||
save()
|
||||
raise AssertionError("HTTP response rejected") from None
|
||||
except Exception:
|
||||
if record["outcome"] == "PENDING":
|
||||
record["outcome"] = "UNKNOWN"
|
||||
save()
|
||||
raise
|
||||
|
||||
try:
|
||||
auth = request("POST", "/auth/login", {"loginId": config["username"], "password": config["password"]})
|
||||
token, refresh = auth.get("accessToken", ""), auth.get("refreshToken", "")
|
||||
require(token and refresh, "Incomplete login")
|
||||
warehouses, total, page_no = [], None, 1
|
||||
while True:
|
||||
page = request("GET", f"/merchant/v1/warehouses?status=ACTIVE&page={page_no}&size=200")
|
||||
if total is None:
|
||||
total = int(page["total"])
|
||||
require(int(page["page"]) == page_no and int(page["size"]) == 200 and int(page["total"]) == total
|
||||
and int(page["pages"]) == (total + 199) // 200, "Warehouse pagination changed")
|
||||
require(len(page["records"]) == min(200, max(0, total - (page_no - 1) * 200)), "Warehouse page incomplete")
|
||||
warehouses.extend(page["records"])
|
||||
if page_no >= int(page["pages"]):
|
||||
break
|
||||
require(page_no < 1000, "Warehouse page limit exceeded")
|
||||
page_no += 1
|
||||
require(len({str(w["id"]) for w in warehouses}) == total and all(w["status"] == "ACTIVE" for w in warehouses), "Invalid warehouse set")
|
||||
expected = {}
|
||||
for kind, title in [("DOMESTIC", "国内收货仓"), ("FBA", "FBA目的仓"), ("OVERSEAS", "海外仓目的仓")]:
|
||||
expected[title] = labels([w for w in warehouses if w["warehouseType"] == kind], lambda w: label(w["warehouseName"], w["warehouseCode"]))
|
||||
channels = []
|
||||
for warehouse in warehouses:
|
||||
if warehouse["warehouseType"] != "DOMESTIC":
|
||||
continue
|
||||
values = request("GET", "/merchant/v1/first-leg/pricing/channels?originWarehouseId=" + str(warehouse["id"]))
|
||||
channels.extend(label(c["channelName"], c["channelCode"]) for c in values)
|
||||
expected["运输渠道"] = list(dict.fromkeys(channels))
|
||||
for kind, title in [("PICKUP", "取货地址"), ("PRIVATE_DESTINATION", "私人目的仓地址")]:
|
||||
addresses = request("GET", "/merchant/v1/orders/first-leg/addresses?addressType=" + kind)
|
||||
expected[title] = labels(addresses, lambda a: "|".join(a[key] for key in ["addressName", "contactName", "city", "addressLine"]))
|
||||
content = request("GET", "/merchant/v1/orders/first-leg/batch-imports/template", binary=True)
|
||||
report["template"] = inspect_template(content, expected)
|
||||
report["warehousePages"] = page_no
|
||||
report["warehouseTotal"] = total
|
||||
report["success"] = report["template"]["passed"]
|
||||
report["limitations"] = ["No import submission or Excel desktop interaction performed",
|
||||
"Second-customer address rejection requires separate evidence"]
|
||||
except Exception as error:
|
||||
report["failure"] = str(error) if isinstance(error, AssertionError) else type(error).__name__
|
||||
finally:
|
||||
if token:
|
||||
try:
|
||||
request("POST", "/auth/logout", {"refreshToken": refresh})
|
||||
report["sessionClosed"] = True
|
||||
except Exception:
|
||||
report["logoutFailure"] = True
|
||||
report["success"] = report["success"] and report["sessionClosed"]
|
||||
report["completedAt"] = datetime.now(timezone.utc).isoformat()
|
||||
save()
|
||||
print(json.dumps({"success": report["success"], "sessionClosed": report["sessionClosed"], "report": str(report_path)}))
|
||||
return 0 if report["success"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main(json.load(sys.stdin)))
|
||||
except Exception:
|
||||
print("Template audit did not start; verify the new report directory and runtime.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,53 @@
|
||||
import importlib.util
|
||||
import io
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
spec = importlib.util.spec_from_file_location("template_audit", Path(__file__).with_name("client-import-template-inspect.py"))
|
||||
audit = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(audit)
|
||||
|
||||
|
||||
def fixture(options=("测试仓|WH-A", "测试仓|WH-B"), rows="A3:A52", target="option_0", hidden=True):
|
||||
ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||
rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
body = io.BytesIO()
|
||||
with zipfile.ZipFile(body, "w") as z:
|
||||
z.writestr("[Content_Types].xml", '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/></Types>')
|
||||
z.writestr("_rels/.rels", f'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{rel}/officeDocument" Target="xl/workbook.xml"/></Relationships>')
|
||||
z.writestr("xl/workbook.xml", f'<workbook xmlns="{ns}" xmlns:r="{rel}"><sheets><sheet name="订单导入" sheetId="1" r:id="rId1"/><sheet name="选项数据" sheetId="2" state="{"hidden" if hidden else "visible"}" r:id="rId2"/><sheet name="填写说明" sheetId="3" r:id="rId3"/></sheets><definedNames><definedName name="option_0">选项数据!$A$1:$A${len(options)}</definedName></definedNames></workbook>')
|
||||
z.writestr("xl/_rels/workbook.xml.rels", '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' + ''.join(f'<Relationship Id="rId{i}" Type="{rel}/worksheet" Target="worksheets/sheet{i}.xml"/>' for i in (1, 2, 3)) + '</Relationships>')
|
||||
z.writestr("xl/worksheets/sheet1.xml", f'<worksheet xmlns="{ns}"><sheetData><row r="2"><c r="A2" t="inlineStr"><is><t>国内收货仓 *</t></is></c></row></sheetData><dataValidations count="1"><dataValidation type="list" showErrorMessage="1" sqref="{rows}"><formula1>{target}</formula1></dataValidation></dataValidations></worksheet>')
|
||||
data = ''.join(f'<row r="{i}"><c r="A{i}" t="inlineStr"><is><t>{escape(value)}</t></is></c></row>' for i, value in enumerate(options, 1))
|
||||
z.writestr("xl/worksheets/sheet2.xml", f'<worksheet xmlns="{ns}"><sheetData>{data}</sheetData></worksheet>')
|
||||
z.writestr("xl/worksheets/sheet3.xml", f'<worksheet xmlns="{ns}"><sheetData/></worksheet>')
|
||||
return body.getvalue()
|
||||
|
||||
|
||||
class TemplateAuditTest(unittest.TestCase):
|
||||
def test_reads_actual_xlsx_names_validation_and_all_options(self):
|
||||
result = audit.inspect_template(fixture(), {"国内收货仓": ["测试仓|WH-A", "测试仓|WH-B"]})
|
||||
self.assertTrue(result["passed"])
|
||||
self.assertEqual(result["checks"]["国内收货仓"]["actualCount"], 2)
|
||||
|
||||
def test_missing_extra_and_duplicate_options_are_reported_without_values(self):
|
||||
result = audit.inspect_template(fixture(), {"国内收货仓": ["测试仓|WH-A", "其他仓|WH-C"]})
|
||||
self.assertFalse(result["passed"])
|
||||
self.assertEqual(result["checks"]["国内收货仓"]["missingCount"], 1)
|
||||
self.assertEqual(result["checks"]["国内收货仓"]["extraCount"], 1)
|
||||
self.assertNotIn("WH-A", str(result))
|
||||
self.assertFalse(audit.compare_options(["A", "A"], ["A"])["passed"])
|
||||
|
||||
def test_refuses_short_ranges_unresolved_names_and_exposed_options(self):
|
||||
for options in ({"rows": "A3:A51"}, {"target": "missing"}, {"hidden": False}):
|
||||
with self.assertRaises(AssertionError):
|
||||
audit.inspect_template(fixture(**options), {"国内收货仓": ["测试仓|WH-A", "测试仓|WH-B"]})
|
||||
|
||||
def test_duplicate_business_labels_remain_distinct_choices(self):
|
||||
self.assertEqual(audit.labels(["A", "A", "A"], lambda x: x), ["A", "A(同名选项 2)", "A(同名选项 3)"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,25 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { executionOptions } from './client-workbench-test-env.mjs'
|
||||
|
||||
export async function execute(args = process.argv.slice(2), execArgs = process.execArgv, env = process.env) {
|
||||
if (!args.length || args.length === 1 && args[0] === '--plan') return { plan: true, businessWrites: false,
|
||||
checks: ['full warehouse catalog', 'channel union', 'own customer addresses', 'named dropdown ranges', '50 input rows'],
|
||||
required: 'Explicit TEST profile, new run-id, and --python=<bundled workspace Python>' }
|
||||
if (args.length !== 4 || !args[3].startsWith('--python=')) throw new Error('Explicit bundled Python required')
|
||||
const options = executionOptions(args.slice(0, 3), execArgs, env)
|
||||
const python = resolve(args[3].slice(9))
|
||||
if (!python.replaceAll('\\', '/').endsWith('/codex-primary-runtime/dependencies/python/python.exe')) throw new Error('Use bundled workspace Python')
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const config = { environment: 'TEST', base: options.base, username: env.CHONGCHONG_MERCHANT_USERNAME,
|
||||
password: env.CHONGCHONG_MERCHANT_PASSWORD, runId: options.runId }
|
||||
const child = spawn(python, [resolve(root, 'scripts/client-import-template-inspect.py')], { cwd: root, windowsHide: true, stdio: ['pipe', 'inherit', 'inherit'] })
|
||||
child.stdin.end(JSON.stringify(config))
|
||||
const code = await new Promise((accept, reject) => { child.on('error', reject); child.on('close', accept) })
|
||||
return { success: code === 0, report: resolve(root, 'reports', `client-template-${options.runId}`, 'report.json') }
|
||||
}
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
execute().then(result => { console.log(JSON.stringify(result)); if (result.success === false) process.exitCode = 1 })
|
||||
.catch(() => { console.error('Template audit did not start. Check explicit TEST profile, runtime and new run-id.'); process.exitCode = 1 })
|
||||
}
|
||||
@@ -23,8 +23,8 @@ export function executionOptions(args, execArgs, env) {
|
||||
requireFact(Boolean(env[key]), `Missing ${key}`)
|
||||
const base = new URL(env.CHONGCHONG_MERCHANT_API_BASE_URL)
|
||||
requireFact(['http:', 'https:'].includes(base.protocol) && !base.username && !base.password
|
||||
&& base.pathname.replace(/\/$/, '') === '/api', 'Expected the configured TEST API base')
|
||||
return { execute: true, runId: args[2].slice(9), base: base.href.replace(/\/$/, '') }
|
||||
&& base.pathname === '/' && !base.search && !base.hash, 'Expected the configured TEST Gateway origin')
|
||||
return { execute: true, runId: args[2].slice(9), base: base.origin + '/api' }
|
||||
}
|
||||
|
||||
export function validatePage(page, number, size) {
|
||||
|
||||
@@ -2,21 +2,22 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { executionOptions, validatePage, verifyWorkbench } from './client-workbench-test-env.mjs'
|
||||
|
||||
const env = { CHONGCHONG_ENV: 'TEST', CHONGCHONG_MERCHANT_API_BASE_URL: 'https://test.invalid/api', CHONGCHONG_MERCHANT_USERNAME: 'fixture', CHONGCHONG_MERCHANT_PASSWORD: 'fixture' }
|
||||
const env = { CHONGCHONG_ENV: 'TEST', CHONGCHONG_MERCHANT_API_BASE_URL: 'https://test.invalid', CHONGCHONG_MERCHANT_USERNAME: 'fixture', CHONGCHONG_MERCHANT_PASSWORD: 'fixture' }
|
||||
const args = ['--execute', '--environment=TEST', '--run-id=fixture']
|
||||
const execArgs = ['--env-file=C:/Users/BY-OY/.codex/env/chongchong-global/test-acceptance.env']
|
||||
test('plan has no credential or environment dependency', () => {
|
||||
assert.deepEqual(executionOptions([], [], {}), { execute: false })
|
||||
assert.deepEqual(executionOptions(['--plan'], [], {}), { execute: false })
|
||||
})
|
||||
test('execution requires explicit TEST profile, safe run id and API prefix', () => {
|
||||
test('execution requires explicit TEST profile, safe run id and Gateway origin', () => {
|
||||
assert.equal(executionOptions(args, execArgs, env).runId, 'fixture')
|
||||
assert.equal(executionOptions(args, execArgs, env).base, 'https://test.invalid/api')
|
||||
for (const [a, x, e] of [
|
||||
[args, [], env], [args, execArgs, { ...env, CHONGCHONG_ENV: 'PROD' }],
|
||||
[args, [execArgs[0].replace('test-acceptance', 'prod-acceptance')], env],
|
||||
[['--execute', '--environment=TEST', '--run-id=../escape'], execArgs, env],
|
||||
[[...args, '--resume'], execArgs, env],
|
||||
[args, execArgs, { ...env, CHONGCHONG_MERCHANT_API_BASE_URL: 'https://test.invalid/' }],
|
||||
[args, execArgs, { ...env, CHONGCHONG_MERCHANT_API_BASE_URL: 'https://test.invalid/other' }],
|
||||
]) assert.throws(() => executionOptions(a, x, e))
|
||||
})
|
||||
function fixture() {
|
||||
|
||||
Reference in New Issue
Block a user