Files
Friendship-and-chat-pc/tests/chat-conversation.test.cjs
dzc a9bac9d3bf feat:聊天后台撤回消息
fix:聊天禁用bug
2026-11-03 19:09:09 +08:00

391 lines
14 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/list')
const plain = (value) => JSON.parse(JSON.stringify(value))
const transpile = (source) =>
ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
}).outputText
const utilsContext = { exports: {}, URL, Intl, Date }
vm.runInNewContext(
transpile(fs.readFileSync(path.join(root, 'chat-utils.ts'), 'utf8')),
utilsContext
)
const utils = utilsContext.exports
function createModerationState() {
const context = {
exports: {},
require: (name) => {
if (name === 'vue') return vue
throw new Error(name)
}
}
vm.runInNewContext(
transpile(fs.readFileSync(path.join(root, '../components/message-moderation.ts'), 'utf8')),
context
)
return context.exports
}
const deferred = () => {
let resolve, reject
const promise = new Promise((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
const conversation = (first = 1, second = 2) => ({
userA: { id: first, nickname: `用户${first}` },
userB: { id: second, nickname: `用户${second}` },
messageCount: 4,
lastMessage: {
id: 4,
sendId: second,
recvId: first,
type: 0,
status: 3,
content: '你好',
sendTime: 1793664000000
}
})
function createHarness(component, overrides = {}, moderation = createModerationState()) {
const calls = []
const lifecycle = {}
const context = {
exports: {},
defineOptions() {},
defineExpose(value) {
context.exposed = value
},
document: { activeElement: null },
HTMLElement: class {},
require(name) {
if (name === 'vue')
return {
...vue,
onMounted(callback) {
lifecycle.mounted = callback
},
onBeforeUnmount(callback) {
lifecycle.unmount = callback
},
onDeactivated(callback) {
lifecycle.deactivate = callback
}
}
if (name === '@/api/chat/private-msg')
return {
async conversationPage(params) {
calls.push(plain(params))
return { list: [], total: 0 }
},
async page(params) {
calls.push(plain(params))
return { list: [], total: 0 }
},
...overrides
}
if (name.endsWith('chat-utils')) return utils
if (name.endsWith('message-moderation')) return moderation
if (name.endsWith('.vue')) return {}
throw new Error(`Unexpected import ${name}`)
}
}
const source = fs.readFileSync(path.join(root, component), 'utf8')
const { descriptor } = parse(source)
const exports =
component === 'index.vue'
? 'filters, applied, pagination, list, total, loading, error, getList, handleQuery, resetQuery, handleSizeChange, drawerRef, openConversation'
: 'filters, applied, pagination, messages, total, loading, error, visible, getList, handleQuery, resetQuery, handleSizeChange, open, handleClose, restoreFocus, scrollRef, searchRef, focusSearch'
const scope = vue.effectScope()
scope.run(() =>
vm.runInNewContext(
transpile(descriptor.scriptSetup.content) + `\nglobalThis.state = { ${exports} };`,
context
)
)
const unmount = lifecycle.unmount
lifecycle.unmount = () => {
unmount?.()
scope.stop()
}
return { state: context.state, exposed: context.exposed, calls, lifecycle, moderation }
}
test('China date ranges include the entire final calendar day independently of machine timezone', () => {
assert.deepEqual(plain(utils.toChinaDateRange(['2026-11-03', '2026-11-03'])), [
Date.parse('2026-11-02T16:00:00.000Z'),
Date.parse('2026-11-03T15:59:59.999Z')
])
assert.equal(utils.toChinaDateRange(null), undefined)
assert.equal(utils.toChinaDateRange(['2026-11-04', '2026-11-03']), undefined)
assert.equal(utils.formatChatTime('2026-11-03 08:01:02'), '2026-11-03 08:01:02')
assert.equal(utils.formatChatTime('2026-11-03T00:01:02Z'), '2026-11-03 08:01:02')
})
test('media payload failures and dangerous protocols have safe fallbacks', () => {
for (const value of [undefined, null, '', '{broken', 'null', '[]', '1'])
assert.equal(utils.parseMedia(value), null)
for (const url of [
'javascript:alert(1)',
'data:text/html,hello',
'file:///etc/passwd',
'//evil.example/x',
'https://user:pass@example.com/file',
'https://x.example/\nfile',
'\\evil.example\\x'
]) {
assert.equal(utils.safeResourceUrl(url, 'https://cdn.example/media/'), '')
}
assert.equal(
utils.safeResourceUrl('a.png', 'https://cdn.example/media'),
'https://cdn.example/media/a.png'
)
assert.equal(utils.safeResourceUrl('https://cdn.example/a.png'), 'https://cdn.example/a.png')
assert.equal(utils.messagePreview({ type: 1, status: 2 }), '[已撤回/屏蔽] [图片]')
assert.equal(utils.messagePreview({ type: 10, content: '1234', status: 0 }), '撤回了一条消息')
assert.equal(
utils.messagePreview({ type: 40, content: '通话时长 01:23', status: 3 }),
'[语音通话] 通话时长 01:23'
)
})
test('text links preserve text, keep HTML escaped, and support uppercase HTTP', () => {
const input = '<img onerror="alert(1)"> HTTP://example.com?a=1&b=2, www.example.com。'
const parts = utils.messageTextParts(input)
assert.equal(parts.map((part) => part.text).join(''), input)
assert.deepEqual(plain(parts.filter((part) => part.href).map((part) => part.href)), [
'http://example.com/?a=1&b=2',
'https://www.example.com/'
])
assert.equal(utils.escapeMessageText('<script>"&\''), '&lt;script&gt;&quot;&amp;&#39;')
assert.ok(utils.messageTextParts('javascript:alert(1)').every((part) => !part.href))
})
test('conversation search resets pagination and uses applied filters for subsequent page requests', async () => {
const { state, calls } = createHarness('index.vue')
state.pagination.pageNum = 4
state.filters.userKeyword = ' 张三 '
state.filters.peerKeyword = ' 李四 '
state.filters.dates = ['2026-11-01', '2026-11-03']
await state.handleQuery()
assert.equal(calls[0].pageNum, 1)
assert.equal(calls[0].pageSize, 20)
assert.equal(calls[0].userKeyword, '张三')
assert.equal(calls[0].peerKeyword, '李四')
assert.equal(calls[0].lastSendTimeList[1], Date.parse('2026-11-03T23:59:59.999+08:00'))
state.filters.userKeyword = '尚未提交的筛选'
state.pagination.pageNum = 2
await state.getList()
assert.equal(calls[1].userKeyword, '张三')
await state.resetQuery()
assert.deepEqual(calls.at(-1), { pageNum: 1, pageSize: 20 })
})
test('late conversation responses cannot replace a newer search or update an unmounted page', async () => {
const first = deferred(),
second = deferred(),
third = deferred()
const pending = [first, second, third]
const { state, lifecycle } = createHarness('index.vue', {
conversationPage: () => pending.shift().promise
})
const oldRequest = state.getList()
const newRequest = state.handleQuery()
second.resolve({ list: [conversation(3, 4)], total: 1 })
await newRequest
first.resolve({ list: [conversation(1, 2)], total: 99 })
await oldRequest
assert.equal(state.list.value[0].userA.id, 3)
assert.equal(state.total.value, 1)
const disposedRequest = state.getList()
lifecycle.unmount()
third.resolve({ list: [conversation(5, 6)], total: 100 })
await disposedRequest
assert.equal(state.list.value[0].userA.id, 3)
})
test('drawer opens bilateral history with independent filters and reverses each latest-first page', async () => {
const calls = []
const { state } = createHarness('components/ConversationDrawer.vue', {
async page(params) {
calls.push(plain(params))
return { list: [{ id: 3 }, { id: 2 }, { id: 1 }], total: 3 }
}
})
state.filters.content = '旧会话筛选'
state.filters.dates = ['2026-10-01', '2026-10-02']
await state.open(conversation(11, 22))
assert.deepEqual(calls[0], { pageNum: 1, pageSize: 20, sendId: 11, recvId: 22 })
assert.deepEqual(plain(state.messages.value.map((message) => message.id)), [1, 2, 3])
state.pagination.pageNum = 7
state.filters.content = ' 内容 '
state.filters.dates = ['2026-11-03', '2026-11-03']
await state.handleQuery()
assert.equal(calls[1].pageNum, 1)
assert.equal(calls[1].content, '内容')
assert.equal(calls[1].sendTimeList[1], Date.parse('2026-11-03T23:59:59.999+08:00'))
await state.resetQuery()
assert.deepEqual(calls[2], { pageNum: 1, pageSize: 20, sendId: 11, recvId: 22 })
})
test('switching or closing a drawer invalidates older requests and restores its trigger focus', async () => {
const first = deferred(),
second = deferred(),
third = deferred()
const pending = [first, second, third]
const { state } = createHarness('components/ConversationDrawer.vue', {
page: () => pending.shift().promise
})
let focused = 0
const trigger = {
isConnected: true,
focus() {
focused++
}
}
const oldRequest = state.open(conversation(1, 2), trigger)
const newRequest = state.open(conversation(3, 4), trigger)
second.resolve({ list: [{ id: 33 }], total: 1 })
await newRequest
first.resolve({ list: [{ id: 11 }], total: 10 })
await oldRequest
assert.equal(state.messages.value[0].id, 33)
const closing = state.getList()
state.visible.value = false
state.handleClose()
third.resolve({ list: [{ id: 99 }], total: 100 })
await closing
assert.equal(state.messages.value.length, 0)
assert.equal(state.loading.value, false)
state.restoreFocus()
assert.equal(focused, 1)
})
test('failed history stays retryable and empty history is a successful result', async () => {
let attempt = 0
const { state } = createHarness('components/ConversationDrawer.vue', {
async page() {
if (!attempt++) throw new Error('offline')
return { list: null, total: 0 }
}
})
await state.open(conversation())
assert.equal(state.error.value, true)
assert.equal(state.loading.value, false)
await state.getList()
assert.equal(state.error.value, false)
assert.equal(state.messages.value.length, 0)
})
test('drawer focuses search without an event payload and closes on keep-alive deactivation', async () => {
const { state, lifecycle } = createHarness('components/ConversationDrawer.vue')
let focused = 0
state.searchRef.value = {
focus() {
focused++
}
}
await state.focusSearch()
assert.equal(focused, 1)
await state.open(conversation())
lifecycle.deactivate()
assert.equal(state.visible.value, false)
assert.equal(state.messages.value.length, 0)
})
test('refreshing a page removed by data changes returns to the last valid page', async () => {
const listRequests = []
const list = createHarness('index.vue', {
async conversationPage(params) {
listRequests.push(params.pageNum)
return { list: params.pageNum === 1 ? [conversation()] : [], total: 1 }
}
})
list.state.pagination.pageNum = 3
await list.state.getList()
assert.deepEqual(listRequests, [3, 1])
assert.equal(list.state.list.value.length, 1)
const detailRequests = []
const detail = createHarness('components/ConversationDrawer.vue', {
async page(params) {
detailRequests.push(params.pageNum)
return { list: params.pageNum === 1 ? [{ id: 1 }] : [], total: 1 }
}
})
await detail.state.open(conversation())
detail.state.pagination.pageNum = 5
await detail.state.getList()
assert.deepEqual(detailRequests, [1, 5, 1])
assert.equal(detail.state.messages.value.length, 1)
})
test('a moderation success refreshes list and history while preserving detail filters and reading position', async () => {
const shared = createModerationState()
let hidden = false,
listReads = 0
const detailQueries = []
const list = createHarness(
'index.vue',
{
async conversationPage() {
listReads++
const row = conversation()
row.lastMessage.status = hidden ? 2 : 3
return { list: [row], total: 1 }
}
},
shared
)
const drawer = createHarness(
'components/ConversationDrawer.vue',
{
async page(params) {
detailQueries.push(plain(params))
return { list: [{ ...conversation().lastMessage, status: hidden ? 2 : 3 }], total: 100 }
}
},
shared
)
await list.state.getList()
await drawer.state.open(conversation())
drawer.state.filters.content = '待保留的条件'
await drawer.state.handleQuery()
drawer.state.pagination.pageNum = 2
drawer.state.scrollRef.value = { scrollTop: 480, scrollHeight: 4000 }
hidden = true
shared.notifyMessageModerated(4)
for (let index = 0; index < 6; index++) await vue.nextTick()
assert.equal(listReads, 2)
assert.equal(list.state.list.value[0].lastMessage.status, 2)
assert.equal(detailQueries.at(-1).content, '待保留的条件')
assert.equal(detailQueries.at(-1).pageNum, 2)
assert.equal(drawer.state.scrollRef.value.scrollTop, 480)
assert.equal(drawer.state.messages.value[0].status, 2)
list.lifecycle.unmount()
drawer.lifecycle.unmount()
})
test('all conversation Vue templates compile', () => {
for (const file of [
'index.vue',
'components/ConversationDrawer.vue',
'components/ConversationMessage.vue',
'components/ConversationUser.vue'
]) {
const filename = path.join(root, file)
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
assert.deepEqual(errors, [], file)
const result = compileTemplate({ source: descriptor.template.content, filename, id: file })
assert.deepEqual(result.errors, [], file)
}
})