Files
goauto/web/tests/unit/syb-return-cancel.test.cjs
T

112 lines
8.9 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/return-cancel.js'), 'utf8').replace(/import[^\n]+\n/g, '').replace('export default', 'return')
const tick = () => new Promise(resolve => setImmediate(resolve))
function instance(overrides = {}) {
const writes = []; const reads = []; const selected = []; const confirmations = []
const dependencies = {
cancelReturnMatch: async (id, body) => { writes.push({ id, body }) },
getReturnMatch: async id => { reads.push(id); return { data: { match: { id, sybProductId: id - 10, status: 'matched' } } } }, ...overrides
}
const options = new Function(...Object.keys(dependencies), source)(...Object.values(dependencies))
const vm = { ...options.methods, ...options.data(), canPurchase: true, loading: false, returnMatchLoading: false, purchaseReadinessLoading: false, matchDetail: {}, loadGeneration: 0,
products: [1, 2, 3].map(id => ({ id, orderCode: `TEST-${id}` })),
purchaseReadiness: { 1: { processStage: 'return_pending' }, 2: { processStage: 'return_pending' }, 3: { processStage: 'return_used' } },
returnMatchByProductId: { 1: { id: 11, status: 'matched' }, 2: { id: 12, status: 'matched' }, 3: { id: 13, status: 'confirmed' } },
purchaseReady(row) { return this.purchaseReadiness[row.id] || {} },
$confirm: async text => { confirmations.push(text) }, $nextTick: async () => {},
$refs: { productTable: { clearSelection: () => selected.splice(0), toggleRowSelection: row => selected.push(row.id) } },
load: async function() { this.loadGeneration++ }
}
vm.selectedProducts = [...vm.products]
Object.entries(options.computed).forEach(([key, fn]) => Object.defineProperty(vm, key, { get: () => fn.call(vm) }))
return { vm, options, writes, reads, selected, confirmations }
}
test('matched pending count only; serial requests include frozen matched precondition and skip confirmed', async () => {
const { vm, writes, confirmations } = instance()
assert.equal(vm.returnCancelCandidates.length, 2)
await vm.runBatchCancelReturns()
assert.deepEqual(writes, [{ id: 11, body: { expectedStatus: 'matched' } }, { id: 12, body: { expectedStatus: 'matched' } }])
assert.match(confirmations[0], /2 条/)
assert.equal(vm.returnCancelResult.successCount, 2); assert.equal(vm.returnCancelResult.skippedCount, 1)
assert.equal(vm.returnCancelBatchLoading, false)
})
test('permission, empty, metadata failure/loading and conflicting actions disable entry', async () => {
for (const [key, value] of [['canPurchase', false], ['returnMatchLoadError', true], ['returnMatchLoading', true], ['purchaseReadinessLoading', true], ['returnTransitionLoading', true], ['returnMatchBatchLoading', true]]) {
const { vm, writes } = instance(); vm[key] = value; await vm.runBatchCancelReturns(); assert.equal(writes.length, 0)
}
const { vm, writes } = instance(); vm.selectedProducts = []; await vm.runBatchCancelReturns(); assert.equal(writes.length, 0)
})
test('confirmation dismissal does not write', async () => {
const { vm, writes } = instance(); vm.$confirm = async () => { throw new Error('cancel') }; await vm.runBatchCancelReturns()
assert.equal(writes.length, 0); assert.equal(vm.returnCancelResult.open, false); assert.equal(vm.returnCancelBatchLoading, false)
})
test('freeze identities, dedupe, prevent duplicate execution and serialize', async () => {
let release; const wait = new Promise(resolve => { release = resolve }); const writes = []
const { vm } = instance({ cancelReturnMatch: async id => { writes.push(id); if (id === 11) await wait } })
vm.selectedProducts.push(vm.products[0]); const running = vm.runBatchCancelReturns(); await tick()
vm.returnMatchByProductId[2].id = 99; vm.selectedProducts = []; await vm.runBatchCancelReturns()
assert.deepEqual(writes, [11]); release(); await running; assert.deepEqual(writes, [11, 12])
})
test('partial failure retains only readback-confirmed eligible failed row', async () => {
const { vm, selected, reads } = instance({ cancelReturnMatch: async id => { if (id === 12) throw { response: { status: 403 } } } })
await vm.runBatchCancelReturns()
assert.equal(vm.returnCancelResult.failedCount, 1); assert.deepEqual(reads, [12]); assert.deepEqual(selected, [2])
})
test('state conflict skips and never recancels original or replacement', async () => {
const { vm, reads } = instance({ cancelReturnMatch: async () => { throw { response: { status: 409, data: { code: 'STATE_CONFLICT' } } } } })
await vm.runBatchCancelReturns(); assert.equal(vm.returnCancelResult.skippedCount, 3); assert.equal(reads.length, 0)
})
test('uncertain timeout reads original once, no blind POST retry', async () => {
let writes = 0
const { vm, reads, selected } = instance({ cancelReturnMatch: async () => { writes++; throw new Error('timeout') }, getReturnMatch: async id => { reads.push(id); return { data: { match: { id, status: 'cancelled' } } } } })
await vm.runBatchCancelReturns(); assert.equal(writes, 2); assert.deepEqual(reads, [11, 12]); assert.equal(vm.returnCancelResult.skippedCount, 3); assert.deepEqual(selected, [])
})
test('failed readback leaves uncertain results unselected', async () => {
const { vm, selected } = instance({ cancelReturnMatch: async () => { throw new Error('offline') }, getReturnMatch: async () => { throw new Error('offline') } })
await vm.runBatchCancelReturns(); assert.equal(vm.returnCancelResult.failedCount, 2); assert.deepEqual(selected, [])
})
for (const error of [new Error('Unauthorized'), { response: { status: 401 } }]) {
test(`auth failure stops remaining writes (${error.message || 'HTTP'})`, async () => {
let writes = 0; const { vm } = instance({ cancelReturnMatch: async () => { writes++; throw error } })
await vm.runBatchCancelReturns(); assert.equal(writes, 1); assert.equal(vm.returnCancelResult.failedCount, 2); assert.equal(vm.returnCancelBatchLoading, false)
})
}
test('new query generation does not restore stale selection', async () => {
const { vm, selected } = instance({ cancelReturnMatch: async () => { throw new Error('timeout') } })
vm.load = async () => { vm.loadGeneration += 2 }; await vm.runBatchCancelReturns(); assert.deepEqual(selected, [])
})
test('filter removes failed row but keeps failure result; refresh failure preserves result', async () => {
const { vm, selected } = instance({ cancelReturnMatch: async () => { throw new Error('timeout') } })
vm.load = async () => { vm.loadGeneration++; vm.products = [] }; await vm.runBatchCancelReturns()
assert.equal(vm.returnCancelResult.failedCount, 2); assert.deepEqual(selected, [])
vm.products = [{ id: 1 }]; vm.selectedProducts = vm.products; vm.load = async () => { throw new Error('offline') }; await vm.runBatchCancelReturns()
assert.match(vm.returnCancelResult.refreshError, /刷新失败/)
})
test('unmount prevents further writes after in-flight request', async () => {
let release; const wait = new Promise(resolve => { release = resolve }); let writes = 0
const { vm, options } = instance({ cancelReturnMatch: async () => { writes++; await wait } })
const running = vm.runBatchCancelReturns(); await tick(); options.beforeUnmount.call(vm); release(); await running
assert.equal(writes, 1); assert.equal(vm.returnCancelBatchLoading, false)
})
test('legacy interceptor false/6401 result is auth failure, not cancellation success', async () => {
let writes = 0
const { vm } = instance({ cancelReturnMatch: async () => { writes++; return false } })
await vm.runBatchCancelReturns(); assert.equal(writes, 1); assert.equal(vm.returnCancelResult.successCount, 0); assert.equal(vm.returnCancelResult.failedCount, 2)
})
test('pending rows can be selected for cancel but never become collection, purchase, image-search or AI candidates', () => {
const script = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-products/index.vue'), 'utf8').split('<script>')[1].split('</script>')[0].replace(/import[^\n]+\n/g, '').replace('export default', 'return')
const options = new Function('quickLink', 'returnCancel', 'returnConfirm', 'ShopeeProductDetailDrawer', 'PddProductDetailDrawer', script)({}, {}, {}, {}, {})
const row = { id: 1, shopeeProductId: 11, imageUrl: 'mock-image' }
const ready = { processStage: 'return_pending', eligible: true, collectionEligible: true, aiMatchEligible: true, pddProductId: 9 }
const vm = { ...options.methods, selectedProducts: [row], purchaseReadiness: { 1: ready }, canPurchase: true }
assert.equal(vm.isSelectableCandidate(row), true)
for (const method of ['isPurchaseCandidate', 'isCollectionCandidate', 'isAIMatchCandidate', 'isImageSearchCandidate', 'isReturnMatchCandidate']) assert.equal(vm[method](row), false, method)
ready.processStage = 'return_used'; assert.equal(vm.isSelectableCandidate(row), false)
ready.processStage = 'purchase_ready'; assert.equal(vm.isPurchaseCandidate(row), true)
})