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:
75
.github/workflows/backend-ci.yml
vendored
Normal file
75
.github/workflows/backend-ci.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
name: Backend CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, develop]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Download dependencies
|
||||||
|
run: go mod download
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: go test -race -count=1 ./...
|
||||||
|
|
||||||
|
- name: Run contract validation
|
||||||
|
run: go test -v -run "TestRouteSpecContract|TestKMPSpecContract" ./internal/integration/
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Build API
|
||||||
|
run: go build -ldflags "-s -w" -o bin/casera-api ./cmd/api
|
||||||
|
|
||||||
|
- name: Build Worker
|
||||||
|
run: go build -ldflags "-s -w" -o bin/casera-worker ./cmd/worker
|
||||||
|
|
||||||
|
lint:
|
||||||
|
name: Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Run go vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: |
|
||||||
|
unformatted=$(gofmt -l .)
|
||||||
|
if [ -n "$unformatted" ]; then
|
||||||
|
echo "Unformatted files:"
|
||||||
|
echo "$unformatted"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
6
Makefile
6
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: build run test clean deps lint docker-build docker-up docker-down migrate
|
.PHONY: build run test contract-test clean deps lint docker-build docker-up docker-down migrate
|
||||||
|
|
||||||
# Binary names
|
# Binary names
|
||||||
API_BINARY=casera-api
|
API_BINARY=casera-api
|
||||||
@@ -47,6 +47,10 @@ run-admin:
|
|||||||
test:
|
test:
|
||||||
go test -v -race -cover ./...
|
go test -v -race -cover ./...
|
||||||
|
|
||||||
|
# Run contract validation tests (routes + KMP vs OpenAPI spec)
|
||||||
|
contract-test:
|
||||||
|
go test -v -run "TestRouteSpecContract|TestKMPSpecContract" ./internal/integration/
|
||||||
|
|
||||||
# Run tests with coverage
|
# Run tests with coverage
|
||||||
test-coverage:
|
test-coverage:
|
||||||
go test -v -race -coverprofile=coverage.out ./...
|
go test -v -race -coverprofile=coverage.out ./...
|
||||||
|
|||||||
4523
docs/openapi.yaml
Normal file
4523
docs/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -28,12 +28,13 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Port int
|
Port int
|
||||||
Debug bool
|
Debug bool
|
||||||
AllowedHosts []string
|
AllowedHosts []string
|
||||||
Timezone string
|
CorsAllowedOrigins []string // Comma-separated origins for CORS (production only; debug uses wildcard)
|
||||||
StaticDir string // Directory for static landing page files
|
Timezone string
|
||||||
BaseURL string // Public base URL for email tracking links (e.g., https://casera.app)
|
StaticDir string // Directory for static landing page files
|
||||||
|
BaseURL string // Public base URL for email tracking links (e.g., https://casera.app)
|
||||||
}
|
}
|
||||||
|
|
||||||
type DatabaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
@@ -166,12 +167,13 @@ func Load() (*Config, error) {
|
|||||||
|
|
||||||
cfg = &Config{
|
cfg = &Config{
|
||||||
Server: ServerConfig{
|
Server: ServerConfig{
|
||||||
Port: viper.GetInt("PORT"),
|
Port: viper.GetInt("PORT"),
|
||||||
Debug: viper.GetBool("DEBUG"),
|
Debug: viper.GetBool("DEBUG"),
|
||||||
AllowedHosts: strings.Split(viper.GetString("ALLOWED_HOSTS"), ","),
|
AllowedHosts: strings.Split(viper.GetString("ALLOWED_HOSTS"), ","),
|
||||||
Timezone: viper.GetString("TIMEZONE"),
|
CorsAllowedOrigins: parseCorsOrigins(viper.GetString("CORS_ALLOWED_ORIGINS")),
|
||||||
StaticDir: viper.GetString("STATIC_DIR"),
|
Timezone: viper.GetString("TIMEZONE"),
|
||||||
BaseURL: viper.GetString("BASE_URL"),
|
StaticDir: viper.GetString("STATIC_DIR"),
|
||||||
|
BaseURL: viper.GetString("BASE_URL"),
|
||||||
},
|
},
|
||||||
Database: dbConfig,
|
Database: dbConfig,
|
||||||
Redis: RedisConfig{
|
Redis: RedisConfig{
|
||||||
@@ -304,10 +306,13 @@ func setDefaults() {
|
|||||||
|
|
||||||
func validate(cfg *Config) error {
|
func validate(cfg *Config) error {
|
||||||
if cfg.Security.SecretKey == "" {
|
if cfg.Security.SecretKey == "" {
|
||||||
// Use a default key but log a warning in production
|
if cfg.Server.Debug {
|
||||||
cfg.Security.SecretKey = "change-me-in-production-secret-key-12345"
|
// In debug mode, use a default key with a warning for local development
|
||||||
if !cfg.Server.Debug {
|
cfg.Security.SecretKey = "change-me-in-production-secret-key-12345"
|
||||||
fmt.Println("WARNING: SECRET_KEY not set, using default (insecure)")
|
fmt.Println("WARNING: SECRET_KEY not set, using default (debug mode only)")
|
||||||
|
} else {
|
||||||
|
// In production, refuse to start without a proper secret key
|
||||||
|
return fmt.Errorf("FATAL: SECRET_KEY environment variable is required in production (DEBUG=false)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,6 +344,23 @@ func (p *PushConfig) ReadAPNSKey() (string, error) {
|
|||||||
return string(content), nil
|
return string(content), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseCorsOrigins splits a comma-separated CORS_ALLOWED_ORIGINS string
|
||||||
|
// into a slice, trimming whitespace. Returns nil if the input is empty.
|
||||||
|
func parseCorsOrigins(raw string) []string {
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
var origins []string
|
||||||
|
for _, p := range parts {
|
||||||
|
trimmed := strings.TrimSpace(p)
|
||||||
|
if trimmed != "" {
|
||||||
|
origins = append(origins, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return origins
|
||||||
|
}
|
||||||
|
|
||||||
// parseDatabaseURL parses a PostgreSQL URL into DatabaseConfig
|
// parseDatabaseURL parses a PostgreSQL URL into DatabaseConfig
|
||||||
// Format: postgres://user:password@host:port/database?sslmode=disable
|
// Format: postgres://user:password@host:port/database?sslmode=disable
|
||||||
func parseDatabaseURL(databaseURL string) (*DatabaseConfig, error) {
|
func parseDatabaseURL(databaseURL string) (*DatabaseConfig, error) {
|
||||||
|
|||||||
@@ -94,6 +94,14 @@ type CreateTaskCompletionRequest struct {
|
|||||||
ImageURLs []string `json:"image_urls"` // Multiple image URLs
|
ImageURLs []string `json:"image_urls"` // Multiple image URLs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateTaskCompletionRequest represents the request to update a task completion
|
||||||
|
type UpdateTaskCompletionRequest struct {
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
ActualCost *decimal.Decimal `json:"actual_cost"`
|
||||||
|
Rating *int `json:"rating"`
|
||||||
|
ImageURLs []string `json:"image_urls"`
|
||||||
|
}
|
||||||
|
|
||||||
// CompletionImageInput represents an image to add to a completion
|
// CompletionImageInput represents an image to add to a completion
|
||||||
type CompletionImageInput struct {
|
type CompletionImageInput struct {
|
||||||
ImageURL string `json:"image_url" validate:"required"`
|
ImageURL string `json:"image_url" validate:"required"`
|
||||||
|
|||||||
@@ -241,3 +241,67 @@ func (h *DocumentHandler) DeactivateDocument(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Document deactivated successfully", "document": response})
|
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Document deactivated successfully", "document": response})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadDocumentImage handles POST /api/documents/:id/images/
|
||||||
|
func (h *DocumentHandler) UploadDocumentImage(c echo.Context) error {
|
||||||
|
user := c.Get(middleware.AuthUserKey).(*models.User)
|
||||||
|
documentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return apperrors.BadRequest("error.invalid_document_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse multipart form
|
||||||
|
if err := c.Request().ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
return apperrors.BadRequest("error.failed_to_parse_form")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for file in common field names
|
||||||
|
var uploadedFile *multipart.FileHeader
|
||||||
|
for _, fieldName := range []string{"image", "file"} {
|
||||||
|
if file, err := c.FormFile(fieldName); err == nil {
|
||||||
|
uploadedFile = file
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if uploadedFile == nil {
|
||||||
|
return apperrors.BadRequest("error.no_file_provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.storageService == nil {
|
||||||
|
return apperrors.Internal(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.storageService.Upload(uploadedFile, "images")
|
||||||
|
if err != nil {
|
||||||
|
return apperrors.BadRequest("error.failed_to_upload_file")
|
||||||
|
}
|
||||||
|
|
||||||
|
caption := c.FormValue("caption")
|
||||||
|
|
||||||
|
response, err := h.documentService.UploadDocumentImage(uint(documentID), user.ID, result.URL, caption)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusCreated, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDocumentImage handles DELETE /api/documents/:id/images/:imageId/
|
||||||
|
func (h *DocumentHandler) DeleteDocumentImage(c echo.Context) error {
|
||||||
|
user := c.Get(middleware.AuthUserKey).(*models.User)
|
||||||
|
documentID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return apperrors.BadRequest("error.invalid_document_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
imageID, err := strconv.ParseUint(c.Param("imageId"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return apperrors.BadRequest("error.invalid_image_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.documentService.DeleteDocumentImage(uint(documentID), uint(imageID), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|||||||
213
internal/handlers/document_handler_test.go
Normal file
213
internal/handlers/document_handler_test.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/treytartt/casera-api/internal/models"
|
||||||
|
"github.com/treytartt/casera-api/internal/repositories"
|
||||||
|
"github.com/treytartt/casera-api/internal/services"
|
||||||
|
"github.com/treytartt/casera-api/internal/testutil"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupDocumentHandler(t *testing.T) (*DocumentHandler, *echo.Echo, *gorm.DB) {
|
||||||
|
db := testutil.SetupTestDB(t)
|
||||||
|
documentRepo := repositories.NewDocumentRepository(db)
|
||||||
|
residenceRepo := repositories.NewResidenceRepository(db)
|
||||||
|
documentService := services.NewDocumentService(documentRepo, residenceRepo)
|
||||||
|
handler := NewDocumentHandler(documentService, nil) // nil storage for JSON-only tests
|
||||||
|
e := testutil.SetupTestRouter()
|
||||||
|
return handler, e, db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocumentHandler_ListDocuments(t *testing.T) {
|
||||||
|
handler, e, db := setupDocumentHandler(t)
|
||||||
|
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||||
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||||
|
testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Test Doc")
|
||||||
|
|
||||||
|
authGroup := e.Group("/api/documents")
|
||||||
|
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||||
|
authGroup.GET("/", handler.ListDocuments)
|
||||||
|
|
||||||
|
t.Run("successful list", func(t *testing.T) {
|
||||||
|
w := testutil.MakeRequest(e, "GET", "/api/documents/", nil, "test-token")
|
||||||
|
testutil.AssertStatusCode(t, w, http.StatusOK)
|
||||||
|
|
||||||
|
var response []map[string]interface{}
|
||||||
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, response, 1)
|
||||||
|
assert.Equal(t, "Test Doc", response[0]["title"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocumentHandler_CreateDocument(t *testing.T) {
|
||||||
|
handler, e, db := setupDocumentHandler(t)
|
||||||
|
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||||
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||||
|
|
||||||
|
authGroup := e.Group("/api/documents")
|
||||||
|
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||||
|
authGroup.POST("/", handler.CreateDocument)
|
||||||
|
|
||||||
|
t.Run("successful creation via JSON", func(t *testing.T) {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"title": "Warranty Doc",
|
||||||
|
"residence_id": residence.ID,
|
||||||
|
}
|
||||||
|
w := testutil.MakeRequest(e, "POST", "/api/documents/", body, "test-token")
|
||||||
|
testutil.AssertStatusCode(t, w, http.StatusCreated)
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Warranty Doc", response["title"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("creation without residence access", func(t *testing.T) {
|
||||||
|
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password")
|
||||||
|
otherResidence := testutil.CreateTestResidence(t, db, otherUser.ID, "Other House")
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"title": "Unauthorized Doc",
|
||||||
|
"residence_id": otherResidence.ID,
|
||||||
|
}
|
||||||
|
w := testutil.MakeRequest(e, "POST", "/api/documents/", body, "test-token")
|
||||||
|
testutil.AssertStatusCode(t, w, http.StatusForbidden)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocumentHandler_GetDocument(t *testing.T) {
|
||||||
|
handler, e, db := setupDocumentHandler(t)
|
||||||
|
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||||
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||||
|
doc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Test Doc")
|
||||||
|
|
||||||
|
authGroup := e.Group("/api/documents")
|
||||||
|
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||||
|
authGroup.GET("/:id/", handler.GetDocument)
|
||||||
|
|
||||||
|
t.Run("successful get", func(t *testing.T) {
|
||||||
|
w := testutil.MakeRequest(e, "GET", fmt.Sprintf("/api/documents/%d/", doc.ID), nil, "test-token")
|
||||||
|
testutil.AssertStatusCode(t, w, http.StatusOK)
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Test Doc", response["title"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("document not found", func(t *testing.T) {
|
||||||
|
w := testutil.MakeRequest(e, "GET", "/api/documents/99999/", nil, "test-token")
|
||||||
|
testutil.AssertStatusCode(t, w, http.StatusNotFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocumentHandler_DeleteDocumentImage(t *testing.T) {
|
||||||
|
handler, e, db := setupDocumentHandler(t)
|
||||||
|
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||||
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||||
|
doc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Test Doc")
|
||||||
|
|
||||||
|
// Create a document image directly
|
||||||
|
img := &models.DocumentImage{
|
||||||
|
DocumentID: doc.ID,
|
||||||
|
ImageURL: "https://example.com/img.jpg",
|
||||||
|
Caption: "Test image",
|
||||||
|
}
|
||||||
|
require.NoError(t, db.Create(img).Error)
|
||||||
|
|
||||||
|
authGroup := e.Group("/api/documents")
|
||||||
|
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||||
|
authGroup.DELETE("/:id/images/:imageId/", handler.DeleteDocumentImage)
|
||||||
|
|
||||||
|
t.Run("successful delete", func(t *testing.T) {
|
||||||
|
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/documents/%d/images/%d/", doc.ID, img.ID), nil, "test-token")
|
||||||
|
testutil.AssertStatusCode(t, w, http.StatusOK)
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Test Doc", response["title"])
|
||||||
|
|
||||||
|
// Verify image is deleted
|
||||||
|
images := response["images"].([]interface{})
|
||||||
|
assert.Len(t, images, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("image not found", func(t *testing.T) {
|
||||||
|
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/documents/%d/images/99999/", doc.ID), nil, "test-token")
|
||||||
|
testutil.AssertStatusCode(t, w, http.StatusNotFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("document not found", func(t *testing.T) {
|
||||||
|
w := testutil.MakeRequest(e, "DELETE", "/api/documents/99999/images/1/", nil, "test-token")
|
||||||
|
testutil.AssertStatusCode(t, w, http.StatusNotFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("access denied", func(t *testing.T) {
|
||||||
|
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password")
|
||||||
|
otherResidence := testutil.CreateTestResidence(t, db, otherUser.ID, "Other House")
|
||||||
|
otherDoc := testutil.CreateTestDocument(t, db, otherResidence.ID, otherUser.ID, "Other Doc")
|
||||||
|
|
||||||
|
otherImg := &models.DocumentImage{
|
||||||
|
DocumentID: otherDoc.ID,
|
||||||
|
ImageURL: "https://example.com/other.jpg",
|
||||||
|
}
|
||||||
|
require.NoError(t, db.Create(otherImg).Error)
|
||||||
|
|
||||||
|
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/documents/%d/images/%d/", otherDoc.ID, otherImg.ID), nil, "test-token")
|
||||||
|
testutil.AssertStatusCode(t, w, http.StatusForbidden)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocumentHandler_UploadDocumentImage_NoStorage(t *testing.T) {
|
||||||
|
handler, e, db := setupDocumentHandler(t)
|
||||||
|
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||||
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||||
|
doc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Test Doc")
|
||||||
|
|
||||||
|
authGroup := e.Group("/api/documents")
|
||||||
|
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||||
|
authGroup.POST("/:id/images/", handler.UploadDocumentImage)
|
||||||
|
|
||||||
|
t.Run("document not found", func(t *testing.T) {
|
||||||
|
// Send a plain request (no multipart) - will fail at parse
|
||||||
|
w := testutil.MakeRequest(e, "POST", "/api/documents/99999/images/", nil, "test-token")
|
||||||
|
// Should get 400 because no multipart form
|
||||||
|
assert.True(t, w.Code == http.StatusBadRequest || w.Code == http.StatusNotFound,
|
||||||
|
"expected 400 or 404, got %d", w.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
_ = doc // used to set up test data
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocumentHandler_DeleteDocument(t *testing.T) {
|
||||||
|
handler, e, db := setupDocumentHandler(t)
|
||||||
|
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||||
|
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||||
|
doc := testutil.CreateTestDocument(t, db, residence.ID, user.ID, "Test Doc")
|
||||||
|
|
||||||
|
authGroup := e.Group("/api/documents")
|
||||||
|
authGroup.Use(testutil.MockAuthMiddleware(user))
|
||||||
|
authGroup.DELETE("/:id/", handler.DeleteDocument)
|
||||||
|
|
||||||
|
t.Run("successful delete", func(t *testing.T) {
|
||||||
|
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/documents/%d/", doc.ID), nil, "test-token")
|
||||||
|
testutil.AssertStatusCode(t, w, http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("document not found after delete", func(t *testing.T) {
|
||||||
|
// Already soft-deleted above
|
||||||
|
w := testutil.MakeRequest(e, "DELETE", fmt.Sprintf("/api/documents/%d/", doc.ID), nil, "test-token")
|
||||||
|
testutil.AssertStatusCode(t, w, http.StatusNotFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -149,6 +149,33 @@ func (h *NotificationHandler) ListDevices(c echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, devices)
|
return c.JSON(http.StatusOK, devices)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnregisterDevice handles POST /api/notifications/devices/unregister/
|
||||||
|
// Accepts {registration_id, platform} and deactivates the matching device
|
||||||
|
func (h *NotificationHandler) UnregisterDevice(c echo.Context) error {
|
||||||
|
user := c.Get(middleware.AuthUserKey).(*models.User)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
RegistrationID string `json:"registration_id"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return apperrors.BadRequest("error.invalid_request")
|
||||||
|
}
|
||||||
|
if req.RegistrationID == "" {
|
||||||
|
return apperrors.BadRequest("error.registration_id_required")
|
||||||
|
}
|
||||||
|
if req.Platform == "" {
|
||||||
|
req.Platform = "ios" // Default to iOS
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.notificationService.UnregisterDevice(req.RegistrationID, req.Platform, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"message": "message.device_unregistered"})
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteDevice handles DELETE /api/notifications/devices/:id/
|
// DeleteDevice handles DELETE /api/notifications/devices/:id/
|
||||||
func (h *NotificationHandler) DeleteDevice(c echo.Context) error {
|
func (h *NotificationHandler) DeleteDevice(c echo.Context) error {
|
||||||
user := c.Get(middleware.AuthUserKey).(*models.User)
|
user := c.Get(middleware.AuthUserKey).(*models.User)
|
||||||
|
|||||||
@@ -149,6 +149,28 @@ func (h *ResidenceHandler) DeleteResidence(c echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, response)
|
return c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetShareCode handles GET /api/residences/:id/share-code/
|
||||||
|
// Returns the active share code for a residence, or null if none exists
|
||||||
|
func (h *ResidenceHandler) GetShareCode(c echo.Context) error {
|
||||||
|
user := c.Get(middleware.AuthUserKey).(*models.User)
|
||||||
|
|
||||||
|
residenceID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return apperrors.BadRequest("error.invalid_residence_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
shareCode, err := h.residenceService.GetShareCode(uint(residenceID), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if shareCode == nil {
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"share_code": nil})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"share_code": shareCode})
|
||||||
|
}
|
||||||
|
|
||||||
// GenerateShareCode handles POST /api/residences/:id/generate-share-code/
|
// GenerateShareCode handles POST /api/residences/:id/generate-share-code/
|
||||||
func (h *ResidenceHandler) GenerateShareCode(c echo.Context) error {
|
func (h *ResidenceHandler) GenerateShareCode(c echo.Context) error {
|
||||||
user := c.Get(middleware.AuthUserKey).(*models.User)
|
user := c.Get(middleware.AuthUserKey).(*models.User)
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ func (h *SubscriptionHandler) ProcessPurchase(c echo.Context) error {
|
|||||||
return apperrors.BadRequest("error.purchase_token_required")
|
return apperrors.BadRequest("error.purchase_token_required")
|
||||||
}
|
}
|
||||||
subscription, err = h.subscriptionService.ProcessGooglePurchase(user.ID, req.PurchaseToken, req.ProductID)
|
subscription, err = h.subscriptionService.ProcessGooglePurchase(user.ID, req.PurchaseToken, req.ProductID)
|
||||||
|
default:
|
||||||
|
return apperrors.BadRequest("error.invalid_platform")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -158,6 +160,8 @@ func (h *SubscriptionHandler) RestoreSubscription(c echo.Context) error {
|
|||||||
subscription, err = h.subscriptionService.ProcessApplePurchase(user.ID, req.ReceiptData, req.TransactionID)
|
subscription, err = h.subscriptionService.ProcessApplePurchase(user.ID, req.ReceiptData, req.TransactionID)
|
||||||
case "android":
|
case "android":
|
||||||
subscription, err = h.subscriptionService.ProcessGooglePurchase(user.ID, req.PurchaseToken, req.ProductID)
|
subscription, err = h.subscriptionService.ProcessGooglePurchase(user.ID, req.PurchaseToken, req.ProductID)
|
||||||
|
default:
|
||||||
|
return apperrors.BadRequest("error.invalid_platform")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -75,7 +74,12 @@ func (h *TaskHandler) GetTasksByResidence(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
daysThreshold := 30
|
daysThreshold := 30
|
||||||
if d := c.QueryParam("days_threshold"); d != "" {
|
// Support "days" param first, fall back to "days_threshold" for backward compatibility
|
||||||
|
if d := c.QueryParam("days"); d != "" {
|
||||||
|
if parsed, err := strconv.Atoi(d); err == nil {
|
||||||
|
daysThreshold = parsed
|
||||||
|
}
|
||||||
|
} else if d := c.QueryParam("days_threshold"); d != "" {
|
||||||
if parsed, err := strconv.Atoi(d); err == nil {
|
if parsed, err := strconv.Atoi(d); err == nil {
|
||||||
daysThreshold = parsed
|
daysThreshold = parsed
|
||||||
}
|
}
|
||||||
@@ -331,23 +335,17 @@ func (h *TaskHandler) CreateCompletion(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle image upload (look for "images" or "image" or "photo" field)
|
// Handle multiple image uploads from various field names
|
||||||
var imageFile interface{}
|
if h.storageService != nil && c.Request().MultipartForm != nil {
|
||||||
for _, fieldName := range []string{"images", "image", "photo"} {
|
for _, fieldName := range []string{"images", "image", "photo", "files"} {
|
||||||
if file, err := c.FormFile(fieldName); err == nil {
|
files := c.Request().MultipartForm.File[fieldName]
|
||||||
imageFile = file
|
for _, file := range files {
|
||||||
break
|
result, err := h.storageService.Upload(file, "completions")
|
||||||
}
|
if err != nil {
|
||||||
}
|
return apperrors.BadRequest("error.failed_to_upload_image")
|
||||||
|
}
|
||||||
if imageFile != nil {
|
req.ImageURLs = append(req.ImageURLs, result.URL)
|
||||||
file := imageFile.(*multipart.FileHeader)
|
|
||||||
if h.storageService != nil {
|
|
||||||
result, err := h.storageService.Upload(file, "completions")
|
|
||||||
if err != nil {
|
|
||||||
return apperrors.BadRequest("error.failed_to_upload_image")
|
|
||||||
}
|
}
|
||||||
req.ImageURLs = append(req.ImageURLs, result.URL)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -364,6 +362,26 @@ func (h *TaskHandler) CreateCompletion(c echo.Context) error {
|
|||||||
return c.JSON(http.StatusCreated, response)
|
return c.JSON(http.StatusCreated, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateCompletion handles PUT /api/task-completions/:id/
|
||||||
|
func (h *TaskHandler) UpdateCompletion(c echo.Context) error {
|
||||||
|
user := c.Get(middleware.AuthUserKey).(*models.User)
|
||||||
|
completionID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return apperrors.BadRequest("error.invalid_completion_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req requests.UpdateTaskCompletionRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return apperrors.BadRequest("error.invalid_request")
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.taskService.UpdateCompletion(uint(completionID), user.ID, &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteCompletion handles DELETE /api/task-completions/:id/
|
// DeleteCompletion handles DELETE /api/task-completions/:id/
|
||||||
func (h *TaskHandler) DeleteCompletion(c echo.Context) error {
|
func (h *TaskHandler) DeleteCompletion(c echo.Context) error {
|
||||||
user := c.Get(middleware.AuthUserKey).(*models.User)
|
user := c.Get(middleware.AuthUserKey).(*models.User)
|
||||||
|
|||||||
229
internal/integration/contract_test.go
Normal file
229
internal/integration/contract_test.go
Normal 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
|
||||||
|
}
|
||||||
286
internal/integration/kmp_contract_test.go
Normal file
286
internal/integration/kmp_contract_test.go
Normal 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
|
||||||
|
}
|
||||||
774
internal/integration/kmp_model_contract_test.go
Normal file
774
internal/integration/kmp_model_contract_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -614,6 +614,11 @@ func (r *TaskRepository) FindCompletionsByUser(userID uint, residenceIDs []uint)
|
|||||||
return completions, err
|
return completions, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateCompletion updates an existing task completion
|
||||||
|
func (r *TaskRepository) UpdateCompletion(completion *models.TaskCompletion) error {
|
||||||
|
return r.db.Omit("Task", "CompletedBy", "Images").Save(completion).Error
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteCompletion deletes a task completion
|
// DeleteCompletion deletes a task completion
|
||||||
func (r *TaskRepository) DeleteCompletion(id uint) error {
|
func (r *TaskRepository) DeleteCompletion(id uint) error {
|
||||||
// Delete images first
|
// Delete images first
|
||||||
|
|||||||
@@ -213,10 +213,27 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
|||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
// corsMiddleware configures CORS - allowing all origins for API access
|
// corsMiddleware configures CORS with restricted origins in production.
|
||||||
|
// In debug mode, all origins are allowed for development convenience.
|
||||||
|
// In production, origins are read from the CORS_ALLOWED_ORIGINS environment variable
|
||||||
|
// (comma-separated), falling back to a restrictive default set.
|
||||||
func corsMiddleware(cfg *config.Config) echo.MiddlewareFunc {
|
func corsMiddleware(cfg *config.Config) echo.MiddlewareFunc {
|
||||||
|
var origins []string
|
||||||
|
if cfg.Server.Debug {
|
||||||
|
origins = []string{"*"}
|
||||||
|
} else {
|
||||||
|
origins = cfg.Server.CorsAllowedOrigins
|
||||||
|
if len(origins) == 0 {
|
||||||
|
// Restrictive default: only the known production domains
|
||||||
|
origins = []string{
|
||||||
|
"https://casera.app",
|
||||||
|
"https://casera.treytartt.com",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return middleware.CORSWithConfig(middleware.CORSConfig{
|
return middleware.CORSWithConfig(middleware.CORSConfig{
|
||||||
AllowOrigins: []string{"*"},
|
AllowOrigins: origins,
|
||||||
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodOptions},
|
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodOptions},
|
||||||
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization, "X-Requested-With", "X-Timezone"},
|
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization, "X-Requested-With", "X-Timezone"},
|
||||||
ExposeHeaders: []string{echo.HeaderContentLength},
|
ExposeHeaders: []string{echo.HeaderContentLength},
|
||||||
@@ -311,6 +328,7 @@ func setupResidenceRoutes(api *echo.Group, residenceHandler *handlers.ResidenceH
|
|||||||
residences.PATCH("/:id/", residenceHandler.UpdateResidence)
|
residences.PATCH("/:id/", residenceHandler.UpdateResidence)
|
||||||
residences.DELETE("/:id/", residenceHandler.DeleteResidence)
|
residences.DELETE("/:id/", residenceHandler.DeleteResidence)
|
||||||
|
|
||||||
|
residences.GET("/:id/share-code/", residenceHandler.GetShareCode)
|
||||||
residences.POST("/:id/generate-share-code/", residenceHandler.GenerateShareCode)
|
residences.POST("/:id/generate-share-code/", residenceHandler.GenerateShareCode)
|
||||||
residences.POST("/:id/generate-share-package/", residenceHandler.GenerateSharePackage)
|
residences.POST("/:id/generate-share-package/", residenceHandler.GenerateSharePackage)
|
||||||
residences.POST("/:id/generate-tasks-report/", residenceHandler.GenerateTasksReport)
|
residences.POST("/:id/generate-tasks-report/", residenceHandler.GenerateTasksReport)
|
||||||
@@ -347,6 +365,7 @@ func setupTaskRoutes(api *echo.Group, taskHandler *handlers.TaskHandler) {
|
|||||||
completions.GET("/", taskHandler.ListCompletions)
|
completions.GET("/", taskHandler.ListCompletions)
|
||||||
completions.POST("/", taskHandler.CreateCompletion)
|
completions.POST("/", taskHandler.CreateCompletion)
|
||||||
completions.GET("/:id/", taskHandler.GetCompletion)
|
completions.GET("/:id/", taskHandler.GetCompletion)
|
||||||
|
completions.PUT("/:id/", taskHandler.UpdateCompletion)
|
||||||
completions.DELETE("/:id/", taskHandler.DeleteCompletion)
|
completions.DELETE("/:id/", taskHandler.DeleteCompletion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -380,6 +399,8 @@ func setupDocumentRoutes(api *echo.Group, documentHandler *handlers.DocumentHand
|
|||||||
documents.DELETE("/:id/", documentHandler.DeleteDocument)
|
documents.DELETE("/:id/", documentHandler.DeleteDocument)
|
||||||
documents.POST("/:id/activate/", documentHandler.ActivateDocument)
|
documents.POST("/:id/activate/", documentHandler.ActivateDocument)
|
||||||
documents.POST("/:id/deactivate/", documentHandler.DeactivateDocument)
|
documents.POST("/:id/deactivate/", documentHandler.DeactivateDocument)
|
||||||
|
documents.POST("/:id/images/", documentHandler.UploadDocumentImage)
|
||||||
|
documents.DELETE("/:id/images/:imageId/", documentHandler.DeleteDocumentImage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,6 +415,7 @@ func setupNotificationRoutes(api *echo.Group, notificationHandler *handlers.Noti
|
|||||||
|
|
||||||
notifications.POST("/devices/", notificationHandler.RegisterDevice)
|
notifications.POST("/devices/", notificationHandler.RegisterDevice)
|
||||||
notifications.POST("/devices/register/", notificationHandler.RegisterDevice) // Alias for mobile clients
|
notifications.POST("/devices/register/", notificationHandler.RegisterDevice) // Alias for mobile clients
|
||||||
|
notifications.POST("/devices/unregister/", notificationHandler.UnregisterDevice)
|
||||||
notifications.GET("/devices/", notificationHandler.ListDevices)
|
notifications.GET("/devices/", notificationHandler.ListDevices)
|
||||||
notifications.DELETE("/devices/:id/", notificationHandler.DeleteDevice)
|
notifications.DELETE("/devices/:id/", notificationHandler.DeleteDevice)
|
||||||
|
|
||||||
|
|||||||
@@ -159,12 +159,18 @@ func (s *AppleAuthService) VerifyIdentityToken(ctx context.Context, idToken stri
|
|||||||
return claims, nil
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifyAudience checks if the token audience matches our client ID
|
// verifyAudience checks if the token audience matches our client ID.
|
||||||
|
// In production (non-debug), an empty clientID causes verification to fail
|
||||||
|
// rather than silently bypassing the check.
|
||||||
func (s *AppleAuthService) verifyAudience(audience jwt.ClaimStrings) bool {
|
func (s *AppleAuthService) verifyAudience(audience jwt.ClaimStrings) bool {
|
||||||
clientID := s.config.AppleAuth.ClientID
|
clientID := s.config.AppleAuth.ClientID
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
// If not configured, skip audience verification (for development)
|
if s.config.Server.Debug {
|
||||||
return true
|
// In debug mode only, skip audience verification for local development
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// In production, missing client ID means we cannot verify the audience
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, aud := range audience {
|
for _, aud := range audience {
|
||||||
|
|||||||
@@ -321,3 +321,89 @@ func (s *DocumentService) DeactivateDocument(documentID, userID uint) (*response
|
|||||||
resp := responses.NewDocumentResponse(document)
|
resp := responses.NewDocumentResponse(document)
|
||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadDocumentImage adds an image to an existing document
|
||||||
|
func (s *DocumentService) UploadDocumentImage(documentID, userID uint, imageURL, caption string) (*responses.DocumentResponse, error) {
|
||||||
|
document, err := s.documentRepo.FindByID(documentID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, apperrors.NotFound("error.document_not_found")
|
||||||
|
}
|
||||||
|
return nil, apperrors.Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access via residence
|
||||||
|
hasAccess, err := s.residenceRepo.HasAccess(document.ResidenceID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, apperrors.Internal(err)
|
||||||
|
}
|
||||||
|
if !hasAccess {
|
||||||
|
return nil, apperrors.Forbidden("error.document_access_denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
img := &models.DocumentImage{
|
||||||
|
DocumentID: documentID,
|
||||||
|
ImageURL: imageURL,
|
||||||
|
Caption: caption,
|
||||||
|
}
|
||||||
|
if err := s.documentRepo.CreateDocumentImage(img); err != nil {
|
||||||
|
return nil, apperrors.Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload with relations
|
||||||
|
document, err = s.documentRepo.FindByID(documentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, apperrors.Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := responses.NewDocumentResponse(document)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDocumentImage removes an image from a document
|
||||||
|
func (s *DocumentService) DeleteDocumentImage(documentID, imageID, userID uint) (*responses.DocumentResponse, error) {
|
||||||
|
// Find the image first
|
||||||
|
image, err := s.documentRepo.FindImageByID(imageID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, apperrors.NotFound("error.document_image_not_found")
|
||||||
|
}
|
||||||
|
return nil, apperrors.Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify image belongs to the specified document
|
||||||
|
if image.DocumentID != documentID {
|
||||||
|
return nil, apperrors.NotFound("error.document_image_not_found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find parent document to check access
|
||||||
|
document, err := s.documentRepo.FindByID(documentID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, apperrors.NotFound("error.document_not_found")
|
||||||
|
}
|
||||||
|
return nil, apperrors.Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access via residence
|
||||||
|
hasAccess, err := s.residenceRepo.HasAccess(document.ResidenceID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, apperrors.Internal(err)
|
||||||
|
}
|
||||||
|
if !hasAccess {
|
||||||
|
return nil, apperrors.Forbidden("error.document_access_denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.documentRepo.DeleteDocumentImage(imageID); err != nil {
|
||||||
|
return nil, apperrors.Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload with relations
|
||||||
|
document, err = s.documentRepo.FindByID(documentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, apperrors.Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := responses.NewDocumentResponse(document)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -98,12 +98,18 @@ func (s *GoogleAuthService) VerifyIDToken(ctx context.Context, idToken string) (
|
|||||||
return &tokenInfo, nil
|
return &tokenInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifyAudience checks if the token audience matches our client ID(s)
|
// verifyAudience checks if the token audience matches our client ID(s).
|
||||||
|
// In production (non-debug), an empty clientID causes verification to fail
|
||||||
|
// rather than silently bypassing the check.
|
||||||
func (s *GoogleAuthService) verifyAudience(aud, azp string) bool {
|
func (s *GoogleAuthService) verifyAudience(aud, azp string) bool {
|
||||||
clientID := s.config.GoogleAuth.ClientID
|
clientID := s.config.GoogleAuth.ClientID
|
||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
// If not configured, skip audience verification (for development)
|
if s.config.Server.Debug {
|
||||||
return true
|
// In debug mode only, skip audience verification for local development
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// In production, missing client ID means we cannot verify the audience
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check both aud and azp (Android vs iOS may use different values)
|
// Check both aud and azp (Android vs iOS may use different values)
|
||||||
|
|||||||
@@ -372,6 +372,39 @@ func (s *NotificationService) DeleteDevice(deviceID uint, platform string, userI
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnregisterDevice deactivates a device by its registration token
|
||||||
|
func (s *NotificationService) UnregisterDevice(registrationID, platform string, userID uint) error {
|
||||||
|
switch platform {
|
||||||
|
case push.PlatformIOS:
|
||||||
|
device, err := s.notificationRepo.FindAPNSDeviceByToken(registrationID)
|
||||||
|
if err != nil {
|
||||||
|
return apperrors.NotFound("error.device_not_found")
|
||||||
|
}
|
||||||
|
// Verify ownership
|
||||||
|
if device.UserID == nil || *device.UserID != userID {
|
||||||
|
return apperrors.NotFound("error.device_not_found")
|
||||||
|
}
|
||||||
|
if err := s.notificationRepo.DeactivateAPNSDevice(device.ID); err != nil {
|
||||||
|
return apperrors.Internal(err)
|
||||||
|
}
|
||||||
|
case push.PlatformAndroid:
|
||||||
|
device, err := s.notificationRepo.FindGCMDeviceByToken(registrationID)
|
||||||
|
if err != nil {
|
||||||
|
return apperrors.NotFound("error.device_not_found")
|
||||||
|
}
|
||||||
|
// Verify ownership
|
||||||
|
if device.UserID == nil || *device.UserID != userID {
|
||||||
|
return apperrors.NotFound("error.device_not_found")
|
||||||
|
}
|
||||||
|
if err := s.notificationRepo.DeactivateGCMDevice(device.ID); err != nil {
|
||||||
|
return apperrors.Internal(err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return apperrors.BadRequest("error.invalid_platform")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// === Response/Request Types ===
|
// === Response/Request Types ===
|
||||||
|
|
||||||
// NotificationResponse represents a notification in API response
|
// NotificationResponse represents a notification in API response
|
||||||
|
|||||||
@@ -353,6 +353,29 @@ func (s *ResidenceService) GenerateShareCode(residenceID, userID uint, expiresIn
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetShareCode retrieves the active share code for a residence (if any)
|
||||||
|
func (s *ResidenceService) GetShareCode(residenceID, userID uint) (*responses.ShareCodeResponse, error) {
|
||||||
|
// Check access
|
||||||
|
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, apperrors.Internal(err)
|
||||||
|
}
|
||||||
|
if !hasAccess {
|
||||||
|
return nil, apperrors.Forbidden("error.residence_access_denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
shareCode, err := s.residenceRepo.GetActiveShareCode(residenceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, apperrors.Internal(err)
|
||||||
|
}
|
||||||
|
if shareCode == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := responses.NewShareCodeResponse(shareCode)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GenerateSharePackage generates a share code and returns package metadata for .casera file
|
// GenerateSharePackage generates a share code and returns package metadata for .casera file
|
||||||
func (s *ResidenceService) GenerateSharePackage(residenceID, userID uint, expiresInHours int) (*responses.SharePackageResponse, error) {
|
func (s *ResidenceService) GenerateSharePackage(residenceID, userID uint, expiresInHours int) (*responses.SharePackageResponse, error) {
|
||||||
// Check ownership (only owners can share residences)
|
// Check ownership (only owners can share residences)
|
||||||
|
|||||||
@@ -894,6 +894,61 @@ func (s *TaskService) ListCompletions(userID uint) ([]responses.TaskCompletionRe
|
|||||||
return responses.NewTaskCompletionListResponse(completions), nil
|
return responses.NewTaskCompletionListResponse(completions), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateCompletion updates an existing task completion
|
||||||
|
func (s *TaskService) UpdateCompletion(completionID, userID uint, req *requests.UpdateTaskCompletionRequest) (*responses.TaskCompletionResponse, error) {
|
||||||
|
completion, err := s.taskRepo.FindCompletionByID(completionID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, apperrors.NotFound("error.completion_not_found")
|
||||||
|
}
|
||||||
|
return nil, apperrors.Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access via task's residence
|
||||||
|
hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, apperrors.Internal(err)
|
||||||
|
}
|
||||||
|
if !hasAccess {
|
||||||
|
return nil, apperrors.Forbidden("error.task_access_denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply updates
|
||||||
|
if req.Notes != nil {
|
||||||
|
completion.Notes = *req.Notes
|
||||||
|
}
|
||||||
|
if req.ActualCost != nil {
|
||||||
|
completion.ActualCost = req.ActualCost
|
||||||
|
}
|
||||||
|
if req.Rating != nil {
|
||||||
|
completion.Rating = req.Rating
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.taskRepo.UpdateCompletion(completion); err != nil {
|
||||||
|
return nil, apperrors.Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any new images
|
||||||
|
for _, imageURL := range req.ImageURLs {
|
||||||
|
image := &models.TaskCompletionImage{
|
||||||
|
CompletionID: completion.ID,
|
||||||
|
ImageURL: imageURL,
|
||||||
|
}
|
||||||
|
if err := s.taskRepo.CreateCompletionImage(image); err != nil {
|
||||||
|
log.Error().Err(err).Uint("completion_id", completion.ID).Msg("Failed to create completion image during update")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload to get full associations
|
||||||
|
updated, err := s.taskRepo.FindCompletionByID(completionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, apperrors.Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := responses.NewTaskCompletionResponse(updated)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteCompletion deletes a task completion
|
// DeleteCompletion deletes a task completion
|
||||||
func (s *TaskService) DeleteCompletion(completionID, userID uint) (*responses.DeleteWithSummaryResponse, error) {
|
func (s *TaskService) DeleteCompletion(completionID, userID uint) (*responses.DeleteWithSummaryResponse, error) {
|
||||||
completion, err := s.taskRepo.FindCompletionByID(completionID)
|
completion, err := s.taskRepo.FindCompletionByID(completionID)
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ func SetupTestDB(t *testing.T) *gorm.DB {
|
|||||||
&models.Contractor{},
|
&models.Contractor{},
|
||||||
&models.ContractorSpecialty{},
|
&models.ContractorSpecialty{},
|
||||||
&models.Document{},
|
&models.Document{},
|
||||||
|
&models.DocumentImage{},
|
||||||
&models.Notification{},
|
&models.Notification{},
|
||||||
&models.NotificationPreference{},
|
&models.NotificationPreference{},
|
||||||
&models.APNSDevice{},
|
&models.APNSDevice{},
|
||||||
|
|||||||
Reference in New Issue
Block a user