Compare commits
No commits in common. 'master' and 'php8-fixes' have entirely different histories.
master
...
php8-fixes
158 changed files with 625 additions and 23843 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 |
||||
|
||||
COPY ../CMakeLists.txt . |
||||
COPY ../vcpkg.json . |
||||
|
||||
RUN vcpkg install |
||||
|
||||
# Cche stays valid if only code changes |
||||
COPY .. . |
||||
|
||||
# generate and build |
||||
RUN cmake -DCMAKE_TOOLCHAIN_FILE:STRING=${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake \ |
||||
-DCMAKE_EXPORT_COMPILE_COMMANDS:BOOL=TRUE -DCMAKE_BUILD_TYPE:STRING=Release \ |
||||
-H/workspace -B/workspace/build -G Ninja |
||||
RUN cmake --build /workspace/build --config Release --target all -j 8 -- |
||||
|
||||
# run tests |
||||
RUN cd /workspace/build && ctest --output-on-failure . |
||||
|
||||
FROM ubuntu:24.04 AS runtime |
||||
|
||||
WORKDIR /app |
||||
|
||||
COPY --from=builder /workspace/build/bin/AutoStore ./AutoStore |
||||
COPY --from=builder /workspace/build/bin/data ./data |
||||
|
||||
CMD ["./AutoStore"] |
||||
CMD ["/workspace/build/bin/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,39 +0,0 @@
|
||||
FROM node:24.0.1-alpine |
||||
|
||||
WORKDIR /usr/src/app |
||||
|
||||
# Install system dependencies |
||||
RUN apk add --no-cache \ |
||||
git \ |
||||
bash \ |
||||
curl \ |
||||
sudo |
||||
|
||||
# Give sudo permissions to the developer user |
||||
RUN echo '%developer ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers.d/developer && \ |
||||
chmod 0440 /etc/sudoers.d/developer |
||||
|
||||
# Configure user permissions |
||||
ARG USER_ID=1000 |
||||
ARG GROUP_ID=1000 |
||||
|
||||
# Create a user with matching UID/GID |
||||
RUN if ! getent group $GROUP_ID > /dev/null 2>&1; then \ |
||||
addgroup -g $GROUP_ID developer; \ |
||||
else \ |
||||
addgroup developer; \ |
||||
fi && \ |
||||
if ! getent passwd $USER_ID > /dev/null 2>&1; then \ |
||||
adduser -D -u $USER_ID -G developer -s /bin/sh developer; \ |
||||
else \ |
||||
adduser -D -G developer -s /bin/sh developer; \ |
||||
fi |
||||
|
||||
RUN chown -R $USER_ID:$GROUP_ID /usr/src/app |
||||
|
||||
USER $USER_ID:$GROUP_ID |
||||
|
||||
# Expose port 3000 for NestJS |
||||
EXPOSE 3000 |
||||
|
||||
CMD ["npm", "run", "start:dev"] |
||||
@ -1,26 +0,0 @@
|
||||
{ |
||||
"name": "NestJS dev container", |
||||
"dockerComposeFile": "./docker-compose.yml", |
||||
"service": "app", |
||||
"workspaceFolder": "/usr/src/app", |
||||
"customizations": { |
||||
"vscode": { |
||||
"settings": { |
||||
"terminal.integrated.defaultProfile.linux": "bash", |
||||
"node.js.version": "24.0.1" |
||||
}, |
||||
"extensions": [ |
||||
"ms-vscode.vscode-typescript-next", |
||||
"ms-nodejs.vscode-node-debug2", |
||||
"ms-vscode.vscode-json", |
||||
"esbenp.prettier-vscode", |
||||
"dbaeumer.vscode-eslint", |
||||
"christian-kohler.npm-intellisense", |
||||
"christian-kohler.path-intellisense" |
||||
] |
||||
} |
||||
}, |
||||
"forwardPorts": [3000], |
||||
"remoteUser": "developer", |
||||
"postCreateCommand": "sudo chown -R developer:1000 /usr/src/app && npm install" |
||||
} |
||||
@ -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-nestjs-img |
||||
container_name: dev-nestjs |
||||
user: "developer" |
||||
volumes: |
||||
- ../:/usr/src/app:cached |
||||
- node_modules:/usr/src/app/node_modules |
||||
environment: |
||||
NODE_ENV: development |
||||
ports: |
||||
- "50080:3000" |
||||
networks: |
||||
- dev-network |
||||
command: sleep infinity |
||||
|
||||
volumes: |
||||
node_modules: |
||||
|
||||
networks: |
||||
dev-network: |
||||
driver: bridge |
||||
@ -1,358 +0,0 @@
|
||||
# NestJS Implementation Plan for AutoStore |
||||
|
||||
## Overview |
||||
Implementation of AutoStore system using NestJS with TypeScript, following Clean Architecture principles. The system stores items with expiration dates and automatically orders new items when they expire. |
||||
|
||||
## Architecture Approach |
||||
- **Clean Architecture** with clear separation of concerns |
||||
- **Domain-Driven Design** with rich domain models |
||||
- **Hexagonal Architecture** with dependency inversion |
||||
- **Repository Pattern** for data persistence |
||||
- **CQRS-like** command/query separation |
||||
- **Dependency Injection** leveraging NestJS IoC container |
||||
|
||||
## Core Domain Logic |
||||
|
||||
### ItemExpirationSpec - Single Source of Truth for Expiration |
||||
|
||||
**File**: `src/domain/specifications/item-expiration.spec.ts` |
||||
**Purpose**: Centralized expiration checking logic - the single source of truth for determining if items are expired |
||||
|
||||
**Key Methods**: |
||||
- `isExpired(item: ItemEntity, currentTime: Date): boolean` - Checks if item expired |
||||
- `getSpec(currentTime: Date): Specification<ItemEntity>` - Returns specification for repository queries |
||||
|
||||
**Place in the flow**: |
||||
- Called by `AddItemCommand.execute()` to check newly created items for immediate expiration |
||||
- Called by `HandleExpiredItemsCommand.execute()` to find expired items for processing |
||||
- Used by `ItemRepository.findWhere()` to query database for expired items |
||||
|
||||
## Detailed Implementation Plan |
||||
|
||||
### Domain Layer |
||||
|
||||
#### 1. Entities |
||||
|
||||
**File**: `src/domain/entities/item.entity.ts` |
||||
**Purpose**: Core business entity representing an item |
||||
|
||||
**Key Methods**: |
||||
- `constructor(id: ItemId, name: string, expirationDate: ExpirationDate, orderUrl: string, userId: UserId): void` - Creates item with validation |
||||
- Getters for all properties |
||||
|
||||
**Place in the flow**: |
||||
- Created by `AddItemCommand.execute()` |
||||
- Retrieved by `ItemRepository` methods |
||||
- Passed to `ItemExpirationSpec.isExpired()` for expiration checking |
||||
|
||||
**File**: `src/domain/entities/user.entity.ts` |
||||
**Purpose**: User entity for item ownership and authentication purposes |
||||
|
||||
**Key Methods**: |
||||
- `constructor(id: UserId, username: string, passwordHash: string): void` - Creates user with validation |
||||
- Getters for all properties |
||||
|
||||
#### 2. Value Objects |
||||
|
||||
**File**: `src/domain/value-objects/item-id.vo.ts` |
||||
**Purpose**: Strong typing for item identifiers |
||||
|
||||
**Key Methods**: |
||||
- `constructor(value: string): void` - Validates UUID format |
||||
- `getValue(): string` - Returns string value |
||||
- `equals(other: ItemId): boolean` - Compares with another ItemId |
||||
|
||||
**File**: `src/domain/value-objects/expiration-date.vo.ts` |
||||
**Purpose**: Immutable expiration date with validation |
||||
|
||||
**Key Methods**: |
||||
- `constructor(value: Date): void` - Validates date format (allows past dates per business rules) |
||||
- `getValue(): Date` - Returns Date object |
||||
- `format(): string` - Returns ISO string format |
||||
|
||||
**Place in the flow**: |
||||
- Used by `ItemEntity` constructor for type-safe date handling |
||||
- Validated by `ItemExpirationSpec.isExpired()` for expiration logic |
||||
|
||||
#### 3. Specifications |
||||
|
||||
**File**: `src/domain/specifications/specification.interface.ts` |
||||
**Purpose**: Generic specification pattern interface |
||||
|
||||
**Key Methods**: |
||||
- `isSatisfiedBy(candidate: T): boolean` - Evaluates specification |
||||
- `getSpec(): object` - Returns specification object for repository implementation |
||||
|
||||
**Place in the flow**: |
||||
- Implemented by `ItemExpirationSpec` for type-safe specifications |
||||
- Used by `ItemRepository.findWhere()` for database queries |
||||
|
||||
### Application Layer |
||||
|
||||
#### 4. Commands |
||||
|
||||
**File**: `src/application/commands/add-item.command.ts` |
||||
**Purpose**: Use case for creating new items with expiration handling |
||||
|
||||
**Key Methods**: |
||||
- `constructor(itemRepo: IItemRepository, orderService: IOrderService, timeProvider: ITimeProvider, expirationSpec: ItemExpirationSpec, logger: Logger): void` - Dependency injection |
||||
- `execute(name: string, expirationDate: string, orderUrl: string, userId: string): Promise<string | null>` - Creates item, handles expired items immediately |
||||
|
||||
**Flow**: |
||||
1. `ItemsController.createItem()` calls `AddItemCommand.execute()` |
||||
2. Creates `ItemEntity` with validated data |
||||
3. Calls `ItemExpirationSpec.isExpired()` to check if item is expired |
||||
4. If expired: |
||||
- calls `OrderHttpService.orderItem()` |
||||
- **returns item ID** (business rule: expired items trigger ordering but still return ID field that might be empty or invalid) |
||||
5. If not expired: calls `ItemRepository.save()` and returns item ID |
||||
|
||||
**File**: `src/application/commands/handle-expired-items.command.ts` |
||||
**Purpose**: Background command to process expired items |
||||
|
||||
**Key Methods**: |
||||
- `constructor(itemRepo: IItemRepository, orderService: IOrderService, timeProvider: ITimeProvider, expirationSpec: ItemExpirationSpec, logger: Logger): void` - Dependency injection |
||||
- `execute(): Promise<void>` - Finds and processes all expired items |
||||
|
||||
**Flow**: |
||||
1. `ExpiredItemsScheduler.handleCron()` calls `HandleExpiredItemsCommand.execute()` |
||||
2. Gets current time from `ITimeProvider` |
||||
3. Calls `ItemExpirationSpec.getSpec()` to get expiration specification |
||||
4. Calls `ItemRepository.findWhere()` to find expired items |
||||
5. For each expired item: calls `OrderHttpService.orderItem()` then `ItemRepository.delete()` |
||||
|
||||
**File**: `src/application/commands/delete-item.command.ts` |
||||
**Purpose**: Use case for deleting user items |
||||
|
||||
**Key Methods**: |
||||
- `constructor(itemRepo: IItemRepository, logger: Logger): void` - Dependency injection |
||||
- `execute(itemId: string, userId: string): Promise<void>` - Validates ownership and deletes item |
||||
|
||||
**Flow**: |
||||
1. `ItemsController.deleteItem()` calls `DeleteItemCommand.execute()` |
||||
2. Calls `ItemRepository.findById()` to retrieve item |
||||
3. Validates ownership by comparing user IDs |
||||
4. Calls `ItemRepository.delete()` to remove item |
||||
|
||||
**File**: `src/application/commands/login-user.command.ts` |
||||
**Purpose**: User authentication use case |
||||
|
||||
**Key Methods**: |
||||
- `constructor(authService: IAuthService, logger: Logger): void` - Dependency injection |
||||
- `execute(username: string, password: string): Promise<string>` - Authenticates and returns JWT token |
||||
|
||||
#### 5. Queries |
||||
|
||||
**File**: `src/application/queries/get-item.query.ts` |
||||
**Purpose**: Retrieves single item by ID with authorization |
||||
|
||||
**Key Methods**: |
||||
- `constructor(itemRepo: IItemRepository, logger: Logger): void` - Dependency injection |
||||
- `execute(itemId: string, userId: string): Promise<ItemEntity>` - Validates ownership and returns item |
||||
|
||||
**Flow**: |
||||
1. `ItemsController.getItem()` calls `GetItemQuery.execute()` |
||||
2. Calls `ItemRepository.findById()` to retrieve item |
||||
3. Validates ownership by comparing user IDs |
||||
4. Returns item entity |
||||
|
||||
**File**: `src/application/queries/list-items.query.ts` |
||||
**Purpose**: Retrieves all items for authenticated user |
||||
|
||||
**Key Methods**: |
||||
- `constructor(itemRepo: IItemRepository, logger: Logger): void` - Dependency injection |
||||
- `execute(userId: string): Promise<ItemEntity[]>` - Returns user's items |
||||
|
||||
**Flow**: |
||||
1. `ItemsController.listItems()` calls `ListItemsQuery.execute()` |
||||
2. Calls `ItemRepository.findByUserId()` to retrieve user's items |
||||
3. Returns array of item entities |
||||
|
||||
#### 6. DTOs |
||||
|
||||
**File**: `src/application/dto/create-item.dto.ts` |
||||
**Purpose**: Request validation for item creation |
||||
|
||||
**Key Properties**: |
||||
- `name: string` - Item name (min: 1, max: 255) |
||||
- `expirationDate: string` - ISO date string (future date validation) |
||||
- `orderUrl: string` - Valid URL format |
||||
|
||||
**Place in the flow**: |
||||
- Used by `ItemsController.createItem()` for request body validation |
||||
|
||||
**File**: `src/application/dto/item-response.dto.ts` |
||||
**Purpose**: Standardized item response format |
||||
|
||||
**Key Properties**: |
||||
- `id: string` - Item ID |
||||
- `name: string` - Item name |
||||
- `expirationDate: string` - ISO date string |
||||
- `orderUrl: string` - Order URL |
||||
- `userId: string` - Owner user ID |
||||
- `createdAt: string` - Creation timestamp |
||||
|
||||
**Place in the flow**: |
||||
- Used by all item controller methods for response transformation |
||||
|
||||
### Infrastructure Layer |
||||
|
||||
#### 7. Repositories |
||||
|
||||
**File**: `src/infrastructure/repositories/file-item-repository.ts` |
||||
**Purpose**: File-based implementation of item repository using JSON files |
||||
|
||||
**Key Methods**: |
||||
- `save(item: ItemEntity): Promise<void>` - Persists item entity |
||||
- `findById(id: ItemId): Promise<ItemEntity | null>` - Finds by ID |
||||
- `findByUserId(userId: UserId): Promise<ItemEntity[]>` - Finds by user |
||||
- `findWhere(spec: Specification<ItemEntity>): Promise<ItemEntity[]>` - Finds by specification using `ItemExpirationSpec` |
||||
- `delete(id: ItemId): Promise<void>` - Deletes item |
||||
- `exists(id: ItemId): Promise<boolean>` - Checks existence |
||||
|
||||
**Place in the flow**: |
||||
- Called by all commands and queries for data persistence and retrieval |
||||
- Uses `ItemExpirationSpec` for finding expired items |
||||
|
||||
#### 8. HTTP Services |
||||
|
||||
**File**: `src/infrastructure/http/order-http.service.ts` |
||||
**Purpose**: HTTP implementation of order service |
||||
|
||||
**Key Methods**: |
||||
- `constructor(httpService: HttpService, logger: Logger): void` - Dependency injection |
||||
- `orderItem(item: ItemEntity): Promise<void>` - Sends POST request to order URL |
||||
|
||||
**Place in the flow**: |
||||
- Called by `AddItemCommand.execute()` for expired items |
||||
- Called by `HandleExpiredItemsCommand.execute()` for batch processing |
||||
|
||||
#### 9. Authentication |
||||
|
||||
**File**: `src/infrastructure/auth/jwt-auth.service.ts` |
||||
**Purpose**: JWT implementation of authentication service |
||||
|
||||
**Key Methods**: |
||||
- `constructor(userRepo: IUserRepository, jwtService: JwtService, configService: ConfigService, logger: Logger): void` - Dependency injection |
||||
- `authenticate(username: string, password: string): Promise<string | null>` - Validates credentials and generates JWT |
||||
- `validateToken(token: string): Promise<boolean>` - Validates JWT token |
||||
- `getUserIdFromToken(token: string): Promise<string | null>` - Extracts user ID from token |
||||
|
||||
**Place in the flow**: |
||||
- Called by `LoginUserCommand.execute()` for user authentication |
||||
- Used by `JwtAuthGuard` for route protection |
||||
|
||||
### Presentation Layer |
||||
|
||||
#### 10. Controllers |
||||
|
||||
**File**: `src/presentation/controllers/items.controller.ts` |
||||
**Purpose**: REST API endpoints for item management |
||||
|
||||
**Key Methods**: |
||||
- `constructor(addItemCmd: AddItemCommand, getItemQry: GetItemQuery, listItemsQry: ListItemsQuery, deleteItemCmd: DeleteItemCommand): void` - Dependency injection |
||||
- `createItem(@Body() dto: CreateItemDto, @Req() req: Request): Promise<ItemResponseDto>` - POST /items |
||||
- `getItem(@Param('id') id: string, @Req() req: Request): Promise<ItemResponseDto>` - GET /items/:id |
||||
- `listItems(@Req() req: Request): Promise<ItemResponseDto[]>` - GET /items |
||||
- `deleteItem(@Param('id') id: string, @Req() req: Request): Promise<void>` - DELETE /items/:id |
||||
|
||||
**Flow**: |
||||
- Receives HTTP requests and validates input |
||||
- Calls appropriate commands/queries based on HTTP method |
||||
- Returns standardized responses with DTOs |
||||
|
||||
**File**: `src/presentation/controllers/auth.controller.ts` |
||||
**Purpose**: Authentication endpoints |
||||
|
||||
**Key Methods**: |
||||
- `constructor(loginUserCmd: LoginUserCommand): void` - Dependency injection |
||||
- `login(@Body() dto: LoginDto): Promise<{ token: string }>` - POST /login |
||||
|
||||
#### 11. Guards |
||||
|
||||
**File**: `src/presentation/guards/jwt-auth.guard.ts` |
||||
**Purpose**: JWT authentication route protection |
||||
|
||||
**Key Methods**: |
||||
- `constructor(jwtAuthService: IJwtAuthService, logger: Logger): void` - Dependency injection |
||||
- `canActivate(context: ExecutionContext): Promise<boolean>` - Validates JWT and attaches user to request |
||||
|
||||
**Place in the flow**: |
||||
- Applied to all protected routes by NestJS Guard System |
||||
- Uses `JwtAuthService` for token validation |
||||
|
||||
## Background Processing |
||||
|
||||
**File**: `src/infrastructure/services/expired-items-scheduler.service.ts` |
||||
**Purpose**: Scheduled job for processing expired items using NestJS scheduler |
||||
|
||||
**Key Methods**: |
||||
- `constructor(handleExpiredItemsCmd: HandleExpiredItemsCommand): void` - Dependency injection |
||||
- `onModuleInit(): Promise<void>` - Processes expired items on application startup |
||||
- `handleExpiredItemsCron(): Promise<void>` - Runs every minute (@Cron(CronExpression.EVERY_MINUTE)) |
||||
- `handleExpiredItemsDaily(): Promise<void>` - Runs every day at midnight (@Cron('0 0 * * *')) |
||||
|
||||
**Flow**: |
||||
1. **On startup**: `onModuleInit()` immediately calls `HandleExpiredItemsCommand.execute()` |
||||
2. **Every minute**: `handleExpiredItemsCron()` processes expired items |
||||
3. **Every midnight**: `handleExpiredItemsDaily()` processes expired items |
||||
4. All methods use try-catch to continue operation despite errors |
||||
5. Comprehensive logging for monitoring and debugging |
||||
|
||||
**Configuration**: |
||||
- Requires `ScheduleModule.forRoot()` in AppModule imports |
||||
- Uses `@nestjs/schedule` package for cron expressions |
||||
- Implements `OnModuleInit` for startup processing |
||||
## Complete Flow Summary |
||||
|
||||
### Item Creation Flow |
||||
``` |
||||
POST /items |
||||
├── JwtAuthGuard (authentication) |
||||
├── CreateItemDto validation |
||||
├── ItemsController.createItem() |
||||
│ ├── AddItemCommand.execute() |
||||
│ │ ├── ItemEntity constructor (validation) |
||||
│ │ ├── ItemExpirationSpec.isExpired() ← SINGLE SOURCE OF TRUTH |
||||
│ │ ├── If expired: OrderHttpService.orderItem() |
||||
│ │ └── If not expired: ItemRepository.save() |
||||
│ └── ItemResponseDto transformation |
||||
└── HTTP response |
||||
``` |
||||
|
||||
### Expired Items Processing Flow |
||||
``` |
||||
Cron Job (every minute) |
||||
└── ExpiredItemsScheduler.handleCron() |
||||
└── HandleExpiredItemsCommand.execute() |
||||
├── ITimeProvider.now() |
||||
├── ItemExpirationSpec.getSpec() ← SINGLE SOURCE OF TRUTH |
||||
├── ItemRepository.findWhere() (using spec) |
||||
├── For each expired item: |
||||
│ ├── OrderHttpService.orderItem() |
||||
│ └── ItemRepository.delete() |
||||
└── Logging |
||||
``` |
||||
|
||||
### Item Retrieval Flow |
||||
``` |
||||
GET /items/:id |
||||
├── JwtAuthGuard (authentication) |
||||
├── ItemsController.getItem() |
||||
│ ├── GetItemQuery.execute() |
||||
│ │ ├── ItemRepository.findById() |
||||
│ │ ├── Ownership validation |
||||
│ │ └── Return ItemEntity |
||||
│ └── ItemResponseDto transformation |
||||
└── HTTP response |
||||
``` |
||||
|
||||
## Key Design Principles |
||||
|
||||
1. **Single Source of Truth**: `ItemExpirationSpec` is the only component that determines expiration logic |
||||
2. **Clear Flow**: Each component has a well-defined place in the execution chain |
||||
3. **Dependency Inversion**: High-level modules don't depend on low-level modules |
||||
4. **Separation of Concerns**: Each layer has distinct responsibilities |
||||
5. **Testability**: All components can be tested in isolation |
||||
|
||||
This implementation plan ensures consistent development regardless of the implementer, providing clear flow definitions and emphasizing `ItemExpirationSpec` as the centralized source for expiration logic. |
||||
@ -1,243 +0,0 @@
|
||||
# NestJS Implementation Review |
||||
|
||||
## Overview |
||||
|
||||
This review analyzes the TypeScript + NestJS implementation of the AutoStore application, focusing on adherence to Clean Architecture principles, SOLID principles, and the critical requirement of maintaining a **single source of truth** for domain knowledge. |
||||
|
||||
## Architecture Assessment |
||||
|
||||
### ✅ Strengths |
||||
|
||||
#### 1. Clean Architecture Implementation |
||||
The implementation successfully follows Clean Architecture with clear layer separation: |
||||
|
||||
- **Domain Layer**: Pure business logic with entities, value objects, and specifications |
||||
- **Application Layer**: Use cases, commands, queries, and infrastructure interfaces |
||||
- **Infrastructure Layer**: Concrete implementations (repositories, HTTP services, auth) |
||||
- **Presentation Layer**: Controllers and API endpoints |
||||
|
||||
#### 2. Specification Pattern Implementation |
||||
**Excellent implementation** of the Specification pattern for maintaining single source of truth: |
||||
|
||||
```typescript |
||||
// Domain specification - SINGLE SOURCE OF TRUTH |
||||
export class ItemExpirationSpec { |
||||
isExpired(item: ItemEntity, currentTime: Date): boolean { |
||||
return this.getSpec(currentTime).match(item); |
||||
} |
||||
|
||||
getSpec(currentTime: Date): SimpleSpecification<ItemEntity> { |
||||
return new SimpleSpecification<ItemEntity>( |
||||
Spec.lte('expirationDate', currentTime.toISOString()) |
||||
); |
||||
} |
||||
} |
||||
``` |
||||
|
||||
This ensures that the expiration logic (`date <= now`) is defined **only once** in the domain layer and reused throughout the application. |
||||
|
||||
#### 3. Value Objects and Entities |
||||
Proper implementation of Domain-Driven Design patterns: |
||||
- **Value Objects**: [`ItemId`](src/domain/value-objects/item-id.vo.ts:1), [`UserId`](src/domain/value-objects/user-id.vo.ts:1), [`ExpirationDate`](src/domain/value-objects/expiration-date.vo.ts:1) |
||||
- **Entities**: [`ItemEntity`](src/domain/entities/item.entity.ts:1) with proper encapsulation |
||||
- **Immutability**: Value objects are immutable with defensive copying |
||||
|
||||
#### 4. Dependency Inversion |
||||
Excellent use of dependency injection and interface segregation: |
||||
|
||||
```typescript |
||||
// Application layer depends on abstractions |
||||
export interface IItemRepository { |
||||
findWhere(specification: ISpecification<ItemEntity>): Promise<ItemEntity[]>; |
||||
// ... other methods |
||||
} |
||||
``` |
||||
|
||||
#### 5. Repository Pattern with Specifications |
||||
The [`FileItemRepository`](src/infrastructure/repositories/file-item-repository.ts:114) properly implements the specification pattern: |
||||
|
||||
```typescript |
||||
async findWhere(specification: ISpecification<ItemEntity>): Promise<ItemEntity[]> { |
||||
// Uses domain specifications for filtering |
||||
if (specification.isSatisfiedBy(item)) { |
||||
matchingItems.push(item); |
||||
} |
||||
} |
||||
``` |
||||
|
||||
#### 6. Background Processing |
||||
The implementation now includes background processing using NestJS's built-in scheduler: |
||||
|
||||
**New Implementation**: [`ExpiredItemsSchedulerService`](src/infrastructure/services/expired-items-scheduler.service.ts:1) |
||||
- **On startup**: Immediately processes expired items via `onModuleInit()` |
||||
- **Every minute**: Runs via `@Cron(CronExpression.EVERY_MINUTE)` |
||||
- **Daily at midnight**: Runs via `@Cron('0 0 * * *')` for daily processing |
||||
- **Robust error handling**: Continues operation despite individual processing failures |
||||
- **Comprehensive logging**: Tracks all scheduling activities |
||||
|
||||
**Flow Integration**: |
||||
``` |
||||
AppModule → ScheduleModule.forRoot() → ExpiredItemsSchedulerService |
||||
↓ |
||||
onModuleInit() → HandleExpiredItemsCommand.execute() [startup] |
||||
↓ |
||||
@Cron(EVERY_MINUTE) → HandleExpiredItemsCommand.execute() [continuous] |
||||
↓ |
||||
@Cron('0 0 * * *') → HandleExpiredItemsCommand.execute() [daily midnight] |
||||
``` |
||||
|
||||
### 🔍 Areas for Improvement |
||||
|
||||
#### 1. Framework Dependency in Application Layer |
||||
The implementation intentionally violates the Clean Architecture principle of framework-independent application layer by using NestJS decorators (`@Injectable()`, `@Inject()`) in the application layer. This decision was made for several practical reasons: |
||||
|
||||
- **Cleaner Construction**: NestJS's dependency injection system provides a clean and declarative way to manage dependencies, making the construction part of the application more maintainable and readable. |
||||
- **Ecosystem Integration**: Leveraging NestJS's native DI system allows for better integration with the framework's features, including interceptors, guards, and lifecycle hooks. |
||||
- **Community Standards**: This approach follows common practices in the NestJS community, making the code more familiar to developers experienced with the framework. |
||||
- **Testing Support**: NestJS provides excellent testing utilities that work seamlessly with decorator-based dependency injection. |
||||
|
||||
While this does create a framework dependency in the application layer, the trade-off is considered worthwhile for the benefits it provides in terms of development speed, maintainability, and framework integration. Alternative approaches like the Adapter Pattern or Factory Pattern could be used to make the application layer truly framework-agnostic, but they would introduce additional complexity and boilerplate code. |
||||
|
||||
#### 2. Specification Pattern Consistency |
||||
|
||||
While the implementation is excellent, there's a minor inconsistency in the [`ItemExpirationSpec`](src/domain/specifications/item-expiration.spec.ts:4) class name. The file is named `item-expiration.spec.ts` but the class is `ItemExpirationSpec`. Consider renaming to `ItemExpirationSpecification` for consistency. |
||||
|
||||
#### 3. Error Handling |
||||
The application could benefit from custom domain exceptions instead of generic `Error` objects: |
||||
|
||||
```typescript |
||||
// Current approach |
||||
throw new Error('Item name cannot be empty'); |
||||
|
||||
// Suggested improvement |
||||
throw new InvalidItemNameException('Item name cannot be empty'); |
||||
``` |
||||
|
||||
#### 4. Domain Events |
||||
Consider implementing domain events for the ordering process to better separate concerns: |
||||
|
||||
```typescript |
||||
// Instead of direct ordering in command |
||||
await this.orderService.orderItem(item); |
||||
|
||||
// Consider domain events |
||||
domainEvents.publish(new ItemExpiredEvent(item)); |
||||
``` |
||||
|
||||
|
||||
|
||||
|
||||
### 🎯 Comparison with PHP and C++ Implementations |
||||
|
||||
#### PHP Implementation |
||||
The PHP implementation follows a similar specification pattern but with some differences: |
||||
|
||||
```php |
||||
// PHP specification |
||||
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')) |
||||
); |
||||
} |
||||
``` |
||||
|
||||
**Key Differences:** |
||||
- PHP uses `DateTimeImmutable` vs TypeScript's `Date` |
||||
- PHP specification includes SQL rendering capabilities in comments |
||||
- Both maintain single source of truth effectively |
||||
|
||||
#### C++ Implementation |
||||
The C++ implementation uses a different approach with a policy pattern: |
||||
|
||||
```cpp |
||||
// C++ policy approach |
||||
bool isExpired(const Item& item, const TimePoint& currentTime) const |
||||
{ |
||||
return item.expirationDate <= currentTime; |
||||
} |
||||
|
||||
ItemExpirationSpec getExpiredSpecification(const TimePoint& currentTime) const |
||||
{ |
||||
return nxl::helpers::SpecificationBuilder() |
||||
.field(FIELD_EXP_DATE) |
||||
.lessOrEqual(currentTime) |
||||
.build(); |
||||
} |
||||
``` |
||||
|
||||
**Key Differences:** |
||||
- C++ uses `std::chrono::system_clock::time_point` |
||||
- C++ uses a builder pattern for specifications |
||||
- Direct comparison in `isExpired` vs specification-based approach |
||||
|
||||
### 🏆 Best Practices Demonstrated |
||||
|
||||
#### 1. Single Source of Truth |
||||
**Excellent adherence** to the requirement that expiration checking logic exists in only one place: |
||||
|
||||
- ✅ Domain specification defines `expirationDate <= currentTime` logic |
||||
- ✅ Application layer uses [`ItemExpirationSpec`](src/application/commands/handle-expired-items.command.ts:18) for business logic |
||||
- ✅ Repository layer uses specifications for filtering without duplicating logic |
||||
- ✅ No hardcoded expiration logic in controllers or infrastructure |
||||
|
||||
#### 2. SOLID Principles |
||||
|
||||
**Single Responsibility Principle**: Each class has one reason to change |
||||
- [`ItemEntity`](src/domain/entities/item.entity.ts:5): Manages item state and validation |
||||
- [`ItemExpirationSpec`](src/domain/specifications/item-expiration.spec.ts:4): Manages expiration logic |
||||
- [`FileItemRepository`](src/infrastructure/repositories/file-item-repository.ts:12): Manages persistence |
||||
|
||||
**Open/Closed Principle**: Extension through specifications, not modification |
||||
- New filtering criteria can be added via new specifications |
||||
- Repository doesn't need modification for new query types |
||||
|
||||
**Liskov Substitution Principle**: Interfaces are properly segregated |
||||
- [`IItemRepository`](src/application/interfaces/item-repository.interface.ts:6) can be implemented by any storage mechanism |
||||
|
||||
**Interface Segregation Principle**: Focused interfaces |
||||
- [`ITimeProvider`](src/application/interfaces/time-provider.interface.ts:1): Single method interface |
||||
- [`IOrderService`](src/application/interfaces/order-service.interface.ts:1): Focused on ordering |
||||
|
||||
**Dependency Inversion Principle**: Dependencies on abstractions |
||||
- Application layer depends on interfaces, not concrete implementations |
||||
|
||||
#### 3. Clean Architecture Boundaries |
||||
|
||||
**Domain Layer**: No external dependencies |
||||
- Pure TypeScript with no framework imports |
||||
- Business logic isolated from infrastructure concerns |
||||
|
||||
**Application Layer**: Orchestrates use cases |
||||
- Depends only on domain layer and infrastructure interfaces |
||||
- Commands and queries properly separated |
||||
|
||||
**Infrastructure Layer**: Implements abstractions |
||||
- [`FileItemRepository`](src/infrastructure/repositories/file-item-repository.ts:12) implements [`IItemRepository`](src/application/interfaces/item-repository.interface.ts:6) |
||||
- [`SystemTimeProvider`](src/infrastructure/services/system-time.provider.ts:5) implements [`ITimeProvider`](src/application/interfaces/time-provider.interface.ts:1) |
||||
|
||||
### 🧪 Testing Quality |
||||
|
||||
The implementation includes comprehensive tests: |
||||
- **Unit tests** for specifications with edge cases |
||||
- **Integration tests** for repositories |
||||
- **Boundary testing** for date/time scenarios |
||||
- **Specification testing** to ensure single source of truth |
||||
|
||||
### 🚀 Recommendations |
||||
|
||||
1. **Maintain the Specification Pattern**: This is the strongest aspect of the implementation |
||||
2. **Consider Domain Events**: For better separation of ordering concerns |
||||
3. **Add Custom Exceptions**: For better error handling and domain expressiveness |
||||
4. **Document Business Rules**: Add comments explaining why expired items are allowed (business requirement) |
||||
|
||||
## Conclusion |
||||
|
||||
The NestJS implementation **excellently demonstrates** Clean Architecture principles and successfully maintains a **single source of truth** for domain knowledge. The specification pattern implementation is particularly strong, ensuring that expiration date checking logic (`date <= now`) exists in exactly one place in the codebase. |
||||
|
||||
The architecture properly separates concerns, follows SOLID principles, and provides a solid foundation for the AutoStore application. The comparison with PHP and C++ implementations shows that while the technical details differ, all implementations successfully maintain the critical single source of truth requirement. |
||||
@ -1,29 +0,0 @@
|
||||
FROM node:24.0.1-alpine as builder |
||||
|
||||
WORKDIR /app |
||||
|
||||
COPY package*.json ./ |
||||
COPY nest-cli.json ./ |
||||
COPY tsconfig.json ./ |
||||
|
||||
RUN npm install |
||||
|
||||
COPY src ./src |
||||
|
||||
RUN npm run build |
||||
RUN npm test |
||||
|
||||
|
||||
FROM node:24.0.1-alpine |
||||
|
||||
WORKDIR /app |
||||
|
||||
COPY package*.json ./ |
||||
|
||||
RUN npm install --only=production |
||||
|
||||
COPY --from=builder /app/dist ./dist |
||||
|
||||
EXPOSE 3000 |
||||
|
||||
CMD [ "node", "dist/main" ] |
||||
@ -1,17 +0,0 @@
|
||||
version: "3.9" |
||||
services: |
||||
app: |
||||
build: |
||||
context: .. |
||||
dockerfile: docker/Dockerfile |
||||
image: nestjs-app-img |
||||
container_name: nestjs-app |
||||
ports: |
||||
- "50080:3000" |
||||
networks: |
||||
- app-network |
||||
restart: unless-stopped |
||||
|
||||
networks: |
||||
app-network: |
||||
driver: bridge |
||||
@ -1,8 +0,0 @@
|
||||
{ |
||||
"$schema": "https://json.schemastore.org/nest-cli", |
||||
"collection": "@nestjs/schematics", |
||||
"sourceRoot": "src", |
||||
"compilerOptions": { |
||||
"deleteOutDir": true |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -1,82 +0,0 @@
|
||||
{ |
||||
"name": "autostore-nestjs", |
||||
"version": "0.0.1", |
||||
"description": "AutoStore implementation with NestJS", |
||||
"author": "", |
||||
"license": "MIT", |
||||
"scripts": { |
||||
"prebuild": "rimraf dist", |
||||
"build": "nest build", |
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", |
||||
"start": "nest start", |
||||
"start:dev": "nest start --watch", |
||||
"start:debug": "nest start --debug --watch", |
||||
"start:prod": "node dist/main", |
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", |
||||
"test": "jest", |
||||
"test:watch": "jest --watch", |
||||
"test:cov": "jest --coverage", |
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", |
||||
"test:e2e": "jest --config ./test/jest-e2e.json" |
||||
}, |
||||
"dependencies": { |
||||
"@nestjs/axios": "^4.0.1", |
||||
"@nestjs/common": "^10.0.0", |
||||
"@nestjs/config": "^4.0.2", |
||||
"@nestjs/core": "^10.0.0", |
||||
"@nestjs/jwt": "^11.0.0", |
||||
"@nestjs/passport": "^11.0.5", |
||||
"@nestjs/platform-express": "^10.0.0", |
||||
"@nestjs/schedule": "^6.0.0", |
||||
"@types/bcrypt": "^6.0.0", |
||||
"axios": "^1.12.1", |
||||
"bcrypt": "^6.0.0", |
||||
"class-transformer": "^0.5.1", |
||||
"class-validator": "^0.14.2", |
||||
"passport": "^0.7.0", |
||||
"passport-jwt": "^4.0.1", |
||||
"reflect-metadata": "^0.1.13", |
||||
"rxjs": "^7.8.1" |
||||
}, |
||||
"devDependencies": { |
||||
"@nestjs/cli": "^11.0.10", |
||||
"@nestjs/schematics": "^10.0.0", |
||||
"@nestjs/testing": "^10.0.0", |
||||
"@types/express": "^4.17.17", |
||||
"@types/jest": "^29.5.2", |
||||
"@types/node": "^20.3.1", |
||||
"@types/supertest": "^6.0.0", |
||||
"@typescript-eslint/eslint-plugin": "^6.0.0", |
||||
"@typescript-eslint/parser": "^6.0.0", |
||||
"eslint": "^8.42.0", |
||||
"eslint-config-prettier": "^9.0.0", |
||||
"eslint-plugin-prettier": "^5.0.0", |
||||
"jest": "^29.5.0", |
||||
"prettier": "^3.0.0", |
||||
"rimraf": "^5.0.1", |
||||
"source-map-support": "^0.5.21", |
||||
"supertest": "^6.3.3", |
||||
"ts-jest": "^29.1.0", |
||||
"ts-loader": "^9.4.3", |
||||
"ts-node": "^10.9.1", |
||||
"tsconfig-paths": "^4.2.0", |
||||
"typescript": "^5.1.3" |
||||
}, |
||||
"jest": { |
||||
"moduleFileExtensions": [ |
||||
"js", |
||||
"json", |
||||
"ts" |
||||
], |
||||
"rootDir": "src", |
||||
"testRegex": ".*__tests__.*\\.spec\\.ts$", |
||||
"transform": { |
||||
"^.+\\.(t|j)s$": "ts-jest" |
||||
}, |
||||
"collectCoverageFrom": [ |
||||
"**/*.(t|j)s" |
||||
], |
||||
"coverageDirectory": "../coverage", |
||||
"testEnvironment": "node" |
||||
} |
||||
} |
||||
@ -1,99 +0,0 @@
|
||||
import { Module } from '@nestjs/common'; |
||||
import { APP_PIPE } from '@nestjs/core'; |
||||
import { ValidationPipe } from '@nestjs/common'; |
||||
import { ConfigModule, ConfigService } from '@nestjs/config'; |
||||
import { JwtModule } from '@nestjs/jwt'; |
||||
import { HttpModule } from '@nestjs/axios'; |
||||
import { ScheduleModule } from '@nestjs/schedule'; |
||||
import { AuthController } from './presentation/controllers/auth.controller'; |
||||
import { ItemsController } from './presentation/controllers/items.controller'; |
||||
import { LoginUserCommand } from './application/commands/login-user.command'; |
||||
import { AddItemCommand } from './application/commands/add-item.command'; |
||||
import { DeleteItemCommand } from './application/commands/delete-item.command'; |
||||
import { HandleExpiredItemsCommand } from './application/commands/handle-expired-items.command'; |
||||
import { GetItemQuery } from './application/queries/get-item.query'; |
||||
import { ListItemsQuery } from './application/queries/list-items.query'; |
||||
import { JwtAuthService } from './infrastructure/auth/jwt-auth.service'; |
||||
import { FileUserRepository } from './infrastructure/repositories/file-user-repository'; |
||||
import { FileItemRepository } from './infrastructure/repositories/file-item-repository'; |
||||
import { OrderHttpService } from './infrastructure/http/order-http.service'; |
||||
import { SystemTimeProvider } from './infrastructure/services/system-time.provider'; |
||||
import { UserInitializationService } from './infrastructure/services/user-initialization.service'; |
||||
import { ItemExpirationSpec } from './domain/specifications/item-expiration.spec'; |
||||
import { ExpiredItemsSchedulerService } from './infrastructure/services/expired-items-scheduler.service'; |
||||
import { LoggerService } from './application/services/logger.service'; |
||||
import { NestLoggerService } from './infrastructure/logging/nest-logger.service'; |
||||
import { NullLoggerService } from './infrastructure/logging/null-logger.service'; |
||||
|
||||
@Module({ |
||||
imports: [ |
||||
ConfigModule.forRoot({ |
||||
isGlobal: true, |
||||
}), |
||||
HttpModule, |
||||
ScheduleModule.forRoot(), |
||||
JwtModule.registerAsync({ |
||||
imports: [ConfigModule], |
||||
useFactory: async (configService: ConfigService) => ({ |
||||
secret: configService.get<string>('JWT_SECRET') || 'default-secret-key', |
||||
signOptions: { |
||||
expiresIn: configService.get<string>('JWT_EXPIRATION') || '1h', |
||||
}, |
||||
}), |
||||
inject: [ConfigService], |
||||
}), |
||||
], |
||||
controllers: [AuthController, ItemsController], |
||||
providers: [ |
||||
LoginUserCommand, |
||||
AddItemCommand, |
||||
DeleteItemCommand, |
||||
HandleExpiredItemsCommand, |
||||
GetItemQuery, |
||||
ListItemsQuery, |
||||
JwtAuthService, |
||||
OrderHttpService, |
||||
SystemTimeProvider, |
||||
ItemExpirationSpec, |
||||
UserInitializationService, |
||||
ExpiredItemsSchedulerService, |
||||
{ |
||||
provide: 'IAuthService', |
||||
useExisting: JwtAuthService, |
||||
}, |
||||
{ |
||||
provide: 'IUserRepository', |
||||
useFactory: (logger: LoggerService) => new FileUserRepository('./data', logger), |
||||
inject: [LoggerService], |
||||
}, |
||||
{ |
||||
provide: 'IItemRepository', |
||||
useFactory: (logger: LoggerService) => new FileItemRepository('./data', logger), |
||||
inject: [LoggerService], |
||||
}, |
||||
{ |
||||
provide: 'IOrderService', |
||||
useExisting: OrderHttpService, |
||||
}, |
||||
{ |
||||
provide: 'ITimeProvider', |
||||
useExisting: SystemTimeProvider, |
||||
}, |
||||
{ |
||||
provide: LoggerService, |
||||
useClass: process.env.NODE_ENV === 'test' ? NullLoggerService : NestLoggerService, |
||||
}, |
||||
{ |
||||
provide: APP_PIPE, |
||||
useValue: new ValidationPipe({ |
||||
whitelist: true, |
||||
forbidNonWhitelisted: true, |
||||
transform: true, |
||||
transformOptions: { |
||||
enableImplicitConversion: true, |
||||
}, |
||||
}), |
||||
}, |
||||
], |
||||
}) |
||||
export class AppModule {} |
||||
@ -1,178 +0,0 @@
|
||||
import { AddItemCommand } from '../add-item.command'; |
||||
import { ItemEntity } from '../../../domain/entities/item.entity'; |
||||
|
||||
// Mock implementations
|
||||
const mockItemRepository = { |
||||
save: jest.fn(), |
||||
}; |
||||
|
||||
const mockOrderService = { |
||||
orderItem: jest.fn(), |
||||
}; |
||||
|
||||
const mockTimeProvider = { |
||||
now: jest.fn(), |
||||
}; |
||||
|
||||
const mockExpirationSpec = { |
||||
isExpired: jest.fn(), |
||||
}; |
||||
|
||||
const mockLogger = { |
||||
log: jest.fn(), |
||||
error: jest.fn(), |
||||
warn: jest.fn(), |
||||
debug: jest.fn(), |
||||
}; |
||||
|
||||
describe('AddItemCommand', () => { |
||||
let addItemCommand: AddItemCommand; |
||||
|
||||
const MOCKED_NOW = '2023-01-01T12:00:00Z'; |
||||
const NOT_EXPIRED_DATE = '2023-01-02T12:00:00Z'; |
||||
const EXPIRED_DATE = '2022-12-31T12:00:00Z'; |
||||
const ITEM_NAME = 'Test Item'; |
||||
const ORDER_URL = 'https://example.com/order'; |
||||
const USER_ID = '550e8400-e29b-41d4-a716-446655440001'; |
||||
|
||||
beforeEach(() => { |
||||
jest.clearAllMocks(); |
||||
|
||||
addItemCommand = new AddItemCommand( |
||||
mockItemRepository as any, |
||||
mockOrderService as any, |
||||
mockTimeProvider as any, |
||||
mockLogger as any, |
||||
mockExpirationSpec as any, |
||||
); |
||||
|
||||
mockTimeProvider.now.mockReturnValue(new Date(MOCKED_NOW)); |
||||
}); |
||||
|
||||
describe('execute', () => { |
||||
describe('when item is not expired', () => { |
||||
beforeEach(() => { |
||||
mockExpirationSpec.isExpired.mockReturnValue(false); |
||||
}); |
||||
|
||||
it('should save item to repository', async () => { |
||||
await addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(mockItemRepository.save).toHaveBeenCalledTimes(1); |
||||
expect(mockItemRepository.save).toHaveBeenCalledWith(expect.any(ItemEntity)); |
||||
}); |
||||
|
||||
it('should not call order service', async () => { |
||||
await addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(mockOrderService.orderItem).not.toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('should return item ID', async () => { |
||||
const result = await addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(result).toBeTruthy(); |
||||
expect(typeof result).toBe('string'); |
||||
expect(result).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); |
||||
}); |
||||
|
||||
it('should validate expiration with ItemExpirationSpec', async () => { |
||||
await addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(mockExpirationSpec.isExpired).toHaveBeenCalledTimes(1); |
||||
expect(mockExpirationSpec.isExpired).toHaveBeenCalledWith( |
||||
expect.any(ItemEntity), |
||||
new Date(MOCKED_NOW) |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('when item is expired', () => { |
||||
beforeEach(() => { |
||||
mockExpirationSpec.isExpired.mockReturnValue(true); |
||||
}); |
||||
|
||||
it('should call order service', async () => { |
||||
await addItemCommand.execute(ITEM_NAME, EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(mockOrderService.orderItem).toHaveBeenCalledTimes(1); |
||||
expect(mockOrderService.orderItem).toHaveBeenCalledWith(expect.any(ItemEntity)); |
||||
}); |
||||
|
||||
it('should not save item to repository', async () => { |
||||
await addItemCommand.execute(ITEM_NAME, EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(mockItemRepository.save).not.toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('should return item ID', async () => { |
||||
const result = await addItemCommand.execute(ITEM_NAME, EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(result).toBeTruthy(); |
||||
expect(typeof result).toBe('string'); |
||||
}); |
||||
|
||||
it('should handle order service failure gracefully', async () => { |
||||
mockOrderService.orderItem.mockRejectedValue(new Error('Order service failed')); |
||||
|
||||
const result = await addItemCommand.execute(ITEM_NAME, EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(result).toBeTruthy(); |
||||
expect(typeof result).toBe('string'); |
||||
expect(result).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); |
||||
expect(mockOrderService.orderItem).toHaveBeenCalledTimes(1); |
||||
expect(mockItemRepository.save).not.toHaveBeenCalled(); |
||||
}); |
||||
}); |
||||
|
||||
describe('input validation', () => { |
||||
it('should throw error when name is empty', async () => { |
||||
await expect( |
||||
addItemCommand.execute('', NOT_EXPIRED_DATE, ORDER_URL, USER_ID) |
||||
).rejects.toThrow('Item name cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when name is only whitespace', async () => { |
||||
await expect( |
||||
addItemCommand.execute(' ', NOT_EXPIRED_DATE, ORDER_URL, USER_ID) |
||||
).rejects.toThrow('Item name cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when expirationDate is empty', async () => { |
||||
await expect( |
||||
addItemCommand.execute(ITEM_NAME, '', ORDER_URL, USER_ID) |
||||
).rejects.toThrow('Expiration date cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when expirationDate is only whitespace', async () => { |
||||
await expect( |
||||
addItemCommand.execute(ITEM_NAME, ' ', ORDER_URL, USER_ID) |
||||
).rejects.toThrow('Expiration date cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when orderUrl is empty', async () => { |
||||
await expect( |
||||
addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, '', USER_ID) |
||||
).rejects.toThrow('Order URL cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when orderUrl is only whitespace', async () => { |
||||
await expect( |
||||
addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ' ', USER_ID) |
||||
).rejects.toThrow('Order URL cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when userId is empty', async () => { |
||||
await expect( |
||||
addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ORDER_URL, '') |
||||
).rejects.toThrow('User ID cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when userId is only whitespace', async () => { |
||||
await expect( |
||||
addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ORDER_URL, ' ') |
||||
).rejects.toThrow('User ID cannot be empty'); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -1,109 +0,0 @@
|
||||
import { Injectable, Inject } from '@nestjs/common'; |
||||
import { ItemEntity } from '../../domain/entities/item.entity'; |
||||
import { ItemId } from '../../domain/value-objects/item-id.vo'; |
||||
import { ExpirationDate } from '../../domain/value-objects/expiration-date.vo'; |
||||
import { UserId } from '../../domain/value-objects/user-id.vo'; |
||||
import { IItemRepository } from '../interfaces/item-repository.interface'; |
||||
import { IOrderService } from '../interfaces/order-service.interface'; |
||||
import { ITimeProvider } from '../interfaces/time-provider.interface'; |
||||
import { ItemExpirationSpec } from '../../domain/specifications/item-expiration.spec'; |
||||
import { LoggerService } from '../services/logger.service'; |
||||
|
||||
@Injectable() |
||||
export class AddItemCommand { |
||||
constructor( |
||||
@Inject('IItemRepository') |
||||
private readonly itemRepository: IItemRepository, |
||||
@Inject('IOrderService') |
||||
private readonly orderService: IOrderService, |
||||
@Inject('ITimeProvider') |
||||
private readonly timeProvider: ITimeProvider, |
||||
@Inject(LoggerService) |
||||
private readonly logger: LoggerService, |
||||
private readonly expirationSpec: ItemExpirationSpec, |
||||
) {} |
||||
|
||||
async execute( |
||||
name: string, |
||||
expirationDate: string, |
||||
orderUrl: string, |
||||
userId: string, |
||||
): Promise<string | null> { |
||||
try { |
||||
this.logger.log(`Adding item: ${name} for user: ${userId}`, AddItemCommand.name); |
||||
|
||||
// Validate input parameters
|
||||
this.validateInput(name, expirationDate, orderUrl, userId); |
||||
|
||||
// Parse expiration date and check if it's in the future
|
||||
const expirationDateObj = new Date(expirationDate); |
||||
const now = this.timeProvider.now(); |
||||
|
||||
// Business rule: Items with past expiration dates are allowed but trigger immediate ordering
|
||||
// Items with future expiration dates are saved normally
|
||||
|
||||
// Create domain entities
|
||||
const itemId = ItemId.generate(); |
||||
const userIdVo = UserId.create(userId); |
||||
const expirationDateVo = ExpirationDate.fromString(expirationDate); |
||||
|
||||
const item = new ItemEntity( |
||||
itemId, |
||||
name, |
||||
expirationDateVo, |
||||
orderUrl, |
||||
userIdVo, |
||||
); |
||||
|
||||
const currentTime = this.timeProvider.now(); |
||||
|
||||
// Check if item is expired
|
||||
if (this.expirationSpec.isExpired(item, currentTime)) { |
||||
this.logger.log(`Item ${name} is expired, ordering replacement`, AddItemCommand.name); |
||||
|
||||
try { |
||||
await this.orderService.orderItem(item); |
||||
this.logger.log(`Successfully ordered replacement for expired item: ${name}`, AddItemCommand.name); |
||||
// Return the item ID even for expired items to match API contract
|
||||
return itemId.getValue(); |
||||
} catch (error) { |
||||
this.logger.error(`Failed to place order for expired item ${itemId.getValue()}: ${error.message}`, undefined, AddItemCommand.name); |
||||
// Still return the ID even if ordering fails
|
||||
return itemId.getValue(); |
||||
} |
||||
} |
||||
|
||||
// Save item if not expired
|
||||
await this.itemRepository.save(item); |
||||
this.logger.log(`Successfully saved item: ${name} with ID: ${itemId.getValue()}`, AddItemCommand.name); |
||||
|
||||
return itemId.getValue(); |
||||
} catch (error) { |
||||
this.logger.error(`Failed to add item: ${error.message}`, undefined, AddItemCommand.name); |
||||
throw error; |
||||
} |
||||
} |
||||
|
||||
private validateInput( |
||||
name: string, |
||||
expirationDate: string, |
||||
orderUrl: string, |
||||
userId: string, |
||||
): void { |
||||
if (!name || name.trim().length === 0) { |
||||
throw new Error('Item name cannot be empty'); |
||||
} |
||||
|
||||
if (!expirationDate || expirationDate.trim().length === 0) { |
||||
throw new Error('Expiration date cannot be empty'); |
||||
} |
||||
|
||||
if (!orderUrl || orderUrl.trim().length === 0) { |
||||
throw new Error('Order URL cannot be empty'); |
||||
} |
||||
|
||||
if (!userId || userId.trim().length === 0) { |
||||
throw new Error('User ID cannot be empty'); |
||||
} |
||||
} |
||||
} |
||||
@ -1,47 +0,0 @@
|
||||
import { Injectable, NotFoundException, UnauthorizedException, Inject } from '@nestjs/common'; |
||||
import { ItemId } from '../../domain/value-objects/item-id.vo'; |
||||
import { UserId } from '../../domain/value-objects/user-id.vo'; |
||||
import { IItemRepository } from '../interfaces/item-repository.interface'; |
||||
import { LoggerService } from '../services/logger.service'; |
||||
|
||||
@Injectable() |
||||
export class DeleteItemCommand { |
||||
constructor( |
||||
@Inject('IItemRepository') |
||||
private readonly itemRepository: IItemRepository, |
||||
@Inject(LoggerService) |
||||
private readonly logger: LoggerService, |
||||
) {} |
||||
|
||||
async execute(itemId: string, userId: string): Promise<void> { |
||||
try { |
||||
this.logger.log(`Deleting item: ${itemId} for user: ${userId}`, DeleteItemCommand.name); |
||||
|
||||
const itemIdVo = ItemId.create(itemId); |
||||
const userIdVo = UserId.create(userId); |
||||
|
||||
const item = await this.itemRepository.findById(itemIdVo); |
||||
|
||||
if (!item) { |
||||
this.logger.warn(`Item not found: ${itemId}`, DeleteItemCommand.name); |
||||
throw new NotFoundException(`Item with ID ${itemId} not found`); |
||||
} |
||||
|
||||
// Validate ownership
|
||||
if (!item.getUserId().equals(userIdVo)) { |
||||
this.logger.warn(`User ${userId} attempted to delete item ${itemId} owned by ${item.getUserId().getValue()}`, DeleteItemCommand.name); |
||||
throw new NotFoundException(`Item with ID ${itemId} not found`); |
||||
} |
||||
|
||||
await this.itemRepository.delete(itemIdVo); |
||||
this.logger.log(`Successfully deleted item: ${itemId}`, DeleteItemCommand.name); |
||||
} catch (error) { |
||||
if (error instanceof NotFoundException || error instanceof UnauthorizedException) { |
||||
throw error; |
||||
} |
||||
|
||||
this.logger.error(`Failed to delete item ${itemId}: ${error.message}`, undefined, DeleteItemCommand.name); |
||||
throw new Error(`Failed to delete item: ${error.message}`); |
||||
} |
||||
} |
||||
} |
||||
@ -1,53 +0,0 @@
|
||||
import { Injectable, Inject } from '@nestjs/common'; |
||||
import { IItemRepository } from '../interfaces/item-repository.interface'; |
||||
import { IOrderService } from '../interfaces/order-service.interface'; |
||||
import { ITimeProvider } from '../interfaces/time-provider.interface'; |
||||
import { ItemExpirationSpec } from '../../domain/specifications/item-expiration.spec'; |
||||
import { LoggerService } from '../services/logger.service'; |
||||
|
||||
@Injectable() |
||||
export class HandleExpiredItemsCommand { |
||||
constructor( |
||||
@Inject('IItemRepository') |
||||
private readonly itemRepository: IItemRepository, |
||||
@Inject('IOrderService') |
||||
private readonly orderService: IOrderService, |
||||
@Inject('ITimeProvider') |
||||
private readonly timeProvider: ITimeProvider, |
||||
@Inject(LoggerService) |
||||
private readonly logger: LoggerService, |
||||
private readonly expirationSpec: ItemExpirationSpec, |
||||
) {} |
||||
|
||||
async execute(): Promise<void> { |
||||
try { |
||||
this.logger.log('Starting expired items processing', HandleExpiredItemsCommand.name); |
||||
|
||||
const currentTime = this.timeProvider.now(); |
||||
const specification = this.expirationSpec.getSpec(currentTime); |
||||
|
||||
const expiredItems = await this.itemRepository.findWhere(specification); |
||||
|
||||
this.logger.log(`Found ${expiredItems.length} expired items to process`, HandleExpiredItemsCommand.name); |
||||
|
||||
for (const item of expiredItems) { |
||||
try { |
||||
this.logger.log(`Processing expired item: ${item.getId().getValue()}`, HandleExpiredItemsCommand.name); |
||||
|
||||
await this.orderService.orderItem(item); |
||||
await this.itemRepository.delete(item.getId()); |
||||
|
||||
this.logger.log(`Successfully processed and deleted expired item: ${item.getId().getValue()}`, HandleExpiredItemsCommand.name); |
||||
} catch (error) { |
||||
this.logger.error(`Failed to process expired item ${item.getId().getValue()}: ${error.message}`, undefined, HandleExpiredItemsCommand.name); |
||||
// Continue processing other items even if one fails
|
||||
} |
||||
} |
||||
|
||||
this.logger.log('Completed expired items processing', HandleExpiredItemsCommand.name); |
||||
} catch (error) { |
||||
this.logger.error(`Failed to handle expired items: ${error.message}`, undefined, HandleExpiredItemsCommand.name); |
||||
throw new Error(`Failed to handle expired items: ${error.message}`); |
||||
} |
||||
} |
||||
} |
||||
@ -1,49 +0,0 @@
|
||||
import { Injectable, UnauthorizedException, Inject } from '@nestjs/common'; |
||||
import { IAuthService } from '../interfaces/auth-service.interface'; |
||||
import { LoggerService } from '../services/logger.service'; |
||||
|
||||
@Injectable() |
||||
export class LoginUserCommand { |
||||
constructor( |
||||
@Inject('IAuthService') |
||||
private readonly authService: IAuthService, |
||||
@Inject(LoggerService) |
||||
private readonly logger: LoggerService, |
||||
) {} |
||||
|
||||
async execute(username: string, password: string): Promise<string> { |
||||
try { |
||||
this.logger.log(`Login attempt for user: ${username}`, LoginUserCommand.name); |
||||
|
||||
// Validate input parameters
|
||||
this.validateInput(username, password); |
||||
|
||||
const token = await this.authService.authenticate(username, password); |
||||
|
||||
if (!token) { |
||||
this.logger.warn(`Authentication failed for user: ${username}`, LoginUserCommand.name); |
||||
throw new UnauthorizedException('Invalid username or password'); |
||||
} |
||||
|
||||
this.logger.log(`Successfully authenticated user: ${username}`, LoginUserCommand.name); |
||||
return token; |
||||
} catch (error) { |
||||
if (error instanceof UnauthorizedException) { |
||||
throw error; |
||||
} |
||||
|
||||
this.logger.error(`Failed to login user ${username}: ${error.message}`, undefined, LoginUserCommand.name); |
||||
throw new Error(`Failed to login: ${error.message}`); |
||||
} |
||||
} |
||||
|
||||
private validateInput(username: string, password: string): void { |
||||
if (!username || username.trim().length === 0) { |
||||
throw new Error('Username cannot be empty'); |
||||
} |
||||
|
||||
if (!password || password.trim().length === 0) { |
||||
throw new Error('Password cannot be empty'); |
||||
} |
||||
} |
||||
} |
||||
@ -1,24 +0,0 @@
|
||||
import { IsNotEmpty, IsString, IsUrl, IsDateString } from 'class-validator'; |
||||
|
||||
export class CreateItemDto { |
||||
@IsNotEmpty() |
||||
@IsString() |
||||
name: string; |
||||
|
||||
@IsNotEmpty() |
||||
@IsDateString() |
||||
expirationDate: string; |
||||
|
||||
@IsNotEmpty() |
||||
@IsUrl() |
||||
orderUrl: string; |
||||
} |
||||
|
||||
export class ItemResponseDto { |
||||
id: string; |
||||
name: string; |
||||
expirationDate: string; |
||||
orderUrl: string; |
||||
userId: string; |
||||
createdAt: string; |
||||
} |
||||
@ -1,17 +0,0 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator'; |
||||
|
||||
export class LoginDto { |
||||
@IsNotEmpty() |
||||
@IsString() |
||||
username: string; |
||||
|
||||
@IsNotEmpty() |
||||
@IsString() |
||||
password: string; |
||||
} |
||||
|
||||
export class LoginResponseDto { |
||||
token: string; |
||||
tokenType: string; |
||||
expiresIn: number; |
||||
} |
||||
@ -1,5 +0,0 @@
|
||||
export interface IAuthService { |
||||
authenticate(username: string, password: string): Promise<string | null>; |
||||
validateToken(token: string): Promise<boolean>; |
||||
getUserIdFromToken(token: string): Promise<string | null>; |
||||
} |
||||
@ -1,13 +0,0 @@
|
||||
import { ItemEntity } from '../../domain/entities/item.entity'; |
||||
import { ItemId } from '../../domain/value-objects/item-id.vo'; |
||||
import { UserId } from '../../domain/value-objects/user-id.vo'; |
||||
import { ISpecification } from '../../domain/specifications/specification.interface'; |
||||
|
||||
export interface IItemRepository { |
||||
save(item: ItemEntity): Promise<void>; |
||||
findById(id: ItemId): Promise<ItemEntity | null>; |
||||
findByUserId(userId: UserId): Promise<ItemEntity[]>; |
||||
findWhere(specification: ISpecification<ItemEntity>): Promise<ItemEntity[]>; |
||||
delete(id: ItemId): Promise<void>; |
||||
exists(id: ItemId): Promise<boolean>; |
||||
} |
||||
@ -1,6 +0,0 @@
|
||||
export interface ILogger { |
||||
log(message: string, context?: string): void; |
||||
error(message: string, trace?: string, context?: string): void; |
||||
warn(message: string, context?: string): void; |
||||
debug(message: string, context?: string): void; |
||||
} |
||||
@ -1,5 +0,0 @@
|
||||
import { ItemEntity } from '../../domain/entities/item.entity'; |
||||
|
||||
export interface IOrderService { |
||||
orderItem(item: ItemEntity): Promise<void>; |
||||
} |
||||
@ -1,3 +0,0 @@
|
||||
export interface ITimeProvider { |
||||
now(): Date; |
||||
} |
||||
@ -1,10 +0,0 @@
|
||||
import { UserEntity } from '../../domain/entities/user.entity'; |
||||
import { UserId } from '../../domain/value-objects/user-id.vo'; |
||||
|
||||
export interface IUserRepository { |
||||
save(user: UserEntity): Promise<void>; |
||||
findById(id: UserId): Promise<UserEntity | null>; |
||||
findByUsername(username: string): Promise<UserEntity | null>; |
||||
exists(id: UserId): Promise<boolean>; |
||||
existsByUsername(username: string): Promise<boolean>; |
||||
} |
||||
@ -1,50 +0,0 @@
|
||||
import { Injectable, NotFoundException, UnauthorizedException, Inject } from '@nestjs/common'; |
||||
import { ItemEntity } from '../../domain/entities/item.entity'; |
||||
import { ItemId } from '../../domain/value-objects/item-id.vo'; |
||||
import { UserId } from '../../domain/value-objects/user-id.vo'; |
||||
import { IItemRepository } from '../interfaces/item-repository.interface'; |
||||
import { LoggerService } from '../services/logger.service'; |
||||
|
||||
@Injectable() |
||||
export class GetItemQuery { |
||||
constructor( |
||||
@Inject('IItemRepository') |
||||
private readonly itemRepository: IItemRepository, |
||||
@Inject(LoggerService) |
||||
private readonly logger: LoggerService, |
||||
) {} |
||||
|
||||
async execute(itemId: string, userId: string): Promise<ItemEntity> { |
||||
try { |
||||
this.logger.log(`Getting item: ${itemId} for user: ${userId}`, GetItemQuery.name); |
||||
|
||||
const itemIdVo = ItemId.create(itemId); |
||||
const userIdVo = UserId.create(userId); |
||||
|
||||
const item = await this.itemRepository.findById(itemIdVo); |
||||
|
||||
if (!item) { |
||||
this.logger.warn(`Item not found: ${itemId}`, GetItemQuery.name); |
||||
throw new NotFoundException(`Item with ID ${itemId} not found`); |
||||
} |
||||
|
||||
// Validate ownership
|
||||
if (!item.getUserId().equals(userIdVo)) { |
||||
this.logger.warn(`User ${userId} attempted to access item ${itemId} owned by ${item.getUserId().getValue()}`, GetItemQuery.name); |
||||
// throw new UnauthorizedException('You do not have permission to access this item');
|
||||
// Go with 404 for safety reasons - it is till not found for that user, but the existence is not compromised
|
||||
throw new NotFoundException(`Item with ID ${itemId} not found`); |
||||
} |
||||
|
||||
this.logger.log(`Successfully retrieved item: ${itemId}`, GetItemQuery.name); |
||||
return item; |
||||
} catch (error) { |
||||
if (error instanceof NotFoundException || error instanceof UnauthorizedException) { |
||||
throw error; |
||||
} |
||||
|
||||
this.logger.error(`Failed to get item ${itemId}: ${error.message}`, undefined, GetItemQuery.name); |
||||
throw new Error(`Failed to get item: ${error.message}`); |
||||
} |
||||
} |
||||
} |
||||
@ -1,30 +0,0 @@
|
||||
import { Injectable, Inject } from '@nestjs/common'; |
||||
import { ItemEntity } from '../../domain/entities/item.entity'; |
||||
import { UserId } from '../../domain/value-objects/user-id.vo'; |
||||
import { IItemRepository } from '../interfaces/item-repository.interface'; |
||||
import { LoggerService } from '../services/logger.service'; |
||||
|
||||
@Injectable() |
||||
export class ListItemsQuery { |
||||
constructor( |
||||
@Inject('IItemRepository') |
||||
private readonly itemRepository: IItemRepository, |
||||
@Inject(LoggerService) |
||||
private readonly logger: LoggerService, |
||||
) {} |
||||
|
||||
async execute(userId: string): Promise<ItemEntity[]> { |
||||
try { |
||||
this.logger.log(`Listing items for user: ${userId}`, ListItemsQuery.name); |
||||
|
||||
const userIdVo = UserId.create(userId); |
||||
const items = await this.itemRepository.findByUserId(userIdVo); |
||||
|
||||
this.logger.log(`Successfully retrieved ${items.length} items for user: ${userId}`, ListItemsQuery.name); |
||||
return items; |
||||
} catch (error) { |
||||
this.logger.error(`Failed to list items for user ${userId}: ${error.message}`, undefined, ListItemsQuery.name); |
||||
throw new Error(`Failed to list items: ${error.message}`); |
||||
} |
||||
} |
||||
} |
||||
@ -1,10 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common'; |
||||
import { ILogger } from '../interfaces/logger.interface'; |
||||
|
||||
@Injectable() |
||||
export abstract class LoggerService implements ILogger { |
||||
abstract log(message: string, context?: string): void; |
||||
abstract error(message: string, trace?: string, context?: string): void; |
||||
abstract warn(message: string, context?: string): void; |
||||
abstract debug(message: string, context?: string): void; |
||||
} |
||||
@ -1,43 +0,0 @@
|
||||
export interface JSendSuccess<T = any> { |
||||
status: 'success'; |
||||
data: T; |
||||
} |
||||
|
||||
export interface JSendError { |
||||
status: 'error'; |
||||
message: string; |
||||
code?: number; |
||||
data?: any; |
||||
} |
||||
|
||||
export interface JSendFail { |
||||
status: 'fail'; |
||||
data: any; |
||||
} |
||||
|
||||
export type JSendResponse<T = any> = JSendSuccess<T> | JSendError | JSendFail; |
||||
|
||||
export class JSendResponseUtil { |
||||
static success<T>(data: T): JSendSuccess<T> { |
||||
return { |
||||
status: 'success', |
||||
data, |
||||
}; |
||||
} |
||||
|
||||
static error(message: string, code?: number, data?: any): JSendError { |
||||
return { |
||||
status: 'error', |
||||
message, |
||||
code, |
||||
data, |
||||
}; |
||||
} |
||||
|
||||
static fail(data: any): JSendFail { |
||||
return { |
||||
status: 'fail', |
||||
data, |
||||
}; |
||||
} |
||||
} |
||||
@ -1,75 +0,0 @@
|
||||
import { ItemId } from '../value-objects/item-id.vo'; |
||||
import { ExpirationDate } from '../value-objects/expiration-date.vo'; |
||||
import { UserId } from '../value-objects/user-id.vo'; |
||||
|
||||
export class ItemEntity { |
||||
private readonly id: ItemId; |
||||
private readonly name: string; |
||||
private readonly expirationDate: ExpirationDate; |
||||
private readonly orderUrl: string; |
||||
private readonly userId: UserId; |
||||
private readonly createdAt: Date; |
||||
|
||||
constructor( |
||||
id: ItemId, |
||||
name: string, |
||||
expirationDate: ExpirationDate, |
||||
orderUrl: string, |
||||
userId: UserId, |
||||
) { |
||||
this.validateName(name); |
||||
this.validateOrderUrl(orderUrl); |
||||
|
||||
this.id = id; |
||||
this.name = name; |
||||
this.expirationDate = expirationDate; |
||||
this.orderUrl = orderUrl; |
||||
this.userId = userId; |
||||
this.createdAt = new Date(); |
||||
} |
||||
|
||||
private validateName(name: string): void { |
||||
if (!name || name.trim().length === 0) { |
||||
throw new Error('Item name cannot be empty'); |
||||
} |
||||
if (name.length > 255) { |
||||
throw new Error('Item name cannot exceed 255 characters'); |
||||
} |
||||
} |
||||
|
||||
private validateOrderUrl(orderUrl: string): void { |
||||
if (!orderUrl || orderUrl.trim().length === 0) { |
||||
throw new Error('Order URL cannot be empty'); |
||||
} |
||||
|
||||
try { |
||||
new URL(orderUrl); |
||||
} catch { |
||||
throw new Error('Order URL must be a valid URL'); |
||||
} |
||||
} |
||||
|
||||
getId(): ItemId { |
||||
return this.id; |
||||
} |
||||
|
||||
getName(): string { |
||||
return this.name; |
||||
} |
||||
|
||||
getExpirationDate(): ExpirationDate { |
||||
return this.expirationDate; |
||||
} |
||||
|
||||
getOrderUrl(): string { |
||||
return this.orderUrl; |
||||
} |
||||
|
||||
getUserId(): UserId { |
||||
return this.userId; |
||||
} |
||||
|
||||
getCreatedAt(): Date { |
||||
return this.createdAt; |
||||
} |
||||
} |
||||
@ -1,62 +0,0 @@
|
||||
import { UserId } from '../value-objects/user-id.vo'; |
||||
|
||||
export class UserEntity { |
||||
private readonly id: UserId; |
||||
private readonly username: string; |
||||
private readonly passwordHash: string; |
||||
private readonly createdAt: Date; |
||||
|
||||
constructor( |
||||
id: UserId, |
||||
username: string, |
||||
passwordHash: string, |
||||
) { |
||||
this.validateUsername(username); |
||||
this.validatePasswordHash(passwordHash); |
||||
|
||||
this.id = id; |
||||
this.username = username; |
||||
this.passwordHash = passwordHash; |
||||
this.createdAt = new Date(); |
||||
} |
||||
|
||||
private validateUsername(username: string): void { |
||||
if (!username || username.trim().length === 0) { |
||||
throw new Error('Username cannot be empty'); |
||||
} |
||||
if (username.length < 3) { |
||||
throw new Error('Username must be at least 3 characters long'); |
||||
} |
||||
if (username.length > 50) { |
||||
throw new Error('Username cannot exceed 50 characters'); |
||||
} |
||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) { |
||||
throw new Error('Username can only contain letters, numbers, underscores, and hyphens'); |
||||
} |
||||
} |
||||
|
||||
private validatePasswordHash(passwordHash: string): void { |
||||
if (!passwordHash || passwordHash.trim().length === 0) { |
||||
throw new Error('Password hash cannot be empty'); |
||||
} |
||||
if (passwordHash.length < 8) { |
||||
throw new Error('Password hash must be at least 8 characters long'); |
||||
} |
||||
} |
||||
|
||||
getId(): UserId { |
||||
return this.id; |
||||
} |
||||
|
||||
getUsername(): string { |
||||
return this.username; |
||||
} |
||||
|
||||
getPasswordHash(): string { |
||||
return this.passwordHash; |
||||
} |
||||
|
||||
getCreatedAt(): Date { |
||||
return this.createdAt; |
||||
} |
||||
} |
||||
@ -1,159 +0,0 @@
|
||||
import { ItemEntity } from '../../entities/item.entity'; |
||||
import { ItemId } from '../../value-objects/item-id.vo'; |
||||
import { ExpirationDate } from '../../value-objects/expiration-date.vo'; |
||||
import { UserId } from '../../value-objects/user-id.vo'; |
||||
import { ItemExpirationSpec } from '../item-expiration.spec'; |
||||
|
||||
describe('ItemExpirationSpec', () => { |
||||
let spec: ItemExpirationSpec; |
||||
let currentTime: Date; |
||||
|
||||
beforeEach(() => { |
||||
spec = new ItemExpirationSpec(); |
||||
currentTime = new Date('2023-01-01T12:00:00Z'); |
||||
}); |
||||
|
||||
const createItemWithExpiration = (expirationDate: Date): ItemEntity => { |
||||
return new ItemEntity( |
||||
ItemId.create('550e8400-e29b-41d4-a716-446655440000'), |
||||
'Test Item', |
||||
ExpirationDate.create(expirationDate), |
||||
'https://example.com/order', |
||||
UserId.create('550e8400-e29b-41d4-a716-446655440001'), |
||||
); |
||||
}; |
||||
|
||||
describe('isExpired', () => { |
||||
it('should return true when item is expired', () => { |
||||
const expiredDate = new Date('2022-12-31T12:00:00Z'); // 1 day before current time
|
||||
const item = createItemWithExpiration(expiredDate); |
||||
|
||||
const result = spec.isExpired(item, currentTime); |
||||
|
||||
expect(result).toBe(true); |
||||
}); |
||||
|
||||
it('should return false when item is not expired', () => { |
||||
const futureDate = new Date('2023-01-02T12:00:00Z'); // 1 day after current time
|
||||
const item = createItemWithExpiration(futureDate); |
||||
|
||||
const result = spec.isExpired(item, currentTime); |
||||
|
||||
expect(result).toBe(false); |
||||
}); |
||||
|
||||
it('should return true when expiration date equals current time', () => { |
||||
const sameTime = new Date(currentTime); |
||||
const item = createItemWithExpiration(sameTime); |
||||
|
||||
const result = spec.isExpired(item, currentTime); |
||||
|
||||
expect(result).toBe(true); |
||||
}); |
||||
|
||||
it('should return false when expiration date is one second in the future', () => { |
||||
const futureTime = new Date(currentTime); |
||||
futureTime.setSeconds(futureTime.getSeconds() + 1); |
||||
const item = createItemWithExpiration(futureTime); |
||||
|
||||
const result = spec.isExpired(item, currentTime); |
||||
|
||||
expect(result).toBe(false); |
||||
}); |
||||
|
||||
it('should return true when expiration date is one second in the past', () => { |
||||
const pastTime = new Date(currentTime); |
||||
pastTime.setSeconds(pastTime.getSeconds() - 1); |
||||
const item = createItemWithExpiration(pastTime); |
||||
|
||||
const result = spec.isExpired(item, currentTime); |
||||
|
||||
expect(result).toBe(true); |
||||
}); |
||||
}); |
||||
|
||||
describe('getSpec', () => { |
||||
it('should return a specification for finding expired items', () => { |
||||
const specification = spec.getSpec(currentTime); |
||||
|
||||
expect(specification).toBeDefined(); |
||||
expect(specification.getSpec).toBeDefined(); |
||||
expect(typeof specification.getSpec()).toBe('object'); |
||||
}); |
||||
|
||||
it('should return specification that matches expired items', () => { |
||||
const specification = spec.getSpec(currentTime); |
||||
|
||||
const expiredItem = createItemWithExpiration(new Date('2022-12-31T12:00:00Z')); |
||||
const validItem = createItemWithExpiration(new Date('2023-01-02T12:00:00Z')); |
||||
|
||||
expect(specification.isSatisfiedBy(expiredItem)).toBe(true); |
||||
expect(specification.isSatisfiedBy(validItem)).toBe(false); |
||||
}); |
||||
|
||||
it('should return specification that matches item with exact current time', () => { |
||||
const specification = spec.getSpec(currentTime); |
||||
|
||||
const itemWithCurrentTime = createItemWithExpiration(currentTime); |
||||
|
||||
expect(specification.isSatisfiedBy(itemWithCurrentTime)).toBe(true); |
||||
}); |
||||
|
||||
it('should return specification with correct expiration criteria', () => { |
||||
const specification = spec.getSpec(currentTime); |
||||
const specObject = specification.getSpec(); |
||||
|
||||
expect(specObject).toEqual(['expirationDate', '<=', currentTime.toISOString()]); |
||||
}); |
||||
}); |
||||
|
||||
describe('time scenarios', () => { |
||||
it('should correctly identify items expired by different time units', () => { |
||||
// Test past dates (should be expired)
|
||||
const fiveMinutesAgo = new Date(currentTime); |
||||
fiveMinutesAgo.setMinutes(fiveMinutesAgo.getMinutes() - 5); |
||||
expect(spec.isExpired(createItemWithExpiration(fiveMinutesAgo), currentTime)).toBe(true); |
||||
|
||||
const twoHoursAgo = new Date(currentTime); |
||||
twoHoursAgo.setHours(twoHoursAgo.getHours() - 2); |
||||
expect(spec.isExpired(createItemWithExpiration(twoHoursAgo), currentTime)).toBe(true); |
||||
|
||||
const threeDaysAgo = new Date(currentTime); |
||||
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3); |
||||
expect(spec.isExpired(createItemWithExpiration(threeDaysAgo), currentTime)).toBe(true); |
||||
|
||||
// Test future dates (should not be expired)
|
||||
const fiveMinutesFuture = new Date(currentTime); |
||||
fiveMinutesFuture.setMinutes(fiveMinutesFuture.getMinutes() + 5); |
||||
expect(spec.isExpired(createItemWithExpiration(fiveMinutesFuture), currentTime)).toBe(false); |
||||
|
||||
const twoHoursFuture = new Date(currentTime); |
||||
twoHoursFuture.setHours(twoHoursFuture.getHours() + 2); |
||||
expect(spec.isExpired(createItemWithExpiration(twoHoursFuture), currentTime)).toBe(false); |
||||
|
||||
const threeDaysFuture = new Date(currentTime); |
||||
threeDaysFuture.setDate(threeDaysFuture.getDate() + 3); |
||||
expect(spec.isExpired(createItemWithExpiration(threeDaysFuture), currentTime)).toBe(false); |
||||
}); |
||||
|
||||
it('should handle special date boundaries correctly', () => { |
||||
// Midnight boundary
|
||||
const midnight = new Date('2023-01-01T00:00:00Z'); |
||||
const itemExpiredAtMidnight = createItemWithExpiration(midnight); |
||||
const currentTimeAtMidnight = new Date('2023-01-01T00:00:00Z'); |
||||
expect(spec.isExpired(itemExpiredAtMidnight, currentTimeAtMidnight)).toBe(true); |
||||
|
||||
// Year boundary
|
||||
const endOfYear = new Date('2022-12-31T23:59:59Z'); |
||||
const itemExpiredAtEndOfYear = createItemWithExpiration(endOfYear); |
||||
const currentTimeNewYear = new Date('2023-01-01T00:00:01Z'); |
||||
expect(spec.isExpired(itemExpiredAtEndOfYear, currentTimeNewYear)).toBe(true); |
||||
|
||||
// Leap year
|
||||
const leapYearDate = new Date('2020-02-29T12:00:00Z'); |
||||
const itemWithLeapYearDate = createItemWithExpiration(leapYearDate); |
||||
const currentTimeAfterLeapYear = new Date('2020-03-01T12:00:00Z'); |
||||
expect(spec.isExpired(itemWithLeapYearDate, currentTimeAfterLeapYear)).toBe(true); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -1,580 +0,0 @@
|
||||
import { SimpleSpecification, Spec } from '../spec.helper'; |
||||
|
||||
describe('Spec Helper', () => { |
||||
describe('Spec static methods', () => { |
||||
describe('eq', () => { |
||||
it('should create equality condition', () => { |
||||
const condition = Spec.eq('name', 'test'); |
||||
expect(condition).toEqual(['name', '=', 'test']); |
||||
}); |
||||
}); |
||||
|
||||
describe('neq', () => { |
||||
it('should create not equal condition', () => { |
||||
const condition = Spec.neq('status', 'inactive'); |
||||
expect(condition).toEqual(['status', '!=', 'inactive']); |
||||
}); |
||||
}); |
||||
|
||||
describe('gt', () => { |
||||
it('should create greater than condition', () => { |
||||
const condition = Spec.gt('age', 18); |
||||
expect(condition).toEqual(['age', '>', 18]); |
||||
}); |
||||
}); |
||||
|
||||
describe('gte', () => { |
||||
it('should create greater than or equal condition', () => { |
||||
const condition = Spec.gte('score', 80); |
||||
expect(condition).toEqual(['score', '>=', 80]); |
||||
}); |
||||
}); |
||||
|
||||
describe('lt', () => { |
||||
it('should create less than condition', () => { |
||||
const condition = Spec.lt('price', 100); |
||||
expect(condition).toEqual(['price', '<', 100]); |
||||
}); |
||||
}); |
||||
|
||||
describe('lte', () => { |
||||
it('should create less than or equal condition', () => { |
||||
const condition = Spec.lte('expirationDate', '2023-01-01'); |
||||
expect(condition).toEqual(['expirationDate', '<=', '2023-01-01']); |
||||
}); |
||||
}); |
||||
|
||||
describe('in', () => { |
||||
it('should create IN condition', () => { |
||||
const condition = Spec.in('role', ['admin', 'user']); |
||||
expect(condition).toEqual(['role', 'IN', ['admin', 'user']]); |
||||
}); |
||||
}); |
||||
|
||||
describe('nin', () => { |
||||
it('should create NOT IN condition', () => { |
||||
const condition = Spec.nin('status', ['banned', 'suspended']); |
||||
expect(condition).toEqual(['status', 'NOT IN', ['banned', 'suspended']]); |
||||
}); |
||||
}); |
||||
|
||||
describe('and', () => { |
||||
it('should create AND group', () => { |
||||
const conditions = [Spec.eq('active', true), Spec.gt('score', 80)]; |
||||
const group = Spec.and(conditions); |
||||
expect(group).toEqual({ AND: [['active', '=', true], ['score', '>', 80]] }); |
||||
}); |
||||
}); |
||||
|
||||
describe('or', () => { |
||||
it('should create OR group', () => { |
||||
const conditions = [Spec.eq('role', 'admin'), Spec.eq('role', 'moderator')]; |
||||
const group = Spec.or(conditions); |
||||
expect(group).toEqual({ OR: [['role', '=', 'admin'], ['role', '=', 'moderator']] }); |
||||
}); |
||||
}); |
||||
|
||||
describe('not', () => { |
||||
it('should create NOT group', () => { |
||||
const condition = Spec.eq('deleted', true); |
||||
const group = Spec.not(condition); |
||||
expect(group).toEqual({ NOT: ['deleted', '=', true] }); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('SimpleSpecification', () => { |
||||
describe('basic operators', () => { |
||||
describe('EQ operator', () => { |
||||
it('should match equal string values', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('name', 'test')); |
||||
const object = { name: 'test' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match different string values', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('name', 'test')); |
||||
const object = { name: 'different' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
|
||||
it('should match equal integer values', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('age', 25)); |
||||
const object = { age: 25 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match different integer values', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('age', 25)); |
||||
const object = { age: 30 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
|
||||
it('should match equal float values', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('price', 19.99)); |
||||
const object = { price: 19.99 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should work with getter methods', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('name', 'test')); |
||||
const object = { |
||||
getName: () => 'test', |
||||
}; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should work with direct property access', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('name', 'test')); |
||||
const object = { |
||||
name: 'test', |
||||
}; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
}); |
||||
|
||||
describe('NEQ operator', () => { |
||||
it('should match when values are not equal', () => { |
||||
const spec = new SimpleSpecification(Spec.neq('status', 'inactive')); |
||||
const object = { status: 'active' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match when values are equal', () => { |
||||
const spec = new SimpleSpecification(Spec.neq('status', 'inactive')); |
||||
const object = { status: 'inactive' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('GT operator', () => { |
||||
it('should match when value is greater', () => { |
||||
const spec = new SimpleSpecification(Spec.gt('score', 80)); |
||||
const object = { score: 90 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match when value is equal', () => { |
||||
const spec = new SimpleSpecification(Spec.gt('score', 80)); |
||||
const object = { score: 80 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
|
||||
it('should not match when value is less', () => { |
||||
const spec = new SimpleSpecification(Spec.gt('score', 80)); |
||||
const object = { score: 70 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('GTE operator', () => { |
||||
it('should match when value is greater', () => { |
||||
const spec = new SimpleSpecification(Spec.gte('score', 80)); |
||||
const object = { score: 90 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should match when value is equal', () => { |
||||
const spec = new SimpleSpecification(Spec.gte('score', 80)); |
||||
const object = { score: 80 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match when value is less', () => { |
||||
const spec = new SimpleSpecification(Spec.gte('score', 80)); |
||||
const object = { score: 70 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('LT operator', () => { |
||||
it('should match when value is less', () => { |
||||
const spec = new SimpleSpecification(Spec.lt('score', 80)); |
||||
const object = { score: 70 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match when value is equal', () => { |
||||
const spec = new SimpleSpecification(Spec.lt('score', 80)); |
||||
const object = { score: 80 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
|
||||
it('should not match when value is greater', () => { |
||||
const spec = new SimpleSpecification(Spec.lt('score', 80)); |
||||
const object = { score: 90 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('LTE operator', () => { |
||||
it('should match when value is less', () => { |
||||
const spec = new SimpleSpecification(Spec.lte('score', 80)); |
||||
const object = { score: 70 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should match when value is equal', () => { |
||||
const spec = new SimpleSpecification(Spec.lte('score', 80)); |
||||
const object = { score: 80 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match when value is greater', () => { |
||||
const spec = new SimpleSpecification(Spec.lte('score', 80)); |
||||
const object = { score: 90 }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('IN operator', () => { |
||||
it('should match when value is in array', () => { |
||||
const spec = new SimpleSpecification(Spec.in('role', ['admin', 'moderator'])); |
||||
const object = { role: 'admin' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match when value is not in array', () => { |
||||
const spec = new SimpleSpecification(Spec.in('role', ['admin', 'moderator'])); |
||||
const object = { role: 'user' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
|
||||
it('should not match when array is empty', () => { |
||||
const spec = new SimpleSpecification(Spec.in('role', [])); |
||||
const object = { role: 'admin' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('NIN operator', () => { |
||||
it('should match when value is not in array', () => { |
||||
const spec = new SimpleSpecification(Spec.nin('status', ['banned', 'suspended'])); |
||||
const object = { status: 'active' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match when value is in array', () => { |
||||
const spec = new SimpleSpecification(Spec.nin('status', ['banned', 'suspended'])); |
||||
const object = { status: 'banned' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
|
||||
it('should match when array is empty', () => { |
||||
const spec = new SimpleSpecification(Spec.nin('status', [])); |
||||
const object = { status: 'active' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('logical groups', () => { |
||||
describe('AND group', () => { |
||||
it('should match when all conditions are met', () => { |
||||
const spec = new SimpleSpecification( |
||||
Spec.and([ |
||||
Spec.eq('status', 'active'), |
||||
Spec.gte('score', 80), |
||||
Spec.in('role', ['admin', 'moderator']), |
||||
]) |
||||
); |
||||
|
||||
const object = { |
||||
status: 'active', |
||||
score: 85, |
||||
role: 'admin', |
||||
}; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match when any condition is not met', () => { |
||||
const spec = new SimpleSpecification( |
||||
Spec.and([ |
||||
Spec.eq('status', 'active'), |
||||
Spec.gte('score', 80), |
||||
Spec.in('role', ['admin', 'moderator']), |
||||
]) |
||||
); |
||||
|
||||
const object1 = { |
||||
status: 'inactive', |
||||
score: 85, |
||||
role: 'admin', |
||||
}; |
||||
|
||||
const object2 = { |
||||
status: 'active', |
||||
score: 70, |
||||
role: 'admin', |
||||
}; |
||||
|
||||
const object3 = { |
||||
status: 'active', |
||||
score: 85, |
||||
role: 'user', |
||||
}; |
||||
|
||||
expect(spec.isSatisfiedBy(object1)).toBe(false); |
||||
expect(spec.isSatisfiedBy(object2)).toBe(false); |
||||
expect(spec.isSatisfiedBy(object3)).toBe(false); |
||||
}); |
||||
|
||||
it('should match when group is empty', () => { |
||||
const spec = new SimpleSpecification(Spec.and([])); |
||||
const object = { any: 'value' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
}); |
||||
|
||||
describe('OR group', () => { |
||||
it('should match when any condition is met', () => { |
||||
const spec = new SimpleSpecification( |
||||
Spec.or([ |
||||
Spec.eq('role', 'admin'), |
||||
Spec.gte('score', 90), |
||||
Spec.in('department', ['IT', 'HR']), |
||||
]) |
||||
); |
||||
|
||||
const object1 = { |
||||
role: 'admin', |
||||
score: 70, |
||||
department: 'Finance', |
||||
}; |
||||
|
||||
const object2 = { |
||||
role: 'user', |
||||
score: 95, |
||||
department: 'Finance', |
||||
}; |
||||
|
||||
const object3 = { |
||||
role: 'user', |
||||
score: 70, |
||||
department: 'IT', |
||||
}; |
||||
|
||||
expect(spec.isSatisfiedBy(object1)).toBe(true); |
||||
expect(spec.isSatisfiedBy(object2)).toBe(true); |
||||
expect(spec.isSatisfiedBy(object3)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match when no conditions are met', () => { |
||||
const spec = new SimpleSpecification( |
||||
Spec.or([ |
||||
Spec.eq('role', 'admin'), |
||||
Spec.gte('score', 90), |
||||
Spec.in('department', ['IT', 'HR']), |
||||
]) |
||||
); |
||||
|
||||
const object = { |
||||
role: 'user', |
||||
score: 70, |
||||
department: 'Finance', |
||||
}; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
|
||||
it('should not match when group is empty', () => { |
||||
const spec = new SimpleSpecification(Spec.or([])); |
||||
const object = { any: 'value' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('NOT group', () => { |
||||
it('should match when condition is not met', () => { |
||||
const spec = new SimpleSpecification( |
||||
Spec.not(Spec.eq('status', 'banned')) |
||||
); |
||||
|
||||
const object = { status: 'active' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should not match when condition is met', () => { |
||||
const spec = new SimpleSpecification( |
||||
Spec.not(Spec.eq('status', 'banned')) |
||||
); |
||||
|
||||
const object = { status: 'banned' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('nested groups', () => { |
||||
it('should handle nested AND-OR groups', () => { |
||||
const spec = new SimpleSpecification( |
||||
Spec.and([ |
||||
Spec.eq('status', 'active'), |
||||
Spec.or([ |
||||
Spec.gte('score', 80), |
||||
Spec.in('role', ['admin', 'moderator']), |
||||
]), |
||||
]) |
||||
); |
||||
|
||||
const matchingObject1 = { |
||||
status: 'active', |
||||
score: 85, |
||||
role: 'user', |
||||
}; |
||||
|
||||
const matchingObject2 = { |
||||
status: 'active', |
||||
score: 70, |
||||
role: 'admin', |
||||
}; |
||||
|
||||
const nonMatchingObject = { |
||||
status: 'inactive', |
||||
score: 85, |
||||
role: 'user', |
||||
}; |
||||
|
||||
expect(spec.isSatisfiedBy(matchingObject1)).toBe(true); |
||||
expect(spec.isSatisfiedBy(matchingObject2)).toBe(true); |
||||
expect(spec.isSatisfiedBy(nonMatchingObject)).toBe(false); |
||||
}); |
||||
|
||||
it('should handle triple nested groups', () => { |
||||
const spec = new SimpleSpecification( |
||||
Spec.and([ |
||||
Spec.eq('active', true), |
||||
Spec.not( |
||||
Spec.or([ |
||||
Spec.eq('role', 'banned'), |
||||
Spec.eq('status', 'suspended'), |
||||
]) |
||||
), |
||||
]) |
||||
); |
||||
|
||||
const matchingObject = { |
||||
active: true, |
||||
role: 'user', |
||||
status: 'active', |
||||
}; |
||||
|
||||
const nonMatchingObject1 = { |
||||
active: false, |
||||
role: 'user', |
||||
status: 'active', |
||||
}; |
||||
|
||||
const nonMatchingObject2 = { |
||||
active: true, |
||||
role: 'banned', |
||||
status: 'active', |
||||
}; |
||||
|
||||
expect(spec.isSatisfiedBy(matchingObject)).toBe(true); |
||||
expect(spec.isSatisfiedBy(nonMatchingObject1)).toBe(false); |
||||
expect(spec.isSatisfiedBy(nonMatchingObject2)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('edge cases', () => { |
||||
it('should return false when field does not exist', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('nonExistentField', 'value')); |
||||
const object = { existingField: 'value' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
|
||||
it('should handle null values correctly', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('optionalField', null)); |
||||
|
||||
const matchingObject = { optionalField: null }; |
||||
const nonMatchingObject = { optionalField: 'value' }; |
||||
|
||||
expect(spec.isSatisfiedBy(matchingObject)).toBe(true); |
||||
expect(spec.isSatisfiedBy(nonMatchingObject)).toBe(false); |
||||
}); |
||||
|
||||
it('should return false for unknown operators', () => { |
||||
const spec = new SimpleSpecification(['field', 'INVALID_OP', 'value']); |
||||
const object = { field: 'value' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(false); |
||||
}); |
||||
|
||||
it('should handle invalid date strings gracefully', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('dateField', 'invalid-date')); |
||||
const object = { dateField: 'invalid-date' }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); // Falls back to regular comparison
|
||||
}); |
||||
|
||||
it('should handle Date objects in objects', () => { |
||||
const testDate = new Date('2023-01-15T10:30:00Z'); |
||||
const spec = new SimpleSpecification(Spec.eq('createdAt', testDate)); |
||||
const object = { createdAt: testDate }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
|
||||
it('should handle date string comparisons with Date objects', () => { |
||||
const testDate = new Date('2023-01-15T10:30:00Z'); |
||||
const spec = new SimpleSpecification(Spec.eq('createdAt', testDate.toISOString())); |
||||
const object = { createdAt: testDate }; |
||||
|
||||
expect(spec.isSatisfiedBy(object)).toBe(true); |
||||
}); |
||||
}); |
||||
|
||||
describe('getSpec', () => { |
||||
it('should return the original specification', () => { |
||||
const originalSpec = Spec.eq('field', 'value'); |
||||
const specification = new SimpleSpecification(originalSpec); |
||||
|
||||
expect(specification.getSpec()).toEqual(originalSpec); |
||||
}); |
||||
}); |
||||
|
||||
describe('match method', () => { |
||||
it('should work as alias for isSatisfiedBy', () => { |
||||
const spec = new SimpleSpecification(Spec.eq('name', 'test')); |
||||
const object = { name: 'test' }; |
||||
|
||||
expect(spec.match(object)).toBe(true); |
||||
expect(spec.match(object)).toBe(spec.isSatisfiedBy(object)); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -1,14 +0,0 @@
|
||||
import { ItemEntity } from '../entities/item.entity'; |
||||
import { SimpleSpecification, Spec } from './spec.helper'; |
||||
|
||||
export class ItemExpirationSpec { |
||||
isExpired(item: ItemEntity, currentTime: Date): boolean { |
||||
return this.getSpec(currentTime).match(item); |
||||
} |
||||
|
||||
getSpec(currentTime: Date): SimpleSpecification<ItemEntity> { |
||||
return new SimpleSpecification<ItemEntity>( |
||||
Spec.lte('expirationDate', currentTime.toISOString()) |
||||
); |
||||
} |
||||
} |
||||
@ -1,192 +0,0 @@
|
||||
import { ISpecification } from './specification.interface'; |
||||
|
||||
export class Spec { |
||||
// Logical group operators
|
||||
static readonly GROUP_AND = 'AND'; |
||||
static readonly GROUP_OR = 'OR'; |
||||
static readonly GROUP_NOT = 'NOT'; |
||||
|
||||
// Comparison operators
|
||||
static readonly OP_EQ = '='; |
||||
static readonly OP_NEQ = '!='; |
||||
static readonly OP_GT = '>'; |
||||
static readonly OP_GTE = '>='; |
||||
static readonly OP_LT = '<'; |
||||
static readonly OP_LTE = '<='; |
||||
static readonly OP_IN = 'IN'; |
||||
static readonly OP_NIN = 'NOT IN'; |
||||
|
||||
// Logical group helpers
|
||||
static and(conditions: any[]): any { |
||||
return { [this.GROUP_AND]: conditions }; |
||||
} |
||||
|
||||
static or(conditions: any[]): any { |
||||
return { [this.GROUP_OR]: conditions }; |
||||
} |
||||
|
||||
static not(condition: any): any { |
||||
return { [this.GROUP_NOT]: condition }; |
||||
} |
||||
|
||||
// Condition helpers
|
||||
static eq(field: string, value: any): any { |
||||
return [field, this.OP_EQ, value]; |
||||
} |
||||
|
||||
static neq(field: string, value: any): any { |
||||
return [field, this.OP_NEQ, value]; |
||||
} |
||||
|
||||
static gt(field: string, value: any): any { |
||||
return [field, this.OP_GT, value]; |
||||
} |
||||
|
||||
static gte(field: string, value: any): any { |
||||
return [field, this.OP_GTE, value]; |
||||
} |
||||
|
||||
static lt(field: string, value: any): any { |
||||
return [field, this.OP_LT, value]; |
||||
} |
||||
|
||||
static lte(field: string, value: any): any { |
||||
return [field, this.OP_LTE, value]; |
||||
} |
||||
|
||||
static in(field: string, values: any[]): any { |
||||
return [field, this.OP_IN, values]; |
||||
} |
||||
|
||||
static nin(field: string, values: any[]): any { |
||||
return [field, this.OP_NIN, values]; |
||||
} |
||||
} |
||||
|
||||
export class SimpleSpecification<T> implements ISpecification<T> { |
||||
private readonly spec: any; |
||||
|
||||
constructor(spec: any) { |
||||
this.spec = spec; |
||||
} |
||||
|
||||
isSatisfiedBy(candidate: T): boolean { |
||||
return this.evaluateSpec(this.spec, candidate); |
||||
} |
||||
|
||||
getSpec(): object { |
||||
return this.spec; |
||||
} |
||||
|
||||
match(object: any): boolean { |
||||
return this.isSatisfiedBy(object); |
||||
} |
||||
|
||||
private evaluateSpec(spec: any, object: any): boolean { |
||||
// Handle logical groups
|
||||
if (spec[Spec.GROUP_AND]) { |
||||
for (const subSpec of spec[Spec.GROUP_AND]) { |
||||
if (!this.evaluateSpec(subSpec, object)) { |
||||
return false; |
||||
} |
||||
} |
||||
return true; |
||||
} |
||||
|
||||
if (spec[Spec.GROUP_OR]) { |
||||
for (const subSpec of spec[Spec.GROUP_OR]) { |
||||
if (this.evaluateSpec(subSpec, object)) { |
||||
return true; |
||||
} |
||||
} |
||||
return false; |
||||
} |
||||
|
||||
if (spec[Spec.GROUP_NOT]) { |
||||
return !this.evaluateSpec(spec[Spec.GROUP_NOT], object); |
||||
} |
||||
|
||||
// Handle simple conditions [field, op, value]
|
||||
const [field, op, value] = spec; |
||||
|
||||
// Check if field exists in the object
|
||||
const getterMethod = 'get' + this.capitalizeFirst(field); |
||||
let fieldValue: any; |
||||
|
||||
if (typeof object === 'object' && object !== null) { |
||||
if (typeof object[getterMethod] === 'function') { |
||||
fieldValue = object[getterMethod](); |
||||
} else if (field in object) { |
||||
fieldValue = object[field]; |
||||
} else { |
||||
return false; |
||||
} |
||||
} else { |
||||
return false; |
||||
} |
||||
|
||||
// Handle Date comparison
|
||||
if (fieldValue instanceof Date && (typeof value === 'string' || value instanceof Date)) { |
||||
return this.compareDates(fieldValue, op, value); |
||||
} |
||||
|
||||
// Evaluate based on operator
|
||||
switch (op) { |
||||
case Spec.OP_EQ: |
||||
return fieldValue == value; |
||||
case Spec.OP_NEQ: |
||||
return fieldValue != value; |
||||
case Spec.OP_GT: |
||||
return fieldValue > value; |
||||
case Spec.OP_GTE: |
||||
return fieldValue >= value; |
||||
case Spec.OP_LT: |
||||
return fieldValue < value; |
||||
case Spec.OP_LTE: |
||||
return fieldValue <= value; |
||||
case Spec.OP_IN: |
||||
return Array.isArray(value) && value.includes(fieldValue); |
||||
case Spec.OP_NIN: |
||||
return Array.isArray(value) && !value.includes(fieldValue); |
||||
default: |
||||
return false; // Unknown operator
|
||||
} |
||||
} |
||||
|
||||
private compareDates(fieldValue: Date, op: string, value: Date | string): boolean { |
||||
let compareValue: Date; |
||||
|
||||
if (typeof value === 'string') { |
||||
compareValue = new Date(value); |
||||
if (isNaN(compareValue.getTime())) { |
||||
return false; // Invalid date string
|
||||
} |
||||
} else { |
||||
compareValue = value; |
||||
} |
||||
|
||||
const fieldTime = fieldValue.getTime(); |
||||
const compareTime = compareValue.getTime(); |
||||
|
||||
switch (op) { |
||||
case Spec.OP_EQ: |
||||
return fieldTime === compareTime; |
||||
case Spec.OP_NEQ: |
||||
return fieldTime !== compareTime; |
||||
case Spec.OP_GT: |
||||
return fieldTime > compareTime; |
||||
case Spec.OP_GTE: |
||||
return fieldTime >= compareTime; |
||||
case Spec.OP_LT: |
||||
return fieldTime < compareTime; |
||||
case Spec.OP_LTE: |
||||
return fieldTime <= compareTime; |
||||
default: |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
private capitalizeFirst(str: string): string { |
||||
return str.charAt(0).toUpperCase() + str.slice(1); |
||||
} |
||||
} |
||||
@ -1,75 +0,0 @@
|
||||
export interface ISpecification<T> { |
||||
isSatisfiedBy(candidate: T): boolean; |
||||
getSpec(): object; |
||||
} |
||||
|
||||
export abstract class Specification<T> implements ISpecification<T> { |
||||
abstract isSatisfiedBy(candidate: T): boolean; |
||||
abstract getSpec(): object; |
||||
|
||||
and(other: Specification<T>): Specification<T> { |
||||
return new AndSpecification(this, other); |
||||
} |
||||
|
||||
or(other: Specification<T>): Specification<T> { |
||||
return new OrSpecification(this, other); |
||||
} |
||||
|
||||
not(): Specification<T> { |
||||
return new NotSpecification(this); |
||||
} |
||||
} |
||||
|
||||
class AndSpecification<T> extends Specification<T> { |
||||
constructor( |
||||
private readonly left: Specification<T>, |
||||
private readonly right: Specification<T>, |
||||
) { |
||||
super(); |
||||
} |
||||
|
||||
isSatisfiedBy(candidate: T): boolean { |
||||
return this.left.isSatisfiedBy(candidate) && this.right.isSatisfiedBy(candidate); |
||||
} |
||||
|
||||
getSpec(): object { |
||||
return { |
||||
AND: [this.left.getSpec(), this.right.getSpec()], |
||||
}; |
||||
} |
||||
} |
||||
|
||||
class OrSpecification<T> extends Specification<T> { |
||||
constructor( |
||||
private readonly left: Specification<T>, |
||||
private readonly right: Specification<T>, |
||||
) { |
||||
super(); |
||||
} |
||||
|
||||
isSatisfiedBy(candidate: T): boolean { |
||||
return this.left.isSatisfiedBy(candidate) || this.right.isSatisfiedBy(candidate); |
||||
} |
||||
|
||||
getSpec(): object { |
||||
return { |
||||
OR: [this.left.getSpec(), this.right.getSpec()], |
||||
}; |
||||
} |
||||
} |
||||
|
||||
class NotSpecification<T> extends Specification<T> { |
||||
constructor(private readonly spec: Specification<T>) { |
||||
super(); |
||||
} |
||||
|
||||
isSatisfiedBy(candidate: T): boolean { |
||||
return !this.spec.isSatisfiedBy(candidate); |
||||
} |
||||
|
||||
getSpec(): object { |
||||
return { |
||||
NOT: this.spec.getSpec(), |
||||
}; |
||||
} |
||||
} |
||||
@ -1,147 +0,0 @@
|
||||
// This is a base test class for UUID value objects to avoid code duplication
|
||||
export abstract class BaseUuidValueObjectSpec<T> { |
||||
protected abstract createValueObject(uuid: string): T; |
||||
protected abstract generateValueObject(): T; |
||||
protected abstract getValidUuid(): string; |
||||
protected abstract getInvalidUuids(): string[]; |
||||
protected abstract getClassName(): string; |
||||
protected abstract getStaticCreateMethod(): (uuid: string) => T; |
||||
|
||||
// Type assertion methods to ensure the value object has the required methods
|
||||
protected getValue(vo: T): string { |
||||
return (vo as any).getValue(); |
||||
} |
||||
|
||||
protected equals(vo1: T, vo2: T): boolean { |
||||
return (vo1 as any).equals(vo2); |
||||
} |
||||
|
||||
protected toString(vo: T): string { |
||||
return (vo as any).toString(); |
||||
} |
||||
|
||||
public runTests() { |
||||
describe(`${this.getClassName()}`, () => { |
||||
describe('constructor', () => { |
||||
it('should create with valid UUID', () => { |
||||
const validUuid = this.getValidUuid(); |
||||
const valueObject = this.createValueObject(validUuid); |
||||
|
||||
expect(this.getValue(valueObject)).toBe(validUuid); |
||||
}); |
||||
|
||||
it('should throw error when UUID is empty', () => { |
||||
expect(() => { |
||||
this.createValueObject(''); |
||||
}).toThrow(`${this.getClassName()} cannot be empty`); |
||||
}); |
||||
|
||||
it('should throw error when UUID is only whitespace', () => { |
||||
expect(() => { |
||||
this.createValueObject(' '); |
||||
}).toThrow(`${this.getClassName()} cannot be empty`); |
||||
}); |
||||
|
||||
it('should throw error when UUID is invalid', () => { |
||||
this.getInvalidUuids().forEach(uuid => { |
||||
expect(() => { |
||||
this.createValueObject(uuid); |
||||
}).toThrow(`${this.getClassName()} must be a valid UUID`); |
||||
}); |
||||
}); |
||||
|
||||
it('should accept UUID with uppercase letters', () => { |
||||
const upperUuid = this.getValidUuid().toUpperCase(); |
||||
const valueObject = this.createValueObject(upperUuid); |
||||
|
||||
expect(this.getValue(valueObject)).toBe(upperUuid); |
||||
}); |
||||
|
||||
it('should accept UUID with mixed case', () => { |
||||
const mixedUuid = '550e8400-E29b-41D4-a716-446655440000'; |
||||
const valueObject = this.createValueObject(mixedUuid); |
||||
|
||||
expect(this.getValue(valueObject)).toBe(mixedUuid); |
||||
}); |
||||
}); |
||||
|
||||
describe('static methods', () => { |
||||
describe('generate', () => { |
||||
it('should generate a valid UUID', () => { |
||||
const valueObject = this.generateValueObject(); |
||||
const sampleValueObject = this.createValueObject(this.getValidUuid()); |
||||
|
||||
expect(valueObject).toBeInstanceOf(Object.getPrototypeOf(sampleValueObject).constructor); |
||||
expect(this.getValue(valueObject)).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); |
||||
}); |
||||
|
||||
it('should generate unique UUIDs', () => { |
||||
const valueObject1 = this.generateValueObject(); |
||||
const valueObject2 = this.generateValueObject(); |
||||
|
||||
expect(this.getValue(valueObject1)).not.toBe(this.getValue(valueObject2)); |
||||
}); |
||||
}); |
||||
|
||||
describe('create', () => { |
||||
it('should create from valid UUID string', () => { |
||||
const validUuid = this.getValidUuid(); |
||||
const valueObject = this.getStaticCreateMethod()(validUuid); |
||||
const sampleValueObject = this.createValueObject(this.getValidUuid()); |
||||
|
||||
expect(valueObject).toBeInstanceOf(Object.getPrototypeOf(sampleValueObject).constructor); |
||||
expect(this.getValue(valueObject)).toBe(validUuid); |
||||
}); |
||||
|
||||
it('should throw error for invalid UUID', () => { |
||||
expect(() => { |
||||
this.getStaticCreateMethod()('invalid-uuid'); |
||||
}).toThrow(`${this.getClassName()} must be a valid UUID`); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('getValue', () => { |
||||
it('should return the UUID value', () => { |
||||
const validUuid = this.getValidUuid(); |
||||
const valueObject = this.createValueObject(validUuid); |
||||
|
||||
expect(this.getValue(valueObject)).toBe(validUuid); |
||||
}); |
||||
}); |
||||
|
||||
describe('equals', () => { |
||||
it('should return true for equal value objects', () => { |
||||
const uuid = this.getValidUuid(); |
||||
const valueObject1 = this.createValueObject(uuid); |
||||
const valueObject2 = this.createValueObject(uuid); |
||||
|
||||
expect(this.equals(valueObject1, valueObject2)).toBe(true); |
||||
}); |
||||
|
||||
it('should return false for different value objects', () => { |
||||
const valueObject1 = this.createValueObject('550e8400-e29b-41d4-a716-446655440000'); |
||||
const valueObject2 = this.createValueObject('550e8400-e29b-41d4-a716-446655440001'); |
||||
|
||||
expect(this.equals(valueObject1, valueObject2)).toBe(false); |
||||
}); |
||||
|
||||
it('should be case sensitive', () => { |
||||
const valueObject1 = this.createValueObject('550e8400-e29b-41d4-a716-446655440000'); |
||||
const valueObject2 = this.createValueObject('550E8400-E29B-41D4-A716-446655440000'); |
||||
|
||||
expect(this.equals(valueObject1, valueObject2)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('toString', () => { |
||||
it('should return string representation of UUID', () => { |
||||
const validUuid = this.getValidUuid(); |
||||
const valueObject = this.createValueObject(validUuid); |
||||
|
||||
expect(this.toString(valueObject)).toBe(validUuid); |
||||
}); |
||||
}); |
||||
}); |
||||
} |
||||
} |
||||
@ -1,149 +0,0 @@
|
||||
import { ExpirationDate } from '../expiration-date.vo'; |
||||
|
||||
describe('ExpirationDate', () => { |
||||
const MOCKED_NOW = new Date('2023-01-01T12:00:00Z'); |
||||
|
||||
describe('constructor', () => { |
||||
it('should create ExpirationDate with valid future date', () => { |
||||
const futureDate = new Date(MOCKED_NOW); |
||||
futureDate.setDate(futureDate.getDate() + 7); // 7 days in the future
|
||||
const expirationDate = new ExpirationDate(futureDate); |
||||
|
||||
expect(expirationDate.getValue()).toEqual(futureDate); |
||||
}); |
||||
|
||||
it('should throw error when date is not a Date object', () => { |
||||
expect(() => { |
||||
new ExpirationDate('not-a-date' as any); |
||||
}).toThrow('Expiration date must be a Date object'); |
||||
}); |
||||
|
||||
it('should throw error when date is invalid', () => { |
||||
expect(() => { |
||||
new ExpirationDate(new Date('invalid-date')); |
||||
}).toThrow('Expiration date must be a valid date'); |
||||
}); |
||||
|
||||
}); |
||||
|
||||
describe('static create', () => { |
||||
it('should create ExpirationDate from valid Date', () => { |
||||
const futureDate = new Date(MOCKED_NOW); |
||||
futureDate.setDate(futureDate.getDate() + 7); |
||||
const expirationDate = ExpirationDate.create(futureDate); |
||||
|
||||
expect(expirationDate).toBeInstanceOf(ExpirationDate); |
||||
expect(expirationDate.getValue()).toEqual(futureDate); |
||||
}); |
||||
}); |
||||
|
||||
describe('static fromString', () => { |
||||
it('should create ExpirationDate from valid ISO date string', () => { |
||||
const futureDate = new Date(MOCKED_NOW); |
||||
futureDate.setDate(futureDate.getDate() + 7); |
||||
const dateString = futureDate.toISOString(); |
||||
|
||||
const expirationDate = ExpirationDate.fromString(dateString); |
||||
|
||||
expect(expirationDate).toBeInstanceOf(ExpirationDate); |
||||
expect(expirationDate.getValue()).toEqual(futureDate); |
||||
}); |
||||
|
||||
it('should throw error for invalid date string', () => { |
||||
expect(() => { |
||||
ExpirationDate.fromString('invalid-date'); |
||||
}).toThrow('Invalid date string format'); |
||||
}); |
||||
}); |
||||
|
||||
describe('getValue', () => { |
||||
it('should return a copy of the date', () => { |
||||
const futureDate = new Date(MOCKED_NOW); |
||||
futureDate.setDate(futureDate.getDate() + 7); |
||||
const expirationDate = new ExpirationDate(futureDate); |
||||
|
||||
const returnedDate = expirationDate.getValue(); |
||||
|
||||
expect(returnedDate).toEqual(futureDate); |
||||
expect(returnedDate).not.toBe(futureDate); // Should be a different object
|
||||
}); |
||||
|
||||
it('should return immutable date', () => { |
||||
const futureDate = new Date(MOCKED_NOW); |
||||
futureDate.setDate(futureDate.getDate() + 7); |
||||
const expirationDate = new ExpirationDate(futureDate); |
||||
|
||||
const returnedDate = expirationDate.getValue(); |
||||
returnedDate.setDate(returnedDate.getDate() + 1); // Try to modify
|
||||
|
||||
// Original should remain unchanged
|
||||
expect(expirationDate.getValue()).toEqual(futureDate); |
||||
}); |
||||
}); |
||||
|
||||
describe('format', () => { |
||||
it('should return ISO string format', () => { |
||||
const futureDate = new Date(MOCKED_NOW); |
||||
futureDate.setDate(futureDate.getDate() + 7); |
||||
const expirationDate = new ExpirationDate(futureDate); |
||||
|
||||
expect(expirationDate.format()).toBe(futureDate.toISOString()); |
||||
}); |
||||
}); |
||||
|
||||
describe('toISOString', () => { |
||||
it('should return ISO string format', () => { |
||||
const futureDate = new Date(MOCKED_NOW); |
||||
futureDate.setDate(futureDate.getDate() + 7); |
||||
const expirationDate = new ExpirationDate(futureDate); |
||||
|
||||
expect(expirationDate.toISOString()).toBe(futureDate.toISOString()); |
||||
}); |
||||
}); |
||||
|
||||
describe('toString', () => { |
||||
it('should return string representation', () => { |
||||
const futureDate = new Date(MOCKED_NOW); |
||||
futureDate.setDate(futureDate.getDate() + 7); |
||||
const expirationDate = new ExpirationDate(futureDate); |
||||
|
||||
expect(expirationDate.toString()).toBe(futureDate.toISOString()); |
||||
}); |
||||
}); |
||||
|
||||
describe('equals', () => { |
||||
it('should return true for equal dates', () => { |
||||
const futureDate = new Date(MOCKED_NOW); |
||||
futureDate.setDate(futureDate.getDate() + 7); |
||||
const expirationDate1 = new ExpirationDate(futureDate); |
||||
const expirationDate2 = new ExpirationDate(futureDate); |
||||
|
||||
expect(expirationDate1.equals(expirationDate2)).toBe(true); |
||||
}); |
||||
|
||||
it('should return false for different dates', () => { |
||||
const futureDate1 = new Date(MOCKED_NOW); |
||||
futureDate1.setDate(futureDate1.getDate() + 7); |
||||
|
||||
const futureDate2 = new Date(MOCKED_NOW); |
||||
futureDate2.setDate(futureDate2.getDate() + 8); |
||||
|
||||
const expirationDate1 = new ExpirationDate(futureDate1); |
||||
const expirationDate2 = new ExpirationDate(futureDate2); |
||||
|
||||
expect(expirationDate1.equals(expirationDate2)).toBe(false); |
||||
}); |
||||
|
||||
it('should return true for dates with same timestamp', () => { |
||||
const timestamp = MOCKED_NOW.getTime() + 86400000; // 1 day in the future
|
||||
const date1 = new Date(timestamp); |
||||
const date2 = new Date(timestamp); |
||||
|
||||
const expirationDate1 = new ExpirationDate(date1); |
||||
const expirationDate2 = new ExpirationDate(date2); |
||||
|
||||
expect(expirationDate1.equals(expirationDate2)).toBe(true); |
||||
}); |
||||
}); |
||||
|
||||
}); |
||||
@ -1,37 +0,0 @@
|
||||
import { ItemId } from '../item-id.vo'; |
||||
import { BaseUuidValueObjectSpec } from './base-uuid-value-object'; |
||||
|
||||
class ItemIdSpec extends BaseUuidValueObjectSpec<ItemId> { |
||||
protected createValueObject(uuid: string): ItemId { |
||||
return new ItemId(uuid); |
||||
} |
||||
|
||||
protected generateValueObject(): ItemId { |
||||
return ItemId.generate(); |
||||
} |
||||
|
||||
protected getValidUuid(): string { |
||||
return '550e8400-e29b-41d4-a716-446655440000'; |
||||
} |
||||
|
||||
protected getInvalidUuids(): string[] { |
||||
return [ |
||||
'not-a-uuid', |
||||
'550e8400-e29b-41d4-a716', // too short
|
||||
'550e8400-e29b-41d4-a716-446655440000-extra', // too long
|
||||
'550e8400-e29b-41d4-a716-44665544000g', // invalid character 'g'
|
||||
'550e8400e29b41d4a716446655440000', // missing hyphens
|
||||
]; |
||||
} |
||||
|
||||
protected getClassName(): string { |
||||
return 'Item ID'; |
||||
} |
||||
|
||||
protected getStaticCreateMethod(): (uuid: string) => ItemId { |
||||
return ItemId.create; |
||||
} |
||||
} |
||||
|
||||
// Run the tests
|
||||
new ItemIdSpec().runTests(); |
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue