Compare commits

..

No commits in common. 'master' and 'cpp17-rebased' have entirely different histories.

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

5
.gitignore vendored

@ -21,6 +21,10 @@ tmp
*.dylib
*.dll
# Fortran module files
*.mod
*.smod
# Compiled Static libraries
*.lai
*.la
@ -236,4 +240,3 @@ gradle-app.setting
hs_err_pid*
replay_pid*
reference-*

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:8080/api/v1/`.

5
cpp17/app/defaults/users.json

@ -1,13 +1,12 @@
[
{
"username": "admin",
"password": "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918",
"password": "admin",
"id": "1000"
},
{
"username": "user",
"password": "04f8996da763b7a969b1028ee3007569eaf3a635486ddab211d512c85b9df8fb",
"password": "user",
"id": "1001"
}
]

2
cpp17/app/src/App.cpp

@ -26,7 +26,7 @@ App::App(int argc, char** argv)
AutoStore::Config{
.dataPath = os::getApplicationDirectory() + "/data",
.host = "0.0.0.0",
.port = 50080,
.port = 8080,
},
logger);

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

2
cpp17/docker/docker-compose.yml

@ -7,4 +7,4 @@ services:
image: autostore-build-cpp-vcpkg-img
container_name: autostore-build-cpp-vcpkg
ports:
- 50080:50080
- 8080:8080

1
cpp17/lib/CMakeLists.txt

@ -13,7 +13,6 @@ find_package(jwt-cpp CONFIG REQUIRED)
configure_file(src/Version.h.in ${CMAKE_BINARY_DIR}/autostore/Version.h)
add_library(${TARGET_NAME} STATIC
src/domain/helpers/Specification.cpp
src/application/queries/GetItem.cpp
src/application/queries/ListItems.cpp
src/application/commands/AddItem.cpp

2
cpp17/lib/include/autostore/AutoStore.h

@ -38,7 +38,7 @@ public:
{
std::string dataPath;
std::string host{"0.0.0.0"};
uint16_t port{50080};
uint16_t port{8080};
};
AutoStore(Config config, ILoggerPtr logger);

3
cpp17/lib/src/application/interfaces/IItemRepository.h

@ -1,7 +1,6 @@
#pragma once
#include "domain/entities/Item.h"
#include "domain/polices/ItemExpirationPolicy.h"
#include <optional>
#include <functional>
#include <string>
@ -19,8 +18,6 @@ public:
virtual std::vector<domain::Item> findByOwner(domain::User::Id_t ownerId) = 0;
virtual std::vector<domain::Item>
findWhere(std::function<bool(const domain::Item&)> predicate) = 0;
virtual std::vector<domain::Item>
findWhere(const domain::ItemExpirationSpec& spec) = 0;
virtual void remove(domain::Item::Id_t id) = 0;
};

3
cpp17/lib/src/domain/entities/User.h

@ -9,9 +9,8 @@ struct User
using Id_t = std::string;
inline static const Id_t NULL_ID{""};
Id_t id;
std::string username;
std::string passwordHash;
std::string password;
};
} // namespace nxl::autostore::domain

141
cpp17/lib/src/domain/helpers/Specification.cpp

@ -1,141 +0,0 @@
#include "Specification.h"
#include <stdexcept>
namespace nxl::helpers {
// Condition render implementation
std::string Condition::render(const Renderer& renderer) const
{
std::string opStr;
switch (op) {
case ComparisonOp::EQ:
opStr = renderer.opEq;
break;
case ComparisonOp::NE:
opStr = renderer.opNe;
break;
case ComparisonOp::LT:
opStr = renderer.opLt;
break;
case ComparisonOp::LE:
opStr = renderer.opLe;
break;
case ComparisonOp::GT:
opStr = renderer.opGt;
break;
case ComparisonOp::GE:
opStr = renderer.opGe;
break;
case ComparisonOp::LIKE:
opStr = renderer.opLike;
break;
case ComparisonOp::IS_NULL:
opStr = renderer.opIsNull;
break;
case ComparisonOp::IS_NOT_NULL:
opStr = renderer.opIsNotNull;
break;
}
std::string result = field + " " + opStr;
if (value) {
result += " " + renderer.formatValue(*value);
}
return result;
}
// ConditionGroup render implementation
std::string ConditionGroup::render(const Renderer& renderer) const
{
if (children.empty())
return "";
std::string opStr = (op == LogicalOp::AND) ? renderer.opAnd : renderer.opOr;
std::string result = renderer.groupStart;
bool first = true;
for (const auto& child : children) {
if (!first)
result += " " + opStr + " ";
result += child->render(renderer);
first = false;
}
result += renderer.groupEnd;
return result;
}
// SpecificationBuilder implementation
SpecificationBuilder::SpecificationBuilder()
{
root = std::make_unique<ConditionGroup>(LogicalOp::AND);
current = root.get();
}
SpecificationBuilder& SpecificationBuilder::field(const std::string& name)
{
lastField = name;
return *this;
}
SpecificationBuilder& SpecificationBuilder::like(std::string pattern)
{
addCondition(ComparisonOp::LIKE, std::move(pattern));
return *this;
}
SpecificationBuilder& SpecificationBuilder::isNull()
{
addCondition(ComparisonOp::IS_NULL);
return *this;
}
SpecificationBuilder& SpecificationBuilder::isNotNull()
{
addCondition(ComparisonOp::IS_NOT_NULL);
return *this;
}
SpecificationBuilder& SpecificationBuilder::andGroup()
{
startGroup(LogicalOp::AND);
return *this;
}
SpecificationBuilder& SpecificationBuilder::orGroup()
{
startGroup(LogicalOp::OR);
return *this;
}
SpecificationBuilder& SpecificationBuilder::endGroup()
{
if (stack.empty())
return *this;
current = stack.back();
stack.pop_back();
return *this;
}
std::unique_ptr<ISpecificationExpr> SpecificationBuilder::build()
{
return std::move(root);
}
void SpecificationBuilder::startGroup(LogicalOp opType)
{
auto newGroup = std::make_unique<ConditionGroup>(opType);
auto* newGroupPtr = newGroup.get();
current->add(std::move(newGroup));
stack.push_back(current);
current = newGroupPtr;
}
// Helper function
SpecificationBuilder makeSpecification()
{
return SpecificationBuilder();
}
} // namespace nxl::helpers

198
cpp17/lib/src/domain/helpers/Specification.h

@ -1,198 +0,0 @@
#pragma once
#include <string>
#include <vector>
#include <memory>
#include <variant>
#include <optional>
#include <functional>
#include <any>
#include <stdexcept>
namespace nxl::helpers {
class ISpecificationExpr;
class Condition;
class ConditionGroup;
enum class ComparisonOp { EQ, NE, LT, LE, GT, GE, LIKE, IS_NULL, IS_NOT_NULL };
enum class LogicalOp { AND, OR };
class ISpecificationExpr
{
public:
virtual ~ISpecificationExpr() = default;
virtual std::string render(const struct Renderer& renderer) const = 0;
};
class ConditionValue
{
public:
template <typename T> ConditionValue(T value) : value_(std::move(value)) {}
const std::any& get() const { return value_; }
template <typename T> const T& as() const
{
return std::any_cast<const T&>(value_);
}
template <typename T> bool is() const { return value_.type() == typeid(T); }
private:
std::any value_;
};
class Condition : public ISpecificationExpr
{
std::string field;
ComparisonOp op;
std::optional<ConditionValue> value; // Optional for operators like IS_NULL
public:
Condition(std::string f, ComparisonOp o, std::optional<ConditionValue> v = {})
: field(std::move(f)), op(o), value(std::move(v))
{}
const std::string& getField() const { return field; }
ComparisonOp getOp() const { return op; }
const std::optional<ConditionValue>& getValue() const { return value; }
std::string render(const struct Renderer& renderer) const override;
};
// Logical group of conditions
class ConditionGroup : public ISpecificationExpr
{
LogicalOp op;
std::vector<std::unique_ptr<ISpecificationExpr>> children;
public:
ConditionGroup(LogicalOp o) : op(o) {}
void add(std::unique_ptr<ISpecificationExpr> expr)
{
children.push_back(std::move(expr));
}
LogicalOp getOp() const { return op; }
const std::vector<std::unique_ptr<ISpecificationExpr>>& getChildren() const
{
return children;
}
std::string render(const struct Renderer& renderer) const override;
};
// Renderer interface - defines how to render operators and grouping
struct Renderer
{
std::string opEq;
std::string opNe;
std::string opLt;
std::string opLe;
std::string opGt;
std::string opGe;
std::string opLike;
std::string opIsNull;
std::string opIsNotNull;
std::string opAnd;
std::string opOr;
std::string groupStart;
std::string groupEnd;
// Value formatting function
std::function<std::string(const ConditionValue&)> formatValue;
};
// Fluent builder for specifications
class SpecificationBuilder
{
std::unique_ptr<ConditionGroup> root;
ConditionGroup* current;
std::vector<ConditionGroup*> stack;
std::string lastField;
public:
SpecificationBuilder();
// Set the field for the next condition
SpecificationBuilder& field(const std::string& name);
template <typename T> SpecificationBuilder& equals(T value)
{
addCondition(ComparisonOp::EQ, std::move(value));
return *this;
}
template <typename T> SpecificationBuilder& notEquals(T value)
{
addCondition(ComparisonOp::NE, std::move(value));
return *this;
}
template <typename T> SpecificationBuilder& lessThan(T value)
{
addCondition(ComparisonOp::LT, std::move(value));
return *this;
}
template <typename T> SpecificationBuilder& lessOrEqual(T value)
{
addCondition(ComparisonOp::LE, std::move(value));
return *this;
}
template <typename T> SpecificationBuilder& greaterThan(T value)
{
addCondition(ComparisonOp::GT, std::move(value));
return *this;
}
template <typename T> SpecificationBuilder& greaterOrEqual(T value)
{
addCondition(ComparisonOp::GE, std::move(value));
return *this;
}
SpecificationBuilder& like(std::string pattern);
SpecificationBuilder& isNull();
SpecificationBuilder& isNotNull();
SpecificationBuilder& andGroup();
SpecificationBuilder& orGroup();
SpecificationBuilder& endGroup();
std::unique_ptr<ISpecificationExpr> build();
private:
template <typename T> void addCondition(ComparisonOp op, T value)
{
if (lastField.empty()) {
throw std::runtime_error("No field specified for condition");
}
auto condition = std::make_unique<Condition>(
lastField, op, ConditionValue(std::move(value)));
current->add(std::move(condition));
lastField.clear();
}
void addCondition(ComparisonOp op)
{
if (lastField.empty()) {
throw std::runtime_error("No field specified for condition");
}
auto condition = std::make_unique<Condition>(lastField, op, std::nullopt);
current->add(std::move(condition));
lastField.clear();
}
void startGroup(LogicalOp opType);
};
// Helper function to create a specification builder
SpecificationBuilder makeSpecification();
} // namespace nxl::helpers

17
cpp17/lib/src/domain/polices/ItemExpirationPolicy.h

@ -1,31 +1,18 @@
#pragma once
#include "domain/entities/Item.h"
#include "domain/helpers/Specification.h"
#include <chrono>
namespace nxl::autostore::domain {
using ItemExpirationSpec = std::unique_ptr<nxl::helpers::ISpecificationExpr>;
class ItemExpirationPolicy
{
public:
using TimePoint = std::chrono::system_clock::time_point;
constexpr static const char* FIELD_EXP_DATE{"expiration_date"};
bool isExpired(const Item& item, const TimePoint& currentTime) const
bool isExpired(const Item& item,
const std::chrono::system_clock::time_point& currentTime) const
{
return item.expirationDate <= currentTime;
}
ItemExpirationSpec getExpiredSpecification(const TimePoint& currentTime) const
{
return nxl::helpers::SpecificationBuilder()
.field(FIELD_EXP_DATE)
.lessOrEqual(currentTime)
.build();
}
};
} // namespace nxl::autostore::domain

6
cpp17/lib/src/infrastructure/auth/FileJwtAuthService.cpp

@ -2,7 +2,6 @@
#include <jwt-cpp/jwt.h>
#include <fstream>
#include <nlohmann/json.hpp>
#include <picosha2.h>
namespace nxl::autostore::infrastructure {
@ -69,9 +68,6 @@ FileJwtAuthService::authenticateUser(std::string_view username,
std::string_view password)
{
try {
std::string passwordHash;
picosha2::hash256_hex_string(password, passwordHash);
std::ifstream file(dbPath);
if (!file.is_open()) {
return std::nullopt;
@ -82,7 +78,7 @@ FileJwtAuthService::authenticateUser(std::string_view username,
for (const auto& userJson : usersJson) {
if (userJson["username"] == username
&& userJson["password"] == passwordHash) {
&& userJson["password"] == password) {
return userJson["id"].get<std::string>();
}
}

2
cpp17/lib/src/infrastructure/http/HttpServer.h

@ -15,7 +15,7 @@ public:
HttpServer(ILoggerPtr logger, application::IAuthService& authService);
~HttpServer();
bool start(int port = 50080, std::string_view host = "0.0.0.0");
bool start(int port = 8080, std::string_view host = "0.0.0.0");
void stop();
bool isRunning() const;

11
cpp17/lib/src/infrastructure/repositories/FileItemRepository.cpp

@ -5,7 +5,6 @@
#include <chrono>
#include <ctime>
#include <iterator>
#include "FileItemRepository.h"
namespace nxl::autostore::infrastructure {
@ -92,14 +91,6 @@ std::vector<domain::Item> FileItemRepository::findWhere(
return matchedItems;
}
std::vector<domain::Item>
nxl::autostore::infrastructure::FileItemRepository::findWhere(
const domain::ItemExpirationSpec& spec)
{
// Not implemented since no SQL-like query language is used
throw std::runtime_error("Not implemented");
}
void FileItemRepository::remove(domain::Item::Id_t id)
{
std::lock_guard<std::mutex> lock(mtx);
@ -129,4 +120,4 @@ void FileItemRepository::persist()
}
}
} // namespace nxl::autostore::infrastructure
} // namespace nxl::autostore::infrastructure

2
cpp17/lib/src/infrastructure/repositories/FileItemRepository.h

@ -16,8 +16,6 @@ public:
std::vector<domain::Item> findByOwner(domain::User::Id_t userId) override;
std::vector<domain::Item>
findWhere(std::function<bool(const domain::Item&)> predicate) override;
virtual std::vector<domain::Item>
findWhere(const domain::ItemExpirationSpec& spec) override;
void remove(domain::Item::Id_t id) override;
private:

3
cpp17/tests/CMakeLists.txt

@ -34,5 +34,4 @@ endfunction()
add_test_target(FileItemRepositoryTest integration/FileItemRepository.test.cpp)
# add_integration_test(FileUserRepositoryTest integration/FileUserRepository.test.cpp)
add_test_target(AddItemTest unit/AddItem.test.cpp)
add_test_target(SpecificationTest unit/Specification.test.cpp)
add_test_target(TaskSchedulerTest unit/TaskScheduler.test.cpp)
add_test_target(TaskSchedulerTest unit/TaskScheduler.test.cpp)

2
cpp17/tests/mocks/MockItemRepository.h

@ -7,7 +7,6 @@ namespace test {
using nxl::autostore::domain::Item;
using nxl::autostore::domain::User;
using nxl::autostore::domain::ItemExpirationSpec;
class MockItemRepository : public nxl::autostore::application::IItemRepository
{
@ -17,7 +16,6 @@ public:
MAKE_MOCK1(findByOwner, std::vector<Item>(User::Id_t), override);
MAKE_MOCK1(findWhere, std::vector<Item>(std::function<bool(const Item&)>),
override);
MAKE_MOCK1(findWhere, std::vector<Item>(const ItemExpirationSpec&), override);
MAKE_MOCK1(remove, void(Item::Id_t), override);
};

692
cpp17/tests/unit/Specification.test.cpp

@ -1,692 +0,0 @@
#include "domain/helpers/Specification.h"
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include <trompeloeil.hpp>
#include <memory>
#include <stdexcept>
#include <chrono>
using trompeloeil::_;
using namespace nxl::helpers;
TEST_CASE("Specification Unit Tests", "[unit][Specification]")
{
const Renderer TEST_RENDERER{
.opEq = "=",
.opNe = "!=",
.opLt = "<",
.opLe = "<=",
.opGt = ">",
.opGe = ">=",
.opLike = "LIKE",
.opIsNull = "IS NULL",
.opIsNotNull = "IS NOT NULL",
.opAnd = "AND",
.opOr = "OR",
.groupStart = "(",
.groupEnd = ")",
.formatValue = [](const ConditionValue& v) -> std::string {
if (v.is<std::string>()) {
return "'" + v.as<std::string>() + "'";
} else if (v.is<const char*>()) {
return "'" + std::string(v.as<const char*>()) + "'";
} else if (v.is<bool>()) {
return v.as<bool>() ? "TRUE" : "FALSE";
} else if (v.is<int>()) {
return std::to_string(v.as<int>());
} else if (v.is<double>()) {
return std::to_string(v.as<double>());
} else if (v.is<std::chrono::system_clock::time_point>()) {
auto time = v.as<std::chrono::system_clock::time_point>();
auto time_t = std::chrono::system_clock::to_time_t(time);
std::string time_str = std::ctime(&time_t);
if (!time_str.empty() && time_str.back() == '\n') {
time_str.pop_back();
}
return "'" + time_str + "'";
}
return "'unknown'";
}};
SECTION("when single equals string condition is added then proper operator "
"is rendered")
{
// Given
auto spec = makeSpecification().field("F").equals("foo").build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result == "(F = 'foo')");
}
SECTION(
"when condition with chrono time_point is used then it renders correctly")
{
// Given
auto now = std::chrono::system_clock::now();
auto spec = makeSpecification().field("timestamp").greaterThan(now).build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result.find("timestamp >") != std::string::npos);
REQUIRE(result.find("'") != std::string::npos);
}
SECTION("when single not equals string condition is added then proper "
"operator is rendered")
{
// Given
auto spec = makeSpecification().field("F").notEquals("foo").build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result == "(F != 'foo')");
}
SECTION(
"when single less than condition is added then proper operator is rendered")
{
// Given
auto spec = makeSpecification().field("F").lessThan(42).build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result == "(F < 42)");
}
SECTION("when single less than or equal condition is added then proper "
"operator is rendered")
{
// Given
auto spec = makeSpecification().field("F").lessOrEqual(42).build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result == "(F <= 42)");
}
SECTION("when single greater than condition is added then proper operator is "
"rendered")
{
// Given
auto spec = makeSpecification().field("F").greaterThan(42).build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result == "(F > 42)");
}
SECTION("when single greater than or equal condition is added then proper "
"operator is rendered")
{
// Given
auto spec = makeSpecification().field("F").greaterOrEqual(42).build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result == "(F >= 42)");
}
SECTION(
"when single like condition is added then proper operator is rendered")
{
// Given
auto spec = makeSpecification().field("F").like("%foo%").build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result == "(F LIKE '%foo%')");
}
SECTION(
"when single is null condition is added then proper operator is rendered")
{
// Given
auto spec = makeSpecification().field("F").isNull().build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result == "(F IS NULL)");
}
SECTION("when single is not null condition is added then proper operator is "
"rendered")
{
// Given
auto spec = makeSpecification().field("F").isNotNull().build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result == "(F IS NOT NULL)");
}
SECTION("when condition with double value is used then it renders correctly")
{
// Given
auto spec = makeSpecification().field("price").equals(19.99).build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result == "(price = 19.990000)");
}
SECTION("when condition with boolean value is used then it renders correctly")
{
// Given
auto spec = makeSpecification().field("active").equals(true).build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result == "(active = TRUE)");
}
SECTION(
"when condition with const char* value is used then it renders correctly")
{
// Given
auto spec = makeSpecification().field("name").equals("test").build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result == "(name = 'test')");
}
SECTION("when multiple AND conditions are added then they are rendered with "
"AND operator")
{
// Given
auto spec = makeSpecification()
.field("name")
.equals("John")
.field("age")
.greaterThan(30)
.field("active")
.equals(true)
.build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result == "(name = 'John' AND age > 30 AND active = TRUE)");
}
SECTION("when AND group is created then conditions are grouped properly")
{
// Given
auto spec = makeSpecification()
.field("name")
.equals("John")
.andGroup()
.field("age")
.greaterThan(30)
.field("active")
.equals(true)
.endGroup()
.build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result == "(name = 'John' AND (age > 30 AND active = TRUE))");
}
SECTION(
"when OR group is created then conditions are grouped with OR operator")
{
// Given
auto spec = makeSpecification()
.orGroup()
.field("name")
.equals("John")
.field("age")
.greaterThan(30)
.field("active")
.equals(true)
.endGroup()
.build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result == "((name = 'John' OR age > 30 OR active = TRUE))");
}
SECTION("when nested groups are created then they are rendered properly")
{
// Given
auto spec = makeSpecification()
.field("category")
.equals("electronics")
.andGroup()
.field("price")
.lessThan(1000)
.orGroup()
.field("brand")
.equals("Sony")
.field("brand")
.equals("Samsung")
.endGroup()
.endGroup()
.build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(
result
== "(category = 'electronics' AND (price < 1000 AND (brand = 'Sony' "
"OR brand = 'Samsung')))");
}
SECTION("when complex specification with multiple nested groups is created "
"then it renders correctly")
{
// Given
auto spec = makeSpecification()
.orGroup()
.andGroup()
.field("type")
.equals("food")
.field("expiration_date")
.lessOrEqual("2023-01-01")
.endGroup()
.andGroup()
.field("type")
.equals("non-food")
.field("expiration_date")
.lessOrEqual("2023-03-01")
.endGroup()
.endGroup()
.build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result
== "(((type = 'food' AND expiration_date <= '2023-01-01') OR (type "
"= 'non-food' AND expiration_date <= '2023-03-01')))");
}
SECTION("when empty group is created then it renders as empty")
{
// Given
auto spec = makeSpecification()
.field("name")
.equals("John")
.andGroup()
.endGroup()
.build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result == "(name = 'John' AND )");
}
SECTION("when endGroup is called without matching startGroup then it should "
"handle gracefully")
{
// Given
auto spec =
makeSpecification().field("name").equals("John").endGroup().build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result == "(name = 'John')");
}
SECTION("when condition is added without field then exception is thrown")
{
// Given
auto builder = makeSpecification();
// When/Then
REQUIRE_THROWS_AS(builder.equals("value"), std::runtime_error);
REQUIRE_THROWS_WITH(builder.equals("value"),
"No field specified for condition");
}
SECTION("when field is called multiple times then last field is used")
{
// Given
auto spec = makeSpecification()
.field("field1")
.field("field2")
.equals("value")
.build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result == "(field2 = 'value')");
}
SECTION("when multiple conditions with same field are added then they are "
"rendered correctly")
{
// Given
auto spec = makeSpecification()
.field("age")
.greaterThan(18)
.field("age")
.lessThan(65)
.build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result == "(age > 18 AND age < 65)");
}
SECTION("when specification has only conditions with no explicit grouping "
"then AND is used")
{
// Given
auto spec = makeSpecification()
.field("field1")
.equals("value1")
.field("field2")
.equals("value2")
.field("field3")
.equals("value3")
.build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(
result
== "(field1 = 'value1' AND field2 = 'value2' AND field3 = 'value3')");
}
SECTION("when mixed data types are used then they render correctly")
{
// Given
auto now = std::chrono::system_clock::now();
auto spec = makeSpecification()
.field("name")
.equals("John")
.field("age")
.greaterThan(30)
.field("salary")
.greaterOrEqual(50000.50)
.field("active")
.equals(true)
.field("created_at")
.lessThan(now)
.build();
// When
auto result = spec->render(TEST_RENDERER);
// Then
REQUIRE(result.find("(name = 'John' AND age > 30 AND salary >= "
"50000.500000 AND active = TRUE AND created_at < '")
!= std::string::npos);
REQUIRE(result.find("'") != std::string::npos);
}
SECTION("when ConditionValue is created with string then it stores and "
"retrieves correctly")
{
// Given
ConditionValue value(std::string("test"));
// Then
REQUIRE(value.is<std::string>());
REQUIRE_FALSE(value.is<int>());
REQUIRE(value.as<std::string>() == "test");
}
SECTION("when ConditionValue is created with int then it stores and "
"retrieves correctly")
{
// Given
ConditionValue value(42);
// Then
REQUIRE(value.is<int>());
REQUIRE_FALSE(value.is<std::string>());
REQUIRE(value.as<int>() == 42);
}
SECTION("when ConditionValue is created with double then it stores and "
"retrieves correctly")
{
// Given
ConditionValue value(3.14);
// Then
REQUIRE(value.is<double>());
REQUIRE_FALSE(value.is<int>());
REQUIRE(value.as<double>() == 3.14);
}
SECTION("when ConditionValue is created with bool then it stores and "
"retrieves correctly")
{
// Given
ConditionValue value(true);
// Then
REQUIRE(value.is<bool>());
REQUIRE_FALSE(value.is<int>());
REQUIRE(value.as<bool>() == true);
}
SECTION("when ConditionValue is created with time_point then it stores and "
"retrieves correctly")
{
// Given
auto now = std::chrono::system_clock::now();
ConditionValue value(now);
// Then
REQUIRE(value.is<std::chrono::system_clock::time_point>());
REQUIRE_FALSE(value.is<int>());
REQUIRE(value.as<std::chrono::system_clock::time_point>() == now);
}
SECTION(
"when renderer has custom formatValue function then it is used correctly")
{
// Given
Renderer customRenderer{
.opEq = "=",
.opNe = "!=",
.opLt = "<",
.opLe = "<=",
.opGt = ">",
.opGe = ">=",
.opLike = "LIKE",
.opIsNull = "IS NULL",
.opIsNotNull = "IS NOT NULL",
.opAnd = "AND",
.opOr = "OR",
.groupStart = "[",
.groupEnd = "]",
.formatValue = [](const ConditionValue& v) -> std::string {
if (v.is<std::string>()) {
return "\"" + v.as<std::string>() + "\"";
} else if (v.is<const char*>()) {
return "\"" + std::string(v.as<const char*>()) + "\"";
} else if (v.is<int>()) {
return "INT:" + std::to_string(v.as<int>());
}
return "CUSTOM";
}};
auto spec = makeSpecification()
.field("name")
.equals(std::string("John"))
.field("age")
.greaterThan(30)
.build();
// When
auto result = spec->render(customRenderer);
// Then
REQUIRE(result == "[name = \"John\" AND age > INT:30]");
}
SECTION(
"when renderer has custom grouping symbols then they are used correctly")
{
// Given
Renderer customRenderer{
.opEq = "=",
.opNe = "!=",
.opLt = "<",
.opLe = "<=",
.opGt = ">",
.opGe = ">=",
.opLike = "LIKE",
.opIsNull = "IS NULL",
.opIsNotNull = "IS NOT NULL",
.opAnd = "AND",
.opOr = "OR",
.groupStart = "{",
.groupEnd = "}",
.formatValue = [](const ConditionValue& v) -> std::string {
if (v.is<std::string>()) {
return "'" + v.as<std::string>() + "'";
} else if (v.is<const char*>()) {
return "'" + std::string(v.as<const char*>()) + "'";
} else if (v.is<int>()) {
return std::to_string(v.as<int>());
}
return "'unknown'";
}};
auto spec = makeSpecification()
.field("name")
.equals(std::string("John"))
.andGroup()
.field("age")
.greaterThan(30)
.endGroup()
.build();
// When
auto result = spec->render(customRenderer);
// Then
REQUIRE(result == "{name = 'John' AND {age > 30}}");
}
SECTION(
"when renderer has custom logical operators then they are used correctly")
{
// Given
Renderer customRenderer{
.opEq = "=",
.opNe = "!=",
.opLt = "<",
.opLe = "<=",
.opGt = ">",
.opGe = ">=",
.opLike = "LIKE",
.opIsNull = "IS NULL",
.opIsNotNull = "IS NOT NULL",
.opAnd = "&&",
.opOr = "||",
.groupStart = "(",
.groupEnd = ")",
.formatValue = [](const ConditionValue& v) -> std::string {
if (v.is<std::string>()) {
return "'" + v.as<std::string>() + "'";
} else if (v.is<const char*>()) {
return "'" + std::string(v.as<const char*>()) + "'";
} else if (v.is<int>()) {
return std::to_string(v.as<int>());
} else if (v.is<bool>()) {
return v.as<bool>() ? "TRUE" : "FALSE";
}
return "'unknown'";
}};
auto spec = makeSpecification()
.orGroup()
.field("name")
.equals(std::string("John"))
.orGroup()
.field("age")
.greaterThan(30)
.field("active")
.equals(true)
.endGroup()
.endGroup()
.build();
// When
auto result = spec->render(customRenderer);
// Then
REQUIRE(result == "((name = 'John' || (age > 30 || active = TRUE)))");
}
SECTION(
"when ConditionGroup has no children then render returns empty string")
{
// Given
ConditionGroup group(LogicalOp::AND);
// When
auto result = group.render(TEST_RENDERER);
// Then
REQUIRE(result == "");
}
SECTION("when makeSpecification helper is used then it creates valid builder")
{
// Given
auto builder = makeSpecification();
// When
auto spec = builder.field("test").equals("value").build();
// Then
REQUIRE(spec != nullptr);
auto result = spec->render(TEST_RENDERER);
REQUIRE(result == "(test = 'value')");
}
}

1
cpp17/vcpkg.json

@ -6,7 +6,6 @@
"nlohmann-json",
"jwt-cpp",
"spdlog",
"picosha2",
"catch2",
"trompeloeil"
],

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

39
nestjs/.devcontainer/Dockerfile

@ -1,39 +0,0 @@
FROM node:24.0.1-alpine
WORKDIR /usr/src/app
# Install system dependencies
RUN apk add --no-cache \
git \
bash \
curl \
sudo
# Give sudo permissions to the developer user
RUN echo '%developer ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers.d/developer && \
chmod 0440 /etc/sudoers.d/developer
# Configure user permissions
ARG USER_ID=1000
ARG GROUP_ID=1000
# Create a user with matching UID/GID
RUN if ! getent group $GROUP_ID > /dev/null 2>&1; then \
addgroup -g $GROUP_ID developer; \
else \
addgroup developer; \
fi && \
if ! getent passwd $USER_ID > /dev/null 2>&1; then \
adduser -D -u $USER_ID -G developer -s /bin/sh developer; \
else \
adduser -D -G developer -s /bin/sh developer; \
fi
RUN chown -R $USER_ID:$GROUP_ID /usr/src/app
USER $USER_ID:$GROUP_ID
# Expose port 3000 for NestJS
EXPOSE 3000
CMD ["npm", "run", "start:dev"]

26
nestjs/.devcontainer/devcontainer.json

@ -1,26 +0,0 @@
{
"name": "NestJS dev container",
"dockerComposeFile": "./docker-compose.yml",
"service": "app",
"workspaceFolder": "/usr/src/app",
"customizations": {
"vscode": {
"settings": {
"terminal.integrated.defaultProfile.linux": "bash",
"node.js.version": "24.0.1"
},
"extensions": [
"ms-vscode.vscode-typescript-next",
"ms-nodejs.vscode-node-debug2",
"ms-vscode.vscode-json",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"christian-kohler.npm-intellisense",
"christian-kohler.path-intellisense"
]
}
},
"forwardPorts": [3000],
"remoteUser": "developer",
"postCreateCommand": "sudo chown -R developer:1000 /usr/src/app && npm install"
}

29
nestjs/.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-nestjs-img
container_name: dev-nestjs
user: "developer"
volumes:
- ../:/usr/src/app:cached
- node_modules:/usr/src/app/node_modules
environment:
NODE_ENV: development
ports:
- "50080:3000"
networks:
- dev-network
command: sleep infinity
volumes:
node_modules:
networks:
dev-network:
driver: bridge

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.

29
nestjs/docker/Dockerfile

@ -1,29 +0,0 @@
FROM node:24.0.1-alpine as builder
WORKDIR /app
COPY package*.json ./
COPY nest-cli.json ./
COPY tsconfig.json ./
RUN npm install
COPY src ./src
RUN npm run build
RUN npm test
FROM node:24.0.1-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --only=production
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD [ "node", "dist/main" ]

17
nestjs/docker/docker-compose.yml

@ -1,17 +0,0 @@
version: "3.9"
services:
app:
build:
context: ..
dockerfile: docker/Dockerfile
image: nestjs-app-img
container_name: nestjs-app
ports:
- "50080:3000"
networks:
- app-network
restart: unless-stopped
networks:
app-network:
driver: bridge

8
nestjs/nest-cli.json

@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

10526
nestjs/package-lock.json generated

File diff suppressed because it is too large Load Diff

82
nestjs/package.json

@ -1,82 +0,0 @@
{
"name": "autostore-nestjs",
"version": "0.0.1",
"description": "AutoStore implementation with NestJS",
"author": "",
"license": "MIT",
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/schedule": "^6.0.0",
"@types/bcrypt": "^6.0.0",
"axios": "^1.12.1",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^11.0.10",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"rimraf": "^5.0.1",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*__tests__.*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

99
nestjs/src/app.module.ts

@ -1,99 +0,0 @@
import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { HttpModule } from '@nestjs/axios';
import { ScheduleModule } from '@nestjs/schedule';
import { AuthController } from './presentation/controllers/auth.controller';
import { ItemsController } from './presentation/controllers/items.controller';
import { LoginUserCommand } from './application/commands/login-user.command';
import { AddItemCommand } from './application/commands/add-item.command';
import { DeleteItemCommand } from './application/commands/delete-item.command';
import { HandleExpiredItemsCommand } from './application/commands/handle-expired-items.command';
import { GetItemQuery } from './application/queries/get-item.query';
import { ListItemsQuery } from './application/queries/list-items.query';
import { JwtAuthService } from './infrastructure/auth/jwt-auth.service';
import { FileUserRepository } from './infrastructure/repositories/file-user-repository';
import { FileItemRepository } from './infrastructure/repositories/file-item-repository';
import { OrderHttpService } from './infrastructure/http/order-http.service';
import { SystemTimeProvider } from './infrastructure/services/system-time.provider';
import { UserInitializationService } from './infrastructure/services/user-initialization.service';
import { ItemExpirationSpec } from './domain/specifications/item-expiration.spec';
import { ExpiredItemsSchedulerService } from './infrastructure/services/expired-items-scheduler.service';
import { LoggerService } from './application/services/logger.service';
import { NestLoggerService } from './infrastructure/logging/nest-logger.service';
import { NullLoggerService } from './infrastructure/logging/null-logger.service';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
HttpModule,
ScheduleModule.forRoot(),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET') || 'default-secret-key',
signOptions: {
expiresIn: configService.get<string>('JWT_EXPIRATION') || '1h',
},
}),
inject: [ConfigService],
}),
],
controllers: [AuthController, ItemsController],
providers: [
LoginUserCommand,
AddItemCommand,
DeleteItemCommand,
HandleExpiredItemsCommand,
GetItemQuery,
ListItemsQuery,
JwtAuthService,
OrderHttpService,
SystemTimeProvider,
ItemExpirationSpec,
UserInitializationService,
ExpiredItemsSchedulerService,
{
provide: 'IAuthService',
useExisting: JwtAuthService,
},
{
provide: 'IUserRepository',
useFactory: (logger: LoggerService) => new FileUserRepository('./data', logger),
inject: [LoggerService],
},
{
provide: 'IItemRepository',
useFactory: (logger: LoggerService) => new FileItemRepository('./data', logger),
inject: [LoggerService],
},
{
provide: 'IOrderService',
useExisting: OrderHttpService,
},
{
provide: 'ITimeProvider',
useExisting: SystemTimeProvider,
},
{
provide: LoggerService,
useClass: process.env.NODE_ENV === 'test' ? NullLoggerService : NestLoggerService,
},
{
provide: APP_PIPE,
useValue: new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
},
],
})
export class AppModule {}

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

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

Loading…
Cancel
Save