Compare commits

..

No commits in common. 'master' and 'php8-fixes2' have entirely different histories.

  1. 4
      .gitignore
  2. 2
      LICENSE
  3. 61
      README.md
  4. 20
      cpp17/docker/Dockerfile
  5. 31
      golang/.devcontainer/Dockerfile
  6. 25
      golang/.devcontainer/devcontainer.json
  7. 29
      golang/.devcontainer/docker-compose.yml
  8. 183
      golang/SPEC_DETAILS.md
  9. 94
      golang/cmd/main.go
  10. 4
      golang/docker/.dockerignore
  11. 33
      golang/docker/Dockerfile
  12. 17
      golang/docker/docker-compose.yml
  13. 39
      golang/go.mod
  14. 90
      golang/go.sum
  15. 97
      golang/internal/application/commands/add_item_command.go
  16. 69
      golang/internal/application/commands/delete_item_command.go
  17. 67
      golang/internal/application/commands/handle_expired_items_command.go
  18. 35
      golang/internal/application/commands/login_user_command.go
  19. 34
      golang/internal/application/dto/create_item_dto.go
  20. 25
      golang/internal/application/dto/item_response_dto.go
  21. 31
      golang/internal/application/dto/jsend_response.go
  22. 50
      golang/internal/application/dto/json_time.go
  23. 33
      golang/internal/application/dto/login_dto.go
  24. 5
      golang/internal/application/errors/errors.go
  25. 11
      golang/internal/application/interfaces/auth_service.go
  26. 17
      golang/internal/application/interfaces/item_repository.go
  27. 12
      golang/internal/application/interfaces/logger.go
  28. 10
      golang/internal/application/interfaces/order_service.go
  29. 9
      golang/internal/application/interfaces/time_provider.go
  30. 13
      golang/internal/application/interfaces/user_repository.go
  31. 63
      golang/internal/application/queries/get_item_query.go
  32. 50
      golang/internal/application/queries/list_items_query.go
  33. 67
      golang/internal/config/config.go
  34. 174
      golang/internal/container/container.go
  35. 11
      golang/internal/domain/entities/errors.go
  36. 118
      golang/internal/domain/entities/item.go
  37. 69
      golang/internal/domain/entities/user.go
  38. 219
      golang/internal/domain/specifications/condition_spec.go
  39. 29
      golang/internal/domain/specifications/item_expiration_spec.go
  40. 407
      golang/internal/domain/specifications/simple_specification.go
  41. 45
      golang/internal/domain/value_objects/base_uuid.go
  42. 23
      golang/internal/domain/value_objects/expiration_date.go
  43. 38
      golang/internal/domain/value_objects/item_id.go
  44. 38
      golang/internal/domain/value_objects/user_id.go
  45. 126
      golang/internal/infrastructure/auth/jwt_auth_service.go
  46. 80
      golang/internal/infrastructure/http/order_url_http_client.go
  47. 41
      golang/internal/infrastructure/logging/standard_logger.go
  48. 223
      golang/internal/infrastructure/repositories/file_item_repository.go
  49. 156
      golang/internal/infrastructure/repositories/file_user_repository.go
  50. 104
      golang/internal/infrastructure/scheduler/expired_items_scheduler.go
  51. 65
      golang/internal/infrastructure/services/user_initialization_service.go
  52. 15
      golang/internal/infrastructure/time/system_time_provider.go
  53. 53
      golang/internal/presentation/controllers/auth_controller.go
  54. 157
      golang/internal/presentation/controllers/items_controller.go
  55. 62
      golang/internal/presentation/middleware/jwt_middleware.go
  56. 117
      golang/internal/presentation/server/server.go
  57. 348
      golang/tests/integration/file_item_repository_test.go
  58. 352
      golang/tests/unit/add_item_command_test.go
  59. 293
      golang/tests/unit/handle_expired_items_command_test.go
  60. 904
      golang/tests/unit/specification_test.go
  61. 99
      golang/tests/unit/test_utils.go
  62. 2
      nestjs/.devcontainer/devcontainer.json
  63. 1207
      nestjs/PLAN-DDD.md
  64. 358
      nestjs/PLAN.md
  65. 243
      nestjs/REVIEW.md
  66. 1
      nestjs/docker/Dockerfile
  67. 1920
      nestjs/package-lock.json
  68. 18
      nestjs/package.json
  69. 12
      nestjs/src/app.controller.ts
  70. 99
      nestjs/src/app.module.ts
  71. 8
      nestjs/src/app.service.ts
  72. 178
      nestjs/src/application/commands/__tests__/add-item.command.spec.ts
  73. 109
      nestjs/src/application/commands/add-item.command.ts
  74. 47
      nestjs/src/application/commands/delete-item.command.ts
  75. 53
      nestjs/src/application/commands/handle-expired-items.command.ts
  76. 49
      nestjs/src/application/commands/login-user.command.ts
  77. 24
      nestjs/src/application/dto/create-item.dto.ts
  78. 17
      nestjs/src/application/dto/login.dto.ts
  79. 5
      nestjs/src/application/interfaces/auth-service.interface.ts
  80. 13
      nestjs/src/application/interfaces/item-repository.interface.ts
  81. 6
      nestjs/src/application/interfaces/logger.interface.ts
  82. 5
      nestjs/src/application/interfaces/order-service.interface.ts
  83. 3
      nestjs/src/application/interfaces/time-provider.interface.ts
  84. 10
      nestjs/src/application/interfaces/user-repository.interface.ts
  85. 50
      nestjs/src/application/queries/get-item.query.ts
  86. 30
      nestjs/src/application/queries/list-items.query.ts
  87. 10
      nestjs/src/application/services/logger.service.ts
  88. 43
      nestjs/src/common/utils/jsend-response.util.ts
  89. 75
      nestjs/src/domain/entities/item.entity.ts
  90. 62
      nestjs/src/domain/entities/user.entity.ts
  91. 159
      nestjs/src/domain/specifications/__tests__/item-expiration.spec.spec.ts
  92. 580
      nestjs/src/domain/specifications/__tests__/spec.helper.spec.ts
  93. 14
      nestjs/src/domain/specifications/item-expiration.spec.ts
  94. 192
      nestjs/src/domain/specifications/spec.helper.ts
  95. 75
      nestjs/src/domain/specifications/specification.interface.ts
  96. 147
      nestjs/src/domain/value-objects/__tests__/base-uuid-value-object.ts
  97. 149
      nestjs/src/domain/value-objects/__tests__/expiration-date.vo.spec.ts
  98. 37
      nestjs/src/domain/value-objects/__tests__/item-id.vo.spec.ts
  99. 52
      nestjs/src/domain/value-objects/__tests__/user-id.vo.spec.ts
  100. 55
      nestjs/src/domain/value-objects/expiration-date.vo.ts
  101. Some files were not shown because too many files have changed in this diff Show More

