376 lines
13 KiB
JavaScript
376 lines
13 KiB
JavaScript
const { test } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
const vm = require('node:vm')
|
|
const ts = require('typescript')
|
|
const vue = require('vue')
|
|
const { parse, compileTemplate } = require('vue/compiler-sfc')
|
|
|
|
const root = path.join(__dirname, '../src/views/chat')
|
|
const plain = (value) => JSON.parse(JSON.stringify(value))
|
|
const transpile = (source) =>
|
|
ts.transpileModule(source, {
|
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
|
}).outputText
|
|
function loadModule(
|
|
file,
|
|
require = (name) => {
|
|
if (name === 'vue') return vue
|
|
throw new Error(name)
|
|
}
|
|
) {
|
|
const context = { exports: {}, require, URL, Intl, Date }
|
|
vm.runInNewContext(transpile(fs.readFileSync(path.join(root, file), 'utf8')), context)
|
|
return context.exports
|
|
}
|
|
const utils = loadModule('list/chat-utils.ts')
|
|
const stateModule = () => loadModule('components/message-moderation.ts')
|
|
const message = (id = 100, overrides = {}) => ({
|
|
id,
|
|
sendId: 1,
|
|
recvId: 2,
|
|
content: '一条聊天消息',
|
|
type: 0,
|
|
status: 3,
|
|
sendTime: '2026-11-03 14:20:00',
|
|
...overrides
|
|
})
|
|
const success = (id = 100, overrides = {}) => ({
|
|
messageId: id,
|
|
status: 2,
|
|
recallMessageId: 101,
|
|
alreadyHidden: false,
|
|
notificationSent: true,
|
|
...overrides
|
|
})
|
|
const deferred = () => {
|
|
let resolve, reject
|
|
const promise = new Promise((yes, no) => {
|
|
resolve = yes
|
|
reject = no
|
|
})
|
|
return { promise, resolve, reject }
|
|
}
|
|
|
|
function harness(initialMessage = message(), options = {}) {
|
|
const shared = options.shared || stateModule()
|
|
const permission = vue.ref(options.permitted !== false)
|
|
const props = vue.reactive({ message: initialMessage, senderName: '张三', receiverName: '李四' })
|
|
const calls = [],
|
|
notices = [],
|
|
lifecycle = {}
|
|
const context = {
|
|
exports: {},
|
|
defineProps: () => props,
|
|
require(name) {
|
|
if (name === 'vue')
|
|
return {
|
|
...vue,
|
|
onBeforeUnmount: (fn) => {
|
|
lifecycle.unmount = fn
|
|
},
|
|
onDeactivated: (fn) => {
|
|
lifecycle.deactivate = fn
|
|
}
|
|
}
|
|
if (name === 'element-plus')
|
|
return {
|
|
ElMessage: {
|
|
success: (text) => notices.push(['success', text]),
|
|
warning: (text) => notices.push(['warning', text])
|
|
}
|
|
}
|
|
if (name === '@/utils/permission') return { checkPermi: () => permission.value }
|
|
if (name === '@/api/chat/private-msg')
|
|
return {
|
|
moderateMessage: async (params) => {
|
|
calls.push(plain(params))
|
|
return options.api ? options.api(params) : success(params.messageId)
|
|
}
|
|
}
|
|
if (name.endsWith('chat-utils')) return utils
|
|
if (name.endsWith('message-moderation')) return shared
|
|
throw new Error(name)
|
|
}
|
|
}
|
|
const source = fs.readFileSync(path.join(root, 'components/MessageModerationActions.vue'), 'utf8')
|
|
const { descriptor } = parse(source)
|
|
const scope = vue.effectScope()
|
|
scope.run(() =>
|
|
vm.runInNewContext(
|
|
transpile(descriptor.scriptSetup.content) +
|
|
'\nglobalThis.state = { open, submit, handleClosed, beforeClose, visible, submitting, reason, errorMessage, target, permitted, busy, disabledReason, isHidden };',
|
|
context
|
|
)
|
|
)
|
|
const unmount = lifecycle.unmount
|
|
lifecycle.unmount = () => {
|
|
unmount?.()
|
|
scope.stop()
|
|
}
|
|
return { state: context.state, calls, notices, props, permission, lifecycle, shared }
|
|
}
|
|
|
|
test('number message IDs must be safe integers while string IDs retain full precision', () => {
|
|
const shared = stateModule()
|
|
for (const id of [Number.MAX_SAFE_INTEGER + 1, NaN, Infinity, 1.5, 0, -1, '', '0', '1e4', '12x'])
|
|
assert.equal(shared.messageIdentity(id), undefined)
|
|
assert.deepEqual(plain(shared.messageIdentity('9223372036854775807')), {
|
|
messageId: '9223372036854775807',
|
|
key: '9223372036854775807'
|
|
})
|
|
assert.deepEqual(plain(shared.messageIdentity('000123')), { messageId: '000123', key: '123' })
|
|
const current = harness(message(Number.MAX_SAFE_INTEGER + 1))
|
|
current.state.open()
|
|
assert.equal(current.state.visible.value, false)
|
|
assert.match(current.notices[0][1], /刷新/)
|
|
assert.equal(current.calls.length, 0)
|
|
current.lifecycle.unmount()
|
|
})
|
|
|
|
test('normal and call messages are eligible; control records and missing permissions cannot open an action', () => {
|
|
const shared = stateModule()
|
|
for (const type of [0, 1, 2, 3, 4, 9, 40, 41])
|
|
assert.equal(shared.canModerateMessageType(type), true)
|
|
for (const type of [-1, 10, 11, 21, 30, 100, 200])
|
|
assert.equal(shared.canModerateMessageType(type), false)
|
|
const control = harness(message(100, { type: 10 }))
|
|
control.state.open()
|
|
assert.equal(control.state.visible.value, false)
|
|
const denied = harness(message(), { permitted: false })
|
|
denied.state.open()
|
|
assert.equal(denied.state.visible.value, false)
|
|
assert.equal(denied.calls.length, 0)
|
|
control.lifecycle.unmount()
|
|
denied.lifecycle.unmount()
|
|
})
|
|
|
|
test('the same message across two views shares one confirmation and submitting lock', async () => {
|
|
const shared = stateModule(),
|
|
pending = deferred()
|
|
const first = harness(message(100), { shared, api: () => pending.promise })
|
|
const second = harness(message('100'), { shared })
|
|
first.state.open()
|
|
second.state.open()
|
|
assert.equal(second.state.visible.value, false)
|
|
assert.equal(second.state.busy.value, true)
|
|
const request = first.state.submit()
|
|
await first.state.submit()
|
|
assert.equal(first.calls.length, 1)
|
|
let closed = false
|
|
first.state.beforeClose(() => {
|
|
closed = true
|
|
})
|
|
assert.equal(closed, false)
|
|
pending.resolve(success())
|
|
await request
|
|
first.state.handleClosed()
|
|
assert.equal(second.state.busy.value, false)
|
|
first.lifecycle.unmount()
|
|
second.lifecycle.unmount()
|
|
})
|
|
|
|
test('cancel releases the lock without sending a request or modifying shared revision', () => {
|
|
const current = harness()
|
|
current.state.open()
|
|
current.state.visible.value = false
|
|
current.state.handleClosed()
|
|
assert.equal(current.calls.length, 0)
|
|
assert.equal(current.shared.messageModerationRevision.value, 0)
|
|
assert.equal(current.shared.isMessageModerationBusy('100'), false)
|
|
current.lifecycle.unmount()
|
|
})
|
|
|
|
test('confirmation snapshots the original message, preserves string IDs, and trims the optional reason', async () => {
|
|
const id = '9223372036854775807'
|
|
const current = harness(message(id), { api: () => success(Number(id)) })
|
|
current.state.open()
|
|
current.props.message = message(200)
|
|
current.state.reason.value = ' 违反聊天规范 '
|
|
await current.state.submit()
|
|
assert.deepEqual(current.calls[0], { messageId: id, reason: '违反聊天规范' })
|
|
assert.equal(current.shared.lastModeratedMessageId.value, id)
|
|
assert.equal(current.shared.messageModerationRevision.value, 1)
|
|
current.lifecycle.unmount()
|
|
})
|
|
|
|
test('reason length and permission changes are checked again before submission', async () => {
|
|
const current = harness()
|
|
current.state.open()
|
|
current.state.reason.value = '字'.repeat(201)
|
|
await current.state.submit()
|
|
assert.equal(current.calls.length, 0)
|
|
assert.match(current.state.errorMessage.value, /200/)
|
|
current.state.reason.value = '有效原因'
|
|
current.permission.value = false
|
|
await current.state.submit()
|
|
await vue.nextTick()
|
|
assert.equal(current.calls.length, 0)
|
|
assert.equal(current.shared.isMessageModerationBusy('100'), false)
|
|
current.lifecycle.unmount()
|
|
})
|
|
|
|
test('a failed request keeps the dialog and reason available for retry', async () => {
|
|
let attempt = 0
|
|
const current = harness(message(), {
|
|
api: () => {
|
|
if (!attempt++) throw new Error('offline')
|
|
return success()
|
|
}
|
|
})
|
|
current.state.open()
|
|
current.state.reason.value = '保留原因'
|
|
await current.state.submit()
|
|
assert.equal(current.state.visible.value, true)
|
|
assert.equal(current.state.submitting.value, false)
|
|
assert.equal(current.state.reason.value, '保留原因')
|
|
assert.match(current.state.errorMessage.value, /失败/)
|
|
assert.equal(current.shared.messageModerationRevision.value, 0)
|
|
await current.state.submit()
|
|
assert.equal(current.calls.length, 2)
|
|
assert.equal(current.state.visible.value, false)
|
|
current.lifecycle.unmount()
|
|
})
|
|
|
|
test('already-hidden messages retry synchronization without a new moderation reason', async () => {
|
|
const current = harness(message(100, { status: 2 }), {
|
|
api: () => success(100, { alreadyHidden: true })
|
|
})
|
|
assert.equal(current.state.isHidden.value, true)
|
|
current.state.open()
|
|
current.state.reason.value = '不应再提交的原因'
|
|
await current.state.submit()
|
|
assert.deepEqual(current.calls[0], { messageId: 100 })
|
|
assert.equal(current.notices[0][1], '已发送撤回同步通知')
|
|
current.lifecycle.unmount()
|
|
})
|
|
|
|
test('failed push notification still refreshes hidden status and offers resynchronization', async () => {
|
|
const current = harness(message(), { api: () => success(100, { notificationSent: false }) })
|
|
current.state.open()
|
|
await current.state.submit()
|
|
assert.equal(current.shared.messageModerationRevision.value, 1)
|
|
assert.equal(current.notices[0][0], 'warning')
|
|
assert.match(current.notices[0][1], /重新同步/)
|
|
assert.equal(current.state.visible.value, false)
|
|
current.lifecycle.unmount()
|
|
})
|
|
|
|
test('unmounting during a request keeps its lock until completion and still notifies other views', async () => {
|
|
const pending = deferred(),
|
|
current = harness(message(), { api: () => pending.promise })
|
|
current.state.open()
|
|
const request = current.state.submit()
|
|
current.lifecycle.unmount()
|
|
assert.equal(current.shared.isMessageModerationBusy('100'), true)
|
|
pending.resolve(success())
|
|
await request
|
|
assert.equal(current.shared.isMessageModerationBusy('100'), false)
|
|
assert.equal(current.shared.messageModerationRevision.value, 1)
|
|
})
|
|
|
|
test('deactivation and permission changes cannot release an in-flight lock through dialog closed', async () => {
|
|
for (const lifecycleChange of ['deactivate', 'permission']) {
|
|
const pending = deferred()
|
|
const current = harness(message(), { api: () => pending.promise })
|
|
current.state.open()
|
|
const request = current.state.submit()
|
|
if (lifecycleChange === 'deactivate') current.lifecycle.deactivate()
|
|
else {
|
|
current.permission.value = false
|
|
await vue.nextTick()
|
|
}
|
|
current.state.handleClosed()
|
|
assert.equal(current.shared.isMessageModerationBusy('100'), true, lifecycleChange)
|
|
assert.equal(current.state.target.value.messageId, 100)
|
|
pending.resolve(success())
|
|
await request
|
|
assert.equal(current.shared.isMessageModerationBusy('100'), false, lifecycleChange)
|
|
assert.equal(current.state.target.value, undefined)
|
|
current.lifecycle.unmount()
|
|
}
|
|
})
|
|
|
|
test('moderation and the legacy table templates compile with text-only confirmation context', () => {
|
|
for (const file of ['components/MessageModerationActions.vue', 'private-msg/index.vue']) {
|
|
const filename = path.join(root, file)
|
|
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
|
|
assert.deepEqual(errors, [])
|
|
const result = compileTemplate({ source: descriptor.template.content, filename, id: file })
|
|
assert.deepEqual(result.errors, [])
|
|
if (file.includes('Actions')) assert.ok(!descriptor.template.content.includes('v-html'))
|
|
}
|
|
})
|
|
|
|
test('the legacy table refreshes the current query after moderation and ignores superseded reads', async () => {
|
|
const shared = stateModule(),
|
|
calls = [],
|
|
pending = []
|
|
let hidden = false,
|
|
unmount
|
|
const context = {
|
|
exports: {},
|
|
ref: vue.ref,
|
|
reactive: vue.reactive,
|
|
onMounted() {},
|
|
require(name) {
|
|
if (name === 'vue')
|
|
return {
|
|
...vue,
|
|
onBeforeUnmount(fn) {
|
|
unmount = fn
|
|
}
|
|
}
|
|
if (name === '@/utils/dict') return { DICT_TYPE: {} }
|
|
if (name === '@/utils/formatTime') return { dateFormatter() {} }
|
|
if (name === '@/utils/permission') return { checkPermi: () => true }
|
|
if (name === '@/api/chat/private-msg')
|
|
return {
|
|
async page(params) {
|
|
calls.push(plain(params))
|
|
return pending.length
|
|
? pending.shift().promise
|
|
: { list: [message(100, { status: hidden ? 2 : 3 })], total: 30 }
|
|
}
|
|
}
|
|
if (name.endsWith('message-moderation')) return shared
|
|
if (name.endsWith('chat-utils')) return utils
|
|
if (name.endsWith('.vue')) return {}
|
|
throw new Error(name)
|
|
}
|
|
}
|
|
const { descriptor } = parse(fs.readFileSync(path.join(root, 'private-msg/index.vue'), 'utf8'))
|
|
const scope = vue.effectScope()
|
|
scope.run(() =>
|
|
vm.runInNewContext(
|
|
transpile(descriptor.scriptSetup.content) +
|
|
'\nglobalThis.state = { getList, list, queryParams };',
|
|
context
|
|
)
|
|
)
|
|
const state = context.state
|
|
state.queryParams.pageNum = 2
|
|
state.queryParams.sendUserName = '张三'
|
|
await state.getList()
|
|
hidden = true
|
|
shared.notifyMessageModerated(100)
|
|
for (let index = 0; index < 5; index++) await vue.nextTick()
|
|
assert.equal(calls.length, 2)
|
|
assert.equal(calls[1].pageNum, 2)
|
|
assert.equal(calls[1].sendUserName, '张三')
|
|
assert.equal(state.list.value[0].status, 2)
|
|
const first = deferred(),
|
|
second = deferred()
|
|
pending.push(first, second)
|
|
const oldRequest = state.getList(),
|
|
newRequest = state.getList()
|
|
second.resolve({ list: [message(100, { status: 2 })], total: 30 })
|
|
await newRequest
|
|
first.resolve({ list: [message(100, { status: 3 })], total: 30 })
|
|
await oldRequest
|
|
assert.equal(state.list.value[0].status, 2)
|
|
unmount()
|
|
scope.stop()
|
|
})
|