363 lines
19 KiB
JavaScript
363 lines
19 KiB
JavaScript
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
const test = require('node:test')
|
|
const { parse, compileScript, compileTemplate, compileStyle } = require('@vue/compiler-sfc')
|
|
const ts = require('typescript')
|
|
const vue = require('vue')
|
|
|
|
const filename = path.join(__dirname, '../src/views/first_visit/wecom_promotion/index.vue')
|
|
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
|
|
assert.deepEqual(errors, [])
|
|
const script = compileScript(descriptor, { id: 'member-sync-test' })
|
|
const template = compileTemplate({ source: descriptor.template.content, filename, id: 'member-sync-test', compilerOptions: { bindingMetadata: script.bindings } })
|
|
assert.deepEqual(template.errors, [])
|
|
assert.deepEqual(compileStyle({ source: descriptor.styles[0].content, filename, id: 'member-sync-test', scoped: true, preprocessLang: 'scss' }).errors, [])
|
|
|
|
function loadModule(source, mockRequire) {
|
|
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
|
const module = { exports: {} }
|
|
new Function('require', 'module', 'exports', compiled)(mockRequire, module, module.exports)
|
|
return module.exports
|
|
}
|
|
|
|
const automation = loadModule(fs.readFileSync(path.join(path.dirname(filename), 'components/promotion-automation.ts'), 'utf8'), require)
|
|
const member = (id, enabled = 1) => ({ id, admin_id: id, userid: `member-${id}`, name: `医助 ${id}`, enabled, reception_available: enabled === 1, is_in_remote_range: true })
|
|
const pool = (id = 1) => ({ id, name: `方案 ${id}`, status: 1, can_operate: true, can_manage_access: true, member_admin_ids: [1, 2], member_rules: [member(1), member(2, 0)], official_link: { range_userids: ['member-1', 'member-2'] }, dispatch_sync: { status: 1 } })
|
|
|
|
function page(api = {}, pools = [pool()]) {
|
|
const messages = []
|
|
const emitMessage = (type, value) => messages.push({ type, message: typeof value === 'string' ? value : value.message })
|
|
const ElMessage = (value) => emitMessage(value.type, value)
|
|
for (const type of ['success', 'warning', 'error']) ElMessage[type] = (value) => emitMessage(type, value)
|
|
const instance = loadModule(script.content, (name) => {
|
|
if (name === 'vue') return { ...vue, onMounted() {} }
|
|
if (name === 'element-plus') return { ElMessage, ElMessageBox: {} }
|
|
if (name === '@element-plus/icons-vue') return {}
|
|
if (name === '@/api/first_visit') return { wecomPromotionOverview: async () => ({ pools }), ...api }
|
|
if (name.endsWith('.vue')) return {}
|
|
if (name === './components/promotion-automation') return automation
|
|
throw new Error(`Unexpected dependency: ${name}`)
|
|
}).default.setup({}, { expose() {} })
|
|
Object.assign(instance.overview, { pools })
|
|
instance.selectedPoolId.value = pools[0]?.id
|
|
return { state: instance, messages }
|
|
}
|
|
|
|
test('disabled local member still in remote snapshot is explicitly pending removal', () => {
|
|
const { state } = page()
|
|
assert.equal(state.routeStatus(member(2, 0)).label, '待移出(仍在企微)')
|
|
assert.equal(state.routeStatus({ ...member(1), is_in_remote_range: '0' }).label, '待加入企微')
|
|
assert.match(state.selectedSyncState.value.description, /当前计划:医助 1;上次企微确认:医助 1、医助 2/)
|
|
state.overview.pools[0].dispatch_sync = { status: 3, last_error: '可信 IP 校验失败' }
|
|
assert.match(state.selectedSyncState.value.description, /可信 IP 校验失败/)
|
|
})
|
|
|
|
test('single toggles never infer remote success from an empty error or planned dispatch', async () => {
|
|
const calls = []
|
|
const { state, messages } = page({ wecomPromotionToggleMember: async (payload) => { calls.push(payload); return { sync_error: '', dispatch: { queued: true } } } })
|
|
await state.handleMemberToggle(member(2), false)
|
|
assert.deepEqual(calls, [{ id: 2, status: 0 }])
|
|
assert.equal(messages.at(-1).type, 'warning')
|
|
assert.match(messages.at(-1).message, /尚未确认同步/)
|
|
assert.doesNotMatch(messages.at(-1).message, /已确认同步|已重新计算/)
|
|
assert.match(state.operationResultText(state.operationResults.value[0]), /尚未确认同步/)
|
|
})
|
|
|
|
test('single rule save reports explicit remote confirmation and preserves precise failures', async () => {
|
|
const { state, messages } = page({ wecomPromotionSaveMember: async () => ({ sync_status: 'failed', sync_error: '企微成员范围不一致' }) })
|
|
Object.assign(state.memberForm, { id: 2, active_range: [] })
|
|
await state.saveMemberRule()
|
|
assert.equal(messages.at(-1).type, 'warning')
|
|
assert.match(messages.at(-1).message, /企微成员范围不一致/)
|
|
state.notifySavedResult('本地已保存', { sync_status: 'synced' })
|
|
assert.equal(messages.at(-1).type, 'success')
|
|
assert.match(messages.at(-1).message, /企微成员范围已确认同步/)
|
|
})
|
|
|
|
test('pool saves with queued work retain a warning instead of implying official link completion', async () => {
|
|
const { state, messages } = page({ wecomPromotionSavePool: async () => ({ id: 1, sync_status: 'pending', sync_queued: true, sync_error: '' }) })
|
|
Object.assign(state.poolForm, { id: 1, name: '方案 1', member_admin_ids: [1] })
|
|
await state.savePool()
|
|
assert.equal(state.poolDialogVisible.value, false)
|
|
assert.equal(messages.at(-1).type, 'warning')
|
|
assert.match(messages.at(-1).message, /尚未确认同步/)
|
|
assert.equal(state.operationResults.value[0].sync_queued, true)
|
|
})
|
|
|
|
test('manual retry targets only its pool and does not import remote links', async () => {
|
|
const calls = []
|
|
const { state, messages } = page({ wecomPromotionSyncMemberRange: async (payload) => { calls.push(payload); return { pool_id: 1, sync_status: 'synced', sync_error: '', sync_queued: false } } })
|
|
await state.syncMemberRange(1)
|
|
assert.deepEqual(calls, [{ pool_id: 1 }])
|
|
assert.equal(state.operationResults.value[0].sync_status, 'synced')
|
|
assert.equal(state.syncingPoolId.value, 0)
|
|
assert.equal(messages.at(-1).type, 'success')
|
|
state.overview.pools[0].can_operate = false
|
|
await state.syncMemberRange(1)
|
|
assert.equal(calls.length, 1)
|
|
})
|
|
|
|
test('batch sync awaits all queued successful pools with at most two concurrent requests', async () => {
|
|
const pending = []
|
|
let active = 0
|
|
let maximum = 0
|
|
const { state } = page({ wecomPromotionSyncMemberRange: ({ pool_id }) => new Promise((resolve) => {
|
|
active++
|
|
maximum = Math.max(maximum, active)
|
|
pending.push({ id: pool_id, finish: (result) => { active--; resolve({ pool_id, ...result }) } })
|
|
}) })
|
|
const results = [1, 2, 3].map((id) => ({ id, name: `方案 ${id}`, success: true, sync_queued: true }))
|
|
results.push({ id: 4, name: '保存失败方案', success: false, sync_queued: true, error: '无权限' })
|
|
const saving = state.syncBatchResults(results)
|
|
assert.deepEqual(pending.map((item) => item.id), [1, 2])
|
|
pending[0].finish({ sync_status: 'synced', sync_error: '' })
|
|
await new Promise((resolve) => setImmediate(resolve))
|
|
assert.deepEqual(pending.map((item) => item.id), [1, 2, 3])
|
|
assert.equal(state.batchSyncProgress.completed, 1)
|
|
pending[1].finish({ sync_status: 'failed', sync_error: '范围校验失败' })
|
|
pending[2].finish({ sync_status: 'pending', sync_error: '' })
|
|
await saving
|
|
assert.equal(maximum, 2)
|
|
assert.equal(state.batchSyncProgress.completed, 3)
|
|
assert.deepEqual(state.operationResults.value.map((item) => item.sync_status), ['synced', 'failed', 'pending', undefined])
|
|
assert.match(state.operationResultText(state.operationResults.value[1]), /范围校验失败/)
|
|
assert.match(state.operationResultText(state.operationResults.value[3]), /本地保存失败:无权限/)
|
|
})
|
|
|
|
test('batch save retains partial failures and never reports queued work as remote success', async () => {
|
|
const pools = [pool(1), pool(2), pool(3)]
|
|
const syncedIds = []
|
|
const { state, messages } = page({
|
|
wecomPromotionBatchUpdatePools: async () => ({ updated: 2, failed: 1, member_updated: 2, results: [
|
|
{ id: 1, name: '方案 1', success: true, sync_queued: true },
|
|
{ id: 2, name: '方案 2', success: true, sync_queued: true },
|
|
{ id: 3, name: '方案 3', success: false, error: '保存失败' }
|
|
] }),
|
|
wecomPromotionSyncMemberRange: async ({ pool_id }) => {
|
|
syncedIds.push(pool_id)
|
|
if (pool_id === 2) throw '企微请求超时'
|
|
return { pool_id, sync_status: 'synced', sync_error: '', sync_queued: false }
|
|
}
|
|
}, pools)
|
|
Object.assign(state.batchConfigApply, { member_status: true })
|
|
Object.assign(state.batchConfigForm, { pool_ids: [1, 2, 3], member_admin_ids: [2], member_status: 0 })
|
|
state.batchConfigDialogVisible.value = true
|
|
await state.saveBatchConfig()
|
|
assert.deepEqual(syncedIds, [1, 2])
|
|
assert.equal(state.savingBatchConfig.value, false)
|
|
assert.equal(state.batchConfigDialogVisible.value, false)
|
|
assert.deepEqual(state.selectedPoolIds.value, [2, 3])
|
|
assert.match(messages.at(-1).message, /企微已确认同步 1 个,1 个尚未确认同步;1 个本地保存失败/)
|
|
assert.equal(messages.at(-1).type, 'warning')
|
|
assert.match(state.operationResults.value[1].sync_error, /企微请求超时/)
|
|
})
|
|
|
|
test('direct batch sync snapshots and deduplicates selection, limits concurrency, and waits for refresh', async () => {
|
|
const pools = [pool(1), pool(2), pool(3), pool(4)]
|
|
const pending = []
|
|
const unrelatedCalls = []
|
|
let active = 0
|
|
let maximum = 0
|
|
let refreshCount = 0
|
|
let finishRefresh
|
|
const api = Object.fromEntries([
|
|
'wecomPromotionBatchUpdatePools', 'wecomPromotionSavePool', 'wecomPromotionSaveMember',
|
|
'wecomPromotionBatchSetOperators', 'wecomPromotionSyncCustomers', 'wecomPromotionToggleMember'
|
|
].map((name) => [name, async (payload) => { unrelatedCalls.push({ name, payload }); return {} }]))
|
|
const { state, messages } = page({
|
|
...api,
|
|
wecomPromotionOverview: () => {
|
|
refreshCount++
|
|
return new Promise((resolve) => { finishRefresh = () => resolve({ pools }) })
|
|
},
|
|
wecomPromotionSyncMemberRange: ({ pool_id }) => new Promise((resolve) => {
|
|
active++
|
|
maximum = Math.max(maximum, active)
|
|
pending.push({ id: pool_id, finish: () => {
|
|
active--
|
|
resolve({ pool_id, sync_status: 'synced', sync_error: '', sync_queued: false })
|
|
} })
|
|
})
|
|
}, pools)
|
|
state.selectedPoolIds.value = [1, 1, 2, 3]
|
|
let completed = false
|
|
const syncing = state.syncSelectedMemberRanges().then(() => { completed = true })
|
|
assert.equal(state.syncingBatchMembers.value, true)
|
|
assert.deepEqual(pending.map((item) => item.id), [1, 2])
|
|
assert.deepEqual({ ...state.batchSyncProgress }, { completed: 0, total: 3 })
|
|
|
|
// A changed ref must not alter the already-dispatched batch's work list.
|
|
state.selectedPoolIds.value = [4]
|
|
pending[0].finish()
|
|
await new Promise((resolve) => setImmediate(resolve))
|
|
assert.deepEqual(pending.map((item) => item.id), [1, 2, 3])
|
|
assert.equal(state.batchSyncProgress.completed, 1)
|
|
assert.equal(refreshCount, 0)
|
|
pending[1].finish()
|
|
await new Promise((resolve) => setImmediate(resolve))
|
|
assert.equal(state.batchSyncProgress.completed, 2)
|
|
assert.equal(completed, false)
|
|
assert.equal(refreshCount, 0)
|
|
pending[2].finish()
|
|
await new Promise((resolve) => setImmediate(resolve))
|
|
assert.equal(maximum, 2)
|
|
assert.equal(refreshCount, 1)
|
|
assert.equal(state.syncingBatchMembers.value, true)
|
|
assert.equal(state.batchSyncProgress.completed, 3)
|
|
assert.equal(completed, false)
|
|
finishRefresh()
|
|
await syncing
|
|
|
|
assert.equal(state.syncingBatchMembers.value, false)
|
|
assert.deepEqual({ ...state.batchSyncProgress }, { completed: 0, total: 0 })
|
|
assert.deepEqual(state.selectedPoolIds.value, [])
|
|
assert.deepEqual(state.operationResults.value.map((item) => item.id), [1, 2, 3])
|
|
assert.ok(state.operationResults.value.every((item) => item.sync_only && item.sync_status === 'synced'))
|
|
assert.deepEqual(unrelatedCalls, [])
|
|
assert.equal(messages.at(-1).type, 'success')
|
|
assert.match(messages.at(-1).message, /已确认同步 3 个/)
|
|
assert.doesNotMatch(messages.at(-1).message, /本地.*保存/)
|
|
for (const result of state.operationResults.value) {
|
|
assert.match(state.operationResultText(result), /已确认同步/)
|
|
assert.doesNotMatch(state.operationResultText(result), /本地.*保存/)
|
|
}
|
|
})
|
|
|
|
test('direct batch sync keeps failed, pending and blocked pools selected with precise results', async () => {
|
|
const pools = [pool(1), pool(2), pool(3), { ...pool(4), official_link: null }, { ...pool(5), can_operate: false }]
|
|
const calls = []
|
|
let refreshCount = 0
|
|
const { state, messages } = page({
|
|
wecomPromotionOverview: async () => { refreshCount++; return { pools } },
|
|
wecomPromotionSyncMemberRange: async ({ pool_id }) => {
|
|
calls.push(pool_id)
|
|
if (pool_id === 2) throw new Error('企微请求超时,请检查网络')
|
|
return { pool_id, sync_status: pool_id === 1 ? 'synced' : 'pending', sync_queued: pool_id === 3, sync_error: '' }
|
|
}
|
|
}, pools)
|
|
state.selectedPoolIds.value = [1, 2, 3, 4, 5]
|
|
await state.syncSelectedMemberRanges()
|
|
|
|
assert.deepEqual(calls, [1, 2, 3])
|
|
assert.equal(refreshCount, 1)
|
|
assert.deepEqual(state.selectedPoolIds.value, [2, 3, 4, 5])
|
|
assert.deepEqual(state.operationResults.value.map((item) => item.sync_status), ['synced', 'failed', 'pending', 'blocked', 'blocked'])
|
|
assert.ok(state.operationResults.value.every((item) => item.sync_only))
|
|
assert.match(state.operationResults.value[1].sync_error, /企微请求超时,请检查网络/)
|
|
assert.equal(state.operationResults.value[2].sync_queued, true)
|
|
assert.match(state.operationResultText(state.operationResults.value[3]), /链接/)
|
|
assert.match(state.operationResultText(state.operationResults.value[4]), /权限|无权/)
|
|
for (const result of state.operationResults.value) assert.doesNotMatch(state.operationResultText(result), /本地.*保存/)
|
|
assert.equal(messages.at(-1).type, 'warning')
|
|
assert.match(messages.at(-1).message, /已确认同步 1 个/)
|
|
assert.match(messages.at(-1).message, /4 个尚未确认同步/)
|
|
assert.doesNotMatch(messages.at(-1).message, /本地.*保存/)
|
|
assert.equal(state.syncingBatchMembers.value, false)
|
|
assert.deepEqual({ ...state.batchSyncProgress }, { completed: 0, total: 0 })
|
|
})
|
|
|
|
test('direct batch sync prevents duplicate requests and freezes member and selection changes while busy', async () => {
|
|
const pools = [pool(1), pool(2)]
|
|
const calls = []
|
|
const toggles = []
|
|
let finish
|
|
const { state } = page({
|
|
wecomPromotionSyncMemberRange: ({ pool_id }) => {
|
|
calls.push(pool_id)
|
|
return new Promise((resolve) => { finish = () => resolve({ pool_id, sync_status: 'synced' }) })
|
|
},
|
|
wecomPromotionToggleMember: async (payload) => { toggles.push(payload); return { sync_status: 'synced' } }
|
|
}, pools)
|
|
state.selectedPoolIds.value = [1]
|
|
const syncing = state.syncSelectedMemberRanges()
|
|
await state.syncSelectedMemberRanges()
|
|
await state.syncMemberRange(2)
|
|
await state.handleMemberToggle(member(2), false)
|
|
state.togglePoolSelection(pools[0], false)
|
|
state.togglePoolSelection(pools[1], true)
|
|
assert.deepEqual(state.selectedPoolIds.value, [1])
|
|
state.toggleAllPoolSelection(false)
|
|
assert.deepEqual(state.selectedPoolIds.value, [1])
|
|
state.toggleAllPoolSelection(true)
|
|
assert.deepEqual(state.selectedPoolIds.value, [1])
|
|
assert.deepEqual(calls, [1])
|
|
assert.deepEqual(toggles, [])
|
|
finish()
|
|
await syncing
|
|
assert.equal(state.syncingBatchMembers.value, false)
|
|
})
|
|
|
|
test('direct batch sync cannot begin while a single sync, member toggle or batch save is active', async () => {
|
|
const calls = []
|
|
const { state } = page({ wecomPromotionSyncMemberRange: async (payload) => { calls.push(payload); return { sync_status: 'synced' } } })
|
|
state.selectedPoolIds.value = [1]
|
|
for (const [busy, value] of [[state.syncingPoolId, 1], [state.togglingMemberId, 2], [state.savingBatchConfig, true]]) {
|
|
busy.value = value
|
|
await state.syncSelectedMemberRanges()
|
|
assert.deepEqual(calls, [])
|
|
assert.equal(state.syncingBatchMembers.value, false)
|
|
busy.value = typeof value === 'boolean' ? false : 0
|
|
}
|
|
})
|
|
|
|
test('direct batch sync rejects empty and oversized selections but accepts 100 unique pools', async () => {
|
|
const pools = Array.from({ length: 101 }, (_, index) => pool(index + 1))
|
|
const calls = []
|
|
const { state, messages } = page({ wecomPromotionSyncMemberRange: async ({ pool_id }) => {
|
|
calls.push(pool_id)
|
|
return { pool_id, sync_status: 'synced' }
|
|
} }, pools)
|
|
await state.syncSelectedMemberRanges()
|
|
assert.equal(messages.at(-1).type, 'warning')
|
|
assert.match(messages.at(-1).message, /选择|勾选/)
|
|
assert.deepEqual(calls, [])
|
|
state.selectedPoolIds.value = pools.map((item) => item.id)
|
|
await state.syncSelectedMemberRanges()
|
|
assert.equal(messages.at(-1).type, 'warning')
|
|
assert.match(messages.at(-1).message, /100/)
|
|
assert.deepEqual(calls, [])
|
|
assert.equal(state.syncingBatchMembers.value, false)
|
|
state.selectedPoolIds.value = [...pools.slice(0, 100).map((item) => item.id), 1]
|
|
await state.syncSelectedMemberRanges()
|
|
assert.deepEqual(calls, pools.slice(0, 100).map((item) => item.id))
|
|
assert.equal(messages.at(-1).type, 'success')
|
|
})
|
|
|
|
test('shared operators can select and sync pools while access and configuration stay manager-only', async () => {
|
|
const pools = [
|
|
{ ...pool(1), can_manage_access: false },
|
|
{ ...pool(2), can_operate: false },
|
|
{ ...pool(3), can_operate: false, can_manage_access: false },
|
|
pool(4)
|
|
]
|
|
const calls = []
|
|
const { state, messages } = page({ wecomPromotionSyncMemberRange: async ({ pool_id }) => {
|
|
calls.push(pool_id)
|
|
return { pool_id, sync_status: 'synced' }
|
|
} }, pools)
|
|
assert.deepEqual(state.selectablePoolIds.value, [1, 2, 4])
|
|
state.togglePoolSelection(pools[0], true)
|
|
state.togglePoolSelection(pools[2], true)
|
|
await vue.nextTick()
|
|
assert.deepEqual(state.selectedPoolIds.value, [1])
|
|
state.openAccessDialog()
|
|
assert.equal(state.accessDialogVisible.value, false)
|
|
assert.equal(messages.at(-1).type, 'warning')
|
|
state.openBatchConfigDialog()
|
|
assert.equal(state.batchConfigDialogVisible.value, false)
|
|
assert.equal(messages.at(-1).type, 'warning')
|
|
await state.syncSelectedMemberRanges()
|
|
assert.deepEqual(calls, [1])
|
|
|
|
state.toggleAllPoolSelection(true)
|
|
assert.deepEqual(state.selectedPoolIds.value, [1, 2, 4])
|
|
state.openAccessDialog()
|
|
assert.deepEqual(state.accessForm.pool_ids, [2, 4])
|
|
state.openBatchConfigDialog()
|
|
assert.deepEqual(state.batchConfigForm.pool_ids, [2, 4])
|
|
state.overview.pools[0].can_operate = false
|
|
await vue.nextTick()
|
|
assert.deepEqual(state.selectedPoolIds.value, [2, 4])
|
|
})
|