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