Close all 25 codex audit findings and add KMP contract tests

Remediate all P0-S priority findings from cross-platform architecture audit:
- Add input validation and authorization checks across handlers
- Harden social auth (Apple/Google) token validation
- Add document ownership verification and file type validation
- Add rate limiting config and CORS origin restrictions
- Add subscription tier enforcement in handlers
- Add OpenAPI 3.0.3 spec (81 schemas, 104 operations)
- Add URL-level contract test (KMP API routes match spec paths)
- Add model-level contract test (65 schemas, 464 fields validated)
- Add CI workflow for backend tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-18 13:15:07 -06:00
parent 215e7c895d
commit bb7493f033
23 changed files with 6549 additions and 43 deletions

View File

@@ -0,0 +1,229 @@
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/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/router"
"github.com/treytartt/casera-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
}

View File

@@ -0,0 +1,286 @@
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
}
// 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
}

View File

@@ -0,0 +1,774 @@
package integration
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
// TestKMPModelSchemaContract validates that KMP @Serializable model classes match
// the OpenAPI spec schemas field-by-field. It checks:
// - Every spec field has a matching KMP property (via @SerialName or property name)
// - Types are compatible (spec string→String, integer→Int, number→Double, etc.)
// - Nullability is compatible (spec nullable:true → Kotlin Type?)
//
// This catches schema drift when the Go API evolves the spec but KMP models aren't updated.
func TestKMPModelSchemaContract(t *testing.T) {
specSchemas := loadSpecSchemas(t)
kmpModels := loadKMPModels(t)
require.NotEmpty(t, specSchemas, "should parse schemas from openapi.yaml")
require.NotEmpty(t, kmpModels, "should parse @Serializable classes from KMP models")
t.Logf("Spec schemas: %d, KMP models: %d", len(specSchemas), len(kmpModels))
// -------------------------------------------------------------------
// Direction 1: spec → KMP — every mapped spec schema field should exist in KMP
// -------------------------------------------------------------------
t.Run("spec fields exist in KMP models", func(t *testing.T) {
for specName, mapping := range schemaToKMPClass {
schema, ok := specSchemas[specName]
if !ok {
t.Errorf("spec schema %q not found in openapi.yaml", specName)
continue
}
kmpClass, ok := kmpModels[mapping.kmpClassName]
if !ok {
t.Errorf("KMP class %q (mapped from spec %q) not found in model files", mapping.kmpClassName, specName)
continue
}
t.Run(specName+"→"+mapping.kmpClassName, func(t *testing.T) {
// Build KMP field index by JSON name
kmpFieldsByJSON := make(map[string]kmpField)
for _, f := range kmpClass.fields {
kmpFieldsByJSON[f.jsonName] = f
}
for fieldName, specField := range schema.properties {
overrideKey := specName + "." + fieldName
// Skip fields known to be absent from KMP
if _, ok := knownMissingFromKMP[overrideKey]; ok {
continue
}
kf, found := kmpFieldsByJSON[fieldName]
if !found {
t.Errorf("spec field %q not found in KMP class %s", fieldName, mapping.kmpClassName)
continue
}
// Type check (unless overridden)
if _, ok := knownTypeOverrides[overrideKey]; !ok {
expectedKotlin := mapSpecTypeToKotlin(specField)
actualKotlin := normalizeKotlinType(kf.kotlinType)
if !typesCompatible(expectedKotlin, actualKotlin) {
t.Errorf("type mismatch: %s.%s — spec %s(%s) → expected Kotlin %q, got %q",
mapping.kmpClassName, fieldName,
specField.typeName, specField.format,
expectedKotlin, actualKotlin)
}
}
// Nullability: if spec says nullable, KMP must allow it
if specField.nullable && !kf.nullable && !specField.isRef && !specField.isArray {
if _, ok := knownTypeOverrides[overrideKey]; !ok {
t.Errorf("nullability mismatch: %s.%s — spec says nullable, KMP type %s is non-nullable",
mapping.kmpClassName, fieldName, kf.kotlinType)
}
}
}
})
}
})
// -------------------------------------------------------------------
// Direction 2: KMP → spec — KMP fields should exist in spec (or be documented)
// -------------------------------------------------------------------
t.Run("KMP fields exist in spec", func(t *testing.T) {
for specName, mapping := range schemaToKMPClass {
schema, ok := specSchemas[specName]
if !ok {
continue
}
kmpClass, ok := kmpModels[mapping.kmpClassName]
if !ok {
continue
}
t.Run(mapping.kmpClassName+"→"+specName, func(t *testing.T) {
for _, kf := range kmpClass.fields {
overrideKey := specName + "." + kf.jsonName
if _, ok := knownExtraInKMP[overrideKey]; ok {
continue
}
// Skip private backing fields (e.g., _verified)
if strings.HasPrefix(kf.propertyName, "_") {
continue
}
if _, found := schema.properties[kf.jsonName]; !found {
t.Errorf("KMP field %s.%s (json: %q) not in spec schema %s",
mapping.kmpClassName, kf.propertyName, kf.jsonName, specName)
}
}
})
}
})
// -------------------------------------------------------------------
// Direction 3: all spec schemas should be mapped (or excluded)
// -------------------------------------------------------------------
t.Run("all spec schemas mapped", func(t *testing.T) {
var unmapped []string
for name := range specSchemas {
if _, ok := schemaToKMPClass[name]; ok {
continue
}
if _, ok := excludedSchemas[name]; ok {
continue
}
unmapped = append(unmapped, name)
}
sort.Strings(unmapped)
if len(unmapped) > 0 {
t.Errorf("OpenAPI schemas without KMP mapping:\n %s\nAdd to schemaToKMPClass or excludedSchemas.",
strings.Join(unmapped, "\n "))
}
})
}
// ==========================================================================
// Schema ↔ KMP class mapping
// ==========================================================================
type classMapping struct {
kmpClassName string
}
var schemaToKMPClass = map[string]classMapping{
// Auth
"LoginRequest": {kmpClassName: "LoginRequest"},
"RegisterRequest": {kmpClassName: "RegisterRequest"},
"ForgotPasswordRequest": {kmpClassName: "ForgotPasswordRequest"},
"VerifyResetCodeRequest": {kmpClassName: "VerifyResetCodeRequest"},
"ResetPasswordRequest": {kmpClassName: "ResetPasswordRequest"},
"UpdateProfileRequest": {kmpClassName: "UpdateProfileRequest"},
"VerifyEmailRequest": {kmpClassName: "VerifyEmailRequest"},
"AppleSignInRequest": {kmpClassName: "AppleSignInRequest"},
"GoogleSignInRequest": {kmpClassName: "GoogleSignInRequest"},
"UserResponse": {kmpClassName: "User"},
"UserProfileResponse": {kmpClassName: "UserProfile"},
"LoginResponse": {kmpClassName: "AuthResponse"},
"RegisterResponse": {kmpClassName: "RegisterResponse"},
"SocialSignInResponse": {kmpClassName: "AppleSignInResponse"}, // Same shape
"VerifyEmailResponse": {kmpClassName: "VerifyEmailResponse"},
"VerifyResetCodeResponse": {kmpClassName: "VerifyResetCodeResponse"},
// Lookups
"ResidenceTypeResponse": {kmpClassName: "ResidenceType"},
"TaskCategoryResponse": {kmpClassName: "TaskCategory"},
"TaskPriorityResponse": {kmpClassName: "TaskPriority"},
"TaskFrequencyResponse": {kmpClassName: "TaskFrequency"},
"ContractorSpecialtyResponse": {kmpClassName: "ContractorSpecialty"},
"SeededDataResponse": {kmpClassName: "SeededDataResponse"},
"TaskTemplateResponse": {kmpClassName: "TaskTemplate"},
"TaskTemplateCategoryGroup": {kmpClassName: "TaskTemplateCategoryGroup"},
"TaskTemplatesGroupedResponse": {kmpClassName: "TaskTemplatesGroupedResponse"},
// Residence
"CreateResidenceRequest": {kmpClassName: "ResidenceCreateRequest"},
"UpdateResidenceRequest": {kmpClassName: "ResidenceUpdateRequest"},
"JoinWithCodeRequest": {kmpClassName: "JoinResidenceRequest"},
"GenerateShareCodeRequest": {kmpClassName: "GenerateShareCodeRequest"},
"ResidenceUserResponse": {kmpClassName: "ResidenceUserResponse"},
"ResidenceResponse": {kmpClassName: "ResidenceResponse"},
"TotalSummary": {kmpClassName: "TotalSummary"},
"MyResidencesResponse": {kmpClassName: "MyResidencesResponse"},
"ShareCodeResponse": {kmpClassName: "ShareCodeResponse"},
"JoinResidenceResponse": {kmpClassName: "JoinResidenceResponse"},
"GenerateShareCodeResponse": {kmpClassName: "GenerateShareCodeResponse"},
// Task
"CreateTaskRequest": {kmpClassName: "TaskCreateRequest"},
"UpdateTaskRequest": {kmpClassName: "TaskUpdateRequest"},
"TaskUserResponse": {kmpClassName: "TaskUserResponse"},
"TaskResponse": {kmpClassName: "TaskResponse"},
"KanbanColumnResponse": {kmpClassName: "TaskColumn"},
"KanbanBoardResponse": {kmpClassName: "TaskColumnsResponse"},
// Task Completion
"CreateTaskCompletionRequest": {kmpClassName: "TaskCompletionCreateRequest"},
"TaskCompletionImageResponse": {kmpClassName: "TaskCompletionImage"},
"TaskCompletionResponse": {kmpClassName: "TaskCompletionResponse"},
// Contractor
"CreateContractorRequest": {kmpClassName: "ContractorCreateRequest"},
"UpdateContractorRequest": {kmpClassName: "ContractorUpdateRequest"},
"ContractorResponse": {kmpClassName: "Contractor"},
// Document
"CreateDocumentRequest": {kmpClassName: "DocumentCreateRequest"},
"UpdateDocumentRequest": {kmpClassName: "DocumentUpdateRequest"},
"DocumentImageResponse": {kmpClassName: "DocumentImage"},
"DocumentResponse": {kmpClassName: "Document"},
// Notification
"RegisterDeviceRequest": {kmpClassName: "DeviceRegistrationRequest"},
"DeviceResponse": {kmpClassName: "DeviceRegistrationResponse"},
"NotificationPreference": {kmpClassName: "NotificationPreference"},
"UpdatePreferencesRequest": {kmpClassName: "UpdateNotificationPreferencesRequest"},
"Notification": {kmpClassName: "Notification"},
"NotificationListResponse": {kmpClassName: "NotificationListResponse"},
// Subscription
"SubscriptionStatusResponse": {kmpClassName: "SubscriptionStatus"},
"UsageResponse": {kmpClassName: "UsageStats"},
"TierLimitsClientResponse": {kmpClassName: "TierLimits"},
"FeatureBenefit": {kmpClassName: "FeatureBenefit"},
"Promotion": {kmpClassName: "Promotion"},
// Common
"ErrorResponse": {kmpClassName: "ErrorResponse"},
"MessageResponse": {kmpClassName: "MessageResponse"},
}
// excludedSchemas are spec schemas intentionally not mapped to KMP classes.
var excludedSchemas = map[string]string{
"TaskWithSummaryResponse": "KMP uses generic WithSummaryResponse<T>",
"DeleteWithSummaryResponse": "KMP uses generic WithSummaryResponse<T>",
"ResidenceWithSummaryResponse": "KMP uses generic WithSummaryResponse<T>",
"ResidenceDeleteWithSummaryResponse": "KMP uses generic WithSummaryResponse<T>",
"TaskCompletionWithSummaryResponse": "KMP uses generic WithSummaryResponse<T>",
"ProcessPurchaseRequest": "KMP splits into platform-specific requests",
"CurrentUserResponse": "KMP unifies into User class",
"DocumentType": "Enum — KMP uses DocumentType enum class",
"NotificationType": "Enum — KMP uses String",
"ToggleFavoriteResponse": "Simple message+bool, not worth a dedicated mapping",
"SharePackageResponse": "Used for .casera file sharing, handled by SharedContractor",
"UnregisterDeviceRequest": "Simple oneoff request",
"UpdateTaskCompletionRequest": "Not yet used in KMP",
"SubscriptionResponse": "Different shape — SubscriptionStatusResponse is mapped",
"UpgradeTriggerResponse": "Shape differs from KMP UpgradeTriggerData",
"UploadResult": "Handled inline in upload response parsing",
}
// knownTypeOverrides documents intentional type differences.
var knownTypeOverrides = map[string]string{
// Spec says string (decimal), KMP uses Double for form binding
"TaskResponse.estimated_cost": "KMP uses Double for numeric form binding",
"TaskResponse.actual_cost": "KMP uses Double for numeric form binding",
"CreateTaskRequest.estimated_cost": "KMP uses Double for numeric form binding",
"UpdateTaskRequest.estimated_cost": "KMP uses Double for numeric form binding",
"UpdateTaskRequest.actual_cost": "KMP uses Double for numeric form binding",
"ResidenceResponse.bathrooms": "KMP uses Double for numeric form binding",
"ResidenceResponse.lot_size": "KMP uses Double for numeric form binding",
"ResidenceResponse.purchase_price": "KMP uses Double for numeric form binding",
"CreateResidenceRequest.bathrooms": "KMP uses Double for numeric form binding",
"CreateResidenceRequest.lot_size": "KMP uses Double for numeric form binding",
"CreateResidenceRequest.purchase_price": "KMP uses Double for numeric form binding",
"UpdateResidenceRequest.bathrooms": "KMP uses Double for numeric form binding",
"UpdateResidenceRequest.lot_size": "KMP uses Double for numeric form binding",
"UpdateResidenceRequest.purchase_price": "KMP uses Double for numeric form binding",
"CreateTaskCompletionRequest.actual_cost": "KMP uses Double for numeric form binding",
"TaskCompletionResponse.actual_cost": "KMP uses Double for numeric form binding",
// Spec says nullable Boolean, KMP uses non-nullable Boolean (defaults to false)
"CreateContractorRequest.is_favorite": "KMP defaults is_favorite to false, not nullable",
// Spec uses inline object for created_by, KMP uses typed classes
"DocumentResponse.created_by": "Spec uses inline object, KMP uses DocumentUser typed class",
"ContractorResponse.created_by": "Spec uses inline object, KMP uses ContractorUser typed class",
// Spec says string (JSON), KMP uses Map<String,String>
"Notification.data": "KMP deserializes JSON string into Map<String,String>",
// Spec uses $ref to enum, KMP uses String
"CreateDocumentRequest.document_type": "KMP uses String; spec uses DocumentType $ref",
"UpdateDocumentRequest.document_type": "KMP uses String; spec uses DocumentType $ref",
"Notification.notification_type": "KMP uses String; spec uses NotificationType $ref",
// Spec has number/double, KMP has Double — these are actually compatible
// but the parser sees "number" vs "Double" which the type checker handles
}
// knownMissingFromKMP: spec fields intentionally absent from KMP.
var knownMissingFromKMP = map[string]string{
"ErrorResponse.details": "KMP uses 'errors' field with different type",
"TaskTemplateResponse.created_at": "KMP doesn't use template timestamps",
"TaskTemplateResponse.updated_at": "KMP doesn't use template timestamps",
"Notification.user_id": "KMP doesn't need user_id on notifications",
"Notification.error_message": "KMP doesn't surface notification error messages",
"Notification.updated_at": "KMP doesn't use notification updated_at",
"NotificationPreference.id": "KMP doesn't need preference record ID",
"NotificationPreference.user_id": "KMP doesn't need user_id on preferences",
"FeatureBenefit.id": "KMP doesn't use benefit record ID",
"FeatureBenefit.display_order": "KMP doesn't use benefit display order",
"FeatureBenefit.is_active": "KMP doesn't filter by active status",
"Promotion.id": "KMP uses promotion_id string instead",
"Promotion.start_date": "KMP doesn't filter by promotion dates",
"Promotion.end_date": "KMP doesn't filter by promotion dates",
"Promotion.target_tier": "KMP doesn't filter by target tier",
"Promotion.is_active": "KMP doesn't filter by active status",
"LoginRequest.email": "Spec allows email login, KMP only sends username",
// Document create/update file fields — KMP handles file upload via multipart, not JSON
"CreateDocumentRequest.file_name": "KMP handles file upload via multipart, not JSON fields",
"CreateDocumentRequest.file_size": "KMP handles file upload via multipart, not JSON fields",
"CreateDocumentRequest.mime_type": "KMP handles file upload via multipart, not JSON fields",
"CreateDocumentRequest.file_url": "KMP handles file upload via multipart, not JSON fields",
"UpdateDocumentRequest.file_name": "KMP handles file upload via multipart, not JSON fields",
"UpdateDocumentRequest.file_size": "KMP handles file upload via multipart, not JSON fields",
"UpdateDocumentRequest.mime_type": "KMP handles file upload via multipart, not JSON fields",
"UpdateDocumentRequest.file_url": "KMP handles file upload via multipart, not JSON fields",
}
// knownExtraInKMP: KMP fields not in the spec (client-side additions).
var knownExtraInKMP = map[string]string{
// Document client-side fields
"DocumentResponse.category": "Client-side field for UI grouping",
"DocumentResponse.tags": "Client-side field",
"DocumentResponse.notes": "Client-side field",
"DocumentResponse.item_name": "Client-side warranty field",
"DocumentResponse.provider": "Client-side warranty field",
"DocumentResponse.provider_contact": "Client-side warranty field",
"DocumentResponse.claim_phone": "Client-side warranty field",
"DocumentResponse.claim_email": "Client-side warranty field",
"DocumentResponse.claim_website": "Client-side warranty field",
"DocumentResponse.start_date": "Client-side warranty field",
"DocumentResponse.days_until_expiration": "Client-side computed field",
"DocumentResponse.warranty_status": "Client-side computed field",
// DocumentImage extra fields
"DocumentImageResponse.uploaded_at": "KMP has uploaded_at for display",
// TaskCompletionImage extra fields
"TaskCompletionImageResponse.uploaded_at": "KMP has uploaded_at for display",
// TaskResponse completions array (included in kanban response)
"TaskResponse.completions": "KMP includes completions array for kanban",
"TaskResponse.custom_interval_days": "KMP supports custom frequency intervals",
// ErrorResponse: KMP has 'errors', 'status_code', 'detail' not in spec
"ErrorResponse.errors": "KMP error response includes field-level errors map",
"ErrorResponse.status_code": "KMP includes HTTP status code",
"ErrorResponse.detail": "KMP error response includes detail field for validation errors",
// User: KMP has 'profile' field, spec splits into UserResponse + CurrentUserResponse
"UserResponse.profile": "KMP User unifies UserResponse + CurrentUserResponse; profile is on CurrentUserResponse",
// Contractor addedBy alias
"ContractorResponse.added_by": "KMP has added_by alias for created_by_id",
// SeededDataResponse: KMP field types differ (direct objects vs Response wrappers)
// These are compatible at JSON level but the type names differ
}
// ==========================================================================
// OpenAPI spec parsing
// ==========================================================================
type specSchema struct {
properties map[string]specField
}
type specField struct {
typeName string
format string
nullable bool
isRef bool
isArray bool
hasAdditionalProperties bool
}
func loadSpecSchemas(t *testing.T) map[string]specSchema {
t.Helper()
data, err := os.ReadFile("../../docs/openapi.yaml")
require.NoError(t, err, "Failed to read openapi.yaml")
var doc struct {
Components struct {
Schemas map[string]yaml.Node `yaml:"schemas"`
} `yaml:"components"`
}
require.NoError(t, yaml.Unmarshal(data, &doc), "Failed to parse openapi.yaml")
result := make(map[string]specSchema)
for name, node := range doc.Components.Schemas {
schema := parseSchemaNode(&node)
result[name] = schema
}
return result
}
func parseSchemaNode(node *yaml.Node) specSchema {
s := specSchema{properties: make(map[string]specField)}
if node.Kind != yaml.MappingNode {
return s
}
// Find "properties" key in the mapping
for i := 0; i < len(node.Content)-1; i += 2 {
key := node.Content[i]
val := node.Content[i+1]
if key.Value == "properties" && val.Kind == yaml.MappingNode {
// Parse each property
for j := 0; j < len(val.Content)-1; j += 2 {
propName := val.Content[j].Value
propNode := val.Content[j+1]
s.properties[propName] = parseFieldNode(propNode)
}
}
}
return s
}
func parseFieldNode(node *yaml.Node) specField {
f := specField{}
if node.Kind != yaml.MappingNode {
return f
}
for i := 0; i < len(node.Content)-1; i += 2 {
key := node.Content[i].Value
val := node.Content[i+1]
switch key {
case "type":
f.typeName = val.Value
case "format":
f.format = val.Value
case "nullable":
f.nullable = val.Value == "true"
case "$ref":
f.isRef = true
case "items":
// Array items — check if array type
case "additionalProperties":
f.hasAdditionalProperties = true
}
if key == "type" && val.Value == "array" {
f.isArray = true
}
}
return f
}
// ==========================================================================
// KMP Kotlin model parsing
// ==========================================================================
type kmpModel struct {
className string
fields []kmpField
}
type kmpField struct {
propertyName string
jsonName string // from @SerialName or property name
kotlinType string
nullable bool
}
// Regex patterns for parsing Kotlin data classes
var (
// Match: @Serializable\ndata class ClassName(
reSerializableClass = regexp.MustCompile(`@Serializable\s*\n\s*data\s+class\s+(\w+)\s*\(`)
// Match: @SerialName("json_name") val propName: Type
// or: @SerialName("json_name") private val propName: Type
reSerialNameField = regexp.MustCompile(`@SerialName\("([^"]+)"\)\s*(?:private\s+)?val\s+(\w+)\s*:\s*([^\n=,)]+)`)
// Match: val propName: Type (without @SerialName)
rePlainField = regexp.MustCompile(`(?:^|\n)\s+val\s+(\w+)\s*:\s*([^\n=,)]+)`)
)
func loadKMPModels(t *testing.T) map[string]kmpModel {
t.Helper()
modelsDir := filepath.Join("..", "..", "..", "MyCribKMM", "composeApp", "src", "commonMain",
"kotlin", "com", "example", "casera", "models")
info, err := os.Stat(modelsDir)
if os.IsNotExist(err) {
t.Skipf("KMP models directory not found at %s", modelsDir)
}
require.NoError(t, err)
require.True(t, info.IsDir())
matches, err := filepath.Glob(filepath.Join(modelsDir, "*.kt"))
require.NoError(t, err)
require.NotEmpty(t, matches)
result := make(map[string]kmpModel)
for _, file := range matches {
data, err := os.ReadFile(file)
require.NoError(t, err)
content := string(data)
models := parseKotlinModels(content)
for _, m := range models {
result[m.className] = m
}
}
return result
}
func parseKotlinModels(content string) []kmpModel {
var models []kmpModel
// Find all @Serializable data class declarations
classMatches := reSerializableClass.FindAllStringSubmatchIndex(content, -1)
for _, loc := range classMatches {
className := content[loc[2]:loc[3]]
classStart := loc[0]
// Find the constructor body (from opening ( to matching ) )
openParen := strings.Index(content[classStart:], "(")
if openParen < 0 {
continue
}
openParen += classStart
closeParen := findMatchingParen(content, openParen)
if closeParen < 0 {
continue
}
constructorBody := content[openParen+1 : closeParen]
// Parse fields from constructor
fields := parseConstructorFields(constructorBody)
models = append(models, kmpModel{
className: className,
fields: fields,
})
}
return models
}
func parseConstructorFields(body string) []kmpField {
var fields []kmpField
// Split by lines and parse each val declaration
lines := strings.Split(body, "\n")
for i := 0; i < len(lines); i++ {
line := strings.TrimSpace(lines[i])
// Check for @SerialName annotation
if strings.Contains(line, "@SerialName(") {
// May be on same line as val, or next line
combined := line
// If val is not on this line, combine with next
if !strings.Contains(line, "val ") && i+1 < len(lines) {
i++
combined = line + " " + strings.TrimSpace(lines[i])
}
match := reSerialNameField.FindStringSubmatch(combined)
if match != nil {
jsonName := match[1]
propName := match[2]
kotlinType := strings.TrimSpace(match[3])
nullable := strings.HasSuffix(kotlinType, "?")
// Clean up type: remove trailing comma, default value
kotlinType = cleanKotlinType(kotlinType)
fields = append(fields, kmpField{
propertyName: propName,
jsonName: jsonName,
kotlinType: kotlinType,
nullable: nullable,
})
}
continue
}
// Check for plain val (no @SerialName)
if strings.Contains(line, "val ") && !strings.Contains(line, "get()") {
// Skip computed properties (have get() = ...)
match := rePlainField.FindStringSubmatch("\n" + line)
if match != nil {
propName := match[1]
kotlinType := strings.TrimSpace(match[2])
nullable := strings.HasSuffix(kotlinType, "?")
kotlinType = cleanKotlinType(kotlinType)
fields = append(fields, kmpField{
propertyName: propName,
jsonName: propName, // No @SerialName, so JSON name = property name
kotlinType: kotlinType,
nullable: nullable,
})
}
}
}
return fields
}
func cleanKotlinType(t string) string {
// Remove trailing comma
t = strings.TrimSuffix(strings.TrimSpace(t), ",")
// Remove default value assignment
if idx := strings.Index(t, " ="); idx > 0 {
t = strings.TrimSpace(t[:idx])
}
// Remove trailing ? for the clean type name
// (but we already captured nullable separately)
return strings.TrimSpace(t)
}
func findMatchingParen(s string, openIdx int) int {
depth := 0
for i := openIdx; i < len(s); i++ {
switch s[i] {
case '(':
depth++
case ')':
depth--
if depth == 0 {
return i
}
}
}
return -1
}
// ==========================================================================
// Type mapping and comparison
// ==========================================================================
func mapSpecTypeToKotlin(f specField) string {
if f.isArray {
return "List"
}
if f.isRef {
return "Object" // Any object reference
}
if f.hasAdditionalProperties {
return "Map"
}
switch f.typeName {
case "string":
return "String"
case "integer":
if f.format == "int64" {
return "Long"
}
return "Int"
case "number":
return "Double"
case "boolean":
return "Boolean"
case "object":
return "Map"
default:
return "Any"
}
}
func normalizeKotlinType(t string) string {
// Remove nullable marker
t = strings.TrimSuffix(t, "?")
// Extract base type from generics
if idx := strings.Index(t, "<"); idx > 0 {
t = t[:idx]
}
return t
}
func typesCompatible(expected, actual string) bool {
if expected == actual {
return true
}
// $ref matches any object type
if expected == "Object" {
return true
}
// Long ↔ Int is acceptable for most API integers
if expected == "Long" && actual == "Int" {
return true
}
if expected == "Int" && actual == "Long" {
return true
}
return false
}
// ==========================================================================
// Summary test — prints a nice overview
// ==========================================================================
func TestKMPModelContractSummary(t *testing.T) {
specSchemas := loadSpecSchemas(t)
kmpModels := loadKMPModels(t)
t.Logf("=== KMP Model Schema Contract Summary ===")
t.Logf("OpenAPI schemas: %d", len(specSchemas))
t.Logf("KMP model classes: %d", len(kmpModels))
t.Logf("Mapped schema→class: %d", len(schemaToKMPClass))
t.Logf("Excluded schemas: %d", len(excludedSchemas))
t.Logf("Known type overrides: %d", len(knownTypeOverrides))
t.Logf("Known missing from KMP: %d", len(knownMissingFromKMP))
t.Logf("Known extra in KMP: %d", len(knownExtraInKMP))
// List mapped pairs
var pairs []string
for spec, mapping := range schemaToKMPClass {
pairs = append(pairs, fmt.Sprintf(" %s → %s", spec, mapping.kmpClassName))
}
sort.Strings(pairs)
t.Logf("Mappings:\n%s", strings.Join(pairs, "\n"))
// Count total fields validated
totalFields := 0
for specName := range schemaToKMPClass {
if schema, ok := specSchemas[specName]; ok {
totalFields += len(schema.properties)
}
}
t.Logf("Total spec fields validated: %d", totalFields)
// Verify all overrides reference valid schema.field combos
for key := range knownTypeOverrides {
parts := strings.SplitN(key, ".", 2)
assert.Len(t, parts, 2, "knownTypeOverrides key %q should be Schema.field", key)
}
for key := range knownMissingFromKMP {
parts := strings.SplitN(key, ".", 2)
assert.Len(t, parts, 2, "knownMissingFromKMP key %q should be Schema.field", key)
}
for key := range knownExtraInKMP {
parts := strings.SplitN(key, ".", 2)
assert.Len(t, parts, 2, "knownExtraInKMP key %q should be Schema.field", key)
}
}