4
.gitignore vendored

@ -21,6 +21,10 @@ tmp
*.dylib
*.dll
# Fortran module files
*.mod
*.smod
# Compiled Static libraries
*.lai
*.la

2
LICENSE

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

61
README.md

@ -4,7 +4,7 @@ This repository hosts multiple implementations of the same back-end application.
Following principles such as **SOLID** and maintainable architectural patterns (**Clean, Hexagonal, Onion, or even DDD**) is recommended to clearly showcase the strengths and idioms of each technology.
Some over-engineering is acceptable to demonstrate architectural features, but please keep implementations readable and avoid excessive complexity (e.g., skip event sourcing or atomic transactions unless intentionally focusing on those patterns for comparison).
Some over-engineering is acceptable to demonstrate architectural features, but please keep implementations readable and avoid excessive complexity (e.g., skip event sourcing or strict CQRS unless intentionally focusing on those patterns for comparison).
---
@ -52,38 +52,35 @@ A system to store items with expiration dates. When items expire, new ones are a
```plaintext
AutoStore/
├── App # app assembly
├── App
│ ├── Main
│ ├── AppConfig
│ └── ...
├── Extern
│ ├── <package manager files>
│ ├── <jwt-lib, http-client, etc.>
│ └── <...downloaded libraries and git submodules>
├── Src # internal/lib/src
├── Src
│ ├── Domain/
│ │ ├── Entities/
│ │ │ ├── User
│ │ │ └── Item
│ │ └── Specifications/
│ │ └── ItemExpirationSpec # domain knowledge (from domain experts)
│ │ └── Services/
│ │ └── ExpirationPolicy
│ ├── Application/
│ │ ├── Commands/ # use cases
│ │ │ ├── Login
│ │ ├── UseCases/
│ │ │ ├── RegisterUser
│ │ │ ├── LoginUser
│ │ │ ├── AddItem
│ │ │ ├── GetItem
│ │ │ ├── DeleteItem
│ │ │ └── HandleExpiredItems
│ │ ├── Queries/ # use cases (read only)
│ │ │ ├── GetItem
│ │ │ └── ListItems
│ │ ├── Interfaces/
│ │ │ ├── IUserRepository
│ │ │ ├── IItemRepository
│ │ │ ├── IAuthService
│ │ │ └── IDateProvider
│ │ ├── Dto/ # data transfer objects (fields mappings, validation, etc.)
│ │ │ └── IClock
│ │ ├── Dto/
│ │ └── Services/
│ │ ├── UserInitializationService
│ │ └── ExpirationScheduler
│ ├── Infrastructure/
│ │ ├── Repositories/
│ │ │ ├── FileUserRepository
@ -91,12 +88,11 @@ AutoStore/
│ │ ├── Adapters/
│ │ │ ├── JwtAuthAdapter
│ │ │ ├── OrderUrlHttpClient
│ │ │ ├── SystemDateProvider
│ │ │ ├── SystemClockImpl
│ │ │ └── <... some extern lib adapters>
│ │ └── Helpers/
│ │ └── <... DRY helpers>
│ ├── Cli # presentation, optional command line use case caller
│ └── WebApi/ # presentation, REST (controllers, middlewares, etc.)
│ └── WebApi/
│ ├── Controllers/
│ │ ├── StoreController
│ │ └── UserController
@ -107,28 +103,14 @@ AutoStore/
└── Integration/
```
---
## Domain Knowledge and Repository Queries
Business rules like expiration checks (`expirationDate <= currentDate`) represent domain knowledge that **must have a single source of truth**. This logic might evolve (e.g., to `<= currentDate - N days` or vary by item type) and should never be duplicated across the codebase.
While simple predicates (like `findWhere(predicate<bool>(Item))`) work for in-memory repositories, SQL-based repositories need to translate these rules into efficient WHERE clauses. One solution (not too over-engineered) would be to pass DTO or value object ready to be put into queries (e.g., `fetchExpiredItems(calculatedConditionFields)`).
But for fun and possible UI search, consider implementing a specification pattern with a simple condition abstraction that exposes the business rule as composable conditions (field, operator, value).
This allows domain services to define rules once, use cases to apply them consistently, and repositories to translate them into optimal queries by interpreting the conditions according to their storage mechanism. Avoid duplicating the business logic in repository implementations - instead, let repositories consume the specification and build their queries accordingly. This aims to overcome to-repo-and-back drawbacks depicted in *Evans, Eric (2003). Domain Driven Design. Final Manuscript*.
---
## Build and Run
Each implementation should include a `<impl>/docker/docker-compose.yml` file so that you can simply run:
Ideally, each implementation should include a `<impl>/docker/docker-compose.yml` file so that you can simply run:
```bash
cd docker && docker compose up --build
docker compose up
```
to build, test and run the application.
to build and run the application.
Otherwise, please provide a `<impl>/README.md` file with setup and running instructions.
@ -146,11 +128,4 @@ Here's a summary of example API endpoints:
| `/items/{id}` | PUT | Update item details |
| `/items/{id}` | DELETE | Delete item |
Suggested base URL is `http://localhost:50080/api/v1/`.
## Testing
- Each implementation should include its own **unit tests** and **integration tests**, which must run automatically during the Docker image build.
- Implementation-independent functional tests are provided in `testing/tavern/`
- Tavern API tests (requests and assertions) must pass for every implementation to ensure consistent behavior across all technology stacks.
- For debugging and verifying the automatic ordering feature, use the helper service in `testing/http-echo-server/` which provides a simple Docker Compose setup that listens on port 8888 and logs all incoming POST requests, allowing you to observe when expired items trigger order notifications.
Suggested base URL is `http://localhost:50080/api/v1/`.

