feat(documents): prepare inbox suggestions and add entity assignments
This commit is contained in:
62
frontend/tests/document-inbox-count.test.cjs
Normal file
62
frontend/tests/document-inbox-count.test.cjs
Normal file
@@ -0,0 +1,62 @@
|
||||
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 ts = require('../../backend/node_modules/typescript')
|
||||
const settle = () => new Promise(setImmediate)
|
||||
|
||||
function setup(api) {
|
||||
const auth = vue.reactive({ user: { id: 'user-1' }, activeTenant: '1', profile: null, activeTenantData: {} })
|
||||
const timers = new Map()
|
||||
let nextTimer = 0
|
||||
const scope = vue.effectScope()
|
||||
const context = vm.createContext({
|
||||
exports: {}, AbortController,
|
||||
require: name => name === '@vueuse/core' ? { createSharedComposable: fn => fn } : require(name),
|
||||
ref: vue.ref, computed: vue.computed, watch: vue.watch, onScopeDispose: vue.onScopeDispose,
|
||||
useAuthStore: () => auth, useNuxtApp: () => ({ $api: api }),
|
||||
setTimeout: fn => { timers.set(++nextTimer, fn); return nextTimer }, clearTimeout: id => timers.delete(id),
|
||||
})
|
||||
const source = fs.readFileSync(`${__dirname}/../composables/useDocumentInboxCount.ts`, 'utf8')
|
||||
vm.runInContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, context)
|
||||
const counter = scope.run(() => context.exports.useDocumentInboxCount())
|
||||
return { auth, counter, timers, close: () => scope.stop() }
|
||||
}
|
||||
|
||||
test('menu count refreshes after filing and cancels its timer when navigation is unmounted', async () => {
|
||||
let count = 3
|
||||
const ui = setup(async () => ({ total: count }))
|
||||
await settle()
|
||||
assert.equal(ui.counter.count.value, 3)
|
||||
assert.equal(ui.timers.size, 1)
|
||||
count = 2
|
||||
await ui.counter.refresh()
|
||||
assert.equal(ui.counter.count.value, 2)
|
||||
assert.equal(ui.timers.size, 1)
|
||||
ui.close()
|
||||
assert.equal(ui.timers.size, 0)
|
||||
})
|
||||
|
||||
test('previous tenant responses cannot replace the current badge; portal and logout clear it', async t => {
|
||||
let finish
|
||||
let requests = 0
|
||||
const ui = setup(() => ++requests === 1 ? new Promise(resolve => { finish = resolve }) : Promise.resolve({ total: 2 }))
|
||||
t.after(ui.close)
|
||||
ui.auth.activeTenant = '2'
|
||||
assert.equal(ui.counter.count.value, 0)
|
||||
await settle()
|
||||
finish({ total: 999 })
|
||||
await settle()
|
||||
assert.equal(ui.counter.count.value, 2)
|
||||
ui.auth.profile = { customer_for_portal: 15 }
|
||||
await settle()
|
||||
assert.equal(ui.counter.count.value, 0)
|
||||
assert.equal(ui.timers.size, 0)
|
||||
ui.auth.profile = null
|
||||
await settle()
|
||||
assert.equal(ui.counter.count.value, 2)
|
||||
ui.auth.user = null
|
||||
await settle()
|
||||
assert.equal(ui.counter.count.value, 0)
|
||||
})
|
||||
@@ -18,13 +18,15 @@ function setup(api, apiBase = 'https://fedeo.example/backend') {
|
||||
let timerId = 0
|
||||
let mount
|
||||
let unmount
|
||||
let countRefreshes = 0
|
||||
const context = vm.createContext({
|
||||
require, exports: {}, URL, AbortController,
|
||||
ref: vue.ref, computed: vue.computed, watch: vue.watch,
|
||||
useAuthStore: () => auth,
|
||||
useDocumentInboxCount: () => ({ refresh: async () => { countRefreshes++ } }),
|
||||
useRuntimeConfig: () => ({ public: { apiBase } }),
|
||||
useFiles: () => ({}), useToast: () => ({ add() {} }),
|
||||
useNuxtApp: () => ({ $api: api }),
|
||||
useNuxtApp: () => ({ $api: (url, options) => /\/assignments$/.test(url) ? Promise.resolve([]) : api(url, options) }),
|
||||
window: { location: { origin: 'https://fedeo.example' } },
|
||||
onMounted: handler => { mount = handler },
|
||||
onBeforeUnmount: handler => { unmount = handler },
|
||||
@@ -33,7 +35,7 @@ function setup(api, apiBase = 'https://fedeo.example/backend') {
|
||||
})
|
||||
const source = fs.readFileSync(`${__dirname}/../pages/organisation/dokumentenablage.vue`, 'utf8').match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
|
||||
vm.runInContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText, context)
|
||||
return { auth, timers, mount, close: () => unmount(), run: code => vm.runInContext(code, context) }
|
||||
return { auth, timers, mount, countRefreshes: () => countRefreshes, close: () => unmount(), run: code => vm.runInContext(code, context) }
|
||||
}
|
||||
|
||||
test('scanner setup preserves absolute and relative backend prefixes', async t => {
|
||||
@@ -193,12 +195,12 @@ test('tenant switching discards pending folder options and filing responses', as
|
||||
|
||||
const aiSuggestion = { folderId: 'folder', typeId: 'invoice', confidence: 'high', reason: 'Rechnung erkannt.' }
|
||||
|
||||
test('AI preselection only files the document after confirmation and reuses its preview result', async t => {
|
||||
test('AI preselection only files the document after confirmation and reads the prepared result without triggering AI', async t => {
|
||||
const requests = []
|
||||
const ui = setup(async (url, options) => {
|
||||
requests.push({ url, options })
|
||||
if (url.endsWith('/filing-options')) return filingOptions
|
||||
if (url.endsWith('/suggestion')) return { suggestion: aiSuggestion }
|
||||
if (url.endsWith('/suggestion')) return { status: 'ready', suggestion: aiSuggestion }
|
||||
if (url.endsWith('/file')) return { id: 'scan' }
|
||||
return { documents: [], total: 0 }
|
||||
})
|
||||
@@ -213,12 +215,14 @@ test('AI preselection only files the document after confirmation and reuses its
|
||||
await settle()
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
await settle()
|
||||
assert.equal(requests.filter(request => request.url.endsWith('/suggestion')).length, 1)
|
||||
assert.equal(requests.filter(request => request.url.endsWith('/suggestion')).length, 2)
|
||||
assert.ok(requests.filter(request => request.url.endsWith('/suggestion')).every(request => request.options.method === 'GET'))
|
||||
await ui.run('fileDocument()')
|
||||
const save = requests.find(request => request.url.endsWith('/file'))
|
||||
assert.equal(save.options.body.folder, 'folder')
|
||||
assert.equal(save.options.body.type, 'invoice')
|
||||
assert.equal(ui.run('previewOpen.value'), false)
|
||||
assert.equal(ui.countRefreshes(), 1)
|
||||
})
|
||||
|
||||
test('late AI results preserve manual choices, and can still be explicitly applied', async t => {
|
||||
@@ -227,7 +231,7 @@ test('late AI results preserve manual choices, and can still be explicitly appli
|
||||
t.after(ui.close)
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
ui.run('filingType.value = "other"; selectionTouched.value = true')
|
||||
pending.resolve({ suggestion: aiSuggestion })
|
||||
pending.resolve({ status: 'ready', suggestion: aiSuggestion })
|
||||
await settle()
|
||||
assert.equal(ui.run('filingFolder.value'), 'root')
|
||||
assert.equal(ui.run('filingType.value'), 'other')
|
||||
@@ -257,10 +261,9 @@ test('closing, changing documents or switching tenant cancels and discards pendi
|
||||
// Keep the replacement document's request separate from the stale first result.
|
||||
if (action === 'document') ui.run('previewOpen.value = false')
|
||||
await settle()
|
||||
pending.resolve({ suggestion: aiSuggestion })
|
||||
pending.resolve({ status: 'ready', suggestion: aiSuggestion })
|
||||
await settle()
|
||||
assert.equal(ui.run('suggestion.value'), null)
|
||||
assert.equal(ui.run('suggestionCache.size'), 0)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -279,3 +282,113 @@ test('AI failures leave manual filing available and optional folder defaults do
|
||||
await settle()
|
||||
assert.equal(ui.run('filingType.value'), 'other')
|
||||
})
|
||||
|
||||
test('pending preparation is polled, preserves manual choices and only explicit refresh queues work', async t => {
|
||||
const requests = []
|
||||
let state = 'pending'
|
||||
const ui = setup(async (url, options) => {
|
||||
if (url.endsWith('/filing-options')) return filingOptions
|
||||
requests.push(options.method)
|
||||
return { status: state, suggestion: state === 'ready' ? aiSuggestion : null }
|
||||
})
|
||||
t.after(ui.close)
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
await settle()
|
||||
assert.equal(ui.run('suggestionStatus.value'), 'pending')
|
||||
assert.equal(ui.run('suggestion.value'), null)
|
||||
assert.equal(ui.timers.size, 1)
|
||||
ui.run('filingType.value = "other"; selectionTouched.value = true')
|
||||
state = 'ready'
|
||||
await ui.run('loadFilingSuggestion()')
|
||||
assert.equal(ui.run('filingType.value'), 'other')
|
||||
assert.equal(ui.run('suggestion.value.typeId'), 'invoice')
|
||||
assert.equal(ui.timers.size, 0)
|
||||
await ui.run('loadFilingSuggestion(true)')
|
||||
assert.deepEqual(requests, ['GET', 'GET', 'POST'])
|
||||
})
|
||||
|
||||
test('preparation polling resumes when a concurrent manual filing attempt fails', async t => {
|
||||
const save = deferred()
|
||||
let state = 'processing'
|
||||
const ui = setup(async url => {
|
||||
if (url.endsWith('/filing-options')) return filingOptions
|
||||
if (url.endsWith('/suggestion')) return { status: state, suggestion: state === 'ready' ? aiSuggestion : null }
|
||||
if (url.endsWith('/file')) { await save.promise; throw { data: { error: 'Bitte erneut versuchen' } } }
|
||||
})
|
||||
t.after(ui.close)
|
||||
const tick = async () => {
|
||||
assert.equal(ui.timers.size, 1)
|
||||
const [id, callback] = [...ui.timers][0]
|
||||
ui.timers.delete(id)
|
||||
await callback()
|
||||
}
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
await settle()
|
||||
ui.run('filingType.value = "other"; selectionTouched.value = true')
|
||||
const filing = ui.run('fileDocument()')
|
||||
await tick()
|
||||
assert.equal(ui.timers.size, 1, 'polling remains scheduled while filing is in progress')
|
||||
save.resolve()
|
||||
await filing
|
||||
assert.equal(ui.run('previewOpen.value'), true)
|
||||
state = 'ready'
|
||||
await tick()
|
||||
assert.equal(ui.run('suggestion.value.typeId'), 'invoice')
|
||||
assert.equal(ui.run('filingType.value'), 'other')
|
||||
assert.equal(ui.timers.size, 0)
|
||||
})
|
||||
|
||||
test('entity assignments are submitted together with filing and search reset preserves a selected item', async t => {
|
||||
const requests = []
|
||||
const ui = setup(async (url, options) => {
|
||||
requests.push({ url, options })
|
||||
if (url.endsWith('/filing-options')) return { ...filingOptions, assignmentKinds: [{ value: 'project', label: 'Projekt' }, { value: 'customer', label: 'Kunde' }] }
|
||||
if (url.endsWith('/assignment-options')) return [{ id: '11', name: 'Umbau' }]
|
||||
if (url.endsWith('/suggestion')) return { status: 'ready', suggestion: aiSuggestion }
|
||||
if (url.endsWith('/file')) return { id: 'scan' }
|
||||
return { documents: [], total: 0 }
|
||||
})
|
||||
t.after(ui.close)
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
await settle()
|
||||
ui.run('assignmentSearch.value = "Umbau"')
|
||||
await settle()
|
||||
await ui.run('loadAssignmentOptions()')
|
||||
ui.run('assignmentId.value = "11"; assignmentSearch.value = ""')
|
||||
await settle()
|
||||
assert.equal(ui.run('assignmentId.value'), '11')
|
||||
ui.run('addAssignment()')
|
||||
assert.equal(ui.run('assignments.value[0].name'), 'Umbau')
|
||||
assert.equal(requests.some(request => request.url.endsWith('/file')), false)
|
||||
ui.run('assignmentKind.value = "customer"')
|
||||
await settle()
|
||||
ui.run('assignmentOptions.value = [{ id: "21", name: "Kunde" }]; assignmentId.value = "21"; assignmentLoading.value = false; addAssignment(); removeAssignment("project")')
|
||||
await ui.run('fileDocument()')
|
||||
const saved = requests.find(request => request.url.endsWith('/file'))
|
||||
assert.equal(saved.options.body.assignments.project, null)
|
||||
assert.equal(saved.options.body.assignments.customer, '21')
|
||||
assert.equal(saved.options.body.folder, 'folder')
|
||||
assert.equal(saved.options.body.type, 'invoice')
|
||||
assert.equal(ui.countRefreshes(), 1)
|
||||
})
|
||||
|
||||
test('old entity search responses are discarded on category and tenant changes', async t => {
|
||||
const pending = deferred()
|
||||
const ui = setup(url => {
|
||||
if (url.endsWith('/filing-options')) return Promise.resolve({ ...filingOptions, assignmentKinds: [{ value: 'project', label: 'Projekt' }, { value: 'customer', label: 'Kunde' }] })
|
||||
if (url.endsWith('/assignment-options')) return pending.promise
|
||||
if (url.endsWith('/suggestion')) return Promise.resolve({ status: 'ready', suggestion: null })
|
||||
return Promise.resolve({ documents: [], total: 0 })
|
||||
})
|
||||
t.after(ui.close)
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
ui.run('assignmentKind.value = "customer"')
|
||||
await settle()
|
||||
ui.auth.activeTenant = '2'
|
||||
await settle()
|
||||
pending.resolve([{ id: 'foreign', name: 'Previous tenant project' }])
|
||||
await settle()
|
||||
assert.equal(ui.run('assignmentOptions.value.length'), 0)
|
||||
assert.equal(ui.run('assignments.value.length'), 0)
|
||||
assert.equal(ui.timers.size, 1, 'only the inbox list polling remains')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user