package integration import ( "fmt" "os" "sort" "strings" "testing" "github.com/labstack/echo/v4" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" "github.com/treytartt/honeydue-api/internal/config" "github.com/treytartt/honeydue-api/internal/router" "github.com/treytartt/honeydue-api/internal/testutil" ) // routeKey is a comparable type for route matching: method + path type routeKey struct { Method string Path string } // TestRouteSpecContract verifies that registered Echo routes match the OpenAPI spec. // It ensures bidirectional consistency: // - Every spec path has a corresponding registered route // - Every registered API route has a corresponding spec path func TestRouteSpecContract(t *testing.T) { // --- Parse OpenAPI spec --- specRoutes := extractSpecRoutes(t) require.NotEmpty(t, specRoutes, "OpenAPI spec should have at least one route") // --- Set up Echo router --- db := testutil.SetupTestDB(t) cfg := &config.Config{} cfg.Server.Debug = true deps := &router.Dependencies{ DB: db, Config: cfg, } e := router.SetupRouter(deps) echoRoutes := extractEchoRoutes(e.Routes()) require.NotEmpty(t, echoRoutes, "Echo router should have at least one route") // --- Bidirectional match --- t.Run("spec routes exist in router", func(t *testing.T) { var missing []string for _, sr := range specRoutes { if shouldSkipSpecRoute(sr.Path) { continue } if !containsRoute(echoRoutes, sr) { missing = append(missing, fmt.Sprintf("%s %s", sr.Method, sr.Path)) } } if len(missing) > 0 { sort.Strings(missing) t.Errorf("OpenAPI spec defines routes not registered in Echo router:\n %s", strings.Join(missing, "\n ")) } }) t.Run("router routes exist in spec", func(t *testing.T) { var missing []string for _, er := range echoRoutes { if shouldSkipRouterRoute(er.Path) { continue } if !containsRoute(specRoutes, er) { missing = append(missing, fmt.Sprintf("%s %s", er.Method, er.Path)) } } if len(missing) > 0 { sort.Strings(missing) t.Errorf("Echo routes not documented in OpenAPI spec:\n %s", strings.Join(missing, "\n ")) } }) } // extractSpecRoutes parses the OpenAPI YAML and returns normalized route keys. // Spec paths use OpenAPI param format: /documents/{id}/ // These are returned as-is since Echo routes are converted to this format. func extractSpecRoutes(t *testing.T) []routeKey { t.Helper() data, err := os.ReadFile("../../docs/openapi.yaml") require.NoError(t, err, "Failed to read openapi.yaml") var spec struct { Paths map[string]map[string]interface{} `yaml:"paths"` } require.NoError(t, yaml.Unmarshal(data, &spec), "Failed to parse openapi.yaml") var routes []routeKey for path, methods := range spec.Paths { for method := range methods { upper := strings.ToUpper(method) // Skip non-HTTP methods (parameters, summary, etc.) switch upper { case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS": routes = append(routes, routeKey{Method: upper, Path: path}) } } } sort.Slice(routes, func(i, j int) bool { return routes[i].Path < routes[j].Path }) return routes } // extractEchoRoutes returns normalized route keys from Echo's registered routes. // Filters out admin, static, health, tracking, and internal routes. func extractEchoRoutes(echoRoutes []*echo.Route) []routeKey { seen := make(map[routeKey]bool) var routes []routeKey for _, r := range echoRoutes { if shouldSkipRoute(r.Path, r.Method) { continue } // Strip /api prefix to match spec paths (spec server base is /api) path := strings.TrimPrefix(r.Path, "/api") // Normalize Echo :param to OpenAPI {param} path = normalizePathToOpenAPI(path) key := routeKey{Method: r.Method, Path: path} if !seen[key] { seen[key] = true routes = append(routes, key) } } sort.Slice(routes, func(i, j int) bool { return routes[i].Path < routes[j].Path }) return routes } // normalizePathToOpenAPI converts Echo `:param` to OpenAPI `{param}` format. func normalizePathToOpenAPI(path string) string { parts := strings.Split(path, "/") for i, part := range parts { if strings.HasPrefix(part, ":") { parts[i] = "{" + strings.TrimPrefix(part, ":") + "}" } } return strings.Join(parts, "/") } // shouldSkipRoute returns true for routes that are not part of the public API spec. func shouldSkipRoute(path, method string) bool { // Skip non-API routes (static files, root page) if !strings.HasPrefix(path, "/api/") { return true } // Skip admin routes if strings.HasPrefix(path, "/api/admin") { return true } // Skip health check (internal, not in spec) if path == "/api/health/" { return true } // Skip email tracking (internal, not in spec) if strings.HasPrefix(path, "/api/track/") { return true } // Skip echo-internal routes (e.g., OPTIONS auto-generated by CORS) if method == "echo_route_not_found" { return true } return false } // shouldSkipSpecRoute returns true for spec routes that require optional services // (e.g., storage/media routes require a non-nil StorageService which is not available in tests). func shouldSkipSpecRoute(path string) bool { // Upload and media routes are conditionally registered (require StorageService) if strings.HasPrefix(path, "/uploads/") || strings.HasPrefix(path, "/media/") { return true } return false } // shouldSkipRouterRoute returns true for router routes that are intentionally // not documented in the OpenAPI spec (internal aliases, webhooks, etc.). func shouldSkipRouterRoute(path string) bool { skipPaths := map[string]bool{ // Internal auth alias for mobile client compatibility "/auth/verify/": true, // Static data cache management (internal) "/static_data/refresh/": true, // Server-to-server webhook routes (called by Apple/Google, not mobile clients) "/subscription/webhook/apple/": true, "/subscription/webhook/google/": true, // User management routes (internal/admin-facing, not in mobile API spec) "/users/": true, "/users/profiles/": true, } if skipPaths[path] { return true } // Skip /users/{id}/ pattern if strings.HasPrefix(path, "/users/{") { return true } return false } // containsRoute checks if a routeKey exists in a slice. func containsRoute(routes []routeKey, target routeKey) bool { for _, r := range routes { if r.Method == target.Method && r.Path == target.Path { return true } } return false }