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>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 11:15:35 +02:00
1 parent 4d30bd95ad
commit 85fa64cfe6
95 files changed
+4707 -4854

No files matched your search

+76 -76
View File
@@ -1,10 +1,10 @@
package test
// Prozessgrenzen-Tests für das Ein-Binary-Modell: talk startet den Daemon
// beim ersten Sprechbefehl selbst (daemon.autostart), parallele Aufrufe
// landen in EINER Queue, `talk daemon start|stop` steuern ihn explizit, und
// ein nicht startbarer Daemon (Port von einem fremden Dienst belegt) führt
// in den Direkt-Pfad statt ins Schweigen.
// Process-boundary tests for the single-binary model: talk starts the daemon
// itself on the first speak command (daemon.autostart), parallel calls land
// in ONE queue, `talk daemon start|stop` control it explicitly, and a daemon
// that cannot start (port occupied by a foreign service) leads to the direct
// path instead of silence.
import (
"context"
@@ -24,10 +24,10 @@ import (
var itemID = regexp.MustCompile(`^[0-9a-f]{6}\n$`)
// autostartEnv ist daemonEnv mit eingeschaltetem Autostart auf einem
// freien Port — und einem Cleanup, das den gespawnten Daemon garantiert
// wieder einsammelt (POST /shutdown; Notbremse: PID-Datei + Kill), damit
// kein Testlauf Hintergrundprozesse hinterlässt.
// autostartEnv is daemonEnv with autostart switched on, on a free port — and
// a cleanup that is guaranteed to collect the spawned daemon again (POST
// /shutdown; emergency brake: PID file + kill), so that no test run leaves
// background processes behind.
func autostartEnv(t *testing.T, root, fakeURL string, extra map[string]string) ([]string, *api.Client) {
t.Helper()
port := freePort(t)
@@ -57,81 +57,81 @@ func autostartEnv(t *testing.T, root, fakeURL string, extra map[string]string) (
return env, client
}
// TestAutostartStartetDaemon (Abnahmekriterium Ein-Binary): ohne laufenden
// Daemon startet `talk say` ihn selbst, meldet das auf stderr, liefert eine
// Item-ID auf stdout, und die Ansage läuft durch Queue und Fake-Provider.
// Danach ist der Daemon da (status), schreibt Log und PID, und `talk daemon
// stop` beendet ihn wieder.
func TestAutostartStartetDaemon(t *testing.T) {
// TestAutostartStartsDaemon (acceptance criterion single binary): without a
// running daemon `talk say` starts it itself, reports that on stderr,
// returns an item ID on stdout, and the announcement runs through the queue
// and the fake provider. Afterwards the daemon is there (status), writes log
// and PID, and `talk daemon stop` ends it again.
func TestAutostartStartsDaemon(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env, client := autostartEnv(t, root, fake.srv.URL, nil)
res := runTalk(t, env, "say", "erster aufruf startet")
res := runTalk(t, env, "say", "first call starts")
if res.code != 0 {
t.Fatalf("talk say: Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say: exit %d, stderr: %s", res.code, res.stderr)
}
if !itemID.MatchString(res.stdout) {
t.Errorf("stdout = %q, want Item-ID (Daemon-Pfad, nicht direkt)", res.stdout)
t.Errorf("stdout = %q, want item ID (daemon path, not direct)", res.stdout)
}
if !strings.Contains(res.stderr, "Daemon gestartet") {
t.Errorf("stderr = %q, will Autostart-Hinweis", res.stderr)
if !strings.Contains(res.stderr, "daemon started") {
t.Errorf("stderr = %q, want the autostart note", res.stderr)
}
if hits := fake.waitCompleted(t, 1); hits[0].Text != "erster aufruf startet" {
t.Errorf("Treffer = %q", hits[0].Text)
if hits := fake.waitCompleted(t, 1); hits[0].Text != "first call starts" {
t.Errorf("hit = %q", hits[0].Text)
}
// Zweiter Aufruf: Daemon läuft schon — kein zweiter Start.
res = runTalk(t, env, "say", "zweiter aufruf")
if res.code != 0 || strings.Contains(res.stderr, "Daemon gestartet") {
t.Errorf("zweiter Aufruf: Exit %d, stderr %q — darf nicht erneut starten", res.code, res.stderr)
// Second call: the daemon is already running — no second start.
res = runTalk(t, env, "say", "second call")
if res.code != 0 || strings.Contains(res.stderr, "daemon started") {
t.Errorf("second call: exit %d, stderr %q — must not start again", res.code, res.stderr)
}
fake.waitCompleted(t, 2)
// Der Daemon ist sichtbar: status, Log- und PID-Datei.
// The daemon is visible: status, log and PID file.
res = runTalk(t, env, "status")
if res.code != 0 || !strings.Contains(res.stdout, "daemon: http://127.0.0.1:") {
t.Errorf("talk status: Exit %d, stdout %q", res.code, res.stdout)
if res.code != 0 || !strings.Contains(res.stdout, "daemon: http://127.0.0.1:") {
t.Errorf("talk status: exit %d, stdout %q", res.code, res.stdout)
}
if _, err := os.Stat(configDir(root) + "/" + daemon.LogFileName); err != nil {
t.Errorf("daemon.log fehlt: %v", err)
t.Errorf("daemon.log missing: %v", err)
}
if pid := daemon.ReadPID(configDir(root)); pid <= 0 {
t.Errorf("daemon.pid fehlt oder leer")
t.Errorf("daemon.pid missing or empty")
}
// stop fährt ihn herunter; danach läuft keiner mehr, PID-Datei weg.
// stop shuts it down; afterwards none is running, the PID file is gone.
res = runTalk(t, env, "daemon", "stop")
if res.code != 0 || res.stdout != "daemon: gestoppt\n" {
t.Fatalf("talk daemon stop: Exit %d, stdout %q, stderr %s", res.code, res.stdout, res.stderr)
if res.code != 0 || res.stdout != "daemon: stopped\n" {
t.Fatalf("talk daemon stop: exit %d, stdout %q, stderr %s", res.code, res.stdout, res.stderr)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if _, err := client.Status(ctx); err == nil {
t.Errorf("Daemon antwortet nach stop noch")
t.Errorf("daemon still answers after stop")
}
if pid := daemon.ReadPID(configDir(root)); pid != 0 {
t.Errorf("daemon.pid nach stop noch da (%d)", pid)
t.Errorf("daemon.pid still there after stop (%d)", pid)
}
// Nochmal stop: idempotent.
// stop once more: idempotent.
res = runTalk(t, env, "daemon", "stop")
if res.code != 0 || !strings.Contains(res.stdout, "läuft nicht") {
t.Errorf("zweites stop: Exit %d, stdout %q", res.code, res.stdout)
if res.code != 0 || !strings.Contains(res.stdout, "not running") {
t.Errorf("second stop: exit %d, stdout %q", res.code, res.stdout)
}
}
// TestAutostartSerialisiertParallele: zwei gleichzeitige talk-Aufrufe ohne
// Daemon — einer startet ihn, der andere findet ihn (oder verliert das
// Port-Rennen und findet ihn trotzdem). Beide Ansagen laufen durch EINE
// Queue: nacheinander, nicht überlappend.
func TestAutostartSerialisiertParallele(t *testing.T) {
// TestAutostartSerializesParallel: two simultaneous talk calls without a
// daemon — one starts it, the other finds it (or loses the port race and
// finds it anyway). Both announcements run through ONE queue: one after
// another, not overlapping.
func TestAutostartSerializesParallel(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 250*time.Millisecond)
env, _ := autostartEnv(t, root, fake.srv.URL, nil)
var wg sync.WaitGroup
results := make([]talkResult, 2)
for i, text := range []string{"eins", "zwei"} {
for i, text := range []string{"one", "two"} {
wg.Add(1)
go func(i int, text string) {
defer wg.Done()
@@ -141,75 +141,75 @@ func TestAutostartSerialisiertParallele(t *testing.T) {
wg.Wait()
for i, res := range results {
if res.code != 0 {
t.Fatalf("talk %d (parallel, autostart): Exit %d, stderr: %s", i, res.code, res.stderr)
t.Fatalf("talk %d (parallel, autostart): exit %d, stderr: %s", i, res.code, res.stderr)
}
if !itemID.MatchString(res.stdout) {
t.Errorf("talk %d stdout = %q, want Item-ID (beide über den Daemon)", i, res.stdout)
t.Errorf("talk %d stdout = %q, want item ID (both via the daemon)", i, res.stdout)
}
}
hits := fake.waitHits(t, 2)
const slack = 25 * time.Millisecond
if hits[1].Start.Before(hits[0].End.Add(-slack)) {
t.Errorf("Ansagen überlappten: Start(2)=%v < Ende(1)=%v — nicht EINE Queue", hits[1].Start, hits[0].End)
t.Errorf("announcements overlapped: start(2)=%v < end(1)=%v — not ONE queue", hits[1].Start, hits[0].End)
}
}
// TestDaemonStartExplizit: `talk daemon start` startet im Hintergrund und
// ist idempotent; der Port steht in der Meldung.
func TestDaemonStartExplizit(t *testing.T) {
// TestDaemonStartExplicit: `talk daemon start` starts in the background and
// is idempotent; the port is in the message.
func TestDaemonStartExplicit(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env, client := autostartEnv(t, root, fake.srv.URL, map[string]string{"TALK_AUTOSTART": "false"})
res := runTalk(t, env, "daemon", "start")
if res.code != 0 || res.stdout != "daemon: gestartet unter "+client.BaseURL+"\n" {
t.Fatalf("talk daemon start: Exit %d, stdout %q, stderr %s", res.code, res.stdout, res.stderr)
if res.code != 0 || res.stdout != "daemon: started at "+client.BaseURL+"\n" {
t.Fatalf("talk daemon start: exit %d, stdout %q, stderr %s", res.code, res.stdout, res.stderr)
}
res = runTalk(t, env, "daemon", "start")
if res.code != 0 || !strings.Contains(res.stdout, "läuft bereits") {
t.Errorf("zweites start: Exit %d, stdout %q", res.code, res.stdout)
if res.code != 0 || !strings.Contains(res.stdout, "already running") {
t.Errorf("second start: exit %d, stdout %q", res.code, res.stdout)
}
// Autostart aus, aber Daemon da: say nutzt ihn.
res = runTalk(t, env, "say", "über explizit gestarteten daemon")
// Autostart off, but the daemon is there: say uses it.
res = runTalk(t, env, "say", "via explicitly started daemon")
if res.code != 0 || !itemID.MatchString(res.stdout) {
t.Errorf("say: Exit %d, stdout %q", res.code, res.stdout)
t.Errorf("say: exit %d, stdout %q", res.code, res.stdout)
}
fake.waitCompleted(t, 1)
}
// TestAutostartScheitertSprichtDirekt: Der Port gehört einem fremden Dienst
// (antwortet nicht wie talk) — der gespawnte Daemon kann nicht binden und
// endet sofort; talk erkennt das am Kind-Ende (kein Timeout-Warten) und
// spricht direkt. Der Benutzer sieht den Grund und den Log-Pfad.
func TestAutostartScheitertSprichtDirekt(t *testing.T) {
// TestAutostartFailsSpeaksDirectly: the port belongs to a foreign service
// (does not answer like talk) — the spawned daemon cannot bind and ends
// immediately; talk notices that from the child's exit (no timeout wait)
// and speaks directly. The user sees the reason and the log path.
func TestAutostartFailsSpeaksDirectly(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
fremd := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(fremd.Close)
port, _ := strconv.Atoi(fremd.URL[strings.LastIndex(fremd.URL, ":")+1:])
t.Cleanup(foreign.Close)
port, _ := strconv.Atoi(foreign.URL[strings.LastIndex(foreign.URL, ":")+1:])
env := daemonEnv(root, port, fake.srv.URL, map[string]string{"TALK_AUTOSTART": "true"})
start := time.Now()
res := runTalk(t, env, "say", "port ist fremd belegt")
res := runTalk(t, env, "say", "port is occupied by a foreign service")
if res.code != 0 {
t.Fatalf("talk say: Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say: exit %d, stderr: %s", res.code, res.stderr)
}
if d := time.Since(start); d > 4*time.Second {
t.Errorf("Dauer %v — Start-Fehlschlag soll am Kind-Ende erkannt werden, nicht per Timeout", d)
t.Errorf("duration %v — the start failure should be detected from the child's exit, not via timeout", d)
}
if !strings.Contains(res.stderr, "Daemon-Start fehlgeschlagen") || !strings.Contains(res.stderr, "spreche direkt") {
t.Errorf("stderr = %q, will Fehlschlag-Grund und Direkt-Hinweis", res.stderr)
if !strings.Contains(res.stderr, "daemon start failed") || !strings.Contains(res.stderr, "speaking directly") {
t.Errorf("stderr = %q, want the failure reason and the direct-path note", res.stderr)
}
if !strings.Contains(res.stderr, daemon.LogFileName) {
t.Errorf("stderr = %q, will Log-Pfad-Hinweis", res.stderr)
t.Errorf("stderr = %q, want the log path hint", res.stderr)
}
if hits := fake.waitHits(t, 1); hits[0].Text != "port ist fremd belegt" {
t.Errorf("Treffer = %q", hits[0].Text)
if hits := fake.waitHits(t, 1); hits[0].Text != "port is occupied by a foreign service" {
t.Errorf("hit = %q", hits[0].Text)
}
logBytes, err := os.ReadFile(configDir(root) + "/" + daemon.LogFileName)
if err != nil || !strings.Contains(string(logBytes), "Port belegen") {
t.Errorf("daemon.log = %q (%v), will Port-belegt-Fehler", logBytes, err)
if err != nil || !strings.Contains(string(logBytes), "bind") {
t.Errorf("daemon.log = %q (%v), want the bind error", logBytes, err)
}
}
+90 -87
View File
@@ -9,9 +9,9 @@ import (
"time"
)
// TestSayNonBlocking (Abnahmekriterium non-blocking): `talk say` kehrt
// deutlich vor Ende der Ansage zurück (Exit 0, Item-ID auf stdout), die
// Ansage läuft im Daemon weiter zu Ende.
// TestSayNonBlocking (acceptance criterion non-blocking): `talk say` returns
// well before the announcement ends (exit 0, item ID on stdout); the
// announcement keeps running to completion in the daemon.
func TestSayNonBlocking(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 600*time.Millisecond)
@@ -19,29 +19,29 @@ func TestSayNonBlocking(t *testing.T) {
env := daemonEnv(root, d.port, fake.srv.URL, nil)
start := time.Now()
res := runTalk(t, env, "say", "langlaufende ansage")
res := runTalk(t, env, "say", "long-running announcement")
elapsed := time.Since(start)
if res.code != 0 {
t.Fatalf("talk say: Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say: exit %d, stderr: %s", res.code, res.stderr)
}
if elapsed >= 500*time.Millisecond {
t.Fatalf("talk say war blockierend (%s; Ansage dauert 600 ms) — Default muss non-blocking sein", elapsed)
t.Fatalf("talk say was blocking (%s; announcement takes 600 ms) — the default must be non-blocking", elapsed)
}
id := strings.TrimSpace(res.stdout)
if id == "" || strings.Contains(id, "\n") {
t.Errorf("stdout = %q, will genau eine Zeile mit der Item-ID", res.stdout)
t.Errorf("stdout = %q, want exactly one line with the item ID", res.stdout)
}
// Die Ansage läuft trotzdem zu Ende (Daemon besitzt sie jetzt).
// The announcement still runs to completion (the daemon owns it now).
hits := fake.waitHits(t, 1)
if hits[0].Text != "langlaufende ansage" {
t.Errorf("Treffer = %q", hits[0].Text)
if hits[0].Text != "long-running announcement" {
t.Errorf("hit = %q", hits[0].Text)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestSayWait: `talk say --wait` kehrt erst nach Ende der Ansage zurück.
// TestSayWait: `talk say --wait` returns only after the announcement ends.
func TestSayWait(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 400*time.Millisecond)
@@ -49,52 +49,54 @@ func TestSayWait(t *testing.T) {
env := daemonEnv(root, d.port, fake.srv.URL, nil)
start := time.Now()
res := runTalk(t, env, "say", "--wait", "warten auf mich")
res := runTalk(t, env, "say", "--wait", "wait for me")
elapsed := time.Since(start)
if res.code != 0 {
t.Fatalf("talk say --wait: Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say --wait: exit %d, stderr: %s", res.code, res.stderr)
}
if elapsed < 350*time.Millisecond {
t.Fatalf("talk say --wait kam zu früh zurück (%s; Ansage dauert 400 ms)", elapsed)
t.Fatalf("talk say --wait returned too early (%s; announcement takes 400 ms)", elapsed)
}
if hits := fake.waitHits(t, 1); hits[0].Text != "warten auf mich" {
t.Errorf("Treffer = %q", hits[0].Text)
if hits := fake.waitHits(t, 1); hits[0].Text != "wait for me" {
t.Errorf("hit = %q", hits[0].Text)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestSayOhneDaemonSprichtDirekt: Daemon tot und Autostart aus → talk
// spricht direkt im eigenen Prozess (hier: gegen den Fake-Provider) und
// sagt das auf stderr — nichts bricht still.
func TestSayOhneDaemonSprichtDirekt(t *testing.T) {
// TestSayWithoutDaemonSpeaksDirectly: daemon dead and autostart off → talk
// speaks directly in its own process (here: against the fake provider) and
// says so on stderr — nothing fails silently.
func TestSayWithoutDaemonSpeaksDirectly(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot
env := daemonEnv(root, 1, fake.srv.URL, nil) // port 1: daemon guaranteed dead
res := runTalk(t, env, "say", "offline ansage")
res := runTalk(t, env, "say", "offline announcement")
if res.code != 0 {
t.Fatalf("talk say (direkt): Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say (direct): exit %d, stderr: %s", res.code, res.stderr)
}
if hits := fake.waitHits(t, 1); hits[0].Text != "offline ansage" {
t.Errorf("Treffer = %q", hits[0].Text)
if hits := fake.waitHits(t, 1); hits[0].Text != "offline announcement" {
t.Errorf("hit = %q", hits[0].Text)
}
if !strings.Contains(res.stderr, "spreche direkt") {
t.Errorf("stderr = %q, will Direkt-Hinweis (nichts bricht still)", res.stderr)
if !strings.Contains(res.stderr, "speaking directly") {
t.Errorf("stderr = %q, want the direct-path note (nothing fails silently)", res.stderr)
}
if strings.Contains(res.stderr, "Daemon gestartet") {
t.Errorf("stderr = %q — Autostart war aus, es darf kein Daemon gestartet werden", res.stderr)
if strings.Contains(res.stderr, "daemon started") {
t.Errorf("stderr = %q — autostart was off, no daemon may be started", res.stderr)
}
}
// TestSayOhneDaemonRespektiertMute: ohne Daemon verwirft der Direkt-Pfad
// Ansagen laut Mute-Datei tonlos (und spielt nach Unmute wieder).
func TestSayOhneDaemonRespektiertMute(t *testing.T) {
// TestSayWithoutDaemonRespectsMute: without a daemon the direct path
// discards announcements silently according to the mute file (and plays
// again after unmute).
func TestSayWithoutDaemonRespectsMute(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env := daemonEnv(root, 1, fake.srv.URL, nil)
// Mute-Datei direkt ins Config-Dir (Zustand „Daemon war mal da").
// Mute file written straight into the config dir (state "a daemon was
// here once").
if err := os.MkdirAll(configDir(root), 0o755); err != nil {
t.Fatal(err)
}
@@ -102,31 +104,32 @@ func TestSayOhneDaemonRespektiertMute(t *testing.T) {
t.Fatal(err)
}
res := runTalk(t, env, "say", "sollte stumm bleiben")
res := runTalk(t, env, "say", "should stay silent")
if res.code != 0 {
t.Fatalf("talk say (mute an): Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say (mute on): exit %d, stderr: %s", res.code, res.stderr)
}
if res.stdout != "muted\n" {
t.Errorf("stdout = %q, want muted", res.stdout)
}
fake.assertNoHits(t, 300*time.Millisecond)
// Unmute (offline) → spielt wieder.
// Unmute (offline) → plays again.
res = runTalk(t, env, "unmute")
if res.code != 0 {
t.Fatalf("talk unmute (offline): Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk unmute (offline): exit %d, stderr: %s", res.code, res.stderr)
}
res = runTalk(t, env, "say", "jetzt wieder")
res = runTalk(t, env, "say", "back again")
if res.code != 0 {
t.Fatalf("talk say (mute aus): Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say (mute off): exit %d, stderr: %s", res.code, res.stderr)
}
if hits := fake.waitHits(t, 1); hits[0].Text != "jetzt wieder" {
t.Errorf("Treffer = %q", hits[0].Text)
if hits := fake.waitHits(t, 1); hits[0].Text != "back again" {
t.Errorf("hit = %q", hits[0].Text)
}
}
// TestCliMuteUnmuteStatusCancel: die Steuer-Befehle als Daemon-Aufrufe mit
// plain Output — mute/unmute als Token, status als Block, cancel mit Zahl.
// TestCliMuteUnmuteStatusCancel: the control commands as daemon calls with
// plain output — mute/unmute as a token, status as a block, cancel with a
// number.
func TestCliMuteUnmuteStatusCancel(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
@@ -138,20 +141,20 @@ func TestCliMuteUnmuteStatusCancel(t *testing.T) {
t.Fatalf("talk mute: %d %q — stderr: %s", res.code, res.stdout, res.stderr)
}
// say bei Mute: verworfen, plain gemeldet, kein Provider-Kontakt.
res = runTalk(t, env, "say", "stille")
// say while muted: discarded, reported plainly, no provider contact.
res = runTalk(t, env, "say", "silence")
if res.code != 0 || res.stdout != "muted\n" {
t.Fatalf("talk say (mute an): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
t.Fatalf("talk say (mute on): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
}
fake.assertNoHits(t, 300*time.Millisecond)
res = runTalk(t, env, "status")
if res.code != 0 {
t.Fatalf("talk status: Exit %d — stderr: %s", res.code, res.stderr)
t.Fatalf("talk status: exit %d — stderr: %s", res.code, res.stderr)
}
for _, want := range []string{"version dev", "queue: 0", "mute: an", "provider: elevenlabs"} {
for _, want := range []string{"version dev", "queue: 0", "mute: on", "provider: elevenlabs"} {
if !strings.Contains(res.stdout, want) {
t.Errorf("talk status stdout = %q, will %q", res.stdout, want)
t.Errorf("talk status stdout = %q, want %q", res.stdout, want)
}
}
@@ -162,18 +165,18 @@ func TestCliMuteUnmuteStatusCancel(t *testing.T) {
res = runTalk(t, env, "cancel")
if res.code != 0 || res.stdout != "cancelled: 0\n" {
t.Fatalf("talk cancel (leer): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
t.Fatalf("talk cancel (empty): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
}
res = runTalk(t, env, "status")
if !strings.Contains(res.stdout, "mute: aus") {
t.Errorf("talk status nach unmute = %q, will mute aus", res.stdout)
if !strings.Contains(res.stdout, "mute: off") {
t.Errorf("talk status after unmute = %q, want mute off", res.stdout)
}
}
// TestCliCancelMitQueue: cancel über die CLI bricht laufende + wartende
// Ansage ab und meldet die Zahl.
func TestCliCancelMitQueue(t *testing.T) {
// TestCliCancelWithQueue: cancel via the CLI aborts the running + the
// waiting announcement and reports the count.
func TestCliCancelWithQueue(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 5*time.Second)
d := startDaemon(t, root, fake.srv.URL, nil)
@@ -181,7 +184,7 @@ func TestCliCancelMitQueue(t *testing.T) {
speak(t, d, `{"text":"blocker"}`)
fake.waitArrived(t, 1)
runTalk(t, env, "say", "wartet auf cancel") // non-blocking enqueue
runTalk(t, env, "say", "waiting for cancel") // non-blocking enqueue
res := runTalk(t, env, "cancel")
if res.code != 0 || res.stdout != "cancelled: 2\n" {
@@ -190,18 +193,18 @@ func TestCliCancelMitQueue(t *testing.T) {
fake.setDelay(50 * time.Millisecond)
waitStatusIdle(t, d, 2*time.Second)
speak(t, d, `{"text":"danach"}`)
speak(t, d, `{"text":"afterwards"}`)
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "danach" {
t.Errorf("Treffer = %q, want danach", hits[0].Text)
if hits[0].Text != "afterwards" {
t.Errorf("hit = %q, want afterwards", hits[0].Text)
}
}
// TestDreiParalleleTalkAufrufe (Abnahmekriterium Task 04): `talkd &` +
// drei parallele `talk`-Aufrufe → drei Ansagen nacheinander. Die Enqueue-
// Reihenfolge der parallelen Prozesse ist offen — geprüft wird, dass alle
// drei abgespielt werden und KEINE zwei überlappen.
func TestDreiParalleleTalkAufrufe(t *testing.T) {
// TestThreeParallelTalkCalls (acceptance criterion Task 04): `talkd &` +
// three parallel `talk` calls → three announcements one after another. The
// enqueue order of the parallel processes is open — what is checked is that
// all three are played and NO two overlap.
func TestThreeParallelTalkCalls(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 200*time.Millisecond)
d := startDaemon(t, root, fake.srv.URL, nil)
@@ -209,7 +212,7 @@ func TestDreiParalleleTalkAufrufe(t *testing.T) {
results := make([]talkResult, 3)
var wg sync.WaitGroup
for i, text := range []string{"erste", "zweite", "dritte"} {
for i, text := range []string{"first", "second", "third"} {
wg.Add(1)
go func(i int, text string) {
defer wg.Done()
@@ -219,7 +222,7 @@ func TestDreiParalleleTalkAufrufe(t *testing.T) {
wg.Wait()
for i, res := range results {
if res.code != 0 {
t.Fatalf("paralleler talk-Aufruf %d: Exit %d — stderr: %s", i, res.code, res.stderr)
t.Fatalf("parallel talk call %d: exit %d — stderr: %s", i, res.code, res.stderr)
}
}
@@ -228,60 +231,60 @@ func TestDreiParalleleTalkAufrufe(t *testing.T) {
for _, h := range hits {
played[h.Text] = true
}
for _, text := range []string{"erste", "zweite", "dritte"} {
for _, text := range []string{"first", "second", "third"} {
if !played[text] {
t.Errorf("Ansage %q fehlt (Treffer: %+v)", text, hits)
t.Errorf("announcement %q missing (hits: %+v)", text, hits)
}
}
const slack = 25 * time.Millisecond
for i := 1; i < len(hits); i++ {
if hits[i].Start.Before(hits[i-1].End.Add(-slack)) {
t.Errorf("Ansagen %d/%d überlappten: Start %v < Ende %v", i-1, i, hits[i].Start, hits[i-1].End)
t.Errorf("announcements %d/%d overlapped: start %v < end %v", i-1, i, hits[i].Start, hits[i-1].End)
}
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestCliSteuerbefehleOhneDaemon: status/cancel ohne Daemon sind klare
// Fehler (kein Stillversagen) — say spricht dagegen direkt.
func TestCliSteuerbefehleOhneDaemon(t *testing.T) {
// TestCliControlCommandsWithoutDaemon: status/cancel without a daemon are
// clear errors (no silent failure) — say, by contrast, speaks directly.
func TestCliControlCommandsWithoutDaemon(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env := daemonEnv(root, 1, fake.srv.URL, nil)
if res := runTalk(t, env, "status"); res.code != 1 || !strings.Contains(res.stderr, "kein Daemon") || !strings.Contains(res.stdout, "läuft nicht") {
t.Errorf("talk status ohne Daemon: %d stdout %q stderr %q", res.code, res.stdout, res.stderr)
if res := runTalk(t, env, "status"); res.code != 1 || !strings.Contains(res.stderr, "no daemon") || !strings.Contains(res.stdout, "not running") {
t.Errorf("talk status without daemon: %d stdout %q stderr %q", res.code, res.stdout, res.stderr)
}
if res := runTalk(t, env, "cancel"); res.code != 1 || !strings.Contains(res.stderr, "nichts zu abbrechen") {
t.Errorf("talk cancel ohne Daemon: %d %q", res.code, res.stderr)
if res := runTalk(t, env, "cancel"); res.code != 1 || !strings.Contains(res.stderr, "nothing to cancel") {
t.Errorf("talk cancel without daemon: %d %q", res.code, res.stderr)
}
}
// TestSayDefaultPfadWieSay: `talk "Text"` nimmt denselben Daemon-Pfad wie
// `talk say "Text"` (Flags vor und nach dem Text).
func TestSayDefaultPfadWieSay(t *testing.T) {
// TestSayDefaultPathLikeSay: `talk "text"` takes the same daemon path as
// `talk say "text"` (flags before and after the text).
func TestSayDefaultPathLikeSay(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
env := daemonEnv(root, d.port, fake.srv.URL, nil)
res := runTalk(t, env, "default aufruf")
res := runTalk(t, env, "default call")
if res.code != 0 {
t.Fatalf("talk \"Text\": Exit %d — stderr: %s", res.code, res.stderr)
t.Fatalf("talk \"text\": exit %d — stderr: %s", res.code, res.stderr)
}
if id := strings.TrimSpace(res.stdout); id == "" {
t.Errorf("stdout = %q, will Item-ID", res.stdout)
t.Errorf("stdout = %q, want item ID", res.stdout)
}
// Flag nach dem Text (parseInterspersed) auf dem Daemon-Pfad.
res = runTalk(t, env, "noch ein text", "--lang=en")
// Flag after the text (parseInterspersed) on the daemon path.
res = runTalk(t, env, "another text", "--lang=en")
if res.code != 0 {
t.Fatalf("talk Text --lang=en: Exit %d — stderr: %s", res.code, res.stderr)
t.Fatalf("talk text --lang=en: exit %d — stderr: %s", res.code, res.stderr)
}
hits := fake.waitHits(t, 2)
if !strings.Contains(hits[1].Text, "noch ein text") {
t.Errorf("zweiter Treffer = %q", hits[1].Text)
if !strings.Contains(hits[1].Text, "another text") {
t.Errorf("second hit = %q", hits[1].Text)
}
waitStatusIdle(t, d, 3*time.Second)
}
+79 -80
View File
@@ -1,13 +1,13 @@
package test
// Prozessgrenzen-Tests für die CORS-Tür (Task 06): OPTIONS-Preflight und
// echter POST auf /speak mit Headern aus der Config-Liste
// TALK_ALLOWED_ORIGINS — beobachtet als Black Box über rohe HTTP-Requests.
// Bindend: exakte Origins; "*" nur explizit; leere Liste = gar keine
// CORS-Header; CORS nur auf /speak. Die Tür ist SERVERSEITIG zu: Jede
// Anfrage mit fremdem Origin-Header außerhalb des erlaubten Wegs (OPTIONS
// /speak, POST /speak von gelisteter Origin) wird mit 403 abgelehnt, bevor
// sie Wirkung hat — CORS allein verhindert nur das Lesen der Antwort.
// Process-boundary tests for the CORS door (Task 06): OPTIONS preflight and
// real POST to /speak with headers from the config list
// TALK_ALLOWED_ORIGINS — observed as a black box via raw HTTP requests.
// Binding: exact origins; "*" only explicitly; empty list = no CORS headers
// at all; CORS only on /speak. The door is closed SERVER-SIDE: every request
// with a foreign Origin header outside the permitted path (OPTIONS /speak,
// POST /speak from a listed origin) is rejected with 403 before it has any
// effect — CORS alone only prevents reading the response.
import (
"net/http"
@@ -17,10 +17,10 @@ import (
const azOrigin = "https://chat.az-gruppe.com"
// TestCORSPraeflightGelisted (Abnahmekriterium): Preflight von gelisteter
// Origin → 204 mit Freigabe-Headern; ungeliste Origin → 204 OHNE
// TestCORSPreflightListed (acceptance criterion): preflight from a listed
// origin → 204 with allow headers; unlisted origin → 204 WITHOUT
// Access-Control-Allow-Origin.
func TestCORSPraeflightGelisted(t *testing.T) {
func TestCORSPreflightListed(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
@@ -29,7 +29,7 @@ func TestCORSPraeflightGelisted(t *testing.T) {
raw := preflight(t, d, azOrigin)
if raw.status != http.StatusNoContent {
t.Fatalf("Preflight-Status = %d, want 204", raw.status)
t.Fatalf("preflight status = %d, want 204", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != azOrigin {
t.Errorf("ACAO = %q, want %q", got, azOrigin)
@@ -41,23 +41,23 @@ func TestCORSPraeflightGelisted(t *testing.T) {
t.Errorf("Access-Control-Allow-Headers = %q, want %q", headers, "Content-Type")
}
if vary := raw.header.Get("Vary"); vary != "Origin" {
t.Errorf("Vary = %q, want Origin (Cache-Korrektheit)", vary)
t.Errorf("Vary = %q, want Origin (cache correctness)", vary)
}
// Ungelistete Origin: sauberer 204, aber bewusst ohne CORS-Header —
// der Browser verwirft den geplanten POST, der Daemon bleibt erreichbar.
// Unlisted origin: a clean 204, but deliberately without CORS headers —
// the browser drops the planned POST, the daemon stays reachable.
raw = preflight(t, d, "https://evil.example")
if raw.status != http.StatusNoContent {
t.Fatalf("Preflight (ungelisted) Status = %d, want 204", raw.status)
t.Fatalf("preflight (unlisted) status = %d, want 204", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("ACAO für ungeliste Origin = %q, want leer", got)
t.Errorf("ACAO for unlisted origin = %q, want empty", got)
}
}
// TestCORSPostGelisted: Auch der echte (non-preflight) POST trägt ACAO,
// wenn die Anfrage-Origin gelistet ist — sonst nicht.
func TestCORSPostGelisted(t *testing.T) {
// TestCORSPostListed: the real (non-preflight) POST also carries ACAO when
// the request origin is listed — otherwise not.
func TestCORSPostListed(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
@@ -67,41 +67,41 @@ func TestCORSPostGelisted(t *testing.T) {
raw := doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Origin": azOrigin,
"Content-Type": "application/json",
}, `{"text":"browser sagt hallo"}`)
}, `{"text":"browser says hello"}`)
if raw.status != http.StatusAccepted {
t.Fatalf("POST /speak Status = %d, want 202", raw.status)
t.Fatalf("POST /speak status = %d, want 202", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != azOrigin {
t.Errorf("ACAO auf POST = %q, want %q", got, azOrigin)
t.Errorf("ACAO on POST = %q, want %q", got, azOrigin)
}
fake.waitCompleted(t, 1)
// Ungelistete Origin: 403 OHNE Wirkung — der Fake-Provider darf keinen
// zweiten Treffer sehen. (Früher 202 ohne CORS-Header: der Browser
// konnte die Antwort nicht lesen, gesprochen wurde trotzdem.)
// Unlisted origin: 403 WITHOUT effect — the fake provider must not see
// a second hit. (Formerly 202 without CORS headers: the browser could
// not read the response, but it was spoken anyway.)
raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Origin": "https://evil.example",
"Content-Type": "application/json",
}, `{"text":"böser browser"}`)
}, `{"text":"evil browser"}`)
if raw.status != http.StatusForbidden {
t.Fatalf("POST /speak (ungelisted) Status = %d, want 403", raw.status)
t.Fatalf("POST /speak (unlisted) status = %d, want 403", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("ACAO für ungeliste Origin = %q, want leer", got)
t.Errorf("ACAO for unlisted origin = %q, want empty", got)
}
fake.assertNoFurtherArrivals(t, 300*time.Millisecond)
}
// TestCORSLeereListe: Ohne allowed_origins (Default) sendet der Daemon
// GAR KEINE CORS-Header — Tür zu, der Browser blockt.
func TestCORSLeereListe(t *testing.T) {
// TestCORSEmptyList: without allowed_origins (default) the daemon sends NO
// CORS headers at all — door closed, the browser blocks.
func TestCORSEmptyList(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil) // keine Origins konfiguriert
d := startDaemon(t, root, fake.srv.URL, nil) // no origins configured
raw := preflight(t, d, azOrigin)
if raw.status != http.StatusNoContent {
t.Fatalf("Preflight-Status = %d, want 204", raw.status)
t.Fatalf("preflight status = %d, want 204", raw.status)
}
for _, h := range []string{
"Access-Control-Allow-Origin",
@@ -109,31 +109,31 @@ func TestCORSLeereListe(t *testing.T) {
"Access-Control-Allow-Headers",
} {
if got := raw.header.Get(h); got != "" {
t.Errorf("%s = %q bei leerer Liste, want nicht gesetzt", h, got)
t.Errorf("%s = %q with an empty list, want not set", h, got)
}
}
raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Origin": azOrigin,
"Content-Type": "application/json",
}, `{"text":"ohne cors"}`)
}, `{"text":"without cors"}`)
if raw.status != http.StatusForbidden {
t.Fatalf("POST /speak mit Origin bei leerer Liste: Status = %d, want 403", raw.status)
t.Fatalf("POST /speak with Origin and an empty list: status = %d, want 403", raw.status)
}
fake.assertNoHits(t, 300*time.Millisecond)
// Ohne Origin-Header (CLI, curl) bleibt derselbe POST erlaubt.
// Without an Origin header (CLI, curl) the same POST remains allowed.
raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Content-Type": "application/json",
}, `{"text":"ohne origin"}`)
}, `{"text":"without origin"}`)
if raw.status != http.StatusAccepted {
t.Fatalf("POST /speak ohne Origin: Status = %d, want 202", raw.status)
t.Fatalf("POST /speak without Origin: status = %d, want 202", raw.status)
}
fake.waitCompleted(t, 1)
}
// TestCORSWildcard: Der Eintrag "*" erlaubt jede Origin ausdrücklich —
// ACAO ist dann das literale "*".
// TestCORSWildcard: the entry "*" explicitly allows every origin — ACAO is
// then the literal "*".
func TestCORSWildcard(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
@@ -141,21 +141,21 @@ func TestCORSWildcard(t *testing.T) {
"TALK_ALLOWED_ORIGINS": "*",
})
for _, origin := range []string{"https://irgendwo.example", "http://localhost:5173"} {
for _, origin := range []string{"https://somewhere.example", "http://localhost:5173"} {
raw := preflight(t, d, origin)
if raw.status != http.StatusNoContent {
t.Fatalf("Preflight von %s: Status = %d, want 204", origin, raw.status)
t.Fatalf("preflight from %s: status = %d, want 204", origin, raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("ACAO von %s = %q, want *", origin, got)
t.Errorf("ACAO from %s = %q, want *", origin, got)
}
}
}
// TestCORSNurAufSpeak: Kein anderer Endpunkt ist aus dem Browser erreichbar
// — auch nicht von einer gelisteten Origin: OPTIONS und GET auf /status mit
// Origin-Header sind 403 ohne CORS-Header.
func TestCORSNurAufSpeak(t *testing.T) {
// TestCORSOnlyOnSpeak: no other endpoint is reachable from the browser — not
// even from a listed origin: OPTIONS and GET on /status with an Origin
// header are 403 without CORS headers.
func TestCORSOnlyOnSpeak(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
@@ -167,79 +167,78 @@ func TestCORSNurAufSpeak(t *testing.T) {
"Access-Control-Request-Method": http.MethodGet,
}, "")
if raw.status != http.StatusForbidden {
t.Fatalf("OPTIONS /status Status = %d, want 403 (kein Browser-Zugriff)", raw.status)
t.Fatalf("OPTIONS /status status = %d, want 403 (no browser access)", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("OPTIONS /status ACAO = %q, want leer", got)
t.Errorf("OPTIONS /status ACAO = %q, want empty", got)
}
raw = doRequest(t, http.MethodGet, d.baseURL+"/status", map[string]string{
"Origin": azOrigin,
}, "")
if raw.status != http.StatusForbidden {
t.Fatalf("GET /status mit Origin: Status = %d, want 403", raw.status)
t.Fatalf("GET /status with Origin: status = %d, want 403", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("GET /status ACAO = %q, want leer (CORS nur auf /speak)", got)
t.Errorf("GET /status ACAO = %q, want empty (CORS only on /speak)", got)
}
}
// TestBrowserKannNichtSteuern: Der eigentliche Angriffsfall. Eine beliebige
// Webseite kann ohne Preflight „simple requests" absetzen (POST mit
// text/plain-Body, Formular-POST ohne Body) — der Browser blockt nur das
// Lesen der Antwort, nicht das Senden. talkd darf darauf weder sprechen noch
// muten noch abbrechen noch das MCP-Tool ausführen: alles 403, Zustand
// unverändert.
func TestBrowserKannNichtSteuern(t *testing.T) {
// TestBrowserCannotControl: the actual attack case. Any web page can send
// "simple requests" without preflight (POST with a text/plain body, form
// POST without body) — the browser only blocks reading the response, not
// sending. talkd must neither speak nor mute nor cancel nor run the MCP tool
// on these: all 403, state unchanged.
func TestBrowserCannotControl(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_ALLOWED_ORIGINS": azOrigin, // Tür für EINE Origin offen — andere trotzdem zu
"TALK_ALLOWED_ORIGINS": azOrigin, // door open for ONE origin — others still closed
})
evil := "https://evil.example"
// Simple request: POST mit text/plain-Body, kein Preflight.
// Simple request: POST with a text/plain body, no preflight.
raw := doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Origin": evil,
"Content-Type": "text/plain",
}, `{"text":"gekapert","now":true}`)
}, `{"text":"hijacked","now":true}`)
if raw.status != http.StatusForbidden {
t.Fatalf("simple POST /speak von %s: Status = %d, want 403", evil, raw.status)
t.Fatalf("simple POST /speak from %s: status = %d, want 403", evil, raw.status)
}
// Steuer-Endpunkte brauchen keinen Body — ein Formular-POST reicht.
// Control endpoints need no body — a form POST is enough.
for _, path := range []string{"/mute", "/unmute", "/cancel"} {
raw = doRequest(t, http.MethodPost, d.baseURL+path, map[string]string{"Origin": evil}, "")
if raw.status != http.StatusForbidden {
t.Errorf("POST %s von %s: Status = %d, want 403", path, evil, raw.status)
t.Errorf("POST %s from %s: status = %d, want 403", path, evil, raw.status)
}
}
// Auch die gelistete Origin darf NICHT steuern — nur sprechen.
// Even the listed origin must NOT control — only speak.
raw = doRequest(t, http.MethodPost, d.baseURL+"/mute", map[string]string{"Origin": azOrigin}, "")
if raw.status != http.StatusForbidden {
t.Errorf("POST /mute von gelisteter Origin: Status = %d, want 403 (CORS nur auf /speak)", raw.status)
t.Errorf("POST /mute from listed origin: status = %d, want 403 (CORS only on /speak)", raw.status)
}
// MCP aus dem Browser: ebenfalls zu.
// MCP from the browser: closed as well.
raw = doRequest(t, http.MethodPost, d.baseURL+"/mcp", map[string]string{
"Origin": evil,
"Content-Type": "text/plain",
}, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"speak","arguments":{"text":"gekapert"}}}`)
}, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"speak","arguments":{"text":"hijacked"}}}`)
if raw.status != http.StatusForbidden {
t.Errorf("POST /mcp von %s: Status = %d, want 403", evil, raw.status)
t.Errorf("POST /mcp from %s: status = %d, want 403", evil, raw.status)
}
// Nichts davon hatte Wirkung: kein Treffer beim Provider, nicht gemutet.
// None of it had any effect: no hit at the provider, not muted.
fake.assertNoHits(t, 300*time.Millisecond)
if st := getStatus(t, d); st.Muted {
t.Errorf("Daemon ist gemutet — Browser-POST /mute hat gewirkt")
t.Errorf("daemon is muted — browser POST /mute took effect")
}
}
// TestStatusCorsFeld: GET /status trägt die CORS-Origins als cors-Feld —
// gelistet als [], wenn die Tür zu ist (Compat-Anforderung Task 06).
func TestStatusCorsFeld(t *testing.T) {
t.Run("gelistet", func(t *testing.T) {
// TestStatusCorsField: GET /status carries the CORS origins as the cors
// field — listed as [] when the door is closed (compat requirement Task 06).
func TestStatusCorsField(t *testing.T) {
t.Run("listed", func(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
@@ -247,17 +246,17 @@ func TestStatusCorsFeld(t *testing.T) {
})
st := getStatus(t, d)
if len(st.Cors) != 2 || st.Cors[0] != azOrigin || st.Cors[1] != "https://grid.ag" {
t.Errorf("cors = %v, want beide gelisteten Origins", st.Cors)
t.Errorf("cors = %v, want both listed origins", st.Cors)
}
})
t.Run("leer = [] (nicht null)", func(t *testing.T) {
t.Run("empty = [] (not null)", func(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
st := getStatus(t, d)
if st.Cors == nil || len(st.Cors) != 0 {
t.Errorf("cors = %v, want [] bei geschlossener Tür", st.Cors)
t.Errorf("cors = %v, want [] with the door closed", st.Cors)
}
})
}
+12 -12
View File
@@ -1,15 +1,15 @@
package test
// Prozessgrenzen-Tests für die Diagnose-Befehle gegen einen echten Daemon.
// Process-boundary tests for the diagnostic commands against a real daemon.
import (
"strings"
"testing"
)
// TestDoctorMitDaemon: Läuft der Daemon, ist er ✔ mit Version und Log-
// Pfad; voices und test funktionieren als Prozess.
func TestDoctorMitDaemon(t *testing.T) {
// TestDoctorWithDaemon: when the daemon runs it is ✔ with version and log
// path; voices and test work as processes.
func TestDoctorWithDaemon(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
@@ -17,24 +17,24 @@ func TestDoctorMitDaemon(t *testing.T) {
res := runTalk(t, env, "doctor")
if res.code != 0 {
t.Fatalf("talk doctor: Exit %d\n%s\n%s", res.code, res.stdout, res.stderr)
t.Fatalf("talk doctor: exit %d\n%s\n%s", res.code, res.stdout, res.stderr)
}
for _, want := range []string{"✔ Daemon", "läuft unter " + d.baseURL, "daemon.log", "✔ elevenlabs", "Key gesetzt"} {
for _, want := range []string{"✔ Daemon", "running at " + d.baseURL, "daemon.log", "✔ elevenlabs", "key set"} {
if !strings.Contains(res.stdout, want) {
t.Errorf("doctor ohne %q:\n%s", want, res.stdout)
t.Errorf("doctor without %q:\n%s", want, res.stdout)
}
}
res = runTalk(t, env, "doctor", "--probe")
if res.code != 0 || !strings.Contains(res.stdout, "Probe ok") {
t.Errorf("doctor --probe: Exit %d\n%s", res.code, res.stdout)
if res.code != 0 || !strings.Contains(res.stdout, "probe ok") {
t.Errorf("doctor --probe: exit %d\n%s", res.code, res.stdout)
}
fake.waitArrived(t, 1)
if res := runTalk(t, env, "voices"); res.code != 0 || !strings.HasPrefix(res.stdout, "PROVIDER") {
t.Errorf("talk voices: Exit %d, stdout %q", res.code, res.stdout)
t.Errorf("talk voices: exit %d, stdout %q", res.code, res.stdout)
}
if res := runTalk(t, env, "test"); res.code != 0 || !strings.Contains(res.stdout, "provider: elevenlabs") {
t.Errorf("talk test: Exit %d, stdout %q, stderr %q", res.code, res.stdout, res.stderr)
if res := runTalk(t, env, "test"); res.code != 0 || !strings.Contains(res.stdout, "provider: elevenlabs") {
t.Errorf("talk test: exit %d, stdout %q, stderr %q", res.code, res.stdout, res.stderr)
}
}
+101 -100
View File
@@ -1,12 +1,12 @@
// Package test enthält die Prozessgrenzen-Tests von talk 2.0 — die primäre
// Test-Seam laut Spec: talkd und talk laufen als echte Prozesse (gebuildet
// aus diesem Repo), angetrieben über HTTP und die CLI, mit zwei injizierten
// Fakes: einem ElevenLabs-Fake-Endpunkt (httptest-Server mit Audio-Bytes)
// und audio.sink=null. Getestet wird nur extern beobachtbares Verhalten:
// HTTP-Antworten, CLI-Exit-Codes/-Output, Fake-Treffer (Reihenfolge und
// Zeit), Dateien im Config-Dir. Tests binden NIEMALS den Default-Port 7800
// und hängen nie von einem echten Daemon ab — talkd bekommt einen freien
// Port per TALK_PORT, CLI-Prozesse denselben (oder Port 1 für „Daemon tot").
// Package test contains the process-boundary tests of talk 2.0 — the primary
// test seam according to the spec: talkd and talk run as real processes
// (built from this repo), driven via HTTP and the CLI, with two injected
// fakes: an ElevenLabs fake endpoint (httptest server with audio bytes)
// and audio.sink=null. Only externally observable behaviour is tested:
// HTTP responses, CLI exit codes/output, fake hits (order and timing),
// files in the config dir. Tests NEVER bind the default port 7800 and never
// depend on a real daemon — talkd gets a free port via TALK_PORT, CLI
// processes the same one (or port 1 for "daemon dead").
package test
import (
@@ -27,8 +27,8 @@ import (
"time"
)
// Die Binaries werden einmal pro Testlauf gebaut (Build-Cache macht es
// billig) und von allen Tests geteilt.
// The binaries are built once per test run (the build cache makes it cheap)
// and shared by all tests.
var talkBin string
func TestMain(m *testing.M) {
@@ -56,8 +56,8 @@ func TestMain(m *testing.M) {
os.Exit(code)
}
// syncBuffer ist ein nebenläufigkeitssicherer Buffer (Daemon-stderr wird
// gelesen, während der Prozess läuft).
// syncBuffer is a concurrency-safe buffer (daemon stderr is read while the
// process is running).
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
@@ -75,23 +75,23 @@ func (b *syncBuffer) String() string {
return b.buf.String()
}
// fakeHit ist ein beobachteter Synthese-Request am Fake-Provider.
// fakeHit is an observed synthesis request at the fake provider.
type fakeHit struct {
Text string // Text aus dem Request-Body
Start time.Time // Ankunft am Fake
End time.Time // Antwort abgeschickt bzw. Abbruch gesehen
Completed bool // true: Antwort geschrieben; false: Client hat abgebrochen
Text string // text from the request body
Start time.Time // arrival at the fake
End time.Time // response sent or cancellation seen
Completed bool // true: response written; false: client cancelled
}
// fakeProvider ist ein ElevenLabs-Fake (POST /v1/text-to-speech/<voice>):
// Er antwortet mit ein paar Bytes „Audio" und protokolliert jeden Request
// mit Ankunfts- und Antwort-Zeit — daraus sind Reihenfolge (Textfolge) und
// Serialisierung (Ankunft des nächsten nach Antwort des vorigen) ablesbar.
// delay ist zur Laufzeit umschaltbar, um blockierende Synthesen zu
// simulieren (Serialisierung, Priorität, cancel). Ankünfte werden separat
// und sofort protokolliert: Sie zeigen, dass der Daemon mit einer Synthese
// BESCHÄFTIGT ist (Queue deterministisch befüllbar), noch bevor die
// (verzögerte) Antwort als Treffer landet.
// fakeProvider is an ElevenLabs fake (POST /v1/text-to-speech/<voice>): it
// answers with a few bytes of "audio" and logs every request with arrival
// and response time — from which order (text sequence) and serialization
// (arrival of the next after the response of the previous) can be read.
// delay can be switched at runtime to simulate blocking syntheses
// (serialization, priority, cancel). Arrivals are logged separately and
// immediately: they show that the daemon is BUSY with a synthesis (queue
// can be filled deterministically) before the (delayed) response lands as
// a hit.
type fakeProvider struct {
srv *httptest.Server
mu sync.Mutex
@@ -124,7 +124,7 @@ func (f *fakeProvider) serve(w http.ResponseWriter, r *http.Request) {
if d > 0 {
select {
case <-time.After(d):
case <-r.Context().Done(): // Client (Daemon) hat abgebrochen
case <-r.Context().Done(): // client (daemon) cancelled
f.record(fakeHit{Text: body.Text, Start: start, End: time.Now()})
return
}
@@ -153,7 +153,7 @@ func (f *fakeProvider) snapshot() []fakeHit {
return out
}
// completed liefert nur die abgeschlossenen (nicht abgebrochenen) Treffer.
// completed returns only the completed (not cancelled) hits.
func completed(hits []fakeHit) []fakeHit {
var out []fakeHit
for _, h := range hits {
@@ -164,8 +164,8 @@ func completed(hits []fakeHit) []fakeHit {
return out
}
// waitArrived pollt, bis n Synthese-Requests angekommen sind — der Daemon
// ist damit nachweisbar beschäftigt und die Queue deterministisch befüllbar.
// waitArrived polls until n synthesis requests have arrived — the daemon is
// then demonstrably busy and the queue can be filled deterministically.
func (f *fakeProvider) waitArrived(t *testing.T, n int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
@@ -178,29 +178,30 @@ func (f *fakeProvider) waitArrived(t *testing.T, n int) {
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("nur %d von %d Ankünften am Fake innerhalb 5 s", len(f.snapshot()), n)
t.Fatalf("only %d of %d arrivals at the fake within 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".
// arrivedCount counts the synthesis requests that arrived (including
// cancelled ones) — observation point for "exactly N play, the rest
// parks/expires".
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).
// assertNoFurtherArrivals checks that no further synthesis request arrives
// within the deadline (parked/discarded announcements stay silent).
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())
t.Fatalf("%d additional arrivals (want none) — hits: %+v", got-n, f.snapshot())
}
}
// waitHits pollt, bis n Requests abgeschlossen wurden (Abbrüche zählen).
// waitHits polls until n requests have finished (cancellations count).
func (f *fakeProvider) waitHits(t *testing.T, n int) []fakeHit {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
@@ -210,12 +211,12 @@ func (f *fakeProvider) waitHits(t *testing.T, n int) []fakeHit {
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("nur %d von %d Fake-Treffern innerhalb 5 s (Treffer: %+v)", len(f.snapshot()), n, f.snapshot())
t.Fatalf("only %d of %d fake hits within 5 s (hits: %+v)", len(f.snapshot()), n, f.snapshot())
return nil
}
// waitCompleted pollt, bis n Requests ERFOLGREICH abgeschlossen wurden
// (abgebrochene Synthesen zählen nicht).
// waitCompleted polls until n requests have finished SUCCESSFULLY
// (cancelled syntheses do not count).
func (f *fakeProvider) waitCompleted(t *testing.T, n int) []fakeHit {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
@@ -225,20 +226,20 @@ func (f *fakeProvider) waitCompleted(t *testing.T, n int) []fakeHit {
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("nur %d von %d abgeschlossenen Treffern innerhalb 5 s (alle: %+v)", len(completed(f.snapshot())), n, f.snapshot())
t.Fatalf("only %d of %d completed hits within 5 s (all: %+v)", len(completed(f.snapshot())), n, f.snapshot())
return nil
}
// assertNoHits prüft, dass innerhalb der Frist kein Request ankam.
// assertNoHits checks that no request arrived within the deadline.
func (f *fakeProvider) assertNoHits(t *testing.T, wait time.Duration) {
t.Helper()
time.Sleep(wait)
if hits := f.snapshot(); len(hits) > 0 {
t.Fatalf("unerwartete Fake-Treffer: %+v", hits)
t.Fatalf("unexpected fake hits: %+v", hits)
}
}
// daemonProc ist ein laufender talkd-Prozess.
// daemonProc is a running talkd process.
type daemonProc struct {
cmd *exec.Cmd
port int
@@ -246,9 +247,9 @@ type daemonProc struct {
baseURL string
}
// startDaemon startet `talk daemon run` als echten Prozess (freier Port per
// TALK_PORT, nie der Default-Port) und wartet, bis GET /status antwortet. Stop per
// t.Cleanup: SIGINT, nach Frist Kill.
// startDaemon starts `talk daemon run` as a real process (free port via
// TALK_PORT, never the default port) and waits until GET /status answers.
// Stopped via t.Cleanup: SIGINT, kill after a deadline.
func startDaemon(t *testing.T, root, fakeURL string, extra map[string]string) *daemonProc {
t.Helper()
port := freePort(t)
@@ -258,7 +259,7 @@ func startDaemon(t *testing.T, root, fakeURL string, extra map[string]string) *d
cmd.Env = env
cmd.Stderr = stderr
if err := cmd.Start(); err != nil {
t.Fatalf("talk daemon run starten: %v", err)
t.Fatalf("starting talk daemon run: %v", err)
}
d := &daemonProc{
cmd: cmd,
@@ -280,12 +281,12 @@ func startDaemon(t *testing.T, root, fakeURL string, extra map[string]string) *d
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("talk daemon antwortet nicht auf %s — stderr:\n%s", d.baseURL+"/status", stderr.String())
t.Fatalf("talk daemon does not answer on %s — stderr:\n%s", d.baseURL+"/status", stderr.String())
return nil
}
// stop fährt den Daemon-Prozess herunter (SIGINT wie ein menschlicher
// Stop; nach 3 s Kill-Fallback) und wartet auf sein Ende.
// stop shuts the daemon process down (SIGINT like a human stop; kill
// fallback after 3 s) and waits for it to end.
func (d *daemonProc) stop(t *testing.T) {
t.Helper()
if d.cmd.Process == nil {
@@ -302,18 +303,18 @@ func (d *daemonProc) stop(t *testing.T) {
}
}
// daemonEnv baut die Prozess-Umgebung für talk-Prozesse (CLI und Daemon):
// geerbtes Environment ohne fremde TALK_*-Variablen (Isolation von Keys/
// Ports der Außenwelt), isoliertes HOME/XDG_CONFIG_HOME (Config-Dir liegt
// damit unter <root>/talk), audio.sink=null und der Fake-Provider als
// ElevenLabs-Endpunkt. port=1 heißt „garantiert toter Daemon" (Verbindung
// wird sofort abgelehnt).
// daemonEnv builds the process environment for talk processes (CLI and
// daemon): the inherited environment without foreign TALK_* variables
// (isolation from keys/ports of the outside world), isolated HOME/
// XDG_CONFIG_HOME (the config dir is thus <root>/talk), audio.sink=null and
// the fake provider as ElevenLabs endpoint. port=1 means "guaranteed dead
// daemon" (connection is refused immediately).
//
// 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.
// TALK_RATE_LIMIT=0s is the base default of the process-boundary tests: the
// Task-04 semantics tests (serialization, priority, …) enqueue in quick
// succession and expect immediate playback — the law (default 30 s) would
// park them. The law tests in ratelimit_test.go deliberately override the
// value via extra.
func daemonEnv(root string, port int, fakeURL string, extra map[string]string) []string {
env := make([]string, 0, 32)
for _, kv := range os.Environ() {
@@ -326,12 +327,12 @@ func daemonEnv(root string, port int, fakeURL string, extra map[string]string) [
"HOME": root,
"XDG_CONFIG_HOME": root,
"TALK_AUDIO_SINK": "null",
"TALK_ELEVENLABS_KEY": "prozesstest-key",
"TALK_ELEVENLABS_KEY": "process-test-key",
"TALK_ELEVENLABS_ENDPOINT": fakeURL,
"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", // Kette soll nie auf echte Fremd-Endpunkte fallen
"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", // the chain must never fall back to real third-party endpoints
"TALK_PORT": strconv.Itoa(port),
"TALK_RATE_LIMIT": "0s", // Gesetz aus, bis ein Test es anstellt (siehe oben)
"TALK_AUTOSTART": "false", // kein Test darf unbemerkt einen Daemon hinterlassen; autostart_test.go schaltet es bewusst an
"TALK_RATE_LIMIT": "0s", // law off until a test turns it on (see above)
"TALK_AUTOSTART": "false", // no test may leave a daemon behind unnoticed; autostart_test.go turns it on deliberately
}
for k, v := range extra {
set[k] = v
@@ -342,32 +343,32 @@ func daemonEnv(root string, port int, fakeURL string, extra map[string]string) [
return env
}
// freePort besorgt einen freien Loopback-Port (bind-and-close; der Daemon
// bindet ihn sofort danach neu).
// freePort obtains a free loopback port (bind-and-close; the daemon rebinds
// it right afterwards).
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("freien Port finden: %v", err)
t.Fatalf("finding a free port: %v", err)
}
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port
}
// configDir ist das Config-Verzeichnis unter dem isolierten Root
// (Linux-Pfadlogik: XDG_CONFIG_HOME/<root> → <root>/talk).
// configDir is the config directory under the isolated root (Linux path
// logic: XDG_CONFIG_HOME/<root> → <root>/talk).
func configDir(root string) string {
return filepath.Join(root, "talk")
}
// talkResult ist das Beobachtungsergebnis eines talk-Prozesses.
// talkResult is the observed result of a talk process.
type talkResult struct {
code int
stdout string
stderr string
}
// runTalk führt die talk-CLI als echten Prozess aus.
// runTalk runs the talk CLI as a real process.
func runTalk(t *testing.T, env []string, args ...string) talkResult {
t.Helper()
cmd := exec.Command(talkBin, args...)
@@ -380,12 +381,12 @@ func runTalk(t *testing.T, env []string, args ...string) talkResult {
if ee, ok := err.(*exec.ExitError); ok {
code = ee.ExitCode()
} else if err != nil {
t.Fatalf("talk ausführen: %v (stderr: %s)", err, errBuf.String())
t.Fatalf("running talk: %v (stderr: %s)", err, errBuf.String())
}
return talkResult{code: code, stdout: out.String(), stderr: errBuf.String()}
}
// statusBody ist die dekodierte GET /status-Antwort.
// statusBody is the decoded GET /status response.
type statusBody struct {
Version string `json:"version"`
Queue int `json:"queue"`
@@ -401,8 +402,8 @@ type statusBody struct {
Cors []string `json:"cors"`
}
// getStatus ruft GET /status auf (Deadline: eine Sekunde — der Daemon ist
// lokal und muss antworten, sonst ist das selbst ein Testergebnis).
// getStatus calls GET /status (deadline: one second — the daemon is local
// and must answer, otherwise that is a test result in itself).
func getStatus(t *testing.T, d *daemonProc) statusBody {
t.Helper()
client := &http.Client{Timeout: time.Second}
@@ -412,16 +413,16 @@ func getStatus(t *testing.T, d *daemonProc) statusBody {
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /status: Status %d", resp.StatusCode)
t.Fatalf("GET /status: status %d", resp.StatusCode)
}
var st statusBody
if err := json.NewDecoder(resp.Body).Decode(&st); err != nil {
t.Fatalf("GET /status dekodieren: %v", err)
t.Fatalf("decoding GET /status: %v", err)
}
return st
}
// waitStatusIdle pollt, bis weder ein Item läuft noch wartet.
// waitStatusIdle polls until neither an item is running nor waiting.
func waitStatusIdle(t *testing.T, d *daemonProc, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
@@ -431,13 +432,13 @@ func waitStatusIdle(t *testing.T, d *daemonProc, timeout time.Duration) {
return
}
if time.Now().After(deadline) {
t.Fatalf("Queue wird nicht idle: %+v — stderr:\n%s", st, d.stderr.String())
t.Fatalf("queue does not become idle: %+v — stderr:\n%s", st, d.stderr.String())
}
time.Sleep(25 * time.Millisecond)
}
}
// speak enqueue per rohem HTTP (Prozessgrenze ohne CLI).
// speak enqueues via raw HTTP (process boundary without the CLI).
func speak(t *testing.T, d *daemonProc, body string) (int, map[string]any) {
t.Helper()
resp, err := http.Post(d.baseURL+"/speak", "application/json", strings.NewReader(body))
@@ -447,13 +448,13 @@ func speak(t *testing.T, d *daemonProc, body string) (int, map[string]any) {
defer resp.Body.Close()
var m map[string]any
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
t.Fatalf("POST /speak Antwort dekodieren: %v", err)
t.Fatalf("decoding POST /speak response: %v", err)
}
return resp.StatusCode, m
}
// postPath macht einen leeren POST auf einen Daemon-Endpunkt (/mute,
// /unmute, /cancel) und dekodiert die JSON-Antwort.
// postPath makes an empty POST to a daemon endpoint (/mute, /unmute,
// /cancel) and decodes the JSON response.
func postPath(t *testing.T, d *daemonProc, path string) (int, map[string]any) {
t.Helper()
resp, err := http.Post(d.baseURL+path, "application/json", strings.NewReader("{}"))
@@ -463,22 +464,22 @@ func postPath(t *testing.T, d *daemonProc, path string) (int, map[string]any) {
defer resp.Body.Close()
var m map[string]any
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
t.Fatalf("POST %s Antwort dekodieren: %v", path, err)
t.Fatalf("decoding POST %s response: %v", path, err)
}
return resp.StatusCode, m
}
// rawResponse ist eine vollständig gelesene HTTP-Antwort (Status, Header,
// Body als String) — Beobachtungsform der rohen CORS-/MCP-Tests.
// rawResponse is a fully read HTTP response (status, header, body as a
// string) — the observation form of the raw CORS/MCP tests.
type rawResponse struct {
status int
header http.Header
body string
}
// doRequest macht einen rohen HTTP-Request mit beliebigen Headern und
// optionalem Body und liest die Antwort komplett (CORS-Preflights und
// MCP-JSON-RPC-Payloads sind literal, kein Client-SDK dazwischen).
// doRequest makes a raw HTTP request with arbitrary headers and an optional
// body and reads the response completely (CORS preflights and MCP JSON-RPC
// payloads are literal, no client SDK in between).
func doRequest(t *testing.T, method, url string, headers map[string]string, body string) rawResponse {
t.Helper()
var rd io.Reader
@@ -487,7 +488,7 @@ func doRequest(t *testing.T, method, url string, headers map[string]string, body
}
req, err := http.NewRequest(method, url, rd)
if err != nil {
t.Fatalf("%s %s bauen: %v", method, url, err)
t.Fatalf("building %s %s: %v", method, url, err)
}
for k, v := range headers {
req.Header.Set(k, v)
@@ -499,13 +500,13 @@ func doRequest(t *testing.T, method, url string, headers map[string]string, body
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("%s %s Antwort lesen: %v", method, url, err)
t.Fatalf("reading %s %s response: %v", method, url, err)
}
return rawResponse{status: resp.StatusCode, header: resp.Header, body: string(raw)}
}
// preflight macht einen OPTIONS-Preflight gegen /speak (so wie ein Browser
// es vor einem echten POST tut).
// preflight makes an OPTIONS preflight against /speak (the way a browser
// does before a real POST).
func preflight(t *testing.T, d *daemonProc, origin string) rawResponse {
t.Helper()
return doRequest(t, http.MethodOptions, d.baseURL+"/speak", map[string]string{
@@ -514,7 +515,7 @@ func preflight(t *testing.T, d *daemonProc, origin string) rawResponse {
}, "")
}
// mcpPost POSTet einen literalen JSON-RPC-Payload an /mcp.
// mcpPost POSTs a literal JSON-RPC payload to /mcp.
func mcpPost(t *testing.T, d *daemonProc, payload string) rawResponse {
t.Helper()
return doRequest(t, http.MethodPost, d.baseURL+"/mcp", map[string]string{
@@ -522,13 +523,13 @@ func mcpPost(t *testing.T, d *daemonProc, payload string) rawResponse {
}, payload)
}
// mcpDecode dekodiert eine JSON-RPC-Antwort in eine generische Map (für
// Feld-Zugriffe der literalen Payload-Tests).
// mcpDecode decodes a JSON-RPC response into a generic map (for field
// access in the literal payload tests).
func mcpDecode(t *testing.T, raw rawResponse) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal([]byte(raw.body), &m); err != nil {
t.Fatalf("JSON-RPC-Antwort dekodieren: %v — Body: %s", err, raw.body)
t.Fatalf("decoding JSON-RPC response: %v — body: %s", err, raw.body)
}
return m
}
+31 -31
View File
@@ -1,9 +1,9 @@
package test
// Prozessgrenzen-Tests für die Längen- und Alters-Gesetze: queue.max_text
// kürzt überlange Texte beim Eintritt (Antwort truncated, CLI-Hinweis, der
// Provider sieht die gekürzte Fassung); queue.max_age verwirft Ansagen, die
// zu lange geparkt haben (expired, nie gesprochen).
// Process-boundary tests for the length and age laws: queue.max_text
// truncates overlong texts on admission (response truncated, CLI note, the
// provider sees the truncated version); queue.max_age discards
// announcements that parked too long (expired, never spoken).
import (
"strings"
@@ -11,48 +11,48 @@ import (
"time"
)
// TestMaxTextKuerzt: Ein 60-Wörter-Text gegen max_text=40 — /speak meldet
// truncated, die CLI warnt auf stderr, der Fake-Provider bekommt höchstens
// 40 Zeichen, und /status zeigt das Limit.
func TestMaxTextKuerzt(t *testing.T) {
// TestMaxTextTruncates: a 60-word text against max_text=40 — /speak reports
// truncated, the CLI warns on stderr, the fake provider receives at most 40
// characters, and /status shows the limit.
func TestMaxTextTruncates(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_MAX_TEXT": "40"})
lang := strings.Repeat("viel zu lange Antwort ", 12)
long := strings.Repeat("far too long an answer ", 12)
code, m := speak(t, d, `{"text":"`+lang+`"}`)
code, m := speak(t, d, `{"text":"`+long+`"}`)
if code != 202 || m["id"] == "" || m["truncated"] != true {
t.Fatalf("speak: %d %v, want 202 mit id und truncated=true", code, m)
t.Fatalf("speak: %d %v, want 202 with id and truncated=true", code, m)
}
hits := fake.waitCompleted(t, 1)
if n := len([]rune(hits[0].Text)); n > 40 || !strings.HasPrefix(hits[0].Text, "viel zu lange Antwort") {
t.Errorf("Provider bekam %q (%d Zeichen), want ≤ 40 Zeichen vom Anfang", hits[0].Text, n)
if n := len([]rune(hits[0].Text)); n > 40 || !strings.HasPrefix(hits[0].Text, "far too long an answer") {
t.Errorf("provider received %q (%d characters), want ≤ 40 characters from the start", hits[0].Text, n)
}
env := daemonEnv(root, d.port, fake.srv.URL, nil)
res := runTalk(t, env, "say", lang)
if res.code != 0 || !strings.Contains(res.stderr, "gekürzt") {
t.Errorf("talk say (lang): Exit %d, stderr %q — will Kürzungs-Hinweis", res.code, res.stderr)
res := runTalk(t, env, "say", long)
if res.code != 0 || !strings.Contains(res.stderr, "truncated") {
t.Errorf("talk say (long): exit %d, stderr %q — want the truncation note", res.code, res.stderr)
}
fake.waitCompleted(t, 2)
res = runTalk(t, env, "say", "kurz genug")
if strings.Contains(res.stderr, "gekürzt") {
t.Errorf("kurzer Text mit Kürzungs-Hinweis: %q", res.stderr)
res = runTalk(t, env, "say", "short enough")
if strings.Contains(res.stderr, "truncated") {
t.Errorf("short text with truncation note: %q", res.stderr)
}
fake.waitCompleted(t, 3)
res = runTalk(t, env, "status")
if !strings.Contains(res.stdout, "limits: max. 40 Zeichen") {
t.Errorf("talk status ohne Limits-Zeile: %q", res.stdout)
if !strings.Contains(res.stdout, "limits: max 40 characters") {
t.Errorf("talk status without limits line: %q", res.stdout)
}
}
// TestMaxAgeVerwirftGeparkte: Rate-Limit 1 s, max_age 1,5 s, drei normal-
// Ansagen direkt hintereinander: die erste spielt sofort, die zweite parkt
// 1 s (frisch genug), die dritte wäre erst nach 2 s fällig — veraltet,
// verworfen, nie beim Provider.
func TestMaxAgeVerwirftGeparkte(t *testing.T) {
// TestMaxAgeDiscardsParked: rate limit 1 s, max_age 1.5 s, three normal
// announcements right after one another: the first plays immediately, the
// second parks 1 s (fresh enough), the third would be due only after 2 s —
// stale, discarded, never at the provider.
func TestMaxAgeDiscardsParked(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
@@ -60,19 +60,19 @@ func TestMaxAgeVerwirftGeparkte(t *testing.T) {
"TALK_MAX_AGE": "1500ms",
})
for _, text := range []string{"eins", "zwei", "drei"} {
for _, text := range []string{"one", "two", "three"} {
code, m := speak(t, d, `{"text":"`+text+`"}`)
if code != 202 || m["id"] == "" {
t.Fatalf("speak %s: %d %v — beim Eintritt darf nichts verfallen", text, code, m)
t.Fatalf("speak %s: %d %v — nothing may expire on admission", text, code, m)
}
}
hits := fake.waitCompleted(t, 2)
if hits[0].Text != "eins" || hits[1].Text != "zwei" {
t.Errorf("gesprochen = %q, %q; want eins, zwei", hits[0].Text, hits[1].Text)
if hits[0].Text != "one" || hits[1].Text != "two" {
t.Errorf("spoken = %q, %q; want one, two", hits[0].Text, hits[1].Text)
}
fake.assertNoFurtherArrivals(t, 1500*time.Millisecond)
waitStatusIdle(t, d, 3*time.Second)
if !strings.Contains(d.stderr.String(), "max_age") {
t.Errorf("Daemon-Protokoll nennt den Verfall nicht:\n%s", d.stderr.String())
t.Errorf("daemon log does not mention the expiry:\n%s", d.stderr.String())
}
}
+86 -84
View File
@@ -1,10 +1,10 @@
package test
// Prozessgrenzen-Tests für den MCP-Endpoint (Task 06): der komplette
// Handshake (initialize → notifications/initialized → tools/list →
// tools/call) als rohe JSON-RPC-HTTP-Payloads gegen einen echten talkd —
// literal, ohne Client-SDK. tools/call muss sich exakt wie POST /speak
// verhalten: dieselbe Queue-Aufnahme inklusive Mute und Gesetz.
// Process-boundary tests for the MCP endpoint (Task 06): the complete
// handshake (initialize → notifications/initialized → tools/list →
// tools/call) as raw JSON-RPC HTTP payloads against a real talkd — literal,
// without a client SDK. tools/call must behave exactly like POST /speak: the
// same queue admission including mute and law.
import (
"fmt"
@@ -14,26 +14,26 @@ import (
"time"
)
// mcpToolText liest content[0].text aus einem tools/call-Ergebnis.
// mcpToolText reads content[0].text from a tools/call result.
func mcpToolText(t *testing.T, m map[string]any) string {
t.Helper()
result, ok := m["result"].(map[string]any)
if !ok {
t.Fatalf("tools/call ohne result: %v", m)
t.Fatalf("tools/call without result: %v", m)
}
content, ok := result["content"].([]any)
if !ok || len(content) == 0 {
t.Fatalf("tools/call ohne content: %v", result)
t.Fatalf("tools/call without content: %v", result)
}
first, ok := content[0].(map[string]any)
if !ok {
t.Fatalf("content[0] ist kein Objekt: %v", content[0])
t.Fatalf("content[0] is not an object: %v", content[0])
}
text, _ := first["text"].(string)
return text
}
// mcpIsError liest isError (fehlt = false — bewusste Semantik).
// mcpIsError reads isError (missing = false — deliberate semantics).
func mcpIsError(m map[string]any) bool {
if result, ok := m["result"].(map[string]any); ok {
if e, ok := result["isError"].(bool); ok {
@@ -43,72 +43,73 @@ func mcpIsError(m map[string]any) bool {
return false
}
// TestMCPHandshake (Abnahmekriterium): die curl-Sequenz initialize →
// notifications/initialized → tools/list → tools/call sieht das speak-Tool
// und enqueued erfolgreich — die Ansage läuft durch Queue und Fake-Provider.
// TestMCPHandshake (acceptance criterion): the curl sequence initialize →
// notifications/initialized → tools/list → tools/call sees the speak tool
// and enqueues successfully — the announcement runs through the queue and
// the fake provider.
func TestMCPHandshake(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
// 1. initialize: angefragte Version wird geechot; capabilities nennen
// tools; serverInfo ist talkd.
// 1. initialize: the requested version is echoed; capabilities name
// tools; serverInfo is talkd.
raw := mcpPost(t, d, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl-test","version":"0"}}}`)
if raw.status != http.StatusOK {
t.Fatalf("initialize: Status %d — Body: %s", raw.status, raw.body)
t.Fatalf("initialize: status %d — body: %s", raw.status, raw.body)
}
if ct := raw.header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("initialize Content-Type = %q, want application/json", ct)
}
m := mcpDecode(t, raw)
if m["jsonrpc"] != "2.0" || fmt.Sprint(m["id"]) != "1" {
t.Fatalf("initialize envelope = %v (jsonrpc/id prüfen)", m)
t.Fatalf("initialize envelope = %v (check jsonrpc/id)", m)
}
result, ok := m["result"].(map[string]any)
if !ok {
t.Fatalf("initialize ohne result: %v", m)
t.Fatalf("initialize without result: %v", m)
}
if result["protocolVersion"] != "2025-06-18" {
t.Errorf("protocolVersion = %v, want Echo der angefragten 2025-06-18", result["protocolVersion"])
t.Errorf("protocolVersion = %v, want echo of the requested 2025-06-18", result["protocolVersion"])
}
caps, ok := result["capabilities"].(map[string]any)
if !ok {
t.Fatalf("capabilities = %v, want Objekt", result["capabilities"])
t.Fatalf("capabilities = %v, want object", result["capabilities"])
}
if _, ok := caps["tools"]; !ok {
t.Errorf("capabilities ohne tools: %v", caps)
t.Errorf("capabilities without tools: %v", caps)
}
serverInfo, _ := result["serverInfo"].(map[string]any)
if serverInfo["name"] != "talkd" {
t.Errorf("serverInfo.name = %v, want talkd", serverInfo["name"])
}
if v, _ := serverInfo["version"].(string); v == "" {
t.Error("serverInfo.version ist leer")
t.Error("serverInfo.version is empty")
}
// Andere angefragte Version wird ebenso geechot (akzeptiert).
// Another requested version is echoed as well (accepted).
raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}`)
m = mcpDecode(t, raw)
if m["result"].(map[string]any)["protocolVersion"] != "2025-03-26" {
t.Errorf("protocolVersion = %v, want Echo 2025-03-26", m["result"])
t.Errorf("protocolVersion = %v, want echo 2025-03-26", m["result"])
}
// 2. notifications/initialized: Notification → 202 ohne Body.
// 2. notifications/initialized: notification → 202 without body.
raw = mcpPost(t, d, `{"jsonrpc":"2.0","method":"notifications/initialized"}`)
if raw.status != http.StatusAccepted {
t.Fatalf("notifications/initialized: Status %d, want 202", raw.status)
t.Fatalf("notifications/initialized: status %d, want 202", raw.status)
}
if raw.body != "" {
t.Errorf("notifications/initialized Body = %q, want leer", raw.body)
t.Errorf("notifications/initialized body = %q, want empty", raw.body)
}
// 3. tools/list: genau das speak-Tool mit dem Brief-Schema.
// 3. tools/list: exactly the speak tool with the briefed schema.
raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":3,"method":"tools/list"}`)
m = mcpDecode(t, raw)
result = m["result"].(map[string]any)
tools, ok := result["tools"].([]any)
if !ok || len(tools) != 1 {
t.Fatalf("tools = %v, want genau ein Tool", result["tools"])
t.Fatalf("tools = %v, want exactly one tool", result["tools"])
}
tool := tools[0].(map[string]any)
if tool["name"] != "speak" {
@@ -116,7 +117,7 @@ func TestMCPHandshake(t *testing.T) {
}
schema, ok := tool["inputSchema"].(map[string]any)
if !ok {
t.Fatalf("inputSchema = %v, want Objekt", tool["inputSchema"])
t.Fatalf("inputSchema = %v, want object", tool["inputSchema"])
}
if schema["type"] != "object" {
t.Errorf("inputSchema.type = %v, want object", schema["type"])
@@ -124,75 +125,76 @@ func TestMCPHandshake(t *testing.T) {
props := schema["properties"].(map[string]any)
for _, name := range []string{"text", "priority", "provider", "lang", "now"} {
if _, ok := props[name]; !ok {
t.Errorf("inputSchema ohne property %q: %v", name, props)
t.Errorf("inputSchema without property %q: %v", name, props)
}
}
if len(props) != 5 {
t.Errorf("inputSchema hat %d properties (%v), want genau die 5 aus dem Brief", len(props), props)
t.Errorf("inputSchema has %d properties (%v), want exactly the 5 from the brief", len(props), props)
}
if req, ok := schema["required"].([]any); !ok || len(req) != 1 || req[0] != "text" {
t.Errorf("required = %v, want [text]", schema["required"])
}
// 4. tools/call: Aufnahme wie POST /speak — "queued: <id>" und die
// Ansage taucht am Fake-Provider auf (gleiche Queue, kein Parallelweg).
raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"speak","arguments":{"text":"hallo über mcp"}}}`)
// 4. tools/call: admission like POST /speak — "queued: <id>" and the
// announcement shows up at the fake provider (same queue, no
// parallel path).
raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"speak","arguments":{"text":"hello via mcp"}}}`)
if raw.status != http.StatusOK {
t.Fatalf("tools/call: Status %d — Body: %s", raw.status, raw.body)
t.Fatalf("tools/call: status %d — body: %s", raw.status, raw.body)
}
m = mcpDecode(t, raw)
if fmt.Sprint(m["id"]) != "4" {
t.Errorf("tools/call id = %v, want Echo 4", m["id"])
t.Errorf("tools/call id = %v, want echo 4", m["id"])
}
text := mcpToolText(t, m)
if !strings.HasPrefix(text, "queued: ") || len(text) <= len("queued: ") {
t.Fatalf("tools/call text = %q, want \"queued: <id>\"", text)
}
if mcpIsError(m) {
t.Errorf("tools/call isError = true bei erfolgreicher Aufnahme: %v", m)
t.Errorf("tools/call isError = true on successful admission: %v", m)
}
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "hallo über mcp" {
t.Errorf("Fake-Treffer = %q, will die MCP-Ansage", hits[0].Text)
if hits[0].Text != "hello via mcp" {
t.Errorf("fake hit = %q, want the MCP announcement", hits[0].Text)
}
}
// TestMCPToolsCallGesetze: muted und Gesetz-Verwürfe sind Tool-ERGEBNISSE
// (isError:false mit erklärendem Text), keine Protokollfehler — und die
// Ansage bleibt stumm.
func TestMCPToolsCallGesetze(t *testing.T) {
// TestMCPToolsCallLaws: muted and law discards are tool RESULTS
// (isError:false with explanatory text), not protocol errors — and the
// announcement stays silent.
func TestMCPToolsCallLaws(t *testing.T) {
t.Run("muted", func(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
postPath(t, d, "/mute")
m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"speak","arguments":{"text":"stille bitte"}}}`))
m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"speak","arguments":{"text":"silence please"}}}`))
if mcpIsError(m) {
t.Fatalf("muted ist isError:true — soll Ergebnis sein: %v", m)
t.Fatalf("muted is isError:true — should be a result: %v", m)
}
if text := mcpToolText(t, m); !strings.HasPrefix(text, "muted") {
t.Errorf("text = %q, will muted-Erklärung", text)
t.Errorf("text = %q, want the muted explanation", text)
}
fake.assertNoHits(t, 300*time.Millisecond)
})
t.Run("rate_limit verwirft low", func(t *testing.T) {
t.Run("rate_limit discards low", func(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "30s"})
// Erste low-Ansage spielt (Fenster frei) und bucht es.
m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"speak","arguments":{"text":"erste","priority":"low"}}}`))
// The first low announcement plays (window free) and books it.
m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"speak","arguments":{"text":"first","priority":"low"}}}`))
if text := mcpToolText(t, m); !strings.HasPrefix(text, "queued: ") {
t.Fatalf("erste low: text = %q, want queued", text)
t.Fatalf("first low: text = %q, want queued", text)
}
fake.waitCompleted(t, 1)
// Zweite low innerhalb des Fensters: Verwurf als Ergebnis.
m = mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"speak","arguments":{"text":"zweite","priority":"low"}}}`))
// Second low inside the window: discard as a result.
m = mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"speak","arguments":{"text":"second","priority":"low"}}}`))
if mcpIsError(m) {
t.Fatalf("Verwurf ist isError:true — soll Ergebnis sein: %v", m)
t.Fatalf("discard is isError:true — should be a result: %v", m)
}
if text := mcpToolText(t, m); !strings.HasPrefix(text, "discarded: rate_limit") {
t.Errorf("text = %q, want discarded: rate_limit …", text)
@@ -200,16 +202,16 @@ func TestMCPToolsCallGesetze(t *testing.T) {
fake.assertNoFurtherArrivals(t, 300*time.Millisecond)
})
t.Run("quiet_hours verwirft", func(t *testing.T) {
t.Run("quiet_hours discards", func(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_HOURS": windowAround(),
})
m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"speak","arguments":{"text":"nachts"}}}`))
m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"speak","arguments":{"text":"at night"}}}`))
if mcpIsError(m) {
t.Fatalf("Ruhezeit-Verwurf ist isError:true — soll Ergebnis sein: %v", m)
t.Fatalf("quiet-hours discard is isError:true — should be a result: %v", m)
}
if text := mcpToolText(t, m); !strings.HasPrefix(text, "discarded: quiet_hours") {
t.Errorf("text = %q, want discarded: quiet_hours …", text)
@@ -218,10 +220,10 @@ func TestMCPToolsCallGesetze(t *testing.T) {
})
}
// TestMCPFehlerPfade: Validierungsfehler als JSON-RPC-Fehler (-32602),
// unbekannte Methode -32601, kaputtes JSON -32700, falsche jsonrpc-Version
// und Batches -32600, GET /mcp 405 (keine server-initiierten Streams).
func TestMCPFehlerPfade(t *testing.T) {
// TestMCPErrorPaths: validation errors as JSON-RPC errors (-32602), unknown
// method -32601, broken JSON -32700, wrong jsonrpc version and batches
// -32600, GET /mcp 405 (no server-initiated streams).
func TestMCPErrorPaths(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
@@ -232,34 +234,34 @@ func TestMCPFehlerPfade(t *testing.T) {
code float64
msgPart string
}{
{"text fehlt", `{"jsonrpc":"2.0","id":21,"method":"tools/call","params":{"name":"speak","arguments":{}}}`, -32602, "text"},
{"text leer", `{"jsonrpc":"2.0","id":22,"method":"tools/call","params":{"name":"speak","arguments":{"text":" "}}}`, -32602, "text"},
{"ungültige priority", `{"jsonrpc":"2.0","id":23,"method":"tools/call","params":{"name":"speak","arguments":{"text":"x","priority":"mega"}}}`, -32602, "priority"},
{"unbekanntes Tool", `{"jsonrpc":"2.0","id":24,"method":"tools/call","params":{"name":"sing","arguments":{"text":"x"}}}`, -32602, "sing"},
{"unbekannte Methode", `{"jsonrpc":"2.0","id":25,"method":"resources/list"}`, -32601, "resources/list"},
{"kaputtes JSON", `{"jsonrpc":"2.0","id":`, -32700, "JSON"},
{"falsche jsonrpc-Version", `{"jsonrpc":"1.0","id":26,"method":"ping"}`, -32600, "2.0"},
{"Batch (seit 2025-06-18 entfernt)", `[{"jsonrpc":"2.0","id":27,"method":"ping"}]`, -32600, "Batch"},
{"text missing", `{"jsonrpc":"2.0","id":21,"method":"tools/call","params":{"name":"speak","arguments":{}}}`, -32602, "text"},
{"text empty", `{"jsonrpc":"2.0","id":22,"method":"tools/call","params":{"name":"speak","arguments":{"text":" "}}}`, -32602, "text"},
{"invalid priority", `{"jsonrpc":"2.0","id":23,"method":"tools/call","params":{"name":"speak","arguments":{"text":"x","priority":"mega"}}}`, -32602, "priority"},
{"unknown tool", `{"jsonrpc":"2.0","id":24,"method":"tools/call","params":{"name":"sing","arguments":{"text":"x"}}}`, -32602, "sing"},
{"unknown method", `{"jsonrpc":"2.0","id":25,"method":"resources/list"}`, -32601, "resources/list"},
{"broken JSON", `{"jsonrpc":"2.0","id":`, -32700, "JSON"},
{"wrong jsonrpc version", `{"jsonrpc":"1.0","id":26,"method":"ping"}`, -32600, "2.0"},
{"batch (removed since 2025-06-18)", `[{"jsonrpc":"2.0","id":27,"method":"ping"}]`, -32600, "batch"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
raw := mcpPost(t, d, tc.payload)
if raw.status != http.StatusOK {
t.Fatalf("Status = %d, want 200 (JSON-RPC-Fehler im Body)", raw.status)
t.Fatalf("status = %d, want 200 (JSON-RPC error in the body)", raw.status)
}
m := mcpDecode(t, raw)
rpcErr, ok := m["error"].(map[string]any)
if !ok {
t.Fatalf("kein error-Objekt: %v", m)
t.Fatalf("no error object: %v", m)
}
if rpcErr["code"] != tc.code {
t.Errorf("code = %v, want %v (Body: %s)", rpcErr["code"], tc.code, raw.body)
t.Errorf("code = %v, want %v (body: %s)", rpcErr["code"], tc.code, raw.body)
}
if msg, _ := rpcErr["message"].(string); !strings.Contains(msg, tc.msgPart) {
t.Errorf("message = %q, will Teil %q", msg, tc.msgPart)
t.Errorf("message = %q, want part %q", msg, tc.msgPart)
}
if _, hasResult := m["result"]; hasResult {
t.Errorf("error-Antwort mit result: %v", m)
t.Errorf("error response with result: %v", m)
}
})
}
@@ -267,34 +269,34 @@ func TestMCPFehlerPfade(t *testing.T) {
t.Run("ping", func(t *testing.T) {
m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":28,"method":"ping"}`))
if _, ok := m["result"]; !ok {
t.Fatalf("ping ohne result: %v", m)
t.Fatalf("ping without result: %v", m)
}
})
t.Run("GET /mcp → 405 (keine server-initiierten Streams)", func(t *testing.T) {
t.Run("GET /mcp → 405 (no server-initiated streams)", func(t *testing.T) {
raw := doRequest(t, http.MethodGet, d.baseURL+"/mcp", nil, "")
if raw.status != http.StatusMethodNotAllowed {
t.Fatalf("GET /mcp Status = %d, want 405", raw.status)
t.Fatalf("GET /mcp status = %d, want 405", raw.status)
}
if allow := raw.header.Get("Allow"); !strings.Contains(allow, "POST") {
t.Errorf("Allow = %q, will POST nennen", allow)
t.Errorf("Allow = %q, want POST named", allow)
}
})
t.Run("Notification mit unbekannter Methode → still 202", func(t *testing.T) {
t.Run("notification with unknown method → silent 202", func(t *testing.T) {
raw := mcpPost(t, d, `{"jsonrpc":"2.0","method":"notifications/roots/list_changed"}`)
if raw.status != http.StatusAccepted || raw.body != "" {
t.Fatalf("Status %d Body %q, want 202 ohne Body", raw.status, raw.body)
t.Fatalf("status %d body %q, want 202 without body", raw.status, raw.body)
}
})
}
// TestStatusMCPFeld: /status meldet mcp:true (Compat-Feld Task 06).
func TestStatusMCPFeld(t *testing.T) {
// TestStatusMCPField: /status reports mcp:true (compat field Task 06).
func TestStatusMCPField(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
if st := getStatus(t, d); !st.MCP {
t.Error("mcp = false, want true (Endpoint ist vorhanden)")
t.Error("mcp = false, want true (endpoint is present)")
}
}
+30 -30
View File
@@ -8,15 +8,15 @@ import (
"time"
)
// TestMutePersistenzUeberRestart (Abnahmekriterium): `talk mute` → Daemon-
// Restart → weiter stumm. Mute verwirft Ansagen dokumentiert mit
// 202 {id:"", muted:true} und ohne jeden Provider-Kontakt.
func TestMutePersistenzUeberRestart(t *testing.T) {
// TestMutePersistenceAcrossRestart (acceptance criterion): `talk mute` →
// daemon restart → still muted. Mute discards announcements as documented
// with 202 {id:"", muted:true} and without any provider contact.
func TestMutePersistenceAcrossRestart(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
// Über die HTTP-API stummschalten: Datei mute liegt im Config-Dir.
// Mute via the HTTP API: the mute file is in the config dir.
code, m := postPath(t, d, "/mute")
if code != 200 || m["muted"] != true {
t.Fatalf("POST /mute: %d %v", code, m)
@@ -24,72 +24,72 @@ func TestMutePersistenzUeberRestart(t *testing.T) {
muteFile := filepath.Join(configDir(root), "mute")
b, err := os.ReadFile(muteFile)
if err != nil {
t.Fatalf("Mute-Datei fehlt im Config-Dir: %v", err)
t.Fatalf("mute file missing in the config dir: %v", err)
}
if strings.TrimSpace(string(b)) != "true" {
t.Errorf("Mute-Datei = %q, want true", b)
t.Errorf("mute file = %q, want true", b)
}
// Ansagen werden verworfen: 202, muted:true, leere ID, kein Treffer.
code, m = speak(t, d, `{"text":"stille"}`)
// Announcements are discarded: 202, muted:true, empty ID, no hit.
code, m = speak(t, d, `{"text":"silence"}`)
if code != 202 || m["muted"] != true || m["id"] != "" {
t.Fatalf("speak bei Mute: %d %v, want 202 {id:, muted:true}", code, m)
t.Fatalf("speak while muted: %d %v, want 202 {id:, muted:true}", code, m)
}
fake.assertNoHits(t, 300*time.Millisecond)
if st := getStatus(t, d); !st.Muted {
t.Fatal("/status meldet muted=false")
t.Fatal("/status reports muted=false")
}
// Daemon-Restart mit demselben Config-Dir: weiter stumm.
// Daemon restart with the same config dir: still muted.
d.stop(t)
d2 := startDaemon(t, root, fake.srv.URL, nil)
if st := getStatus(t, d2); !st.Muted {
t.Fatalf("nach Restart muted = false, want true (persistent)")
t.Fatalf("after restart muted = false, want true (persistent)")
}
code, m = speak(t, d2, `{"text":"immer noch stumm"}`)
code, m = speak(t, d2, `{"text":"still silent"}`)
if code != 202 || m["muted"] != true {
t.Fatalf("speak nach Restart bei Mute: %d %v", code, m)
t.Fatalf("speak after restart while muted: %d %v", code, m)
}
fake.assertNoHits(t, 300*time.Millisecond)
// Unmute: Ansagen laufen wieder.
// Unmute: announcements play again.
code, m = postPath(t, d2, "/unmute")
if code != 200 || m["muted"] != false {
t.Fatalf("POST /unmute: %d %v", code, m)
}
code, m = speak(t, d2, `{"text":"wieder da"}`)
code, m = speak(t, d2, `{"text":"back again"}`)
if code != 202 || m["id"] == "" {
t.Fatalf("speak nach unmute: %d %v, want 202 mit id", code, m)
t.Fatalf("speak after unmute: %d %v, want 202 with id", code, m)
}
hits := fake.waitHits(t, 1)
if hits[0].Text != "wieder da" {
t.Errorf("Treffer = %q, want wieder da", hits[0].Text)
if hits[0].Text != "back again" {
t.Errorf("hit = %q, want back again", hits[0].Text)
}
}
// TestOfflineMuteDateiWirdVonDaemonGelesen: eine offline (CLI, ohne
// Daemon) geschriebene Mute-Datei wirkt sofort im Daemon — beide lesen
// dieselbe Quelle.
func TestOfflineMuteDateiWirdVonDaemonGelesen(t *testing.T) {
// TestOfflineMuteFileIsReadByDaemon: a mute file written offline (CLI,
// without daemon) takes effect in the daemon immediately — both read the
// same source.
func TestOfflineMuteFileIsReadByDaemon(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
deadEnv := daemonEnv(root, 1, fake.srv.URL, nil)
// Offline stummschalten (talk mute ohne Daemon → Datei direkt).
// Mute offline (talk mute without daemon → file directly).
res := runTalk(t, deadEnv, "mute")
if res.code != 0 || res.stdout != "muted\n" {
t.Fatalf("talk mute (offline): %d %q — stderr: %s", res.code, res.stdout, res.stderr)
}
// Der laufende Daemon sieht den Zustand bei der nächsten Ansage.
// The running daemon sees the state on the next announcement.
if st := getStatus(t, d); !st.Muted {
t.Fatal("Daemon sieht offline geschriebene Mute-Datei nicht")
t.Fatal("daemon does not see the mute file written offline")
}
code, m := speak(t, d, `{"text":"soll verworfen werden"}`)
code, m := speak(t, d, `{"text":"should be discarded"}`)
if code != 202 || m["muted"] != true {
t.Fatalf("speak: %d %v, want verworfen", code, m)
t.Fatalf("speak: %d %v, want discarded", code, m)
}
fake.assertNoHits(t, 300*time.Millisecond)
@@ -98,6 +98,6 @@ func TestOfflineMuteDateiWirdVonDaemonGelesen(t *testing.T) {
t.Fatalf("talk unmute (offline): %d — stderr: %s", res.code, res.stderr)
}
if st := getStatus(t, d); st.Muted {
t.Fatal("Daemon sieht offline geschriebene Unmute-Datei nicht")
t.Fatal("daemon does not see the unmute file written offline")
}
}
+11 -11
View File
@@ -1,8 +1,8 @@
package test
// Prozessgrenzen-Test für den Ruhezeit-Toast: quiet_mode = toast in aktiver
// Ruhezeit löst eine Desktop-Benachrichtigung aus — hier ein fake
// notify-send im PATH des Daemons, das seine Argumente protokolliert.
// Process-boundary test for the quiet-hours toast: quiet_mode = toast during
// active quiet hours fires a desktop notification — here a fake notify-send
// in the daemon's PATH that logs its arguments.
import (
"os"
@@ -13,9 +13,9 @@ import (
"time"
)
func TestRuhezeitToastZeigtNotification(t *testing.T) {
func TestQuietHoursToastShowsNotification(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("fake notify-send gibt es nur auf Linux")
t.Skip("the fake notify-send exists only on Linux")
}
root := t.TempDir()
bin := filepath.Join(root, "bin")
@@ -29,25 +29,25 @@ func TestRuhezeitToastZeigtNotification(t *testing.T) {
}
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_QUIET_HOURS": fensterUm(),
"TALK_QUIET_HOURS": windowAround(),
"TALK_QUIET_MODE": "toast",
"PATH": bin + string(os.PathListSeparator) + os.Getenv("PATH"),
})
code, m := speak(t, d, `{"text":"Build fertig, bitte Review"}`)
code, m := speak(t, d, `{"text":"Build done, please review"}`)
if code != 202 || m["discarded"] != "quiet_hours" || m["toast"] != true {
t.Fatalf("speak in Ruhezeit (toast): %d %v", code, m)
t.Fatalf("speak during quiet hours (toast): %d %v", code, m)
}
deadline := time.Now().Add(3 * time.Second)
for {
if b, err := os.ReadFile(seen); err == nil {
if !strings.Contains(string(b), "Build fertig, bitte Review") || !strings.Contains(string(b), "talk") {
t.Errorf("notify-send-Argumente = %q", b)
if !strings.Contains(string(b), "Build done, please review") || !strings.Contains(string(b), "talk") {
t.Errorf("notify-send arguments = %q", b)
}
break
}
if time.Now().After(deadline) {
t.Fatalf("notify-send wurde nicht gerufen — Daemon-Log:\n%s", d.stderr.String())
t.Fatalf("notify-send was not called — daemon log:\n%s", d.stderr.String())
}
time.Sleep(25 * time.Millisecond)
}
+91 -91
View File
@@ -8,26 +8,26 @@ import (
"time"
)
// TestSerialisierung: zwei schnell hintereinander eingereihte Ansagen
// spielen nacheinander — die zweite Synthese erreicht den Fake-Provider
// erst NACH der Antwort der ersten (kein Überlappen), und die Textfolge
// bleibt enqueue-stabil. Der null-Sink verwirft jede Ansage sichtbar.
func TestSerialisierung(t *testing.T) {
// TestSerialization: two announcements enqueued in quick succession play one
// after another — the second synthesis reaches the fake provider only AFTER
// the response to the first (no overlap), and the text order stays
// enqueue-stable. The null sink visibly discards every announcement.
func TestSerialization(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 250*time.Millisecond)
d := startDaemon(t, root, fake.srv.URL, nil)
code, first := speak(t, d, `{"text":"erste ansage"}`)
code, first := speak(t, d, `{"text":"first announcement"}`)
if code != 202 || first["id"] == "" || first["muted"] == true {
t.Fatalf("erste: Status=%d Body=%v, want 202 mit id", code, first)
t.Fatalf("first: status=%d body=%v, want 202 with id", code, first)
}
// Sobald die erste Synthese beim Fake angekommen ist (Daemon
// beschäftigt), zwei weitere schnell hinterher — sie müssen sich
// hinter der laufenden einreihen.
// As soon as the first synthesis has arrived at the fake (daemon busy),
// two more in quick succession — they must line up behind the running
// one.
fake.waitArrived(t, 1)
speak(t, d, `{"text":"zweite ansage"}`)
speak(t, d, `{"text":"dritte ansage"}`)
speak(t, d, `{"text":"second announcement"}`)
speak(t, d, `{"text":"third announcement"}`)
hits := fake.waitHits(t, 3)
@@ -35,74 +35,74 @@ func TestSerialisierung(t *testing.T) {
for _, h := range hits {
texts = append(texts, h.Text)
}
if strings.Join(texts, "|") != "erste ansage|zweite ansage|dritte ansage" {
t.Errorf("Abspiel-Reihenfolge = %v, want FIFO", texts)
if strings.Join(texts, "|") != "first announcement|second announcement|third announcement" {
t.Errorf("playback order = %v, want FIFO", texts)
}
// Serialisierung: die nächste Synthese startet erst nach der Antwort
// der vorherigen (kleine Toleranz für Uhr-Jitter).
// Serialization: the next synthesis starts only after the response to
// the previous one (small tolerance for clock jitter).
const slack = 25 * time.Millisecond
for i := 1; i < len(hits); i++ {
if hits[i].Start.Before(hits[i-1].End.Add(-slack)) {
t.Errorf("Ansage %d überlappt mit Ansage %d: Start %v < Ende %v", i, i-1, hits[i].Start, hits[i-1].End)
t.Errorf("announcement %d overlaps announcement %d: start %v < end %v", i, i-1, hits[i].Start, hits[i-1].End)
}
}
// Erst idle warten: Die dritte Verwurf-Zeile entsteht daemon-seitig
// erst kurz NACH der beim Fake aufgezeichneten Antwort.
// Wait for idle first: the third discard line appears daemon-side only
// shortly AFTER the response recorded at the fake.
waitStatusIdle(t, d, 3*time.Second)
// Der null-Sink hat alle drei verworfen (tonlos, aber sichtbar).
if n := strings.Count(d.stderr.String(), "verworfen (null sink)"); n < 3 {
t.Errorf("null-Sink-Verwürfe im Daemon-Log = %d, want >= 3 (stderr: %s)", n, d.stderr.String())
// The null sink discarded all three (silent, but visible).
if n := strings.Count(d.stderr.String(), "discarded (null sink)"); n < 3 {
t.Errorf("null-sink discards in the daemon log = %d, want >= 3 (stderr: %s)", n, d.stderr.String())
}
}
// TestPrioritaet: während einer langen Ansage werden low/normal/high
// eingereiht — die Abarbeitung folgt high > normal > low (FIFO je Level).
func TestPrioritaet(t *testing.T) {
// TestPriority: during a long announcement low/normal/high are enqueued —
// processing follows high > normal > low (FIFO per level).
func TestPriority(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 200*time.Millisecond)
d := startDaemon(t, root, fake.srv.URL, nil)
speak(t, d, `{"text":"blocker"}`)
fake.waitArrived(t, 1) // Blocker läuft, Worker gebunden.
fake.waitArrived(t, 1) // blocker running, worker bound.
// Eingereiht wird in Klassen-Durcheinander; die Queue-Sicht meldet
// IDs — also text→id einsammeln und die Reihenfolge über IDs prüfen.
// Enqueued in mixed class order; the queue view reports IDs — so
// collect text→id and check the order via IDs.
enq := make(map[string]string)
for _, tc := range []struct{ body, text string }{
{`{"text":"niedrig","priority":"low"}`, "niedrig"},
{`{"text":"normal-eins"}`, "normal-eins"},
{`{"text":"normal-zwei"}`, "normal-zwei"},
{`{"text":"dringend","priority":"high"}`, "dringend"},
{`{"text":"low one","priority":"low"}`, "low one"},
{`{"text":"normal-one"}`, "normal-one"},
{`{"text":"normal-two"}`, "normal-two"},
{`{"text":"urgent","priority":"high"}`, "urgent"},
} {
code, m := speak(t, d, tc.body)
if code != 202 {
t.Fatalf("speak %s: Status %d", tc.body, code)
t.Fatalf("speak %s: status %d", tc.body, code)
}
id, _ := m["id"].(string)
if id == "" {
t.Fatalf("speak %s: keine ID in %v", tc.body, m)
t.Fatalf("speak %s: no ID in %v", tc.body, m)
}
enq[tc.text] = id
}
wantOrder := []string{"dringend", "normal-eins", "normal-zwei", "niedrig"}
wantOrder := []string{"urgent", "normal-one", "normal-two", "low one"}
// Status zeigt aktuelles Item und die wartenden in Abarbeitungs-
// Reihenfolge (deterministisch: der Blocker hält den Worker).
// Status shows the current item and the waiting ones in processing
// order (deterministic: the blocker holds the worker).
st := getStatus(t, d)
if st.Current == nil || st.Current.Text != "blocker" {
t.Fatalf("current = %+v, will blocker", st.Current)
t.Fatalf("current = %+v, want blocker", st.Current)
}
if st.Queue != 4 {
t.Fatalf("Queue-Länge = %d, want 4", st.Queue)
t.Fatalf("queue length = %d, want 4", st.Queue)
}
var wantIDs []string
for _, text := range wantOrder {
wantIDs = append(wantIDs, enq[text])
}
if fmt.Sprint(st.QueueIDs) != fmt.Sprint(wantIDs) {
t.Errorf("queue_ids = %v, want %v (Abarbeitungs-Reihenfolge)", st.QueueIDs, wantIDs)
t.Errorf("queue_ids = %v, want %v (processing order)", st.QueueIDs, wantIDs)
}
hits := fake.waitHits(t, 5)
@@ -112,15 +112,15 @@ func TestPrioritaet(t *testing.T) {
}
want := append([]string{"blocker"}, wantOrder...)
if strings.Join(texts, "|") != strings.Join(want, "|") {
t.Errorf("Abspiel-Reihenfolge = %v, want %v", texts, want)
t.Errorf("playback order = %v, want %v", texts, want)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestNowSpringtVor: --now über die CLI setzt now:true — die Ansage
// springt vor alle wartenden normal-Items (unterbricht aber nicht den
// laufenden Blocker).
func TestNowSpringtVor(t *testing.T) {
// TestNowJumpsAhead: --now via the CLI sets now:true — the announcement
// jumps ahead of all waiting normal items (but does not interrupt the
// running blocker).
func TestNowJumpsAhead(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 600*time.Millisecond)
d := startDaemon(t, root, fake.srv.URL, nil)
@@ -129,14 +129,14 @@ func TestNowSpringtVor(t *testing.T) {
speak(t, d, `{"text":"blocker"}`)
fake.waitArrived(t, 1)
speak(t, d, `{"text":"spaeter"}`)
speak(t, d, `{"text":"later"}`)
res := runTalk(t, env, "say", "--now", "sofort")
res := runTalk(t, env, "say", "--now", "right away")
if res.code != 0 {
t.Fatalf("talk say --now: Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk say --now: exit %d, stderr: %s", res.code, res.stderr)
}
if id := strings.TrimSpace(res.stdout); id == "" || strings.Contains(id, "\n") {
t.Errorf("talk say --now stdout = %q, will eine Zeile mit der Item-ID", res.stdout)
t.Errorf("talk say --now stdout = %q, want one line with the item ID", res.stdout)
}
hits := fake.waitHits(t, 3)
@@ -144,15 +144,15 @@ func TestNowSpringtVor(t *testing.T) {
for _, h := range hits {
texts = append(texts, h.Text)
}
if strings.Join(texts, "|") != "blocker|sofort|spaeter" {
t.Errorf("Abspiel-Reihenfolge = %v, want [blocker sofort spaeter] — now muss vor normal springen", texts)
if strings.Join(texts, "|") != "blocker|right away|later" {
t.Errorf("playback order = %v, want [blocker right away later] — now must jump ahead of normal", texts)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestCancel: bricht die laufende Ansage ab (Synthese-Request wird
// abgebrochen), leert die Queue, meldet die Gesamtzahl — und der Daemon
// bleibt danach funktionstüchtig.
// TestCancel: aborts the running announcement (synthesis request is
// cancelled), empties the queue, reports the total count — and the daemon
// remains functional afterwards.
func TestCancel(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 5*time.Second)
@@ -160,47 +160,47 @@ func TestCancel(t *testing.T) {
code, first := speak(t, d, `{"text":"blocker"}`)
if code != 202 {
t.Fatalf("speak: Status %d", code)
t.Fatalf("speak: status %d", code)
}
fake.waitArrived(t, 1) // Blocker steckt in der Synthese (5 s Fake-Delay).
fake.waitArrived(t, 1) // blocker is stuck in synthesis (5 s fake delay).
code, second := speak(t, d, `{"text":"wartet"}`)
code, second := speak(t, d, `{"text":"waiting"}`)
if code != 202 {
t.Fatalf("speak: Status %d", code)
t.Fatalf("speak: status %d", code)
}
// Status-Vertrag: aktuelles Item + ein wartendes.
// Status contract: current item + one waiting.
st := getStatus(t, d)
if st.Current == nil || st.Current.ID != first["id"] {
t.Fatalf("current = %+v, will den Blocker (%v)", st.Current, first["id"])
t.Fatalf("current = %+v, want the blocker (%v)", st.Current, first["id"])
}
if st.Queue != 1 || len(st.QueueIDs) != 1 || st.QueueIDs[0] != second["id"] {
t.Fatalf("queue = %v (%d), will das wartende Item", st.QueueIDs, st.Queue)
t.Fatalf("queue = %v (%d), want the waiting item", st.QueueIDs, st.Queue)
}
code, m := postPath(t, d, "/cancel")
if code != 200 {
t.Fatalf("POST /cancel: Status %d", code)
t.Fatalf("POST /cancel: status %d", code)
}
if m["cancelled"] != float64(2) {
t.Fatalf("cancelled = %v, want 2 (laufende + wartende)", m["cancelled"])
t.Fatalf("cancelled = %v, want 2 (running + waiting)", m["cancelled"])
}
// Der Daemon wird sofort idle (Synthese abgebrochen) und nimmt neue
// Ansagen an — cancel beschädigt den Daemon nicht.
// The daemon becomes idle immediately (synthesis cancelled) and accepts
// new announcements — cancel does not damage the daemon.
waitStatusIdle(t, d, 2*time.Second)
fake.setDelay(50 * time.Millisecond)
speak(t, d, `{"text":"nachher"}`)
speak(t, d, `{"text":"afterwards"}`)
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "nachher" {
t.Errorf("erster abgeschlossener Treffer = %q, will nachher (abgebrochene zählen nicht)", hits[0].Text)
if hits[0].Text != "afterwards" {
t.Errorf("first completed hit = %q, want afterwards (cancelled ones do not count)", hits[0].Text)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestSpeakValidierung: der HTTP-Vertrag lehnt kaputte Anfragen mit 400
// ab (und 405 bei falscher Methode), ohne den Daemon zu beschädigen.
func TestSpeakValidierung(t *testing.T) {
// TestSpeakValidation: the HTTP contract rejects broken requests with 400
// (and 405 for the wrong method) without damaging the daemon.
func TestSpeakValidation(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
@@ -209,64 +209,64 @@ func TestSpeakValidierung(t *testing.T) {
name string
body string
}{
{"kein JSON", `kein json`},
{"text fehlt", `{}`},
{"text leer", `{"text":" "}`},
{"priority Müll", `{"text":"x","priority":"sofort"}`},
{"provider unbekannt", `{"text":"x","provider":"gibtsnicht"}`},
{"lang Müll", `{"text":"x","lang":"fr"}`},
{"not JSON", `not json`},
{"text missing", `{}`},
{"text empty", `{"text":" "}`},
{"priority garbage", `{"text":"x","priority":"immediately"}`},
{"provider unknown", `{"text":"x","provider":"doesnotexist"}`},
{"lang garbage", `{"text":"x","lang":"fr"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, m := speak(t, d, tc.body)
if code != 400 {
t.Fatalf("Status = %d, want 400 (Body: %v)", code, m)
t.Fatalf("status = %d, want 400 (body: %v)", code, m)
}
if _, ok := m["error"]; !ok {
t.Errorf("Antwort ohne error-Feld: %v", m)
t.Errorf("response without error field: %v", m)
}
})
}
// Falsche Methode → 405 (Methoden-gebundene Routen).
// Wrong method → 405 (method-bound routes).
resp, err := http.Post(d.baseURL+"/status", "application/json", nil)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("POST /status: Status = %d, want 405", resp.StatusCode)
t.Errorf("POST /status: status = %d, want 405", resp.StatusCode)
}
// Danach ist der Daemon weiterhin gesund und speakbar.
code, m := speak(t, d, `{"text":"gesund"}`)
// Afterwards the daemon is still healthy and speakable.
code, m := speak(t, d, `{"text":"healthy"}`)
if code != 202 || m["id"] == "" {
t.Fatalf("nach Validierungs-Fehlern: speak = %d %v, want 202 mit id", code, m)
t.Fatalf("after validation errors: speak = %d %v, want 202 with id", code, m)
}
fake.waitHits(t, 1)
}
// TestStatusVertrag: Felder der Status-Antwort im Leerlauf (Version,
// Mute aus, kein current, Provider aufgelöst).
func TestStatusVertrag(t *testing.T) {
// TestStatusContract: fields of the status response when idle (version,
// mute off, no current, provider resolved).
func TestStatusContract(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
st := getStatus(t, d)
if st.Version != "dev" {
t.Errorf("version = %q, want dev (ohne ldflags gebaut)", st.Version)
t.Errorf("version = %q, want dev (built without ldflags)", st.Version)
}
if st.Muted {
t.Error("muted = true im Frischstart, want false")
t.Error("muted = true on a fresh start, want false")
}
if st.Current != nil {
t.Errorf("current = %+v, want null im Leerlauf", st.Current)
t.Errorf("current = %+v, want null when idle", st.Current)
}
if st.Queue != 0 {
t.Errorf("queue = %d, want 0", st.Queue)
}
// Aktiver Provider: elfenlabs zuerst, weil Key + Fake-Endpunkt gesetzt.
// Active provider: elevenlabs first, because key + fake endpoint are set.
if st.Provider != "elevenlabs" {
t.Errorf("provider = %q, want elevenlabs", st.Provider)
}
+126 -125
View File
@@ -1,10 +1,10 @@
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.
// 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"
@@ -14,17 +14,17 @@ import (
"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 {
// 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 ist die Gesetz-Sicht aus GET /status.
// lawStatus is the law view from GET /status.
type lawStatus struct {
RateLimit struct {
Interval string `json:"interval"`
@@ -39,7 +39,7 @@ type lawStatus struct {
} `json:"quiet_hours"`
}
// getLawStatus ruft GET /status und dekodiert nur die Gesetz-Felder.
// 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")
@@ -49,13 +49,13 @@ func getLawStatus(t *testing.T, d *daemonProc) lawStatus {
defer resp.Body.Close()
var st lawStatus
if err := json.NewDecoder(resp.Body).Decode(&st); err != nil {
t.Fatalf("GET /status dekodieren: %v", err)
t.Fatalf("decoding GET /status: %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.
// 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
@@ -63,104 +63,104 @@ func discardedOf(m map[string]any) string {
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) {
// 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, 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)
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) // erste läuft — das Fenster (1 s) ist gebucht.
fake.waitArrived(t, 1) // first is running — the window (1 s) is booked.
// CLI-Beobachtung: talk say --priority low meldet den Verwurf plain.
res := runTalk(t, env, "say", "--priority", "low", "spam eins")
// 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)
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"} {
// 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 mit leerer id und discarded=rate_limit", text, code, m)
t.Fatalf("low %s: status=%d body=%v, want 202 with empty id and 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)
// 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)
}
// … und sichtbar geparkt: Queue zeigt sie, der nächste Slot liegt in
// der Zukunft.
// … 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] != geparkte["id"] {
t.Fatalf("queue = %v (%d), will die geparkte Ansage", st.QueueIDs, st.Queue)
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 mit Burst-Recht", law.RateLimit)
t.Errorf("rate_limit = %+v, want interval 1s with burst rights", law.RateLimit)
}
if law.RateLimit.NextSlot == nil {
t.Errorf("next_slot = null während geparkter Ansage, want Termin")
t.Errorf("next_slot = null while an announcement is parked, want a time")
}
// Genau eine ist bislang angekommen: die geparkte spielt NICHT vor
// dem Fenster-Ablauf (Abstand zum Fenster-Rand reichlich).
// 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)
// Nach dem Fenster spielt die geparkte — und zwar frühestens zum Slot.
// After the window the parked one plays — at the slot at the earliest.
hits := fake.waitCompleted(t, 2)
if hits[1].Text != "geparkte ansage" {
t.Errorf("zweiter Treffer = %q, will die geparkte Ansage", hits[1].Text)
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("zweiter Start nach %v, want >= Fenster (1 s, Toleranz 150 ms)", gap)
t.Errorf("second start after %v, want >= window (1 s, tolerance 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) {
// 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":"erste"}`)
fake.waitArrived(t, 1) // Fenster für 8 s gebucht.
speak(t, d, `{"text":"first"}`)
fake.waitArrived(t, 1) // window booked for 8 s.
code, m := speak(t, d, `{"text":"dringend","priority":"high"}`)
code, m := speak(t, d, `{"text":"urgent","priority":"high"}`)
if code != 202 || m["id"] == "" {
t.Fatalf("high: Status=%d Body=%v, want 202 mit id", code, m)
t.Fatalf("high: status=%d body=%v, want 202 with id", code, m)
}
// high startet, sobald die erste fertig ist — bewusst innerhalb
// des Fensters (Verstoß mit Recht).
// 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 != "dringend" {
t.Errorf("zweiter Treffer = %q, will dringend", hits[1].Text)
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 startete nach %v — Burst greift nicht (Fenster 8 s)", gap)
t.Errorf("high started after %v — burst does not take effect (window 8 s)", gap)
}
waitStatusIdle(t, d, 3*time.Second)
})
t.Run("burst aus", func(t *testing.T) {
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{
@@ -168,130 +168,131 @@ func TestHighBurstProzess(t *testing.T) {
"TALK_ALLOW_HIGH_BURST": "false",
})
speak(t, d, `{"text":"erste"}`)
speak(t, d, `{"text":"first"}`)
fake.waitArrived(t, 1)
code, m := speak(t, d, `{"text":"dringend","priority":"high"}`)
code, m := speak(t, d, `{"text":"urgent","priority":"high"}`)
if code != 202 || m["id"] == "" {
t.Fatalf("high: Status=%d Body=%v, want 202 mit id (parkt)", code, m)
t.Fatalf("high: status=%d body=%v, want 202 with id (parks)", code, m)
}
// high bleibt geparkt: keine zweite Ankunft, Queue zeigt es.
// 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), will geparktes high", st.QueueIDs, st.Queue)
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")
}
// Aufräumen über cancel — und der Daemon bleibt gesund (now
// umgeht das weiter laufende 8-s-Fenster).
// 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 geparkte Ansage", code, cm)
t.Fatalf("cancel: %d %v, want 1 parked announcement", code, cm)
}
speak(t, d, `{"text":"danach","now":true}`)
speak(t, d, `{"text":"afterwards","now":true}`)
hits := fake.waitCompleted(t, 2)
if hits[1].Text != "danach" {
t.Errorf("Treffer nach cancel = %q, want danach", hits[1].Text)
if hits[1].Text != "afterwards" {
t.Errorf("hit after cancel = %q, want afterwards", hits[1].Text)
}
})
}
// TestNowBypassesRateUndRuhe: now ist der dokumentierte Ausbruch — es
// spielt trotz vollem Rate-Fensters UND aktiver Ruhezeit.
func TestNowBypassesRateUndRuhe(t *testing.T) {
// 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": fensterUm(),
"TALK_QUIET_HOURS": windowAround(),
})
// Normal verfällt in der Ruhezeit (hier: still).
code, m := speak(t, d, `{"text":"ruhig bitte"}`)
// 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 in Ruhezeit: Status=%d Body=%v, want 202 mit leerer id und discarded=quiet_hours", code, m)
t.Fatalf("normal during quiet hours: status=%d body=%v, want 202 with empty id and discarded=quiet_hours", code, m)
}
// now bricht durch: Aufnahme mit ID, Synthese trotz beider Gesetze.
code, m = speak(t, d, `{"text":"notfall","now":true}`)
// 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 mit id", code, m)
t.Fatalf("now: status=%d body=%v, want 202 with id", code, m)
}
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "notfall" {
t.Errorf("Treffer = %q, will notfall", hits[0].Text)
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 (Fenster um jetzt): %+v", law.QuietHours)
t.Errorf("quiet_hours.active = false, want true (window around now): %+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) {
// 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)
fenster := fensterUm()
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_QUIET_HOURS": fenster})
window := windowAround()
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_QUIET_HOURS": window})
for _, body := range []string{
`{"text":"abends eins"}`,
`{"text":"abends dringend","priority":"high"}`,
`{"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 mit leerer id und discarded=quiet_hours", body, code, m)
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, "ruhezeit") || !strings.Contains(log, "verworfen") {
t.Errorf("Daemon-Log will Verwurf-Zeile mit ruhezeit, hat:\n%s", log)
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 != 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.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 im aktiven Fenster, want Termin")
t.Error("quiet_hours.until = null in an active window, want a time")
}
}
// 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) {
// 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": fensterUm(),
"TALK_QUIET_HOURS": windowAround(),
"TALK_QUIET_MODE": "toast",
})
code, m := speak(t, d, `{"text":"abend kurz"}`)
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 mit leerer id, discarded=quiet_hours und toast=true", code, m)
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 will Toast-Zeile (Notification-Pfad), hat:\n%s", log)
t.Errorf("daemon log should have a toast line (notification path), has:\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) {
// 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"})
@@ -299,35 +300,35 @@ func TestStatusGesetzeFelder(t *testing.T) {
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)
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 deaktiviert im Modus discard", law.QuietHours)
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)
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",
"rate-limit: 30s",
"high burst allowed",
"quiet-hours: off",
"mode discard",
} {
if !strings.Contains(res.stdout, want) {
t.Errorf("talk status stdout = %q, will %q", res.stdout, want)
t.Errorf("talk status stdout = %q, want %q", res.stdout, want)
}
}
// Nach einer Ansage steht der nächste Slot: HTTP-Feld + CLI-Zeile.
speak(t, d, `{"text":"einmal reicht"}`)
// 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 nach abgespielter Ansage, want Termin")
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, "nächster slot") {
t.Errorf("talk status nach Ansage = %q (Exit %d), will Slot-Zeile", res.stdout, res.code)
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)
}
}
+76 -55
View File
@@ -1,8 +1,8 @@
// wrap-Prozessgrenzen-Tests (Task 07): talk läuft als echter Prozess,
// talkd als echter Daemon, Fake-Provider + sink null wie im Harness —
// beobachtet wird nur extern: Exit-Codes, Befehls-stdout, Ansage-Text am
// Fake. Die gewrappten Befehle sind echte Binaries (sleep/sh/true), die
// Dauern bewusst klein.
// wrap process-boundary tests (Task 07): talk runs as a real process, talkd
// as a real daemon, fake provider + sink null as in the harness — only the
// externally observable is checked: exit codes, command stdout, announcement
// text at the fake. The wrapped commands are real binaries (sleep/sh/true),
// the durations deliberately small.
package test
import (
@@ -12,9 +12,9 @@ import (
"time"
)
// TestWrapErfolgMitDauer (Abnahmekriterium): `talk wrap -- sleep 1`
// annonciert Erfolg inklusive Dauer und exitet 0.
func TestWrapErfolgMitDauer(t *testing.T) {
// TestWrapSuccessWithDuration (acceptance criterion): `talk wrap -- sleep 1`
// announces success including the duration and exits 0.
func TestWrapSuccessWithDuration(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
@@ -22,20 +22,21 @@ func TestWrapErfolgMitDauer(t *testing.T) {
res := runTalk(t, env, "wrap", "--", "sleep", "1")
if res.code != 0 {
t.Fatalf("talk wrap -- sleep 1: Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk wrap -- sleep 1: exit %d, stderr: %s", res.code, res.stderr)
}
hits := fake.waitCompleted(t, 1)
// sleep 1 misst ~1 s (unter Last gern mehr) — Zahl offen, Form nicht.
if !regexp.MustCompile(`^sleep erfolgreich\. Dauer: \d+ Sekunden?\.$`).MatchString(hits[0].Text) {
t.Errorf("Ansage = %q, will „sleep erfolgreich. Dauer: N Sekunde(n).“", hits[0].Text)
// sleep 1 measures ~1 s (often more under load) — the number is open,
// the form is not.
if !regexp.MustCompile(`^sleep succeeded\. Duration: \d+ seconds?\.$`).MatchString(hits[0].Text) {
t.Errorf("announcement = %q, want \"sleep succeeded. Duration: N second(s).\"", hits[0].Text)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestWrapFehlschlagCodeDurchgereicht (Abnahmekriterium): scheitert der
// Befehl mit Code 2, exitet wrap mit 2 und sagt den Misserfolg mit Code —
// ohne Dauer, wortgenau nach Brief.
func TestWrapFehlschlagCodeDurchgereicht(t *testing.T) {
// TestWrapFailureCodePassedThrough (acceptance criterion): when the command
// fails with code 2, wrap exits 2 and announces the failure with the code —
// without duration, word for word as briefed.
func TestWrapFailureCodePassedThrough(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
@@ -43,19 +44,39 @@ func TestWrapFehlschlagCodeDurchgereicht(t *testing.T) {
res := runTalk(t, env, "wrap", "--", "sh", "-c", "exit 2")
if res.code != 2 {
t.Fatalf("talk wrap -- sh -c 'exit 2': Exit %d, want 2 (Durchreichung)", res.code)
t.Fatalf("talk wrap -- sh -c 'exit 2': exit %d, want 2 (passed through)", res.code)
}
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "sh fehlgeschlagen, Code 2." {
t.Errorf("Ansage = %q, want %q", hits[0].Text, "sh fehlgeschlagen, Code 2.")
if hits[0].Text != "sh failed, code 2." {
t.Errorf("announcement = %q, want %q", hits[0].Text, "sh failed, code 2.")
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestWrapFlagShielding (Regression, Task-07-Kern): `talk wrap -- true
// --bar` reicht --bar an den Befehl weiter — talk darf --bar NICHT als
// eigenes Flag parsen (wäre Exit 2 mit Flag-Fehler). GNU true ignoriert
// Operanden, exitet also 0, und die Erfolg-Ansage läuft.
// TestWrapGermanAnnouncement: TALK_LANG=de switches talk's own announcement
// to German (the detection cannot decide on a bare label like "sh" and
// falls back to the configured language).
func TestWrapGermanAnnouncement(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
env := daemonEnv(root, d.port, fake.srv.URL, map[string]string{"TALK_LANG": "de"})
res := runTalk(t, env, "wrap", "--", "sh", "-c", "exit 2")
if res.code != 2 {
t.Fatalf("talk wrap -- sh -c 'exit 2' (de): exit %d, want 2", res.code)
}
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "sh fehlgeschlagen, Code 2." {
t.Errorf("announcement = %q, want %q", hits[0].Text, "sh fehlgeschlagen, Code 2.")
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestWrapFlagShielding (regression, core of Task 07): `talk wrap -- true
// --bar` passes --bar on to the command — talk must NOT parse --bar as its
// own flag (that would be exit 2 with a flag error). GNU true ignores
// operands, so it exits 0, and the success announcement runs.
func TestWrapFlagShielding(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
@@ -64,20 +85,20 @@ func TestWrapFlagShielding(t *testing.T) {
res := runTalk(t, env, "wrap", "--", "true", "--bar")
if res.code != 0 {
t.Fatalf("talk wrap -- true --bar: Exit %d, want 0 (--bar gehört dem Befehl) — stderr: %s", res.code, res.stderr)
t.Fatalf("talk wrap -- true --bar: exit %d, want 0 (--bar belongs to the command) — stderr: %s", res.code, res.stderr)
}
if strings.Contains(res.stderr, "flag provided but not defined") {
t.Errorf("talk hat --bar als eigenes Flag geparsed — Shielding kaputt: %s", res.stderr)
t.Errorf("talk parsed --bar as its own flag — shielding broken: %s", res.stderr)
}
hits := fake.waitCompleted(t, 1)
if !strings.HasPrefix(hits[0].Text, "true erfolgreich.") {
t.Errorf("Ansage = %q, will Erfolg für true", hits[0].Text)
if !strings.HasPrefix(hits[0].Text, "true succeeded.") {
t.Errorf("announcement = %q, want success for true", hits[0].Text)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestWrapLabelOverride: TALK_WRAP_LABEL ersetzt den Basisnamen in der
// Ansage (Fleet-Muster: Env statt Config-Datei).
// TestWrapLabelOverride: TALK_WRAP_LABEL replaces the base name in the
// announcement (fleet pattern: env instead of config file).
func TestWrapLabelOverride(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
@@ -86,18 +107,18 @@ func TestWrapLabelOverride(t *testing.T) {
res := runTalk(t, env, "wrap", "--", "false")
if res.code != 1 {
t.Fatalf("talk wrap -- false: Exit %d, want 1", res.code)
t.Fatalf("talk wrap -- false: exit %d, want 1", res.code)
}
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "Deployment fehlgeschlagen, Code 1." {
t.Errorf("Ansage = %q, want %q", hits[0].Text, "Deployment fehlgeschlagen, Code 1.")
if hits[0].Text != "Deployment failed, code 1." {
t.Errorf("announcement = %q, want %q", hits[0].Text, "Deployment failed, code 1.")
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestWrapNonBlocking: wrap kehrt nach dem enqueue zurück, bevor die
// Ansage zu Ende gespielt ist (Ansage dauert 600 ms am Fake) — stdout
// gehört dabei allein dem Befehl, die Item-ID geht auf stderr.
// TestWrapNonBlocking: wrap returns after the enqueue, before the
// announcement has finished playing (the announcement takes 600 ms at the
// fake) — stdout belongs to the command alone, the item ID goes to stderr.
func TestWrapNonBlocking(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 600*time.Millisecond)
@@ -105,29 +126,29 @@ func TestWrapNonBlocking(t *testing.T) {
env := daemonEnv(root, d.port, fake.srv.URL, nil)
start := time.Now()
res := runTalk(t, env, "wrap", "--", "sh", "-c", "echo bauausgabe")
res := runTalk(t, env, "wrap", "--", "sh", "-c", "echo build-output")
elapsed := time.Since(start)
if res.code != 0 {
t.Fatalf("talk wrap: Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk wrap: exit %d, stderr: %s", res.code, res.stderr)
}
if elapsed >= 500*time.Millisecond {
t.Fatalf("talk wrap war blockierend (%s; Ansage dauert 600 ms) — Default muss non-blocking sein", elapsed)
t.Fatalf("talk wrap was blocking (%s; announcement takes 600 ms) — the default must be non-blocking", elapsed)
}
if res.stdout != "bauausgabe\n" {
t.Errorf("stdout = %q, will ausschließlich die Befehlsausgabe (Item-ID gehört auf stderr)", res.stdout)
if res.stdout != "build-output\n" {
t.Errorf("stdout = %q, want exclusively the command output (the item ID belongs on stderr)", res.stdout)
}
if strings.TrimSpace(res.stderr) == "" {
t.Errorf("stderr = %q, will mindestens die Item-ID der Ansage", res.stderr)
t.Errorf("stderr = %q, want at least the item ID of the announcement", res.stderr)
}
// Die Ansage läuft im Daemon trotzdem zu Ende.
// The announcement still runs to completion in the daemon.
fake.waitCompleted(t, 1)
waitStatusIdle(t, d, 3*time.Second)
}
// TestWrapWait: `talk wrap --wait -- …` kehrt erst nach Ende der Ansage
// zurück (geerbtes say-Flag, hier: 400 ms Synthese).
// TestWrapWait: `talk wrap --wait -- …` returns only after the announcement
// ends (inherited say flag, here: 400 ms synthesis).
func TestWrapWait(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 400*time.Millisecond)
@@ -139,30 +160,30 @@ func TestWrapWait(t *testing.T) {
elapsed := time.Since(start)
if res.code != 0 {
t.Fatalf("talk wrap --wait: Exit %d, stderr: %s", res.code, res.stderr)
t.Fatalf("talk wrap --wait: exit %d, stderr: %s", res.code, res.stderr)
}
if elapsed < 350*time.Millisecond {
t.Fatalf("talk wrap --wait kam zu früh zurück (%s; Ansage dauert 400 ms)", elapsed)
t.Fatalf("talk wrap --wait returned too early (%s; announcement takes 400 ms)", elapsed)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestWrapOhneDaemon: Daemon tot, Autostart aus → Ansage direkt gegen den
// Fake, und der Exit-Code des Befehls bleibt trotzdem.
func TestWrapOhneDaemon(t *testing.T) {
// TestWrapWithoutDaemon: daemon dead, autostart off → announcement directly
// against the fake, and the command's exit code is still preserved.
func TestWrapWithoutDaemon(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot
env := daemonEnv(root, 1, fake.srv.URL, nil) // port 1: daemon guaranteed dead
res := runTalk(t, env, "wrap", "--", "sh", "-c", "exit 3")
if res.code != 3 {
t.Fatalf("talk wrap (direkt): Exit %d, want 3 (Befehls-Code gewinnt)", res.code)
t.Fatalf("talk wrap (direct): exit %d, want 3 (the command's code wins)", res.code)
}
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "sh fehlgeschlagen, Code 3." {
t.Errorf("Ansage = %q, want %q", hits[0].Text, "sh fehlgeschlagen, Code 3.")
if hits[0].Text != "sh failed, code 3." {
t.Errorf("announcement = %q, want %q", hits[0].Text, "sh failed, code 3.")
}
if !strings.Contains(res.stderr, "spreche direkt") {
t.Errorf("stderr = %q, will Direkt-Hinweis (nichts bricht still)", res.stderr)
if !strings.Contains(res.stderr, "speaking directly") {
t.Errorf("stderr = %q, want the direct-path note (nothing fails silently)", res.stderr)
}
}