380 lines
17 KiB
JavaScript
380 lines
17 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 settle = () => new Promise(setImmediate)
|
|
const deferred = () => {
|
|
let resolve
|
|
const promise = new Promise(done => { resolve = done })
|
|
return { promise, resolve }
|
|
}
|
|
const source = fs.readFileSync(`${__dirname}/../pages/communication/chat.vue`, 'utf8').match(/<script setup>([\s\S]*?)<\/script>/)[1]
|
|
const nativeRoom = { key: 'allgemein', name: 'Allgemein', exists: true, type: 'general' }
|
|
const sharedRoom = { id: 'shared-room', name: 'Partnerchat', remote: true, unread: 3 }
|
|
|
|
function fixture(override = () => undefined) {
|
|
return async (url, options) => {
|
|
const result = override(url, options)
|
|
if (result !== undefined) return result
|
|
if (url === '/api/collaboration') return { rooms: [sharedRoom], invitations: [], managedRooms: [{ id: 'admin-only' }] }
|
|
if (url === '/api/communication/chat/room-preferences') return { hiddenRoomKeys: [] }
|
|
if (url === '/api/communication/chat/rooms') return { rooms: [nativeRoom] }
|
|
if (url.endsWith('/project-rooms') || url.endsWith('/direct-rooms')) return { rooms: [] }
|
|
if (url.endsWith('/unread')) return { rooms: { allgemein: { count: 2 } } }
|
|
if (url.endsWith('/members')) return { members: [{ userId: 'member', displayName: 'Kollege' }] }
|
|
if (url.includes('/sync?')) return { messages: [] }
|
|
if (url.endsWith('/messages')) return { messages: [{ id: 1, body: 'Interne Nachricht' }] }
|
|
if (url.endsWith('/read')) return {}
|
|
throw new Error(`Unexpected request: ${url}`)
|
|
}
|
|
}
|
|
|
|
function setup(api = fixture(), query = {}) {
|
|
const auth = vue.reactive({ user: { id: 'user-1' }, activeTenant: '1' })
|
|
const route = vue.reactive({ query })
|
|
const calls = [], toasts = [], timers = new Map()
|
|
const listeners = new Map()
|
|
const events = {
|
|
addEventListener: (event, handler) => listeners.set(event, handler),
|
|
removeEventListener: event => listeners.delete(event),
|
|
}
|
|
const document = { hidden: false, ...events }
|
|
let mount, unmount, timerId = 0
|
|
const scope = vue.effectScope()
|
|
const context = vm.createContext({
|
|
ref: vue.ref, reactive: vue.reactive, computed: vue.computed, watch: vue.watch, nextTick: vue.nextTick,
|
|
useToast: () => ({ add: item => toasts.push(item) }),
|
|
useAuthStore: () => auth, useRoute: () => route,
|
|
useRouter: () => ({ replace: async location => { route.query = location.query } }),
|
|
useNuxtApp: () => ({ $api: (url, options) => { calls.push({ url, options }); return api(url, options) } }),
|
|
onMounted: handler => { mount = handler }, onBeforeUnmount: handler => { unmount = handler },
|
|
window: { ...events, setInterval: handler => { timers.set(++timerId, handler); return timerId } },
|
|
clearInterval: id => timers.delete(id), document,
|
|
})
|
|
scope.run(() => vm.runInContext(source, context))
|
|
return { auth, route, calls, toasts, timers, listeners, document, mount, close: () => { unmount(); scope.stop() }, run: code => vm.runInContext(code, context) }
|
|
}
|
|
|
|
test('only member rooms enter normal chat, with separate keys, groups and unread counts', async t => {
|
|
const ui = setup()
|
|
t.after(ui.close)
|
|
await ui.run('refreshLists()')
|
|
assert.equal(ui.run('decoratedRooms.value.length'), 2)
|
|
assert.equal(ui.run('decoratedRooms.value.find(r => r.source === "collaboration").key'), 'collaboration:shared-room')
|
|
assert.equal(ui.run('groupedRooms.value.at(-1).label'), 'Zusammenarbeit')
|
|
assert.equal(ui.run('decoratedRooms.value.at(-1).unread'), 3)
|
|
assert.equal(ui.run('decoratedRooms.value[0].unread'), 2)
|
|
assert.equal(ui.run('decoratedRooms.value.some(r => r.id === "admin-only")'), false)
|
|
})
|
|
|
|
test('opening a collaboration deep link never provisions, reads or sends through native chat APIs', async t => {
|
|
const ui = setup(fixture(), { room: 'collaboration:shared-room' })
|
|
t.after(ui.close)
|
|
ui.mount()
|
|
await settle()
|
|
assert.equal(ui.run('isCollaboration.value'), true)
|
|
assert.equal(ui.route.query.room, 'collaboration:shared-room')
|
|
ui.run('draft.value = "Partnernachricht"')
|
|
await ui.run('pollMessages()')
|
|
await ui.run('sendMessage()')
|
|
assert.equal(ui.calls.some(({ url }) => url.startsWith('/api/communication/chat/rooms/')), false)
|
|
ui.route.query = { room: 'allgemein' }
|
|
await settle()
|
|
assert.equal(ui.run('isInternal.value'), true)
|
|
assert.equal(ui.run('messages.value[0].body'), 'Interne Nachricht')
|
|
ui.route.query = { room: 'collaboration:shared-room' }
|
|
await settle()
|
|
assert.equal(ui.run('isCollaboration.value'), true)
|
|
assert.equal(ui.run('messages.value.length'), 0)
|
|
})
|
|
|
|
test('accepting an invitation opens the returned local binding and clears the invitation', async t => {
|
|
let accepted = false
|
|
const ui = setup(fixture(url => {
|
|
if (url === '/api/collaboration/invitations/invite/accept') { accepted = true; return { roomId: sharedRoom.id } }
|
|
if (url === '/api/collaboration') return { rooms: accepted ? [sharedRoom] : [], invitations: accepted ? [] : [{ id: 'invite' }] }
|
|
}))
|
|
t.after(ui.close)
|
|
ui.mount()
|
|
await settle()
|
|
assert.equal(ui.run('invitations.value.length'), 1)
|
|
await ui.run('respondToInvitation("invite", true)')
|
|
assert.equal(ui.run('invitations.value.length'), 0)
|
|
assert.equal(ui.run('isCollaboration.value'), true)
|
|
assert.equal(ui.route.query.room, 'collaboration:shared-room')
|
|
assert.equal(ui.calls.find(({ url }) => url.endsWith('/accept')).options.method, 'POST')
|
|
})
|
|
|
|
test('declining an invitation keeps the current chat open', async t => {
|
|
let declined = false
|
|
const ui = setup(fixture(url => {
|
|
if (url.endsWith('/invite/decline')) { declined = true; return { ok: true } }
|
|
if (url === '/api/collaboration') return { rooms: [], invitations: declined ? [] : [{ id: 'invite' }] }
|
|
}))
|
|
t.after(ui.close)
|
|
ui.mount()
|
|
await settle()
|
|
await ui.run('respondToInvitation("invite", false)')
|
|
assert.equal(ui.run('invitations.value.length'), 0)
|
|
assert.equal(ui.run('activeRoomKey.value'), 'allgemein')
|
|
assert.equal(ui.run('messages.value[0].body'), 'Interne Nachricht')
|
|
})
|
|
|
|
test('a late native room load cannot populate a collaboration room or mark it read', async t => {
|
|
const pending = deferred()
|
|
const ui = setup(fixture(url => url.endsWith('/allgemein/messages') ? pending.promise : undefined))
|
|
t.after(ui.close)
|
|
ui.mount()
|
|
await settle()
|
|
await ui.run('selectRoom(decoratedRooms.value.find(r => r.source === "collaboration"))')
|
|
pending.resolve({ messages: [{ id: 99, body: 'Late private content' }] })
|
|
await settle()
|
|
assert.equal(ui.run('messages.value.length'), 0)
|
|
assert.equal(ui.run('members.value.length'), 0)
|
|
assert.equal(ui.run('roomLoading.value'), false)
|
|
assert.equal(ui.calls.some(({ url }) => url.endsWith('/read')), false)
|
|
})
|
|
|
|
test('late sync and send results cannot overwrite the selected collaboration room', async t => {
|
|
const sync = deferred(), send = deferred()
|
|
const ui = setup(fixture((url, options) => {
|
|
if (url.includes('/sync?')) return sync.promise
|
|
if (url.endsWith('/messages') && options?.method === 'POST') return send.promise
|
|
}))
|
|
t.after(ui.close)
|
|
ui.mount()
|
|
await settle()
|
|
const polling = ui.run('pollMessages()')
|
|
ui.run('draft.value = "Internal draft"')
|
|
const sending = ui.run('sendMessage()')
|
|
await ui.run('selectRoom(decoratedRooms.value.find(r => r.source === "collaboration"))')
|
|
const reads = ui.calls.filter(({ url }) => url.endsWith('/read')).length
|
|
sync.resolve({ messages: [{ id: 2, body: 'Late sync' }] })
|
|
send.resolve({ id: 3, body: 'Late sent message' })
|
|
await Promise.all([polling, sending])
|
|
assert.equal(ui.run('messages.value.length'), 0)
|
|
assert.equal(ui.run('draft.value'), '')
|
|
assert.equal(ui.run('sending.value'), false)
|
|
assert.equal(ui.calls.filter(({ url }) => url.endsWith('/read')).length, reads)
|
|
})
|
|
|
|
test('switching tenants clears visible content immediately and ignores older room lists', async t => {
|
|
const stale = deferred(), fresh = deferred()
|
|
let lists = 0
|
|
const ui = setup(fixture(url => {
|
|
if (url === '/api/collaboration') return ++lists === 1 ? stale.promise : fresh.promise
|
|
}))
|
|
t.after(ui.close)
|
|
ui.mount()
|
|
ui.run('messages.value = [{ id: 1, body: "Previous tenant" }]; draft.value = "Private draft"')
|
|
ui.auth.activeTenant = '2'
|
|
assert.equal(ui.run('messages.value.length'), 0)
|
|
assert.equal(ui.run('collaborationRooms.value.length'), 0)
|
|
assert.equal(ui.run('draft.value'), '')
|
|
assert.equal(ui.run('session.value'), 'user-1:2')
|
|
fresh.resolve({ rooms: [{ id: 'tenant-2' }], invitations: [] })
|
|
await settle()
|
|
stale.resolve({ rooms: [{ id: 'tenant-1-secret' }], invitations: [{ id: 'private-invite' }] })
|
|
await settle()
|
|
assert.equal(ui.run('collaborationRooms.value[0].id'), 'tenant-2')
|
|
assert.equal(ui.run('invitations.value.length'), 0)
|
|
})
|
|
|
|
test('collaboration and internal service failures do not block the other room type', async t => {
|
|
const ui = setup(fixture(url => {
|
|
if (url === '/api/collaboration') throw { statusCode: 503, data: { message: 'Unterräume nicht erreichbar' } }
|
|
}))
|
|
t.after(ui.close)
|
|
ui.mount()
|
|
await settle()
|
|
assert.equal(ui.run('isInternal.value'), true)
|
|
assert.equal(ui.run('messages.value.length'), 1)
|
|
assert.equal(ui.run('collaborationError.value'), 'Unterräume nicht erreichbar')
|
|
const other = setup(fixture(url => {
|
|
if (url === '/api/communication/chat/rooms') throw { statusCode: 409, data: { message: 'Chat nicht verfügbar' } }
|
|
}), { room: 'collaboration:shared-room' })
|
|
t.after(other.close)
|
|
other.mount()
|
|
await settle()
|
|
assert.equal(other.run('isCollaboration.value'), true)
|
|
assert.equal(other.run('listError.value'), 'Chat nicht verfügbar')
|
|
})
|
|
|
|
test('revoked membership removes the active collaboration room instead of exposing managed rooms', async t => {
|
|
let revoked = false
|
|
const ui = setup(fixture(url => {
|
|
if (url === '/api/collaboration' && revoked) return { rooms: [], invitations: [], managedRooms: [sharedRoom] }
|
|
}), { room: 'collaboration:shared-room' })
|
|
t.after(ui.close)
|
|
ui.mount()
|
|
await settle()
|
|
assert.equal(ui.run('isCollaboration.value'), true)
|
|
revoked = true
|
|
await ui.run('loadCollaboration()')
|
|
assert.equal(ui.run('activeRoom.value'), null)
|
|
assert.equal(ui.run('isCollaboration.value'), false)
|
|
assert.equal(ui.run('isInternal.value'), false)
|
|
})
|
|
|
|
test('logout clears the room and invitations without starting requests with the previous credentials', async t => {
|
|
const ui = setup(fixture(), { room: 'collaboration:shared-room' })
|
|
t.after(ui.close)
|
|
ui.mount()
|
|
await settle()
|
|
const calls = ui.calls.length
|
|
ui.auth.user = null
|
|
await settle()
|
|
await ui.run('refreshLists()')
|
|
assert.equal(ui.run('activeRoom.value'), null)
|
|
assert.equal(ui.run('invitations.value.length'), 0)
|
|
assert.equal(ui.calls.length, calls)
|
|
})
|
|
|
|
test('unmount cancels both polling timers and ignores pending lists', async () => {
|
|
const pending = deferred()
|
|
const ui = setup(fixture(url => url === '/api/collaboration' ? pending.promise : undefined))
|
|
ui.mount()
|
|
assert.equal(ui.timers.size, 2)
|
|
ui.close()
|
|
pending.resolve({ rooms: [sharedRoom], invitations: [] })
|
|
await settle()
|
|
assert.equal(ui.timers.size, 0)
|
|
assert.equal(ui.listeners.size, 0)
|
|
assert.equal(ui.run('collaborationRooms.value.length'), 0)
|
|
assert.equal(ui.run('messages.value.length'), 0)
|
|
})
|
|
|
|
test('hiding the active room persists the preference, switches away and allows restoring it', async t => {
|
|
const ui = setup()
|
|
t.after(ui.close)
|
|
ui.mount()
|
|
await settle()
|
|
await ui.run('setRoomHidden(activeRoom.value, true)')
|
|
assert.equal(ui.run('hiddenRooms.value[0].key'), 'allgemein')
|
|
assert.equal(ui.run('visibleRooms.value.some(room => room.key === "allgemein")'), false)
|
|
assert.equal(ui.run('isCollaboration.value'), true)
|
|
assert.equal(ui.run('messages.value.length'), 0)
|
|
const save = ui.calls.find(call => call.url.endsWith('/room-preferences') && call.options?.method === 'PUT')
|
|
assert.equal(save.options.body.roomKey, 'allgemein')
|
|
assert.equal(save.options.body.hidden, true)
|
|
await ui.run('setRoomHidden(hiddenRooms.value[0], false)')
|
|
assert.equal(ui.run('hiddenRooms.value.length'), 0)
|
|
assert.equal(ui.run('activeRoomKey.value'), 'allgemein')
|
|
assert.equal(ui.run('messages.value[0].body'), 'Interne Nachricht')
|
|
})
|
|
|
|
test('saved hidden rooms stay hidden on reload, including federated room deep links', async t => {
|
|
const ui = setup(fixture(url => url.endsWith('/room-preferences') ? { hiddenRoomKeys: ['collaboration:shared-room', 'allgemein'] } : undefined), { room: 'collaboration:shared-room' })
|
|
t.after(ui.close)
|
|
ui.mount()
|
|
await settle()
|
|
assert.equal(ui.run('visibleRooms.value.length'), 0)
|
|
assert.equal(ui.run('activeRoom.value'), null)
|
|
assert.equal(ui.calls.some(({ url }) => /\/rooms\/.+\/(messages|read)/.test(url)), false)
|
|
await ui.run('selectRoom(decoratedRooms.value[0])')
|
|
assert.equal(ui.run('activeRoom.value'), null)
|
|
await ui.run('setRoomHidden(hiddenRooms.value.find(r => r.source === "collaboration"), false)')
|
|
assert.equal(ui.run('isCollaboration.value'), true)
|
|
})
|
|
|
|
test('preference save failure leaves the active conversation and list unchanged', async t => {
|
|
const ui = setup(fixture((url, options) => {
|
|
if (url.endsWith('/room-preferences') && options?.method === 'PUT') throw { data: { message: 'Speichern fehlgeschlagen' } }
|
|
}))
|
|
t.after(ui.close)
|
|
ui.mount()
|
|
await settle()
|
|
await ui.run('setRoomHidden(activeRoom.value, true)')
|
|
assert.equal(ui.run('hiddenRooms.value.length'), 0)
|
|
assert.equal(ui.run('activeRoomKey.value'), 'allgemein')
|
|
assert.equal(ui.run('messages.value[0].body'), 'Interne Nachricht')
|
|
assert.equal(ui.toasts.at(-1).title, 'Speichern fehlgeschlagen')
|
|
})
|
|
|
|
test('pending hide operations cannot change a new tenant, and hiding the last room clears its content', async t => {
|
|
const pending = deferred()
|
|
const ui = setup(fixture((url, options) => {
|
|
if (url.endsWith('/room-preferences') && options?.method === 'PUT') return pending.promise
|
|
}))
|
|
t.after(ui.close)
|
|
ui.mount()
|
|
await settle()
|
|
const hiding = ui.run('setRoomHidden(activeRoom.value, true)')
|
|
ui.auth.activeTenant = '2'
|
|
await settle()
|
|
pending.resolve({ hidden: true, roomKey: 'allgemein' })
|
|
await hiding
|
|
assert.equal(ui.run('hiddenRoomKeys.value.length'), 0)
|
|
assert.equal(ui.run('activeRoomKey.value'), 'allgemein')
|
|
const single = setup(fixture(url => url === '/api/collaboration' ? { rooms: [], invitations: [] } : undefined))
|
|
t.after(single.close)
|
|
single.mount()
|
|
await settle()
|
|
await single.run('setRoomHidden(activeRoom.value, true)')
|
|
await settle()
|
|
assert.equal(single.run('activeRoom.value'), null)
|
|
assert.equal(single.run('messages.value.length'), 0)
|
|
assert.equal(single.run('members.value.length'), 0)
|
|
assert.equal(single.run('visibleRooms.value.length'), 0)
|
|
})
|
|
|
|
test('removing an active project from the permitted room list clears its messages and members', async t => {
|
|
let disabled = false
|
|
const ui = setup(fixture(url => url.endsWith('/project-rooms') ? { rooms: disabled ? [] : [{ key: 'project_1', exists: true, type: 'project' }] } : undefined), { room: 'project_1' })
|
|
t.after(ui.close)
|
|
ui.mount()
|
|
await settle()
|
|
assert.equal(ui.run('messages.value.length'), 1)
|
|
disabled = true
|
|
await ui.run('loadRoomLists()')
|
|
await settle()
|
|
assert.equal(ui.run('activeRoom.value'), null)
|
|
assert.equal(ui.run('messages.value.length'), 0)
|
|
assert.equal(ui.run('members.value.length'), 0)
|
|
})
|
|
|
|
test('returning to chat immediately reflects a disabled or re-enabled project without waiting for polling', async t => {
|
|
let disabled = false
|
|
const ui = setup(fixture(url => url.endsWith('/project-rooms') ? { rooms: disabled ? [] : [{ key: 'project_1', exists: true, type: 'project' }] } : undefined), { room: 'project_1' })
|
|
t.after(ui.close)
|
|
ui.mount()
|
|
await settle()
|
|
disabled = true
|
|
ui.document.hidden = true
|
|
ui.listeners.get('visibilitychange')()
|
|
await settle()
|
|
assert.equal(ui.run('activeRoom.value.key'), 'project_1')
|
|
ui.document.hidden = false
|
|
ui.listeners.get('visibilitychange')()
|
|
await settle()
|
|
assert.equal(ui.run('activeRoom.value'), null)
|
|
assert.equal(ui.run('messages.value.length'), 0)
|
|
disabled = false
|
|
ui.listeners.get('focus')()
|
|
await settle()
|
|
assert.equal(ui.run('groupedRooms.value.find(group => group.label === "Projekte").rooms[0].key'), 'project_1')
|
|
})
|
|
|
|
|
|
test('native image-only sends retain the draft after an error and clear it after success', async t => {
|
|
let fail = true
|
|
const ui = setup(fixture((url, options) => {
|
|
if (url.endsWith('/messages') && options?.method === 'POST') {
|
|
if (fail) throw { status: 503, data: { message: 'Offline' } }
|
|
return { id: 2, body: '', image: { name: 'Bild', width: 80, height: 40 }, own: true }
|
|
}
|
|
}))
|
|
t.after(ui.close)
|
|
await ui.run('initialize()')
|
|
ui.run('draftImage.value = { dataUrl: "data:image/webp;base64,AAAA", name: "Bild" }')
|
|
await ui.run('sendMessage()')
|
|
assert.equal(ui.run('draftImage.value.name'), 'Bild')
|
|
fail = false
|
|
await ui.run('sendMessage()')
|
|
assert.equal(ui.run('messages.value.at(-1).image.name'), 'Bild')
|
|
assert.equal(ui.run('draftImage.value'), null)
|
|
assert.ok(ui.calls.some(c => c.options?.body?.image?.name === 'Bild'))
|
|
})
|