152 lines
7.1 KiB
JavaScript
152 lines
7.1 KiB
JavaScript
const { test } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const vm = require('node:vm')
|
|
const vue = require('vue')
|
|
const { createDraftHarness } = require('./helpers/chat-drafts.cjs')
|
|
const settle = () => new Promise(setImmediate)
|
|
const deferred = () => { let resolve; return { promise: new Promise(done => { resolve = done }), resolve: value => resolve(value) } }
|
|
|
|
function setup(component, values, extra = {}) {
|
|
const props = vue.reactive(values), events = [], scope = vue.effectScope()
|
|
let unmount = () => {}
|
|
const source = fs.readFileSync(`${__dirname}/../components/${component}.vue`, 'utf8').match(/<script setup>([\s\S]*?)<\/script>/)[1].replace(/^import .*$/mg, '')
|
|
const context = vm.createContext({ ref: vue.ref, computed: vue.computed, watch: vue.watch, nextTick: vue.nextTick,
|
|
defineProps: () => props, defineEmits: () => (name, value) => {
|
|
events.push({ name, value })
|
|
if (name === 'update:modelValue') props.modelValue = value
|
|
if (name === 'update:image') props.image = value
|
|
}, onMounted: () => {}, onBeforeUnmount: handler => { unmount = handler }, clearInterval: () => {}, document: { hidden: false }, ...extra })
|
|
scope.run(() => vm.runInContext(source, context))
|
|
return { props, events, run: code => vm.runInContext(code, context), close: () => { unmount(); scope.stop() } }
|
|
}
|
|
|
|
test('composer inserts emoji at selection and sends image-only messages while respecting disabled state', async t => {
|
|
const ui = setup('ChatComposer', { modelValue: 'Hallo Welt', image: null, busy: false, disabled: false }, { prepareChatImage: async file => ({ dataUrl: 'data:image/webp;base64,AAAA', name: file.name }) })
|
|
t.after(ui.close)
|
|
ui.run('textarea.value = { selectionStart: 6, selectionEnd: 10, focus() {}, setSelectionRange() {} }')
|
|
await ui.run('insertEmoji("👋")')
|
|
assert.equal(ui.props.modelValue, 'Hallo 👋')
|
|
ui.props.modelValue = ''
|
|
ui.run('send()')
|
|
assert.equal(ui.events.some(e => e.name === 'send'), false)
|
|
await ui.run('attach({ name: "Bild.png" })')
|
|
ui.run('send()')
|
|
assert.equal(ui.events.filter(e => e.name === 'send').length, 1)
|
|
ui.props.disabled = true
|
|
ui.run('send()')
|
|
assert.equal(ui.events.filter(e => e.name === 'send').length, 1)
|
|
})
|
|
|
|
test('a late image conversion cannot attach a draft after the room unmounts', async () => {
|
|
const pending = deferred()
|
|
const ui = setup('ChatComposer', { modelValue: '', image: null, busy: false, disabled: false }, { prepareChatImage: () => pending.promise })
|
|
const attaching = ui.run('attach({ name: "Bild.png" })')
|
|
assert.equal(ui.run('preparing.value'), true)
|
|
ui.close()
|
|
pending.resolve({ dataUrl: 'private image', name: 'Bild.png' })
|
|
await attaching
|
|
assert.equal(ui.props.image, null)
|
|
assert.equal(ui.events.length, 0)
|
|
})
|
|
|
|
test('reaction refresh batches messages and stale poll results cannot overwrite an own reaction', async t => {
|
|
const pending = deferred(), calls = []
|
|
let delay = false
|
|
const ui = setup('ChatReactions', { endpoint: '/room/a', messages: [{ id: 1 }, { id: 2, pending: true }], disabled: false,
|
|
api: async (url, options) => {
|
|
calls.push({ url, options })
|
|
if (options?.method === 'PUT') return { reactions: { 1: [{ emoji: '👍', count: 1, own: true }] } }
|
|
if (delay) return pending.promise
|
|
return { reactions: {} }
|
|
} })
|
|
t.after(ui.close)
|
|
await settle()
|
|
assert.equal(calls[0].url, '/room/a/reactions?ids=1')
|
|
delay = true
|
|
const polling = ui.run('refresh()')
|
|
await ui.run('react(1, "👍")')
|
|
pending.resolve({ reactions: {} })
|
|
await polling
|
|
assert.equal(ui.run('reactions.value[1][0].own'), true)
|
|
assert.equal(calls.find(c => c.options?.method === 'PUT').options.body.active, true)
|
|
ui.props.disabled = true
|
|
const count = calls.length
|
|
await ui.run('react(1, "👍")')
|
|
assert.equal(calls.length, count)
|
|
})
|
|
|
|
test('image display rejects external URLs and ignores responses after a room change', async t => {
|
|
const pending = deferred()
|
|
const ui = setup('ChatImage', { endpoint: '/room/a', message: { id: 1 }, api: () => pending.promise })
|
|
t.after(ui.close)
|
|
ui.props.endpoint = '/room/b'
|
|
await vue.nextTick()
|
|
pending.resolve({ dataUrl: 'https://tracking.example/image.png' })
|
|
await settle()
|
|
assert.equal(ui.run('dataUrl.value'), '')
|
|
assert.equal(ui.run('error.value'), 'Bild derzeit nicht verfügbar.')
|
|
ui.props.api = async () => ({ dataUrl: 'data:image/webp;base64,AAAA' })
|
|
await ui.run('load()')
|
|
assert.equal(ui.run('dataUrl.value'), 'data:image/webp;base64,AAAA')
|
|
})
|
|
|
|
function collaborationApi(onSend) {
|
|
return async (url, options) => {
|
|
if (options?.method === 'POST' && url.endsWith('/messages')) return onSend(options.body)
|
|
if (url.endsWith('/messages')) return { messages: [], hasMore: false }
|
|
if (url.endsWith('/members')) return { members: [] }
|
|
if (url.endsWith('/read')) return {}
|
|
return { canSend: true, name: 'Partnerchat' }
|
|
}
|
|
}
|
|
|
|
test('collaboration drafts retain text, images and the retry ID when reopening after a failed send', async t => {
|
|
const drafts = createDraftHarness(), attempts = []
|
|
t.after(drafts.close)
|
|
let fail = true
|
|
const props = { roomId: 'shared', base: '/api/collaboration', api: collaborationApi(body => {
|
|
attempts.push(body)
|
|
if (fail) throw { data: { message: 'Offline' } }
|
|
return { id: 'message-1', seq: 1, own: true, clientId: body.clientId, body: body.text }
|
|
}) }
|
|
const first = setup('CollaborationRoom', props, { useChatDraft: drafts.useChatDraft })
|
|
await settle()
|
|
first.run('draft.value = " Partnerentwurf\\n "; draftImage.value = { dataUrl: "data:image/webp;base64,AAAA", name: "Plan" }')
|
|
await first.run('send()')
|
|
assert.equal(first.run('draft.value'), ' Partnerentwurf\n ')
|
|
assert.equal(first.run('draftImage.value.name'), 'Plan')
|
|
await first.run('load()')
|
|
assert.equal(first.run('draft.value'), ' Partnerentwurf\n ')
|
|
first.close()
|
|
const second = setup('CollaborationRoom', props, { useChatDraft: drafts.useChatDraft })
|
|
t.after(second.close)
|
|
await settle()
|
|
assert.equal(second.run('draft.value'), ' Partnerentwurf\n ')
|
|
fail = false
|
|
await second.run('send()')
|
|
assert.equal(attempts[0].clientId, attempts[1].clientId)
|
|
assert.equal(second.run('draft.value'), '')
|
|
assert.equal(second.run('draftImage.value'), null)
|
|
assert.equal(Object.keys(drafts.storage).length, 0)
|
|
})
|
|
|
|
test('collaboration send completion after unmount does not erase a newer draft in the same room', async t => {
|
|
const drafts = createDraftHarness(), pending = deferred()
|
|
t.after(drafts.close)
|
|
const props = { roomId: 'shared', base: '/api/collaboration', api: collaborationApi(() => pending.promise) }
|
|
const first = setup('CollaborationRoom', props, { useChatDraft: drafts.useChatDraft })
|
|
await settle()
|
|
first.run('draft.value = "Gesendet"')
|
|
const sending = first.run('send()')
|
|
first.close()
|
|
const second = setup('CollaborationRoom', props, { useChatDraft: drafts.useChatDraft })
|
|
t.after(second.close)
|
|
await settle()
|
|
second.run('draft.value = "Neuer Entwurf"')
|
|
pending.resolve({ id: 'message-1', seq: 1, body: 'Gesendet' })
|
|
await sending
|
|
assert.equal(second.run('draft.value'), 'Neuer Entwurf')
|
|
assert.equal(second.run('messages.value.length'), 0)
|
|
})
|