Compare commits
No commits in common. 'master' and 'cpp17-rebased' have entirely different histories.
master
...
cpp17-reba
209 changed files with 89 additions and 31889 deletions
@ -1,28 +1,14 @@
|
||||
FROM kuyoh/vcpkg:2025.06.13-ubuntu24.04 AS builder |
||||
FROM kuyoh/vcpkg:2025.06.13-ubuntu24.04 AS base |
||||
|
||||
WORKDIR /workspace |
||||
|
||||
COPY ../CMakeLists.txt . |
||||
COPY ../vcpkg.json . |
||||
|
||||
RUN vcpkg install |
||||
|
||||
# Cche stays valid if only code changes |
||||
COPY .. . |
||||
|
||||
# generate and build |
||||
RUN cmake -DCMAKE_TOOLCHAIN_FILE:STRING=${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake \ |
||||
-DCMAKE_EXPORT_COMPILE_COMMANDS:BOOL=TRUE -DCMAKE_BUILD_TYPE:STRING=Release \ |
||||
-H/workspace -B/workspace/build -G Ninja |
||||
RUN cmake --build /workspace/build --config Release --target all -j 8 -- |
||||
|
||||
# run tests |
||||
RUN cd /workspace/build && ctest --output-on-failure . |
||||
|
||||
FROM ubuntu:24.04 AS runtime |
||||
|
||||
WORKDIR /app |
||||
|
||||
COPY --from=builder /workspace/build/bin/AutoStore ./AutoStore |
||||
COPY --from=builder /workspace/build/bin/data ./data |
||||
|
||||
CMD ["./AutoStore"] |
||||
CMD ["/workspace/build/bin/AutoStore"] |
||||
|
||||
@ -1,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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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,31 +0,0 @@
|
||||
FROM golang:1.25.1-alpine3.22 |
||||
|
||||
WORKDIR /usr/src/app |
||||
|
||||
# Install system dependencies |
||||
RUN apk add --no-cache \ |
||||
git \ |
||||
bash \ |
||||
curl \ |
||||
sudo |
||||
|
||||
# Configure user and group IDs (default: 1000:1000) |
||||
ARG USER_ID=1000 |
||||
ARG GROUP_ID=1000 |
||||
|
||||
# Create a group and user with specific UID/GID |
||||
RUN addgroup -g ${GROUP_ID} developer \ |
||||
&& adduser -D -u ${USER_ID} -G developer -s /bin/bash developer \ |
||||
&& echo "developer ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/developer \ |
||||
&& chmod 0440 /etc/sudoers.d/developer |
||||
|
||||
RUN chown -R ${USER_ID}:${GROUP_ID} /usr/src/app |
||||
|
||||
USER developer |
||||
|
||||
# Install Go tools |
||||
RUN go install github.com/go-delve/delve/cmd/dlv@latest |
||||
|
||||
EXPOSE 3000 |
||||
|
||||
CMD ["go", "run", "main.go"] |
||||
@ -1,25 +0,0 @@
|
||||
{ |
||||
"name": "Go dev container", |
||||
"dockerComposeFile": "./docker-compose.yml", |
||||
"service": "app", |
||||
"workspaceFolder": "/usr/src/app", |
||||
"customizations": { |
||||
"vscode": { |
||||
"settings": { |
||||
"terminal.integrated.defaultProfile.linux": "bash", |
||||
"go.useLanguageServer": true, |
||||
"go.gopath": "/go", |
||||
"go.goroot": "/usr/local/go" |
||||
}, |
||||
"extensions": [ |
||||
"golang.go", |
||||
"ms-vscode.go-tools", |
||||
"ms-vscode.vscode-go", |
||||
"ms-vscode.vscode-docker" |
||||
] |
||||
} |
||||
}, |
||||
"forwardPorts": [3000], |
||||
"remoteUser": "developer", |
||||
"postCreateCommand": "sudo chown -R developer:1000 /usr/src/app && go mod tidy" |
||||
} |
||||
@ -1,29 +0,0 @@
|
||||
version: "3.9" |
||||
services: |
||||
app: |
||||
build: |
||||
context: .. |
||||
dockerfile: .devcontainer/Dockerfile |
||||
args: |
||||
USER_ID: ${USER_ID:-1000} |
||||
GROUP_ID: ${GROUP_ID:-1000} |
||||
image: dev-golang-img |
||||
container_name: dev-golang |
||||
user: "developer" |
||||
volumes: |
||||
- ../:/usr/src/app:cached |
||||
- golang_modules:/go/pkg/mod |
||||
environment: |
||||
NODE_ENV: development |
||||
ports: |
||||
- "50080:3000" |
||||
networks: |
||||
- dev-network |
||||
command: sleep infinity |
||||
|
||||
volumes: |
||||
golang_modules: |
||||
|
||||
networks: |
||||
dev-network: |
||||
driver: bridge |
||||
@ -1,183 +0,0 @@
|
||||
# Specification Pattern Implementation |
||||
|
||||
The Specification pattern allows you to encapsulate business logic for filtering and querying data. Instead of writing SQL queries directly, you build specifications using Go code that can later be converted to SQL, used for in-memory filtering, or other purposes. |
||||
|
||||
## Core Components |
||||
|
||||
### 1. Basic Data Structures (`condition_spec.go`) |
||||
|
||||
#### `Condition` |
||||
Represents a single comparison operation: |
||||
```go |
||||
type Condition struct { |
||||
Field string // Field name (e.g., "age", "name") |
||||
Operator string // Comparison operator (e.g., "=", ">", "IN") |
||||
Value interface{} // Value to compare against |
||||
} |
||||
``` |
||||
|
||||
#### `LogicalGroup` |
||||
Combines multiple conditions with logical operators: |
||||
```go |
||||
type LogicalGroup struct { |
||||
Operator string // "AND", "OR", or "NOT" |
||||
Conditions []Condition // Simple conditions |
||||
Spec *Spec // Nested specification for complex logic |
||||
} |
||||
``` |
||||
|
||||
#### `Spec` |
||||
The main specification container (can hold either a single condition or a logical group): |
||||
```go |
||||
type Spec struct { |
||||
Condition *Condition // For simple conditions |
||||
LogicalGroup *LogicalGroup // For complex logic |
||||
} |
||||
``` |
||||
|
||||
### 2. Builder Functions |
||||
|
||||
These functions create specifications in a fluent, readable way: |
||||
|
||||
```go |
||||
// Simple conditions |
||||
userSpec := Eq("name", "John") // name = "John" |
||||
ageSpec := Gt("age", 18) // age > 18 |
||||
roleSpec := In("role", []interface{}{"admin", "moderator"}) // role IN ("admin", "moderator") |
||||
|
||||
// Logical combinations |
||||
complexSpec := And( |
||||
Eq("status", "active"), |
||||
Or( |
||||
Gt("age", 21), |
||||
Eq("role", "admin"), |
||||
), |
||||
) |
||||
``` |
||||
|
||||
### 3. Specification Interface (`simple_specification.go`) |
||||
|
||||
The `Specification[T]` interface provides methods for: |
||||
- **Evaluation**: `IsSatisfiedBy(candidate T) bool` |
||||
- **Composition**: `And()`, `Or()`, `Not()` |
||||
- **Introspection**: `GetConditions()`, `GetSpec()` |
||||
|
||||
## How It Works |
||||
|
||||
### 1. Building Specifications |
||||
|
||||
```go |
||||
// Create a specification for active users over 18 |
||||
spec := And( |
||||
Eq("status", "active"), |
||||
Gt("age", 18), |
||||
) |
||||
``` |
||||
|
||||
### 2. Evaluating Specifications |
||||
|
||||
The `SimpleSpecification` uses reflection to evaluate conditions against Go objects: |
||||
|
||||
```go |
||||
user := &User{Name: "John", Age: 25, Status: "active"} |
||||
specification := NewSimpleSpecification[*User](spec) |
||||
isMatch := specification.IsSatisfiedBy(user) // true |
||||
``` |
||||
|
||||
### 3. Field Access |
||||
|
||||
The implementation looks for field values in this order: |
||||
1. `GetFieldName()` method (e.g., `GetAge()`) |
||||
2. `FieldName()` method (e.g., `Age()`) |
||||
3. Exported struct field |
||||
|
||||
### 4. Type Comparisons |
||||
|
||||
The system handles different data types intelligently: |
||||
- **Numbers**: Direct comparison with type conversion |
||||
- **Strings**: Lexicographic comparison |
||||
- **Time**: Special handling for `time.Time` and objects with `Time()` methods |
||||
- **Collections**: `IN` and `NOT IN` operations |
||||
- **Nil values**: Proper null handling |
||||
|
||||
## Examples |
||||
|
||||
### Simple Usage |
||||
```go |
||||
// Find all active users |
||||
activeSpec := Eq("status", "active") |
||||
spec := NewSimpleSpecification[*User](activeSpec) |
||||
|
||||
users := []User{{Status: "active"}, {Status: "inactive"}} |
||||
for _, user := range users { |
||||
if spec.IsSatisfiedBy(&user) { |
||||
fmt.Println("Active user:", user.Name) |
||||
} |
||||
} |
||||
``` |
||||
|
||||
### Complex Logic |
||||
```go |
||||
// Find admin users OR users with high scores |
||||
complexSpec := Or( |
||||
Eq("role", "admin"), |
||||
And( |
||||
Gt("score", 90), |
||||
Eq("status", "active"), |
||||
), |
||||
) |
||||
``` |
||||
|
||||
### Time Comparisons |
||||
```go |
||||
// Find expired items |
||||
expiredSpec := Lte("expirationDate", time.Now()) |
||||
``` |
||||
|
||||
## Key Benefits |
||||
|
||||
1. **Type Safety**: Compile-time checking with generics |
||||
2. **Readability**: Business logic expressed in readable Go code |
||||
3. **Reusability**: Specifications can be composed and reused |
||||
4. **Testability**: Easy to test business rules in isolation |
||||
5. **Flexibility**: Can be converted to SQL, used for filtering, etc. |
||||
|
||||
## Performance Considerations |
||||
|
||||
- Reflection is used for field access (consider caching for high-frequency operations) |
||||
- Complex nested specifications may impact performance |
||||
- Time comparisons handle multiple formats but may be slower than direct comparisons |
||||
|
||||
## Common Patterns |
||||
|
||||
### Repository Integration |
||||
```go |
||||
type UserRepository interface { |
||||
FindWhere(ctx context.Context, spec Specification[*User]) ([]*User, error) |
||||
} |
||||
``` |
||||
|
||||
### Business Rule Encapsulation |
||||
```go |
||||
func ActiveUserSpec() *Spec { |
||||
return And( |
||||
Eq("status", "active"), |
||||
Neq("deletedAt", nil), |
||||
) |
||||
} |
||||
``` |
||||
|
||||
### Dynamic Query Building |
||||
```go |
||||
func BuildUserSearchSpec(filters UserFilters) *Spec { |
||||
conditions := []*Spec{} |
||||
|
||||
if filters.Status != "" { |
||||
conditions = append(conditions, Eq("status", filters.Status)) |
||||
} |
||||
if filters.MinAge > 0 { |
||||
conditions = append(conditions, Gte("age", filters.MinAge)) |
||||
} |
||||
|
||||
return And(conditions...) |
||||
} |
||||
@ -1,94 +0,0 @@
|
||||
package main |
||||
|
||||
import ( |
||||
"context" |
||||
"log" |
||||
"net/http" |
||||
"os" |
||||
"os/signal" |
||||
"syscall" |
||||
"time" |
||||
|
||||
"autostore/internal/application/interfaces" |
||||
"autostore/internal/config" |
||||
"autostore/internal/container" |
||||
) |
||||
|
||||
func main() { |
||||
// Load configuration
|
||||
cfg, err := config.Load() |
||||
if err != nil { |
||||
log.Fatalf("Failed to load configuration: %v", err) |
||||
} |
||||
|
||||
// Validate configuration
|
||||
if err := cfg.Validate(); err != nil { |
||||
log.Fatalf("Invalid configuration: %v", err) |
||||
} |
||||
|
||||
// Create dependency injection container
|
||||
container := container.NewContainer(cfg) |
||||
if err := container.Initialize(); err != nil { |
||||
log.Fatalf("Failed to initialize container: %v", err) |
||||
} |
||||
|
||||
// Get server and scheduler from container
|
||||
server := container.GetServer() |
||||
scheduler := container.GetExpiredItemsScheduler() |
||||
logger := container.GetLogger() |
||||
|
||||
// Setup graceful shutdown
|
||||
shutdownComplete := make(chan struct{}) |
||||
go setupGracefulShutdown(server, scheduler, logger, shutdownComplete) |
||||
|
||||
// Start scheduler
|
||||
if err := scheduler.Start(context.Background()); err != nil { |
||||
logger.Error(context.Background(), "Failed to start scheduler", "error", err) |
||||
log.Fatalf("Failed to start scheduler: %v", err) |
||||
} |
||||
|
||||
// Start server
|
||||
if err := server.Start(); err != nil { |
||||
if err == http.ErrServerClosed { |
||||
// This is expected during graceful shutdown
|
||||
logger.Info(context.Background(), "Server shutdown complete") |
||||
} else { |
||||
logger.Error(context.Background(), "Server failed to start", "error", err) |
||||
log.Fatalf("Server failed to start: %v", err) |
||||
} |
||||
} |
||||
|
||||
// Wait for graceful shutdown to complete
|
||||
<-shutdownComplete |
||||
logger.Info(context.Background(), "Application exiting gracefully") |
||||
} |
||||
|
||||
func setupGracefulShutdown(server interface { |
||||
Shutdown(ctx context.Context) error |
||||
}, scheduler interface { |
||||
Stop() error |
||||
}, logger interfaces.ILogger, shutdownComplete chan struct{}) { |
||||
sigChan := make(chan os.Signal, 1) |
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) |
||||
|
||||
sig := <-sigChan |
||||
logger.Info(context.Background(), "Received shutdown signal", "signal", sig) |
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
||||
defer cancel() |
||||
|
||||
if err := scheduler.Stop(); err != nil { |
||||
logger.Error(ctx, "Scheduler shutdown failed", "error", err) |
||||
} else { |
||||
logger.Info(ctx, "Scheduler shutdown completed gracefully") |
||||
} |
||||
|
||||
if err := server.Shutdown(ctx); err != nil { |
||||
logger.Error(ctx, "Server shutdown failed", "error", err) |
||||
} else { |
||||
logger.Info(ctx, "Server shutdown completed gracefully") |
||||
} |
||||
|
||||
// Signal that shutdown is complete
|
||||
close(shutdownComplete) |
||||
} |
||||
@ -1,4 +0,0 @@
|
||||
.devcontainer |
||||
.git |
||||
.gitignore |
||||
README.md |
||||
@ -1,33 +0,0 @@
|
||||
FROM golang:1.21-alpine AS builder |
||||
|
||||
WORKDIR /app |
||||
|
||||
COPY go.mod go.sum ./ |
||||
|
||||
# Download all dependencies |
||||
RUN go mod download |
||||
|
||||
COPY . . |
||||
|
||||
# Generate go.sum |
||||
RUN go mod tidy |
||||
|
||||
# Run tests |
||||
RUN go test ./tests/unit -v && \ |
||||
go test ./tests/integration -v |
||||
|
||||
# Build the application |
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd |
||||
|
||||
# Runtime stage |
||||
FROM alpine:latest |
||||
|
||||
RUN apk --no-cache add ca-certificates |
||||
|
||||
WORKDIR /root/ |
||||
|
||||
COPY --from=builder /app/main . |
||||
|
||||
EXPOSE 3000 |
||||
|
||||
CMD ["./main"] |
||||
@ -1,17 +0,0 @@
|
||||
version: "3.9" |
||||
services: |
||||
app: |
||||
build: |
||||
context: .. |
||||
dockerfile: docker/Dockerfile |
||||
image: golang-autostore-img |
||||
container_name: golang-autostore-app |
||||
ports: |
||||
- "50080:3000" |
||||
networks: |
||||
- app-network |
||||
restart: unless-stopped |
||||
|
||||
networks: |
||||
app-network: |
||||
driver: bridge |
||||
@ -1,39 +0,0 @@
|
||||
module autostore |
||||
|
||||
go 1.21 |
||||
|
||||
require ( |
||||
github.com/gin-gonic/gin v1.9.1 |
||||
github.com/golang-jwt/jwt/v4 v4.5.2 |
||||
github.com/google/uuid v1.3.0 |
||||
github.com/stretchr/testify v1.8.3 |
||||
golang.org/x/crypto v0.9.0 |
||||
) |
||||
|
||||
require ( |
||||
github.com/bytedance/sonic v1.9.1 // indirect |
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect |
||||
github.com/davecgh/go-spew v1.1.1 // indirect |
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect |
||||
github.com/gin-contrib/sse v0.1.0 // indirect |
||||
github.com/go-playground/locales v0.14.1 // indirect |
||||
github.com/go-playground/universal-translator v0.18.1 // indirect |
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect |
||||
github.com/goccy/go-json v0.10.2 // indirect |
||||
github.com/json-iterator/go v1.1.12 // indirect |
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect |
||||
github.com/leodido/go-urn v1.2.4 // indirect |
||||
github.com/mattn/go-isatty v0.0.19 // indirect |
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect |
||||
github.com/modern-go/reflect2 v1.0.2 // indirect |
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect |
||||
github.com/pmezard/go-difflib v1.0.0 // indirect |
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect |
||||
github.com/ugorji/go/codec v1.2.11 // indirect |
||||
golang.org/x/arch v0.3.0 // indirect |
||||
golang.org/x/net v0.10.0 // indirect |
||||
golang.org/x/sys v0.8.0 // indirect |
||||
golang.org/x/text v0.9.0 // indirect |
||||
google.golang.org/protobuf v1.30.0 // indirect |
||||
gopkg.in/yaml.v3 v3.0.1 // indirect |
||||
) |
||||
@ -1,90 +0,0 @@
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= |
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= |
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= |
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= |
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= |
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= |
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= |
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= |
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= |
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= |
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= |
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= |
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= |
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= |
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= |
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= |
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= |
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= |
||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= |
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= |
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= |
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= |
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= |
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= |
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= |
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= |
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= |
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= |
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= |
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= |
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= |
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= |
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= |
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= |
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= |
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= |
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= |
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= |
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= |
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= |
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= |
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= |
||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= |
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= |
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= |
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= |
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= |
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= |
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= |
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= |
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= |
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= |
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= |
||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= |
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= |
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= |
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= |
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= |
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= |
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= |
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= |
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= |
||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= |
||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= |
||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= |
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= |
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= |
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= |
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= |
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= |
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= |
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= |
||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= |
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= |
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= |
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= |
||||
@ -1,97 +0,0 @@
|
||||
package commands |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"fmt" |
||||
"time" |
||||
|
||||
"autostore/internal/application/interfaces" |
||||
"autostore/internal/domain/entities" |
||||
"autostore/internal/domain/specifications" |
||||
"autostore/internal/domain/value_objects" |
||||
) |
||||
|
||||
var ( |
||||
ErrItemCreationFailed = errors.New("failed to create item") |
||||
ErrInvalidUserID = errors.New("invalid user ID") |
||||
) |
||||
|
||||
type AddItemCommand struct { |
||||
itemRepo interfaces.IItemRepository |
||||
orderService interfaces.IOrderService |
||||
timeProvider interfaces.ITimeProvider |
||||
expirationSpec *specifications.ItemExpirationSpec |
||||
logger interfaces.ILogger |
||||
} |
||||
|
||||
func NewAddItemCommand( |
||||
itemRepo interfaces.IItemRepository, |
||||
orderService interfaces.IOrderService, |
||||
timeProvider interfaces.ITimeProvider, |
||||
expirationSpec *specifications.ItemExpirationSpec, |
||||
logger interfaces.ILogger, |
||||
) *AddItemCommand { |
||||
return &AddItemCommand{ |
||||
itemRepo: itemRepo, |
||||
orderService: orderService, |
||||
timeProvider: timeProvider, |
||||
expirationSpec: expirationSpec, |
||||
logger: logger, |
||||
} |
||||
} |
||||
|
||||
func (c *AddItemCommand) Execute(ctx context.Context, name string, expirationDate time.Time, orderURL string, userID string) (string, error) { |
||||
c.logger.Info(ctx, "Executing AddItemCommand", "name", name, "userID", userID) |
||||
|
||||
// Convert string IDs to value objects
|
||||
itemID, err := value_objects.NewRandomItemID() |
||||
if err != nil { |
||||
c.logger.Error(ctx, "Failed to generate item ID", "error", err) |
||||
return "", fmt.Errorf("%w: %v", ErrItemCreationFailed, err) |
||||
} |
||||
|
||||
userIDObj, err := value_objects.NewUserID(userID) |
||||
if err != nil { |
||||
c.logger.Error(ctx, "Invalid user ID", "userID", userID, "error", err) |
||||
return "", fmt.Errorf("%w: %v", ErrInvalidUserID, err) |
||||
} |
||||
|
||||
// Create expiration date value object
|
||||
expirationDateObj, err := value_objects.NewExpirationDate(expirationDate) |
||||
if err != nil { |
||||
c.logger.Error(ctx, "Invalid expiration date", "expirationDate", expirationDate, "error", err) |
||||
return "", fmt.Errorf("%w: %v", ErrItemCreationFailed, err) |
||||
} |
||||
|
||||
// Create item entity
|
||||
item, err := entities.NewItem(itemID, name, expirationDateObj, orderURL, userIDObj) |
||||
if err != nil { |
||||
c.logger.Error(ctx, "Failed to create item entity", "error", err) |
||||
return "", fmt.Errorf("%w: %v", ErrItemCreationFailed, err) |
||||
} |
||||
|
||||
// Get current time and check if item is expired
|
||||
currentTime := c.timeProvider.Now() |
||||
isExpired := c.expirationSpec.IsExpired(item, currentTime) |
||||
|
||||
// Save the item first (even if expired, as per business rule #4)
|
||||
if err := c.itemRepo.Save(ctx, item); err != nil { |
||||
c.logger.Error(ctx, "Failed to save item", "itemID", itemID.String(), "error", err) |
||||
return "", fmt.Errorf("%w: %v", ErrItemCreationFailed, err) |
||||
} |
||||
|
||||
if isExpired { |
||||
c.logger.Info(ctx, "Item is expired, triggering order", "itemID", itemID.String()) |
||||
|
||||
// Business rule: When an item expires, a new item of the same type is automatically ordered
|
||||
if err := c.orderService.OrderItem(ctx, item); err != nil { |
||||
c.logger.Error(ctx, "Failed to order expired item", "itemID", itemID.String(), "error", err) |
||||
// Don't fail the entire operation if ordering fails, just log it
|
||||
c.logger.Warn(ctx, "Item created but ordering failed", "itemID", itemID.String(), "error", err) |
||||
} |
||||
} |
||||
|
||||
c.logger.Info(ctx, "Item created successfully", "itemID", itemID.String()) |
||||
return itemID.String(), nil |
||||
} |
||||
@ -1,69 +0,0 @@
|
||||
package commands |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
|
||||
"autostore/internal/application/interfaces" |
||||
"autostore/internal/domain/value_objects" |
||||
) |
||||
|
||||
var ( |
||||
ErrItemDeletionFailed = fmt.Errorf("failed to delete item") |
||||
ErrItemNotFound = fmt.Errorf("item not found") |
||||
ErrUnauthorizedAccess = fmt.Errorf("unauthorized access to item") |
||||
) |
||||
|
||||
type DeleteItemCommand struct { |
||||
itemRepo interfaces.IItemRepository |
||||
logger interfaces.ILogger |
||||
} |
||||
|
||||
func NewDeleteItemCommand( |
||||
itemRepo interfaces.IItemRepository, |
||||
logger interfaces.ILogger, |
||||
) *DeleteItemCommand { |
||||
return &DeleteItemCommand{ |
||||
itemRepo: itemRepo, |
||||
logger: logger, |
||||
} |
||||
} |
||||
|
||||
func (c *DeleteItemCommand) Execute(ctx context.Context, itemID string, userID string) error { |
||||
c.logger.Info(ctx, "Executing DeleteItemCommand", "itemID", itemID, "userID", userID) |
||||
|
||||
// Convert string IDs to value objects
|
||||
itemIDObj, err := value_objects.NewItemID(itemID) |
||||
if err != nil { |
||||
c.logger.Error(ctx, "Invalid item ID", "itemID", itemID, "error", err) |
||||
return fmt.Errorf("invalid item ID: %w", err) |
||||
} |
||||
|
||||
userIDObj, err := value_objects.NewUserID(userID) |
||||
if err != nil { |
||||
c.logger.Error(ctx, "Invalid user ID", "userID", userID, "error", err) |
||||
return fmt.Errorf("invalid user ID: %w", err) |
||||
} |
||||
|
||||
// Find item by ID
|
||||
item, err := c.itemRepo.FindByID(ctx, itemIDObj) |
||||
if err != nil { |
||||
c.logger.Error(ctx, "Failed to find item", "itemID", itemID, "error", err) |
||||
return fmt.Errorf("%w: %v", ErrItemNotFound, err) |
||||
} |
||||
|
||||
// Validate ownership - only the item's owner can delete it
|
||||
if !item.GetUserID().Equals(userIDObj) { |
||||
c.logger.Warn(ctx, "Unauthorized deletion attempt", "itemID", itemID, "userID", userID, "ownerID", item.GetUserID().String()) |
||||
return ErrUnauthorizedAccess |
||||
} |
||||
|
||||
// Delete the item
|
||||
if err := c.itemRepo.Delete(ctx, itemIDObj); err != nil { |
||||
c.logger.Error(ctx, "Failed to delete item", "itemID", itemID, "error", err) |
||||
return fmt.Errorf("%w: %v", ErrItemDeletionFailed, err) |
||||
} |
||||
|
||||
c.logger.Info(ctx, "Item deleted successfully", "itemID", itemID, "userID", userID) |
||||
return nil |
||||
} |
||||
@ -1,67 +0,0 @@
|
||||
package commands |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
|
||||
"autostore/internal/application/interfaces" |
||||
"autostore/internal/domain/specifications" |
||||
) |
||||
|
||||
type HandleExpiredItemsCommand struct { |
||||
itemRepo interfaces.IItemRepository |
||||
orderService interfaces.IOrderService |
||||
timeProvider interfaces.ITimeProvider |
||||
expirationSpec *specifications.ItemExpirationSpec |
||||
logger interfaces.ILogger |
||||
} |
||||
|
||||
func NewHandleExpiredItemsCommand( |
||||
itemRepo interfaces.IItemRepository, |
||||
orderService interfaces.IOrderService, |
||||
timeProvider interfaces.ITimeProvider, |
||||
expirationSpec *specifications.ItemExpirationSpec, |
||||
logger interfaces.ILogger, |
||||
) *HandleExpiredItemsCommand { |
||||
return &HandleExpiredItemsCommand{ |
||||
itemRepo: itemRepo, |
||||
orderService: orderService, |
||||
timeProvider: timeProvider, |
||||
expirationSpec: expirationSpec, |
||||
logger: logger, |
||||
} |
||||
} |
||||
|
||||
func (c *HandleExpiredItemsCommand) Execute(ctx context.Context) error { |
||||
c.logger.Info(ctx, "Starting expired items processing") |
||||
|
||||
currentTime := c.timeProvider.Now() |
||||
expirationSpec := c.expirationSpec.GetSpec(currentTime) |
||||
|
||||
expiredItems, err := c.itemRepo.FindWhere(ctx, expirationSpec) |
||||
if err != nil { |
||||
c.logger.Error(ctx, "Failed to find expired items", "error", err) |
||||
return fmt.Errorf("failed to find expired items: %w", err) |
||||
} |
||||
|
||||
c.logger.Info(ctx, "Found expired items", "count", len(expiredItems)) |
||||
|
||||
for _, item := range expiredItems { |
||||
c.logger.Info(ctx, "Processing expired item", "item_id", item.GetID().String(), "item_name", item.GetName()) |
||||
|
||||
if err := c.orderService.OrderItem(ctx, item); err != nil { |
||||
c.logger.Error(ctx, "Failed to order replacement item", "item_id", item.GetID().String(), "error", err) |
||||
continue |
||||
} |
||||
|
||||
if err := c.itemRepo.Delete(ctx, item.GetID()); err != nil { |
||||
c.logger.Error(ctx, "Failed to delete expired item", "item_id", item.GetID().String(), "error", err) |
||||
return fmt.Errorf("failed to delete expired item %s: %w", item.GetID().String(), err) |
||||
} |
||||
|
||||
c.logger.Info(ctx, "Successfully processed expired item", "item_id", item.GetID().String()) |
||||
} |
||||
|
||||
c.logger.Info(ctx, "Completed expired items processing", "processed_count", len(expiredItems)) |
||||
return nil |
||||
} |
||||
@ -1,35 +0,0 @@
|
||||
package commands |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
"autostore/internal/application/interfaces" |
||||
) |
||||
|
||||
type LoginUserCommand struct { |
||||
authService interfaces.IAuthService |
||||
logger interfaces.ILogger |
||||
} |
||||
|
||||
func NewLoginUserCommand( |
||||
authService interfaces.IAuthService, |
||||
logger interfaces.ILogger, |
||||
) *LoginUserCommand { |
||||
return &LoginUserCommand{ |
||||
authService: authService, |
||||
logger: logger, |
||||
} |
||||
} |
||||
|
||||
func (c *LoginUserCommand) Execute(ctx context.Context, username string, password string) (string, error) { |
||||
c.logger.Info(ctx, "Executing login command", "username", username) |
||||
|
||||
token, err := c.authService.Authenticate(ctx, username, password) |
||||
if err != nil { |
||||
c.logger.Warn(ctx, "Authentication failed", "username", username, "error", err) |
||||
return "", err |
||||
} |
||||
|
||||
c.logger.Info(ctx, "Login successful", "username", username) |
||||
return token, nil |
||||
} |
||||
@ -1,34 +0,0 @@
|
||||
package dto |
||||
|
||||
import ( |
||||
"errors" |
||||
"net/url" |
||||
) |
||||
|
||||
var ( |
||||
ErrInvalidItemName = errors.New("item name cannot be empty") |
||||
ErrInvalidOrderURL = errors.New("invalid order URL format") |
||||
ErrInvalidExpirationDate = errors.New("invalid expiration date") |
||||
) |
||||
|
||||
type CreateItemDTO struct { |
||||
Name string `json:"name" binding:"required"` |
||||
ExpirationDate JSONTime `json:"expirationDate" binding:"required"` |
||||
OrderURL string `json:"orderUrl" binding:"required"` |
||||
} |
||||
|
||||
func (dto *CreateItemDTO) Validate() error { |
||||
if dto.Name == "" { |
||||
return ErrInvalidItemName |
||||
} |
||||
|
||||
if _, err := url.ParseRequestURI(dto.OrderURL); err != nil { |
||||
return ErrInvalidOrderURL |
||||
} |
||||
|
||||
if dto.ExpirationDate.Time.IsZero() { |
||||
return ErrInvalidExpirationDate |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
@ -1,25 +0,0 @@
|
||||
package dto |
||||
|
||||
import ( |
||||
"autostore/internal/domain/entities" |
||||
) |
||||
|
||||
type ItemResponseDTO struct { |
||||
ID string `json:"id"` |
||||
Name string `json:"name"` |
||||
ExpirationDate JSONTime `json:"expirationDate"` |
||||
OrderURL string `json:"orderUrl"` |
||||
UserID string `json:"userId"` |
||||
CreatedAt JSONTime `json:"createdAt"` |
||||
} |
||||
|
||||
func (dto *ItemResponseDTO) FromEntity(item *entities.ItemEntity) *ItemResponseDTO { |
||||
return &ItemResponseDTO{ |
||||
ID: item.GetID().String(), |
||||
Name: item.GetName(), |
||||
ExpirationDate: JSONTime{item.GetExpirationDate().Time()}, |
||||
OrderURL: item.GetOrderURL(), |
||||
UserID: item.GetUserID().String(), |
||||
CreatedAt: JSONTime{item.GetCreatedAt()}, |
||||
} |
||||
} |
||||
@ -1,31 +0,0 @@
|
||||
package dto |
||||
|
||||
type JSendResponse struct { |
||||
Status string `json:"status"` |
||||
Data interface{} `json:"data,omitempty"` |
||||
Message string `json:"message,omitempty"` |
||||
Code int `json:"code,omitempty"` |
||||
} |
||||
|
||||
func JSendSuccess(data interface{}) JSendResponse { |
||||
return JSendResponse{ |
||||
Status: "success", |
||||
Data: data, |
||||
} |
||||
} |
||||
|
||||
func JSendError(message string, code int) JSendResponse { |
||||
return JSendResponse{ |
||||
Status: "error", |
||||
Message: message, |
||||
Code: code, |
||||
} |
||||
} |
||||
|
||||
func JSendFail(message string, code int) JSendResponse { |
||||
return JSendResponse{ |
||||
Status: "fail", |
||||
Message: message, |
||||
Code: code, |
||||
} |
||||
} |
||||
@ -1,50 +0,0 @@
|
||||
package dto |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"fmt" |
||||
"time" |
||||
) |
||||
|
||||
// JSONTime is a custom time type that can unmarshal from JSON strings
|
||||
type JSONTime struct { |
||||
time.Time |
||||
} |
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler interface
|
||||
func (jt *JSONTime) UnmarshalJSON(data []byte) error { |
||||
// Remove quotes from JSON string
|
||||
var str string |
||||
if err := json.Unmarshal(data, &str); err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Try to parse with different formats
|
||||
formats := []string{ |
||||
time.RFC3339, |
||||
"2006-01-02T15:04:05", |
||||
"2006-01-02T15:04:05Z", |
||||
"2006-01-02T15:04:05.000Z", |
||||
"2006-01-02T15:04:05.000000Z", |
||||
"2006-01-02T15:04:05.000000000Z", |
||||
} |
||||
|
||||
for _, format := range formats { |
||||
if t, err := time.Parse(format, str); err == nil { |
||||
jt.Time = t |
||||
return nil |
||||
} |
||||
} |
||||
|
||||
return fmt.Errorf("unable to parse time: %s", str) |
||||
} |
||||
|
||||
// MarshalJSON implements json.Marshaler interface
|
||||
func (jt JSONTime) MarshalJSON() ([]byte, error) { |
||||
return json.Marshal(jt.Time.Format(time.RFC3339)) |
||||
} |
||||
|
||||
// String returns the time in RFC3339 format
|
||||
func (jt JSONTime) String() string { |
||||
return jt.Time.Format(time.RFC3339) |
||||
} |
||||
@ -1,33 +0,0 @@
|
||||
package dto |
||||
|
||||
import ( |
||||
"errors" |
||||
) |
||||
|
||||
var ( |
||||
ErrInvalidUsername = errors.New("username cannot be empty") |
||||
ErrInvalidPassword = errors.New("password cannot be empty") |
||||
) |
||||
|
||||
type LoginDTO struct { |
||||
Username string `json:"username" binding:"required"` |
||||
Password string `json:"password" binding:"required"` |
||||
} |
||||
|
||||
func (dto *LoginDTO) Validate() error { |
||||
if dto.Username == "" { |
||||
return ErrInvalidUsername |
||||
} |
||||
|
||||
if dto.Password == "" { |
||||
return ErrInvalidPassword |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
type LoginResponseDTO struct { |
||||
Token string `json:"token"` |
||||
TokenType string `json:"tokenType"` |
||||
ExpiresIn int `json:"expiresIn"` |
||||
} |
||||
@ -1,5 +0,0 @@
|
||||
package errors |
||||
|
||||
import "errors" |
||||
|
||||
var ErrNotImplemented = errors.New("not implemented") |
||||
@ -1,11 +0,0 @@
|
||||
package interfaces |
||||
|
||||
import ( |
||||
"context" |
||||
) |
||||
|
||||
type IAuthService interface { |
||||
Authenticate(ctx context.Context, username string, password string) (string, error) |
||||
ValidateToken(ctx context.Context, token string) (bool, error) |
||||
GetUserIDFromToken(ctx context.Context, token string) (string, error) |
||||
} |
||||
@ -1,17 +0,0 @@
|
||||
package interfaces |
||||
|
||||
import ( |
||||
"context" |
||||
"autostore/internal/domain/entities" |
||||
"autostore/internal/domain/specifications" |
||||
"autostore/internal/domain/value_objects" |
||||
) |
||||
|
||||
type IItemRepository interface { |
||||
Save(ctx context.Context, item *entities.ItemEntity) error |
||||
FindByID(ctx context.Context, id value_objects.ItemID) (*entities.ItemEntity, error) |
||||
FindByUserID(ctx context.Context, userID value_objects.UserID) ([]*entities.ItemEntity, error) |
||||
FindWhere(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) |
||||
Delete(ctx context.Context, id value_objects.ItemID) error |
||||
Exists(ctx context.Context, id value_objects.ItemID) (bool, error) |
||||
} |
||||
@ -1,12 +0,0 @@
|
||||
package interfaces |
||||
|
||||
import ( |
||||
"context" |
||||
) |
||||
|
||||
type ILogger interface { |
||||
Info(ctx context.Context, msg string, fields ...interface{}) |
||||
Error(ctx context.Context, msg string, fields ...interface{}) |
||||
Debug(ctx context.Context, msg string, fields ...interface{}) |
||||
Warn(ctx context.Context, msg string, fields ...interface{}) |
||||
} |
||||
@ -1,10 +0,0 @@
|
||||
package interfaces |
||||
|
||||
import ( |
||||
"context" |
||||
"autostore/internal/domain/entities" |
||||
) |
||||
|
||||
type IOrderService interface { |
||||
OrderItem(ctx context.Context, item *entities.ItemEntity) error |
||||
} |
||||
@ -1,9 +0,0 @@
|
||||
package interfaces |
||||
|
||||
import ( |
||||
"time" |
||||
) |
||||
|
||||
type ITimeProvider interface { |
||||
Now() time.Time |
||||
} |
||||
@ -1,13 +0,0 @@
|
||||
package interfaces |
||||
|
||||
import ( |
||||
"context" |
||||
"autostore/internal/domain/entities" |
||||
"autostore/internal/domain/value_objects" |
||||
) |
||||
|
||||
type IUserRepository interface { |
||||
FindByUsername(ctx context.Context, username string) (*entities.UserEntity, error) |
||||
FindByID(ctx context.Context, id value_objects.UserID) (*entities.UserEntity, error) |
||||
Save(ctx context.Context, user *entities.UserEntity) error |
||||
} |
||||
@ -1,63 +0,0 @@
|
||||
package queries |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
|
||||
"autostore/internal/application/interfaces" |
||||
"autostore/internal/domain/entities" |
||||
"autostore/internal/domain/value_objects" |
||||
) |
||||
|
||||
var ( |
||||
ErrItemNotFound = fmt.Errorf("item not found") |
||||
ErrUnauthorizedAccess = fmt.Errorf("unauthorized access to item") |
||||
) |
||||
|
||||
type GetItemQuery struct { |
||||
itemRepo interfaces.IItemRepository |
||||
logger interfaces.ILogger |
||||
} |
||||
|
||||
func NewGetItemQuery( |
||||
itemRepo interfaces.IItemRepository, |
||||
logger interfaces.ILogger, |
||||
) *GetItemQuery { |
||||
return &GetItemQuery{ |
||||
itemRepo: itemRepo, |
||||
logger: logger, |
||||
} |
||||
} |
||||
|
||||
func (q *GetItemQuery) Execute(ctx context.Context, itemID string, userID string) (*entities.ItemEntity, error) { |
||||
q.logger.Info(ctx, "Executing GetItemQuery", "itemID", itemID, "userID", userID) |
||||
|
||||
// Convert string IDs to value objects
|
||||
itemIDObj, err := value_objects.NewItemID(itemID) |
||||
if err != nil { |
||||
q.logger.Error(ctx, "Invalid item ID", "itemID", itemID, "error", err) |
||||
return nil, fmt.Errorf("invalid item ID: %w", err) |
||||
} |
||||
|
||||
userIDObj, err := value_objects.NewUserID(userID) |
||||
if err != nil { |
||||
q.logger.Error(ctx, "Invalid user ID", "userID", userID, "error", err) |
||||
return nil, fmt.Errorf("invalid user ID: %w", err) |
||||
} |
||||
|
||||
// Find item by ID
|
||||
item, err := q.itemRepo.FindByID(ctx, itemIDObj) |
||||
if err != nil { |
||||
q.logger.Error(ctx, "Failed to find item", "itemID", itemID, "error", err) |
||||
return nil, fmt.Errorf("%w: %v", ErrItemNotFound, err) |
||||
} |
||||
|
||||
// Validate ownership - only the item's owner can access it
|
||||
if !item.GetUserID().Equals(userIDObj) { |
||||
q.logger.Warn(ctx, "Unauthorized access attempt", "itemID", itemID, "userID", userID, "ownerID", item.GetUserID().String()) |
||||
return nil, ErrUnauthorizedAccess |
||||
} |
||||
|
||||
q.logger.Info(ctx, "Item retrieved successfully", "itemID", itemID, "userID", userID) |
||||
return item, nil |
||||
} |
||||
@ -1,50 +0,0 @@
|
||||
package queries |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
|
||||
"autostore/internal/application/interfaces" |
||||
"autostore/internal/domain/entities" |
||||
"autostore/internal/domain/value_objects" |
||||
) |
||||
|
||||
var ( |
||||
ErrFailedToListItems = fmt.Errorf("failed to list items") |
||||
) |
||||
|
||||
type ListItemsQuery struct { |
||||
itemRepo interfaces.IItemRepository |
||||
logger interfaces.ILogger |
||||
} |
||||
|
||||
func NewListItemsQuery( |
||||
itemRepo interfaces.IItemRepository, |
||||
logger interfaces.ILogger, |
||||
) *ListItemsQuery { |
||||
return &ListItemsQuery{ |
||||
itemRepo: itemRepo, |
||||
logger: logger, |
||||
} |
||||
} |
||||
|
||||
func (q *ListItemsQuery) Execute(ctx context.Context, userID string) ([]*entities.ItemEntity, error) { |
||||
q.logger.Info(ctx, "Executing ListItemsQuery", "userID", userID) |
||||
|
||||
// Convert string ID to value object
|
||||
userIDObj, err := value_objects.NewUserID(userID) |
||||
if err != nil { |
||||
q.logger.Error(ctx, "Invalid user ID", "userID", userID, "error", err) |
||||
return nil, fmt.Errorf("invalid user ID: %w", err) |
||||
} |
||||
|
||||
// Find all items for the user
|
||||
items, err := q.itemRepo.FindByUserID(ctx, userIDObj) |
||||
if err != nil { |
||||
q.logger.Error(ctx, "Failed to list items for user", "userID", userID, "error", err) |
||||
return nil, fmt.Errorf("%w: %v", ErrFailedToListItems, err) |
||||
} |
||||
|
||||
q.logger.Info(ctx, "Items listed successfully", "userID", userID, "count", len(items)) |
||||
return items, nil |
||||
} |
||||
@ -1,67 +0,0 @@
|
||||
package config |
||||
|
||||
import ( |
||||
"os" |
||||
"strconv" |
||||
"time" |
||||
) |
||||
|
||||
type Config struct { |
||||
ServerPort int `env:"SERVER_PORT" envDefault:"3000"` |
||||
JWTSecret string `env:"JWT_SECRET" envDefault:"your-secret-key"` |
||||
DataDirectory string `env:"DATA_DIRECTORY" envDefault:"./data"` |
||||
LogLevel string `env:"LOG_LEVEL" envDefault:"info"` |
||||
SchedulerInterval time.Duration `env:"SCHEDULER_INTERVAL" envDefault:"1m"` |
||||
ReadTimeout time.Duration `env:"READ_TIMEOUT" envDefault:"30s"` |
||||
WriteTimeout time.Duration `env:"WRITE_TIMEOUT" envDefault:"30s"` |
||||
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"30s"` |
||||
} |
||||
|
||||
func Load() (*Config, error) { |
||||
cfg := &Config{ |
||||
ServerPort: getEnvAsInt("SERVER_PORT", 3000), |
||||
JWTSecret: getEnvAsString("JWT_SECRET", "your-secret-key"), |
||||
DataDirectory: getEnvAsString("DATA_DIRECTORY", "./data"), |
||||
LogLevel: getEnvAsString("LOG_LEVEL", "info"), |
||||
SchedulerInterval: getEnvAsDuration("SCHEDULER_INTERVAL", "1m"), |
||||
ReadTimeout: getEnvAsDuration("READ_TIMEOUT", "30s"), |
||||
WriteTimeout: getEnvAsDuration("WRITE_TIMEOUT", "30s"), |
||||
ShutdownTimeout: getEnvAsDuration("SHUTDOWN_TIMEOUT", "30s"), |
||||
} |
||||
|
||||
return cfg, nil |
||||
} |
||||
|
||||
func (c *Config) Validate() error { |
||||
// For now, we'll keep it simple and not add validation
|
||||
// In a real application, you would validate the configuration here
|
||||
return nil |
||||
} |
||||
|
||||
func getEnvAsString(key, defaultValue string) string { |
||||
if value, exists := os.LookupEnv(key); exists { |
||||
return value |
||||
} |
||||
return defaultValue |
||||
} |
||||
|
||||
func getEnvAsInt(key string, defaultValue int) int { |
||||
if value, exists := os.LookupEnv(key); exists { |
||||
if intValue, err := strconv.Atoi(value); err == nil { |
||||
return intValue |
||||
} |
||||
} |
||||
return defaultValue |
||||
} |
||||
|
||||
func getEnvAsDuration(key string, defaultValue string) time.Duration { |
||||
if value, exists := os.LookupEnv(key); exists { |
||||
if duration, err := time.ParseDuration(value); err == nil { |
||||
return duration |
||||
} |
||||
} |
||||
if duration, err := time.ParseDuration(defaultValue); err == nil { |
||||
return duration |
||||
} |
||||
return time.Minute // Default to 1 minute if parsing fails
|
||||
} |
||||
@ -1,174 +0,0 @@
|
||||
package container |
||||
|
||||
import ( |
||||
"context" |
||||
"os" |
||||
|
||||
"autostore/internal/application/commands" |
||||
"autostore/internal/application/interfaces" |
||||
"autostore/internal/application/queries" |
||||
"autostore/internal/config" |
||||
"autostore/internal/domain/specifications" |
||||
"autostore/internal/infrastructure/auth" |
||||
"autostore/internal/infrastructure/http" |
||||
"autostore/internal/infrastructure/logging" |
||||
"autostore/internal/infrastructure/repositories" |
||||
"autostore/internal/infrastructure/scheduler" |
||||
"autostore/internal/infrastructure/services" |
||||
"autostore/internal/infrastructure/time" |
||||
"autostore/internal/presentation/controllers" |
||||
"autostore/internal/presentation/middleware" |
||||
"autostore/internal/presentation/server" |
||||
) |
||||
type Container struct { |
||||
config *config.Config |
||||
|
||||
// Infrastructure
|
||||
logger interfaces.ILogger |
||||
timeProvider interfaces.ITimeProvider |
||||
authService interfaces.IAuthService |
||||
expiredItemsScheduler *scheduler.ExpiredItemsScheduler |
||||
|
||||
// Domain
|
||||
expirationSpec *specifications.ItemExpirationSpec |
||||
|
||||
// Application
|
||||
addItemCommand *commands.AddItemCommand |
||||
deleteItemCommand *commands.DeleteItemCommand |
||||
handleExpiredItemsCommand *commands.HandleExpiredItemsCommand |
||||
loginUserCommand *commands.LoginUserCommand |
||||
getItemQuery *queries.GetItemQuery |
||||
listItemsQuery *queries.ListItemsQuery |
||||
|
||||
|
||||
// Presentation
|
||||
itemsController *controllers.ItemsController |
||||
authController *controllers.AuthController |
||||
jwtMiddleware *middleware.JWTMiddleware |
||||
server *server.Server |
||||
} |
||||
|
||||
|
||||
func NewContainer(config *config.Config) *Container { |
||||
return &Container{ |
||||
config: config, |
||||
} |
||||
} |
||||
|
||||
func (c *Container) Initialize() error { |
||||
// Initialize infrastructure
|
||||
c.logger = logging.NewStandardLogger(os.Stdout) |
||||
c.timeProvider = time.NewSystemTimeProvider() |
||||
|
||||
// Initialize user repository
|
||||
userRepository := repositories.NewFileUserRepository(c.config.DataDirectory, c.logger) |
||||
|
||||
// Initialize user initialization service and create default users
|
||||
userInitService := services.NewUserInitializationService(userRepository, c.logger) |
||||
if err := userInitService.InitializeDefaultUsers(); err != nil { |
||||
c.logger.Error(context.Background(), "Failed to initialize default users", "error", err) |
||||
// Continue even if user initialization fails
|
||||
} |
||||
|
||||
// Initialize auth service with user repository
|
||||
c.authService = auth.NewJWTAuthService(userRepository, c.config.JWTSecret, c.logger) |
||||
|
||||
// Initialize domain
|
||||
c.expirationSpec = specifications.NewItemExpirationSpec() |
||||
|
||||
// Initialize item repository
|
||||
itemRepository := repositories.NewFileItemRepository(c.config.DataDirectory, c.logger) |
||||
|
||||
// Initialize order service
|
||||
orderService := http.NewOrderURLHttpClient(c.logger) |
||||
|
||||
// Initialize application
|
||||
c.addItemCommand = commands.NewAddItemCommand(itemRepository, orderService, c.timeProvider, c.expirationSpec, c.logger) |
||||
c.deleteItemCommand = commands.NewDeleteItemCommand(itemRepository, c.logger) |
||||
c.handleExpiredItemsCommand = commands.NewHandleExpiredItemsCommand(itemRepository, orderService, c.timeProvider, c.expirationSpec, c.logger) |
||||
c.loginUserCommand = commands.NewLoginUserCommand(c.authService, c.logger) |
||||
c.getItemQuery = queries.NewGetItemQuery(itemRepository, c.logger) |
||||
c.listItemsQuery = queries.NewListItemsQuery(itemRepository, c.logger) |
||||
|
||||
// Initialize scheduler
|
||||
c.expiredItemsScheduler = scheduler.NewExpiredItemsScheduler(c.handleExpiredItemsCommand, c.logger) |
||||
|
||||
// Initialize presentation
|
||||
c.itemsController = controllers.NewItemsController(c.addItemCommand, c.getItemQuery, c.listItemsQuery, c.deleteItemCommand, c.logger) |
||||
c.authController = controllers.NewAuthController(c.loginUserCommand, c.logger) |
||||
c.jwtMiddleware = middleware.NewJWTMiddleware(c.authService, c.logger) |
||||
|
||||
serverConfig := &server.Config{ |
||||
Port: c.config.ServerPort, |
||||
ReadTimeout: c.config.ReadTimeout, |
||||
WriteTimeout: c.config.WriteTimeout, |
||||
ShutdownTimeout: c.config.ShutdownTimeout, |
||||
} |
||||
c.server = server.NewServer(serverConfig, c.logger, c.itemsController, c.authController, c.jwtMiddleware) |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// Getters for infrastructure
|
||||
func (c *Container) GetLogger() interfaces.ILogger { |
||||
return c.logger |
||||
} |
||||
|
||||
func (c *Container) GetTimeProvider() interfaces.ITimeProvider { |
||||
return c.timeProvider |
||||
} |
||||
|
||||
func (c *Container) GetAuthService() interfaces.IAuthService { |
||||
return c.authService |
||||
} |
||||
|
||||
// Getters for domain
|
||||
func (c *Container) GetExpirationSpec() *specifications.ItemExpirationSpec { |
||||
return c.expirationSpec |
||||
} |
||||
|
||||
// Getters for application
|
||||
func (c *Container) GetAddItemCommand() *commands.AddItemCommand { |
||||
return c.addItemCommand |
||||
} |
||||
|
||||
func (c *Container) GetDeleteItemCommand() *commands.DeleteItemCommand { |
||||
return c.deleteItemCommand |
||||
} |
||||
|
||||
func (c *Container) GetHandleExpiredItemsCommand() *commands.HandleExpiredItemsCommand { |
||||
return c.handleExpiredItemsCommand |
||||
} |
||||
|
||||
func (c *Container) GetLoginUserCommand() *commands.LoginUserCommand { |
||||
return c.loginUserCommand |
||||
} |
||||
|
||||
func (c *Container) GetGetItemQuery() *queries.GetItemQuery { |
||||
return c.getItemQuery |
||||
} |
||||
|
||||
func (c *Container) GetListItemsQuery() *queries.ListItemsQuery { |
||||
return c.listItemsQuery |
||||
} |
||||
|
||||
// Getters for presentation
|
||||
func (c *Container) GetItemsController() *controllers.ItemsController { |
||||
return c.itemsController |
||||
} |
||||
|
||||
func (c *Container) GetAuthController() *controllers.AuthController { |
||||
return c.authController |
||||
} |
||||
|
||||
func (c *Container) GetJWTMiddleware() *middleware.JWTMiddleware { |
||||
return c.jwtMiddleware |
||||
} |
||||
|
||||
func (c *Container) GetServer() *server.Server { |
||||
return c.server |
||||
} |
||||
|
||||
func (c *Container) GetExpiredItemsScheduler() *scheduler.ExpiredItemsScheduler { |
||||
return c.expiredItemsScheduler |
||||
} |
||||
@ -1,11 +0,0 @@
|
||||
package entities |
||||
|
||||
import "errors" |
||||
|
||||
var ( |
||||
ErrInvalidItemName = errors.New("item name cannot be empty") |
||||
ErrInvalidOrderURL = errors.New("order URL cannot be empty") |
||||
ErrInvalidUsername = errors.New("username cannot be empty") |
||||
ErrInvalidPassword = errors.New("password cannot be empty") |
||||
ErrFailedToHashPassword = errors.New("failed to hash password") |
||||
) |
||||
@ -1,118 +0,0 @@
|
||||
package entities |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"fmt" |
||||
"time" |
||||
|
||||
"autostore/internal/domain/value_objects" |
||||
) |
||||
|
||||
type itemEntityJSON struct { |
||||
ID string `json:"id"` |
||||
Name string `json:"name"` |
||||
ExpirationDate time.Time `json:"expirationDate"` |
||||
OrderURL string `json:"orderUrl"` |
||||
UserID string `json:"userId"` |
||||
CreatedAt time.Time `json:"createdAt"` |
||||
} |
||||
|
||||
type ItemEntity struct { |
||||
id value_objects.ItemID |
||||
name string |
||||
expirationDate value_objects.ExpirationDate |
||||
orderURL string |
||||
userID value_objects.UserID |
||||
createdAt time.Time |
||||
} |
||||
|
||||
func NewItem(id value_objects.ItemID, name string, expirationDate value_objects.ExpirationDate, orderURL string, userID value_objects.UserID) (*ItemEntity, error) { |
||||
if name == "" { |
||||
return nil, ErrInvalidItemName |
||||
} |
||||
|
||||
if orderURL == "" { |
||||
return nil, ErrInvalidOrderURL |
||||
} |
||||
|
||||
return &ItemEntity{ |
||||
id: id, |
||||
name: name, |
||||
expirationDate: expirationDate, |
||||
orderURL: orderURL, |
||||
userID: userID, |
||||
createdAt: time.Now(), |
||||
}, nil |
||||
} |
||||
|
||||
func (i *ItemEntity) GetID() value_objects.ItemID { |
||||
return i.id |
||||
} |
||||
|
||||
func (i *ItemEntity) GetName() string { |
||||
return i.name |
||||
} |
||||
|
||||
func (i *ItemEntity) GetExpirationDate() value_objects.ExpirationDate { |
||||
return i.expirationDate |
||||
} |
||||
|
||||
func (i *ItemEntity) ExpirationDate() time.Time { |
||||
return i.expirationDate.Time() |
||||
} |
||||
|
||||
func (i *ItemEntity) GetOrderURL() string { |
||||
return i.orderURL |
||||
} |
||||
|
||||
func (i *ItemEntity) GetUserID() value_objects.UserID { |
||||
return i.userID |
||||
} |
||||
|
||||
func (i *ItemEntity) GetCreatedAt() time.Time { |
||||
return i.createdAt |
||||
} |
||||
|
||||
// MarshalJSON implements json.Marshaler interface
|
||||
func (i *ItemEntity) MarshalJSON() ([]byte, error) { |
||||
return json.Marshal(&itemEntityJSON{ |
||||
ID: i.id.String(), |
||||
Name: i.name, |
||||
ExpirationDate: i.expirationDate.Time(), |
||||
OrderURL: i.orderURL, |
||||
UserID: i.userID.String(), |
||||
CreatedAt: i.createdAt, |
||||
}) |
||||
} |
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler interface
|
||||
func (i *ItemEntity) UnmarshalJSON(data []byte) error { |
||||
var aux itemEntityJSON |
||||
if err := json.Unmarshal(data, &aux); err != nil { |
||||
return err |
||||
} |
||||
|
||||
id, err := value_objects.NewItemIDFromString(aux.ID) |
||||
if err != nil { |
||||
return fmt.Errorf("invalid item ID: %w", err) |
||||
} |
||||
|
||||
userID, err := value_objects.NewUserIDFromString(aux.UserID) |
||||
if err != nil { |
||||
return fmt.Errorf("invalid user ID: %w", err) |
||||
} |
||||
|
||||
expirationDate, err := value_objects.NewExpirationDate(aux.ExpirationDate) |
||||
if err != nil { |
||||
return fmt.Errorf("invalid expiration date: %w", err) |
||||
} |
||||
|
||||
i.id = id |
||||
i.name = aux.Name |
||||
i.expirationDate = expirationDate |
||||
i.orderURL = aux.OrderURL |
||||
i.userID = userID |
||||
i.createdAt = aux.CreatedAt |
||||
|
||||
return nil |
||||
} |
||||
@ -1,69 +0,0 @@
|
||||
package entities |
||||
|
||||
import ( |
||||
"autostore/internal/domain/value_objects" |
||||
|
||||
"golang.org/x/crypto/bcrypt" |
||||
) |
||||
|
||||
type UserEntity struct { |
||||
id value_objects.UserID |
||||
username string |
||||
passwordHash string |
||||
} |
||||
|
||||
func NewUser(id value_objects.UserID, username string, password string) (*UserEntity, error) { |
||||
if username == "" { |
||||
return nil, ErrInvalidUsername |
||||
} |
||||
|
||||
if password == "" { |
||||
return nil, ErrInvalidPassword |
||||
} |
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) |
||||
if err != nil { |
||||
return nil, ErrFailedToHashPassword |
||||
} |
||||
|
||||
return &UserEntity{ |
||||
id: id, |
||||
username: username, |
||||
passwordHash: string(hashedPassword), |
||||
}, nil |
||||
} |
||||
|
||||
// NewUserWithHashedPassword creates a user entity with a pre-hashed password
|
||||
// This is used when reconstructing a user from the database
|
||||
func NewUserWithHashedPassword(id value_objects.UserID, username string, passwordHash string) (*UserEntity, error) { |
||||
if username == "" { |
||||
return nil, ErrInvalidUsername |
||||
} |
||||
|
||||
if passwordHash == "" { |
||||
return nil, ErrInvalidPassword |
||||
} |
||||
|
||||
return &UserEntity{ |
||||
id: id, |
||||
username: username, |
||||
passwordHash: passwordHash, |
||||
}, nil |
||||
} |
||||
|
||||
func (u *UserEntity) GetID() value_objects.UserID { |
||||
return u.id |
||||
} |
||||
|
||||
func (u *UserEntity) GetUsername() string { |
||||
return u.username |
||||
} |
||||
|
||||
func (u *UserEntity) GetPasswordHash() string { |
||||
return u.passwordHash |
||||
} |
||||
|
||||
func (u *UserEntity) ValidatePassword(password string) bool { |
||||
err := bcrypt.CompareHashAndPassword([]byte(u.passwordHash), []byte(password)) |
||||
return err == nil |
||||
} |
||||
@ -1,219 +0,0 @@
|
||||
package specifications |
||||
|
||||
const ( |
||||
GROUP_AND = "AND" |
||||
GROUP_OR = "OR" |
||||
GROUP_NOT = "NOT" |
||||
) |
||||
|
||||
const ( |
||||
OP_EQ = "=" |
||||
OP_NEQ = "!=" |
||||
OP_GT = ">" |
||||
OP_GTE = ">=" |
||||
OP_LT = "<" |
||||
OP_LTE = "<=" |
||||
OP_IN = "IN" |
||||
OP_NIN = "NOT IN" |
||||
) |
||||
|
||||
type Condition struct { |
||||
Field string `json:"field"` |
||||
Operator string `json:"operator"` |
||||
Value interface{} `json:"value"` |
||||
} |
||||
|
||||
type LogicalGroup struct { |
||||
Operator string `json:"operator"` |
||||
Conditions []Condition `json:"conditions"` |
||||
Spec *Spec `json:"spec"` |
||||
} |
||||
|
||||
type Spec struct { |
||||
Condition *Condition `json:"condition,omitempty"` |
||||
LogicalGroup *LogicalGroup `json:"logicalGroup,omitempty"` |
||||
} |
||||
|
||||
func And(conditions ...*Spec) *Spec { |
||||
if len(conditions) == 0 { |
||||
return nil |
||||
} |
||||
if len(conditions) == 1 { |
||||
return conditions[0] |
||||
} |
||||
|
||||
var flatConditions []Condition |
||||
var nestedSpecs []*Spec |
||||
|
||||
for _, spec := range conditions { |
||||
if spec.Condition != nil { |
||||
flatConditions = append(flatConditions, *spec.Condition) |
||||
} else { |
||||
nestedSpecs = append(nestedSpecs, spec) |
||||
} |
||||
} |
||||
|
||||
result := &Spec{ |
||||
LogicalGroup: &LogicalGroup{ |
||||
Operator: GROUP_AND, |
||||
Conditions: flatConditions, |
||||
}, |
||||
} |
||||
|
||||
if len(nestedSpecs) == 1 { |
||||
result.LogicalGroup.Spec = nestedSpecs[0] |
||||
} else if len(nestedSpecs) > 1 { |
||||
result.LogicalGroup.Spec = And(nestedSpecs...) |
||||
} |
||||
|
||||
return result |
||||
} |
||||
|
||||
func Or(conditions ...*Spec) *Spec { |
||||
if len(conditions) == 0 { |
||||
return nil |
||||
} |
||||
if len(conditions) == 1 { |
||||
return conditions[0] |
||||
} |
||||
|
||||
var flatConditions []Condition |
||||
var nestedSpecs []*Spec |
||||
|
||||
for _, spec := range conditions { |
||||
if spec.Condition != nil { |
||||
flatConditions = append(flatConditions, *spec.Condition) |
||||
} else { |
||||
nestedSpecs = append(nestedSpecs, spec) |
||||
} |
||||
} |
||||
|
||||
result := &Spec{ |
||||
LogicalGroup: &LogicalGroup{ |
||||
Operator: GROUP_OR, |
||||
Conditions: flatConditions, |
||||
}, |
||||
} |
||||
|
||||
if len(nestedSpecs) == 1 { |
||||
result.LogicalGroup.Spec = nestedSpecs[0] |
||||
} else if len(nestedSpecs) > 1 { |
||||
result.LogicalGroup.Spec = Or(nestedSpecs...) |
||||
} |
||||
|
||||
return result |
||||
} |
||||
|
||||
func Not(condition *Spec) *Spec { |
||||
return &Spec{ |
||||
LogicalGroup: &LogicalGroup{ |
||||
Operator: GROUP_NOT, |
||||
Spec: condition, |
||||
}, |
||||
} |
||||
} |
||||
|
||||
func Eq(field string, value interface{}) *Spec { |
||||
return &Spec{ |
||||
Condition: &Condition{ |
||||
Field: field, |
||||
Operator: OP_EQ, |
||||
Value: value, |
||||
}, |
||||
} |
||||
} |
||||
|
||||
func Neq(field string, value interface{}) *Spec { |
||||
return &Spec{ |
||||
Condition: &Condition{ |
||||
Field: field, |
||||
Operator: OP_NEQ, |
||||
Value: value, |
||||
}, |
||||
} |
||||
} |
||||
|
||||
func Gt(field string, value interface{}) *Spec { |
||||
return &Spec{ |
||||
Condition: &Condition{ |
||||
Field: field, |
||||
Operator: OP_GT, |
||||
Value: value, |
||||
}, |
||||
} |
||||
} |
||||
|
||||
func Gte(field string, value interface{}) *Spec { |
||||
return &Spec{ |
||||
Condition: &Condition{ |
||||
Field: field, |
||||
Operator: OP_GTE, |
||||
Value: value, |
||||
}, |
||||
} |
||||
} |
||||
|
||||
func Lt(field string, value interface{}) *Spec { |
||||
return &Spec{ |
||||
Condition: &Condition{ |
||||
Field: field, |
||||
Operator: OP_LT, |
||||
Value: value, |
||||
}, |
||||
} |
||||
} |
||||
|
||||
func Lte(field string, value interface{}) *Spec { |
||||
return &Spec{ |
||||
Condition: &Condition{ |
||||
Field: field, |
||||
Operator: OP_LTE, |
||||
Value: value, |
||||
}, |
||||
} |
||||
} |
||||
|
||||
func In(field string, values []interface{}) *Spec { |
||||
return &Spec{ |
||||
Condition: &Condition{ |
||||
Field: field, |
||||
Operator: OP_IN, |
||||
Value: values, |
||||
}, |
||||
} |
||||
} |
||||
|
||||
func Nin(field string, values []interface{}) *Spec { |
||||
return &Spec{ |
||||
Condition: &Condition{ |
||||
Field: field, |
||||
Operator: OP_NIN, |
||||
Value: values, |
||||
}, |
||||
} |
||||
} |
||||
|
||||
func GetConditions(spec *Spec) []Condition { |
||||
var conditions []Condition |
||||
flattenConditions(spec, &conditions) |
||||
return conditions |
||||
} |
||||
|
||||
func flattenConditions(spec *Spec, conditions *[]Condition) { |
||||
if spec == nil { |
||||
return |
||||
} |
||||
|
||||
if spec.Condition != nil { |
||||
*conditions = append(*conditions, *spec.Condition) |
||||
} |
||||
|
||||
if spec.LogicalGroup != nil { |
||||
for _, cond := range spec.LogicalGroup.Conditions { |
||||
*conditions = append(*conditions, cond) |
||||
} |
||||
if spec.LogicalGroup.Spec != nil { |
||||
flattenConditions(spec.LogicalGroup.Spec, conditions) |
||||
} |
||||
} |
||||
} |
||||
@ -1,29 +0,0 @@
|
||||
package specifications |
||||
|
||||
import ( |
||||
"autostore/internal/domain/entities" |
||||
"time" |
||||
) |
||||
|
||||
type ItemExpirationSpec struct{} |
||||
|
||||
func NewItemExpirationSpec() *ItemExpirationSpec { |
||||
return &ItemExpirationSpec{} |
||||
} |
||||
|
||||
// IsExpired checks if an item is expired using the new condition-based specification
|
||||
func (s *ItemExpirationSpec) IsExpired(item *entities.ItemEntity, currentTime time.Time) bool { |
||||
return s.GetSpec(currentTime).IsSatisfiedBy(item) |
||||
} |
||||
|
||||
// GetSpec returns a condition-based specification for checking item expiration
|
||||
func (s *ItemExpirationSpec) GetSpec(currentTime time.Time) Specification[*entities.ItemEntity] { |
||||
// Create a condition that checks if expirationDate <= currentTime
|
||||
spec := Lte("expirationDate", currentTime) |
||||
return NewSimpleSpecification[*entities.ItemEntity](spec) |
||||
} |
||||
|
||||
// GetConditionSpec returns the raw condition spec for query generation
|
||||
func (s *ItemExpirationSpec) GetConditionSpec(currentTime time.Time) *Spec { |
||||
return Lte("expirationDate", currentTime) |
||||
} |
||||
@ -1,407 +0,0 @@
|
||||
package specifications |
||||
|
||||
import ( |
||||
"fmt" |
||||
"reflect" |
||||
"strings" |
||||
"time" |
||||
) |
||||
|
||||
type Specification[T any] interface { |
||||
IsSatisfiedBy(candidate T) bool |
||||
And(other Specification[T]) Specification[T] |
||||
Or(other Specification[T]) Specification[T] |
||||
Not() Specification[T] |
||||
GetConditions() []Condition |
||||
GetSpec() *Spec |
||||
} |
||||
|
||||
type SimpleSpecification[T any] struct { |
||||
spec *Spec |
||||
} |
||||
|
||||
func NewSimpleSpecification[T any](spec *Spec) *SimpleSpecification[T] { |
||||
return &SimpleSpecification[T]{spec: spec} |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) IsSatisfiedBy(candidate T) bool { |
||||
return s.evaluateSpec(s.spec, candidate) |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) And(other Specification[T]) Specification[T] { |
||||
return &CompositeSpecification[T]{left: s, right: other, op: "AND"} |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) Or(other Specification[T]) Specification[T] { |
||||
return &CompositeSpecification[T]{left: s, right: other, op: "OR"} |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) Not() Specification[T] { |
||||
return NewSimpleSpecification[T](Not(s.spec)) |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) GetSpec() *Spec { |
||||
return s.spec |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) GetConditions() []Condition { |
||||
return GetConditions(s.spec) |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) evaluateSpec(spec *Spec, candidate T) bool { |
||||
if spec == nil { |
||||
return false |
||||
} |
||||
|
||||
if spec.LogicalGroup != nil { |
||||
return s.evaluateLogicalGroup(spec.LogicalGroup, candidate) |
||||
} |
||||
|
||||
if spec.Condition != nil { |
||||
return s.evaluateCondition(*spec.Condition, candidate) |
||||
} |
||||
|
||||
return false |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) evaluateLogicalGroup(group *LogicalGroup, candidate T) bool { |
||||
switch group.Operator { |
||||
case GROUP_AND: |
||||
return s.evaluateAndGroup(group, candidate) |
||||
case GROUP_OR: |
||||
return s.evaluateOrGroup(group, candidate) |
||||
case GROUP_NOT: |
||||
if group.Spec != nil { |
||||
return !s.evaluateSpec(group.Spec, candidate) |
||||
} |
||||
} |
||||
return false |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) evaluateAndGroup(group *LogicalGroup, candidate T) bool { |
||||
for _, cond := range group.Conditions { |
||||
if !s.evaluateCondition(cond, candidate) { |
||||
return false |
||||
} |
||||
} |
||||
|
||||
if group.Spec != nil { |
||||
return s.evaluateSpec(group.Spec, candidate) |
||||
} |
||||
|
||||
return len(group.Conditions) > 0 || group.Spec != nil |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) evaluateOrGroup(group *LogicalGroup, candidate T) bool { |
||||
for _, cond := range group.Conditions { |
||||
if s.evaluateCondition(cond, candidate) { |
||||
return true |
||||
} |
||||
} |
||||
|
||||
if group.Spec != nil { |
||||
return s.evaluateSpec(group.Spec, candidate) |
||||
} |
||||
|
||||
return false |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) evaluateCondition(condition Condition, candidate T) bool { |
||||
fieldValue, err := s.getFieldValue(candidate, condition.Field) |
||||
if err != nil { |
||||
return false |
||||
} |
||||
|
||||
return s.compareValues(fieldValue, condition.Operator, condition.Value) |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) getFieldValue(candidate T, fieldName string) (interface{}, error) { |
||||
v := reflect.ValueOf(candidate) |
||||
|
||||
if v.Kind() == reflect.Ptr { |
||||
if v.IsNil() { |
||||
return nil, fmt.Errorf("candidate is nil") |
||||
} |
||||
v = v.Elem() |
||||
} |
||||
|
||||
if v.Kind() != reflect.Struct { |
||||
return nil, fmt.Errorf("candidate is not a struct") |
||||
} |
||||
|
||||
getterName := "Get" + strings.Title(fieldName) |
||||
|
||||
originalV := reflect.ValueOf(candidate) |
||||
if method := originalV.MethodByName(getterName); method.IsValid() { |
||||
return s.callMethod(method) |
||||
} |
||||
|
||||
if method := originalV.MethodByName(fieldName); method.IsValid() { |
||||
return s.callMethod(method) |
||||
} |
||||
|
||||
if v.Kind() == reflect.Struct { |
||||
if method := v.MethodByName(getterName); method.IsValid() { |
||||
return s.callMethod(method) |
||||
} |
||||
|
||||
if method := v.MethodByName(fieldName); method.IsValid() { |
||||
return s.callMethod(method) |
||||
} |
||||
|
||||
if field := v.FieldByName(fieldName); field.IsValid() && field.CanInterface() { |
||||
return field.Interface(), nil |
||||
} |
||||
} |
||||
|
||||
return nil, fmt.Errorf("field %s not found", fieldName) |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) callMethod(method reflect.Value) (interface{}, error) { |
||||
if !method.IsValid() || method.Type().NumIn() != 0 || method.Type().NumOut() == 0 { |
||||
return nil, fmt.Errorf("invalid method") |
||||
} |
||||
|
||||
results := method.Call(nil) |
||||
if len(results) == 0 { |
||||
return nil, fmt.Errorf("method returned no values") |
||||
} |
||||
|
||||
return results[0].Interface(), nil |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) compareValues(fieldValue interface{}, operator string, compareValue interface{}) bool { |
||||
if fieldValue == nil { |
||||
return (operator == OP_EQ && compareValue == nil) || (operator == OP_NEQ && compareValue != nil) |
||||
} |
||||
|
||||
if s.isTimeComparable(fieldValue, compareValue) { |
||||
return s.compareTimes(fieldValue, operator, compareValue) |
||||
} |
||||
|
||||
if operator == OP_IN || operator == OP_NIN { |
||||
return s.compareIn(fieldValue, operator, compareValue) |
||||
} |
||||
|
||||
return s.compareGeneral(fieldValue, operator, compareValue) |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) isTimeComparable(fieldValue, compareValue interface{}) bool { |
||||
_, fieldIsTime := fieldValue.(time.Time) |
||||
_, compareIsTime := compareValue.(time.Time) |
||||
|
||||
if fieldIsTime || compareIsTime { |
||||
return true |
||||
} |
||||
|
||||
return s.hasTimeMethod(fieldValue) || s.hasTimeMethod(compareValue) |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) hasTimeMethod(value interface{}) bool { |
||||
v := reflect.ValueOf(value) |
||||
if !v.IsValid() || v.Kind() == reflect.Ptr { |
||||
return false |
||||
} |
||||
|
||||
method := v.MethodByName("Time") |
||||
return method.IsValid() && method.Type().NumIn() == 0 && method.Type().NumOut() == 1 |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) compareTimes(fieldValue interface{}, operator string, compareValue interface{}) bool { |
||||
fieldTime := s.extractTime(fieldValue) |
||||
compareTime := s.extractTime(compareValue) |
||||
|
||||
if fieldTime == nil || compareTime == nil { |
||||
return false |
||||
} |
||||
|
||||
switch operator { |
||||
case OP_EQ: |
||||
return fieldTime.Equal(*compareTime) |
||||
case OP_NEQ: |
||||
return !fieldTime.Equal(*compareTime) |
||||
case OP_GT: |
||||
return fieldTime.After(*compareTime) |
||||
case OP_GTE: |
||||
return fieldTime.After(*compareTime) || fieldTime.Equal(*compareTime) |
||||
case OP_LT: |
||||
return fieldTime.Before(*compareTime) |
||||
case OP_LTE: |
||||
return fieldTime.Before(*compareTime) || fieldTime.Equal(*compareTime) |
||||
} |
||||
|
||||
return false |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) extractTime(value interface{}) *time.Time { |
||||
switch v := value.(type) { |
||||
case time.Time: |
||||
return &v |
||||
case string: |
||||
if t, err := time.Parse(time.RFC3339, v); err == nil { |
||||
return &t |
||||
} |
||||
default: |
||||
if method := reflect.ValueOf(value).MethodByName("Time"); method.IsValid() { |
||||
if results := method.Call(nil); len(results) > 0 { |
||||
if t, ok := results[0].Interface().(time.Time); ok { |
||||
return &t |
||||
} |
||||
} |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) compareIn(fieldValue interface{}, operator string, compareValue interface{}) bool { |
||||
compareSlice, ok := compareValue.([]interface{}) |
||||
if !ok { |
||||
return false |
||||
} |
||||
|
||||
for _, v := range compareSlice { |
||||
if reflect.DeepEqual(fieldValue, v) { |
||||
return operator == OP_IN |
||||
} |
||||
} |
||||
|
||||
return operator == OP_NIN |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) compareGeneral(fieldValue interface{}, operator string, compareValue interface{}) bool { |
||||
fieldVal := reflect.ValueOf(fieldValue) |
||||
compareVal := reflect.ValueOf(compareValue) |
||||
|
||||
if !s.makeComparable(&fieldVal, &compareVal) { |
||||
return false |
||||
} |
||||
|
||||
switch operator { |
||||
case OP_EQ: |
||||
return reflect.DeepEqual(fieldValue, compareValue) |
||||
case OP_NEQ: |
||||
return !reflect.DeepEqual(fieldValue, compareValue) |
||||
case OP_GT: |
||||
return s.isGreater(fieldVal, compareVal) |
||||
case OP_GTE: |
||||
return s.isGreater(fieldVal, compareVal) || reflect.DeepEqual(fieldValue, compareValue) |
||||
case OP_LT: |
||||
return s.isLess(fieldVal, compareVal) |
||||
case OP_LTE: |
||||
return s.isLess(fieldVal, compareVal) || reflect.DeepEqual(fieldValue, compareValue) |
||||
} |
||||
|
||||
return false |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) makeComparable(fieldVal, compareVal *reflect.Value) bool { |
||||
if fieldVal.Kind() == compareVal.Kind() { |
||||
return true |
||||
} |
||||
|
||||
if compareVal.CanConvert(fieldVal.Type()) { |
||||
*compareVal = compareVal.Convert(fieldVal.Type()) |
||||
return true |
||||
} |
||||
|
||||
if fieldVal.CanConvert(compareVal.Type()) { |
||||
*fieldVal = fieldVal.Convert(compareVal.Type()) |
||||
return true |
||||
} |
||||
|
||||
return false |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) isGreater(fieldVal, compareVal reflect.Value) bool { |
||||
switch fieldVal.Kind() { |
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: |
||||
return fieldVal.Int() > compareVal.Int() |
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: |
||||
return fieldVal.Uint() > compareVal.Uint() |
||||
case reflect.Float32, reflect.Float64: |
||||
return fieldVal.Float() > compareVal.Float() |
||||
case reflect.String: |
||||
return fieldVal.String() > compareVal.String() |
||||
} |
||||
return false |
||||
} |
||||
|
||||
func (s *SimpleSpecification[T]) isLess(fieldVal, compareVal reflect.Value) bool { |
||||
switch fieldVal.Kind() { |
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: |
||||
return fieldVal.Int() < compareVal.Int() |
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: |
||||
return fieldVal.Uint() < compareVal.Uint() |
||||
case reflect.Float32, reflect.Float64: |
||||
return fieldVal.Float() < compareVal.Float() |
||||
case reflect.String: |
||||
return fieldVal.String() < compareVal.String() |
||||
} |
||||
return false |
||||
} |
||||
|
||||
type CompositeSpecification[T any] struct { |
||||
left Specification[T] |
||||
right Specification[T] |
||||
op string |
||||
} |
||||
|
||||
func (c *CompositeSpecification[T]) IsSatisfiedBy(candidate T) bool { |
||||
switch c.op { |
||||
case "AND": |
||||
return c.left.IsSatisfiedBy(candidate) && c.right.IsSatisfiedBy(candidate) |
||||
case "OR": |
||||
return c.left.IsSatisfiedBy(candidate) || c.right.IsSatisfiedBy(candidate) |
||||
} |
||||
return false |
||||
} |
||||
|
||||
func (c *CompositeSpecification[T]) And(other Specification[T]) Specification[T] { |
||||
return &CompositeSpecification[T]{left: c, right: other, op: "AND"} |
||||
} |
||||
|
||||
func (c *CompositeSpecification[T]) Or(other Specification[T]) Specification[T] { |
||||
return &CompositeSpecification[T]{left: c, right: other, op: "OR"} |
||||
} |
||||
|
||||
func (c *CompositeSpecification[T]) Not() Specification[T] { |
||||
return &NotSpecification[T]{spec: c} |
||||
} |
||||
|
||||
func (c *CompositeSpecification[T]) GetConditions() []Condition { |
||||
leftConditions := c.left.GetConditions() |
||||
rightConditions := c.right.GetConditions() |
||||
return append(leftConditions, rightConditions...) |
||||
} |
||||
|
||||
func (c *CompositeSpecification[T]) GetSpec() *Spec { |
||||
return nil |
||||
} |
||||
|
||||
type NotSpecification[T any] struct { |
||||
spec Specification[T] |
||||
} |
||||
|
||||
func (n *NotSpecification[T]) IsSatisfiedBy(candidate T) bool { |
||||
return !n.spec.IsSatisfiedBy(candidate) |
||||
} |
||||
|
||||
func (n *NotSpecification[T]) And(other Specification[T]) Specification[T] { |
||||
return &CompositeSpecification[T]{left: n, right: other, op: "AND"} |
||||
} |
||||
|
||||
func (n *NotSpecification[T]) Or(other Specification[T]) Specification[T] { |
||||
return &CompositeSpecification[T]{left: n, right: other, op: "OR"} |
||||
} |
||||
|
||||
func (n *NotSpecification[T]) Not() Specification[T] { |
||||
return n.spec |
||||
} |
||||
|
||||
func (n *NotSpecification[T]) GetConditions() []Condition { |
||||
return n.spec.GetConditions() |
||||
} |
||||
|
||||
func (n *NotSpecification[T]) GetSpec() *Spec { |
||||
return nil |
||||
} |
||||
@ -1,45 +0,0 @@
|
||||
package value_objects |
||||
|
||||
import ( |
||||
"errors" |
||||
|
||||
"github.com/google/uuid" |
||||
) |
||||
|
||||
var ( |
||||
ErrInvalidUUID = errors.New("invalid UUID format") |
||||
) |
||||
|
||||
type BaseUUID struct { |
||||
value string |
||||
} |
||||
|
||||
func NewBaseUUID(value string) (BaseUUID, error) { |
||||
if value == "" { |
||||
return BaseUUID{}, ErrInvalidUUID |
||||
} |
||||
|
||||
_, err := uuid.Parse(value) |
||||
if err != nil { |
||||
return BaseUUID{}, ErrInvalidUUID |
||||
} |
||||
|
||||
return BaseUUID{value: value}, nil |
||||
} |
||||
|
||||
func NewRandomBaseUUID() (BaseUUID, error) { |
||||
id, err := uuid.NewRandom() |
||||
if err != nil { |
||||
return BaseUUID{}, err |
||||
} |
||||
|
||||
return BaseUUID{value: id.String()}, nil |
||||
} |
||||
|
||||
func (b BaseUUID) String() string { |
||||
return b.value |
||||
} |
||||
|
||||
func (b BaseUUID) Equals(other BaseUUID) bool { |
||||
return b.value == other.value |
||||
} |
||||
@ -1,23 +0,0 @@
|
||||
package value_objects |
||||
|
||||
import ( |
||||
"time" |
||||
) |
||||
|
||||
type ExpirationDate struct { |
||||
value time.Time |
||||
} |
||||
|
||||
func NewExpirationDate(value time.Time) (ExpirationDate, error) { |
||||
// According to business rules, expired items can be added to the store,
|
||||
// so we don't need to validate if the date is in the past
|
||||
return ExpirationDate{value: value}, nil |
||||
} |
||||
|
||||
func (e ExpirationDate) Time() time.Time { |
||||
return e.value |
||||
} |
||||
|
||||
func (e ExpirationDate) String() string { |
||||
return e.value.Format(time.RFC3339) |
||||
} |
||||
@ -1,38 +0,0 @@
|
||||
package value_objects |
||||
|
||||
import ( |
||||
"errors" |
||||
) |
||||
|
||||
var ( |
||||
ErrInvalidItemID = errors.New("invalid item ID") |
||||
) |
||||
|
||||
type ItemID struct { |
||||
BaseUUID |
||||
} |
||||
|
||||
func NewItemID(value string) (ItemID, error) { |
||||
baseUUID, err := NewBaseUUID(value) |
||||
if err != nil { |
||||
return ItemID{}, ErrInvalidItemID |
||||
} |
||||
|
||||
return ItemID{BaseUUID: baseUUID}, nil |
||||
} |
||||
|
||||
func NewRandomItemID() (ItemID, error) { |
||||
baseUUID, err := NewRandomBaseUUID() |
||||
if err != nil { |
||||
return ItemID{}, ErrInvalidItemID |
||||
} |
||||
|
||||
return ItemID{BaseUUID: baseUUID}, nil |
||||
} |
||||
func NewItemIDFromString(value string) (ItemID, error) { |
||||
return NewItemID(value) |
||||
} |
||||
|
||||
func (i ItemID) Equals(other ItemID) bool { |
||||
return i.BaseUUID.Equals(other.BaseUUID) |
||||
} |
||||
@ -1,38 +0,0 @@
|
||||
package value_objects |
||||
|
||||
import ( |
||||
"errors" |
||||
) |
||||
|
||||
var ( |
||||
ErrInvalidUserID = errors.New("invalid user ID") |
||||
) |
||||
|
||||
type UserID struct { |
||||
BaseUUID |
||||
} |
||||
|
||||
func NewUserID(value string) (UserID, error) { |
||||
baseUUID, err := NewBaseUUID(value) |
||||
if err != nil { |
||||
return UserID{}, ErrInvalidUserID |
||||
} |
||||
|
||||
return UserID{BaseUUID: baseUUID}, nil |
||||
} |
||||
|
||||
func NewRandomUserID() (UserID, error) { |
||||
baseUUID, err := NewRandomBaseUUID() |
||||
if err != nil { |
||||
return UserID{}, ErrInvalidUserID |
||||
} |
||||
|
||||
return UserID{BaseUUID: baseUUID}, nil |
||||
} |
||||
func NewUserIDFromString(value string) (UserID, error) { |
||||
return NewUserID(value) |
||||
} |
||||
|
||||
func (u UserID) Equals(other UserID) bool { |
||||
return u.BaseUUID.Equals(other.BaseUUID) |
||||
} |
||||
@ -1,126 +0,0 @@
|
||||
package auth |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"fmt" |
||||
"time" |
||||
|
||||
"github.com/golang-jwt/jwt/v4" |
||||
|
||||
"autostore/internal/application/interfaces" |
||||
"autostore/internal/domain/entities" |
||||
) |
||||
|
||||
var ( |
||||
ErrInvalidCredentials = errors.New("invalid credentials") |
||||
ErrInvalidToken = errors.New("invalid token") |
||||
ErrTokenExpired = errors.New("token expired") |
||||
) |
||||
|
||||
type JWTAuthService struct { |
||||
userRepo interfaces.IUserRepository |
||||
secretKey string |
||||
logger interfaces.ILogger |
||||
} |
||||
|
||||
func NewJWTAuthService( |
||||
userRepo interfaces.IUserRepository, |
||||
secretKey string, |
||||
logger interfaces.ILogger, |
||||
) *JWTAuthService { |
||||
return &JWTAuthService{ |
||||
userRepo: userRepo, |
||||
secretKey: secretKey, |
||||
logger: logger, |
||||
} |
||||
} |
||||
|
||||
func (s *JWTAuthService) Authenticate(ctx context.Context, username string, password string) (string, error) { |
||||
user, err := s.userRepo.FindByUsername(ctx, username) |
||||
if err != nil { |
||||
s.logger.Error(ctx, "Failed to find user by username", "error", err, "username", username) |
||||
return "", ErrInvalidCredentials |
||||
} |
||||
|
||||
if user == nil { |
||||
s.logger.Warn(ctx, "User not found", "username", username) |
||||
return "", ErrInvalidCredentials |
||||
} |
||||
|
||||
if !user.ValidatePassword(password) { |
||||
s.logger.Warn(ctx, "Invalid password", "username", username) |
||||
return "", ErrInvalidCredentials |
||||
} |
||||
|
||||
token, err := s.generateToken(user) |
||||
if err != nil { |
||||
s.logger.Error(ctx, "Failed to generate token", "error", err, "username", username) |
||||
return "", err |
||||
} |
||||
|
||||
s.logger.Info(ctx, "User authenticated successfully", "username", username, "userID", user.GetID().String()) |
||||
return token, nil |
||||
} |
||||
|
||||
func (s *JWTAuthService) ValidateToken(ctx context.Context, tokenString string) (bool, error) { |
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { |
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { |
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) |
||||
} |
||||
return []byte(s.secretKey), nil |
||||
}) |
||||
|
||||
if err != nil { |
||||
s.logger.Warn(ctx, "Token validation failed", "error", err) |
||||
return false, nil |
||||
} |
||||
|
||||
return token.Valid, nil |
||||
} |
||||
|
||||
func (s *JWTAuthService) GetUserIDFromToken(ctx context.Context, tokenString string) (string, error) { |
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { |
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { |
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) |
||||
} |
||||
return []byte(s.secretKey), nil |
||||
}) |
||||
|
||||
if err != nil { |
||||
s.logger.Warn(ctx, "Failed to parse token", "error", err) |
||||
return "", ErrInvalidToken |
||||
} |
||||
|
||||
if !token.Valid { |
||||
s.logger.Warn(ctx, "Invalid token") |
||||
return "", ErrInvalidToken |
||||
} |
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims) |
||||
if !ok { |
||||
s.logger.Warn(ctx, "Invalid token claims") |
||||
return "", ErrInvalidToken |
||||
} |
||||
|
||||
userID, ok := claims["sub"].(string) |
||||
if !ok { |
||||
s.logger.Warn(ctx, "User ID not found in token claims") |
||||
return "", ErrInvalidToken |
||||
} |
||||
|
||||
return userID, nil |
||||
} |
||||
|
||||
func (s *JWTAuthService) generateToken(user *entities.UserEntity) (string, error) { |
||||
claims := jwt.MapClaims{ |
||||
"sub": user.GetID().String(), |
||||
"username": user.GetUsername(), |
||||
"iss": "autostore", |
||||
"iat": time.Now().Unix(), |
||||
"exp": time.Now().Add(time.Hour * 24).Unix(), // 24 hours expiration
|
||||
} |
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) |
||||
return token.SignedString([]byte(s.secretKey)) |
||||
} |
||||
@ -1,80 +0,0 @@
|
||||
package http |
||||
|
||||
import ( |
||||
"bytes" |
||||
"context" |
||||
"encoding/json" |
||||
"fmt" |
||||
"net/http" |
||||
"time" |
||||
|
||||
"autostore/internal/application/interfaces" |
||||
"autostore/internal/domain/entities" |
||||
) |
||||
|
||||
type OrderRequest struct { |
||||
ItemID string `json:"itemId"` |
||||
ItemName string `json:"itemName"` |
||||
OrderURL string `json:"orderUrl"` |
||||
UserID string `json:"userId"` |
||||
OrderedAt time.Time `json:"orderedAt"` |
||||
} |
||||
|
||||
type OrderURLHttpClient struct { |
||||
client *http.Client |
||||
logger interfaces.ILogger |
||||
} |
||||
|
||||
func NewOrderURLHttpClient(logger interfaces.ILogger) *OrderURLHttpClient { |
||||
return &OrderURLHttpClient{ |
||||
client: &http.Client{ |
||||
Timeout: 30 * time.Second, |
||||
}, |
||||
logger: logger, |
||||
} |
||||
} |
||||
|
||||
func (c *OrderURLHttpClient) OrderItem(ctx context.Context, item *entities.ItemEntity) error { |
||||
orderURL := item.GetOrderURL() |
||||
if orderURL == "" { |
||||
return fmt.Errorf("order URL is empty") |
||||
} |
||||
|
||||
orderRequest := OrderRequest{ |
||||
ItemID: item.GetID().String(), |
||||
ItemName: item.GetName(), |
||||
OrderURL: orderURL, |
||||
UserID: item.GetUserID().String(), |
||||
OrderedAt: time.Now(), |
||||
} |
||||
|
||||
jsonData, err := json.Marshal(orderRequest) |
||||
if err != nil { |
||||
return fmt.Errorf("failed to marshal order request: %w", err) |
||||
} |
||||
|
||||
c.logger.Info(ctx, "Sending order request", "orderURL", orderURL, "itemID", orderRequest.ItemID) |
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", orderURL, bytes.NewBuffer(jsonData)) |
||||
if err != nil { |
||||
return fmt.Errorf("failed to create request: %w", err) |
||||
} |
||||
|
||||
req.Header.Set("Content-Type", "application/json") |
||||
|
||||
resp, err := c.client.Do(req) |
||||
if err != nil { |
||||
return fmt.Errorf("failed to send order request: %w", err) |
||||
} |
||||
defer resp.Body.Close() |
||||
|
||||
// We don't care about the response body, just that the request was sent
|
||||
if resp.StatusCode >= 400 { |
||||
c.logger.Warn(ctx, "Order request returned non-success status", "status", resp.StatusCode, "orderURL", orderURL) |
||||
// Don't fail the operation, just log the warning
|
||||
return nil |
||||
} |
||||
|
||||
c.logger.Info(ctx, "Order request sent successfully", "status", resp.StatusCode, "orderURL", orderURL) |
||||
return nil |
||||
} |
||||
@ -1,41 +0,0 @@
|
||||
package logging |
||||
|
||||
import ( |
||||
"context" |
||||
"io" |
||||
"log" |
||||
) |
||||
|
||||
type StandardLogger struct { |
||||
logger *log.Logger |
||||
} |
||||
|
||||
func NewStandardLogger(writer io.Writer) *StandardLogger { |
||||
return &StandardLogger{ |
||||
logger: log.New(writer, "", log.LstdFlags), |
||||
} |
||||
} |
||||
|
||||
func (l *StandardLogger) Info(ctx context.Context, msg string, fields ...interface{}) { |
||||
l.log("INFO", msg, fields...) |
||||
} |
||||
|
||||
func (l *StandardLogger) Error(ctx context.Context, msg string, fields ...interface{}) { |
||||
l.log("ERROR", msg, fields...) |
||||
} |
||||
|
||||
func (l *StandardLogger) Debug(ctx context.Context, msg string, fields ...interface{}) { |
||||
l.log("DEBUG", msg, fields...) |
||||
} |
||||
|
||||
func (l *StandardLogger) Warn(ctx context.Context, msg string, fields ...interface{}) { |
||||
l.log("WARN", msg, fields...) |
||||
} |
||||
|
||||
func (l *StandardLogger) log(level, msg string, fields ...interface{}) { |
||||
if len(fields) > 0 { |
||||
l.logger.Printf("[%s] %s %v", level, msg, fields) |
||||
} else { |
||||
l.logger.Printf("[%s] %s", level, msg) |
||||
} |
||||
} |
||||
@ -1,223 +0,0 @@
|
||||
package repositories |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"fmt" |
||||
"os" |
||||
"path/filepath" |
||||
"sync" |
||||
|
||||
"autostore/internal/application/interfaces" |
||||
"autostore/internal/domain/entities" |
||||
"autostore/internal/domain/specifications" |
||||
"autostore/internal/domain/value_objects" |
||||
) |
||||
|
||||
type FileItemRepository struct { |
||||
dataDirectory string |
||||
logger interfaces.ILogger |
||||
mu sync.RWMutex |
||||
} |
||||
|
||||
func NewFileItemRepository(dataDirectory string, logger interfaces.ILogger) *FileItemRepository { |
||||
return &FileItemRepository{ |
||||
dataDirectory: dataDirectory, |
||||
logger: logger, |
||||
} |
||||
} |
||||
|
||||
func (r *FileItemRepository) Save(ctx context.Context, item *entities.ItemEntity) error { |
||||
r.logger.Info(ctx, "Saving item", "itemID", item.GetID().String()) |
||||
|
||||
filePath := filepath.Join(r.dataDirectory, "items.json") |
||||
|
||||
r.mu.Lock() |
||||
defer r.mu.Unlock() |
||||
|
||||
items, err := r.loadItems(filePath) |
||||
if err != nil { |
||||
r.logger.Error(ctx, "Failed to load items", "error", err) |
||||
return fmt.Errorf("failed to load items: %w", err) |
||||
} |
||||
|
||||
items[item.GetID().String()] = item |
||||
if err := r.saveItems(filePath, items); err != nil { |
||||
r.logger.Error(ctx, "Failed to save items", "error", err) |
||||
return fmt.Errorf("failed to save items: %w", err) |
||||
} |
||||
|
||||
r.logger.Info(ctx, "Item saved successfully", "itemID", item.GetID().String()) |
||||
return nil |
||||
} |
||||
|
||||
func (r *FileItemRepository) FindByID(ctx context.Context, id value_objects.ItemID) (*entities.ItemEntity, error) { |
||||
r.logger.Info(ctx, "Finding item by ID", "itemID", id.String()) |
||||
|
||||
filePath := filepath.Join(r.dataDirectory, "items.json") |
||||
|
||||
r.mu.RLock() |
||||
defer r.mu.RUnlock() |
||||
|
||||
items, err := r.loadItems(filePath) |
||||
if err != nil { |
||||
r.logger.Error(ctx, "Failed to load items", "error", err) |
||||
return nil, fmt.Errorf("failed to load items: %w", err) |
||||
} |
||||
|
||||
item, ok := items[id.String()] |
||||
if !ok { |
||||
r.logger.Info(ctx, "Item not found", "itemID", id.String()) |
||||
return nil, fmt.Errorf("item with ID %s not found", id.String()) |
||||
} |
||||
|
||||
r.logger.Info(ctx, "Item found successfully", "itemID", id.String()) |
||||
return item, nil |
||||
} |
||||
|
||||
func (r *FileItemRepository) FindByUserID(ctx context.Context, userID value_objects.UserID) ([]*entities.ItemEntity, error) { |
||||
r.logger.Info(ctx, "Finding items by user ID", "userID", userID.String()) |
||||
|
||||
filePath := filepath.Join(r.dataDirectory, "items.json") |
||||
|
||||
r.mu.RLock() |
||||
defer r.mu.RUnlock() |
||||
|
||||
items, err := r.loadItems(filePath) |
||||
if err != nil { |
||||
r.logger.Error(ctx, "Failed to load items", "error", err) |
||||
return nil, fmt.Errorf("failed to load items: %w", err) |
||||
} |
||||
|
||||
var userItems []*entities.ItemEntity |
||||
for _, item := range items { |
||||
if item.GetUserID().Equals(userID) { |
||||
userItems = append(userItems, item) |
||||
} |
||||
} |
||||
|
||||
r.logger.Info(ctx, "Found items for user", "userID", userID.String(), "count", len(userItems)) |
||||
return userItems, nil |
||||
} |
||||
|
||||
func (r *FileItemRepository) FindWhere(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) { |
||||
r.logger.Info(ctx, "Finding items by specification") |
||||
|
||||
filePath := filepath.Join(r.dataDirectory, "items.json") |
||||
|
||||
r.mu.RLock() |
||||
defer r.mu.RUnlock() |
||||
|
||||
items, err := r.loadItems(filePath) |
||||
if err != nil { |
||||
r.logger.Error(ctx, "Failed to load items", "error", err) |
||||
return nil, fmt.Errorf("failed to load items: %w", err) |
||||
} |
||||
|
||||
var filteredItems []*entities.ItemEntity |
||||
for _, item := range items { |
||||
if spec.IsSatisfiedBy(item) { |
||||
filteredItems = append(filteredItems, item) |
||||
} |
||||
} |
||||
|
||||
r.logger.Info(ctx, "Found items matching specification", "count", len(filteredItems)) |
||||
return filteredItems, nil |
||||
} |
||||
|
||||
func (r *FileItemRepository) Delete(ctx context.Context, id value_objects.ItemID) error { |
||||
r.logger.Info(ctx, "Deleting item", "itemID", id.String()) |
||||
|
||||
filePath := filepath.Join(r.dataDirectory, "items.json") |
||||
|
||||
r.mu.Lock() |
||||
defer r.mu.Unlock() |
||||
|
||||
items, err := r.loadItems(filePath) |
||||
if err != nil { |
||||
r.logger.Error(ctx, "Failed to load items", "error", err) |
||||
return fmt.Errorf("failed to load items: %w", err) |
||||
} |
||||
|
||||
if _, exists := items[id.String()]; !exists { |
||||
r.logger.Info(ctx, "Item not found for deletion", "itemID", id.String()) |
||||
return fmt.Errorf("item with ID %s not found", id.String()) |
||||
} |
||||
|
||||
delete(items, id.String()) |
||||
if err := r.saveItems(filePath, items); err != nil { |
||||
r.logger.Error(ctx, "Failed to save items after deletion", "error", err) |
||||
return fmt.Errorf("failed to save items: %w", err) |
||||
} |
||||
|
||||
r.logger.Info(ctx, "Item deleted successfully", "itemID", id.String()) |
||||
return nil |
||||
} |
||||
|
||||
func (r *FileItemRepository) Exists(ctx context.Context, id value_objects.ItemID) (bool, error) { |
||||
r.logger.Info(ctx, "Checking if item exists", "itemID", id.String()) |
||||
|
||||
filePath := filepath.Join(r.dataDirectory, "items.json") |
||||
|
||||
r.mu.RLock() |
||||
defer r.mu.RUnlock() |
||||
|
||||
items, err := r.loadItems(filePath) |
||||
if err != nil { |
||||
r.logger.Error(ctx, "Failed to load items", "error", err) |
||||
return false, fmt.Errorf("failed to load items: %w", err) |
||||
} |
||||
|
||||
_, ok := items[id.String()] |
||||
r.logger.Info(ctx, "Item existence check completed", "itemID", id.String(), "exists", ok) |
||||
return ok, nil |
||||
} |
||||
|
||||
func (r *FileItemRepository) loadItems(filePath string) (map[string]*entities.ItemEntity, error) { |
||||
// Ensure the directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { |
||||
return nil, fmt.Errorf("failed to create directory: %w", err) |
||||
} |
||||
|
||||
data, err := os.ReadFile(filePath) |
||||
if err != nil { |
||||
if os.IsNotExist(err) { |
||||
return make(map[string]*entities.ItemEntity), nil |
||||
} |
||||
return nil, fmt.Errorf("failed to read file: %w", err) |
||||
} |
||||
|
||||
var items map[string]*entities.ItemEntity |
||||
if len(data) == 0 { |
||||
return make(map[string]*entities.ItemEntity), nil |
||||
} |
||||
|
||||
if err := json.Unmarshal(data, &items); err != nil { |
||||
return nil, fmt.Errorf("failed to unmarshal items: %w", err) |
||||
} |
||||
return items, nil |
||||
} |
||||
|
||||
func (r *FileItemRepository) saveItems(filePath string, items map[string]*entities.ItemEntity) error { |
||||
// Ensure the directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { |
||||
return fmt.Errorf("failed to create directory: %w", err) |
||||
} |
||||
|
||||
data, err := json.MarshalIndent(items, "", " ") |
||||
if err != nil { |
||||
return fmt.Errorf("failed to marshal items: %w", err) |
||||
} |
||||
|
||||
// Write to a temporary file first, then rename for atomic operation
|
||||
tempPath := filePath + ".tmp" |
||||
if err := os.WriteFile(tempPath, data, 0644); err != nil { |
||||
return fmt.Errorf("failed to write temporary file: %w", err) |
||||
} |
||||
|
||||
if err := os.Rename(tempPath, filePath); err != nil { |
||||
return fmt.Errorf("failed to rename temporary file: %w", err) |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
@ -1,156 +0,0 @@
|
||||
package repositories |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"os" |
||||
"path/filepath" |
||||
"sync" |
||||
|
||||
"autostore/internal/application/interfaces" |
||||
"autostore/internal/domain/entities" |
||||
"autostore/internal/domain/value_objects" |
||||
) |
||||
|
||||
type FileUserRepository struct { |
||||
filePath string |
||||
logger interfaces.ILogger |
||||
mu sync.RWMutex |
||||
} |
||||
|
||||
func NewFileUserRepository(dataDirectory string, logger interfaces.ILogger) *FileUserRepository { |
||||
// Ensure the data directory exists
|
||||
if err := os.MkdirAll(dataDirectory, 0755); err != nil { |
||||
logger.Error(context.Background(), "Failed to create data directory", "error", err, "path", dataDirectory) |
||||
} |
||||
|
||||
filePath := filepath.Join(dataDirectory, "users.json") |
||||
|
||||
// Initialize the file if it doesn't exist
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) { |
||||
if err := os.WriteFile(filePath, []byte("{}"), 0644); err != nil { |
||||
logger.Error(context.Background(), "Failed to create users file", "error", err, "path", filePath) |
||||
} |
||||
} |
||||
|
||||
return &FileUserRepository{ |
||||
filePath: filePath, |
||||
logger: logger, |
||||
} |
||||
} |
||||
|
||||
func (r *FileUserRepository) FindByUsername(ctx context.Context, username string) (*entities.UserEntity, error) { |
||||
r.mu.RLock() |
||||
defer r.mu.RUnlock() |
||||
|
||||
users, err := r.readUsers() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
for _, userData := range users { |
||||
if userData.Username == username { |
||||
return r.dataToEntity(userData) |
||||
} |
||||
} |
||||
|
||||
return nil, nil |
||||
} |
||||
|
||||
func (r *FileUserRepository) FindByID(ctx context.Context, id value_objects.UserID) (*entities.UserEntity, error) { |
||||
r.mu.RLock() |
||||
defer r.mu.RUnlock() |
||||
|
||||
users, err := r.readUsers() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
userIDStr := id.String() |
||||
if userData, exists := users[userIDStr]; exists { |
||||
return r.dataToEntity(userData) |
||||
} |
||||
|
||||
return nil, nil |
||||
} |
||||
|
||||
func (r *FileUserRepository) Save(ctx context.Context, user *entities.UserEntity) error { |
||||
r.mu.Lock() |
||||
defer r.mu.Unlock() |
||||
|
||||
users, err := r.readUsers() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Check if username already exists for a different user
|
||||
userIDStr := user.GetID().String() |
||||
for existingID, existingUser := range users { |
||||
if existingID != userIDStr && existingUser.Username == user.GetUsername() { |
||||
return errors.New("username already exists") |
||||
} |
||||
} |
||||
|
||||
users[userIDStr] = r.entityToData(user) |
||||
|
||||
if err := r.writeUsers(users); err != nil { |
||||
return err |
||||
} |
||||
|
||||
r.logger.Info(ctx, "User saved successfully", "userID", userIDStr, "username", user.GetUsername()) |
||||
return nil |
||||
} |
||||
|
||||
// Helper types for JSON serialization
|
||||
type userData struct { |
||||
ID string `json:"id"` |
||||
Username string `json:"username"` |
||||
PasswordHash string `json:"passwordHash"` |
||||
CreatedAt string `json:"createdAt"` |
||||
} |
||||
|
||||
func (r *FileUserRepository) readUsers() (map[string]userData, error) { |
||||
data, err := os.ReadFile(r.filePath) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to read users file: %w", err) |
||||
} |
||||
|
||||
var users map[string]userData |
||||
if err := json.Unmarshal(data, &users); err != nil { |
||||
return nil, fmt.Errorf("failed to parse users file: %w", err) |
||||
} |
||||
|
||||
return users, nil |
||||
} |
||||
|
||||
func (r *FileUserRepository) writeUsers(users map[string]userData) error { |
||||
data, err := json.MarshalIndent(users, "", " ") |
||||
if err != nil { |
||||
return fmt.Errorf("failed to marshal users: %w", err) |
||||
} |
||||
|
||||
if err := os.WriteFile(r.filePath, data, 0644); err != nil { |
||||
return fmt.Errorf("failed to write users file: %w", err) |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (r *FileUserRepository) entityToData(user *entities.UserEntity) userData { |
||||
return userData{ |
||||
ID: user.GetID().String(), |
||||
Username: user.GetUsername(), |
||||
PasswordHash: user.GetPasswordHash(), |
||||
CreatedAt: "", // Will be added when user entity has createdAt field
|
||||
} |
||||
} |
||||
|
||||
func (r *FileUserRepository) dataToEntity(data userData) (*entities.UserEntity, error) { |
||||
userID, err := value_objects.NewUserID(data.ID) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("invalid user ID: %w", err) |
||||
} |
||||
return entities.NewUserWithHashedPassword(userID, data.Username, data.PasswordHash) |
||||
} |
||||
@ -1,104 +0,0 @@
|
||||
package scheduler |
||||
|
||||
import ( |
||||
"context" |
||||
"time" |
||||
|
||||
"autostore/internal/application/commands" |
||||
"autostore/internal/application/interfaces" |
||||
) |
||||
|
||||
type ExpiredItemsScheduler struct { |
||||
handleExpiredItemsCmd *commands.HandleExpiredItemsCommand |
||||
logger interfaces.ILogger |
||||
ticker *time.Ticker |
||||
done chan struct{} |
||||
} |
||||
|
||||
func NewExpiredItemsScheduler( |
||||
handleExpiredItemsCmd *commands.HandleExpiredItemsCommand, |
||||
logger interfaces.ILogger, |
||||
) *ExpiredItemsScheduler { |
||||
return &ExpiredItemsScheduler{ |
||||
handleExpiredItemsCmd: handleExpiredItemsCmd, |
||||
logger: logger, |
||||
done: make(chan struct{}), |
||||
} |
||||
} |
||||
|
||||
func (s *ExpiredItemsScheduler) Start(ctx context.Context) error { |
||||
s.logger.Info(ctx, "Starting expired items scheduler") |
||||
|
||||
// Process expired items immediately on startup
|
||||
if err := s.processExpiredItems(ctx); err != nil { |
||||
s.logger.Error(ctx, "Failed to process expired items on startup", "error", err) |
||||
return err |
||||
} |
||||
|
||||
// Calculate duration until next midnight
|
||||
now := time.Now() |
||||
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) |
||||
if now.After(midnight) { |
||||
midnight = midnight.Add(24 * time.Hour) |
||||
} |
||||
durationUntilMidnight := midnight.Sub(now) |
||||
|
||||
// Start a timer for the first midnight
|
||||
firstMidnightTimer := time.NewTimer(durationUntilMidnight) |
||||
|
||||
go func() { |
||||
for { |
||||
select { |
||||
case <-firstMidnightTimer.C: |
||||
s.processExpiredItems(ctx) |
||||
|
||||
// After first midnight, set up daily ticker
|
||||
s.ticker = time.NewTicker(24 * time.Hour) |
||||
firstMidnightTimer.Stop() |
||||
|
||||
for { |
||||
select { |
||||
case <-s.ticker.C: |
||||
s.processExpiredItems(ctx) |
||||
case <-s.done: |
||||
s.ticker.Stop() |
||||
return |
||||
case <-ctx.Done(): |
||||
s.ticker.Stop() |
||||
return |
||||
} |
||||
} |
||||
case <-s.done: |
||||
firstMidnightTimer.Stop() |
||||
return |
||||
case <-ctx.Done(): |
||||
firstMidnightTimer.Stop() |
||||
return |
||||
} |
||||
} |
||||
}() |
||||
|
||||
s.logger.Info(ctx, "Expired items scheduler started", "next_run", midnight.Format(time.RFC3339)) |
||||
return nil |
||||
} |
||||
|
||||
func (s *ExpiredItemsScheduler) Stop() error { |
||||
s.logger.Info(context.Background(), "Stopping expired items scheduler") |
||||
close(s.done) |
||||
if s.ticker != nil { |
||||
s.ticker.Stop() |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (s *ExpiredItemsScheduler) processExpiredItems(ctx context.Context) error { |
||||
s.logger.Info(ctx, "Running scheduled expired items processing") |
||||
|
||||
if err := s.handleExpiredItemsCmd.Execute(ctx); err != nil { |
||||
s.logger.Error(ctx, "Scheduled expired items processing failed", "error", err) |
||||
return err |
||||
} |
||||
|
||||
s.logger.Info(ctx, "Scheduled expired items processing completed") |
||||
return nil |
||||
} |
||||
@ -1,65 +0,0 @@
|
||||
package services |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
"autostore/internal/application/interfaces" |
||||
"autostore/internal/domain/entities" |
||||
"autostore/internal/domain/value_objects" |
||||
) |
||||
|
||||
type UserInitializationService struct { |
||||
userRepository interfaces.IUserRepository |
||||
logger interfaces.ILogger |
||||
} |
||||
|
||||
func NewUserInitializationService(userRepository interfaces.IUserRepository, logger interfaces.ILogger) *UserInitializationService { |
||||
return &UserInitializationService{ |
||||
userRepository: userRepository, |
||||
logger: logger, |
||||
} |
||||
} |
||||
|
||||
func (s *UserInitializationService) InitializeDefaultUsers() error { |
||||
defaultUsers := []struct { |
||||
username string |
||||
password string |
||||
}{ |
||||
{username: "admin", password: "admin"}, |
||||
{username: "user", password: "user"}, |
||||
} |
||||
|
||||
for _, userData := range defaultUsers { |
||||
existingUser, err := s.userRepository.FindByUsername(context.Background(), userData.username) |
||||
if err != nil { |
||||
s.logger.Error(context.Background(), "Failed to check if user exists", "error", err, "username", userData.username) |
||||
continue |
||||
} |
||||
|
||||
if existingUser != nil { |
||||
s.logger.Info(context.Background(), "Default user already exists", "username", userData.username) |
||||
continue |
||||
} |
||||
|
||||
userID, err := value_objects.NewRandomUserID() |
||||
if err != nil { |
||||
s.logger.Error(context.Background(), "Failed to generate user ID", "error", err) |
||||
continue |
||||
} |
||||
|
||||
user, err := entities.NewUser(userID, userData.username, userData.password) |
||||
if err != nil { |
||||
s.logger.Error(context.Background(), "Failed to create user entity", "error", err, "username", userData.username) |
||||
continue |
||||
} |
||||
|
||||
if err := s.userRepository.Save(context.Background(), user); err != nil { |
||||
s.logger.Error(context.Background(), "Failed to save user", "error", err, "username", userData.username) |
||||
continue |
||||
} |
||||
|
||||
s.logger.Info(context.Background(), "Created default user", "username", userData.username) |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
@ -1,15 +0,0 @@
|
||||
package time |
||||
|
||||
import ( |
||||
"time" |
||||
) |
||||
|
||||
type SystemTimeProvider struct{} |
||||
|
||||
func NewSystemTimeProvider() *SystemTimeProvider { |
||||
return &SystemTimeProvider{} |
||||
} |
||||
|
||||
func (p *SystemTimeProvider) Now() time.Time { |
||||
return time.Now() |
||||
} |
||||
@ -1,53 +0,0 @@
|
||||
package controllers |
||||
|
||||
import ( |
||||
"net/http" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
|
||||
"autostore/internal/application/commands" |
||||
"autostore/internal/application/dto" |
||||
"autostore/internal/application/interfaces" |
||||
) |
||||
|
||||
type AuthController struct { |
||||
loginUserCmd *commands.LoginUserCommand |
||||
logger interfaces.ILogger |
||||
} |
||||
|
||||
func NewAuthController( |
||||
loginUserCmd *commands.LoginUserCommand, |
||||
logger interfaces.ILogger, |
||||
) *AuthController { |
||||
return &AuthController{ |
||||
loginUserCmd: loginUserCmd, |
||||
logger: logger, |
||||
} |
||||
} |
||||
|
||||
func (ctrl *AuthController) Login(c *gin.Context) { |
||||
var loginDTO dto.LoginDTO |
||||
if err := c.ShouldBindJSON(&loginDTO); err != nil { |
||||
c.JSON(http.StatusBadRequest, dto.JSendError("Invalid request body", http.StatusBadRequest)) |
||||
return |
||||
} |
||||
|
||||
if err := loginDTO.Validate(); err != nil { |
||||
c.JSON(http.StatusBadRequest, dto.JSendError(err.Error(), http.StatusBadRequest)) |
||||
return |
||||
} |
||||
|
||||
token, err := ctrl.loginUserCmd.Execute(c.Request.Context(), loginDTO.Username, loginDTO.Password) |
||||
if err != nil { |
||||
c.JSON(http.StatusUnauthorized, dto.JSendError(err.Error(), http.StatusUnauthorized)) |
||||
return |
||||
} |
||||
|
||||
response := &dto.LoginResponseDTO{ |
||||
Token: token, |
||||
TokenType: "Bearer", |
||||
ExpiresIn: 3600, // 1 hour in seconds
|
||||
} |
||||
|
||||
c.JSON(http.StatusOK, dto.JSendSuccess(response)) |
||||
} |
||||
@ -1,157 +0,0 @@
|
||||
package controllers |
||||
|
||||
import ( |
||||
"net/http" |
||||
"time" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
"errors" |
||||
|
||||
"autostore/internal/application/commands" |
||||
"autostore/internal/application/dto" |
||||
"autostore/internal/application/interfaces" |
||||
"autostore/internal/application/queries" |
||||
) |
||||
|
||||
type ItemsController struct { |
||||
addItemCmd *commands.AddItemCommand |
||||
getItemQry *queries.GetItemQuery |
||||
listItemsQry *queries.ListItemsQuery |
||||
deleteItemCmd *commands.DeleteItemCommand |
||||
logger interfaces.ILogger |
||||
} |
||||
|
||||
func NewItemsController( |
||||
addItemCmd *commands.AddItemCommand, |
||||
getItemQry *queries.GetItemQuery, |
||||
listItemsQry *queries.ListItemsQuery, |
||||
deleteItemCmd *commands.DeleteItemCommand, |
||||
logger interfaces.ILogger, |
||||
) *ItemsController { |
||||
return &ItemsController{ |
||||
addItemCmd: addItemCmd, |
||||
getItemQry: getItemQry, |
||||
listItemsQry: listItemsQry, |
||||
deleteItemCmd: deleteItemCmd, |
||||
logger: logger, |
||||
} |
||||
} |
||||
|
||||
func (ctrl *ItemsController) CreateItem(c *gin.Context) { |
||||
userID, exists := c.Get("userID") |
||||
if !exists { |
||||
c.JSON(http.StatusUnauthorized, dto.JSendError("Unauthorized", http.StatusUnauthorized)) |
||||
return |
||||
} |
||||
|
||||
var createItemDTO dto.CreateItemDTO |
||||
if err := c.ShouldBindJSON(&createItemDTO); err != nil { |
||||
c.JSON(http.StatusBadRequest, dto.JSendError("Invalid request body", http.StatusBadRequest)) |
||||
return |
||||
} |
||||
|
||||
if err := createItemDTO.Validate(); err != nil { |
||||
c.JSON(http.StatusBadRequest, dto.JSendError(err.Error(), http.StatusBadRequest)) |
||||
return |
||||
} |
||||
|
||||
itemID, err := ctrl.addItemCmd.Execute(c.Request.Context(), createItemDTO.Name, createItemDTO.ExpirationDate.Time, createItemDTO.OrderURL, userID.(string)) |
||||
if err != nil { |
||||
c.JSON(http.StatusInternalServerError, dto.JSendError(err.Error(), http.StatusInternalServerError)) |
||||
return |
||||
} |
||||
|
||||
response := &dto.ItemResponseDTO{ |
||||
ID: itemID, |
||||
Name: createItemDTO.Name, |
||||
ExpirationDate: createItemDTO.ExpirationDate, |
||||
OrderURL: createItemDTO.OrderURL, |
||||
UserID: userID.(string), |
||||
CreatedAt: dto.JSONTime{Time: time.Now()}, |
||||
} |
||||
|
||||
c.JSON(http.StatusCreated, dto.JSendSuccess(response)) |
||||
} |
||||
|
||||
func (ctrl *ItemsController) GetItem(c *gin.Context) { |
||||
userID, exists := c.Get("userID") |
||||
if !exists { |
||||
c.JSON(http.StatusUnauthorized, dto.JSendError("Unauthorized", http.StatusUnauthorized)) |
||||
return |
||||
} |
||||
|
||||
itemID := c.Param("id") |
||||
if itemID == "" { |
||||
c.JSON(http.StatusBadRequest, dto.JSendError("Item ID is required", http.StatusBadRequest)) |
||||
return |
||||
} |
||||
|
||||
item, err := ctrl.getItemQry.Execute(c.Request.Context(), itemID, userID.(string)) |
||||
if err != nil { |
||||
if errors.Is(err, queries.ErrItemNotFound) { |
||||
c.JSON(http.StatusNotFound, dto.JSendError("Item not found", http.StatusNotFound)) |
||||
return |
||||
} |
||||
if errors.Is(err, queries.ErrUnauthorizedAccess) { |
||||
c.JSON(http.StatusNotFound, dto.JSendError("Item not found", http.StatusNotFound)) |
||||
return |
||||
} |
||||
c.JSON(http.StatusInternalServerError, dto.JSendError(err.Error(), http.StatusInternalServerError)) |
||||
return |
||||
} |
||||
|
||||
response := (&dto.ItemResponseDTO{}).FromEntity(item) |
||||
|
||||
c.JSON(http.StatusOK, dto.JSendSuccess(response)) |
||||
} |
||||
|
||||
func (ctrl *ItemsController) ListItems(c *gin.Context) { |
||||
userID, exists := c.Get("userID") |
||||
if !exists { |
||||
c.JSON(http.StatusUnauthorized, dto.JSendError("Unauthorized", http.StatusUnauthorized)) |
||||
return |
||||
} |
||||
|
||||
items, err := ctrl.listItemsQry.Execute(c.Request.Context(), userID.(string)) |
||||
if err != nil { |
||||
c.JSON(http.StatusInternalServerError, dto.JSendError(err.Error(), http.StatusInternalServerError)) |
||||
return |
||||
} |
||||
|
||||
var response []*dto.ItemResponseDTO |
||||
for _, item := range items { |
||||
response = append(response, (&dto.ItemResponseDTO{}).FromEntity(item)) |
||||
} |
||||
|
||||
c.JSON(http.StatusOK, dto.JSendSuccess(response)) |
||||
} |
||||
|
||||
func (ctrl *ItemsController) DeleteItem(c *gin.Context) { |
||||
userID, exists := c.Get("userID") |
||||
if !exists { |
||||
c.JSON(http.StatusUnauthorized, dto.JSendError("Unauthorized", http.StatusUnauthorized)) |
||||
return |
||||
} |
||||
|
||||
itemID := c.Param("id") |
||||
if itemID == "" { |
||||
c.JSON(http.StatusBadRequest, dto.JSendError("Item ID is required", http.StatusBadRequest)) |
||||
return |
||||
} |
||||
|
||||
err := ctrl.deleteItemCmd.Execute(c.Request.Context(), itemID, userID.(string)) |
||||
if err != nil { |
||||
if errors.Is(err, commands.ErrItemNotFound) { |
||||
c.JSON(http.StatusNotFound, dto.JSendError("Item not found", http.StatusNotFound)) |
||||
return |
||||
} |
||||
if errors.Is(err, commands.ErrUnauthorizedAccess) { |
||||
c.JSON(http.StatusNotFound, dto.JSendError("Item not found", http.StatusNotFound)) |
||||
return |
||||
} |
||||
c.JSON(http.StatusInternalServerError, dto.JSendError(err.Error(), http.StatusInternalServerError)) |
||||
return |
||||
} |
||||
|
||||
c.JSON(http.StatusNoContent, dto.JSendSuccess(nil)) |
||||
} |
||||
@ -1,62 +0,0 @@
|
||||
package middleware |
||||
|
||||
import ( |
||||
"net/http" |
||||
"strings" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
|
||||
"autostore/internal/application/interfaces" |
||||
"autostore/internal/application/dto" |
||||
) |
||||
|
||||
type JWTMiddleware struct { |
||||
authService interfaces.IAuthService |
||||
logger interfaces.ILogger |
||||
} |
||||
|
||||
func NewJWTMiddleware( |
||||
authService interfaces.IAuthService, |
||||
logger interfaces.ILogger, |
||||
) *JWTMiddleware { |
||||
return &JWTMiddleware{ |
||||
authService: authService, |
||||
logger: logger, |
||||
} |
||||
} |
||||
|
||||
func (m *JWTMiddleware) Middleware() gin.HandlerFunc { |
||||
return func(c *gin.Context) { |
||||
authHeader := c.GetHeader("Authorization") |
||||
if authHeader == "" { |
||||
c.JSON(http.StatusUnauthorized, dto.JSendError("Authorization header is required", http.StatusUnauthorized)) |
||||
c.Abort() |
||||
return |
||||
} |
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2) |
||||
if !(len(parts) == 2 && parts[0] == "Bearer") { |
||||
c.JSON(http.StatusUnauthorized, dto.JSendError("Invalid authorization format", http.StatusUnauthorized)) |
||||
c.Abort() |
||||
return |
||||
} |
||||
|
||||
token := parts[1] |
||||
valid, err := m.authService.ValidateToken(c.Request.Context(), token) |
||||
if err != nil || !valid { |
||||
c.JSON(http.StatusUnauthorized, dto.JSendError("Invalid or expired token", http.StatusUnauthorized)) |
||||
c.Abort() |
||||
return |
||||
} |
||||
|
||||
userID, err := m.authService.GetUserIDFromToken(c.Request.Context(), token) |
||||
if err != nil { |
||||
c.JSON(http.StatusUnauthorized, dto.JSendError("Failed to get user ID from token", http.StatusUnauthorized)) |
||||
c.Abort() |
||||
return |
||||
} |
||||
|
||||
c.Set("userID", userID) |
||||
c.Next() |
||||
} |
||||
} |
||||
@ -1,117 +0,0 @@
|
||||
package server |
||||
|
||||
import ( |
||||
"context" |
||||
"net/http" |
||||
"strconv" |
||||
"time" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
|
||||
"autostore/internal/application/interfaces" |
||||
"autostore/internal/presentation/controllers" |
||||
"autostore/internal/presentation/middleware" |
||||
) |
||||
|
||||
type Server struct { |
||||
config *Config |
||||
logger interfaces.ILogger |
||||
itemsCtrl *controllers.ItemsController |
||||
authCtrl *controllers.AuthController |
||||
jwtMiddleware *middleware.JWTMiddleware |
||||
httpServer *http.Server |
||||
} |
||||
|
||||
type Config struct { |
||||
Port int |
||||
ReadTimeout time.Duration |
||||
WriteTimeout time.Duration |
||||
ShutdownTimeout time.Duration |
||||
} |
||||
|
||||
func NewServer( |
||||
config *Config, |
||||
logger interfaces.ILogger, |
||||
itemsCtrl *controllers.ItemsController, |
||||
authCtrl *controllers.AuthController, |
||||
jwtMiddleware *middleware.JWTMiddleware, |
||||
) *Server { |
||||
return &Server{ |
||||
config: config, |
||||
logger: logger, |
||||
itemsCtrl: itemsCtrl, |
||||
authCtrl: authCtrl, |
||||
jwtMiddleware: jwtMiddleware, |
||||
} |
||||
} |
||||
|
||||
func (s *Server) Start() error { |
||||
gin.SetMode(gin.ReleaseMode) |
||||
router := s.SetupRoutes() |
||||
|
||||
s.httpServer = &http.Server{ |
||||
Addr: ":" + strconv.Itoa(s.config.Port), |
||||
Handler: router, |
||||
ReadTimeout: s.config.ReadTimeout, |
||||
WriteTimeout: s.config.WriteTimeout, |
||||
} |
||||
|
||||
s.logger.Info(context.Background(), "Server starting on port %d", s.config.Port) |
||||
return s.httpServer.ListenAndServe() |
||||
} |
||||
|
||||
func (s *Server) Shutdown(ctx context.Context) error { |
||||
s.logger.Info(ctx, "Server shutting down") |
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(ctx, s.config.ShutdownTimeout) |
||||
defer cancel() |
||||
|
||||
return s.httpServer.Shutdown(shutdownCtx) |
||||
} |
||||
|
||||
func (s *Server) SetupRoutes() *gin.Engine { |
||||
router := gin.New() |
||||
|
||||
// Middleware
|
||||
router.Use(gin.Recovery()) |
||||
router.Use(s.corsMiddleware()) |
||||
|
||||
// Health check
|
||||
router.GET("/health", func(c *gin.Context) { |
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"}) |
||||
}) |
||||
|
||||
// API v1 group
|
||||
v1 := router.Group("/api/v1") |
||||
|
||||
// Auth routes (no JWT middleware)
|
||||
v1.POST("/login", s.authCtrl.Login) |
||||
|
||||
// Items routes (with JWT middleware)
|
||||
items := v1.Group("/items") |
||||
items.Use(s.jwtMiddleware.Middleware()) |
||||
{ |
||||
items.POST("", s.itemsCtrl.CreateItem) |
||||
items.GET("", s.itemsCtrl.ListItems) |
||||
items.GET("/:id", s.itemsCtrl.GetItem) |
||||
items.DELETE("/:id", s.itemsCtrl.DeleteItem) |
||||
} |
||||
|
||||
return router |
||||
} |
||||
|
||||
func (s *Server) corsMiddleware() gin.HandlerFunc { |
||||
return func(c *gin.Context) { |
||||
c.Header("Access-Control-Allow-Origin", "*") |
||||
c.Header("Access-Control-Allow-Credentials", "true") |
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") |
||||
c.Header("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") |
||||
|
||||
if c.Request.Method == "OPTIONS" { |
||||
c.AbortWithStatus(204) |
||||
return |
||||
} |
||||
|
||||
c.Next() |
||||
} |
||||
} |
||||
@ -1,348 +0,0 @@
|
||||
package integration |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"fmt" |
||||
"os" |
||||
"path/filepath" |
||||
"testing" |
||||
"time" |
||||
|
||||
"autostore/internal/domain/entities" |
||||
"autostore/internal/domain/specifications" |
||||
"autostore/internal/domain/value_objects" |
||||
"autostore/internal/infrastructure/repositories" |
||||
"github.com/stretchr/testify/assert" |
||||
) |
||||
|
||||
const ( |
||||
ITEM_ID_1 = "00000000-0000-0000-0000-000000000001" |
||||
ITEM_ID_2 = "00000000-0000-0000-0000-000000000002" |
||||
ITEM_ID_3 = "00000000-0000-0000-0000-000000000003" |
||||
EXPIRED_ID = "00000000-0000-0000-0000-000000000004" |
||||
VALID_ID = "00000000-0000-0000-0000-000000000005" |
||||
NON_EXISTENT_ID = "00000000-0000-0000-0000-000000000099" |
||||
ITEM_NAME_1 = "Test Item 1" |
||||
ITEM_NAME_2 = "Test Item 2" |
||||
ITEM_NAME_3 = "Test Item 3" |
||||
EXPIRED_NAME = "Expired Item" |
||||
VALID_NAME = "Valid Item" |
||||
ORDER_URL_1 = "http://example.com/order1" |
||||
ORDER_URL_2 = "http://example.com/order2" |
||||
ORDER_URL_3 = "http://example.com/order3" |
||||
EXPIRED_ORDER_URL = "http://example.com/expired-order" |
||||
VALID_ORDER_URL = "http://example.com/valid-order" |
||||
USER_ID_1 = "10000000-0000-0000-0000-000000000001" |
||||
USER_ID_2 = "10000000-0000-0000-0000-000000000002" |
||||
USER_ID = "10000000-0000-0000-0000-000000000003" |
||||
MOCKED_NOW = "2023-01-01T12:00:00Z" |
||||
DATE_FORMAT = time.RFC3339 |
||||
) |
||||
|
||||
type mockLogger struct { |
||||
infoLogs []string |
||||
errorLogs []string |
||||
} |
||||
|
||||
func (m *mockLogger) Info(ctx context.Context, msg string, args ...any) { |
||||
m.infoLogs = append(m.infoLogs, fmt.Sprintf(msg, args...)) |
||||
} |
||||
|
||||
func (m *mockLogger) Error(ctx context.Context, msg string, args ...any) { |
||||
m.errorLogs = append(m.errorLogs, fmt.Sprintf(msg, args...)) |
||||
} |
||||
|
||||
func (m *mockLogger) Debug(ctx context.Context, msg string, args ...any) { |
||||
// Debug implementation - can be empty for tests
|
||||
} |
||||
|
||||
func (m *mockLogger) Warn(ctx context.Context, msg string, args ...any) { |
||||
// Warn implementation - can be empty for tests
|
||||
} |
||||
|
||||
func setupTest(t *testing.T) (string, *repositories.FileItemRepository, *mockLogger, func()) { |
||||
testStoragePath := t.TempDir() |
||||
logger := &mockLogger{} |
||||
repo := repositories.NewFileItemRepository(testStoragePath, logger) |
||||
|
||||
cleanup := func() { |
||||
os.RemoveAll(testStoragePath) |
||||
} |
||||
|
||||
return testStoragePath, repo, logger, cleanup |
||||
} |
||||
|
||||
func createTestItem(id, name string, expiration time.Time, orderURL, userID string) *entities.ItemEntity { |
||||
itemID, _ := value_objects.NewItemID(id) |
||||
userIDVO, _ := value_objects.NewUserID(userID) |
||||
expirationVO, _ := value_objects.NewExpirationDate(expiration) |
||||
item, _ := entities.NewItem(itemID, name, expirationVO, orderURL, userIDVO) |
||||
return item |
||||
} |
||||
|
||||
func createTestItem1() *entities.ItemEntity { |
||||
expiration, _ := time.Parse(DATE_FORMAT, "2025-01-01T12:00:00Z") |
||||
return createTestItem(ITEM_ID_1, ITEM_NAME_1, expiration, ORDER_URL_1, USER_ID_1) |
||||
} |
||||
|
||||
func createTestItem2() *entities.ItemEntity { |
||||
expiration, _ := time.Parse(DATE_FORMAT, "2025-01-02T12:00:00Z") |
||||
return createTestItem(ITEM_ID_2, ITEM_NAME_2, expiration, ORDER_URL_2, USER_ID_2) |
||||
} |
||||
|
||||
func createTestItem3() *entities.ItemEntity { |
||||
expiration, _ := time.Parse(DATE_FORMAT, "2025-01-03T12:00:00Z") |
||||
return createTestItem(ITEM_ID_3, ITEM_NAME_3, expiration, ORDER_URL_3, USER_ID_1) |
||||
} |
||||
|
||||
func createExpiredItem() *entities.ItemEntity { |
||||
expiration, _ := time.Parse(DATE_FORMAT, "2022-01-01T12:00:00Z") // Past date
|
||||
return createTestItem(EXPIRED_ID, EXPIRED_NAME, expiration, EXPIRED_ORDER_URL, USER_ID) |
||||
} |
||||
|
||||
func createValidItem() *entities.ItemEntity { |
||||
expiration, _ := time.Parse(DATE_FORMAT, "2024-01-01T12:00:00Z") // Future date
|
||||
return createTestItem(VALID_ID, VALID_NAME, expiration, VALID_ORDER_URL, USER_ID) |
||||
} |
||||
|
||||
func createExpiredItemForUser1() *entities.ItemEntity { |
||||
expiration, _ := time.Parse(DATE_FORMAT, "2022-01-01T12:00:00Z") // Past date
|
||||
return createTestItem(ITEM_ID_1, ITEM_NAME_1, expiration, ORDER_URL_1, USER_ID_1) |
||||
} |
||||
|
||||
func createValidItemForUser1() *entities.ItemEntity { |
||||
expiration, _ := time.Parse(DATE_FORMAT, "2024-01-01T12:00:00Z") // Future date
|
||||
return createTestItem(ITEM_ID_2, ITEM_NAME_2, expiration, ORDER_URL_2, USER_ID_1) |
||||
} |
||||
|
||||
func createExpiredItemForUser2() *entities.ItemEntity { |
||||
expiration, _ := time.Parse(DATE_FORMAT, "2022-01-01T12:00:00Z") // Past date
|
||||
return createTestItem(ITEM_ID_3, ITEM_NAME_3, expiration, ORDER_URL_3, USER_ID_2) |
||||
} |
||||
|
||||
func TestWhenItemIsSavedThenFileIsCreated(t *testing.T) { |
||||
testStoragePath, repo, _, cleanup := setupTest(t) |
||||
defer cleanup() |
||||
|
||||
item := createTestItem1() |
||||
|
||||
err := repo.Save(context.Background(), item) |
||||
assert.NoError(t, err) |
||||
|
||||
filePath := filepath.Join(testStoragePath, "items.json") |
||||
assert.FileExists(t, filePath) |
||||
|
||||
data, err := os.ReadFile(filePath) |
||||
assert.NoError(t, err) |
||||
|
||||
var items map[string]*entities.ItemEntity |
||||
err = json.Unmarshal(data, &items) |
||||
assert.NoError(t, err) |
||||
assert.Len(t, items, 1) |
||||
|
||||
savedItem := items[item.GetID().String()] |
||||
assert.NotNil(t, savedItem) |
||||
assert.Equal(t, item.GetID().String(), savedItem.GetID().String()) |
||||
assert.Equal(t, item.GetName(), savedItem.GetName()) |
||||
assert.Equal(t, item.GetOrderURL(), savedItem.GetOrderURL()) |
||||
assert.Equal(t, item.GetUserID().String(), savedItem.GetUserID().String()) |
||||
} |
||||
|
||||
func TestWhenItemExistsThenFindByIDReturnsItem(t *testing.T) { |
||||
_, repo, _, cleanup := setupTest(t) |
||||
defer cleanup() |
||||
|
||||
item := createTestItem1() |
||||
err := repo.Save(context.Background(), item) |
||||
assert.NoError(t, err) |
||||
|
||||
itemID, _ := value_objects.NewItemID(ITEM_ID_1) |
||||
foundItem, err := repo.FindByID(context.Background(), itemID) |
||||
assert.NoError(t, err) |
||||
assert.NotNil(t, foundItem) |
||||
assert.Equal(t, item.GetID().String(), foundItem.GetID().String()) |
||||
assert.Equal(t, item.GetName(), foundItem.GetName()) |
||||
} |
||||
|
||||
func TestWhenItemDoesNotExistThenFindByIDReturnsNil(t *testing.T) { |
||||
_, repo, _, cleanup := setupTest(t) |
||||
defer cleanup() |
||||
|
||||
nonExistentID, _ := value_objects.NewItemID(NON_EXISTENT_ID) |
||||
foundItem, err := repo.FindByID(context.Background(), nonExistentID) |
||||
assert.Error(t, err) |
||||
assert.Contains(t, err.Error(), "not found") |
||||
assert.Nil(t, foundItem) |
||||
} |
||||
|
||||
func TestWhenUserHasMultipleItemsThenFindByUserIDReturnsAllUserItems(t *testing.T) { |
||||
_, repo, _, cleanup := setupTest(t) |
||||
defer cleanup() |
||||
|
||||
item1 := createTestItem1() |
||||
item2 := createTestItem2() |
||||
item3 := createTestItem3() |
||||
|
||||
repo.Save(context.Background(), item1) |
||||
repo.Save(context.Background(), item2) |
||||
repo.Save(context.Background(), item3) |
||||
|
||||
userID, _ := value_objects.NewUserID(USER_ID_1) |
||||
userItems, err := repo.FindByUserID(context.Background(), userID) |
||||
assert.NoError(t, err) |
||||
assert.Len(t, userItems, 2) |
||||
|
||||
itemIDs := make([]string, len(userItems)) |
||||
for i, item := range userItems { |
||||
itemIDs[i] = item.GetID().String() |
||||
} |
||||
assert.Contains(t, itemIDs, ITEM_ID_1) |
||||
assert.Contains(t, itemIDs, ITEM_ID_3) |
||||
} |
||||
|
||||
func TestWhenItemIsDeletedThenItIsNoLongerFound(t *testing.T) { |
||||
_, repo, _, cleanup := setupTest(t) |
||||
defer cleanup() |
||||
|
||||
item := createTestItem1() |
||||
err := repo.Save(context.Background(), item) |
||||
assert.NoError(t, err) |
||||
|
||||
itemID, _ := value_objects.NewItemID(ITEM_ID_1) |
||||
err = repo.Delete(context.Background(), itemID) |
||||
assert.NoError(t, err) |
||||
|
||||
foundItem, err := repo.FindByID(context.Background(), itemID) |
||||
assert.Error(t, err) |
||||
assert.Contains(t, err.Error(), "not found") |
||||
assert.Nil(t, foundItem) |
||||
} |
||||
|
||||
func TestWhenNonExistentItemIsDeletedThenErrorIsReturned(t *testing.T) { |
||||
_, repo, _, cleanup := setupTest(t) |
||||
defer cleanup() |
||||
|
||||
nonExistentID, _ := value_objects.NewItemID(NON_EXISTENT_ID) |
||||
err := repo.Delete(context.Background(), nonExistentID) |
||||
assert.Error(t, err) |
||||
assert.Contains(t, err.Error(), fmt.Sprintf("item with ID %s not found", NON_EXISTENT_ID)) |
||||
} |
||||
|
||||
func TestWhenFilteringByExpirationThenOnlyExpiredItemsAreReturned(t *testing.T) { |
||||
_, repo, _, cleanup := setupTest(t) |
||||
defer cleanup() |
||||
|
||||
expiredItem := createExpiredItem() |
||||
validItem := createValidItem() |
||||
|
||||
repo.Save(context.Background(), expiredItem) |
||||
repo.Save(context.Background(), validItem) |
||||
|
||||
now, _ := time.Parse(DATE_FORMAT, MOCKED_NOW) |
||||
expirationSpec := specifications.NewItemExpirationSpec() |
||||
spec := expirationSpec.GetSpec(now) |
||||
|
||||
filteredItems, err := repo.FindWhere(context.Background(), spec) |
||||
assert.NoError(t, err) |
||||
assert.Len(t, filteredItems, 1) |
||||
assert.Equal(t, expiredItem.GetID().String(), filteredItems[0].GetID().String()) |
||||
} |
||||
|
||||
func TestWhenFilteringByUserIDThenOnlyUserItemsAreReturned(t *testing.T) { |
||||
_, repo, _, cleanup := setupTest(t) |
||||
defer cleanup() |
||||
|
||||
item1 := createTestItem1() |
||||
item2 := createTestItem2() |
||||
item3 := createTestItem3() |
||||
|
||||
repo.Save(context.Background(), item1) |
||||
repo.Save(context.Background(), item2) |
||||
repo.Save(context.Background(), item3) |
||||
|
||||
userID, _ := value_objects.NewUserID(USER_ID_1) |
||||
spec := specifications.NewSimpleSpecification[*entities.ItemEntity](specifications.Eq("userID", userID)) |
||||
|
||||
userItems, err := repo.FindWhere(context.Background(), spec) |
||||
assert.NoError(t, err) |
||||
assert.Len(t, userItems, 2) |
||||
|
||||
itemIDs := make([]string, len(userItems)) |
||||
for i, item := range userItems { |
||||
itemIDs[i] = item.GetID().String() |
||||
} |
||||
assert.Contains(t, itemIDs, ITEM_ID_1) |
||||
assert.Contains(t, itemIDs, ITEM_ID_3) |
||||
} |
||||
|
||||
func TestWhenUsingComplexFilterThenOnlyMatchingItemsAreReturned(t *testing.T) { |
||||
_, repo, _, cleanup := setupTest(t) |
||||
defer cleanup() |
||||
|
||||
item1 := createExpiredItemForUser1() |
||||
item2 := createValidItemForUser1() |
||||
item3 := createExpiredItemForUser2() |
||||
|
||||
repo.Save(context.Background(), item1) |
||||
repo.Save(context.Background(), item2) |
||||
repo.Save(context.Background(), item3) |
||||
|
||||
now, _ := time.Parse(DATE_FORMAT, MOCKED_NOW) |
||||
userID, _ := value_objects.NewUserID(USER_ID_1) |
||||
userSpec := specifications.NewSimpleSpecification[*entities.ItemEntity](specifications.Eq("userID", userID)) |
||||
expirationSpec := specifications.NewItemExpirationSpec() |
||||
expirationSpecWithTime := expirationSpec.GetSpec(now) |
||||
complexSpec := userSpec.And(expirationSpecWithTime) |
||||
|
||||
filteredItems, err := repo.FindWhere(context.Background(), complexSpec) |
||||
assert.NoError(t, err) |
||||
assert.Len(t, filteredItems, 1) |
||||
assert.Equal(t, item1.GetID().String(), filteredItems[0].GetID().String()) |
||||
} |
||||
|
||||
func TestWhenCheckingExistenceOfExistingItemThenReturnsTrue(t *testing.T) { |
||||
_, repo, _, cleanup := setupTest(t) |
||||
defer cleanup() |
||||
|
||||
item := createTestItem1() |
||||
err := repo.Save(context.Background(), item) |
||||
assert.NoError(t, err) |
||||
|
||||
itemID, _ := value_objects.NewItemID(ITEM_ID_1) |
||||
exists, err := repo.Exists(context.Background(), itemID) |
||||
assert.NoError(t, err) |
||||
assert.True(t, exists) |
||||
} |
||||
|
||||
func TestWhenCheckingExistenceOfNonExistentItemThenReturnsFalse(t *testing.T) { |
||||
_, repo, _, cleanup := setupTest(t) |
||||
defer cleanup() |
||||
|
||||
nonExistentID, _ := value_objects.NewItemID(NON_EXISTENT_ID) |
||||
exists, err := repo.Exists(context.Background(), nonExistentID) |
||||
assert.NoError(t, err) |
||||
assert.False(t, exists) |
||||
} |
||||
|
||||
func TestWhenDifferentRepositoryInstancesShareSameFile(t *testing.T) { |
||||
testStoragePath, _, _, cleanup := setupTest(t) |
||||
defer cleanup() |
||||
|
||||
logger := &mockLogger{} |
||||
repo1 := repositories.NewFileItemRepository(testStoragePath, logger) |
||||
repo2 := repositories.NewFileItemRepository(testStoragePath, logger) |
||||
|
||||
item := createTestItem1() |
||||
err := repo1.Save(context.Background(), item) |
||||
assert.NoError(t, err) |
||||
|
||||
itemID, _ := value_objects.NewItemID(ITEM_ID_1) |
||||
foundItem, err := repo2.FindByID(context.Background(), itemID) |
||||
assert.NoError(t, err) |
||||
assert.NotNil(t, foundItem) |
||||
assert.Equal(t, item.GetID().String(), foundItem.GetID().String()) |
||||
assert.Equal(t, item.GetName(), foundItem.GetName()) |
||||
assert.Equal(t, item.GetOrderURL(), foundItem.GetOrderURL()) |
||||
assert.Equal(t, item.GetUserID().String(), foundItem.GetUserID().String()) |
||||
} |
||||
@ -1,352 +0,0 @@
|
||||
package unit |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"testing" |
||||
"time" |
||||
|
||||
"autostore/internal/application/commands" |
||||
"autostore/internal/domain/entities" |
||||
"autostore/internal/domain/specifications" |
||||
) |
||||
|
||||
|
||||
func createTestCommand() (*commands.AddItemCommand, *mockItemRepository, *mockOrderService, *mockTimeProvider, *mockLogger) { |
||||
itemRepo := &mockItemRepository{} |
||||
orderService := &mockOrderService{} |
||||
timeProvider := &mockTimeProvider{ |
||||
nowFunc: func() time.Time { |
||||
t, _ := time.Parse(dateFormat, mockedNow) |
||||
return t |
||||
}, |
||||
} |
||||
expirationSpec := specifications.NewItemExpirationSpec() |
||||
logger := &mockLogger{} |
||||
|
||||
cmd := commands.NewAddItemCommand(itemRepo, orderService, timeProvider, expirationSpec, logger) |
||||
return cmd, itemRepo, orderService, timeProvider, logger |
||||
} |
||||
|
||||
func TestWhenItemNotExpiredThenItemSaved(t *testing.T) { |
||||
// Given
|
||||
cmd, itemRepo, _, _, _ := createTestCommand() |
||||
|
||||
expirationTime, _ := time.Parse(dateFormat, notExpiredDate) |
||||
|
||||
itemSaved := false |
||||
itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error { |
||||
itemSaved = true |
||||
if item.GetName() != itemName { |
||||
t.Errorf("Expected item name %s, got %s", itemName, item.GetName()) |
||||
} |
||||
if item.GetOrderURL() != orderURL { |
||||
t.Errorf("Expected order URL %s, got %s", orderURL, item.GetOrderURL()) |
||||
} |
||||
if item.GetUserID().String() != userID { |
||||
t.Errorf("Expected user ID %s, got %s", userID, item.GetUserID().String()) |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// When
|
||||
resultID, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID) |
||||
|
||||
// Then
|
||||
if err != nil { |
||||
t.Errorf("Expected no error, got %v", err) |
||||
} |
||||
if resultID == "" { |
||||
t.Error("Expected non-empty result ID") |
||||
} |
||||
if !itemSaved { |
||||
t.Error("Expected item to be saved") |
||||
} |
||||
} |
||||
|
||||
func TestWhenItemNotExpiredThenOrderIsNotPlaced(t *testing.T) { |
||||
// Given
|
||||
cmd, _, orderService, _, _ := createTestCommand() |
||||
|
||||
expirationTime, _ := time.Parse(dateFormat, notExpiredDate) |
||||
|
||||
orderPlaced := false |
||||
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { |
||||
orderPlaced = true |
||||
return nil |
||||
} |
||||
|
||||
// When
|
||||
_, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID) |
||||
|
||||
// Then
|
||||
if err != nil { |
||||
t.Errorf("Expected no error, got %v", err) |
||||
} |
||||
if orderPlaced { |
||||
t.Error("Expected order not to be placed for non-expired item") |
||||
} |
||||
} |
||||
|
||||
func TestWhenItemNotExpiredThenNewItemIdIsReturned(t *testing.T) { |
||||
// Given
|
||||
cmd, _, _, _, _ := createTestCommand() |
||||
|
||||
expirationTime, _ := time.Parse(dateFormat, notExpiredDate) |
||||
|
||||
// When
|
||||
resultID, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID) |
||||
|
||||
// Then
|
||||
if err != nil { |
||||
t.Errorf("Expected no error, got %v", err) |
||||
} |
||||
if resultID == "" { |
||||
t.Error("Expected non-empty result ID") |
||||
} |
||||
} |
||||
|
||||
func TestWhenItemIsExpiredThenOrderPlaced(t *testing.T) { |
||||
// Given
|
||||
cmd, itemRepo, orderService, timeProvider, _ := createTestCommand() |
||||
|
||||
// Set expiration time to 1 hour before current time to ensure it's expired
|
||||
currentTime := timeProvider.Now() |
||||
expirationTime := currentTime.Add(-1 * time.Hour) |
||||
|
||||
orderPlaced := false |
||||
var orderedItem *entities.ItemEntity |
||||
|
||||
itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error { |
||||
return nil |
||||
} |
||||
|
||||
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { |
||||
orderPlaced = true |
||||
orderedItem = item |
||||
return nil |
||||
} |
||||
|
||||
// When
|
||||
resultID, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID) |
||||
|
||||
// Then
|
||||
if err != nil { |
||||
t.Errorf("Expected no error, got %v", err) |
||||
} |
||||
if resultID == "" { |
||||
t.Error("Expected non-empty result ID") |
||||
} |
||||
if !orderPlaced { |
||||
t.Error("Expected order to be placed for expired item") |
||||
} |
||||
if orderedItem == nil { |
||||
t.Error("Expected ordered item to be captured") |
||||
} |
||||
if orderedItem != nil && orderedItem.GetName() != itemName { |
||||
t.Errorf("Expected ordered item name %s, got %s", itemName, orderedItem.GetName()) |
||||
} |
||||
} |
||||
|
||||
func TestWhenItemNameIsEmptyThenErrorReturned(t *testing.T) { |
||||
// Given
|
||||
cmd, _, _, _, _ := createTestCommand() |
||||
|
||||
expirationTime, _ := time.Parse(dateFormat, notExpiredDate) |
||||
|
||||
// When
|
||||
_, err := cmd.Execute(context.Background(), "", expirationTime, orderURL, userID) |
||||
|
||||
// Then
|
||||
if err == nil { |
||||
t.Error("Expected error for empty item name") |
||||
} |
||||
} |
||||
|
||||
func TestWhenOrderUrlIsEmptyThenErrorReturned(t *testing.T) { |
||||
// Given
|
||||
cmd, _, _, _, _ := createTestCommand() |
||||
|
||||
expirationTime, _ := time.Parse(dateFormat, notExpiredDate) |
||||
|
||||
// When
|
||||
_, err := cmd.Execute(context.Background(), itemName, expirationTime, "", userID) |
||||
|
||||
// Then
|
||||
if err == nil { |
||||
t.Error("Expected error for empty order URL") |
||||
} |
||||
} |
||||
|
||||
func TestWhenUserIdIsEmptyThenErrorReturned(t *testing.T) { |
||||
// Given
|
||||
cmd, _, _, _, _ := createTestCommand() |
||||
|
||||
expirationTime, _ := time.Parse(dateFormat, notExpiredDate) |
||||
|
||||
// When
|
||||
_, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, "") |
||||
|
||||
// Then
|
||||
if err == nil { |
||||
t.Error("Expected error for empty user ID") |
||||
} |
||||
} |
||||
|
||||
func TestWhenOrderServiceFailsThenErrorLogged(t *testing.T) { |
||||
// Given
|
||||
cmd, itemRepo, orderService, _, _ := createTestCommand() |
||||
|
||||
expirationTime, _ := time.Parse(dateFormat, expiredDate) |
||||
|
||||
itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error { |
||||
return nil |
||||
} |
||||
|
||||
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { |
||||
return errors.New("order service failed") |
||||
} |
||||
|
||||
// When - the handler should not throw an exception when the order service fails
|
||||
// It should log the error and continue
|
||||
resultID, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID) |
||||
|
||||
// Then
|
||||
if err != nil { |
||||
t.Errorf("Expected no error when order service fails, got %v", err) |
||||
} |
||||
if resultID == "" { |
||||
t.Error("Expected non-empty result ID even when order service fails") |
||||
} |
||||
} |
||||
|
||||
func TestWhenRepositorySaveThrowsExceptionThenErrorReturned(t *testing.T) { |
||||
// Given
|
||||
cmd, itemRepo, _, _, _ := createTestCommand() |
||||
|
||||
expirationTime, _ := time.Parse(dateFormat, notExpiredDate) |
||||
|
||||
expectedError := errors.New("repository error") |
||||
itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error { |
||||
return expectedError |
||||
} |
||||
|
||||
// When
|
||||
_, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID) |
||||
|
||||
// Then
|
||||
if err == nil { |
||||
t.Error("Expected error when repository save fails") |
||||
} |
||||
} |
||||
|
||||
func TestWhenRepositorySaveThrowsExceptionThenOrderIsNotPlaced(t *testing.T) { |
||||
// Given
|
||||
cmd, itemRepo, orderService, _, _ := createTestCommand() |
||||
|
||||
expirationTime, _ := time.Parse(dateFormat, expiredDate) |
||||
|
||||
expectedError := errors.New("repository error") |
||||
itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error { |
||||
return expectedError |
||||
} |
||||
|
||||
orderPlaced := false |
||||
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { |
||||
orderPlaced = true |
||||
return nil |
||||
} |
||||
|
||||
// When
|
||||
_, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID) |
||||
|
||||
// Then
|
||||
if err == nil { |
||||
t.Error("Expected error when repository save fails") |
||||
} |
||||
if orderPlaced { |
||||
t.Error("Expected order not to be placed when repository save fails") |
||||
} |
||||
} |
||||
|
||||
func TestWhenTimeProviderThrowsExceptionThenErrorReturned(t *testing.T) { |
||||
// Given
|
||||
cmd, _, _, timeProvider, _ := createTestCommand() |
||||
|
||||
expirationTime, _ := time.Parse(dateFormat, notExpiredDate) |
||||
|
||||
expectedError := errors.New("time provider error") |
||||
timeProvider.nowFunc = func() time.Time { |
||||
panic(expectedError) |
||||
} |
||||
|
||||
// When
|
||||
defer func() { |
||||
if r := recover(); r != nil { |
||||
// Expected panic
|
||||
} else { |
||||
t.Error("Expected panic when time provider fails") |
||||
} |
||||
}() |
||||
|
||||
cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID) |
||||
} |
||||
|
||||
func TestWhenItemExpirationDateIsExactlyCurrentTimeThenOrderIsPlaced(t *testing.T) { |
||||
// Given
|
||||
cmd, itemRepo, orderService, timeProvider, _ := createTestCommand() |
||||
|
||||
// Use the exact current time from the time provider
|
||||
currentTime := timeProvider.Now() |
||||
|
||||
orderPlaced := false |
||||
itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error { |
||||
return nil |
||||
} |
||||
|
||||
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { |
||||
orderPlaced = true |
||||
return nil |
||||
} |
||||
|
||||
// When
|
||||
resultID, err := cmd.Execute(context.Background(), itemName, currentTime, orderURL, userID) |
||||
|
||||
// Then
|
||||
if err != nil { |
||||
t.Errorf("Expected no error, got %v", err) |
||||
} |
||||
if resultID == "" { |
||||
t.Error("Expected non-empty result ID") |
||||
} |
||||
if !orderPlaced { |
||||
t.Error("Expected order to be placed when expiration date equals current time") |
||||
} |
||||
} |
||||
|
||||
func TestWhenItemExpirationDateIsInFutureThenItemSaved(t *testing.T) { |
||||
// Given
|
||||
cmd, itemRepo, _, _, _ := createTestCommand() |
||||
|
||||
expirationTime, _ := time.Parse(dateFormat, notExpiredDate) |
||||
|
||||
itemSaved := false |
||||
itemRepo.saveFunc = func(ctx context.Context, item *entities.ItemEntity) error { |
||||
itemSaved = true |
||||
return nil |
||||
} |
||||
|
||||
// When
|
||||
resultID, err := cmd.Execute(context.Background(), itemName, expirationTime, orderURL, userID) |
||||
|
||||
// Then
|
||||
if err != nil { |
||||
t.Errorf("Expected no error, got %v", err) |
||||
} |
||||
if resultID == "" { |
||||
t.Error("Expected non-empty result ID") |
||||
} |
||||
if !itemSaved { |
||||
t.Error("Expected item to be saved when expiration date is in future") |
||||
} |
||||
} |
||||
@ -1,293 +0,0 @@
|
||||
package unit |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"testing" |
||||
"time" |
||||
|
||||
"autostore/internal/application/commands" |
||||
"autostore/internal/domain/entities" |
||||
"autostore/internal/domain/specifications" |
||||
"autostore/internal/domain/value_objects" |
||||
) |
||||
|
||||
func createExpiredItem1() *entities.ItemEntity { |
||||
expirationTime, _ := time.Parse(dateFormat, expiredDate) |
||||
itemID, _ := value_objects.NewItemIDFromString("550e8400-e29b-41d4-a716-446655440001") |
||||
userID, _ := value_objects.NewUserIDFromString("550e8400-e29b-41d4-a716-446655440003") |
||||
expirationDate, _ := value_objects.NewExpirationDate(expirationTime) |
||||
|
||||
item, _ := entities.NewItem(itemID, "Expired Item 1", expirationDate, "http://example.com/order1", userID) |
||||
return item |
||||
} |
||||
|
||||
func createExpiredItem2() *entities.ItemEntity { |
||||
expirationTime, _ := time.Parse(dateFormat, expiredDate) |
||||
itemID, _ := value_objects.NewItemIDFromString("550e8400-e29b-41d4-a716-446655440002") |
||||
userID, _ := value_objects.NewUserIDFromString("550e8400-e29b-41d4-a716-446655440004") |
||||
expirationDate, _ := value_objects.NewExpirationDate(expirationTime) |
||||
|
||||
item, _ := entities.NewItem(itemID, "Expired Item 2", expirationDate, "http://example.com/order2", userID) |
||||
return item |
||||
} |
||||
|
||||
func createTestHandleExpiredItemsCommand() (*commands.HandleExpiredItemsCommand, *mockItemRepository, *mockOrderService, *mockTimeProvider, *specifications.ItemExpirationSpec, *mockLogger) { |
||||
itemRepo := &mockItemRepository{} |
||||
orderService := &mockOrderService{} |
||||
timeProvider := &mockTimeProvider{ |
||||
nowFunc: func() time.Time { |
||||
t, _ := time.Parse(dateFormat, mockedNow) |
||||
return t |
||||
}, |
||||
} |
||||
expirationSpec := specifications.NewItemExpirationSpec() |
||||
logger := &mockLogger{} |
||||
|
||||
cmd := commands.NewHandleExpiredItemsCommand(itemRepo, orderService, timeProvider, expirationSpec, logger) |
||||
return cmd, itemRepo, orderService, timeProvider, expirationSpec, logger |
||||
} |
||||
|
||||
func TestWhenNoExpiredItemsExistThenNoOrdersPlaced(t *testing.T) { |
||||
// Given
|
||||
cmd, itemRepo, orderService, _, _, logger := createTestHandleExpiredItemsCommand() |
||||
|
||||
itemRepo.findWhereFunc = func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) { |
||||
return []*entities.ItemEntity{}, nil |
||||
} |
||||
|
||||
orderCalled := false |
||||
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { |
||||
orderCalled = true |
||||
return nil |
||||
} |
||||
|
||||
deleteCalled := false |
||||
itemRepo.deleteFunc = func(ctx context.Context, id value_objects.ItemID) error { |
||||
deleteCalled = true |
||||
return nil |
||||
} |
||||
|
||||
// When
|
||||
err := cmd.Execute(context.Background()) |
||||
|
||||
// Then
|
||||
if err != nil { |
||||
t.Errorf("Expected no error, got %v", err) |
||||
} |
||||
if orderCalled { |
||||
t.Error("Expected order service not to be called") |
||||
} |
||||
if deleteCalled { |
||||
t.Error("Expected delete not to be called") |
||||
} |
||||
if len(logger.errorLogs) > 0 { |
||||
t.Error("Expected no error logs") |
||||
} |
||||
} |
||||
|
||||
func TestWhenExpiredItemsExistThenOrdersPlacedAndItemsDeleted(t *testing.T) { |
||||
// Given
|
||||
cmd, itemRepo, orderService, _, _, logger := createTestHandleExpiredItemsCommand() |
||||
|
||||
expiredItem1 := createExpiredItem1() |
||||
expiredItem2 := createExpiredItem2() |
||||
expiredItems := []*entities.ItemEntity{expiredItem1, expiredItem2} |
||||
|
||||
itemRepo.findWhereFunc = func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) { |
||||
return expiredItems, nil |
||||
} |
||||
|
||||
orderCallCount := 0 |
||||
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { |
||||
orderCallCount++ |
||||
return nil |
||||
} |
||||
|
||||
deleteCallCount := 0 |
||||
itemRepo.deleteFunc = func(ctx context.Context, id value_objects.ItemID) error { |
||||
deleteCallCount++ |
||||
return nil |
||||
} |
||||
|
||||
// When
|
||||
err := cmd.Execute(context.Background()) |
||||
|
||||
// Then
|
||||
if err != nil { |
||||
t.Errorf("Expected no error, got %v", err) |
||||
} |
||||
if orderCallCount != 2 { |
||||
t.Errorf("Expected order service to be called 2 times, got %d", orderCallCount) |
||||
} |
||||
if deleteCallCount != 2 { |
||||
t.Errorf("Expected delete to be called 2 times, got %d", deleteCallCount) |
||||
} |
||||
if len(logger.errorLogs) > 0 { |
||||
t.Error("Expected no error logs") |
||||
} |
||||
} |
||||
|
||||
func TestWhenOrderServiceFailsForOneItemThenErrorLoggedAndOtherItemProcessed(t *testing.T) { |
||||
// Given
|
||||
cmd, itemRepo, orderService, _, _, logger := createTestHandleExpiredItemsCommand() |
||||
|
||||
expiredItem1 := createExpiredItem1() |
||||
expiredItem2 := createExpiredItem2() |
||||
expiredItems := []*entities.ItemEntity{expiredItem1, expiredItem2} |
||||
|
||||
itemRepo.findWhereFunc = func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) { |
||||
return expiredItems, nil |
||||
} |
||||
|
||||
orderCallCount := 0 |
||||
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { |
||||
orderCallCount++ |
||||
if orderCallCount == 1 { |
||||
return errors.New("order service failed") |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
deleteCallCount := 0 |
||||
itemRepo.deleteFunc = func(ctx context.Context, id value_objects.ItemID) error { |
||||
deleteCallCount++ |
||||
return nil |
||||
} |
||||
|
||||
// When
|
||||
err := cmd.Execute(context.Background()) |
||||
|
||||
// Then
|
||||
if err != nil { |
||||
t.Errorf("Expected no error, got %v", err) |
||||
} |
||||
if orderCallCount != 2 { |
||||
t.Errorf("Expected order service to be called 2 times, got %d", orderCallCount) |
||||
} |
||||
if deleteCallCount != 1 { |
||||
t.Errorf("Expected delete to be called 1 time (only for successful order), got %d", deleteCallCount) |
||||
} |
||||
if len(logger.errorLogs) != 1 { |
||||
t.Errorf("Expected 1 error log, got %d", len(logger.errorLogs)) |
||||
} |
||||
} |
||||
|
||||
func TestWhenRepositoryFindThrowsErrorThenErrorReturned(t *testing.T) { |
||||
// Given
|
||||
cmd, itemRepo, orderService, _, _, logger := createTestHandleExpiredItemsCommand() |
||||
|
||||
expectedError := errors.New("repository find error") |
||||
itemRepo.findWhereFunc = func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) { |
||||
return nil, expectedError |
||||
} |
||||
|
||||
orderCalled := false |
||||
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { |
||||
orderCalled = true |
||||
return nil |
||||
} |
||||
|
||||
// When
|
||||
err := cmd.Execute(context.Background()) |
||||
|
||||
// Then
|
||||
if err == nil { |
||||
t.Error("Expected error, got nil") |
||||
} |
||||
if orderCalled { |
||||
t.Error("Expected order service not to be called") |
||||
} |
||||
if len(logger.errorLogs) != 1 { |
||||
t.Errorf("Expected 1 error log, got %d", len(logger.errorLogs)) |
||||
} |
||||
} |
||||
|
||||
func TestWhenRepositoryDeleteThrowsExceptionThenErrorLogged(t *testing.T) { |
||||
// Given
|
||||
cmd, itemRepo, orderService, _, _, logger := createTestHandleExpiredItemsCommand() |
||||
|
||||
expiredItem1 := createExpiredItem1() |
||||
expiredItems := []*entities.ItemEntity{expiredItem1} |
||||
|
||||
itemRepo.findWhereFunc = func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) { |
||||
return expiredItems, nil |
||||
} |
||||
|
||||
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { |
||||
return nil |
||||
} |
||||
|
||||
expectedError := errors.New("delete failed") |
||||
itemRepo.deleteFunc = func(ctx context.Context, id value_objects.ItemID) error { |
||||
return expectedError |
||||
} |
||||
|
||||
// When
|
||||
err := cmd.Execute(context.Background()) |
||||
|
||||
// Then
|
||||
if err == nil { |
||||
t.Error("Expected error, got nil") |
||||
} |
||||
if len(logger.errorLogs) != 1 { |
||||
t.Errorf("Expected 1 error log, got %d", len(logger.errorLogs)) |
||||
} |
||||
} |
||||
|
||||
func TestWhenTimeProviderThrowsErrorThenErrorReturned(t *testing.T) { |
||||
// Given
|
||||
cmd, _, _, timeProvider, _, _ := createTestHandleExpiredItemsCommand() |
||||
|
||||
timeProvider.nowFunc = func() time.Time { |
||||
panic("time provider error") |
||||
} |
||||
|
||||
// When & Then
|
||||
defer func() { |
||||
if r := recover(); r != nil { |
||||
// Expected panic
|
||||
} else { |
||||
t.Error("Expected panic when time provider fails") |
||||
} |
||||
}() |
||||
|
||||
cmd.Execute(context.Background()) |
||||
} |
||||
|
||||
func TestWhenAllOrderServicesFailThenAllErrorsLogged(t *testing.T) { |
||||
// Given
|
||||
cmd, itemRepo, orderService, _, _, logger := createTestHandleExpiredItemsCommand() |
||||
|
||||
expiredItem1 := createExpiredItem1() |
||||
expiredItem2 := createExpiredItem2() |
||||
expiredItems := []*entities.ItemEntity{expiredItem1, expiredItem2} |
||||
|
||||
itemRepo.findWhereFunc = func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) { |
||||
return expiredItems, nil |
||||
} |
||||
|
||||
orderService.orderItemFunc = func(ctx context.Context, item *entities.ItemEntity) error { |
||||
return errors.New("order service failed") |
||||
} |
||||
|
||||
deleteCalled := false |
||||
itemRepo.deleteFunc = func(ctx context.Context, id value_objects.ItemID) error { |
||||
deleteCalled = true |
||||
return nil |
||||
} |
||||
|
||||
// When
|
||||
err := cmd.Execute(context.Background()) |
||||
|
||||
// Then
|
||||
if err != nil { |
||||
t.Errorf("Expected no error, got %v", err) |
||||
} |
||||
if deleteCalled { |
||||
t.Error("Expected delete not to be called when all orders fail") |
||||
} |
||||
if len(logger.errorLogs) != 2 { |
||||
t.Errorf("Expected 2 error logs, got %d", len(logger.errorLogs)) |
||||
} |
||||
} |
||||
@ -1,904 +0,0 @@
|
||||
package unit |
||||
|
||||
import ( |
||||
"testing" |
||||
"time" |
||||
|
||||
"autostore/internal/domain/specifications" |
||||
) |
||||
|
||||
// Test data constants
|
||||
const ( |
||||
TEST_STRING = "test-value" |
||||
TEST_INT = 42 |
||||
TEST_FLOAT = 3.14 |
||||
TEST_DATE = "2023-01-15 10:30:00" |
||||
TEST_DATE_2 = "2023-02-15 10:30:00" |
||||
INVALID_DATE = "invalid-date" |
||||
) |
||||
|
||||
// TestObject is a simple struct for testing specifications
|
||||
type TestObject struct { |
||||
name string |
||||
age int |
||||
price float64 |
||||
status string |
||||
score int |
||||
role string |
||||
department string |
||||
active bool |
||||
optionalField interface{} |
||||
dateField string |
||||
createdAt time.Time |
||||
expirationDate time.Time |
||||
} |
||||
|
||||
// NewTestObject creates a new TestObject with the given field values
|
||||
func NewTestObject(fields map[string]interface{}) *TestObject { |
||||
obj := &TestObject{} |
||||
|
||||
if val, ok := fields["name"]; ok { |
||||
obj.name = val.(string) |
||||
} |
||||
if val, ok := fields["age"]; ok { |
||||
obj.age = val.(int) |
||||
} |
||||
if val, ok := fields["price"]; ok { |
||||
obj.price = val.(float64) |
||||
} |
||||
if val, ok := fields["status"]; ok { |
||||
obj.status = val.(string) |
||||
} |
||||
if val, ok := fields["score"]; ok { |
||||
obj.score = val.(int) |
||||
} |
||||
if val, ok := fields["role"]; ok { |
||||
obj.role = val.(string) |
||||
} |
||||
if val, ok := fields["department"]; ok { |
||||
obj.department = val.(string) |
||||
} |
||||
if val, ok := fields["active"]; ok { |
||||
obj.active = val.(bool) |
||||
} |
||||
if val, ok := fields["optionalField"]; ok { |
||||
obj.optionalField = val |
||||
} |
||||
if val, ok := fields["dateField"]; ok { |
||||
obj.dateField = val.(string) |
||||
} |
||||
if val, ok := fields["createdAt"]; ok { |
||||
switch v := val.(type) { |
||||
case time.Time: |
||||
obj.createdAt = v |
||||
case string: |
||||
obj.createdAt, _ = time.Parse(dateFormat, v) |
||||
} |
||||
} |
||||
if val, ok := fields["expirationDate"]; ok { |
||||
switch v := val.(type) { |
||||
case time.Time: |
||||
obj.expirationDate = v |
||||
case string: |
||||
obj.expirationDate, _ = time.Parse(dateFormat, v) |
||||
} |
||||
} |
||||
|
||||
return obj |
||||
} |
||||
|
||||
// Getter methods for TestObject
|
||||
func (o *TestObject) GetName() string { |
||||
return o.name |
||||
} |
||||
|
||||
func (o *TestObject) GetAge() int { |
||||
return o.age |
||||
} |
||||
|
||||
func (o *TestObject) GetPrice() float64 { |
||||
return o.price |
||||
} |
||||
|
||||
func (o *TestObject) GetStatus() string { |
||||
return o.status |
||||
} |
||||
|
||||
func (o *TestObject) GetScore() int { |
||||
return o.score |
||||
} |
||||
|
||||
func (o *TestObject) GetRole() string { |
||||
return o.role |
||||
} |
||||
|
||||
func (o *TestObject) GetDepartment() string { |
||||
return o.department |
||||
} |
||||
|
||||
func (o *TestObject) GetActive() bool { |
||||
return o.active |
||||
} |
||||
|
||||
func (o *TestObject) GetOptionalField() interface{} { |
||||
return o.optionalField |
||||
} |
||||
|
||||
func (o *TestObject) GetDateField() string { |
||||
return o.dateField |
||||
} |
||||
|
||||
func (o *TestObject) GetCreatedAt() time.Time { |
||||
return o.createdAt |
||||
} |
||||
|
||||
func (o *TestObject) GetExpirationDate() time.Time { |
||||
return o.expirationDate |
||||
} |
||||
|
||||
// EQ Operator Tests
|
||||
|
||||
func TestWhenUsingEqWithStringThenMatchesCorrectly(t *testing.T) { |
||||
// Given
|
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("name", TEST_STRING)) |
||||
matchingObject := NewTestObject(map[string]interface{}{"name": TEST_STRING}) |
||||
nonMatchingObject := NewTestObject(map[string]interface{}{"name": "different"}) |
||||
|
||||
// When
|
||||
matchResult := spec.IsSatisfiedBy(matchingObject) |
||||
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) |
||||
|
||||
// Then
|
||||
if !matchResult { |
||||
t.Error("Expected matching object to satisfy the specification") |
||||
} |
||||
if noMatchResult { |
||||
t.Error("Expected non-matching object to not satisfy the specification") |
||||
} |
||||
} |
||||
|
||||
func TestWhenUsingEqWithIntegerThenMatchesCorrectly(t *testing.T) { |
||||
// Given
|
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("age", TEST_INT)) |
||||
matchingObject := NewTestObject(map[string]interface{}{"age": TEST_INT}) |
||||
nonMatchingObject := NewTestObject(map[string]interface{}{"age": 100}) |
||||
|
||||
// When
|
||||
matchResult := spec.IsSatisfiedBy(matchingObject) |
||||
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) |
||||
|
||||
// Then
|
||||
if !matchResult { |
||||
t.Error("Expected matching object to satisfy the specification") |
||||
} |
||||
if noMatchResult { |
||||
t.Error("Expected non-matching object to not satisfy the specification") |
||||
} |
||||
} |
||||
|
||||
func TestWhenUsingEqWithFloatThenMatchesCorrectly(t *testing.T) { |
||||
// Given
|
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("price", TEST_FLOAT)) |
||||
matchingObject := NewTestObject(map[string]interface{}{"price": TEST_FLOAT}) |
||||
nonMatchingObject := NewTestObject(map[string]interface{}{"price": 1.0}) |
||||
|
||||
// When
|
||||
matchResult := spec.IsSatisfiedBy(matchingObject) |
||||
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) |
||||
|
||||
// Then
|
||||
if !matchResult { |
||||
t.Error("Expected matching object to satisfy the specification") |
||||
} |
||||
if noMatchResult { |
||||
t.Error("Expected non-matching object to not satisfy the specification") |
||||
} |
||||
} |
||||
|
||||
// NEQ Operator Tests
|
||||
|
||||
func TestWhenUsingNeqThenMatchesCorrectly(t *testing.T) { |
||||
// Given
|
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Neq("status", "inactive")) |
||||
matchingObject := NewTestObject(map[string]interface{}{"status": "active"}) |
||||
nonMatchingObject := NewTestObject(map[string]interface{}{"status": "inactive"}) |
||||
|
||||
// When
|
||||
matchResult := spec.IsSatisfiedBy(matchingObject) |
||||
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) |
||||
|
||||
// Then
|
||||
if !matchResult { |
||||
t.Error("Expected matching object to satisfy the specification") |
||||
} |
||||
if noMatchResult { |
||||
t.Error("Expected non-matching object to not satisfy the specification") |
||||
} |
||||
} |
||||
|
||||
// Comparison Operators Tests
|
||||
|
||||
func TestWhenUsingGtThenMatchesCorrectly(t *testing.T) { |
||||
// Given
|
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Gt("score", 80)) |
||||
matchingObject := NewTestObject(map[string]interface{}{"score": 90}) |
||||
nonMatchingObject := NewTestObject(map[string]interface{}{"score": 70}) |
||||
|
||||
// When
|
||||
matchResult := spec.IsSatisfiedBy(matchingObject) |
||||
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) |
||||
|
||||
// Then
|
||||
if !matchResult { |
||||
t.Error("Expected matching object to satisfy the specification") |
||||
} |
||||
if noMatchResult { |
||||
t.Error("Expected non-matching object to not satisfy the specification") |
||||
} |
||||
} |
||||
|
||||
func TestWhenUsingGteThenMatchesCorrectly(t *testing.T) { |
||||
// Given
|
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Gte("score", 80)) |
||||
matchingObject1 := NewTestObject(map[string]interface{}{"score": 80}) |
||||
matchingObject2 := NewTestObject(map[string]interface{}{"score": 90}) |
||||
nonMatchingObject := NewTestObject(map[string]interface{}{"score": 70}) |
||||
|
||||
// When
|
||||
matchResult1 := spec.IsSatisfiedBy(matchingObject1) |
||||
matchResult2 := spec.IsSatisfiedBy(matchingObject2) |
||||
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) |
||||
|
||||
// Then
|
||||
if !matchResult1 { |
||||
t.Error("Expected matching object 1 to satisfy the specification") |
||||
} |
||||
if !matchResult2 { |
||||
t.Error("Expected matching object 2 to satisfy the specification") |
||||
} |
||||
if noMatchResult { |
||||
t.Error("Expected non-matching object to not satisfy the specification") |
||||
} |
||||
} |
||||
|
||||
func TestWhenUsingLtThenMatchesCorrectly(t *testing.T) { |
||||
// Given
|
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Lt("score", 80)) |
||||
matchingObject := NewTestObject(map[string]interface{}{"score": 70}) |
||||
nonMatchingObject := NewTestObject(map[string]interface{}{"score": 90}) |
||||
|
||||
// When
|
||||
matchResult := spec.IsSatisfiedBy(matchingObject) |
||||
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) |
||||
|
||||
// Then
|
||||
if !matchResult { |
||||
t.Error("Expected matching object to satisfy the specification") |
||||
} |
||||
if noMatchResult { |
||||
t.Error("Expected non-matching object to not satisfy the specification") |
||||
} |
||||
} |
||||
|
||||
func TestWhenUsingLteThenMatchesCorrectly(t *testing.T) { |
||||
// Given
|
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Lte("score", 80)) |
||||
matchingObject1 := NewTestObject(map[string]interface{}{"score": 80}) |
||||
matchingObject2 := NewTestObject(map[string]interface{}{"score": 70}) |
||||
nonMatchingObject := NewTestObject(map[string]interface{}{"score": 90}) |
||||
|
||||
// When
|
||||
matchResult1 := spec.IsSatisfiedBy(matchingObject1) |
||||
matchResult2 := spec.IsSatisfiedBy(matchingObject2) |
||||
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) |
||||
|
||||
// Then
|
||||
if !matchResult1 { |
||||
t.Error("Expected matching object 1 to satisfy the specification") |
||||
} |
||||
if !matchResult2 { |
||||
t.Error("Expected matching object 2 to satisfy the specification") |
||||
} |
||||
if noMatchResult { |
||||
t.Error("Expected non-matching object to not satisfy the specification") |
||||
} |
||||
} |
||||
|
||||
// IN Operator Tests
|
||||
|
||||
func TestWhenUsingInThenMatchesCorrectly(t *testing.T) { |
||||
// Given
|
||||
validValues := []interface{}{"admin", "moderator", "editor"} |
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.In("role", validValues)) |
||||
matchingObject := NewTestObject(map[string]interface{}{"role": "admin"}) |
||||
nonMatchingObject := NewTestObject(map[string]interface{}{"role": "user"}) |
||||
|
||||
// When
|
||||
matchResult := spec.IsSatisfiedBy(matchingObject) |
||||
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) |
||||
|
||||
// Then
|
||||
if !matchResult { |
||||
t.Error("Expected matching object to satisfy the specification") |
||||
} |
||||
if noMatchResult { |
||||
t.Error("Expected non-matching object to not satisfy the specification") |
||||
} |
||||
} |
||||
|
||||
func TestWhenUsingInWithEmptyArrayThenNeverMatches(t *testing.T) { |
||||
// Given
|
||||
validValues := []interface{}{} |
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.In("role", validValues)) |
||||
testObject := NewTestObject(map[string]interface{}{"role": "admin"}) |
||||
|
||||
// When
|
||||
result := spec.IsSatisfiedBy(testObject) |
||||
|
||||
// Then
|
||||
if result { |
||||
t.Error("Expected object to not satisfy the specification with empty array") |
||||
} |
||||
} |
||||
|
||||
// NOT IN Operator Tests
|
||||
|
||||
func TestWhenUsingNotInThenMatchesCorrectly(t *testing.T) { |
||||
// Given
|
||||
invalidValues := []interface{}{"banned", "suspended"} |
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Nin("status", invalidValues)) |
||||
matchingObject := NewTestObject(map[string]interface{}{"status": "active"}) |
||||
nonMatchingObject := NewTestObject(map[string]interface{}{"status": "banned"}) |
||||
|
||||
// When
|
||||
matchResult := spec.IsSatisfiedBy(matchingObject) |
||||
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) |
||||
|
||||
// Then
|
||||
if !matchResult { |
||||
t.Error("Expected matching object to satisfy the specification") |
||||
} |
||||
if noMatchResult { |
||||
t.Error("Expected non-matching object to not satisfy the specification") |
||||
} |
||||
} |
||||
|
||||
// DateTime Tests
|
||||
|
||||
func TestWhenUsingDateTimeComparisonWithStringsThenMatchesCorrectly(t *testing.T) { |
||||
// Given
|
||||
testDate2, _ := time.Parse(dateFormat, TEST_DATE_2) |
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Lt("createdAt", testDate2)) |
||||
matchingObject := NewTestObject(map[string]interface{}{"createdAt": TEST_DATE}) |
||||
nonMatchingObject := NewTestObject(map[string]interface{}{"createdAt": TEST_DATE_2}) |
||||
|
||||
// When
|
||||
matchResult := spec.IsSatisfiedBy(matchingObject) |
||||
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) |
||||
|
||||
// Then
|
||||
if !matchResult { |
||||
t.Error("Expected matching object to satisfy the specification") |
||||
} |
||||
if noMatchResult { |
||||
t.Error("Expected non-matching object to not satisfy the specification") |
||||
} |
||||
} |
||||
|
||||
func TestWhenUsingDateTimeComparisonWithTimeObjectsThenMatchesCorrectly(t *testing.T) { |
||||
// Given
|
||||
testDate, _ := time.Parse(dateFormat, TEST_DATE) |
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Lte("expirationDate", testDate)) |
||||
matchingDate, _ := time.Parse(dateFormat, TEST_DATE) |
||||
nonMatchingDate, _ := time.Parse(dateFormat, TEST_DATE_2) |
||||
matchingObject := NewTestObject(map[string]interface{}{"expirationDate": matchingDate}) |
||||
nonMatchingObject := NewTestObject(map[string]interface{}{"expirationDate": nonMatchingDate}) |
||||
|
||||
// When
|
||||
matchResult := spec.IsSatisfiedBy(matchingObject) |
||||
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) |
||||
|
||||
// Then
|
||||
if !matchResult { |
||||
t.Error("Expected matching object to satisfy the specification") |
||||
} |
||||
if noMatchResult { |
||||
t.Error("Expected non-matching object to not satisfy the specification") |
||||
} |
||||
} |
||||
|
||||
// AND Group Tests
|
||||
|
||||
func TestWhenUsingAndGroupThenMatchesOnlyWhenAllConditionsMet(t *testing.T) { |
||||
// Given
|
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.And( |
||||
specifications.Eq("status", "active"), |
||||
specifications.Gte("score", 80), |
||||
specifications.In("role", []interface{}{"admin", "moderator"}), |
||||
)) |
||||
matchingObject := NewTestObject(map[string]interface{}{ |
||||
"status": "active", |
||||
"score": 85, |
||||
"role": "admin", |
||||
}) |
||||
nonMatchingObject1 := NewTestObject(map[string]interface{}{ |
||||
"status": "inactive", |
||||
"score": 85, |
||||
"role": "admin", |
||||
}) |
||||
nonMatchingObject2 := NewTestObject(map[string]interface{}{ |
||||
"status": "active", |
||||
"score": 70, |
||||
"role": "admin", |
||||
}) |
||||
nonMatchingObject3 := NewTestObject(map[string]interface{}{ |
||||
"status": "active", |
||||
"score": 85, |
||||
"role": "user", |
||||
}) |
||||
|
||||
// When
|
||||
matchResult := spec.IsSatisfiedBy(matchingObject) |
||||
noMatchResult1 := spec.IsSatisfiedBy(nonMatchingObject1) |
||||
noMatchResult2 := spec.IsSatisfiedBy(nonMatchingObject2) |
||||
noMatchResult3 := spec.IsSatisfiedBy(nonMatchingObject3) |
||||
|
||||
// Then
|
||||
if !matchResult { |
||||
t.Error("Expected matching object to satisfy the specification") |
||||
} |
||||
if noMatchResult1 { |
||||
t.Error("Expected non-matching object 1 to not satisfy the specification") |
||||
} |
||||
if noMatchResult2 { |
||||
t.Error("Expected non-matching object 2 to not satisfy the specification") |
||||
} |
||||
if noMatchResult3 { |
||||
t.Error("Expected non-matching object 3 to not satisfy the specification") |
||||
} |
||||
} |
||||
|
||||
// OR Group Tests
|
||||
|
||||
func TestWhenUsingOrGroupThenMatchesWhenAnyConditionMet(t *testing.T) { |
||||
// Given
|
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Or( |
||||
specifications.Eq("role", "admin"), |
||||
specifications.Gte("score", 90), |
||||
specifications.In("department", []interface{}{"IT", "HR"}), |
||||
)) |
||||
matchingObject1 := NewTestObject(map[string]interface{}{ |
||||
"role": "admin", |
||||
"score": 70, |
||||
"department": "Finance", |
||||
}) |
||||
matchingObject2 := NewTestObject(map[string]interface{}{ |
||||
"role": "user", |
||||
"score": 95, |
||||
"department": "Finance", |
||||
}) |
||||
matchingObject3 := NewTestObject(map[string]interface{}{ |
||||
"role": "user", |
||||
"score": 70, |
||||
"department": "IT", |
||||
}) |
||||
nonMatchingObject := NewTestObject(map[string]interface{}{ |
||||
"role": "user", |
||||
"score": 70, |
||||
"department": "Finance", |
||||
}) |
||||
|
||||
// When
|
||||
matchResult1 := spec.IsSatisfiedBy(matchingObject1) |
||||
matchResult2 := spec.IsSatisfiedBy(matchingObject2) |
||||
matchResult3 := spec.IsSatisfiedBy(matchingObject3) |
||||
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) |
||||
|
||||
// Then
|
||||
if !matchResult1 { |
||||
t.Error("Expected matching object 1 to satisfy the specification") |
||||
} |
||||
if !matchResult2 { |
||||
t.Error("Expected matching object 2 to satisfy the specification") |
||||
} |
||||
if !matchResult3 { |
||||
t.Error("Expected matching object 3 to satisfy the specification") |
||||
} |
||||
if noMatchResult { |
||||
t.Error("Expected non-matching object to not satisfy the specification") |
||||
} |
||||
} |
||||
|
||||
// NOT Group Tests
|
||||
|
||||
func TestWhenUsingNotGroupThenInvertsCondition(t *testing.T) { |
||||
// Given
|
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Not(specifications.Eq("status", "banned"))) |
||||
matchingObject := NewTestObject(map[string]interface{}{"status": "active"}) |
||||
nonMatchingObject := NewTestObject(map[string]interface{}{"status": "banned"}) |
||||
|
||||
// When
|
||||
matchResult := spec.IsSatisfiedBy(matchingObject) |
||||
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) |
||||
|
||||
// Then
|
||||
if !matchResult { |
||||
t.Error("Expected matching object to satisfy the specification") |
||||
} |
||||
if noMatchResult { |
||||
t.Error("Expected non-matching object to not satisfy the specification") |
||||
} |
||||
} |
||||
|
||||
// Complex Nested Groups Tests
|
||||
|
||||
func TestWhenUsingNestedAndOrGroupsThenMatchesCorrectly(t *testing.T) { |
||||
// Given
|
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.And( |
||||
specifications.Eq("status", "active"), |
||||
specifications.Or( |
||||
specifications.Gte("score", 80), |
||||
specifications.In("role", []interface{}{"admin", "moderator"}), |
||||
), |
||||
)) |
||||
matchingObject1 := NewTestObject(map[string]interface{}{ |
||||
"status": "active", |
||||
"score": 85, |
||||
"role": "user", |
||||
}) |
||||
matchingObject2 := NewTestObject(map[string]interface{}{ |
||||
"status": "active", |
||||
"score": 70, |
||||
"role": "admin", |
||||
}) |
||||
nonMatchingObject := NewTestObject(map[string]interface{}{ |
||||
"status": "inactive", |
||||
"score": 85, |
||||
"role": "user", |
||||
}) |
||||
|
||||
// When
|
||||
matchResult1 := spec.IsSatisfiedBy(matchingObject1) |
||||
matchResult2 := spec.IsSatisfiedBy(matchingObject2) |
||||
noMatchResult := spec.IsSatisfiedBy(nonMatchingObject) |
||||
|
||||
// Then
|
||||
if !matchResult1 { |
||||
t.Error("Expected matching object 1 to satisfy the specification") |
||||
} |
||||
if !matchResult2 { |
||||
t.Error("Expected matching object 2 to satisfy the specification") |
||||
} |
||||
if noMatchResult { |
||||
t.Error("Expected non-matching object to not satisfy the specification") |
||||
} |
||||
} |
||||
|
||||
func TestWhenUsingTripleNestedGroupsThenMatchesCorrectly(t *testing.T) { |
||||
// Given
|
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.And( |
||||
specifications.Eq("active", true), |
||||
specifications.Not(specifications.Or( |
||||
specifications.Eq("role", "banned"), |
||||
specifications.Eq("status", "suspended"), |
||||
)), |
||||
)) |
||||
matchingObject := NewTestObject(map[string]interface{}{ |
||||
"active": true, |
||||
"role": "user", |
||||
"status": "active", |
||||
}) |
||||
nonMatchingObject1 := NewTestObject(map[string]interface{}{ |
||||
"active": false, |
||||
"role": "user", |
||||
"status": "active", |
||||
}) |
||||
nonMatchingObject2 := NewTestObject(map[string]interface{}{ |
||||
"active": true, |
||||
"role": "banned", |
||||
"status": "active", |
||||
}) |
||||
|
||||
// When
|
||||
matchResult := spec.IsSatisfiedBy(matchingObject) |
||||
noMatchResult1 := spec.IsSatisfiedBy(nonMatchingObject1) |
||||
noMatchResult2 := spec.IsSatisfiedBy(nonMatchingObject2) |
||||
|
||||
// Then
|
||||
if !matchResult { |
||||
t.Error("Expected matching object to satisfy the specification") |
||||
} |
||||
if noMatchResult1 { |
||||
t.Error("Expected non-matching object 1 to not satisfy the specification") |
||||
} |
||||
if noMatchResult2 { |
||||
t.Error("Expected non-matching object 2 to not satisfy the specification") |
||||
} |
||||
} |
||||
|
||||
// Edge Case Tests
|
||||
|
||||
func TestWhenFieldDoesNotExistThenReturnsFalse(t *testing.T) { |
||||
// Given
|
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("nonExistentField", "value")) |
||||
testObject := NewTestObject(map[string]interface{}{"existingField": "value"}) |
||||
|
||||
// When
|
||||
result := spec.IsSatisfiedBy(testObject) |
||||
|
||||
// Then
|
||||
if result { |
||||
t.Error("Expected object to not satisfy the specification when field doesn't exist") |
||||
} |
||||
} |
||||
|
||||
func TestWhenFieldIsNilThenReturnsFalse(t *testing.T) { |
||||
// Given
|
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("optionalField", "value")) |
||||
testObject := NewTestObject(map[string]interface{}{"optionalField": nil}) |
||||
|
||||
// When
|
||||
result := spec.IsSatisfiedBy(testObject) |
||||
|
||||
// Then
|
||||
if result { |
||||
t.Error("Expected object to not satisfy the specification when field is nil") |
||||
} |
||||
} |
||||
|
||||
func TestWhenUsingInvalidDateStringThenFallsBackToRegularComparison(t *testing.T) { |
||||
// Given
|
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("dateField", INVALID_DATE)) |
||||
testObject := NewTestObject(map[string]interface{}{"dateField": INVALID_DATE}) |
||||
|
||||
// When
|
||||
result := spec.IsSatisfiedBy(testObject) |
||||
|
||||
// Then
|
||||
if !result { |
||||
t.Error("Expected object to satisfy the specification with invalid date string") |
||||
} |
||||
} |
||||
|
||||
// Spec Helper Method Tests
|
||||
|
||||
func TestWhenUsingSpecHelpersThenCreatesCorrectSpecification(t *testing.T) { |
||||
// Given
|
||||
eqSpec := specifications.Eq("field", "value") |
||||
neqSpec := specifications.Neq("field", "value") |
||||
gtSpec := specifications.Gt("field", 10) |
||||
gteSpec := specifications.Gte("field", 10) |
||||
ltSpec := specifications.Lt("field", 10) |
||||
lteSpec := specifications.Lte("field", 10) |
||||
inSpec := specifications.In("field", []interface{}{"a", "b"}) |
||||
ninSpec := specifications.Nin("field", []interface{}{"a", "b"}) |
||||
|
||||
// When & Then
|
||||
if eqSpec.Condition == nil || eqSpec.Condition.Operator != specifications.OP_EQ || eqSpec.Condition.Value != "value" { |
||||
t.Error("EQ spec not created correctly") |
||||
} |
||||
if neqSpec.Condition == nil || neqSpec.Condition.Operator != specifications.OP_NEQ || neqSpec.Condition.Value != "value" { |
||||
t.Error("NEQ spec not created correctly") |
||||
} |
||||
if gtSpec.Condition == nil || gtSpec.Condition.Operator != specifications.OP_GT || gtSpec.Condition.Value != 10 { |
||||
t.Error("GT spec not created correctly") |
||||
} |
||||
if gteSpec.Condition == nil || gteSpec.Condition.Operator != specifications.OP_GTE || gteSpec.Condition.Value != 10 { |
||||
t.Error("GTE spec not created correctly") |
||||
} |
||||
if ltSpec.Condition == nil || ltSpec.Condition.Operator != specifications.OP_LT || ltSpec.Condition.Value != 10 { |
||||
t.Error("LT spec not created correctly") |
||||
} |
||||
if lteSpec.Condition == nil || lteSpec.Condition.Operator != specifications.OP_LTE || lteSpec.Condition.Value != 10 { |
||||
t.Error("LTE spec not created correctly") |
||||
} |
||||
if inSpec.Condition == nil || inSpec.Condition.Operator != specifications.OP_IN { |
||||
t.Error("IN spec not created correctly") |
||||
} |
||||
if ninSpec.Condition == nil || ninSpec.Condition.Operator != specifications.OP_NIN { |
||||
t.Error("NIN spec not created correctly") |
||||
} |
||||
} |
||||
|
||||
func TestWhenUsingLogicalGroupHelpersThenCreatesCorrectSpecification(t *testing.T) { |
||||
// Given
|
||||
andSpec := specifications.And(specifications.Eq("a", 1), specifications.Eq("b", 2)) |
||||
orSpec := specifications.Or(specifications.Eq("a", 1), specifications.Eq("b", 2)) |
||||
notSpec := specifications.Not(specifications.Eq("a", 1)) |
||||
|
||||
// When & Then
|
||||
if andSpec.LogicalGroup == nil || andSpec.LogicalGroup.Operator != specifications.GROUP_AND || len(andSpec.LogicalGroup.Conditions) != 2 { |
||||
t.Error("AND spec not created correctly") |
||||
} |
||||
if orSpec.LogicalGroup == nil || orSpec.LogicalGroup.Operator != specifications.GROUP_OR || len(orSpec.LogicalGroup.Conditions) != 2 { |
||||
t.Error("OR spec not created correctly") |
||||
} |
||||
if notSpec.LogicalGroup == nil || notSpec.LogicalGroup.Operator != specifications.GROUP_NOT || notSpec.LogicalGroup.Spec == nil { |
||||
t.Error("NOT spec not created correctly") |
||||
} |
||||
} |
||||
|
||||
func TestGetSpecReturnsOriginalSpecification(t *testing.T) { |
||||
// Given
|
||||
originalSpec := specifications.Eq("field", "value") |
||||
specification := specifications.NewSimpleSpecification[*TestObject](originalSpec) |
||||
|
||||
// When
|
||||
retrievedSpec := specification.GetSpec() |
||||
|
||||
// Then
|
||||
if retrievedSpec != originalSpec { |
||||
t.Error("Expected retrieved spec to be the same as original spec") |
||||
} |
||||
} |
||||
|
||||
func TestGetConditionsReturnsAllConditions(t *testing.T) { |
||||
// Given
|
||||
spec := specifications.NewSimpleSpecification[*TestObject](specifications.And( |
||||
specifications.Eq("status", "active"), |
||||
specifications.Gte("score", 80), |
||||
)) |
||||
|
||||
// When
|
||||
conditions := spec.GetConditions() |
||||
|
||||
// Then
|
||||
if len(conditions) != 2 { |
||||
t.Error("Expected 2 conditions") |
||||
} |
||||
|
||||
// Check that both conditions are present
|
||||
foundStatus := false |
||||
foundScore := false |
||||
for _, cond := range conditions { |
||||
if cond.Field == "status" && cond.Operator == specifications.OP_EQ && cond.Value == "active" { |
||||
foundStatus = true |
||||
} |
||||
if cond.Field == "score" && cond.Operator == specifications.OP_GTE && cond.Value == 80 { |
||||
foundScore = true |
||||
} |
||||
} |
||||
|
||||
if !foundStatus { |
||||
t.Error("Expected status condition to be found") |
||||
} |
||||
if !foundScore { |
||||
t.Error("Expected score condition to be found") |
||||
} |
||||
} |
||||
|
||||
// Composite Specification Tests
|
||||
|
||||
func TestCompositeSpecificationAndOperation(t *testing.T) { |
||||
// Given
|
||||
leftSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("status", "active")) |
||||
rightSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Gte("score", 80)) |
||||
compositeSpec := leftSpec.And(rightSpec) |
||||
|
||||
matchingObject := NewTestObject(map[string]interface{}{ |
||||
"status": "active", |
||||
"score": 85, |
||||
}) |
||||
nonMatchingObject := NewTestObject(map[string]interface{}{ |
||||
"status": "inactive", |
||||
"score": 85, |
||||
}) |
||||
|
||||
// When
|
||||
matchResult := compositeSpec.IsSatisfiedBy(matchingObject) |
||||
noMatchResult := compositeSpec.IsSatisfiedBy(nonMatchingObject) |
||||
|
||||
// Then
|
||||
if !matchResult { |
||||
t.Error("Expected matching object to satisfy the composite specification") |
||||
} |
||||
if noMatchResult { |
||||
t.Error("Expected non-matching object to not satisfy the composite specification") |
||||
} |
||||
} |
||||
|
||||
func TestCompositeSpecificationOrOperation(t *testing.T) { |
||||
// Given
|
||||
leftSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("role", "admin")) |
||||
rightSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Gte("score", 90)) |
||||
compositeSpec := leftSpec.Or(rightSpec) |
||||
|
||||
matchingObject1 := NewTestObject(map[string]interface{}{ |
||||
"role": "admin", |
||||
"score": 70, |
||||
}) |
||||
matchingObject2 := NewTestObject(map[string]interface{}{ |
||||
"role": "user", |
||||
"score": 95, |
||||
}) |
||||
nonMatchingObject := NewTestObject(map[string]interface{}{ |
||||
"role": "user", |
||||
"score": 70, |
||||
}) |
||||
|
||||
// When
|
||||
matchResult1 := compositeSpec.IsSatisfiedBy(matchingObject1) |
||||
matchResult2 := compositeSpec.IsSatisfiedBy(matchingObject2) |
||||
noMatchResult := compositeSpec.IsSatisfiedBy(nonMatchingObject) |
||||
|
||||
// Then
|
||||
if !matchResult1 { |
||||
t.Error("Expected matching object 1 to satisfy the composite specification") |
||||
} |
||||
if !matchResult2 { |
||||
t.Error("Expected matching object 2 to satisfy the composite specification") |
||||
} |
||||
if noMatchResult { |
||||
t.Error("Expected non-matching object to not satisfy the composite specification") |
||||
} |
||||
} |
||||
|
||||
func TestCompositeSpecificationNotOperation(t *testing.T) { |
||||
// Given
|
||||
baseSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("status", "banned")) |
||||
compositeSpec := baseSpec.Not() |
||||
|
||||
matchingObject := NewTestObject(map[string]interface{}{"status": "active"}) |
||||
nonMatchingObject := NewTestObject(map[string]interface{}{"status": "banned"}) |
||||
|
||||
// When
|
||||
matchResult := compositeSpec.IsSatisfiedBy(matchingObject) |
||||
noMatchResult := compositeSpec.IsSatisfiedBy(nonMatchingObject) |
||||
|
||||
// Then
|
||||
if !matchResult { |
||||
t.Error("Expected matching object to satisfy the NOT specification") |
||||
} |
||||
if noMatchResult { |
||||
t.Error("Expected non-matching object to not satisfy the NOT specification") |
||||
} |
||||
} |
||||
|
||||
func TestCompositeSpecificationGetConditions(t *testing.T) { |
||||
// Given
|
||||
leftSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("status", "active")) |
||||
rightSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Gte("score", 80)) |
||||
compositeSpec := leftSpec.And(rightSpec) |
||||
|
||||
// When
|
||||
conditions := compositeSpec.GetConditions() |
||||
|
||||
// Then
|
||||
if len(conditions) != 2 { |
||||
t.Error("Expected 2 conditions from composite specification") |
||||
} |
||||
|
||||
// Check that both conditions are present
|
||||
foundStatus := false |
||||
foundScore := false |
||||
for _, cond := range conditions { |
||||
if cond.Field == "status" && cond.Operator == specifications.OP_EQ && cond.Value == "active" { |
||||
foundStatus = true |
||||
} |
||||
if cond.Field == "score" && cond.Operator == specifications.OP_GTE && cond.Value == 80 { |
||||
foundScore = true |
||||
} |
||||
} |
||||
|
||||
if !foundStatus { |
||||
t.Error("Expected status condition to be found") |
||||
} |
||||
if !foundScore { |
||||
t.Error("Expected score condition to be found") |
||||
} |
||||
} |
||||
|
||||
func TestCompositeSpecificationGetSpecReturnsNil(t *testing.T) { |
||||
// Given
|
||||
leftSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Eq("status", "active")) |
||||
rightSpec := specifications.NewSimpleSpecification[*TestObject](specifications.Gte("score", 80)) |
||||
compositeSpec := leftSpec.And(rightSpec) |
||||
|
||||
// When
|
||||
spec := compositeSpec.GetSpec() |
||||
|
||||
// Then
|
||||
if spec != nil { |
||||
t.Error("Expected composite specification to return nil for GetSpec") |
||||
} |
||||
} |
||||
@ -1,99 +0,0 @@
|
||||
package unit |
||||
|
||||
import ( |
||||
"context" |
||||
"time" |
||||
|
||||
"autostore/internal/domain/entities" |
||||
"autostore/internal/domain/specifications" |
||||
"autostore/internal/domain/value_objects" |
||||
) |
||||
|
||||
// Common constants for tests
|
||||
const ( |
||||
mockedNow = "2023-01-01 12:00:00" |
||||
notExpiredDate = "2023-01-02 12:00:00" |
||||
expiredDate = "2022-12-31 12:00:00" |
||||
itemName = "Test Item" |
||||
orderURL = "http://example.com/order" |
||||
userID = "550e8400-e29b-41d4-a716-446655440000" // Valid UUID
|
||||
dateFormat = "2006-01-02 15:04:05" |
||||
) |
||||
|
||||
// Mock implementations shared across tests
|
||||
type mockItemRepository struct { |
||||
saveFunc func(ctx context.Context, item *entities.ItemEntity) error |
||||
findWhereFunc func(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) |
||||
deleteFunc func(ctx context.Context, id value_objects.ItemID) error |
||||
} |
||||
|
||||
func (m *mockItemRepository) Save(ctx context.Context, item *entities.ItemEntity) error { |
||||
if m.saveFunc != nil { |
||||
return m.saveFunc(ctx, item) |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (m *mockItemRepository) FindByID(ctx context.Context, id value_objects.ItemID) (*entities.ItemEntity, error) { |
||||
return nil, nil |
||||
} |
||||
|
||||
func (m *mockItemRepository) FindByUserID(ctx context.Context, userID value_objects.UserID) ([]*entities.ItemEntity, error) { |
||||
return nil, nil |
||||
} |
||||
|
||||
func (m *mockItemRepository) FindWhere(ctx context.Context, spec specifications.Specification[*entities.ItemEntity]) ([]*entities.ItemEntity, error) { |
||||
if m.findWhereFunc != nil { |
||||
return m.findWhereFunc(ctx, spec) |
||||
} |
||||
return nil, nil |
||||
} |
||||
|
||||
func (m *mockItemRepository) Delete(ctx context.Context, id value_objects.ItemID) error { |
||||
if m.deleteFunc != nil { |
||||
return m.deleteFunc(ctx, id) |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (m *mockItemRepository) Exists(ctx context.Context, id value_objects.ItemID) (bool, error) { |
||||
return false, nil |
||||
} |
||||
|
||||
type mockOrderService struct { |
||||
orderItemFunc func(ctx context.Context, item *entities.ItemEntity) error |
||||
} |
||||
|
||||
func (m *mockOrderService) OrderItem(ctx context.Context, item *entities.ItemEntity) error { |
||||
if m.orderItemFunc != nil { |
||||
return m.orderItemFunc(ctx, item) |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
type mockTimeProvider struct { |
||||
nowFunc func() time.Time |
||||
} |
||||
|
||||
func (m *mockTimeProvider) Now() time.Time { |
||||
if m.nowFunc != nil { |
||||
return m.nowFunc() |
||||
} |
||||
return time.Now() |
||||
} |
||||
|
||||
type mockLogger struct { |
||||
infoLogs []string |
||||
errorLogs []string |
||||
} |
||||
|
||||
func (m *mockLogger) Info(ctx context.Context, msg string, fields ...interface{}) { |
||||
m.infoLogs = append(m.infoLogs, msg) |
||||
} |
||||
|
||||
func (m *mockLogger) Error(ctx context.Context, msg string, fields ...interface{}) { |
||||
m.errorLogs = append(m.errorLogs, msg) |
||||
} |
||||
|
||||
func (m *mockLogger) Debug(ctx context.Context, msg string, fields ...interface{}) {} |
||||
func (m *mockLogger) Warn(ctx context.Context, msg string, fields ...interface{}) {} |
||||
@ -1,39 +0,0 @@
|
||||
FROM node:24.0.1-alpine |
||||
|
||||
WORKDIR /usr/src/app |
||||
|
||||
# Install system dependencies |
||||
RUN apk add --no-cache \ |
||||
git \ |
||||
bash \ |
||||
curl \ |
||||
sudo |
||||
|
||||
# Give sudo permissions to the developer user |
||||
RUN echo '%developer ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers.d/developer && \ |
||||
chmod 0440 /etc/sudoers.d/developer |
||||
|
||||
# Configure user permissions |
||||
ARG USER_ID=1000 |
||||
ARG GROUP_ID=1000 |
||||
|
||||
# Create a user with matching UID/GID |
||||
RUN if ! getent group $GROUP_ID > /dev/null 2>&1; then \ |
||||
addgroup -g $GROUP_ID developer; \ |
||||
else \ |
||||
addgroup developer; \ |
||||
fi && \ |
||||
if ! getent passwd $USER_ID > /dev/null 2>&1; then \ |
||||
adduser -D -u $USER_ID -G developer -s /bin/sh developer; \ |
||||
else \ |
||||
adduser -D -G developer -s /bin/sh developer; \ |
||||
fi |
||||
|
||||
RUN chown -R $USER_ID:$GROUP_ID /usr/src/app |
||||
|
||||
USER $USER_ID:$GROUP_ID |
||||
|
||||
# Expose port 3000 for NestJS |
||||
EXPOSE 3000 |
||||
|
||||
CMD ["npm", "run", "start:dev"] |
||||
@ -1,26 +0,0 @@
|
||||
{ |
||||
"name": "NestJS dev container", |
||||
"dockerComposeFile": "./docker-compose.yml", |
||||
"service": "app", |
||||
"workspaceFolder": "/usr/src/app", |
||||
"customizations": { |
||||
"vscode": { |
||||
"settings": { |
||||
"terminal.integrated.defaultProfile.linux": "bash", |
||||
"node.js.version": "24.0.1" |
||||
}, |
||||
"extensions": [ |
||||
"ms-vscode.vscode-typescript-next", |
||||
"ms-nodejs.vscode-node-debug2", |
||||
"ms-vscode.vscode-json", |
||||
"esbenp.prettier-vscode", |
||||
"dbaeumer.vscode-eslint", |
||||
"christian-kohler.npm-intellisense", |
||||
"christian-kohler.path-intellisense" |
||||
] |
||||
} |
||||
}, |
||||
"forwardPorts": [3000], |
||||
"remoteUser": "developer", |
||||
"postCreateCommand": "sudo chown -R developer:1000 /usr/src/app && npm install" |
||||
} |
||||
@ -1,29 +0,0 @@
|
||||
version: "3.9" |
||||
services: |
||||
app: |
||||
build: |
||||
context: .. |
||||
dockerfile: .devcontainer/Dockerfile |
||||
args: |
||||
USER_ID: ${USER_ID:-1000} |
||||
GROUP_ID: ${GROUP_ID:-1000} |
||||
image: dev-nestjs-img |
||||
container_name: dev-nestjs |
||||
user: "developer" |
||||
volumes: |
||||
- ../:/usr/src/app:cached |
||||
- node_modules:/usr/src/app/node_modules |
||||
environment: |
||||
NODE_ENV: development |
||||
ports: |
||||
- "50080:3000" |
||||
networks: |
||||
- dev-network |
||||
command: sleep infinity |
||||
|
||||
volumes: |
||||
node_modules: |
||||
|
||||
networks: |
||||
dev-network: |
||||
driver: bridge |
||||
@ -1,358 +0,0 @@
|
||||
# NestJS Implementation Plan for AutoStore |
||||
|
||||
## Overview |
||||
Implementation of AutoStore system using NestJS with TypeScript, following Clean Architecture principles. The system stores items with expiration dates and automatically orders new items when they expire. |
||||
|
||||
## Architecture Approach |
||||
- **Clean Architecture** with clear separation of concerns |
||||
- **Domain-Driven Design** with rich domain models |
||||
- **Hexagonal Architecture** with dependency inversion |
||||
- **Repository Pattern** for data persistence |
||||
- **CQRS-like** command/query separation |
||||
- **Dependency Injection** leveraging NestJS IoC container |
||||
|
||||
## Core Domain Logic |
||||
|
||||
### ItemExpirationSpec - Single Source of Truth for Expiration |
||||
|
||||
**File**: `src/domain/specifications/item-expiration.spec.ts` |
||||
**Purpose**: Centralized expiration checking logic - the single source of truth for determining if items are expired |
||||
|
||||
**Key Methods**: |
||||
- `isExpired(item: ItemEntity, currentTime: Date): boolean` - Checks if item expired |
||||
- `getSpec(currentTime: Date): Specification<ItemEntity>` - Returns specification for repository queries |
||||
|
||||
**Place in the flow**: |
||||
- Called by `AddItemCommand.execute()` to check newly created items for immediate expiration |
||||
- Called by `HandleExpiredItemsCommand.execute()` to find expired items for processing |
||||
- Used by `ItemRepository.findWhere()` to query database for expired items |
||||
|
||||
## Detailed Implementation Plan |
||||
|
||||
### Domain Layer |
||||
|
||||
#### 1. Entities |
||||
|
||||
**File**: `src/domain/entities/item.entity.ts` |
||||
**Purpose**: Core business entity representing an item |
||||
|
||||
**Key Methods**: |
||||
- `constructor(id: ItemId, name: string, expirationDate: ExpirationDate, orderUrl: string, userId: UserId): void` - Creates item with validation |
||||
- Getters for all properties |
||||
|
||||
**Place in the flow**: |
||||
- Created by `AddItemCommand.execute()` |
||||
- Retrieved by `ItemRepository` methods |
||||
- Passed to `ItemExpirationSpec.isExpired()` for expiration checking |
||||
|
||||
**File**: `src/domain/entities/user.entity.ts` |
||||
**Purpose**: User entity for item ownership and authentication purposes |
||||
|
||||
**Key Methods**: |
||||
- `constructor(id: UserId, username: string, passwordHash: string): void` - Creates user with validation |
||||
- Getters for all properties |
||||
|
||||
#### 2. Value Objects |
||||
|
||||
**File**: `src/domain/value-objects/item-id.vo.ts` |
||||
**Purpose**: Strong typing for item identifiers |
||||
|
||||
**Key Methods**: |
||||
- `constructor(value: string): void` - Validates UUID format |
||||
- `getValue(): string` - Returns string value |
||||
- `equals(other: ItemId): boolean` - Compares with another ItemId |
||||
|
||||
**File**: `src/domain/value-objects/expiration-date.vo.ts` |
||||
**Purpose**: Immutable expiration date with validation |
||||
|
||||
**Key Methods**: |
||||
- `constructor(value: Date): void` - Validates date format (allows past dates per business rules) |
||||
- `getValue(): Date` - Returns Date object |
||||
- `format(): string` - Returns ISO string format |
||||
|
||||
**Place in the flow**: |
||||
- Used by `ItemEntity` constructor for type-safe date handling |
||||
- Validated by `ItemExpirationSpec.isExpired()` for expiration logic |
||||
|
||||
#### 3. Specifications |
||||
|
||||
**File**: `src/domain/specifications/specification.interface.ts` |
||||
**Purpose**: Generic specification pattern interface |
||||
|
||||
**Key Methods**: |
||||
- `isSatisfiedBy(candidate: T): boolean` - Evaluates specification |
||||
- `getSpec(): object` - Returns specification object for repository implementation |
||||
|
||||
**Place in the flow**: |
||||
- Implemented by `ItemExpirationSpec` for type-safe specifications |
||||
- Used by `ItemRepository.findWhere()` for database queries |
||||
|
||||
### Application Layer |
||||
|
||||
#### 4. Commands |
||||
|
||||
**File**: `src/application/commands/add-item.command.ts` |
||||
**Purpose**: Use case for creating new items with expiration handling |
||||
|
||||
**Key Methods**: |
||||
- `constructor(itemRepo: IItemRepository, orderService: IOrderService, timeProvider: ITimeProvider, expirationSpec: ItemExpirationSpec, logger: Logger): void` - Dependency injection |
||||
- `execute(name: string, expirationDate: string, orderUrl: string, userId: string): Promise<string | null>` - Creates item, handles expired items immediately |
||||
|
||||
**Flow**: |
||||
1. `ItemsController.createItem()` calls `AddItemCommand.execute()` |
||||
2. Creates `ItemEntity` with validated data |
||||
3. Calls `ItemExpirationSpec.isExpired()` to check if item is expired |
||||
4. If expired: |
||||
- calls `OrderHttpService.orderItem()` |
||||
- **returns item ID** (business rule: expired items trigger ordering but still return ID field that might be empty or invalid) |
||||
5. If not expired: calls `ItemRepository.save()` and returns item ID |
||||
|
||||
**File**: `src/application/commands/handle-expired-items.command.ts` |
||||
**Purpose**: Background command to process expired items |
||||
|
||||
**Key Methods**: |
||||
- `constructor(itemRepo: IItemRepository, orderService: IOrderService, timeProvider: ITimeProvider, expirationSpec: ItemExpirationSpec, logger: Logger): void` - Dependency injection |
||||
- `execute(): Promise<void>` - Finds and processes all expired items |
||||
|
||||
**Flow**: |
||||
1. `ExpiredItemsScheduler.handleCron()` calls `HandleExpiredItemsCommand.execute()` |
||||
2. Gets current time from `ITimeProvider` |
||||
3. Calls `ItemExpirationSpec.getSpec()` to get expiration specification |
||||
4. Calls `ItemRepository.findWhere()` to find expired items |
||||
5. For each expired item: calls `OrderHttpService.orderItem()` then `ItemRepository.delete()` |
||||
|
||||
**File**: `src/application/commands/delete-item.command.ts` |
||||
**Purpose**: Use case for deleting user items |
||||
|
||||
**Key Methods**: |
||||
- `constructor(itemRepo: IItemRepository, logger: Logger): void` - Dependency injection |
||||
- `execute(itemId: string, userId: string): Promise<void>` - Validates ownership and deletes item |
||||
|
||||
**Flow**: |
||||
1. `ItemsController.deleteItem()` calls `DeleteItemCommand.execute()` |
||||
2. Calls `ItemRepository.findById()` to retrieve item |
||||
3. Validates ownership by comparing user IDs |
||||
4. Calls `ItemRepository.delete()` to remove item |
||||
|
||||
**File**: `src/application/commands/login-user.command.ts` |
||||
**Purpose**: User authentication use case |
||||
|
||||
**Key Methods**: |
||||
- `constructor(authService: IAuthService, logger: Logger): void` - Dependency injection |
||||
- `execute(username: string, password: string): Promise<string>` - Authenticates and returns JWT token |
||||
|
||||
#### 5. Queries |
||||
|
||||
**File**: `src/application/queries/get-item.query.ts` |
||||
**Purpose**: Retrieves single item by ID with authorization |
||||
|
||||
**Key Methods**: |
||||
- `constructor(itemRepo: IItemRepository, logger: Logger): void` - Dependency injection |
||||
- `execute(itemId: string, userId: string): Promise<ItemEntity>` - Validates ownership and returns item |
||||
|
||||
**Flow**: |
||||
1. `ItemsController.getItem()` calls `GetItemQuery.execute()` |
||||
2. Calls `ItemRepository.findById()` to retrieve item |
||||
3. Validates ownership by comparing user IDs |
||||
4. Returns item entity |
||||
|
||||
**File**: `src/application/queries/list-items.query.ts` |
||||
**Purpose**: Retrieves all items for authenticated user |
||||
|
||||
**Key Methods**: |
||||
- `constructor(itemRepo: IItemRepository, logger: Logger): void` - Dependency injection |
||||
- `execute(userId: string): Promise<ItemEntity[]>` - Returns user's items |
||||
|
||||
**Flow**: |
||||
1. `ItemsController.listItems()` calls `ListItemsQuery.execute()` |
||||
2. Calls `ItemRepository.findByUserId()` to retrieve user's items |
||||
3. Returns array of item entities |
||||
|
||||
#### 6. DTOs |
||||
|
||||
**File**: `src/application/dto/create-item.dto.ts` |
||||
**Purpose**: Request validation for item creation |
||||
|
||||
**Key Properties**: |
||||
- `name: string` - Item name (min: 1, max: 255) |
||||
- `expirationDate: string` - ISO date string (future date validation) |
||||
- `orderUrl: string` - Valid URL format |
||||
|
||||
**Place in the flow**: |
||||
- Used by `ItemsController.createItem()` for request body validation |
||||
|
||||
**File**: `src/application/dto/item-response.dto.ts` |
||||
**Purpose**: Standardized item response format |
||||
|
||||
**Key Properties**: |
||||
- `id: string` - Item ID |
||||
- `name: string` - Item name |
||||
- `expirationDate: string` - ISO date string |
||||
- `orderUrl: string` - Order URL |
||||
- `userId: string` - Owner user ID |
||||
- `createdAt: string` - Creation timestamp |
||||
|
||||
**Place in the flow**: |
||||
- Used by all item controller methods for response transformation |
||||
|
||||
### Infrastructure Layer |
||||
|
||||
#### 7. Repositories |
||||
|
||||
**File**: `src/infrastructure/repositories/file-item-repository.ts` |
||||
**Purpose**: File-based implementation of item repository using JSON files |
||||
|
||||
**Key Methods**: |
||||
- `save(item: ItemEntity): Promise<void>` - Persists item entity |
||||
- `findById(id: ItemId): Promise<ItemEntity | null>` - Finds by ID |
||||
- `findByUserId(userId: UserId): Promise<ItemEntity[]>` - Finds by user |
||||
- `findWhere(spec: Specification<ItemEntity>): Promise<ItemEntity[]>` - Finds by specification using `ItemExpirationSpec` |
||||
- `delete(id: ItemId): Promise<void>` - Deletes item |
||||
- `exists(id: ItemId): Promise<boolean>` - Checks existence |
||||
|
||||
**Place in the flow**: |
||||
- Called by all commands and queries for data persistence and retrieval |
||||
- Uses `ItemExpirationSpec` for finding expired items |
||||
|
||||
#### 8. HTTP Services |
||||
|
||||
**File**: `src/infrastructure/http/order-http.service.ts` |
||||
**Purpose**: HTTP implementation of order service |
||||
|
||||
**Key Methods**: |
||||
- `constructor(httpService: HttpService, logger: Logger): void` - Dependency injection |
||||
- `orderItem(item: ItemEntity): Promise<void>` - Sends POST request to order URL |
||||
|
||||
**Place in the flow**: |
||||
- Called by `AddItemCommand.execute()` for expired items |
||||
- Called by `HandleExpiredItemsCommand.execute()` for batch processing |
||||
|
||||
#### 9. Authentication |
||||
|
||||
**File**: `src/infrastructure/auth/jwt-auth.service.ts` |
||||
**Purpose**: JWT implementation of authentication service |
||||
|
||||
**Key Methods**: |
||||
- `constructor(userRepo: IUserRepository, jwtService: JwtService, configService: ConfigService, logger: Logger): void` - Dependency injection |
||||
- `authenticate(username: string, password: string): Promise<string | null>` - Validates credentials and generates JWT |
||||
- `validateToken(token: string): Promise<boolean>` - Validates JWT token |
||||
- `getUserIdFromToken(token: string): Promise<string | null>` - Extracts user ID from token |
||||
|
||||
**Place in the flow**: |
||||
- Called by `LoginUserCommand.execute()` for user authentication |
||||
- Used by `JwtAuthGuard` for route protection |
||||
|
||||
### Presentation Layer |
||||
|
||||
#### 10. Controllers |
||||
|
||||
**File**: `src/presentation/controllers/items.controller.ts` |
||||
**Purpose**: REST API endpoints for item management |
||||
|
||||
**Key Methods**: |
||||
- `constructor(addItemCmd: AddItemCommand, getItemQry: GetItemQuery, listItemsQry: ListItemsQuery, deleteItemCmd: DeleteItemCommand): void` - Dependency injection |
||||
- `createItem(@Body() dto: CreateItemDto, @Req() req: Request): Promise<ItemResponseDto>` - POST /items |
||||
- `getItem(@Param('id') id: string, @Req() req: Request): Promise<ItemResponseDto>` - GET /items/:id |
||||
- `listItems(@Req() req: Request): Promise<ItemResponseDto[]>` - GET /items |
||||
- `deleteItem(@Param('id') id: string, @Req() req: Request): Promise<void>` - DELETE /items/:id |
||||
|
||||
**Flow**: |
||||
- Receives HTTP requests and validates input |
||||
- Calls appropriate commands/queries based on HTTP method |
||||
- Returns standardized responses with DTOs |
||||
|
||||
**File**: `src/presentation/controllers/auth.controller.ts` |
||||
**Purpose**: Authentication endpoints |
||||
|
||||
**Key Methods**: |
||||
- `constructor(loginUserCmd: LoginUserCommand): void` - Dependency injection |
||||
- `login(@Body() dto: LoginDto): Promise<{ token: string }>` - POST /login |
||||
|
||||
#### 11. Guards |
||||
|
||||
**File**: `src/presentation/guards/jwt-auth.guard.ts` |
||||
**Purpose**: JWT authentication route protection |
||||
|
||||
**Key Methods**: |
||||
- `constructor(jwtAuthService: IJwtAuthService, logger: Logger): void` - Dependency injection |
||||
- `canActivate(context: ExecutionContext): Promise<boolean>` - Validates JWT and attaches user to request |
||||
|
||||
**Place in the flow**: |
||||
- Applied to all protected routes by NestJS Guard System |
||||
- Uses `JwtAuthService` for token validation |
||||
|
||||
## Background Processing |
||||
|
||||
**File**: `src/infrastructure/services/expired-items-scheduler.service.ts` |
||||
**Purpose**: Scheduled job for processing expired items using NestJS scheduler |
||||
|
||||
**Key Methods**: |
||||
- `constructor(handleExpiredItemsCmd: HandleExpiredItemsCommand): void` - Dependency injection |
||||
- `onModuleInit(): Promise<void>` - Processes expired items on application startup |
||||
- `handleExpiredItemsCron(): Promise<void>` - Runs every minute (@Cron(CronExpression.EVERY_MINUTE)) |
||||
- `handleExpiredItemsDaily(): Promise<void>` - Runs every day at midnight (@Cron('0 0 * * *')) |
||||
|
||||
**Flow**: |
||||
1. **On startup**: `onModuleInit()` immediately calls `HandleExpiredItemsCommand.execute()` |
||||
2. **Every minute**: `handleExpiredItemsCron()` processes expired items |
||||
3. **Every midnight**: `handleExpiredItemsDaily()` processes expired items |
||||
4. All methods use try-catch to continue operation despite errors |
||||
5. Comprehensive logging for monitoring and debugging |
||||
|
||||
**Configuration**: |
||||
- Requires `ScheduleModule.forRoot()` in AppModule imports |
||||
- Uses `@nestjs/schedule` package for cron expressions |
||||
- Implements `OnModuleInit` for startup processing |
||||
## Complete Flow Summary |
||||
|
||||
### Item Creation Flow |
||||
``` |
||||
POST /items |
||||
├── JwtAuthGuard (authentication) |
||||
├── CreateItemDto validation |
||||
├── ItemsController.createItem() |
||||
│ ├── AddItemCommand.execute() |
||||
│ │ ├── ItemEntity constructor (validation) |
||||
│ │ ├── ItemExpirationSpec.isExpired() ← SINGLE SOURCE OF TRUTH |
||||
│ │ ├── If expired: OrderHttpService.orderItem() |
||||
│ │ └── If not expired: ItemRepository.save() |
||||
│ └── ItemResponseDto transformation |
||||
└── HTTP response |
||||
``` |
||||
|
||||
### Expired Items Processing Flow |
||||
``` |
||||
Cron Job (every minute) |
||||
└── ExpiredItemsScheduler.handleCron() |
||||
└── HandleExpiredItemsCommand.execute() |
||||
├── ITimeProvider.now() |
||||
├── ItemExpirationSpec.getSpec() ← SINGLE SOURCE OF TRUTH |
||||
├── ItemRepository.findWhere() (using spec) |
||||
├── For each expired item: |
||||
│ ├── OrderHttpService.orderItem() |
||||
│ └── ItemRepository.delete() |
||||
└── Logging |
||||
``` |
||||
|
||||
### Item Retrieval Flow |
||||
``` |
||||
GET /items/:id |
||||
├── JwtAuthGuard (authentication) |
||||
├── ItemsController.getItem() |
||||
│ ├── GetItemQuery.execute() |
||||
│ │ ├── ItemRepository.findById() |
||||
│ │ ├── Ownership validation |
||||
│ │ └── Return ItemEntity |
||||
│ └── ItemResponseDto transformation |
||||
└── HTTP response |
||||
``` |
||||
|
||||
## Key Design Principles |
||||
|
||||
1. **Single Source of Truth**: `ItemExpirationSpec` is the only component that determines expiration logic |
||||
2. **Clear Flow**: Each component has a well-defined place in the execution chain |
||||
3. **Dependency Inversion**: High-level modules don't depend on low-level modules |
||||
4. **Separation of Concerns**: Each layer has distinct responsibilities |
||||
5. **Testability**: All components can be tested in isolation |
||||
|
||||
This implementation plan ensures consistent development regardless of the implementer, providing clear flow definitions and emphasizing `ItemExpirationSpec` as the centralized source for expiration logic. |
||||
@ -1,243 +0,0 @@
|
||||
# NestJS Implementation Review |
||||
|
||||
## Overview |
||||
|
||||
This review analyzes the TypeScript + NestJS implementation of the AutoStore application, focusing on adherence to Clean Architecture principles, SOLID principles, and the critical requirement of maintaining a **single source of truth** for domain knowledge. |
||||
|
||||
## Architecture Assessment |
||||
|
||||
### ✅ Strengths |
||||
|
||||
#### 1. Clean Architecture Implementation |
||||
The implementation successfully follows Clean Architecture with clear layer separation: |
||||
|
||||
- **Domain Layer**: Pure business logic with entities, value objects, and specifications |
||||
- **Application Layer**: Use cases, commands, queries, and infrastructure interfaces |
||||
- **Infrastructure Layer**: Concrete implementations (repositories, HTTP services, auth) |
||||
- **Presentation Layer**: Controllers and API endpoints |
||||
|
||||
#### 2. Specification Pattern Implementation |
||||
**Excellent implementation** of the Specification pattern for maintaining single source of truth: |
||||
|
||||
```typescript |
||||
// Domain specification - SINGLE SOURCE OF TRUTH |
||||
export class ItemExpirationSpec { |
||||
isExpired(item: ItemEntity, currentTime: Date): boolean { |
||||
return this.getSpec(currentTime).match(item); |
||||
} |
||||
|
||||
getSpec(currentTime: Date): SimpleSpecification<ItemEntity> { |
||||
return new SimpleSpecification<ItemEntity>( |
||||
Spec.lte('expirationDate', currentTime.toISOString()) |
||||
); |
||||
} |
||||
} |
||||
``` |
||||
|
||||
This ensures that the expiration logic (`date <= now`) is defined **only once** in the domain layer and reused throughout the application. |
||||
|
||||
#### 3. Value Objects and Entities |
||||
Proper implementation of Domain-Driven Design patterns: |
||||
- **Value Objects**: [`ItemId`](src/domain/value-objects/item-id.vo.ts:1), [`UserId`](src/domain/value-objects/user-id.vo.ts:1), [`ExpirationDate`](src/domain/value-objects/expiration-date.vo.ts:1) |
||||
- **Entities**: [`ItemEntity`](src/domain/entities/item.entity.ts:1) with proper encapsulation |
||||
- **Immutability**: Value objects are immutable with defensive copying |
||||
|
||||
#### 4. Dependency Inversion |
||||
Excellent use of dependency injection and interface segregation: |
||||
|
||||
```typescript |
||||
// Application layer depends on abstractions |
||||
export interface IItemRepository { |
||||
findWhere(specification: ISpecification<ItemEntity>): Promise<ItemEntity[]>; |
||||
// ... other methods |
||||
} |
||||
``` |
||||
|
||||
#### 5. Repository Pattern with Specifications |
||||
The [`FileItemRepository`](src/infrastructure/repositories/file-item-repository.ts:114) properly implements the specification pattern: |
||||
|
||||
```typescript |
||||
async findWhere(specification: ISpecification<ItemEntity>): Promise<ItemEntity[]> { |
||||
// Uses domain specifications for filtering |
||||
if (specification.isSatisfiedBy(item)) { |
||||
matchingItems.push(item); |
||||
} |
||||
} |
||||
``` |
||||
|
||||
#### 6. Background Processing |
||||
The implementation now includes background processing using NestJS's built-in scheduler: |
||||
|
||||
**New Implementation**: [`ExpiredItemsSchedulerService`](src/infrastructure/services/expired-items-scheduler.service.ts:1) |
||||
- **On startup**: Immediately processes expired items via `onModuleInit()` |
||||
- **Every minute**: Runs via `@Cron(CronExpression.EVERY_MINUTE)` |
||||
- **Daily at midnight**: Runs via `@Cron('0 0 * * *')` for daily processing |
||||
- **Robust error handling**: Continues operation despite individual processing failures |
||||
- **Comprehensive logging**: Tracks all scheduling activities |
||||
|
||||
**Flow Integration**: |
||||
``` |
||||
AppModule → ScheduleModule.forRoot() → ExpiredItemsSchedulerService |
||||
↓ |
||||
onModuleInit() → HandleExpiredItemsCommand.execute() [startup] |
||||
↓ |
||||
@Cron(EVERY_MINUTE) → HandleExpiredItemsCommand.execute() [continuous] |
||||
↓ |
||||
@Cron('0 0 * * *') → HandleExpiredItemsCommand.execute() [daily midnight] |
||||
``` |
||||
|
||||
### 🔍 Areas for Improvement |
||||
|
||||
#### 1. Framework Dependency in Application Layer |
||||
The implementation intentionally violates the Clean Architecture principle of framework-independent application layer by using NestJS decorators (`@Injectable()`, `@Inject()`) in the application layer. This decision was made for several practical reasons: |
||||
|
||||
- **Cleaner Construction**: NestJS's dependency injection system provides a clean and declarative way to manage dependencies, making the construction part of the application more maintainable and readable. |
||||
- **Ecosystem Integration**: Leveraging NestJS's native DI system allows for better integration with the framework's features, including interceptors, guards, and lifecycle hooks. |
||||
- **Community Standards**: This approach follows common practices in the NestJS community, making the code more familiar to developers experienced with the framework. |
||||
- **Testing Support**: NestJS provides excellent testing utilities that work seamlessly with decorator-based dependency injection. |
||||
|
||||
While this does create a framework dependency in the application layer, the trade-off is considered worthwhile for the benefits it provides in terms of development speed, maintainability, and framework integration. Alternative approaches like the Adapter Pattern or Factory Pattern could be used to make the application layer truly framework-agnostic, but they would introduce additional complexity and boilerplate code. |
||||
|
||||
#### 2. Specification Pattern Consistency |
||||
|
||||
While the implementation is excellent, there's a minor inconsistency in the [`ItemExpirationSpec`](src/domain/specifications/item-expiration.spec.ts:4) class name. The file is named `item-expiration.spec.ts` but the class is `ItemExpirationSpec`. Consider renaming to `ItemExpirationSpecification` for consistency. |
||||
|
||||
#### 3. Error Handling |
||||
The application could benefit from custom domain exceptions instead of generic `Error` objects: |
||||
|
||||
```typescript |
||||
// Current approach |
||||
throw new Error('Item name cannot be empty'); |
||||
|
||||
// Suggested improvement |
||||
throw new InvalidItemNameException('Item name cannot be empty'); |
||||
``` |
||||
|
||||
#### 4. Domain Events |
||||
Consider implementing domain events for the ordering process to better separate concerns: |
||||
|
||||
```typescript |
||||
// Instead of direct ordering in command |
||||
await this.orderService.orderItem(item); |
||||
|
||||
// Consider domain events |
||||
domainEvents.publish(new ItemExpiredEvent(item)); |
||||
``` |
||||
|
||||
|
||||
|
||||
|
||||
### 🎯 Comparison with PHP and C++ Implementations |
||||
|
||||
#### PHP Implementation |
||||
The PHP implementation follows a similar specification pattern but with some differences: |
||||
|
||||
```php |
||||
// PHP specification |
||||
public function isExpired(Item $item, DateTimeImmutable $currentTime): bool |
||||
{ |
||||
return $this->getSpec($currentTime)->match($item); |
||||
} |
||||
|
||||
public function getSpec(DateTimeImmutable $currentTime): Specification |
||||
{ |
||||
return new Specification( |
||||
Spec::lte('expirationDate', $currentTime->format('Y-m-d H:i:s')) |
||||
); |
||||
} |
||||
``` |
||||
|
||||
**Key Differences:** |
||||
- PHP uses `DateTimeImmutable` vs TypeScript's `Date` |
||||
- PHP specification includes SQL rendering capabilities in comments |
||||
- Both maintain single source of truth effectively |
||||
|
||||
#### C++ Implementation |
||||
The C++ implementation uses a different approach with a policy pattern: |
||||
|
||||
```cpp |
||||
// C++ policy approach |
||||
bool isExpired(const Item& item, const TimePoint& currentTime) const |
||||
{ |
||||
return item.expirationDate <= currentTime; |
||||
} |
||||
|
||||
ItemExpirationSpec getExpiredSpecification(const TimePoint& currentTime) const |
||||
{ |
||||
return nxl::helpers::SpecificationBuilder() |
||||
.field(FIELD_EXP_DATE) |
||||
.lessOrEqual(currentTime) |
||||
.build(); |
||||
} |
||||
``` |
||||
|
||||
**Key Differences:** |
||||
- C++ uses `std::chrono::system_clock::time_point` |
||||
- C++ uses a builder pattern for specifications |
||||
- Direct comparison in `isExpired` vs specification-based approach |
||||
|
||||
### 🏆 Best Practices Demonstrated |
||||
|
||||
#### 1. Single Source of Truth |
||||
**Excellent adherence** to the requirement that expiration checking logic exists in only one place: |
||||
|
||||
- ✅ Domain specification defines `expirationDate <= currentTime` logic |
||||
- ✅ Application layer uses [`ItemExpirationSpec`](src/application/commands/handle-expired-items.command.ts:18) for business logic |
||||
- ✅ Repository layer uses specifications for filtering without duplicating logic |
||||
- ✅ No hardcoded expiration logic in controllers or infrastructure |
||||
|
||||
#### 2. SOLID Principles |
||||
|
||||
**Single Responsibility Principle**: Each class has one reason to change |
||||
- [`ItemEntity`](src/domain/entities/item.entity.ts:5): Manages item state and validation |
||||
- [`ItemExpirationSpec`](src/domain/specifications/item-expiration.spec.ts:4): Manages expiration logic |
||||
- [`FileItemRepository`](src/infrastructure/repositories/file-item-repository.ts:12): Manages persistence |
||||
|
||||
**Open/Closed Principle**: Extension through specifications, not modification |
||||
- New filtering criteria can be added via new specifications |
||||
- Repository doesn't need modification for new query types |
||||
|
||||
**Liskov Substitution Principle**: Interfaces are properly segregated |
||||
- [`IItemRepository`](src/application/interfaces/item-repository.interface.ts:6) can be implemented by any storage mechanism |
||||
|
||||
**Interface Segregation Principle**: Focused interfaces |
||||
- [`ITimeProvider`](src/application/interfaces/time-provider.interface.ts:1): Single method interface |
||||
- [`IOrderService`](src/application/interfaces/order-service.interface.ts:1): Focused on ordering |
||||
|
||||
**Dependency Inversion Principle**: Dependencies on abstractions |
||||
- Application layer depends on interfaces, not concrete implementations |
||||
|
||||
#### 3. Clean Architecture Boundaries |
||||
|
||||
**Domain Layer**: No external dependencies |
||||
- Pure TypeScript with no framework imports |
||||
- Business logic isolated from infrastructure concerns |
||||
|
||||
**Application Layer**: Orchestrates use cases |
||||
- Depends only on domain layer and infrastructure interfaces |
||||
- Commands and queries properly separated |
||||
|
||||
**Infrastructure Layer**: Implements abstractions |
||||
- [`FileItemRepository`](src/infrastructure/repositories/file-item-repository.ts:12) implements [`IItemRepository`](src/application/interfaces/item-repository.interface.ts:6) |
||||
- [`SystemTimeProvider`](src/infrastructure/services/system-time.provider.ts:5) implements [`ITimeProvider`](src/application/interfaces/time-provider.interface.ts:1) |
||||
|
||||
### 🧪 Testing Quality |
||||
|
||||
The implementation includes comprehensive tests: |
||||
- **Unit tests** for specifications with edge cases |
||||
- **Integration tests** for repositories |
||||
- **Boundary testing** for date/time scenarios |
||||
- **Specification testing** to ensure single source of truth |
||||
|
||||
### 🚀 Recommendations |
||||
|
||||
1. **Maintain the Specification Pattern**: This is the strongest aspect of the implementation |
||||
2. **Consider Domain Events**: For better separation of ordering concerns |
||||
3. **Add Custom Exceptions**: For better error handling and domain expressiveness |
||||
4. **Document Business Rules**: Add comments explaining why expired items are allowed (business requirement) |
||||
|
||||
## Conclusion |
||||
|
||||
The NestJS implementation **excellently demonstrates** Clean Architecture principles and successfully maintains a **single source of truth** for domain knowledge. The specification pattern implementation is particularly strong, ensuring that expiration date checking logic (`date <= now`) exists in exactly one place in the codebase. |
||||
|
||||
The architecture properly separates concerns, follows SOLID principles, and provides a solid foundation for the AutoStore application. The comparison with PHP and C++ implementations shows that while the technical details differ, all implementations successfully maintain the critical single source of truth requirement. |
||||
@ -1,29 +0,0 @@
|
||||
FROM node:24.0.1-alpine as builder |
||||
|
||||
WORKDIR /app |
||||
|
||||
COPY package*.json ./ |
||||
COPY nest-cli.json ./ |
||||
COPY tsconfig.json ./ |
||||
|
||||
RUN npm install |
||||
|
||||
COPY src ./src |
||||
|
||||
RUN npm run build |
||||
RUN npm test |
||||
|
||||
|
||||
FROM node:24.0.1-alpine |
||||
|
||||
WORKDIR /app |
||||
|
||||
COPY package*.json ./ |
||||
|
||||
RUN npm install --only=production |
||||
|
||||
COPY --from=builder /app/dist ./dist |
||||
|
||||
EXPOSE 3000 |
||||
|
||||
CMD [ "node", "dist/main" ] |
||||
@ -1,17 +0,0 @@
|
||||
version: "3.9" |
||||
services: |
||||
app: |
||||
build: |
||||
context: .. |
||||
dockerfile: docker/Dockerfile |
||||
image: nestjs-app-img |
||||
container_name: nestjs-app |
||||
ports: |
||||
- "50080:3000" |
||||
networks: |
||||
- app-network |
||||
restart: unless-stopped |
||||
|
||||
networks: |
||||
app-network: |
||||
driver: bridge |
||||
@ -1,8 +0,0 @@
|
||||
{ |
||||
"$schema": "https://json.schemastore.org/nest-cli", |
||||
"collection": "@nestjs/schematics", |
||||
"sourceRoot": "src", |
||||
"compilerOptions": { |
||||
"deleteOutDir": true |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -1,82 +0,0 @@
|
||||
{ |
||||
"name": "autostore-nestjs", |
||||
"version": "0.0.1", |
||||
"description": "AutoStore implementation with NestJS", |
||||
"author": "", |
||||
"license": "MIT", |
||||
"scripts": { |
||||
"prebuild": "rimraf dist", |
||||
"build": "nest build", |
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", |
||||
"start": "nest start", |
||||
"start:dev": "nest start --watch", |
||||
"start:debug": "nest start --debug --watch", |
||||
"start:prod": "node dist/main", |
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", |
||||
"test": "jest", |
||||
"test:watch": "jest --watch", |
||||
"test:cov": "jest --coverage", |
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", |
||||
"test:e2e": "jest --config ./test/jest-e2e.json" |
||||
}, |
||||
"dependencies": { |
||||
"@nestjs/axios": "^4.0.1", |
||||
"@nestjs/common": "^10.0.0", |
||||
"@nestjs/config": "^4.0.2", |
||||
"@nestjs/core": "^10.0.0", |
||||
"@nestjs/jwt": "^11.0.0", |
||||
"@nestjs/passport": "^11.0.5", |
||||
"@nestjs/platform-express": "^10.0.0", |
||||
"@nestjs/schedule": "^6.0.0", |
||||
"@types/bcrypt": "^6.0.0", |
||||
"axios": "^1.12.1", |
||||
"bcrypt": "^6.0.0", |
||||
"class-transformer": "^0.5.1", |
||||
"class-validator": "^0.14.2", |
||||
"passport": "^0.7.0", |
||||
"passport-jwt": "^4.0.1", |
||||
"reflect-metadata": "^0.1.13", |
||||
"rxjs": "^7.8.1" |
||||
}, |
||||
"devDependencies": { |
||||
"@nestjs/cli": "^11.0.10", |
||||
"@nestjs/schematics": "^10.0.0", |
||||
"@nestjs/testing": "^10.0.0", |
||||
"@types/express": "^4.17.17", |
||||
"@types/jest": "^29.5.2", |
||||
"@types/node": "^20.3.1", |
||||
"@types/supertest": "^6.0.0", |
||||
"@typescript-eslint/eslint-plugin": "^6.0.0", |
||||
"@typescript-eslint/parser": "^6.0.0", |
||||
"eslint": "^8.42.0", |
||||
"eslint-config-prettier": "^9.0.0", |
||||
"eslint-plugin-prettier": "^5.0.0", |
||||
"jest": "^29.5.0", |
||||
"prettier": "^3.0.0", |
||||
"rimraf": "^5.0.1", |
||||
"source-map-support": "^0.5.21", |
||||
"supertest": "^6.3.3", |
||||
"ts-jest": "^29.1.0", |
||||
"ts-loader": "^9.4.3", |
||||
"ts-node": "^10.9.1", |
||||
"tsconfig-paths": "^4.2.0", |
||||
"typescript": "^5.1.3" |
||||
}, |
||||
"jest": { |
||||
"moduleFileExtensions": [ |
||||
"js", |
||||
"json", |
||||
"ts" |
||||
], |
||||
"rootDir": "src", |
||||
"testRegex": ".*__tests__.*\\.spec\\.ts$", |
||||
"transform": { |
||||
"^.+\\.(t|j)s$": "ts-jest" |
||||
}, |
||||
"collectCoverageFrom": [ |
||||
"**/*.(t|j)s" |
||||
], |
||||
"coverageDirectory": "../coverage", |
||||
"testEnvironment": "node" |
||||
} |
||||
} |
||||
@ -1,99 +0,0 @@
|
||||
import { Module } from '@nestjs/common'; |
||||
import { APP_PIPE } from '@nestjs/core'; |
||||
import { ValidationPipe } from '@nestjs/common'; |
||||
import { ConfigModule, ConfigService } from '@nestjs/config'; |
||||
import { JwtModule } from '@nestjs/jwt'; |
||||
import { HttpModule } from '@nestjs/axios'; |
||||
import { ScheduleModule } from '@nestjs/schedule'; |
||||
import { AuthController } from './presentation/controllers/auth.controller'; |
||||
import { ItemsController } from './presentation/controllers/items.controller'; |
||||
import { LoginUserCommand } from './application/commands/login-user.command'; |
||||
import { AddItemCommand } from './application/commands/add-item.command'; |
||||
import { DeleteItemCommand } from './application/commands/delete-item.command'; |
||||
import { HandleExpiredItemsCommand } from './application/commands/handle-expired-items.command'; |
||||
import { GetItemQuery } from './application/queries/get-item.query'; |
||||
import { ListItemsQuery } from './application/queries/list-items.query'; |
||||
import { JwtAuthService } from './infrastructure/auth/jwt-auth.service'; |
||||
import { FileUserRepository } from './infrastructure/repositories/file-user-repository'; |
||||
import { FileItemRepository } from './infrastructure/repositories/file-item-repository'; |
||||
import { OrderHttpService } from './infrastructure/http/order-http.service'; |
||||
import { SystemTimeProvider } from './infrastructure/services/system-time.provider'; |
||||
import { UserInitializationService } from './infrastructure/services/user-initialization.service'; |
||||
import { ItemExpirationSpec } from './domain/specifications/item-expiration.spec'; |
||||
import { ExpiredItemsSchedulerService } from './infrastructure/services/expired-items-scheduler.service'; |
||||
import { LoggerService } from './application/services/logger.service'; |
||||
import { NestLoggerService } from './infrastructure/logging/nest-logger.service'; |
||||
import { NullLoggerService } from './infrastructure/logging/null-logger.service'; |
||||
|
||||
@Module({ |
||||
imports: [ |
||||
ConfigModule.forRoot({ |
||||
isGlobal: true, |
||||
}), |
||||
HttpModule, |
||||
ScheduleModule.forRoot(), |
||||
JwtModule.registerAsync({ |
||||
imports: [ConfigModule], |
||||
useFactory: async (configService: ConfigService) => ({ |
||||
secret: configService.get<string>('JWT_SECRET') || 'default-secret-key', |
||||
signOptions: { |
||||
expiresIn: configService.get<string>('JWT_EXPIRATION') || '1h', |
||||
}, |
||||
}), |
||||
inject: [ConfigService], |
||||
}), |
||||
], |
||||
controllers: [AuthController, ItemsController], |
||||
providers: [ |
||||
LoginUserCommand, |
||||
AddItemCommand, |
||||
DeleteItemCommand, |
||||
HandleExpiredItemsCommand, |
||||
GetItemQuery, |
||||
ListItemsQuery, |
||||
JwtAuthService, |
||||
OrderHttpService, |
||||
SystemTimeProvider, |
||||
ItemExpirationSpec, |
||||
UserInitializationService, |
||||
ExpiredItemsSchedulerService, |
||||
{ |
||||
provide: 'IAuthService', |
||||
useExisting: JwtAuthService, |
||||
}, |
||||
{ |
||||
provide: 'IUserRepository', |
||||
useFactory: (logger: LoggerService) => new FileUserRepository('./data', logger), |
||||
inject: [LoggerService], |
||||
}, |
||||
{ |
||||
provide: 'IItemRepository', |
||||
useFactory: (logger: LoggerService) => new FileItemRepository('./data', logger), |
||||
inject: [LoggerService], |
||||
}, |
||||
{ |
||||
provide: 'IOrderService', |
||||
useExisting: OrderHttpService, |
||||
}, |
||||
{ |
||||
provide: 'ITimeProvider', |
||||
useExisting: SystemTimeProvider, |
||||
}, |
||||
{ |
||||
provide: LoggerService, |
||||
useClass: process.env.NODE_ENV === 'test' ? NullLoggerService : NestLoggerService, |
||||
}, |
||||
{ |
||||
provide: APP_PIPE, |
||||
useValue: new ValidationPipe({ |
||||
whitelist: true, |
||||
forbidNonWhitelisted: true, |
||||
transform: true, |
||||
transformOptions: { |
||||
enableImplicitConversion: true, |
||||
}, |
||||
}), |
||||
}, |
||||
], |
||||
}) |
||||
export class AppModule {} |
||||
@ -1,178 +0,0 @@
|
||||
import { AddItemCommand } from '../add-item.command'; |
||||
import { ItemEntity } from '../../../domain/entities/item.entity'; |
||||
|
||||
// Mock implementations
|
||||
const mockItemRepository = { |
||||
save: jest.fn(), |
||||
}; |
||||
|
||||
const mockOrderService = { |
||||
orderItem: jest.fn(), |
||||
}; |
||||
|
||||
const mockTimeProvider = { |
||||
now: jest.fn(), |
||||
}; |
||||
|
||||
const mockExpirationSpec = { |
||||
isExpired: jest.fn(), |
||||
}; |
||||
|
||||
const mockLogger = { |
||||
log: jest.fn(), |
||||
error: jest.fn(), |
||||
warn: jest.fn(), |
||||
debug: jest.fn(), |
||||
}; |
||||
|
||||
describe('AddItemCommand', () => { |
||||
let addItemCommand: AddItemCommand; |
||||
|
||||
const MOCKED_NOW = '2023-01-01T12:00:00Z'; |
||||
const NOT_EXPIRED_DATE = '2023-01-02T12:00:00Z'; |
||||
const EXPIRED_DATE = '2022-12-31T12:00:00Z'; |
||||
const ITEM_NAME = 'Test Item'; |
||||
const ORDER_URL = 'https://example.com/order'; |
||||
const USER_ID = '550e8400-e29b-41d4-a716-446655440001'; |
||||
|
||||
beforeEach(() => { |
||||
jest.clearAllMocks(); |
||||
|
||||
addItemCommand = new AddItemCommand( |
||||
mockItemRepository as any, |
||||
mockOrderService as any, |
||||
mockTimeProvider as any, |
||||
mockLogger as any, |
||||
mockExpirationSpec as any, |
||||
); |
||||
|
||||
mockTimeProvider.now.mockReturnValue(new Date(MOCKED_NOW)); |
||||
}); |
||||
|
||||
describe('execute', () => { |
||||
describe('when item is not expired', () => { |
||||
beforeEach(() => { |
||||
mockExpirationSpec.isExpired.mockReturnValue(false); |
||||
}); |
||||
|
||||
it('should save item to repository', async () => { |
||||
await addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(mockItemRepository.save).toHaveBeenCalledTimes(1); |
||||
expect(mockItemRepository.save).toHaveBeenCalledWith(expect.any(ItemEntity)); |
||||
}); |
||||
|
||||
it('should not call order service', async () => { |
||||
await addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(mockOrderService.orderItem).not.toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('should return item ID', async () => { |
||||
const result = await addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(result).toBeTruthy(); |
||||
expect(typeof result).toBe('string'); |
||||
expect(result).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); |
||||
}); |
||||
|
||||
it('should validate expiration with ItemExpirationSpec', async () => { |
||||
await addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(mockExpirationSpec.isExpired).toHaveBeenCalledTimes(1); |
||||
expect(mockExpirationSpec.isExpired).toHaveBeenCalledWith( |
||||
expect.any(ItemEntity), |
||||
new Date(MOCKED_NOW) |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('when item is expired', () => { |
||||
beforeEach(() => { |
||||
mockExpirationSpec.isExpired.mockReturnValue(true); |
||||
}); |
||||
|
||||
it('should call order service', async () => { |
||||
await addItemCommand.execute(ITEM_NAME, EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(mockOrderService.orderItem).toHaveBeenCalledTimes(1); |
||||
expect(mockOrderService.orderItem).toHaveBeenCalledWith(expect.any(ItemEntity)); |
||||
}); |
||||
|
||||
it('should not save item to repository', async () => { |
||||
await addItemCommand.execute(ITEM_NAME, EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(mockItemRepository.save).not.toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('should return item ID', async () => { |
||||
const result = await addItemCommand.execute(ITEM_NAME, EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(result).toBeTruthy(); |
||||
expect(typeof result).toBe('string'); |
||||
}); |
||||
|
||||
it('should handle order service failure gracefully', async () => { |
||||
mockOrderService.orderItem.mockRejectedValue(new Error('Order service failed')); |
||||
|
||||
const result = await addItemCommand.execute(ITEM_NAME, EXPIRED_DATE, ORDER_URL, USER_ID); |
||||
|
||||
expect(result).toBeTruthy(); |
||||
expect(typeof result).toBe('string'); |
||||
expect(result).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); |
||||
expect(mockOrderService.orderItem).toHaveBeenCalledTimes(1); |
||||
expect(mockItemRepository.save).not.toHaveBeenCalled(); |
||||
}); |
||||
}); |
||||
|
||||
describe('input validation', () => { |
||||
it('should throw error when name is empty', async () => { |
||||
await expect( |
||||
addItemCommand.execute('', NOT_EXPIRED_DATE, ORDER_URL, USER_ID) |
||||
).rejects.toThrow('Item name cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when name is only whitespace', async () => { |
||||
await expect( |
||||
addItemCommand.execute(' ', NOT_EXPIRED_DATE, ORDER_URL, USER_ID) |
||||
).rejects.toThrow('Item name cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when expirationDate is empty', async () => { |
||||
await expect( |
||||
addItemCommand.execute(ITEM_NAME, '', ORDER_URL, USER_ID) |
||||
).rejects.toThrow('Expiration date cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when expirationDate is only whitespace', async () => { |
||||
await expect( |
||||
addItemCommand.execute(ITEM_NAME, ' ', ORDER_URL, USER_ID) |
||||
).rejects.toThrow('Expiration date cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when orderUrl is empty', async () => { |
||||
await expect( |
||||
addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, '', USER_ID) |
||||
).rejects.toThrow('Order URL cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when orderUrl is only whitespace', async () => { |
||||
await expect( |
||||
addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ' ', USER_ID) |
||||
).rejects.toThrow('Order URL cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when userId is empty', async () => { |
||||
await expect( |
||||
addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ORDER_URL, '') |
||||
).rejects.toThrow('User ID cannot be empty'); |
||||
}); |
||||
|
||||
it('should throw error when userId is only whitespace', async () => { |
||||
await expect( |
||||
addItemCommand.execute(ITEM_NAME, NOT_EXPIRED_DATE, ORDER_URL, ' ') |
||||
).rejects.toThrow('User ID cannot be empty'); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -1,109 +0,0 @@
|
||||
import { Injectable, Inject } from '@nestjs/common'; |
||||
import { ItemEntity } from '../../domain/entities/item.entity'; |
||||
import { ItemId } from '../../domain/value-objects/item-id.vo'; |
||||
import { ExpirationDate } from '../../domain/value-objects/expiration-date.vo'; |
||||
import { UserId } from '../../domain/value-objects/user-id.vo'; |
||||
import { IItemRepository } from '../interfaces/item-repository.interface'; |
||||
import { IOrderService } from '../interfaces/order-service.interface'; |
||||
import { ITimeProvider } from '../interfaces/time-provider.interface'; |
||||
import { ItemExpirationSpec } from '../../domain/specifications/item-expiration.spec'; |
||||
import { LoggerService } from '../services/logger.service'; |
||||
|
||||
@Injectable() |
||||
export class AddItemCommand { |
||||
constructor( |
||||
@Inject('IItemRepository') |
||||
private readonly itemRepository: IItemRepository, |
||||
@Inject('IOrderService') |
||||
private readonly orderService: IOrderService, |
||||
@Inject('ITimeProvider') |
||||
private readonly timeProvider: ITimeProvider, |
||||
@Inject(LoggerService) |
||||
private readonly logger: LoggerService, |
||||
private readonly expirationSpec: ItemExpirationSpec, |
||||
) {} |
||||
|
||||
async execute( |
||||
name: string, |
||||
expirationDate: string, |
||||
orderUrl: string, |
||||
userId: string, |
||||
): Promise<string | null> { |
||||
try { |
||||
this.logger.log(`Adding item: ${name} for user: ${userId}`, AddItemCommand.name); |
||||
|
||||
// Validate input parameters
|
||||
this.validateInput(name, expirationDate, orderUrl, userId); |
||||
|
||||
// Parse expiration date and check if it's in the future
|
||||
const expirationDateObj = new Date(expirationDate); |
||||
const now = this.timeProvider.now(); |
||||
|
||||
// Business rule: Items with past expiration dates are allowed but trigger immediate ordering
|
||||
// Items with future expiration dates are saved normally
|
||||
|
||||
// Create domain entities
|
||||
const itemId = ItemId.generate(); |
||||
const userIdVo = UserId.create(userId); |
||||
const expirationDateVo = ExpirationDate.fromString(expirationDate); |
||||
|
||||
const item = new ItemEntity( |
||||
itemId, |
||||
name, |
||||
expirationDateVo, |
||||
orderUrl, |
||||
userIdVo, |
||||
); |
||||
|
||||
const currentTime = this.timeProvider.now(); |
||||
|
||||
// Check if item is expired
|
||||
if (this.expirationSpec.isExpired(item, currentTime)) { |
||||
this.logger.log(`Item ${name} is expired, ordering replacement`, AddItemCommand.name); |
||||
|
||||
try { |
||||
await this.orderService.orderItem(item); |
||||
this.logger.log(`Successfully ordered replacement for expired item: ${name}`, AddItemCommand.name); |
||||
// Return the item ID even for expired items to match API contract
|
||||
return itemId.getValue(); |
||||
} catch (error) { |
||||
this.logger.error(`Failed to place order for expired item ${itemId.getValue()}: ${error.message}`, undefined, AddItemCommand.name); |
||||
// Still return the ID even if ordering fails
|
||||
return itemId.getValue(); |
||||
} |
||||
} |
||||
|
||||
// Save item if not expired
|
||||
await this.itemRepository.save(item); |
||||
this.logger.log(`Successfully saved item: ${name} with ID: ${itemId.getValue()}`, AddItemCommand.name); |
||||
|
||||
return itemId.getValue(); |
||||
} catch (error) { |
||||
this.logger.error(`Failed to add item: ${error.message}`, undefined, AddItemCommand.name); |
||||
throw error; |
||||
} |
||||
} |
||||
|
||||
private validateInput( |
||||
name: string, |
||||
expirationDate: string, |
||||
orderUrl: string, |
||||
userId: string, |
||||
): void { |
||||
if (!name || name.trim().length === 0) { |
||||
throw new Error('Item name cannot be empty'); |
||||
} |
||||
|
||||
if (!expirationDate || expirationDate.trim().length === 0) { |
||||
throw new Error('Expiration date cannot be empty'); |
||||
} |
||||
|
||||
if (!orderUrl || orderUrl.trim().length === 0) { |
||||
throw new Error('Order URL cannot be empty'); |
||||
} |
||||
|
||||
if (!userId || userId.trim().length === 0) { |
||||
throw new Error('User ID cannot be empty'); |
||||
} |
||||
} |
||||
} |
||||
@ -1,47 +0,0 @@
|
||||
import { Injectable, NotFoundException, UnauthorizedException, Inject } from '@nestjs/common'; |
||||
import { ItemId } from '../../domain/value-objects/item-id.vo'; |
||||
import { UserId } from '../../domain/value-objects/user-id.vo'; |
||||
import { IItemRepository } from '../interfaces/item-repository.interface'; |
||||
import { LoggerService } from '../services/logger.service'; |
||||
|
||||
@Injectable() |
||||
export class DeleteItemCommand { |
||||
constructor( |
||||
@Inject('IItemRepository') |
||||
private readonly itemRepository: IItemRepository, |
||||
@Inject(LoggerService) |
||||
private readonly logger: LoggerService, |
||||
) {} |
||||
|
||||
async execute(itemId: string, userId: string): Promise<void> { |
||||
try { |
||||
this.logger.log(`Deleting item: ${itemId} for user: ${userId}`, DeleteItemCommand.name); |
||||
|
||||
const itemIdVo = ItemId.create(itemId); |
||||
const userIdVo = UserId.create(userId); |
||||
|
||||
const item = await this.itemRepository.findById(itemIdVo); |
||||
|
||||
if (!item) { |
||||
this.logger.warn(`Item not found: ${itemId}`, DeleteItemCommand.name); |
||||
throw new NotFoundException(`Item with ID ${itemId} not found`); |
||||
} |
||||
|
||||
// Validate ownership
|
||||
if (!item.getUserId().equals(userIdVo)) { |
||||
this.logger.warn(`User ${userId} attempted to delete item ${itemId} owned by ${item.getUserId().getValue()}`, DeleteItemCommand.name); |
||||
throw new NotFoundException(`Item with ID ${itemId} not found`); |
||||
} |
||||
|
||||
await this.itemRepository.delete(itemIdVo); |
||||
this.logger.log(`Successfully deleted item: ${itemId}`, DeleteItemCommand.name); |
||||
} catch (error) { |
||||
if (error instanceof NotFoundException || error instanceof UnauthorizedException) { |
||||
throw error; |
||||
} |
||||
|
||||
this.logger.error(`Failed to delete item ${itemId}: ${error.message}`, undefined, DeleteItemCommand.name); |
||||
throw new Error(`Failed to delete item: ${error.message}`); |
||||
} |
||||
} |
||||
} |
||||
@ -1,53 +0,0 @@
|
||||
import { Injectable, Inject } from '@nestjs/common'; |
||||
import { IItemRepository } from '../interfaces/item-repository.interface'; |
||||
import { IOrderService } from '../interfaces/order-service.interface'; |
||||
import { ITimeProvider } from '../interfaces/time-provider.interface'; |
||||
import { ItemExpirationSpec } from '../../domain/specifications/item-expiration.spec'; |
||||
import { LoggerService } from '../services/logger.service'; |
||||
|
||||
@Injectable() |
||||
export class HandleExpiredItemsCommand { |
||||
constructor( |
||||
@Inject('IItemRepository') |
||||
private readonly itemRepository: IItemRepository, |
||||
@Inject('IOrderService') |
||||
private readonly orderService: IOrderService, |
||||
@Inject('ITimeProvider') |
||||
private readonly timeProvider: ITimeProvider, |
||||
@Inject(LoggerService) |
||||
private readonly logger: LoggerService, |
||||
private readonly expirationSpec: ItemExpirationSpec, |
||||
) {} |
||||
|
||||
async execute(): Promise<void> { |
||||
try { |
||||
this.logger.log('Starting expired items processing', HandleExpiredItemsCommand.name); |
||||
|
||||
const currentTime = this.timeProvider.now(); |
||||
const specification = this.expirationSpec.getSpec(currentTime); |
||||
|
||||
const expiredItems = await this.itemRepository.findWhere(specification); |
||||
|
||||
this.logger.log(`Found ${expiredItems.length} expired items to process`, HandleExpiredItemsCommand.name); |
||||
|
||||
for (const item of expiredItems) { |
||||
try { |
||||
this.logger.log(`Processing expired item: ${item.getId().getValue()}`, HandleExpiredItemsCommand.name); |
||||
|
||||
await this.orderService.orderItem(item); |
||||
await this.itemRepository.delete(item.getId()); |
||||
|
||||
this.logger.log(`Successfully processed and deleted expired item: ${item.getId().getValue()}`, HandleExpiredItemsCommand.name); |
||||
} catch (error) { |
||||
this.logger.error(`Failed to process expired item ${item.getId().getValue()}: ${error.message}`, undefined, HandleExpiredItemsCommand.name); |
||||
// Continue processing other items even if one fails
|
||||
} |
||||
} |
||||
|
||||
this.logger.log('Completed expired items processing', HandleExpiredItemsCommand.name); |
||||
} catch (error) { |
||||
this.logger.error(`Failed to handle expired items: ${error.message}`, undefined, HandleExpiredItemsCommand.name); |
||||
throw new Error(`Failed to handle expired items: ${error.message}`); |
||||
} |
||||
} |
||||
} |
||||
@ -1,49 +0,0 @@
|
||||
import { Injectable, UnauthorizedException, Inject } from '@nestjs/common'; |
||||
import { IAuthService } from '../interfaces/auth-service.interface'; |
||||
import { LoggerService } from '../services/logger.service'; |
||||
|
||||
@Injectable() |
||||
export class LoginUserCommand { |
||||
constructor( |
||||
@Inject('IAuthService') |
||||
private readonly authService: IAuthService, |
||||
@Inject(LoggerService) |
||||
private readonly logger: LoggerService, |
||||
) {} |
||||
|
||||
async execute(username: string, password: string): Promise<string> { |
||||
try { |
||||
this.logger.log(`Login attempt for user: ${username}`, LoginUserCommand.name); |
||||
|
||||
// Validate input parameters
|
||||
this.validateInput(username, password); |
||||
|
||||
const token = await this.authService.authenticate(username, password); |
||||
|
||||
if (!token) { |
||||
this.logger.warn(`Authentication failed for user: ${username}`, LoginUserCommand.name); |
||||
throw new UnauthorizedException('Invalid username or password'); |
||||
} |
||||
|
||||
this.logger.log(`Successfully authenticated user: ${username}`, LoginUserCommand.name); |
||||
return token; |
||||
} catch (error) { |
||||
if (error instanceof UnauthorizedException) { |
||||
throw error; |
||||
} |
||||
|
||||
this.logger.error(`Failed to login user ${username}: ${error.message}`, undefined, LoginUserCommand.name); |
||||
throw new Error(`Failed to login: ${error.message}`); |
||||
} |
||||
} |
||||
|
||||
private validateInput(username: string, password: string): void { |
||||
if (!username || username.trim().length === 0) { |
||||
throw new Error('Username cannot be empty'); |
||||
} |
||||
|
||||
if (!password || password.trim().length === 0) { |
||||
throw new Error('Password cannot be empty'); |
||||
} |
||||
} |
||||
} |
||||
@ -1,24 +0,0 @@
|
||||
import { IsNotEmpty, IsString, IsUrl, IsDateString } from 'class-validator'; |
||||
|
||||
export class CreateItemDto { |
||||
@IsNotEmpty() |
||||
@IsString() |
||||
name: string; |
||||
|
||||
@IsNotEmpty() |
||||
@IsDateString() |
||||
expirationDate: string; |
||||
|
||||
@IsNotEmpty() |
||||
@IsUrl() |
||||
orderUrl: string; |
||||
} |
||||
|
||||
export class ItemResponseDto { |
||||
id: string; |
||||
name: string; |
||||
expirationDate: string; |
||||
orderUrl: string; |
||||
userId: string; |
||||
createdAt: string; |
||||
} |
||||
@ -1,17 +0,0 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator'; |
||||
|
||||
export class LoginDto { |
||||
@IsNotEmpty() |
||||
@IsString() |
||||
username: string; |
||||
|
||||
@IsNotEmpty() |
||||
@IsString() |
||||
password: string; |
||||
} |
||||
|
||||
export class LoginResponseDto { |
||||
token: string; |
||||
tokenType: string; |
||||
expiresIn: number; |
||||
} |
||||
@ -1,5 +0,0 @@
|
||||
export interface IAuthService { |
||||
authenticate(username: string, password: string): Promise<string | null>; |
||||
validateToken(token: string): Promise<boolean>; |
||||
getUserIdFromToken(token: string): Promise<string | null>; |
||||
} |
||||
@ -1,13 +0,0 @@
|
||||
import { ItemEntity } from '../../domain/entities/item.entity'; |
||||
import { ItemId } from '../../domain/value-objects/item-id.vo'; |
||||
import { UserId } from '../../domain/value-objects/user-id.vo'; |
||||
import { ISpecification } from '../../domain/specifications/specification.interface'; |
||||
|
||||
export interface IItemRepository { |
||||
save(item: ItemEntity): Promise<void>; |
||||
findById(id: ItemId): Promise<ItemEntity | null>; |
||||
findByUserId(userId: UserId): Promise<ItemEntity[]>; |
||||
findWhere(specification: ISpecification<ItemEntity>): Promise<ItemEntity[]>; |
||||
delete(id: ItemId): Promise<void>; |
||||
exists(id: ItemId): Promise<boolean>; |
||||
} |
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue