Total rebrand across all Go API source files: - Go module path: casera-api -> honeydue-api - All imports updated (130+ files) - Docker: containers, images, networks renamed - Email templates: support email, noreply, icon URL - Domains: casera.app/mycrib.treytartt.com -> honeyDue.treytartt.com - Bundle IDs: com.tt.casera -> com.tt.honeyDue - IAP product IDs updated - Landing page, admin panel, config defaults - Seeds, CI workflows, Makefile, docs - Database table names preserved (no migration needed) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
230 lines
6.3 KiB
Go
230 lines
6.3 KiB
Go
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
|
|
}
|