Files
talk/internal/provider/edge_test.go
T

321 lines
11 KiB
Go
Raw Normal View History

package provider
import (
"context"
"encoding/hex"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
)
// TestEdgeAvailable: Edge braucht nur einen Endpunkt, keinen Key — der
// Konstruktor bäkt den Default-Endpunkt ein, daher ist ein über NewEdge
// gebauter Edge immer verfügbar; nur der Nullwert (direkt gebaut, ohne
// Endpunkt) ist es nicht.
func TestEdgeAvailable(t *testing.T) {
if (&Edge{}).Available(context.Background()) {
t.Error("Available ohne Endpunkt (Nullwert) = true, want false")
}
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "wss://edge.example.com",
})})
if !p.Available(context.Background()) {
t.Error("Available mit Endpunkt = false, want true")
}
if !NewEdge(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) {
t.Error("Available mit Default-Endpunkt = false, want true (edge ist der Key-freie Fallback)")
}
}
// TestEdgeVerbindungFehlschlaegt (Fehlerpfad-Mock): ein garantiert toter
// Loopback-Port liefert einen klaren Fehler — sofort, ohne Netz.
func TestEdgeVerbindungFehlschlaegt(t *testing.T) {
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "ws://127.0.0.1:1/",
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
if err == nil {
t.Fatal("Synthesize gegen toten Port: kein Fehler")
}
if !strings.Contains(err.Error(), "edge") || !strings.Contains(err.Error(), "WebSocket") {
t.Errorf("Fehler = %v, will klaren edge/WebSocket-Fehler", err)
}
}
// TestEdgeUngueltigesSchema: ein Nicht-w(s)-Endpunkt ist ein klarer Fehler.
func TestEdgeUngueltigesSchema(t *testing.T) {
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "http://127.0.0.1:1234/",
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
if err == nil || !strings.Contains(err.Error(), "ws://") {
t.Errorf("Fehler = %v, will Schema-Hinweis ws://", err)
}
}
// TestEdgeSSML: die SSML-Anfrage enthält Stimme und den von Steuerzeichen
// befreiten, XML-escapten Text; xml:lang ist wie in der Referenz fest
// „en-US".
func TestEdgeSSML(t *testing.T) {
got := edgeSSML("de-DE-KatjaNeural", "AT&T <Kunde> „fertig“")
for _, want := range []string{
"voice name='de-DE-KatjaNeural'",
"xml:lang='en-US'",
"AT&amp;T &lt;Kunde&gt;",
} {
if !strings.Contains(got, want) {
t.Errorf("SSML = %q, will %q", got, want)
}
}
if strings.Contains(got, "AT&T") {
t.Errorf("SSML enthält unescaptes &: %q", got)
}
}
// TestEdgeEscape: Steuerzeichen (u. a. Vertical Tab aus OCR-Texten) werden
// durch Leerzeichen ersetzt, & < > escapt.
func TestEdgeEscape(t *testing.T) {
in := "a\x0bb&c<d>\x08e"
if want := "a b&amp;c&lt;d&gt; e"; edgeEscape(in) != want {
t.Errorf("edgeEscape(%q) = %q, want %q", in, edgeEscape(in), want)
}
}
// TestEdgeSpeechConfigMsg: Output-Format (mono-mp3) und Pfad stimmen.
func TestEdgeSpeechConfigMsg(t *testing.T) {
msg := edgeSpeechConfigMsg(time.Unix(0, 0).UTC())
if !strings.Contains(msg, "Path:speech.config") {
t.Errorf("speech.config-Nachricht ohne Path: %q", msg)
}
if !strings.Contains(msg, edgeOutputFormat) {
t.Errorf("speech.config-Nachricht ohne Ausgabeformat %q: %q", edgeOutputFormat, msg)
}
}
// TestEdgeRequestURL: Query-Parameter (Token, Sec-MS-GEC, Version) werden
// ergänzt; der Token landet nicht in der URL-Debug-Ausgabe.
func TestEdgeRequestURL(t *testing.T) {
p := &Edge{Endpoint: "wss://edge.example.com/path"}
raw, err := p.requestURL(time.Unix(1700000000, 0).UTC())
if err != nil {
t.Fatalf("requestURL: %v", err)
}
for _, want := range []string{
"TrustedClientToken=" + edgeTrustedClientToken,
"Sec-MS-GEC=",
"Sec-MS-GEC-Version=" + edgeSecMSGECVersion,
} {
if !strings.Contains(raw, want) {
t.Errorf("requestURL = %q, will %q", raw, want)
}
}
if got := redactURL(raw); strings.Contains(got, edgeTrustedClientToken) {
t.Errorf("redactURL leakt den Token: %q", got)
}
}
// TestEdgeSecMSGEC: Format „<16 Hex>-<64 Hex>" (groß), 5-Minuten-Raster
// (innerhalb des Fensters stabil, über die Grenze anders) und exakte
// Referenzvektoren der edge-tts-DRM-Implementierung (v2).
func TestEdgeSecMSGEC(t *testing.T) {
// Referenzvektoren: (Unix-Sekunden, erwarteter Token) — erzeugt mit der
// Python-Referenz (edge-tts src/edge_tts/drm.py, Stand 2026-10).
vectors := []struct {
ts int64
want string
}{
{1700000400, "73C7C85FE76CF347-452672184D494A9F80E179C2FBFEE2E98480C4D4AAFC1F4A577E43B023BEEEC9"},
{1700000599, "73C7C85FE76CF347-452672184D494A9F80E179C2FBFEE2E98480C4D4AAFC1F4A577E43B023BEEEC9"},
{1765800000, "B9B5447115B4FD1A-6BB36279F5B3D1B53F84F786AF05C81A86909ABE30E695496BBE88FFAE2E3E83"},
{0, "52BC7A17B50DFEC9-28C4394775C8751CD2F6859C030C06DB1489ACAB3B3A7651831FC26895177ED6"},
}
for _, v := range vectors {
if got := edgeSecMSGEC(time.Unix(v.ts, 0).UTC()); got != v.want {
t.Errorf("edgeSecMSGEC(%d) =\n %s\nwant\n %s", v.ts, got, v.want)
}
}
// Fenster-Stabilität (Basis am Fensterbeginn, mod 300 == 0).
base := time.Unix(1700000400, 0).UTC()
token := edgeSecMSGEC(base)
if edgeSecMSGEC(base.Add(4*time.Minute)) != token {
t.Error("Sec-MS-GEC weicht innerhalb des 5-Minuten-Fensters ab")
}
if edgeSecMSGEC(base.Add(6*time.Minute)) == token {
t.Error("Sec-MS-GEC ändert sich über die Fenster-Grenze nicht")
}
if len(token) != 16+1+64 || !strings.Contains(token, "-") {
t.Errorf("Token-Format = %q, want <16 Hex>-<64 Hex>", token)
}
}
// TestEdgeRequestID: 32 Hex-Zeichen, eindeutig.
func TestEdgeRequestID(t *testing.T) {
a, b := edgeRequestID(), edgeRequestID()
if len(a) != 32 {
t.Errorf("RequestID-Länge = %d, want 32", len(a))
}
if _, err := hex.DecodeString(a); err != nil {
t.Errorf("RequestID ist kein Hex: %v", err)
}
if a == b {
t.Error("RequestID nicht eindeutig")
}
}
// TestEdgeSplitBinary: Binär-Rahmen: 2 Bytes big-endian Header-Länge, dann
// Header (Path:audio), dann Payload.
func TestEdgeSplitBinary(t *testing.T) {
msg := []byte{0, 12, 'P', 'a', 't', 'h', ':', 'a', 'u', 'd', 'i', 'o', '\r', '\n', 1, 2, 3}
header, payload, ok := splitEdgeBinary(msg)
if !ok || header != "Path:audio\r\n" || string(payload) != "\x01\x02\x03" {
t.Errorf("splitEdgeBinary = %q, %x, %v", header, payload, ok)
}
if _, _, ok := splitEdgeBinary([]byte{0, 99, 'x'}); ok {
t.Error("übergroßer Header-Längenangabe soll abgelehnt werden")
}
}
// fakeEdgeServer ist ein Loopback-Fake des Edge-WSS-Protokolls (kein
// echter Netz-Call — alles bleibt auf 127.0.0.1). Er prüft die beiden
// Client-Nachrichten und antwortet nach Protokoll: Audio-Binär-Rahmen mit
// Path:audio, danach turn.end. audio ist die zu liefernde Payload;
// closing schließt die Verbindung vor turn.end (Fehlerpfad).
func fakeEdgeServer(t *testing.T, audio []byte, closing bool) (*httptest.Server, *fakeEdgeSeen) {
t.Helper()
seen := &fakeEdgeSeen{}
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("TrustedClientToken"); got != edgeTrustedClientToken {
t.Errorf("TrustedClientToken = %q", got)
}
if r.Header.Get("Origin") != edgeOrigin {
t.Errorf("Origin = %q", r.Header.Get("Origin"))
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Errorf("Upgrade: %v", err)
return
}
defer conn.Close()
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
return
}
if _, cfg, err := conn.ReadMessage(); err != nil {
t.Errorf("speech.config lesen: %v", err)
} else {
seen.mu.Lock()
seen.config = string(cfg)
seen.mu.Unlock()
}
if _, req, err := conn.ReadMessage(); err != nil {
t.Errorf("ssml lesen: %v", err)
} else {
seen.mu.Lock()
seen.ssml = string(req)
seen.mu.Unlock()
}
if closing {
return // Verbindung dichtmachen, ohne zu antworten
}
header := "X-RequestId:dummy\r\nPath:audio\r\n"
frame := make([]byte, 0, 2+len(header)+len(audio))
frame = append(frame, byte(len(header)>>8), byte(len(header)))
frame = append(frame, header...)
frame = append(frame, audio...)
if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
return
}
if err := conn.WriteMessage(websocket.TextMessage, []byte("X-RequestId:dummy\r\nPath:turn.end\r\n\r\n")); err != nil {
return
}
}))
t.Cleanup(ts.Close)
return ts, seen
}
type fakeEdgeSeen struct {
mu sync.Mutex
config string
ssml string
}
func (s *fakeEdgeSeen) snapshot() (configMsg, ssml string) {
s.mu.Lock()
defer s.mu.Unlock()
return s.config, s.ssml
}
// TestEdgeLoopbackFakeServer: kompletter Protokoll-Durchlauf gegen den
// Loopback-Fake (fake-bar getestet: Handshake-Parameter, beide
// Client-Nachrichten, Binär-Rahmen-Parsing, turn.end-Ende) — siehe
// Task-03-Report für die Abgrenzung zum echten Endpoint.
func TestEdgeLoopbackFakeServer(t *testing.T) {
wantAudio := []byte("\xff\xfb\x90\x00" + "edge-audio")
ts, seen := fakeEdgeServer(t, wantAudio, false)
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "ws://" + ts.Listener.Addr().String(),
})})
data, err := p.Synthesize(context.Background(), "Hallo & fertig", "de")
if err != nil {
t.Fatalf("Synthesize gegen Loopback-Fake: %v", err)
}
if string(data) != string(wantAudio) {
t.Errorf("Synthesize = %x, want %x", data, wantAudio)
}
configMsg, ssml := seen.snapshot()
if !strings.Contains(configMsg, "Path:speech.config") {
t.Errorf("speech.config = %q", configMsg)
}
if !strings.Contains(ssml, "Path:ssml") {
t.Errorf("ssml-Anfrage ohne Path:ssml: %q", ssml)
}
if !strings.Contains(ssml, "voice name='de-DE-KatjaNeural'") {
t.Errorf("ssml ohne Default-Stimme de: %q", ssml)
}
if !strings.Contains(ssml, "Hallo &amp; fertig") {
t.Errorf("ssml ohne escapten Text: %q", ssml)
}
}
// TestEdgeLoopbackVerbindungsAbbruch: Server schließt nach den Anfragen
// ohne Audio → klarer „Protokoll geändert?"-Fehler.
func TestEdgeLoopbackVerbindungsAbbruch(t *testing.T) {
ts, _ := fakeEdgeServer(t, nil, true)
p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{
EnvEdgeEndpoint: "ws://" + ts.Listener.Addr().String(),
})})
_, err := p.Synthesize(context.Background(), "Hallo", "de")
if err == nil || !strings.Contains(err.Error(), "Protokoll") {
t.Errorf("Fehler = %v, will Protokoll-Änderungs-Hinweis", err)
}
}
// TestEdgeIntegrationEcht ist die als testing.Short überspringbare
// Integrations-Markierung gegen den ECHTEN Microsoft-Endpunkt (kostenlos,
// aber echtes Netz — Standard-Testläufe und -short überspringen; einmalige
// manuelle Abnahme mit TALK_TEST_REAL_EDGE=1).
func TestEdgeIntegrationEcht(t *testing.T) {
if testing.Short() {
t.Skip("Übersprungen (-short)")
}
if os.Getenv("TALK_TEST_REAL_EDGE") != "1" {
t.Skip("Übersprungen (echtes Netz) — mit TALK_TEST_REAL_EDGE=1 ausführen")
}
p := NewEdge(ChainConfig{})
data, err := p.Synthesize(context.Background(), "Kurzer Sprachtest über Edge.", "de")
if err != nil {
t.Fatalf("Synthesize gegen echten Endpunkt: %v", err)
}
if len(data) == 0 {
t.Fatal("Synthesize gegen echten Endpunkt: 0 Bytes")
}
t.Logf("Edge-Integration: %d Bytes Audio", len(data))
}