Share one finance remark per approval instance across Web and mobile. Require the explicit permission and the current tenant user's own done task for every read and write; keep general instance responses free of financial remarks. Add idempotent MySQL migration without automatic role grants, cross-end editors, isolated backend and frontend tests, and rollout requirements. Refs #7; platform validation and target database migration remain pending.
300 lines
10 KiB
JavaScript
300 lines
10 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { readFileSync } from 'node:fs'
|
|
import { test } from 'node:test'
|
|
import { parse, compileTemplate } from '@vue/compiler-sfc'
|
|
import ts from 'typescript'
|
|
import { ref, reactive, computed, proxyRefs } from 'vue'
|
|
|
|
const root = new URL('../../', import.meta.url)
|
|
const fields = [
|
|
'canManageFinanceRemark',
|
|
'financeRemarkVisible',
|
|
'financeRemarkLoading',
|
|
'financeRemarkSaving',
|
|
'financeRemarkText',
|
|
'openFinanceRemark',
|
|
'saveFinanceRemark',
|
|
'cancelFinanceRemark',
|
|
'clearFinanceAccess',
|
|
'load',
|
|
'rows'
|
|
]
|
|
function evaluate(source, deps, tail = '') {
|
|
const withoutImports = source.replace(/import[\s\S]*?from\s*['"][^'"]+['"]/g, '')
|
|
const js = ts.transpileModule(withoutImports + tail, {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
|
}).outputText
|
|
const exports = {}
|
|
return (
|
|
new Function(...Object.keys(deps), 'exports', js)(...Object.values(deps), exports) ||
|
|
exports.default ||
|
|
exports
|
|
)
|
|
}
|
|
function harness(platform) {
|
|
const calls = []
|
|
const hooks = {}
|
|
const api = {
|
|
list: {
|
|
list: [{ id: 'task-1', instanceId: 'instance-1', financeRemark: '列表旧值' }],
|
|
total: 1,
|
|
canManageFinanceRemark: true
|
|
},
|
|
get: async () => ({ financeRemark: '最新备注' }),
|
|
put: async () => ({ financeRemark: '' })
|
|
}
|
|
const deps = {
|
|
ref,
|
|
reactive,
|
|
computed,
|
|
defineOptions() {},
|
|
watchDebounced() {},
|
|
onMounted() {},
|
|
onActivated() {},
|
|
onDeactivated(fn) {
|
|
hooks.hide = fn
|
|
},
|
|
useRouter: () => ({}),
|
|
useMenuPerm: () => ({ hasMenuPerm: () => false }),
|
|
useUserStore: () => ({}),
|
|
ElMessage: { success() {}, error() {}, warning() {} },
|
|
uni: { showToast() {}, navigateTo() {} },
|
|
VanEmpty: {},
|
|
getToken: () => 'token',
|
|
forceRelogin() {},
|
|
fetchOaTasks: async () => {
|
|
calls.push(['list'])
|
|
return api.list
|
|
},
|
|
fetchOaFinanceRemark: async (id) => {
|
|
calls.push(['get', id])
|
|
return api.get(id)
|
|
},
|
|
fetchUpdateOaFinanceRemark: async (id, text) => {
|
|
calls.push(['put', id, text])
|
|
return api.put(id, text)
|
|
}
|
|
}
|
|
const path =
|
|
platform === 'web'
|
|
? 'web-admin/src/views/oa-approval/done/index.vue'
|
|
: 'mobile-h5/pages/oa/done/index.vue'
|
|
const { descriptor } = parse(readFileSync(new URL(path, root), 'utf8'))
|
|
let vm
|
|
if (platform === 'web') {
|
|
vm = proxyRefs(
|
|
evaluate(
|
|
descriptor.scriptSetup.content,
|
|
deps,
|
|
'\nreturn {' +
|
|
fields.map((k) => `${k}: typeof ${k} === 'undefined' ? undefined : ${k}`).join(',') +
|
|
'}'
|
|
)
|
|
)
|
|
} else {
|
|
const options = evaluate(descriptor.script.content, deps)
|
|
vm = options.data()
|
|
for (const [key, fn] of Object.entries(options.methods)) vm[key] = fn.bind(vm)
|
|
hooks.hide = () => options.onHide?.call(vm)
|
|
}
|
|
const load = () => (platform === 'web' ? vm.load() : vm.loadList(true))
|
|
const rows = () => (platform === 'web' ? vm.rows : vm.list)
|
|
return { vm, calls, api, load, rows, hooks, descriptor }
|
|
}
|
|
for (const platform of ['web', 'mobile']) {
|
|
test(`${platform}: capability gates access and opening gets latest instance value`, async () => {
|
|
const h = harness(platform)
|
|
await h.load()
|
|
assert.equal(h.vm.canManageFinanceRemark, true)
|
|
await h.vm.openFinanceRemark(h.rows()[0])
|
|
assert.deepEqual(h.calls.at(-1), ['get', 'instance-1'])
|
|
assert.equal(h.vm.financeRemarkText, '最新备注')
|
|
assert.equal(h.vm.financeRemarkVisible, true)
|
|
})
|
|
test(`${platform}: cancellation makes no write and discards late GET`, async () => {
|
|
const h = harness(platform)
|
|
await h.load()
|
|
let resolve
|
|
h.api.get = () =>
|
|
new Promise((r) => {
|
|
resolve = r
|
|
})
|
|
const opening = h.vm.openFinanceRemark(h.rows()[0])
|
|
h.vm.cancelFinanceRemark()
|
|
resolve({ financeRemark: '不得重新显示' })
|
|
await opening
|
|
assert.equal(h.vm.financeRemarkVisible, false)
|
|
assert.equal(h.vm.financeRemarkText, '')
|
|
assert.equal(
|
|
h.calls.some((c) => c[0] === 'put'),
|
|
false
|
|
)
|
|
})
|
|
test(`${platform}: save failure preserves input; clear succeeds and refreshes`, async () => {
|
|
const h = harness(platform)
|
|
await h.load()
|
|
await h.vm.openFinanceRemark(h.rows()[0])
|
|
h.vm.financeRemarkText = '我的未保存输入'
|
|
h.api.put = async () => {
|
|
throw new Error('failure')
|
|
}
|
|
await h.vm.saveFinanceRemark()
|
|
assert.equal(h.vm.financeRemarkText, '我的未保存输入')
|
|
assert.equal(h.vm.financeRemarkVisible, true)
|
|
h.api.put = async () => ({ financeRemark: '' })
|
|
h.vm.financeRemarkText = ''
|
|
await h.vm.saveFinanceRemark()
|
|
assert.deepEqual(h.calls.slice(-2), [['put', 'instance-1', ''], ['list']])
|
|
assert.equal(h.vm.financeRemarkVisible, false)
|
|
})
|
|
test(`${platform}: missing capability strips cached notes and prevents GET`, async () => {
|
|
const h = harness(platform)
|
|
await h.load()
|
|
await h.vm.openFinanceRemark(h.rows()[0])
|
|
h.api.list = { list: [{ instanceId: 'instance-1', financeRemark: '敏感备注' }], total: 1 }
|
|
await h.load()
|
|
assert.equal(h.vm.canManageFinanceRemark, false)
|
|
assert.equal(h.vm.financeRemarkVisible, false)
|
|
assert.equal(h.vm.financeRemarkText, '')
|
|
assert.equal('financeRemark' in h.rows()[0], false)
|
|
const count = h.calls.length
|
|
await h.vm.openFinanceRemark(h.rows()[0])
|
|
assert.equal(h.calls.length, count)
|
|
})
|
|
test(`${platform}: hiding page clears notes and invalidates pending reads`, async () => {
|
|
const h = harness(platform)
|
|
await h.load()
|
|
let resolve
|
|
h.api.get = () =>
|
|
new Promise((r) => {
|
|
resolve = r
|
|
})
|
|
const opening = h.vm.openFinanceRemark(h.rows()[0])
|
|
h.hooks.hide()
|
|
resolve({ financeRemark: '不得恢复的旧备注' })
|
|
await opening
|
|
assert.equal(h.vm.canManageFinanceRemark, false)
|
|
assert.equal(h.vm.financeRemarkText, '')
|
|
assert.equal('financeRemark' in h.rows()[0], false)
|
|
})
|
|
test(`${platform}: overlong input cannot send PUT`, async () => {
|
|
const h = harness(platform)
|
|
await h.load()
|
|
await h.vm.openFinanceRemark(h.rows()[0])
|
|
h.vm.financeRemarkText = '字'.repeat(513)
|
|
await h.vm.saveFinanceRemark()
|
|
assert.equal(
|
|
h.calls.some((c) => c[0] === 'put'),
|
|
false
|
|
)
|
|
assert.equal(h.vm.financeRemarkVisible, true)
|
|
})
|
|
test(`${platform}: saving while reading or already saving sends no duplicate PUT`, async () => {
|
|
const h = harness(platform)
|
|
await h.load()
|
|
let resolveGet
|
|
h.api.get = () =>
|
|
new Promise((r) => {
|
|
resolveGet = r
|
|
})
|
|
const opening = h.vm.openFinanceRemark(h.rows()[0])
|
|
await h.vm.saveFinanceRemark()
|
|
assert.equal(
|
|
h.calls.some((c) => c[0] === 'put'),
|
|
false
|
|
)
|
|
resolveGet({ financeRemark: '备注' })
|
|
await opening
|
|
let resolvePut
|
|
h.api.put = () =>
|
|
new Promise((r) => {
|
|
resolvePut = r
|
|
})
|
|
const saving = h.vm.saveFinanceRemark()
|
|
await h.vm.saveFinanceRemark()
|
|
assert.equal(h.calls.filter((c) => c[0] === 'put').length, 1)
|
|
resolvePut({ financeRemark: '备注' })
|
|
await saving
|
|
})
|
|
test(`${platform}: late list response cannot restore permission after hiding`, async () => {
|
|
const h = harness(platform)
|
|
await h.load()
|
|
let resolve
|
|
const previous = h.api.list
|
|
h.api.list = new Promise((r) => {
|
|
resolve = r
|
|
})
|
|
const loading = h.load()
|
|
h.hooks.hide()
|
|
resolve(previous)
|
|
await loading
|
|
assert.equal(h.vm.canManageFinanceRemark, false)
|
|
assert.equal('financeRemark' in h.rows()[0], false)
|
|
})
|
|
test(`${platform}: failed latest-value request cannot save stale list text`, async () => {
|
|
const h = harness(platform)
|
|
await h.load()
|
|
h.api.get = async () => {
|
|
throw new Error('forbidden')
|
|
}
|
|
await h.vm.openFinanceRemark(h.rows()[0])
|
|
assert.equal(h.vm.financeRemarkVisible, false)
|
|
assert.equal(h.vm.financeRemarkText, '')
|
|
await h.vm.saveFinanceRemark()
|
|
assert.equal(
|
|
h.calls.some((c) => c[0] === 'put'),
|
|
false
|
|
)
|
|
})
|
|
test(`${platform}: template compiles with plain text and gated edit controls`, () => {
|
|
const { descriptor } = harness(platform)
|
|
const result = compileTemplate({
|
|
source: descriptor.template.content,
|
|
filename: 'done.vue',
|
|
id: platform
|
|
})
|
|
assert.deepEqual(result.errors, [])
|
|
assert.match(result.code, /canManageFinanceRemark/)
|
|
assert.match(result.code, /toDisplayString\([^)]*financeRemark/)
|
|
assert.doesNotMatch(result.code, /innerHTML/)
|
|
if (platform === 'mobile')
|
|
assert.match(result.code, /withModifiers[^\n]*openFinanceRemark[^\n]*[\s\S]*?\["stop"\]/)
|
|
})
|
|
}
|
|
for (const platform of ['web', 'mobile']) {
|
|
test(`${platform}: finance API uses dedicated authenticated instance endpoint`, async () => {
|
|
const calls = []
|
|
const request = (config) => {
|
|
calls.push(config)
|
|
return Promise.resolve({})
|
|
}
|
|
request.get = (config) => request({ ...config, method: 'GET' })
|
|
request.put = (config) => request({ ...config, method: 'PUT' })
|
|
const path =
|
|
platform === 'web' ? 'web-admin/src/api/oaApproval.ts' : 'mobile-h5/utils/oa-api.js'
|
|
const api = evaluate(readFileSync(new URL(path, root), 'utf8'), {
|
|
request,
|
|
get: (url, header) => request({ url, header, method: 'GET' }),
|
|
getToken: () => 'token'
|
|
})
|
|
assert.equal(typeof api.fetchOaFinanceRemark, 'function')
|
|
await api.fetchOaFinanceRemark('instance-1')
|
|
await api.fetchUpdateOaFinanceRemark('instance-1', '')
|
|
assert.equal(calls[0].method, 'GET')
|
|
assert.equal(calls[1].method, 'PUT')
|
|
assert.equal(calls[0].url, '/web/admin/oa-approval/instances/instance-1/finance-remark')
|
|
assert.equal(calls[1].url, calls[0].url)
|
|
assert.deepEqual(calls[1].data, { financeRemark: '' })
|
|
if (platform === 'mobile') assert.equal(calls[1].header.Authorization, 'Bearer token')
|
|
})
|
|
}
|
|
test('web: delayed refresh on an inactive cached page cannot restore finance data', async () => {
|
|
const h = harness('web')
|
|
await h.load()
|
|
h.hooks.hide()
|
|
const count = h.calls.length
|
|
await h.load()
|
|
assert.equal(h.vm.canManageFinanceRemark, false)
|
|
assert.equal(h.calls.length, count)
|
|
})
|