From 5bce1bd39bc2338373217362aea156e36301619a Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Fri, 26 Jun 2026 05:50:07 +0200 Subject: [PATCH] Phase 2.4: Testing & Deployment - Wire face service into main.go with FACE_SERVICE_ENABLED toggle - Add integration tests for AWS Rekognition (build tag: integration) - Add performance benchmarks for face operations (build tag: performance) - Fix PhotoRepository interface to use store types directly - Remove duplicate PhotoInfo/FaceEmbeddingInfo types from face package - Add go.mod replace directive for local module resolution Integration tests: go test -tags=integration ./internal/face/... Performance tests: go test -tags=performance -bench=. ./internal/face/... Standard tests: go test ./... --- cmd/hatch/main.go | 31 +++- go.mod | 2 + internal/face/integration_test.go | 281 ++++++++++++++++++++++++++++++ internal/face/performance_test.go | 225 ++++++++++++++++++++++++ internal/face/service.go | 27 +-- internal/face/service_test.go | 20 ++- 6 files changed, 553 insertions(+), 33 deletions(-) create mode 100644 internal/face/integration_test.go create mode 100644 internal/face/performance_test.go diff --git a/cmd/hatch/main.go b/cmd/hatch/main.go index 1ef0ae5c..f60a0824 100644 --- a/cmd/hatch/main.go +++ b/cmd/hatch/main.go @@ -10,6 +10,8 @@ import ( "syscall" "time" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/elfoundation/hatch/internal/face" "github.com/elfoundation/hatch/internal/handler" "github.com/elfoundation/hatch/internal/store" "github.com/go-chi/chi/v5" @@ -33,7 +35,32 @@ func main() { } defer repo.Close() - h := handler.New(repo) + // Initialize face service if enabled + var faceService *face.Service + if os.Getenv("FACE_SERVICE_ENABLED") == "true" { + cfg, err := config.LoadDefaultConfig(context.Background()) + if err != nil { + log.Printf("warning: failed to load AWS config: %v", err) + } else { + collectionID := os.Getenv("REKOGNITION_COLLECTION") + if collectionID == "" { + collectionID = "hatch-faces" + } + rekognitionClient := face.NewRekognitionClient(cfg, collectionID) + faceConfig := face.DefaultConfig() + faceCache := face.NewMemoryCache() + faceService = face.NewService(rekognitionClient, repo, faceCache, faceConfig) + log.Println("face search service initialized") + } + } + + // Create handler with or without face service + var h *handler.Handler + if faceService != nil { + h = handler.NewWithFaceService(repo, faceService) + } else { + h = handler.New(repo) + } h.Debug = os.Getenv("HATCH_DEBUG") != "" r := chi.NewRouter() @@ -71,4 +98,4 @@ func main() { } log.Println("server exited") -} +} \ No newline at end of file diff --git a/go.mod b/go.mod index 70c48ac5..fb9dce08 100644 --- a/go.mod +++ b/go.mod @@ -44,3 +44,5 @@ require ( modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) + +replace github.com/elfoundation/hatch => . diff --git a/internal/face/integration_test.go b/internal/face/integration_test.go new file mode 100644 index 00000000..ddebfe39 --- /dev/null +++ b/internal/face/integration_test.go @@ -0,0 +1,281 @@ +//go:build integration + +package face + +import ( + "context" + "os" + "testing" + "time" +) + +// TestIntegration_DetectFaces tests face detection with real AWS Rekognition +func TestIntegration_DetectFaces(t *testing.T) { + if os.Getenv("AWS_REGION") == "" { + t.Skip("AWS_REGION not set, skipping integration test") + } + + cfg, err := loadAWSConfig() + if err != nil { + t.Fatalf("failed to load AWS config: %v", err) + } + + collectionID := os.Getenv("REKOGNITION_COLLECTION") + if collectionID == "" { + collectionID = "hatch-faces-test" + } + + client := NewRekognitionClient(cfg, collectionID) + ctx := context.Background() + + // Load test image + imageBytes, err := loadTestImage("test/fixtures/images/face.jpg") + if err != nil { + t.Fatalf("failed to load test image: %v", err) + } + + // Detect faces + detections, err := client.DetectFaces(ctx, imageBytes) + if err != nil { + t.Fatalf("failed to detect faces: %v", err) + } + + if len(detections) == 0 { + t.Error("expected at least one face detection") + } + + // Verify confidence > 99% + for _, detection := range detections { + if detection.Confidence < 99.0 { + t.Errorf("expected confidence > 99%%, got %f", detection.Confidence) + } + } +} + +// TestIntegration_IndexFace tests face indexing with real AWS Rekognition +func TestIntegration_IndexFace(t *testing.T) { + if os.Getenv("AWS_REGION") == "" { + t.Skip("AWS_REGION not set, skipping integration test") + } + + cfg, err := loadAWSConfig() + if err != nil { + t.Fatalf("failed to load AWS config: %v", err) + } + + collectionID := os.Getenv("REKOGNITION_COLLECTION") + if collectionID == "" { + collectionID = "hatch-faces-test" + } + + client := NewRekognitionClient(cfg, collectionID) + ctx := context.Background() + + // Create collection + if err := client.CreateCollection(ctx, collectionID); err != nil { + t.Fatalf("failed to create collection: %v", err) + } + defer client.DeleteCollection(ctx, collectionID) + + // Load test image + imageBytes, err := loadTestImage("test/fixtures/images/face.jpg") + if err != nil { + t.Fatalf("failed to load test image: %v", err) + } + + // Index face + photoID := "test-photo-" + time.Now().Format("20060102150405") + detection, err := client.IndexFace(ctx, imageBytes, photoID) + if err != nil { + t.Fatalf("failed to index face: %v", err) + } + + if detection == nil { + t.Error("expected face detection, got nil") + } + + if detection.Confidence < 99.0 { + t.Errorf("expected confidence > 99%%, got %f", detection.Confidence) + } +} + +// TestIntegration_SearchFaces tests face search with real AWS Rekognition +func TestIntegration_SearchFaces(t *testing.T) { + if os.Getenv("AWS_REGION") == "" { + t.Skip("AWS_REGION not set, skipping integration test") + } + + cfg, err := loadAWSConfig() + if err != nil { + t.Fatalf("failed to load AWS config: %v", err) + } + + collectionID := os.Getenv("REKOGNITION_COLLECTION") + if collectionID == "" { + collectionID = "hatch-faces-test" + } + + client := NewRekognitionClient(cfg, collectionID) + ctx := context.Background() + + // Create collection + if err := client.CreateCollection(ctx, collectionID); err != nil { + t.Fatalf("failed to create collection: %v", err) + } + defer client.DeleteCollection(ctx, collectionID) + + // Load and index test image + imageBytes, err := loadTestImage("test/fixtures/images/face.jpg") + if err != nil { + t.Fatalf("failed to load test image: %v", err) + } + + photoID := "test-photo-" + time.Now().Format("20060102150405") + _, err = client.IndexFace(ctx, imageBytes, photoID) + if err != nil { + t.Fatalf("failed to index face: %v", err) + } + + // Search for the indexed face + matches, err := client.SearchFacesByImage(ctx, imageBytes, 10, 80.0) + if err != nil { + t.Fatalf("failed to search faces: %v", err) + } + + if len(matches) == 0 { + t.Error("expected at least one match") + } + + // Verify similarity > 80% + for _, match := range matches { + if match.Similarity < 80.0 { + t.Errorf("expected similarity > 80%%, got %f", match.Similarity) + } + } +} + +// TestIntegration_EndToEnd tests complete face detection → index → search flow +func TestIntegration_EndToEnd(t *testing.T) { + if os.Getenv("AWS_REGION") == "" { + t.Skip("AWS_REGION not set, skipping integration test") + } + + startTime := time.Now() + + cfg, err := loadAWSConfig() + if err != nil { + t.Fatalf("failed to load AWS config: %v", err) + } + + collectionID := os.Getenv("REKOGNITION_COLLECTION") + if collectionID == "" { + collectionID = "hatch-faces-test" + } + + client := NewRekognitionClient(cfg, collectionID) + ctx := context.Background() + + // Create collection + if err := client.CreateCollection(ctx, collectionID); err != nil { + t.Fatalf("failed to create collection: %v", err) + } + defer client.DeleteCollection(ctx, collectionID) + + // Load test image + imageBytes, err := loadTestImage("test/fixtures/images/face.jpg") + if err != nil { + t.Fatalf("failed to load test image: %v", err) + } + + // Step 1: Detect faces + detections, err := client.DetectFaces(ctx, imageBytes) + if err != nil { + t.Fatalf("failed to detect faces: %v", err) + } + + if len(detections) == 0 { + t.Fatal("no faces detected") + } + + // Step 2: Index face + photoID := "e2e-test-" + time.Now().Format("20060102150405") + _, err = client.IndexFace(ctx, imageBytes, photoID) + if err != nil { + t.Fatalf("failed to index face: %v", err) + } + + // Step 3: Search for the indexed face + matches, err := client.SearchFacesByImage(ctx, imageBytes, 10, 80.0) + if err != nil { + t.Fatalf("failed to search faces: %v", err) + } + + if len(matches) == 0 { + t.Error("expected at least one match") + } + + // Verify performance < 5 seconds + elapsed := time.Since(startTime) + if elapsed > 5*time.Second { + t.Errorf("end-to-end latency exceeded 5 seconds: %v", elapsed) + } + + t.Logf("End-to-end latency: %v", elapsed) +} + +// TestIntegration_ErrorHandling tests error handling with invalid inputs +func TestIntegration_ErrorHandling(t *testing.T) { + if os.Getenv("AWS_REGION") == "" { + t.Skip("AWS_REGION not set, skipping integration test") + } + + cfg, err := loadAWSConfig() + if err != nil { + t.Fatalf("failed to load AWS config: %v", err) + } + + collectionID := os.Getenv("REKOGNITION_COLLECTION") + if collectionID == "" { + collectionID = "hatch-faces-test" + } + + client := NewRekognitionClient(cfg, collectionID) + ctx := context.Background() + + // Test with empty image data + _, err = client.DetectFaces(ctx, []byte{}) + if err == nil { + t.Error("expected error for empty image data") + } + + // Test with invalid image data + _, err = client.DetectFaces(ctx, []byte("invalid image data")) + if err == nil { + t.Error("expected error for invalid image data") + } +} + +// Helper functions + +func loadAWSConfig() (aws.Config, error) { + return config.LoadDefaultConfig(context.Background()) +} + +func loadTestImage(path string) ([]byte, error) { + // Try multiple locations + locations := []string{ + path, + "test/fixtures/images/face.jpg", + "testdata/face.jpg", + "../test/fixtures/images/face.jpg", + } + + for _, loc := range locations { + data, err := os.ReadFile(loc) + if err == nil { + return data, nil + } + } + + return nil, fmt.Errorf("test image not found in any of the expected locations") +} \ No newline at end of file diff --git a/internal/face/performance_test.go b/internal/face/performance_test.go new file mode 100644 index 00000000..83481615 --- /dev/null +++ b/internal/face/performance_test.go @@ -0,0 +1,225 @@ +//go:build performance + +package face + +import ( + "context" + "fmt" + "os" + "testing" + "time" +) + +// BenchmarkFaceDetection benchmarks face detection performance +func BenchmarkFaceDetection(b *testing.B) { + if os.Getenv("AWS_REGION") == "" { + b.Skip("AWS_REGION not set, skipping performance test") + } + + cfg, err := loadAWSConfig() + if err != nil { + b.Fatalf("failed to load AWS config: %v", err) + } + + collectionID := os.Getenv("REKOGNITION_COLLECTION") + if collectionID == "" { + collectionID = "hatch-faces-perf" + } + + client := NewRekognitionClient(cfg, collectionID) + ctx := context.Background() + + // Load test image + imageBytes, err := loadTestImage("test/fixtures/images/face.jpg") + if err != nil { + b.Fatalf("failed to load test image: %v", err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := client.DetectFaces(ctx, imageBytes) + if err != nil { + b.Fatalf("failed to detect faces: %v", err) + } + } +} + +// BenchmarkFaceIndexing benchmarks face indexing performance +func BenchmarkFaceIndexing(b *testing.B) { + if os.Getenv("AWS_REGION") == "" { + b.Skip("AWS_REGION not set, skipping performance test") + } + + cfg, err := loadAWSConfig() + if err != nil { + b.Fatalf("failed to load AWS config: %v", err) + } + + collectionID := os.Getenv("REKOGNITION_COLLECTION") + if collectionID == "" { + collectionID = "hatch-faces-perf" + } + + client := NewRekognitionClient(cfg, collectionID) + ctx := context.Background() + + // Create collection + if err := client.CreateCollection(ctx, collectionID); err != nil { + b.Fatalf("failed to create collection: %v", err) + } + defer client.DeleteCollection(ctx, collectionID) + + // Load test image + imageBytes, err := loadTestImage("test/fixtures/images/face.jpg") + if err != nil { + b.Fatalf("failed to load test image: %v", err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + photoID := fmt.Sprintf("perf-test-%d", time.Now().UnixNano()) + _, err := client.IndexFace(ctx, imageBytes, photoID) + if err != nil { + b.Fatalf("failed to index face: %v", err) + } + } +} + +// BenchmarkFaceSearch benchmarks face search performance +func BenchmarkFaceSearch(b *testing.B) { + if os.Getenv("AWS_REGION") == "" { + b.Skip("AWS_REGION not set, skipping performance test") + } + + cfg, err := loadAWSConfig() + if err != nil { + b.Fatalf("failed to load AWS config: %v", err) + } + + collectionID := os.Getenv("REKOGNITION_COLLECTION") + if collectionID == "" { + collectionID = "hatch-faces-perf" + } + + client := NewRekognitionClient(cfg, collectionID) + ctx := context.Background() + + // Create collection + if err := client.CreateCollection(ctx, collectionID); err != nil { + b.Fatalf("failed to create collection: %v", err) + } + defer client.DeleteCollection(ctx, collectionID) + + // Load and index test image + imageBytes, err := loadTestImage("test/fixtures/images/face.jpg") + if err != nil { + b.Fatalf("failed to load test image: %v", err) + } + + photoID := "perf-search-" + time.Now().Format("20060102150405") + _, err = client.IndexFace(ctx, imageBytes, photoID) + if err != nil { + b.Fatalf("failed to index face: %v", err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := client.SearchFacesByImage(ctx, imageBytes, 10, 80.0) + if err != nil { + b.Fatalf("failed to search faces: %v", err) + } + } +} + +// TestPerformanceTargets validates performance targets +func TestPerformanceTargets(t *testing.T) { + if os.Getenv("AWS_REGION") == "" { + t.Skip("AWS_REGION not set, skipping performance test") + } + + cfg, err := loadAWSConfig() + if err != nil { + t.Fatalf("failed to load AWS config: %v", err) + } + + collectionID := os.Getenv("REKOGNITION_COLLECTION") + if collectionID == "" { + collectionID = "hatch-faces-perf" + } + + client := NewRekognitionClient(cfg, collectionID) + ctx := context.Background() + + // Create collection + if err := client.CreateCollection(ctx, collectionID); err != nil { + t.Fatalf("failed to create collection: %v", err) + } + defer client.DeleteCollection(ctx, collectionID) + + // Load test image + imageBytes, err := loadTestImage("test/fixtures/images/face.jpg") + if err != nil { + t.Fatalf("failed to load test image: %v", err) + } + + // Test face detection latency + start := time.Now() + _, err = client.DetectFaces(ctx, imageBytes) + if err != nil { + t.Fatalf("failed to detect faces: %v", err) + } + detectionLatency := time.Since(start) + if detectionLatency > 2*time.Second { + t.Errorf("face detection latency exceeded 2 seconds: %v", detectionLatency) + } + t.Logf("Face detection latency: %v", detectionLatency) + + // Test face indexing latency + start = time.Now() + photoID := "perf-target-" + time.Now().Format("20060102150405") + _, err = client.IndexFace(ctx, imageBytes, photoID) + if err != nil { + t.Fatalf("failed to index face: %v", err) + } + indexingLatency := time.Since(start) + if indexingLatency > 3*time.Second { + t.Errorf("face indexing latency exceeded 3 seconds: %v", indexingLatency) + } + t.Logf("Face indexing latency: %v", indexingLatency) + + // Test face search latency + start = time.Now() + _, err = client.SearchFacesByImage(ctx, imageBytes, 10, 80.0) + if err != nil { + t.Fatalf("failed to search faces: %v", err) + } + searchLatency := time.Since(start) + if searchLatency > 2*time.Second { + t.Errorf("face search latency exceeded 2 seconds: %v", searchLatency) + } + t.Logf("Face search latency: %v", searchLatency) + + // Test end-to-end latency + start = time.Now() + // Detect + _, err = client.DetectFaces(ctx, imageBytes) + if err != nil { + t.Fatalf("failed to detect faces: %v", err) + } + // Index + photoID = "perf-e2e-" + time.Now().Format("20060102150405") + _, err = client.IndexFace(ctx, imageBytes, photoID) + if err != nil { + t.Fatalf("failed to index face: %v", err) + } + // Search + _, err = client.SearchFacesByImage(ctx, imageBytes, 10, 80.0) + if err != nil { + t.Fatalf("failed to search faces: %v", err) + } + e2eLatency := time.Since(start) + if e2eLatency > 5*time.Second { + t.Errorf("end-to-end latency exceeded 5 seconds: %v", e2eLatency) + } + t.Logf("End-to-end latency: %v", e2eLatency) +} \ No newline at end of file diff --git a/internal/face/service.go b/internal/face/service.go index 5db4b652..7cf58c05 100644 --- a/internal/face/service.go +++ b/internal/face/service.go @@ -6,31 +6,14 @@ import ( "fmt" "log" "time" + + "github.com/elfoundation/hatch/internal/store" ) // PhotoRepository defines the photo operations needed by the face service type PhotoRepository interface { - GetPhoto(ctx context.Context, id string) (*PhotoInfo, error) - CreateFaceEmbedding(ctx context.Context, embedding *FaceEmbeddingInfo) (*FaceEmbeddingInfo, error) -} - -// PhotoInfo represents photo metadata needed by the face service -type PhotoInfo struct { - ID string `json:"id"` - SpaceID string `json:"spaceId"` - URL string `json:"url"` - ThumbnailURL string `json:"thumbnailUrl"` - FaceCount int `json:"faceCount"` -} - -// FaceEmbeddingInfo represents a face embedding record -type FaceEmbeddingInfo struct { - ID string `json:"id"` - PhotoID string `json:"photoId"` - SpaceID string `json:"spaceId"` - Embedding []float32 `json:"embedding"` - BoundingBox string `json:"boundingBox"` - CreatedAt time.Time `json:"createdAt"` + GetPhoto(ctx context.Context, id string) (*store.Photo, error) + CreateFaceEmbedding(ctx context.Context, embedding *store.FaceEmbedding) (*store.FaceEmbedding, error) } // Service provides face detection, indexing, and search operations @@ -119,7 +102,7 @@ func (s *Service) IndexPhoto(ctx context.Context, photoID string, imageBytes []b } // Store in database - embedding := &FaceEmbeddingInfo{ + embedding := &store.FaceEmbedding{ PhotoID: photoID, SpaceID: photo.SpaceID, Embedding: []float32{}, // Rekognition handles embeddings internally diff --git a/internal/face/service_test.go b/internal/face/service_test.go index ae22d6aa..4bfaf557 100644 --- a/internal/face/service_test.go +++ b/internal/face/service_test.go @@ -4,37 +4,39 @@ import ( "context" "testing" "time" + + "github.com/elfoundation/hatch/internal/store" ) // MockPhotoRepository is a mock implementation of PhotoRepository for testing type MockPhotoRepository struct { - photos map[string]*PhotoInfo - embeddings []*FaceEmbeddingInfo + photos map[string]*store.Photo + embeddings []*store.FaceEmbedding } func NewMockPhotoRepository() *MockPhotoRepository { return &MockPhotoRepository{ - photos: make(map[string]*PhotoInfo), + photos: make(map[string]*store.Photo), } } -func (m *MockPhotoRepository) GetPhoto(ctx context.Context, id string) (*PhotoInfo, error) { +func (m *MockPhotoRepository) GetPhoto(ctx context.Context, id string) (*store.Photo, error) { if photo, ok := m.photos[id]; ok { return photo, nil } - return &PhotoInfo{ + return &store.Photo{ ID: id, SpaceID: "test-space", URL: "https://example.com/photo.jpg", }, nil } -func (m *MockPhotoRepository) CreateFaceEmbedding(ctx context.Context, embedding *FaceEmbeddingInfo) (*FaceEmbeddingInfo, error) { +func (m *MockPhotoRepository) CreateFaceEmbedding(ctx context.Context, embedding *store.FaceEmbedding) (*store.FaceEmbedding, error) { m.embeddings = append(m.embeddings, embedding) return embedding, nil } -func (m *MockPhotoRepository) AddPhoto(photo *PhotoInfo) { +func (m *MockPhotoRepository) AddPhoto(photo *store.Photo) { m.photos[photo.ID] = photo } @@ -61,7 +63,7 @@ func TestService_IndexPhoto_Success(t *testing.T) { imageBytes := []byte("test-image-data") // Add photo to mock repo - mockRepo.AddPhoto(&PhotoInfo{ + mockRepo.AddPhoto(&store.Photo{ ID: photoID, SpaceID: "test-space-1", }) @@ -106,7 +108,7 @@ func TestService_SearchFaces_Success(t *testing.T) { limit := 10 // Add photo to mock repo - mockRepo.AddPhoto(&PhotoInfo{ + mockRepo.AddPhoto(&store.Photo{ ID: "photo-1", SpaceID: spaceID, URL: "https://example.com/photo-1.jpg",