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>
775 lines
28 KiB
Go
775 lines
28 KiB
Go
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)
|
|
}
|
|
}
|