Files
crawler-plugin/frontend-vue/tests/dispatch-guard.test.ts
T
huangzd1997 3ce0569c59 fix(提示语): 失败提示优先展示后端真实原因,修正 10+ 处误导文案
上架/删除品牌/去重/转换/拆分/撤回/查ASIN/产品风险/跟价/巡店/店铺匹配/视频工作台:
- 解析失败不再只报通用文案,逐条展示后端文件级 errorMessage(上架新增「店铺未找到」专属弹窗)
- 匹配失败不再一律「请检查店铺名」,按 matchStatus 区分 CONFLICT/PENDING
- 去重/转换/拆分全失败弹窗优先列各文件真实原因,取不到才回退猜测文案
- dispatch-guard 新增 fileErrors 选项与 collectDistinctErrors 导出
2026-09-17 23:02:15 +08:00

493 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
EXCEL_CSV_EXTENSIONS,
EXCEL_EXTENSIONS,
basenameOf,
checkParseResult,
checkQueuePayload,
checkSelectedFiles,
collectDistinctErrors,
extensionOf,
findUnsafeJsonPaths,
guardPassed,
type GuardResult,
} from '../src/shared/dispatch-guard.ts'
function codes(result: GuardResult) {
return result.issues.map((issue) => issue.code)
}
// ---------------------------------------------------------------- 路径工具
test('test_task_101_dispatch_guard_normal_path_helpers', () => {
assert.equal(basenameOf('D:\\brand\\鲍丽明.xlsx'), '鲍丽明.xlsx')
assert.equal(basenameOf('/home/u/a/b.csv'), 'b.csv')
assert.equal(basenameOf('plain.xlsx'), 'plain.xlsx')
assert.equal(basenameOf('D:\\brand\\'), 'brand', '结尾斜杠不应产生空名')
assert.equal(extensionOf('D:\\brand\\鲍丽明.XLSX'), '.xlsx', '扩展名统一小写')
assert.equal(extensionOf('archive.tar.gz'), '.gz')
assert.equal(extensionOf('noext'), '')
assert.equal(extensionOf('.gitignore'), '', '隐藏文件不算扩展名')
assert.equal(extensionOf('trailingdot.'), '')
})
// ------------------------------------------------------------ 文件选择校验
test('test_task_101_dispatch_guard_normal_selected_files_pass', () => {
const result = checkSelectedFiles(
['D:\\brand\\a.xlsx', 'D:\\brand\\b.XLS'],
{ allowedExtensions: EXCEL_EXTENSIONS },
)
assert.equal(result.ok, true)
assert.equal(result.needsConfirm, false)
assert.equal(result.message, '')
assert.deepEqual(codes(result), [])
})
test('test_task_101_dispatch_guard_normal_selected_files_folder_items', () => {
// expandBrandFolderRecursive 返回的是 {absolutePath, relativePath} 结构
const result = checkSelectedFiles(
[
{ absolutePath: 'D:\\brand\\x\\a.xlsx', relativePath: 'x/a.xlsx' },
{ relativePath: 'x/b.csv' },
],
{ allowedExtensions: EXCEL_CSV_EXTENSIONS },
)
assert.equal(result.ok, true)
})
test('test_task_101_dispatch_guard_boundary_selected_files_single', () => {
const result = checkSelectedFiles(['a.xlsx'], { allowedExtensions: EXCEL_EXTENSIONS, maxFiles: 1 })
assert.equal(result.ok, true)
const over = checkSelectedFiles(['a.xlsx', 'b.xlsx'], {
allowedExtensions: EXCEL_EXTENSIONS,
maxFiles: 1,
})
assert.equal(over.ok, false)
assert.ok(codes(over).includes('files.too-many'))
assert.match(over.message, /一次最多选择 1 个文件/)
})
test('test_task_101_dispatch_guard_boundary_selected_files_empty', () => {
const empty = checkSelectedFiles([])
assert.equal(empty.ok, false)
assert.deepEqual(codes(empty), ['files.empty'])
assert.match(empty.message, /没有选择任何文件/)
})
test('test_task_101_dispatch_guard_invalid_selected_files_bad_extension', () => {
const result = checkSelectedFiles(
['D:\\brand\\good.xlsx', 'D:\\brand\\报表.txt', 'D:\\brand\\note.pdf'],
{ allowedExtensions: EXCEL_EXTENSIONS },
)
assert.equal(result.ok, false)
assert.deepEqual(codes(result), ['files.bad-extension'])
assert.match(result.message, /报表\.txt/)
assert.match(result.message, /note\.pdf/)
assert.ok(!result.message.includes('good.xlsx'), '合规文件不应出现在错误里')
})
test('test_task_101_dispatch_guard_invalid_selected_files_not_array', () => {
for (const bad of [null, undefined, 'a.xlsx', 42, {}]) {
const result = checkSelectedFiles(bad)
assert.equal(result.ok, false, `${String(bad)} 应被拦截`)
assert.deepEqual(codes(result), ['files.not-array'])
}
})
test('test_task_101_dispatch_guard_invalid_selected_files_blank_path', () => {
const result = checkSelectedFiles(['a.xlsx', '', ' ', { absolutePath: '' }])
assert.equal(result.ok, false)
assert.ok(codes(result).includes('files.blank-path'))
assert.match(result.message, /有 3 个文件路径为空/)
})
test('test_task_101_dispatch_guard_confirm_selected_files_duplicated', () => {
const result = checkSelectedFiles(['D:\\a.xlsx', 'd:\\A.XLSX'], {
allowedExtensions: EXCEL_EXTENSIONS,
})
assert.equal(result.ok, true, '重复不阻塞')
assert.equal(result.needsConfirm, true)
assert.deepEqual(codes(result), ['files.duplicated'])
assert.match(result.message, /重复选择/)
})
test('test_task_101_dispatch_guard_selected_files_block_wins_over_confirm', () => {
// 同时命中重复(confirm)与非法扩展名(block)时,只展示 block 文案
const result = checkSelectedFiles(['a.txt', 'a.txt'], { allowedExtensions: EXCEL_EXTENSIONS })
assert.equal(result.ok, false)
assert.equal(result.needsConfirm, false)
assert.match(result.message, /不支持/)
assert.ok(!result.message.includes('重复选择'))
})
// ------------------------------------------------------------ 解析结果校验
const parseVo = (over: Record<string, unknown> = {}) => ({
taskId: 12,
totalRows: 100,
acceptedRows: 100,
droppedRows: 0,
groupCount: 8,
...over,
})
test('test_task_101_dispatch_guard_normal_parse_result_pass', () => {
const result = checkParseResult(parseVo())
assert.equal(result.ok, true)
assert.equal(result.needsConfirm, false)
assert.deepEqual(codes(result), [])
})
test('test_task_101_dispatch_guard_normal_parse_result_missing_optional_counts', () => {
// collect-data 的 Vo 里 totalRows/droppedRows/groupCount 都是可选的
const result = checkParseResult({ taskId: 3, acceptedRows: 20 })
assert.equal(result.ok, true)
assert.equal(result.needsConfirm, false)
})
test('test_task_101_dispatch_guard_boundary_parse_result_single_row', () => {
const result = checkParseResult(parseVo({ totalRows: 1, acceptedRows: 1, droppedRows: 0 }))
assert.equal(result.ok, true)
})
test('test_task_101_dispatch_guard_boundary_parse_result_zero_rows_blocked', () => {
// 这是最危险的一种:任务建好了但没有明细,推给 Python 会一直停在 RUNNING
const allDropped = checkParseResult(parseVo({ totalRows: 500, acceptedRows: 0, droppedRows: 500 }))
assert.equal(allDropped.ok, false)
assert.deepEqual(codes(allDropped), ['parse.all-dropped'])
assert.match(allDropped.message, /共读取 500 行/)
assert.match(allDropped.message, /卡在执行中/)
const emptyFile = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0, droppedRows: 0 }))
assert.equal(emptyFile.ok, false)
assert.deepEqual(codes(emptyFile), ['parse.empty-file'])
assert.match(emptyFile.message, /没有读到任何数据行/)
})
test('test_task_101_dispatch_guard_parse_result_file_errors_shown_instead_of_empty_file', () => {
// 2026-09-17:店铺未录入后台导致文件级失败,用户被「空文件」文案误导反复重传
const result = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), {
fileErrors: ['后台店铺管理中未找到店铺:林清斌,请先添加店铺信息'],
})
assert.equal(result.ok, false)
assert.deepEqual(codes(result), ['parse.file-failed'])
assert.match(result.message, /林清斌/)
assert.ok(!result.message.includes('没有读到任何数据行'), '有具体原因时不展示通用空文件文案')
})
test('test_task_101_dispatch_guard_parse_result_file_errors_dedupe_blank_and_limit', () => {
const deduped = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), {
fileErrors: ['店铺未匹配', ' ', '店铺未匹配', '', '第二条原因'],
})
assert.deepEqual(codes(deduped), ['parse.file-failed'])
assert.equal(
deduped.message.split('\n').filter((line) => line.startsWith('· ')).length,
2,
'空串与重复原因不应重复展示',
)
const many = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), {
fileErrors: Array.from({ length: 10 }, (_, index) => `原因 ${index}`),
})
assert.match(many.message, /另有 2 条不同原因/)
})
test('test_task_101_dispatch_guard_parse_result_blank_file_errors_fallback_to_empty_file', () => {
const result = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), {
fileErrors: [' ', ''],
})
assert.deepEqual(codes(result), ['parse.empty-file'])
})
test('test_task_101_dispatch_guard_collect_distinct_errors', () => {
assert.deepEqual(collectDistinctErrors(['a', ' ', 'a', '', 'b']), ['a', 'b'], '去空去重且保持顺序')
assert.deepEqual(collectDistinctErrors([' 店铺未找到 ']), ['店铺未找到'], '首尾空白裁剪')
assert.deepEqual(collectDistinctErrors([]), [])
assert.deepEqual(collectDistinctErrors(undefined), [])
assert.deepEqual(collectDistinctErrors([null, 7, undefined]), [], '非字符串项忽略')
})
test('test_task_101_dispatch_guard_parse_result_file_errors_ignored_when_rows_present', () => {
const result = checkParseResult(parseVo(), { fileErrors: ['某文件失败'] })
assert.equal(result.ok, true)
assert.equal(result.needsConfirm, false)
})
test('test_task_101_dispatch_guard_parse_result_zero_rows_allowed_when_not_required', () => {
const result = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), { requireRows: false })
assert.equal(result.ok, true)
})
test('test_task_101_dispatch_guard_confirm_parse_result_partial_dropped', () => {
const result = checkParseResult(parseVo({ totalRows: 100, acceptedRows: 88, droppedRows: 12 }), {
requiredColumnsHint: 'ASIN / 国家',
})
assert.equal(result.ok, true, '还有 88 行可跑,不该硬拦')
assert.equal(result.needsConfirm, true)
assert.deepEqual(codes(result), ['parse.partial-dropped'])
assert.match(result.message, /12 行/)
assert.match(result.message, /只有 88 行会被执行/)
assert.match(result.message, /ASIN \/ 国家/)
assert.equal(result.title, '解析结果需要确认')
})
test('test_task_101_dispatch_guard_parse_result_require_groups', () => {
const noGroup = checkParseResult(parseVo({ groupCount: 0 }), { requireGroups: true })
assert.equal(noGroup.ok, false)
assert.deepEqual(codes(noGroup), ['parse.no-group'])
const ignored = checkParseResult(parseVo({ groupCount: 0 }))
assert.equal(ignored.ok, true, '没要求分组时 groupCount=0 不拦')
})
test('test_task_101_dispatch_guard_invalid_parse_result_bad_task_id', () => {
for (const taskId of [0, -1, null, undefined, NaN, '12', 1.5, Infinity]) {
const result = checkParseResult(parseVo({ taskId }))
assert.equal(result.ok, false, `taskId=${String(taskId)} 应被拦截`)
assert.deepEqual(codes(result), ['parse.bad-task-id'])
}
})
test('test_task_101_dispatch_guard_invalid_parse_result_not_object', () => {
for (const bad of [null, undefined, 'ok', 7, []]) {
const result = checkParseResult(bad)
assert.equal(result.ok, false, `${String(bad)} 应被拦截`)
assert.deepEqual(codes(result), ['parse.not-object'])
}
})
test('test_task_101_dispatch_guard_invalid_parse_result_bad_counts', () => {
const result = checkParseResult(parseVo({ acceptedRows: NaN, totalRows: -3 }))
assert.equal(result.ok, false)
assert.deepEqual(codes(result), ['parse.bad-count', 'parse.bad-count'])
assert.match(result.message, /totalRows/)
assert.match(result.message, /acceptedRows/)
})
test('test_task_101_dispatch_guard_parse_result_does_not_mutate_input', () => {
const input = parseVo({ totalRows: 10, acceptedRows: 6, droppedRows: 4 })
const snapshot = JSON.parse(JSON.stringify(input))
checkParseResult(input, { requiredColumnsHint: 'ASIN' })
assert.deepEqual(input, snapshot)
})
// ------------------------------------------------------------ 入队 payload
const runPayload = (over: Record<string, unknown> = {}) => ({
type: 'similar-asin-run',
ts: 1700000000000,
data: {
taskId: 12,
user_id: 3,
api_key: 'sk-xxx',
acceptedRows: 88,
...over,
},
})
test('test_task_101_dispatch_guard_normal_queue_payload_pass', () => {
const result = checkQueuePayload(runPayload(), {
expectedType: 'similar-asin-run',
requiredDataKeys: ['taskId', 'api_key'],
})
assert.equal(result.ok, true)
assert.deepEqual(codes(result), [])
})
test('test_task_101_dispatch_guard_normal_queue_payload_optional_undefined_is_fine', () => {
// payload 里到处是 `taskNo: vo?.taskNo`undefined 可选字段不能被误拦
const result = checkQueuePayload(runPayload({ taskNo: undefined, note: undefined }), {
requiredDataKeys: ['taskId'],
})
assert.equal(result.ok, true)
assert.deepEqual(codes(result), [])
})
test('test_task_101_dispatch_guard_boundary_queue_payload_empty_collections', () => {
const emptyArray = checkQueuePayload(runPayload({ items: [] }), { nonEmptyArrayKeys: ['items'] })
assert.equal(emptyArray.ok, false)
assert.deepEqual(codes(emptyArray), ['payload.empty-array'])
assert.match(emptyArray.message, /没得可做/)
const oneItem = checkQueuePayload(runPayload({ items: [{ asin: 'B001' }] }), {
nonEmptyArrayKeys: ['items'],
})
assert.equal(oneItem.ok, true, '1 条也算有内容')
const emptyMap = checkQueuePayload(runPayload({ asin_rows_by_country: {} }), {
nonEmptyObjectKeys: ['asin_rows_by_country'],
})
assert.equal(emptyMap.ok, false)
assert.deepEqual(codes(emptyMap), ['payload.empty-object'])
const filledMap = checkQueuePayload(runPayload({ asin_rows_by_country: { DE: [{ asin: 'B1' }] } }), {
nonEmptyObjectKeys: ['asin_rows_by_country'],
})
assert.equal(filledMap.ok, true)
})
test('test_task_101_dispatch_guard_boundary_queue_payload_positive_numbers', () => {
// 0 行 / 0 页不是 nullrequiredDataKeys 拦不住,但 Python 端会翻 0 页后空转
const zero = checkQueuePayload(runPayload({ totalRows: 0, totalPages: 0 }), {
positiveNumberKeys: ['totalRows', 'totalPages'],
})
assert.equal(zero.ok, false)
assert.deepEqual(codes(zero), ['payload.non-positive'])
assert.match(zero.message, /totalRows=0/)
assert.match(zero.message, /totalPages=0/)
const one = checkQueuePayload(runPayload({ totalRows: 1, totalPages: 1 }), {
positiveNumberKeys: ['totalRows', 'totalPages'],
})
assert.equal(one.ok, true, '1 行 1 页是合法下界')
const missing = checkQueuePayload(runPayload(), { positiveNumberKeys: ['totalRows'] })
assert.equal(missing.ok, false, '字段缺失同样算不可执行')
const negative = checkQueuePayload(runPayload({ totalRows: -5 }), {
positiveNumberKeys: ['totalRows'],
})
assert.equal(negative.ok, false)
})
test('test_task_101_dispatch_guard_invalid_queue_payload_missing_required', () => {
const result = checkQueuePayload(runPayload({ api_key: ' ', taskId: null }), {
requiredDataKeys: ['taskId', 'api_key', 'shop_name'],
})
assert.equal(result.ok, false)
assert.deepEqual(codes(result), ['payload.missing-field'])
assert.match(result.message, /taskId、api_key、shop_name/)
})
test('test_task_101_dispatch_guard_invalid_queue_payload_shape', () => {
const notObject = checkQueuePayload('{"type":"x"}')
assert.equal(notObject.ok, false)
assert.deepEqual(codes(notObject), ['payload.not-object'])
const noType = checkQueuePayload({ data: { taskId: 1 } })
assert.equal(noType.ok, false)
assert.ok(codes(noType).includes('payload.bad-type'))
const wrongType = checkQueuePayload(runPayload(), { expectedType: 'publish-run' })
assert.equal(wrongType.ok, false)
assert.ok(codes(wrongType).includes('payload.type-mismatch'))
const noData = checkQueuePayload({ type: 'x-run', data: null })
assert.equal(noData.ok, false)
assert.ok(codes(noData).includes('payload.bad-data'))
})
test('test_task_101_dispatch_guard_invalid_queue_payload_unsafe_json', () => {
// NaN 会被 JSON.stringify 静默改写成 nullPython 侧拿到 None 后空转
const nan = checkQueuePayload(runPayload({ minPrice: NaN }))
assert.equal(nan.ok, false)
assert.deepEqual(codes(nan), ['payload.unsafe-json'])
assert.match(nan.message, /payload\.data\.minPrice/)
assert.match(nan.message, /NaN 会被序列化成 null/)
const infinite = checkQueuePayload(runPayload({ rounds: Infinity }))
assert.equal(infinite.ok, false)
assert.match(infinite.message, /payload\.data\.rounds/)
})
test('test_task_101_dispatch_guard_invalid_queue_payload_circular_reference', () => {
const payload = runPayload() as Record<string, unknown>
const data = payload.data as Record<string, unknown>
data.self = data
const result = checkQueuePayload(payload)
assert.equal(result.ok, false)
assert.deepEqual(codes(result), ['payload.unsafe-json'])
assert.match(result.message, /循环引用/)
// 校验本身不能抛错,也不能改动输入
assert.equal(data.self, data)
})
// ------------------------------------------------- findUnsafeJsonPaths 细节
test('test_task_101_dispatch_guard_unsafe_json_normal_clean_object', () => {
assert.deepEqual(findUnsafeJsonPaths({ a: 1, b: 'x', c: true, d: null, e: [1, 2], f: {} }), [])
assert.deepEqual(findUnsafeJsonPaths({ when: new Date(0) }), [], '有效 Date 可安全序列化')
})
test('test_task_101_dispatch_guard_unsafe_json_boundary_undefined_placement', () => {
// 对象属性 undefined = 可选字段,放行;数组元素 undefined 会变 null,报告
assert.deepEqual(findUnsafeJsonPaths({ optional: undefined }), [])
const inArray = findUnsafeJsonPaths({ items: [1, undefined, 3] })
assert.equal(inArray.length, 1)
assert.equal(inArray[0].path, 'payload.items[1]')
const holes = findUnsafeJsonPaths({ items: [1, , 3] })
assert.equal(holes.length, 1)
assert.match(holes[0].reason, /空洞/)
})
test('test_task_101_dispatch_guard_unsafe_json_invalid_types', () => {
const found = findUnsafeJsonPaths({
fn: () => 1,
sym: Symbol('s'),
big: BigInt(9),
map: new Map([['a', 1]]),
set: new Set([1]),
badDate: new Date('nope'),
})
const byPath = Object.fromEntries(found.map((item) => [item.path, item.reason]))
assert.match(byPath['payload.fn'], /函数/)
assert.match(byPath['payload.sym'], /Symbol/)
assert.match(byPath['payload.big'], /BigInt/)
assert.match(byPath['payload.map'], /Map/)
assert.match(byPath['payload.set'], /Set/)
assert.match(byPath['payload.badDate'], /无效日期/)
})
test('test_task_101_dispatch_guard_unsafe_json_boundary_deep_nesting', () => {
let deep: Record<string, unknown> = { leaf: 1 }
for (let i = 0; i < 70; i += 1) deep = { next: deep }
const found = findUnsafeJsonPaths(deep)
assert.ok(found.length > 0)
assert.match(found[0].reason, /嵌套层级超过/)
})
test('test_task_101_dispatch_guard_unsafe_json_repeated_reference_is_not_circular', () => {
// 同一个对象被两个 key 引用(DAG)不是环,不该误报
const shared = { asin: 'B001' }
assert.deepEqual(findUnsafeJsonPaths({ a: shared, b: shared }), [])
assert.deepEqual(findUnsafeJsonPaths({ list: [shared, shared] }), [])
})
// ----------------------------------------------------------------- 其他
test('test_task_101_dispatch_guard_passed_helper_is_inert', () => {
const result = guardPassed()
assert.equal(result.ok, true)
assert.equal(result.needsConfirm, false)
assert.equal(result.message, '')
assert.deepEqual(result.issues, [])
})
test('test_task_101_dispatch_guard_repeated_calls_are_idempotent', () => {
const files = ['a.xlsx', 'b.xlsx']
assert.deepEqual(checkSelectedFiles(files), checkSelectedFiles(files))
const vo = parseVo({ droppedRows: 5, acceptedRows: 95 })
assert.deepEqual(checkParseResult(vo), checkParseResult(vo))
const payload = runPayload()
assert.deepEqual(checkQueuePayload(payload), checkQueuePayload(payload))
})
test('test_task_101_dispatch_guard_dependency_failure_does_not_throw', () => {
// Proxy getter 故障时校验不应把异常抛给调用方之外的路径;这里确认异常可被捕获
const poisoned = new Proxy(
{ taskId: 1, totalRows: 5, acceptedRows: 5 },
{
get(target, prop, receiver) {
if (prop === 'acceptedRows') throw new Error('getter down')
return Reflect.get(target, prop, receiver)
},
},
)
assert.throws(() => checkParseResult(poisoned), /getter down/)
// 修好之后同一入口可以继续用,说明模块没有内部状态残留
assert.equal(checkParseResult({ taskId: 1, totalRows: 5, acceptedRows: 5 }).ok, true)
})