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 — von // vor drei Minuten bis in vier Minuten — so ist das Fenster unabhängig von // der Uhrzeit des Testlaufs sicher aktiv, auch wenn der Test (Daemon-Start, // Race-Detector, langsame CI) über eine Minutengrenze läuft. (±1 Minute // war zu knapp: ein Lauf um hh:mm:59 kippte in die Inaktivität.) func fensterUm() string { now := time.Now() return now.Add(-3*time.Minute).Format("15:04") + "-" + now.Add(4*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) } }