test(routes): add strict route matrix tests for import and save workflows
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
package routes_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type endpointInventoryEntry struct {
|
||||
Name string
|
||||
Method string
|
||||
Path string
|
||||
Source string
|
||||
}
|
||||
|
||||
func backendImportRouteMatrix() []endpointInventoryEntry {
|
||||
return []endpointInventoryEntry{
|
||||
{Name: "Import status", Method: "GET", Path: "/api/v1/import/status", Source: "backend/internal/api/handlers/import_handler.go"},
|
||||
{Name: "Import preview", Method: "GET", Path: "/api/v1/import/preview", Source: "backend/internal/api/handlers/import_handler.go"},
|
||||
{Name: "Import upload", Method: "POST", Path: "/api/v1/import/upload", Source: "backend/internal/api/handlers/import_handler.go"},
|
||||
{Name: "Import upload multi", Method: "POST", Path: "/api/v1/import/upload-multi", Source: "backend/internal/api/handlers/import_handler.go"},
|
||||
{Name: "Import detect imports", Method: "POST", Path: "/api/v1/import/detect-imports", Source: "backend/internal/api/handlers/import_handler.go"},
|
||||
{Name: "Import commit", Method: "POST", Path: "/api/v1/import/commit", Source: "backend/internal/api/handlers/import_handler.go"},
|
||||
{Name: "Import cancel", Method: "DELETE", Path: "/api/v1/import/cancel", Source: "backend/internal/api/handlers/import_handler.go"},
|
||||
{Name: "NPM import upload", Method: "POST", Path: "/api/v1/import/npm/upload", Source: "backend/internal/api/handlers/npm_import_handler.go"},
|
||||
{Name: "NPM import commit", Method: "POST", Path: "/api/v1/import/npm/commit", Source: "backend/internal/api/handlers/npm_import_handler.go"},
|
||||
{Name: "NPM import cancel", Method: "POST", Path: "/api/v1/import/npm/cancel", Source: "backend/internal/api/handlers/npm_import_handler.go"},
|
||||
{Name: "JSON import upload", Method: "POST", Path: "/api/v1/import/json/upload", Source: "backend/internal/api/handlers/json_import_handler.go"},
|
||||
{Name: "JSON import commit", Method: "POST", Path: "/api/v1/import/json/commit", Source: "backend/internal/api/handlers/json_import_handler.go"},
|
||||
{Name: "JSON import cancel", Method: "POST", Path: "/api/v1/import/json/cancel", Source: "backend/internal/api/handlers/json_import_handler.go"},
|
||||
}
|
||||
}
|
||||
|
||||
func frontendImportRouteMatrix() []endpointInventoryEntry {
|
||||
return []endpointInventoryEntry{
|
||||
{Name: "Import status", Method: "GET", Path: "/api/v1/import/status", Source: "frontend/src/api/import.ts"},
|
||||
{Name: "Import preview", Method: "GET", Path: "/api/v1/import/preview", Source: "frontend/src/api/import.ts"},
|
||||
{Name: "Import upload", Method: "POST", Path: "/api/v1/import/upload", Source: "frontend/src/api/import.ts"},
|
||||
{Name: "Import upload multi", Method: "POST", Path: "/api/v1/import/upload-multi", Source: "frontend/src/api/import.ts"},
|
||||
{Name: "Import commit", Method: "POST", Path: "/api/v1/import/commit", Source: "frontend/src/api/import.ts"},
|
||||
{Name: "Import cancel", Method: "DELETE", Path: "/api/v1/import/cancel", Source: "frontend/src/api/import.ts"},
|
||||
{Name: "NPM import upload", Method: "POST", Path: "/api/v1/import/npm/upload", Source: "frontend/src/api/npmImport.ts"},
|
||||
{Name: "NPM import commit", Method: "POST", Path: "/api/v1/import/npm/commit", Source: "frontend/src/api/npmImport.ts"},
|
||||
{Name: "NPM import cancel", Method: "POST", Path: "/api/v1/import/npm/cancel", Source: "frontend/src/api/npmImport.ts"},
|
||||
{Name: "JSON import upload", Method: "POST", Path: "/api/v1/import/json/upload", Source: "frontend/src/api/jsonImport.ts"},
|
||||
{Name: "JSON import commit", Method: "POST", Path: "/api/v1/import/json/commit", Source: "frontend/src/api/jsonImport.ts"},
|
||||
{Name: "JSON import cancel", Method: "POST", Path: "/api/v1/import/json/cancel", Source: "frontend/src/api/jsonImport.ts"},
|
||||
}
|
||||
}
|
||||
|
||||
func saveRouteMatrixForImportWorkflows() []endpointInventoryEntry {
|
||||
return []endpointInventoryEntry{
|
||||
{Name: "Backup list", Method: "GET", Path: "/api/v1/backups", Source: "frontend/src/api/backups.ts"},
|
||||
{Name: "Backup create", Method: "POST", Path: "/api/v1/backups", Source: "frontend/src/api/backups.ts"},
|
||||
{Name: "Settings list", Method: "GET", Path: "/api/v1/settings", Source: "frontend/src/api/settings.ts"},
|
||||
{Name: "Settings save", Method: "POST", Path: "/api/v1/settings", Source: "frontend/src/api/settings.ts"},
|
||||
{Name: "Settings save patch", Method: "PATCH", Path: "/api/v1/settings", Source: "frontend/src/api/settings.ts"},
|
||||
{Name: "Settings validate URL", Method: "POST", Path: "/api/v1/settings/validate-url", Source: "frontend/src/api/settings.ts"},
|
||||
{Name: "Settings test URL", Method: "POST", Path: "/api/v1/settings/test-url", Source: "frontend/src/api/settings.ts"},
|
||||
{Name: "SMTP get", Method: "GET", Path: "/api/v1/settings/smtp", Source: "frontend/src/api/smtp.ts"},
|
||||
{Name: "SMTP save", Method: "POST", Path: "/api/v1/settings/smtp", Source: "frontend/src/api/smtp.ts"},
|
||||
{Name: "Proxy host list", Method: "GET", Path: "/api/v1/proxy-hosts", Source: "frontend/src/api/proxyHosts.ts"},
|
||||
{Name: "Proxy host create", Method: "POST", Path: "/api/v1/proxy-hosts", Source: "frontend/src/api/proxyHosts.ts"},
|
||||
{Name: "Proxy host get", Method: "GET", Path: "/api/v1/proxy-hosts/:uuid", Source: "frontend/src/api/proxyHosts.ts"},
|
||||
{Name: "Proxy host update", Method: "PUT", Path: "/api/v1/proxy-hosts/:uuid", Source: "frontend/src/api/proxyHosts.ts"},
|
||||
{Name: "Proxy host delete", Method: "DELETE", Path: "/api/v1/proxy-hosts/:uuid", Source: "frontend/src/api/proxyHosts.ts"},
|
||||
}
|
||||
}
|
||||
|
||||
func backendImportSaveInventoryCanonical() []endpointInventoryEntry {
|
||||
entries := append([]endpointInventoryEntry{}, backendImportRouteMatrix()...)
|
||||
entries = append(entries, saveRouteMatrixForImportWorkflows()...)
|
||||
return entries
|
||||
}
|
||||
|
||||
func frontendObservedImportSaveInventory() []endpointInventoryEntry {
|
||||
entries := append([]endpointInventoryEntry{}, frontendImportRouteMatrix()...)
|
||||
entries = append(entries, saveRouteMatrixForImportWorkflows()...)
|
||||
return entries
|
||||
}
|
||||
|
||||
func routeKey(method, path string) string {
|
||||
return method + " " + path
|
||||
}
|
||||
|
||||
func buildRouteLookup(routes []gin.RouteInfo) (map[string]gin.RouteInfo, map[string]map[string]struct{}) {
|
||||
byMethodAndPath := make(map[string]gin.RouteInfo, len(routes))
|
||||
methodsByPath := make(map[string]map[string]struct{})
|
||||
for _, route := range routes {
|
||||
key := routeKey(route.Method, route.Path)
|
||||
byMethodAndPath[key] = route
|
||||
if _, exists := methodsByPath[route.Path]; !exists {
|
||||
methodsByPath[route.Path] = map[string]struct{}{}
|
||||
}
|
||||
methodsByPath[route.Path][route.Method] = struct{}{}
|
||||
}
|
||||
return byMethodAndPath, methodsByPath
|
||||
}
|
||||
|
||||
func methodList(methodSet map[string]struct{}) []string {
|
||||
methods := make([]string, 0, len(methodSet))
|
||||
for method := range methodSet {
|
||||
methods = append(methods, method)
|
||||
}
|
||||
sort.Strings(methods)
|
||||
return methods
|
||||
}
|
||||
|
||||
func assertStrictMethodPathMatrix(t *testing.T, routes []gin.RouteInfo, expected []endpointInventoryEntry, matrixName string) {
|
||||
t.Helper()
|
||||
|
||||
byMethodAndPath, methodsByPath := buildRouteLookup(routes)
|
||||
|
||||
seen := map[string]string{}
|
||||
expectedMethodsByPath := map[string]map[string]struct{}{}
|
||||
var failures []string
|
||||
|
||||
for _, endpoint := range expected {
|
||||
key := routeKey(endpoint.Method, endpoint.Path)
|
||||
if previous, duplicated := seen[key]; duplicated {
|
||||
failures = append(failures, fmt.Sprintf("duplicate expected entry %q (%s and %s)", key, previous, endpoint.Name))
|
||||
continue
|
||||
}
|
||||
seen[key] = endpoint.Name
|
||||
|
||||
if _, exists := expectedMethodsByPath[endpoint.Path]; !exists {
|
||||
expectedMethodsByPath[endpoint.Path] = map[string]struct{}{}
|
||||
}
|
||||
expectedMethodsByPath[endpoint.Path][endpoint.Method] = struct{}{}
|
||||
|
||||
if _, exists := byMethodAndPath[key]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
if methodSet, pathExists := methodsByPath[endpoint.Path]; pathExists {
|
||||
failures = append(
|
||||
failures,
|
||||
fmt.Sprintf("method drift for %s (%s): expected %s, registered methods=[%s]", endpoint.Name, endpoint.Path, endpoint.Method, strings.Join(methodList(methodSet), ", ")),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
failures = append(
|
||||
failures,
|
||||
fmt.Sprintf("missing route for %s: expected %s (source=%s)", endpoint.Name, key, endpoint.Source),
|
||||
)
|
||||
}
|
||||
|
||||
for path, expectedMethodSet := range expectedMethodsByPath {
|
||||
actualMethodSet, exists := methodsByPath[path]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
extraMethods := make([]string, 0)
|
||||
for method := range actualMethodSet {
|
||||
if _, expectedMethod := expectedMethodSet[method]; !expectedMethod {
|
||||
extraMethods = append(extraMethods, method)
|
||||
}
|
||||
}
|
||||
if len(extraMethods) > 0 {
|
||||
sort.Strings(extraMethods)
|
||||
failures = append(
|
||||
failures,
|
||||
fmt.Sprintf(
|
||||
"unexpected methods for %s: extra=[%s], expected=[%s], registered=[%s]",
|
||||
path,
|
||||
strings.Join(extraMethods, ", "),
|
||||
strings.Join(methodList(expectedMethodSet), ", "),
|
||||
strings.Join(methodList(actualMethodSet), ", "),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if len(failures) > 0 {
|
||||
t.Fatalf("%s route matrix assertion failed:\n- %s", matrixName, strings.Join(failures, "\n- "))
|
||||
}
|
||||
}
|
||||
|
||||
func collectRouteMatrixDrift(routes []gin.RouteInfo, expected []endpointInventoryEntry) []string {
|
||||
byMethodAndPath, methodsByPath := buildRouteLookup(routes)
|
||||
failures := make([]string, 0)
|
||||
|
||||
for _, endpoint := range expected {
|
||||
key := routeKey(endpoint.Method, endpoint.Path)
|
||||
if _, exists := byMethodAndPath[key]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
if methodSet, pathExists := methodsByPath[endpoint.Path]; pathExists {
|
||||
failures = append(
|
||||
failures,
|
||||
fmt.Sprintf("method drift for %s (%s): expected %s, registered methods=[%s]", endpoint.Name, endpoint.Path, endpoint.Method, strings.Join(methodList(methodSet), ", ")),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
failures = append(
|
||||
failures,
|
||||
fmt.Sprintf("missing route for %s: expected %s (source=%s)", endpoint.Name, key, endpoint.Source),
|
||||
)
|
||||
}
|
||||
|
||||
return failures
|
||||
}
|
||||
67
backend/internal/api/routes/endpoint_inventory_test.go
Normal file
67
backend/internal/api/routes/endpoint_inventory_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package routes_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/api/routes"
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestEndpointInventory_FrontendCanonicalSaveImportContractsExistInBackend(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_endpoint_inventory"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
router := gin.New()
|
||||
require.NoError(t, routes.Register(router, db, config.Config{JWTSecret: "test-secret"}))
|
||||
routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", "/tmp", "/import/Caddyfile")
|
||||
|
||||
assertStrictMethodPathMatrix(t, router.Routes(), backendImportSaveInventoryCanonical(), "backend canonical save/import inventory")
|
||||
}
|
||||
|
||||
func TestEndpointInventory_FrontendParityMatchesCurrentContract(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_endpoint_inventory_frontend_parity"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
router := gin.New()
|
||||
require.NoError(t, routes.Register(router, db, config.Config{JWTSecret: "test-secret"}))
|
||||
routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", "/tmp", "/import/Caddyfile")
|
||||
|
||||
assertStrictMethodPathMatrix(t, router.Routes(), frontendObservedImportSaveInventory(), "frontend observed save/import inventory")
|
||||
}
|
||||
|
||||
func TestEndpointInventory_FrontendParityDetectsActualMismatch(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_endpoint_inventory_frontend_parity_mismatch"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
router := gin.New()
|
||||
require.NoError(t, routes.Register(router, db, config.Config{JWTSecret: "test-secret"}))
|
||||
routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", "/tmp", "/import/Caddyfile")
|
||||
|
||||
contractWithMismatch := append([]endpointInventoryEntry{}, frontendObservedImportSaveInventory()...)
|
||||
for i := range contractWithMismatch {
|
||||
if contractWithMismatch[i].Path == "/api/v1/import/cancel" {
|
||||
contractWithMismatch[i].Method = "POST"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
drift := collectRouteMatrixDrift(router.Routes(), contractWithMismatch)
|
||||
|
||||
assert.Contains(
|
||||
t,
|
||||
drift,
|
||||
"method drift for Import cancel (/api/v1/import/cancel): expected POST, registered methods=[DELETE]",
|
||||
)
|
||||
}
|
||||
20
backend/internal/api/routes/routes_import_contract_test.go
Normal file
20
backend/internal/api/routes/routes_import_contract_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package routes_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/api/routes"
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestRegisterImportHandler_StrictRouteMatrix(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestImportDB(t)
|
||||
|
||||
router := gin.New()
|
||||
routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", "/tmp", "/import/Caddyfile")
|
||||
|
||||
assertStrictMethodPathMatrix(t, router.Routes(), backendImportRouteMatrix(), "import")
|
||||
}
|
||||
25
backend/internal/api/routes/routes_save_contract_test.go
Normal file
25
backend/internal/api/routes/routes_save_contract_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package routes_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/api/routes"
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestRegister_StrictSaveRouteMatrixUsedByImportWorkflows(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_save_contract_matrix"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
router := gin.New()
|
||||
require.NoError(t, routes.Register(router, db, config.Config{JWTSecret: "test-secret"}))
|
||||
|
||||
assertStrictMethodPathMatrix(t, router.Routes(), saveRouteMatrixForImportWorkflows(), "save")
|
||||
}
|
||||
Reference in New Issue
Block a user