Files
talk/test/ratelimit_test.go
m3ta-chironandClaude Fable 5.1 85fa64cfe6 refactor: switch the whole project to English
talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.

Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.

Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 11:15:35 +02:00

335 lines
13 KiB
Go

package test
// Process-boundary tests for the anti-spam law (Task 05): rate limit (low
// discarded, normal parked, high burst, now breakout) and quiet hours
// (discard/toast) — observed as a black box via HTTP/CLI, fake provider and
// daemon log. The harness provides TALK_RATE_LIMIT=0s as the base; these
// tests deliberately turn the law on via extra.
import (
"encoding/json"
"net/http"
"strings"
"testing"
"time"
)
// windowAround places a quiet-hours window around the current local time —
// from three minutes ago until four minutes from now — so the window is
// safely active regardless of the time of the test run, even when the test
// (daemon start, race detector, slow CI) crosses a minute boundary. (±1
// minute was too tight: a run at hh:mm:59 tipped into inactivity.)
func windowAround() string {
now := time.Now()
return now.Add(-3*time.Minute).Format("15:04") + "-" + now.Add(4*time.Minute).Format("15:04")
}
// lawStatus is the law view from 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 calls GET /status and decodes only the law fields.
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("decoding GET /status: %v", err)
}
return st
}
// discardedOf reads the discarded field of a /speak response map — when the
// key is missing (JSON decode into map[string]any yields nil) it is empty.
func discardedOf(m map[string]any) string {
if v, ok := m["discarded"].(string); ok {
return v
}
return ""
}
// TestRateLimitLaw (acceptance criterion): short rate limit (1/s) — of five
// announcements exactly the first plays immediately; low expires right on
// admission (202 names the reason), normal parks and plays after the window.
func TestRateLimitLaw(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, first := speak(t, d, `{"text":"first announcement"}`)
if code != 202 || first["id"] == "" {
t.Fatalf("first: status=%d body=%v, want 202 with id", code, first)
}
fake.waitArrived(t, 1) // first is running — the window (1 s) is booked.
// CLI observation: talk say --priority low reports the discard plainly.
res := runTalk(t, env, "say", "--priority", "low", "spam one")
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")
}
// Two more low announcements expire on admission as well.
for _, text := range []string{"spam two", "spam three"} {
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 with empty id and discarded=rate_limit", text, code, m)
}
}
// Normal is admitted …
code, parked := speak(t, d, `{"text":"parked announcement"}`)
if code != 202 || parked["id"] == "" || discardedOf(parked) != "" {
t.Fatalf("normal: status=%d body=%v, want 202 with id", code, parked)
}
// … and visibly parked: the queue shows it, the next slot lies in the
// future.
st := getStatus(t, d)
if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != parked["id"] {
t.Fatalf("queue = %v (%d), want the parked announcement", st.QueueIDs, st.Queue)
}
law := getLawStatus(t, d)
if law.RateLimit.Interval != "1s" || !law.RateLimit.AllowHighBurst {
t.Errorf("rate_limit = %+v, want interval 1s with burst rights", law.RateLimit)
}
if law.RateLimit.NextSlot == nil {
t.Errorf("next_slot = null while an announcement is parked, want a time")
}
// Exactly one has arrived so far: the parked one does NOT play before
// the window expires (ample distance to the window edge).
fake.assertNoFurtherArrivals(t, 400*time.Millisecond)
// After the window the parked one plays — at the slot at the earliest.
hits := fake.waitCompleted(t, 2)
if hits[1].Text != "parked announcement" {
t.Errorf("second hit = %q, want the parked announcement", hits[1].Text)
}
if gap := hits[1].Start.Sub(hits[0].Start); gap < 850*time.Millisecond {
t.Errorf("second start after %v, want >= window (1 s, tolerance 150 ms)", gap)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestHighBurstProcess: with burst rights (default) high starts immediately
// after the running announcement despite a full window; without rights high
// parks like normal. (The later playback after a long window is covered by
// the parking test above with a short window.)
func TestHighBurstProcess(t *testing.T) {
t.Run("burst on (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":"first"}`)
fake.waitArrived(t, 1) // window booked for 8 s.
code, m := speak(t, d, `{"text":"urgent","priority":"high"}`)
if code != 202 || m["id"] == "" {
t.Fatalf("high: status=%d body=%v, want 202 with id", code, m)
}
// high starts as soon as the first is done — deliberately inside
// the window (violation with rights).
hits := fake.waitCompleted(t, 2)
if hits[1].Text != "urgent" {
t.Errorf("second hit = %q, want urgent", hits[1].Text)
}
if gap := hits[1].Start.Sub(hits[0].Start); gap >= 4*time.Second {
t.Errorf("high started after %v — burst does not take effect (window 8 s)", gap)
}
waitStatusIdle(t, d, 3*time.Second)
})
t.Run("burst off", 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":"first"}`)
fake.waitArrived(t, 1)
code, m := speak(t, d, `{"text":"urgent","priority":"high"}`)
if code != 202 || m["id"] == "" {
t.Fatalf("high: status=%d body=%v, want 202 with id (parks)", code, m)
}
// high stays parked: no second arrival, the queue shows it.
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), want the parked high", st.QueueIDs, st.Queue)
}
if law := getLawStatus(t, d); law.RateLimit.AllowHighBurst {
t.Error("allow_high_burst = true, want false")
}
// Clean up via cancel — and the daemon stays healthy (now bypasses
// the still running 8 s window).
code, cm := postPath(t, d, "/cancel")
if code != 200 || cm["cancelled"] != float64(1) {
t.Fatalf("cancel: %d %v, want 1 parked announcement", code, cm)
}
speak(t, d, `{"text":"afterwards","now":true}`)
hits := fake.waitCompleted(t, 2)
if hits[1].Text != "afterwards" {
t.Errorf("hit after cancel = %q, want afterwards", hits[1].Text)
}
})
}
// TestNowBypassesRateAndQuiet: now is the documented breakout — it plays
// despite a full rate window AND active quiet hours.
func TestNowBypassesRateAndQuiet(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": windowAround(),
})
// Normal expires during quiet hours (here: silently).
code, m := speak(t, d, `{"text":"quiet please"}`)
if code != 202 || m["id"] != "" || m["discarded"] != "quiet_hours" {
t.Fatalf("normal during quiet hours: status=%d body=%v, want 202 with empty id and discarded=quiet_hours", code, m)
}
// now breaks through: admission with ID, synthesis despite both laws.
code, m = speak(t, d, `{"text":"emergency","now":true}`)
if code != 202 || m["id"] == "" || discardedOf(m) != "" {
t.Fatalf("now: status=%d body=%v, want 202 with id", code, m)
}
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "emergency" {
t.Errorf("hit = %q, want emergency", 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 (window around now): %+v", law.QuietHours)
}
}
// TestQuietHoursDiscard: an active window discards normal AND high silently
// (burst is a rate right, not a quiet-hours right) — without provider
// contact, with a discard line in the daemon log and the window end in
// /status.
func TestQuietHoursDiscard(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
window := windowAround()
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_QUIET_HOURS": window})
for _, body := range []string{
`{"text":"evening one"}`,
`{"text":"evening urgent","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 with empty id and discarded=quiet_hours", body, code, m)
}
}
fake.assertNoHits(t, 400*time.Millisecond)
log := d.stderr.String()
if !strings.Contains(log, "quiet hours") || !strings.Contains(log, "discarded") {
t.Errorf("daemon log should have a discard line mentioning quiet hours, has:\n%s", log)
}
law := getLawStatus(t, d)
if law.QuietHours.Window != window || !law.QuietHours.Active || law.QuietHours.Mode != "discard" {
t.Errorf("quiet_hours = %+v, want window %q active in mode discard", law.QuietHours, window)
}
if law.QuietHours.Until == nil {
t.Error("quiet_hours.until = null in an active window, want a time")
}
}
// TestQuietHoursToast: quiet_mode=toast replaces audio with a notification —
// currently a daemon log line via the notification path (documented
// limitation until the OS integration).
func TestQuietHoursToast(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_QUIET_HOURS": windowAround(),
"TALK_QUIET_MODE": "toast",
})
code, m := speak(t, d, `{"text":"evening short"}`)
if code != 202 || m["id"] != "" || m["discarded"] != "quiet_hours" || m["toast"] != true {
t.Fatalf("toast: status=%d body=%v, want 202 with empty id, discarded=quiet_hours and toast=true", code, m)
}
fake.assertNoHits(t, 300*time.Millisecond)
if log := d.stderr.String(); !strings.Contains(log, "toast") {
t.Errorf("daemon log should have a toast line (notification path), has:\n%s", log)
}
}
// TestStatusLawFields (acceptance criterion): /status and `talk status` show
// rate-limit and quiet-hours state — defaults when idle, a booked slot after
// an announcement.
func TestStatusLawFields(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 on, slot free", law.RateLimit)
}
if law.QuietHours.Window != "" || law.QuietHours.Active || law.QuietHours.Mode != "discard" || law.QuietHours.Until != nil {
t.Errorf("quiet_hours = %+v, want disabled in mode 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 allowed",
"quiet-hours: off",
"mode discard",
} {
if !strings.Contains(res.stdout, want) {
t.Errorf("talk status stdout = %q, want %q", res.stdout, want)
}
}
// After an announcement the next slot is set: HTTP field + CLI line.
speak(t, d, `{"text":"once is enough"}`)
fake.waitArrived(t, 1)
if law := getLawStatus(t, d); law.RateLimit.NextSlot == nil {
t.Error("next_slot = null after a played announcement, want a time")
}
res = runTalk(t, env, "status")
if res.code != 0 || !strings.Contains(res.stdout, "next slot") {
t.Errorf("talk status after announcement = %q (exit %d), want the slot line", res.stdout, res.code)
}
}