From 31f16d697a7d202e7870b7e9e1b9665cfdaecd2f Mon Sep 17 00:00:00 2001 From: chodak166 Date: Sat, 20 Sep 2025 18:22:05 +0200 Subject: [PATCH] Golang implementation cleanup and refactoring --- LICENSE | 2 +- README.md | 14 +- golang/SPEC_DETAILS.md | 317 +++--- golang/docker/Dockerfile | 4 + .../domain/specifications/condition_spec.go | 210 ++-- .../specifications/simple_specification.go | 476 +++++---- .../integration/file_item_repository_test.go | 348 +++++++ golang/tests/unit/add_item_command_test.go | 352 +++++++ .../unit/handle_expired_items_command_test.go | 293 ++++++ golang/tests/unit/specification_test.go | 904 ++++++++++++++++++ golang/tests/unit/test_utils.go | 99 ++ 11 files changed, 2441 insertions(+), 578 deletions(-) create mode 100644 golang/tests/integration/file_item_repository_test.go create mode 100644 golang/tests/unit/add_item_command_test.go create mode 100644 golang/tests/unit/handle_expired_items_command_test.go create mode 100644 golang/tests/unit/specification_test.go create mode 100644 golang/tests/unit/test_utils.go diff --git a/LICENSE b/LICENSE index 2071b23..d63bade 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) +Copyright (c) 2025 chodak166 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index c9c4ad5..3d89881 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ AutoStore/ │ │ │ ├── User │ │ │ └── Item │ │ └── Specifications/ -│ │ └── ItemExpirationSpec +│ │ └── ItemExpirationSpec # domain knowledge (from domain experts) │ ├── Application/ │ │ ├── Commands/ # use cases │ │ │ ├── Login @@ -79,7 +79,7 @@ AutoStore/ │ │ │ ├── IUserRepository │ │ │ ├── IItemRepository │ │ │ ├── IAuthService -│ │ │ └── IClock +│ │ │ └── IDateProvider │ │ ├── Dto/ # data transfer objects (fields mappings, validation, etc.) │ │ └── Services/ │ │ ├── UserInitializationService @@ -91,11 +91,12 @@ AutoStore/ │ │ ├── Adapters/ │ │ │ ├── JwtAuthAdapter │ │ │ ├── OrderUrlHttpClient -│ │ │ ├── SystemClock +│ │ │ ├── SystemDateProvider │ │ │ └── <... some extern lib adapters> │ │ └── Helpers/ │ │ └── <... DRY helpers> -│ └── WebApi/ # presentation (controllers, middlewares, etc.) +│ ├── Cli # presentation, optional command line use case caller +│ └── WebApi/ # presentation, REST (controllers, middlewares, etc.) │ ├── Controllers/ │ │ ├── StoreController │ │ └── UserController @@ -125,7 +126,7 @@ Ideally, each implementation should include a `/docker/docker-compose.yml` ```bash docker compose up --build ``` -to build and run the application. +to build, test and run the application. Otherwise, please provide a `/README.md` file with setup and running instructions. @@ -143,4 +144,5 @@ Here's a summary of example API endpoints: | `/items/{id}` | PUT | Update item details | | `/items/{id}` | DELETE | Delete item | -Suggested base URL is `http://localhost:50080/api/v1/`. \ No newline at end of file +Suggested base URL is `http://localhost:50080/api/v1/`. + diff --git a/golang/SPEC_DETAILS.md b/golang/SPEC_DETAILS.md index 93399c2..f6964fb 100644 --- a/golang/SPEC_DETAILS.md +++ b/golang/SPEC_DETAILS.md @@ -1,262 +1,187 @@ -# Specification Pattern in Go - Detailed Explanation +# Specification Pattern Implementation -## What is the Specification Pattern? +This document explains the Specification pattern implementation for building dynamic queries and conditions that can be used across different layers of the application. -The Specification Pattern is a design pattern that allows you to encapsulate business rules and domain logic in reusable, composable objects. Instead of scattering business rules throughout your codebase, you create specification objects that represent individual rules that can be combined using logical operations (AND, OR, NOT). +## Overview -## Why Use It? +The Specification pattern allows you to encapsulate business logic for filtering and querying data. Instead of writing SQL queries directly, you build specifications using Go code that can later be converted to SQL, used for in-memory filtering, or other purposes. -1. **Single Source of Truth**: Business rules are defined in one place -2. **Reusability**: Rules can be reused across different parts of the application -3. **Composability**: Simple rules can be combined to create complex rules -4. **Testability**: Each specification can be tested in isolation -5. **Flexibility**: Easy to modify or extend business rules +## Core Components -## How It Works in Our Go Implementation +### 1. Basic Data Structures (`condition_spec.go`) -### Core Components - -#### 1. Specification Interface -```go -type Specification[T any] interface { - IsSatisfiedBy(candidate T) bool - And(other Specification[T]) Specification[T] - Or(other Specification[T]) Specification[T] - Not() Specification[T] - GetConditions() []Condition - GetSpec() *Spec -} -``` - -This interface defines the contract that all specifications must implement. The generic type `T` allows specifications to work with any type of object. - -#### 2. SimpleSpecification - The Workhorse -```go -type SimpleSpecification[T any] struct { - spec *Spec -} -``` - -This is the main implementation that evaluates conditions against objects using reflection. - -#### 3. Condition Structure +#### `Condition` +Represents a single comparison operation: ```go type Condition struct { - Field string // Field name to check (e.g., "expirationDate") - Operator string // Comparison operator (e.g., "<=", "==", ">") + Field string // Field name (e.g., "age", "name") + Operator string // Comparison operator (e.g., "=", ">", "IN") Value interface{} // Value to compare against } ``` -#### 4. Spec Structure - The Building Block -```go -type Spec struct { - Condition *Condition // Single condition - LogicalGroup *LogicalGroup // Group of conditions (AND/OR/NOT) -} -``` - -### How It Evaluates Conditions - -The magic happens in the `getFieldValue` method. When you specify a condition like `Lte("expirationDate", currentTime)`, the system needs to: - -1. **Find the field**: Look for a getter method like `ExpirationDate()` or `GetExpirationDate()` -2. **Extract the value**: Call the method to get the actual value -3. **Compare values**: Use the appropriate comparison based on the operator - -#### Field Resolution Strategy -```go -func (s *SimpleSpecification[T]) getFieldValue(candidate T, fieldName string) (interface{}, error) { - // 1. Try "GetFieldName" method first - getterName := "Get" + strings.Title(fieldName) - method := v.MethodByName(getterName) - - // 2. Try field name as method (e.g., "ExpirationDate") - method = v.MethodByName(fieldName) - - // 3. Try direct field access (for exported fields only) - field := v.FieldByName(fieldName) - if field.IsValid() && field.CanInterface() { - return field.Interface(), nil - } -} -``` - -This approach handles different naming conventions and respects Go's visibility rules. - -### Logical Operations - -#### AND Operation +#### `LogicalGroup` +Combines multiple conditions with logical operators: ```go -func (s *SimpleSpecification[T]) And(other Specification[T]) Specification[T] { - if otherSpec, ok := other.(*SimpleSpecification[T]); ok { - // Both are SimpleSpecifications - combine their specs - return NewSimpleSpecification[T](SpecBuilder.And(s.spec, otherSpec.spec)) - } - - // Different types - create a composite specification - return &CompositeSpecification[T]{ - left: s, - right: other, - op: "AND", - } +type LogicalGroup struct { + Operator string // "AND", "OR", or "NOT" + Conditions []Condition // Simple conditions + Spec *Spec // Nested specification for complex logic } ``` -#### CompositeSpecification - Handling Mixed Types -When you try to combine different specification types, instead of panicking, we create a `CompositeSpecification` that: - -1. **Stores both specifications**: Keeps references to both left and right specs -2. **Evaluates independently**: Calls `IsSatisfiedBy` on each specification -3. **Combines results**: Applies the logical operation (AND/OR) to the results - +#### `Spec` +The main specification container (can hold either a single condition or a logical group): ```go -func (c *CompositeSpecification[T]) IsSatisfiedBy(candidate T) bool { - switch c.op { - case "AND": - return c.left.IsSatisfiedBy(candidate) && c.right.IsSatisfiedBy(candidate) - case "OR": - return c.left.IsSatisfiedBy(candidate) || c.right.IsSatisfiedBy(candidate) - } +type Spec struct { + Condition *Condition // For simple conditions + LogicalGroup *LogicalGroup // For complex logic } ``` -### Building Specifications +### 2. Builder Functions -We provide convenient helper functions that mirror the PHP/NestJS implementations: +These functions create specifications in a fluent, readable way: ```go // Simple conditions -spec := Eq("name", "Apple") // name == "Apple" -spec := Gt("price", 10.0) // price > 10.0 -spec := Lte("expirationDate", time) // expirationDate <= time +userSpec := Eq("name", "John") // name = "John" +ageSpec := Gt("age", 18) // age > 18 +roleSpec := In("role", []interface{}{"admin", "moderator"}) // role IN ("admin", "moderator") // Logical combinations -spec := And( +complexSpec := And( Eq("status", "active"), Or( - Gt("score", 80), - In("role", []interface{}{"admin", "moderator"}) + Gt("age", 21), + Eq("role", "admin"), ), - Not(Eq("deleted", true)) ) ``` -### Real-World Usage: ItemExpirationSpec +### 3. Specification Interface (`simple_specification.go`) -Here's how we use it to check if items are expired: +The `Specification[T]` interface provides methods for: +- **Evaluation**: `IsSatisfiedBy(candidate T) bool` +- **Composition**: `And()`, `Or()`, `Not()` +- **Introspection**: `GetConditions()`, `GetSpec()` -```go -func (s *ItemExpirationSpec) IsExpired(item *entities.ItemEntity, currentTime time.Time) bool { - return s.GetSpec(currentTime).IsSatisfiedBy(item) -} +## How It Works -func (s *ItemExpirationSpec) GetSpec(currentTime time.Time) Specification[*entities.ItemEntity] { - // Create condition: expirationDate <= currentTime - spec := Lte("expirationDate", currentTime) - return NewSimpleSpecification[*entities.ItemEntity](spec) -} -``` - -### Repository Integration - -The specification integrates seamlessly with repositories: +### 1. Building Specifications ```go -func (r *FileItemRepository) FindWhere(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) { - var filteredItems []*entities.ItemEntity - for _, item := range items { - if spec.IsSatisfiedBy(item) { - filteredItems = append(filteredItems, item) - } - } - return filteredItems, nil -} +// Create a specification for active users over 18 +spec := And( + Eq("status", "active"), + Gt("age", 18), +) ``` -## Key Benefits of This Implementation +### 2. Evaluating Specifications -### 1. Type Safety -Using generics (`Specification[T any]`) ensures type safety at compile time. +The `SimpleSpecification` uses reflection to evaluate conditions against Go objects: -### 2. Flexibility -- Supports different field access patterns (getters, direct fields) -- Handles various data types (strings, numbers, dates, etc.) -- Allows complex nested conditions +```go +user := &User{Name: "John", Age: 25, Status: "active"} +specification := NewSimpleSpecification[*User](spec) +isMatch := specification.IsSatisfiedBy(user) // true +``` -### 3. No Panic Zone -This implementation gracefully handles mixed types using `CompositeSpecification`. +### 3. Field Access -### 4. Reflection Safety -The field resolution respects Go's visibility rules and provides clear error messages when fields can't be accessed. +The implementation looks for field values in this order: +1. `GetFieldName()` method (e.g., `GetAge()`) +2. `FieldName()` method (e.g., `Age()`) +3. Exported struct field -### 5. Performance -- Caches reflection information when possible -- Short-circuits evaluation (stops at first false in AND, first true in OR) -- Minimal overhead for simple conditions +### 4. Type Comparisons -## Common Patterns and Examples +The system handles different data types intelligently: +- **Numbers**: Direct comparison with type conversion +- **Strings**: Lexicographic comparison +- **Time**: Special handling for `time.Time` and objects with `Time()` methods +- **Collections**: `IN` and `NOT IN` operations +- **Nil values**: Proper null handling -### Checking Expired Items -```go -expirationSpec := NewItemExpirationSpec() -isExpired := expirationSpec.IsExpired(item, time.Now()) -``` +## Examples -### Finding Active Users +### Simple Usage ```go -activeUserSpec := And( - Eq("status", "active"), - Gte("lastLogin", thirtyDaysAgo), - Not(Eq("role", "banned")) -) +// Find all active users +activeSpec := Eq("status", "active") +spec := NewSimpleSpecification[*User](activeSpec) + +users := []User{{Status: "active"}, {Status: "inactive"}} +for _, user := range users { + if spec.IsSatisfiedBy(&user) { + fmt.Println("Active user:", user.Name) + } +} ``` -### Complex Business Rules +### Complex Logic ```go -premiumCustomerSpec := Or( - And( - Eq("subscriptionType", "premium"), - Gte("subscriptionEndDate", time.Now()), - ), +// Find admin users OR users with high scores +complexSpec := Or( + Eq("role", "admin"), And( - Eq("totalPurchases", 1000), - Gte("customerSince", oneYearAgo), + Gt("score", 90), + Eq("status", "active"), ), ) ``` -## Troubleshooting Common Issues +### Time Comparisons +```go +// Find expired items +expiredSpec := Lte("expirationDate", time.Now()) +``` -### 1. Field Not Found -**Error**: `field expirationDate not found or not accessible` -**Solution**: Ensure your entity has a getter method like `ExpirationDate()` or `GetExpirationDate()` +## Key Benefits -### 2. Type Mismatch -**Error**: Comparison fails between different types -**Solution**: The system tries to convert types automatically, but ensure your condition values match the expected field types +1. **Type Safety**: Compile-time checking with generics +2. **Readability**: Business logic expressed in readable Go code +3. **Reusability**: Specifications can be composed and reused +4. **Testability**: Easy to test business rules in isolation +5. **Flexibility**: Can be converted to SQL, used for filtering, etc. -### 3. Performance Concerns -**Issue**: Reflection is slower than direct field access -**Solution**: For performance-critical paths, consider implementing custom specifications or caching results +## Performance Considerations -## Best Practices +- Reflection is used for field access (consider caching for high-frequency operations) +- Complex nested specifications may impact performance +- Time comparisons handle multiple formats but may be slower than direct comparisons -1. **Use getter methods**: Prefer `GetFieldName()` or `FieldName()` methods over direct field access -2. **Keep conditions simple**: Complex business logic should be in domain services, not specifications -3. **Test specifications**: Write unit tests for your specifications to ensure they work correctly -4. **Document business rules**: Add comments explaining what each specification represents -5. **Reuse specifications**: Create specification factories for commonly used rules +## Common Patterns -## Comparison with PHP/NestJS +### Repository Integration +```go +type UserRepository interface { + FindWhere(ctx context.Context, spec Specification[*User]) ([]*User, error) +} +``` -Our Go implementation maintains the same API surface as the PHP and NestJS versions: +### Business Rule Encapsulation +```go +func ActiveUserSpec() *Spec { + return And( + Eq("status", "active"), + Neq("deletedAt", nil), + ) +} +``` -- **Same helper functions**: `Eq()`, `Gt()`, `Lte()`, `And()`, `Or()`, `Not()` -- **Same condition structure**: `[field, operator, value]` -- **Same logical operations**: AND, OR, NOT combinations -- **Same usage patterns**: Build specs, then call `IsSatisfiedBy()` +### Dynamic Query Building +```go +func BuildUserSearchSpec(filters UserFilters) *Spec { + conditions := []*Spec{} -The main difference is that Go's static typing and lack of runtime metadata requires more sophisticated reflection handling, which we've encapsulated in the `getFieldValue` method. + if filters.Status != "" { + conditions = append(conditions, Eq("status", filters.Status)) + } + if filters.MinAge > 0 { + conditions = append(conditions, Gte("age", filters.MinAge)) + } -This implementation provides a robust, production-ready specification pattern that follows Go best practices while maintaining compatibility with the PHP and NestJS implementations. \ No newline at end of file + return And(conditions...) +} \ No newline at end of file diff --git a/golang/docker/Dockerfile b/golang/docker/Dockerfile index 7ce9809..a3dd91b 100644 --- a/golang/docker/Dockerfile +++ b/golang/docker/Dockerfile @@ -12,6 +12,10 @@ COPY . . # Generate go.sum RUN go mod tidy +# Run tests +RUN go test ./tests/unit -v && \ + go test ./tests/integration -v + # Build the application RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd diff --git a/golang/internal/domain/specifications/condition_spec.go b/golang/internal/domain/specifications/condition_spec.go index ea32642..e2b9b75 100644 --- a/golang/internal/domain/specifications/condition_spec.go +++ b/golang/internal/domain/specifications/condition_spec.go @@ -1,75 +1,110 @@ package specifications -import ( +const ( + GROUP_AND = "AND" + GROUP_OR = "OR" + GROUP_NOT = "NOT" +) + +const ( + OP_EQ = "=" + OP_NEQ = "!=" + OP_GT = ">" + OP_GTE = ">=" + OP_LT = "<" + OP_LTE = "<=" + OP_IN = "IN" + OP_NIN = "NOT IN" ) -// Condition represents a single condition in the specification type Condition struct { Field string `json:"field"` Operator string `json:"operator"` Value interface{} `json:"value"` } -// LogicalGroup represents a logical group of conditions (AND, OR, NOT) type LogicalGroup struct { - Operator string `json:"operator"` // "AND", "OR", "NOT" - Conditions []Condition `json:"conditions"` // For AND/OR - Spec *Spec `json:"spec"` // For NOT + Operator string `json:"operator"` + Conditions []Condition `json:"conditions"` + Spec *Spec `json:"spec"` } -// Spec represents a specification that can be either a single condition or a logical group type Spec struct { - Condition *Condition `json:"condition,omitempty"` + Condition *Condition `json:"condition,omitempty"` LogicalGroup *LogicalGroup `json:"logicalGroup,omitempty"` } -// SpecHelper provides methods to build specifications -type SpecHelper struct{} +func And(conditions ...*Spec) *Spec { + if len(conditions) == 0 { + return nil + } + if len(conditions) == 1 { + return conditions[0] + } -// NewSpecHelper creates a new SpecHelper instance -func NewSpecHelper() *SpecHelper { - return &SpecHelper{} -} + var flatConditions []Condition + var nestedSpecs []*Spec -// Logical group operators -const ( - GROUP_AND = "AND" - GROUP_OR = "OR" - GROUP_NOT = "NOT" -) - -// Comparison operators -const ( - OP_EQ = "=" - OP_NEQ = "!=" - OP_GT = ">" - OP_GTE = ">=" - OP_LT = "<" - OP_LTE = "<=" - OP_IN = "IN" - OP_NIN = "NOT IN" -) + for _, spec := range conditions { + if spec.Condition != nil { + flatConditions = append(flatConditions, *spec.Condition) + } else { + nestedSpecs = append(nestedSpecs, spec) + } + } -// Logical group helpers -func (h *SpecHelper) And(conditions ...*Spec) *Spec { - return &Spec{ + result := &Spec{ LogicalGroup: &LogicalGroup{ Operator: GROUP_AND, - Conditions: h.extractConditions(conditions), + Conditions: flatConditions, }, } + + if len(nestedSpecs) == 1 { + result.LogicalGroup.Spec = nestedSpecs[0] + } else if len(nestedSpecs) > 1 { + result.LogicalGroup.Spec = And(nestedSpecs...) + } + + return result } -func (h *SpecHelper) Or(conditions ...*Spec) *Spec { - return &Spec{ +func Or(conditions ...*Spec) *Spec { + if len(conditions) == 0 { + return nil + } + if len(conditions) == 1 { + return conditions[0] + } + + var flatConditions []Condition + var nestedSpecs []*Spec + + for _, spec := range conditions { + if spec.Condition != nil { + flatConditions = append(flatConditions, *spec.Condition) + } else { + nestedSpecs = append(nestedSpecs, spec) + } + } + + result := &Spec{ LogicalGroup: &LogicalGroup{ Operator: GROUP_OR, - Conditions: h.extractConditions(conditions), + Conditions: flatConditions, }, } + + if len(nestedSpecs) == 1 { + result.LogicalGroup.Spec = nestedSpecs[0] + } else if len(nestedSpecs) > 1 { + result.LogicalGroup.Spec = Or(nestedSpecs...) + } + + return result } -func (h *SpecHelper) Not(condition *Spec) *Spec { +func Not(condition *Spec) *Spec { return &Spec{ LogicalGroup: &LogicalGroup{ Operator: GROUP_NOT, @@ -78,8 +113,7 @@ func (h *SpecHelper) Not(condition *Spec) *Spec { } } -// Condition helpers -func (h *SpecHelper) Eq(field string, value interface{}) *Spec { +func Eq(field string, value interface{}) *Spec { return &Spec{ Condition: &Condition{ Field: field, @@ -89,7 +123,7 @@ func (h *SpecHelper) Eq(field string, value interface{}) *Spec { } } -func (h *SpecHelper) Neq(field string, value interface{}) *Spec { +func Neq(field string, value interface{}) *Spec { return &Spec{ Condition: &Condition{ Field: field, @@ -99,7 +133,7 @@ func (h *SpecHelper) Neq(field string, value interface{}) *Spec { } } -func (h *SpecHelper) Gt(field string, value interface{}) *Spec { +func Gt(field string, value interface{}) *Spec { return &Spec{ Condition: &Condition{ Field: field, @@ -109,7 +143,7 @@ func (h *SpecHelper) Gt(field string, value interface{}) *Spec { } } -func (h *SpecHelper) Gte(field string, value interface{}) *Spec { +func Gte(field string, value interface{}) *Spec { return &Spec{ Condition: &Condition{ Field: field, @@ -119,7 +153,7 @@ func (h *SpecHelper) Gte(field string, value interface{}) *Spec { } } -func (h *SpecHelper) Lt(field string, value interface{}) *Spec { +func Lt(field string, value interface{}) *Spec { return &Spec{ Condition: &Condition{ Field: field, @@ -129,7 +163,7 @@ func (h *SpecHelper) Lt(field string, value interface{}) *Spec { } } -func (h *SpecHelper) Lte(field string, value interface{}) *Spec { +func Lte(field string, value interface{}) *Spec { return &Spec{ Condition: &Condition{ Field: field, @@ -139,7 +173,7 @@ func (h *SpecHelper) Lte(field string, value interface{}) *Spec { } } -func (h *SpecHelper) In(field string, values []interface{}) *Spec { +func In(field string, values []interface{}) *Spec { return &Spec{ Condition: &Condition{ Field: field, @@ -149,7 +183,7 @@ func (h *SpecHelper) In(field string, values []interface{}) *Spec { } } -func (h *SpecHelper) Nin(field string, values []interface{}) *Spec { +func Nin(field string, values []interface{}) *Spec { return &Spec{ Condition: &Condition{ Field: field, @@ -159,26 +193,13 @@ func (h *SpecHelper) Nin(field string, values []interface{}) *Spec { } } -// extractConditions extracts conditions from specs for logical groups -func (h *SpecHelper) extractConditions(specs []*Spec) []Condition { +func GetConditions(spec *Spec) []Condition { var conditions []Condition - for _, spec := range specs { - if spec.Condition != nil { - conditions = append(conditions, *spec.Condition) - } - } + flattenConditions(spec, &conditions) return conditions } -// GetConditions returns all conditions from a spec (flattened) -func (h *SpecHelper) GetConditions(spec *Spec) []Condition { - var conditions []Condition - h.flattenConditions(spec, &conditions) - return conditions -} - -// flattenConditions recursively flattens conditions from a spec -func (h *SpecHelper) flattenConditions(spec *Spec, conditions *[]Condition) { +func flattenConditions(spec *Spec, conditions *[]Condition) { if spec == nil { return } @@ -188,60 +209,11 @@ func (h *SpecHelper) flattenConditions(spec *Spec, conditions *[]Condition) { } if spec.LogicalGroup != nil { - if spec.LogicalGroup.Operator == GROUP_AND || spec.LogicalGroup.Operator == GROUP_OR { - for _, cond := range spec.LogicalGroup.Conditions { - *conditions = append(*conditions, cond) - } - } else if spec.LogicalGroup.Operator == GROUP_NOT && spec.LogicalGroup.Spec != nil { - h.flattenConditions(spec.LogicalGroup.Spec, conditions) + for _, cond := range spec.LogicalGroup.Conditions { + *conditions = append(*conditions, cond) + } + if spec.LogicalGroup.Spec != nil { + flattenConditions(spec.LogicalGroup.Spec, conditions) } } -} - -// Global Spec helper instance -var SpecBuilder = NewSpecHelper() - -// Convenience functions that use the global Spec instance -func And(conditions ...*Spec) *Spec { - return SpecBuilder.And(conditions...) -} - -func Or(conditions ...*Spec) *Spec { - return SpecBuilder.Or(conditions...) -} - -func Not(condition *Spec) *Spec { - return SpecBuilder.Not(condition) -} - -func Eq(field string, value interface{}) *Spec { - return SpecBuilder.Eq(field, value) -} - -func Neq(field string, value interface{}) *Spec { - return SpecBuilder.Neq(field, value) -} - -func Gt(field string, value interface{}) *Spec { - return SpecBuilder.Gt(field, value) -} - -func Gte(field string, value interface{}) *Spec { - return SpecBuilder.Gte(field, value) -} - -func Lt(field string, value interface{}) *Spec { - return SpecBuilder.Lt(field, value) -} - -func Lte(field string, value interface{}) *Spec { - return SpecBuilder.Lte(field, value) -} - -func In(field string, values []interface{}) *Spec { - return SpecBuilder.In(field, values) -} - -func Nin(field string, values []interface{}) *Spec { - return SpecBuilder.Nin(field, values) } \ No newline at end of file diff --git a/golang/internal/domain/specifications/simple_specification.go b/golang/internal/domain/specifications/simple_specification.go index ef11c3e..c8f0f98 100644 --- a/golang/internal/domain/specifications/simple_specification.go +++ b/golang/internal/domain/specifications/simple_specification.go @@ -7,106 +7,6 @@ import ( "time" ) -// CompositeSpecification implements logical operations between different specification types -type CompositeSpecification[T any] struct { - left Specification[T] - right Specification[T] - op string // "AND", "OR" -} - -// IsSatisfiedBy checks if the candidate satisfies the composite specification -func (c *CompositeSpecification[T]) IsSatisfiedBy(candidate T) bool { - switch c.op { - case "AND": - return c.left.IsSatisfiedBy(candidate) && c.right.IsSatisfiedBy(candidate) - case "OR": - return c.left.IsSatisfiedBy(candidate) || c.right.IsSatisfiedBy(candidate) - default: - return false - } -} - -// And creates a new specification that is the logical AND of this and another specification -func (c *CompositeSpecification[T]) And(other Specification[T]) Specification[T] { - return &CompositeSpecification[T]{ - left: c, - right: other, - op: "AND", - } -} - -// Or creates a new specification that is the logical OR of this and another specification -func (c *CompositeSpecification[T]) Or(other Specification[T]) Specification[T] { - return &CompositeSpecification[T]{ - left: c, - right: other, - op: "OR", - } -} - -// Not creates a new specification that is the logical NOT of this specification -func (c *CompositeSpecification[T]) Not() Specification[T] { - // For composite specifications, we need to create a wrapper that negates the result - return &NotSpecification[T]{spec: c} -} - -// GetConditions returns all conditions from this specification -func (c *CompositeSpecification[T]) GetConditions() []Condition { - // Combine conditions from both sides - leftConditions := c.left.GetConditions() - rightConditions := c.right.GetConditions() - return append(leftConditions, rightConditions...) -} - -// GetSpec returns nil for composite specifications as they don't have a single spec structure -func (c *CompositeSpecification[T]) GetSpec() *Spec { - return nil -} - -// NotSpecification implements the NOT operation for specifications -type NotSpecification[T any] struct { - spec Specification[T] -} - -// IsSatisfiedBy checks if the candidate does NOT satisfy the wrapped specification -func (n *NotSpecification[T]) IsSatisfiedBy(candidate T) bool { - return !n.spec.IsSatisfiedBy(candidate) -} - -// And creates a new specification that is the logical AND of this and another specification -func (n *NotSpecification[T]) And(other Specification[T]) Specification[T] { - return &CompositeSpecification[T]{ - left: n, - right: other, - op: "AND", - } -} - -// Or creates a new specification that is the logical OR of this and another specification -func (n *NotSpecification[T]) Or(other Specification[T]) Specification[T] { - return &CompositeSpecification[T]{ - left: n, - right: other, - op: "OR", - } -} - -// Not creates a new specification that is the logical NOT of this specification (double negation) -func (n *NotSpecification[T]) Not() Specification[T] { - return n.spec -} - -// GetConditions returns all conditions from the wrapped specification -func (n *NotSpecification[T]) GetConditions() []Condition { - return n.spec.GetConditions() -} - -// GetSpec returns nil for not specifications -func (n *NotSpecification[T]) GetSpec() *Spec { - return nil -} - -// Specification defines the interface for specifications type Specification[T any] interface { IsSatisfiedBy(candidate T) bool And(other Specification[T]) Specification[T] @@ -116,73 +16,47 @@ type Specification[T any] interface { GetSpec() *Spec } -// SimpleSpecification implements the Specification interface using condition-based specs type SimpleSpecification[T any] struct { spec *Spec } -// NewSimpleSpecification creates a new SimpleSpecification with the given spec func NewSimpleSpecification[T any](spec *Spec) *SimpleSpecification[T] { - return &SimpleSpecification[T]{ - spec: spec, - } + return &SimpleSpecification[T]{spec: spec} } -// IsSatisfiedBy checks if the candidate satisfies the specification func (s *SimpleSpecification[T]) IsSatisfiedBy(candidate T) bool { return s.evaluateSpec(s.spec, candidate) } -// And creates a new specification that is the logical AND of this and another specification func (s *SimpleSpecification[T]) And(other Specification[T]) Specification[T] { - if otherSpec, ok := other.(*SimpleSpecification[T]); ok { - return NewSimpleSpecification[T](SpecBuilder.And(s.spec, otherSpec.spec)) - } - panic("And operation not supported between different specification types") + return &CompositeSpecification[T]{left: s, right: other, op: "AND"} } -// Or creates a new specification that is the logical OR of this and another specification func (s *SimpleSpecification[T]) Or(other Specification[T]) Specification[T] { - if otherSpec, ok := other.(*SimpleSpecification[T]); ok { - return NewSimpleSpecification[T](SpecBuilder.Or(s.spec, otherSpec.spec)) - } - panic("Or operation not supported between different specification types") + return &CompositeSpecification[T]{left: s, right: other, op: "OR"} } -// Not creates a new specification that is the logical NOT of this specification func (s *SimpleSpecification[T]) Not() Specification[T] { - return NewSimpleSpecification[T](SpecBuilder.Not(s.spec)) + return NewSimpleSpecification[T](Not(s.spec)) } -// GetSpec returns the underlying specification structure func (s *SimpleSpecification[T]) GetSpec() *Spec { return s.spec } -// GetConditions returns all conditions from this specification func (s *SimpleSpecification[T]) GetConditions() []Condition { - return SpecBuilder.GetConditions(s.spec) + return GetConditions(s.spec) } -// evaluateSpec recursively evaluates a specification against a candidate func (s *SimpleSpecification[T]) evaluateSpec(spec *Spec, candidate T) bool { if spec == nil { return false } - // Handle logical groups if spec.LogicalGroup != nil { - switch spec.LogicalGroup.Operator { - case GROUP_AND: - return s.evaluateAndGroup(spec.LogicalGroup, candidate) - case GROUP_OR: - return s.evaluateOrGroup(spec.LogicalGroup, candidate) - case GROUP_NOT: - return s.evaluateNotGroup(spec.LogicalGroup, candidate) - } + return s.evaluateLogicalGroup(spec.LogicalGroup, candidate) } - // Handle simple condition if spec.Condition != nil { return s.evaluateCondition(*spec.Condition, candidate) } @@ -190,52 +64,64 @@ func (s *SimpleSpecification[T]) evaluateSpec(spec *Spec, candidate T) bool { return false } -// evaluateAndGroup evaluates an AND group +func (s *SimpleSpecification[T]) evaluateLogicalGroup(group *LogicalGroup, candidate T) bool { + switch group.Operator { + case GROUP_AND: + return s.evaluateAndGroup(group, candidate) + case GROUP_OR: + return s.evaluateOrGroup(group, candidate) + case GROUP_NOT: + if group.Spec != nil { + return !s.evaluateSpec(group.Spec, candidate) + } + } + return false +} + func (s *SimpleSpecification[T]) evaluateAndGroup(group *LogicalGroup, candidate T) bool { for _, cond := range group.Conditions { if !s.evaluateCondition(cond, candidate) { return false } } - return true + + if group.Spec != nil { + return s.evaluateSpec(group.Spec, candidate) + } + + return len(group.Conditions) > 0 || group.Spec != nil } -// evaluateOrGroup evaluates an OR group func (s *SimpleSpecification[T]) evaluateOrGroup(group *LogicalGroup, candidate T) bool { for _, cond := range group.Conditions { if s.evaluateCondition(cond, candidate) { return true } } - return false -} -// evaluateNotGroup evaluates a NOT group -func (s *SimpleSpecification[T]) evaluateNotGroup(group *LogicalGroup, candidate T) bool { if group.Spec != nil { - return !s.evaluateSpec(group.Spec, candidate) + return s.evaluateSpec(group.Spec, candidate) } + return false } -// evaluateCondition evaluates a single condition against a candidate func (s *SimpleSpecification[T]) evaluateCondition(condition Condition, candidate T) bool { - // Get the field value from the candidate fieldValue, err := s.getFieldValue(candidate, condition.Field) if err != nil { return false } - // Handle time comparison return s.compareValues(fieldValue, condition.Operator, condition.Value) } -// getFieldValue retrieves the value of a field from an object using reflection func (s *SimpleSpecification[T]) getFieldValue(candidate T, fieldName string) (interface{}, error) { v := reflect.ValueOf(candidate) - // If candidate is a pointer, get the underlying value if v.Kind() == reflect.Ptr { + if v.IsNil() { + return nil, fmt.Errorf("candidate is nil") + } v = v.Elem() } @@ -243,158 +129,130 @@ func (s *SimpleSpecification[T]) getFieldValue(candidate T, fieldName string) (i return nil, fmt.Errorf("candidate is not a struct") } - // Try to get the field by getter method first (preferred approach) getterName := "Get" + strings.Title(fieldName) - method := v.MethodByName(getterName) - if method.IsValid() && method.Type().NumIn() == 0 && method.Type().NumOut() > 0 { - results := method.Call(nil) - if len(results) > 0 { - return results[0].Interface(), nil - } + + originalV := reflect.ValueOf(candidate) + if method := originalV.MethodByName(getterName); method.IsValid() { + return s.callMethod(method) } - // Try alternative getter naming (e.g., ExpirationDate() instead of GetExpirationDate()) - method = v.MethodByName(fieldName) - if method.IsValid() && method.Type().NumIn() == 0 && method.Type().NumOut() > 0 { - results := method.Call(nil) - if len(results) > 0 { - return results[0].Interface(), nil + if method := originalV.MethodByName(fieldName); method.IsValid() { + return s.callMethod(method) + } + + if v.Kind() == reflect.Struct { + if method := v.MethodByName(getterName); method.IsValid() { + return s.callMethod(method) + } + + if method := v.MethodByName(fieldName); method.IsValid() { + return s.callMethod(method) + } + + if field := v.FieldByName(fieldName); field.IsValid() && field.CanInterface() { + return field.Interface(), nil } } - // Try to get the field by name (for exported fields only) - field := v.FieldByName(fieldName) - if field.IsValid() && field.CanInterface() { - return field.Interface(), nil + return nil, fmt.Errorf("field %s not found", fieldName) +} + +func (s *SimpleSpecification[T]) callMethod(method reflect.Value) (interface{}, error) { + if !method.IsValid() || method.Type().NumIn() != 0 || method.Type().NumOut() == 0 { + return nil, fmt.Errorf("invalid method") } - return nil, fmt.Errorf("field %s not found or not accessible", fieldName) + results := method.Call(nil) + if len(results) == 0 { + return nil, fmt.Errorf("method returned no values") + } + + return results[0].Interface(), nil } -// compareValues compares two values using the specified operator func (s *SimpleSpecification[T]) compareValues(fieldValue interface{}, operator string, compareValue interface{}) bool { - // Handle time comparison + if fieldValue == nil { + return (operator == OP_EQ && compareValue == nil) || (operator == OP_NEQ && compareValue != nil) + } + if s.isTimeComparable(fieldValue, compareValue) { return s.compareTimes(fieldValue, operator, compareValue) } - // Convert both values to the same type for comparison - fieldVal := reflect.ValueOf(fieldValue) - compareVal := reflect.ValueOf(compareValue) - - // Handle IN and NOT IN operators if operator == OP_IN || operator == OP_NIN { return s.compareIn(fieldValue, operator, compareValue) } - // For other operators, try to convert to comparable types - if fieldVal.Kind() != compareVal.Kind() { - // Try to convert compareValue to the same type as fieldValue - if compareVal.CanConvert(fieldVal.Type()) { - compareVal = compareVal.Convert(fieldVal.Type()) - } else if fieldVal.CanConvert(compareVal.Type()) { - fieldVal = fieldVal.Convert(compareVal.Type()) - } else { - return false - } - } - - switch operator { - case OP_EQ: - return reflect.DeepEqual(fieldValue, compareValue) - case OP_NEQ: - return !reflect.DeepEqual(fieldValue, compareValue) - case OP_GT: - return s.compareGreater(fieldVal, compareVal) - case OP_GTE: - return s.compareGreater(fieldVal, compareVal) || reflect.DeepEqual(fieldValue, compareValue) - case OP_LT: - return s.compareLess(fieldVal, compareVal) - case OP_LTE: - return s.compareLess(fieldVal, compareVal) || reflect.DeepEqual(fieldValue, compareValue) - default: - return false - } + return s.compareGeneral(fieldValue, operator, compareValue) } -// isTimeComparable checks if the values can be compared as times func (s *SimpleSpecification[T]) isTimeComparable(fieldValue, compareValue interface{}) bool { _, fieldIsTime := fieldValue.(time.Time) _, compareIsTime := compareValue.(time.Time) - // If both are time.Time, they are time comparable - if fieldIsTime && compareIsTime { + if fieldIsTime || compareIsTime { return true } - // If one is time.Time and the other is string, check if string is a valid time - if fieldIsTime { - if compareStr, ok := compareValue.(string); ok { - _, err := time.Parse(time.RFC3339, compareStr) - return err == nil - } - } + return s.hasTimeMethod(fieldValue) || s.hasTimeMethod(compareValue) +} - if compareIsTime { - if fieldStr, ok := fieldValue.(string); ok { - _, err := time.Parse(time.RFC3339, fieldStr) - return err == nil - } +func (s *SimpleSpecification[T]) hasTimeMethod(value interface{}) bool { + v := reflect.ValueOf(value) + if !v.IsValid() || v.Kind() == reflect.Ptr { + return false } - return false + method := v.MethodByName("Time") + return method.IsValid() && method.Type().NumIn() == 0 && method.Type().NumOut() == 1 } -// compareTimes compares time values func (s *SimpleSpecification[T]) compareTimes(fieldValue interface{}, operator string, compareValue interface{}) bool { - var fieldTime, compareTime time.Time - var err error + fieldTime := s.extractTime(fieldValue) + compareTime := s.extractTime(compareValue) - // Convert fieldValue to time.Time - switch v := fieldValue.(type) { - case time.Time: - fieldTime = v - case string: - fieldTime, err = time.Parse(time.RFC3339, v) - if err != nil { - return false - } - default: - return false - } - - // Convert compareValue to time.Time - switch v := compareValue.(type) { - case time.Time: - compareTime = v - case string: - compareTime, err = time.Parse(time.RFC3339, v) - if err != nil { - return false - } - default: + if fieldTime == nil || compareTime == nil { return false } switch operator { case OP_EQ: - return fieldTime.Equal(compareTime) + return fieldTime.Equal(*compareTime) case OP_NEQ: - return !fieldTime.Equal(compareTime) + return !fieldTime.Equal(*compareTime) case OP_GT: - return fieldTime.After(compareTime) + return fieldTime.After(*compareTime) case OP_GTE: - return fieldTime.After(compareTime) || fieldTime.Equal(compareTime) + return fieldTime.After(*compareTime) || fieldTime.Equal(*compareTime) case OP_LT: - return fieldTime.Before(compareTime) + return fieldTime.Before(*compareTime) case OP_LTE: - return fieldTime.Before(compareTime) || fieldTime.Equal(compareTime) + return fieldTime.Before(*compareTime) || fieldTime.Equal(*compareTime) + } + + return false +} + +func (s *SimpleSpecification[T]) extractTime(value interface{}) *time.Time { + switch v := value.(type) { + case time.Time: + return &v + case string: + if t, err := time.Parse(time.RFC3339, v); err == nil { + return &t + } default: - return false + if method := reflect.ValueOf(value).MethodByName("Time"); method.IsValid() { + if results := method.Call(nil); len(results) > 0 { + if t, ok := results[0].Interface().(time.Time); ok { + return &t + } + } + } } + return nil } -// compareIn handles IN and NOT IN operators func (s *SimpleSpecification[T]) compareIn(fieldValue interface{}, operator string, compareValue interface{}) bool { compareSlice, ok := compareValue.([]interface{}) if !ok { @@ -410,8 +268,51 @@ func (s *SimpleSpecification[T]) compareIn(fieldValue interface{}, operator stri return operator == OP_NIN } -// compareGreater compares if fieldVal is greater than compareVal -func (s *SimpleSpecification[T]) compareGreater(fieldVal, compareVal reflect.Value) bool { +func (s *SimpleSpecification[T]) compareGeneral(fieldValue interface{}, operator string, compareValue interface{}) bool { + fieldVal := reflect.ValueOf(fieldValue) + compareVal := reflect.ValueOf(compareValue) + + if !s.makeComparable(&fieldVal, &compareVal) { + return false + } + + switch operator { + case OP_EQ: + return reflect.DeepEqual(fieldValue, compareValue) + case OP_NEQ: + return !reflect.DeepEqual(fieldValue, compareValue) + case OP_GT: + return s.isGreater(fieldVal, compareVal) + case OP_GTE: + return s.isGreater(fieldVal, compareVal) || reflect.DeepEqual(fieldValue, compareValue) + case OP_LT: + return s.isLess(fieldVal, compareVal) + case OP_LTE: + return s.isLess(fieldVal, compareVal) || reflect.DeepEqual(fieldValue, compareValue) + } + + return false +} + +func (s *SimpleSpecification[T]) makeComparable(fieldVal, compareVal *reflect.Value) bool { + if fieldVal.Kind() == compareVal.Kind() { + return true + } + + if compareVal.CanConvert(fieldVal.Type()) { + *compareVal = compareVal.Convert(fieldVal.Type()) + return true + } + + if fieldVal.CanConvert(compareVal.Type()) { + *fieldVal = fieldVal.Convert(compareVal.Type()) + return true + } + + return false +} + +func (s *SimpleSpecification[T]) isGreater(fieldVal, compareVal reflect.Value) bool { switch fieldVal.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: return fieldVal.Int() > compareVal.Int() @@ -421,13 +322,11 @@ func (s *SimpleSpecification[T]) compareGreater(fieldVal, compareVal reflect.Val return fieldVal.Float() > compareVal.Float() case reflect.String: return fieldVal.String() > compareVal.String() - default: - return false } + return false } -// compareLess compares if fieldVal is less than compareVal -func (s *SimpleSpecification[T]) compareLess(fieldVal, compareVal reflect.Value) bool { +func (s *SimpleSpecification[T]) isLess(fieldVal, compareVal reflect.Value) bool { switch fieldVal.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: return fieldVal.Int() < compareVal.Int() @@ -437,7 +336,72 @@ func (s *SimpleSpecification[T]) compareLess(fieldVal, compareVal reflect.Value) return fieldVal.Float() < compareVal.Float() case reflect.String: return fieldVal.String() < compareVal.String() - default: - return false } + return false +} + +type CompositeSpecification[T any] struct { + left Specification[T] + right Specification[T] + op string +} + +func (c *CompositeSpecification[T]) IsSatisfiedBy(candidate T) bool { + switch c.op { + case "AND": + return c.left.IsSatisfiedBy(candidate) && c.right.IsSatisfiedBy(candidate) + case "OR": + return c.left.IsSatisfiedBy(candidate) || c.right.IsSatisfiedBy(candidate) + } + return false +} + +func (c *CompositeSpecification[T]) And(other Specification[T]) Specification[T] { + return &CompositeSpecification[T]{left: c, right: other, op: "AND"} +} + +func (c *CompositeSpecification[T]) Or(other Specification[T]) Specification[T] { + return &CompositeSpecification[T]{left: c, right: other, op: "OR"} +} + +func (c *CompositeSpecification[T]) Not() Specification[T] { + return &NotSpecification[T]{spec: c} +} + +func (c *CompositeSpecification[T]) GetConditions() []Condition { + leftConditions := c.left.GetConditions() + rightConditions := c.right.GetConditions() + return append(leftConditions, rightConditions...) +} + +func (c *CompositeSpecification[T]) GetSpec() *Spec { + return nil +} + +type NotSpecification[T any] struct { + spec Specification[T] +} + +func (n *NotSpecification[T]) IsSatisfiedBy(candidate T) bool { + return !n.spec.IsSatisfiedBy(candidate) +} + +func (n *NotSpecification[T]) And(other Specification[T]) Specification[T] { + return &CompositeSpecification[T]{left: n, right: other, op: "AND"} +} + +func (n *NotSpecification[T]) Or(other Specification[T]) Specification[T] { + return &CompositeSpecification[T]{left: n, right: other, op: "OR"} +} + +func (n *NotSpecification[T]) Not() Specification[T] { + return n.spec +} + +func (n *NotSpecification[T]) GetConditions() []Condition { + return n.spec.GetConditions() +} + +func (n *NotSpecification[T]) GetSpec() *Spec { + return nil } \ No newline at end of file diff --git a/golang/tests/integration/file_item_repository_test.go b/golang/tests/integration/file_item_repository_test.go new file mode 100644 index 0000000..627059b --- /dev/null +++ b/golang/tests/integration/file_item_repository_test.go @@ -0,0 +1,348 @@ +package integration + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "autostore/internal/domain/entities" + "autostore/internal/domain/specifications" + "autostore/internal/domain/value_objects" + "autostore/internal/infrastructure/repositories" + "github.com/stretchr/testify/assert" +) + +const ( + ITEM_ID_1 = "00000000-0000-0000-0000-000000000001" + ITEM_ID_2 = "00000000-0000-0000-0000-000000000002" + ITEM_ID_3 = "00000000-0000-0000-0000-000000000003" + EXPIRED_ID = "00000000-0000-0000-0000-000000000004" + VALID_ID = "00000000-0000-0000-0000-000000000005" + NON_EXISTENT_ID = "00000000-0000-0000-0000-000000000099" + ITEM_NAME_1 = "Test Item 1" + ITEM_NAME_2 = "Test Item 2" + ITEM_NAME_3 = "Test Item 3" + EXPIRED_NAME = "Expired Item" + VALID_NAME = "Valid Item" + ORDER_URL_1 = "http://example.com/order1" + ORDER_URL_2 = "http://example.com/order2" + ORDER_URL_3 = "http://example.com/order3" + EXPIRED_ORDER_URL = "http://example.com/expired-order" + VALID_ORDER_URL = "http://example.com/valid-order" + USER_ID_1 = "10000000-0000-0000-0000-000000000001" + USER_ID_2 = "10000000-0000-0000-0000-000000000002" + USER_ID = "10000000-0000-0000-0000-000000000003" + MOCKED_NOW = "2023-01-01T12:00:00Z" + DATE_FORMAT = time.RFC3339 +) + +type mockLogger struct { + infoLogs []string + errorLogs []string +} + +func (m *mockLogger) Info(ctx context.Context, msg string, args ...any) { + m.infoLogs = append(m.infoLogs, fmt.Sprintf(msg, args...)) +} + +func (m *mockLogger) Error(ctx context.Context, msg string, args ...any) { + m.errorLogs = append(m.errorLogs, fmt.Sprintf(msg, args...)) +} + +func (m *mockLogger) Debug(ctx context.Context, msg string, args ...any) { + // Debug implementation - can be empty for tests +} + +func (m *mockLogger) Warn(ctx context.Context, msg string, args ...any) { + // Warn implementation - can be empty for tests +} + +func setupTest(t *testing.T) (string, *repositories.FileItemRepository, *mockLogger, func()) { + testStoragePath := t.TempDir() + logger := &mockLogger{} + repo := repositories.NewFileItemRepository(testStoragePath, logger) + + cleanup := func() { + os.RemoveAll(testStoragePath) + } + + return testStoragePath, repo, logger, cleanup +} + +func createTestItem(id, name string, expiration time.Time, orderURL, userID string) *entities.ItemEntity { + itemID, _ := value_objects.NewItemID(id) + userIDVO, _ := value_objects.NewUserID(userID) + expirationVO, _ := value_objects.NewExpirationDate(expiration) + item, _ := entities.NewItem(itemID, name, expirationVO, orderURL, userIDVO) + return item +} + +func createTestItem1() *entities.ItemEntity { + expiration, _ := time.Parse(DATE_FORMAT, "2025-01-01T12:00:00Z") + return createTestItem(ITEM_ID_1, ITEM_NAME_1, expiration, ORDER_URL_1, USER_ID_1) +} + +func createTestItem2() *entities.ItemEntity { + expiration, _ := time.Parse(DATE_FORMAT, "2025-01-02T12:00:00Z") + return createTestItem(ITEM_ID_2, ITEM_NAME_2, expiration, ORDER_URL_2, USER_ID_2) +} + +func createTestItem3() *entities.ItemEntity { + expiration, _ := time.Parse(DATE_FORMAT, "2025-01-03T12:00:00Z") + return createTestItem(ITEM_ID_3, ITEM_NAME_3, expiration, ORDER_URL_3, USER_ID_1) +} + +func createExpiredItem() *entities.ItemEntity { + expiration, _ := time.Parse(DATE_FORMAT, "2022-01-01T12:00:00Z") // Past date + return createTestItem(EXPIRED_ID, EXPIRED_NAME, expiration, EXPIRED_ORDER_URL, USER_ID) +} + +func createValidItem() *entities.ItemEntity { + expiration, _ := time.Parse(DATE_FORMAT, "2024-01-01T12:00:00Z") // Future date + return createTestItem(VALID_ID, VALID_NAME, expiration, VALID_ORDER_URL, USER_ID) +} + +func createExpiredItemForUser1() *entities.ItemEntity { + expiration, _ := time.Parse(DATE_FORMAT, "2022-01-01T12:00:00Z") // Past date + return createTestItem(ITEM_ID_1, ITEM_NAME_1, expiration, ORDER_URL_1, USER_ID_1) +} + +func createValidItemForUser1() *entities.ItemEntity { + expiration, _ := time.Parse(DATE_FORMAT, "2024-01-01T12:00:00Z") // Future date + return createTestItem(ITEM_ID_2, ITEM_NAME_2, expiration, ORDER_URL_2, USER_ID_1) +} + +func createExpiredItemForUser2() *entities.ItemEntity { + expiration, _ := time.Parse(DATE_FORMAT, "2022-01-01T12:00:00Z") // Past date + return createTestItem(ITEM_ID_3, ITEM_NAME_3, expiration, ORDER_URL_3, USER_ID_2) +} + +func TestWhenItemIsSavedThenFileIsCreated(t *testing.T) { + testStoragePath, repo, _, cleanup := setupTest(t) + defer cleanup() + + item := createTestItem1() + + err := repo.Save(context.Background(), item) + assert.NoError(t, err) + + filePath := filepath.Join(testStoragePath, "items.json") + assert.FileExists(t, filePath) + + data, err := os.ReadFile(filePath) + assert.NoError(t, err) + + var items map[string]*entities.ItemEntity + err = json.Unmarshal(data, &items) + assert.NoError(t, err) + assert.Len(t, items, 1) + + savedItem := items[item.GetID().String()] + assert.NotNil(t, savedItem) + assert.Equal(t, item.GetID().String(), savedItem.GetID().String()) + assert.Equal(t, item.GetName(), savedItem.GetName()) + assert.Equal(t, item.GetOrderURL(), savedItem.GetOrderURL()) + assert.Equal(t, item.GetUserID().String(), savedItem.GetUserID().String()) +} + +func TestWhenItemExistsThenFindByIDReturnsItem(t *testing.T) { + _, repo, _, cleanup := setupTest(t) + defer cleanup() + + item := createTestItem1() + err := repo.Save(context.Background(), item) + assert.NoError(t, err) + + itemID, _ := value_objects.NewItemID(ITEM_ID_1) + foundItem, err := repo.FindByID(context.Background(), itemID) + assert.NoError(t, err) + assert.NotNil(t, foundItem) + assert.Equal(t, item.GetID().String(), foundItem.GetID().String()) + assert.Equal(t, item.GetName(), foundItem.GetName()) +} + +func TestWhenItemDoesNotExistThenFindByIDReturnsNil(t *testing.T) { + _, repo, _, cleanup := setupTest(t) + defer cleanup() + + nonExistentID, _ := value_objects.NewItemID(NON_EXISTENT_ID) + foundItem, err := repo.FindByID(context.Background(), nonExistentID) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") + assert.Nil(t, foundItem) +} + +func TestWhenUserHasMultipleItemsThenFindByUserIDReturnsAllUserItems(t *testing.T) { + _, repo, _, cleanup := setupTest(t) + defer cleanup() + + item1 := createTestItem1() + item2 := createTestItem2() + item3 := createTestItem3() + + repo.Save(context.Background(), item1) + repo.Save(context.Background(), item2) + repo.Save(context.Background(), item3) + + userID, _ := value_objects.NewUserID(USER_ID_1) + userItems, err := repo.FindByUserID(context.Background(), userID) + assert.NoError(t, err) + assert.Len(t, userItems, 2) + + itemIDs := make([]string, len(userItems)) + for i, item := range userItems { + itemIDs[i] = item.GetID().String() + } + assert.Contains(t, itemIDs, ITEM_ID_1) + assert.Contains(t, itemIDs, ITEM_ID_3) +} + +func TestWhenItemIsDeletedThenItIsNoLongerFound(t *testing.T) { + _, repo, _, cleanup := setupTest(t) + defer cleanup() + + item := createTestItem1() + err := repo.Save(context.Background(), item) + assert.NoError(t, err) + + itemID, _ := value_objects.NewItemID(ITEM_ID_1) + err = repo.Delete(context.Background(), itemID) + assert.NoError(t, err) + + foundItem, err := repo.FindByID(context.Background(), itemID) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") + assert.Nil(t, foundItem) +} + +func TestWhenNonExistentItemIsDeletedThenErrorIsReturned(t *testing.T) { + _, repo, _, cleanup := setupTest(t) + defer cleanup() + + nonExistentID, _ := value_objects.NewItemID(NON_EXISTENT_ID) + err := repo.Delete(context.Background(), nonExistentID) + assert.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("item with ID %s not found", NON_EXISTENT_ID)) +} + +func TestWhenFilteringByExpirationThenOnlyExpiredItemsAreReturned(t *testing.T) { + _, repo, _, cleanup := setupTest(t) + defer cleanup() + + expiredItem := createExpiredItem() + validItem := createValidItem() + + repo.Save(context.Background(), expiredItem) + repo.Save(context.Background(), validItem) + + now, _ := time.Parse(DATE_FORMAT, MOCKED_NOW) + expirationSpec := specifications.NewItemExpirationSpec() + spec := expirationSpec.GetSpec(now) + + filteredItems, err := repo.FindWhere(context.Background(), spec) + assert.NoError(t, err) + assert.Len(t, filteredItems, 1) + assert.Equal(t, expiredItem.GetID().String(), filteredItems[0].GetID().String()) +} + +func TestWhenFilteringByUserIDThenOnlyUserItemsAreReturned(t *testing.T) { + _, repo, _, cleanup := setupTest(t) + defer cleanup() + + item1 := createTestItem1() + item2 := createTestItem2() + item3 := createTestItem3() + + repo.Save(context.Background(), item1) + repo.Save(context.Background(), item2) + repo.Save(context.Background(), item3) + + userID, _ := value_objects.NewUserID(USER_ID_1) + spec := specifications.NewSimpleSpecification[*entities.ItemEntity](specifications.Eq("userID", userID)) + + userItems, err := repo.FindWhere(context.Background(), spec) + assert.NoError(t, err) + assert.Len(t, userItems, 2) + + itemIDs := make([]string, len(userItems)) + for i, item := range userItems { + itemIDs[i] = item.GetID().String() + } + assert.Contains(t, itemIDs, ITEM_ID_1) + assert.Contains(t, itemIDs, ITEM_ID_3) +} + +func TestWhenUsingComplexFilterThenOnlyMatchingItemsAreReturned(t *testing.T) { + _, repo, _, cleanup := setupTest(t) + defer cleanup() + + item1 := createExpiredItemForUser1() + item2 := createValidItemForUser1() + item3 := createExpiredItemForUser2() + + repo.Save(context.Background(), item1) + repo.Save(context.Background(), item2) + repo.Save(context.Background(), item3) + + now, _ := time.Parse(DATE_FORMAT, MOCKED_NOW) + userID, _ := value_objects.NewUserID(USER_ID_1) + userSpec := specifications.NewSimpleSpecification[*entities.ItemEntity](specifications.Eq("userID", userID)) + expirationSpec := specifications.NewItemExpirationSpec() + expirationSpecWithTime := expirationSpec.GetSpec(now) + complexSpec := userSpec.And(expirationSpecWithTime) + + filteredItems, err := repo.FindWhere(context.Background(), complexSpec) + assert.NoError(t, err) + assert.Len(t, filteredItems, 1) + assert.Equal(t, item1.GetID().String(), filteredItems[0].GetID().String()) +} + +func TestWhenCheckingExistenceOfExistingItemThenReturnsTrue(t *testing.T) { + _, repo, _, cleanup := setupTest(t) + defer cleanup() + + item := createTestItem1() + err := repo.Save(context.Background(), item) + assert.NoError(t, err) + + itemID, _ := value_objects.NewItemID(ITEM_ID_1) + exists, err := repo.Exists(context.Background(), itemID) + assert.NoError(t, err) + assert.True(t, exists) +} + +func TestWhenCheckingExistenceOfNonExistentItemThenReturnsFalse(t *testing.T) { + _, repo, _, cleanup := setupTest(t) + defer cleanup() + + nonExistentID, _ := value_objects.NewItemID(NON_EXISTENT_ID) + exists, err := repo.Exists(context.Background(), nonExistentID) + assert.NoError(t, err) + assert.False(t, exists) +} + +func TestWhenDifferentRepositoryInstancesShareSameFile(t *testing.T) { + testStoragePath, _, _, cleanup := setupTest(t) + defer cleanup() + + logger := &mockLogger{} + repo1 := repositories.NewFileItemRepository(testStoragePath, logger) + repo2 := repositories.NewFileItemRepository(testStoragePath, logger) + + item := createTestItem1() + err := repo1.Save(context.Background(), item) + assert.NoError(t, err) + + itemID, _ := value_objects.NewItemID(ITEM_ID_1) + foundItem, err := repo2.FindByID(context.Background(), itemID) + assert.NoError(t, err) + assert.NotNil(t, foundItem) + assert.Equal(t, item.GetID().String(), foundItem.GetID().String()) + assert.Equal(t, item.GetName(), foundItem.GetName()) + assert.Equal(t, item.GetOrderURL(), foundItem.GetOrderURL()) + assert.Equal(t, item.GetUserID().String(), foundItem.GetUserID().String()) +} \ No newline at end of file diff --git a/golang/tests/unit/add_item_command_test.go b/golang/tests/unit/add_item_command_test.go new file mode 100644 index 0000000..9b387ec --- /dev/null +++ b/golang/tests/unit/add_item_command_test.go @@ -0,0 +1,352 @@ +package unit + +import ( + "context" + "errors" + "testing" + "time" + + "autostore/internal/application/commands" + "autostore/internal/domain/entities" + "autostore/internal/domain/specifications" +) + + +func createTestCommand() (*commands.AddItemCommand, *mockItemRepository, *mockOrderService, *mockTimeProvider, *mockLogger) { + itemRepo := &mockItemRepository{} + orderService := &mockOrderService{} + timeProvider := &mockTimeProvider{ + nowFunc: func() time.Time { + t, _ := time.Parse(dateFormat, mockedNow) + return t + }, + } + expirationSpec := specifications.NewItemExpirationSpec() + logger := &mockLogger{} + + cmd := commands.NewAddItemCommand(itemRepo, orderService, timeProvider, expirationSpec, logger) + return cmd, itemRepo, orderService, timeProvider, logger +} + +func TestWhenItemNotExpiredThenItemSaved(t *testing.T) { + // Given + cmd, itemRepo, _, _, _ := createTestCommand() + + expirationTime, _ := time.Parse(dateFormat, notExpiredDate) + + itemSaved := false + itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error { + itemSaved = true + if item.GetName() != itemName { + t.Errorf("Expected item name %s, got %s", itemName, item.GetName()) + } + if item.GetOrderURL() != orderURL { + t.Errorf("Expected order URL %s, got %s", orderURL, item.GetOrderURL()) + } + if item.GetUserID().String() != userID { + t.Errorf("Expected user ID %s, got %s", userID, item.GetUserID().String()) + } + return nil + } + + // When + resultID, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID) + + // Then + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if resultID == "" { + t.Error("Expected non-empty result ID") + } + if !itemSaved { + t.Error("Expected item to be saved") + } +} + +func TestWhenItemNotExpiredThenOrderIsNotPlaced(t *testing.T) { + // Given + cmd, _, orderService, _, _ := createTestCommand() + + expirationTime, _ := time.Parse(dateFormat, notExpiredDate) + + orderPlaced := false + orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { + orderPlaced = true + return nil + } + + // When + _, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID) + + // Then + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if orderPlaced { + t.Error("Expected order not to be placed for non-expired item") + } +} + +func TestWhenItemNotExpiredThenNewItemIdIsReturned(t *testing.T) { + // Given + cmd, _, _, _, _ := createTestCommand() + + expirationTime, _ := time.Parse(dateFormat, notExpiredDate) + + // When + resultID, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID) + + // Then + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if resultID == "" { + t.Error("Expected non-empty result ID") + } +} + +func TestWhenItemIsExpiredThenOrderPlaced(t *testing.T) { + // Given + cmd, itemRepo, orderService, timeProvider, _ := createTestCommand() + + // Set expiration time to 1 hour before current time to ensure it's expired + currentTime := timeProvider.Now() + expirationTime := currentTime.Add(-1 * time.Hour) + + orderPlaced := false + var orderedItem *entities.ItemEntity + + itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error { + return nil + } + + orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { + orderPlaced = true + orderedItem = item + return nil + } + + // When + resultID, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID) + + // Then + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if resultID == "" { + t.Error("Expected non-empty result ID") + } + if !orderPlaced { + t.Error("Expected order to be placed for expired item") + } + if orderedItem == nil { + t.Error("Expected ordered item to be captured") + } + if orderedItem != nil && orderedItem.GetName() != itemName { + t.Errorf("Expected ordered item name %s, got %s", itemName, orderedItem.GetName()) + } +} + +func TestWhenItemNameIsEmptyThenErrorReturned(t *testing.T) { + // Given + cmd, _, _, _, _ := createTestCommand() + + expirationTime, _ := time.Parse(dateFormat, notExpiredDate) + + // When + _, err := cmd.Execute(context.Background(), "", expirationTime, orderURL, userID) + + // Then + if err == nil { + t.Error("Expected error for empty item name") + } +} + +func TestWhenOrderUrlIsEmptyThenErrorReturned(t *testing.T) { + // Given + cmd, _, _, _, _ := createTestCommand() + + expirationTime, _ := time.Parse(dateFormat, notExpiredDate) + + // When + _, err := cmd.Execute(context.Background(), itemName, expirationTime, "", userID) + + // Then + if err == nil { + t.Error("Expected error for empty order URL") + } +} + +func TestWhenUserIdIsEmptyThenErrorReturned(t *testing.T) { + // Given + cmd, _, _, _, _ := createTestCommand() + + expirationTime, _ := time.Parse(dateFormat, notExpiredDate) + + // When + _, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, "") + + // Then + if err == nil { + t.Error("Expected error for empty user ID") + } +} + +func TestWhenOrderServiceFailsThenErrorLogged(t *testing.T) { + // Given + cmd, itemRepo, orderService, _, _ := createTestCommand() + + expirationTime, _ := time.Parse(dateFormat, expiredDate) + + itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error { + return nil + } + + orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { + return errors.New("order service failed") + } + + // When - the handler should not throw an exception when the order service fails + // It should log the error and continue + resultID, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID) + + // Then + if err != nil { + t.Errorf("Expected no error when order service fails, got %v", err) + } + if resultID == "" { + t.Error("Expected non-empty result ID even when order service fails") + } +} + +func TestWhenRepositorySaveThrowsExceptionThenErrorReturned(t *testing.T) { + // Given + cmd, itemRepo, _, _, _ := createTestCommand() + + expirationTime, _ := time.Parse(dateFormat, notExpiredDate) + + expectedError := errors.New("repository error") + itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error { + return expectedError + } + + // When + _, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID) + + // Then + if err == nil { + t.Error("Expected error when repository save fails") + } +} + +func TestWhenRepositorySaveThrowsExceptionThenOrderIsNotPlaced(t *testing.T) { + // Given + cmd, itemRepo, orderService, _, _ := createTestCommand() + + expirationTime, _ := time.Parse(dateFormat, expiredDate) + + expectedError := errors.New("repository error") + itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error { + return expectedError + } + + orderPlaced := false + orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { + orderPlaced = true + return nil + } + + // When + _, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID) + + // Then + if err == nil { + t.Error("Expected error when repository save fails") + } + if orderPlaced { + t.Error("Expected order not to be placed when repository save fails") + } +} + +func TestWhenTimeProviderThrowsExceptionThenErrorReturned(t *testing.T) { + // Given + cmd, _, _, timeProvider, _ := createTestCommand() + + expirationTime, _ := time.Parse(dateFormat, notExpiredDate) + + expectedError := errors.New("time provider error") + timeProvider.nowFunc = func() time.Time { + panic(expectedError) + } + + // When + defer func() { + if r := recover(); r != nil { + // Expected panic + } else { + t.Error("Expected panic when time provider fails") + } + }() + + cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID) +} + +func TestWhenItemExpirationDateIsExactlyCurrentTimeThenOrderIsPlaced(t *testing.T) { + // Given + cmd, itemRepo, orderService, timeProvider, _ := createTestCommand() + + // Use the exact current time from the time provider + currentTime := timeProvider.Now() + + orderPlaced := false + itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error { + return nil + } + + orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { + orderPlaced = true + return nil + } + + // When + resultID, err := cmd.Execute(context.Background(), itemName, currentTime, orderURL, userID) + + // Then + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if resultID == "" { + t.Error("Expected non-empty result ID") + } + if !orderPlaced { + t.Error("Expected order to be placed when expiration date equals current time") + } +} + +func TestWhenItemExpirationDateIsInFutureThenItemSaved(t *testing.T) { + // Given + cmd, itemRepo, _, _, _ := createTestCommand() + + expirationTime, _ := time.Parse(dateFormat, notExpiredDate) + + itemSaved := false + itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error { + itemSaved = true + return nil + } + + // When + resultID, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID) + + // Then + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if resultID == "" { + t.Error("Expected non-empty result ID") + } + if !itemSaved { + t.Error("Expected item to be saved when expiration date is in future") + } +} \ No newline at end of file diff --git a/golang/tests/unit/handle_expired_items_command_test.go b/golang/tests/unit/handle_expired_items_command_test.go new file mode 100644 index 0000000..69e8c60 --- /dev/null +++ b/golang/tests/unit/handle_expired_items_command_test.go @@ -0,0 +1,293 @@ +package unit + +import ( + "context" + "errors" + "testing" + "time" + + "autostore/internal/application/commands" + "autostore/internal/domain/entities" + "autostore/internal/domain/specifications" + "autostore/internal/domain/value_objects" +) + +func createExpiredItem1() *entities.ItemEntity { + expirationTime, _ := time.Parse(dateFormat, expiredDate) + itemID, _ := value_objects.NewItemIDFromString("550e8400-e29b-41d4-a716-446655440001") + userID, _ := value_objects.NewUserIDFromString("550e8400-e29b-41d4-a716-446655440003") + expirationDate, _ := value_objects.NewExpirationDate(expirationTime) + + item, _ := entities.NewItem(itemID, "Expired Item 1", expirationDate, "http://example.com/order1", userID) + return item +} + +func createExpiredItem2() *entities.ItemEntity { + expirationTime, _ := time.Parse(dateFormat, expiredDate) + itemID, _ := value_objects.NewItemIDFromString("550e8400-e29b-41d4-a716-446655440002") + userID, _ := value_objects.NewUserIDFromString("550e8400-e29b-41d4-a716-446655440004") + expirationDate, _ := value_objects.NewExpirationDate(expirationTime) + + item, _ := entities.NewItem(itemID, "Expired Item 2", expirationDate, "http://example.com/order2", userID) + return item +} + +func createTestHandleExpiredItemsCommand() (*commands.HandleExpiredItemsCommand, *mockItemRepository, *mockOrderService, *mockTimeProvider, *specifications.ItemExpirationSpec, *mockLogger) { + itemRepo := &mockItemRepository{} + orderService := &mockOrderService{} + timeProvider := &mockTimeProvider{ + nowFunc: func() time.Time { + t, _ := time.Parse(dateFormat, mockedNow) + return t + }, + } + expirationSpec := specifications.NewItemExpirationSpec() + logger := &mockLogger{} + + cmd := commands.NewHandleExpiredItemsCommand(itemRepo, orderService, timeProvider, expirationSpec, logger) + return cmd, itemRepo, orderService, timeProvider, expirationSpec, logger +} + +func TestWhenNoExpiredItemsExistThenNoOrdersPlaced(t *testing.T) { + // Given + cmd, itemRepo, orderService, _, _, logger := createTestHandleExpiredItemsCommand() + + itemRepo.findWhereFunc = func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) { + return []*entities.ItemEntity{}, nil + } + + orderCalled := false + orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { + orderCalled = true + return nil + } + + deleteCalled := false + itemRepo.deleteFunc = func(ctx context.Context, id value_objects.ItemID) error { + deleteCalled = true + return nil + } + + // When + err := cmd.Execute(context.Background()) + + // Then + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if orderCalled { + t.Error("Expected order service not to be called") + } + if deleteCalled { + t.Error("Expected delete not to be called") + } + if len(logger.errorLogs) > 0 { + t.Error("Expected no error logs") + } +} + +func TestWhenExpiredItemsExistThenOrdersPlacedAndItemsDeleted(t *testing.T) { + // Given + cmd, itemRepo, orderService, _, _, logger := createTestHandleExpiredItemsCommand() + + expiredItem1 := createExpiredItem1() + expiredItem2 := createExpiredItem2() + expiredItems := []*entities.ItemEntity{expiredItem1, expiredItem2} + + itemRepo.findWhereFunc = func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) { + return expiredItems, nil + } + + orderCallCount := 0 + orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { + orderCallCount++ + return nil + } + + deleteCallCount := 0 + itemRepo.deleteFunc = func(ctx context.Context, id value_objects.ItemID) error { + deleteCallCount++ + return nil + } + + // When + err := cmd.Execute(context.Background()) + + // Then + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if orderCallCount != 2 { + t.Errorf("Expected order service to be called 2 times, got %d", orderCallCount) + } + if deleteCallCount != 2 { + t.Errorf("Expected delete to be called 2 times, got %d", deleteCallCount) + } + if len(logger.errorLogs) > 0 { + t.Error("Expected no error logs") + } +} + +func TestWhenOrderServiceFailsForOneItemThenErrorLoggedAndOtherItemProcessed(t *testing.T) { + // Given + cmd, itemRepo, orderService, _, _, logger := createTestHandleExpiredItemsCommand() + + expiredItem1 := createExpiredItem1() + expiredItem2 := createExpiredItem2() + expiredItems := []*entities.ItemEntity{expiredItem1, expiredItem2} + + itemRepo.findWhereFunc = func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) { + return expiredItems, nil + } + + orderCallCount := 0 + orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { + orderCallCount++ + if orderCallCount == 1 { + return errors.New("order service failed") + } + return nil + } + + deleteCallCount := 0 + itemRepo.deleteFunc = func(ctx context.Context, id value_objects.ItemID) error { + deleteCallCount++ + return nil + } + + // When + err := cmd.Execute(context.Background()) + + // Then + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if orderCallCount != 2 { + t.Errorf("Expected order service to be called 2 times, got %d", orderCallCount) + } + if deleteCallCount != 1 { + t.Errorf("Expected delete to be called 1 time (only for successful order), got %d", deleteCallCount) + } + if len(logger.errorLogs) != 1 { + t.Errorf("Expected 1 error log, got %d", len(logger.errorLogs)) + } +} + +func TestWhenRepositoryFindThrowsErrorThenErrorReturned(t *testing.T) { + // Given + cmd, itemRepo, orderService, _, _, logger := createTestHandleExpiredItemsCommand() + + expectedError := errors.New("repository find error") + itemRepo.findWhereFunc = func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) { + return nil, expectedError + } + + orderCalled := false + orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { + orderCalled = true + return nil + } + + // When + err := cmd.Execute(context.Background()) + + // Then + if err == nil { + t.Error("Expected error, got nil") + } + if orderCalled { + t.Error("Expected order service not to be called") + } + if len(logger.errorLogs) != 1 { + t.Errorf("Expected 1 error log, got %d", len(logger.errorLogs)) + } +} + +func TestWhenRepositoryDeleteThrowsExceptionThenErrorLogged(t *testing.T) { + // Given + cmd, itemRepo, orderService, _, _, logger := createTestHandleExpiredItemsCommand() + + expiredItem1 := createExpiredItem1() + expiredItems := []*entities.ItemEntity{expiredItem1} + + itemRepo.findWhereFunc = func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) { + return expiredItems, nil + } + + orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { + return nil + } + + expectedError := errors.New("delete failed") + itemRepo.deleteFunc = func(ctx context.Context, id value_objects.ItemID) error { + return expectedError + } + + // When + err := cmd.Execute(context.Background()) + + // Then + if err == nil { + t.Error("Expected error, got nil") + } + if len(logger.errorLogs) != 1 { + t.Errorf("Expected 1 error log, got %d", len(logger.errorLogs)) + } +} + +func TestWhenTimeProviderThrowsErrorThenErrorReturned(t *testing.T) { + // Given + cmd, _, _, timeProvider, _, _ := createTestHandleExpiredItemsCommand() + + timeProvider.nowFunc = func() time.Time { + panic("time provider error") + } + + // When & Then + defer func() { + if r := recover(); r != nil { + // Expected panic + } else { + t.Error("Expected panic when time provider fails") + } + }() + + cmd.Execute(context.Background()) +} + +func TestWhenAllOrderServicesFailThenAllErrorsLogged(t *testing.T) { + // Given + cmd, itemRepo, orderService, _, _, logger := createTestHandleExpiredItemsCommand() + + expiredItem1 := createExpiredItem1() + expiredItem2 := createExpiredItem2() + expiredItems := []*entities.ItemEntity{expiredItem1, expiredItem2} + + itemRepo.findWhereFunc = func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) { + return expiredItems, nil + } + + orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { + return errors.New("order service failed") + } + + deleteCalled := false + itemRepo.deleteFunc = func(ctx context.Context, id value_objects.ItemID) error { + deleteCalled = true + return nil + } + + // When + err := cmd.Execute(context.Background()) + + // Then + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if deleteCalled { + t.Error("Expected delete not to be called when all orders fail") + } + if len(logger.errorLogs) != 2 { + t.Errorf("Expected 2 error logs, got %d", len(logger.errorLogs)) + } +} \ No newline at end of file diff --git a/golang/tests/unit/specification_test.go b/golang/tests/unit/specification_test.go new file mode 100644 index 0000000..c57f7e0 --- /dev/null +++ b/golang/tests/unit/specification_test.go @@ -0,0 +1,904 @@ +package unit + +import ( + "testing" + "time" + + "autostore/internal/domain/specifications" +) + +// Test data constants +const ( + TEST_STRING = "test-value" + TEST_INT = 42 + TEST_FLOAT = 3.14 + TEST_DATE = "2023-01-15 10:30:00" + TEST_DATE_2 = "2023-02-15 10:30:00" + INVALID_DATE = "invalid-date" +) + +// TestObject is a simple struct for testing specifications +type TestObject struct { + name string + age int + price float64 + status string + score int + role string + department string + active bool + optionalField interface{} + dateField string + createdAt time.Time + expirationDate time.Time +} + +// NewTestObject creates a new TestObject with the given field values +func NewTestObject(fields map[string]interface{}) *TestObject { + obj := &TestObject{} + + if val, ok := fields["name"]; ok { + obj.name = val.(string) + } + if val, ok := fields["age"]; ok { + obj.age = val.(int) + } + if val, ok := fields["price"]; ok { + obj.price = val.(float64) + } + if val, ok := fields["status"]; ok { + obj.status = val.(string) + } + if val, ok := fields["score"]; ok { + obj.score = val.(int) + } + if val, ok := fields["role"]; ok { + obj.role = val.(string) + } + if val, ok := fields["department"]; ok { + obj.department = val.(string) + } + if val, ok := fields["active"]; ok { + obj.active = val.(bool) + } + if val, ok := fields["optionalField"]; ok { + obj.optionalField = val + } + if val, ok := fields["dateField"]; ok { + obj.dateField = val.(string) + } + if val, ok := fields["createdAt"]; ok { + switch v := val.(type) { + case time.Time: + obj.createdAt = v + case string: + obj.createdAt, _ = time.Parse(dateFormat, v) + } + } + if val, ok := fields["expirationDate"]; ok { + switch v := val.(type) { + case time.Time: + obj.expirationDate = v + case string: + obj.expirationDate, _ = time.Parse(dateFormat, v) + } + } + + return obj +} + +// Getter methods for TestObject +func (o *TestObject) GetName() string { + return o.name +} + +func (o *TestObject) GetAge() int { + return o.age +} + +func (o *TestObject) GetPrice() float64 { + return o.price +} + +func (o *TestObject) GetStatus() string { + return o.status +} + +func (o *TestObject) GetScore() int { + return o.score +} + +func (o *TestObject) GetRole() string { + return o.role +} + +func (o *TestObject) GetDepartment() string { + return o.department +} + +func (o *TestObject) GetActive() bool { + return o.active +} + +func (o *TestObject) GetOptionalField() interface{} { + return o.optionalField +} + +func (o *TestObject) GetDateField() string { + return o.dateField +} + +func (o *TestObject) GetCreatedAt() time.Time { + return o.createdAt +} + +func (o *TestObject) GetExpirationDate() time.Time { + return o.expirationDate +} + +// EQ Operator Tests + +func TestWhenUsingEqWithStringThenMatchesCorrectly(t *testing.T) { + // Given + spec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("name", TEST_STRING)) + matchingObject := NewTestObject(map[string]interface{}{"name": TEST_STRING}) + nonMatchingObject := NewTestObject(map[string]interface{}{"name": "different"}) + + // When + matchResult := spec.IsSatisfiedBy(matchingObject) + noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) + + // Then + if !matchResult { + t.Error("Expected matching object to satisfy the specification") + } + if noMatchResult { + t.Error("Expected non-matching object to not satisfy the specification") + } +} + +func TestWhenUsingEqWithIntegerThenMatchesCorrectly(t *testing.T) { + // Given + spec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("age", TEST_INT)) + matchingObject := NewTestObject(map[string]interface{}{"age": TEST_INT}) + nonMatchingObject := NewTestObject(map[string]interface{}{"age": 100}) + + // When + matchResult := spec.IsSatisfiedBy(matchingObject) + noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) + + // Then + if !matchResult { + t.Error("Expected matching object to satisfy the specification") + } + if noMatchResult { + t.Error("Expected non-matching object to not satisfy the specification") + } +} + +func TestWhenUsingEqWithFloatThenMatchesCorrectly(t *testing.T) { + // Given + spec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("price", TEST_FLOAT)) + matchingObject := NewTestObject(map[string]interface{}{"price": TEST_FLOAT}) + nonMatchingObject := NewTestObject(map[string]interface{}{"price": 1.0}) + + // When + matchResult := spec.IsSatisfiedBy(matchingObject) + noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) + + // Then + if !matchResult { + t.Error("Expected matching object to satisfy the specification") + } + if noMatchResult { + t.Error("Expected non-matching object to not satisfy the specification") + } +} + +// NEQ Operator Tests + +func TestWhenUsingNeqThenMatchesCorrectly(t *testing.T) { + // Given + spec := specifications.NewSimpleSpecification[*TestObject](specifications.Neq("status", "inactive")) + matchingObject := NewTestObject(map[string]interface{}{"status": "active"}) + nonMatchingObject := NewTestObject(map[string]interface{}{"status": "inactive"}) + + // When + matchResult := spec.IsSatisfiedBy(matchingObject) + noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) + + // Then + if !matchResult { + t.Error("Expected matching object to satisfy the specification") + } + if noMatchResult { + t.Error("Expected non-matching object to not satisfy the specification") + } +} + +// Comparison Operators Tests + +func TestWhenUsingGtThenMatchesCorrectly(t *testing.T) { + // Given + spec := specifications.NewSimpleSpecification[*TestObject](specifications.Gt("score", 80)) + matchingObject := NewTestObject(map[string]interface{}{"score": 90}) + nonMatchingObject := NewTestObject(map[string]interface{}{"score": 70}) + + // When + matchResult := spec.IsSatisfiedBy(matchingObject) + noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) + + // Then + if !matchResult { + t.Error("Expected matching object to satisfy the specification") + } + if noMatchResult { + t.Error("Expected non-matching object to not satisfy the specification") + } +} + +func TestWhenUsingGteThenMatchesCorrectly(t *testing.T) { + // Given + spec := specifications.NewSimpleSpecification[*TestObject](specifications.Gte("score", 80)) + matchingObject1 := NewTestObject(map[string]interface{}{"score": 80}) + matchingObject2 := NewTestObject(map[string]interface{}{"score": 90}) + nonMatchingObject := NewTestObject(map[string]interface{}{"score": 70}) + + // When + matchResult1 := spec.IsSatisfiedBy(matchingObject1) + matchResult2 := spec.IsSatisfiedBy(matchingObject2) + noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) + + // Then + if !matchResult1 { + t.Error("Expected matching object 1 to satisfy the specification") + } + if !matchResult2 { + t.Error("Expected matching object 2 to satisfy the specification") + } + if noMatchResult { + t.Error("Expected non-matching object to not satisfy the specification") + } +} + +func TestWhenUsingLtThenMatchesCorrectly(t *testing.T) { + // Given + spec := specifications.NewSimpleSpecification[*TestObject](specifications.Lt("score", 80)) + matchingObject := NewTestObject(map[string]interface{}{"score": 70}) + nonMatchingObject := NewTestObject(map[string]interface{}{"score": 90}) + + // When + matchResult := spec.IsSatisfiedBy(matchingObject) + noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) + + // Then + if !matchResult { + t.Error("Expected matching object to satisfy the specification") + } + if noMatchResult { + t.Error("Expected non-matching object to not satisfy the specification") + } +} + +func TestWhenUsingLteThenMatchesCorrectly(t *testing.T) { + // Given + spec := specifications.NewSimpleSpecification[*TestObject](specifications.Lte("score", 80)) + matchingObject1 := NewTestObject(map[string]interface{}{"score": 80}) + matchingObject2 := NewTestObject(map[string]interface{}{"score": 70}) + nonMatchingObject := NewTestObject(map[string]interface{}{"score": 90}) + + // When + matchResult1 := spec.IsSatisfiedBy(matchingObject1) + matchResult2 := spec.IsSatisfiedBy(matchingObject2) + noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) + + // Then + if !matchResult1 { + t.Error("Expected matching object 1 to satisfy the specification") + } + if !matchResult2 { + t.Error("Expected matching object 2 to satisfy the specification") + } + if noMatchResult { + t.Error("Expected non-matching object to not satisfy the specification") + } +} + +// IN Operator Tests + +func TestWhenUsingInThenMatchesCorrectly(t *testing.T) { + // Given + validValues := []interface{}{"admin", "moderator", "editor"} + spec := specifications.NewSimpleSpecification[*TestObject](specifications.In("role", validValues)) + matchingObject := NewTestObject(map[string]interface{}{"role": "admin"}) + nonMatchingObject := NewTestObject(map[string]interface{}{"role": "user"}) + + // When + matchResult := spec.IsSatisfiedBy(matchingObject) + noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) + + // Then + if !matchResult { + t.Error("Expected matching object to satisfy the specification") + } + if noMatchResult { + t.Error("Expected non-matching object to not satisfy the specification") + } +} + +func TestWhenUsingInWithEmptyArrayThenNeverMatches(t *testing.T) { + // Given + validValues := []interface{}{} + spec := specifications.NewSimpleSpecification[*TestObject](specifications.In("role", validValues)) + testObject := NewTestObject(map[string]interface{}{"role": "admin"}) + + // When + result := spec.IsSatisfiedBy(testObject) + + // Then + if result { + t.Error("Expected object to not satisfy the specification with empty array") + } +} + +// NOT IN Operator Tests + +func TestWhenUsingNotInThenMatchesCorrectly(t *testing.T) { + // Given + invalidValues := []interface{}{"banned", "suspended"} + spec := specifications.NewSimpleSpecification[*TestObject](specifications.Nin("status", invalidValues)) + matchingObject := NewTestObject(map[string]interface{}{"status": "active"}) + nonMatchingObject := NewTestObject(map[string]interface{}{"status": "banned"}) + + // When + matchResult := spec.IsSatisfiedBy(matchingObject) + noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) + + // Then + if !matchResult { + t.Error("Expected matching object to satisfy the specification") + } + if noMatchResult { + t.Error("Expected non-matching object to not satisfy the specification") + } +} + +// DateTime Tests + +func TestWhenUsingDateTimeComparisonWithStringsThenMatchesCorrectly(t *testing.T) { + // Given + testDate2, _ := time.Parse(dateFormat, TEST_DATE_2) + spec := specifications.NewSimpleSpecification[*TestObject](specifications.Lt("createdAt", testDate2)) + matchingObject := NewTestObject(map[string]interface{}{"createdAt": TEST_DATE}) + nonMatchingObject := NewTestObject(map[string]interface{}{"createdAt": TEST_DATE_2}) + + // When + matchResult := spec.IsSatisfiedBy(matchingObject) + noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) + + // Then + if !matchResult { + t.Error("Expected matching object to satisfy the specification") + } + if noMatchResult { + t.Error("Expected non-matching object to not satisfy the specification") + } +} + +func TestWhenUsingDateTimeComparisonWithTimeObjectsThenMatchesCorrectly(t *testing.T) { + // Given + testDate, _ := time.Parse(dateFormat, TEST_DATE) + spec := specifications.NewSimpleSpecification[*TestObject](specifications.Lte("expirationDate", testDate)) + matchingDate, _ := time.Parse(dateFormat, TEST_DATE) + nonMatchingDate, _ := time.Parse(dateFormat, TEST_DATE_2) + matchingObject := NewTestObject(map[string]interface{}{"expirationDate": matchingDate}) + nonMatchingObject := NewTestObject(map[string]interface{}{"expirationDate": nonMatchingDate}) + + // When + matchResult := spec.IsSatisfiedBy(matchingObject) + noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) + + // Then + if !matchResult { + t.Error("Expected matching object to satisfy the specification") + } + if noMatchResult { + t.Error("Expected non-matching object to not satisfy the specification") + } +} + +// AND Group Tests + +func TestWhenUsingAndGroupThenMatchesOnlyWhenAllConditionsMet(t *testing.T) { + // Given + spec := specifications.NewSimpleSpecification[*TestObject](specifications.And( + specifications.Eq("status", "active"), + specifications.Gte("score", 80), + specifications.In("role", []interface{}{"admin", "moderator"}), + )) + matchingObject := NewTestObject(map[string]interface{}{ + "status": "active", + "score": 85, + "role": "admin", + }) + nonMatchingObject1 := NewTestObject(map[string]interface{}{ + "status": "inactive", + "score": 85, + "role": "admin", + }) + nonMatchingObject2 := NewTestObject(map[string]interface{}{ + "status": "active", + "score": 70, + "role": "admin", + }) + nonMatchingObject3 := NewTestObject(map[string]interface{}{ + "status": "active", + "score": 85, + "role": "user", + }) + + // When + matchResult := spec.IsSatisfiedBy(matchingObject) + noMatchResult1 := spec.IsSatisfiedBy(nonMatchingObject1) + noMatchResult2 := spec.IsSatisfiedBy(nonMatchingObject2) + noMatchResult3 := spec.IsSatisfiedBy(nonMatchingObject3) + + // Then + if !matchResult { + t.Error("Expected matching object to satisfy the specification") + } + if noMatchResult1 { + t.Error("Expected non-matching object 1 to not satisfy the specification") + } + if noMatchResult2 { + t.Error("Expected non-matching object 2 to not satisfy the specification") + } + if noMatchResult3 { + t.Error("Expected non-matching object 3 to not satisfy the specification") + } +} + +// OR Group Tests + +func TestWhenUsingOrGroupThenMatchesWhenAnyConditionMet(t *testing.T) { + // Given + spec := specifications.NewSimpleSpecification[*TestObject](specifications.Or( + specifications.Eq("role", "admin"), + specifications.Gte("score", 90), + specifications.In("department", []interface{}{"IT", "HR"}), + )) + matchingObject1 := NewTestObject(map[string]interface{}{ + "role": "admin", + "score": 70, + "department": "Finance", + }) + matchingObject2 := NewTestObject(map[string]interface{}{ + "role": "user", + "score": 95, + "department": "Finance", + }) + matchingObject3 := NewTestObject(map[string]interface{}{ + "role": "user", + "score": 70, + "department": "IT", + }) + nonMatchingObject := NewTestObject(map[string]interface{}{ + "role": "user", + "score": 70, + "department": "Finance", + }) + + // When + matchResult1 := spec.IsSatisfiedBy(matchingObject1) + matchResult2 := spec.IsSatisfiedBy(matchingObject2) + matchResult3 := spec.IsSatisfiedBy(matchingObject3) + noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) + + // Then + if !matchResult1 { + t.Error("Expected matching object 1 to satisfy the specification") + } + if !matchResult2 { + t.Error("Expected matching object 2 to satisfy the specification") + } + if !matchResult3 { + t.Error("Expected matching object 3 to satisfy the specification") + } + if noMatchResult { + t.Error("Expected non-matching object to not satisfy the specification") + } +} + +// NOT Group Tests + +func TestWhenUsingNotGroupThenInvertsCondition(t *testing.T) { + // Given + spec := specifications.NewSimpleSpecification[*TestObject](specifications.Not(specifications.Eq("status", "banned"))) + matchingObject := NewTestObject(map[string]interface{}{"status": "active"}) + nonMatchingObject := NewTestObject(map[string]interface{}{"status": "banned"}) + + // When + matchResult := spec.IsSatisfiedBy(matchingObject) + noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) + + // Then + if !matchResult { + t.Error("Expected matching object to satisfy the specification") + } + if noMatchResult { + t.Error("Expected non-matching object to not satisfy the specification") + } +} + +// Complex Nested Groups Tests + +func TestWhenUsingNestedAndOrGroupsThenMatchesCorrectly(t *testing.T) { + // Given + spec := specifications.NewSimpleSpecification[*TestObject](specifications.And( + specifications.Eq("status", "active"), + specifications.Or( + specifications.Gte("score", 80), + specifications.In("role", []interface{}{"admin", "moderator"}), + ), + )) + matchingObject1 := NewTestObject(map[string]interface{}{ + "status": "active", + "score": 85, + "role": "user", + }) + matchingObject2 := NewTestObject(map[string]interface{}{ + "status": "active", + "score": 70, + "role": "admin", + }) + nonMatchingObject := NewTestObject(map[string]interface{}{ + "status": "inactive", + "score": 85, + "role": "user", + }) + + // When + matchResult1 := spec.IsSatisfiedBy(matchingObject1) + matchResult2 := spec.IsSatisfiedBy(matchingObject2) + noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) + + // Then + if !matchResult1 { + t.Error("Expected matching object 1 to satisfy the specification") + } + if !matchResult2 { + t.Error("Expected matching object 2 to satisfy the specification") + } + if noMatchResult { + t.Error("Expected non-matching object to not satisfy the specification") + } +} + +func TestWhenUsingTripleNestedGroupsThenMatchesCorrectly(t *testing.T) { + // Given + spec := specifications.NewSimpleSpecification[*TestObject](specifications.And( + specifications.Eq("active", true), + specifications.Not(specifications.Or( + specifications.Eq("role", "banned"), + specifications.Eq("status", "suspended"), + )), + )) + matchingObject := NewTestObject(map[string]interface{}{ + "active": true, + "role": "user", + "status": "active", + }) + nonMatchingObject1 := NewTestObject(map[string]interface{}{ + "active": false, + "role": "user", + "status": "active", + }) + nonMatchingObject2 := NewTestObject(map[string]interface{}{ + "active": true, + "role": "banned", + "status": "active", + }) + + // When + matchResult := spec.IsSatisfiedBy(matchingObject) + noMatchResult1 := spec.IsSatisfiedBy(nonMatchingObject1) + noMatchResult2 := spec.IsSatisfiedBy(nonMatchingObject2) + + // Then + if !matchResult { + t.Error("Expected matching object to satisfy the specification") + } + if noMatchResult1 { + t.Error("Expected non-matching object 1 to not satisfy the specification") + } + if noMatchResult2 { + t.Error("Expected non-matching object 2 to not satisfy the specification") + } +} + +// Edge Case Tests + +func TestWhenFieldDoesNotExistThenReturnsFalse(t *testing.T) { + // Given + spec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("nonExistentField", "value")) + testObject := NewTestObject(map[string]interface{}{"existingField": "value"}) + + // When + result := spec.IsSatisfiedBy(testObject) + + // Then + if result { + t.Error("Expected object to not satisfy the specification when field doesn't exist") + } +} + +func TestWhenFieldIsNilThenReturnsFalse(t *testing.T) { + // Given + spec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("optionalField", "value")) + testObject := NewTestObject(map[string]interface{}{"optionalField": nil}) + + // When + result := spec.IsSatisfiedBy(testObject) + + // Then + if result { + t.Error("Expected object to not satisfy the specification when field is nil") + } +} + +func TestWhenUsingInvalidDateStringThenFallsBackToRegularComparison(t *testing.T) { + // Given + spec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("dateField", INVALID_DATE)) + testObject := NewTestObject(map[string]interface{}{"dateField": INVALID_DATE}) + + // When + result := spec.IsSatisfiedBy(testObject) + + // Then + if !result { + t.Error("Expected object to satisfy the specification with invalid date string") + } +} + +// Spec Helper Method Tests + +func TestWhenUsingSpecHelpersThenCreatesCorrectSpecification(t *testing.T) { + // Given + eqSpec := specifications.Eq("field", "value") + neqSpec := specifications.Neq("field", "value") + gtSpec := specifications.Gt("field", 10) + gteSpec := specifications.Gte("field", 10) + ltSpec := specifications.Lt("field", 10) + lteSpec := specifications.Lte("field", 10) + inSpec := specifications.In("field", []interface{}{"a", "b"}) + ninSpec := specifications.Nin("field", []interface{}{"a", "b"}) + + // When & Then + if eqSpec.Condition == nil || eqSpec.Condition.Operator != specifications.OP_EQ || eqSpec.Condition.Value != "value" { + t.Error("EQ spec not created correctly") + } + if neqSpec.Condition == nil || neqSpec.Condition.Operator != specifications.OP_NEQ || neqSpec.Condition.Value != "value" { + t.Error("NEQ spec not created correctly") + } + if gtSpec.Condition == nil || gtSpec.Condition.Operator != specifications.OP_GT || gtSpec.Condition.Value != 10 { + t.Error("GT spec not created correctly") + } + if gteSpec.Condition == nil || gteSpec.Condition.Operator != specifications.OP_GTE || gteSpec.Condition.Value != 10 { + t.Error("GTE spec not created correctly") + } + if ltSpec.Condition == nil || ltSpec.Condition.Operator != specifications.OP_LT || ltSpec.Condition.Value != 10 { + t.Error("LT spec not created correctly") + } + if lteSpec.Condition == nil || lteSpec.Condition.Operator != specifications.OP_LTE || lteSpec.Condition.Value != 10 { + t.Error("LTE spec not created correctly") + } + if inSpec.Condition == nil || inSpec.Condition.Operator != specifications.OP_IN { + t.Error("IN spec not created correctly") + } + if ninSpec.Condition == nil || ninSpec.Condition.Operator != specifications.OP_NIN { + t.Error("NIN spec not created correctly") + } +} + +func TestWhenUsingLogicalGroupHelpersThenCreatesCorrectSpecification(t *testing.T) { + // Given + andSpec := specifications.And(specifications.Eq("a", 1), specifications.Eq("b", 2)) + orSpec := specifications.Or(specifications.Eq("a", 1), specifications.Eq("b", 2)) + notSpec := specifications.Not(specifications.Eq("a", 1)) + + // When & Then + if andSpec.LogicalGroup == nil || andSpec.LogicalGroup.Operator != specifications.GROUP_AND || len(andSpec.LogicalGroup.Conditions) != 2 { + t.Error("AND spec not created correctly") + } + if orSpec.LogicalGroup == nil || orSpec.LogicalGroup.Operator != specifications.GROUP_OR || len(orSpec.LogicalGroup.Conditions) != 2 { + t.Error("OR spec not created correctly") + } + if notSpec.LogicalGroup == nil || notSpec.LogicalGroup.Operator != specifications.GROUP_NOT || notSpec.LogicalGroup.Spec == nil { + t.Error("NOT spec not created correctly") + } +} + +func TestGetSpecReturnsOriginalSpecification(t *testing.T) { + // Given + originalSpec := specifications.Eq("field", "value") + specification := specifications.NewSimpleSpecification[*TestObject](originalSpec) + + // When + retrievedSpec := specification.GetSpec() + + // Then + if retrievedSpec != originalSpec { + t.Error("Expected retrieved spec to be the same as original spec") + } +} + +func TestGetConditionsReturnsAllConditions(t *testing.T) { + // Given + spec := specifications.NewSimpleSpecification[*TestObject](specifications.And( + specifications.Eq("status", "active"), + specifications.Gte("score", 80), + )) + + // When + conditions := spec.GetConditions() + + // Then + if len(conditions) != 2 { + t.Error("Expected 2 conditions") + } + + // Check that both conditions are present + foundStatus := false + foundScore := false + for _, cond := range conditions { + if cond.Field == "status" && cond.Operator == specifications.OP_EQ && cond.Value == "active" { + foundStatus = true + } + if cond.Field == "score" && cond.Operator == specifications.OP_GTE && cond.Value == 80 { + foundScore = true + } + } + + if !foundStatus { + t.Error("Expected status condition to be found") + } + if !foundScore { + t.Error("Expected score condition to be found") + } +} + +// Composite Specification Tests + +func TestCompositeSpecificationAndOperation(t *testing.T) { + // Given + leftSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("status", "active")) + rightSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Gte("score", 80)) + compositeSpec := leftSpec.And(rightSpec) + + matchingObject := NewTestObject(map[string]interface{}{ + "status": "active", + "score": 85, + }) + nonMatchingObject := NewTestObject(map[string]interface{}{ + "status": "inactive", + "score": 85, + }) + + // When + matchResult := compositeSpec.IsSatisfiedBy(matchingObject) + noMatchResult := compositeSpec.IsSatisfiedBy(nonMatchingObject) + + // Then + if !matchResult { + t.Error("Expected matching object to satisfy the composite specification") + } + if noMatchResult { + t.Error("Expected non-matching object to not satisfy the composite specification") + } +} + +func TestCompositeSpecificationOrOperation(t *testing.T) { + // Given + leftSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("role", "admin")) + rightSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Gte("score", 90)) + compositeSpec := leftSpec.Or(rightSpec) + + matchingObject1 := NewTestObject(map[string]interface{}{ + "role": "admin", + "score": 70, + }) + matchingObject2 := NewTestObject(map[string]interface{}{ + "role": "user", + "score": 95, + }) + nonMatchingObject := NewTestObject(map[string]interface{}{ + "role": "user", + "score": 70, + }) + + // When + matchResult1 := compositeSpec.IsSatisfiedBy(matchingObject1) + matchResult2 := compositeSpec.IsSatisfiedBy(matchingObject2) + noMatchResult := compositeSpec.IsSatisfiedBy(nonMatchingObject) + + // Then + if !matchResult1 { + t.Error("Expected matching object 1 to satisfy the composite specification") + } + if !matchResult2 { + t.Error("Expected matching object 2 to satisfy the composite specification") + } + if noMatchResult { + t.Error("Expected non-matching object to not satisfy the composite specification") + } +} + +func TestCompositeSpecificationNotOperation(t *testing.T) { + // Given + baseSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("status", "banned")) + compositeSpec := baseSpec.Not() + + matchingObject := NewTestObject(map[string]interface{}{"status": "active"}) + nonMatchingObject := NewTestObject(map[string]interface{}{"status": "banned"}) + + // When + matchResult := compositeSpec.IsSatisfiedBy(matchingObject) + noMatchResult := compositeSpec.IsSatisfiedBy(nonMatchingObject) + + // Then + if !matchResult { + t.Error("Expected matching object to satisfy the NOT specification") + } + if noMatchResult { + t.Error("Expected non-matching object to not satisfy the NOT specification") + } +} + +func TestCompositeSpecificationGetConditions(t *testing.T) { + // Given + leftSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("status", "active")) + rightSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Gte("score", 80)) + compositeSpec := leftSpec.And(rightSpec) + + // When + conditions := compositeSpec.GetConditions() + + // Then + if len(conditions) != 2 { + t.Error("Expected 2 conditions from composite specification") + } + + // Check that both conditions are present + foundStatus := false + foundScore := false + for _, cond := range conditions { + if cond.Field == "status" && cond.Operator == specifications.OP_EQ && cond.Value == "active" { + foundStatus = true + } + if cond.Field == "score" && cond.Operator == specifications.OP_GTE && cond.Value == 80 { + foundScore = true + } + } + + if !foundStatus { + t.Error("Expected status condition to be found") + } + if !foundScore { + t.Error("Expected score condition to be found") + } +} + +func TestCompositeSpecificationGetSpecReturnsNil(t *testing.T) { + // Given + leftSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("status", "active")) + rightSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Gte("score", 80)) + compositeSpec := leftSpec.And(rightSpec) + + // When + spec := compositeSpec.GetSpec() + + // Then + if spec != nil { + t.Error("Expected composite specification to return nil for GetSpec") + } +} diff --git a/golang/tests/unit/test_utils.go b/golang/tests/unit/test_utils.go new file mode 100644 index 0000000..8498247 --- /dev/null +++ b/golang/tests/unit/test_utils.go @@ -0,0 +1,99 @@ +package unit + +import ( + "context" + "time" + + "autostore/internal/domain/entities" + "autostore/internal/domain/specifications" + "autostore/internal/domain/value_objects" +) + +// Common constants for tests +const ( + mockedNow = "2023-01-01 12:00:00" + notExpiredDate = "2023-01-02 12:00:00" + expiredDate = "2022-12-31 12:00:00" + itemName = "Test Item" + orderURL = "http://example.com/order" + userID = "550e8400-e29b-41d4-a716-446655440000" // Valid UUID + dateFormat = "2006-01-02 15:04:05" +) + +// Mock implementations shared across tests +type mockItemRepository struct { + saveFunc func(ctx context.Context, item *entities.ItemEntity) error + findWhereFunc func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) + deleteFunc func(ctx context.Context, id value_objects.ItemID) error +} + +func (m *mockItemRepository) Save(ctx context.Context, item *entities.ItemEntity) error { + if m.saveFunc != nil { + return m.saveFunc(ctx, item) + } + return nil +} + +func (m *mockItemRepository) FindByID(ctx context.Context, id value_objects.ItemID) (*entities.ItemEntity, error) { + return nil, nil +} + +func (m *mockItemRepository) FindByUserID(ctx context.Context, userID value_objects.UserID) ([]*entities.ItemEntity, error) { + return nil, nil +} + +func (m *mockItemRepository) FindWhere(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) { + if m.findWhereFunc != nil { + return m.findWhereFunc(ctx, spec) + } + return nil, nil +} + +func (m *mockItemRepository) Delete(ctx context.Context, id value_objects.ItemID) error { + if m.deleteFunc != nil { + return m.deleteFunc(ctx, id) + } + return nil +} + +func (m *mockItemRepository) Exists(ctx context.Context, id value_objects.ItemID) (bool, error) { + return false, nil +} + +type mockOrderService struct { + orderItemFunc func(ctx context.Context, item *entities.ItemEntity) error +} + +func (m *mockOrderService) OrderItem(ctx context.Context, item *entities.ItemEntity) error { + if m.orderItemFunc != nil { + return m.orderItemFunc(ctx, item) + } + return nil +} + +type mockTimeProvider struct { + nowFunc func() time.Time +} + +func (m *mockTimeProvider) Now() time.Time { + if m.nowFunc != nil { + return m.nowFunc() + } + return time.Now() +} + +type mockLogger struct { + infoLogs []string + errorLogs []string +} + +func (m *mockLogger) Info(ctx context.Context, msg string, fields ...interface{}) { + m.infoLogs = append(m.infoLogs, msg) +} + +func (m *mockLogger) Error(ctx context.Context, msg string, fields ...interface{}) { + m.errorLogs = append(m.errorLogs, msg) +} + +func (m *mockLogger) Debug(ctx context.Context, msg string, fields ...interface{}) {} +func (m *mockLogger) Warn(ctx context.Context, msg string, fields ...interface{}) {} \ No newline at end of file