feat(chat): preserve message drafts in session temp store

This commit is contained in:
2026-10-15 17:45:17 +02:00
parent e933782731
commit 697f538b6e
8 changed files with 426 additions and 16 deletions

View File

@@ -3,6 +3,7 @@ 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 = () => {
@@ -33,6 +34,7 @@ function fixture(override = () => undefined) {
function setup(api = fixture(), query = {}) {
const auth = vue.reactive({ user: { id: 'user-1' }, activeTenant: '1' })
const drafts = createDraftHarness({ auth })
const route = vue.reactive({ query })
const calls = [], toasts = [], timers = new Map()
const listeners = new Map()
@@ -47,6 +49,7 @@ function setup(api = fixture(), query = {}) {
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,
useChatDraft: drafts.useChatDraft,
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 },
@@ -54,7 +57,7 @@ function setup(api = fixture(), query = {}) {
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) }
return { auth, route, calls, toasts, timers, listeners, document, mount, drafts, close: () => { unmount(); scope.stop(); drafts.close() }, run: code => vm.runInContext(code, context) }
}
test('only member rooms enter normal chat, with separate keys, groups and unread counts', async t => {
@@ -377,3 +380,69 @@ test('native image-only sends retain the draft after an error and clear it after
assert.equal(ui.run('draftImage.value'), null)
assert.ok(ui.calls.some(c => c.options?.body?.image?.name === 'Bild'))
})
test('native drafts survive switching chats, hiding a room and reloading its history', async t => {
const ui = setup(fixture(url => url.endsWith('/project-rooms') ? { rooms: [{ key: 'project_1', exists: true, type: 'project' }] } : undefined))
t.after(ui.close)
ui.mount()
await settle()
ui.run('draft.value = " Hallo\\nWelt 👋 "; draftImage.value = { dataUrl: "data:image/webp;base64,AAAA", name: "Skizze" }')
await ui.run('selectRoom(decoratedRooms.value.find(r => r.key === "project_1"))')
assert.equal(ui.run('draft.value'), '')
assert.equal(ui.run('draftImage.value'), null)
ui.run('draft.value = "Projektentwurf"')
await ui.run('selectRoom(decoratedRooms.value.find(r => r.key === "allgemein"))')
assert.equal(ui.run('draft.value'), ' Hallo\nWelt 👋 ')
assert.equal(ui.run('draftImage.value.name'), 'Skizze')
await ui.run('loadActiveRoom()')
assert.equal(ui.run('draft.value'), ' Hallo\nWelt 👋 ')
await ui.run('setRoomHidden(activeRoom.value, true)')
assert.equal(ui.run('draft.value'), 'Projektentwurf')
await ui.run('setRoomHidden(hiddenRooms.value[0], false)')
assert.equal(ui.run('draft.value'), ' Hallo\nWelt 👋 ')
})
test('failed sends retain the exact draft after switching away, and late success clears only the sent room', async t => {
const pending = deferred()
let fail = true
const ui = setup(fixture((url, options) => {
if (url.endsWith('/messages') && options?.method === 'POST') {
if (fail) throw { data: { message: 'Offline' } }
return pending.promise
}
}))
t.after(ui.close)
ui.mount()
await settle()
ui.run('draft.value = " Noch nicht fertig\\n "')
await ui.run('sendMessage()')
assert.equal(ui.run('draft.value'), ' Noch nicht fertig\n ')
fail = false
const sending = ui.run('sendMessage()')
await ui.run('selectRoom(decoratedRooms.value.find(r => r.source === "collaboration"))')
pending.resolve({ id: 2, body: 'Noch nicht fertig' })
await sending
await ui.run('selectRoom(decoratedRooms.value.find(r => r.key === "allgemein"))')
assert.equal(ui.run('draft.value'), '')
assert.equal(Object.keys(ui.drafts.storage).length, 0)
})
test('editing a room after returning to a pending send preserves the new draft', async t => {
const pending = deferred()
const ui = setup(fixture((url, options) => url.endsWith('/messages') && options?.method === 'POST' ? pending.promise : undefined))
t.after(ui.close)
ui.mount()
await settle()
ui.run('draft.value = "Vorheriger Entwurf"')
const sending = ui.run('sendMessage()')
await ui.run('selectRoom(decoratedRooms.value.find(r => r.source === "collaboration"))')
await ui.run('selectRoom(decoratedRooms.value.find(r => r.key === "allgemein"))')
assert.equal(ui.run('draftSending.value'), true)
await ui.run('sendMessage()')
assert.equal(ui.calls.filter(({ url, options }) => url.endsWith('/messages') && options?.method === 'POST').length, 1)
ui.run('draft.value = "Weitergeschrieben"')
pending.resolve({ id: 2, body: 'Vorheriger Entwurf' })
await sending
assert.equal(ui.run('draft.value'), 'Weitergeschrieben')
assert.equal(ui.run('draftSending.value'), false)
})

View File

@@ -0,0 +1,123 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const vue = require('vue')
const { createDraftHarness, memoryStorage } = require('./helpers/chat-drafts.cjs')
const image = { dataUrl: 'data:image/webp;base64,AAAA', name: 'Skizze.webp' }
test('text and image drafts survive room changes and a browser reload in the same session', t => {
const storage = memoryStorage(), first = createDraftHarness({ storage })
t.after(first.close)
const room = vue.ref('allgemein'), composer = first.useChatDraft(room)
composer.draft.value = ' Hallo\nWelt 👋 '
composer.draftImage.value = image
room.value = 'project_1'
assert.equal(composer.draft.value, '')
assert.equal(composer.draftImage.value, null)
composer.draft.value = 'Projektentwurf'
room.value = 'allgemein'
assert.equal(composer.draft.value, ' Hallo\nWelt 👋 ')
assert.equal(composer.draftImage.value.name, image.name)
const reloaded = createDraftHarness({ storage })
t.after(reloaded.close)
const restored = reloaded.useChatDraft('allgemein')
assert.equal(restored.draft.value, composer.draft.value)
assert.equal(restored.draftImage.value.dataUrl, image.dataUrl)
assert.equal(restored.captureDraft().value.clientId, composer.captureDraft().value.clientId)
assert.equal(reloaded.useChatDraft('project_1').draft.value, 'Projektentwurf')
assert.equal(first.calls.length, 0)
})
test('tenant and profile scopes cannot read each other; logout removes the session drafts', t => {
const ui = createDraftHarness()
t.after(ui.close)
const composer = ui.useChatDraft('allgemein')
composer.draft.value = 'Privater Entwurf'
ui.auth.activeTenant = '2'
assert.equal(composer.draft.value, '')
composer.draft.value = 'Mandant 2'
ui.auth.activeTenant = '1'
assert.equal(composer.draft.value, 'Privater Entwurf')
ui.auth.profile = { id: 'other-profile' }
assert.equal(composer.draft.value, '')
ui.auth.profile = { id: 'profile-1' }
assert.equal(composer.draft.value, 'Privater Entwurf')
ui.auth.user = { id: 'another-user' }
assert.equal(composer.draft.value, '')
assert.equal(Object.keys(ui.storage).length, 0)
composer.draft.value = 'Neuer Benutzer'
ui.auth.user = null
assert.equal(composer.draft.value, '')
assert.equal(Object.keys(ui.storage).length, 0)
})
test('successful sends remove only the captured version, even after changing rooms', t => {
const ui = createDraftHarness()
t.after(ui.close)
const room = vue.ref('allgemein'), composer = ui.useChatDraft(room)
composer.draft.value = 'Gesendet'
const sent = composer.captureDraft()
room.value = 'project_1'
composer.draft.value = 'Noch nicht gesendet'
composer.clearDraft(sent)
assert.equal(composer.draft.value, 'Noch nicht gesendet')
room.value = 'allgemein'
assert.equal(composer.draft.value, '')
composer.draftImage.value = image
const older = composer.captureDraft()
composer.draft.value = 'Neuer Text zum Bild'
composer.clearDraft(older)
assert.equal(composer.draft.value, 'Neuer Text zum Bild')
assert.equal(composer.draftImage.value.name, image.name)
composer.clearDraft(composer.captureDraft())
assert.equal(composer.draft.value, '')
assert.equal(composer.draftImage.value, null)
})
test('empty drafts are removed and never enter the shared profile temp_config', async t => {
const ui = createDraftHarness()
t.after(ui.close)
const composer = ui.useChatDraft('direct_user-2')
composer.draft.value = 'Geheim'
ui.store.modifySearchString('contacts', 'Beispiel')
assert.equal(JSON.stringify(ui.calls).includes('Geheim'), false)
ui.store.setStoredTempConfig({ settings: { density: 'compact' } })
assert.equal(composer.draft.value, 'Geheim')
composer.draft.value = ''
assert.equal(Object.keys(ui.storage).length, 0)
})
test('guest conversations never reuse internal drafts and keep temporary text through a retry', t => {
const ui = createDraftHarness()
t.after(ui.close)
ui.useChatDraft('collaboration:one').draft.value = 'Interner Entwurf'
const room = vue.ref('collaboration:one'), guest = ui.useChatDraft(room, { persist: false })
assert.equal(guest.draft.value, '')
guest.draft.value = 'Gastentwurf'
assert.equal(guest.draft.value, 'Gastentwurf')
room.value = 'collaboration:two'
assert.equal(guest.draft.value, '')
assert.equal(Object.values(ui.storage).some(value => value.includes('Gastentwurf')), false)
})
test('corrupt or unavailable browser storage does not prevent writing or sending', t => {
const storage = memoryStorage(), initial = createDraftHarness({ storage })
t.after(initial.close)
initial.useChatDraft('allgemein').draft.value = 'Alt'
storage[Object.keys(storage)[0]] = 'invalid json'
const corrupt = createDraftHarness({ storage })
t.after(corrupt.close)
assert.equal(corrupt.useChatDraft('allgemein').draft.value, '')
const blocked = createDraftHarness({ storage: {
getItem() { throw Error('Blocked') }, setItem() { throw Error('Quota exceeded') }, removeItem() { throw Error('Blocked') },
} })
t.after(blocked.close)
const composer = blocked.useChatDraft('allgemein')
composer.draft.value = 'Im Speicher'
composer.draftImage.value = image
assert.equal(composer.draft.value, 'Im Speicher')
assert.equal(composer.draftImage.value.name, image.name)
assert.equal(blocked.toasts.length, 1)
composer.clearDraft(composer.captureDraft())
assert.equal(composer.draft.value, '')
})

View File

@@ -3,6 +3,7 @@ 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) } }
@@ -89,3 +90,62 @@ test('image display rejects external URLs and ignores responses after a room cha
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)
})

