forgejo-mcp-broker/cmd/broker/main_integration_test.go

182 lines
4.8 KiB
Go
Raw Permalink Normal View History

package main_test
import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
)
// Binary path shared across tests — built once in TestMain to keep the
// integration suite fast.
var binPath string
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "fjmcp-broker-bin-*")
if err != nil {
fmt.Fprintln(os.Stderr, "integration: mkdir:", err)
os.Exit(1)
}
defer os.RemoveAll(dir)
binPath = filepath.Join(dir, "fjmcp-broker")
build := exec.Command("go", "build", "-o", binPath, ".")
build.Stderr = os.Stderr
if err := build.Run(); err != nil {
fmt.Fprintln(os.Stderr, "integration: build:", err)
os.Exit(1)
}
os.Exit(m.Run())
}
func TestBinary_Version(t *testing.T) {
out, err := exec.Command(binPath, "--version").CombinedOutput()
if err != nil {
t.Fatalf("--version should exit 0: %v (output: %s)", err, out)
}
if !strings.Contains(string(out), "fjmcp-broker") {
t.Errorf("version output missing binary name: %s", out)
}
}
func TestBinary_MissingConfig_FailsWithClearError(t *testing.T) {
cmd := exec.Command(binPath)
// Reset env so the binary sees no ambient config.
cmd.Env = []string{"PATH=" + os.Getenv("PATH")}
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatal("binary should exit nonzero with no config")
}
// Every required field should be mentioned, not just the first.
for _, want := range []string{
"public-url", "forgejo-url",
"forgejo-oauth-client-id", "forgejo-oauth-client-secret",
} {
if !strings.Contains(string(out), want) {
t.Errorf("config error should mention %q, got:\n%s", want, out)
}
}
}
func TestBinary_Health_And_SigtermShutsDownCleanly(t *testing.T) {
addr := freePort(t)
storePath := filepath.Join(t.TempDir(), "broker.db")
cmd := exec.Command(binPath,
"--public-url", "http://localhost:1234",
"--forgejo-url", "https://forgejo.example.com",
"--forgejo-oauth-client-id", "test-id",
"--forgejo-oauth-client-secret", "test-secret",
"--store-path", storePath,
"--listen", addr,
)
cmd.Env = []string{"PATH=" + os.Getenv("PATH")}
// Capture stderr so test failures surface the broker's logs.
stderrR, stderrW := io.Pipe()
cmd.Stderr = stderrW
var capturedStderr strings.Builder
go func() {
_, _ = io.Copy(&capturedStderr, stderrR)
}()
if err := cmd.Start(); err != nil {
t.Fatalf("start broker: %v", err)
}
// Ensure we always try to reap the process.
defer func() {
if cmd.ProcessState == nil && cmd.Process != nil {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}
_ = stderrW.Close()
}()
waitListening(t, addr, 5*time.Second)
resp, err := http.Get("http://" + addr + "/healthz")
if err != nil {
t.Fatalf("GET /healthz: %v", err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("/healthz status = %d, want 200\nbody: %s", resp.StatusCode, body)
}
var h map[string]string
if err := json.Unmarshal(body, &h); err != nil {
t.Fatalf("/healthz body not JSON: %v\nbody: %s", err, body)
}
for _, k := range []string{"status", "version", "git_revision", "build_date", "store"} {
if h[k] == "" {
t.Errorf("/healthz missing field %q: %v", k, h)
}
}
if h["status"] != "ok" {
t.Errorf("/healthz status = %q, want ok (body: %s)", h["status"], body)
}
if h["store"] != "ok" {
t.Errorf("/healthz store = %q, want ok (body: %s)", h["store"], body)
}
// Send SIGTERM and verify clean exit.
start := time.Now()
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatalf("signal SIGTERM: %v", err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
if err != nil {
t.Errorf("broker exit with error: %v\nstderr:\n%s", err, capturedStderr.String())
}
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Errorf("shutdown took %s, want < 2s", elapsed)
}
case <-time.After(3 * time.Second):
_ = cmd.Process.Kill()
t.Fatal("broker did not exit within 3s of SIGTERM")
}
}
// freePort returns a 127.0.0.1:<port> the kernel assigned. The listener is
// closed immediately, so there's a tiny race window before the broker
// rebinds — acceptable on loopback.
func freePort(t *testing.T) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("pick free port: %v", err)
}
addr := l.Addr().String()
_ = l.Close()
return addr
}
// waitListening polls the target until a TCP dial succeeds or the deadline
// expires. Serves as a sync barrier for "the binary is up".
func waitListening(t *testing.T, addr string, within time.Duration) {
t.Helper()
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
c, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
if err == nil {
_ = c.Close()
return
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("broker did not start listening on %s within %s", addr, within)
}