Kundenansicht im Dashboard: Kennung bei jeder Anfrage, Banner mit Beenden, Hinweis bei ersetzter oder abgelaufener Ansicht

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-01 19:36:55 +00:00
1 parent c5afd42a5f
commit c1fda8a717
6 files changed
+443 -5

No files matched your search

+173
View File
@@ -0,0 +1,173 @@
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)
})
+121
View File
@@ -0,0 +1,121 @@
// Playwright-Checks der Kundenansicht im Dashboard: statischer Server fuer public/ + per page.route gemockte API.
// Nicht Teil von npm test. Aufruf: node test/ui/kundenansicht.ui.mjs
// Screenshots landen in /root/reports/admin-umzug-ui/.
import http from 'node:http'
import { readFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
const SHOTS = '/root/reports/admin-umzug-ui'
await mkdir(SHOTS, { recursive: true })
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/json' }
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x')
try {
const file = path.join(pub, url.pathname === '/' ? 'dashboard.html' : url.pathname)
const data = await readFile(file)
res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' })
res.end(data)
} catch {
res.writeHead(404)
res.end('nicht gefunden')
}
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const base = `http://127.0.0.1:${server.address().port}`
const VIEW = 'a'.repeat(32)
const customer = { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' }
const ME = {
neu: { authenticated: true, role: 'admin', viewAs: true, viewId: VIEW, viewExpiresAt: Date.now() + 3600000, admin: { name: 'Andrej', email: 'andrej@webklar.com', isAdmin: false }, customer },
alt: { authenticated: true, role: 'admin', viewAs: true, admin: { name: 'Kenso', email: 'kenso@webklar.com', isAdmin: true }, customer },
}
let scenario = 'neu'
let replaceAfterMe = false
const seen = { headers: [], ende: 0, chatRead: 0 }
async function mockApi(page) {
await page.route('**/api/**', async (route) => {
const req = route.request()
const p = new URL(req.url()).pathname
const json = (body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
if (p === '/api/auth/me') return json(ME[scenario])
// nur Skript-Anfragen zaehlen: Bilder (<img src="/api/avatar.svg">) koennen keinen Header tragen und sind reine Leseanfragen
if (['fetch', 'xhr'].includes(req.resourceType())) seen.headers.push(req.headers()['x-kundenansicht'] || null)
if (replaceAfterMe && p === '/api/projects') return json({ code: 'view_replaced', kunde: 'Kunde Zwei', error: 'ersetzt' }, 409)
if (p === '/api/auth/kundenansicht/ende') {
seen.ende += 1
return json({ redirect: `${base}/zurueck-im-ticketsystem.html` })
}
if (p === '/api/chat/read') seen.chatRead += 1
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Bäckerei Berg', status: 'live' }] })
if (p === '/api/contact') return json({ name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' })
if (p === '/api/chat/unread-count') return json({ unread: 0 })
if (p === '/api/chat/messages') return json({ messages: [], unread: 2 })
return json({})
})
}
let failed = 0
const check = (name, ok, detail = '') => {
console.log(`${ok ? 'OK ' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`)
if (!ok) failed += 1
}
const browser = await chromium.launch()
try {
for (const [label, viewport] of [['desktop', { width: 1280, height: 900 }], ['phone', { width: 390, height: 844 }]]) {
const ctx = await browser.newContext({ viewport, deviceScaleFactor: 1 })
const page = await ctx.newPage()
page.setDefaultTimeout(60000)
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
await mockApi(page)
const open = async (sc, hash = '') => {
scenario = sc
seen.headers.length = 0
await page.goto('about:blank')
await page.goto(`${base}/dashboard.html${hash}`)
await page.waitForFunction(() => document.getElementById('customer-name')?.textContent)
await page.waitForTimeout(400)
}
// 1) neue Kundenansicht: Banner, Kennung an den Anfragen, Chat nicht als gelesen markieren, Beenden
replaceAfterMe = false
seen.chatRead = 0
await open('neu', '#chat')
const banner = (await page.textContent('#viewas-banner')).replace(/\s+/g, ' ').trim()
check(`${label} banner_text`, banner === 'Kundenansicht: Berta Berg · geöffnet von Andrej Beenden', banner)
check(`${label} kennung_an_anfragen`, seen.headers.length > 0 && seen.headers.every((h) => h === VIEW), JSON.stringify(seen.headers))
check(`${label} chat_nicht_gelesen`, seen.chatRead === 0, `chat/read ${seen.chatRead}x`)
await page.screenshot({ path: `${SHOTS}/${label}-banner.png` })
await Promise.all([page.waitForURL(/zurueck-im-ticketsystem/), page.click('#exit-viewas-btn')])
check(`${label} beenden_ruft_ende_und_leitet_um`, seen.ende >= 1)
// 2) ersetzte Ansicht: Overlay statt Inhalt
replaceAfterMe = true
await open('neu')
await page.waitForSelector('#kb-view-overlay', { timeout: 15000 }).catch(() => null)
const overlay = await page.textContent('#kb-view-overlay').catch(() => '')
check(`${label} overlay_ersetzt`, /Diese Ansicht wurde durch die Kundenansicht von Kunde Zwei ersetzt\./.test(overlay || ''), overlay)
await page.screenshot({ path: `${SHOTS}/${label}-ersetzt.png` })
replaceAfterMe = false
// 3) alte Ansicht aus dem Admin-Panel: altes Banner, keine Kennung
await open('alt')
const old = (await page.textContent('#viewas-banner')).replace(/\s+/g, ' ').trim()
check(`${label} altes_banner`, old.includes('Admin-Ansicht') && old.includes('Zurück zur Admin-Übersicht'), old)
check(`${label} alt_ohne_kennung`, seen.headers.every((h) => h === null), JSON.stringify(seen.headers))
check(`${label} keine_js_fehler`, errors.length === 0, errors.join(' | '))
await ctx.close()
}
} finally {
await browser.close()
server.close()
}
console.log(failed ? `${failed} Check(s) fehlgeschlagen` : 'alle Checks ok')
process.exit(failed ? 1 : 0)