import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import vm from 'node:vm' // public/kundenansicht.js im Browser-Nachbau: Kennung X-Kundenansicht an jede eigene /api-Anfrage, // Hinweis-Overlay bei ersetzter (409 view_replaced) oder abgelaufener (401 view_expired) Ansicht const SRC = readFileSync(new URL('../public/kundenansicht.js', import.meta.url), 'utf8') const ID = 'a'.repeat(32) function fakeElement(tag) { return { tag, id: '', className: '', textContent: '', type: '', children: [], attrs: {}, listeners: {}, appendChild(child) { this.children.push(child) return child }, setAttribute(name, value) { this.attrs[name] = value }, addEventListener(name, fn) { this.listeners[name] = fn }, } } function browser({ respond = () => ({ status: 200, body: {} }) } = {}) { const calls = [] const body = fakeElement('body') const all = () => { const out = [] const walk = (n) => { out.push(n) n.children.forEach(walk) } walk(body) return out } const document = { body, createElement: (tag) => fakeElement(tag), getElementById: (id) => all().find((n) => n.id === id) || null, } const xhrs = [] class XMLHttpRequest { constructor() { this.headers = {} this.listeners = {} xhrs.push(this) } open(method, url) { this.method = method this.url = url } setRequestHeader(name, value) { this.headers[name] = value } addEventListener(name, fn) { this.listeners[name] = fn } send() { this.sent = true } } const ctx = { URL, Headers, Response, console, document, XMLHttpRequest, location: { href: 'https://project.webklar.com/dashboard.html', origin: 'https://project.webklar.com' }, close: () => { ctx.closed = true }, fetch: async (input, init = {}) => { calls.push({ url: typeof input === 'string' ? input : input.url, headers: new Headers(init.headers || {}) }) const { status, body: json } = respond(input) return new Response(JSON.stringify(json), { status, headers: { 'content-type': 'application/json' } }) }, } ctx.window = ctx vm.createContext(ctx) vm.runInContext(SRC, ctx) const overlayText = () => { const box = document.getElementById('kb-view-overlay') if (!box) return null const texts = [] const walk = (n) => { if (n.textContent) texts.push(n.textContent) n.children.forEach(walk) } walk(box) return texts.join(' | ') } return { ctx, calls, xhrs, overlayText } } const settle = () => new Promise((resolve) => setTimeout(resolve, 10)) test('ohne ID kein Header', async () => { const { ctx, calls } = browser() await ctx.fetch('/api/projects') assert.equal(calls[0].headers.get('x-kundenansicht'), null) }) test('mit ID: Header bei /api/... gleicher Herkunft, nicht bei fremder Herkunft oder /img', async () => { const { ctx, calls } = browser() ctx.KbView.setId(ID) assert.equal(ctx.KbView.id(), ID) await ctx.fetch('/api/projects') await ctx.fetch('https://example.org/api/x') await ctx.fetch('/img/logo.png') await ctx.fetch('https://project.webklar.com/api/news', { method: 'POST', headers: { 'Content-Type': 'application/json' } }) assert.equal(calls[0].headers.get('x-kundenansicht'), ID) assert.equal(calls[1].headers.get('x-kundenansicht'), null) assert.equal(calls[2].headers.get('x-kundenansicht'), null) assert.equal(calls[3].headers.get('x-kundenansicht'), ID) assert.equal(calls[3].headers.get('content-type'), 'application/json', 'vorhandene Header bleiben') }) test('409 view_replaced zeigt Overlay mit Kundennamen, Antwort bleibt lesbar', async () => { const { ctx, overlayText } = browser({ respond: () => ({ status: 409, body: { code: 'view_replaced', kunde: 'Kunde Zwei', error: 'x' } }) }) ctx.KbView.setId(ID) const res = await ctx.fetch('/api/projects') assert.equal((await res.json()).code, 'view_replaced') await settle() assert.match(overlayText(), /Diese Ansicht wurde durch die Kundenansicht von Kunde Zwei ersetzt\./) assert.match(overlayText(), /Tab schließen/) }) test('401 view_expired zeigt Abgelaufen-Overlay, andere 401 nicht', async () => { const other = browser({ respond: () => ({ status: 401, body: { error: 'Nicht angemeldet' } }) }) await other.ctx.fetch('/api/projects') await settle() assert.equal(other.overlayText(), null) const { ctx, overlayText } = browser({ respond: () => ({ status: 401, body: { code: 'view_expired' } }) }) await ctx.fetch('/api/projects') await settle() assert.match(overlayText(), /Die Kundenansicht ist abgelaufen\. Bitte im Ticketsystem erneut öffnen\./) }) test('Overlay-Knopf schliesst den Tab, zweiter Hinweis ersetzt den Text statt zu stapeln', async () => { const { ctx, overlayText } = browser() ctx.KbView.showReplaced('Kunde Zwei') ctx.KbView.showExpired() const box = ctx.document.getElementById('kb-view-overlay') assert.equal(ctx.document.body.children.length, 1) assert.match(overlayText(), /abgelaufen/) const find = (n) => (n.tag === 'button' ? n : n.children.map(find).find(Boolean)) find(box).listeners.click() assert.equal(ctx.closed, true) }) test('XHR bekommt den Header ebenfalls (nur eigene /api-Adressen)', () => { const { ctx, xhrs } = browser() ctx.KbView.setId(ID) const a = new ctx.XMLHttpRequest() a.open('POST', '/api/chat/attachments') a.send() const b = new ctx.XMLHttpRequest() b.open('GET', 'https://example.org/api/x') b.send() assert.equal(xhrs[0].headers['X-Kundenansicht'], ID) assert.equal(xhrs[1].headers['X-Kundenansicht'], undefined) })