hatch-surf/internal/face/service_test.go
Chris Anderson 5bce1bd39b
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
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 ./...
2026-06-26 05:50:07 +02:00

318 lines
7.6 KiB
Go

package face
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]*store.Photo
embeddings []*store.FaceEmbedding
}
func NewMockPhotoRepository() *MockPhotoRepository {
return &MockPhotoRepository{
photos: make(map[string]*store.Photo),
}
}
func (m *MockPhotoRepository) GetPhoto(ctx context.Context, id string) (*store.Photo, error) {
if photo, ok := m.photos[id]; ok {
return photo, nil
}
return &store.Photo{
ID: id,
SpaceID: "test-space",
URL: "https://example.com/photo.jpg",
}, nil
}
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 *store.Photo) {
m.photos[photo.ID] = photo
}
// Test helper functions
func setupTestService() (*Service, *MockClient, *MockPhotoRepository, *MemoryCache) {
mockClient := NewMockClient()
mockRepo := NewMockPhotoRepository()
cache := NewMemoryCache()
config := DefaultConfig()
service := NewService(mockClient, mockRepo, cache, config)
return service, mockClient, mockRepo, cache
}
// Tests
func TestService_IndexPhoto_Success(t *testing.T) {
service, mockClient, mockRepo, _ := setupTestService()
ctx := context.Background()
// Test data
photoID := "test-photo-1"
imageBytes := []byte("test-image-data")
// Add photo to mock repo
mockRepo.AddPhoto(&store.Photo{
ID: photoID,
SpaceID: "test-space-1",
})
// Execute
err := service.IndexPhoto(ctx, photoID, imageBytes)
// Assert
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
_ = mockClient // Client was called internally
}
func TestService_IndexPhoto_AlreadyIndexed(t *testing.T) {
service, _, _, cache := setupTestService()
ctx := context.Background()
// Test data
photoID := "test-photo-1"
imageBytes := []byte("test-image-data")
// Pre-populate cache
cache.Set(ctx, "indexed:"+photoID, "true", 24*time.Hour)
// Execute
err := service.IndexPhoto(ctx, photoID, imageBytes)
// Assert
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
}
func TestService_SearchFaces_Success(t *testing.T) {
service, _, mockRepo, _ := setupTestService()
ctx := context.Background()
// Test data
spaceID := "test-space-1"
imageBytes := []byte("test-image-data")
limit := 10
// Add photo to mock repo
mockRepo.AddPhoto(&store.Photo{
ID: "photo-1",
SpaceID: spaceID,
URL: "https://example.com/photo-1.jpg",
})
// Execute
result, err := service.SearchFaces(ctx, spaceID, imageBytes, limit)
// Assert
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
if result == nil {
t.Fatal("Expected result, got nil")
}
if len(result.Matches) == 0 {
t.Error("Expected at least one match")
}
if result.CacheHit {
t.Error("Expected cache hit to be false")
}
}
func TestService_SearchFaces_CacheHit(t *testing.T) {
service, _, _, cache := setupTestService()
ctx := context.Background()
// Test data
spaceID := "test-space-1"
imageBytes := []byte("test-image-data")
limit := 10
// Pre-populate cache with a result
cachedResult := &FaceSearchResult{
Matches: []FaceMatch{
{
FaceID: "cached-face-1",
Similarity: 95.0,
},
},
PhotoCount: 1,
}
cache.SetJSON(ctx, "search:"+spaceID+":"+computeHash(imageBytes), cachedResult, 5*time.Minute)
// Execute
result, err := service.SearchFaces(ctx, spaceID, imageBytes, limit)
// Assert
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
if result == nil {
t.Fatal("Expected result, got nil")
}
if !result.CacheHit {
t.Error("Expected cache hit to be true")
}
if result.PhotoCount != 1 {
t.Errorf("Expected 1 photo, got %d", result.PhotoCount)
}
}
func TestService_CreateCollection(t *testing.T) {
service, _, _, _ := setupTestService()
ctx := context.Background()
// Execute
err := service.CreateCollection(ctx, "test-collection")
// Assert
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
}
func TestService_DeleteCollection(t *testing.T) {
service, _, _, _ := setupTestService()
ctx := context.Background()
// Execute
err := service.DeleteCollection(ctx, "test-collection")
// Assert
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
}
func TestConfig_Default(t *testing.T) {
config := DefaultConfig()
if config.Region != "us-east-1" {
t.Errorf("Expected region us-east-1, got %s", config.Region)
}
if config.CollectionID != "hatch-faces" {
t.Errorf("Expected collection ID hatch-faces, got %s", config.CollectionID)
}
if config.MaxFaces != 10 {
t.Errorf("Expected max faces 10, got %d", config.MaxFaces)
}
if config.Threshold != 80.0 {
t.Errorf("Expected threshold 80.0, got %f", config.Threshold)
}
if config.SearchCacheTTL != 5*time.Minute {
t.Errorf("Expected search cache TTL 5m, got %v", config.SearchCacheTTL)
}
}
func TestConfig_Validate(t *testing.T) {
// Valid config
config := DefaultConfig()
if err := config.Validate(); err != nil {
t.Errorf("Expected valid config, got error: %v", err)
}
// Invalid: missing region
invalidConfig := DefaultConfig()
invalidConfig.Region = ""
if err := invalidConfig.Validate(); err == nil {
t.Error("Expected error for missing region")
}
// Invalid: missing collection ID
invalidConfig2 := DefaultConfig()
invalidConfig2.CollectionID = ""
if err := invalidConfig2.Validate(); err == nil {
t.Error("Expected error for missing collection ID")
}
}
func TestCacheKeys(t *testing.T) {
keys := CacheKeys{}
if keys.Search("space1", "hash1") != "search:space1:hash1" {
t.Error("Search key mismatch")
}
if keys.Photo("photo1") != "photo:photo1" {
t.Error("Photo key mismatch")
}
if keys.Face("face1") != "face:face1" {
t.Error("Face key mismatch")
}
if keys.Indexed("photo1") != "indexed:photo1" {
t.Error("Indexed key mismatch")
}
}
func TestValidateImage(t *testing.T) {
service, _, _, _ := setupTestService()
// Test with valid JPEG header (need at least 100 bytes)
validJPEG := make([]byte, 200)
validJPEG[0] = 0xFF
validJPEG[1] = 0xD8
validJPEG[2] = 0xFF
validJPEG[3] = 0xE0
if err := service.ValidateImage(validJPEG); err != nil {
t.Errorf("Expected no error for valid JPEG, got: %v", err)
}
// Test with valid PNG header (need at least 100 bytes)
validPNG := make([]byte, 200)
validPNG[0] = 0x89
validPNG[1] = 0x50
validPNG[2] = 0x4E
validPNG[3] = 0x47
if err := service.ValidateImage(validPNG); err != nil {
t.Errorf("Expected no error for valid PNG, got: %v", err)
}
// Test with too large image
largeImage := make([]byte, 11*1024*1024) // 11MB
if err := service.ValidateImage(largeImage); err != ErrImageTooLarge {
t.Errorf("Expected ErrImageTooLarge, got: %v", err)
}
// Test with too small image
smallImage := make([]byte, 10)
if err := service.ValidateImage(smallImage); err != ErrImageTooSmall {
t.Errorf("Expected ErrImageTooSmall, got: %v", err)
}
// Test with invalid format (large enough but wrong header)
invalidImage := make([]byte, 200)
if err := service.ValidateImage(invalidImage); err != ErrInvalidImageFormat {
t.Errorf("Expected ErrInvalidImageFormat, got: %v", err)
}
}
func TestComputeHash(t *testing.T) {
data1 := []byte("test data for hashing")
data2 := []byte("different data for hashing")
hash1 := computeHash(data1)
hash2 := computeHash(data2)
if hash1 == hash2 {
t.Error("Expected different hashes for different data")
}
// Same data should produce same hash
hash1Again := computeHash(data1)
if hash1 != hash1Again {
t.Error("Expected same hash for same data")
}
}