20
cpp17/docker/Dockerfile

@ -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"]

31
golang/.devcontainer/Dockerfile

@ -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"]

25
golang/.devcontainer/devcontainer.json

@ -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"
}

29
golang/.devcontainer/docker-compose.yml

@ -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

183
golang/SPEC_DETAILS.md

@ -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...)
}

94
golang/cmd/main.go

@ -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)
}

4
golang/docker/.dockerignore

@ -1,4 +0,0 @@
.devcontainer
.git
.gitignore
README.md

33
golang/docker/Dockerfile

@ -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"]

17
golang/docker/docker-compose.yml

@ -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

39
golang/go.mod

@ -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
)

90
golang/go.sum

@ -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=

97
golang/internal/application/commands/add_item_command.go

@ -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
}

69
golang/internal/application/commands/delete_item_command.go

@ -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
}

67
golang/internal/application/commands/handle_expired_items_command.go

@ -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
}

35
golang/internal/application/commands/login_user_command.go

@ -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
}

34
golang/internal/application/dto/create_item_dto.go

@ -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
}

25
golang/internal/application/dto/item_response_dto.go

@ -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()},
}
}

31
golang/internal/application/dto/jsend_response.go

@ -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,
}
}

50
golang/internal/application/dto/json_time.go

@ -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)
}

