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>
457 lines
16 KiB
TypeScript
457 lines
16 KiB
TypeScript
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')
|
||
})
|
||
})
|