Files
swyxweb/frontend/src/swyx/protocol.test.ts
T
SvenandClaude Opus 5 01594a1f3d Adressdaten-Bereich, Mock hinter einem Profil, Webhook
Drei Stränge, die sich über dieselben Dateien ziehen (HomePage, index.css,
README, application.properties) und deshalb nicht getrennt committet werden
können, ohne einen nicht übersetzbaren Zwischenstand zu hinterlassen:

Adressdaten: Der Bereich zeigt den gesamten Bestand des globalen Telefon-
buchs - zuerst aus dem Adress-Cache der App (Protokoll 8), sonst über das
Kommando "addresses" und ersatzweise aus rund 110 Einzelabfragen hinter
einem Wartedialog. Der Mock gibt denselben Bestand heraus und lässt sich
über /api/mock/address-cache leeren, um die Rückfallebene zu prüfen.

Mock hinter dem Profil "mock": Seine Bohnen (/ws und /api/mock/**) hängen
jetzt an @Profile, sind ohne das Profil also nicht vorhanden. Damit kann
der Mock nicht versehentlich in einer Produktivumgebung mitlaufen; das
Container-Image setzt das Profil nicht. Je ein Test hält beide Richtungen
fest.

Webhook: POST /api/webhook nimmt beliebiges JSON eines fremden Systems an
und reicht es über Server-Sent Events an die offenen Browser weiter, wo es
der neue Bereich "Webhook" unverändert anzeigt. Der WebSocket kam dafür
nicht in Frage - er gehört der SwyxTray-App. Das Backend hält die letzten
50 Nachrichten vor und liefert sie beim Wiederverbinden anhand der
Last-Event-ID nach; ein Heartbeat und X-Accel-Buffering: no halten die
Verbindung durch Reverse Proxys hindurch offen. Ein Token (app.webhook.token)
ist vorgesehen, aber nicht voreingestellt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 15:11:55 +02:00

457 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest'
import {
callStateOf,
describeCall,
describeContact,
describeTab,
parseAddresses,
mergeSnapshot,
parseHello,
parseResult,
parseContacts,
parseSnapshot,
parseTabs,
type CallEvent,
} from './protocol'
/**
* Die Beispielnachrichten sind gegen die laufende SwyxTray-App 1.0.0.0
* mitgeschnitten (ws://192.168.180.135:17654/ws).
*/
const HELLO = '{"app":"SwyxTray","version":"1.0.0.0","protocol":1,"session":7,"type":"hello"}'
/** Wörtlich mitgeschnitten, während ein Anruf auf Leitung 1 einging. */
const RINGING_SNAPSHOT =
'{"connected":true,"serverUp":true,"overall":"Ringing","statusText":"Eingehender Ruf",' +
'"user":"Muster, M.","server":"127.0.0.1","lines":[' +
'{"line":1,"state":"LSRinging","stateCode":3,"stateText":"klingelt","peer":"001602107449",' +
'"peerNumber":"001602107449","peerName":"","busy":true,"selected":true},' +
'{"line":2,"state":"LSInactive","stateCode":0,"stateText":"frei","peer":"unbekannt",' +
'"peerNumber":"","peerName":"","busy":false,"selected":false},' +
'{"line":3,"state":"LSInactive","stateCode":0,"stateText":"frei","peer":"unbekannt",' +
'"peerNumber":"","peerName":"","busy":false,"selected":false},' +
'{"line":4,"state":"LSInactive","stateCode":0,"stateText":"frei","peer":"unbekannt",' +
'"peerNumber":"","peerName":"","busy":false,"selected":false}],"type":"snapshot"}'
/** Leitungseintrag im Snapshot; Vorgabe ist eine freie Leitung. */
function line(overrides: Record<string, unknown> = {}) {
return {
line: 1,
state: 'LSInactive',
stateCode: 0,
stateText: 'frei',
peer: 'unbekannt',
peerNumber: '',
peerName: '',
busy: false,
selected: false,
...overrides,
}
}
function snapshot(...entries: Record<string, unknown>[]) {
return {
connected: true,
serverUp: true,
overall: 'Idle',
statusText: 'Bereit',
user: 'Muster, M.',
server: '127.0.0.1',
lines: entries,
type: 'snapshot',
}
}
describe('parseHello', () => {
it('liest die Begrüßung der App', () => {
expect(parseHello(JSON.parse(HELLO))).toEqual({
app: 'SwyxTray',
version: '1.0.0.0',
protocol: 1,
session: 7,
})
})
it('hält andere Nachrichten für keine Begrüßung', () => {
expect(parseHello(snapshot())).toBeNull()
})
it('liest die Protokollversion der Tab-Verwaltung', () => {
expect(
parseHello(JSON.parse('{"app":"SwyxTray","version":"1.0.0.0","protocol":6,"session":1,"type":"hello"}'))
?.protocol,
).toBe(6)
})
it('liest die Protokollversion des Adress-Caches', () => {
// Wörtlich mitgeschnitten am 21.08.2026 gegen die laufende Anlage.
expect(
parseHello(JSON.parse('{"app":"SwyxTray","version":"1.0.0.0","protocol":8,"session":3,"type":"hello"}'))
?.protocol,
).toBe(8)
})
it('liest die Protokollversion der Adressdaten', () => {
// Wörtlich mitgeschnitten, nachdem die App `contacts` gelernt hatte.
expect(
parseHello(JSON.parse('{"app":"SwyxTray","version":"1.0.0.0","protocol":7,"session":3,"type":"hello"}'))
?.protocol,
).toBe(7)
})
})
describe('callStateOf', () => {
it('hält freie Leitungen für frei', () => {
expect(callStateOf('LSInactive', 'frei')).toBeNull()
expect(callStateOf('LSTerminate', 'beendet')).toBeNull()
})
it('erkennt einen eingehenden Anruf am Zustand', () => {
expect(callStateOf('LSRinging', 'klingelt')).toBe('incoming')
})
it('erkennt ihn auch nur am Klartext der App', () => {
expect(callStateOf('LSIrgendwas', 'klingelt')).toBe('incoming')
})
it('erkennt den Wählvorgang', () => {
expect(callStateOf('LSDialing', 'wählt')).toBe('outgoing')
expect(callStateOf('LSAlerting', 'Verbindungsaufbau')).toBe('outgoing')
})
it('hält alles Unbekannte für belegt statt für frei', () => {
// Sonst verschwände eine Leitung stillschweigend der Fehler, wegen dem
// eingehende Anrufe gar nicht angezeigt wurden.
expect(callStateOf('LSActive', 'verbunden')).toBe('connected')
expect(callStateOf('LSVölligNeu', 'was auch immer')).toBe('connected')
})
})
describe('parseSnapshot', () => {
it('liest den Zustand der App', () => {
expect(parseSnapshot(snapshot(line()))).toMatchObject({
connected: true,
serverUp: true,
overall: 'Idle',
statusText: 'Bereit',
user: 'Muster, M.',
server: '127.0.0.1',
})
})
it('verwirft freie Leitungen die App schickt immer alle vier', () => {
const parsed = parseSnapshot(
snapshot(line({ line: 1 }), line({ line: 2 }), line({ line: 3 }), line({ line: 4 })),
)
expect(parsed?.lines).toEqual([])
})
it('liest die klingelnde Leitung mit Gegenstelle', () => {
const parsed = parseSnapshot(
snapshot(
line({ line: 1 }),
line({
line: 2,
state: 'LSRinging',
stateCode: 4,
stateText: 'klingelt',
peer: 'Muster GmbH (+493012345)',
peerNumber: '+493012345',
peerName: 'Muster GmbH',
busy: true,
}),
),
)
expect(parsed?.lines).toEqual([
{
line: 2,
event: 'incoming',
direction: 'incoming',
peer: 'Muster GmbH (+493012345)',
peerNumber: '+493012345',
peerName: 'Muster GmbH',
stateText: 'klingelt',
state: 'LSRinging',
},
])
})
it('lässt den Platzhalter "unbekannt" weg', () => {
const parsed = parseSnapshot(snapshot(line({ state: 'LSActive', stateText: 'verbunden' })))
expect(parsed?.lines[0]).toMatchObject({ line: 1, event: 'connected', peer: undefined })
})
it('hält andere Nachrichten für keinen Snapshot', () => {
expect(parseSnapshot(JSON.parse(HELLO))).toBeNull()
expect(parseSnapshot('hallo')).toBeNull()
})
})
describe('echter eingehender Anruf', () => {
it('meldet den mitgeschnittenen Anruf als klingelnde Leitung', () => {
const parsed = parseSnapshot(JSON.parse(RINGING_SNAPSHOT))
expect(parsed?.statusText).toBe('Eingehender Ruf')
expect(parsed?.lines).toEqual([
{
line: 1,
event: 'incoming',
direction: 'incoming',
peer: '001602107449',
peerNumber: '001602107449',
peerName: undefined,
stateText: 'klingelt',
state: 'LSRinging',
},
])
// Aus dem Vergleich mit dem vorherigen Stand entsteht die Anrufmeldung.
const { events } = mergeSnapshot({}, parsed!.lines)
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({ line: 1, event: 'incoming' })
})
it('räumt die Leitung wieder ab, wenn der Anrufer auflegt', () => {
const ringing = parseSnapshot(JSON.parse(RINGING_SNAPSHOT))!
const { lines } = mergeSnapshot({}, ringing.lines)
const idle = parseSnapshot(snapshot(line({ line: 1 }), line({ line: 2 })))!
const { lines: next, events } = mergeSnapshot(lines, idle.lines)
expect(next).toEqual({})
expect(events[0]).toMatchObject({ line: 1, event: 'ended', peerNumber: '001602107449' })
})
})
describe('mergeSnapshot', () => {
const ringing: CallEvent = {
line: 2,
event: 'incoming',
direction: 'incoming',
peerNumber: '+493012345',
peerName: 'Muster GmbH',
}
it('meldet eine neu klingelnde Leitung', () => {
const { lines, events } = mergeSnapshot({}, [ringing])
expect(events).toEqual([ringing])
expect(lines[2]).toEqual(ringing)
})
it('meldet nichts, solange sich nichts ändert', () => {
const { lines } = mergeSnapshot({}, [ringing])
expect(mergeSnapshot(lines, [ringing]).events).toEqual([])
})
it('führt die Richtung über das Annehmen hinweg mit', () => {
const { lines } = mergeSnapshot({}, [ringing])
const connected = { ...ringing, event: 'connected' as const, direction: undefined }
const { events } = mergeSnapshot(lines, [connected])
expect(events).toEqual([{ ...connected, direction: 'incoming' }])
})
it('meldet das Ende, wenn die Leitung im Snapshot fehlt', () => {
const { lines } = mergeSnapshot({}, [ringing])
const { lines: next, events } = mergeSnapshot(lines, [])
expect(events).toEqual([{ ...ringing, event: 'ended', state: undefined, stateText: undefined }])
expect(next).toEqual({})
})
it('erkennt einen zweiten Anruf auf derselben Leitung', () => {
const { lines } = mergeSnapshot({}, [ringing])
const other = { ...ringing, peerNumber: '+499998888', peerName: 'Andere GmbH' }
expect(mergeSnapshot(lines, [other]).events).toEqual([other])
})
})
describe('parseResult', () => {
it('liest die Erfolgsquittung mit Leitungsnummer', () => {
expect(parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}'))).toEqual({
id: 11,
ok: true,
line: 4,
error: undefined,
})
})
it('liest die Fehlerquittung', () => {
expect(
parseResult(JSON.parse('{"id":8,"ok":false,"error":"Unbekanntes Kommando","type":"result"}')),
).toMatchObject({ id: 8, ok: false, error: 'Unbekanntes Kommando' })
})
it('liest die beiden Quittungen auf focus', () => {
// Fenster war da und ist jetzt vorn.
expect(parseResult(JSON.parse('{"type":"result","id":9,"ok":true,"focused":true}'))).toMatchObject(
{ id: 9, ok: true, focused: true },
)
// Kein Fenster gefunden die App hat die URL im Browser geöffnet.
expect(parseResult(JSON.parse('{"type":"result","id":9,"ok":true,"focused":false}'))).toMatchObject(
{ id: 9, ok: true, focused: false },
)
})
it('lässt focused bei allen anderen Quittungen offen', () => {
expect(parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}'))?.focused).toBeUndefined()
})
it('liest die Quittung auf tabs', () => {
const raw =
'{"type":"result","id":12,"ok":true,"tabs":[' +
'{"id":43,"title":"Kundenakte","url":"https://crm.example.local/kunden/4711","active":true},' +
'{"id":44,"title":"SwyxWeb","url":"http://localhost:5173/","active":false}]}'
expect(parseResult(JSON.parse(raw))?.tabs).toEqual([
{ id: 43, title: 'Kundenakte', url: 'https://crm.example.local/kunden/4711', active: true },
{ id: 44, title: 'SwyxWeb', url: 'http://localhost:5173/', active: false },
])
})
it('liest die Kennung aus der Quittung auf opentab', () => {
expect(parseResult(JSON.parse('{"type":"result","id":13,"ok":true,"tabId":43}'))).toMatchObject({
id: 13,
ok: true,
tabId: 43,
})
})
it('lässt tabs und tabId bei allen anderen Quittungen offen', () => {
const result = parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}'))
expect(result?.tabs).toBeUndefined()
expect(result?.tabId).toBeUndefined()
expect(result?.contacts).toBeUndefined()
})
it('liest die Quittung auf contacts', () => {
// Wörtlich mitgeschnitten Suchbegriff "abt".
const raw =
'{"id":20,"ok":true,"contacts":[' +
'{"name":"Abt, Bettina","number":"7587","description":"S-SB"},' +
'{"name":"Abdul, Rokhsareh","number":"5215","description":""}],"type":"result"}'
expect(parseResult(JSON.parse(raw))?.contacts).toEqual([
{ name: 'Abt, Bettina', number: '7587', description: 'S-SB' },
// Die leere Beschreibung der App wird zu undefined.
{ name: 'Abdul, Rokhsareh', number: '5215', description: undefined },
])
})
it('liest die leere Trefferliste als leere Liste, nicht als "nicht gefragt"', () => {
expect(parseResult(JSON.parse('{"id":21,"ok":true,"contacts":[],"type":"result"}'))?.contacts).toEqual(
[],
)
})
it('liest die Fehlerquittung auf einen leeren Suchbegriff', () => {
const raw =
'{"id":22,"ok":false,' +
'"error":"Feld \'query\' fehlt oder ist zu lang (max. 128 Zeichen).","type":"result"}'
expect(parseResult(JSON.parse(raw))).toMatchObject({
id: 22,
ok: false,
error: "Feld 'query' fehlt oder ist zu lang (max. 128 Zeichen).",
})
})
it('liest die Quittung auf addresses', () => {
const raw =
'{"type":"result","id":5,"ok":true,"addresses":[' +
'{"name":"Muster GmbH","number":"+493012345","description":"Globales Telefonbuch"}]}'
expect(parseResult(JSON.parse(raw))?.addresses).toEqual([
{ name: 'Muster GmbH', number: '+493012345', description: 'Globales Telefonbuch' },
])
})
it('liest die Fehlerquittung älterer App-Versionen auf addresses', () => {
// Genau das antwortet die Anlage am 21.08.2026 noch.
const raw = '{"id":5,"ok":false,"error":"Unbekanntes Kommando \'addresses\'.","type":"result"}'
expect(parseResult(JSON.parse(raw))).toMatchObject({
id: 5,
ok: false,
error: "Unbekanntes Kommando 'addresses'.",
})
})
it('hält einen Snapshot für keine Quittung', () => {
expect(parseResult(snapshot())).toBeNull()
})
})
describe('parseTabs', () => {
it('nimmt fehlende Felder hin nur die Kennung ist Pflicht', () => {
expect(parseTabs([{ id: 7 }])).toEqual([{ id: 7, title: undefined, url: undefined, active: false }])
})
it('verwirft Einträge ohne brauchbare Kennung', () => {
// Ohne Kennung ließe sich der Tab ohnehin nicht schließen.
expect(parseTabs([{ title: 'Ohne Kennung' }, 'Unfug', null, { id: 9 }])).toEqual([
{ id: 9, title: undefined, url: undefined, active: false },
])
})
})
describe('describeTab', () => {
it('nimmt den Titel, sonst die URL, sonst die Kennung', () => {
expect(describeTab({ id: 1, title: 'Kundenakte', url: 'https://x/', active: true })).toBe('Kundenakte')
expect(describeTab({ id: 2, url: 'https://x/', active: false })).toBe('https://x/')
expect(describeTab({ id: 3, active: false })).toBe('Tab 3')
})
})
describe('parseAddresses', () => {
it('liest die unaufgeforderte Cache-Nachricht', () => {
const raw =
'{"type":"addresses","addresses":[' +
'{"name":"Muster GmbH","number":"+493012345","description":"Globales Telefonbuch"}]}'
expect(parseAddresses(JSON.parse(raw))).toEqual([
{ name: 'Muster GmbH', number: '+493012345', description: 'Globales Telefonbuch' },
])
})
it('hält den leeren Cache für eine Antwort, nicht für ein Versehen', () => {
// Wörtlich mitgeschnitten: genau das schickt die Anlage zurzeit.
expect(parseAddresses(JSON.parse('{"addresses":[],"type":"addresses"}'))).toEqual([])
})
it('hält andere Nachrichten für keine Cache-Nachricht', () => {
expect(parseAddresses(JSON.parse(HELLO))).toBeNull()
expect(parseAddresses(snapshot())).toBeNull()
})
it('ist keine Quittung sie trägt weder id noch ok', () => {
expect(parseResult(JSON.parse('{"addresses":[],"type":"addresses"}'))).toBeNull()
})
})
describe('parseContacts', () => {
it('nimmt einen Eintrag ohne Namen hin die Rufnummer genügt', () => {
expect(parseContacts([{ number: '4711' }])).toEqual([
{ name: undefined, number: '4711', description: undefined },
])
})
it('verwirft Einträge ohne Namen und ohne Rufnummer', () => {
// Anzeigen ließe sich so ein Eintrag nicht, wählen erst recht nicht.
expect(parseContacts([{ description: 'HH-MT' }, 'Unfug', null, { name: 'Muster, Max' }])).toEqual([
{ name: 'Muster, Max', number: undefined, description: undefined },
])
})
it('hält die Reihenfolge der App ein sie sortiert bereits nach Namen', () => {
const entries = [{ name: 'Abt, Bettina', number: '7587' }, { name: 'Zentrale', number: '0' }]
expect(parseContacts(entries).map((c) => c.name)).toEqual(['Abt, Bettina', 'Zentrale'])
})
})
describe('describeContact', () => {
it('nimmt den Namen, sonst die Rufnummer', () => {
expect(describeContact({ name: 'Muster, Max', number: '4711' })).toBe('Muster, Max')
expect(describeContact({ number: '4711' })).toBe('4711')
expect(describeContact({})).toBe('Ohne Namen')
})
})
describe('describeCall', () => {
it('bevorzugt die fertige Anzeigeform der App', () => {
expect(describeCall({ line: 1, event: 'incoming', peer: 'Muster GmbH (+49301)' })).toBe(
'Muster GmbH (+49301)',
)
expect(describeCall({ line: 3, event: 'connected' })).toBe('Leitung 3')
})
})