Compare commits
No commits in common. 'master' and 'ts-nestjs' have entirely different histories.
76 changed files with 82 additions and 6030 deletions
@ -1,28 +1,14 @@ |
|||||||
FROM kuyoh/vcpkg:2025.06.13-ubuntu24.04 AS builder |
FROM kuyoh/vcpkg:2025.06.13-ubuntu24.04 AS base |
||||||
|
|
||||||
WORKDIR /workspace |
WORKDIR /workspace |
||||||
|
|
||||||
COPY ../CMakeLists.txt . |
|
||||||
COPY ../vcpkg.json . |
|
||||||
|
|
||||||
RUN vcpkg install |
|
||||||
|
|
||||||
# Cche stays valid if only code changes |
|
||||||
COPY .. . |
COPY .. . |
||||||
|
|
||||||
|
# generate and build |
||||||
RUN cmake -DCMAKE_TOOLCHAIN_FILE:STRING=${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake \ |
RUN cmake -DCMAKE_TOOLCHAIN_FILE:STRING=${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake \ |
||||||
-DCMAKE_EXPORT_COMPILE_COMMANDS:BOOL=TRUE -DCMAKE_BUILD_TYPE:STRING=Release \ |
-DCMAKE_EXPORT_COMPILE_COMMANDS:BOOL=TRUE -DCMAKE_BUILD_TYPE:STRING=Release \ |
||||||
-H/workspace -B/workspace/build -G Ninja |
-H/workspace -B/workspace/build -G Ninja |
||||||
RUN cmake --build /workspace/build --config Release --target all -j 8 -- |
RUN cmake --build /workspace/build --config Release --target all -j 8 -- |
||||||
|
|
||||||
# run tests |
|
||||||
RUN cd /workspace/build && ctest --output-on-failure . |
RUN cd /workspace/build && ctest --output-on-failure . |
||||||
|
|
||||||
FROM ubuntu:24.04 AS runtime |
CMD ["/workspace/build/bin/AutoStore"] |
||||||
|
|
||||||
WORKDIR /app |
|
||||||
|
|
||||||
COPY --from=builder /workspace/build/bin/AutoStore ./AutoStore |
|
||||||
COPY --from=builder /workspace/build/bin/data ./data |
|
||||||
|
|
||||||
CMD ["./AutoStore"] |
|
||||||
|
|||||||
@ -1,31 +0,0 @@ |
|||||||
FROM golang:1.25.1-alpine3.22 |
|
||||||
|
|
||||||
WORKDIR /usr/src/app |
|
||||||
|
|
||||||
# Install system dependencies |
|
||||||
RUN apk add --no-cache \ |
|
||||||
git \ |
|
||||||
bash \ |
|
||||||
curl \ |
|
||||||
sudo |
|
||||||
|
|
||||||
# Configure user and group IDs (default: 1000:1000) |
|
||||||
ARG USER_ID=1000 |
|
||||||
ARG GROUP_ID=1000 |
|
||||||
|
|
||||||
# Create a group and user with specific UID/GID |
|
||||||
RUN addgroup -g ${GROUP_ID} developer \ |
|
||||||
&& adduser -D -u ${USER_ID} -G developer -s /bin/bash developer \ |
|
||||||
&& echo "developer ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/developer \ |
|
||||||
&& chmod 0440 /etc/sudoers.d/developer |
|
||||||
|
|
||||||
RUN chown -R ${USER_ID}:${GROUP_ID} /usr/src/app |
|
||||||
|
|
||||||
USER developer |
|
||||||
|
|
||||||
# Install Go tools |
|
||||||
RUN go install github.com/go-delve/delve/cmd/dlv@latest |
|
||||||
|
|
||||||
EXPOSE 3000 |
|
||||||
|
|
||||||
CMD ["go", "run", "main.go"] |
|
||||||
@ -1,25 +0,0 @@ |
|||||||
{ |
|
||||||
"name": "Go dev container", |
|
||||||
"dockerComposeFile": "./docker-compose.yml", |
|
||||||
"service": "app", |
|
||||||
"workspaceFolder": "/usr/src/app", |
|
||||||
"customizations": { |
|
||||||
"vscode": { |
|
||||||
"settings": { |
|
||||||
"terminal.integrated.defaultProfile.linux": "bash", |
|
||||||
"go.useLanguageServer": true, |
|
||||||
"go.gopath": "/go", |
|
||||||
"go.goroot": "/usr/local/go" |
|
||||||
}, |
|
||||||
"extensions": [ |
|
||||||
"golang.go", |
|
||||||
"ms-vscode.go-tools", |
|
||||||
"ms-vscode.vscode-go", |
|
||||||
"ms-vscode.vscode-docker" |
|
||||||
] |
|
||||||
} |
|
||||||
}, |
|
||||||
"forwardPorts": [3000], |
|
||||||
"remoteUser": "developer", |
|
||||||
"postCreateCommand": "sudo chown -R developer:1000 /usr/src/app && go mod tidy" |
|
||||||
} |
|
||||||
@ -1,29 +0,0 @@ |
|||||||
version: "3.9" |
|
||||||
services: |
|
||||||
app: |
|
||||||
build: |
|
||||||
context: .. |
|
||||||
dockerfile: .devcontainer/Dockerfile |
|
||||||
args: |
|
||||||
USER_ID: ${USER_ID:-1000} |
|
||||||
GROUP_ID: ${GROUP_ID:-1000} |
|
||||||
image: dev-golang-img |
|
||||||
container_name: dev-golang |
|
||||||
user: "developer" |
|
||||||
volumes: |
|
||||||
- ../:/usr/src/app:cached |
|
||||||
- golang_modules:/go/pkg/mod |
|
||||||
environment: |
|
||||||
NODE_ENV: development |
|
||||||
ports: |
|
||||||
- "50080:3000" |
|
||||||
networks: |
|
||||||
- dev-network |
|
||||||
command: sleep infinity |
|
||||||
|
|
||||||
volumes: |
|
||||||
golang_modules: |
|
||||||
|
|
||||||
networks: |
|
||||||
dev-network: |
|
||||||
driver: bridge |
|
||||||
@ -1,183 +0,0 @@ |
|||||||
# Specification Pattern Implementation |
|
||||||
|
|
||||||
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. |
|
||||||
|
|
||||||
## Core Components |
|
||||||
|
|
||||||
### 1. Basic Data Structures (`condition_spec.go`) |
|
||||||
|
|
||||||
#### `Condition` |
|
||||||
Represents a single comparison operation: |
|
||||||
```go |
|
||||||
type Condition struct { |
|
||||||
Field string // Field name (e.g., "age", "name") |
|
||||||
Operator string // Comparison operator (e.g., "=", ">", "IN") |
|
||||||
Value interface{} // Value to compare against |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
#### `LogicalGroup` |
|
||||||
Combines multiple conditions with logical operators: |
|
||||||
```go |
|
||||||
type LogicalGroup struct { |
|
||||||
Operator string // "AND", "OR", or "NOT" |
|
||||||
Conditions []Condition // Simple conditions |
|
||||||
Spec *Spec // Nested specification for complex logic |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
#### `Spec` |
|
||||||
The main specification container (can hold either a single condition or a logical group): |
|
||||||
```go |
|
||||||
type Spec struct { |
|
||||||
Condition *Condition // For simple conditions |
|
||||||
LogicalGroup *LogicalGroup // For complex logic |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
### 2. Builder Functions |
|
||||||
|
|
||||||
These functions create specifications in a fluent, readable way: |
|
||||||
|
|
||||||
```go |
|
||||||
// Simple conditions |
|
||||||
userSpec := Eq("name", "John") // name = "John" |
|
||||||
ageSpec := Gt("age", 18) // age > 18 |
|
||||||
roleSpec := In("role", []interface{}{"admin", "moderator"}) // role IN ("admin", "moderator") |
|
||||||
|
|
||||||
// Logical combinations |
|
||||||
complexSpec := And( |
|
||||||
Eq("status", "active"), |
|
||||||
Or( |
|
||||||
Gt("age", 21), |
|
||||||
Eq("role", "admin"), |
|
||||||
), |
|
||||||
) |
|
||||||
``` |
|
||||||
|
|
||||||
### 3. Specification Interface (`simple_specification.go`) |
|
||||||
|
|
||||||
The `Specification[T]` interface provides methods for: |
|
||||||
- **Evaluation**: `IsSatisfiedBy(candidate T) bool` |
|
||||||
- **Composition**: `And()`, `Or()`, `Not()` |
|
||||||
- **Introspection**: `GetConditions()`, `GetSpec()` |
|
||||||
|
|
||||||
## How It Works |
|
||||||
|
|
||||||
### 1. Building Specifications |
|
||||||
|
|
||||||
```go |
|
||||||
// Create a specification for active users over 18 |
|
||||||
spec := And( |
|
||||||
Eq("status", "active"), |
|
||||||
Gt("age", 18), |
|
||||||
) |
|
||||||
``` |
|
||||||
|
|
||||||
### 2. Evaluating Specifications |
|
||||||
|
|
||||||
The `SimpleSpecification` uses reflection to evaluate conditions against Go objects: |
|
||||||
|
|
||||||
```go |
|
||||||
user := &User{Name: "John", Age: 25, Status: "active"} |
|
||||||
specification := NewSimpleSpecification[*User](spec) |
|
||||||
isMatch := specification.IsSatisfiedBy(user) // true |
|
||||||
``` |
|
||||||
|
|
||||||
### 3. Field Access |
|
||||||
|
|
||||||
The implementation looks for field values in this order: |
|
||||||
1. `GetFieldName()` method (e.g., `GetAge()`) |
|
||||||
2. `FieldName()` method (e.g., `Age()`) |
|
||||||
3. Exported struct field |
|
||||||
|
|
||||||
### 4. Type Comparisons |
|
||||||
|
|
||||||
The system handles different data types intelligently: |
|
||||||
- **Numbers**: Direct comparison with type conversion |
|
||||||
- **Strings**: Lexicographic comparison |
|
||||||
- **Time**: Special handling for `time.Time` and objects with `Time()` methods |
|
||||||
- **Collections**: `IN` and `NOT IN` operations |
|
||||||
- **Nil values**: Proper null handling |
|
||||||
|
|
||||||
## Examples |
|
||||||
|
|
||||||
### Simple Usage |
|
||||||
```go |
|
||||||
// Find all active users |
|
||||||
activeSpec := Eq("status", "active") |
|
||||||
spec := NewSimpleSpecification[*User](activeSpec) |
|
||||||
|
|
||||||
users := []User{{Status: "active"}, {Status: "inactive"}} |
|
||||||
for _, user := range users { |
|
||||||
if spec.IsSatisfiedBy(&user) { |
|
||||||
fmt.Println("Active user:", user.Name) |
|
||||||
} |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
### Complex Logic |
|
||||||
```go |
|
||||||
// Find admin users OR users with high scores |
|
||||||
complexSpec := Or( |
|
||||||
Eq("role", "admin"), |
|
||||||
And( |
|
||||||
Gt("score", 90), |
|
||||||
Eq("status", "active"), |
|
||||||
), |
|
||||||
) |
|
||||||
``` |
|
||||||
|
|
||||||
### Time Comparisons |
|
||||||
```go |
|
||||||
// Find expired items |
|
||||||
expiredSpec := Lte("expirationDate", time.Now()) |
|
||||||
``` |
|
||||||
|
|
||||||
## Key Benefits |
|
||||||
|
|
||||||
1. **Type Safety**: Compile-time checking with generics |
|
||||||
2. **Readability**: Business logic expressed in readable Go code |
|
||||||
3. **Reusability**: Specifications can be composed and reused |
|
||||||
4. **Testability**: Easy to test business rules in isolation |
|
||||||
5. **Flexibility**: Can be converted to SQL, used for filtering, etc. |
|
||||||
|
|
||||||
## Performance Considerations |
|
||||||
|
|
||||||
- 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 |
|
||||||
|
|
||||||
## Common Patterns |
|
||||||
|
|
||||||
### Repository Integration |
|
||||||
```go |
|
||||||
type UserRepository interface { |
|
||||||
FindWhere(ctx context.Context, spec Specification[*User]) ([]*User, error) |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
### Business Rule Encapsulation |
|
||||||
```go |
|
||||||
func ActiveUserSpec() *Spec { |
|
||||||
return And( |
|
||||||
Eq("status", "active"), |
|
||||||
Neq("deletedAt", nil), |
|
||||||
) |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
### Dynamic Query Building |
|
||||||
```go |
|
||||||
func BuildUserSearchSpec(filters UserFilters) *Spec { |
|
||||||
conditions := []*Spec{} |
|
||||||
|
|
||||||
if filters.Status != "" { |
|
||||||
conditions = append(conditions, Eq("status", filters.Status)) |
|
||||||
} |
|
||||||
if filters.MinAge > 0 { |
|
||||||
conditions = append(conditions, Gte("age", filters.MinAge)) |
|
||||||
} |
|
||||||
|
|
||||||
return And(conditions...) |
|
||||||
} |
|
||||||
@ -1,94 +0,0 @@ |
|||||||
package main |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
"log" |
|
||||||
"net/http" |
|
||||||
"os" |
|
||||||
"os/signal" |
|
||||||
"syscall" |
|
||||||
"time" |
|
||||||
|
|
||||||
"autostore/internal/application/interfaces" |
|
||||||
"autostore/internal/config" |
|
||||||
"autostore/internal/container" |
|
||||||
) |
|
||||||
|
|
||||||
func main() { |
|
||||||
// Load configuration
|
|
||||||
cfg, err := config.Load() |
|
||||||
if err != nil { |
|
||||||
log.Fatalf("Failed to load configuration: %v", err) |
|
||||||
} |
|
||||||
|
|
||||||
// Validate configuration
|
|
||||||
if err := cfg.Validate(); err != nil { |
|
||||||
log.Fatalf("Invalid configuration: %v", err) |
|
||||||
} |
|
||||||
|
|
||||||
// Create dependency injection container
|
|
||||||
container := container.NewContainer(cfg) |
|
||||||
if err := container.Initialize(); err != nil { |
|
||||||
log.Fatalf("Failed to initialize container: %v", err) |
|
||||||
} |
|
||||||
|
|
||||||
// Get server and scheduler from container
|
|
||||||
server := container.GetServer() |
|
||||||
scheduler := container.GetExpiredItemsScheduler() |
|
||||||
logger := container.GetLogger() |
|
||||||
|
|
||||||
// Setup graceful shutdown
|
|
||||||
shutdownComplete := make(chan struct{}) |
|
||||||
go setupGracefulShutdown(server, scheduler, logger, shutdownComplete) |
|
||||||
|
|
||||||
// Start scheduler
|
|
||||||
if err := scheduler.Start(context.Background()); err != nil { |
|
||||||
logger.Error(context.Background(), "Failed to start scheduler", "error", err) |
|
||||||
log.Fatalf("Failed to start scheduler: %v", err) |
|
||||||
} |
|
||||||
|
|
||||||
// Start server
|
|
||||||
if err := server.Start(); err != nil { |
|
||||||
if err == http.ErrServerClosed { |
|
||||||
// This is expected during graceful shutdown
|
|
||||||
logger.Info(context.Background(), "Server shutdown complete") |
|
||||||
} else { |
|
||||||
logger.Error(context.Background(), "Server failed to start", "error", err) |
|
||||||
log.Fatalf("Server failed to start: %v", err) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Wait for graceful shutdown to complete
|
|
||||||
<-shutdownComplete |
|
||||||
logger.Info(context.Background(), "Application exiting gracefully") |
|
||||||
} |
|
||||||
|
|
||||||
func setupGracefulShutdown(server interface { |
|
||||||
Shutdown(ctx context.Context) error |
|
||||||
}, scheduler interface { |
|
||||||
Stop() error |
|
||||||
}, logger interfaces.ILogger, shutdownComplete chan struct{}) { |
|
||||||
sigChan := make(chan os.Signal, 1) |
|
||||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) |
|
||||||
|
|
||||||
sig := <-sigChan |
|
||||||
logger.Info(context.Background(), "Received shutdown signal", "signal", sig) |
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
|
||||||
defer cancel() |
|
||||||
|
|
||||||
if err := scheduler.Stop(); err != nil { |
|
||||||
logger.Error(ctx, "Scheduler shutdown failed", "error", err) |
|
||||||
} else { |
|
||||||
logger.Info(ctx, "Scheduler shutdown completed gracefully") |
|
||||||
} |
|
||||||
|
|
||||||
if err := server.Shutdown(ctx); err != nil { |
|
||||||
logger.Error(ctx, "Server shutdown failed", "error", err) |
|
||||||
} else { |
|
||||||
logger.Info(ctx, "Server shutdown completed gracefully") |
|
||||||
} |
|
||||||
|
|
||||||
// Signal that shutdown is complete
|
|
||||||
close(shutdownComplete) |
|
||||||
} |
|
||||||
@ -1,4 +0,0 @@ |
|||||||
.devcontainer |
|
||||||
.git |
|
||||||
.gitignore |
|
||||||
README.md |
|
||||||
@ -1,33 +0,0 @@ |
|||||||
FROM golang:1.21-alpine AS builder |
|
||||||
|
|
||||||
WORKDIR /app |
|
||||||
|
|
||||||
COPY go.mod go.sum ./ |
|
||||||
|
|
||||||
# Download all dependencies |
|
||||||
RUN go mod download |
|
||||||
|
|
||||||
COPY . . |
|
||||||
|
|
||||||
# Generate go.sum |
|
||||||
RUN go mod tidy |
|
||||||
|
|
||||||
# Run tests |
|
||||||
RUN go test ./tests/unit -v && \ |
|
||||||
go test ./tests/integration -v |
|
||||||
|
|
||||||
# Build the application |
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd |
|
||||||
|
|
||||||
# Runtime stage |
|
||||||
FROM alpine:latest |
|
||||||
|
|
||||||
RUN apk --no-cache add ca-certificates |
|
||||||
|
|
||||||
WORKDIR /root/ |
|
||||||
|
|
||||||
COPY --from=builder /app/main . |
|
||||||
|
|
||||||
EXPOSE 3000 |
|
||||||
|
|
||||||
CMD ["./main"] |
|
||||||
@ -1,17 +0,0 @@ |
|||||||
version: "3.9" |
|
||||||
services: |
|
||||||
app: |
|
||||||
build: |
|
||||||
context: .. |
|
||||||
dockerfile: docker/Dockerfile |
|
||||||
image: golang-autostore-img |
|
||||||
container_name: golang-autostore-app |
|
||||||
ports: |
|
||||||
- "50080:3000" |
|
||||||
networks: |
|
||||||
- app-network |
|
||||||
restart: unless-stopped |
|
||||||
|
|
||||||
networks: |
|
||||||
app-network: |
|
||||||
driver: bridge |
|
||||||
@ -1,39 +0,0 @@ |
|||||||
module autostore |
|
||||||
|
|
||||||
go 1.21 |
|
||||||
|
|
||||||
require ( |
|
||||||
github.com/gin-gonic/gin v1.9.1 |
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2 |
|
||||||
github.com/google/uuid v1.3.0 |
|
||||||
github.com/stretchr/testify v1.8.3 |
|
||||||
golang.org/x/crypto v0.9.0 |
|
||||||
) |
|
||||||
|
|
||||||
require ( |
|
||||||
github.com/bytedance/sonic v1.9.1 // indirect |
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect |
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect |
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect |
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect |
|
||||||
github.com/go-playground/locales v0.14.1 // indirect |
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect |
|
||||||
github.com/go-playground/validator/v10 v10.14.0 // indirect |
|
||||||
github.com/goccy/go-json v0.10.2 // indirect |
|
||||||
github.com/json-iterator/go v1.1.12 // indirect |
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect |
|
||||||
github.com/leodido/go-urn v1.2.4 // indirect |
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect |
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect |
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect |
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect |
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect |
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect |
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect |
|
||||||
golang.org/x/arch v0.3.0 // indirect |
|
||||||
golang.org/x/net v0.10.0 // indirect |
|
||||||
golang.org/x/sys v0.8.0 // indirect |
|
||||||
golang.org/x/text v0.9.0 // indirect |
|
||||||
google.golang.org/protobuf v1.30.0 // indirect |
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect |
|
||||||
) |
|
||||||
@ -1,90 +0,0 @@ |
|||||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= |
|
||||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= |
|
||||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= |
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= |
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= |
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= |
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= |
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= |
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= |
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= |
|
||||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= |
|
||||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= |
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= |
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= |
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= |
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= |
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= |
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= |
|
||||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= |
|
||||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= |
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= |
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= |
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= |
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= |
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= |
|
||||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= |
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= |
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= |
|
||||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= |
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= |
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= |
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= |
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= |
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= |
|
||||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= |
|
||||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= |
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= |
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= |
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= |
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= |
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= |
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= |
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= |
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= |
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= |
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= |
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= |
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= |
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= |
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= |
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= |
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= |
|
||||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= |
|
||||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= |
|
||||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= |
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= |
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= |
|
||||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= |
|
||||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= |
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= |
|
||||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= |
|
||||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= |
|
||||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= |
|
||||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= |
|
||||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= |
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= |
|
||||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|
||||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= |
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|
||||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= |
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= |
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= |
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= |
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= |
|
||||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= |
|
||||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= |
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= |
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= |
|
||||||
@ -1,97 +0,0 @@ |
|||||||
package commands |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
"errors" |
|
||||||
"fmt" |
|
||||||
"time" |
|
||||||
|
|
||||||
"autostore/internal/application/interfaces" |
|
||||||
"autostore/internal/domain/entities" |
|
||||||
"autostore/internal/domain/specifications" |
|
||||||
"autostore/internal/domain/value_objects" |
|
||||||
) |
|
||||||
|
|
||||||
var ( |
|
||||||
ErrItemCreationFailed = errors.New("failed to create item") |
|
||||||
ErrInvalidUserID = errors.New("invalid user ID") |
|
||||||
) |
|
||||||
|
|
||||||
type AddItemCommand struct { |
|
||||||
itemRepo interfaces.IItemRepository |
|
||||||
orderService interfaces.IOrderService |
|
||||||
timeProvider interfaces.ITimeProvider |
|
||||||
expirationSpec *specifications.ItemExpirationSpec |
|
||||||
logger interfaces.ILogger |
|
||||||
} |
|
||||||
|
|
||||||
func NewAddItemCommand( |
|
||||||
itemRepo interfaces.IItemRepository, |
|
||||||
orderService interfaces.IOrderService, |
|
||||||
timeProvider interfaces.ITimeProvider, |
|
||||||
expirationSpec *specifications.ItemExpirationSpec, |
|
||||||
logger interfaces.ILogger, |
|
||||||
) *AddItemCommand { |
|
||||||
return &AddItemCommand{ |
|
||||||
itemRepo: itemRepo, |
|
||||||
orderService: orderService, |
|
||||||
timeProvider: timeProvider, |
|
||||||
expirationSpec: expirationSpec, |
|
||||||
logger: logger, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (c *AddItemCommand) Execute(ctx context.Context, name string, expirationDate time.Time, orderURL string, userID string) (string, error) { |
|
||||||
c.logger.Info(ctx, "Executing AddItemCommand", "name", name, "userID", userID) |
|
||||||
|
|
||||||
// Convert string IDs to value objects
|
|
||||||
itemID, err := value_objects.NewRandomItemID() |
|
||||||
if err != nil { |
|
||||||
c.logger.Error(ctx, "Failed to generate item ID", "error", err) |
|
||||||
return "", fmt.Errorf("%w: %v", ErrItemCreationFailed, err) |
|
||||||
} |
|
||||||
|
|
||||||
userIDObj, err := value_objects.NewUserID(userID) |
|
||||||
if err != nil { |
|
||||||
c.logger.Error(ctx, "Invalid user ID", "userID", userID, "error", err) |
|
||||||
return "", fmt.Errorf("%w: %v", ErrInvalidUserID, err) |
|
||||||
} |
|
||||||
|
|
||||||
// Create expiration date value object
|
|
||||||
expirationDateObj, err := value_objects.NewExpirationDate(expirationDate) |
|
||||||
if err != nil { |
|
||||||
c.logger.Error(ctx, "Invalid expiration date", "expirationDate", expirationDate, "error", err) |
|
||||||
return "", fmt.Errorf("%w: %v", ErrItemCreationFailed, err) |
|
||||||
} |
|
||||||
|
|
||||||
// Create item entity
|
|
||||||
item, err := entities.NewItem(itemID, name, expirationDateObj, orderURL, userIDObj) |
|
||||||
if err != nil { |
|
||||||
c.logger.Error(ctx, "Failed to create item entity", "error", err) |
|
||||||
return "", fmt.Errorf("%w: %v", ErrItemCreationFailed, err) |
|
||||||
} |
|
||||||
|
|
||||||
// Get current time and check if item is expired
|
|
||||||
currentTime := c.timeProvider.Now() |
|
||||||
isExpired := c.expirationSpec.IsExpired(item, currentTime) |
|
||||||
|
|
||||||
// Save the item first (even if expired, as per business rule #4)
|
|
||||||
if err := c.itemRepo.Save(ctx, item); err != nil { |
|
||||||
c.logger.Error(ctx, "Failed to save item", "itemID", itemID.String(), "error", err) |
|
||||||
return "", fmt.Errorf("%w: %v", ErrItemCreationFailed, err) |
|
||||||
} |
|
||||||
|
|
||||||
if isExpired { |
|
||||||
c.logger.Info(ctx, "Item is expired, triggering order", "itemID", itemID.String()) |
|
||||||
|
|
||||||
// Business rule: When an item expires, a new item of the same type is automatically ordered
|
|
||||||
if err := c.orderService.OrderItem(ctx, item); err != nil { |
|
||||||
c.logger.Error(ctx, "Failed to order expired item", "itemID", itemID.String(), "error", err) |
|
||||||
// Don't fail the entire operation if ordering fails, just log it
|
|
||||||
c.logger.Warn(ctx, "Item created but ordering failed", "itemID", itemID.String(), "error", err) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
c.logger.Info(ctx, "Item created successfully", "itemID", itemID.String()) |
|
||||||
return itemID.String(), nil |
|
||||||
} |
|
||||||
@ -1,69 +0,0 @@ |
|||||||
package commands |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
"fmt" |
|
||||||
|
|
||||||
"autostore/internal/application/interfaces" |
|
||||||
"autostore/internal/domain/value_objects" |
|
||||||
) |
|
||||||
|
|
||||||
var ( |
|
||||||
ErrItemDeletionFailed = fmt.Errorf("failed to delete item") |
|
||||||
ErrItemNotFound = fmt.Errorf("item not found") |
|
||||||
ErrUnauthorizedAccess = fmt.Errorf("unauthorized access to item") |
|
||||||
) |
|
||||||
|
|
||||||
type DeleteItemCommand struct { |
|
||||||
itemRepo interfaces.IItemRepository |
|
||||||
logger interfaces.ILogger |
|
||||||
} |
|
||||||
|
|
||||||
func NewDeleteItemCommand( |
|
||||||
itemRepo interfaces.IItemRepository, |
|
||||||
logger interfaces.ILogger, |
|
||||||
) *DeleteItemCommand { |
|
||||||
return &DeleteItemCommand{ |
|
||||||
itemRepo: itemRepo, |
|
||||||
logger: logger, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (c *DeleteItemCommand) Execute(ctx context.Context, itemID string, userID string) error { |
|
||||||
c.logger.Info(ctx, "Executing DeleteItemCommand", "itemID", itemID, "userID", userID) |
|
||||||
|
|
||||||
// Convert string IDs to value objects
|
|
||||||
itemIDObj, err := value_objects.NewItemID(itemID) |
|
||||||
if err != nil { |
|
||||||
c.logger.Error(ctx, "Invalid item ID", "itemID", itemID, "error", err) |
|
||||||
return fmt.Errorf("invalid item ID: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
userIDObj, err := value_objects.NewUserID(userID) |
|
||||||
if err != nil { |
|
||||||
c.logger.Error(ctx, "Invalid user ID", "userID", userID, "error", err) |
|
||||||
return fmt.Errorf("invalid user ID: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
// Find item by ID
|
|
||||||
item, err := c.itemRepo.FindByID(ctx, itemIDObj) |
|
||||||
if err != nil { |
|
||||||
c.logger.Error(ctx, "Failed to find item", "itemID", itemID, "error", err) |
|
||||||
return fmt.Errorf("%w: %v", ErrItemNotFound, err) |
|
||||||
} |
|
||||||
|
|
||||||
// Validate ownership - only the item's owner can delete it
|
|
||||||
if !item.GetUserID().Equals(userIDObj) { |
|
||||||
c.logger.Warn(ctx, "Unauthorized deletion attempt", "itemID", itemID, "userID", userID, "ownerID", item.GetUserID().String()) |
|
||||||
return ErrUnauthorizedAccess |
|
||||||
} |
|
||||||
|
|
||||||
// Delete the item
|
|
||||||
if err := c.itemRepo.Delete(ctx, itemIDObj); err != nil { |
|
||||||
c.logger.Error(ctx, "Failed to delete item", "itemID", itemID, "error", err) |
|
||||||
return fmt.Errorf("%w: %v", ErrItemDeletionFailed, err) |
|
||||||
} |
|
||||||
|
|
||||||
c.logger.Info(ctx, "Item deleted successfully", "itemID", itemID, "userID", userID) |
|
||||||
return nil |
|
||||||
} |
|
||||||
@ -1,67 +0,0 @@ |
|||||||
package commands |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
"fmt" |
|
||||||
|
|
||||||
"autostore/internal/application/interfaces" |
|
||||||
"autostore/internal/domain/specifications" |
|
||||||
) |
|
||||||
|
|
||||||
type HandleExpiredItemsCommand struct { |
|
||||||
itemRepo interfaces.IItemRepository |
|
||||||
orderService interfaces.IOrderService |
|
||||||
timeProvider interfaces.ITimeProvider |
|
||||||
expirationSpec *specifications.ItemExpirationSpec |
|
||||||
logger interfaces.ILogger |
|
||||||
} |
|
||||||
|
|
||||||
func NewHandleExpiredItemsCommand( |
|
||||||
itemRepo interfaces.IItemRepository, |
|
||||||
orderService interfaces.IOrderService, |
|
||||||
timeProvider interfaces.ITimeProvider, |
|
||||||
expirationSpec *specifications.ItemExpirationSpec, |
|
||||||
logger interfaces.ILogger, |
|
||||||
) *HandleExpiredItemsCommand { |
|
||||||
return &HandleExpiredItemsCommand{ |
|
||||||
itemRepo: itemRepo, |
|
||||||
orderService: orderService, |
|
||||||
timeProvider: timeProvider, |
|
||||||
expirationSpec: expirationSpec, |
|
||||||
logger: logger, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (c *HandleExpiredItemsCommand) Execute(ctx context.Context) error { |
|
||||||
c.logger.Info(ctx, "Starting expired items processing") |
|
||||||
|
|
||||||
currentTime := c.timeProvider.Now() |
|
||||||
expirationSpec := c.expirationSpec.GetSpec(currentTime) |
|
||||||
|
|
||||||
expiredItems, err := c.itemRepo.FindWhere(ctx, expirationSpec) |
|
||||||
if err != nil { |
|
||||||
c.logger.Error(ctx, "Failed to find expired items", "error", err) |
|
||||||
return fmt.Errorf("failed to find expired items: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
c.logger.Info(ctx, "Found expired items", "count", len(expiredItems)) |
|
||||||
|
|
||||||
for _, item := range expiredItems { |
|
||||||
c.logger.Info(ctx, "Processing expired item", "item_id", item.GetID().String(), "item_name", item.GetName()) |
|
||||||
|
|
||||||
if err := c.orderService.OrderItem(ctx, item); err != nil { |
|
||||||
c.logger.Error(ctx, "Failed to order replacement item", "item_id", item.GetID().String(), "error", err) |
|
||||||
continue |
|
||||||
} |
|
||||||
|
|
||||||
if err := c.itemRepo.Delete(ctx, item.GetID()); err != nil { |
|
||||||
c.logger.Error(ctx, "Failed to delete expired item", "item_id", item.GetID().String(), "error", err) |
|
||||||
return fmt.Errorf("failed to delete expired item %s: %w", item.GetID().String(), err) |
|
||||||
} |
|
||||||
|
|
||||||
c.logger.Info(ctx, "Successfully processed expired item", "item_id", item.GetID().String()) |
|
||||||
} |
|
||||||
|
|
||||||
c.logger.Info(ctx, "Completed expired items processing", "processed_count", len(expiredItems)) |
|
||||||
return nil |
|
||||||
} |
|
||||||
@ -1,35 +0,0 @@ |
|||||||
package commands |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
|
|
||||||
"autostore/internal/application/interfaces" |
|
||||||
) |
|
||||||
|
|
||||||
type LoginUserCommand struct { |
|
||||||
authService interfaces.IAuthService |
|
||||||
logger interfaces.ILogger |
|
||||||
} |
|
||||||
|
|
||||||
func NewLoginUserCommand( |
|
||||||
authService interfaces.IAuthService, |
|
||||||
logger interfaces.ILogger, |
|
||||||
) *LoginUserCommand { |
|
||||||
return &LoginUserCommand{ |
|
||||||
authService: authService, |
|
||||||
logger: logger, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (c *LoginUserCommand) Execute(ctx context.Context, username string, password string) (string, error) { |
|
||||||
c.logger.Info(ctx, "Executing login command", "username", username) |
|
||||||
|
|
||||||
token, err := c.authService.Authenticate(ctx, username, password) |
|
||||||
if err != nil { |
|
||||||
c.logger.Warn(ctx, "Authentication failed", "username", username, "error", err) |
|
||||||
return "", err |
|
||||||
} |
|
||||||
|
|
||||||
c.logger.Info(ctx, "Login successful", "username", username) |
|
||||||
return token, nil |
|
||||||
} |
|
||||||
@ -1,34 +0,0 @@ |
|||||||
package dto |
|
||||||
|
|
||||||
import ( |
|
||||||
"errors" |
|
||||||
"net/url" |
|
||||||
) |
|
||||||
|
|
||||||
var ( |
|
||||||
ErrInvalidItemName = errors.New("item name cannot be empty") |
|
||||||
ErrInvalidOrderURL = errors.New("invalid order URL format") |
|
||||||
ErrInvalidExpirationDate = errors.New("invalid expiration date") |
|
||||||
) |
|
||||||
|
|
||||||
type CreateItemDTO struct { |
|
||||||
Name string `json:"name" binding:"required"` |
|
||||||
ExpirationDate JSONTime `json:"expirationDate" binding:"required"` |
|
||||||
OrderURL string `json:"orderUrl" binding:"required"` |
|
||||||
} |
|
||||||
|
|
||||||
func (dto *CreateItemDTO) Validate() error { |
|
||||||
if dto.Name == "" { |
|
||||||
return ErrInvalidItemName |
|
||||||
} |
|
||||||
|
|
||||||
if _, err := url.ParseRequestURI(dto.OrderURL); err != nil { |
|
||||||
return ErrInvalidOrderURL |
|
||||||
} |
|
||||||
|
|
||||||
if dto.ExpirationDate.Time.IsZero() { |
|
||||||
return ErrInvalidExpirationDate |
|
||||||
} |
|
||||||
|
|
||||||
return nil |
|
||||||
} |
|
||||||
@ -1,25 +0,0 @@ |
|||||||
package dto |
|
||||||
|
|
||||||
import ( |
|
||||||
"autostore/internal/domain/entities" |
|
||||||
) |
|
||||||
|
|
||||||
type ItemResponseDTO struct { |
|
||||||
ID string `json:"id"` |
|
||||||
Name string `json:"name"` |
|
||||||
ExpirationDate JSONTime `json:"expirationDate"` |
|
||||||
OrderURL string `json:"orderUrl"` |
|
||||||
UserID string `json:"userId"` |
|
||||||
CreatedAt JSONTime `json:"createdAt"` |
|
||||||
} |
|
||||||
|
|
||||||
func (dto *ItemResponseDTO) FromEntity(item *entities.ItemEntity) *ItemResponseDTO { |
|
||||||
return &ItemResponseDTO{ |
|
||||||
ID: item.GetID().String(), |
|
||||||
Name: item.GetName(), |
|
||||||
ExpirationDate: JSONTime{item.GetExpirationDate().Time()}, |
|
||||||
OrderURL: item.GetOrderURL(), |
|
||||||
UserID: item.GetUserID().String(), |
|
||||||
CreatedAt: JSONTime{item.GetCreatedAt()}, |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,31 +0,0 @@ |
|||||||
package dto |
|
||||||
|
|
||||||
type JSendResponse struct { |
|
||||||
Status string `json:"status"` |
|
||||||
Data interface{} `json:"data,omitempty"` |
|
||||||
Message string `json:"message,omitempty"` |
|
||||||
Code int `json:"code,omitempty"` |
|
||||||
} |
|
||||||
|
|
||||||
func JSendSuccess(data interface{}) JSendResponse { |
|
||||||
return JSendResponse{ |
|
||||||
Status: "success", |
|
||||||
Data: data, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func JSendError(message string, code int) JSendResponse { |
|
||||||
return JSendResponse{ |
|
||||||
Status: "error", |
|
||||||
Message: message, |
|
||||||
Code: code, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func JSendFail(message string, code int) JSendResponse { |
|
||||||
return JSendResponse{ |
|
||||||
Status: "fail", |
|
||||||
Message: message, |
|
||||||
Code: code, |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,50 +0,0 @@ |
|||||||
package dto |
|
||||||
|
|
||||||
import ( |
|
||||||
"encoding/json" |
|
||||||
"fmt" |
|
||||||
"time" |
|
||||||
) |
|
||||||
|
|
||||||
// JSONTime is a custom time type that can unmarshal from JSON strings
|
|
||||||
type JSONTime struct { |
|
||||||
time.Time |
|
||||||
} |
|
||||||
|
|
||||||
// UnmarshalJSON implements json.Unmarshaler interface
|
|
||||||
func (jt *JSONTime) UnmarshalJSON(data []byte) error { |
|
||||||
// Remove quotes from JSON string
|
|
||||||
var str string |
|
||||||
if err := json.Unmarshal(data, &str); err != nil { |
|
||||||
return err |
|
||||||
} |
|
||||||
|
|
||||||
// Try to parse with different formats
|
|
||||||
formats := []string{ |
|
||||||
time.RFC3339, |
|
||||||
"2006-01-02T15:04:05", |
|
||||||
"2006-01-02T15:04:05Z", |
|
||||||
"2006-01-02T15:04:05.000Z", |
|
||||||
"2006-01-02T15:04:05.000000Z", |
|
||||||
"2006-01-02T15:04:05.000000000Z", |
|
||||||
} |
|
||||||
|
|
||||||
for _, format := range formats { |
|
||||||
if t, err := time.Parse(format, str); err == nil { |
|
||||||
jt.Time = t |
|
||||||
return nil |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return fmt.Errorf("unable to parse time: %s", str) |
|
||||||
} |
|
||||||
|
|
||||||
// MarshalJSON implements json.Marshaler interface
|
|
||||||
func (jt JSONTime) MarshalJSON() ([]byte, error) { |
|
||||||
return json.Marshal(jt.Time.Format(time.RFC3339)) |
|
||||||
} |
|
||||||
|
|
||||||
// String returns the time in RFC3339 format
|
|
||||||
func (jt JSONTime) String() string { |
|
||||||
return jt.Time.Format(time.RFC3339) |
|
||||||
} |
|
||||||
@ -1,33 +0,0 @@ |
|||||||
package dto |
|
||||||
|
|
||||||
import ( |
|
||||||
"errors" |
|
||||||
) |
|
||||||
|
|
||||||
var ( |
|
||||||
ErrInvalidUsername = errors.New("username cannot be empty") |
|
||||||
ErrInvalidPassword = errors.New("password cannot be empty") |
|
||||||
) |
|
||||||
|
|
||||||
type LoginDTO struct { |
|
||||||
Username string `json:"username" binding:"required"` |
|
||||||
Password string `json:"password" binding:"required"` |
|
||||||
} |
|
||||||
|
|
||||||
func (dto *LoginDTO) Validate() error { |
|
||||||
if dto.Username == "" { |
|
||||||
return ErrInvalidUsername |
|
||||||
} |
|
||||||
|
|
||||||
if dto.Password == "" { |
|
||||||
return ErrInvalidPassword |
|
||||||
} |
|
||||||
|
|
||||||
return nil |
|
||||||
} |
|
||||||
|
|
||||||
type LoginResponseDTO struct { |
|
||||||
Token string `json:"token"` |
|
||||||
TokenType string `json:"tokenType"` |
|
||||||
ExpiresIn int `json:"expiresIn"` |
|
||||||
} |
|
||||||
@ -1,5 +0,0 @@ |
|||||||
package errors |
|
||||||
|
|
||||||
import "errors" |
|
||||||
|
|
||||||
var ErrNotImplemented = errors.New("not implemented") |
|
||||||
@ -1,11 +0,0 @@ |
|||||||
package interfaces |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
) |
|
||||||
|
|
||||||
type IAuthService interface { |
|
||||||
Authenticate(ctx context.Context, username string, password string) (string, error) |
|
||||||
ValidateToken(ctx context.Context, token string) (bool, error) |
|
||||||
GetUserIDFromToken(ctx context.Context, token string) (string, error) |
|
||||||
} |
|
||||||
@ -1,17 +0,0 @@ |
|||||||
package interfaces |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
"autostore/internal/domain/entities" |
|
||||||
"autostore/internal/domain/specifications" |
|
||||||
"autostore/internal/domain/value_objects" |
|
||||||
) |
|
||||||
|
|
||||||
type IItemRepository interface { |
|
||||||
Save(ctx context.Context, item *entities.ItemEntity) error |
|
||||||
FindByID(ctx context.Context, id value_objects.ItemID) (*entities.ItemEntity, error) |
|
||||||
FindByUserID(ctx context.Context, userID value_objects.UserID) ([]*entities.ItemEntity, error) |
|
||||||
FindWhere(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) |
|
||||||
Delete(ctx context.Context, id value_objects.ItemID) error |
|
||||||
Exists(ctx context.Context, id value_objects.ItemID) (bool, error) |
|
||||||
} |
|
||||||
@ -1,12 +0,0 @@ |
|||||||
package interfaces |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
) |
|
||||||
|
|
||||||
type ILogger interface { |
|
||||||
Info(ctx context.Context, msg string, fields ...interface{}) |
|
||||||
Error(ctx context.Context, msg string, fields ...interface{}) |
|
||||||
Debug(ctx context.Context, msg string, fields ...interface{}) |
|
||||||
Warn(ctx context.Context, msg string, fields ...interface{}) |
|
||||||
} |
|
||||||
@ -1,10 +0,0 @@ |
|||||||
package interfaces |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
"autostore/internal/domain/entities" |
|
||||||
) |
|
||||||
|
|
||||||
type IOrderService interface { |
|
||||||
OrderItem(ctx context.Context, item *entities.ItemEntity) error |
|
||||||
} |
|
||||||
@ -1,9 +0,0 @@ |
|||||||
package interfaces |
|
||||||
|
|
||||||
import ( |
|
||||||
"time" |
|
||||||
) |
|
||||||
|
|
||||||
type ITimeProvider interface { |
|
||||||
Now() time.Time |
|
||||||
} |
|
||||||
@ -1,13 +0,0 @@ |
|||||||
package interfaces |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
"autostore/internal/domain/entities" |
|
||||||
"autostore/internal/domain/value_objects" |
|
||||||
) |
|
||||||
|
|
||||||
type IUserRepository interface { |
|
||||||
FindByUsername(ctx context.Context, username string) (*entities.UserEntity, error) |
|
||||||
FindByID(ctx context.Context, id value_objects.UserID) (*entities.UserEntity, error) |
|
||||||
Save(ctx context.Context, user *entities.UserEntity) error |
|
||||||
} |
|
||||||
@ -1,63 +0,0 @@ |
|||||||
package queries |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
"fmt" |
|
||||||
|
|
||||||
"autostore/internal/application/interfaces" |
|
||||||
"autostore/internal/domain/entities" |
|
||||||
"autostore/internal/domain/value_objects" |
|
||||||
) |
|
||||||
|
|
||||||
var ( |
|
||||||
ErrItemNotFound = fmt.Errorf("item not found") |
|
||||||
ErrUnauthorizedAccess = fmt.Errorf("unauthorized access to item") |
|
||||||
) |
|
||||||
|
|
||||||
type GetItemQuery struct { |
|
||||||
itemRepo interfaces.IItemRepository |
|
||||||
logger interfaces.ILogger |
|
||||||
} |
|
||||||
|
|
||||||
func NewGetItemQuery( |
|
||||||
itemRepo interfaces.IItemRepository, |
|
||||||
logger interfaces.ILogger, |
|
||||||
) *GetItemQuery { |
|
||||||
return &GetItemQuery{ |
|
||||||
itemRepo: itemRepo, |
|
||||||
logger: logger, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (q *GetItemQuery) Execute(ctx context.Context, itemID string, userID string) (*entities.ItemEntity, error) { |
|
||||||
q.logger.Info(ctx, "Executing GetItemQuery", "itemID", itemID, "userID", userID) |
|
||||||
|
|
||||||
// Convert string IDs to value objects
|
|
||||||
itemIDObj, err := value_objects.NewItemID(itemID) |
|
||||||
if err != nil { |
|
||||||
q.logger.Error(ctx, "Invalid item ID", "itemID", itemID, "error", err) |
|
||||||
return nil, fmt.Errorf("invalid item ID: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
userIDObj, err := value_objects.NewUserID(userID) |
|
||||||
if err != nil { |
|
||||||
q.logger.Error(ctx, "Invalid user ID", "userID", userID, "error", err) |
|
||||||
return nil, fmt.Errorf("invalid user ID: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
// Find item by ID
|
|
||||||
item, err := q.itemRepo.FindByID(ctx, itemIDObj) |
|
||||||
if err != nil { |
|
||||||
q.logger.Error(ctx, "Failed to find item", "itemID", itemID, "error", err) |
|
||||||
return nil, fmt.Errorf("%w: %v", ErrItemNotFound, err) |
|
||||||
} |
|
||||||
|
|
||||||
// Validate ownership - only the item's owner can access it
|
|
||||||
if !item.GetUserID().Equals(userIDObj) { |
|
||||||
q.logger.Warn(ctx, "Unauthorized access attempt", "itemID", itemID, "userID", userID, "ownerID", item.GetUserID().String()) |
|
||||||
return nil, ErrUnauthorizedAccess |
|
||||||
} |
|
||||||
|
|
||||||
q.logger.Info(ctx, "Item retrieved successfully", "itemID", itemID, "userID", userID) |
|
||||||
return item, nil |
|
||||||
} |
|
||||||
@ -1,50 +0,0 @@ |
|||||||
package queries |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
"fmt" |
|
||||||
|
|
||||||
"autostore/internal/application/interfaces" |
|
||||||
"autostore/internal/domain/entities" |
|
||||||
"autostore/internal/domain/value_objects" |
|
||||||
) |
|
||||||
|
|
||||||
var ( |
|
||||||
ErrFailedToListItems = fmt.Errorf("failed to list items") |
|
||||||
) |
|
||||||
|
|
||||||
type ListItemsQuery struct { |
|
||||||
itemRepo interfaces.IItemRepository |
|
||||||
logger interfaces.ILogger |
|
||||||
} |
|
||||||
|
|
||||||
func NewListItemsQuery( |
|
||||||
itemRepo interfaces.IItemRepository, |
|
||||||
logger interfaces.ILogger, |
|
||||||
) *ListItemsQuery { |
|
||||||
return &ListItemsQuery{ |
|
||||||
itemRepo: itemRepo, |
|
||||||
logger: logger, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (q *ListItemsQuery) Execute(ctx context.Context, userID string) ([]*entities.ItemEntity, error) { |
|
||||||
q.logger.Info(ctx, "Executing ListItemsQuery", "userID", userID) |
|
||||||
|
|
||||||
// Convert string ID to value object
|
|
||||||
userIDObj, err := value_objects.NewUserID(userID) |
|
||||||
if err != nil { |
|
||||||
q.logger.Error(ctx, "Invalid user ID", "userID", userID, "error", err) |
|
||||||
return nil, fmt.Errorf("invalid user ID: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
// Find all items for the user
|
|
||||||
items, err := q.itemRepo.FindByUserID(ctx, userIDObj) |
|
||||||
if err != nil { |
|
||||||
q.logger.Error(ctx, "Failed to list items for user", "userID", userID, "error", err) |
|
||||||
return nil, fmt.Errorf("%w: %v", ErrFailedToListItems, err) |
|
||||||
} |
|
||||||
|
|
||||||
q.logger.Info(ctx, "Items listed successfully", "userID", userID, "count", len(items)) |
|
||||||
return items, nil |
|
||||||
} |
|
||||||
@ -1,67 +0,0 @@ |
|||||||
package config |
|
||||||
|
|
||||||
import ( |
|
||||||
"os" |
|
||||||
"strconv" |
|
||||||
"time" |
|
||||||
) |
|
||||||
|
|
||||||
type Config struct { |
|
||||||
ServerPort int `env:"SERVER_PORT" envDefault:"3000"` |
|
||||||
JWTSecret string `env:"JWT_SECRET" envDefault:"your-secret-key"` |
|
||||||
DataDirectory string `env:"DATA_DIRECTORY" envDefault:"./data"` |
|
||||||
LogLevel string `env:"LOG_LEVEL" envDefault:"info"` |
|
||||||
SchedulerInterval time.Duration `env:"SCHEDULER_INTERVAL" envDefault:"1m"` |
|
||||||
ReadTimeout time.Duration `env:"READ_TIMEOUT" envDefault:"30s"` |
|
||||||
WriteTimeout time.Duration `env:"WRITE_TIMEOUT" envDefault:"30s"` |
|
||||||
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"30s"` |
|
||||||
} |
|
||||||
|
|
||||||
func Load() (*Config, error) { |
|
||||||
cfg := &Config{ |
|
||||||
ServerPort: getEnvAsInt("SERVER_PORT", 3000), |
|
||||||
JWTSecret: getEnvAsString("JWT_SECRET", "your-secret-key"), |
|
||||||
DataDirectory: getEnvAsString("DATA_DIRECTORY", "./data"), |
|
||||||
LogLevel: getEnvAsString("LOG_LEVEL", "info"), |
|
||||||
SchedulerInterval: getEnvAsDuration("SCHEDULER_INTERVAL", "1m"), |
|
||||||
ReadTimeout: getEnvAsDuration("READ_TIMEOUT", "30s"), |
|
||||||
WriteTimeout: getEnvAsDuration("WRITE_TIMEOUT", "30s"), |
|
||||||
ShutdownTimeout: getEnvAsDuration("SHUTDOWN_TIMEOUT", "30s"), |
|
||||||
} |
|
||||||
|
|
||||||
return cfg, nil |
|
||||||
} |
|
||||||
|
|
||||||
func (c *Config) Validate() error { |
|
||||||
// For now, we'll keep it simple and not add validation
|
|
||||||
// In a real application, you would validate the configuration here
|
|
||||||
return nil |
|
||||||
} |
|
||||||
|
|
||||||
func getEnvAsString(key, defaultValue string) string { |
|
||||||
if value, exists := os.LookupEnv(key); exists { |
|
||||||
return value |
|
||||||
} |
|
||||||
return defaultValue |
|
||||||
} |
|
||||||
|
|
||||||
func getEnvAsInt(key string, defaultValue int) int { |
|
||||||
if value, exists := os.LookupEnv(key); exists { |
|
||||||
if intValue, err := strconv.Atoi(value); err == nil { |
|
||||||
return intValue |
|
||||||
} |
|
||||||
} |
|
||||||
return defaultValue |
|
||||||
} |
|
||||||
|
|
||||||
func getEnvAsDuration(key string, defaultValue string) time.Duration { |
|
||||||
if value, exists := os.LookupEnv(key); exists { |
|
||||||
if duration, err := time.ParseDuration(value); err == nil { |
|
||||||
return duration |
|
||||||
} |
|
||||||
} |
|
||||||
if duration, err := time.ParseDuration(defaultValue); err == nil { |
|
||||||
return duration |
|
||||||
} |
|
||||||
return time.Minute // Default to 1 minute if parsing fails
|
|
||||||
} |
|
||||||
@ -1,174 +0,0 @@ |
|||||||
package container |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
"os" |
|
||||||
|
|
||||||
"autostore/internal/application/commands" |
|
||||||
"autostore/internal/application/interfaces" |
|
||||||
"autostore/internal/application/queries" |
|
||||||
"autostore/internal/config" |
|
||||||
"autostore/internal/domain/specifications" |
|
||||||
"autostore/internal/infrastructure/auth" |
|
||||||
"autostore/internal/infrastructure/http" |
|
||||||
"autostore/internal/infrastructure/logging" |
|
||||||
"autostore/internal/infrastructure/repositories" |
|
||||||
"autostore/internal/infrastructure/scheduler" |
|
||||||
"autostore/internal/infrastructure/services" |
|
||||||
"autostore/internal/infrastructure/time" |
|
||||||
"autostore/internal/presentation/controllers" |
|
||||||
"autostore/internal/presentation/middleware" |
|
||||||
"autostore/internal/presentation/server" |
|
||||||
) |
|
||||||
type Container struct { |
|
||||||
config *config.Config |
|
||||||
|
|
||||||
// Infrastructure
|
|
||||||
logger interfaces.ILogger |
|
||||||
timeProvider interfaces.ITimeProvider |
|
||||||
authService interfaces.IAuthService |
|
||||||
expiredItemsScheduler *scheduler.ExpiredItemsScheduler |
|
||||||
|
|
||||||
// Domain
|
|
||||||
expirationSpec *specifications.ItemExpirationSpec |
|
||||||
|
|
||||||
// Application
|
|
||||||
addItemCommand *commands.AddItemCommand |
|
||||||
deleteItemCommand *commands.DeleteItemCommand |
|
||||||
handleExpiredItemsCommand *commands.HandleExpiredItemsCommand |
|
||||||
loginUserCommand *commands.LoginUserCommand |
|
||||||
getItemQuery *queries.GetItemQuery |
|
||||||
listItemsQuery *queries.ListItemsQuery |
|
||||||
|
|
||||||
|
|
||||||
// Presentation
|
|
||||||
itemsController *controllers.ItemsController |
|
||||||
authController *controllers.AuthController |
|
||||||
jwtMiddleware *middleware.JWTMiddleware |
|
||||||
server *server.Server |
|
||||||
} |
|
||||||
|
|
||||||
|
|
||||||
func NewContainer(config *config.Config) *Container { |
|
||||||
return &Container{ |
|
||||||
config: config, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (c *Container) Initialize() error { |
|
||||||
// Initialize infrastructure
|
|
||||||
c.logger = logging.NewStandardLogger(os.Stdout) |
|
||||||
c.timeProvider = time.NewSystemTimeProvider() |
|
||||||
|
|
||||||
// Initialize user repository
|
|
||||||
userRepository := repositories.NewFileUserRepository(c.config.DataDirectory, c.logger) |
|
||||||
|
|
||||||
// Initialize user initialization service and create default users
|
|
||||||
userInitService := services.NewUserInitializationService(userRepository, c.logger) |
|
||||||
if err := userInitService.InitializeDefaultUsers(); err != nil { |
|
||||||
c.logger.Error(context.Background(), "Failed to initialize default users", "error", err) |
|
||||||
// Continue even if user initialization fails
|
|
||||||
} |
|
||||||
|
|
||||||
// Initialize auth service with user repository
|
|
||||||
c.authService = auth.NewJWTAuthService(userRepository, c.config.JWTSecret, c.logger) |
|
||||||
|
|
||||||
// Initialize domain
|
|
||||||
c.expirationSpec = specifications.NewItemExpirationSpec() |
|
||||||
|
|
||||||
// Initialize item repository
|
|
||||||
itemRepository := repositories.NewFileItemRepository(c.config.DataDirectory, c.logger) |
|
||||||
|
|
||||||
// Initialize order service
|
|
||||||
orderService := http.NewOrderURLHttpClient(c.logger) |
|
||||||
|
|
||||||
// Initialize application
|
|
||||||
c.addItemCommand = commands.NewAddItemCommand(itemRepository, orderService, c.timeProvider, c.expirationSpec, c.logger) |
|
||||||
c.deleteItemCommand = commands.NewDeleteItemCommand(itemRepository, c.logger) |
|
||||||
c.handleExpiredItemsCommand = commands.NewHandleExpiredItemsCommand(itemRepository, orderService, c.timeProvider, c.expirationSpec, c.logger) |
|
||||||
c.loginUserCommand = commands.NewLoginUserCommand(c.authService, c.logger) |
|
||||||
c.getItemQuery = queries.NewGetItemQuery(itemRepository, c.logger) |
|
||||||
c.listItemsQuery = queries.NewListItemsQuery(itemRepository, c.logger) |
|
||||||
|
|
||||||
// Initialize scheduler
|
|
||||||
c.expiredItemsScheduler = scheduler.NewExpiredItemsScheduler(c.handleExpiredItemsCommand, c.logger) |
|
||||||
|
|
||||||
// Initialize presentation
|
|
||||||
c.itemsController = controllers.NewItemsController(c.addItemCommand, c.getItemQuery, c.listItemsQuery, c.deleteItemCommand, c.logger) |
|
||||||
c.authController = controllers.NewAuthController(c.loginUserCommand, c.logger) |
|
||||||
c.jwtMiddleware = middleware.NewJWTMiddleware(c.authService, c.logger) |
|
||||||
|
|
||||||
serverConfig := &server.Config{ |
|
||||||
Port: c.config.ServerPort, |
|
||||||
ReadTimeout: c.config.ReadTimeout, |
|
||||||
WriteTimeout: c.config.WriteTimeout, |
|
||||||
ShutdownTimeout: c.config.ShutdownTimeout, |
|
||||||
} |
|
||||||
c.server = server.NewServer(serverConfig, c.logger, c.itemsController, c.authController, c.jwtMiddleware) |
|
||||||
|
|
||||||
return nil |
|
||||||
} |
|
||||||
|
|
||||||
// Getters for infrastructure
|
|
||||||
func (c *Container) GetLogger() interfaces.ILogger { |
|
||||||
return c.logger |
|
||||||
} |
|
||||||
|
|
||||||
func (c *Container) GetTimeProvider() interfaces.ITimeProvider { |
|
||||||
return c.timeProvider |
|
||||||
} |
|
||||||
|
|
||||||
func (c *Container) GetAuthService() interfaces.IAuthService { |
|
||||||
return c.authService |
|
||||||
} |
|
||||||
|
|
||||||
// Getters for domain
|
|
||||||
func (c *Container) GetExpirationSpec() *specifications.ItemExpirationSpec { |
|
||||||
return c.expirationSpec |
|
||||||
} |
|
||||||
|
|
||||||
// Getters for application
|
|
||||||
func (c *Container) GetAddItemCommand() *commands.AddItemCommand { |
|
||||||
return c.addItemCommand |
|
||||||
} |
|
||||||
|
|
||||||
func (c *Container) GetDeleteItemCommand() *commands.DeleteItemCommand { |
|
||||||
return c.deleteItemCommand |
|
||||||
} |
|
||||||
|
|
||||||
func (c *Container) GetHandleExpiredItemsCommand() *commands.HandleExpiredItemsCommand { |
|
||||||
return c.handleExpiredItemsCommand |
|
||||||
} |
|
||||||
|
|
||||||
func (c *Container) GetLoginUserCommand() *commands.LoginUserCommand { |
|
||||||
return c.loginUserCommand |
|
||||||
} |
|
||||||
|
|
||||||
func (c *Container) GetGetItemQuery() *queries.GetItemQuery { |
|
||||||
return c.getItemQuery |
|
||||||
} |
|
||||||
|
|
||||||
func (c *Container) GetListItemsQuery() *queries.ListItemsQuery { |
|
||||||
return c.listItemsQuery |
|
||||||
} |
|
||||||
|
|
||||||
// Getters for presentation
|
|
||||||
func (c *Container) GetItemsController() *controllers.ItemsController { |
|
||||||
return c.itemsController |
|
||||||
} |
|
||||||
|
|
||||||
func (c *Container) GetAuthController() *controllers.AuthController { |
|
||||||
return c.authController |
|
||||||
} |
|
||||||
|
|
||||||
func (c *Container) GetJWTMiddleware() *middleware.JWTMiddleware { |
|
||||||
return c.jwtMiddleware |
|
||||||
} |
|
||||||
|
|
||||||
func (c *Container) GetServer() *server.Server { |
|
||||||
return c.server |
|
||||||
} |
|
||||||
|
|
||||||
func (c *Container) GetExpiredItemsScheduler() *scheduler.ExpiredItemsScheduler { |
|
||||||
return c.expiredItemsScheduler |
|
||||||
} |
|
||||||
@ -1,11 +0,0 @@ |
|||||||
package entities |
|
||||||
|
|
||||||
import "errors" |
|
||||||
|
|
||||||
var ( |
|
||||||
ErrInvalidItemName = errors.New("item name cannot be empty") |
|
||||||
ErrInvalidOrderURL = errors.New("order URL cannot be empty") |
|
||||||
ErrInvalidUsername = errors.New("username cannot be empty") |
|
||||||
ErrInvalidPassword = errors.New("password cannot be empty") |
|
||||||
ErrFailedToHashPassword = errors.New("failed to hash password") |
|
||||||
) |
|
||||||
@ -1,118 +0,0 @@ |
|||||||
package entities |
|
||||||
|
|
||||||
import ( |
|
||||||
"encoding/json" |
|
||||||
"fmt" |
|
||||||
"time" |
|
||||||
|
|
||||||
"autostore/internal/domain/value_objects" |
|
||||||
) |
|
||||||
|
|
||||||
type itemEntityJSON struct { |
|
||||||
ID string `json:"id"` |
|
||||||
Name string `json:"name"` |
|
||||||
ExpirationDate time.Time `json:"expirationDate"` |
|
||||||
OrderURL string `json:"orderUrl"` |
|
||||||
UserID string `json:"userId"` |
|
||||||
CreatedAt time.Time `json:"createdAt"` |
|
||||||
} |
|
||||||
|
|
||||||
type ItemEntity struct { |
|
||||||
id value_objects.ItemID |
|
||||||
name string |
|
||||||
expirationDate value_objects.ExpirationDate |
|
||||||
orderURL string |
|
||||||
userID value_objects.UserID |
|
||||||
createdAt time.Time |
|
||||||
} |
|
||||||
|
|
||||||
func NewItem(id value_objects.ItemID, name string, expirationDate value_objects.ExpirationDate, orderURL string, userID value_objects.UserID) (*ItemEntity, error) { |
|
||||||
if name == "" { |
|
||||||
return nil, ErrInvalidItemName |
|
||||||
} |
|
||||||
|
|
||||||
if orderURL == "" { |
|
||||||
return nil, ErrInvalidOrderURL |
|
||||||
} |
|
||||||
|
|
||||||
return &ItemEntity{ |
|
||||||
id: id, |
|
||||||
name: name, |
|
||||||
expirationDate: expirationDate, |
|
||||||
orderURL: orderURL, |
|
||||||
userID: userID, |
|
||||||
createdAt: time.Now(), |
|
||||||
}, nil |
|
||||||
} |
|
||||||
|
|
||||||
func (i *ItemEntity) GetID() value_objects.ItemID { |
|
||||||
return i.id |
|
||||||
} |
|
||||||
|
|
||||||
func (i *ItemEntity) GetName() string { |
|
||||||
return i.name |
|
||||||
} |
|
||||||
|
|
||||||
func (i *ItemEntity) GetExpirationDate() value_objects.ExpirationDate { |
|
||||||
return i.expirationDate |
|
||||||
} |
|
||||||
|
|
||||||
func (i *ItemEntity) ExpirationDate() time.Time { |
|
||||||
return i.expirationDate.Time() |
|
||||||
} |
|
||||||
|
|
||||||
func (i *ItemEntity) GetOrderURL() string { |
|
||||||
return i.orderURL |
|
||||||
} |
|
||||||
|
|
||||||
func (i *ItemEntity) GetUserID() value_objects.UserID { |
|
||||||
return i.userID |
|
||||||
} |
|
||||||
|
|
||||||
func (i *ItemEntity) GetCreatedAt() time.Time { |
|
||||||
return i.createdAt |
|
||||||
} |
|
||||||
|
|
||||||
// MarshalJSON implements json.Marshaler interface
|
|
||||||
func (i *ItemEntity) MarshalJSON() ([]byte, error) { |
|
||||||
return json.Marshal(&itemEntityJSON{ |
|
||||||
ID: i.id.String(), |
|
||||||
Name: i.name, |
|
||||||
ExpirationDate: i.expirationDate.Time(), |
|
||||||
OrderURL: i.orderURL, |
|
||||||
UserID: i.userID.String(), |
|
||||||
CreatedAt: i.createdAt, |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
// UnmarshalJSON implements json.Unmarshaler interface
|
|
||||||
func (i *ItemEntity) UnmarshalJSON(data []byte) error { |
|
||||||
var aux itemEntityJSON |
|
||||||
if err := json.Unmarshal(data, &aux); err != nil { |
|
||||||
return err |
|
||||||
} |
|
||||||
|
|
||||||
id, err := value_objects.NewItemIDFromString(aux.ID) |
|
||||||
if err != nil { |
|
||||||
return fmt.Errorf("invalid item ID: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
userID, err := value_objects.NewUserIDFromString(aux.UserID) |
|
||||||
if err != nil { |
|
||||||
return fmt.Errorf("invalid user ID: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
expirationDate, err := value_objects.NewExpirationDate(aux.ExpirationDate) |
|
||||||
if err != nil { |
|
||||||
return fmt.Errorf("invalid expiration date: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
i.id = id |
|
||||||
i.name = aux.Name |
|
||||||
i.expirationDate = expirationDate |
|
||||||
i.orderURL = aux.OrderURL |
|
||||||
i.userID = userID |
|
||||||
i.createdAt = aux.CreatedAt |
|
||||||
|
|
||||||
return nil |
|
||||||
} |
|
||||||
@ -1,69 +0,0 @@ |
|||||||
package entities |
|
||||||
|
|
||||||
import ( |
|
||||||
"autostore/internal/domain/value_objects" |
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt" |
|
||||||
) |
|
||||||
|
|
||||||
type UserEntity struct { |
|
||||||
id value_objects.UserID |
|
||||||
username string |
|
||||||
passwordHash string |
|
||||||
} |
|
||||||
|
|
||||||
func NewUser(id value_objects.UserID, username string, password string) (*UserEntity, error) { |
|
||||||
if username == "" { |
|
||||||
return nil, ErrInvalidUsername |
|
||||||
} |
|
||||||
|
|
||||||
if password == "" { |
|
||||||
return nil, ErrInvalidPassword |
|
||||||
} |
|
||||||
|
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) |
|
||||||
if err != nil { |
|
||||||
return nil, ErrFailedToHashPassword |
|
||||||
} |
|
||||||
|
|
||||||
return &UserEntity{ |
|
||||||
id: id, |
|
||||||
username: username, |
|
||||||
passwordHash: string(hashedPassword), |
|
||||||
}, nil |
|
||||||
} |
|
||||||
|
|
||||||
// NewUserWithHashedPassword creates a user entity with a pre-hashed password
|
|
||||||
// This is used when reconstructing a user from the database
|
|
||||||
func NewUserWithHashedPassword(id value_objects.UserID, username string, passwordHash string) (*UserEntity, error) { |
|
||||||
if username == "" { |
|
||||||
return nil, ErrInvalidUsername |
|
||||||
} |
|
||||||
|
|
||||||
if passwordHash == "" { |
|
||||||
return nil, ErrInvalidPassword |
|
||||||
} |
|
||||||
|
|
||||||
return &UserEntity{ |
|
||||||
id: id, |
|
||||||
username: username, |
|
||||||
passwordHash: passwordHash, |
|
||||||
}, nil |
|
||||||
} |
|
||||||
|
|
||||||
func (u *UserEntity) GetID() value_objects.UserID { |
|
||||||
return u.id |
|
||||||
} |
|
||||||
|
|
||||||
func (u *UserEntity) GetUsername() string { |
|
||||||
return u.username |
|
||||||
} |
|
||||||
|
|
||||||
func (u *UserEntity) GetPasswordHash() string { |
|
||||||
return u.passwordHash |
|
||||||
} |
|
||||||
|
|
||||||
func (u *UserEntity) ValidatePassword(password string) bool { |
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(u.passwordHash), []byte(password)) |
|
||||||
return err == nil |
|
||||||
} |
|
||||||
@ -1,219 +0,0 @@ |
|||||||
package specifications |
|
||||||
|
|
||||||
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" |
|
||||||
) |
|
||||||
|
|
||||||
type Condition struct { |
|
||||||
Field string `json:"field"` |
|
||||||
Operator string `json:"operator"` |
|
||||||
Value interface{} `json:"value"` |
|
||||||
} |
|
||||||
|
|
||||||
type LogicalGroup struct { |
|
||||||
Operator string `json:"operator"` |
|
||||||
Conditions []Condition `json:"conditions"` |
|
||||||
Spec *Spec `json:"spec"` |
|
||||||
} |
|
||||||
|
|
||||||
type Spec struct { |
|
||||||
Condition *Condition `json:"condition,omitempty"` |
|
||||||
LogicalGroup *LogicalGroup `json:"logicalGroup,omitempty"` |
|
||||||
} |
|
||||||
|
|
||||||
func And(conditions ...*Spec) *Spec { |
|
||||||
if len(conditions) == 0 { |
|
||||||
return nil |
|
||||||
} |
|
||||||
if len(conditions) == 1 { |
|
||||||
return conditions[0] |
|
||||||
} |
|
||||||
|
|
||||||
var flatConditions []Condition |
|
||||||
var nestedSpecs []*Spec |
|
||||||
|
|
||||||
for _, spec := range conditions { |
|
||||||
if spec.Condition != nil { |
|
||||||
flatConditions = append(flatConditions, *spec.Condition) |
|
||||||
} else { |
|
||||||
nestedSpecs = append(nestedSpecs, spec) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
result := &Spec{ |
|
||||||
LogicalGroup: &LogicalGroup{ |
|
||||||
Operator: GROUP_AND, |
|
||||||
Conditions: flatConditions, |
|
||||||
}, |
|
||||||
} |
|
||||||
|
|
||||||
if len(nestedSpecs) == 1 { |
|
||||||
result.LogicalGroup.Spec = nestedSpecs[0] |
|
||||||
} else if len(nestedSpecs) > 1 { |
|
||||||
result.LogicalGroup.Spec = And(nestedSpecs...) |
|
||||||
} |
|
||||||
|
|
||||||
return result |
|
||||||
} |
|
||||||
|
|
||||||
func Or(conditions ...*Spec) *Spec { |
|
||||||
if len(conditions) == 0 { |
|
||||||
return nil |
|
||||||
} |
|
||||||
if len(conditions) == 1 { |
|
||||||
return conditions[0] |
|
||||||
} |
|
||||||
|
|
||||||
var flatConditions []Condition |
|
||||||
var nestedSpecs []*Spec |
|
||||||
|
|
||||||
for _, spec := range conditions { |
|
||||||
if spec.Condition != nil { |
|
||||||
flatConditions = append(flatConditions, *spec.Condition) |
|
||||||
} else { |
|
||||||
nestedSpecs = append(nestedSpecs, spec) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
result := &Spec{ |
|
||||||
LogicalGroup: &LogicalGroup{ |
|
||||||
Operator: GROUP_OR, |
|
||||||
Conditions: flatConditions, |
|
||||||
}, |
|
||||||
} |
|
||||||
|
|
||||||
if len(nestedSpecs) == 1 { |
|
||||||
result.LogicalGroup.Spec = nestedSpecs[0] |
|
||||||
} else if len(nestedSpecs) > 1 { |
|
||||||
result.LogicalGroup.Spec = Or(nestedSpecs...) |
|
||||||
} |
|
||||||
|
|
||||||
return result |
|
||||||
} |
|
||||||
|
|
||||||
func Not(condition *Spec) *Spec { |
|
||||||
return &Spec{ |
|
||||||
LogicalGroup: &LogicalGroup{ |
|
||||||
Operator: GROUP_NOT, |
|
||||||
Spec: condition, |
|
||||||
}, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func Eq(field string, value interface{}) *Spec { |
|
||||||
return &Spec{ |
|
||||||
Condition: &Condition{ |
|
||||||
Field: field, |
|
||||||
Operator: OP_EQ, |
|
||||||
Value: value, |
|
||||||
}, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func Neq(field string, value interface{}) *Spec { |
|
||||||
return &Spec{ |
|
||||||
Condition: &Condition{ |
|
||||||
Field: field, |
|
||||||
Operator: OP_NEQ, |
|
||||||
Value: value, |
|
||||||
}, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func Gt(field string, value interface{}) *Spec { |
|
||||||
return &Spec{ |
|
||||||
Condition: &Condition{ |
|
||||||
Field: field, |
|
||||||
Operator: OP_GT, |
|
||||||
Value: value, |
|
||||||
}, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func Gte(field string, value interface{}) *Spec { |
|
||||||
return &Spec{ |
|
||||||
Condition: &Condition{ |
|
||||||
Field: field, |
|
||||||
Operator: OP_GTE, |
|
||||||
Value: value, |
|
||||||
}, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func Lt(field string, value interface{}) *Spec { |
|
||||||
return &Spec{ |
|
||||||
Condition: &Condition{ |
|
||||||
Field: field, |
|
||||||
Operator: OP_LT, |
|
||||||
Value: value, |
|
||||||
}, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func Lte(field string, value interface{}) *Spec { |
|
||||||
return &Spec{ |
|
||||||
Condition: &Condition{ |
|
||||||
Field: field, |
|
||||||
Operator: OP_LTE, |
|
||||||
Value: value, |
|
||||||
}, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func In(field string, values []interface{}) *Spec { |
|
||||||
return &Spec{ |
|
||||||
Condition: &Condition{ |
|
||||||
Field: field, |
|
||||||
Operator: OP_IN, |
|
||||||
Value: values, |
|
||||||
}, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func Nin(field string, values []interface{}) *Spec { |
|
||||||
return &Spec{ |
|
||||||
Condition: &Condition{ |
|
||||||
Field: field, |
|
||||||
Operator: OP_NIN, |
|
||||||
Value: values, |
|
||||||
}, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func GetConditions(spec *Spec) []Condition { |
|
||||||
var conditions []Condition |
|
||||||
flattenConditions(spec, &conditions) |
|
||||||
return conditions |
|
||||||
} |
|
||||||
|
|
||||||
func flattenConditions(spec *Spec, conditions *[]Condition) { |
|
||||||
if spec == nil { |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
if spec.Condition != nil { |
|
||||||
*conditions = append(*conditions, *spec.Condition) |
|
||||||
} |
|
||||||
|
|
||||||
if spec.LogicalGroup != nil { |
|
||||||
for _, cond := range spec.LogicalGroup.Conditions { |
|
||||||
*conditions = append(*conditions, cond) |
|
||||||
} |
|
||||||
if spec.LogicalGroup.Spec != nil { |
|
||||||
flattenConditions(spec.LogicalGroup.Spec, conditions) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,29 +0,0 @@ |
|||||||
package specifications |
|
||||||
|
|
||||||
import ( |
|
||||||
"autostore/internal/domain/entities" |
|
||||||
"time" |
|
||||||
) |
|
||||||
|
|
||||||
type ItemExpirationSpec struct{} |
|
||||||
|
|
||||||
func NewItemExpirationSpec() *ItemExpirationSpec { |
|
||||||
return &ItemExpirationSpec{} |
|
||||||
} |
|
||||||
|
|
||||||
// IsExpired checks if an item is expired using the new condition-based specification
|
|
||||||
func (s *ItemExpirationSpec) IsExpired(item *entities.ItemEntity, currentTime time.Time) bool { |
|
||||||
return s.GetSpec(currentTime).IsSatisfiedBy(item) |
|
||||||
} |
|
||||||
|
|
||||||
// GetSpec returns a condition-based specification for checking item expiration
|
|
||||||
func (s *ItemExpirationSpec) GetSpec(currentTime time.Time) Specification[*entities.ItemEntity] { |
|
||||||
// Create a condition that checks if expirationDate <= currentTime
|
|
||||||
spec := Lte("expirationDate", currentTime) |
|
||||||
return NewSimpleSpecification[*entities.ItemEntity](spec) |
|
||||||
} |
|
||||||
|
|
||||||
// GetConditionSpec returns the raw condition spec for query generation
|
|
||||||
func (s *ItemExpirationSpec) GetConditionSpec(currentTime time.Time) *Spec { |
|
||||||
return Lte("expirationDate", currentTime) |
|
||||||
} |
|
||||||
@ -1,407 +0,0 @@ |
|||||||
package specifications |
|
||||||
|
|
||||||
import ( |
|
||||||
"fmt" |
|
||||||
"reflect" |
|
||||||
"strings" |
|
||||||
"time" |
|
||||||
) |
|
||||||
|
|
||||||
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 |
|
||||||
} |
|
||||||
|
|
||||||
type SimpleSpecification[T any] struct { |
|
||||||
spec *Spec |
|
||||||
} |
|
||||||
|
|
||||||
func NewSimpleSpecification[T any](spec *Spec) *SimpleSpecification[T] { |
|
||||||
return &SimpleSpecification[T]{spec: spec} |
|
||||||
} |
|
||||||
|
|
||||||
func (s *SimpleSpecification[T]) IsSatisfiedBy(candidate T) bool { |
|
||||||
return s.evaluateSpec(s.spec, candidate) |
|
||||||
} |
|
||||||
|
|
||||||
func (s *SimpleSpecification[T]) And(other Specification[T]) Specification[T] { |
|
||||||
return &CompositeSpecification[T]{left: s, right: other, op: "AND"} |
|
||||||
} |
|
||||||
|
|
||||||
func (s *SimpleSpecification[T]) Or(other Specification[T]) Specification[T] { |
|
||||||
return &CompositeSpecification[T]{left: s, right: other, op: "OR"} |
|
||||||
} |
|
||||||
|
|
||||||
func (s *SimpleSpecification[T]) Not() Specification[T] { |
|
||||||
return NewSimpleSpecification[T](Not(s.spec)) |
|
||||||
} |
|
||||||
|
|
||||||
func (s *SimpleSpecification[T]) GetSpec() *Spec { |
|
||||||
return s.spec |
|
||||||
} |
|
||||||
|
|
||||||
func (s *SimpleSpecification[T]) GetConditions() []Condition { |
|
||||||
return GetConditions(s.spec) |
|
||||||
} |
|
||||||
|
|
||||||
func (s *SimpleSpecification[T]) evaluateSpec(spec *Spec, candidate T) bool { |
|
||||||
if spec == nil { |
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
if spec.LogicalGroup != nil { |
|
||||||
return s.evaluateLogicalGroup(spec.LogicalGroup, candidate) |
|
||||||
} |
|
||||||
|
|
||||||
if spec.Condition != nil { |
|
||||||
return s.evaluateCondition(*spec.Condition, candidate) |
|
||||||
} |
|
||||||
|
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
func (s *SimpleSpecification[T]) evaluateLogicalGroup(group *LogicalGroup, candidate T) bool { |
|
||||||
switch group.Operator { |
|
||||||
case GROUP_AND: |
|
||||||
return s.evaluateAndGroup(group, candidate) |
|
||||||
case GROUP_OR: |
|
||||||
return s.evaluateOrGroup(group, candidate) |
|
||||||
case GROUP_NOT: |
|
||||||
if group.Spec != nil { |
|
||||||
return !s.evaluateSpec(group.Spec, candidate) |
|
||||||
} |
|
||||||
} |
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
func (s *SimpleSpecification[T]) evaluateAndGroup(group *LogicalGroup, candidate T) bool { |
|
||||||
for _, cond := range group.Conditions { |
|
||||||
if !s.evaluateCondition(cond, candidate) { |
|
||||||
return false |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if group.Spec != nil { |
|
||||||
return s.evaluateSpec(group.Spec, candidate) |
|
||||||
} |
|
||||||
|
|
||||||
return len(group.Conditions) > 0 || group.Spec != nil |
|
||||||
} |
|
||||||
|
|
||||||
func (s *SimpleSpecification[T]) evaluateOrGroup(group *LogicalGroup, candidate T) bool { |
|
||||||
for _, cond := range group.Conditions { |
|
||||||
if s.evaluateCondition(cond, candidate) { |
|
||||||
return true |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if group.Spec != nil { |
|
||||||
return s.evaluateSpec(group.Spec, candidate) |
|
||||||
} |
|
||||||
|
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
func (s *SimpleSpecification[T]) evaluateCondition(condition Condition, candidate T) bool { |
|
||||||
fieldValue, err := s.getFieldValue(candidate, condition.Field) |
|
||||||
if err != nil { |
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
return s.compareValues(fieldValue, condition.Operator, condition.Value) |
|
||||||
} |
|
||||||
|
|
||||||
func (s *SimpleSpecification[T]) getFieldValue(candidate T, fieldName string) (interface{}, error) { |
|
||||||
v := reflect.ValueOf(candidate) |
|
||||||
|
|
||||||
if v.Kind() == reflect.Ptr { |
|
||||||
if v.IsNil() { |
|
||||||
return nil, fmt.Errorf("candidate is nil") |
|
||||||
} |
|
||||||
v = v.Elem() |
|
||||||
} |
|
||||||
|
|
||||||
if v.Kind() != reflect.Struct { |
|
||||||
return nil, fmt.Errorf("candidate is not a struct") |
|
||||||
} |
|
||||||
|
|
||||||
getterName := "Get" + strings.Title(fieldName) |
|
||||||
|
|
||||||
originalV := reflect.ValueOf(candidate) |
|
||||||
if method := originalV.MethodByName(getterName); method.IsValid() { |
|
||||||
return s.callMethod(method) |
|
||||||
} |
|
||||||
|
|
||||||
if method := originalV.MethodByName(fieldName); method.IsValid() { |
|
||||||
return s.callMethod(method) |
|
||||||
} |
|
||||||
|
|
||||||
if v.Kind() == reflect.Struct { |
|
||||||
if method := v.MethodByName(getterName); method.IsValid() { |
|
||||||
return s.callMethod(method) |
|
||||||
} |
|
||||||
|
|
||||||
if method := v.MethodByName(fieldName); method.IsValid() { |
|
||||||
return s.callMethod(method) |
|
||||||
} |
|
||||||
|
|
||||||
if field := v.FieldByName(fieldName); field.IsValid() && field.CanInterface() { |
|
||||||
return field.Interface(), nil |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
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 results[0].Interface(), nil |
|
||||||
} |
|
||||||
|
|
||||||
func (s *SimpleSpecification[T]) compareValues(fieldValue interface{}, operator string, compareValue interface{}) bool { |
|
||||||
if fieldValue == nil { |
|
||||||
return (operator == OP_EQ && compareValue == nil) || (operator == OP_NEQ && compareValue != nil) |
|
||||||
} |
|
||||||
|
|
||||||
if s.isTimeComparable(fieldValue, compareValue) { |
|
||||||
return s.compareTimes(fieldValue, operator, compareValue) |
|
||||||
} |
|
||||||
|
|
||||||
if operator == OP_IN || operator == OP_NIN { |
|
||||||
return s.compareIn(fieldValue, operator, compareValue) |
|
||||||
} |
|
||||||
|
|
||||||
return s.compareGeneral(fieldValue, operator, compareValue) |
|
||||||
} |
|
||||||
|
|
||||||
func (s *SimpleSpecification[T]) isTimeComparable(fieldValue, compareValue interface{}) bool { |
|
||||||
_, fieldIsTime := fieldValue.(time.Time) |
|
||||||
_, compareIsTime := compareValue.(time.Time) |
|
||||||
|
|
||||||
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 |
|
||||||
} |
|
||||||
|
|
||||||
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 { |
|
||||||
case OP_EQ: |
|
||||||
return fieldTime.Equal(*compareTime) |
|
||||||
case OP_NEQ: |
|
||||||
return !fieldTime.Equal(*compareTime) |
|
||||||
case OP_GT: |
|
||||||
return fieldTime.After(*compareTime) |
|
||||||
case OP_GTE: |
|
||||||
return fieldTime.After(*compareTime) || fieldTime.Equal(*compareTime) |
|
||||||
case OP_LT: |
|
||||||
return fieldTime.Before(*compareTime) |
|
||||||
case OP_LTE: |
|
||||||
return fieldTime.Before(*compareTime) || fieldTime.Equal(*compareTime) |
|
||||||
} |
|
||||||
|
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
func (s *SimpleSpecification[T]) extractTime(value interface{}) *time.Time { |
|
||||||
switch v := value.(type) { |
|
||||||
case time.Time: |
|
||||||
return &v |
|
||||||
case string: |
|
||||||
if t, err := time.Parse(time.RFC3339, v); err == nil { |
|
||||||
return &t |
|
||||||
} |
|
||||||
default: |
|
||||||
if method := reflect.ValueOf(value).MethodByName("Time"); method.IsValid() { |
|
||||||
if results := method.Call(nil); len(results) > 0 { |
|
||||||
if t, ok := results[0].Interface().(time.Time); ok { |
|
||||||
return &t |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
return nil |
|
||||||
} |
|
||||||
|
|
||||||
func (s *SimpleSpecification[T]) compareIn(fieldValue interface{}, operator string, compareValue interface{}) bool { |
|
||||||
compareSlice, ok := compareValue.([]interface{}) |
|
||||||
if !ok { |
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
for _, v := range compareSlice { |
|
||||||
if reflect.DeepEqual(fieldValue, v) { |
|
||||||
return operator == OP_IN |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return operator == OP_NIN |
|
||||||
} |
|
||||||
|
|
||||||
func (s *SimpleSpecification[T]) compareGeneral(fieldValue interface{}, operator string, compareValue interface{}) bool { |
|
||||||
fieldVal := reflect.ValueOf(fieldValue) |
|
||||||
compareVal := reflect.ValueOf(compareValue) |
|
||||||
|
|
||||||
if !s.makeComparable(&fieldVal, &compareVal) { |
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
switch operator { |
|
||||||
case OP_EQ: |
|
||||||
return reflect.DeepEqual(fieldValue, compareValue) |
|
||||||
case OP_NEQ: |
|
||||||
return !reflect.DeepEqual(fieldValue, compareValue) |
|
||||||
case OP_GT: |
|
||||||
return s.isGreater(fieldVal, compareVal) |
|
||||||
case OP_GTE: |
|
||||||
return s.isGreater(fieldVal, compareVal) || reflect.DeepEqual(fieldValue, compareValue) |
|
||||||
case OP_LT: |
|
||||||
return s.isLess(fieldVal, compareVal) |
|
||||||
case OP_LTE: |
|
||||||
return s.isLess(fieldVal, compareVal) || reflect.DeepEqual(fieldValue, compareValue) |
|
||||||
} |
|
||||||
|
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
func (s *SimpleSpecification[T]) makeComparable(fieldVal, compareVal *reflect.Value) bool { |
|
||||||
if fieldVal.Kind() == compareVal.Kind() { |
|
||||||
return true |
|
||||||
} |
|
||||||
|
|
||||||
if compareVal.CanConvert(fieldVal.Type()) { |
|
||||||
*compareVal = compareVal.Convert(fieldVal.Type()) |
|
||||||
return true |
|
||||||
} |
|
||||||
|
|
||||||
if fieldVal.CanConvert(compareVal.Type()) { |
|
||||||
*fieldVal = fieldVal.Convert(compareVal.Type()) |
|
||||||
return true |
|
||||||
} |
|
||||||
|
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
func (s *SimpleSpecification[T]) isGreater(fieldVal, compareVal reflect.Value) bool { |
|
||||||
switch fieldVal.Kind() { |
|
||||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: |
|
||||||
return fieldVal.Int() > compareVal.Int() |
|
||||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: |
|
||||||
return fieldVal.Uint() > compareVal.Uint() |
|
||||||
case reflect.Float32, reflect.Float64: |
|
||||||
return fieldVal.Float() > compareVal.Float() |
|
||||||
case reflect.String: |
|
||||||
return fieldVal.String() > compareVal.String() |
|
||||||
} |
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
func (s *SimpleSpecification[T]) isLess(fieldVal, compareVal reflect.Value) bool { |
|
||||||
switch fieldVal.Kind() { |
|
||||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: |
|
||||||
return fieldVal.Int() < compareVal.Int() |
|
||||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: |
|
||||||
return fieldVal.Uint() < compareVal.Uint() |
|
||||||
case reflect.Float32, reflect.Float64: |
|
||||||
return fieldVal.Float() < compareVal.Float() |
|
||||||
case reflect.String: |
|
||||||
return fieldVal.String() < compareVal.String() |
|
||||||
} |
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
type CompositeSpecification[T any] struct { |
|
||||||
left Specification[T] |
|
||||||
right Specification[T] |
|
||||||
op string |
|
||||||
} |
|
||||||
|
|
||||||
func (c *CompositeSpecification[T]) IsSatisfiedBy(candidate T) bool { |
|
||||||
switch c.op { |
|
||||||
case "AND": |
|
||||||
return c.left.IsSatisfiedBy(candidate) && c.right.IsSatisfiedBy(candidate) |
|
||||||
case "OR": |
|
||||||
return c.left.IsSatisfiedBy(candidate) || c.right.IsSatisfiedBy(candidate) |
|
||||||
} |
|
||||||
return false |
|
||||||
} |
|
||||||
|
|
||||||
func (c *CompositeSpecification[T]) And(other Specification[T]) Specification[T] { |
|
||||||
return &CompositeSpecification[T]{left: c, right: other, op: "AND"} |
|
||||||
} |
|
||||||
|
|
||||||
func (c *CompositeSpecification[T]) Or(other Specification[T]) Specification[T] { |
|
||||||
return &CompositeSpecification[T]{left: c, right: other, op: "OR"} |
|
||||||
} |
|
||||||
|
|
||||||
func (c *CompositeSpecification[T]) Not() Specification[T] { |
|
||||||
return &NotSpecification[T]{spec: c} |
|
||||||
} |
|
||||||
|
|
||||||
func (c *CompositeSpecification[T]) GetConditions() []Condition { |
|
||||||
leftConditions := c.left.GetConditions() |
|
||||||
rightConditions := c.right.GetConditions() |
|
||||||
return append(leftConditions, rightConditions...) |
|
||||||
} |
|
||||||
|
|
||||||
func (c *CompositeSpecification[T]) GetSpec() *Spec { |
|
||||||
return nil |
|
||||||
} |
|
||||||
|
|
||||||
type NotSpecification[T any] struct { |
|
||||||
spec Specification[T] |
|
||||||
} |
|
||||||
|
|
||||||
func (n *NotSpecification[T]) IsSatisfiedBy(candidate T) bool { |
|
||||||
return !n.spec.IsSatisfiedBy(candidate) |
|
||||||
} |
|
||||||
|
|
||||||
func (n *NotSpecification[T]) And(other Specification[T]) Specification[T] { |
|
||||||
return &CompositeSpecification[T]{left: n, right: other, op: "AND"} |
|
||||||
} |
|
||||||
|
|
||||||
func (n *NotSpecification[T]) Or(other Specification[T]) Specification[T] { |
|
||||||
return &CompositeSpecification[T]{left: n, right: other, op: "OR"} |
|
||||||
} |
|
||||||
|
|
||||||
func (n *NotSpecification[T]) Not() Specification[T] { |
|
||||||
return n.spec |
|
||||||
} |
|
||||||
|
|
||||||
func (n *NotSpecification[T]) GetConditions() []Condition { |
|
||||||
return n.spec.GetConditions() |
|
||||||
} |
|
||||||
|
|
||||||
func (n *NotSpecification[T]) GetSpec() *Spec { |
|
||||||
return nil |
|
||||||
} |
|
||||||
@ -1,45 +0,0 @@ |
|||||||
package value_objects |
|
||||||
|
|
||||||
import ( |
|
||||||
"errors" |
|
||||||
|
|
||||||
"github.com/google/uuid" |
|
||||||
) |
|
||||||
|
|
||||||
var ( |
|
||||||
ErrInvalidUUID = errors.New("invalid UUID format") |
|
||||||
) |
|
||||||
|
|
||||||
type BaseUUID struct { |
|
||||||
value string |
|
||||||
} |
|
||||||
|
|
||||||
func NewBaseUUID(value string) (BaseUUID, error) { |
|
||||||
if value == "" { |
|
||||||
return BaseUUID{}, ErrInvalidUUID |
|
||||||
} |
|
||||||
|
|
||||||
_, err := uuid.Parse(value) |
|
||||||
if err != nil { |
|
||||||
return BaseUUID{}, ErrInvalidUUID |
|
||||||
} |
|
||||||
|
|
||||||
return BaseUUID{value: value}, nil |
|
||||||
} |
|
||||||
|
|
||||||
func NewRandomBaseUUID() (BaseUUID, error) { |
|
||||||
id, err := uuid.NewRandom() |
|
||||||
if err != nil { |
|
||||||
return BaseUUID{}, err |
|
||||||
} |
|
||||||
|
|
||||||
return BaseUUID{value: id.String()}, nil |
|
||||||
} |
|
||||||
|
|
||||||
func (b BaseUUID) String() string { |
|
||||||
return b.value |
|
||||||
} |
|
||||||
|
|
||||||
func (b BaseUUID) Equals(other BaseUUID) bool { |
|
||||||
return b.value == other.value |
|
||||||
} |
|
||||||
@ -1,23 +0,0 @@ |
|||||||
package value_objects |
|
||||||
|
|
||||||
import ( |
|
||||||
"time" |
|
||||||
) |
|
||||||
|
|
||||||
type ExpirationDate struct { |
|
||||||
value time.Time |
|
||||||
} |
|
||||||
|
|
||||||
func NewExpirationDate(value time.Time) (ExpirationDate, error) { |
|
||||||
// According to business rules, expired items can be added to the store,
|
|
||||||
// so we don't need to validate if the date is in the past
|
|
||||||
return ExpirationDate{value: value}, nil |
|
||||||
} |
|
||||||
|
|
||||||
func (e ExpirationDate) Time() time.Time { |
|
||||||
return e.value |
|
||||||
} |
|
||||||
|
|
||||||
func (e ExpirationDate) String() string { |
|
||||||
return e.value.Format(time.RFC3339) |
|
||||||
} |
|
||||||
@ -1,38 +0,0 @@ |
|||||||
package value_objects |
|
||||||
|
|
||||||
import ( |
|
||||||
"errors" |
|
||||||
) |
|
||||||
|
|
||||||
var ( |
|
||||||
ErrInvalidItemID = errors.New("invalid item ID") |
|
||||||
) |
|
||||||
|
|
||||||
type ItemID struct { |
|
||||||
BaseUUID |
|
||||||
} |
|
||||||
|
|
||||||
func NewItemID(value string) (ItemID, error) { |
|
||||||
baseUUID, err := NewBaseUUID(value) |
|
||||||
if err != nil { |
|
||||||
return ItemID{}, ErrInvalidItemID |
|
||||||
} |
|
||||||
|
|
||||||
return ItemID{BaseUUID: baseUUID}, nil |
|
||||||
} |
|
||||||
|
|
||||||
func NewRandomItemID() (ItemID, error) { |
|
||||||
baseUUID, err := NewRandomBaseUUID() |
|
||||||
if err != nil { |
|
||||||
return ItemID{}, ErrInvalidItemID |
|
||||||
} |
|
||||||
|
|
||||||
return ItemID{BaseUUID: baseUUID}, nil |
|
||||||
} |
|
||||||
func NewItemIDFromString(value string) (ItemID, error) { |
|
||||||
return NewItemID(value) |
|
||||||
} |
|
||||||
|
|
||||||
func (i ItemID) Equals(other ItemID) bool { |
|
||||||
return i.BaseUUID.Equals(other.BaseUUID) |
|
||||||
} |
|
||||||
@ -1,38 +0,0 @@ |
|||||||
package value_objects |
|
||||||
|
|
||||||
import ( |
|
||||||
"errors" |
|
||||||
) |
|
||||||
|
|
||||||
var ( |
|
||||||
ErrInvalidUserID = errors.New("invalid user ID") |
|
||||||
) |
|
||||||
|
|
||||||
type UserID struct { |
|
||||||
BaseUUID |
|
||||||
} |
|
||||||
|
|
||||||
func NewUserID(value string) (UserID, error) { |
|
||||||
baseUUID, err := NewBaseUUID(value) |
|
||||||
if err != nil { |
|
||||||
return UserID{}, ErrInvalidUserID |
|
||||||
} |
|
||||||
|
|
||||||
return UserID{BaseUUID: baseUUID}, nil |
|
||||||
} |
|
||||||
|
|
||||||
func NewRandomUserID() (UserID, error) { |
|
||||||
baseUUID, err := NewRandomBaseUUID() |
|
||||||
if err != nil { |
|
||||||
return UserID{}, ErrInvalidUserID |
|
||||||
} |
|
||||||
|
|
||||||
return UserID{BaseUUID: baseUUID}, nil |
|
||||||
} |
|
||||||
func NewUserIDFromString(value string) (UserID, error) { |
|
||||||
return NewUserID(value) |
|
||||||
} |
|
||||||
|
|
||||||
func (u UserID) Equals(other UserID) bool { |
|
||||||
return u.BaseUUID.Equals(other.BaseUUID) |
|
||||||
} |
|
||||||
@ -1,126 +0,0 @@ |
|||||||
package auth |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
"errors" |
|
||||||
"fmt" |
|
||||||
"time" |
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v4" |
|
||||||
|
|
||||||
"autostore/internal/application/interfaces" |
|
||||||
"autostore/internal/domain/entities" |
|
||||||
) |
|
||||||
|
|
||||||
var ( |
|
||||||
ErrInvalidCredentials = errors.New("invalid credentials") |
|
||||||
ErrInvalidToken = errors.New("invalid token") |
|
||||||
ErrTokenExpired = errors.New("token expired") |
|
||||||
) |
|
||||||
|
|
||||||
type JWTAuthService struct { |
|
||||||
userRepo interfaces.IUserRepository |
|
||||||
secretKey string |
|
||||||
logger interfaces.ILogger |
|
||||||
} |
|
||||||
|
|
||||||
func NewJWTAuthService( |
|
||||||
userRepo interfaces.IUserRepository, |
|
||||||
secretKey string, |
|
||||||
logger interfaces.ILogger, |
|
||||||
) *JWTAuthService { |
|
||||||
return &JWTAuthService{ |
|
||||||
userRepo: userRepo, |
|
||||||
secretKey: secretKey, |
|
||||||
logger: logger, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (s *JWTAuthService) Authenticate(ctx context.Context, username string, password string) (string, error) { |
|
||||||
user, err := s.userRepo.FindByUsername(ctx, username) |
|
||||||
if err != nil { |
|
||||||
s.logger.Error(ctx, "Failed to find user by username", "error", err, "username", username) |
|
||||||
return "", ErrInvalidCredentials |
|
||||||
} |
|
||||||
|
|
||||||
if user == nil { |
|
||||||
s.logger.Warn(ctx, "User not found", "username", username) |
|
||||||
return "", ErrInvalidCredentials |
|
||||||
} |
|
||||||
|
|
||||||
if !user.ValidatePassword(password) { |
|
||||||
s.logger.Warn(ctx, "Invalid password", "username", username) |
|
||||||
return "", ErrInvalidCredentials |
|
||||||
} |
|
||||||
|
|
||||||
token, err := s.generateToken(user) |
|
||||||
if err != nil { |
|
||||||
s.logger.Error(ctx, "Failed to generate token", "error", err, "username", username) |
|
||||||
return "", err |
|
||||||
} |
|
||||||
|
|
||||||
s.logger.Info(ctx, "User authenticated successfully", "username", username, "userID", user.GetID().String()) |
|
||||||
return token, nil |
|
||||||
} |
|
||||||
|
|
||||||
func (s *JWTAuthService) ValidateToken(ctx context.Context, tokenString string) (bool, error) { |
|
||||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { |
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { |
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) |
|
||||||
} |
|
||||||
return []byte(s.secretKey), nil |
|
||||||
}) |
|
||||||
|
|
||||||
if err != nil { |
|
||||||
s.logger.Warn(ctx, "Token validation failed", "error", err) |
|
||||||
return false, nil |
|
||||||
} |
|
||||||
|
|
||||||
return token.Valid, nil |
|
||||||
} |
|
||||||
|
|
||||||
func (s *JWTAuthService) GetUserIDFromToken(ctx context.Context, tokenString string) (string, error) { |
|
||||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { |
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { |
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) |
|
||||||
} |
|
||||||
return []byte(s.secretKey), nil |
|
||||||
}) |
|
||||||
|
|
||||||
if err != nil { |
|
||||||
s.logger.Warn(ctx, "Failed to parse token", "error", err) |
|
||||||
return "", ErrInvalidToken |
|
||||||
} |
|
||||||
|
|
||||||
if !token.Valid { |
|
||||||
s.logger.Warn(ctx, "Invalid token") |
|
||||||
return "", ErrInvalidToken |
|
||||||
} |
|
||||||
|
|
||||||
claims, ok := token.Claims.(jwt.MapClaims) |
|
||||||
if !ok { |
|
||||||
s.logger.Warn(ctx, "Invalid token claims") |
|
||||||
return "", ErrInvalidToken |
|
||||||
} |
|
||||||
|
|
||||||
userID, ok := claims["sub"].(string) |
|
||||||
if !ok { |
|
||||||
s.logger.Warn(ctx, "User ID not found in token claims") |
|
||||||
return "", ErrInvalidToken |
|
||||||
} |
|
||||||
|
|
||||||
return userID, nil |
|
||||||
} |
|
||||||
|
|
||||||
func (s *JWTAuthService) generateToken(user *entities.UserEntity) (string, error) { |
|
||||||
claims := jwt.MapClaims{ |
|
||||||
"sub": user.GetID().String(), |
|
||||||
"username": user.GetUsername(), |
|
||||||
"iss": "autostore", |
|
||||||
"iat": time.Now().Unix(), |
|
||||||
"exp": time.Now().Add(time.Hour * 24).Unix(), // 24 hours expiration
|
|
||||||
} |
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) |
|
||||||
return token.SignedString([]byte(s.secretKey)) |
|
||||||
} |
|
||||||
@ -1,80 +0,0 @@ |
|||||||
package http |
|
||||||
|
|
||||||
import ( |
|
||||||
"bytes" |
|
||||||
"context" |
|
||||||
"encoding/json" |
|
||||||
"fmt" |
|
||||||
"net/http" |
|
||||||
"time" |
|
||||||
|
|
||||||
"autostore/internal/application/interfaces" |
|
||||||
"autostore/internal/domain/entities" |
|
||||||
) |
|
||||||
|
|
||||||
type OrderRequest struct { |
|
||||||
ItemID string `json:"itemId"` |
|
||||||
ItemName string `json:"itemName"` |
|
||||||
OrderURL string `json:"orderUrl"` |
|
||||||
UserID string `json:"userId"` |
|
||||||
OrderedAt time.Time `json:"orderedAt"` |
|
||||||
} |
|
||||||
|
|
||||||
type OrderURLHttpClient struct { |
|
||||||
client *http.Client |
|
||||||
logger interfaces.ILogger |
|
||||||
} |
|
||||||
|
|
||||||
func NewOrderURLHttpClient(logger interfaces.ILogger) *OrderURLHttpClient { |
|
||||||
return &OrderURLHttpClient{ |
|
||||||
client: &http.Client{ |
|
||||||
Timeout: 30 * time.Second, |
|
||||||
}, |
|
||||||
logger: logger, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (c *OrderURLHttpClient) OrderItem(ctx context.Context, item *entities.ItemEntity) error { |
|
||||||
orderURL := item.GetOrderURL() |
|
||||||
if orderURL == "" { |
|
||||||
return fmt.Errorf("order URL is empty") |
|
||||||
} |
|
||||||
|
|
||||||
orderRequest := OrderRequest{ |
|
||||||
ItemID: item.GetID().String(), |
|
||||||
ItemName: item.GetName(), |
|
||||||
OrderURL: orderURL, |
|
||||||
UserID: item.GetUserID().String(), |
|
||||||
OrderedAt: time.Now(), |
|
||||||
} |
|
||||||
|
|
||||||
jsonData, err := json.Marshal(orderRequest) |
|
||||||
if err != nil { |
|
||||||
return fmt.Errorf("failed to marshal order request: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
c.logger.Info(ctx, "Sending order request", "orderURL", orderURL, "itemID", orderRequest.ItemID) |
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", orderURL, bytes.NewBuffer(jsonData)) |
|
||||||
if err != nil { |
|
||||||
return fmt.Errorf("failed to create request: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json") |
|
||||||
|
|
||||||
resp, err := c.client.Do(req) |
|
||||||
if err != nil { |
|
||||||
return fmt.Errorf("failed to send order request: %w", err) |
|
||||||
} |
|
||||||
defer resp.Body.Close() |
|
||||||
|
|
||||||
// We don't care about the response body, just that the request was sent
|
|
||||||
if resp.StatusCode >= 400 { |
|
||||||
c.logger.Warn(ctx, "Order request returned non-success status", "status", resp.StatusCode, "orderURL", orderURL) |
|
||||||
// Don't fail the operation, just log the warning
|
|
||||||
return nil |
|
||||||
} |
|
||||||
|
|
||||||
c.logger.Info(ctx, "Order request sent successfully", "status", resp.StatusCode, "orderURL", orderURL) |
|
||||||
return nil |
|
||||||
} |
|
||||||
@ -1,41 +0,0 @@ |
|||||||
package logging |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
"io" |
|
||||||
"log" |
|
||||||
) |
|
||||||
|
|
||||||
type StandardLogger struct { |
|
||||||
logger *log.Logger |
|
||||||
} |
|
||||||
|
|
||||||
func NewStandardLogger(writer io.Writer) *StandardLogger { |
|
||||||
return &StandardLogger{ |
|
||||||
logger: log.New(writer, "", log.LstdFlags), |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (l *StandardLogger) Info(ctx context.Context, msg string, fields ...interface{}) { |
|
||||||
l.log("INFO", msg, fields...) |
|
||||||
} |
|
||||||
|
|
||||||
func (l *StandardLogger) Error(ctx context.Context, msg string, fields ...interface{}) { |
|
||||||
l.log("ERROR", msg, fields...) |
|
||||||
} |
|
||||||
|
|
||||||
func (l *StandardLogger) Debug(ctx context.Context, msg string, fields ...interface{}) { |
|
||||||
l.log("DEBUG", msg, fields...) |
|
||||||
} |
|
||||||
|
|
||||||
func (l *StandardLogger) Warn(ctx context.Context, msg string, fields ...interface{}) { |
|
||||||
l.log("WARN", msg, fields...) |
|
||||||
} |
|
||||||
|
|
||||||
func (l *StandardLogger) log(level, msg string, fields ...interface{}) { |
|
||||||
if len(fields) > 0 { |
|
||||||
l.logger.Printf("[%s] %s %v", level, msg, fields) |
|
||||||
} else { |
|
||||||
l.logger.Printf("[%s] %s", level, msg) |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,223 +0,0 @@ |
|||||||
package repositories |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
"encoding/json" |
|
||||||
"fmt" |
|
||||||
"os" |
|
||||||
"path/filepath" |
|
||||||
"sync" |
|
||||||
|
|
||||||
"autostore/internal/application/interfaces" |
|
||||||
"autostore/internal/domain/entities" |
|
||||||
"autostore/internal/domain/specifications" |
|
||||||
"autostore/internal/domain/value_objects" |
|
||||||
) |
|
||||||
|
|
||||||
type FileItemRepository struct { |
|
||||||
dataDirectory string |
|
||||||
logger interfaces.ILogger |
|
||||||
mu sync.RWMutex |
|
||||||
} |
|
||||||
|
|
||||||
func NewFileItemRepository(dataDirectory string, logger interfaces.ILogger) *FileItemRepository { |
|
||||||
return &FileItemRepository{ |
|
||||||
dataDirectory: dataDirectory, |
|
||||||
logger: logger, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (r *FileItemRepository) Save(ctx context.Context, item *entities.ItemEntity) error { |
|
||||||
r.logger.Info(ctx, "Saving item", "itemID", item.GetID().String()) |
|
||||||
|
|
||||||
filePath := filepath.Join(r.dataDirectory, "items.json") |
|
||||||
|
|
||||||
r.mu.Lock() |
|
||||||
defer r.mu.Unlock() |
|
||||||
|
|
||||||
items, err := r.loadItems(filePath) |
|
||||||
if err != nil { |
|
||||||
r.logger.Error(ctx, "Failed to load items", "error", err) |
|
||||||
return fmt.Errorf("failed to load items: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
items[item.GetID().String()] = item |
|
||||||
if err := r.saveItems(filePath, items); err != nil { |
|
||||||
r.logger.Error(ctx, "Failed to save items", "error", err) |
|
||||||
return fmt.Errorf("failed to save items: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
r.logger.Info(ctx, "Item saved successfully", "itemID", item.GetID().String()) |
|
||||||
return nil |
|
||||||
} |
|
||||||
|
|
||||||
func (r *FileItemRepository) FindByID(ctx context.Context, id value_objects.ItemID) (*entities.ItemEntity, error) { |
|
||||||
r.logger.Info(ctx, "Finding item by ID", "itemID", id.String()) |
|
||||||
|
|
||||||
filePath := filepath.Join(r.dataDirectory, "items.json") |
|
||||||
|
|
||||||
r.mu.RLock() |
|
||||||
defer r.mu.RUnlock() |
|
||||||
|
|
||||||
items, err := r.loadItems(filePath) |
|
||||||
if err != nil { |
|
||||||
r.logger.Error(ctx, "Failed to load items", "error", err) |
|
||||||
return nil, fmt.Errorf("failed to load items: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
item, ok := items[id.String()] |
|
||||||
if !ok { |
|
||||||
r.logger.Info(ctx, "Item not found", "itemID", id.String()) |
|
||||||
return nil, fmt.Errorf("item with ID %s not found", id.String()) |
|
||||||
} |
|
||||||
|
|
||||||
r.logger.Info(ctx, "Item found successfully", "itemID", id.String()) |
|
||||||
return item, nil |
|
||||||
} |
|
||||||
|
|
||||||
func (r *FileItemRepository) FindByUserID(ctx context.Context, userID value_objects.UserID) ([]*entities.ItemEntity, error) { |
|
||||||
r.logger.Info(ctx, "Finding items by user ID", "userID", userID.String()) |
|
||||||
|
|
||||||
filePath := filepath.Join(r.dataDirectory, "items.json") |
|
||||||
|
|
||||||
r.mu.RLock() |
|
||||||
defer r.mu.RUnlock() |
|
||||||
|
|
||||||
items, err := r.loadItems(filePath) |
|
||||||
if err != nil { |
|
||||||
r.logger.Error(ctx, "Failed to load items", "error", err) |
|
||||||
return nil, fmt.Errorf("failed to load items: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
var userItems []*entities.ItemEntity |
|
||||||
for _, item := range items { |
|
||||||
if item.GetUserID().Equals(userID) { |
|
||||||
userItems = append(userItems, item) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
r.logger.Info(ctx, "Found items for user", "userID", userID.String(), "count", len(userItems)) |
|
||||||
return userItems, nil |
|
||||||
} |
|
||||||
|
|
||||||
func (r *FileItemRepository) FindWhere(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) { |
|
||||||
r.logger.Info(ctx, "Finding items by specification") |
|
||||||
|
|
||||||
filePath := filepath.Join(r.dataDirectory, "items.json") |
|
||||||
|
|
||||||
r.mu.RLock() |
|
||||||
defer r.mu.RUnlock() |
|
||||||
|
|
||||||
items, err := r.loadItems(filePath) |
|
||||||
if err != nil { |
|
||||||
r.logger.Error(ctx, "Failed to load items", "error", err) |
|
||||||
return nil, fmt.Errorf("failed to load items: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
var filteredItems []*entities.ItemEntity |
|
||||||
for _, item := range items { |
|
||||||
if spec.IsSatisfiedBy(item) { |
|
||||||
filteredItems = append(filteredItems, item) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
r.logger.Info(ctx, "Found items matching specification", "count", len(filteredItems)) |
|
||||||
return filteredItems, nil |
|
||||||
} |
|
||||||
|
|
||||||
func (r *FileItemRepository) Delete(ctx context.Context, id value_objects.ItemID) error { |
|
||||||
r.logger.Info(ctx, "Deleting item", "itemID", id.String()) |
|
||||||
|
|
||||||
filePath := filepath.Join(r.dataDirectory, "items.json") |
|
||||||
|
|
||||||
r.mu.Lock() |
|
||||||
defer r.mu.Unlock() |
|
||||||
|
|
||||||
items, err := r.loadItems(filePath) |
|
||||||
if err != nil { |
|
||||||
r.logger.Error(ctx, "Failed to load items", "error", err) |
|
||||||
return fmt.Errorf("failed to load items: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
if _, exists := items[id.String()]; !exists { |
|
||||||
r.logger.Info(ctx, "Item not found for deletion", "itemID", id.String()) |
|
||||||
return fmt.Errorf("item with ID %s not found", id.String()) |
|
||||||
} |
|
||||||
|
|
||||||
delete(items, id.String()) |
|
||||||
if err := r.saveItems(filePath, items); err != nil { |
|
||||||
r.logger.Error(ctx, "Failed to save items after deletion", "error", err) |
|
||||||
return fmt.Errorf("failed to save items: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
r.logger.Info(ctx, "Item deleted successfully", "itemID", id.String()) |
|
||||||
return nil |
|
||||||
} |
|
||||||
|
|
||||||
func (r *FileItemRepository) Exists(ctx context.Context, id value_objects.ItemID) (bool, error) { |
|
||||||
r.logger.Info(ctx, "Checking if item exists", "itemID", id.String()) |
|
||||||
|
|
||||||
filePath := filepath.Join(r.dataDirectory, "items.json") |
|
||||||
|
|
||||||
r.mu.RLock() |
|
||||||
defer r.mu.RUnlock() |
|
||||||
|
|
||||||
items, err := r.loadItems(filePath) |
|
||||||
if err != nil { |
|
||||||
r.logger.Error(ctx, "Failed to load items", "error", err) |
|
||||||
return false, fmt.Errorf("failed to load items: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
_, ok := items[id.String()] |
|
||||||
r.logger.Info(ctx, "Item existence check completed", "itemID", id.String(), "exists", ok) |
|
||||||
return ok, nil |
|
||||||
} |
|
||||||
|
|
||||||
func (r *FileItemRepository) loadItems(filePath string) (map[string]*entities.ItemEntity, error) { |
|
||||||
// Ensure the directory exists
|
|
||||||
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { |
|
||||||
return nil, fmt.Errorf("failed to create directory: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
data, err := os.ReadFile(filePath) |
|
||||||
if err != nil { |
|
||||||
if os.IsNotExist(err) { |
|
||||||
return make(map[string]*entities.ItemEntity), nil |
|
||||||
} |
|
||||||
return nil, fmt.Errorf("failed to read file: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
var items map[string]*entities.ItemEntity |
|
||||||
if len(data) == 0 { |
|
||||||
return make(map[string]*entities.ItemEntity), nil |
|
||||||
} |
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &items); err != nil { |
|
||||||
return nil, fmt.Errorf("failed to unmarshal items: %w", err) |
|
||||||
} |
|
||||||
return items, nil |
|
||||||
} |
|
||||||
|
|
||||||
func (r *FileItemRepository) saveItems(filePath string, items map[string]*entities.ItemEntity) error { |
|
||||||
// Ensure the directory exists
|
|
||||||
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { |
|
||||||
return fmt.Errorf("failed to create directory: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
data, err := json.MarshalIndent(items, "", " ") |
|
||||||
if err != nil { |
|
||||||
return fmt.Errorf("failed to marshal items: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
// Write to a temporary file first, then rename for atomic operation
|
|
||||||
tempPath := filePath + ".tmp" |
|
||||||
if err := os.WriteFile(tempPath, data, 0644); err != nil { |
|
||||||
return fmt.Errorf("failed to write temporary file: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
if err := os.Rename(tempPath, filePath); err != nil { |
|
||||||
return fmt.Errorf("failed to rename temporary file: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
return nil |
|
||||||
} |
|
||||||
@ -1,156 +0,0 @@ |
|||||||
package repositories |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
"encoding/json" |
|
||||||
"errors" |
|
||||||
"fmt" |
|
||||||
"os" |
|
||||||
"path/filepath" |
|
||||||
"sync" |
|
||||||
|
|
||||||
"autostore/internal/application/interfaces" |
|
||||||
"autostore/internal/domain/entities" |
|
||||||
"autostore/internal/domain/value_objects" |
|
||||||
) |
|
||||||
|
|
||||||
type FileUserRepository struct { |
|
||||||
filePath string |
|
||||||
logger interfaces.ILogger |
|
||||||
mu sync.RWMutex |
|
||||||
} |
|
||||||
|
|
||||||
func NewFileUserRepository(dataDirectory string, logger interfaces.ILogger) *FileUserRepository { |
|
||||||
// Ensure the data directory exists
|
|
||||||
if err := os.MkdirAll(dataDirectory, 0755); err != nil { |
|
||||||
logger.Error(context.Background(), "Failed to create data directory", "error", err, "path", dataDirectory) |
|
||||||
} |
|
||||||
|
|
||||||
filePath := filepath.Join(dataDirectory, "users.json") |
|
||||||
|
|
||||||
// Initialize the file if it doesn't exist
|
|
||||||
if _, err := os.Stat(filePath); os.IsNotExist(err) { |
|
||||||
if err := os.WriteFile(filePath, []byte("{}"), 0644); err != nil { |
|
||||||
logger.Error(context.Background(), "Failed to create users file", "error", err, "path", filePath) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return &FileUserRepository{ |
|
||||||
filePath: filePath, |
|
||||||
logger: logger, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (r *FileUserRepository) FindByUsername(ctx context.Context, username string) (*entities.UserEntity, error) { |
|
||||||
r.mu.RLock() |
|
||||||
defer r.mu.RUnlock() |
|
||||||
|
|
||||||
users, err := r.readUsers() |
|
||||||
if err != nil { |
|
||||||
return nil, err |
|
||||||
} |
|
||||||
|
|
||||||
for _, userData := range users { |
|
||||||
if userData.Username == username { |
|
||||||
return r.dataToEntity(userData) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return nil, nil |
|
||||||
} |
|
||||||
|
|
||||||
func (r *FileUserRepository) FindByID(ctx context.Context, id value_objects.UserID) (*entities.UserEntity, error) { |
|
||||||
r.mu.RLock() |
|
||||||
defer r.mu.RUnlock() |
|
||||||
|
|
||||||
users, err := r.readUsers() |
|
||||||
if err != nil { |
|
||||||
return nil, err |
|
||||||
} |
|
||||||
|
|
||||||
userIDStr := id.String() |
|
||||||
if userData, exists := users[userIDStr]; exists { |
|
||||||
return r.dataToEntity(userData) |
|
||||||
} |
|
||||||
|
|
||||||
return nil, nil |
|
||||||
} |
|
||||||
|
|
||||||
func (r *FileUserRepository) Save(ctx context.Context, user *entities.UserEntity) error { |
|
||||||
r.mu.Lock() |
|
||||||
defer r.mu.Unlock() |
|
||||||
|
|
||||||
users, err := r.readUsers() |
|
||||||
if err != nil { |
|
||||||
return err |
|
||||||
} |
|
||||||
|
|
||||||
// Check if username already exists for a different user
|
|
||||||
userIDStr := user.GetID().String() |
|
||||||
for existingID, existingUser := range users { |
|
||||||
if existingID != userIDStr && existingUser.Username == user.GetUsername() { |
|
||||||
return errors.New("username already exists") |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
users[userIDStr] = r.entityToData(user) |
|
||||||
|
|
||||||
if err := r.writeUsers(users); err != nil { |
|
||||||
return err |
|
||||||
} |
|
||||||
|
|
||||||
r.logger.Info(ctx, "User saved successfully", "userID", userIDStr, "username", user.GetUsername()) |
|
||||||
return nil |
|
||||||
} |
|
||||||
|
|
||||||
// Helper types for JSON serialization
|
|
||||||
type userData struct { |
|
||||||
ID string `json:"id"` |
|
||||||
Username string `json:"username"` |
|
||||||
PasswordHash string `json:"passwordHash"` |
|
||||||
CreatedAt string `json:"createdAt"` |
|
||||||
} |
|
||||||
|
|
||||||
func (r *FileUserRepository) readUsers() (map[string]userData, error) { |
|
||||||
data, err := os.ReadFile(r.filePath) |
|
||||||
if err != nil { |
|
||||||
return nil, fmt.Errorf("failed to read users file: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
var users map[string]userData |
|
||||||
if err := json.Unmarshal(data, &users); err != nil { |
|
||||||
return nil, fmt.Errorf("failed to parse users file: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
return users, nil |
|
||||||
} |
|
||||||
|
|
||||||
func (r *FileUserRepository) writeUsers(users map[string]userData) error { |
|
||||||
data, err := json.MarshalIndent(users, "", " ") |
|
||||||
if err != nil { |
|
||||||
return fmt.Errorf("failed to marshal users: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
if err := os.WriteFile(r.filePath, data, 0644); err != nil { |
|
||||||
return fmt.Errorf("failed to write users file: %w", err) |
|
||||||
} |
|
||||||
|
|
||||||
return nil |
|
||||||
} |
|
||||||
|
|
||||||
func (r *FileUserRepository) entityToData(user *entities.UserEntity) userData { |
|
||||||
return userData{ |
|
||||||
ID: user.GetID().String(), |
|
||||||
Username: user.GetUsername(), |
|
||||||
PasswordHash: user.GetPasswordHash(), |
|
||||||
CreatedAt: "", // Will be added when user entity has createdAt field
|
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (r *FileUserRepository) dataToEntity(data userData) (*entities.UserEntity, error) { |
|
||||||
userID, err := value_objects.NewUserID(data.ID) |
|
||||||
if err != nil { |
|
||||||
return nil, fmt.Errorf("invalid user ID: %w", err) |
|
||||||
} |
|
||||||
return entities.NewUserWithHashedPassword(userID, data.Username, data.PasswordHash) |
|
||||||
} |
|
||||||
@ -1,104 +0,0 @@ |
|||||||
package scheduler |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
"time" |
|
||||||
|
|
||||||
"autostore/internal/application/commands" |
|
||||||
"autostore/internal/application/interfaces" |
|
||||||
) |
|
||||||
|
|
||||||
type ExpiredItemsScheduler struct { |
|
||||||
handleExpiredItemsCmd *commands.HandleExpiredItemsCommand |
|
||||||
logger interfaces.ILogger |
|
||||||
ticker *time.Ticker |
|
||||||
done chan struct{} |
|
||||||
} |
|
||||||
|
|
||||||
func NewExpiredItemsScheduler( |
|
||||||
handleExpiredItemsCmd *commands.HandleExpiredItemsCommand, |
|
||||||
logger interfaces.ILogger, |
|
||||||
) *ExpiredItemsScheduler { |
|
||||||
return &ExpiredItemsScheduler{ |
|
||||||
handleExpiredItemsCmd: handleExpiredItemsCmd, |
|
||||||
logger: logger, |
|
||||||
done: make(chan struct{}), |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (s *ExpiredItemsScheduler) Start(ctx context.Context) error { |
|
||||||
s.logger.Info(ctx, "Starting expired items scheduler") |
|
||||||
|
|
||||||
// Process expired items immediately on startup
|
|
||||||
if err := s.processExpiredItems(ctx); err != nil { |
|
||||||
s.logger.Error(ctx, "Failed to process expired items on startup", "error", err) |
|
||||||
return err |
|
||||||
} |
|
||||||
|
|
||||||
// Calculate duration until next midnight
|
|
||||||
now := time.Now() |
|
||||||
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) |
|
||||||
if now.After(midnight) { |
|
||||||
midnight = midnight.Add(24 * time.Hour) |
|
||||||
} |
|
||||||
durationUntilMidnight := midnight.Sub(now) |
|
||||||
|
|
||||||
// Start a timer for the first midnight
|
|
||||||
firstMidnightTimer := time.NewTimer(durationUntilMidnight) |
|
||||||
|
|
||||||
go func() { |
|
||||||
for { |
|
||||||
select { |
|
||||||
case <-firstMidnightTimer.C: |
|
||||||
s.processExpiredItems(ctx) |
|
||||||
|
|
||||||
// After first midnight, set up daily ticker
|
|
||||||
s.ticker = time.NewTicker(24 * time.Hour) |
|
||||||
firstMidnightTimer.Stop() |
|
||||||
|
|
||||||
for { |
|
||||||
select { |
|
||||||
case <-s.ticker.C: |
|
||||||
s.processExpiredItems(ctx) |
|
||||||
case <-s.done: |
|
||||||
s.ticker.Stop() |
|
||||||
return |
|
||||||
case <-ctx.Done(): |
|
||||||
s.ticker.Stop() |
|
||||||
return |
|
||||||
} |
|
||||||
} |
|
||||||
case <-s.done: |
|
||||||
firstMidnightTimer.Stop() |
|
||||||
return |
|
||||||
case <-ctx.Done(): |
|
||||||
firstMidnightTimer.Stop() |
|
||||||
return |
|
||||||
} |
|
||||||
} |
|
||||||
}() |
|
||||||
|
|
||||||
s.logger.Info(ctx, "Expired items scheduler started", "next_run", midnight.Format(time.RFC3339)) |
|
||||||
return nil |
|
||||||
} |
|
||||||
|
|
||||||
func (s *ExpiredItemsScheduler) Stop() error { |
|
||||||
s.logger.Info(context.Background(), "Stopping expired items scheduler") |
|
||||||
close(s.done) |
|
||||||
if s.ticker != nil { |
|
||||||
s.ticker.Stop() |
|
||||||
} |
|
||||||
return nil |
|
||||||
} |
|
||||||
|
|
||||||
func (s *ExpiredItemsScheduler) processExpiredItems(ctx context.Context) error { |
|
||||||
s.logger.Info(ctx, "Running scheduled expired items processing") |
|
||||||
|
|
||||||
if err := s.handleExpiredItemsCmd.Execute(ctx); err != nil { |
|
||||||
s.logger.Error(ctx, "Scheduled expired items processing failed", "error", err) |
|
||||||
return err |
|
||||||
} |
|
||||||
|
|
||||||
s.logger.Info(ctx, "Scheduled expired items processing completed") |
|
||||||
return nil |
|
||||||
} |
|
||||||
@ -1,65 +0,0 @@ |
|||||||
package services |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
|
|
||||||
"autostore/internal/application/interfaces" |
|
||||||
"autostore/internal/domain/entities" |
|
||||||
"autostore/internal/domain/value_objects" |
|
||||||
) |
|
||||||
|
|
||||||
type UserInitializationService struct { |
|
||||||
userRepository interfaces.IUserRepository |
|
||||||
logger interfaces.ILogger |
|
||||||
} |
|
||||||
|
|
||||||
func NewUserInitializationService(userRepository interfaces.IUserRepository, logger interfaces.ILogger) *UserInitializationService { |
|
||||||
return &UserInitializationService{ |
|
||||||
userRepository: userRepository, |
|
||||||
logger: logger, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (s *UserInitializationService) InitializeDefaultUsers() error { |
|
||||||
defaultUsers := []struct { |
|
||||||
username string |
|
||||||
password string |
|
||||||
}{ |
|
||||||
{username: "admin", password: "admin"}, |
|
||||||
{username: "user", password: "user"}, |
|
||||||
} |
|
||||||
|
|
||||||
for _, userData := range defaultUsers { |
|
||||||
existingUser, err := s.userRepository.FindByUsername(context.Background(), userData.username) |
|
||||||
if err != nil { |
|
||||||
s.logger.Error(context.Background(), "Failed to check if user exists", "error", err, "username", userData.username) |
|
||||||
continue |
|
||||||
} |
|
||||||
|
|
||||||
if existingUser != nil { |
|
||||||
s.logger.Info(context.Background(), "Default user already exists", "username", userData.username) |
|
||||||
continue |
|
||||||
} |
|
||||||
|
|
||||||
userID, err := value_objects.NewRandomUserID() |
|
||||||
if err != nil { |
|
||||||
s.logger.Error(context.Background(), "Failed to generate user ID", "error", err) |
|
||||||
continue |
|
||||||
} |
|
||||||
|
|
||||||
user, err := entities.NewUser(userID, userData.username, userData.password) |
|
||||||
if err != nil { |
|
||||||
s.logger.Error(context.Background(), "Failed to create user entity", "error", err, "username", userData.username) |
|
||||||
continue |
|
||||||
} |
|
||||||
|
|
||||||
if err := s.userRepository.Save(context.Background(), user); err != nil { |
|
||||||
s.logger.Error(context.Background(), "Failed to save user", "error", err, "username", userData.username) |
|
||||||
continue |
|
||||||
} |
|
||||||
|
|
||||||
s.logger.Info(context.Background(), "Created default user", "username", userData.username) |
|
||||||
} |
|
||||||
|
|
||||||
return nil |
|
||||||
} |
|
||||||
@ -1,15 +0,0 @@ |
|||||||
package time |
|
||||||
|
|
||||||
import ( |
|
||||||
"time" |
|
||||||
) |
|
||||||
|
|
||||||
type SystemTimeProvider struct{} |
|
||||||
|
|
||||||
func NewSystemTimeProvider() *SystemTimeProvider { |
|
||||||
return &SystemTimeProvider{} |
|
||||||
} |
|
||||||
|
|
||||||
func (p *SystemTimeProvider) Now() time.Time { |
|
||||||
return time.Now() |
|
||||||
} |
|
||||||
@ -1,53 +0,0 @@ |
|||||||
package controllers |
|
||||||
|
|
||||||
import ( |
|
||||||
"net/http" |
|
||||||
|
|
||||||
"github.com/gin-gonic/gin" |
|
||||||
|
|
||||||
"autostore/internal/application/commands" |
|
||||||
"autostore/internal/application/dto" |
|
||||||
"autostore/internal/application/interfaces" |
|
||||||
) |
|
||||||
|
|
||||||
type AuthController struct { |
|
||||||
loginUserCmd *commands.LoginUserCommand |
|
||||||
logger interfaces.ILogger |
|
||||||
} |
|
||||||
|
|
||||||
func NewAuthController( |
|
||||||
loginUserCmd *commands.LoginUserCommand, |
|
||||||
logger interfaces.ILogger, |
|
||||||
) *AuthController { |
|
||||||
return &AuthController{ |
|
||||||
loginUserCmd: loginUserCmd, |
|
||||||
logger: logger, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (ctrl *AuthController) Login(c *gin.Context) { |
|
||||||
var loginDTO dto.LoginDTO |
|
||||||
if err := c.ShouldBindJSON(&loginDTO); err != nil { |
|
||||||
c.JSON(http.StatusBadRequest, dto.JSendError("Invalid request body", http.StatusBadRequest)) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
if err := loginDTO.Validate(); err != nil { |
|
||||||
c.JSON(http.StatusBadRequest, dto.JSendError(err.Error(), http.StatusBadRequest)) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
token, err := ctrl.loginUserCmd.Execute(c.Request.Context(), loginDTO.Username, loginDTO.Password) |
|
||||||
if err != nil { |
|
||||||
c.JSON(http.StatusUnauthorized, dto.JSendError(err.Error(), http.StatusUnauthorized)) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
response := &dto.LoginResponseDTO{ |
|
||||||
Token: token, |
|
||||||
TokenType: "Bearer", |
|
||||||
ExpiresIn: 3600, // 1 hour in seconds
|
|
||||||
} |
|
||||||
|
|
||||||
c.JSON(http.StatusOK, dto.JSendSuccess(response)) |
|
||||||
} |
|
||||||
@ -1,157 +0,0 @@ |
|||||||
package controllers |
|
||||||
|
|
||||||
import ( |
|
||||||
"net/http" |
|
||||||
"time" |
|
||||||
|
|
||||||
"github.com/gin-gonic/gin" |
|
||||||
"errors" |
|
||||||
|
|
||||||
"autostore/internal/application/commands" |
|
||||||
"autostore/internal/application/dto" |
|
||||||
"autostore/internal/application/interfaces" |
|
||||||
"autostore/internal/application/queries" |
|
||||||
) |
|
||||||
|
|
||||||
type ItemsController struct { |
|
||||||
addItemCmd *commands.AddItemCommand |
|
||||||
getItemQry *queries.GetItemQuery |
|
||||||
listItemsQry *queries.ListItemsQuery |
|
||||||
deleteItemCmd *commands.DeleteItemCommand |
|
||||||
logger interfaces.ILogger |
|
||||||
} |
|
||||||
|
|
||||||
func NewItemsController( |
|
||||||
addItemCmd *commands.AddItemCommand, |
|
||||||
getItemQry *queries.GetItemQuery, |
|
||||||
listItemsQry *queries.ListItemsQuery, |
|
||||||
deleteItemCmd *commands.DeleteItemCommand, |
|
||||||
logger interfaces.ILogger, |
|
||||||
) *ItemsController { |
|
||||||
return &ItemsController{ |
|
||||||
addItemCmd: addItemCmd, |
|
||||||
getItemQry: getItemQry, |
|
||||||
listItemsQry: listItemsQry, |
|
||||||
deleteItemCmd: deleteItemCmd, |
|
||||||
logger: logger, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (ctrl *ItemsController) CreateItem(c *gin.Context) { |
|
||||||
userID, exists := c.Get("userID") |
|
||||||
if !exists { |
|
||||||
c.JSON(http.StatusUnauthorized, dto.JSendError("Unauthorized", http.StatusUnauthorized)) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
var createItemDTO dto.CreateItemDTO |
|
||||||
if err := c.ShouldBindJSON(&createItemDTO); err != nil { |
|
||||||
c.JSON(http.StatusBadRequest, dto.JSendError("Invalid request body", http.StatusBadRequest)) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
if err := createItemDTO.Validate(); err != nil { |
|
||||||
c.JSON(http.StatusBadRequest, dto.JSendError(err.Error(), http.StatusBadRequest)) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
itemID, err := ctrl.addItemCmd.Execute(c.Request.Context(), createItemDTO.Name, createItemDTO.ExpirationDate.Time, createItemDTO.OrderURL, userID.(string)) |
|
||||||
if err != nil { |
|
||||||
c.JSON(http.StatusInternalServerError, dto.JSendError(err.Error(), http.StatusInternalServerError)) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
response := &dto.ItemResponseDTO{ |
|
||||||
ID: itemID, |
|
||||||
Name: createItemDTO.Name, |
|
||||||
ExpirationDate: createItemDTO.ExpirationDate, |
|
||||||
OrderURL: createItemDTO.OrderURL, |
|
||||||
UserID: userID.(string), |
|
||||||
CreatedAt: dto.JSONTime{Time: time.Now()}, |
|
||||||
} |
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, dto.JSendSuccess(response)) |
|
||||||
} |
|
||||||
|
|
||||||
func (ctrl *ItemsController) GetItem(c *gin.Context) { |
|
||||||
userID, exists := c.Get("userID") |
|
||||||
if !exists { |
|
||||||
c.JSON(http.StatusUnauthorized, dto.JSendError("Unauthorized", http.StatusUnauthorized)) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
itemID := c.Param("id") |
|
||||||
if itemID == "" { |
|
||||||
c.JSON(http.StatusBadRequest, dto.JSendError("Item ID is required", http.StatusBadRequest)) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
item, err := ctrl.getItemQry.Execute(c.Request.Context(), itemID, userID.(string)) |
|
||||||
if err != nil { |
|
||||||
if errors.Is(err, queries.ErrItemNotFound) { |
|
||||||
c.JSON(http.StatusNotFound, dto.JSendError("Item not found", http.StatusNotFound)) |
|
||||||
return |
|
||||||
} |
|
||||||
if errors.Is(err, queries.ErrUnauthorizedAccess) { |
|
||||||
c.JSON(http.StatusNotFound, dto.JSendError("Item not found", http.StatusNotFound)) |
|
||||||
return |
|
||||||
} |
|
||||||
c.JSON(http.StatusInternalServerError, dto.JSendError(err.Error(), http.StatusInternalServerError)) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
response := (&dto.ItemResponseDTO{}).FromEntity(item) |
|
||||||
|
|
||||||
c.JSON(http.StatusOK, dto.JSendSuccess(response)) |
|
||||||
} |
|
||||||
|
|
||||||
func (ctrl *ItemsController) ListItems(c *gin.Context) { |
|
||||||
userID, exists := c.Get("userID") |
|
||||||
if !exists { |
|
||||||
c.JSON(http.StatusUnauthorized, dto.JSendError("Unauthorized", http.StatusUnauthorized)) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
items, err := ctrl.listItemsQry.Execute(c.Request.Context(), userID.(string)) |
|
||||||
if err != nil { |
|
||||||
c.JSON(http.StatusInternalServerError, dto.JSendError(err.Error(), http.StatusInternalServerError)) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
var response []*dto.ItemResponseDTO |
|
||||||
for _, item := range items { |
|
||||||
response = append(response, (&dto.ItemResponseDTO{}).FromEntity(item)) |
|
||||||
} |
|
||||||
|
|
||||||
c.JSON(http.StatusOK, dto.JSendSuccess(response)) |
|
||||||
} |
|
||||||
|
|
||||||
func (ctrl *ItemsController) DeleteItem(c *gin.Context) { |
|
||||||
userID, exists := c.Get("userID") |
|
||||||
if !exists { |
|
||||||
c.JSON(http.StatusUnauthorized, dto.JSendError("Unauthorized", http.StatusUnauthorized)) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
itemID := c.Param("id") |
|
||||||
if itemID == "" { |
|
||||||
c.JSON(http.StatusBadRequest, dto.JSendError("Item ID is required", http.StatusBadRequest)) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
err := ctrl.deleteItemCmd.Execute(c.Request.Context(), itemID, userID.(string)) |
|
||||||
if err != nil { |
|
||||||
if errors.Is(err, commands.ErrItemNotFound) { |
|
||||||
c.JSON(http.StatusNotFound, dto.JSendError("Item not found", http.StatusNotFound)) |
|
||||||
return |
|
||||||
} |
|
||||||
if errors.Is(err, commands.ErrUnauthorizedAccess) { |
|
||||||
c.JSON(http.StatusNotFound, dto.JSendError("Item not found", http.StatusNotFound)) |
|
||||||
return |
|
||||||
} |
|
||||||
c.JSON(http.StatusInternalServerError, dto.JSendError(err.Error(), http.StatusInternalServerError)) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
c.JSON(http.StatusNoContent, dto.JSendSuccess(nil)) |
|
||||||
} |
|
||||||
@ -1,62 +0,0 @@ |
|||||||
package middleware |
|
||||||
|
|
||||||
import ( |
|
||||||
"net/http" |
|
||||||
"strings" |
|
||||||
|
|
||||||
"github.com/gin-gonic/gin" |
|
||||||
|
|
||||||
"autostore/internal/application/interfaces" |
|
||||||
"autostore/internal/application/dto" |
|
||||||
) |
|
||||||
|
|
||||||
type JWTMiddleware struct { |
|
||||||
authService interfaces.IAuthService |
|
||||||
logger interfaces.ILogger |
|
||||||
} |
|
||||||
|
|
||||||
func NewJWTMiddleware( |
|
||||||
authService interfaces.IAuthService, |
|
||||||
logger interfaces.ILogger, |
|
||||||
) *JWTMiddleware { |
|
||||||
return &JWTMiddleware{ |
|
||||||
authService: authService, |
|
||||||
logger: logger, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (m *JWTMiddleware) Middleware() gin.HandlerFunc { |
|
||||||
return func(c *gin.Context) { |
|
||||||
authHeader := c.GetHeader("Authorization") |
|
||||||
if authHeader == "" { |
|
||||||
c.JSON(http.StatusUnauthorized, dto.JSendError("Authorization header is required", http.StatusUnauthorized)) |
|
||||||
c.Abort() |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
parts := strings.SplitN(authHeader, " ", 2) |
|
||||||
if !(len(parts) == 2 && parts[0] == "Bearer") { |
|
||||||
c.JSON(http.StatusUnauthorized, dto.JSendError("Invalid authorization format", http.StatusUnauthorized)) |
|
||||||
c.Abort() |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
token := parts[1] |
|
||||||
valid, err := m.authService.ValidateToken(c.Request.Context(), token) |
|
||||||
if err != nil || !valid { |
|
||||||
c.JSON(http.StatusUnauthorized, dto.JSendError("Invalid or expired token", http.StatusUnauthorized)) |
|
||||||
c.Abort() |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
userID, err := m.authService.GetUserIDFromToken(c.Request.Context(), token) |
|
||||||
if err != nil { |
|
||||||
c.JSON(http.StatusUnauthorized, dto.JSendError("Failed to get user ID from token", http.StatusUnauthorized)) |
|
||||||
c.Abort() |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
c.Set("userID", userID) |
|
||||||
c.Next() |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,117 +0,0 @@ |
|||||||
package server |
|
||||||
|
|
||||||
import ( |
|
||||||
"context" |
|
||||||
"net/http" |
|
||||||
"strconv" |
|
||||||
"time" |
|
||||||
|
|
||||||
"github.com/gin-gonic/gin" |
|
||||||
|
|
||||||
"autostore/internal/application/interfaces" |
|
||||||
"autostore/internal/presentation/controllers" |
|
||||||
"autostore/internal/presentation/middleware" |
|
||||||
) |
|
||||||
|
|
||||||
type Server struct { |
|
||||||
config *Config |
|
||||||
logger interfaces.ILogger |
|
||||||
itemsCtrl *controllers.ItemsController |
|
||||||
authCtrl *controllers.AuthController |
|
||||||
jwtMiddleware *middleware.JWTMiddleware |
|
||||||
httpServer *http.Server |
|
||||||
} |
|
||||||
|
|
||||||
type Config struct { |
|
||||||
Port int |
|
||||||
ReadTimeout time.Duration |
|
||||||
WriteTimeout time.Duration |
|
||||||
ShutdownTimeout time.Duration |
|
||||||
} |
|
||||||
|
|
||||||
func NewServer( |
|
||||||
config *Config, |
|
||||||
logger interfaces.ILogger, |
|
||||||
itemsCtrl *controllers.ItemsController, |
|
||||||
authCtrl *controllers.AuthController, |
|
||||||
jwtMiddleware *middleware.JWTMiddleware, |
|
||||||
) *Server { |
|
||||||
return &Server{ |
|
||||||
config: config, |
|
||||||
logger: logger, |
|
||||||
itemsCtrl: itemsCtrl, |
|
||||||
authCtrl: authCtrl, |
|
||||||
jwtMiddleware: jwtMiddleware, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (s *Server) Start() error { |
|
||||||
gin.SetMode(gin.ReleaseMode) |
|
||||||
router := s.SetupRoutes() |
|
||||||
|
|
||||||
s.httpServer = &http.Server{ |
|
||||||
Addr: ":" + strconv.Itoa(s.config.Port), |
|
||||||
Handler: router, |
|
||||||
ReadTimeout: s.config.ReadTimeout, |
|
||||||
WriteTimeout: s.config.WriteTimeout, |
|
||||||
} |
|
||||||
|
|
||||||
s.logger.Info(context.Background(), "Server starting on port %d", s.config.Port) |
|
||||||
return s.httpServer.ListenAndServe() |
|
||||||
} |
|
||||||
|
|
||||||
func (s *Server) Shutdown(ctx context.Context) error { |
|
||||||
s.logger.Info(ctx, "Server shutting down") |
|
||||||
|
|
||||||
shutdownCtx, cancel := context.WithTimeout(ctx, s.config.ShutdownTimeout) |
|
||||||
defer cancel() |
|
||||||
|
|
||||||
return s.httpServer.Shutdown(shutdownCtx) |
|
||||||
} |
|
||||||
|
|
||||||
func (s *Server) SetupRoutes() *gin.Engine { |
|
||||||
router := gin.New() |
|
||||||
|
|
||||||
// Middleware
|
|
||||||
router.Use(gin.Recovery()) |
|
||||||
router.Use(s.corsMiddleware()) |
|
||||||
|
|
||||||
// Health check
|
|
||||||
router.GET("/health", func(c *gin.Context) { |
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"}) |
|
||||||
}) |
|
||||||
|
|
||||||
// API v1 group
|
|
||||||
v1 := router.Group("/api/v1") |
|
||||||
|
|
||||||
// Auth routes (no JWT middleware)
|
|
||||||
v1.POST("/login", s.authCtrl.Login) |
|
||||||
|
|
||||||
// Items routes (with JWT middleware)
|
|
||||||
items := v1.Group("/items") |
|
||||||
items.Use(s.jwtMiddleware.Middleware()) |
|
||||||
{ |
|
||||||
items.POST("", s.itemsCtrl.CreateItem) |
|
||||||
items.GET("", s.itemsCtrl.ListItems) |
|
||||||
items.GET("/:id", s.itemsCtrl.GetItem) |
|
||||||
items.DELETE("/:id", s.itemsCtrl.DeleteItem) |
|
||||||
} |
|
||||||
|
|
||||||
return router |
|
||||||
} |
|
||||||
|
|
||||||
func (s *Server) corsMiddleware() gin.HandlerFunc { |
|
||||||
return func(c *gin.Context) { |
|
||||||
c.Header("Access-Control-Allow-Origin", "*") |
|
||||||
c.Header("Access-Control-Allow-Credentials", "true") |
|
||||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") |
|
||||||
c.Header("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") |
|
||||||
|
|
||||||
if c.Request.Method == "OPTIONS" { |
|
||||||
c.AbortWithStatus(204) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
c.Next() |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,348 +0,0 @@ |
|||||||
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()) |
|
||||||
} |
|
||||||
@ -1,352 +0,0 @@ |
|||||||
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") |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,293 +0,0 @@ |
|||||||
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)) |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,904 +0,0 @@ |
|||||||
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") |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,99 +0,0 @@ |
|||||||
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{}) {} |
|
||||||
@ -1,189 +0,0 @@ |
|||||||
# Specification Pattern in PHP for AutoStore |
|
||||||
|
|
||||||
The Specification pattern is a way to encapsulate business rules or query criteria in a single place. In AutoStore, this pattern helps us define conditions like "find expired items" without duplicating logic across the codebase. |
|
||||||
|
|
||||||
## Why Do We Need This? |
|
||||||
|
|
||||||
Imagine checking if an item is expired in multiple places: |
|
||||||
|
|
||||||
```php |
|
||||||
// In a repository |
|
||||||
$expiredItems = array_filter($items, function($item) { |
|
||||||
return $item->getExpirationDate() <= new \DateTime(); |
|
||||||
}); |
|
||||||
|
|
||||||
// In a service |
|
||||||
if ($item->getExpirationDate() <= new \DateTime()) { |
|
||||||
$this->sendOrderNotification($item); |
|
||||||
} |
|
||||||
|
|
||||||
// In SQL query |
|
||||||
$stmt = $pdo->prepare("SELECT * FROM items WHERE expirationDate <= :expirationDate"); |
|
||||||
$stmt->execute(['expirationDate' => (new DateTime())->format('Y-m-d H:i:s')]); |
|
||||||
$items = $stmt->fetchAll(PDO::FETCH_ASSOC); |
|
||||||
``` |
|
||||||
|
|
||||||
This creates problems: |
|
||||||
- **Duplication**: The same rule appears multiple times |
|
||||||
- **Maintenance**: If the rule changes, you need to update it everywhere |
|
||||||
- **Database queries**: In-memory filtering doesn't translate to SQL WHERE clauses |
|
||||||
|
|
||||||
## How This Implementation Works |
|
||||||
|
|
||||||
The implementation has two main classes: |
|
||||||
|
|
||||||
1. **Specification**: Evaluates conditions against objects |
|
||||||
2. **Spec**: Helper class with constants and methods to build specifications |
|
||||||
|
|
||||||
### Basic Structure |
|
||||||
|
|
||||||
A specification can be: |
|
||||||
- A simple comparison: `[field, operator, value]` (e.g., `['expirationDate', '<=', $today]`) |
|
||||||
- A logical group: `AND`, `OR`, or `NOT` containing other specifications |
|
||||||
|
|
||||||
### Simple Example: Finding Expired Items |
|
||||||
|
|
||||||
```php |
|
||||||
// Create a specification for expired items |
|
||||||
$expiredSpec = new Specification( |
|
||||||
Spec::lte('expirationDate', new \DateTimeImmutable()) |
|
||||||
); |
|
||||||
|
|
||||||
// Use it to check if an item is expired |
|
||||||
if ($expiredSpec->match($item)) { |
|
||||||
echo "This item is expired!"; |
|
||||||
} |
|
||||||
|
|
||||||
// Or filter an array of items |
|
||||||
$expiredItems = array_filter($items, function($item) use ($expiredSpec) { |
|
||||||
return $expiredSpec->match($item); |
|
||||||
}); |
|
||||||
``` |
|
||||||
|
|
||||||
### Creating Complex Conditions |
|
||||||
|
|
||||||
You can combine conditions using `and`, `or`, and `not`: |
|
||||||
|
|
||||||
```php |
|
||||||
// Find active items that are expiring in the next 7 days |
|
||||||
$aboutToExpireSpec = new Specification( |
|
||||||
Spec::and([ |
|
||||||
Spec::eq('status', 'active'), |
|
||||||
Spec::and([ |
|
||||||
Spec::gte('expirationDate', new \DateTimeImmutable()), |
|
||||||
Spec::lte('expirationDate', new \DateTimeImmutable('+7 days')) |
|
||||||
]) |
|
||||||
]) |
|
||||||
); |
|
||||||
``` |
|
||||||
|
|
||||||
## Available Operators |
|
||||||
|
|
||||||
### Comparison Operators |
|
||||||
|
|
||||||
| Method | Description | Example | |
|
||||||
|--------|-------------|---------| |
|
||||||
| `Spec::eq(field, value)` | Equals | `Spec::eq('name', 'Milk')` | |
|
||||||
| `Spec::neq(field, value)` | Not equals | `Spec::neq('status', 'deleted')` | |
|
||||||
| `Spec::gt(field, value)` | Greater than | `Spec::gt('quantity', 10)` | |
|
||||||
| `Spec::gte(field, value)` | Greater than or equal | `Spec::gte('price', 5.99)` | |
|
||||||
| `Spec::lt(field, value)` | Less than | `Spec::lt('weight', 2.5)` | |
|
||||||
| `Spec::lte(field, value)` | Less than or equal | `Spec::lte('expirationDate', $today)` | |
|
||||||
| `Spec::in(field, [values])` | Value is in list | `Spec::in('category', ['dairy', 'meat'])` | |
|
||||||
| `Spec::nin(field, [values])` | Value is not in list | `Spec::nin('status', ['deleted', 'archived'])` | |
|
||||||
|
|
||||||
### Logical Operators |
|
||||||
|
|
||||||
| Method | Description | Example | |
|
||||||
|--------|-------------|---------| |
|
||||||
| `Spec::and([specs])` | All conditions must be true | `Spec::and([$spec1, $spec2])` | |
|
||||||
| `Spec::or([specs])` | At least one condition must be true | `Spec::or([$spec1, $spec2])` | |
|
||||||
| `Spec::not(spec)` | Negates the condition | `Spec::not($spec1)` | |
|
||||||
|
|
||||||
## Best Practice: Domain-Specific Specifications |
|
||||||
|
|
||||||
For important business rules, create dedicated classes: |
|
||||||
|
|
||||||
```php |
|
||||||
// src/Domain/Specifications/ItemExpirationSpec.php |
|
||||||
class ItemExpirationSpec |
|
||||||
{ |
|
||||||
public function isExpired(Item $item, DateTimeImmutable $currentTime): bool |
|
||||||
{ |
|
||||||
return $this->getSpec($currentTime)->match($item); |
|
||||||
} |
|
||||||
|
|
||||||
public function getSpec(DateTimeImmutable $currentTime): Specification |
|
||||||
{ |
|
||||||
return new Specification( |
|
||||||
Spec::lte('expirationDate', $currentTime->format('Y-m-d H:i:s')) |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
## Using Specifications with Repositories |
|
||||||
|
|
||||||
### For In-Memory Repositories |
|
||||||
|
|
||||||
```php |
|
||||||
public function findWhere(Specification $specification): array |
|
||||||
{ |
|
||||||
$result = []; |
|
||||||
foreach ($this->items as $item) { |
|
||||||
if ($specification->match($item)) { |
|
||||||
$result[] = $item; |
|
||||||
} |
|
||||||
} |
|
||||||
return $result; |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
### For SQL Repositories |
|
||||||
|
|
||||||
The SQL renderer in the example converts specifications to WHERE clauses: |
|
||||||
|
|
||||||
```php |
|
||||||
public function findWhere(Specification $specification): array |
|
||||||
{ |
|
||||||
$params = []; |
|
||||||
$sqlRenderer = new SqlRenderer(); |
|
||||||
$whereClause = $sqlRenderer->render($spec->getSpec(), $params); |
|
||||||
|
|
||||||
$sql = "SELECT * FROM items WHERE $whereClause"; |
|
||||||
$stmt = $this->pdo->prepare($sql); |
|
||||||
$stmt->execute($params); |
|
||||||
|
|
||||||
return $this->mapToItems($stmt->fetchAll(\PDO::FETCH_ASSOC)); |
|
||||||
} |
|
||||||
``` |
|
||||||
|
|
||||||
## Example: SQL Renderer Usage |
|
||||||
|
|
||||||
The commented example in the code shows how to convert a specification to SQL: |
|
||||||
|
|
||||||
```php |
|
||||||
$spec = Spec::and([ |
|
||||||
Spec::eq('status', 'active'), |
|
||||||
Spec::or([ |
|
||||||
Spec::gt('score', 80), |
|
||||||
Spec::in('role', ['admin', 'moderator']) |
|
||||||
]), |
|
||||||
Spec::not(Spec::eq('deleted', true)) |
|
||||||
]); |
|
||||||
|
|
||||||
$params = []; |
|
||||||
$sqlRenderer = new SqlRenderer(); |
|
||||||
$whereClause = $sqlRenderer->render($spec, $params); |
|
||||||
|
|
||||||
// Results in SQL like: |
|
||||||
// (status = ? AND ((score > ?) OR (role IN (?,?))) AND NOT (deleted = ?)) |
|
||||||
// with params: ['active', 80, 'admin', 'moderator', true] |
|
||||||
``` |
|
||||||
|
|
||||||
## Benefits for AutoStore |
|
||||||
|
|
||||||
1. **Consistent business rules**: Item expiration logic is defined once |
|
||||||
2. **Clean code**: No duplicated conditions across repositories, services, etc. |
|
||||||
3. **Efficiency**: Can be used both for in-memory filtering and SQL queries |
|
||||||
4. **Flexibility**: If business rules change (e.g., items expire 3 days after their date), you only update one place |
|
||||||
@ -1,57 +0,0 @@ |
|||||||
<?php |
|
||||||
|
|
||||||
declare(strict_types=1); |
|
||||||
|
|
||||||
require_once __DIR__ . '/../vendor/autoload.php'; |
|
||||||
|
|
||||||
use AutoStore\DiContainer; |
|
||||||
use AutoStore\Application\Interfaces\IUserRepository; |
|
||||||
use Psr\Log\LoggerInterface; |
|
||||||
|
|
||||||
try { |
|
||||||
$diContainer = new DiContainer(); |
|
||||||
$logger = $diContainer->get(LoggerInterface::class); |
|
||||||
$userRepository = $diContainer->get(IUserRepository::class); |
|
||||||
|
|
||||||
$logger->info('Starting default users initialization...'); |
|
||||||
|
|
||||||
$requiredUsernames = ['admin', 'user']; |
|
||||||
$missingUsers = []; |
|
||||||
|
|
||||||
foreach ($requiredUsernames as $username) { |
|
||||||
$user = $userRepository->findByUsername($username); |
|
||||||
if ($user === null) { |
|
||||||
$missingUsers[] = $username; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (empty($missingUsers)) { |
|
||||||
$logger->info('Default users already exist. Skipping initialization.'); |
|
||||||
exit(0); |
|
||||||
} |
|
||||||
|
|
||||||
$defaultUsers = [ |
|
||||||
['username' => 'admin', 'password' => 'admin'], |
|
||||||
['username' => 'user', 'password' => 'user'] |
|
||||||
]; |
|
||||||
|
|
||||||
foreach ($defaultUsers as $userData) { |
|
||||||
if (in_array($userData['username'], $missingUsers)) { |
|
||||||
$user = new \AutoStore\Domain\Entities\User( |
|
||||||
uniqid('user_', true), |
|
||||||
$userData['username'], |
|
||||||
password_hash($userData['password'], PASSWORD_DEFAULT) |
|
||||||
); |
|
||||||
$userRepository->save($user); |
|
||||||
$logger->info("Created default user: {$userData['username']}"); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
$logger->info('Default users initialization completed successfully'); |
|
||||||
exit(0); |
|
||||||
|
|
||||||
} catch (\Exception $e) { |
|
||||||
$logger = $logger ?? new \Monolog\Logger('user-init'); |
|
||||||
$logger->error('Default users initialization failed: ' . $e->getMessage()); |
|
||||||
exit(1); |
|
||||||
} |
|
||||||
@ -1,58 +1,45 @@ |
|||||||
version: "3.9" |
version: "3.9" |
||||||
services: |
services: |
||||||
app: |
php: |
||||||
image: php82-app-img |
|
||||||
build: |
build: |
||||||
context: .. |
context: .. |
||||||
dockerfile: docker/Dockerfile |
dockerfile: docker/Dockerfile |
||||||
target: php-fpm |
image: php82-app-img |
||||||
container_name: php82-app |
container_name: php82-app |
||||||
restart: unless-stopped |
|
||||||
volumes: |
volumes: |
||||||
- ../:/var/www/html:cached |
- ..:/var/www/html |
||||||
networks: |
networks: |
||||||
- app-network |
- app-network |
||||||
healthcheck: |
|
||||||
# The container is ready when port 9000 is open |
|
||||||
test: ["CMD", "sh", "-c", "echo | nc -w 5 127.0.0.1 9000"] |
|
||||||
interval: 10s |
|
||||||
timeout: 5s |
|
||||||
retries: 10 |
|
||||||
|
|
||||||
scheduler: |
nginx: |
||||||
image: php82-app-img |
|
||||||
build: |
build: |
||||||
context: .. |
context: .. |
||||||
dockerfile: docker/Dockerfile |
dockerfile: docker/nginx.Dockerfile |
||||||
target: php-fpm |
image: php82-nginx-img |
||||||
container_name: php82-scheduler |
container_name: php82-nginx |
||||||
restart: unless-stopped |
ports: |
||||||
entrypoint: ["php", "/var/www/html/cli/scheduler.php"] |
- "50080:80" |
||||||
volumes: |
volumes: |
||||||
- ../:/var/www/html:cached |
- ..:/var/www/html |
||||||
depends_on: |
depends_on: |
||||||
app: |
- php |
||||||
condition: service_healthy |
|
||||||
networks: |
networks: |
||||||
- app-network |
- app-network |
||||||
|
|
||||||
nginx: |
scheduler: |
||||||
image: php82-nginx-img |
|
||||||
build: |
build: |
||||||
context: .. |
context: .. |
||||||
dockerfile: docker/Dockerfile |
dockerfile: docker/Dockerfile |
||||||
target: nginx |
image: php82-app-img |
||||||
container_name: php82-nginx |
container_name: php82-scheduler |
||||||
restart: unless-stopped |
|
||||||
ports: |
|
||||||
- "50080:80" |
|
||||||
volumes: |
volumes: |
||||||
- ../:/var/www/html:cached |
- ..:/var/www/html |
||||||
|
command: ["php", "/var/www/html/cli/scheduler.php"] |
||||||
depends_on: |
depends_on: |
||||||
app: |
- php |
||||||
condition: service_healthy |
|
||||||
networks: |
networks: |
||||||
- app-network |
- app-network |
||||||
|
restart: unless-stopped |
||||||
|
|
||||||
networks: |
networks: |
||||||
app-network: |
app-network: |
||||||
|
|||||||
@ -1,39 +1,5 @@ |
|||||||
#!/bin/sh |
#!/bin/sh |
||||||
|
|
||||||
set -e |
chown -R www-data:www-data /var/www/html |
||||||
|
|
||||||
# Set group to www-data, but leave owner untouched |
exec "$@" |
||||||
echo "Setting permissions..." |
|
||||||
chgrp -R www-data /var/www/html |
|
||||||
chmod -R g+ws /var/www/html |
|
||||||
|
|
||||||
echo "Installing dependencies..." |
|
||||||
su -s /bin/sh www-data -c "cd /var/www/html && composer install" |
|
||||||
|
|
||||||
echo "Running tests..." |
|
||||||
su -s /bin/sh www-data -c "php vendor/bin/phpunit tests" |
|
||||||
|
|
||||||
echo "Initializing default users..." |
|
||||||
su -s /bin/sh www-data -c "php /var/www/html/cli/initialize-default-users.php" |
|
||||||
|
|
||||||
exec "$@" |
|
||||||
|
|
||||||
|
|
||||||
# #!/bin/sh |
|
||||||
|
|
||||||
# set -e |
|
||||||
|
|
||||||
# echo "Setting permissions..." |
|
||||||
# chgrp -R www-data /var/www/html |
|
||||||
# chmod -R g+ws /var/www/html |
|
||||||
|
|
||||||
# echo "Installing dependencies..." |
|
||||||
# su -s /bin/sh www-data -c "cd /var/www/html && composer install" |
|
||||||
|
|
||||||
# echo "Running tests..." |
|
||||||
# su -s /bin/sh www-data -c "php vendor/bin/phpunit tests" |
|
||||||
|
|
||||||
# echo "Initializing default users..." |
|
||||||
# su -s /bin/sh www-data -c "php /var/www/html/cli/initialize-default-users.php" |
|
||||||
|
|
||||||
# exec su -s /bin/sh www-data -c "$@" |
|
||||||
@ -0,0 +1,7 @@ |
|||||||
|
FROM nginx:alpine |
||||||
|
|
||||||
|
COPY docker/default.conf /etc/nginx/conf.d/default.conf |
||||||
|
|
||||||
|
EXPOSE 80 |
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"] |
||||||
Loading…
Reference in new issue