package integration import ( "fmt" "os" "path/filepath" "regexp" "sort" "strings" "testing" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" ) // TestKMPSpecContract verifies that KMP API clients (*Api.kt) match the OpenAPI spec. // It ensures bidirectional consistency: // - Every spec endpoint (minus exclusions) has a KMP implementation // - Every KMP endpoint (after alias resolution) exists in the spec func TestKMPSpecContract(t *testing.T) { // --- Parse OpenAPI spec --- specRoutes := extractSpecRoutesForKMP(t) require.NotEmpty(t, specRoutes, "OpenAPI spec should have at least one route") // --- Extract KMP routes --- kmpRoutes := extractKMPRoutes(t) require.NotEmpty(t, kmpRoutes, "KMP API clients should have at least one route") t.Logf("Spec routes: %d, KMP routes: %d", len(specRoutes), len(kmpRoutes)) // --- Direction 1: Every spec endpoint covered by KMP --- t.Run("spec endpoints covered by KMP", func(t *testing.T) { var missing []string for _, sr := range specRoutes { if specEndpointsKMPSkips[sr] { continue } if !containsRoute(kmpRoutes, sr) { missing = append(missing, fmt.Sprintf("%s %s", sr.Method, sr.Path)) } } if len(missing) > 0 { sort.Strings(missing) t.Errorf("OpenAPI spec defines endpoints not implemented in KMP:\n %s", strings.Join(missing, "\n ")) } }) // --- Direction 2: Every KMP endpoint exists in spec --- t.Run("KMP endpoints exist in spec", func(t *testing.T) { var missing []string for _, kr := range kmpRoutes { if !containsRoute(specRoutes, kr) { missing = append(missing, fmt.Sprintf("%s %s", kr.Method, kr.Path)) } } if len(missing) > 0 { sort.Strings(missing) t.Errorf("KMP API clients call endpoints not defined in OpenAPI spec:\n %s", strings.Join(missing, "\n ")) } }) } // specEndpointsKMPSkips are spec endpoints that KMP intentionally does not implement. // All paths must use normalized form ({_} for all path params) to match extractSpecRoutesForKMP output. var specEndpointsKMPSkips = map[routeKey]bool{ // Upload endpoints — KMP embeds file uploads in domain-specific multipart calls {Method: "POST", Path: "/uploads/image/"}: true, {Method: "POST", Path: "/uploads/document/"}: true, {Method: "POST", Path: "/uploads/completion-file/"}: true, {Method: "POST", Path: "/uploads/completion/"}: true, {Method: "DELETE", Path: "/uploads/"}: true, // Media proxy — KMP uses dynamic URLs from API responses {Method: "GET", Path: "/media/{_}"}: true, // /media/{path} {Method: "GET", Path: "/media/document/{_}"}: true, {Method: "GET", Path: "/media/document-image/{_}"}: true, {Method: "GET", Path: "/media/completion-image/{_}"}: true, // PUT/PATCH variants where KMP uses only one method {Method: "PUT", Path: "/contractors/{_}/"}: true, // KMP uses PATCH {Method: "PUT", Path: "/documents/{_}/"}: true, // KMP uses PATCH {Method: "PATCH", Path: "/residences/{_}/"}: true, // KMP uses PUT {Method: "PATCH", Path: "/tasks/{_}/completions/"}: true, // KMP uses PUT on /task-completions/{_}/ {Method: "PATCH", Path: "/auth/profile/"}: true, // KMP uses PUT // Task action endpoints where KMP uses PATCH on main resource or different endpoint {Method: "POST", Path: "/tasks/{_}/mark-in-progress/"}: true, // KMP uses PATCH tasks/{_}/ {Method: "POST", Path: "/tasks/{_}/quick-complete/"}: true, // KMP uses POST /task-completions/ // Subscription endpoints handled client-side or via different path {Method: "POST", Path: "/subscription/cancel/"}: true, // Handled by App Store / Play Store {Method: "GET", Path: "/subscription/"}: true, // KMP uses /subscription/status/ {Method: "GET", Path: "/subscription/upgrade-trigger/{_}/"}: true, // KMP uses list endpoint // Auth endpoints not yet implemented in KMP {Method: "POST", Path: "/auth/resend-verification/"}: true, // Document warranty endpoint — KMP filters via query params on /documents/ {Method: "GET", Path: "/documents/warranties/"}: true, // Device management — KMP uses different endpoints {Method: "GET", Path: "/notifications/devices/"}: true, // KMP doesn't list devices {Method: "POST", Path: "/notifications/devices/"}: true, // KMP uses /notifications/devices/register/ {Method: "POST", Path: "/notifications/devices/unregister/"}: true, // KMP uses DELETE on device ID {Method: "PATCH", Path: "/notifications/preferences/"}: true, // KMP uses PUT // Stripe web-only and server-to-server endpoints — not implemented in mobile KMP {Method: "POST", Path: "/subscription/checkout/"}: true, // Web-only (Stripe Checkout) {Method: "POST", Path: "/subscription/portal/"}: true, // Web-only (Stripe Customer Portal) {Method: "POST", Path: "/subscription/webhook/stripe/"}: true, // Server-to-server (Stripe webhook) } // kmpRouteAliases maps KMP paths to their canonical spec paths. // Applied after extraction but before spec comparison. // Currently empty — all KMP paths match spec paths after the /auth/verify/ → /auth/verify-email/ fix. var kmpRouteAliases = map[routeKey]routeKey{} // kmpDynamicExpansions maps dynamic route patterns to concrete actions. // Used for TaskApi.postTaskAction which builds paths like /tasks/$id/$action/ var kmpDynamicExpansions = map[routeKey][]string{ {Method: "POST", Path: "/tasks/{_}/{_}/"}: {"cancel", "uncancel", "archive", "unarchive"}, } // Regex patterns for extracting HTTP calls from KMP *Api.kt files. var ( // Standard Ktor calls: client.get("$baseUrl/path/") reStandardCall = regexp.MustCompile(`client\.(get|post|put|patch|delete)\(\s*"?\$baseUrl(/[^")\s]+)"?`) // Multipart calls: submitFormWithBinaryData(url = "$baseUrl/path/", ...) reMultipartCall = regexp.MustCompile(`submitFormWithBinaryData\(\s*(?:\n\s*)?url\s*=\s*"\$baseUrl(/[^"]+)"`) ) // extractKMPRoutes scans KMP *Api.kt files and returns normalized route keys. func extractKMPRoutes(t *testing.T) []routeKey { t.Helper() // KMP source directory relative to this test file kmpDir := filepath.Join("..", "..", "..", "MyCribKMM", "composeApp", "src", "commonMain", "kotlin", "com", "example", "casera", "network") // Verify directory exists — skip in CI where KMP repo may not be checked out info, err := os.Stat(kmpDir) if os.IsNotExist(err) { t.Skipf("KMP network directory not found at %s (expected in monorepo layout)", kmpDir) } require.NoError(t, err, "Failed to stat KMP network directory at %s", kmpDir) require.True(t, info.IsDir(), "KMP network path is not a directory") // Find all *Api.kt files matches, err := filepath.Glob(filepath.Join(kmpDir, "*Api.kt")) require.NoError(t, err, "Failed to glob *Api.kt files") require.NotEmpty(t, matches, "No *Api.kt files found in %s", kmpDir) seen := make(map[routeKey]bool) var routes []routeKey for _, file := range matches { data, err := os.ReadFile(file) require.NoError(t, err, "Failed to read %s", file) content := string(data) // Extract standard HTTP calls for _, match := range reStandardCall.FindAllStringSubmatch(content, -1) { method := strings.ToUpper(match[1]) path := normalizeKMPPath(match[2]) key := routeKey{Method: method, Path: path} if !seen[key] { seen[key] = true routes = append(routes, key) } } // Extract multipart calls (these are always POST) for _, match := range reMultipartCall.FindAllStringSubmatch(content, -1) { path := normalizeKMPPath(match[1]) key := routeKey{Method: "POST", Path: path} if !seen[key] { seen[key] = true routes = append(routes, key) } } } // Expand dynamic routes (e.g., /tasks/$id/$action/ → cancel, uncancel, etc.) routes = expandDynamicRoutes(routes, seen) // Resolve aliases (e.g., /auth/verify/ → /auth/verify-email/) routes = resolveAliases(routes) sort.Slice(routes, func(i, j int) bool { if routes[i].Path == routes[j].Path { return routes[i].Method < routes[j].Method } return routes[i].Path < routes[j].Path }) return routes } // normalizeKMPPath converts KMP string interpolation paths to a generic param format. // Replaces $variable segments with {_} to match normalized spec paths. // Example: /tasks/$id/$action/ → /tasks/{_}/{_}/ func normalizeKMPPath(path string) string { parts := strings.Split(path, "/") for i, part := range parts { if strings.HasPrefix(part, "$") { parts[i] = "{_}" } } return strings.Join(parts, "/") } // normalizeSpecPath converts OpenAPI {paramName} segments to {_} for comparison. // Example: /tasks/{id}/completions/ → /tasks/{_}/completions/ // Special case: /media/{path} stays as /media/{path} (wildcard, not a segment param) func normalizeSpecPath(path string) string { parts := strings.Split(path, "/") for i, part := range parts { if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") { parts[i] = "{_}" } } return strings.Join(parts, "/") } // expandDynamicRoutes expands routes with multiple path params into concrete action routes. // For example, POST /tasks/{_}/{_}/ becomes POST /tasks/{_}/cancel/, etc. func expandDynamicRoutes(routes []routeKey, seen map[routeKey]bool) []routeKey { var expanded []routeKey for _, r := range routes { if actions, ok := kmpDynamicExpansions[r]; ok { // Replace the last {_} segment with each concrete action for _, action := range actions { // Build path: replace last {_} with action name path := strings.TrimSuffix(r.Path, "{_}/") + action + "/" key := routeKey{Method: r.Method, Path: path} if !seen[key] { seen[key] = true expanded = append(expanded, key) } } // Don't include the generic pattern itself continue } expanded = append(expanded, r) } return expanded } // resolveAliases replaces KMP routes with their canonical spec equivalents. func resolveAliases(routes []routeKey) []routeKey { result := make([]routeKey, 0, len(routes)) for _, r := range routes { if canonical, ok := kmpRouteAliases[r]; ok { result = append(result, canonical) } else { result = append(result, r) } } return result } // extractSpecRoutesForKMP parses openapi.yaml and returns routes normalized for KMP comparison. func extractSpecRoutesForKMP(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") seen := make(map[routeKey]bool) var routes []routeKey for path, methods := range spec.Paths { for method := range methods { upper := strings.ToUpper(method) switch upper { case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS": normalized := normalizeSpecPath(path) key := routeKey{Method: upper, Path: normalized} if !seen[key] { seen[key] = true routes = append(routes, key) } } } } sort.Slice(routes, func(i, j int) bool { if routes[i].Path == routes[j].Path { return routes[i].Method < routes[j].Method } return routes[i].Path < routes[j].Path }) return routes }