Phase 2.4: Testing & Deployment
Some checks are pending
CI / go (push) Waiting to run
CI / docker (push) Waiting to run
CI / lint-go (push) Waiting to run
CI / lint-docs (push) Waiting to run
CI / check-paperclip (push) Waiting to run

- 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 ./...
This commit is contained in:
Chris Anderson 2026-06-26 05:50:07 +02:00
parent a63d2dbf62
commit 5bce1bd39b
6 changed files with 553 additions and 33 deletions

View File

@ -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()

2
go.mod
View File

@ -44,3 +44,5 @@ require (
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
replace github.com/elfoundation/hatch => .

View File

@ -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")
}

View File

@ -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)
}

View File

@ -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

View File

@ -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",