Files
honeyDueAPI/internal/integration/kmp_contract_test.go
Trey t 793e50ce52 Add regional task templates API with climate zone lookup
Adds a new endpoint GET /api/tasks/templates/by-region/?zip= that resolves
ZIP codes to IECC climate regions and returns relevant home maintenance
task templates. Includes climate region model, region lookup service with
tests, seed data for all 8 climate zones with 50+ templates, and OpenAPI spec.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:15:30 -06:00

293 lines
11 KiB
Go

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
// Regional templates — not yet implemented in KMP (planned)
{Method: "GET", Path: "/tasks/templates/by-region/"}: true,
// 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
}