chore: initial commit of Go API gateway
Baseline for the Kong replacement on api.riotpiao.com. Brings the working tree under version control for the first time: gateway source, the task board that drives the agent runs, test fixtures, and K8s manifests. Anchor the gateway ignore rule to the repo root. Unanchored, "gateway" also matched the cmd/gateway/ source directory, so the program entrypoint was excluded from every commit. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
package testsupport
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// startForTest binds the stub on an ephemeral port so the suite does not fight
|
||||
// with anything already holding 9080, and fails fast if Start blocks — the
|
||||
// original harness called ListenAndServe inline and never returned.
|
||||
func startForTest(t *testing.T) *Snapshot {
|
||||
t.Helper()
|
||||
t.Setenv("HARNESS_STUB_PORT", "0")
|
||||
|
||||
type result struct {
|
||||
snap *Snapshot
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
snap, err := Start()
|
||||
done <- result{snap, err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case r := <-done:
|
||||
if r.err != nil {
|
||||
t.Fatalf("Start() error: %v", r.err)
|
||||
}
|
||||
t.Cleanup(Close)
|
||||
return r.snap
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Start() did not return within 5s — it is blocking instead of serving in the background")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartServesFixedJSON(t *testing.T) {
|
||||
snap := startForTest(t)
|
||||
|
||||
resp, err := snap.Client.Get(snap.URL(PathJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", PathJSON, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Type"); got != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want %q", got, "application/json")
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if want := `{"status":"ok"}`; strings.TrimSpace(string(body)) != want {
|
||||
t.Errorf("body = %q, want %q", strings.TrimSpace(string(body)), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEArrivesIncrementally(t *testing.T) {
|
||||
snap := startForTest(t)
|
||||
|
||||
resp, err := snap.Client.Get(snap.URL(PathSSE))
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", PathSSE, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// The first event must be readable before the handler has written the last
|
||||
// one. If the response were buffered to completion, this read would only
|
||||
// unblock after every token plus every gap had elapsed.
|
||||
deadline := time.Now().Add(time.Duration(len(SSETokens)) * SSEGap)
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("read first event: %v", err)
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Error("first SSE event arrived only after the whole stream was written; not incremental")
|
||||
}
|
||||
if want := "data: " + SSETokens[0]; strings.TrimSpace(line) != want {
|
||||
t.Errorf("first event = %q, want %q", strings.TrimSpace(line), want)
|
||||
}
|
||||
|
||||
rest, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("read remaining events: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(rest), "data: [DONE]") {
|
||||
t.Error("stream did not end with the [DONE] sentinel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartIsIdempotent(t *testing.T) {
|
||||
first := startForTest(t)
|
||||
|
||||
second, err := Start()
|
||||
if err != nil {
|
||||
t.Fatalf("second Start() error: %v", err)
|
||||
}
|
||||
if first.BaseURL != second.BaseURL {
|
||||
t.Errorf("second Start() bound a different address: %q vs %q", second.BaseURL, first.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHarnessFixtureLoadsAndStaysOnLoopback(t *testing.T) {
|
||||
// DefaultConfigPath is relative to the repo root; tests run in the package
|
||||
// directory, so walk back up to it.
|
||||
routes, err := config.LoadRoutesFromFile("../../" + DefaultConfigPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRoutesFromFile(%s): %v", DefaultConfigPath, err)
|
||||
}
|
||||
if len(routes) == 0 {
|
||||
t.Fatal("fixture declared no routes")
|
||||
}
|
||||
|
||||
for name, route := range routes {
|
||||
if !strings.HasPrefix(route.Upstream.Address, "127.0.0.1:") {
|
||||
t.Errorf("route %q upstream %q is not on loopback", name, route.Upstream.Address)
|
||||
}
|
||||
if route.Upstream.AuthRequired {
|
||||
t.Errorf("route %q requires auth; the harness must run with no credentials", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user