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. import ( "context" "net/http" "net/http/httptest" "os" "regexp" "strconv" "strings" "sync" "testing" "time" "code.m3ta.dev/m3tam3re/talk/internal/api" "code.m3ta.dev/m3tam3re/talk/internal/daemon" ) 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. func autostartEnv(t *testing.T, root, fakeURL string, extra map[string]string) ([]string, *api.Client) { t.Helper() port := freePort(t) set := map[string]string{"TALK_AUTOSTART": "true"} for k, v := range extra { set[k] = v } env := daemonEnv(root, port, fakeURL, set) client := api.NewClient(port) t.Cleanup(func() { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() _ = client.Shutdown(ctx) deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { if _, err := client.Status(ctx); err != nil { break } time.Sleep(50 * time.Millisecond) } if pid := daemon.ReadPID(configDir(root)); pid > 0 { if p, err := os.FindProcess(pid); err == nil { _ = p.Kill() } } }) 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) { root := t.TempDir() fake := newFakeProvider(t, 0) env, client := autostartEnv(t, root, fake.srv.URL, nil) res := runTalk(t, env, "say", "erster aufruf startet") if res.code != 0 { 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) } if !strings.Contains(res.stderr, "Daemon gestartet") { t.Errorf("stderr = %q, will Autostart-Hinweis", res.stderr) } if hits := fake.waitCompleted(t, 1); hits[0].Text != "erster aufruf startet" { t.Errorf("Treffer = %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) } fake.waitCompleted(t, 2) // Der Daemon ist sichtbar: status, Log- und PID-Datei. 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 _, err := os.Stat(configDir(root) + "/" + daemon.LogFileName); err != nil { t.Errorf("daemon.log fehlt: %v", err) } if pid := daemon.ReadPID(configDir(root)); pid <= 0 { t.Errorf("daemon.pid fehlt oder leer") } // stop fährt ihn herunter; danach läuft keiner mehr, PID-Datei weg. 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) } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if _, err := client.Status(ctx); err == nil { t.Errorf("Daemon antwortet nach stop noch") } if pid := daemon.ReadPID(configDir(root)); pid != 0 { t.Errorf("daemon.pid nach stop noch da (%d)", pid) } // Nochmal stop: 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) } } // 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) { 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"} { wg.Add(1) go func(i int, text string) { defer wg.Done() results[i] = runTalk(t, env, "say", "--wait", text) }(i, text) } 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) } if !itemID.MatchString(res.stdout) { t.Errorf("talk %d stdout = %q, want Item-ID (beide über den 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) } } // TestDaemonStartExplizit: `talk daemon start` startet im Hintergrund und // ist idempotent; der Port steht in der Meldung. func TestDaemonStartExplizit(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) } 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) } // Autostart aus, aber Daemon da: say nutzt ihn. res = runTalk(t, env, "say", "über explizit gestarteten daemon") if res.code != 0 || !itemID.MatchString(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) { root := t.TempDir() fake := newFakeProvider(t, 0) fremd := 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:]) 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") if res.code != 0 { 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) } 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.LogFileName) { t.Errorf("stderr = %q, will Log-Pfad-Hinweis", res.stderr) } if hits := fake.waitHits(t, 1); hits[0].Text != "port ist fremd belegt" { t.Errorf("Treffer = %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) } }