Files
FEDEO/frontend/tests/chat-collaboration.test.cjs
flfeders 7e6c2fbac5
All checks were successful
Build and Release FEDEO / verify (push) Successful in 57s
Build and Release FEDEO / build-and-release (push) Successful in 6m27s
feat(communication): add room visibility and project chat controls
2026-10-02 18:27:02 +02:00

330 lines
15 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()
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: { setInterval: handler => { timers.set(++timerId, handler); return timerId } },
clearInterval: id => timers.delete(id), document: { hidden: false },
})
scope.run(() => vm.runInContext(source, context))
return { auth, route, calls, toasts, timers, 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.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)
})