- 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 ./...
318 lines
7.6 KiB
Go
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")
|
|
}
|
|
}
|