Files
3x-ui/internal/web/routes_contract_test.go
T
Gleb Gudkov ed6bc1d898 docs(api): align OpenAPI with runtime contracts (#6409)
Document the cookie-authenticated WebSocket upgrade and its emitted envelopes without exporting pseudo-paths. Align REST response schemas, paged-client filters, and subscription HEAD operations with their runtime implementations, then regenerate frontend and docs artifacts.
2026-09-04 15:23:41 +02:00

140 lines
4.2 KiB
Go

package web
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
"time"
"github.com/robfig/cron/v3"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
)
/*
frontend/src/pages/api-docs/endpoints.ts is a hand-maintained registry: an
API route omitted there silently vanishes from the generated OpenAPI docs,
and an entry for a removed route documents an endpoint that 404s. This test
constructs the real router and diffs it against the registry both ways.
Scope: everything under /panel/api/ plus the session-auth surface the
registry also documents (/login, /logout, /csrf-token, /getTwoFactorEnable,
/ws). SPA page routes are UI, not API, and stay out; registry paths that
start with "/{" describe the standalone subscription server, which this
engine does not serve.
*/
var contractExtraRoutes = map[string]bool{
"POST /login": true,
"POST /logout": true,
"GET /csrf-token": true,
"POST /getTwoFactorEnable": true,
"GET /ws": true,
}
func inContractScope(method, path string) bool {
return strings.HasPrefix(path, "/panel/api/") || contractExtraRoutes[method+" "+path]
}
func registeredContractRoutes(t *testing.T) map[string]bool {
t.Helper()
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("init db: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
previous := global.GetWebServer()
s := NewServer()
s.cron = cron.New(cron.WithLocation(time.Local), cron.WithSeconds())
global.SetWebServer(s)
t.Cleanup(func() {
s.cancel()
global.SetWebServer(previous)
})
engine, err := s.initRouter()
if err != nil {
t.Fatalf("init router: %v", err)
}
routes := make(map[string]bool)
for _, r := range engine.Routes() {
routes[r.Method+" "+r.Path] = true
}
if len(routes) == 0 {
t.Fatal("no routes registered; router construction is broken")
}
return routes
}
func documentedContractRoutes(t *testing.T) map[string]bool {
t.Helper()
source, err := os.ReadFile(filepath.Join("..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts"))
if err != nil {
t.Fatalf("read endpoints.ts: %v", err)
}
text := string(source)
methodRe := regexp.MustCompile(`method:\s*'(GET|POST|PUT|DELETE|PATCH|HEAD|WS)'`)
pathRe := regexp.MustCompile(`path:\s*'([^']+)'`)
methods := methodRe.FindAllStringSubmatchIndex(text, -1)
if declared := strings.Count(text, "method: '"); len(methods) != declared {
t.Fatalf("parsed %d method fields but endpoints.ts declares %d — the parser regex no longer matches the file shape", len(methods), declared)
}
docs := make(map[string]bool)
for i, m := range methods {
segmentEnd := len(text)
if i+1 < len(methods) {
segmentEnd = methods[i+1][0]
}
pathMatch := pathRe.FindStringSubmatch(text[m[1]:segmentEnd])
if pathMatch == nil {
t.Fatalf("entry %d in endpoints.ts has a method but no path before the next entry — the parser cannot pair it", i)
}
method := text[m[2]:m[3]]
if strings.HasPrefix(pathMatch[1], "/{") || !strings.HasPrefix(pathMatch[1], "/") {
continue
}
docs[method+" "+pathMatch[1]] = true
}
if len(docs) == 0 {
t.Fatal("no entries parsed from endpoints.ts; the parser regex is broken")
}
return docs
}
func TestRouteRegistryContract(t *testing.T) {
registered := registeredContractRoutes(t)
documented := documentedContractRoutes(t)
t.Run("every API route is documented", func(t *testing.T) {
var missing []string
for route := range registered {
fields := strings.Fields(route)
if inContractScope(fields[0], fields[1]) && !documented[route] {
missing = append(missing, route)
}
}
sort.Strings(missing)
for _, route := range missing {
t.Error(fmt.Errorf("route %s is registered but absent from endpoints.ts — add an entry or it vanishes from the API docs", route))
}
})
t.Run("every documented route is registered", func(t *testing.T) {
var stale []string
for route := range documented {
if !registered[route] {
stale = append(stale, route)
}
}
sort.Strings(stale)
for _, route := range stale {
t.Error(fmt.Errorf("endpoints.ts documents %s but the server does not register it — remove or fix the entry", route))
}
})
}