mirror of
https://github.com/Pacerino/CaddyProxyManager.git
synced 2026-07-22 13:33:36 -04:00
Add unit/integration tests across backend handlers, auth, caddy and util packages, plus frontend component and API tests with the new test setup. Update CI workflow and Vite config to run the suites.
135 lines
4.3 KiB
Go
135 lines
4.3 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/Pacerino/CaddyProxyManager/internal/config"
|
|
"github.com/Pacerino/CaddyProxyManager/internal/database"
|
|
"github.com/Pacerino/CaddyProxyManager/internal/jobqueue"
|
|
|
|
"github.com/glebarez/sqlite"
|
|
"github.com/go-chi/chi/v5"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// newTestHandler returns a Handler backed by a fresh in-memory SQLite database
|
|
// with the schema migrated. Each test gets an isolated database (private
|
|
// in-memory connection, single connection so it persists for the test).
|
|
func newTestHandler(t *testing.T) Handler {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open("file::memory:?_pragma=foreign_keys(1)"), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
// A private in-memory DB lives only as long as its single connection, so
|
|
// pin the pool to one connection for the test's lifetime.
|
|
sqlDB, _ := db.DB()
|
|
sqlDB.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
|
|
if err := db.AutoMigrate(&database.Host{}, &database.Upstream{}, &database.HostPlugin{}, &database.User{}, &database.ModuleConfig{}); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
return Handler{DB: db}
|
|
}
|
|
|
|
// fakeCaddyServer starts an httptest server that accepts the admin API calls
|
|
// the APIProvider makes, so WriteHost/Apply succeed without a real Caddy.
|
|
func fakeCaddyServer(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// GET traversal endpoints return "null" (absent) so the provider
|
|
// bootstraps; everything else returns 200.
|
|
if r.Method == http.MethodGet {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("null"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
// useAPIMode points the global config at the fake Caddy and uses API mode with
|
|
// no reload side effects. It restores the previous config on cleanup.
|
|
func useAPIMode(t *testing.T, adminURL string) {
|
|
t.Helper()
|
|
prev := config.Configuration.Caddy
|
|
config.Configuration.Caddy.Mode = "api"
|
|
config.Configuration.Caddy.AdminURL = adminURL
|
|
config.Configuration.Caddy.ServerName = "srv0"
|
|
config.Configuration.Caddy.Listen = []string{":80"}
|
|
config.Configuration.Caddy.ReloadStrategy = "none"
|
|
t.Cleanup(func() { config.Configuration.Caddy = prev })
|
|
}
|
|
|
|
// startJobQueue starts the async job worker for tests that enqueue jobs and
|
|
// stops it on cleanup. Jobs run on a single worker goroutine.
|
|
func startJobQueue(t *testing.T) {
|
|
t.Helper()
|
|
jobqueue.Start()
|
|
t.Cleanup(func() { _ = jobqueue.Shutdown() })
|
|
}
|
|
|
|
// drainJobQueue blocks until all previously enqueued jobs have run, by
|
|
// enqueueing a sentinel job and waiting for it to execute. The worker is a
|
|
// single goroutine processing jobs in order, so once the sentinel runs every
|
|
// earlier job has completed.
|
|
func drainJobQueue(t *testing.T) {
|
|
t.Helper()
|
|
done := make(chan struct{})
|
|
if err := jobqueue.AddJob(jobqueue.Job{
|
|
Name: "TestSentinel",
|
|
Action: func() error { close(done); return nil },
|
|
}); err != nil {
|
|
t.Fatalf("enqueue sentinel: %v", err)
|
|
}
|
|
<-done
|
|
}
|
|
|
|
// doRequest invokes a handler func with an optional JSON body and chi URL
|
|
// params, returning the recorded response.
|
|
func doRequest(handler http.HandlerFunc, method, target string, body any, urlParams map[string]string) *httptest.ResponseRecorder {
|
|
var reader *bytes.Reader
|
|
if body != nil {
|
|
buf, _ := json.Marshal(body)
|
|
reader = bytes.NewReader(buf)
|
|
} else {
|
|
reader = bytes.NewReader(nil)
|
|
}
|
|
req := httptest.NewRequest(method, target, reader)
|
|
if len(urlParams) > 0 {
|
|
rctx := chi.NewRouteContext()
|
|
for k, v := range urlParams {
|
|
rctx.URLParams.Add(k, v)
|
|
}
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
handler(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// decodeResult unmarshals the { result } envelope into out.
|
|
func decodeResult(t *testing.T, rec *httptest.ResponseRecorder, out any) {
|
|
t.Helper()
|
|
var env struct {
|
|
Result json.RawMessage `json:"result"`
|
|
Error json.RawMessage `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
|
t.Fatalf("decode envelope: %v (body=%s)", err, rec.Body.String())
|
|
}
|
|
if out != nil && len(env.Result) > 0 {
|
|
if err := json.Unmarshal(env.Result, out); err != nil {
|
|
t.Fatalf("decode result: %v (result=%s)", err, env.Result)
|
|
}
|
|
}
|
|
}
|