From 5979d45710a3d958d072d15d29e516a4d347cdb7 Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Sun, 18 Oct 2026 17:40:51 +0200 Subject: [PATCH] =?UTF-8?q?test:=20Prozessgrenzen-Tests=20f=C3=BCr=20Rate-?= =?UTF-8?q?Limit=20und=20Ruhezeiten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kurzes Fenster (1 s): fünf Ansagen → genau eine spielt sofort, low verfällt beim Eintritt (202 + CLI 'discarded: rate_limit'), normal parkt (sichtbar in queue_ids/next_slot) und spielt frühestens zum Slot (Timing-am-Fake gemessen). High-Burst an/aus; now bricht durch Rate-Limit UND Ruhezeit; Ruhezeit-Fenster künstlich um die lokale Jetztzeit gelegt → discard (auch high) und toast (Protokoll-Zeile, kein Provider-Kontakt); /status- und talk-status-Felder im Default. Harness-Basis: TALK_RATE_LIMIT=0s — die Task-04-Semantik-Tests erwarten dichtes Durchspielen, Gesetz-Tests überschreiben bewusst. --- test/harness_test.go | 26 ++++ test/ratelimit_test.go | 331 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 357 insertions(+) create mode 100644 test/ratelimit_test.go diff --git a/test/harness_test.go b/test/harness_test.go index cc63f6f..a0c03cc 100644 --- a/test/harness_test.go +++ b/test/harness_test.go @@ -188,6 +188,25 @@ func (f *fakeProvider) waitArrived(t *testing.T, n int) { t.Fatalf("nur %d von %d Ankünften am Fake innerhalb 5 s", len(f.snapshot()), n) } +// arrivedCount zählt die angekommten Synthese-Requests (auch abgebrochene) +// — Beobachtungspunkt für „genau N spielen, der Rest parkt/verfällt". +func (f *fakeProvider) arrivedCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.arrived) +} + +// assertNoFurtherArrivals prüft, dass innerhalb der Frist kein weiterer +// Synthese-Request ankommt (geparkte/verworfene Ansagen bleiben stumm). +func (f *fakeProvider) assertNoFurtherArrivals(t *testing.T, wait time.Duration) { + t.Helper() + n := f.arrivedCount() + time.Sleep(wait) + if got := f.arrivedCount(); got != n { + t.Fatalf("%d zusätzliche Ankünfte (want keine) — Treffer: %+v", got-n, f.snapshot()) + } +} + // waitHits pollt, bis n Requests abgeschlossen wurden (Abbrüche zählen). func (f *fakeProvider) waitHits(t *testing.T, n int) []fakeHit { t.Helper() @@ -296,6 +315,12 @@ func (d *daemonProc) stop(t *testing.T) { // damit unter /talk), audio.sink=null und der Fake-Provider als // ElevenLabs-Endpunkt. port=1 heißt „garantiert toter Daemon" (Verbindung // wird sofort abgelehnt). +// +// TALK_RATE_LIMIT=0s ist der Basis-Default der Prozessgrenzen-Tests: Die +// Task-04-Semantik-Tests (Serialisierung, Priorität, …) enqueueen dicht +// hintereinander und erwarten sofortiges Durchspielen — das Gesetz +// (Default 30 s) würde sie parken. Die Gesetz-Tests in ratelimit_test.go +// überschreiben den Wert bewusst per extra. func daemonEnv(root string, port int, fakeURL string, extra map[string]string) []string { env := make([]string, 0, 32) for _, kv := range os.Environ() { @@ -312,6 +337,7 @@ func daemonEnv(root string, port int, fakeURL string, extra map[string]string) [ "TALK_ELEVENLABS_ENDPOINT": fakeURL, "TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", // Kette soll nie auf echte Fremd-Endpunkte fallen "TALK_PORT": strconv.Itoa(port), + "TALK_RATE_LIMIT": "0s", // Gesetz aus, bis ein Test es anstellt (siehe oben) } for k, v := range extra { set[k] = v diff --git a/test/ratelimit_test.go b/test/ratelimit_test.go new file mode 100644 index 0000000..68f6c4d --- /dev/null +++ b/test/ratelimit_test.go @@ -0,0 +1,331 @@ +package test + +// Prozessgrenzen-Tests für das Anti-Spam-Gesetz (Task 05): Rate-Limit +// (low verworfen, normal geparkt, high-burst, now-Ausbruch) und Ruhezeiten +// (discard/toast) — beobachtet als Black Box über HTTP/CLI, Fake-Provider +// und Daemon-Protokoll. Der Harness stellt dafür TALK_RATE_LIMIT=0s als +// Basis; diese Tests stellen das Gesetz bewusst per extra an. + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + "time" +) + +// fensterUm legt ein Ruhezeit-Fenster um die aktuelle lokale Zeit (±1 Min, +// Minute-Auflösung reicht) — so ist das Fenster unabhängig von der Uhrzeit +// des Testlaufs sicher aktiv. +func fensterUm() string { + now := time.Now() + return now.Add(-time.Minute).Format("15:04") + "-" + now.Add(time.Minute).Format("15:04") +} + +// lawStatus ist die Gesetz-Sicht aus GET /status. +type lawStatus struct { + RateLimit struct { + Interval string `json:"interval"` + AllowHighBurst bool `json:"allow_high_burst"` + NextSlot *string `json:"next_slot"` + } `json:"rate_limit"` + QuietHours struct { + Window string `json:"window"` + Active bool `json:"active"` + Mode string `json:"mode"` + Until *string `json:"until"` + } `json:"quiet_hours"` +} + +// getLawStatus ruft GET /status und dekodiert nur die Gesetz-Felder. +func getLawStatus(t *testing.T, d *daemonProc) lawStatus { + t.Helper() + resp, err := http.Get(d.baseURL + "/status") + if err != nil { + t.Fatalf("GET /status: %v — stderr:\n%s", err, d.stderr.String()) + } + defer resp.Body.Close() + var st lawStatus + if err := json.NewDecoder(resp.Body).Decode(&st); err != nil { + t.Fatalf("GET /status dekodieren: %v", err) + } + return st +} + +// discardedOf liest das discarded-Feld einer /speak-Antwort-Map — fehlt der +// Schlüssel (JSON-Decode in map[string]any liefert nil), ist er leer. +func discardedOf(m map[string]any) string { + if v, ok := m["discarded"].(string); ok { + return v + } + return "" +} + +// TestRateLimitGesetz (Abnahmekriterium): kurzes Rate-Limit (1/s) — von +// fünf Ansagen spielt genau die erste sofort; low verfällt noch beim +// Eintritt (202 nennt den Grund), normal parkt und spielt nach dem Fenster. +func TestRateLimitGesetz(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 100*time.Millisecond) + d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "1s"}) + env := daemonEnv(root, d.port, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "1s"}) + + code, erste := speak(t, d, `{"text":"erste ansage"}`) + if code != 202 || erste["id"] == "" { + t.Fatalf("erste: Status=%d Body=%v, want 202 mit id", code, erste) + } + fake.waitArrived(t, 1) // erste läuft — das Fenster (1 s) ist gebucht. + + // CLI-Beobachtung: talk say --priority low meldet den Verwurf plain. + res := runTalk(t, env, "say", "--priority", "low", "spam eins") + if res.code != 0 { + t.Fatalf("talk say --priority low: Exit %d, stderr: %s", res.code, res.stderr) + } + if res.stdout != "discarded: rate_limit\n" { + t.Errorf("stdout = %q, want %q", res.stdout, "discarded: rate_limit\n") + } + + // Zwei weitere low-Ansagen verfallen ebenso beim Eintritt. + for _, text := range []string{"spam zwei", "spam drei"} { + code, m := speak(t, d, `{"text":"`+text+`","priority":"low"}`) + if code != 202 || m["id"] != "" || m["discarded"] != "rate_limit" { + t.Fatalf("low %s: Status=%d Body=%v, want 202 mit leerer id und discarded=rate_limit", text, code, m) + } + } + + // Normal wird aufgenommen … + code, geparkte := speak(t, d, `{"text":"geparkte ansage"}`) + if code != 202 || geparkte["id"] == "" || discardedOf(geparkte) != "" { + t.Fatalf("normal: Status=%d Body=%v, want 202 mit id", code, geparkte) + } + + // … und sichtbar geparkt: Queue zeigt sie, der nächste Slot liegt in + // der Zukunft. + st := getStatus(t, d) + if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != geparkte["id"] { + t.Fatalf("queue = %v (%d), will die geparkte Ansage", st.QueueIDs, st.Queue) + } + law := getLawStatus(t, d) + if law.RateLimit.Interval != "1s" || !law.RateLimit.AllowHighBurst { + t.Errorf("rate_limit = %+v, want Interval 1s mit Burst-Recht", law.RateLimit) + } + if law.RateLimit.NextSlot == nil { + t.Errorf("next_slot = null während geparkter Ansage, want Termin") + } + + // Genau eine ist bislang angekommen: die geparkte spielt NICHT vor + // dem Fenster-Ablauf (Abstand zum Fenster-Rand reichlich). + fake.assertNoFurtherArrivals(t, 400*time.Millisecond) + + // Nach dem Fenster spielt die geparkte — und zwar frühestens zum Slot. + hits := fake.waitCompleted(t, 2) + if hits[1].Text != "geparkte ansage" { + t.Errorf("zweiter Treffer = %q, will die geparkte Ansage", hits[1].Text) + } + if gap := hits[1].Start.Sub(hits[0].Start); gap < 850*time.Millisecond { + t.Errorf("zweiter Start nach %v, want >= Fenster (1 s, Toleranz 150 ms)", gap) + } + waitStatusIdle(t, d, 3*time.Second) +} + +// TestHighBurstProzess: mit Burst-Recht (Default) startet high trotz +// vollem Fensters sofort nach der laufenden Ansage; ohne Recht parkt high +// wie normal. (Das spätere Abspielen nach langem Fenster deckt der +// Park-Test oben mit kurzem Fenster.) +func TestHighBurstProzess(t *testing.T) { + t.Run("burst an (Default)", func(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 50*time.Millisecond) + d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "8s"}) + + speak(t, d, `{"text":"erste"}`) + fake.waitArrived(t, 1) // Fenster für 8 s gebucht. + + code, m := speak(t, d, `{"text":"dringend","priority":"high"}`) + if code != 202 || m["id"] == "" { + t.Fatalf("high: Status=%d Body=%v, want 202 mit id", code, m) + } + + // high startet, sobald die erste fertig ist — bewusst innerhalb + // des Fensters (Verstoß mit Recht). + hits := fake.waitCompleted(t, 2) + if hits[1].Text != "dringend" { + t.Errorf("zweiter Treffer = %q, will dringend", hits[1].Text) + } + if gap := hits[1].Start.Sub(hits[0].Start); gap >= 4*time.Second { + t.Errorf("high startete nach %v — Burst greift nicht (Fenster 8 s)", gap) + } + waitStatusIdle(t, d, 3*time.Second) + }) + + t.Run("burst aus", func(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 0) + d := startDaemon(t, root, fake.srv.URL, map[string]string{ + "TALK_RATE_LIMIT": "8s", + "TALK_ALLOW_HIGH_BURST": "false", + }) + + speak(t, d, `{"text":"erste"}`) + fake.waitArrived(t, 1) + + code, m := speak(t, d, `{"text":"dringend","priority":"high"}`) + if code != 202 || m["id"] == "" { + t.Fatalf("high: Status=%d Body=%v, want 202 mit id (parkt)", code, m) + } + + // high bleibt geparkt: keine zweite Ankunft, Queue zeigt es. + fake.assertNoFurtherArrivals(t, 700*time.Millisecond) + st := getStatus(t, d) + if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != m["id"] { + t.Fatalf("queue = %v (%d), will geparktes high", st.QueueIDs, st.Queue) + } + if law := getLawStatus(t, d); law.RateLimit.AllowHighBurst { + t.Error("allow_high_burst = true, want false") + } + + // Aufräumen über cancel — und der Daemon bleibt gesund (now + // umgeht das weiter laufende 8-s-Fenster). + code, cm := postPath(t, d, "/cancel") + if code != 200 || cm["cancelled"] != float64(1) { + t.Fatalf("cancel: %d %v, want 1 geparkte Ansage", code, cm) + } + speak(t, d, `{"text":"danach","now":true}`) + hits := fake.waitCompleted(t, 2) + if hits[1].Text != "danach" { + t.Errorf("Treffer nach cancel = %q, want danach", hits[1].Text) + } + }) +} + +// TestNowBypassesRateUndRuhe: now ist der dokumentierte Ausbruch — es +// spielt trotz vollem Rate-Fensters UND aktiver Ruhezeit. +func TestNowBypassesRateUndRuhe(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 0) + d := startDaemon(t, root, fake.srv.URL, map[string]string{ + "TALK_RATE_LIMIT": "30s", + "TALK_QUIET_HOURS": fensterUm(), + }) + + // Normal verfällt in der Ruhezeit (hier: still). + code, m := speak(t, d, `{"text":"ruhig bitte"}`) + if code != 202 || m["id"] != "" || m["discarded"] != "quiet_hours" { + t.Fatalf("normal in Ruhezeit: Status=%d Body=%v, want 202 mit leerer id und discarded=quiet_hours", code, m) + } + + // now bricht durch: Aufnahme mit ID, Synthese trotz beider Gesetze. + code, m = speak(t, d, `{"text":"notfall","now":true}`) + if code != 202 || m["id"] == "" || discardedOf(m) != "" { + t.Fatalf("now: Status=%d Body=%v, want 202 mit id", code, m) + } + hits := fake.waitCompleted(t, 1) + if hits[0].Text != "notfall" { + t.Errorf("Treffer = %q, will notfall", hits[0].Text) + } + fake.assertNoFurtherArrivals(t, 300*time.Millisecond) + + if law := getLawStatus(t, d); !law.QuietHours.Active { + t.Errorf("quiet_hours.active = false, want true (Fenster um jetzt): %+v", law.QuietHours) + } +} + +// TestRuhezeitDiscard: aktives Fenster verwirft normal UND high still +// (Burst ist ein Rate-Recht, kein Ruhe-Recht) — ohne Provider-Kontakt, +// mit Verwurf-Zeile im Daemon-Protokoll und Fenster-Ende in /status. +func TestRuhezeitDiscard(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 0) + fenster := fensterUm() + d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_QUIET_HOURS": fenster}) + + for _, body := range []string{ + `{"text":"abends eins"}`, + `{"text":"abends dringend","priority":"high"}`, + } { + code, m := speak(t, d, body) + if code != 202 || m["id"] != "" || m["discarded"] != "quiet_hours" || m["toast"] != nil { + t.Fatalf("%s: Status=%d Body=%v, want 202 mit leerer id und discarded=quiet_hours", body, code, m) + } + } + fake.assertNoHits(t, 400*time.Millisecond) + + log := d.stderr.String() + if !strings.Contains(log, "ruhezeit") || !strings.Contains(log, "verworfen") { + t.Errorf("Daemon-Log will Verwurf-Zeile mit ruhezeit, hat:\n%s", log) + } + + law := getLawStatus(t, d) + if law.QuietHours.Window != fenster || !law.QuietHours.Active || law.QuietHours.Mode != "discard" { + t.Errorf("quiet_hours = %+v, want Fenster %q aktiv im Modus discard", law.QuietHours, fenster) + } + if law.QuietHours.Until == nil { + t.Error("quiet_hours.until = null im aktiven Fenster, want Termin") + } +} + +// TestRuhezeitToast: quiet_mode=toast ersetzt Audio durch eine +// Benachrichtigung — derzeit eine Daemon-Protokoll-Zeile über den +// Notification-Pfad (dokumentierte Einschränkung bis zur OS-Anbindung). +func TestRuhezeitToast(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 0) + d := startDaemon(t, root, fake.srv.URL, map[string]string{ + "TALK_QUIET_HOURS": fensterUm(), + "TALK_QUIET_MODE": "toast", + }) + + code, m := speak(t, d, `{"text":"abend kurz"}`) + if code != 202 || m["id"] != "" || m["discarded"] != "quiet_hours" || m["toast"] != true { + t.Fatalf("toast: Status=%d Body=%v, want 202 mit leerer id, discarded=quiet_hours und toast=true", code, m) + } + fake.assertNoHits(t, 300*time.Millisecond) + + if log := d.stderr.String(); !strings.Contains(log, "toast") { + t.Errorf("Daemon-Log will Toast-Zeile (Notification-Pfad), hat:\n%s", log) + } +} + +// TestStatusGesetzeFelder (Abnahmekriterium): /status und `talk status` +// zeigen Rate-Limit- und Ruhezeit-State — Defaults im Leerlauf, gebuchter +// Slot nach einer Ansage. +func TestStatusGesetzeFelder(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 0) + d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "30s"}) + env := daemonEnv(root, d.port, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "30s"}) + + law := getLawStatus(t, d) + if law.RateLimit.Interval != "30s" || !law.RateLimit.AllowHighBurst || law.RateLimit.NextSlot != nil { + t.Errorf("rate_limit = %+v, want Default 30s, Burst an, Slot frei", law.RateLimit) + } + if law.QuietHours.Window != "" || law.QuietHours.Active || law.QuietHours.Mode != "discard" || law.QuietHours.Until != nil { + t.Errorf("quiet_hours = %+v, want deaktiviert im Modus discard", law.QuietHours) + } + + res := runTalk(t, env, "status") + if res.code != 0 { + t.Fatalf("talk status: Exit %d — stderr: %s", res.code, res.stderr) + } + for _, want := range []string{ + "rate-limit: 30s", + "high-burst erlaubt", + "ruhezeit: aus", + "modus discard", + } { + if !strings.Contains(res.stdout, want) { + t.Errorf("talk status stdout = %q, will %q", res.stdout, want) + } + } + + // Nach einer Ansage steht der nächste Slot: HTTP-Feld + CLI-Zeile. + speak(t, d, `{"text":"einmal reicht"}`) + fake.waitArrived(t, 1) + if law := getLawStatus(t, d); law.RateLimit.NextSlot == nil { + t.Error("next_slot = null nach abgespielter Ansage, want Termin") + } + res = runTalk(t, env, "status") + if res.code != 0 || !strings.Contains(res.stdout, "nächster slot") { + t.Errorf("talk status nach Ansage = %q (Exit %d), will Slot-Zeile", res.stdout, res.code) + } +}