Files

162 lines
4.2 KiB
Go

package handoff_test
import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"chat-agent-system/internal/handoff"
)
// TestRealFDHandoffE2E builds maintpage, hands off a live listener, then
// starts a stub server via the maintpage admin token — zero-downtime port reuse.
func TestRealFDHandoffE2E(t *testing.T) {
if testing.Short() {
t.Skip("real handoff e2e")
}
modRoot, err := filepath.Abs("../..")
if err != nil {
t.Fatal(err)
}
tmp := t.TempDir()
maintBin := filepath.Join(tmp, "maintpage")
stubBin := filepath.Join(tmp, "stubserver")
build := func(out, pkg string) {
cmd := exec.Command("go", "build", "-o", out, pkg)
cmd.Dir = modRoot
if o, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build %s: %v\n%s", pkg, err, o)
}
}
build(maintBin, "./cmd/maintpage")
// stub must live inside module for internal import
stubPkgDir := filepath.Join(modRoot, "cmd", "_handoff_e2e_stub")
_ = os.RemoveAll(stubPkgDir)
if err := os.MkdirAll(stubPkgDir, 0o755); err != nil {
t.Fatal(err)
}
defer os.RemoveAll(stubPkgDir)
if err := os.WriteFile(filepath.Join(stubPkgDir, "main.go"), []byte(`package main
import (
"fmt"
"net/http"
"os"
"chat-agent-system/internal/handoff"
)
func main() {
ln, err := handoff.ListenOrAdopt("127.0.0.1:0")
if err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
_ = handoff.SignalReady()
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "{\"ok\":true}") })
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "{\"ok\":true}") })
go func() { _ = http.Serve(ln, mux) }()
select {}
}
`), 0o644); err != nil {
t.Fatal(err)
}
build(stubBin, "./cmd/_handoff_e2e_stub")
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := ln.Addr().String()
token := fmt.Sprintf("e2e-%d", time.Now().UnixNano())
dataDir := filepath.Join(tmp, "data")
_ = os.MkdirAll(dataDir, 0o700)
extra := []string{
"MAINT_LISTEN_ADDR=" + addr,
"MAINT_MESSAGE=handoff-e2e",
"MAINT_ADMIN_TOKEN=" + token,
"MAINT_WINDOW_ID=1",
"MAINT_SERVER_BIN=" + stubBin,
"DATA_DIR=" + dataDir,
}
cmd, err := handoff.PassToChild(ln, maintBin, nil, extra, 20*time.Second)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}
// stub may have replaced maintpage; best-effort kill by port holders is out of scope —
// stub exits when we kill its process via token start response pid if present.
})
_ = ln.Close()
deadline := time.Now().Add(8 * time.Second)
var status map[string]any
for time.Now().Before(deadline) {
resp, err := http.Get("http://" + addr + "/api/v1/maintenance/status")
if err == nil {
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode == 200 && json.Unmarshal(body, &status) == nil && status["active"] == true {
break
}
}
time.Sleep(200 * time.Millisecond)
}
if status["active"] != true {
t.Fatalf("maintpage not active: %+v", status)
}
req, _ := http.NewRequest(http.MethodPost, "http://"+addr+"/api/v1/maintenance/admin/start", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
startBody, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("start: %d %s", resp.StatusCode, startBody)
}
var startResp struct {
OK bool `json:"ok"`
PID int `json:"pid"`
}
_ = json.Unmarshal(startBody, &startResp)
if startResp.PID > 0 {
t.Cleanup(func() {
proc, err := os.FindProcess(startResp.PID)
if err == nil && proc != nil {
_ = proc.Kill()
}
})
}
deadline = time.Now().Add(8 * time.Second)
ok := false
for time.Now().Before(deadline) {
resp, err := http.Get("http://" + addr + "/api/v1/health")
if err == nil {
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
var h map[string]any
if resp.StatusCode == 200 && json.Unmarshal(body, &h) == nil && h["ok"] == true {
ok = true
break
}
}
time.Sleep(200 * time.Millisecond)
}
if !ok {
t.Fatal("stub server health not ok after handoff resume")
}
}