Add face service package for AWS Rekognition integration
- Client interface and MockClient for testing - Service layer with IndexPhoto and SearchFaces methods - CacheService interface and MemoryCache implementation - Configuration with environment variable support - Error types and user-facing messages - Unit tests for service layer Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
6e472f3eea
commit
4bf37faf4f
153
internal/face/cache.go
Normal file
153
internal/face/cache.go
Normal file
@ -0,0 +1,153 @@
|
||||
package face
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CacheService defines the interface for caching face search results
|
||||
type CacheService interface {
|
||||
// Get retrieves a cached value by key
|
||||
Get(ctx context.Context, key string) (string, error)
|
||||
|
||||
// Set stores a value with expiration
|
||||
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
|
||||
|
||||
// Delete removes cached values
|
||||
Delete(ctx context.Context, keys ...string) error
|
||||
|
||||
// GetJSON retrieves and unmarshals a cached JSON value
|
||||
GetJSON(ctx context.Context, key string, dest interface{}) error
|
||||
|
||||
// SetJSON marshals and stores a JSON value
|
||||
SetJSON(ctx context.Context, key string, value interface{}, expiration time.Duration) error
|
||||
}
|
||||
|
||||
// MemoryCache is an in-memory cache implementation for testing and development
|
||||
type MemoryCache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*cacheEntry
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
value string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// NewMemoryCache creates a new in-memory cache
|
||||
func NewMemoryCache() *MemoryCache {
|
||||
return &MemoryCache{
|
||||
entries: make(map[string]*cacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a value from the cache
|
||||
func (c *MemoryCache) Get(ctx context.Context, key string) (string, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
entry, ok := c.entries[key]
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return entry.value, nil
|
||||
}
|
||||
|
||||
// Set stores a value in the cache
|
||||
func (c *MemoryCache) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
var strValue string
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
strValue = v
|
||||
default:
|
||||
bytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
strValue = string(bytes)
|
||||
}
|
||||
|
||||
c.entries[key] = &cacheEntry{
|
||||
value: strValue,
|
||||
expiresAt: time.Now().Add(expiration),
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes values from the cache
|
||||
func (c *MemoryCache) Delete(ctx context.Context, keys ...string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
for _, key := range keys {
|
||||
delete(c.entries, key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetJSON retrieves and unmarshals a cached JSON value
|
||||
func (c *MemoryCache) GetJSON(ctx context.Context, key string, dest interface{}) error {
|
||||
value, err := c.Get(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return json.Unmarshal([]byte(value), dest)
|
||||
}
|
||||
|
||||
// SetJSON marshals and stores a JSON value
|
||||
func (c *MemoryCache) SetJSON(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||
return c.Set(ctx, key, value, expiration)
|
||||
}
|
||||
|
||||
// Cleanup removes expired entries (call periodically)
|
||||
func (c *MemoryCache) Cleanup() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for key, entry := range c.entries {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(c.entries, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CacheKeys returns common cache key patterns
|
||||
type CacheKeys struct{}
|
||||
|
||||
// Search returns the cache key for search results
|
||||
func (CacheKeys) Search(spaceID string, imageHash string) string {
|
||||
return "search:" + spaceID + ":" + imageHash
|
||||
}
|
||||
|
||||
// Photo returns the cache key for photo metadata
|
||||
func (CacheKeys) Photo(photoID string) string {
|
||||
return "photo:" + photoID
|
||||
}
|
||||
|
||||
// Face returns the cache key for face embeddings
|
||||
func (CacheKeys) Face(faceID string) string {
|
||||
return "face:" + faceID
|
||||
}
|
||||
|
||||
// Indexed returns the cache key for indexed photos
|
||||
func (CacheKeys) Indexed(photoID string) string {
|
||||
return "indexed:" + photoID
|
||||
}
|
||||
191
internal/face/client.go
Normal file
191
internal/face/client.go
Normal file
@ -0,0 +1,191 @@
|
||||
package face
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client defines the interface for face detection, indexing, and search operations
|
||||
type Client interface {
|
||||
// CreateCollection creates a new face collection for a space
|
||||
CreateCollection(ctx context.Context, collectionName string) error
|
||||
|
||||
// DeleteCollection deletes a face collection
|
||||
DeleteCollection(ctx context.Context, collectionName string) error
|
||||
|
||||
// DetectFaces detects faces in an image
|
||||
DetectFaces(ctx context.Context, imageBytes []byte) ([]FaceDetection, error)
|
||||
|
||||
// IndexFace indexes a face in a collection
|
||||
IndexFace(ctx context.Context, imageBytes []byte, photoID string) (*FaceDetection, error)
|
||||
|
||||
// SearchFacesByImage searches for similar faces in the collection
|
||||
SearchFacesByImage(ctx context.Context, imageBytes []byte, maxFaces int, threshold float64) ([]FaceMatch, error)
|
||||
}
|
||||
|
||||
// Compile-time check that RekognitionClient implements Client
|
||||
var _ Client = (*RekognitionClient)(nil)
|
||||
|
||||
// MockClient is a mock implementation of the Client interface for testing
|
||||
type MockClient struct {
|
||||
mu sync.RWMutex
|
||||
collections map[string]*CollectionInfo
|
||||
faces map[string][]FaceIndex
|
||||
detections []FaceDetection
|
||||
searchResults []FaceMatch
|
||||
|
||||
// Mock configuration
|
||||
DetectFacesFunc func(ctx context.Context, imageBytes []byte) ([]FaceDetection, error)
|
||||
IndexFaceFunc func(ctx context.Context, imageBytes []byte, photoID string) (*FaceDetection, error)
|
||||
SearchFacesFunc func(ctx context.Context, imageBytes []byte, maxFaces int, threshold float64) ([]FaceMatch, error)
|
||||
}
|
||||
|
||||
// FaceIndex represents an indexed face (internal to mock)
|
||||
type FaceIndex struct {
|
||||
FaceID string `json:"faceId"`
|
||||
ExternalImageID string `json:"externalImageId"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
BoundingBox BoundingBox `json:"boundingBox"`
|
||||
IndexedAt time.Time `json:"indexedAt"`
|
||||
}
|
||||
|
||||
// NewMockClient creates a new mock client for testing
|
||||
func NewMockClient() *MockClient {
|
||||
return &MockClient{
|
||||
collections: make(map[string]*CollectionInfo),
|
||||
faces: make(map[string][]FaceIndex),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateCollection creates a mock collection
|
||||
func (m *MockClient) CreateCollection(ctx context.Context, collectionName string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.collections[collectionName] = &CollectionInfo{
|
||||
CollectionID: collectionName,
|
||||
FaceCount: 0,
|
||||
CreationDate: time.Now(),
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCollection deletes a mock collection
|
||||
func (m *MockClient) DeleteCollection(ctx context.Context, collectionName string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
delete(m.collections, collectionName)
|
||||
delete(m.faces, collectionName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DetectFaces returns mock face detections
|
||||
func (m *MockClient) DetectFaces(ctx context.Context, imageBytes []byte) ([]FaceDetection, error) {
|
||||
if m.DetectFacesFunc != nil {
|
||||
return m.DetectFacesFunc(ctx, imageBytes)
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Return mock detections
|
||||
if len(m.detections) > 0 {
|
||||
return m.detections, nil
|
||||
}
|
||||
|
||||
// Default: return one face
|
||||
return []FaceDetection{
|
||||
{
|
||||
Confidence: 99.5,
|
||||
BoundingBox: BoundingBox{
|
||||
Left: 0.1,
|
||||
Top: 0.1,
|
||||
Width: 0.3,
|
||||
Height: 0.3,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IndexFace indexes a mock face
|
||||
func (m *MockClient) IndexFace(ctx context.Context, imageBytes []byte, photoID string) (*FaceDetection, error) {
|
||||
if m.IndexFaceFunc != nil {
|
||||
return m.IndexFaceFunc(ctx, imageBytes, photoID)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
detection := &FaceDetection{
|
||||
Confidence: 99.5,
|
||||
BoundingBox: BoundingBox{
|
||||
Left: 0.1,
|
||||
Top: 0.1,
|
||||
Width: 0.3,
|
||||
Height: 0.3,
|
||||
},
|
||||
}
|
||||
|
||||
// Store in mock database
|
||||
faceIndex := FaceIndex{
|
||||
FaceID: fmt.Sprintf("face-%s-%d", photoID, time.Now().UnixNano()),
|
||||
ExternalImageID: photoID,
|
||||
Confidence: 99.5,
|
||||
BoundingBox: detection.BoundingBox,
|
||||
IndexedAt: time.Now(),
|
||||
}
|
||||
m.faces["hatch-default"] = append(m.faces["hatch-default"], faceIndex)
|
||||
|
||||
return detection, nil
|
||||
}
|
||||
|
||||
// SearchFacesByImage returns mock search results
|
||||
func (m *MockClient) SearchFacesByImage(ctx context.Context, imageBytes []byte, maxFaces int, threshold float64) ([]FaceMatch, error) {
|
||||
if m.SearchFacesFunc != nil {
|
||||
return m.SearchFacesFunc(ctx, imageBytes, maxFaces, threshold)
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Return mock matches
|
||||
if len(m.searchResults) > 0 {
|
||||
if maxFaces > 0 && len(m.searchResults) > maxFaces {
|
||||
return m.searchResults[:maxFaces], nil
|
||||
}
|
||||
return m.searchResults, nil
|
||||
}
|
||||
|
||||
// Default: return one match
|
||||
return []FaceMatch{
|
||||
{
|
||||
FaceID: "face-mock-1",
|
||||
ExternalImageID: "photo-1",
|
||||
Similarity: 95.0,
|
||||
BoundingBox: BoundingBox{
|
||||
Left: 0.1,
|
||||
Top: 0.1,
|
||||
Width: 0.3,
|
||||
Height: 0.3,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetDetections configures mock face detections
|
||||
func (m *MockClient) SetDetections(detections []FaceDetection) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.detections = detections
|
||||
}
|
||||
|
||||
// SetSearchResults configures mock search results
|
||||
func (m *MockClient) SetSearchResults(results []FaceMatch) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.searchResults = results
|
||||
}
|
||||
102
internal/face/config.go
Normal file
102
internal/face/config.go
Normal file
@ -0,0 +1,102 @@
|
||||
package face
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds configuration for the face service
|
||||
type Config struct {
|
||||
// AWS Configuration
|
||||
Region string
|
||||
CollectionID string
|
||||
|
||||
// Face Detection Configuration
|
||||
MaxFaces int
|
||||
Threshold float64
|
||||
QualityFilter string
|
||||
|
||||
// Cache Configuration
|
||||
CacheTTL time.Duration
|
||||
SearchCacheTTL time.Duration
|
||||
PhotoCacheTTL time.Duration
|
||||
|
||||
// Rate Limiting
|
||||
GuestRateLimit int
|
||||
GuestRateWindowMin int
|
||||
AuthRateLimit int
|
||||
AuthRateWindowMin int
|
||||
|
||||
// Image Optimization
|
||||
MaxImageSize int
|
||||
ResizeWidth int
|
||||
ResizeHeight int
|
||||
ImageQuality int
|
||||
}
|
||||
|
||||
// DefaultConfig returns default configuration
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Region: getEnv("AWS_REGION", "us-east-1"),
|
||||
CollectionID: getEnv("REKOGNITION_COLLECTION", "hatch-faces"),
|
||||
MaxFaces: getEnvInt("REKOGNITION_MAX_FACES", 10),
|
||||
Threshold: getEnvFloat("REKOGNITION_THRESHOLD", 80.0),
|
||||
QualityFilter: getEnv("REKOGNITION_QUALITY_FILTER", "AUTO"),
|
||||
CacheTTL: 5 * time.Minute,
|
||||
SearchCacheTTL: 5 * time.Minute,
|
||||
PhotoCacheTTL: 24 * time.Hour,
|
||||
GuestRateLimit: getEnvInt("RATE_LIMIT_GUEST_SEARCHES", 20),
|
||||
GuestRateWindowMin: getEnvInt("RATE_LIMIT_GUEST_WINDOW_MINUTES", 15),
|
||||
AuthRateLimit: getEnvInt("RATE_LIMIT_AUTH_SEARCHES", 100),
|
||||
AuthRateWindowMin: getEnvInt("RATE_LIMIT_AUTH_WINDOW_MINUTES", 1),
|
||||
MaxImageSize: 10 * 1024 * 1024, // 10MB
|
||||
ResizeWidth: 1024,
|
||||
ResizeHeight: 1024,
|
||||
ImageQuality: 85,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks if the configuration is valid
|
||||
func (c *Config) Validate() error {
|
||||
if c.Region == "" {
|
||||
return ErrMissingRegion
|
||||
}
|
||||
if c.CollectionID == "" {
|
||||
return ErrMissingCollectionID
|
||||
}
|
||||
if c.MaxFaces <= 0 {
|
||||
return ErrInvalidMaxFaces
|
||||
}
|
||||
if c.Threshold < 0 || c.Threshold > 100 {
|
||||
return ErrInvalidThreshold
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions for environment variables
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultValue int) int {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if intValue, err := strconv.Atoi(value); err == nil {
|
||||
return intValue
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvFloat(key string, defaultValue float64) float64 {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if floatValue, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
return floatValue
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
94
internal/face/errors.go
Normal file
94
internal/face/errors.go
Normal file
@ -0,0 +1,94 @@
|
||||
package face
|
||||
|
||||
import "errors"
|
||||
|
||||
// Sentinel errors for face operations
|
||||
var (
|
||||
// Client errors
|
||||
ErrNoFacesDetected = errors.New("no faces detected in image")
|
||||
ErrImageTooSmall = errors.New("image too small for face detection")
|
||||
ErrImageTooLarge = errors.New("image exceeds maximum size")
|
||||
ErrInvalidImageFormat = errors.New("invalid image format")
|
||||
ErrImageProcessingFailed = errors.New("failed to process image")
|
||||
|
||||
// Collection errors
|
||||
ErrCollectionNotFound = errors.New("face collection not found")
|
||||
ErrCollectionExists = errors.New("face collection already exists")
|
||||
ErrCollectionCreationFailed = errors.New("failed to create face collection")
|
||||
|
||||
// Indexing errors
|
||||
ErrIndexingFailed = errors.New("failed to index face")
|
||||
ErrFaceAlreadyIndexed = errors.New("face already indexed")
|
||||
|
||||
// Search errors
|
||||
ErrSearchFailed = errors.New("face search failed")
|
||||
ErrNoMatchesFound = errors.New("no matching faces found")
|
||||
|
||||
// Configuration errors
|
||||
ErrMissingRegion = errors.New("AWS region is required")
|
||||
ErrMissingCollectionID = errors.New("collection ID is required")
|
||||
ErrInvalidMaxFaces = errors.New("max faces must be positive")
|
||||
ErrInvalidThreshold = errors.New("threshold must be between 0 and 100")
|
||||
|
||||
// Rate limiting errors
|
||||
ErrRateLimitExceeded = errors.New("rate limit exceeded")
|
||||
|
||||
// AWS service errors
|
||||
ErrAWSServiceError = errors.New("AWS Rekognition service error")
|
||||
ErrAWSAccessDenied = errors.New("AWS access denied")
|
||||
ErrAWSThrottling = errors.New("AWS request throttled")
|
||||
ErrAWSInvalidImage = errors.New("invalid image for AWS Rekognition")
|
||||
)
|
||||
|
||||
// FaceSearchError represents a structured error for face search operations
|
||||
type FaceSearchError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (e *FaceSearchError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// NewFaceSearchError creates a new FaceSearchError
|
||||
func NewFaceSearchError(code, message, details string) *FaceSearchError {
|
||||
return &FaceSearchError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
|
||||
// Common error codes
|
||||
const (
|
||||
ErrorCodeNoFaces = "NO_FACES_DETECTED"
|
||||
ErrorCodeInvalidImage = "INVALID_IMAGE"
|
||||
ErrorCodeRateLimited = "RATE_LIMITED"
|
||||
ErrorCodeCollectionError = "COLLECTION_ERROR"
|
||||
ErrorCodeSearchFailed = "SEARCH_FAILED"
|
||||
ErrorCodeIndexingFailed = "INDEXING_FAILED"
|
||||
ErrorCodeInternalError = "INTERNAL_ERROR"
|
||||
)
|
||||
|
||||
// User-facing error messages
|
||||
var UserErrorMessages = map[error]string{
|
||||
ErrNoFacesDetected: "No faces detected in the image. Please upload a photo with clear faces.",
|
||||
ErrImageTooSmall: "Image is too small. Please upload a larger image (minimum 100x100 pixels).",
|
||||
ErrImageTooLarge: "Image is too large. Please upload an image smaller than 10MB.",
|
||||
ErrInvalidImageFormat: "Invalid image format. Please upload a JPEG, PNG, or WebP image.",
|
||||
ErrCollectionNotFound: "Face collection not found. Please contact support.",
|
||||
ErrRateLimitExceeded: "Search limit exceeded. Please try again later.",
|
||||
ErrAWSServiceError: "An error occurred while processing your request. Please try again.",
|
||||
ErrAWSAccessDenied: "Access denied. Please contact support.",
|
||||
ErrAWSThrottling: "Service is busy. Please try again in a few moments.",
|
||||
}
|
||||
|
||||
// GetUserMessage returns a user-friendly error message
|
||||
func GetUserMessage(err error) string {
|
||||
if msg, ok := UserErrorMessages[err]; ok {
|
||||
return msg
|
||||
}
|
||||
return "An unexpected error occurred. Please try again."
|
||||
}
|
||||
35
internal/face/models.go
Normal file
35
internal/face/models.go
Normal file
@ -0,0 +1,35 @@
|
||||
package face
|
||||
|
||||
import "time"
|
||||
|
||||
// FaceSearchResult represents the result of a face search
|
||||
type FaceSearchResult struct {
|
||||
Matches []FaceMatch `json:"matches"`
|
||||
PhotoCount int `json:"photoCount"`
|
||||
SearchTime time.Duration `json:"searchTime"`
|
||||
CacheHit bool `json:"cacheHit"`
|
||||
}
|
||||
|
||||
// CollectionInfo represents information about a face collection
|
||||
type CollectionInfo struct {
|
||||
CollectionID string `json:"collectionId"`
|
||||
FaceCount int `json:"faceCount"`
|
||||
CreationDate time.Time `json:"creationDate"`
|
||||
LastUpdated time.Time `json:"lastUpdated"`
|
||||
}
|
||||
|
||||
// SearchRequest represents a face search request
|
||||
type SearchRequest struct {
|
||||
ImageBytes []byte `json:"-"`
|
||||
ImageURL string `json:"imageUrl,omitempty"`
|
||||
MaxFaces int `json:"maxFaces,omitempty"`
|
||||
Threshold float64 `json:"threshold,omitempty"`
|
||||
}
|
||||
|
||||
// IndexRequest represents a face indexing request
|
||||
type IndexRequest struct {
|
||||
PhotoID string `json:"photoId"`
|
||||
SpaceID string `json:"spaceId"`
|
||||
ImageBytes []byte `json:"-"`
|
||||
ImageURL string `json:"imageUrl,omitempty"`
|
||||
}
|
||||
218
internal/face/rekognition.go
Normal file
218
internal/face/rekognition.go
Normal file
@ -0,0 +1,218 @@
|
||||
package face
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"bytes"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/service/rekognition"
|
||||
"github.com/aws/aws-sdk-go-v2/service/rekognition/types"
|
||||
)
|
||||
|
||||
// RekognitionClient wraps AWS Rekognition operations
|
||||
type RekognitionClient struct {
|
||||
client *rekognition.Client
|
||||
collectionID string
|
||||
}
|
||||
|
||||
// FaceDetection represents a detected face
|
||||
type FaceDetection struct {
|
||||
Confidence float64 `json:"confidence"`
|
||||
BoundingBox BoundingBox `json:"boundingBox"`
|
||||
}
|
||||
|
||||
// BoundingBox represents face location in image
|
||||
type BoundingBox struct {
|
||||
Left float64 `json:"left"`
|
||||
Top float64 `json:"top"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}
|
||||
|
||||
// FaceMatch represents a matched face
|
||||
type FaceMatch struct {
|
||||
FaceID string `json:"faceId"`
|
||||
ExternalImageID string `json:"externalImageId"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
BoundingBox BoundingBox `json:"boundingBox"`
|
||||
PhotoID string `json:"photoId,omitempty"`
|
||||
PhotoURL string `json:"photoUrl,omitempty"`
|
||||
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
|
||||
}
|
||||
|
||||
// NewRekognitionClient creates a new Rekognition client
|
||||
func NewRekognitionClient(cfg aws.Config, collectionID string) *RekognitionClient {
|
||||
return &RekognitionClient{
|
||||
client: rekognition.NewFromConfig(cfg),
|
||||
collectionID: collectionID,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateCollection creates a new face collection
|
||||
func (r *RekognitionClient) CreateCollection(ctx context.Context, collectionName string) error {
|
||||
_, err := r.client.CreateCollection(ctx, &rekognition.CreateCollectionInput{
|
||||
CollectionId: &collectionName,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create collection: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCollection deletes a face collection
|
||||
func (r *RekognitionClient) DeleteCollection(ctx context.Context, collectionName string) error {
|
||||
_, err := r.client.DeleteCollection(ctx, &rekognition.DeleteCollectionInput{
|
||||
CollectionId: &collectionName,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete collection: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DetectFaces detects faces in an image
|
||||
func (r *RekognitionClient) DetectFaces(ctx context.Context, imageBytes []byte) ([]FaceDetection, error) {
|
||||
input := &rekognition.DetectFacesInput{
|
||||
Image: &types.Image{
|
||||
Bytes: imageBytes,
|
||||
},
|
||||
Attributes: []types.Attribute{
|
||||
types.AttributeAll,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := r.client.DetectFaces(ctx, input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to detect faces: %w", err)
|
||||
}
|
||||
|
||||
var detections []FaceDetection
|
||||
for _, face := range result.FaceDetails {
|
||||
if face.BoundingBox == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
detection := FaceDetection{
|
||||
Confidence: float64(*face.Confidence),
|
||||
BoundingBox: BoundingBox{
|
||||
Left: float64(*face.BoundingBox.Left),
|
||||
Top: float64(*face.BoundingBox.Top),
|
||||
Width: float64(*face.BoundingBox.Width),
|
||||
Height: float64(*face.BoundingBox.Height),
|
||||
},
|
||||
}
|
||||
detections = append(detections, detection)
|
||||
}
|
||||
|
||||
return detections, nil
|
||||
}
|
||||
|
||||
// IndexFace indexes a face in the collection
|
||||
func (r *RekognitionClient) IndexFace(ctx context.Context, imageBytes []byte, photoID string) (*FaceDetection, error) {
|
||||
input := &rekognition.IndexFacesInput{
|
||||
CollectionId: &r.collectionID,
|
||||
Image: &types.Image{
|
||||
Bytes: imageBytes,
|
||||
},
|
||||
ExternalImageId: &photoID,
|
||||
MaxFaces: aws.Int32(1),
|
||||
QualityFilter: types.QualityFilterAuto,
|
||||
}
|
||||
|
||||
result, err := r.client.IndexFaces(ctx, input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to index face: %w", err)
|
||||
}
|
||||
|
||||
if len(result.FaceRecords) == 0 {
|
||||
return nil, fmt.Errorf("no faces found in image")
|
||||
}
|
||||
|
||||
faceRecord := result.FaceRecords[0]
|
||||
if faceRecord.FaceDetail == nil || faceRecord.FaceDetail.BoundingBox == nil {
|
||||
return nil, fmt.Errorf("no face detail available")
|
||||
}
|
||||
|
||||
return &FaceDetection{
|
||||
Confidence: float64(*faceRecord.Face.Confidence),
|
||||
BoundingBox: BoundingBox{
|
||||
Left: float64(*faceRecord.FaceDetail.BoundingBox.Left),
|
||||
Top: float64(*faceRecord.FaceDetail.BoundingBox.Top),
|
||||
Width: float64(*faceRecord.FaceDetail.BoundingBox.Width),
|
||||
Height: float64(*faceRecord.FaceDetail.BoundingBox.Height),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SearchFacesByImage searches for similar faces
|
||||
func (r *RekognitionClient) SearchFacesByImage(ctx context.Context, imageBytes []byte, maxFaces int, threshold float64) ([]FaceMatch, error) {
|
||||
input := &rekognition.SearchFacesByImageInput{
|
||||
CollectionId: &r.collectionID,
|
||||
Image: &types.Image{
|
||||
Bytes: imageBytes,
|
||||
},
|
||||
MaxFaces: aws.Int32(int32(maxFaces)),
|
||||
FaceMatchThreshold: aws.Float32(float32(threshold)),
|
||||
}
|
||||
|
||||
result, err := r.client.SearchFacesByImage(ctx, input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search faces: %w", err)
|
||||
}
|
||||
|
||||
var matches []FaceMatch
|
||||
for _, match := range result.FaceMatches {
|
||||
if match.Face == nil || match.Face.BoundingBox == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
faceMatch := FaceMatch{
|
||||
FaceID: aws.ToString(match.Face.FaceId),
|
||||
ExternalImageID: aws.ToString(match.Face.ExternalImageId),
|
||||
Similarity: float64(aws.ToFloat32(match.Similarity)),
|
||||
BoundingBox: BoundingBox{
|
||||
Left: float64(*match.Face.BoundingBox.Left),
|
||||
Top: float64(*match.Face.BoundingBox.Top),
|
||||
Width: float64(*match.Face.BoundingBox.Width),
|
||||
Height: float64(*match.Face.BoundingBox.Height),
|
||||
},
|
||||
}
|
||||
matches = append(matches, faceMatch)
|
||||
}
|
||||
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// ExtractImageBytes extracts image bytes from a multipart file
|
||||
func ExtractImageBytes(file multipart.File) ([]byte, error) {
|
||||
// Read all bytes
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
// Validate it's an image
|
||||
_, _, err = image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid image format: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// NewRekognitionClientFromEnv creates a client from environment variables
|
||||
func NewRekognitionClientFromEnv(collectionID string) (*RekognitionClient, error) {
|
||||
cfg, err := config.LoadDefaultConfig(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load AWS config: %w", err)
|
||||
}
|
||||
|
||||
return NewRekognitionClient(cfg, collectionID), nil
|
||||
}
|
||||
220
internal/face/service.go
Normal file
220
internal/face/service.go
Normal file
@ -0,0 +1,220 @@
|
||||
package face
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// Service provides face detection, indexing, and search operations
|
||||
type Service struct {
|
||||
client Client
|
||||
repo PhotoRepository
|
||||
cache CacheService
|
||||
config *Config
|
||||
keys CacheKeys
|
||||
}
|
||||
|
||||
// NewService creates a new face service
|
||||
func NewService(client Client, repo PhotoRepository, cache CacheService, config *Config) *Service {
|
||||
return &Service{
|
||||
client: client,
|
||||
repo: repo,
|
||||
cache: cache,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateImage validates that image bytes are a valid image
|
||||
func (s *Service) ValidateImage(imageBytes []byte) error {
|
||||
if len(imageBytes) > s.config.MaxImageSize {
|
||||
return ErrImageTooLarge
|
||||
}
|
||||
if len(imageBytes) < 100 { // Minimum viable image size
|
||||
return ErrImageTooSmall
|
||||
}
|
||||
// Check for valid image headers
|
||||
if len(imageBytes) < 4 {
|
||||
return ErrInvalidImageFormat
|
||||
}
|
||||
// JPEG: starts with FF D8 FF
|
||||
// PNG: starts with 89 50 4E 47
|
||||
// WebP: starts with 52 49 46 46 (RIFF)
|
||||
if imageBytes[0] == 0xFF && imageBytes[1] == 0xD8 && imageBytes[2] == 0xFF {
|
||||
return nil // JPEG
|
||||
}
|
||||
if imageBytes[0] == 0x89 && imageBytes[1] == 0x50 && imageBytes[2] == 0x4E && imageBytes[3] == 0x47 {
|
||||
return nil // PNG
|
||||
}
|
||||
if imageBytes[0] == 0x52 && imageBytes[1] == 0x49 && imageBytes[2] == 0x46 && imageBytes[3] == 0x46 {
|
||||
return nil // WebP (RIFF)
|
||||
}
|
||||
return ErrInvalidImageFormat
|
||||
}
|
||||
|
||||
// IndexPhoto indexes all faces in a photo
|
||||
func (s *Service) IndexPhoto(ctx context.Context, photoID string, imageBytes []byte) error {
|
||||
// Check if already indexed
|
||||
indexKey := s.keys.Indexed(photoID)
|
||||
if indexed, _ := s.cache.Get(ctx, indexKey); indexed == "true" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Detect faces
|
||||
detections, err := s.client.DetectFaces(ctx, imageBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to detect faces: %w", err)
|
||||
}
|
||||
|
||||
if len(detections) == 0 {
|
||||
log.Printf("No faces detected in photo %s", photoID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get photo metadata
|
||||
photo, err := s.repo.GetPhoto(ctx, photoID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get photo: %w", err)
|
||||
}
|
||||
|
||||
// Index each face with confidence > 99%
|
||||
indexedCount := 0
|
||||
for _, detection := range detections {
|
||||
if detection.Confidence < 99.0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Index face in Rekognition
|
||||
_, err := s.client.IndexFace(ctx, imageBytes, photoID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to index face in photo %s: %v", photoID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Store in database
|
||||
embedding := &FaceEmbeddingInfo{
|
||||
PhotoID: photoID,
|
||||
SpaceID: photo.SpaceID,
|
||||
Embedding: []float32{}, // Rekognition handles embeddings internally
|
||||
BoundingBox: fmt.Sprintf("%.2f,%.2f,%.2f,%.2f",
|
||||
detection.BoundingBox.Left,
|
||||
detection.BoundingBox.Top,
|
||||
detection.BoundingBox.Width,
|
||||
detection.BoundingBox.Height),
|
||||
}
|
||||
|
||||
if _, err := s.repo.CreateFaceEmbedding(ctx, embedding); err != nil {
|
||||
log.Printf("Failed to store face embedding for photo %s: %v", photoID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
indexedCount++
|
||||
}
|
||||
|
||||
// Mark as indexed in cache
|
||||
s.cache.Set(ctx, indexKey, "true", s.config.PhotoCacheTTL)
|
||||
|
||||
log.Printf("Indexed %d faces in photo %s", indexedCount, photoID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchFaces searches for faces in a space
|
||||
func (s *Service) SearchFaces(ctx context.Context, spaceID string, imageBytes []byte, limit int) (*FaceSearchResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Check cache for similar searches
|
||||
imageHash := computeHash(imageBytes)
|
||||
cacheKey := s.keys.Search(spaceID, imageHash)
|
||||
|
||||
var cachedResult FaceSearchResult
|
||||
if err := s.cache.GetJSON(ctx, cacheKey, &cachedResult); err == nil && cachedResult.Matches != nil {
|
||||
cachedResult.CacheHit = true
|
||||
cachedResult.SearchTime = time.Since(startTime)
|
||||
return &cachedResult, nil
|
||||
}
|
||||
|
||||
// Search in Rekognition
|
||||
matches, err := s.client.SearchFacesByImage(ctx, imageBytes, limit, s.config.Threshold)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search faces: %w", err)
|
||||
}
|
||||
|
||||
// Get photo metadata for matches
|
||||
var resultMatches []FaceMatch
|
||||
for _, match := range matches {
|
||||
// Get photo ID from external image ID
|
||||
photoID := match.ExternalImageID
|
||||
photo, err := s.repo.GetPhoto(ctx, photoID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get photo %s: %v", photoID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Enrich match with photo data
|
||||
match.PhotoID = photoID
|
||||
match.PhotoURL = photo.URL
|
||||
match.ThumbnailURL = photo.ThumbnailURL
|
||||
resultMatches = append(resultMatches, match)
|
||||
}
|
||||
|
||||
// Build result
|
||||
result := &FaceSearchResult{
|
||||
Matches: resultMatches,
|
||||
PhotoCount: len(resultMatches),
|
||||
SearchTime: time.Since(startTime),
|
||||
CacheHit: false,
|
||||
}
|
||||
|
||||
// Cache result
|
||||
s.cache.SetJSON(ctx, cacheKey, result, s.config.SearchCacheTTL)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CreateCollection creates a face collection for a space
|
||||
func (s *Service) CreateCollection(ctx context.Context, collectionName string) error {
|
||||
return s.client.CreateCollection(ctx, collectionName)
|
||||
}
|
||||
|
||||
// DeleteCollection deletes a face collection for a space
|
||||
func (s *Service) DeleteCollection(ctx context.Context, collectionName string) error {
|
||||
return s.client.DeleteCollection(ctx, collectionName)
|
||||
}
|
||||
|
||||
// computeHash computes a SHA256 hash of the image bytes for caching
|
||||
func computeHash(data []byte) string {
|
||||
// Use first 1KB for quick hashing (full hash is expensive for large images)
|
||||
sampleSize := 1024
|
||||
if len(data) < sampleSize {
|
||||
sampleSize = len(data)
|
||||
}
|
||||
hash := sha256.Sum256(data[:sampleSize])
|
||||
return fmt.Sprintf("%x", hash[:8]) // Use first 8 bytes for shorter key
|
||||
}
|
||||
315
internal/face/service_test.go
Normal file
315
internal/face/service_test.go
Normal file
@ -0,0 +1,315 @@
|
||||
package face
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MockPhotoRepository is a mock implementation of PhotoRepository for testing
|
||||
type MockPhotoRepository struct {
|
||||
photos map[string]*PhotoInfo
|
||||
embeddings []*FaceEmbeddingInfo
|
||||
}
|
||||
|
||||
func NewMockPhotoRepository() *MockPhotoRepository {
|
||||
return &MockPhotoRepository{
|
||||
photos: make(map[string]*PhotoInfo),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockPhotoRepository) GetPhoto(ctx context.Context, id string) (*PhotoInfo, error) {
|
||||
if photo, ok := m.photos[id]; ok {
|
||||
return photo, nil
|
||||
}
|
||||
return &PhotoInfo{
|
||||
ID: id,
|
||||
SpaceID: "test-space",
|
||||
URL: "https://example.com/photo.jpg",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockPhotoRepository) CreateFaceEmbedding(ctx context.Context, embedding *FaceEmbeddingInfo) (*FaceEmbeddingInfo, error) {
|
||||
m.embeddings = append(m.embeddings, embedding)
|
||||
return embedding, nil
|
||||
}
|
||||
|
||||
func (m *MockPhotoRepository) AddPhoto(photo *PhotoInfo) {
|
||||
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(&PhotoInfo{
|
||||
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(&PhotoInfo{
|
||||
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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user