View File

@@ -0,0 +1,36 @@
const fs = require('node:fs')
const vm = require('node:vm')
const { randomUUID } = require('node:crypto')
const vue = require('vue')
function memoryStorage() {
const storage = Object.create(null)
Object.defineProperties(storage, {
getItem: { value: key => storage[key] ?? null },
setItem: { value: (key, value) => { storage[key] = value } },
removeItem: { value: key => { delete storage[key] } },
})
return storage
}
function createDraftHarness({ auth = vue.reactive({ user: { id: 'user-1' }, activeTenant: '1', profile: { id: 'profile-1' } }), storage = memoryStorage() } = {}) {
const scope = vue.effectScope(), toasts = [], calls = []
const context = vm.createContext({ ...vue, crypto: { randomUUID }, sessionStorage: storage,
defineStore: (_, setup) => setup,
useAuthStore: () => auth,
useRuntimeConfig: () => ({ public: { apiBase: 'https://api.example.test' } }),
useToast: () => ({ add: toast => toasts.push(toast) }),
useNuxtApp: () => ({ $api: async (url, options) => { calls.push({ url, options }) } }),
})
const storeSource = fs.readFileSync(`${__dirname}/../../stores/temp.js`, 'utf8').replace(/^import .*$/mg, '').replace('export const', 'const')
const store = scope.run(() => vm.runInContext(`${storeSource}\nuseTempStore()`, context))
context.useTempStore = () => store
const composable = fs.readFileSync(`${__dirname}/../../composables/useChatDraft.js`, 'utf8').replace('export function', 'function')
vm.runInContext(composable, context)
return { auth, storage, store, calls, toasts,
useChatDraft: (key, options) => scope.run(() => context.useChatDraft(key, options)),
close: () => scope.stop(),
}
}
module.exports = { createDraftHarness, memoryStorage }