33
golang/internal/application/dto/login_dto.go

@ -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"`
}

5
golang/internal/application/errors/errors.go

@ -1,5 +0,0 @@
package errors
import "errors"
var ErrNotImplemented = errors.New("not implemented")

11
golang/internal/application/interfaces/auth_service.go

@ -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)
}

17
golang/internal/application/interfaces/item_repository.go

@ -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)
}

12
golang/internal/application/interfaces/logger.go

@ -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{})
}

10
golang/internal/application/interfaces/order_service.go

@ -1,10 +0,0 @@
package interfaces
import (
"context"
"autostore/internal/domain/entities"
)
type IOrderService interface {
OrderItem(ctx context.Context, item *entities.ItemEntity) error
}

9
golang/internal/application/interfaces/time_provider.go

@ -1,9 +0,0 @@
package interfaces
import (
"time"
)
type ITimeProvider interface {
Now() time.Time
}

13
golang/internal/application/interfaces/user_repository.go

@ -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
}

63
golang/internal/application/queries/get_item_query.go

@ -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
}

50
golang/internal/application/queries/list_items_query.go

@ -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
}

67
golang/internal/config/config.go

@ -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
}

174
golang/internal/container/container.go

@ -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
}

11
golang/internal/domain/entities/errors.go

@ -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")
)

118
golang/internal/domain/entities/item.go

@ -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
}

69
golang/internal/domain/entities/user.go

@ -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
}

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

@ -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)
}
}
}

29
golang/internal/domain/specifications/item_expiration_spec.go

@ -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)
}

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

@ -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
}

45
golang/internal/domain/value_objects/base_uuid.go

@ -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
}

23
golang/internal/domain/value_objects/expiration_date.go

@ -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)
}

38
golang/internal/domain/value_objects/item_id.go

@ -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)
}

38
golang/internal/domain/value_objects/user_id.go

@ -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)
}

126
golang/internal/infrastructure/auth/jwt_auth_service.go

@ -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))
}

