215 lines
8.0 KiB
Go
215 lines
8.0 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|