112 lines
8.9 KiB
JavaScript
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)
|
|
})
|