80
golang/internal/infrastructure/http/order_url_http_client.go

@ -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
}

41
golang/internal/infrastructure/logging/standard_logger.go

@ -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)
}
}

223
golang/internal/infrastructure/repositories/file_item_repository.go

@ -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
}

156
golang/internal/infrastructure/repositories/file_user_repository.go

@ -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)
}

104
golang/internal/infrastructure/scheduler/expired_items_scheduler.go

@ -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
}

65
golang/internal/infrastructure/services/user_initialization_service.go

@ -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
}

15
golang/internal/infrastructure/time/system_time_provider.go

@ -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()
}

53
golang/internal/presentation/controllers/auth_controller.go

@ -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))
}

157
golang/internal/presentation/controllers/items_controller.go

@ -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))
}

62
golang/internal/presentation/middleware/jwt_middleware.go

@ -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()
}
}

117
golang/internal/presentation/server/server.go

@ -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()
}
}

348
golang/tests/integration/file_item_repository_test.go

@ -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())
}

352
golang/tests/unit/add_item_command_test.go

@ -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")
}
}

293
golang/tests/unit/handle_expired_items_command_test.go

@ -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))
}
}

904
golang/tests/unit/specification_test.go

@ -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")
}
}

99
golang/tests/unit/test_utils.go

@ -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{}) {}

2
nestjs/.devcontainer/devcontainer.json

@ -22,5 +22,5 @@
},
"forwardPorts": [3000],
"remoteUser": "developer",
"postCreateCommand": "sudo chown -R developer:1000 /usr/src/app && npm install"
"postCreateCommand": "sudo chown -R developer:developer /usr/src/app && npm install"
}

1207
nestjs/PLAN-DDD.md

File diff suppressed because it is too large Load Diff

358
nestjs/PLAN.md

@ -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.

243
nestjs/REVIEW.md

@ -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
nestjs/docker/Dockerfile

@ -11,7 +11,6 @@ RUN npm install
COPY src ./src
RUN npm run build
RUN npm test
FROM node:24.0.1-alpine

1920
nestjs/package-lock.json generated

File diff suppressed because it is too large Load Diff

18
nestjs/package.json

@ -20,26 +20,14 @@
"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/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.17",
@ -69,7 +57,7 @@
"ts"
],
"rootDir": "src",
"testRegex": ".*__tests__.*\\.spec\\.ts$",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
@ -79,4 +67,4 @@
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
}

12
nestjs/src/app.controller.ts

@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

99
nestjs/src/app.module.ts

@ -1,99 +1,10 @@
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';
import { AppController } from './app.controller';
import { AppService } from './app.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,
},
}),
},
],
imports: [],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

8
nestjs/src/app.service.ts

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

178
nestjs/src/application/commands/__tests__/add-item.command.spec.ts

@ -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');
});
});
});
});

109
nestjs/src/application/commands/add-item.command.ts

@ -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');
}
}
}

47
nestjs/src/application/commands/delete-item.command.ts

@ -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}`);
}
}
}

53
nestjs/src/application/commands/handle-expired-items.command.ts

@ -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}`);
}
}
}

49
nestjs/src/application/commands/login-user.command.ts

@ -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');
}
}
}

24
nestjs/src/application/dto/create-item.dto.ts

@ -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;
}

17
nestjs/src/application/dto/login.dto.ts

@ -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;
}

5
nestjs/src/application/interfaces/auth-service.interface.ts

@ -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>;
}

13
nestjs/src/application/interfaces/item-repository.interface.ts

@ -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>;
}

6
nestjs/src/application/interfaces/logger.interface.ts

@ -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;
}

5
nestjs/src/application/interfaces/order-service.interface.ts

@ -1,5 +0,0 @@
import { ItemEntity } from '../../domain/entities/item.entity';
export interface IOrderService {
orderItem(item: ItemEntity): Promise<void>;
}

3
nestjs/src/application/interfaces/time-provider.interface.ts

@ -1,3 +0,0 @@
export interface ITimeProvider {
now(): Date;
}

