Browse Source

Golang implementation cleanup and refactoring

golang-init
chodak166 3 months ago
parent
commit
31f16d697a
  1. 2
      LICENSE
  2. 12
      README.md
  3. 315
      golang/SPEC_DETAILS.md
  4. 4
      golang/docker/Dockerfile
  5. 200
      golang/internal/domain/specifications/condition_spec.go
  6. 466
      golang/internal/domain/specifications/simple_specification.go
  7. 348
      golang/tests/integration/file_item_repository_test.go
  8. 352
      golang/tests/unit/add_item_command_test.go
  9. 293
      golang/tests/unit/handle_expired_items_command_test.go
  10. 904
      golang/tests/unit/specification_test.go
  11. 99
      golang/tests/unit/test_utils.go

2
LICENSE

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) <year> <copyright holders> Copyright (c) 2025 chodak166
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

12
README.md

@ -65,7 +65,7 @@ AutoStore/
│ │ │ ├── User │ │ │ ├── User
│ │ │ └── Item │ │ │ └── Item
│ │ └── Specifications/ │ │ └── Specifications/
│ │ └── ItemExpirationSpec │ │ └── ItemExpirationSpec # domain knowledge (from domain experts)
│ ├── Application/ │ ├── Application/
│ │ ├── Commands/ # use cases │ │ ├── Commands/ # use cases
│ │ │ ├── Login │ │ │ ├── Login
@ -79,7 +79,7 @@ AutoStore/
│ │ │ ├── IUserRepository │ │ │ ├── IUserRepository
│ │ │ ├── IItemRepository │ │ │ ├── IItemRepository
│ │ │ ├── IAuthService │ │ │ ├── IAuthService
│ │ │ └── IClock │ │ │ └── IDateProvider
│ │ ├── Dto/ # data transfer objects (fields mappings, validation, etc.) │ │ ├── Dto/ # data transfer objects (fields mappings, validation, etc.)
│ │ └── Services/ │ │ └── Services/
│ │ ├── UserInitializationService │ │ ├── UserInitializationService
@ -91,11 +91,12 @@ AutoStore/
│ │ ├── Adapters/ │ │ ├── Adapters/
│ │ │ ├── JwtAuthAdapter │ │ │ ├── JwtAuthAdapter
│ │ │ ├── OrderUrlHttpClient │ │ │ ├── OrderUrlHttpClient
│ │ │ ├── SystemClock │ │ │ ├── SystemDateProvider
│ │ │ └── <... some extern lib adapters> │ │ │ └── <... some extern lib adapters>
│ │ └── Helpers/ │ │ └── Helpers/
│ │ └── <... DRY helpers> │ │ └── <... DRY helpers>
│ └── WebApi/ # presentation (controllers, middlewares, etc.) │ ├── Cli # presentation, optional command line use case caller
│ └── WebApi/ # presentation, REST (controllers, middlewares, etc.)
│ ├── Controllers/ │ ├── Controllers/
│ │ ├── StoreController │ │ ├── StoreController
│ │ └── UserController │ │ └── UserController
@ -125,7 +126,7 @@ Ideally, each implementation should include a `<impl>/docker/docker-compose.yml`
```bash ```bash
docker compose up --build docker compose up --build
``` ```
to build and run the application. to build, test and run the application.
Otherwise, please provide a `<impl>/README.md` file with setup and running instructions. Otherwise, please provide a `<impl>/README.md` file with setup and running instructions.
@ -144,3 +145,4 @@ Here's a summary of example API endpoints:
| `/items/{id}` | DELETE | Delete item | | `/items/{id}` | DELETE | Delete item |
Suggested base URL is `http://localhost:50080/api/v1/`. Suggested base URL is `http://localhost:50080/api/v1/`.

315
golang/SPEC_DETAILS.md

