11 changed files with 2441 additions and 578 deletions
@ -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 |
## Core Components |
||||||
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 |
|
||||||
|
|
||||||
## How It Works in Our Go Implementation |
### 1. Basic Data Structures (`condition_spec.go`) |
||||||
|
|
||||||
### Core Components |
#### `Condition` |
||||||
|
Represents a single comparison operation: |
||||||
#### 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 |
|
||||||
```go |
```go |
||||||
type Condition struct { |
type Condition struct { |
||||||
Field string // Field name to check (e.g., "expirationDate") |
Field string // Field name (e.g., "age", "name") |
||||||
Operator string // Comparison operator (e.g., "<=", "==", ">") |
Operator string // Comparison operator (e.g., "=", ">", "IN") |
||||||
Value interface{} // Value to compare against |
Value interface{} // Value to compare against |
||||||
} |
} |
||||||
``` |
``` |
||||||
|
|
||||||
#### 4. Spec Structure - The Building Block |
#### `LogicalGroup` |
||||||
|
Combines multiple conditions with logical operators: |
||||||
```go |
```go |
||||||
type Spec struct { |
type LogicalGroup struct { |
||||||
Condition *Condition // Single condition |
Operator string // "AND", "OR", or "NOT" |
||||||
LogicalGroup *LogicalGroup // Group of conditions (AND/OR/NOT) |
Conditions []Condition // Simple conditions |
||||||
|
Spec *Spec // Nested specification for complex logic |
||||||
} |
} |
||||||
``` |
``` |
||||||
|
|
||||||
### How It Evaluates Conditions |
#### `Spec` |
||||||
|
The main specification container (can hold either a single condition or a logical group): |
||||||
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 |
```go |
||||||
func (s *SimpleSpecification[T]) getFieldValue(candidate T, fieldName string) (interface{}, error) { |
type Spec struct { |
||||||
// 1. Try "GetFieldName" method first |
Condition *Condition // For simple conditions |
||||||
getterName := "Get" + strings.Title(fieldName) |
LogicalGroup *LogicalGroup // For complex logic |
||||||
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 |
|
||||||
```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", |
|
||||||
} |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
#### 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 |
|
||||||
|
|
||||||
```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) |
|
||||||
} |
|
||||||
} |
} |
||||||
``` |
``` |
||||||
|
|
||||||
### 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 |
```go |
||||||
// Simple conditions |
// Simple conditions |
||||||
spec := Eq("name", "Apple") // name == "Apple" |
userSpec := Eq("name", "John") // name = "John" |
||||||
spec := Gt("price", 10.0) // price > 10.0 |
ageSpec := Gt("age", 18) // age > 18 |
||||||
spec := Lte("expirationDate", time) // expirationDate <= time |
roleSpec := In("role", []interface{}{"admin", "moderator"}) // role IN ("admin", "moderator") |
||||||
|
|
||||||
// Logical combinations |
// Logical combinations |
||||||
spec := And( |
complexSpec := And( |
||||||
Eq("status", "active"), |
Eq("status", "active"), |
||||||
Or( |
Or( |
||||||
Gt("score", 80), |
Gt("age", 21), |
||||||
In("role", []interface{}{"admin", "moderator"}) |
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 |
## How It Works |
||||||
func (s *ItemExpirationSpec) IsExpired(item *entities.ItemEntity, currentTime time.Time) bool { |
|
||||||
return s.GetSpec(currentTime).IsSatisfiedBy(item) |
|
||||||
} |
|
||||||
|
|
||||||
func (s *ItemExpirationSpec) GetSpec(currentTime time.Time) Specification[*entities.ItemEntity] { |
### 1. Building Specifications |
||||||
// Create condition: expirationDate <= currentTime |
|
||||||
spec := Lte("expirationDate", currentTime) |
```go |
||||||
return NewSimpleSpecification[*entities.ItemEntity](spec) |
// Create a specification for active users over 18 |
||||||
} |
spec := And( |
||||||
|
Eq("status", "active"), |
||||||
|
Gt("age", 18), |
||||||
|
) |
||||||
``` |
``` |
||||||
|
|
||||||
### Repository Integration |
### 2. Evaluating Specifications |
||||||
|
|
||||||
The specification integrates seamlessly with repositories: |
The `SimpleSpecification` uses reflection to evaluate conditions against Go objects: |
||||||
|
|
||||||
```go |
```go |
||||||
func (r *FileItemRepository) FindWhere(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) { |
user := &User{Name: "John", Age: 25, Status: "active"} |
||||||
var filteredItems []*entities.ItemEntity |
specification := NewSimpleSpecification[*User](spec) |
||||||
for _, item := range items { |
isMatch := specification.IsSatisfiedBy(user) // true |
||||||
if spec.IsSatisfiedBy(item) { |
|
||||||
filteredItems = append(filteredItems, item) |
|
||||||
} |
|
||||||
} |
|
||||||
return filteredItems, nil |
|
||||||
} |
|
||||||
``` |
``` |
||||||
|
|
||||||
## Key Benefits of This Implementation |
### 3. Field Access |
||||||
|
|
||||||
### 1. Type Safety |
|
||||||
Using generics (`Specification[T any]`) ensures type safety at compile time. |
|
||||||
|
|
||||||
### 2. Flexibility |
The implementation looks for field values in this order: |
||||||
- Supports different field access patterns (getters, direct fields) |
1. `GetFieldName()` method (e.g., `GetAge()`) |
||||||
- Handles various data types (strings, numbers, dates, etc.) |
2. `FieldName()` method (e.g., `Age()`) |
||||||
- Allows complex nested conditions |
3. Exported struct field |
||||||
|
|
||||||
### 3. No Panic Zone |
### 4. Type Comparisons |
||||||
This implementation gracefully handles mixed types using `CompositeSpecification`. |
|
||||||
|
|
||||||
### 4. Reflection Safety |
The system handles different data types intelligently: |
||||||
The field resolution respects Go's visibility rules and provides clear error messages when fields can't be accessed. |
- **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 |
||||||
|
|
||||||
### 5. Performance |
## Examples |
||||||
- Caches reflection information when possible |
|
||||||
- Short-circuits evaluation (stops at first false in AND, first true in OR) |
|
||||||
- Minimal overhead for simple conditions |
|
||||||
|
|
||||||
## Common Patterns and Examples |
### Simple Usage |
||||||
|
|
||||||
### Checking Expired Items |
|
||||||
```go |
```go |
||||||
expirationSpec := NewItemExpirationSpec() |
// Find all active users |
||||||
isExpired := expirationSpec.IsExpired(item, time.Now()) |
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) |
||||||
|
} |
||||||
|
} |
||||||
``` |
``` |
||||||
|
|
||||||
### Finding Active Users |
### Complex Logic |
||||||
```go |
```go |
||||||
activeUserSpec := And( |
// Find admin users OR users with high scores |
||||||
|
complexSpec := Or( |
||||||
|
Eq("role", "admin"), |
||||||
|
And( |
||||||
|
Gt("score", 90), |
||||||
Eq("status", "active"), |
Eq("status", "active"), |
||||||
Gte("lastLogin", thirtyDaysAgo), |
), |
||||||
Not(Eq("role", "banned")) |
|
||||||
) |
) |
||||||
``` |
``` |
||||||
|
|
||||||
### Complex Business Rules |
### Time Comparisons |
||||||
```go |
```go |
||||||
premiumCustomerSpec := Or( |
// Find expired items |
||||||
And( |
expiredSpec := Lte("expirationDate", time.Now()) |
||||||
Eq("subscriptionType", "premium"), |
|
||||||
Gte("subscriptionEndDate", time.Now()), |
|
||||||
), |
|
||||||
And( |
|
||||||
Eq("totalPurchases", 1000), |
|
||||||
Gte("customerSince", oneYearAgo), |
|
||||||
), |
|
||||||
) |
|
||||||
``` |
``` |
||||||
|
|
||||||
## Troubleshooting Common Issues |
## Key Benefits |
||||||
|
|
||||||
### 1. Field Not Found |
|
||||||
**Error**: `field expirationDate not found or not accessible` |
|
||||||
**Solution**: Ensure your entity has a getter method like `ExpirationDate()` or `GetExpirationDate()` |
|
||||||
|
|
||||||
### 2. Type Mismatch |
1. **Type Safety**: Compile-time checking with generics |
||||||
**Error**: Comparison fails between different types |
2. **Readability**: Business logic expressed in readable Go code |
||||||
**Solution**: The system tries to convert types automatically, but ensure your condition values match the expected field types |
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 |
## Performance Considerations |
||||||
**Issue**: Reflection is slower than direct field access |
|
||||||
**Solution**: For performance-critical paths, consider implementing custom specifications or caching results |
|
||||||
|
|
||||||
## 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 |
## Common Patterns |
||||||
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 |
|
||||||
|
|
||||||
## 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()` |
### Dynamic Query Building |
||||||
- **Same condition structure**: `[field, operator, value]` |
```go |
||||||
- **Same logical operations**: AND, OR, NOT combinations |
func BuildUserSearchSpec(filters UserFilters) *Spec { |
||||||
- **Same usage patterns**: Build specs, then call `IsSatisfiedBy()` |
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. |
return And(conditions...) |
||||||
|
} |
||||||
@ -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()) |
||||||
|
} |
||||||
@ -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") |
||||||
|
} |
||||||
|
} |
||||||
@ -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)) |
||||||
|
} |
||||||
|
} |
||||||
@ -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") |
||||||
|
} |
||||||
|
} |
||||||
@ -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{}) {} |
||||||
Loading…
Reference in new issue