Files
goauto/web/tests/unit/syb-quick-link.test.cjs
T

136 lines
9.7 KiB
JavaScript

const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const source = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-products/quick-link.js'), 'utf8').replace(/import[^\n]+\n/g, '').replace('export default', 'return')
const row = { id: 1, shopeeProductId: 11 }
const tick = () => new Promise(resolve => setImmediate(resolve))
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes }); return { promise, resolve } }
function instance(overrides = {}) {
const calls = { link: [], match: [], reads: 0, saved: [], refresh: 0 }
const dependencies = {
ElMessage: { warning() {} },
ElMessageBox: { confirm: async () => {}, close() {} },
listDevices: async () => ({ data: { items: [{ id: 10, status: 'offline', name: 'A' }, { id: 20, status: 'online', name: 'B' }] } }),
getShopeeProduct: async id => { calls.reads++; return { data: { product: { id, specContextVersion: 'before', pddProductId: calls.link.length ? 55 : null }, autoMatchTimeoutSeconds: 370 } } },
linkShopeeProductPdd: async (id, body) => { calls.link.push(body); return { data: { product: { id, specContextVersion: 'after' }, collectionSource: { taskId: 9, pddProductId: 55 } } } },
autoMatchShopeeSpecMappings: async (id, body, seconds) => { calls.match.push({ id, body, seconds }); return { data: { unmatchedCount: 0 } } },
previewPurchaseTasks: async () => ({ data: { items: [{ aiMatchEligible: true }] } }),
createRequestId: () => 'request-id', readQuickLinkDevice: () => null,
rememberQuickLinkDevice: (user, id) => { calls.saved.push(id); return true }, ...overrides
}
const options = new Function(...Object.keys(dependencies), source)(...Object.values(dependencies))
const vm = { ...options.methods, ...options.data(), canPurchase: true, products: [row], $store: { getters: { userId: 1 } }, purchaseReady: () => ({ processNextAction: 'open_pdd_link' }), loadPurchaseReadiness: async () => { calls.refresh++ } }
return { vm, calls, options }
}
test('first selection continues original row and forwards frozen context / #254 budget', async () => {
const { vm, calls } = instance()
await vm.quickStart(row); assert.equal(calls.link.length, 0)
vm.quickPicker.deviceId = 10; vm.quickSaveDevice(); await tick()
assert.equal(calls.link.length, 1); assert.equal(calls.link[0].latestCollection.sybProductId, 1)
assert.equal(calls.link[0].latestCollection.deviceId, 10)
assert.equal(calls.match[0].body.specContextVersion, 'after'); assert.equal(calls.match[0].seconds, 370)
assert.equal(vm.quickBusyId, null)
})
test('switch saves shared preference only; cancel changes nothing', async () => {
const { vm, calls } = instance(); vm.quickDeviceId = 10
await vm.quickOpenPicker(); vm.quickPicker.deviceId = 20; vm.quickCancelPicker()
assert.equal(vm.quickDeviceId, 10); assert.equal(calls.saved.length, 0)
await vm.quickOpenPicker(); vm.quickPicker.deviceId = 20; vm.quickSaveDevice()
assert.equal(vm.quickDeviceId, 20); assert.equal(calls.link.length, 0)
})
test('in-flight device is frozen and double click cannot duplicate POST', async () => {
const wait = deferred(); let links = 0, device
const { vm } = instance({ getShopeeProduct: () => wait.promise, linkShopeeProductPdd: async (id, body) => { links++; device = body.latestCollection.deviceId; return { data: { product: { specContextVersion: 'after' }, collectionSource: {} } } } })
vm.quickDeviceId = 10; const running = vm.quickStart(row)
vm.quickDeviceId = 20; await vm.quickStart(row)
wait.resolve({ data: { product: { specContextVersion: 'before' }, autoMatchTimeoutSeconds: 130 } }); await running
assert.equal(links, 1); assert.equal(device, 10)
})
test('leaving during prepare prevents later association / matching', async () => {
const wait = deferred(); const { vm, calls, options } = instance({ getShopeeProduct: () => wait.promise })
vm.quickDeviceId = 10; const running = vm.quickStart(row); options.beforeUnmount.call(vm)
wait.resolve({ data: { product: { specContextVersion: 'before' } } }); await running
assert.equal(calls.link.length, 0); assert.equal(calls.match.length, 0)
})
for (const error of ['timeout', 'Network Error', 'AI 匹配服务暂时不可用']) {
test(`${error}: finite readback, end loading, no automatic write retry`, async () => {
let matches = 0
const { vm, calls } = instance({ autoMatchShopeeSpecMappings: async () => { matches++; throw new Error(error) } })
vm.quickDeviceId = 10; await vm.quickStart(row)
assert.equal(calls.link.length, 1); assert.equal(matches, 1); assert.equal(calls.reads, 2)
assert.equal(vm.quickBusyId, null); assert.match(vm.quickMessages[1], /当前已有关联/)
})
}
test('failed recovery reports unknown and clears busy', async () => {
const { vm } = instance({ getShopeeProduct: async () => { throw new Error('offline') } })
vm.quickDeviceId = 10; await vm.quickStart(row)
assert.equal(vm.quickBusyId, null); assert.match(vm.quickMessages[1], /结果未确认/)
})
test('definite candidate rejection explains cause without AI or a write retry', async () => {
let links = 0
const { vm, calls } = instance({ linkShopeeProductPdd: async () => { links++; throw { response: { status: 409, data: { message: '最近一次临时采集不完整,请先补采' } } } } })
vm.quickDeviceId = 10; await vm.quickStart(row)
assert.equal(links, 1); assert.equal(calls.match.length, 0); assert.equal(calls.reads, 1)
assert.equal(vm.quickBusyId, null); assert.equal(vm.quickMessages[1], '最近一次临时采集不完整,请先补采')
})
test('manual-required readiness retains association without AI call', async () => {
const { vm, calls } = instance({ previewPurchaseTasks: async () => ({ data: { items: [{ aiMatchEligible: false, eligible: false }] } }) })
vm.quickDeviceId = 10; await vm.quickStart(row)
assert.equal(calls.link.length, 1); assert.equal(calls.match.length, 0); assert.match(vm.quickMessages[1], /人工处理/)
})
test('preference isolates user and API environment, handles storage failure', () => {
const source = fs.readFileSync(path.join(__dirname, '../../src/utils/quick-link-device-preference.js'), 'utf8').replace(/export function/g, 'function')
const map = new Map(); const process = { env: { VUE_APP_BASE_API: '/a' } }
const storage = { getItem: k => map.get(k), setItem: (k, v) => map.set(k, v) }
const p = new Function('localStorage', 'process', source + '; return {readQuickLinkDevice,rememberQuickLinkDevice}')(storage, process)
p.rememberQuickLinkDevice(1, 10); assert.equal(p.readQuickLinkDevice(1), 10); assert.equal(p.readQuickLinkDevice(2), null)
process.env.VUE_APP_BASE_API = '/b'; assert.equal(p.readQuickLinkDevice(1), null)
storage.setItem = () => { throw new Error('denied') }; assert.equal(p.rememberQuickLinkDevice(1, 20), false)
})
function replacementInstance(overrides = {}) {
const writes = [], previews = [], confirms = []
const context = instance({
getShopeeProduct: async () => ({ data: { product: { pddProductId: 44, specContextVersion: 'old' }, autoMatchTimeoutSeconds: 130 } }),
ElMessageBox: { confirm: async (...args) => { confirms.push(args) }, close() {} },
linkShopeeProductPdd: async (id, body) => {
const r = body.latestCollection.replacement
;(r.preview ? previews : writes).push(body)
return { data: { product: { specContextVersion: 'new', pddProductId: r.preview ? 44 : 55 }, collectionSource: { taskId: 9, pddProductId: 55, targetVersion: 'target', title: '新商品', previousTitle: '原商品', deviceName: 'A' } } }
}, ...overrides
})
context.vm.purchaseReady = () => ({ pddProductId: 44, processNextAction: 'open_pdd', processStage: 'pdd_collection_failed' })
context.vm.quickDeviceId = 10
return { ...context, writes, previews, confirms }
}
test('replacement confirms once and submits the frozen source', async () => {
const { vm, writes, previews, confirms, calls } = replacementInstance()
await vm.quickStart(row)
assert.equal(previews.length, 1); assert.equal(confirms.length, 1); assert.equal(writes.length, 1)
assert.deepEqual(writes[0].latestCollection.replacement, { preview: false, expectedPddProductId: 44, taskId: 9, targetPddProductId: 55, targetVersion: 'target' })
assert.equal(calls.match.length, 1)
})
test('cancel replacement never writes or matches', async () => {
const { vm, writes, calls } = replacementInstance({ ElMessageBox: { confirm: async () => { throw 'cancel' } } })
await vm.quickStart(row)
assert.equal(writes.length, 0); assert.equal(calls.match.length, 0); assert.match(vm.quickMessages[1], /已取消/)
})
test('same product continues matching without replacement confirmation', async () => {
const { vm, confirms, calls } = replacementInstance({ linkShopeeProductPdd: async () => ({ data: { product: { specContextVersion: 'old' }, collectionSource: { taskId: 9, pddProductId: 44, targetVersion: 'same' } } }) })
await vm.quickStart(row)
assert.equal(confirms.length, 0); assert.equal(calls.match.length, 1)
})
test('failed replacement readback does not call old association a success', async () => {
const { vm, calls } = replacementInstance({ linkShopeeProductPdd: async () => { throw new Error('timeout') } })
await vm.quickStart(row)
assert.match(vm.quickMessages[1], /仍为原商品/); assert.equal(calls.match.length, 0)
})
test('replacement is not shown for collecting, successful, unknown or other manual stages', () => {
const { vm } = replacementInstance()
for (const stage of ['pdd_collecting', 'purchase_succeeded', 'order_review', 'task_created', 'color_mapping', 'manual_action']) {
vm.purchaseReady = () => ({ pddProductId: 44, processNextAction: 'open_pdd', processStage: stage })
assert.equal(vm.quickCanLink(row), false, stage)
}
})