2026-10-18 17:40:51 +02:00
|
|
|
package test
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 17:40:51 +02:00
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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 {
|
2026-10-18 17:40:51 +02:00
|
|
|
now := time.Now()
|
2026-10-24 10:37:46 +02:00
|
|
|
return now.Add(-3*time.Minute).Format("15:04") + "-" + now.Add(4*time.Minute).Format("15:04")
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// lawStatus is the law view from GET /status.
|
2026-10-18 17:40:51 +02:00
|
|
|
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"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// getLawStatus calls GET /status and decodes only the law fields.
|
2026-10-18 17:40:51 +02:00
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("decoding GET /status: %v", err)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
return st
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 17:40:51 +02:00
|
|
|
func discardedOf(m map[string]any) string {
|
|
|
|
|
if v, ok := m["discarded"].(string); ok {
|
|
|
|
|
return v
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-18 17:40:51 +02:00
|
|
|
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"})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
fake.waitArrived(t, 1) // first is running — the window (1 s) is booked.
|
2026-10-18 17:40:51 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// CLI observation: talk say --priority low reports the discard plainly.
|
|
|
|
|
res := runTalk(t, env, "say", "--priority", "low", "spam one")
|
2026-10-18 17:40:51 +02:00
|
|
|
if res.code != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("talk say --priority low: exit %d, stderr: %s", res.code, res.stderr)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
if res.stdout != "discarded: rate_limit\n" {
|
|
|
|
|
t.Errorf("stdout = %q, want %q", res.stdout, "discarded: rate_limit\n")
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Two more low announcements expire on admission as well.
|
|
|
|
|
for _, text := range []string{"spam two", "spam three"} {
|
2026-10-18 17:40:51 +02:00
|
|
|
code, m := speak(t, d, `{"text":"`+text+`","priority":"low"}`)
|
|
|
|
|
if code != 202 || m["id"] != "" || m["discarded"] != "rate_limit" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("low %s: status=%d body=%v, want 202 with empty id and discarded=rate_limit", text, code, m)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// … and visibly parked: the queue shows it, the next slot lies in the
|
|
|
|
|
// future.
|
2026-10-18 17:40:51 +02:00
|
|
|
st := getStatus(t, d)
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
law := getLawStatus(t, d)
|
|
|
|
|
if law.RateLimit.Interval != "1s" || !law.RateLimit.AllowHighBurst {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("rate_limit = %+v, want interval 1s with burst rights", law.RateLimit)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
if law.RateLimit.NextSlot == nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("next_slot = null while an announcement is parked, want a time")
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Exactly one has arrived so far: the parked one does NOT play before
|
|
|
|
|
// the window expires (ample distance to the window edge).
|
2026-10-18 17:40:51 +02:00
|
|
|
fake.assertNoFurtherArrivals(t, 400*time.Millisecond)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// After the window the parked one plays — at the slot at the earliest.
|
2026-10-18 17:40:51 +02:00
|
|
|
hits := fake.waitCompleted(t, 2)
|
2026-10-24 11:15:35 +02:00
|
|
|
if hits[1].Text != "parked announcement" {
|
|
|
|
|
t.Errorf("second hit = %q, want the parked announcement", hits[1].Text)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
if gap := hits[1].Start.Sub(hits[0].Start); gap < 850*time.Millisecond {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("second start after %v, want >= window (1 s, tolerance 150 ms)", gap)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
waitStatusIdle(t, d, 3*time.Second)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-18 17:40:51 +02:00
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 50*time.Millisecond)
|
|
|
|
|
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "8s"})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
speak(t, d, `{"text":"first"}`)
|
|
|
|
|
fake.waitArrived(t, 1) // window booked for 8 s.
|
2026-10-18 17:40:51 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
code, m := speak(t, d, `{"text":"urgent","priority":"high"}`)
|
2026-10-18 17:40:51 +02:00
|
|
|
if code != 202 || m["id"] == "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("high: status=%d body=%v, want 202 with id", code, m)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// high starts as soon as the first is done — deliberately inside
|
|
|
|
|
// the window (violation with rights).
|
2026-10-18 17:40:51 +02:00
|
|
|
hits := fake.waitCompleted(t, 2)
|
2026-10-24 11:15:35 +02:00
|
|
|
if hits[1].Text != "urgent" {
|
|
|
|
|
t.Errorf("second hit = %q, want urgent", hits[1].Text)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
if gap := hits[1].Start.Sub(hits[0].Start); gap >= 4*time.Second {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("high started after %v — burst does not take effect (window 8 s)", gap)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
waitStatusIdle(t, d, 3*time.Second)
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("burst off", func(t *testing.T) {
|
2026-10-18 17:40:51 +02:00
|
|
|
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",
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
speak(t, d, `{"text":"first"}`)
|
2026-10-18 17:40:51 +02:00
|
|
|
fake.waitArrived(t, 1)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
code, m := speak(t, d, `{"text":"urgent","priority":"high"}`)
|
2026-10-18 17:40:51 +02:00
|
|
|
if code != 202 || m["id"] == "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("high: status=%d body=%v, want 202 with id (parks)", code, m)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// high stays parked: no second arrival, the queue shows it.
|
2026-10-18 17:40:51 +02:00
|
|
|
fake.assertNoFurtherArrivals(t, 700*time.Millisecond)
|
|
|
|
|
st := getStatus(t, d)
|
|
|
|
|
if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != m["id"] {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("queue = %v (%d), want the parked high", st.QueueIDs, st.Queue)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
if law := getLawStatus(t, d); law.RateLimit.AllowHighBurst {
|
|
|
|
|
t.Error("allow_high_burst = true, want false")
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Clean up via cancel — and the daemon stays healthy (now bypasses
|
|
|
|
|
// the still running 8 s window).
|
2026-10-18 17:40:51 +02:00
|
|
|
code, cm := postPath(t, d, "/cancel")
|
|
|
|
|
if code != 200 || cm["cancelled"] != float64(1) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("cancel: %d %v, want 1 parked announcement", code, cm)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
speak(t, d, `{"text":"afterwards","now":true}`)
|
2026-10-18 17:40:51 +02:00
|
|
|
hits := fake.waitCompleted(t, 2)
|
2026-10-24 11:15:35 +02:00
|
|
|
if hits[1].Text != "afterwards" {
|
|
|
|
|
t.Errorf("hit after cancel = %q, want afterwards", hits[1].Text)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestNowBypassesRateAndQuiet: now is the documented breakout — it plays
|
|
|
|
|
// despite a full rate window AND active quiet hours.
|
|
|
|
|
func TestNowBypassesRateAndQuiet(t *testing.T) {
|
2026-10-18 17:40:51 +02:00
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 0)
|
|
|
|
|
d := startDaemon(t, root, fake.srv.URL, map[string]string{
|
|
|
|
|
"TALK_RATE_LIMIT": "30s",
|
2026-10-24 11:15:35 +02:00
|
|
|
"TALK_QUIET_HOURS": windowAround(),
|
2026-10-18 17:40:51 +02:00
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Normal expires during quiet hours (here: silently).
|
|
|
|
|
code, m := speak(t, d, `{"text":"quiet please"}`)
|
2026-10-18 17:40:51 +02:00
|
|
|
if code != 202 || m["id"] != "" || m["discarded"] != "quiet_hours" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("normal during quiet hours: status=%d body=%v, want 202 with empty id and discarded=quiet_hours", code, m)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// now breaks through: admission with ID, synthesis despite both laws.
|
|
|
|
|
code, m = speak(t, d, `{"text":"emergency","now":true}`)
|
2026-10-18 17:40:51 +02:00
|
|
|
if code != 202 || m["id"] == "" || discardedOf(m) != "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("now: status=%d body=%v, want 202 with id", code, m)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
hits := fake.waitCompleted(t, 1)
|
2026-10-24 11:15:35 +02:00
|
|
|
if hits[0].Text != "emergency" {
|
|
|
|
|
t.Errorf("hit = %q, want emergency", hits[0].Text)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
fake.assertNoFurtherArrivals(t, 300*time.Millisecond)
|
|
|
|
|
|
|
|
|
|
if law := getLawStatus(t, d); !law.QuietHours.Active {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("quiet_hours.active = false, want true (window around now): %+v", law.QuietHours)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-18 17:40:51 +02:00
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 0)
|
2026-10-24 11:15:35 +02:00
|
|
|
window := windowAround()
|
|
|
|
|
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_QUIET_HOURS": window})
|
2026-10-18 17:40:51 +02:00
|
|
|
|
|
|
|
|
for _, body := range []string{
|
2026-10-24 11:15:35 +02:00
|
|
|
`{"text":"evening one"}`,
|
|
|
|
|
`{"text":"evening urgent","priority":"high"}`,
|
2026-10-18 17:40:51 +02:00
|
|
|
} {
|
|
|
|
|
code, m := speak(t, d, body)
|
|
|
|
|
if code != 202 || m["id"] != "" || m["discarded"] != "quiet_hours" || m["toast"] != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("%s: status=%d body=%v, want 202 with empty id and discarded=quiet_hours", body, code, m)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
fake.assertNoHits(t, 400*time.Millisecond)
|
|
|
|
|
|
|
|
|
|
log := d.stderr.String()
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
law := getLawStatus(t, d)
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
if law.QuietHours.Until == nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("quiet_hours.until = null in an active window, want a time")
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-18 17:40:51 +02:00
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 0)
|
|
|
|
|
d := startDaemon(t, root, fake.srv.URL, map[string]string{
|
2026-10-24 11:15:35 +02:00
|
|
|
"TALK_QUIET_HOURS": windowAround(),
|
2026-10-18 17:40:51 +02:00
|
|
|
"TALK_QUIET_MODE": "toast",
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
code, m := speak(t, d, `{"text":"evening short"}`)
|
2026-10-18 17:40:51 +02:00
|
|
|
if code != 202 || m["id"] != "" || m["discarded"] != "quiet_hours" || m["toast"] != true {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("toast: status=%d body=%v, want 202 with empty id, discarded=quiet_hours and toast=true", code, m)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
fake.assertNoHits(t, 300*time.Millisecond)
|
|
|
|
|
|
|
|
|
|
if log := d.stderr.String(); !strings.Contains(log, "toast") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("daemon log should have a toast line (notification path), has:\n%s", log)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-18 17:40:51 +02:00
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("rate_limit = %+v, want default 30s, burst on, slot free", law.RateLimit)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
if law.QuietHours.Window != "" || law.QuietHours.Active || law.QuietHours.Mode != "discard" || law.QuietHours.Until != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("quiet_hours = %+v, want disabled in mode discard", law.QuietHours)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res := runTalk(t, env, "status")
|
|
|
|
|
if res.code != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("talk status: exit %d — stderr: %s", res.code, res.stderr)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
for _, want := range []string{
|
2026-10-24 11:15:35 +02:00
|
|
|
"rate-limit: 30s",
|
|
|
|
|
"high burst allowed",
|
|
|
|
|
"quiet-hours: off",
|
|
|
|
|
"mode discard",
|
2026-10-18 17:40:51 +02:00
|
|
|
} {
|
|
|
|
|
if !strings.Contains(res.stdout, want) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("talk status stdout = %q, want %q", res.stdout, want)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// After an announcement the next slot is set: HTTP field + CLI line.
|
|
|
|
|
speak(t, d, `{"text":"once is enough"}`)
|
2026-10-18 17:40:51 +02:00
|
|
|
fake.waitArrived(t, 1)
|
|
|
|
|
if law := getLawStatus(t, d); law.RateLimit.NextSlot == nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("next_slot = null after a played announcement, want a time")
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
res = runTalk(t, env, "status")
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-18 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
}
|