10
nestjs/src/application/interfaces/user-repository.interface.ts

@ -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>;
}

50
nestjs/src/application/queries/get-item.query.ts

@ -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}`);
}
}
}

30
nestjs/src/application/queries/list-items.query.ts

@ -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}`);
}
}
}

10
nestjs/src/application/services/logger.service.ts

@ -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;
}

43
nestjs/src/common/utils/jsend-response.util.ts

@ -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,
};
}
}

75
nestjs/src/domain/entities/item.entity.ts

@ -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;
}
}

62
nestjs/src/domain/entities/user.entity.ts

@ -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;
}
}

159
nestjs/src/domain/specifications/__tests__/item-expiration.spec.spec.ts

@ -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);
});
});
});

580
nestjs/src/domain/specifications/__tests__/spec.helper.spec.ts

@ -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));
});
});
});

14
nestjs/src/domain/specifications/item-expiration.spec.ts

@ -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())
);
}
}

192
nestjs/src/domain/specifications/spec.helper.ts

@ -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);
}
}

75
nestjs/src/domain/specifications/specification.interface.ts

@ -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(),
};
}
}

147
nestjs/src/domain/value-objects/__tests__/base-uuid-value-object.ts

@ -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);
});
});
});
}
}

149
nestjs/src/domain/value-objects/__tests__/expiration-date.vo.spec.ts

@ -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);
});
});
});

37
nestjs/src/domain/value-objects/__tests__/item-id.vo.spec.ts

@ -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();

52
nestjs/src/domain/value-objects/__tests__/user-id.vo.spec.ts

@ -1,52 +0,0 @@
import { UserId } from '../user-id.vo';
import { BaseUuidValueObjectSpec } from './base-uuid-value-object';
class UserIdSpec extends BaseUuidValueObjectSpec<UserId> {
protected createValueObject(uuid: string): UserId {
return new UserId(uuid);
}
protected generateValueObject(): UserId {
return UserId.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 'User ID';
}
protected getStaticCreateMethod(): (uuid: string) => UserId {
return UserId.create;
}
}
// Run the tests
new UserIdSpec().runTests();
// Additional UserId-specific tests
describe('UserId', () => {
describe('comparison with ItemId', () => {
it('should not be equal to ItemId with same UUID value', () => {
const uuid = '550e8400-e29b-41d4-a716-446655440000';
const userId = new UserId(uuid);
const itemId = { getValue: () => uuid, equals: (other: any) => userId.getValue() === other.getValue() };
// This test demonstrates that UserId and ItemId are different types
// even if they contain the same UUID value
expect(userId.equals(itemId as any)).toBe(false);
});
});
});

55
nestjs/src/domain/value-objects/expiration-date.vo.ts

@ -1,55 +0,0 @@
export class ExpirationDate {
private readonly value: Date;
constructor(value: Date) {
this.validateDate(value);
this.value = new Date(value); // Create a copy to ensure immutability
}
static create(value: Date): ExpirationDate {
return new ExpirationDate(value);
}
static fromString(dateString: string): ExpirationDate {
const date = new Date(dateString);
if (isNaN(date.getTime())) {
throw new Error('Invalid date string format');
}
return new ExpirationDate(date);
}
private validateDate(date: Date): void {
if (!(date instanceof Date)) {
throw new Error('Expiration date must be a Date object');
}
if (isNaN(date.getTime())) {
throw new Error('Expiration date must be a valid date');
}
// Note: We don't validate against current time here because:
// 1. Business logic allows creating expired items (they trigger ordering)
// 2. Validation should happen at the application layer based on business rules
// 3. This allows for more flexibility in testing and edge cases
}
getValue(): Date {
return new Date(this.value); // Return a copy to maintain immutability
}
format(): string {
return this.value.toISOString();
}
toISOString(): string {
return this.value.toISOString();
}
toString(): string {
return this.format();
}
equals(other: ExpirationDate): boolean {
return this.value.getTime() === other.value.getTime();
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save