@ -1,262 +1,187 @@
# Specification Pattern in Go - Detailed Explanation # Specification Pattern Implementation
## What is the Specification Pattern? This document explains the Specification pattern implementation for building dynamic queries and conditions that can be used across different layers of the application.
The Specification Pattern is a design pattern that allows you to encapsulate business rules and domain logic in reusable, composable objects. Instead of scattering business rules throughout your codebase, you create specification objects that represent individual rules that can be combined using logical operations (AND, OR, NOT). ## Overview
## Why Use It? The Specification pattern allows you to encapsulate business logic for filtering and querying data. Instead of writing SQL queries directly, you build specifications using Go code that can later be converted to SQL, used for in-memory filtering, or other purposes.
1. **Single Source of Truth**: Business rules are defined in one place ## 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`
```go Combines multiple conditions with logical operators:
type Spec struct {
Condition *Condition // Single condition
LogicalGroup *LogicalGroup // Group of conditions (AND/OR/NOT)
}
```
### How It Evaluates Conditions
The magic happens in the `getFieldValue` method. When you specify a condition like `Lte("expirationDate", currentTime)`, the system needs to:
1. **Find the field**: Look for a getter method like `ExpirationDate()` or `GetExpirationDate()`
2. **Extract the value**: Call the method to get the actual value
3. **Compare values**: Use the appropriate comparison based on the operator
#### Field Resolution Strategy
```go
func (s *SimpleSpecification[T]) getFieldValue(candidate T, fieldName string) (interface{}, error) {
// 1. Try "GetFieldName" method first
getterName := "Get" + strings.Title(fieldName)
method := v.MethodByName(getterName)
// 2. Try field name as method (e.g., "ExpirationDate")
method = v.MethodByName(fieldName)
// 3. Try direct field access (for exported fields only)
field := v.FieldByName(fieldName)
if field.IsValid() && field.CanInterface() {
return field.Interface(), nil
}
}
```
This approach handles different naming conventions and respects Go's visibility rules.
### Logical Operations
#### AND Operation
```go ```go
func (s *SimpleSpecification[T]) And(other Specification[T]) Specification[T] { type LogicalGroup struct {
if otherSpec, ok := other.(*SimpleSpecification[T]); ok { Operator string // "AND", "OR", or "NOT"
// Both are SimpleSpecifications - combine their specs Conditions []Condition // Simple conditions
return NewSimpleSpecification[T](SpecBuilder.And(s.spec, otherSpec.spec)) Spec *Spec // Nested specification for complex logic
}
// Different types - create a composite specification
return &CompositeSpecification[T]{
left: s,
right: other,
op: "AND",
}
} }
``` ```
#### CompositeSpecification - Handling Mixed Types #### `Spec`
When you try to combine different specification types, instead of panicking, we create a `CompositeSpecification` that: The main specification container (can hold either a single condition or a logical group):
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 ```go
func (c *CompositeSpecification[T]) IsSatisfiedBy(candidate T) bool { type Spec struct {
switch c.op { Condition *Condition // For simple conditions
case "AND": LogicalGroup *LogicalGroup // For complex logic
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
- Supports different field access patterns (getters, direct fields)
- Handles various data types (strings, numbers, dates, etc.)
- Allows complex nested conditions
### 3. No Panic Zone The implementation looks for field values in this order:
This implementation gracefully handles mixed types using `CompositeSpecification`. 1. `GetFieldName()` method (e.g., `GetAge()`)
2. `FieldName()` method (e.g., `Age()`)
3. Exported struct field
### 4. Reflection Safety ### 4. Type Comparisons
The field resolution respects Go's visibility rules and provides clear error messages when fields can't be accessed.
### 5. Performance The system handles different data types intelligently:
- Caches reflection information when possible - **Numbers**: Direct comparison with type conversion
- Short-circuits evaluation (stops at first false in AND, first true in OR) - **Strings**: Lexicographic comparison
- Minimal overhead for simple conditions - **Time**: Special handling for `time.Time` and objects with `Time()` methods
- **Collections**: `IN` and `NOT IN` operations
- **Nil values**: Proper null handling
## Common Patterns and Examples ## Examples
### Checking Expired Items ### Simple Usage
```go ```go
expirationSpec := NewItemExpirationSpec() // Find all active users
isExpired := expirationSpec.IsExpired(item, time.Now()) activeSpec := Eq("status", "active")
``` spec := NewSimpleSpecification[*User](activeSpec)
### Finding Active Users users := []User{{Status: "active"}, {Status: "inactive"}}
```go for _, user := range users {
activeUserSpec := And( if spec.IsSatisfiedBy(&user) {
Eq("status", "active"), fmt.Println("Active user:", user.Name)
Gte("lastLogin", thirtyDaysAgo), }
Not(Eq("role", "banned")) }
)
``` ```
### Complex Business Rules ### Complex Logic
```go ```go
premiumCustomerSpec := Or( // Find admin users OR users with high scores
complexSpec := Or(
Eq("role", "admin"),
And( And(
Eq("subscriptionType", "premium"), Gt("score", 90),
Gte("subscriptionEndDate", time.Now()), Eq("status", "active"),
),
And(
Eq("totalPurchases", 1000),
Gte("customerSince", oneYearAgo),
), ),
) )
``` ```
## Troubleshooting Common Issues ### Time Comparisons
```go
// Find expired items
expiredSpec := Lte("expirationDate", time.Now())
```
### 1. Field Not Found ## Key Benefits
**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...)
}

4
golang/docker/Dockerfile

@ -12,6 +12,10 @@ COPY . .
# Generate go.sum # Generate go.sum
RUN go mod tidy RUN go mod tidy
# Run tests
RUN go test ./tests/unit -v && \
go test ./tests/integration -v
# Build the application # Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd

200
golang/internal/domain/specifications/condition_spec.go

@ -1,75 +1,110 @@
package specifications package specifications
import ( const (
GROUP_AND = "AND"
GROUP_OR = "OR"
GROUP_NOT = "NOT"
)
const (
OP_EQ = "="
OP_NEQ = "!="
OP_GT = ">"
OP_GTE = ">="
OP_LT = "<"
OP_LTE = "<="
OP_IN = "IN"
OP_NIN = "NOT IN"
) )
// Condition represents a single condition in the specification
type Condition struct { type Condition struct {
Field string `json:"field"` Field string `json:"field"`
Operator string `json:"operator"` Operator string `json:"operator"`
Value interface{} `json:"value"` Value interface{} `json:"value"`
} }
// LogicalGroup represents a logical group of conditions (AND, OR, NOT)
type LogicalGroup struct { type LogicalGroup struct {
Operator string `json:"operator"` // "AND", "OR", "NOT" Operator string `json:"operator"`
Conditions []Condition `json:"conditions"` // For AND/OR Conditions []Condition `json:"conditions"`
Spec *Spec `json:"spec"` // For NOT Spec *Spec `json:"spec"`
} }
// Spec represents a specification that can be either a single condition or a logical group
type Spec struct { type Spec struct {
Condition *Condition `json:"condition,omitempty"` Condition *Condition `json:"condition,omitempty"`
LogicalGroup *LogicalGroup `json:"logicalGroup,omitempty"` LogicalGroup *LogicalGroup `json:"logicalGroup,omitempty"`
} }
// SpecHelper provides methods to build specifications func And(conditions ...*Spec) *Spec {
type SpecHelper struct{} if len(conditions) == 0 {
return nil
// NewSpecHelper creates a new SpecHelper instance }
func NewSpecHelper() *SpecHelper { if len(conditions) == 1 {
return &SpecHelper{} return conditions[0]
} }
// Logical group operators var flatConditions []Condition
const ( var nestedSpecs []*Spec
GROUP_AND = "AND"
GROUP_OR = "OR"
GROUP_NOT = "NOT"
)
// Comparison operators for _, spec := range conditions {
const ( if spec.Condition != nil {
OP_EQ = "=" flatConditions = append(flatConditions, *spec.Condition)
OP_NEQ = "!=" } else {
OP_GT = ">" nestedSpecs = append(nestedSpecs, spec)
OP_GTE = ">=" }
OP_LT = "<" }
OP_LTE = "<="
OP_IN = "IN"
OP_NIN = "NOT IN"
)
// Logical group helpers result := &Spec{
func (h *SpecHelper) And(conditions ...*Spec) *Spec {
return &Spec{
LogicalGroup: &LogicalGroup{ LogicalGroup: &LogicalGroup{
Operator: GROUP_AND, Operator: GROUP_AND,
Conditions: h.extractConditions(conditions), Conditions: flatConditions,
}, },
} }
if len(nestedSpecs) == 1 {
result.LogicalGroup.Spec = nestedSpecs[0]
} else if len(nestedSpecs) > 1 {
result.LogicalGroup.Spec = And(nestedSpecs...)
}
return result
} }
func (h *SpecHelper) Or(conditions ...*Spec) *Spec { func Or(conditions ...*Spec) *Spec {
return &Spec{ if len(conditions) == 0 {
return nil
}
if len(conditions) == 1 {
return conditions[0]
}
var flatConditions []Condition
var nestedSpecs []*Spec
for _, spec := range conditions {
if spec.Condition != nil {
flatConditions = append(flatConditions, *spec.Condition)
} else {
nestedSpecs = append(nestedSpecs, spec)
}
}
result := &Spec{
LogicalGroup: &LogicalGroup{ LogicalGroup: &LogicalGroup{
Operator: GROUP_OR, Operator: GROUP_OR,
Conditions: h.extractConditions(conditions), Conditions: flatConditions,
}, },
} }
if len(nestedSpecs) == 1 {
result.LogicalGroup.Spec = nestedSpecs[0]
} else if len(nestedSpecs) > 1 {
result.LogicalGroup.Spec = Or(nestedSpecs...)
}
return result
} }
func (h *SpecHelper) Not(condition *Spec) *Spec { func Not(condition *Spec) *Spec {
return &Spec{ return &Spec{
LogicalGroup: &LogicalGroup{ LogicalGroup: &LogicalGroup{
Operator: GROUP_NOT, Operator: GROUP_NOT,
@ -78,8 +113,7 @@ func (h *SpecHelper) Not(condition *Spec) *Spec {
} }
} }
// Condition helpers func Eq(field string, value interface{}) *Spec {
func (h *SpecHelper) Eq(field string, value interface{}) *Spec {
return &Spec{ return &Spec{
Condition: &Condition{ Condition: &Condition{
Field: field, Field: field,
@ -89,7 +123,7 @@ func (h *SpecHelper) Eq(field string, value interface{}) *Spec {
} }
} }
func (h *SpecHelper) Neq(field string, value interface{}) *Spec { func Neq(field string, value interface{}) *Spec {
return &Spec{ return &Spec{
Condition: &Condition{ Condition: &Condition{
Field: field, Field: field,
@ -99,7 +133,7 @@ func (h *SpecHelper) Neq(field string, value interface{}) *Spec {
} }
} }
func (h *SpecHelper) Gt(field string, value interface{}) *Spec { func Gt(field string, value interface{}) *Spec {
return &Spec{ return &Spec{
Condition: &Condition{ Condition: &Condition{
Field: field, Field: field,
@ -109,7 +143,7 @@ func (h *SpecHelper) Gt(field string, value interface{}) *Spec {
} }
} }
func (h *SpecHelper) Gte(field string, value interface{}) *Spec { func Gte(field string, value interface{}) *Spec {
return &Spec{ return &Spec{
Condition: &Condition{ Condition: &Condition{
Field: field, Field: field,
@ -119,7 +153,7 @@ func (h *SpecHelper) Gte(field string, value interface{}) *Spec {
} }
} }
func (h *SpecHelper) Lt(field string, value interface{}) *Spec { func Lt(field string, value interface{}) *Spec {
return &Spec{ return &Spec{
Condition: &Condition{ Condition: &Condition{
Field: field, Field: field,
@ -129,7 +163,7 @@ func (h *SpecHelper) Lt(field string, value interface{}) *Spec {
} }
} }
func (h *SpecHelper) Lte(field string, value interface{}) *Spec { func Lte(field string, value interface{}) *Spec {
return &Spec{ return &Spec{
Condition: &Condition{ Condition: &Condition{
Field: field, Field: field,
@ -139,7 +173,7 @@ func (h *SpecHelper) Lte(field string, value interface{}) *Spec {
} }
} }
func (h *SpecHelper) In(field string, values []interface{}) *Spec { func In(field string, values []interface{}) *Spec {
return &Spec{ return &Spec{
Condition: &Condition{ Condition: &Condition{
Field: field, Field: field,
@ -149,7 +183,7 @@ func (h *SpecHelper) In(field string, values []interface{}) *Spec {
} }
} }
func (h *SpecHelper) Nin(field string, values []interface{}) *Spec { func Nin(field string, values []interface{}) *Spec {
return &Spec{ return &Spec{
Condition: &Condition{ Condition: &Condition{
Field: field, Field: field,
@ -159,26 +193,13 @@ func (h *SpecHelper) Nin(field string, values []interface{}) *Spec {
} }
} }
// extractConditions extracts conditions from specs for logical groups func GetConditions(spec *Spec) []Condition {
func (h *SpecHelper) extractConditions(specs []*Spec) []Condition {
var conditions []Condition
for _, spec := range specs {
if spec.Condition != nil {
conditions = append(conditions, *spec.Condition)
}
}
return conditions
}
// GetConditions returns all conditions from a spec (flattened)
func (h *SpecHelper) GetConditions(spec *Spec) []Condition {
var conditions []Condition var conditions []Condition
h.flattenConditions(spec, &conditions) flattenConditions(spec, &conditions)
return conditions return conditions
} }
// flattenConditions recursively flattens conditions from a spec func flattenConditions(spec *Spec, conditions *[]Condition) {
func (h *SpecHelper) flattenConditions(spec *Spec, conditions *[]Condition) {
if spec == nil { if spec == nil {
return return
} }
@ -188,60 +209,11 @@ func (h *SpecHelper) flattenConditions(spec *Spec, conditions *[]Condition) {
} }
if spec.LogicalGroup != nil { if spec.LogicalGroup != nil {
if spec.LogicalGroup.Operator == GROUP_AND || spec.LogicalGroup.Operator == GROUP_OR {
for _, cond := range spec.LogicalGroup.Conditions { for _, cond := range spec.LogicalGroup.Conditions {
*conditions = append(*conditions, cond) *conditions = append(*conditions, cond)
} }
} else if spec.LogicalGroup.Operator == GROUP_NOT && spec.LogicalGroup.Spec != nil { if spec.LogicalGroup.Spec != nil {
h.flattenConditions(spec.LogicalGroup.Spec, conditions) flattenConditions(spec.LogicalGroup.Spec, conditions)
}
}
}
// Global Spec helper instance
var SpecBuilder = NewSpecHelper()
// Convenience functions that use the global Spec instance
func And(conditions ...*Spec) *Spec {
return SpecBuilder.And(conditions...)
}
func Or(conditions ...*Spec) *Spec {
return SpecBuilder.Or(conditions...)
}
func Not(condition *Spec) *Spec {
return SpecBuilder.Not(condition)
}
func Eq(field string, value interface{}) *Spec {
return SpecBuilder.Eq(field, value)
}
func Neq(field string, value interface{}) *Spec {
return SpecBuilder.Neq(field, value)
}
func Gt(field string, value interface{}) *Spec {
return SpecBuilder.Gt(field, value)
}
func Gte(field string, value interface{}) *Spec {
return SpecBuilder.Gte(field, value)
} }
func Lt(field string, value interface{}) *Spec {
return SpecBuilder.Lt(field, value)
} }
func Lte(field string, value interface{}) *Spec {
return SpecBuilder.Lte(field, value)
}
func In(field string, values []interface{}) *Spec {
return SpecBuilder.In(field, values)
}
func Nin(field string, values []interface{}) *Spec {
return SpecBuilder.Nin(field, values)
} }

466
golang/internal/domain/specifications/simple_specification.go

@ -7,106 +7,6 @@ import (
"time" "time"
) )
// CompositeSpecification implements logical operations between different specification types
type CompositeSpecification[T any] struct {
left Specification[T]
right Specification[T]
op string // "AND", "OR"
}
// IsSatisfiedBy checks if the candidate satisfies the composite specification
func (c *CompositeSpecification[T]) IsSatisfiedBy(candidate T) bool {
switch c.op {
case "AND":
return c.left.IsSatisfiedBy(candidate) && c.right.IsSatisfiedBy(candidate)
case "OR":
return c.left.IsSatisfiedBy(candidate) || c.right.IsSatisfiedBy(candidate)
default:
return false
}
}
// And creates a new specification that is the logical AND of this and another specification
func (c *CompositeSpecification[T]) And(other Specification[T]) Specification[T] {
return &CompositeSpecification[T]{
left: c,
right: other,
op: "AND",
}
}
// Or creates a new specification that is the logical OR of this and another specification
func (c *CompositeSpecification[T]) Or(other Specification[T]) Specification[T] {
return &CompositeSpecification[T]{
left: c,
right: other,
op: "OR",
}
}
// Not creates a new specification that is the logical NOT of this specification
func (c *CompositeSpecification[T]) Not() Specification[T] {
// For composite specifications, we need to create a wrapper that negates the result
return &NotSpecification[T]{spec: c}
}
// GetConditions returns all conditions from this specification
func (c *CompositeSpecification[T]) GetConditions() []Condition {
// Combine conditions from both sides
leftConditions := c.left.GetConditions()
rightConditions := c.right.GetConditions()
return append(leftConditions, rightConditions...)
}
// GetSpec returns nil for composite specifications as they don't have a single spec structure
func (c *CompositeSpecification[T]) GetSpec() *Spec {
return nil
}
// NotSpecification implements the NOT operation for specifications
type NotSpecification[T any] struct {
spec Specification[T]
}
// IsSatisfiedBy checks if the candidate does NOT satisfy the wrapped specification
func (n *NotSpecification[T]) IsSatisfiedBy(candidate T) bool {
return !n.spec.IsSatisfiedBy(candidate)
}
// And creates a new specification that is the logical AND of this and another specification
func (n *NotSpecification[T]) And(other Specification[T]) Specification[T] {
return &CompositeSpecification[T]{
left: n,
right: other,
op: "AND",
}
}
// Or creates a new specification that is the logical OR of this and another specification
func (n *NotSpecification[T]) Or(other Specification[T]) Specification[T] {
return &CompositeSpecification[T]{
left: n,
right: other,
op: "OR",
}
}
// Not creates a new specification that is the logical NOT of this specification (double negation)
func (n *NotSpecification[T]) Not() Specification[T] {
return n.spec
}
// GetConditions returns all conditions from the wrapped specification
func (n *NotSpecification[T]) GetConditions() []Condition {
return n.spec.GetConditions()
}
// GetSpec returns nil for not specifications
func (n *NotSpecification[T]) GetSpec() *Spec {
return nil
}
// Specification defines the interface for specifications
type Specification[T any] interface { type Specification[T any] interface {
IsSatisfiedBy(candidate T) bool IsSatisfiedBy(candidate T) bool
And(other Specification[T]) Specification[T] And(other Specification[T]) Specification[T]
@ -116,73 +16,47 @@ type Specification[T any] interface {
GetSpec() *Spec GetSpec() *Spec
} }
// SimpleSpecification implements the Specification interface using condition-based specs
type SimpleSpecification[T any] struct { type SimpleSpecification[T any] struct {
spec *Spec spec *Spec
} }
// NewSimpleSpecification creates a new SimpleSpecification with the given spec
func NewSimpleSpecification[T any](spec *Spec) *SimpleSpecification[T] { func NewSimpleSpecification[T any](spec *Spec) *SimpleSpecification[T] {
return &SimpleSpecification[T]{ return &SimpleSpecification[T]{spec: spec}
spec: spec,
}
} }
// IsSatisfiedBy checks if the candidate satisfies the specification
func (s *SimpleSpecification[T]) IsSatisfiedBy(candidate T) bool { func (s *SimpleSpecification[T]) IsSatisfiedBy(candidate T) bool {
return s.evaluateSpec(s.spec, candidate) return s.evaluateSpec(s.spec, candidate)
} }
// And creates a new specification that is the logical AND of this and another specification
func (s *SimpleSpecification[T]) And(other Specification[T]) Specification[T] { func (s *SimpleSpecification[T]) And(other Specification[T]) Specification[T] {
if otherSpec, ok := other.(*SimpleSpecification[T]); ok { return &CompositeSpecification[T]{left: s, right: other, op: "AND"}
return NewSimpleSpecification[T](SpecBuilder.And(s.spec, otherSpec.spec))
}
panic("And operation not supported between different specification types")
} }
// Or creates a new specification that is the logical OR of this and another specification
func (s *SimpleSpecification[T]) Or(other Specification[T]) Specification[T] { func (s *SimpleSpecification[T]) Or(other Specification[T]) Specification[T] {
if otherSpec, ok := other.(*SimpleSpecification[T]); ok { return &CompositeSpecification[T]{left: s, right: other, op: "OR"}
return NewSimpleSpecification[T](SpecBuilder.Or(s.spec, otherSpec.spec))
}
panic("Or operation not supported between different specification types")
} }
// Not creates a new specification that is the logical NOT of this specification
func (s *SimpleSpecification[T]) Not() Specification[T] { func (s *SimpleSpecification[T]) Not() Specification[T] {
return NewSimpleSpecification[T](SpecBuilder.Not(s.spec)) return NewSimpleSpecification[T](Not(s.spec))
} }
// GetSpec returns the underlying specification structure
func (s *SimpleSpecification[T]) GetSpec() *Spec { func (s *SimpleSpecification[T]) GetSpec() *Spec {
return s.spec return s.spec
} }
// GetConditions returns all conditions from this specification
func (s *SimpleSpecification[T]) GetConditions() []Condition { func (s *SimpleSpecification[T]) GetConditions() []Condition {
return SpecBuilder.GetConditions(s.spec) return GetConditions(s.spec)
} }
// evaluateSpec recursively evaluates a specification against a candidate
func (s *SimpleSpecification[T]) evaluateSpec(spec *Spec, candidate T) bool { func (s *SimpleSpecification[T]) evaluateSpec(spec *Spec, candidate T) bool {
if spec == nil { if spec == nil {
return false return false
} }
// Handle logical groups
if spec.LogicalGroup != nil { if spec.LogicalGroup != nil {
switch spec.LogicalGroup.Operator { return s.evaluateLogicalGroup(spec.LogicalGroup, candidate)
case GROUP_AND:
return s.evaluateAndGroup(spec.LogicalGroup, candidate)
case GROUP_OR:
return s.evaluateOrGroup(spec.LogicalGroup, candidate)
case GROUP_NOT:
return s.evaluateNotGroup(spec.LogicalGroup, candidate)
}
} }
// Handle simple condition
if spec.Condition != nil { if spec.Condition != nil {
return s.evaluateCondition(*spec.Condition, candidate) return s.evaluateCondition(*spec.Condition, candidate)
} }
@ -190,52 +64,64 @@ func (s *SimpleSpecification[T]) evaluateSpec(spec *Spec, candidate T) bool {
return false return false
} }
// evaluateAndGroup evaluates an AND group func (s *SimpleSpecification[T]) evaluateLogicalGroup(group *LogicalGroup, candidate T) bool {
switch group.Operator {
case GROUP_AND:
return s.evaluateAndGroup(group, candidate)
case GROUP_OR:
return s.evaluateOrGroup(group, candidate)
case GROUP_NOT:
if group.Spec != nil {
return !s.evaluateSpec(group.Spec, candidate)
}
}
return false
}
func (s *SimpleSpecification[T]) evaluateAndGroup(group *LogicalGroup, candidate T) bool { func (s *SimpleSpecification[T]) evaluateAndGroup(group *LogicalGroup, candidate T) bool {
for _, cond := range group.Conditions { for _, cond := range group.Conditions {
if !s.evaluateCondition(cond, candidate) { if !s.evaluateCondition(cond, candidate) {
return false return false
} }
} }
return true
if group.Spec != nil {
return s.evaluateSpec(group.Spec, candidate)
}
return len(group.Conditions) > 0 || group.Spec != nil
} }
// evaluateOrGroup evaluates an OR group
func (s *SimpleSpecification[T]) evaluateOrGroup(group *LogicalGroup, candidate T) bool { func (s *SimpleSpecification[T]) evaluateOrGroup(group *LogicalGroup, candidate T) bool {
for _, cond := range group.Conditions { for _, cond := range group.Conditions {
if s.evaluateCondition(cond, candidate) { if s.evaluateCondition(cond, candidate) {
return true return true
} }
} }
return false
}
// evaluateNotGroup evaluates a NOT group
func (s *SimpleSpecification[T]) evaluateNotGroup(group *LogicalGroup, candidate T) bool {
if group.Spec != nil { if group.Spec != nil {
return !s.evaluateSpec(group.Spec, candidate) return s.evaluateSpec(group.Spec, candidate)
} }
return false return false
} }
// evaluateCondition evaluates a single condition against a candidate
func (s *SimpleSpecification[T]) evaluateCondition(condition Condition, candidate T) bool { func (s *SimpleSpecification[T]) evaluateCondition(condition Condition, candidate T) bool {
// Get the field value from the candidate
fieldValue, err := s.getFieldValue(candidate, condition.Field) fieldValue, err := s.getFieldValue(candidate, condition.Field)
if err != nil { if err != nil {
return false return false
} }
// Handle time comparison
return s.compareValues(fieldValue, condition.Operator, condition.Value) return s.compareValues(fieldValue, condition.Operator, condition.Value)
} }
// getFieldValue retrieves the value of a field from an object using reflection
func (s *SimpleSpecification[T]) getFieldValue(candidate T, fieldName string) (interface{}, error) { func (s *SimpleSpecification[T]) getFieldValue(candidate T, fieldName string) (interface{}, error) {
v := reflect.ValueOf(candidate) v := reflect.ValueOf(candidate)
// If candidate is a pointer, get the underlying value
if v.Kind() == reflect.Ptr { if v.Kind() == reflect.Ptr {
if v.IsNil() {
return nil, fmt.Errorf("candidate is nil")
}
v = v.Elem() v = v.Elem()
} }
@ -243,175 +129,190 @@ func (s *SimpleSpecification[T]) getFieldValue(candidate T, fieldName string) (i
return nil, fmt.Errorf("candidate is not a struct") return nil, fmt.Errorf("candidate is not a struct")
} }
// Try to get the field by getter method first (preferred approach)
getterName := "Get" + strings.Title(fieldName) getterName := "Get" + strings.Title(fieldName)
method := v.MethodByName(getterName)
if method.IsValid() && method.Type().NumIn() == 0 && method.Type().NumOut() > 0 { originalV := reflect.ValueOf(candidate)
results := method.Call(nil) if method := originalV.MethodByName(getterName); method.IsValid() {
if len(results) > 0 { return s.callMethod(method)
return results[0].Interface(), nil
} }
if method := originalV.MethodByName(fieldName); method.IsValid() {
return s.callMethod(method)
} }
// Try alternative getter naming (e.g., ExpirationDate() instead of GetExpirationDate()) if v.Kind() == reflect.Struct {
method = v.MethodByName(fieldName) if method := v.MethodByName(getterName); method.IsValid() {
if method.IsValid() && method.Type().NumIn() == 0 && method.Type().NumOut() > 0 { return s.callMethod(method)
results := method.Call(nil)
if len(results) > 0 {
return results[0].Interface(), nil
} }
if method := v.MethodByName(fieldName); method.IsValid() {
return s.callMethod(method)
} }
// Try to get the field by name (for exported fields only) if field := v.FieldByName(fieldName); field.IsValid() && field.CanInterface() {
field := v.FieldByName(fieldName)
if field.IsValid() && field.CanInterface() {
return field.Interface(), nil return field.Interface(), nil
} }
}
return nil, fmt.Errorf("field %s not found", fieldName)
}
func (s *SimpleSpecification[T]) callMethod(method reflect.Value) (interface{}, error) {
if !method.IsValid() || method.Type().NumIn() != 0 || method.Type().NumOut() == 0 {
return nil, fmt.Errorf("invalid method")
}
results := method.Call(nil)
if len(results) == 0 {
return nil, fmt.Errorf("method returned no values")
}
return nil, fmt.Errorf("field %s not found or not accessible", fieldName) return results[0].Interface(), nil
} }
// compareValues compares two values using the specified operator
func (s *SimpleSpecification[T]) compareValues(fieldValue interface{}, operator string, compareValue interface{}) bool { func (s *SimpleSpecification[T]) compareValues(fieldValue interface{}, operator string, compareValue interface{}) bool {
// Handle time comparison if fieldValue == nil {
return (operator == OP_EQ && compareValue == nil) || (operator == OP_NEQ && compareValue != nil)
}
if s.isTimeComparable(fieldValue, compareValue) { if s.isTimeComparable(fieldValue, compareValue) {
return s.compareTimes(fieldValue, operator, compareValue) return s.compareTimes(fieldValue, operator, compareValue)
} }
// Convert both values to the same type for comparison
fieldVal := reflect.ValueOf(fieldValue)
compareVal := reflect.ValueOf(compareValue)
// Handle IN and NOT IN operators
if operator == OP_IN || operator == OP_NIN { if operator == OP_IN || operator == OP_NIN {
return s.compareIn(fieldValue, operator, compareValue) return s.compareIn(fieldValue, operator, compareValue)
} }
// For other operators, try to convert to comparable types return s.compareGeneral(fieldValue, operator, compareValue)
if fieldVal.Kind() != compareVal.Kind() { }
// Try to convert compareValue to the same type as fieldValue
if compareVal.CanConvert(fieldVal.Type()) { func (s *SimpleSpecification[T]) isTimeComparable(fieldValue, compareValue interface{}) bool {
compareVal = compareVal.Convert(fieldVal.Type()) _, fieldIsTime := fieldValue.(time.Time)
} else if fieldVal.CanConvert(compareVal.Type()) { _, compareIsTime := compareValue.(time.Time)
fieldVal = fieldVal.Convert(compareVal.Type())
} else { if fieldIsTime || compareIsTime {
return true
}
return s.hasTimeMethod(fieldValue) || s.hasTimeMethod(compareValue)
}
func (s *SimpleSpecification[T]) hasTimeMethod(value interface{}) bool {
v := reflect.ValueOf(value)
if !v.IsValid() || v.Kind() == reflect.Ptr {
return false return false
} }
method := v.MethodByName("Time")
return method.IsValid() && method.Type().NumIn() == 0 && method.Type().NumOut() == 1
}
func (s *SimpleSpecification[T]) compareTimes(fieldValue interface{}, operator string, compareValue interface{}) bool {
fieldTime := s.extractTime(fieldValue)
compareTime := s.extractTime(compareValue)
if fieldTime == nil || compareTime == nil {
return false
} }
switch operator { switch operator {
case OP_EQ: case OP_EQ:
return reflect.DeepEqual(fieldValue, compareValue) return fieldTime.Equal(*compareTime)
case OP_NEQ: case OP_NEQ:
return !reflect.DeepEqual(fieldValue, compareValue) return !fieldTime.Equal(*compareTime)
case OP_GT: case OP_GT:
return s.compareGreater(fieldVal, compareVal) return fieldTime.After(*compareTime)
case OP_GTE: case OP_GTE:
return s.compareGreater(fieldVal, compareVal) || reflect.DeepEqual(fieldValue, compareValue) return fieldTime.After(*compareTime) || fieldTime.Equal(*compareTime)
case OP_LT: case OP_LT:
return s.compareLess(fieldVal, compareVal) return fieldTime.Before(*compareTime)
case OP_LTE: case OP_LTE:
return s.compareLess(fieldVal, compareVal) || reflect.DeepEqual(fieldValue, compareValue) return fieldTime.Before(*compareTime) || fieldTime.Equal(*compareTime)
default:
return false
}
} }
// isTimeComparable checks if the values can be compared as times return false
func (s *SimpleSpecification[T]) isTimeComparable(fieldValue, compareValue interface{}) bool {
_, fieldIsTime := fieldValue.(time.Time)
_, compareIsTime := compareValue.(time.Time)
// If both are time.Time, they are time comparable
if fieldIsTime && compareIsTime {
return true
} }
// If one is time.Time and the other is string, check if string is a valid time func (s *SimpleSpecification[T]) extractTime(value interface{}) *time.Time {
if fieldIsTime { switch v := value.(type) {
if compareStr, ok := compareValue.(string); ok { case time.Time:
_, err := time.Parse(time.RFC3339, compareStr) return &v
return err == nil case string:
if t, err := time.Parse(time.RFC3339, v); err == nil {
return &t
} }
default:
if method := reflect.ValueOf(value).MethodByName("Time"); method.IsValid() {
if results := method.Call(nil); len(results) > 0 {
if t, ok := results[0].Interface().(time.Time); ok {
return &t
} }
if compareIsTime {
if fieldStr, ok := fieldValue.(string); ok {
_, err := time.Parse(time.RFC3339, fieldStr)
return err == nil
} }
} }
}
return nil
}
func (s *SimpleSpecification[T]) compareIn(fieldValue interface{}, operator string, compareValue interface{}) bool {
compareSlice, ok := compareValue.([]interface{})
if !ok {
return false return false
} }
// compareTimes compares time values for _, v := range compareSlice {
func (s *SimpleSpecification[T]) compareTimes(fieldValue interface{}, operator string, compareValue interface{}) bool { if reflect.DeepEqual(fieldValue, v) {
var fieldTime, compareTime time.Time return operator == OP_IN
var err error
// Convert fieldValue to time.Time
switch v := fieldValue.(type) {
case time.Time:
fieldTime = v
case string:
fieldTime, err = time.Parse(time.RFC3339, v)
if err != nil {
return false
} }
default:
return false
} }
// Convert compareValue to time.Time return operator == OP_NIN
switch v := compareValue.(type) {
case time.Time:
compareTime = v
case string:
compareTime, err = time.Parse(time.RFC3339, v)
if err != nil {
return false
} }
default:
func (s *SimpleSpecification[T]) compareGeneral(fieldValue interface{}, operator string, compareValue interface{}) bool {
fieldVal := reflect.ValueOf(fieldValue)
compareVal := reflect.ValueOf(compareValue)
if !s.makeComparable(&fieldVal, &compareVal) {
return false return false
} }
switch operator { switch operator {
case OP_EQ: case OP_EQ:
return fieldTime.Equal(compareTime) return reflect.DeepEqual(fieldValue, compareValue)
case OP_NEQ: case OP_NEQ:
return !fieldTime.Equal(compareTime) return !reflect.DeepEqual(fieldValue, compareValue)
case OP_GT: case OP_GT:
return fieldTime.After(compareTime) return s.isGreater(fieldVal, compareVal)
case OP_GTE: case OP_GTE:
return fieldTime.After(compareTime) || fieldTime.Equal(compareTime) return s.isGreater(fieldVal, compareVal) || reflect.DeepEqual(fieldValue, compareValue)
case OP_LT: case OP_LT:
return fieldTime.Before(compareTime) return s.isLess(fieldVal, compareVal)
case OP_LTE: case OP_LTE:
return fieldTime.Before(compareTime) || fieldTime.Equal(compareTime) return s.isLess(fieldVal, compareVal) || reflect.DeepEqual(fieldValue, compareValue)
default:
return false
}
} }
// compareIn handles IN and NOT IN operators
func (s *SimpleSpecification[T]) compareIn(fieldValue interface{}, operator string, compareValue interface{}) bool {
compareSlice, ok := compareValue.([]interface{})
if !ok {
return false return false
} }
for _, v := range compareSlice { func (s *SimpleSpecification[T]) makeComparable(fieldVal, compareVal *reflect.Value) bool {
if reflect.DeepEqual(fieldValue, v) { if fieldVal.Kind() == compareVal.Kind() {
return operator == OP_IN return true
}
if compareVal.CanConvert(fieldVal.Type()) {
*compareVal = compareVal.Convert(fieldVal.Type())
return true
} }
if fieldVal.CanConvert(compareVal.Type()) {
*fieldVal = fieldVal.Convert(compareVal.Type())
return true
} }
return operator == OP_NIN return false
} }
// compareGreater compares if fieldVal is greater than compareVal func (s *SimpleSpecification[T]) isGreater(fieldVal, compareVal reflect.Value) bool {
func (s *SimpleSpecification[T]) compareGreater(fieldVal, compareVal reflect.Value) bool {
switch fieldVal.Kind() { switch fieldVal.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return fieldVal.Int() > compareVal.Int() return fieldVal.Int() > compareVal.Int()
@ -421,13 +322,11 @@ func (s *SimpleSpecification[T]) compareGreater(fieldVal, compareVal reflect.Val
return fieldVal.Float() > compareVal.Float() return fieldVal.Float() > compareVal.Float()
case reflect.String: case reflect.String:
return fieldVal.String() > compareVal.String() return fieldVal.String() > compareVal.String()
default:
return false
} }
return false
} }
// compareLess compares if fieldVal is less than compareVal func (s *SimpleSpecification[T]) isLess(fieldVal, compareVal reflect.Value) bool {
func (s *SimpleSpecification[T]) compareLess(fieldVal, compareVal reflect.Value) bool {
switch fieldVal.Kind() { switch fieldVal.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return fieldVal.Int() < compareVal.Int() return fieldVal.Int() < compareVal.Int()
@ -437,7 +336,72 @@ func (s *SimpleSpecification[T]) compareLess(fieldVal, compareVal reflect.Value)
return fieldVal.Float() < compareVal.Float() return fieldVal.Float() < compareVal.Float()
case reflect.String: case reflect.String:
return fieldVal.String() < compareVal.String() return fieldVal.String() < compareVal.String()
default: }
return false
}
type CompositeSpecification[T any] struct {
left Specification[T]
right Specification[T]
op string
}
func (c *CompositeSpecification[T]) IsSatisfiedBy(candidate T) bool {
switch c.op {
case "AND":
return c.left.IsSatisfiedBy(candidate) && c.right.IsSatisfiedBy(candidate)
case "OR":
return c.left.IsSatisfiedBy(candidate) || c.right.IsSatisfiedBy(candidate)
}
return false return false
} }
func (c *CompositeSpecification[T]) And(other Specification[T]) Specification[T] {
return &CompositeSpecification[T]{left: c, right: other, op: "AND"}
}
func (c *CompositeSpecification[T]) Or(other Specification[T]) Specification[T] {
return &CompositeSpecification[T]{left: c, right: other, op: "OR"}
}
func (c *CompositeSpecification[T]) Not() Specification[T] {
return &NotSpecification[T]{spec: c}
}
func (c *CompositeSpecification[T]) GetConditions() []Condition {
leftConditions := c.left.GetConditions()
rightConditions := c.right.GetConditions()
return append(leftConditions, rightConditions...)
}
func (c *CompositeSpecification[T]) GetSpec() *Spec {
return nil
}
type NotSpecification[T any] struct {
spec Specification[T]
}
func (n *NotSpecification[T]) IsSatisfiedBy(candidate T) bool {
return !n.spec.IsSatisfiedBy(candidate)
}
func (n *NotSpecification[T]) And(other Specification[T]) Specification[T] {
return &CompositeSpecification[T]{left: n, right: other, op: "AND"}
}
func (n *NotSpecification[T]) Or(other Specification[T]) Specification[T] {
return &CompositeSpecification[T]{left: n, right: other, op: "OR"}
}
func (n *NotSpecification[T]) Not() Specification[T] {
return n.spec
}
func (n *NotSpecification[T]) GetConditions() []Condition {
return n.spec.GetConditions()
}
func (n *NotSpecification[T]) GetSpec() *Spec {
return nil
} }

348
golang/tests/integration/file_item_repository_test.go

@ -0,0 +1,348 @@
package integration
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"autostore/internal/domain/entities"
"autostore/internal/domain/specifications"
"autostore/internal/domain/value_objects"
"autostore/internal/infrastructure/repositories"
"github.com/stretchr/testify/assert"
)
const (
ITEM_ID_1 = "00000000-0000-0000-0000-000000000001"
ITEM_ID_2 = "00000000-0000-0000-0000-000000000002"
ITEM_ID_3 = "00000000-0000-0000-0000-000000000003"
EXPIRED_ID = "00000000-0000-0000-0000-000000000004"
VALID_ID = "00000000-0000-0000-0000-000000000005"
NON_EXISTENT_ID = "00000000-0000-0000-0000-000000000099"
ITEM_NAME_1 = "Test Item 1"
ITEM_NAME_2 = "Test Item 2"
ITEM_NAME_3 = "Test Item 3"
EXPIRED_NAME = "Expired Item"
VALID_NAME = "Valid Item"
ORDER_URL_1 = "http://example.com/order1"
ORDER_URL_2 = "http://example.com/order2"
ORDER_URL_3 = "http://example.com/order3"
EXPIRED_ORDER_URL = "http://example.com/expired-order"
VALID_ORDER_URL = "http://example.com/valid-order"
USER_ID_1 = "10000000-0000-0000-0000-000000000001"
USER_ID_2 = "10000000-0000-0000-0000-000000000002"
USER_ID = "10000000-0000-0000-0000-000000000003"
MOCKED_NOW = "2023-01-01T12:00:00Z"
DATE_FORMAT = time.RFC3339
)
type mockLogger struct {
infoLogs []string
errorLogs []string
}
func (m *mockLogger) Info(ctx context.Context, msg string, args ...any) {
m.infoLogs = append(m.infoLogs, fmt.Sprintf(msg, args...))
}
func (m *mockLogger) Error(ctx context.Context, msg string, args ...any) {
m.errorLogs = append(m.errorLogs, fmt.Sprintf(msg, args...))
}
func (m *mockLogger) Debug(ctx context.Context, msg string, args ...any) {
// Debug implementation - can be empty for tests
}
func (m *mockLogger) Warn(ctx context.Context, msg string, args ...any) {
// Warn implementation - can be empty for tests
}
func setupTest(t *testing.T) (string, *repositories.FileItemRepository, *mockLogger, func()) {
testStoragePath := t.TempDir()
logger := &mockLogger{}
repo := repositories.NewFileItemRepository(testStoragePath, logger)
cleanup := func() {
os.RemoveAll(testStoragePath)
}
return testStoragePath, repo, logger, cleanup
}
func createTestItem(id, name string, expiration time.Time, orderURL, userID string) *entities.ItemEntity {
itemID, _ := value_objects.NewItemID(id)
userIDVO, _ := value_objects.NewUserID(userID)
expirationVO, _ := value_objects.NewExpirationDate(expiration)
item, _ := entities.NewItem(itemID, name, expirationVO, orderURL, userIDVO)
return item
}
func createTestItem1() *entities.ItemEntity {
expiration, _ := time.Parse(DATE_FORMAT, "2025-01-01T12:00:00Z")
return createTestItem(ITEM_ID_1, ITEM_NAME_1, expiration, ORDER_URL_1, USER_ID_1)
}
func createTestItem2() *entities.ItemEntity {
expiration, _ := time.Parse(DATE_FORMAT, "2025-01-02T12:00:00Z")
return createTestItem(ITEM_ID_2, ITEM_NAME_2, expiration, ORDER_URL_2, USER_ID_2)
}
func createTestItem3() *entities.ItemEntity {
expiration, _ := time.Parse(DATE_FORMAT, "2025-01-03T12:00:00Z")
return createTestItem(ITEM_ID_3, ITEM_NAME_3, expiration, ORDER_URL_3, USER_ID_1)
}
func createExpiredItem() *entities.ItemEntity {
expiration, _ := time.Parse(DATE_FORMAT, "2022-01-01T12:00:00Z") // Past date
return createTestItem(EXPIRED_ID, EXPIRED_NAME, expiration, EXPIRED_ORDER_URL, USER_ID)
}
func createValidItem() *entities.ItemEntity {
expiration, _ := time.Parse(DATE_FORMAT, "2024-01-01T12:00:00Z") // Future date
return createTestItem(VALID_ID, VALID_NAME, expiration, VALID_ORDER_URL, USER_ID)
}
func createExpiredItemForUser1() *entities.ItemEntity {
expiration, _ := time.Parse(DATE_FORMAT, "2022-01-01T12:00:00Z") // Past date
return createTestItem(ITEM_ID_1, ITEM_NAME_1, expiration, ORDER_URL_1, USER_ID_1)
}
func createValidItemForUser1() *entities.ItemEntity {
expiration, _ := time.Parse(DATE_FORMAT, "2024-01-01T12:00:00Z") // Future date
return createTestItem(ITEM_ID_2, ITEM_NAME_2, expiration, ORDER_URL_2, USER_ID_1)
}
func createExpiredItemForUser2() *entities.ItemEntity {
expiration, _ := time.Parse(DATE_FORMAT, "2022-01-01T12:00:00Z") // Past date
return createTestItem(ITEM_ID_3, ITEM_NAME_3, expiration, ORDER_URL_3, USER_ID_2)
}
func TestWhenItemIsSavedThenFileIsCreated(t *testing.T) {
testStoragePath, repo, _, cleanup := setupTest(t)
defer cleanup()
item := createTestItem1()
err := repo.Save(context.Background(), item)
assert.NoError(t, err)
filePath := filepath.Join(testStoragePath, "items.json")
assert.FileExists(t, filePath)
data, err := os.ReadFile(filePath)
assert.NoError(t, err)
var items map[string]*entities.ItemEntity
err = json.Unmarshal(data, &items)
assert.NoError(t, err)
assert.Len(t, items, 1)
savedItem := items[item.GetID().String()]
assert.NotNil(t, savedItem)
assert.Equal(t, item.GetID().String(), savedItem.GetID().String())
assert.Equal(t, item.GetName(), savedItem.GetName())
assert.Equal(t, item.GetOrderURL(), savedItem.GetOrderURL())
assert.Equal(t, item.GetUserID().String(), savedItem.GetUserID().String())
}
func TestWhenItemExistsThenFindByIDReturnsItem(t *testing.T) {
_, repo, _, cleanup := setupTest(t)
defer cleanup()
item := createTestItem1()
err := repo.Save(context.Background(), item)
assert.NoError(t, err)
itemID, _ := value_objects.NewItemID(ITEM_ID_1)
foundItem, err := repo.FindByID(context.Background(), itemID)
assert.NoError(t, err)
assert.NotNil(t, foundItem)
assert.Equal(t, item.GetID().String(), foundItem.GetID().String())
assert.Equal(t, item.GetName(), foundItem.GetName())
}
func TestWhenItemDoesNotExistThenFindByIDReturnsNil(t *testing.T) {
_, repo, _, cleanup := setupTest(t)
defer cleanup()
nonExistentID, _ := value_objects.NewItemID(NON_EXISTENT_ID)
foundItem, err := repo.FindByID(context.Background(), nonExistentID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
assert.Nil(t, foundItem)
}
func TestWhenUserHasMultipleItemsThenFindByUserIDReturnsAllUserItems(t *testing.T) {
_, repo, _, cleanup := setupTest(t)
defer cleanup()
item1 := createTestItem1()
item2 := createTestItem2()
item3 := createTestItem3()
repo.Save(context.Background(), item1)
repo.Save(context.Background(), item2)
repo.Save(context.Background(), item3)
userID, _ := value_objects.NewUserID(USER_ID_1)
userItems, err := repo.FindByUserID(context.Background(), userID)
assert.NoError(t, err)
assert.Len(t, userItems, 2)
itemIDs := make([]string, len(userItems))
for i, item := range userItems {
itemIDs[i] = item.GetID().String()
}
assert.Contains(t, itemIDs, ITEM_ID_1)
assert.Contains(t, itemIDs, ITEM_ID_3)
}
func TestWhenItemIsDeletedThenItIsNoLongerFound(t *testing.T) {
_, repo, _, cleanup := setupTest(t)
defer cleanup()
item := createTestItem1()
err := repo.Save(context.Background(), item)
assert.NoError(t, err)
itemID, _ := value_objects.NewItemID(ITEM_ID_1)
err = repo.Delete(context.Background(), itemID)
assert.NoError(t, err)
foundItem, err := repo.FindByID(context.Background(), itemID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
assert.Nil(t, foundItem)
}
func TestWhenNonExistentItemIsDeletedThenErrorIsReturned(t *testing.T) {
_, repo, _, cleanup := setupTest(t)
defer cleanup()
nonExistentID, _ := value_objects.NewItemID(NON_EXISTENT_ID)
err := repo.Delete(context.Background(), nonExistentID)
assert.Error(t, err)
assert.Contains(t, err.Error(), fmt.Sprintf("item with ID %s not found", NON_EXISTENT_ID))
}
func TestWhenFilteringByExpirationThenOnlyExpiredItemsAreReturned(t *testing.T) {
_, repo, _, cleanup := setupTest(t)
defer cleanup()
expiredItem := createExpiredItem()
validItem := createValidItem()
repo.Save(context.Background(), expiredItem)
repo.Save(context.Background(), validItem)
now, _ := time.Parse(DATE_FORMAT, MOCKED_NOW)
expirationSpec := specifications.NewItemExpirationSpec()
spec := expirationSpec.GetSpec(now)
filteredItems, err := repo.FindWhere(context.Background(), spec)
assert.NoError(t, err)
assert.Len(t, filteredItems, 1)
assert.Equal(t, expiredItem.GetID().String(), filteredItems[0].GetID().String())
}
func TestWhenFilteringByUserIDThenOnlyUserItemsAreReturned(t *testing.T) {
_, repo, _, cleanup := setupTest(t)
defer cleanup()
item1 := createTestItem1()
item2 := createTestItem2()
item3 := createTestItem3()
repo.Save(context.Background(), item1)
repo.Save(context.Background(), item2)
repo.Save(context.Background(), item3)
userID, _ := value_objects.NewUserID(USER_ID_1)
spec := specifications.NewSimpleSpecification[*entities.ItemEntity](specifications.Eq("userID", userID))
userItems, err := repo.FindWhere(context.Background(), spec)
assert.NoError(t, err)
assert.Len(t, userItems, 2)
itemIDs := make([]string, len(userItems))
for i, item := range userItems {
itemIDs[i] = item.GetID().String()
}
assert.Contains(t, itemIDs, ITEM_ID_1)
assert.Contains(t, itemIDs, ITEM_ID_3)
}
func TestWhenUsingComplexFilterThenOnlyMatchingItemsAreReturned(t *testing.T) {
_, repo, _, cleanup := setupTest(t)
defer cleanup()
item1 := createExpiredItemForUser1()
item2 := createValidItemForUser1()
item3 := createExpiredItemForUser2()
repo.Save(context.Background(), item1)
repo.Save(context.Background(), item2)
repo.Save(context.Background(), item3)
now, _ := time.Parse(DATE_FORMAT, MOCKED_NOW)
userID, _ := value_objects.NewUserID(USER_ID_1)
userSpec := specifications.NewSimpleSpecification[*entities.ItemEntity](specifications.Eq("userID", userID))
expirationSpec := specifications.NewItemExpirationSpec()
expirationSpecWithTime := expirationSpec.GetSpec(now)
complexSpec := userSpec.And(expirationSpecWithTime)
filteredItems, err := repo.FindWhere(context.Background(), complexSpec)
assert.NoError(t, err)
assert.Len(t, filteredItems, 1)
assert.Equal(t, item1.GetID().String(), filteredItems[0].GetID().String())
}
func TestWhenCheckingExistenceOfExistingItemThenReturnsTrue(t *testing.T) {
_, repo, _, cleanup := setupTest(t)
defer cleanup()
item := createTestItem1()
err := repo.Save(context.Background(), item)
assert.NoError(t, err)
itemID, _ := value_objects.NewItemID(ITEM_ID_1)
exists, err := repo.Exists(context.Background(), itemID)
assert.NoError(t, err)
assert.True(t, exists)
}
func TestWhenCheckingExistenceOfNonExistentItemThenReturnsFalse(t *testing.T) {
_, repo, _, cleanup := setupTest(t)
defer cleanup()
nonExistentID, _ := value_objects.NewItemID(NON_EXISTENT_ID)
exists, err := repo.Exists(context.Background(), nonExistentID)
assert.NoError(t, err)
assert.False(t, exists)
}
func TestWhenDifferentRepositoryInstancesShareSameFile(t *testing.T) {
testStoragePath, _, _, cleanup := setupTest(t)
defer cleanup()
logger := &mockLogger{}
repo1 := repositories.NewFileItemRepository(testStoragePath, logger)
repo2 := repositories.NewFileItemRepository(testStoragePath, logger)
item := createTestItem1()
err := repo1.Save(context.Background(), item)
assert.NoError(t, err)
itemID, _ := value_objects.NewItemID(ITEM_ID_1)
foundItem, err := repo2.FindByID(context.Background(), itemID)
assert.NoError(t, err)
assert.NotNil(t, foundItem)
assert.Equal(t, item.GetID().String(), foundItem.GetID().String())
assert.Equal(t, item.GetName(), foundItem.GetName())
assert.Equal(t, item.GetOrderURL(), foundItem.GetOrderURL())
assert.Equal(t, item.GetUserID().String(), foundItem.GetUserID().String())
}

352
golang/tests/unit/add_item_command_test.go

@ -0,0 +1,352 @@
package unit
import (
"context"
"errors"
"testing"
"time"
"autostore/internal/application/commands"
"autostore/internal/domain/entities"
"autostore/internal/domain/specifications"
)
func createTestCommand() (*commands.AddItemCommand, *mockItemRepository, *mockOrderService, *mockTimeProvider, *mockLogger) {
itemRepo := &mockItemRepository{}
orderService := &mockOrderService{}
timeProvider := &mockTimeProvider{
nowFunc: func() time.Time {
t, _ := time.Parse(dateFormat, mockedNow)
return t
},
}
expirationSpec := specifications.NewItemExpirationSpec()
logger := &mockLogger{}
cmd := commands.NewAddItemCommand(itemRepo, orderService, timeProvider, expirationSpec, logger)
return cmd, itemRepo, orderService, timeProvider, logger
}
func TestWhenItemNotExpiredThenItemSaved(t *testing.T) {
// Given
cmd, itemRepo, _, _, _ := createTestCommand()
expirationTime, _ := time.Parse(dateFormat, notExpiredDate)
itemSaved := false
itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error {
itemSaved = true
if item.GetName() != itemName {
t.Errorf("Expected item name %s, got %s", itemName, item.GetName())
}
if item.GetOrderURL() != orderURL {
t.Errorf("Expected order URL %s, got %s", orderURL, item.GetOrderURL())
}
if item.GetUserID().String() != userID {
t.Errorf("Expected user ID %s, got %s", userID, item.GetUserID().String())
}
return nil
}
// When
resultID, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID)
// Then
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if resultID == "" {
t.Error("Expected non-empty result ID")
}
if !itemSaved {
t.Error("Expected item to be saved")
}
}
func TestWhenItemNotExpiredThenOrderIsNotPlaced(t *testing.T) {
// Given
cmd, _, orderService, _, _ := createTestCommand()
expirationTime, _ := time.Parse(dateFormat, notExpiredDate)
orderPlaced := false
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error {
orderPlaced = true
return nil
}
// When
_, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID)
// Then
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if orderPlaced {
t.Error("Expected order not to be placed for non-expired item")
}
}
func TestWhenItemNotExpiredThenNewItemIdIsReturned(t *testing.T) {
// Given
cmd, _, _, _, _ := createTestCommand()
expirationTime, _ := time.Parse(dateFormat, notExpiredDate)
// When
resultID, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID)
// Then
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if resultID == "" {
t.Error("Expected non-empty result ID")
}
}
func TestWhenItemIsExpiredThenOrderPlaced(t *testing.T) {
// Given
cmd, itemRepo, orderService, timeProvider, _ := createTestCommand()
// Set expiration time to 1 hour before current time to ensure it's expired
currentTime := timeProvider.Now()
expirationTime := currentTime.Add(-1 * time.Hour)
orderPlaced := false
var orderedItem *entities.ItemEntity
itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error {
return nil
}
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error {
orderPlaced = true
orderedItem = item
return nil
}
// When
resultID, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID)
// Then
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if resultID == "" {
t.Error("Expected non-empty result ID")
}
if !orderPlaced {
t.Error("Expected order to be placed for expired item")
}
if orderedItem == nil {
t.Error("Expected ordered item to be captured")
}
if orderedItem != nil && orderedItem.GetName() != itemName {
t.Errorf("Expected ordered item name %s, got %s", itemName, orderedItem.GetName())
}
}
func TestWhenItemNameIsEmptyThenErrorReturned(t *testing.T) {
// Given
cmd, _, _, _, _ := createTestCommand()
expirationTime, _ := time.Parse(dateFormat, notExpiredDate)
// When
_, err := cmd.Execute(context.Background(), "", expirationTime, orderURL, userID)
// Then
if err == nil {
t.Error("Expected error for empty item name")
}
}
func TestWhenOrderUrlIsEmptyThenErrorReturned(t *testing.T) {
// Given
cmd, _, _, _, _ := createTestCommand()
expirationTime, _ := time.Parse(dateFormat, notExpiredDate)
// When
_, err := cmd.Execute(context.Background(), itemName, expirationTime, "", userID)
// Then
if err == nil {
t.Error("Expected error for empty order URL")
}
}
func TestWhenUserIdIsEmptyThenErrorReturned(t *testing.T) {
// Given
cmd, _, _, _, _ := createTestCommand()
expirationTime, _ := time.Parse(dateFormat, notExpiredDate)
// When
_, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, "")
// Then
if err == nil {
t.Error("Expected error for empty user ID")
}
}
func TestWhenOrderServiceFailsThenErrorLogged(t *testing.T) {
// Given
cmd, itemRepo, orderService, _, _ := createTestCommand()
expirationTime, _ := time.Parse(dateFormat, expiredDate)
itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error {
return nil
}
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error {
return errors.New("order service failed")
}
// When - the handler should not throw an exception when the order service fails
// It should log the error and continue
resultID, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID)
// Then
if err != nil {
t.Errorf("Expected no error when order service fails, got %v", err)
}
if resultID == "" {
t.Error("Expected non-empty result ID even when order service fails")
}
}
func TestWhenRepositorySaveThrowsExceptionThenErrorReturned(t *testing.T) {
// Given
cmd, itemRepo, _, _, _ := createTestCommand()
expirationTime, _ := time.Parse(dateFormat, notExpiredDate)
expectedError := errors.New("repository error")
itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error {
return expectedError
}
// When
_, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID)
// Then
if err == nil {
t.Error("Expected error when repository save fails")
}
}
func TestWhenRepositorySaveThrowsExceptionThenOrderIsNotPlaced(t *testing.T) {
// Given
cmd, itemRepo, orderService, _, _ := createTestCommand()
expirationTime, _ := time.Parse(dateFormat, expiredDate)
expectedError := errors.New("repository error")
itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error {
return expectedError
}
orderPlaced := false
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error {
orderPlaced = true
return nil
}
// When
_, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID)
// Then
if err == nil {
t.Error("Expected error when repository save fails")
}
if orderPlaced {
t.Error("Expected order not to be placed when repository save fails")
}
}
func TestWhenTimeProviderThrowsExceptionThenErrorReturned(t *testing.T) {
// Given
cmd, _, _, timeProvider, _ := createTestCommand()
expirationTime, _ := time.Parse(dateFormat, notExpiredDate)
expectedError := errors.New("time provider error")
timeProvider.nowFunc = func() time.Time {
panic(expectedError)
}
// When
defer func() {
if r := recover(); r != nil {
// Expected panic
} else {
t.Error("Expected panic when time provider fails")
}
}()
cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID)
}
func TestWhenItemExpirationDateIsExactlyCurrentTimeThenOrderIsPlaced(t *testing.T) {
// Given
cmd, itemRepo, orderService, timeProvider, _ := createTestCommand()
// Use the exact current time from the time provider
currentTime := timeProvider.Now()
orderPlaced := false
itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error {
return nil
}
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error {
orderPlaced = true
return nil
}
// When
resultID, err := cmd.Execute(context.Background(), itemName, currentTime, orderURL, userID)
// Then
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if resultID == "" {
t.Error("Expected non-empty result ID")
}
if !orderPlaced {
t.Error("Expected order to be placed when expiration date equals current time")
}
}
func TestWhenItemExpirationDateIsInFutureThenItemSaved(t *testing.T) {
// Given
cmd, itemRepo, _, _, _ := createTestCommand()
expirationTime, _ := time.Parse(dateFormat, notExpiredDate)
itemSaved := false
itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error {
itemSaved = true
return nil
}
// When
resultID, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID)
// Then
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if resultID == "" {
t.Error("Expected non-empty result ID")
}
if !itemSaved {
t.Error("Expected item to be saved when expiration date is in future")
}
}

293
golang/tests/unit/handle_expired_items_command_test.go

@ -0,0 +1,293 @@
package unit
import (
"context"
"errors"
"testing"
"time"
"autostore/internal/application/commands"
"autostore/internal/domain/entities"
"autostore/internal/domain/specifications"
"autostore/internal/domain/value_objects"
)
func createExpiredItem1() *entities.ItemEntity {
expirationTime, _ := time.Parse(dateFormat, expiredDate)
itemID, _ := value_objects.NewItemIDFromString("550e8400-e29b-41d4-a716-446655440001")
userID, _ := value_objects.NewUserIDFromString("550e8400-e29b-41d4-a716-446655440003")
expirationDate, _ := value_objects.NewExpirationDate(expirationTime)
item, _ := entities.NewItem(itemID, "Expired Item 1", expirationDate, "http://example.com/order1", userID)
return item
}
func createExpiredItem2() *entities.ItemEntity {
expirationTime, _ := time.Parse(dateFormat, expiredDate)
itemID, _ := value_objects.NewItemIDFromString("550e8400-e29b-41d4-a716-446655440002")
userID, _ := value_objects.NewUserIDFromString("550e8400-e29b-41d4-a716-446655440004")
expirationDate, _ := value_objects.NewExpirationDate(expirationTime)
item, _ := entities.NewItem(itemID, "Expired Item 2", expirationDate, "http://example.com/order2", userID)
return item
}
func createTestHandleExpiredItemsCommand() (*commands.HandleExpiredItemsCommand, *mockItemRepository, *mockOrderService, *mockTimeProvider, *specifications.ItemExpirationSpec, *mockLogger) {
itemRepo := &mockItemRepository{}
orderService := &mockOrderService{}
timeProvider := &mockTimeProvider{
nowFunc: func() time.Time {
t, _ := time.Parse(dateFormat, mockedNow)
return t
},
}
expirationSpec := specifications.NewItemExpirationSpec()
logger := &mockLogger{}
cmd := commands.NewHandleExpiredItemsCommand(itemRepo, orderService, timeProvider, expirationSpec, logger)
return cmd, itemRepo, orderService, timeProvider, expirationSpec, logger
}
func TestWhenNoExpiredItemsExistThenNoOrdersPlaced(t *testing.T) {
// Given
cmd, itemRepo, orderService, _, _, logger := createTestHandleExpiredItemsCommand()
itemRepo.findWhereFunc = func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) {
return []*entities.ItemEntity{}, nil
}
orderCalled := false
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error {
orderCalled = true
return nil
}
deleteCalled := false
itemRepo.deleteFunc = func(ctx context.Context, id value_objects.ItemID) error {
deleteCalled = true
return nil
}
// When
err := cmd.Execute(context.Background())
// Then
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if orderCalled {
t.Error("Expected order service not to be called")
}
if deleteCalled {
t.Error("Expected delete not to be called")
}
if len(logger.errorLogs) > 0 {
t.Error("Expected no error logs")
}
}
func TestWhenExpiredItemsExistThenOrdersPlacedAndItemsDeleted(t *testing.T) {
// Given
cmd, itemRepo, orderService, _, _, logger := createTestHandleExpiredItemsCommand()
expiredItem1 := createExpiredItem1()
expiredItem2 := createExpiredItem2()
expiredItems := []*entities.ItemEntity{expiredItem1, expiredItem2}
itemRepo.findWhereFunc = func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) {
return expiredItems, nil
}
orderCallCount := 0
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error {
orderCallCount++
return nil
}
deleteCallCount := 0
itemRepo.deleteFunc = func(ctx context.Context, id value_objects.ItemID) error {
deleteCallCount++
return nil
}
// When
err := cmd.Execute(context.Background())
// Then
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if orderCallCount != 2 {
t.Errorf("Expected order service to be called 2 times, got %d", orderCallCount)
}
if deleteCallCount != 2 {
t.Errorf("Expected delete to be called 2 times, got %d", deleteCallCount)
}
if len(logger.errorLogs) > 0 {
t.Error("Expected no error logs")
}
}
func TestWhenOrderServiceFailsForOneItemThenErrorLoggedAndOtherItemProcessed(t *testing.T) {
// Given
cmd, itemRepo, orderService, _, _, logger := createTestHandleExpiredItemsCommand()
expiredItem1 := createExpiredItem1()
expiredItem2 := createExpiredItem2()
expiredItems := []*entities.ItemEntity{expiredItem1, expiredItem2}
itemRepo.findWhereFunc = func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) {
return expiredItems, nil
}
orderCallCount := 0
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error {
orderCallCount++
if orderCallCount == 1 {
return errors.New("order service failed")
}
return nil
}
deleteCallCount := 0
itemRepo.deleteFunc = func(ctx context.Context, id value_objects.ItemID) error {
deleteCallCount++
return nil
}
// When
err := cmd.Execute(context.Background())
// Then
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if orderCallCount != 2 {
t.Errorf("Expected order service to be called 2 times, got %d", orderCallCount)
}
if deleteCallCount != 1 {
t.Errorf("Expected delete to be called 1 time (only for successful order), got %d", deleteCallCount)
}
if len(logger.errorLogs) != 1 {
t.Errorf("Expected 1 error log, got %d", len(logger.errorLogs))
}
}
func TestWhenRepositoryFindThrowsErrorThenErrorReturned(t *testing.T) {
// Given
cmd, itemRepo, orderService, _, _, logger := createTestHandleExpiredItemsCommand()
expectedError := errors.New("repository find error")
itemRepo.findWhereFunc = func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) {
return nil, expectedError
}
orderCalled := false
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error {
orderCalled = true
return nil
}
// When
err := cmd.Execute(context.Background())
// Then
if err == nil {
t.Error("Expected error, got nil")
}
if orderCalled {
t.Error("Expected order service not to be called")
}
if len(logger.errorLogs) != 1 {
t.Errorf("Expected 1 error log, got %d", len(logger.errorLogs))
}
}
func TestWhenRepositoryDeleteThrowsExceptionThenErrorLogged(t *testing.T) {
// Given
cmd, itemRepo, orderService, _, _, logger := createTestHandleExpiredItemsCommand()
expiredItem1 := createExpiredItem1()
expiredItems := []*entities.ItemEntity{expiredItem1}
itemRepo.findWhereFunc = func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) {
return expiredItems, nil
}
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error {
return nil
}
expectedError := errors.New("delete failed")
itemRepo.deleteFunc = func(ctx context.Context, id value_objects.ItemID) error {
return expectedError
}
// When
err := cmd.Execute(context.Background())
// Then
if err == nil {
t.Error("Expected error, got nil")
}
if len(logger.errorLogs) != 1 {
t.Errorf("Expected 1 error log, got %d", len(logger.errorLogs))
}
}
func TestWhenTimeProviderThrowsErrorThenErrorReturned(t *testing.T) {
// Given
cmd, _, _, timeProvider, _, _ := createTestHandleExpiredItemsCommand()
timeProvider.nowFunc = func() time.Time {
panic("time provider error")
}
// When & Then
defer func() {
if r := recover(); r != nil {
// Expected panic
} else {
t.Error("Expected panic when time provider fails")
}
}()
cmd.Execute(context.Background())
}
func TestWhenAllOrderServicesFailThenAllErrorsLogged(t *testing.T) {
// Given
cmd, itemRepo, orderService, _, _, logger := createTestHandleExpiredItemsCommand()
expiredItem1 := createExpiredItem1()
expiredItem2 := createExpiredItem2()
expiredItems := []*entities.ItemEntity{expiredItem1, expiredItem2}
itemRepo.findWhereFunc = func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) {
return expiredItems, nil
}
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error {
return errors.New("order service failed")
}
deleteCalled := false
itemRepo.deleteFunc = func(ctx context.Context, id value_objects.ItemID) error {
deleteCalled = true
return nil
}
// When
err := cmd.Execute(context.Background())
// Then
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if deleteCalled {
t.Error("Expected delete not to be called when all orders fail")
}
if len(logger.errorLogs) != 2 {
t.Errorf("Expected 2 error logs, got %d", len(logger.errorLogs))
}
}

904
golang/tests/unit/specification_test.go

@ -0,0 +1,904 @@
package unit
import (
"testing"
"time"
"autostore/internal/domain/specifications"
)
// Test data constants
const (
TEST_STRING = "test-value"
TEST_INT = 42
TEST_FLOAT = 3.14
TEST_DATE = "2023-01-15 10:30:00"
TEST_DATE_2 = "2023-02-15 10:30:00"
INVALID_DATE = "invalid-date"
)
// TestObject is a simple struct for testing specifications
type TestObject struct {
name string
age int
price float64
status string
score int
role string
department string
active bool
optionalField interface{}
dateField string
createdAt time.Time
expirationDate time.Time
}
// NewTestObject creates a new TestObject with the given field values
func NewTestObject(fields map[string]interface{}) *TestObject {
obj := &TestObject{}
if val, ok := fields["name"]; ok {
obj.name = val.(string)
}
if val, ok := fields["age"]; ok {
obj.age = val.(int)
}
if val, ok := fields["price"]; ok {
obj.price = val.(float64)
}
if val, ok := fields["status"]; ok {
obj.status = val.(string)
}
if val, ok := fields["score"]; ok {
obj.score = val.(int)
}
if val, ok := fields["role"]; ok {
obj.role = val.(string)
}
if val, ok := fields["department"]; ok {
obj.department = val.(string)
}
if val, ok := fields["active"]; ok {
obj.active = val.(bool)
}
if val, ok := fields["optionalField"]; ok {
obj.optionalField = val
}
if val, ok := fields["dateField"]; ok {
obj.dateField = val.(string)
}
if val, ok := fields["createdAt"]; ok {
switch v := val.(type) {
case time.Time:
obj.createdAt = v
case string:
obj.createdAt, _ = time.Parse(dateFormat, v)
}
}
if val, ok := fields["expirationDate"]; ok {
switch v := val.(type) {
case time.Time:
obj.expirationDate = v
case string:
obj.expirationDate, _ = time.Parse(dateFormat, v)
}
}
return obj
}
// Getter methods for TestObject
func (o *TestObject) GetName() string {
return o.name
}
func (o *TestObject) GetAge() int {
return o.age
}
func (o *TestObject) GetPrice() float64 {
return o.price
}
func (o *TestObject) GetStatus() string {
return o.status
}
func (o *TestObject) GetScore() int {
return o.score
}
func (o *TestObject) GetRole() string {
return o.role
}
func (o *TestObject) GetDepartment() string {
return o.department
}
func (o *TestObject) GetActive() bool {
return o.active
}
func (o *TestObject) GetOptionalField() interface{} {
return o.optionalField
}
func (o *TestObject) GetDateField() string {
return o.dateField
}
func (o *TestObject) GetCreatedAt() time.Time {
return o.createdAt
}
func (o *TestObject) GetExpirationDate() time.Time {
return o.expirationDate
}
// EQ Operator Tests
func TestWhenUsingEqWithStringThenMatchesCorrectly(t *testing.T) {
// Given
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("name", TEST_STRING))
matchingObject := NewTestObject(map[string]interface{}{"name": TEST_STRING})
nonMatchingObject := NewTestObject(map[string]interface{}{"name": "different"})
// When
matchResult := spec.IsSatisfiedBy(matchingObject)
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject)
// Then
if !matchResult {
t.Error("Expected matching object to satisfy the specification")
}
if noMatchResult {
t.Error("Expected non-matching object to not satisfy the specification")
}
}
func TestWhenUsingEqWithIntegerThenMatchesCorrectly(t *testing.T) {
// Given
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("age", TEST_INT))
matchingObject := NewTestObject(map[string]interface{}{"age": TEST_INT})
nonMatchingObject := NewTestObject(map[string]interface{}{"age": 100})
// When
matchResult := spec.IsSatisfiedBy(matchingObject)
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject)
// Then
if !matchResult {
t.Error("Expected matching object to satisfy the specification")
}
if noMatchResult {
t.Error("Expected non-matching object to not satisfy the specification")
}
}
func TestWhenUsingEqWithFloatThenMatchesCorrectly(t *testing.T) {
// Given
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("price", TEST_FLOAT))
matchingObject := NewTestObject(map[string]interface{}{"price": TEST_FLOAT})
nonMatchingObject := NewTestObject(map[string]interface{}{"price": 1.0})
// When
matchResult := spec.IsSatisfiedBy(matchingObject)
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject)
// Then
if !matchResult {
t.Error("Expected matching object to satisfy the specification")
}
if noMatchResult {
t.Error("Expected non-matching object to not satisfy the specification")
}
}
// NEQ Operator Tests
func TestWhenUsingNeqThenMatchesCorrectly(t *testing.T) {
// Given
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Neq("status", "inactive"))
matchingObject := NewTestObject(map[string]interface{}{"status": "active"})
nonMatchingObject := NewTestObject(map[string]interface{}{"status": "inactive"})
// When
matchResult := spec.IsSatisfiedBy(matchingObject)
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject)
// Then
if !matchResult {
t.Error("Expected matching object to satisfy the specification")
}
if noMatchResult {
t.Error("Expected non-matching object to not satisfy the specification")
}
}
// Comparison Operators Tests
func TestWhenUsingGtThenMatchesCorrectly(t *testing.T) {
// Given
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Gt("score", 80))
matchingObject := NewTestObject(map[string]interface{}{"score": 90})
nonMatchingObject := NewTestObject(map[string]interface{}{"score": 70})
// When
matchResult := spec.IsSatisfiedBy(matchingObject)
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject)
// Then
if !matchResult {
t.Error("Expected matching object to satisfy the specification")
}
if noMatchResult {
t.Error("Expected non-matching object to not satisfy the specification")
}
}
func TestWhenUsingGteThenMatchesCorrectly(t *testing.T) {
// Given
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Gte("score", 80))
matchingObject1 := NewTestObject(map[string]interface{}{"score": 80})
matchingObject2 := NewTestObject(map[string]interface{}{"score": 90})
nonMatchingObject := NewTestObject(map[string]interface{}{"score": 70})
// When
matchResult1 := spec.IsSatisfiedBy(matchingObject1)
matchResult2 := spec.IsSatisfiedBy(matchingObject2)
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject)
// Then
if !matchResult1 {
t.Error("Expected matching object 1 to satisfy the specification")
}
if !matchResult2 {
t.Error("Expected matching object 2 to satisfy the specification")
}
if noMatchResult {
t.Error("Expected non-matching object to not satisfy the specification")
}
}
func TestWhenUsingLtThenMatchesCorrectly(t *testing.T) {
// Given
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Lt("score", 80))
matchingObject := NewTestObject(map[string]interface{}{"score": 70})
nonMatchingObject := NewTestObject(map[string]interface{}{"score": 90})
// When
matchResult := spec.IsSatisfiedBy(matchingObject)
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject)
// Then
if !matchResult {
t.Error("Expected matching object to satisfy the specification")
}
if noMatchResult {
t.Error("Expected non-matching object to not satisfy the specification")
}
}
func TestWhenUsingLteThenMatchesCorrectly(t *testing.T) {
// Given
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Lte("score", 80))
matchingObject1 := NewTestObject(map[string]interface{}{"score": 80})
matchingObject2 := NewTestObject(map[string]interface{}{"score": 70})
nonMatchingObject := NewTestObject(map[string]interface{}{"score": 90})
// When
matchResult1 := spec.IsSatisfiedBy(matchingObject1)
matchResult2 := spec.IsSatisfiedBy(matchingObject2)
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject)
// Then
if !matchResult1 {
t.Error("Expected matching object 1 to satisfy the specification")
}
if !matchResult2 {
t.Error("Expected matching object 2 to satisfy the specification")
}
if noMatchResult {
t.Error("Expected non-matching object to not satisfy the specification")
}
}
// IN Operator Tests
func TestWhenUsingInThenMatchesCorrectly(t *testing.T) {
// Given
validValues := []interface{}{"admin", "moderator", "editor"}
spec := specifications.NewSimpleSpecification[*TestObject](specifications.In("role", validValues))
matchingObject := NewTestObject(map[string]interface{}{"role": "admin"})
nonMatchingObject := NewTestObject(map[string]interface{}{"role": "user"})
// When
matchResult := spec.IsSatisfiedBy(matchingObject)
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject)
// Then
if !matchResult {
t.Error("Expected matching object to satisfy the specification")
}
if noMatchResult {
t.Error("Expected non-matching object to not satisfy the specification")
}
}
func TestWhenUsingInWithEmptyArrayThenNeverMatches(t *testing.T) {
// Given
validValues := []interface{}{}
spec := specifications.NewSimpleSpecification[*TestObject](specifications.In("role", validValues))
testObject := NewTestObject(map[string]interface{}{"role": "admin"})
// When
result := spec.IsSatisfiedBy(testObject)
// Then
if result {
t.Error("Expected object to not satisfy the specification with empty array")
}
}
// NOT IN Operator Tests
func TestWhenUsingNotInThenMatchesCorrectly(t *testing.T) {
// Given
invalidValues := []interface{}{"banned", "suspended"}
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Nin("status", invalidValues))
matchingObject := NewTestObject(map[string]interface{}{"status": "active"})
nonMatchingObject := NewTestObject(map[string]interface{}{"status": "banned"})
// When
matchResult := spec.IsSatisfiedBy(matchingObject)
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject)
// Then
if !matchResult {
t.Error("Expected matching object to satisfy the specification")
}
if noMatchResult {
t.Error("Expected non-matching object to not satisfy the specification")
}
}
// DateTime Tests
func TestWhenUsingDateTimeComparisonWithStringsThenMatchesCorrectly(t *testing.T) {
// Given
testDate2, _ := time.Parse(dateFormat, TEST_DATE_2)
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Lt("createdAt", testDate2))
matchingObject := NewTestObject(map[string]interface{}{"createdAt": TEST_DATE})
nonMatchingObject := NewTestObject(map[string]interface{}{"createdAt": TEST_DATE_2})
// When
matchResult := spec.IsSatisfiedBy(matchingObject)
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject)
// Then
if !matchResult {
t.Error("Expected matching object to satisfy the specification")
}
if noMatchResult {
t.Error("Expected non-matching object to not satisfy the specification")
}
}
func TestWhenUsingDateTimeComparisonWithTimeObjectsThenMatchesCorrectly(t *testing.T) {
// Given
testDate, _ := time.Parse(dateFormat, TEST_DATE)
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Lte("expirationDate", testDate))
matchingDate, _ := time.Parse(dateFormat, TEST_DATE)
nonMatchingDate, _ := time.Parse(dateFormat, TEST_DATE_2)
matchingObject := NewTestObject(map[string]interface{}{"expirationDate": matchingDate})
nonMatchingObject := NewTestObject(map[string]interface{}{"expirationDate": nonMatchingDate})
// When
matchResult := spec.IsSatisfiedBy(matchingObject)
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject)
// Then
if !matchResult {
t.Error("Expected matching object to satisfy the specification")
}
if noMatchResult {
t.Error("Expected non-matching object to not satisfy the specification")
}
}
// AND Group Tests
func TestWhenUsingAndGroupThenMatchesOnlyWhenAllConditionsMet(t *testing.T) {
// Given
spec := specifications.NewSimpleSpecification[*TestObject](specifications.And(
specifications.Eq("status", "active"),
specifications.Gte("score", 80),
specifications.In("role", []interface{}{"admin", "moderator"}),
))
matchingObject := NewTestObject(map[string]interface{}{
"status": "active",
"score": 85,
"role": "admin",
})
nonMatchingObject1 := NewTestObject(map[string]interface{}{
"status": "inactive",
"score": 85,
"role": "admin",
})
nonMatchingObject2 := NewTestObject(map[string]interface{}{
"status": "active",
"score": 70,
"role": "admin",
})
nonMatchingObject3 := NewTestObject(map[string]interface{}{
"status": "active",
"score": 85,
"role": "user",
})
// When
matchResult := spec.IsSatisfiedBy(matchingObject)
noMatchResult1 := spec.IsSatisfiedBy(nonMatchingObject1)
noMatchResult2 := spec.IsSatisfiedBy(nonMatchingObject2)
noMatchResult3 := spec.IsSatisfiedBy(nonMatchingObject3)
// Then
if !matchResult {
t.Error("Expected matching object to satisfy the specification")
}
if noMatchResult1 {
t.Error("Expected non-matching object 1 to not satisfy the specification")
}
if noMatchResult2 {
t.Error("Expected non-matching object 2 to not satisfy the specification")
}
if noMatchResult3 {
t.Error("Expected non-matching object 3 to not satisfy the specification")
}
}
// OR Group Tests
func TestWhenUsingOrGroupThenMatchesWhenAnyConditionMet(t *testing.T) {
// Given
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Or(
specifications.Eq("role", "admin"),
specifications.Gte("score", 90),
specifications.In("department", []interface{}{"IT", "HR"}),
))
matchingObject1 := NewTestObject(map[string]interface{}{
"role": "admin",
"score": 70,
"department": "Finance",
})
matchingObject2 := NewTestObject(map[string]interface{}{
"role": "user",
"score": 95,
"department": "Finance",
})
matchingObject3 := NewTestObject(map[string]interface{}{
"role": "user",
"score": 70,
"department": "IT",
})
nonMatchingObject := NewTestObject(map[string]interface{}{
"role": "user",
"score": 70,
"department": "Finance",
})
// When
matchResult1 := spec.IsSatisfiedBy(matchingObject1)
matchResult2 := spec.IsSatisfiedBy(matchingObject2)
matchResult3 := spec.IsSatisfiedBy(matchingObject3)
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject)
// Then
if !matchResult1 {
t.Error("Expected matching object 1 to satisfy the specification")
}
if !matchResult2 {
t.Error("Expected matching object 2 to satisfy the specification")
}
if !matchResult3 {
t.Error("Expected matching object 3 to satisfy the specification")
}
if noMatchResult {
t.Error("Expected non-matching object to not satisfy the specification")
}
}
// NOT Group Tests
func TestWhenUsingNotGroupThenInvertsCondition(t *testing.T) {
// Given
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Not(specifications.Eq("status", "banned")))
matchingObject := NewTestObject(map[string]interface{}{"status": "active"})
nonMatchingObject := NewTestObject(map[string]interface{}{"status": "banned"})
// When
matchResult := spec.IsSatisfiedBy(matchingObject)
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject)
// Then
if !matchResult {
t.Error("Expected matching object to satisfy the specification")
}
if noMatchResult {
t.Error("Expected non-matching object to not satisfy the specification")
}
}
// Complex Nested Groups Tests
func TestWhenUsingNestedAndOrGroupsThenMatchesCorrectly(t *testing.T) {
// Given
spec := specifications.NewSimpleSpecification[*TestObject](specifications.And(
specifications.Eq("status", "active"),
specifications.Or(
specifications.Gte("score", 80),
specifications.In("role", []interface{}{"admin", "moderator"}),
),
))
matchingObject1 := NewTestObject(map[string]interface{}{
"status": "active",
"score": 85,
"role": "user",
})
matchingObject2 := NewTestObject(map[string]interface{}{
"status": "active",
"score": 70,
"role": "admin",
})
nonMatchingObject := NewTestObject(map[string]interface{}{
"status": "inactive",
"score": 85,
"role": "user",
})
// When
matchResult1 := spec.IsSatisfiedBy(matchingObject1)
matchResult2 := spec.IsSatisfiedBy(matchingObject2)
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject)
// Then
if !matchResult1 {
t.Error("Expected matching object 1 to satisfy the specification")
}
if !matchResult2 {
t.Error("Expected matching object 2 to satisfy the specification")
}
if noMatchResult {
t.Error("Expected non-matching object to not satisfy the specification")
}
}
func TestWhenUsingTripleNestedGroupsThenMatchesCorrectly(t *testing.T) {
// Given
spec := specifications.NewSimpleSpecification[*TestObject](specifications.And(
specifications.Eq("active", true),
specifications.Not(specifications.Or(
specifications.Eq("role", "banned"),
specifications.Eq("status", "suspended"),
)),
))
matchingObject := NewTestObject(map[string]interface{}{
"active": true,
"role": "user",
"status": "active",
})
nonMatchingObject1 := NewTestObject(map[string]interface{}{
"active": false,
"role": "user",
"status": "active",
})
nonMatchingObject2 := NewTestObject(map[string]interface{}{
"active": true,
"role": "banned",
"status": "active",
})
// When
matchResult := spec.IsSatisfiedBy(matchingObject)
noMatchResult1 := spec.IsSatisfiedBy(nonMatchingObject1)
noMatchResult2 := spec.IsSatisfiedBy(nonMatchingObject2)
// Then
if !matchResult {
t.Error("Expected matching object to satisfy the specification")
}
if noMatchResult1 {
t.Error("Expected non-matching object 1 to not satisfy the specification")
}
if noMatchResult2 {
t.Error("Expected non-matching object 2 to not satisfy the specification")
}
}
// Edge Case Tests
func TestWhenFieldDoesNotExistThenReturnsFalse(t *testing.T) {
// Given
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("nonExistentField", "value"))
testObject := NewTestObject(map[string]interface{}{"existingField": "value"})
// When
result := spec.IsSatisfiedBy(testObject)
// Then
if result {
t.Error("Expected object to not satisfy the specification when field doesn't exist")
}
}
func TestWhenFieldIsNilThenReturnsFalse(t *testing.T) {
// Given
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("optionalField", "value"))
testObject := NewTestObject(map[string]interface{}{"optionalField": nil})
// When
result := spec.IsSatisfiedBy(testObject)
// Then
if result {
t.Error("Expected object to not satisfy the specification when field is nil")
}
}
func TestWhenUsingInvalidDateStringThenFallsBackToRegularComparison(t *testing.T) {
// Given
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("dateField", INVALID_DATE))
testObject := NewTestObject(map[string]interface{}{"dateField": INVALID_DATE})
// When
result := spec.IsSatisfiedBy(testObject)
// Then
if !result {
t.Error("Expected object to satisfy the specification with invalid date string")
}
}
// Spec Helper Method Tests
func TestWhenUsingSpecHelpersThenCreatesCorrectSpecification(t *testing.T) {
// Given
eqSpec := specifications.Eq("field", "value")
neqSpec := specifications.Neq("field", "value")
gtSpec := specifications.Gt("field", 10)
gteSpec := specifications.Gte("field", 10)
ltSpec := specifications.Lt("field", 10)
lteSpec := specifications.Lte("field", 10)
inSpec := specifications.In("field", []interface{}{"a", "b"})
ninSpec := specifications.Nin("field", []interface{}{"a", "b"})
// When & Then
if eqSpec.Condition == nil || eqSpec.Condition.Operator != specifications.OP_EQ || eqSpec.Condition.Value != "value" {
t.Error("EQ spec not created correctly")
}
if neqSpec.Condition == nil || neqSpec.Condition.Operator != specifications.OP_NEQ || neqSpec.Condition.Value != "value" {
t.Error("NEQ spec not created correctly")
}
if gtSpec.Condition == nil || gtSpec.Condition.Operator != specifications.OP_GT || gtSpec.Condition.Value != 10 {
t.Error("GT spec not created correctly")
}
if gteSpec.Condition == nil || gteSpec.Condition.Operator != specifications.OP_GTE || gteSpec.Condition.Value != 10 {
t.Error("GTE spec not created correctly")
}
if ltSpec.Condition == nil || ltSpec.Condition.Operator != specifications.OP_LT || ltSpec.Condition.Value != 10 {
t.Error("LT spec not created correctly")
}
if lteSpec.Condition == nil || lteSpec.Condition.Operator != specifications.OP_LTE || lteSpec.Condition.Value != 10 {
t.Error("LTE spec not created correctly")
}
if inSpec.Condition == nil || inSpec.Condition.Operator != specifications.OP_IN {
t.Error("IN spec not created correctly")
}
if ninSpec.Condition == nil || ninSpec.Condition.Operator != specifications.OP_NIN {
t.Error("NIN spec not created correctly")
}
}
func TestWhenUsingLogicalGroupHelpersThenCreatesCorrectSpecification(t *testing.T) {
// Given
andSpec := specifications.And(specifications.Eq("a", 1), specifications.Eq("b", 2))
orSpec := specifications.Or(specifications.Eq("a", 1), specifications.Eq("b", 2))
notSpec := specifications.Not(specifications.Eq("a", 1))
// When & Then
if andSpec.LogicalGroup == nil || andSpec.LogicalGroup.Operator != specifications.GROUP_AND || len(andSpec.LogicalGroup.Conditions) != 2 {
t.Error("AND spec not created correctly")
}
if orSpec.LogicalGroup == nil || orSpec.LogicalGroup.Operator != specifications.GROUP_OR || len(orSpec.LogicalGroup.Conditions) != 2 {
t.Error("OR spec not created correctly")
}
if notSpec.LogicalGroup == nil || notSpec.LogicalGroup.Operator != specifications.GROUP_NOT || notSpec.LogicalGroup.Spec == nil {
t.Error("NOT spec not created correctly")
}
}
func TestGetSpecReturnsOriginalSpecification(t *testing.T) {
// Given
originalSpec := specifications.Eq("field", "value")
specification := specifications.NewSimpleSpecification[*TestObject](originalSpec)
// When
retrievedSpec := specification.GetSpec()
// Then
if retrievedSpec != originalSpec {
t.Error("Expected retrieved spec to be the same as original spec")
}
}
func TestGetConditionsReturnsAllConditions(t *testing.T) {
// Given
spec := specifications.NewSimpleSpecification[*TestObject](specifications.And(
specifications.Eq("status", "active"),
specifications.Gte("score", 80),
))
// When
conditions := spec.GetConditions()
// Then
if len(conditions) != 2 {
t.Error("Expected 2 conditions")
}
// Check that both conditions are present
foundStatus := false
foundScore := false
for _, cond := range conditions {
if cond.Field == "status" && cond.Operator == specifications.OP_EQ && cond.Value == "active" {
foundStatus = true
}
if cond.Field == "score" && cond.Operator == specifications.OP_GTE && cond.Value == 80 {
foundScore = true
}
}
if !foundStatus {
t.Error("Expected status condition to be found")
}
if !foundScore {
t.Error("Expected score condition to be found")
}
}
// Composite Specification Tests
func TestCompositeSpecificationAndOperation(t *testing.T) {
// Given
leftSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("status", "active"))
rightSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Gte("score", 80))
compositeSpec := leftSpec.And(rightSpec)
matchingObject := NewTestObject(map[string]interface{}{
"status": "active",
"score": 85,
})
nonMatchingObject := NewTestObject(map[string]interface{}{
"status": "inactive",
"score": 85,
})
// When
matchResult := compositeSpec.IsSatisfiedBy(matchingObject)
noMatchResult := compositeSpec.IsSatisfiedBy(nonMatchingObject)
// Then
if !matchResult {
t.Error("Expected matching object to satisfy the composite specification")
}
if noMatchResult {
t.Error("Expected non-matching object to not satisfy the composite specification")
}
}
func TestCompositeSpecificationOrOperation(t *testing.T) {
// Given
leftSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("role", "admin"))
rightSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Gte("score", 90))
compositeSpec := leftSpec.Or(rightSpec)
matchingObject1 := NewTestObject(map[string]interface{}{
"role": "admin",
"score": 70,
})
matchingObject2 := NewTestObject(map[string]interface{}{
"role": "user",
"score": 95,
})
nonMatchingObject := NewTestObject(map[string]interface{}{
"role": "user",
"score": 70,
})
// When
matchResult1 := compositeSpec.IsSatisfiedBy(matchingObject1)
matchResult2 := compositeSpec.IsSatisfiedBy(matchingObject2)
noMatchResult := compositeSpec.IsSatisfiedBy(nonMatchingObject)
// Then
if !matchResult1 {
t.Error("Expected matching object 1 to satisfy the composite specification")
}
if !matchResult2 {
t.Error("Expected matching object 2 to satisfy the composite specification")
}
if noMatchResult {
t.Error("Expected non-matching object to not satisfy the composite specification")
}
}
func TestCompositeSpecificationNotOperation(t *testing.T) {
// Given
baseSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("status", "banned"))
compositeSpec := baseSpec.Not()
matchingObject := NewTestObject(map[string]interface{}{"status": "active"})
nonMatchingObject := NewTestObject(map[string]interface{}{"status": "banned"})
// When
matchResult := compositeSpec.IsSatisfiedBy(matchingObject)
noMatchResult := compositeSpec.IsSatisfiedBy(nonMatchingObject)
// Then
if !matchResult {
t.Error("Expected matching object to satisfy the NOT specification")
}
if noMatchResult {
t.Error("Expected non-matching object to not satisfy the NOT specification")
}
}
func TestCompositeSpecificationGetConditions(t *testing.T) {
// Given
leftSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("status", "active"))
rightSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Gte("score", 80))
compositeSpec := leftSpec.And(rightSpec)
// When
conditions := compositeSpec.GetConditions()
// Then
if len(conditions) != 2 {
t.Error("Expected 2 conditions from composite specification")
}
// Check that both conditions are present
foundStatus := false
foundScore := false
for _, cond := range conditions {
if cond.Field == "status" && cond.Operator == specifications.OP_EQ && cond.Value == "active" {
foundStatus = true
}
if cond.Field == "score" && cond.Operator == specifications.OP_GTE && cond.Value == 80 {
foundScore = true
}
}
if !foundStatus {
t.Error("Expected status condition to be found")
}
if !foundScore {
t.Error("Expected score condition to be found")
}
}
func TestCompositeSpecificationGetSpecReturnsNil(t *testing.T) {
// Given
leftSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("status", "active"))
rightSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Gte("score", 80))
compositeSpec := leftSpec.And(rightSpec)
// When
spec := compositeSpec.GetSpec()
// Then
if spec != nil {
t.Error("Expected composite specification to return nil for GetSpec")
}
}

99
golang/tests/unit/test_utils.go

@ -0,0 +1,99 @@
package unit
import (
"context"
"time"
"autostore/internal/domain/entities"
"autostore/internal/domain/specifications"
"autostore/internal/domain/value_objects"
)
// Common constants for tests
const (
mockedNow = "2023-01-01 12:00:00"
notExpiredDate = "2023-01-02 12:00:00"
expiredDate = "2022-12-31 12:00:00"
itemName = "Test Item"
orderURL = "http://example.com/order"
userID = "550e8400-e29b-41d4-a716-446655440000" // Valid UUID
dateFormat = "2006-01-02 15:04:05"
)
// Mock implementations shared across tests
type mockItemRepository struct {
saveFunc func(ctx context.Context, item *entities.ItemEntity) error
findWhereFunc func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error)
deleteFunc func(ctx context.Context, id value_objects.ItemID) error
}
func (m *mockItemRepository) Save(ctx context.Context, item *entities.ItemEntity) error {
if m.saveFunc != nil {
return m.saveFunc(ctx, item)
}
return nil
}
func (m *mockItemRepository) FindByID(ctx context.Context, id value_objects.ItemID) (*entities.ItemEntity, error) {
return nil, nil
}
func (m *mockItemRepository) FindByUserID(ctx context.Context, userID value_objects.UserID) ([]*entities.ItemEntity, error) {
return nil, nil
}
func (m *mockItemRepository) FindWhere(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) {
if m.findWhereFunc != nil {
return m.findWhereFunc(ctx, spec)
}
return nil, nil
}
func (m *mockItemRepository) Delete(ctx context.Context, id value_objects.ItemID) error {
if m.deleteFunc != nil {
return m.deleteFunc(ctx, id)
}
return nil
}
func (m *mockItemRepository) Exists(ctx context.Context, id value_objects.ItemID) (bool, error) {
return false, nil
}
type mockOrderService struct {
orderItemFunc func(ctx context.Context, item *entities.ItemEntity) error
}
func (m *mockOrderService) OrderItem(ctx context.Context, item *entities.ItemEntity) error {
if m.orderItemFunc != nil {
return m.orderItemFunc(ctx, item)
}
return nil
}
type mockTimeProvider struct {
nowFunc func() time.Time
}
func (m *mockTimeProvider) Now() time.Time {
if m.nowFunc != nil {
return m.nowFunc()
}
return time.Now()
}
type mockLogger struct {
infoLogs []string
errorLogs []string
}
func (m *mockLogger) Info(ctx context.Context, msg string, fields ...interface{}) {
m.infoLogs = append(m.infoLogs, msg)
}
func (m *mockLogger) Error(ctx context.Context, msg string, fields ...interface{}) {
m.errorLogs = append(m.errorLogs, msg)
}
func (m *mockLogger) Debug(ctx context.Context, msg string, fields ...interface{}) {}
func (m *mockLogger) Warn(ctx context.Context, msg string, fields ...interface{}) {}
Loading…
Cancel
Save