diff --git a/README.md b/README.md index 18aebdb..7031ed9 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,11 @@ A system to store items with expiration dates. When items expire, new ones are a 3. **When an item expires, a new item of the same type is automatically ordered.** 4. **Expired items can be added to the store, triggering immediate ordering.** 5. **Every item belongs to a user.** +6. **Only the item's owner can manage it.** #### Application Requirements -1. **Users can register and log in to obtain a JWT.** +1. **Users can log in to obtain a JWT.** 2. **Authenticated users manage their personal collection of items via an HTTP API.** 3. **Each item has an associated "order URL".** 4. **When an item expires, the system must notify the "order URL" with an HTTP POST request.** @@ -30,17 +31,20 @@ A system to store items with expiration dates. When items expire, new ones are a 6. **Upon startup, the system must verify expiration dates for all items.** 7. **Persistent storage must be used (file, database, etc.).** +**Note:** For simplicity, user CRUD is skipped. Integrate with an OP (OpenID Provider) service like Keycloak, Authentic, or Zitadel, or mock authentication with a simple Docker service. Alternatively, simply authenticate a predefined user and return a JWT on login. + + --- ## Layer Boundaries -| Layer | Responsibility | Internal Dependencies | External Dependencies | -|------------------|--------------------------------------------------------------- |----------------------|-----------------------| -| **Domain** | Entities, value objects, domain services (pure business logic) | None | None (language only) | -| **Application** | Use cases, orchestration, DTOs, infrastructure interfaces | Domain | None or minimal | -| **Infrastructure**| Implementations (repositories, HTTP, auth), background jobs | Application | Any (framework/lib) | -| **Presentation** | API controllers, DTOs, auth middleware | Application | UI/web/CLI/others | -| **Assembly** | Main app, DI, startup logic, job scheduling | Any layer | DI container, config, framework, etc.| +| Layer | Responsibility | Internal Dependencies | External Dependencies | +|-------------------|--------------------------------------------------------------- |-----------------------|-----------------------| +| **Domain** | Entities, value objects, domain services (pure business logic) | None | None (language only) | +| **Application** | Use cases, orchestration, DTOs, infrastructure interfaces | Domain | None or minimal | +| **Infrastructure**| Implementations (repositories, HTTP, auth), background jobs | Application | Any (framework/lib) | +| **Presentation** | API controllers, DTOs, auth middleware | Application | UI/web/CLI/others | +| **Assembly** | Main app, DI, startup logic, job scheduling | Any layer | DI container, config, framework, etc.| --- @@ -117,15 +121,11 @@ Here's a summary of example API endpoints: | Endpoint | Method | Description | |-------------------------|--------|--------------------------------------| -| `/register` | POST | Register a new user account | -| `/login` | POST | Authenticate user and get JWT token | -| `/users` | GET | Get list of all users | -| `/users/{id}` | GET | Get user by ID | -| `/users/{id}` | POST | Create new user (admin) | -| `/users/{id}` | PUT | Update user details | -| `/users/{id}` | DELETE | Delete user account | +| `/login` | POST | Authenticate user and get JWT token | | `/items` | GET | Get user's items | | `/items` | POST | Create new item | | `/items/{id}` | GET | Get item by ID | | `/items/{id}` | PUT | Update item details | | `/items/{id}` | DELETE | Delete item | + +Suggested base URL is `http://localhost:8080/api/v1/`. \ No newline at end of file diff --git a/cpp17/CMakePresets.json b/cpp17/CMakePresets.json index d1fe37f..df63a30 100644 --- a/cpp17/CMakePresets.json +++ b/cpp17/CMakePresets.json @@ -2,18 +2,31 @@ "version": 3, "configurePresets": [ { - "name": "default", + "name": "debug", "toolchainFile": "${env:VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "CMAKE_EXPORT_COMPILE_COMMANDS": "TRUE" } + }, + { + "name": "release", + "toolchainFile": "${env:VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release", + "CMAKE_EXPORT_COMPILE_COMMANDS": "TRUE" + } } ], "buildPresets": [ { - "name": "default", - "configurePreset": "default", + "name": "debug", + "configurePreset": "debug", + "jobs": 8 + }, + { + "name": "release", + "configurePreset": "release", "jobs": 8 } ] diff --git a/cpp17/README.md b/cpp17/README.md index 0e27a23..d36b05e 100644 --- a/cpp17/README.md +++ b/cpp17/README.md @@ -22,7 +22,7 @@ A system to store items with expiration dates. When items expire, new ones are a #### Application Requirements -1. **Users can register and log in to obtain a JWT.** +1. **Users can log in to obtain a JWT.** 2. **Authenticated users manage their personal collection of items via an HTTP API.** 3. **Each item has an associated "order URL".** 4. **When an item expires, the system must notify the "order URL" with an HTTP POST request.** @@ -30,17 +30,20 @@ A system to store items with expiration dates. When items expire, new ones are a 6. **Upon startup, the system must verify expiration dates for all items.** 7. **Persistent storage must be used (file, database, etc.).** +**Note:** For simplicity, user CRUD is skipped. Integrate with an OP (OpenID Provider) service like Keycloak, Authentic, or Zitadel, or mock authentication with a simple Docker service. Alternatively, simply authenticate a predefined user and return a JWT on login. + + --- ## Layer Boundaries -| Layer | Responsibility | Internal Dependencies | External Dependencies | -|------------------|--------------------------------------------------------------- |----------------------|-----------------------| -| **Domain** | Entities, value objects, domain services (pure business logic) | None | None (language only) | -| **Application** | Use cases, orchestration, DTOs, infrastructure interfaces | Domain | None or minimal | +| Layer | Responsibility | Internal Dependencies | External Dependencies | +|-------------------|--------------------------------------------------------------- |----------------------|-----------------------| +| **Domain** | Entities, value objects, domain services (pure business logic) | None | None (language only) | +| **Application** | Use cases, orchestration, DTOs, infrastructure interfaces | Domain | None or minimal | | **Infrastructure**| Implementations (repositories, HTTP, auth), background jobs | Application | Any (framework/lib) | -| **Presentation** | API controllers, DTOs, auth middleware | Application | UI/web/CLI/others | -| **Assembly** | Main app, DI, startup logic, job scheduling | Any layer | DI container, config, framework, etc.| +| **Presentation** | API controllers, DTOs, auth middleware | Application | UI/web/CLI/others | +| **Assembly** | Main app, DI, startup logic, job scheduling | Any layer | DI container, config, framework, etc.| --- @@ -74,7 +77,7 @@ AutoStore/ │ │ │ ├── IUserRepository │ │ │ ├── IItemRepository │ │ │ ├── IAuthService -│ │ │ └── IClock +│ │ │ └── ITimeProvider │ │ ├── Dto/ │ │ └── Services/ │ ├── Infrastructure/ @@ -109,3 +112,19 @@ docker compose up to build and run the application. Otherwise, please provide a `/README.md` file with setup and running instructions. + +## API Endpoints + +See `openapi.yaml` file for suggested API (test it with Tavern, Postman etc.). +Here's a summary of example API endpoints: + +| Endpoint | Method | Description | +|-------------------------|--------|--------------------------------------| +| `/login` | POST | Authenticate user and get JWT token | +| `/items` | GET | Get user's items | +| `/items` | POST | Create new item | +| `/items/{id}` | GET | Get item by ID | +| `/items/{id}` | PUT | Update item details | +| `/items/{id}` | DELETE | Delete item | + +Suggested base URL is `http://localhost:8080/api/v1/`. \ No newline at end of file diff --git a/cpp17/app/CMakeLists.txt b/cpp17/app/CMakeLists.txt index 069d100..c17e745 100644 --- a/cpp17/app/CMakeLists.txt +++ b/cpp17/app/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.20) -project(AutoStoreApp LANGUAGES CXX VERSION 0.1.0) +project(AutoStoreApp LANGUAGES CXX) set(TARGET_NAME AutoStore) set(CMAKE_CXX_STANDARD 17) @@ -8,8 +8,6 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(spdlog CONFIG REQUIRED) -configure_file(src/Version.h.in ${CMAKE_BINARY_DIR}/Version.h) - set(SOURCES src/Main.cpp src/App.cpp @@ -27,10 +25,12 @@ target_include_directories(${TARGET_NAME} ${CMAKE_BINARY_DIR} ) - # for docker test -# target_compile_options(${TARGET_NAME} PRIVATE -static-libgcc -static-libstdc++) -# target_link_options(${TARGET_NAME} PRIVATE -static-libgcc -static-libstdc++) +# Create data directory and copy defalut users.json for development +add_custom_command( + TARGET ${TARGET_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/data" && ${CMAKE_COMMAND} -E copy + "${CMAKE_CURRENT_LIST_DIR}/defaults/users.json" + "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/data/users.json" +) target_link_libraries(${TARGET_NAME} PRIVATE ${LIBRARIES}) - -# add_subdirectory(tests/unit) diff --git a/cpp17/app/defaults/users.json b/cpp17/app/defaults/users.json new file mode 100644 index 0000000..9012992 --- /dev/null +++ b/cpp17/app/defaults/users.json @@ -0,0 +1,12 @@ +[ + { + "username": "admin", + "password": "admin", + "id": "1000" + }, + { + "username": "user", + "password": "user", + "id": "1001" + } +] diff --git a/cpp17/app/src/App.cpp b/cpp17/app/src/App.cpp index 8765ae9..9ec7ffb 100644 --- a/cpp17/app/src/App.cpp +++ b/cpp17/app/src/App.cpp @@ -1,5 +1,6 @@ #include "App.h" #include "SpdLogger.h" +#include "OsHelpers.h" #include #include @@ -17,15 +18,13 @@ App::App(int argc, char** argv) signal(SIGINT, App::handleSignal); signal(SIGTERM, App::handleSignal); - std::filesystem::create_directories("data"); - auto spdLogger = spdlog::stdout_color_mt("console"); spdLogger->set_pattern("[%Y-%m-%d %H:%M:%S] [%^%l%$] %v"); spdLogger->set_level(spdlog::level::debug); log = logger = std::make_shared(spdLogger, 9); autoStore = std::make_unique( AutoStore::Config{ - .dataPath = "data", + .dataPath = os::getApplicationDirectory() + "/data", .host = "0.0.0.0", .port = 8080, }, diff --git a/cpp17/app/src/Main.cpp b/cpp17/app/src/Main.cpp index a9baa97..d769b56 100644 --- a/cpp17/app/src/Main.cpp +++ b/cpp17/app/src/Main.cpp @@ -1,5 +1,5 @@ #include "App.h" -#include "Version.h" +#include "autostore/Version.h" #include int main(int argc, char** argv) diff --git a/cpp17/app/src/OsHelpers.h b/cpp17/app/src/OsHelpers.h new file mode 100644 index 0000000..e6e5209 --- /dev/null +++ b/cpp17/app/src/OsHelpers.h @@ -0,0 +1,48 @@ +#pragma once + +#include + +#ifdef _WIN32 + +#include + +#ifndef PATH_MAX +#define PATH_MAX 4096 +#endif + +namespace nxl::os { + +std::string getApplicationDirectory() +{ + char exePath[PATH_MAX]; + GetModuleFileNameA(NULL, exePath, PATH_MAX); + std::string exeDir = std::string(dirname(exePath)); + return exeDir; +} + +} // namespace nxl::os + +#else +#include +#include +#include + +namespace nxl::os { + +std::string getApplicationDirectory() +{ + char result[PATH_MAX] = {0}; + std::string path; + ssize_t count = readlink("/proc/self/exe", result, PATH_MAX); + if (count != -1) { + result[count] = '\0'; + path = dirname(result); + } else { + path = "./"; + } + return path; +} + +} // namespace nxl::os + +#endif diff --git a/cpp17/doc/add-item-sequence.md b/cpp17/doc/add-item-sequence.md index 1fb5e71..90070bd 100644 --- a/cpp17/doc/add-item-sequence.md +++ b/cpp17/doc/add-item-sequence.md @@ -4,7 +4,7 @@ sequenceDiagram participant Controller as StoreController participant UseCase as AddItem Use Case - participant Clock as IClock + participant Clock as ITimeProvider participant Policy as ExpirationPolicy participant OrderService as OrderingService participant HttpClient as HttpClient @@ -12,7 +12,7 @@ sequenceDiagram Controller->>UseCase: execute(item) - UseCase->>Clock: getCurrentTime() + UseCase->>Clock: now() Clock-->>UseCase: DateTime UseCase->>Policy: IsExpired(item, currentTime) diff --git a/cpp17/lib/CMakeLists.txt b/cpp17/lib/CMakeLists.txt index fac829b..37040fa 100644 --- a/cpp17/lib/CMakeLists.txt +++ b/cpp17/lib/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.20) -project(AutoStoreLib) +project(AutoStoreLib LANGUAGES CXX VERSION 0.1.0) set(TARGET_NAME AutoStoreLib) set(CMAKE_CXX_STANDARD 17) @@ -8,17 +8,32 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) # Find dependencies find_package(httplib CONFIG REQUIRED) find_package(nlohmann_json CONFIG REQUIRED) +find_package(jwt-cpp CONFIG REQUIRED) + +configure_file(src/Version.h.in ${CMAKE_BINARY_DIR}/autostore/Version.h) add_library(${TARGET_NAME} STATIC - src/AutoStore.cpp - src/infrastructure/repositories/FileUserRepository.cpp + src/application/queries/GetItem.cpp + src/application/queries/ListItems.cpp + src/application/commands/AddItem.cpp + src/application/commands/HandleExpiredItems.cpp + src/application/commands/DeleteItem.cpp + src/application/commands/LoginUser.cpp src/infrastructure/repositories/FileItemRepository.cpp src/infrastructure/http/HttpServer.cpp + src/infrastructure/http/HttpJwtMiddleware.cpp src/infrastructure/http/HttpOrderService.cpp src/infrastructure/helpers/Jsend.cpp src/infrastructure/helpers/JsonItem.cpp + src/infrastructure/auth/FileJwtAuthService.cpp + src/infrastructure/services/TaskScheduler.cpp + src/infrastructure/adapters/CvBlocker.cpp + src/infrastructure/adapters/SystemThreadManager.cpp + src/infrastructure/adapters/SystemTimeProvider.cpp + src/webapi/controllers/BaseController.cpp src/webapi/controllers/StoreController.cpp - src/application/commands/AddItem.cpp + src/webapi/controllers/AuthController.cpp + src/AutoStore.cpp ) target_include_directories(${TARGET_NAME} @@ -26,6 +41,7 @@ target_include_directories(${TARGET_NAME} ${CMAKE_CURRENT_SOURCE_DIR}/include/autostore PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_BINARY_DIR} ) target_sources(${TARGET_NAME} @@ -34,12 +50,12 @@ target_sources(${TARGET_NAME} BASE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/include FILES include/autostore/AutoStore.h + include/autostore/ILogger.h ) - - target_link_libraries(${TARGET_NAME} PUBLIC httplib::httplib nlohmann_json::nlohmann_json + jwt-cpp::jwt-cpp ) \ No newline at end of file diff --git a/cpp17/lib/include/autostore/AutoStore.h b/cpp17/lib/include/autostore/AutoStore.h index 624c694..c8860e8 100644 --- a/cpp17/lib/include/autostore/AutoStore.h +++ b/cpp17/lib/include/autostore/AutoStore.h @@ -10,20 +10,24 @@ namespace nxl::autostore { namespace application { class IItemRepository; -class IClock; +class ITimeProvider; class IOrderService; +class IAuthService; } // namespace application namespace infrastructure { class HttpServer; +class TaskScheduler; } // namespace infrastructure namespace webapi { class StoreController; +class AuthController; } // namespace webapi namespace application { class AddItem; +class LoginUser; } // namespace application class AutoStore @@ -48,10 +52,13 @@ private: ILoggerPtr log; std::unique_ptr httpServer; + std::unique_ptr taskScheduler; std::unique_ptr storeController; + std::unique_ptr authController; std::unique_ptr itemRepository; - std::unique_ptr clock; + std::unique_ptr clock; std::unique_ptr orderService; + std::unique_ptr authService; }; } // namespace nxl::autostore \ No newline at end of file diff --git a/cpp17/lib/include/autostore/ILogger.h b/cpp17/lib/include/autostore/ILogger.h index 2aac268..7901707 100644 --- a/cpp17/lib/include/autostore/ILogger.h +++ b/cpp17/lib/include/autostore/ILogger.h @@ -10,7 +10,7 @@ namespace nxl::autostore { template auto to_printf_arg(T&& arg) { if constexpr (std::is_same_v, std::string_view>) { - return std::string(arg).c_str(); + return arg.data(); } else if constexpr (std::is_same_v, std::string>) { return arg.c_str(); } else { diff --git a/cpp17/lib/src/AutoStore.cpp b/cpp17/lib/src/AutoStore.cpp index 9e72c31..6860a11 100644 --- a/cpp17/lib/src/AutoStore.cpp +++ b/cpp17/lib/src/AutoStore.cpp @@ -1,15 +1,24 @@ #include "AutoStore.h" #include "infrastructure/repositories/FileItemRepository.h" -#include "infrastructure/adapters/SystemClock.h" +#include "infrastructure/adapters/SystemTimeProvider.h" #include "infrastructure/http/HttpOrderService.h" +#include "infrastructure/auth/FileJwtAuthService.h" #include "webapi/controllers/StoreController.h" +#include "webapi/controllers/AuthController.h" #include "infrastructure/http/HttpServer.h" +#include "infrastructure/services/TaskScheduler.h" +#include "application/commands/HandleExpiredItems.h" +#include "infrastructure/adapters/SystemTimeProvider.h" +#include "infrastructure/adapters/SystemThreadManager.h" +#include "infrastructure/adapters/CvBlocker.h" #include #include #include namespace nxl::autostore { +using namespace infrastructure; + AutoStore::AutoStore(Config config, ILoggerPtr logger) : config{std::move(config)}, log{std::move(logger)} {} @@ -29,22 +38,44 @@ bool AutoStore::initialize() // Initialize repositories and services std::string itemsDbPath = std::filesystem::path(config.dataPath) / "items.json"; - itemRepository = - std::make_unique(itemsDbPath); + itemRepository = std::make_unique(itemsDbPath); + + clock = std::make_unique(); + + orderService = std::make_unique(log); + + // Initialize auth service + std::string usersDbPath = + std::filesystem::path(config.dataPath) / "users.json"; + authService = std::make_unique(usersDbPath); - clock = std::make_unique(); + // Initialize dependencies for task scheduler + auto timeProvider = std::make_shared(); + auto threadManager = std::make_shared(); + auto blocker = std::make_shared(); - orderService = std::make_unique(log); + // Initialize task scheduler (for handling expired items) + taskScheduler = std::make_unique(log, timeProvider, + threadManager, blocker); // Initialize HTTP server - httpServer = std::make_unique(log); + httpServer = std::make_unique(log, *authService); // Initialize store controller storeController = std::make_unique( webapi::StoreController::Context{ - application::AddItem{*itemRepository, *clock, *orderService}}); + application::AddItem{*itemRepository, *clock, *orderService}, + application::ListItems{*itemRepository}, + application::GetItem{*itemRepository}, + application::DeleteItem{*itemRepository}, *authService}); + + // Initialize auth controller + authController = std::make_unique( + webapi::AuthController::Context{application::LoginUser{*authService}}); + + log->info("Data path: %s", config.dataPath); + log->info("AutoStore initialized successfully, handling expired items..."); - log->info("AutoStore initialized successfully"); return true; } catch (const std::exception& e) { log->error("Failed to initialize AutoStore: %s", e.what()); @@ -57,7 +88,17 @@ bool AutoStore::start() log->info("Starting AutoStore services..."); try { + taskScheduler->schedule( + [this]() { + application::HandleExpiredItems{*itemRepository, *clock, *orderService} + .execute(); + }, + 00, 00, 00, // midnight (00:00:00) + TaskScheduler::RunMode::Forever | TaskScheduler::RunMode::OnStart); + taskScheduler->start(); + storeController->registerRoutes(httpServer->getServer()); + authController->registerRoutes(httpServer->getServer()); if (!httpServer->start(config.port, config.host)) { log->error("Failed to start HTTP server"); @@ -67,7 +108,7 @@ bool AutoStore::start() log->info("AutoStore services started successfully"); log->info("HTTP server listening on http://%s:%d", config.host, config.port); - log->info("API endpoint: POST http://%s:%d/api/items", config.host, + log->info("API endpoint: POST http://%s:%d/api/v1", config.host, config.port); return true; diff --git a/cpp17/app/src/Version.h.in b/cpp17/lib/src/Version.h.in similarity index 100% rename from cpp17/app/src/Version.h.in rename to cpp17/lib/src/Version.h.in diff --git a/cpp17/lib/src/application/commands/AddItem.cpp b/cpp17/lib/src/application/commands/AddItem.cpp index 68acc02..f690a4d 100644 --- a/cpp17/lib/src/application/commands/AddItem.cpp +++ b/cpp17/lib/src/application/commands/AddItem.cpp @@ -3,17 +3,23 @@ namespace nxl::autostore::application { -AddItem::AddItem(IItemRepository& itemRepository, IClock& clock, +AddItem::AddItem(IItemRepository& itemRepository, ITimeProvider& clock, IOrderService& orderService) : itemRepository(itemRepository), clock(clock), orderService(orderService) {} domain::Item::Id_t AddItem::execute(domain::Item&& item) { - const auto currentTime = clock.getCurrentTime(); + if (item.userId == domain::User::NULL_ID) { + throw std::runtime_error("User ID not provided"); + } + + const auto currentTime = clock.now(); if (expirationPolicy.isExpired(item, currentTime)) { + item.id = domain::Item::NULL_ID; orderService.orderItem(item); + return item.id; // TODO: test that } return itemRepository.save(item); } diff --git a/cpp17/lib/src/application/commands/AddItem.h b/cpp17/lib/src/application/commands/AddItem.h index bbefb54..b8d0e5c 100644 --- a/cpp17/lib/src/application/commands/AddItem.h +++ b/cpp17/lib/src/application/commands/AddItem.h @@ -3,7 +3,7 @@ #include "domain/entities/Item.h" #include "domain/polices/ItemExpirationPolicy.h" #include "application/interfaces/IItemRepository.h" -#include "application/interfaces/IClock.h" +#include "application/interfaces/ITimeProvider.h" #include "application/interfaces/IOrderService.h" namespace nxl::autostore::application { @@ -13,13 +13,13 @@ class AddItem public: virtual ~AddItem() = default; - AddItem(IItemRepository& itemRepository, IClock& clock, + AddItem(IItemRepository& itemRepository, ITimeProvider& clock, IOrderService& orderService); domain::Item::Id_t execute(domain::Item&& item); private: IItemRepository& itemRepository; - IClock& clock; + ITimeProvider& clock; IOrderService& orderService; domain::ItemExpirationPolicy expirationPolicy; }; diff --git a/cpp17/lib/src/application/commands/DeleteItem.cpp b/cpp17/lib/src/application/commands/DeleteItem.cpp new file mode 100644 index 0000000..7beee40 --- /dev/null +++ b/cpp17/lib/src/application/commands/DeleteItem.cpp @@ -0,0 +1,19 @@ +#include "DeleteItem.h" +#include "application/exceptions/AutoStoreExceptions.h" + +namespace nxl::autostore::application { + +DeleteItem::DeleteItem(IItemRepository& itemRepository) + : itemRepository(itemRepository) +{} + +void DeleteItem::execute(domain::Item::Id_t id, domain::User::Id_t ownerId) +{ + auto item = itemRepository.findById(id); + if (!item || item->userId != ownerId) { + throw ItemNotFoundException("Item not found"); + } + itemRepository.remove(id); +} + +} // namespace nxl::autostore::application \ No newline at end of file diff --git a/cpp17/lib/src/application/commands/DeleteItem.h b/cpp17/lib/src/application/commands/DeleteItem.h new file mode 100644 index 0000000..6e29d4f --- /dev/null +++ b/cpp17/lib/src/application/commands/DeleteItem.h @@ -0,0 +1,21 @@ +#pragma once + +#include "domain/entities/Item.h" +#include "application/interfaces/IItemRepository.h" +#include + +namespace nxl::autostore::application { + +class DeleteItem +{ +public: + virtual ~DeleteItem() = default; + + explicit DeleteItem(IItemRepository& itemRepository); + void execute(domain::Item::Id_t id, domain::User::Id_t ownerId); + +private: + IItemRepository& itemRepository; +}; + +} // namespace nxl::autostore::application \ No newline at end of file diff --git a/cpp17/lib/src/application/commands/HandleExpiredItems.cpp b/cpp17/lib/src/application/commands/HandleExpiredItems.cpp new file mode 100644 index 0000000..229a0d4 --- /dev/null +++ b/cpp17/lib/src/application/commands/HandleExpiredItems.cpp @@ -0,0 +1,29 @@ +#include "HandleExpiredItems.h" +#include + +namespace nxl::autostore::application { + +HandleExpiredItems::HandleExpiredItems(IItemRepository& itemRepository, + ITimeProvider& clock, + IOrderService& orderService) + : itemRepository(itemRepository), clock(clock), orderService(orderService) +{} + +uint16_t HandleExpiredItems::execute() +{ + const auto currentTime = clock.now(); + + auto items = itemRepository.findWhere([&](const domain::Item& i) { + return expirationPolicy.isExpired(i, currentTime); + }); + + // remove expired one and order a new one + for (auto& item : items) { + itemRepository.remove(item.id); + orderService.orderItem(item); + } + + return items.size(); +} + +} // namespace nxl::autostore::application \ No newline at end of file diff --git a/cpp17/lib/src/application/commands/HandleExpiredItems.h b/cpp17/lib/src/application/commands/HandleExpiredItems.h new file mode 100644 index 0000000..5b748bd --- /dev/null +++ b/cpp17/lib/src/application/commands/HandleExpiredItems.h @@ -0,0 +1,30 @@ +#pragma once + +#include "domain/entities/Item.h" +#include "domain/polices/ItemExpirationPolicy.h" +#include "application/interfaces/IItemRepository.h" +#include "application/interfaces/ITimeProvider.h" +#include "application/interfaces/IOrderService.h" + +namespace nxl::autostore::application { + +class HandleExpiredItems +{ +public: + virtual ~HandleExpiredItems() = default; + + HandleExpiredItems(IItemRepository& itemRepository, ITimeProvider& clock, + IOrderService& orderService); + /** + * @returns number of expired items + */ + uint16_t execute(); + +private: + IItemRepository& itemRepository; + ITimeProvider& clock; + IOrderService& orderService; + domain::ItemExpirationPolicy expirationPolicy; +}; + +} // namespace nxl::autostore::application \ No newline at end of file diff --git a/cpp17/lib/src/application/commands/LoginUser.cpp b/cpp17/lib/src/application/commands/LoginUser.cpp new file mode 100644 index 0000000..a008ae1 --- /dev/null +++ b/cpp17/lib/src/application/commands/LoginUser.cpp @@ -0,0 +1,20 @@ +#include "LoginUser.h" +#include + +namespace nxl::autostore::application { + +LoginUser::LoginUser(IAuthService& authService) : authService(authService) {} + +std::string LoginUser::execute(std::string_view username, + std::string_view password) +{ + auto userId = authService.authenticateUser(username, password); + if (!userId) { + throw std::runtime_error("Invalid username or password"); + } + + // Generate a token for the authenticated user + return authService.generateToken(*userId); +} + +} // namespace nxl::autostore::application \ No newline at end of file diff --git a/cpp17/lib/src/application/commands/LoginUser.h b/cpp17/lib/src/application/commands/LoginUser.h new file mode 100644 index 0000000..eb1c3a9 --- /dev/null +++ b/cpp17/lib/src/application/commands/LoginUser.h @@ -0,0 +1,20 @@ +#pragma once + +#include "domain/entities/User.h" +#include "application/interfaces/IAuthService.h" + +namespace nxl::autostore::application { + +class LoginUser +{ +public: + virtual ~LoginUser() = default; + + LoginUser(IAuthService& authService); + std::string execute(std::string_view username, std::string_view password); + +private: + IAuthService& authService; +}; + +} // namespace nxl::autostore::application \ No newline at end of file diff --git a/cpp17/lib/src/application/exceptions/AutoStoreExceptions.h b/cpp17/lib/src/application/exceptions/AutoStoreExceptions.h new file mode 100644 index 0000000..3317a9d --- /dev/null +++ b/cpp17/lib/src/application/exceptions/AutoStoreExceptions.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include + +namespace nxl::autostore::application { + +class ItemNotFoundException : public std::runtime_error +{ +public: + explicit ItemNotFoundException(const std::string& message) + : std::runtime_error(message) + {} +}; + +} // namespace nxl::autostore::application \ No newline at end of file diff --git a/cpp17/lib/src/application/interfaces/IAuthService.h b/cpp17/lib/src/application/interfaces/IAuthService.h index 3189bd1..cc26bb5 100644 --- a/cpp17/lib/src/application/interfaces/IAuthService.h +++ b/cpp17/lib/src/application/interfaces/IAuthService.h @@ -1,5 +1,6 @@ #pragma once +#include "domain/entities/User.h" #include #include #include @@ -10,8 +11,11 @@ class IAuthService { public: virtual ~IAuthService() = default; + virtual std::optional + authenticateUser(std::string_view username, std::string_view password) = 0; virtual std::string generateToken(std::string_view userId) = 0; - virtual std::optional validateToken(std::string_view token) = 0; + virtual std::optional + extractUserId(std::string_view token) = 0; }; } // namespace nxl::autostore::application \ No newline at end of file diff --git a/cpp17/lib/src/application/interfaces/IBlocker.h b/cpp17/lib/src/application/interfaces/IBlocker.h new file mode 100644 index 0000000..f0b377c --- /dev/null +++ b/cpp17/lib/src/application/interfaces/IBlocker.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +namespace nxl::autostore::application { + +class IBlocker +{ +public: + using TimePoint = std::chrono::system_clock::time_point; + virtual ~IBlocker() = default; + virtual void block() = 0; + virtual void blockFor(const std::chrono::milliseconds& duration) = 0; + virtual void blockUntil(const TimePoint& timePoint) = 0; + virtual void notify() = 0; + virtual bool isBlocked() = 0; + virtual bool wasNotified() = 0; +}; + +} // namespace nxl::autostore::application \ No newline at end of file diff --git a/cpp17/lib/src/application/interfaces/IClock.h b/cpp17/lib/src/application/interfaces/IClock.h deleted file mode 100644 index 21b51c1..0000000 --- a/cpp17/lib/src/application/interfaces/IClock.h +++ /dev/null @@ -1,14 +0,0 @@ -#pragma once - -#include - -namespace nxl::autostore::application { - -class IClock -{ -public: - virtual ~IClock() = default; - virtual std::chrono::system_clock::time_point getCurrentTime() const = 0; -}; - -} // namespace nxl::autostore::application \ No newline at end of file diff --git a/cpp17/lib/src/application/interfaces/IItemRepository.h b/cpp17/lib/src/application/interfaces/IItemRepository.h index cbb312b..d31026f 100644 --- a/cpp17/lib/src/application/interfaces/IItemRepository.h +++ b/cpp17/lib/src/application/interfaces/IItemRepository.h @@ -2,6 +2,7 @@ #include "domain/entities/Item.h" #include +#include #include #include #include @@ -13,10 +14,11 @@ class IItemRepository public: virtual ~IItemRepository() = default; virtual domain::Item::Id_t save(const domain::Item& item) = 0; - virtual std::optional findById(std::string_view id) = 0; - virtual std::vector findByUser(std::string_view userId) = 0; - virtual std::vector findAll() = 0; - virtual void remove(std::string_view id) = 0; + virtual std::optional findById(domain::Item::Id_t id) = 0; + virtual std::vector findByOwner(domain::User::Id_t ownerId) = 0; + virtual std::vector + findWhere(std::function predicate) = 0; + virtual void remove(domain::Item::Id_t id) = 0; }; } // namespace nxl::autostore::application \ No newline at end of file diff --git a/cpp17/lib/src/application/interfaces/IThreadManager.h b/cpp17/lib/src/application/interfaces/IThreadManager.h new file mode 100644 index 0000000..850cd4c --- /dev/null +++ b/cpp17/lib/src/application/interfaces/IThreadManager.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +namespace nxl::autostore::application { + +class IThreadManager +{ +public: + class ThreadHandle + { + public: + virtual ~ThreadHandle() = default; + virtual void join() = 0; + virtual bool joinable() const = 0; + }; + + using ThreadHandlePtr = std::unique_ptr; + + virtual ~IThreadManager() = default; + + virtual ThreadHandlePtr createThread(std::function func) = 0; + virtual std::thread::id getCurrentThreadId() const = 0; + virtual void sleep(const std::chrono::milliseconds& duration) = 0; +}; + +} // namespace nxl::autostore::application \ No newline at end of file diff --git a/cpp17/lib/src/application/interfaces/ITimeProvider.h b/cpp17/lib/src/application/interfaces/ITimeProvider.h new file mode 100644 index 0000000..b8d90d9 --- /dev/null +++ b/cpp17/lib/src/application/interfaces/ITimeProvider.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace nxl::autostore::application { + +class ITimeProvider +{ +public: + using Clock = std::chrono::system_clock; + virtual ~ITimeProvider() = default; + + virtual Clock::time_point now() const = 0; + virtual std::tm to_tm(const Clock::time_point& timePoint) const = 0; + virtual Clock::time_point from_tm(const std::tm& tm) const = 0; +}; + +} // namespace nxl::autostore::application \ No newline at end of file diff --git a/cpp17/lib/src/application/interfaces/IUserRepository.h b/cpp17/lib/src/application/interfaces/IUserRepository.h deleted file mode 100644 index a9cb85c..0000000 --- a/cpp17/lib/src/application/interfaces/IUserRepository.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include "domain/entities/User.h" -#include -#include -#include -#include - -namespace nxl::autostore::application { - -class IUserRepository -{ -public: - virtual ~IUserRepository() = default; - virtual void save(const domain::User& user) = 0; - virtual std::optional findById(std::string_view id) = 0; - virtual std::optional - findByUsername(std::string_view username) = 0; - virtual std::vector findAll() = 0; - virtual void remove(std::string_view id) = 0; -}; - -} // namespace nxl::autostore::application \ No newline at end of file diff --git a/cpp17/lib/src/application/queries/GetItem.cpp b/cpp17/lib/src/application/queries/GetItem.cpp new file mode 100644 index 0000000..ea8271a --- /dev/null +++ b/cpp17/lib/src/application/queries/GetItem.cpp @@ -0,0 +1,21 @@ +#include "GetItem.h" +#include "../exceptions/AutoStoreExceptions.h" + +namespace nxl::autostore::application { + +GetItem::GetItem(IItemRepository& itemRepository) + : itemRepository(itemRepository) +{} + +std::optional GetItem::execute(domain::Item::Id_t id, + domain::User::Id_t ownerId) +{ + auto item = itemRepository.findById(id); + // act as not found when ownerId does not match for security + if (!item || item->userId != ownerId) { + throw ItemNotFoundException("Item not found"); + } + return item; +} + +} // namespace nxl::autostore::application \ No newline at end of file diff --git a/cpp17/lib/src/application/queries/GetItem.h b/cpp17/lib/src/application/queries/GetItem.h new file mode 100644 index 0000000..02ad75a --- /dev/null +++ b/cpp17/lib/src/application/queries/GetItem.h @@ -0,0 +1,23 @@ +#pragma once + +#include "domain/entities/Item.h" +#include "application/interfaces/IItemRepository.h" +#include +#include + +namespace nxl::autostore::application { + +class GetItem +{ +public: + virtual ~GetItem() = default; + + explicit GetItem(IItemRepository& itemRepository); + std::optional execute(domain::Item::Id_t id, + domain::User::Id_t ownerId); + +private: + IItemRepository& itemRepository; +}; + +} // namespace nxl::autostore::application \ No newline at end of file diff --git a/cpp17/lib/src/application/queries/ListItems.cpp b/cpp17/lib/src/application/queries/ListItems.cpp new file mode 100644 index 0000000..5358df8 --- /dev/null +++ b/cpp17/lib/src/application/queries/ListItems.cpp @@ -0,0 +1,14 @@ +#include "ListItems.h" + +namespace nxl::autostore::application { + +ListItems::ListItems(IItemRepository& itemRepository) + : itemRepository(itemRepository) +{} + +std::vector ListItems::execute(domain::User::Id_t ownerId) +{ + return itemRepository.findByOwner(ownerId); +} + +} // namespace nxl::autostore::application \ No newline at end of file diff --git a/cpp17/lib/src/application/queries/ListItems.h b/cpp17/lib/src/application/queries/ListItems.h new file mode 100644 index 0000000..9768236 --- /dev/null +++ b/cpp17/lib/src/application/queries/ListItems.h @@ -0,0 +1,21 @@ +#pragma once + +#include "domain/entities/Item.h" +#include "application/interfaces/IItemRepository.h" +#include + +namespace nxl::autostore::application { + +class ListItems +{ +public: + virtual ~ListItems() = default; + + explicit ListItems(IItemRepository& itemRepository); + std::vector execute(domain::User::Id_t ownerId); + +private: + IItemRepository& itemRepository; +}; + +} // namespace nxl::autostore::application \ No newline at end of file diff --git a/cpp17/lib/src/domain/entities/User.h b/cpp17/lib/src/domain/entities/User.h index e515b0a..875a314 100644 --- a/cpp17/lib/src/domain/entities/User.h +++ b/cpp17/lib/src/domain/entities/User.h @@ -7,9 +7,10 @@ namespace nxl::autostore::domain { struct User { using Id_t = std::string; + inline static const Id_t NULL_ID{""}; Id_t id; std::string username; - std::string passwordHash; + std::string password; }; } // namespace nxl::autostore::domain \ No newline at end of file diff --git a/cpp17/lib/src/infrastructure/adapters/CvBlocker.cpp b/cpp17/lib/src/infrastructure/adapters/CvBlocker.cpp new file mode 100644 index 0000000..af5bfee --- /dev/null +++ b/cpp17/lib/src/infrastructure/adapters/CvBlocker.cpp @@ -0,0 +1,55 @@ +#include "CvBlocker.h" + +namespace nxl::autostore::infrastructure { + +void CvBlocker::block() +{ + notified = false; + std::unique_lock lock(mutex); + blocked = true; + conditionVar.wait(lock); + blocked = false; +} + +void CvBlocker::blockFor(const std::chrono::milliseconds& duration) +{ + notified = false; + std::unique_lock lock(mutex); + blocked = true; + conditionVar.wait_for(lock, duration); + blocked = false; +} + +void CvBlocker::blockUntil(const TimePoint& timePoint) +{ + blockUntilTimePoint(timePoint); +} + +void CvBlocker::notify() +{ + notified = true; + conditionVar.notify_all(); +} + +bool CvBlocker::isBlocked() +{ + return blocked; +} + +bool CvBlocker::wasNotified() +{ + return notified; +} + +template +void CvBlocker::blockUntilTimePoint( + const std::chrono::time_point& timePoint) +{ + notified = false; + std::unique_lock lock(mutex); + blocked = true; + conditionVar.wait_until(lock, timePoint); + blocked = false; +} + +} // namespace nxl::autostore::infrastructure \ No newline at end of file diff --git a/cpp17/lib/src/infrastructure/adapters/CvBlocker.h b/cpp17/lib/src/infrastructure/adapters/CvBlocker.h new file mode 100644 index 0000000..e1c04b4 --- /dev/null +++ b/cpp17/lib/src/infrastructure/adapters/CvBlocker.h @@ -0,0 +1,33 @@ +#pragma once + +#include "application/interfaces/IBlocker.h" +#include +#include +#include + +namespace nxl::autostore::infrastructure { + +class CvBlocker : public application::IBlocker +{ +public: + ~CvBlocker() override = default; + void block() override; + void blockFor(const std::chrono::milliseconds& duration) override; + void blockUntil(const TimePoint& timePoint) override; + void notify() override; + bool isBlocked() override; + bool wasNotified() override; + +private: + template + void blockUntilTimePoint( + const std::chrono::time_point& timePoint); + +private: + std::condition_variable conditionVar; + std::mutex mutex; + std::atomic notified{false}; + std::atomic blocked{false}; +}; + +} // namespace nxl::autostore::infrastructure \ No newline at end of file diff --git a/cpp17/lib/src/infrastructure/adapters/SystemClock.h b/cpp17/lib/src/infrastructure/adapters/SystemClock.h deleted file mode 100644 index 22d8cdb..0000000 --- a/cpp17/lib/src/infrastructure/adapters/SystemClock.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -#include "application/interfaces/IClock.h" -#include - -namespace nxl::autostore::infrastructure { - -class SystemClock : public application::IClock -{ -public: - std::chrono::system_clock::time_point getCurrentTime() const override - { - return std::chrono::system_clock::now(); - } -}; - -} // namespace nxl::autostore::infrastructure \ No newline at end of file diff --git a/cpp17/lib/src/infrastructure/adapters/SystemThreadManager.cpp b/cpp17/lib/src/infrastructure/adapters/SystemThreadManager.cpp new file mode 100644 index 0000000..28f13ec --- /dev/null +++ b/cpp17/lib/src/infrastructure/adapters/SystemThreadManager.cpp @@ -0,0 +1,43 @@ +#include "SystemThreadManager.h" + +namespace nxl::autostore::infrastructure { + +SystemThreadManager::SystemThreadHandle::SystemThreadHandle( + std::thread&& thread) + : thread{std::move(thread)} +{} + +SystemThreadManager::SystemThreadHandle::~SystemThreadHandle() +{ + if (thread.joinable()) { + thread.join(); + } +} + +void SystemThreadManager::SystemThreadHandle::join() +{ + thread.join(); +} + +bool SystemThreadManager::SystemThreadHandle::joinable() const +{ + return thread.joinable(); +} + +application::IThreadManager::ThreadHandlePtr +SystemThreadManager::createThread(std::function func) +{ + return std::make_unique(std::thread(func)); +} + +std::thread::id SystemThreadManager::getCurrentThreadId() const +{ + return std::this_thread::get_id(); +} + +void SystemThreadManager::sleep(const std::chrono::milliseconds& duration) +{ + std::this_thread::sleep_for(duration); +} + +} // namespace nxl::autostore::infrastructure \ No newline at end of file diff --git a/cpp17/lib/src/infrastructure/adapters/SystemThreadManager.h b/cpp17/lib/src/infrastructure/adapters/SystemThreadManager.h new file mode 100644 index 0000000..de16b7c --- /dev/null +++ b/cpp17/lib/src/infrastructure/adapters/SystemThreadManager.h @@ -0,0 +1,27 @@ +#pragma once + +#include "application/interfaces/IThreadManager.h" + +namespace nxl::autostore::infrastructure { + +class SystemThreadManager : public application::IThreadManager +{ +public: + class SystemThreadHandle : public ThreadHandle + { + public: + explicit SystemThreadHandle(std::thread&& thread); + ~SystemThreadHandle() override; + void join() override; + bool joinable() const override; + + private: + std::thread thread; + }; + + ThreadHandlePtr createThread(std::function func) override; + std::thread::id getCurrentThreadId() const override; + void sleep(const std::chrono::milliseconds& duration) override; +}; + +} // namespace nxl::autostore::infrastructure \ No newline at end of file diff --git a/cpp17/lib/src/infrastructure/adapters/SystemTimeProvider.cpp b/cpp17/lib/src/infrastructure/adapters/SystemTimeProvider.cpp new file mode 100644 index 0000000..afc3e27 --- /dev/null +++ b/cpp17/lib/src/infrastructure/adapters/SystemTimeProvider.cpp @@ -0,0 +1,22 @@ +#include "SystemTimeProvider.h" + +namespace nxl::autostore::infrastructure { + +SystemTimeProvider::Clock::time_point SystemTimeProvider::now() const +{ + return Clock::now(); +} + +std::tm SystemTimeProvider::to_tm(const Clock::time_point& timePoint) const +{ + auto time_t_now = Clock::to_time_t(timePoint); + return *std::localtime(&time_t_now); +} + +SystemTimeProvider::Clock::time_point +SystemTimeProvider::from_tm(const std::tm& tm) const +{ + return Clock::from_time_t(std::mktime(const_cast(&tm))); +} + +} // namespace nxl::autostore::infrastructure \ No newline at end of file diff --git a/cpp17/lib/src/infrastructure/adapters/SystemTimeProvider.h b/cpp17/lib/src/infrastructure/adapters/SystemTimeProvider.h new file mode 100644 index 0000000..1a8a900 --- /dev/null +++ b/cpp17/lib/src/infrastructure/adapters/SystemTimeProvider.h @@ -0,0 +1,15 @@ +#pragma once + +#include "application/interfaces/ITimeProvider.h" + +namespace nxl::autostore::infrastructure { + +class SystemTimeProvider : public application::ITimeProvider +{ +public: + Clock::time_point now() const override; + std::tm to_tm(const Clock::time_point& timePoint) const override; + Clock::time_point from_tm(const std::tm& tm) const override; +}; + +} // namespace nxl::autostore::infrastructure \ No newline at end of file diff --git a/cpp17/lib/src/infrastructure/auth/FileJwtAuthService.cpp b/cpp17/lib/src/infrastructure/auth/FileJwtAuthService.cpp new file mode 100644 index 0000000..a08694f --- /dev/null +++ b/cpp17/lib/src/infrastructure/auth/FileJwtAuthService.cpp @@ -0,0 +1,92 @@ +#include "FileJwtAuthService.h" +#include +#include +#include + +namespace nxl::autostore::infrastructure { + +namespace { +// hardcoded secret key for demo purposes +constexpr const char* secretKey{"secret-key"}; +} // namespace + +/** + * @note Normally that would be generated by the OP + */ +std::string FileJwtAuthService::generateToken(std::string_view userId) +{ + auto token = + jwt::create() + .set_issuer("autostore") + .set_type("JWS") + .set_payload_claim("sub", jwt::claim(std::string(userId))) + .set_expires_at(std::chrono::system_clock::now() + std::chrono::hours(24)) + .sign(jwt::algorithm::hs256{secretKey}); + + return token; +} + +std::optional +FileJwtAuthService::extractUserId(std::string_view token) +{ + // Check cache first + std::string tokenStr(token); + auto cacheIt = uidCache.find(tokenStr); + if (cacheIt != uidCache.end()) { + return cacheIt->second; + } + + try { + auto decoded = jwt::decode(tokenStr); + + auto verifier = jwt::verify() + .allow_algorithm(jwt::algorithm::hs256{secretKey}) + .with_issuer("autostore"); + + verifier.verify(decoded); + + auto subClaim = decoded.get_payload_claim("sub"); + auto userId = subClaim.as_string(); + + if (uidCache.size() >= MAX_UID_CACHE_SIZE) { + // Remove the oldest entry (first element) to make space + uidCache.erase(uidCache.begin()); + } + uidCache[tokenStr] = userId; + + return userId; + } catch (const std::exception& e) { + return std::nullopt; + } +} + +/** + * @note Normally that wouldn't be this app's concern + */ +std::optional +FileJwtAuthService::authenticateUser(std::string_view username, + std::string_view password) +{ + try { + std::ifstream file(dbPath); + if (!file.is_open()) { + return std::nullopt; + } + + nlohmann::json usersJson; + file >> usersJson; + + for (const auto& userJson : usersJson) { + if (userJson["username"] == username + && userJson["password"] == password) { + return userJson["id"].get(); + } + } + + return std::nullopt; + } catch (const std::exception& e) { + return std::nullopt; + } +} + +} // namespace nxl::autostore::infrastructure \ No newline at end of file diff --git a/cpp17/lib/src/infrastructure/auth/FileJwtAuthService.h b/cpp17/lib/src/infrastructure/auth/FileJwtAuthService.h new file mode 100644 index 0000000..56881f9 --- /dev/null +++ b/cpp17/lib/src/infrastructure/auth/FileJwtAuthService.h @@ -0,0 +1,31 @@ +#pragma once + +#include "application/interfaces/IAuthService.h" +#include +#include +#include +#include + +namespace nxl::autostore::infrastructure { + +class FileJwtAuthService : public application::IAuthService +{ +public: + FileJwtAuthService(std::string dbPath) : dbPath{std::move(dbPath)} {} + + std::string generateToken(std::string_view userId) override; + + std::optional + extractUserId(std::string_view token) override; + + std::optional + authenticateUser(std::string_view username, + std::string_view password) override; + +private: + static constexpr size_t MAX_UID_CACHE_SIZE = 1024; + std::string dbPath; + std::unordered_map uidCache; +}; + +} // namespace nxl::autostore::infrastructure \ No newline at end of file diff --git a/cpp17/lib/src/infrastructure/helpers/JsonItem.cpp b/cpp17/lib/src/infrastructure/helpers/JsonItem.cpp index 568c24e..0413d68 100644 --- a/cpp17/lib/src/infrastructure/helpers/JsonItem.cpp +++ b/cpp17/lib/src/infrastructure/helpers/JsonItem.cpp @@ -17,7 +17,7 @@ domain::Item JsonItem::fromJsonObj(const nlohmann::json& j) item.id = j.value("id", ""); item.name = j.value("name", ""); item.orderUrl = j.value("orderUrl", ""); - item.userId = j.value("userId", "default-user"); + item.userId = j.value("userId", domain::User::NULL_ID); if (j["expirationDate"].is_number()) { // Handle numeric timestamp diff --git a/cpp17/lib/src/infrastructure/http/HttpJwtMiddleware.cpp b/cpp17/lib/src/infrastructure/http/HttpJwtMiddleware.cpp new file mode 100644 index 0000000..9c68b24 --- /dev/null +++ b/cpp17/lib/src/infrastructure/http/HttpJwtMiddleware.cpp @@ -0,0 +1,37 @@ +#include "HttpJwtMiddleware.h" + +namespace nxl::autostore::infrastructure { + +httplib::Server::HandlerResponse HttpJwtMiddleware::authenticate( + const httplib::Request& req, httplib::Response& res, + application::IAuthService& authService, ILoggerPtr log) +{ + log->v(1, "Pre-request handler: %s", req.path); + + // Skip authentication for login endpoint + if (req.path == "/api/v1/login") { + log->v(1, "Skipping authentication for login endpoint"); + return httplib::Server::HandlerResponse::Unhandled; + } + + auto it = req.headers.find("Authorization"); + if (it != req.headers.end()) { + auto authHeader = it->second; + if (authHeader.find("Bearer ") == 0) { + auto token = authHeader.substr(7); // Remove "Bearer " prefix + if (authService.extractUserId(token)) { + log->v(1, "Authorized request"); + return httplib::Server::HandlerResponse::Unhandled; + } + } + } + + log->v(1, "Unauthorized request"); + res.status = 401; + res.set_content( + R"({"status":"error","message":"Unauthorized - Invalid or missing token"})", + "application/json"); + return httplib::Server::HandlerResponse::Handled; +} + +} // namespace nxl::autostore::infrastructure \ No newline at end of file diff --git a/cpp17/lib/src/infrastructure/http/HttpJwtMiddleware.h b/cpp17/lib/src/infrastructure/http/HttpJwtMiddleware.h new file mode 100644 index 0000000..efa9651 --- /dev/null +++ b/cpp17/lib/src/infrastructure/http/HttpJwtMiddleware.h @@ -0,0 +1,20 @@ +#pragma once + +#include "application/interfaces/IAuthService.h" +#include "autostore/ILogger.h" +#include + +namespace nxl::autostore::infrastructure { + +class HttpJwtMiddleware +{ +public: + HttpJwtMiddleware() = default; + ~HttpJwtMiddleware() = default; + + static httplib::Server::HandlerResponse + authenticate(const httplib::Request& req, httplib::Response& res, + application::IAuthService& authService, ILoggerPtr logger); +}; + +} // namespace nxl::autostore::infrastructure \ No newline at end of file diff --git a/cpp17/lib/src/infrastructure/http/HttpOrderService.cpp b/cpp17/lib/src/infrastructure/http/HttpOrderService.cpp index 29ba304..0902a17 100644 --- a/cpp17/lib/src/infrastructure/http/HttpOrderService.cpp +++ b/cpp17/lib/src/infrastructure/http/HttpOrderService.cpp @@ -1,35 +1,364 @@ +/** + * HTTP-based order service implementation with retry logic and + * connection pooling + * + * FLOW OVERVIEW: + * 1. orderItem() validates input and enqueues order request + * 2. Background worker thread processes queue sequentially (FIFO) + * 3. Failed requests are retried with exponential backoff (1s, 2s, 4s...) + * 4. HTTP clients are cached per host and auto-cleaned when unused + * 5. Service shuts down gracefully, completing queued orders + * + * IMPORTANT LIMITATIONS: + * - Uses single worker thread - retries of failed requests will block + * processing of new orders until retry delay expires + * - Not suitable for time-critical operations due to sequential processing + * - Designed for fire-and-forget order notifications, not real-time + * transactions + */ + #include "HttpOrderService.h" +#include "autostore/Version.h" +#include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include namespace nxl::autostore::infrastructure { -HttpOrderService::HttpOrderService(ILoggerPtr logger) : log{std::move(logger)} -{} +namespace { -void HttpOrderService::orderItem(const domain::Item& item) +constexpr int MAX_RETRIES = 3; +constexpr int CONNECTION_TIMEOUT_SECONDS = 5; +constexpr int READ_TIMEOUT_SECONDS = 5; +constexpr int WRITE_TIMEOUT_SECONDS = 5; +constexpr char CONTENT_TYPE_JSON[] = "application/json"; + +std::pair parseUrl(const std::string& url) { - if (item.orderUrl.empty()) { - throw std::runtime_error("Order URL is empty for item: " + item.name); + static const std::regex url_regex( + R"(^(https?:\/\/)?([^\/:]+)(?::(\d+))?(\/[^\?]*)?(\?.*)?$)"); + + std::smatch matches; + if (!std::regex_match(url, matches, url_regex) || matches.size() < 5) { + throw std::runtime_error("Invalid URL format: " + url); + } + + std::string host = matches[2].str(); + std::string port = matches[3].str(); + std::string path = matches[4].str(); + std::string query = matches[5].str(); + + if (!port.empty()) { + host += ":" + port; } - std::string payload = - R"({"itemName": ")" + item.name + R"(", "itemId": ")" + item.id + "\"}"; - sendPostRequest(item.orderUrl, payload); + if (path.empty()) { + path = "/"; + } + + path += query; + return {host, path}; } -void HttpOrderService::sendPostRequest(std::string_view url, - std::string_view payload) +std::string createOrderPayload(const domain::Item& item) +{ + // Escape JSON special characters in strings + auto escapeJson = [](const std::string& str) { + std::string escaped; + escaped.reserve(str.size() + 10); // Reserve extra space for escapes + + for (char c : str) { + switch (c) { + case '"': + escaped += "\\\""; + break; + case '\\': + escaped += "\\\\"; + break; + case '\b': + escaped += "\\b"; + break; + case '\f': + escaped += "\\f"; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + case '\t': + escaped += "\\t"; + break; + default: + escaped += c; + break; + } + } + return escaped; + }; + + return R"({"itemName": ")" + escapeJson(item.name) + R"(", "itemId": ")" + + escapeJson(item.id) + "\"}"; +} + +} // namespace + +struct OrderRequest +{ + std::string url; + std::string payload; + int retryCount = 0; + std::chrono::system_clock::time_point nextAttemptTime; + + OrderRequest() = default; + OrderRequest(std::string url, std::string payload, int rc = 0, + std::chrono::system_clock::time_point nat = + std::chrono::system_clock::now()) + : url{std::move(url)}, payload{std::move(payload)}, retryCount{rc}, + nextAttemptTime(nat) + {} +}; + +class HttpOrderService::Impl +{ +public: + explicit Impl(ILoggerPtr logger) + : log{std::move(logger)}, shutdownRequested{false} + { + if (!log) { + throw std::invalid_argument("Logger cannot be null"); + } + + userAgent = "Autostore/" + nxl::getVersionString(); + workerThread = std::thread(&Impl::processQueue, this); + } + + ~Impl() + { + shutdown(); + if (workerThread.joinable()) { + workerThread.join(); + } + } + + void enqueueOrder(const std::string& url, std::string payload) + { + { + std::lock_guard lock(queueMutex); + if (shutdownRequested) { + throw std::runtime_error( + "Service is shutting down, cannot enqueue new orders"); + } + orderQueue.emplace(url, std::move(payload)); + } + queueCondition.notify_one(); + } + +private: + void shutdown() + { + { + std::lock_guard lock(queueMutex); + shutdownRequested = true; + } + queueCondition.notify_one(); + } + + bool shouldShutdown() const + { + return shutdownRequested && orderQueue.empty(); + } + + bool isRequestReady(const OrderRequest& request) const + { + return request.nextAttemptTime <= std::chrono::system_clock::now(); + } + + void processQueue() + { + while (true) { + std::unique_lock lock(queueMutex); + + // Wait for orders or shutdown signal + queueCondition.wait( + lock, [this] { return !orderQueue.empty() || shutdownRequested; }); + + if (shouldShutdown()) { + break; + } + + if (orderQueue.empty()) { + continue; + } + + // Check if the front request is ready to be processed + if (!isRequestReady(orderQueue.front())) { + // Wait until the next attempt time + auto waitTime = + orderQueue.front().nextAttemptTime - std::chrono::system_clock::now(); + if (waitTime > std::chrono::milliseconds(0)) { + queueCondition.wait_for(lock, waitTime); + } + continue; + } + + // Extract request for processing + OrderRequest request = std::move(orderQueue.front()); + orderQueue.pop(); + + // Release lock before processing to avoid blocking other operations + lock.unlock(); + + processRequest(request); + } + } + + void processRequest(OrderRequest& request) + { + try { + sendPostRequest(request.url, request.payload); + log->i("Order request sent successfully to: %s", request.url.c_str()); + } catch (const std::exception& e) { + log->e("Failed to send order request to %s: %s", request.url.c_str(), + e.what()); + handleFailedRequest(request); + } + } + + void handleFailedRequest(OrderRequest& request) + { + if (request.retryCount < MAX_RETRIES) { + request.retryCount++; + // Exponential backoff: 1s, 2s, 4s, 8s... + auto delay = std::chrono::seconds(1 << (request.retryCount - 1)); + request.nextAttemptTime = std::chrono::system_clock::now() + delay; + + log->w("Retrying order request to %s (attempt %d/%d) in %ld seconds", + request.url.c_str(), request.retryCount, MAX_RETRIES, + delay.count()); + + { + std::lock_guard lock(queueMutex); + if (!shutdownRequested) { + orderQueue.push(std::move(request)); + } + } + queueCondition.notify_one(); + } else { + log->e("Max retries exceeded for order request to: %s", + request.url.c_str()); + } + } + + std::shared_ptr getOrCreateClient(const std::string& host) + { + std::lock_guard lock(clientsMutex); + + auto it = clients.find(host); + if (it != clients.end()) { + // Check if client is still valid + auto client = it->second.lock(); + if (client) { + return client; + } else { + // Remove expired weak_ptr + clients.erase(it); + } + } + + // Create new client + auto client = std::make_shared(host); + configureClient(*client); + clients[host] = client; + return client; + } + + void configureClient(httplib::Client& client) + { + client.set_connection_timeout(CONNECTION_TIMEOUT_SECONDS, 0); + client.set_read_timeout(READ_TIMEOUT_SECONDS, 0); + client.set_write_timeout(WRITE_TIMEOUT_SECONDS, 0); + + // Enable keep-alive for better performance + client.set_keep_alive(true); + + // Set reasonable limits + client.set_compress(true); + } + + void sendPostRequest(const std::string& url, const std::string& payload) + { + auto [host, path] = parseUrl(url); + auto client = getOrCreateClient(host); + + httplib::Headers headers = {{"Content-Type", CONTENT_TYPE_JSON}, + {"User-Agent", userAgent}, + {"Accept", CONTENT_TYPE_JSON}}; + + log->i("Sending POST request to: %s%s", host.c_str(), path.c_str()); + log->v(1, "Payload: %s", payload.c_str()); + + auto res = client->Post(path, headers, payload, CONTENT_TYPE_JSON); + + if (!res) { + throw std::runtime_error("Failed to connect to: " + host); + } + + log->v(2, "Response status: %d", res->status); + log->v(3, "Response body: %s", res->body.c_str()); + + if (res->status < 200 || res->status >= 300) { + std::string error_msg = + "HTTP request failed with status: " + std::to_string(res->status) + + " for URL: " + url; + if (!res->body.empty()) { + error_msg += " Response: " + res->body; + } + throw std::runtime_error(error_msg); + } + } + + ILoggerPtr log; + std::queue orderQueue; + std::mutex queueMutex; + std::condition_variable queueCondition; + std::thread workerThread; + std::atomic shutdownRequested; + + // Use weak_ptr to allow automatic cleanup of unused clients + std::unordered_map> clients; + std::mutex clientsMutex; + std::string userAgent; +}; + +HttpOrderService::HttpOrderService(ILoggerPtr logger) + : impl{std::make_unique(std::move(logger))} +{} + +HttpOrderService::~HttpOrderService() = default; + +void HttpOrderService::orderItem(const domain::Item& item) { - // In a real implementation, this would use an HTTP client library - // For now, we'll simulate the HTTP call - log->i("POST request to: %s", url); - log->v(1, "Payload: %s", payload); + if (item.orderUrl.empty()) { + throw std::runtime_error("Order URL is empty for item: " + item.name); + } - // Simulate HTTP error handling - if (url.find("error") != std::string::npos) { - throw std::runtime_error("Failed to send order request to: " - + std::string(url)); + if (item.orderUrl.find("://") == std::string::npos) { + throw std::runtime_error("Invalid URL format for item: " + item.name + + " (missing protocol)"); } + + std::string payload = createOrderPayload(item); + impl->enqueueOrder(item.orderUrl, std::move(payload)); } } // namespace nxl::autostore::infrastructure \ No newline at end of file diff --git a/cpp17/lib/src/infrastructure/http/HttpOrderService.h b/cpp17/lib/src/infrastructure/http/HttpOrderService.h index 44af9d2..5b6e24b 100644 --- a/cpp17/lib/src/infrastructure/http/HttpOrderService.h +++ b/cpp17/lib/src/infrastructure/http/HttpOrderService.h @@ -10,11 +10,15 @@ class HttpOrderService : public application::IOrderService { public: explicit HttpOrderService(ILoggerPtr logger); + virtual ~HttpOrderService(); void orderItem(const domain::Item& item) override; private: ILoggerPtr log; void sendPostRequest(std::string_view url, std::string_view payload); + + class Impl; + std::unique_ptr impl; }; } // namespace nxl::autostore::infrastructure \ No newline at end of file diff --git a/cpp17/lib/src/infrastructure/http/HttpServer.cpp b/cpp17/lib/src/infrastructure/http/HttpServer.cpp index 23bac48..3dd5e75 100644 --- a/cpp17/lib/src/infrastructure/http/HttpServer.cpp +++ b/cpp17/lib/src/infrastructure/http/HttpServer.cpp @@ -1,4 +1,5 @@ #include "infrastructure/http/HttpServer.h" +#include "infrastructure/http/HttpJwtMiddleware.h" #include #include @@ -8,7 +9,10 @@ namespace { constexpr std::chrono::seconds defaultStartupTimeout{5}; } -HttpServer::HttpServer(ILoggerPtr logger) : log{std::move(logger)} {} +HttpServer::HttpServer(ILoggerPtr logger, + application::IAuthService& authService) + : log{std::move(logger)}, authService{authService} +{} HttpServer::~HttpServer() { @@ -23,16 +27,12 @@ bool HttpServer::start(int port, std::string_view host) return true; } - // std::cout << "Starting HTTP server on " << host << ":" << port << - // std::endl; log->info("Starting HTTP server on %s:%d...", host.data(), port); std::promise startupPromise; std::future startupFuture = startupPromise.get_future(); serverThread = std::thread([host, port, this, &startupPromise]() { - // std::cout << "Server thread started, binding to " << host << ":" << port - // << std::endl; log->v(1, "Server thread started, binding to %s:%d...", host, port); try { @@ -42,24 +42,29 @@ bool HttpServer::start(int port, std::string_view host) return; } + server.set_logger([this](const httplib::Request& req, const auto& res) { + log->v(1, "Request: %s\n%s", req.path, req.body); + log->v(1, "Response: %d, %s", res.status, res.body); + }); + + // JWT authentication middleware + server.set_pre_request_handler( + [this](const httplib::Request& req, + httplib::Response& res) -> httplib::Server::HandlerResponse { + return HttpJwtMiddleware::authenticate(req, res, authService, log); + }); + // Signal that the server has bound to the port startupPromise.set_value(true); - // std::cout << "Server thread listening on " << host << ":" << port - // << std::endl; log->info("Server thread listening on %s:%d", host, port); - // Start listening for connections bool listenResult = server.listen_after_bind(); - // std::cout << "Server stopped, listen result: " << listenResult - // << std::endl; log->info("Server stopped, listen result: %d", listenResult); } catch (const std::exception& e) { - // std::cerr << "Server thread exception: " << e.what() << std::endl; log->error("Server thread exception: %s", e.what()); startupPromise.set_exception(std::current_exception()); } catch (...) { - // std::cerr << "Server thread unknown exception" << std::endl; log->error("Server thread unknown exception"); startupPromise.set_exception(std::current_exception()); } @@ -68,7 +73,6 @@ bool HttpServer::start(int port, std::string_view host) // Wait for the server to start (with a timeout) if (startupFuture.wait_for(defaultStartupTimeout) == std::future_status::timeout) { - // std::cerr << "Failed to start HTTP server - timeout" << std::endl; log->error("Failed to start HTTP server - timeout"); return false; } @@ -76,19 +80,15 @@ bool HttpServer::start(int port, std::string_view host) try { // Check if the server bound to the port successfully if (!startupFuture.get()) { - // std::cerr << "Failed to start HTTP server - could not bind to port" - // << std::endl; log->error("Failed to start HTTP server - could not bind to port"); return false; } } catch (const std::exception& e) { - // std::cerr << "Failed to start HTTP server - " << e.what() << std::endl; log->error("Failed to start HTTP server - %s", e.what()); return false; } running = true; - // std::cout << "HTTP server is running" << std::endl; log->info("HTTP server is running"); return true; } @@ -99,17 +99,14 @@ void HttpServer::stop() return; } - // std::cout << "Stopping HTTP server..." << std::endl; log->info("Stopping HTTP server..."); server.stop(); if (serverThread.joinable()) { - // std::cout << "Waiting for server thread to join..." << std::endl; log->info("Waiting for server thread to join..."); serverThread.join(); } running = false; - // std::cout << "HTTP server stopped" << std::endl; log->info("HTTP server stopped"); } diff --git a/cpp17/lib/src/infrastructure/http/HttpServer.h b/cpp17/lib/src/infrastructure/http/HttpServer.h index 83f1516..f47d76e 100644 --- a/cpp17/lib/src/infrastructure/http/HttpServer.h +++ b/cpp17/lib/src/infrastructure/http/HttpServer.h @@ -1,6 +1,7 @@ #pragma once #include "autostore/ILogger.h" +#include "application/interfaces/IAuthService.h" #include #include #include @@ -11,7 +12,7 @@ namespace nxl::autostore::infrastructure { class HttpServer { public: - explicit HttpServer(ILoggerPtr logger); + HttpServer(ILoggerPtr logger, application::IAuthService& authService); ~HttpServer(); bool start(int port = 8080, std::string_view host = "0.0.0.0"); @@ -22,6 +23,7 @@ public: private: ILoggerPtr log; + application::IAuthService& authService; bool running{false}; httplib::Server server; std::thread serverThread; diff --git a/cpp17/lib/src/infrastructure/repositories/FileItemRepository.cpp b/cpp17/lib/src/infrastructure/repositories/FileItemRepository.cpp index b52891f..a0f180e 100644 --- a/cpp17/lib/src/infrastructure/repositories/FileItemRepository.cpp +++ b/cpp17/lib/src/infrastructure/repositories/FileItemRepository.cpp @@ -59,7 +59,7 @@ domain::Item::Id_t FileItemRepository::save(const domain::Item& item) return id; } -std::optional FileItemRepository::findById(std::string_view id) +std::optional FileItemRepository::findById(domain::Item::Id_t id) { std::lock_guard lock(mtx); auto it = std::find_if(items.begin(), items.end(), @@ -72,7 +72,7 @@ std::optional FileItemRepository::findById(std::string_view id) } std::vector -FileItemRepository::findByUser(std::string_view userId) +FileItemRepository::findByOwner(domain::User::Id_t userId) { std::lock_guard lock(mtx); std::vector userItems; @@ -81,13 +81,17 @@ FileItemRepository::findByUser(std::string_view userId) return userItems; } -std::vector FileItemRepository::findAll() +std::vector FileItemRepository::findWhere( + std::function predicate) { std::lock_guard lock(mtx); - return items; + std::vector matchedItems; + std::copy_if(items.begin(), items.end(), std::back_inserter(matchedItems), + predicate); + return matchedItems; } -void FileItemRepository::remove(std::string_view id) +void FileItemRepository::remove(domain::Item::Id_t id) { std::lock_guard lock(mtx); items.erase(std::remove_if(items.begin(), items.end(), diff --git a/cpp17/lib/src/infrastructure/repositories/FileItemRepository.h b/cpp17/lib/src/infrastructure/repositories/FileItemRepository.h index 9f27aaa..f5c6759 100644 --- a/cpp17/lib/src/infrastructure/repositories/FileItemRepository.h +++ b/cpp17/lib/src/infrastructure/repositories/FileItemRepository.h @@ -12,10 +12,11 @@ class FileItemRepository : public application::IItemRepository public: explicit FileItemRepository(std::string_view dbPath); domain::Item::Id_t save(const domain::Item& item) override; - std::optional findById(std::string_view id) override; - std::vector findByUser(std::string_view userId) override; - std::vector findAll() override; - void remove(std::string_view id) override; + std::optional findById(domain::Item::Id_t id) override; + std::vector findByOwner(domain::User::Id_t userId) override; + std::vector + findWhere(std::function predicate) override; + void remove(domain::Item::Id_t id) override; private: void load(); diff --git a/cpp17/lib/src/infrastructure/repositories/FileUserRepository.cpp b/cpp17/lib/src/infrastructure/repositories/FileUserRepository.cpp deleted file mode 100644 index 18c5f8a..0000000 --- a/cpp17/lib/src/infrastructure/repositories/FileUserRepository.cpp +++ /dev/null @@ -1,130 +0,0 @@ -#include "infrastructure/repositories/FileUserRepository.h" -#include "nlohmann/json.hpp" -#include -#include - -namespace nxl::autostore::infrastructure { - -namespace { - -// Helper functions for JSON serialization -inline void userToJson(nlohmann::json& j, const domain::User& u) -{ - j = nlohmann::json{ - {"id", u.id}, {"username", u.username}, {"passwordHash", u.passwordHash}}; -} - -inline void jsonToUser(const nlohmann::json& j, domain::User& u) -{ - j.at("id").get_to(u.id); - j.at("username").get_to(u.username); - j.at("passwordHash").get_to(u.passwordHash); -} - -// Helper functions for vector serialization -inline nlohmann::json usersToJson(const std::vector& users) -{ - nlohmann::json j = nlohmann::json::array(); - for (const auto& user : users) { - nlohmann::json userJson; - userToJson(userJson, user); - j.push_back(userJson); - } - return j; -} - -inline std::vector jsonToUsers(const nlohmann::json& j) -{ - std::vector users; - for (const auto& userJson : j) { - domain::User user; - jsonToUser(userJson, user); - users.push_back(user); - } - return users; -} - -} // namespace - -FileUserRepository::FileUserRepository(std::string_view dbPath) : dbPath(dbPath) -{ - load(); -} - -void FileUserRepository::save(const domain::User& user) -{ - std::lock_guard lock(mtx); - auto it = - std::find_if(users.begin(), users.end(), - [&](const domain::User& u) { return u.id == user.id; }); - - if (it != users.end()) { - *it = user; - } else { - users.push_back(user); - } - persist(); -} - -std::optional FileUserRepository::findById(std::string_view id) -{ - std::lock_guard lock(mtx); - auto it = std::find_if(users.begin(), users.end(), - [&](const domain::User& u) { return u.id == id; }); - - if (it != users.end()) { - return *it; - } - return std::nullopt; -} - -std::optional -FileUserRepository::findByUsername(std::string_view username) -{ - std::lock_guard lock(mtx); - auto it = - std::find_if(users.begin(), users.end(), - [&](const domain::User& u) { return u.username == username; }); - - if (it != users.end()) { - return *it; - } - return std::nullopt; -} - -std::vector FileUserRepository::findAll() -{ - std::lock_guard lock(mtx); - return users; -} - -void FileUserRepository::remove(std::string_view id) -{ - std::lock_guard lock(mtx); - users.erase(std::remove_if(users.begin(), users.end(), - [&](const domain::User& u) { return u.id == id; }), - users.end()); - persist(); -} - -void FileUserRepository::load() -{ - std::lock_guard lock(mtx); - std::ifstream file(dbPath); - if (file.is_open()) { - nlohmann::json j; - file >> j; - users = jsonToUsers(j); - } -} - -void FileUserRepository::persist() -{ - std::ofstream file(dbPath); - if (file.is_open()) { - nlohmann::json j = usersToJson(users); - file << j.dump(4); - } -} - -} // namespace nxl::autostore::infrastructure \ No newline at end of file diff --git a/cpp17/lib/src/infrastructure/repositories/FileUserRepository.h b/cpp17/lib/src/infrastructure/repositories/FileUserRepository.h deleted file mode 100644 index 5edd987..0000000 --- a/cpp17/lib/src/infrastructure/repositories/FileUserRepository.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include "application/interfaces/IUserRepository.h" -#include -#include -#include - -namespace nxl::autostore::infrastructure { - -class FileUserRepository : public application::IUserRepository -{ -public: - explicit FileUserRepository(std::string_view dbPath); - void save(const domain::User& user) override; - std::optional findById(std::string_view id) override; - std::optional - findByUsername(std::string_view username) override; - std::vector findAll() override; - void remove(std::string_view id) override; - -private: - void load(); - void persist(); - - std::string dbPath; - std::vector users; - std::mutex mtx; -}; - -} // namespace nxl::autostore::infrastructure \ No newline at end of file diff --git a/cpp17/lib/src/infrastructure/services/TaskScheduler.cpp b/cpp17/lib/src/infrastructure/services/TaskScheduler.cpp new file mode 100644 index 0000000..6118b9f --- /dev/null +++ b/cpp17/lib/src/infrastructure/services/TaskScheduler.cpp @@ -0,0 +1,138 @@ +#include "TaskScheduler.h" +#include +#include +#include +#include +#include + +namespace nxl::autostore::infrastructure { + +namespace { + +std::chrono::system_clock::time_point +today(uint8_t hour, uint8_t minute, uint8_t second, + const application::ITimeProvider& timeProvider) +{ + using namespace std::chrono; + using days = duration>; + + auto now = timeProvider.now(); + auto midnight = time_point_cast(floor(now)); + auto offset = hours{hour} + minutes{minute} + seconds{second}; + return midnight + offset; +} + +} // namespace + +TaskScheduler::TaskScheduler( + ILoggerPtr logger, std::shared_ptr timeProvider, + std::shared_ptr threadManager, + std::shared_ptr blocker) + : logger{std::move(logger)}, timeProvider{std::move(timeProvider)}, + threadManager{std::move(threadManager)}, blocker{std::move(blocker)}, + running(false), stopRequested(false) +{} + +void TaskScheduler::schedule(TaskFunction task, int hour, int minute, + int second, RunMode mode) +{ + if (hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 + || second > 59) { + throw std::invalid_argument("Invalid time parameters"); + } + + if ((mode & RunMode::Forever) && (mode & RunMode::Once)) { + throw std::invalid_argument( + "Forever and Once modes are mutually exclusive"); + } + + std::lock_guard lock(tasksMutex); + tasks.emplace_back(std::move(task), hour, minute, second, mode); +} + +void TaskScheduler::start() +{ + if (running) { + return; + } + + running = true; + stopRequested = false; + + threadHandle = threadManager->createThread([this]() { + logger->info("TaskScheduler thread started"); + + while (!stopRequested) { + auto now = timeProvider->now(); + bool shouldExecuteOnStart = false; + + { + std::lock_guard lock(tasksMutex); + + for (auto& task : tasks) { + if (stopRequested) + break; + + bool executeNow = false; + + if ((task.mode & RunMode::OnStart) && !task.executed) { + executeNow = true; + shouldExecuteOnStart = true; + } else if (task.mode & RunMode::Once + || task.mode & RunMode::Forever) { + if (!task.executed || (task.mode & RunMode::Forever)) { + auto taskTime = + today(task.hour, task.minute, task.second, *timeProvider); + + if (taskTime <= now) { + if (task.mode & RunMode::Forever) { + taskTime += std::chrono::hours(24); + } + executeNow = true; + } + + task.nextExecution = taskTime; + } + } + + if (executeNow) { + try { + task.function(); + task.executed = true; + logger->info("Task executed successfully"); + } catch (const std::exception& e) { + logger->error("Task execution failed: %s", e.what()); + } + } + } + } + + if (shouldExecuteOnStart) { + continue; + } + + if (!stopRequested) { + blocker->blockFor(std::chrono::milliseconds(100)); + } + } + + running = false; + logger->info("TaskScheduler thread stopped"); + }); +} + +void TaskScheduler::stop() +{ + if (!running) { + return; + } + + stopRequested = true; + blocker->notify(); + + if (threadHandle && threadHandle->joinable()) { + threadHandle->join(); + } +} + +} // namespace nxl::autostore::infrastructure \ No newline at end of file diff --git a/cpp17/lib/src/infrastructure/services/TaskScheduler.h b/cpp17/lib/src/infrastructure/services/TaskScheduler.h new file mode 100644 index 0000000..17987f0 --- /dev/null +++ b/cpp17/lib/src/infrastructure/services/TaskScheduler.h @@ -0,0 +1,82 @@ +#pragma once + +#include "application/interfaces/ITimeProvider.h" +#include "application/interfaces/IThreadManager.h" +#include "application/interfaces/IBlocker.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace nxl::autostore::infrastructure { + +class TaskScheduler +{ +public: + /** + * @note Forever and Once are mutually exclusive + */ + enum class RunMode { OnStart = 1, Forever = 2, Once = 4 }; + + using TaskFunction = std::function; + + struct ScheduledTask + { + TaskFunction function; + int hour; + int minute; + int second; + RunMode mode; + bool executed; + application::ITimeProvider::Clock::time_point nextExecution; + + ScheduledTask(TaskFunction t, int h, int m, int s, RunMode md) + : function(std::move(t)), hour(h), minute(m), second(s), mode(md), + executed(false) + {} + }; + + TaskScheduler(ILoggerPtr logger, + std::shared_ptr timeProvider, + std::shared_ptr threadManager, + std::shared_ptr blocker); + + TaskScheduler(const TaskScheduler&) = delete; + TaskScheduler& operator=(const TaskScheduler&) = delete; + virtual ~TaskScheduler() = default; + + void schedule(TaskFunction task, int hour, int minute, int second, + RunMode mode); + void start(); + void stop(); + +private: + ILoggerPtr logger; + std::shared_ptr timeProvider; + std::shared_ptr threadManager; + std::shared_ptr blocker; + + std::vector tasks; + std::mutex tasksMutex; + std::atomic running; + std::atomic stopRequested; + std::unique_ptr threadHandle; +}; + +constexpr TaskScheduler::RunMode operator|(TaskScheduler::RunMode a, + TaskScheduler::RunMode b) +{ + return static_cast(static_cast(a) + | static_cast(b)); +} + +constexpr int operator&(TaskScheduler::RunMode a, TaskScheduler::RunMode b) +{ + return static_cast(a) & static_cast(b); +} + +} // namespace nxl::autostore::infrastructure \ No newline at end of file diff --git a/cpp17/lib/src/webapi/controllers/AuthController.cpp b/cpp17/lib/src/webapi/controllers/AuthController.cpp new file mode 100644 index 0000000..afc1581 --- /dev/null +++ b/cpp17/lib/src/webapi/controllers/AuthController.cpp @@ -0,0 +1,58 @@ +#include "webapi/controllers/AuthController.h" +#include "infrastructure/helpers/JsonItem.h" +#include "infrastructure/helpers/Jsend.h" +#include "application/commands/LoginUser.h" +#include + +namespace nxl::autostore::webapi { + +AuthController::AuthController(Context&& context) + : BaseController(std::move(context)) +{} + +std::vector AuthController::getRoutes() const +{ + return {{"/api/v1/login", "POST", + [this](const httplib::Request& req, httplib::Response& res) { + const_cast(this)->loginUser(req, res); + }}}; +} + +void AuthController::loginUser(const httplib::Request& req, + httplib::Response& res) +{ + try { + if (req.body.empty()) { + sendError(res, "Request body is empty", + httplib::StatusCode::BadRequest_400); + return; + } + + auto requestBody = nlohmann::json::parse(req.body); + std::string username = requestBody.value("username", ""); + std::string password = requestBody.value("password", ""); + + if (username.empty() || password.empty()) { + sendError(res, "Username and password are required", + httplib::StatusCode::BadRequest_400); + return; + } + + try { + std::string token = + getContext().loginUserUc.execute(username, password); + nlohmann::json responseData = nlohmann::json::object(); + responseData["token"] = token; + res.status = httplib::StatusCode::OK_200; + res.set_content(infrastructure::Jsend::success(responseData), + "application/json"); + } catch (const std::exception& e) { + sendError(res, "Authentication failed: " + std::string(e.what()), + httplib::StatusCode::Unauthorized_401); + } + } catch (const std::exception& e) { + sendError(res, e.what(), httplib::StatusCode::BadRequest_400); + } +} + +} // namespace nxl::autostore::webapi \ No newline at end of file diff --git a/cpp17/lib/src/webapi/controllers/AuthController.h b/cpp17/lib/src/webapi/controllers/AuthController.h new file mode 100644 index 0000000..4e8e85e --- /dev/null +++ b/cpp17/lib/src/webapi/controllers/AuthController.h @@ -0,0 +1,26 @@ +#pragma once + +#include "webapi/controllers/BaseController.h" +#include "application/commands/LoginUser.h" +#include + +namespace nxl::autostore::webapi { + +class AuthController : public BaseController +{ +public: + struct Context + { + application::LoginUser loginUserUc; + }; + + AuthController(Context&& context); + +protected: + std::vector getRoutes() const override; + +private: + void loginUser(const httplib::Request& req, httplib::Response& res); +}; + +} // namespace nxl::autostore::webapi \ No newline at end of file diff --git a/cpp17/lib/src/webapi/controllers/BaseController.cpp b/cpp17/lib/src/webapi/controllers/BaseController.cpp new file mode 100644 index 0000000..d800ed8 --- /dev/null +++ b/cpp17/lib/src/webapi/controllers/BaseController.cpp @@ -0,0 +1,23 @@ +#include "webapi/controllers/BaseController.h" + +namespace nxl::autostore::webapi { + +void BaseController::registerRoutes(httplib::Server& server) +{ + auto routes = getRoutes(); + for (const auto& route : routes) { + if (route.method == "GET") { + server.Get(route.path, route.handler); + } else if (route.method == "POST") { + server.Post(route.path, route.handler); + } else if (route.method == "PUT") { + server.Put(route.path, route.handler); + } else if (route.method == "DELETE") { + server.Delete(route.path, route.handler); + } else if (route.method == "PATCH") { + server.Patch(route.path, route.handler); + } + } +} + +} // namespace nxl::autostore::webapi \ No newline at end of file diff --git a/cpp17/lib/src/webapi/controllers/BaseController.h b/cpp17/lib/src/webapi/controllers/BaseController.h new file mode 100644 index 0000000..62b50a7 --- /dev/null +++ b/cpp17/lib/src/webapi/controllers/BaseController.h @@ -0,0 +1,86 @@ +#pragma once + +#include "infrastructure/helpers/Jsend.h" +#include +#include +#include +#include + +namespace nxl::autostore::webapi { + +class BaseController +{ +public: + using HttpRequestHandler = + std::function; + + struct RouteConfig + { + std::string path; + std::string method; + HttpRequestHandler handler; + }; + + template + BaseController(Context&& context) + : contextStorage( + std::make_unique>(std::move(context))) + {} + + virtual ~BaseController() = default; + + void registerRoutes(httplib::Server& server); + +protected: + virtual std::vector getRoutes() const = 0; + + void sendError(httplib::Response& res, std::string_view message, int status) + { + res.status = status; + res.set_content(infrastructure::Jsend::error(message, status), + "application/json"); + } + + template T& getContext() + { + return static_cast*>(contextStorage.get())->getContext(); + } + + std::string extractUserToken(const httplib::Request& req) + { + auto header = req.get_header_value("Authorization"); + if (header.empty()) { + throw std::runtime_error("Authorization header is missing"); + } + + if (header.substr(0, 7) != "Bearer ") { + throw std::runtime_error("Authorization header is invalid"); + } + + return header.substr(7); + } + + template + std::optional extractUserId(const httplib::Request& req) + { + auto token = extractUserToken(req); + return getContext().authService.extractUserId(token); + } + +private: + struct ContextHolderBase + { + virtual ~ContextHolderBase() = default; + }; + + template struct ContextHolder : ContextHolderBase + { + ContextHolder(T&& ctx) : context(std::move(ctx)) {} + T& getContext() { return context; } + T context; + }; + + std::unique_ptr contextStorage; +}; + +} // namespace nxl::autostore::webapi \ No newline at end of file diff --git a/cpp17/lib/src/webapi/controllers/StoreController.cpp b/cpp17/lib/src/webapi/controllers/StoreController.cpp index 1605a21..5c066dc 100644 --- a/cpp17/lib/src/webapi/controllers/StoreController.cpp +++ b/cpp17/lib/src/webapi/controllers/StoreController.cpp @@ -2,51 +2,126 @@ #include "infrastructure/helpers/JsonItem.h" #include "infrastructure/helpers/Jsend.h" #include "application/commands/AddItem.h" +#include "application/queries/ListItems.h" +#include "application/queries/GetItem.h" +#include "application/commands/DeleteItem.h" +#include "application/exceptions/AutoStoreExceptions.h" +#include namespace nxl::autostore::webapi { -using infrastructure::Jsend; -using infrastructure::JsonItem; - StoreController::StoreController(Context&& context) - : context{std::move(context)} + : BaseController(std::move(context)) {} -void StoreController::registerRoutes(httplib::Server& server) +std::vector StoreController::getRoutes() const { - server.Post("/api/items", - [this](const httplib::Request& req, httplib::Response& res) { - this->addItem(req, res); - }); + return {{"/api/v1/items", "POST", + [this](const httplib::Request& req, httplib::Response& res) { + const_cast(this)->addItem(req, res); + }}, + {"/api/v1/items", "GET", + [this](const httplib::Request& req, httplib::Response& res) { + const_cast(this)->listItems(req, res); + }}, + {"/api/v1/items/(.*)", "GET", + [this](const httplib::Request& req, httplib::Response& res) { + const_cast(this)->getItem(req, res); + }}, + {"/api/v1/items/(.*)", "DELETE", + [this](const httplib::Request& req, httplib::Response& res) { + const_cast(this)->deleteItem(req, res); + }}}; } void StoreController::addItem(const httplib::Request& req, httplib::Response& res) { try { - if (req.body.empty()) { - res.status = 400; - res.set_content(Jsend::error("Request body is empty", 400), - "application/json"); - return; - } + auto userId = extractUserId(req); + assertUserId(userId); + auto item = infrastructure::JsonItem::fromJson(req.body); + item.userId = userId.value(); + auto itemId = getContext().addItemUc.execute(std::move(item)); + nlohmann::json responseData = nlohmann::json::object(); + responseData["id"] = itemId; + res.status = httplib::StatusCode::Created_201; + res.set_content(infrastructure::Jsend::success(responseData), + "application/json"); + } catch (const std::exception& e) { + sendError(res, e.what(), httplib::StatusCode::InternalServerError_500); + } +} + +void StoreController::listItems(const httplib::Request& req, + httplib::Response& res) +{ + try { + auto userId = extractUserId(req); + assertUserId(userId); + auto items = getContext().listItemsUc.execute(userId.value()); - auto item = JsonItem::fromJson(req.body); - try { - nlohmann::json responseData = nlohmann::json::object(); - responseData["id"] = context.addItemUc.execute(std::move(item)); - res.status = 201; - res.set_content(Jsend::success(responseData), "application/json"); - } catch (const std::exception& e) { - res.status = 500; - res.set_content( - Jsend::error("Failed to add item: " + std::string(e.what()), - res.status), - "application/json"); + nlohmann::json responseData = nlohmann::json::array(); + for (const auto& item : items) { + responseData.push_back(infrastructure::JsonItem::toJsonObj(item)); } + + res.status = httplib::StatusCode::OK_200; + res.set_content(infrastructure::Jsend::success(responseData), + "application/json"); } catch (const std::exception& e) { - res.status = 400; - res.set_content(Jsend::error(e.what(), res.status), "application/json"); + sendError(res, e.what(), httplib::StatusCode::InternalServerError_500); + } +} + +void StoreController::getItem(const httplib::Request& req, + httplib::Response& res) +{ + try { + auto itemId = req.matches[1]; + auto userId = extractUserId(req); + assertUserId(userId); + auto item = getContext().getItemUc.execute(itemId, userId.value()); + + auto responseData = infrastructure::JsonItem::toJsonObj(*item); + res.status = httplib::StatusCode::OK_200; + res.set_content(infrastructure::Jsend::success(responseData), + "application/json"); + } catch (const nxl::autostore::application::ItemNotFoundException& e) { + sendError(res, e.what(), httplib::StatusCode::NotFound_404); + } catch (const std::exception& e) { + sendError(res, e.what(), httplib::StatusCode::InternalServerError_500); + } +} + +void StoreController::deleteItem(const httplib::Request& req, + httplib::Response& res) +{ + try { + auto itemId = req.matches[1]; + auto userId = extractUserId(req); + assertUserId(userId); + getContext().deleteItemUc.execute(itemId, userId.value()); + + nlohmann::json responseData = nlohmann::json::object(); + responseData["message"] = "Item deleted successfully"; + res.status = httplib::StatusCode::NoContent_204; + + // Actually, no content should follow 204 response + // res.set_content(infrastructure::Jsend::success(responseData), + // "application/json"); + } catch (const nxl::autostore::application::ItemNotFoundException& e) { + sendError(res, e.what(), httplib::StatusCode::NotFound_404); + } catch (const std::exception& e) { + sendError(res, e.what(), httplib::StatusCode::InternalServerError_500); + } +} + +void StoreController::assertUserId( + std::optional userId) const +{ + if (!userId) { + throw std::runtime_error("User ID not found in request"); } } diff --git a/cpp17/lib/src/webapi/controllers/StoreController.h b/cpp17/lib/src/webapi/controllers/StoreController.h index 772330f..fc0e315 100644 --- a/cpp17/lib/src/webapi/controllers/StoreController.h +++ b/cpp17/lib/src/webapi/controllers/StoreController.h @@ -1,27 +1,40 @@ #pragma once +#include "webapi/controllers/BaseController.h" #include "application/commands/AddItem.h" -#include // TODO: forward declaration +#include "application/queries/ListItems.h" +#include "application/queries/GetItem.h" +#include "application/commands/DeleteItem.h" +#include "application/interfaces/IAuthService.h" +#include "infrastructure/helpers/JsonItem.h" +#include namespace nxl::autostore::webapi { -class StoreController +class StoreController : public BaseController { public: struct Context { application::AddItem addItemUc; + application::ListItems listItemsUc; + application::GetItem getItemUc; + application::DeleteItem deleteItemUc; + application::IAuthService& authService; }; StoreController(Context&& context); - void registerRoutes(httplib::Server& server); +protected: + std::vector getRoutes() const override; private: void addItem(const httplib::Request& req, httplib::Response& res); + void listItems(const httplib::Request& req, httplib::Response& res); + void getItem(const httplib::Request& req, httplib::Response& res); + void deleteItem(const httplib::Request& req, httplib::Response& res); -private: - Context context; + void assertUserId(std::optional userId) const; }; } // namespace nxl::autostore::webapi \ No newline at end of file diff --git a/cpp17/tests/CMakeLists.txt b/cpp17/tests/CMakeLists.txt index b5c34a9..e046728 100644 --- a/cpp17/tests/CMakeLists.txt +++ b/cpp17/tests/CMakeLists.txt @@ -3,9 +3,11 @@ cmake_minimum_required(VERSION 3.20) enable_testing() find_package(Catch2 CONFIG REQUIRED) +find_package(trompeloeil CONFIG REQUIRED) -# Macro to create a test executable -function(add_integration_test TEST_NAME SOURCE_FILE) + +# Macro to create test executable +function(add_test_target TEST_NAME SOURCE_FILE) add_executable(${TEST_NAME} ${SOURCE_FILE} ) @@ -14,12 +16,14 @@ function(add_integration_test TEST_NAME SOURCE_FILE) PRIVATE AutoStoreLib Catch2::Catch2WithMain + trompeloeil::trompeloeil ) target_include_directories(${TEST_NAME} PRIVATE ${PROJECT_SOURCE_DIR}/lib/include ${PROJECT_SOURCE_DIR}/lib/src + ${PROJECT_SOURCE_DIR}/tests ) # Add test to CTest @@ -27,5 +31,7 @@ function(add_integration_test TEST_NAME SOURCE_FILE) endfunction() # Create test executables -add_integration_test(FileUserRepositoryTest integration/FileUserRepository.test.cpp) -add_integration_test(FileItemRepositoryTest integration/FileItemRepository.test.cpp) \ No newline at end of file +add_test_target(FileItemRepositoryTest integration/FileItemRepository.test.cpp) +# add_integration_test(FileUserRepositoryTest integration/FileUserRepository.test.cpp) +add_test_target(AddItemTest unit/AddItem.test.cpp) +add_test_target(TaskSchedulerTest unit/TaskScheduler.test.cpp) \ No newline at end of file diff --git a/cpp17/tests/helpers/AddItemTestHelpers.h b/cpp17/tests/helpers/AddItemTestHelpers.h new file mode 100644 index 0000000..89ce457 --- /dev/null +++ b/cpp17/tests/helpers/AddItemTestHelpers.h @@ -0,0 +1,57 @@ +#pragma once + +#include "domain/entities/Item.h" +#include "domain/entities/User.h" +#include +#include + +namespace nxl::autostore::domain { +// Equality operator for Item to make trompeloeil work +inline bool operator==(const Item& lhs, const Item& rhs) +{ + return lhs.id == rhs.id && lhs.name == rhs.name + && lhs.orderUrl == rhs.orderUrl && lhs.userId == rhs.userId + && lhs.expirationDate == rhs.expirationDate; +} +} // namespace nxl::autostore::domain + +namespace test { + +constexpr const char* TEST_ITEM_ID_1 = "item123"; +constexpr const char* TEST_ITEM_ID_2 = "item456"; +constexpr const char* TEST_ITEM_NAME_1 = "testitem"; +constexpr const char* TEST_ORDER_URL_1 = "https://example.com/order1"; +constexpr const char* TEST_USER_ID_1 = "user123"; +constexpr const char* TEST_USER_ID_2 = "user456"; + +// Fixed test timepoint: 2020-01-01 12:00 +constexpr std::chrono::system_clock::time_point TEST_TIMEPOINT_NOW = + std::chrono::system_clock::time_point(std::chrono::seconds(1577880000)); + +// Helper function to create a test item with default values +nxl::autostore::domain::Item +createTestItem(const std::string& id = TEST_ITEM_ID_1, + const std::string& name = TEST_ITEM_NAME_1, + const std::string& orderUrl = TEST_ORDER_URL_1, + const std::string& userId = TEST_USER_ID_1, + const std::chrono::system_clock::time_point& expirationDate = + std::chrono::system_clock::now() + std::chrono::hours(24)) +{ + nxl::autostore::domain::Item item; + item.id = id; + item.name = name; + item.orderUrl = orderUrl; + item.userId = userId; + item.expirationDate = expirationDate; + return item; +} + +// Helper function to create an expired test item +nxl::autostore::domain::Item createExpiredTestItem() +{ + return createTestItem(TEST_ITEM_ID_1, TEST_ITEM_NAME_1, TEST_ORDER_URL_1, + TEST_USER_ID_1, + TEST_TIMEPOINT_NOW - std::chrono::hours(1)); +} + +} // namespace test \ No newline at end of file diff --git a/cpp17/tests/integration/FileItemRepository.test.cpp b/cpp17/tests/integration/FileItemRepository.test.cpp index 3585960..4367dec 100644 --- a/cpp17/tests/integration/FileItemRepository.test.cpp +++ b/cpp17/tests/integration/FileItemRepository.test.cpp @@ -117,12 +117,15 @@ TEST_CASE("FileItemRepository Integration Tests", domain::Item testItem = Test::createTestItem(); // When - repository.save(testItem); + auto savedItemId = repository.save(testItem); // Then - auto foundItem = repository.findById(Test::TEST_ITEM_ID_1); + auto foundItem = repository.findById(savedItemId); REQUIRE(foundItem.has_value()); - Test::verifyDefaultTestItem(*foundItem); + REQUIRE(foundItem->id == savedItemId); + REQUIRE(foundItem->name == Test::TEST_ITEM_NAME_1); + REQUIRE(foundItem->orderUrl == Test::TEST_ORDER_URL_1); + REQUIRE(foundItem->userId == Test::TEST_USER_ID_1); } SECTION("when a new item is saved then it can be found by user") @@ -132,12 +135,15 @@ TEST_CASE("FileItemRepository Integration Tests", domain::Item testItem = Test::createTestItem(); // When - repository.save(testItem); + auto savedItemId = repository.save(testItem); // Then - auto userItems = repository.findByUser(Test::TEST_USER_ID_1); + auto userItems = repository.findByOwner(Test::TEST_USER_ID_1); REQUIRE(userItems.size() == 1); - Test::verifyDefaultTestItem(userItems[0]); + REQUIRE(userItems[0].id == savedItemId); + REQUIRE(userItems[0].name == Test::TEST_ITEM_NAME_1); + REQUIRE(userItems[0].orderUrl == Test::TEST_ORDER_URL_1); + REQUIRE(userItems[0].userId == Test::TEST_USER_ID_1); } SECTION("when multiple items are saved then findAll returns all items") @@ -148,11 +154,12 @@ TEST_CASE("FileItemRepository Integration Tests", domain::Item secondItem = Test::createSecondTestItem(); // When - repository.save(firstItem); - repository.save(secondItem); + auto firstItemId = repository.save(firstItem); + auto secondItemId = repository.save(secondItem); // Then - auto allItems = repository.findAll(); + auto allItems = + repository.findWhere([](const domain::Item&) { return true; }); REQUIRE(allItems.size() == 2); // Verify both items are present (order doesn't matter) @@ -160,11 +167,15 @@ TEST_CASE("FileItemRepository Integration Tests", bool foundSecond = false; for (const auto& item : allItems) { - if (item.id == Test::TEST_ITEM_ID_1) { - Test::verifyDefaultTestItem(item); + if (item.id == firstItemId) { + REQUIRE(item.name == Test::TEST_ITEM_NAME_1); + REQUIRE(item.orderUrl == Test::TEST_ORDER_URL_1); + REQUIRE(item.userId == Test::TEST_USER_ID_1); foundFirst = true; - } else if (item.id == Test::TEST_ITEM_ID_2) { - Test::verifySecondTestItem(item); + } else if (item.id == secondItemId) { + REQUIRE(item.name == Test::TEST_ITEM_NAME_2); + REQUIRE(item.orderUrl == Test::TEST_ORDER_URL_2); + REQUIRE(item.userId == Test::TEST_USER_ID_2); foundSecond = true; } } @@ -184,11 +195,11 @@ TEST_CASE("FileItemRepository Integration Tests", Test::TEST_USER_ID_1); // When - repository.save(firstItem); - repository.save(secondItem); + auto firstItemId = repository.save(firstItem); + auto secondItemId = repository.save(secondItem); // Then - auto userItems = repository.findByUser(Test::TEST_USER_ID_1); + auto userItems = repository.findByOwner(Test::TEST_USER_ID_1); REQUIRE(userItems.size() == 2); // Verify both items are present (order doesn't matter) @@ -196,10 +207,12 @@ TEST_CASE("FileItemRepository Integration Tests", bool foundSecond = false; for (const auto& item : userItems) { - if (item.id == Test::TEST_ITEM_ID_1) { - Test::verifyDefaultTestItem(item); + if (item.id == firstItemId) { + REQUIRE(item.name == Test::TEST_ITEM_NAME_1); + REQUIRE(item.orderUrl == Test::TEST_ORDER_URL_1); + REQUIRE(item.userId == Test::TEST_USER_ID_1); foundFirst = true; - } else if (item.id == "item789") { + } else if (item.id == secondItemId) { REQUIRE(item.name == "thirditem"); REQUIRE(item.orderUrl == "https://example.com/order3"); REQUIRE(item.userId == Test::TEST_USER_ID_1); @@ -216,18 +229,20 @@ TEST_CASE("FileItemRepository Integration Tests", // Given infrastructure::FileItemRepository repository(testDbPath); domain::Item testItem = Test::createTestItem(); - repository.save(testItem); + auto savedItemId = repository.save(testItem); // When + testItem.id = savedItemId; testItem.name = "updateditemname"; testItem.orderUrl = "https://updated.example.com/order"; testItem.userId = Test::TEST_USER_ID_2; - repository.save(testItem); + auto updatedItemId = repository.save(testItem); // Then - auto foundItem = repository.findById(Test::TEST_ITEM_ID_1); + REQUIRE(savedItemId == updatedItemId); // ID should not change for updates + auto foundItem = repository.findById(updatedItemId); REQUIRE(foundItem.has_value()); - REQUIRE(foundItem->id == Test::TEST_ITEM_ID_1); + REQUIRE(foundItem->id == updatedItemId); REQUIRE(foundItem->name == "updateditemname"); REQUIRE(foundItem->orderUrl == "https://updated.example.com/order"); REQUIRE(foundItem->userId == Test::TEST_USER_ID_2); @@ -238,13 +253,13 @@ TEST_CASE("FileItemRepository Integration Tests", // Given infrastructure::FileItemRepository repository(testDbPath); domain::Item testItem = Test::createTestItem(); - repository.save(testItem); + auto savedItemId = repository.save(testItem); // When - repository.remove(Test::TEST_ITEM_ID_1); + repository.remove(savedItemId); // Then - auto foundItem = repository.findById(Test::TEST_ITEM_ID_1); + auto foundItem = repository.findById(savedItemId); REQUIRE_FALSE(foundItem.has_value()); } @@ -253,13 +268,13 @@ TEST_CASE("FileItemRepository Integration Tests", // Given infrastructure::FileItemRepository repository(testDbPath); domain::Item testItem = Test::createTestItem(); - repository.save(testItem); + auto savedItemId = repository.save(testItem); // When - repository.remove(Test::TEST_ITEM_ID_1); + repository.remove(savedItemId); // Then - auto userItems = repository.findByUser(Test::TEST_USER_ID_1); + auto userItems = repository.findByOwner(Test::TEST_USER_ID_1); REQUIRE(userItems.empty()); } @@ -269,16 +284,20 @@ TEST_CASE("FileItemRepository Integration Tests", infrastructure::FileItemRepository repository(testDbPath); domain::Item firstItem = Test::createTestItem(); domain::Item secondItem = Test::createSecondTestItem(); - repository.save(firstItem); - repository.save(secondItem); + auto firstItemId = repository.save(firstItem); + auto secondItemId = repository.save(secondItem); // When - repository.remove(Test::TEST_ITEM_ID_1); + repository.remove(firstItemId); // Then - auto allItems = repository.findAll(); + auto allItems = + repository.findWhere([](const domain::Item&) { return true; }); REQUIRE(allItems.size() == 1); - Test::verifySecondTestItem(allItems[0]); + REQUIRE(allItems[0].id == secondItemId); + REQUIRE(allItems[0].name == Test::TEST_ITEM_NAME_2); + REQUIRE(allItems[0].orderUrl == Test::TEST_ORDER_URL_2); + REQUIRE(allItems[0].userId == Test::TEST_USER_ID_2); } SECTION( @@ -303,45 +322,52 @@ TEST_CASE("FileItemRepository Integration Tests", repository.save(testItem); // When - auto userItems = repository.findByUser(Test::NON_EXISTENT_USER_ID); + auto userItems = repository.findByOwner(Test::NON_EXISTENT_USER_ID); // Then REQUIRE(userItems.empty()); } - SECTION("when remove is called with non-existent id then it does nothing") { // Given infrastructure::FileItemRepository repository(testDbPath); domain::Item testItem = Test::createTestItem(); - repository.save(testItem); + auto savedItemId = repository.save(testItem); // When repository.remove(Test::NON_EXISTENT_ID); // Then - auto allItems = repository.findAll(); + auto allItems = + repository.findWhere([](const domain::Item&) { return true; }); REQUIRE(allItems.size() == 1); - Test::verifyDefaultTestItem(allItems[0]); + REQUIRE(allItems[0].id == savedItemId); + REQUIRE(allItems[0].name == Test::TEST_ITEM_NAME_1); + REQUIRE(allItems[0].orderUrl == Test::TEST_ORDER_URL_1); + REQUIRE(allItems[0].userId == Test::TEST_USER_ID_1); } SECTION( "when repository is created with existing data file then it loads the data") { // Given + std::string savedItemId; { infrastructure::FileItemRepository firstRepository(testDbPath); domain::Item testItem = Test::createTestItem(); - firstRepository.save(testItem); + savedItemId = firstRepository.save(testItem); } // When infrastructure::FileItemRepository secondRepository(testDbPath); // Then - auto foundItem = secondRepository.findById(Test::TEST_ITEM_ID_1); + auto foundItem = secondRepository.findById(savedItemId); REQUIRE(foundItem.has_value()); - Test::verifyDefaultTestItem(*foundItem); + REQUIRE(foundItem->id == savedItemId); + REQUIRE(foundItem->name == Test::TEST_ITEM_NAME_1); + REQUIRE(foundItem->orderUrl == Test::TEST_ORDER_URL_1); + REQUIRE(foundItem->userId == Test::TEST_USER_ID_1); } SECTION("when repository is created with non-existent data file then it " @@ -356,7 +382,8 @@ TEST_CASE("FileItemRepository Integration Tests", infrastructure::FileItemRepository repository(nonExistentDbPath); // Then - auto allItems = repository.findAll(); + auto allItems = + repository.findWhere([](const domain::Item&) { return true; }); REQUIRE(allItems.empty()); } diff --git a/cpp17/tests/integration/FileUserRepository.test.cpp b/cpp17/tests/integration/FileUserRepository.test.cpp deleted file mode 100644 index b33d2cf..0000000 --- a/cpp17/tests/integration/FileUserRepository.test.cpp +++ /dev/null @@ -1,312 +0,0 @@ -#include "infrastructure/repositories/FileUserRepository.h" -#include "domain/entities/User.h" -#include -#include -#include -#include -#include - -using namespace nxl::autostore; -using Catch::Matchers::Equals; - -namespace Test { -// Constants for magic strings and numbers -constexpr const char* TEST_USER_ID_1 = "user123"; -constexpr const char* TEST_USER_ID_2 = "user456"; -constexpr const char* TEST_USERNAME_1 = "testuser"; -constexpr const char* TEST_USERNAME_2 = "anotheruser"; -constexpr const char* TEST_PASSWORD_HASH_1 = "hashedpassword123"; -constexpr const char* TEST_PASSWORD_HASH_2 = "hashedpassword456"; -constexpr const char* NON_EXISTENT_ID = "nonexistent"; -constexpr const char* NON_EXISTENT_USERNAME = "nonexistentuser"; -constexpr const char* TEST_DIR_NAME = "autostore_test"; -constexpr const char* TEST_DB_FILE_NAME = "test_users.json"; - -// Helper function to create a test user with default values -domain::User -createTestUser(const std::string& id = TEST_USER_ID_1, - const std::string& username = TEST_USERNAME_1, - const std::string& passwordHash = TEST_PASSWORD_HASH_1) -{ - domain::User user; - user.id = id; - user.username = username; - user.passwordHash = passwordHash; - return user; -} - -// Helper function to create a second test user -domain::User createSecondTestUser() -{ - return createTestUser(TEST_USER_ID_2, TEST_USERNAME_2, TEST_PASSWORD_HASH_2); -} - -// Helper function to set up test environment -std::string setupTestEnvironment() -{ - std::filesystem::path testDir = - std::filesystem::temp_directory_path() / TEST_DIR_NAME; - std::filesystem::create_directories(testDir); - std::string testDbPath = (testDir / TEST_DB_FILE_NAME).string(); - - // Clean up any existing test file - if (std::filesystem::exists(testDbPath)) { - std::filesystem::remove(testDbPath); - } - - return testDbPath; -} - -// Helper function to clean up test environment -void cleanupTestEnvironment() -{ - std::filesystem::path testDir = - std::filesystem::temp_directory_path() / TEST_DIR_NAME; - if (std::filesystem::exists(testDir)) { - std::filesystem::remove_all(testDir); - } -} - -// Helper function to verify user properties match expected values -void verifyUserProperties(const domain::User& user, - const std::string& expectedId, - const std::string& expectedUsername, - const std::string& expectedPasswordHash) -{ - REQUIRE(user.id == expectedId); - REQUIRE(user.username == expectedUsername); - REQUIRE(user.passwordHash == expectedPasswordHash); -} - -// Helper function to verify user properties match default test user values -void verifyDefaultTestUser(const domain::User& user) -{ - verifyUserProperties(user, TEST_USER_ID_1, TEST_USERNAME_1, - TEST_PASSWORD_HASH_1); -} - -// Helper function to verify user properties match second test user values -void verifySecondTestUser(const domain::User& user) -{ - verifyUserProperties(user, TEST_USER_ID_2, TEST_USERNAME_2, - TEST_PASSWORD_HASH_2); -} -} // namespace Test - -TEST_CASE("FileUserRepository Integration Tests", - "[integration][FileUserRepository]") -{ - // Setup test environment - std::string testDbPath = Test::setupTestEnvironment(); - - SECTION("when a new user is saved then it can be found by id") - { - // Given - infrastructure::FileUserRepository repository(testDbPath); - domain::User testUser = Test::createTestUser(); - - // When - repository.save(testUser); - - // Then - auto foundUser = repository.findById(Test::TEST_USER_ID_1); - REQUIRE(foundUser.has_value()); - Test::verifyDefaultTestUser(*foundUser); - } - - SECTION("when a new user is saved then it can be found by username") - { - // Given - infrastructure::FileUserRepository repository(testDbPath); - domain::User testUser = Test::createTestUser(); - - // When - repository.save(testUser); - - // Then - auto foundUser = repository.findByUsername(Test::TEST_USERNAME_1); - REQUIRE(foundUser.has_value()); - Test::verifyDefaultTestUser(*foundUser); - } - - SECTION("when multiple users are saved then findAll returns all users") - { - // Given - infrastructure::FileUserRepository repository(testDbPath); - domain::User firstUser = Test::createTestUser(); - domain::User secondUser = Test::createSecondTestUser(); - - // When - repository.save(firstUser); - repository.save(secondUser); - - // Then - auto allUsers = repository.findAll(); - REQUIRE(allUsers.size() == 2); - - // Verify both users are present (order doesn't matter) - bool foundFirst = false; - bool foundSecond = false; - - for (const auto& user : allUsers) { - if (user.id == Test::TEST_USER_ID_1) { - Test::verifyDefaultTestUser(user); - foundFirst = true; - } else if (user.id == Test::TEST_USER_ID_2) { - Test::verifySecondTestUser(user); - foundSecond = true; - } - } - - REQUIRE(foundFirst); - REQUIRE(foundSecond); - } - - SECTION("when an existing user is saved then it is updated") - { - // Given - infrastructure::FileUserRepository repository(testDbPath); - domain::User testUser = Test::createTestUser(); - repository.save(testUser); - - // When - testUser.username = "updatedusername"; - testUser.passwordHash = "updatedpasswordhash"; - repository.save(testUser); - - // Then - auto foundUser = repository.findById(Test::TEST_USER_ID_1); - REQUIRE(foundUser.has_value()); - REQUIRE(foundUser->id == Test::TEST_USER_ID_1); - REQUIRE(foundUser->username == "updatedusername"); - REQUIRE(foundUser->passwordHash == "updatedpasswordhash"); - } - - SECTION("when a user is removed then it cannot be found by id") - { - // Given - infrastructure::FileUserRepository repository(testDbPath); - domain::User testUser = Test::createTestUser(); - repository.save(testUser); - - // When - repository.remove(Test::TEST_USER_ID_1); - - // Then - auto foundUser = repository.findById(Test::TEST_USER_ID_1); - REQUIRE_FALSE(foundUser.has_value()); - } - - SECTION("when a user is removed then it cannot be found by username") - { - // Given - infrastructure::FileUserRepository repository(testDbPath); - domain::User testUser = Test::createTestUser(); - repository.save(testUser); - - // When - repository.remove(Test::TEST_USER_ID_1); - - // Then - auto foundUser = repository.findByUsername(Test::TEST_USERNAME_1); - REQUIRE_FALSE(foundUser.has_value()); - } - - SECTION("when a user is removed then it is not in findAll") - { - // Given - infrastructure::FileUserRepository repository(testDbPath); - domain::User firstUser = Test::createTestUser(); - domain::User secondUser = Test::createSecondTestUser(); - repository.save(firstUser); - repository.save(secondUser); - - // When - repository.remove(Test::TEST_USER_ID_1); - - // Then - auto allUsers = repository.findAll(); - REQUIRE(allUsers.size() == 1); - Test::verifySecondTestUser(allUsers[0]); - } - - SECTION( - "when findById is called with non-existent id then it returns nullopt") - { - // Given - infrastructure::FileUserRepository repository(testDbPath); - - // When - auto foundUser = repository.findById(Test::NON_EXISTENT_ID); - - // Then - REQUIRE_FALSE(foundUser.has_value()); - } - - SECTION("when findByUsername is called with non-existent username then it " - "returns nullopt") - { - // Given - infrastructure::FileUserRepository repository(testDbPath); - - // When - auto foundUser = repository.findByUsername(Test::NON_EXISTENT_USERNAME); - - // Then - REQUIRE_FALSE(foundUser.has_value()); - } - - SECTION("when remove is called with non-existent id then it does nothing") - { - // Given - infrastructure::FileUserRepository repository(testDbPath); - domain::User testUser = Test::createTestUser(); - repository.save(testUser); - - // When - repository.remove(Test::NON_EXISTENT_ID); - - // Then - auto allUsers = repository.findAll(); - REQUIRE(allUsers.size() == 1); - Test::verifyDefaultTestUser(allUsers[0]); - } - - SECTION( - "when repository is created with existing data file then it loads the data") - { - // Given - { - infrastructure::FileUserRepository firstRepository(testDbPath); - domain::User testUser = Test::createTestUser(); - firstRepository.save(testUser); - } - - // When - infrastructure::FileUserRepository secondRepository(testDbPath); - - // Then - auto foundUser = secondRepository.findById(Test::TEST_USER_ID_1); - REQUIRE(foundUser.has_value()); - Test::verifyDefaultTestUser(*foundUser); - } - - SECTION("when repository is created with non-existent data file then it " - "starts empty") - { - // Given - std::filesystem::path testDir = - std::filesystem::temp_directory_path() / Test::TEST_DIR_NAME; - std::string nonExistentDbPath = (testDir / "nonexistent.json").string(); - - // When - infrastructure::FileUserRepository repository(nonExistentDbPath); - - // Then - auto allUsers = repository.findAll(); - REQUIRE(allUsers.empty()); - } - - // Clean up test environment - Test::cleanupTestEnvironment(); -} \ No newline at end of file diff --git a/cpp17/tests/mocks/MockBlocker.h b/cpp17/tests/mocks/MockBlocker.h new file mode 100644 index 0000000..f36d7d5 --- /dev/null +++ b/cpp17/tests/mocks/MockBlocker.h @@ -0,0 +1,19 @@ +#pragma once + +#include "application/interfaces/IBlocker.h" +#include + +namespace test { + +class MockBlocker : public nxl::autostore::application::IBlocker +{ +public: + MAKE_MOCK0(block, void(), override); + MAKE_MOCK1(blockFor, void(const std::chrono::milliseconds&), override); + MAKE_MOCK1(blockUntil, void(const TimePoint&), override); + MAKE_MOCK0(notify, void(), override); + MAKE_MOCK0(isBlocked, bool(), override); + MAKE_MOCK0(wasNotified, bool(), override); +}; + +} // namespace test \ No newline at end of file diff --git a/cpp17/tests/mocks/MockItemRepository.h b/cpp17/tests/mocks/MockItemRepository.h new file mode 100644 index 0000000..3e8d6cd --- /dev/null +++ b/cpp17/tests/mocks/MockItemRepository.h @@ -0,0 +1,22 @@ +#pragma once + +#include "application/interfaces/IItemRepository.h" +#include + +namespace test { + +using nxl::autostore::domain::Item; +using nxl::autostore::domain::User; + +class MockItemRepository : public nxl::autostore::application::IItemRepository +{ +public: + MAKE_MOCK1(save, Item::Id_t(const Item&), override); + MAKE_MOCK1(findById, std::optional(Item::Id_t), override); + MAKE_MOCK1(findByOwner, std::vector(User::Id_t), override); + MAKE_MOCK1(findWhere, std::vector(std::function), + override); + MAKE_MOCK1(remove, void(Item::Id_t), override); +}; + +} // namespace test \ No newline at end of file diff --git a/cpp17/tests/mocks/MockOrderService.h b/cpp17/tests/mocks/MockOrderService.h new file mode 100644 index 0000000..2ebe9b7 --- /dev/null +++ b/cpp17/tests/mocks/MockOrderService.h @@ -0,0 +1,14 @@ +#pragma once + +#include "application/interfaces/IOrderService.h" +#include + +namespace test { + +class MockOrderService : public nxl::autostore::application::IOrderService +{ +public: + MAKE_MOCK1(orderItem, void(const nxl::autostore::domain::Item&), override); +}; + +} // namespace test \ No newline at end of file diff --git a/cpp17/tests/mocks/MockThreadManager.h b/cpp17/tests/mocks/MockThreadManager.h new file mode 100644 index 0000000..71b234d --- /dev/null +++ b/cpp17/tests/mocks/MockThreadManager.h @@ -0,0 +1,24 @@ +#pragma once + +#include "application/interfaces/IThreadManager.h" +#include + +namespace test { + +class MockThreadHandle + : public nxl::autostore::application::IThreadManager::ThreadHandle +{ +public: + MAKE_MOCK0(join, void(), override); + MAKE_CONST_MOCK0(joinable, bool(), override); +}; + +class MockThreadManager : public nxl::autostore::application::IThreadManager +{ +public: + MAKE_MOCK1(createThread, ThreadHandlePtr(std::function), override); + MAKE_CONST_MOCK0(getCurrentThreadId, std::thread::id(), override); + MAKE_MOCK1(sleep, void(const std::chrono::milliseconds&), override); +}; + +} // namespace test \ No newline at end of file diff --git a/cpp17/tests/mocks/MockTimeProvider.h b/cpp17/tests/mocks/MockTimeProvider.h new file mode 100644 index 0000000..7785118 --- /dev/null +++ b/cpp17/tests/mocks/MockTimeProvider.h @@ -0,0 +1,16 @@ +#pragma once + +#include "application/interfaces/ITimeProvider.h" +#include + +namespace test { + +class MockTimeProvider : public nxl::autostore::application::ITimeProvider +{ +public: + MAKE_MOCK0(now, Clock::time_point(), const override); + MAKE_MOCK1(to_tm, std::tm(const Clock::time_point&), const override); + MAKE_MOCK1(from_tm, Clock::time_point(const std::tm&), const override); +}; + +} // namespace test \ No newline at end of file diff --git a/cpp17/tests/mocks/TestLogger.h b/cpp17/tests/mocks/TestLogger.h new file mode 100644 index 0000000..a9cce1c --- /dev/null +++ b/cpp17/tests/mocks/TestLogger.h @@ -0,0 +1,51 @@ +#pragma once +#include +#include +#include + +namespace test { + +class TestLogger : public nxl::autostore::ILogger +{ +public: + TestLogger() = default; + virtual ~TestLogger() = default; + + void log(LogLevel level, std::string_view message) override; + void vlog(int8_t, std::string_view message) override; + +private: + std::mutex mutex_; +}; + +void TestLogger::log(LogLevel level, std::string_view message) +{ + std::lock_guard lock(mutex_); + const char* levelStr = ""; + switch (level) { + case LogLevel::Info: + levelStr = "INFO"; + break; + case LogLevel::Warning: + levelStr = "WARNING"; + break; + case LogLevel::Error: + levelStr = "ERROR"; + break; + case LogLevel::Debug: + levelStr = "DEBUG"; + break; + case LogLevel::Verbose: + levelStr = "VERBOSE"; + break; + } + std::cout << "[" << levelStr << "] " << message << std::endl; +} + +void TestLogger::vlog(int8_t level, std::string_view message) +{ + std::lock_guard lock(mutex_); + std::cout << "[V" << static_cast(level) << "] " << message << std::endl; +} + +} // namespace test \ No newline at end of file diff --git a/cpp17/tests/unit/AddItem.test.cpp b/cpp17/tests/unit/AddItem.test.cpp new file mode 100644 index 0000000..de9f098 --- /dev/null +++ b/cpp17/tests/unit/AddItem.test.cpp @@ -0,0 +1,223 @@ +#include "application/commands/AddItem.h" +#include "domain/entities/Item.h" +#include "mocks/MockItemRepository.h" +#include "mocks/MockTimeProvider.h" +#include "mocks/MockOrderService.h" +#include "helpers/AddItemTestHelpers.h" +#include +#include +#include +#include +#include + +using trompeloeil::_; + +using namespace nxl::autostore; +using namespace std::chrono; + +TEST_CASE("AddItem Unit Tests", "[unit][AddItem]") +{ + test::MockItemRepository mockRepository; + test::MockTimeProvider mockClock; + test::MockOrderService mockOrderService; + + SECTION( + "when user id is present and item is not expired then the item is saved") + { + // Given + auto testItem = test::createTestItem(); + auto expectedItemId = "saved_item_id"; + + REQUIRE_CALL(mockRepository, save(testItem)).RETURN(expectedItemId); + REQUIRE_CALL(mockClock, now()).RETURN(test::TEST_TIMEPOINT_NOW); + FORBID_CALL(mockOrderService, orderItem(_)); + + application::AddItem addItem(mockRepository, mockClock, mockOrderService); + + // When + auto resultItemId = addItem.execute(std::move(testItem)); + + // Then + REQUIRE(resultItemId == expectedItemId); + } + + SECTION("when item has null user id then a runtime error is thrown") + { + // Given + auto testItem = test::createTestItem(); + testItem.userId = domain::User::NULL_ID; + + FORBID_CALL(mockRepository, save(_)); + FORBID_CALL(mockClock, now()); + FORBID_CALL(mockOrderService, orderItem(_)); + + application::AddItem addItem(mockRepository, mockClock, mockOrderService); + + // When & Then + REQUIRE_THROWS_AS(addItem.execute(std::move(testItem)), std::runtime_error); + } + + SECTION("when item is expired then the order is placed") + { + // Given + auto testItem = test::createExpiredTestItem(); + + REQUIRE_CALL(mockClock, now()).RETURN(test::TEST_TIMEPOINT_NOW); + REQUIRE_CALL(mockOrderService, orderItem(_)); + FORBID_CALL(mockRepository, save(_)); + + application::AddItem addItem(mockRepository, mockClock, mockOrderService); + + // When + addItem.execute(std::move(testItem)); + + // Then + // Order was placed (verified by REQUIRE_CALL above) + } + + SECTION("when item is expired then null id is returned") + { + // Given + auto testItem = test::createExpiredTestItem(); + + REQUIRE_CALL(mockClock, now()).RETURN(test::TEST_TIMEPOINT_NOW); + REQUIRE_CALL(mockOrderService, orderItem(_)); + FORBID_CALL(mockRepository, save(_)); + + application::AddItem addItem(mockRepository, mockClock, mockOrderService); + + // When + auto resultItemId = addItem.execute(std::move(testItem)); + + // Then + REQUIRE(resultItemId == domain::Item::NULL_ID); + } + + SECTION("when item expiration date is exactly current time then the order is " + "placed") + { + // Given + auto testItem = test::createTestItem(); + testItem.expirationDate = test::TEST_TIMEPOINT_NOW; + + REQUIRE_CALL(mockClock, now()).RETURN(test::TEST_TIMEPOINT_NOW); + REQUIRE_CALL(mockOrderService, orderItem(_)); + FORBID_CALL(mockRepository, save(_)); + + application::AddItem addItem(mockRepository, mockClock, mockOrderService); + + // When + addItem.execute(std::move(testItem)); + + // Then + // Order was placed (verified by REQUIRE_CALL above) + } + + SECTION("when item expiration date is exactly current time then null id is " + "returned") + { + // Given + auto testItem = test::createTestItem(); + testItem.expirationDate = test::TEST_TIMEPOINT_NOW; + + REQUIRE_CALL(mockClock, now()).RETURN(test::TEST_TIMEPOINT_NOW); + REQUIRE_CALL(mockOrderService, orderItem(_)); + FORBID_CALL(mockRepository, save(_)); + + application::AddItem addItem(mockRepository, mockClock, mockOrderService); + + // When + auto resultItemId = addItem.execute(std::move(testItem)); + + // Then + REQUIRE(resultItemId == domain::Item::NULL_ID); + } + + SECTION("when item expiration date is in the future then the item is saved") + { + // Given + auto testItem = test::createTestItem(); + auto expectedItemId = "saved_item_id"; + + REQUIRE_CALL(mockClock, now()).RETURN(test::TEST_TIMEPOINT_NOW); + REQUIRE_CALL(mockRepository, save(testItem)).RETURN(expectedItemId); + FORBID_CALL(mockOrderService, orderItem(_)); + + application::AddItem addItem(mockRepository, mockClock, mockOrderService); + + // When + addItem.execute(std::move(testItem)); + + // Then + // Item was saved (verified by REQUIRE_CALL above) + } + + SECTION( + "when item expiration date is in the future then the item id is returned") + { + // Given + auto testItem = test::createTestItem(); + auto expectedItemId = "saved_item_id"; + + REQUIRE_CALL(mockClock, now()).RETURN(test::TEST_TIMEPOINT_NOW); + REQUIRE_CALL(mockRepository, save(testItem)).RETURN(expectedItemId); + FORBID_CALL(mockOrderService, orderItem(_)); + + application::AddItem addItem(mockRepository, mockClock, mockOrderService); + + // When + auto resultItemId = addItem.execute(std::move(testItem)); + + // Then + REQUIRE(resultItemId == expectedItemId); + } + + SECTION( + "when repository save throws exception then a runtime error is thrown") + { + // Given + auto testItem = test::createTestItem(); + auto expectedException = std::runtime_error("Repository error"); + + REQUIRE_CALL(mockClock, now()).RETURN(test::TEST_TIMEPOINT_NOW); + REQUIRE_CALL(mockRepository, save(testItem)).THROW(expectedException); + FORBID_CALL(mockOrderService, orderItem(_)); + + application::AddItem addItem(mockRepository, mockClock, mockOrderService); + + // When & Then + REQUIRE_THROWS_AS(addItem.execute(std::move(testItem)), std::runtime_error); + } + + SECTION("when order service throws exception then a runtime error is thrown") + { + // Given + auto testItem = test::createExpiredTestItem(); + auto expectedException = std::runtime_error("Order service error"); + + REQUIRE_CALL(mockClock, now()).RETURN(test::TEST_TIMEPOINT_NOW); + REQUIRE_CALL(mockOrderService, orderItem(_)).THROW(expectedException); + FORBID_CALL(mockRepository, save(_)); + + application::AddItem addItem(mockRepository, mockClock, mockOrderService); + + // When & Then + REQUIRE_THROWS_AS(addItem.execute(std::move(testItem)), std::runtime_error); + } + + SECTION("when clock throws exception then a runtime error is thrown") + { + // Given + auto testItem = test::createTestItem(); + auto expectedException = std::runtime_error("Clock error"); + + REQUIRE_CALL(mockClock, now()).THROW(expectedException); + FORBID_CALL(mockRepository, save(_)); + FORBID_CALL(mockOrderService, orderItem(_)); + + application::AddItem addItem(mockRepository, mockClock, mockOrderService); + + // When & Then + REQUIRE_THROWS_AS(addItem.execute(std::move(testItem)), std::runtime_error); + } +} \ No newline at end of file diff --git a/cpp17/tests/unit/TaskScheduler.test.cpp b/cpp17/tests/unit/TaskScheduler.test.cpp new file mode 100644 index 0000000..55149a2 --- /dev/null +++ b/cpp17/tests/unit/TaskScheduler.test.cpp @@ -0,0 +1,326 @@ +#include "infrastructure/services/TaskScheduler.h" +#include "mocks/TestLogger.h" +#include "mocks/MockTimeProvider.h" +#include "mocks/MockThreadManager.h" +#include "mocks/MockBlocker.h" +#include +#include +#include +#include + +using trompeloeil::_; + +using namespace nxl::autostore; +using namespace std::chrono; +using nxl::autostore::infrastructure::TaskScheduler; + +namespace test { + +// Fixed test timepoint: 2020-01-01 12:00 +constexpr std::chrono::system_clock::time_point TIMEPOINT_NOW = + std::chrono::system_clock::time_point(std::chrono::seconds(1577880000)); + +} // namespace test + +TEST_CASE("TaskScheduler Unit Tests", "[unit][TaskScheduler]") +{ + // Common mock objects that all sections can use + auto logger = std::make_shared(); + auto timeProvider = std::make_shared(); + auto threadMgr = std::make_shared(); + auto blocker = std::make_shared(); + + SECTION("when start is called then createThread is called") + { + // Given + // Expect createThread to be called + REQUIRE_CALL(*threadMgr, createThread(_)) + .RETURN(std::make_unique()); + + TaskScheduler scheduler(logger, timeProvider, threadMgr, blocker); + + // When + scheduler.start(); + } + + SECTION("when scheduler is created then it is not running") + { + // When + TaskScheduler scheduler(logger, timeProvider, threadMgr, blocker); + + // Then - calling stop on a non-running scheduler should not cause issues + // and no thread operations should be called + FORBID_CALL(*threadMgr, createThread(_)); + scheduler.stop(); + } + + SECTION("when task is scheduled with OnStart mode then it executes " + "immediately after start") + { + // Given + bool taskExecuted = false; + std::function threadFn; + + // Expect createThread to be called, save thread function + REQUIRE_CALL(*threadMgr, createThread(_)) + .RETURN(std::make_unique()) + .LR_SIDE_EFFECT(threadFn = std::move(_1)); + + ALLOW_CALL(*timeProvider, now()).LR_RETURN(test::TIMEPOINT_NOW); + FORBID_CALL(*blocker, blockFor(_)); + + TaskScheduler scheduler(logger, timeProvider, threadMgr, blocker); + + auto taskFunction = [&]() { + taskExecuted = true; + scheduler.stop(); // prevent infinite loop in threadFn + }; + + // When + scheduler.schedule(taskFunction, 0, 0, 0, TaskScheduler::RunMode::OnStart); + scheduler.start(); + threadFn(); + + // Then + REQUIRE(taskExecuted); + scheduler.stop(); + } + + SECTION( + "when task is scheduled with Once mode then it executes at specified time") + { + // Given + auto threadHandle = std::make_unique(); + bool taskExecuted = false; + std::function threadFn; + auto currentTime = test::TIMEPOINT_NOW; // current "now", starts at 12:00 + std::chrono::seconds timeDelta{5}; + std::chrono::milliseconds actualDelay{0}; + + auto initialTime = test::TIMEPOINT_NOW; + auto expectedExecutionTime = initialTime + timeDelta; + + // Set up thread handle expectations before moving it + ALLOW_CALL(*threadHandle, join()); + ALLOW_CALL(*threadHandle, joinable()).RETURN(true); + + // Expect createThread to be called, save thread function + REQUIRE_CALL(*threadMgr, createThread(_)) + .LR_RETURN(std::move(threadHandle)) + .LR_SIDE_EFFECT(threadFn = std::move(_1)); + + // Mock time provider calls - return initial time first, then execution time + ALLOW_CALL(*timeProvider, now()).LR_RETURN(currentTime); + + // Allow blocker calls, save delay value + ALLOW_CALL(*blocker, blockFor(_)) + .LR_SIDE_EFFECT(actualDelay += _1; currentTime += _1 // let the time flow + ); + ALLOW_CALL(*blocker, notify()); + + TaskScheduler scheduler(logger, timeProvider, threadMgr, blocker); + + auto taskFunction = [&]() { + taskExecuted = true; + scheduler.stop(); // prevent infinite loop in threadFn + }; + + // When + scheduler.schedule(taskFunction, 12, 0, timeDelta.count(), + TaskScheduler::RunMode::Once); + scheduler.start(); + + // Execute the thread function to simulate the scheduler thread + threadFn(); + + // Then + REQUIRE(taskExecuted); + REQUIRE(actualDelay == timeDelta); + } + + SECTION("when task is scheduled with Forever and OnStart mode then it " + "executes repeatedly") + { + // Given + auto threadHandle = std::make_unique(); + std::function threadFn; + int executionCount = 0; + auto currentTime = test::TIMEPOINT_NOW; + + // Set up thread handle expectations before moving it + ALLOW_CALL(*threadHandle, join()); + ALLOW_CALL(*threadHandle, joinable()).RETURN(true); + + // Expect createThread to be called, save thread function + REQUIRE_CALL(*threadMgr, createThread(_)) + .LR_RETURN(std::move(threadHandle)) + .LR_SIDE_EFFECT(threadFn = std::move(_1)); + + // Mock time provider calls + ALLOW_CALL(*timeProvider, now()).LR_RETURN(currentTime); + + // Allow blocker calls and simulate time passage + ALLOW_CALL(*blocker, blockFor(_)).LR_SIDE_EFFECT(currentTime += _1); + ALLOW_CALL(*blocker, notify()); + + TaskScheduler scheduler(logger, timeProvider, threadMgr, blocker); + + auto taskFunction = [&]() { + executionCount++; + if (executionCount >= 3) { + scheduler.stop(); // stop after 3 executions + } + }; + + // When + scheduler.schedule(taskFunction, 0, 0, 0, + TaskScheduler::RunMode::Forever + | TaskScheduler::RunMode::OnStart); + scheduler.start(); + + // Execute the thread function to simulate the scheduler thread + threadFn(); + + // Then + REQUIRE(executionCount >= 3); + } + + SECTION("when invalid time parameters are provided then exception is thrown") + { + // Given + TaskScheduler scheduler(logger, timeProvider, threadMgr, blocker); + + // When & Then - invalid hour + REQUIRE_THROWS_AS( + scheduler.schedule([]() {}, -1, 0, 0, TaskScheduler::RunMode::Once), + std::invalid_argument); + REQUIRE_THROWS_AS( + scheduler.schedule([]() {}, 24, 0, 0, TaskScheduler::RunMode::Once), + std::invalid_argument); + + // When & Then - invalid minute + REQUIRE_THROWS_AS( + scheduler.schedule([]() {}, 0, -1, 0, TaskScheduler::RunMode::Once), + std::invalid_argument); + REQUIRE_THROWS_AS( + scheduler.schedule([]() {}, 0, 60, 0, TaskScheduler::RunMode::Once), + std::invalid_argument); + + // When & Then - invalid second + REQUIRE_THROWS_AS( + scheduler.schedule([]() {}, 0, 0, -1, TaskScheduler::RunMode::Once), + std::invalid_argument); + REQUIRE_THROWS_AS( + scheduler.schedule([]() {}, 0, 0, 61, TaskScheduler::RunMode::Once), + std::invalid_argument); + } + // std::invalid_argument); + // } + + SECTION("when invalid mode combination is used then exception is thrown") + { + // Given + TaskScheduler scheduler(logger, timeProvider, threadMgr, blocker); + + // When & Then + REQUIRE_THROWS_AS(scheduler.schedule([]() {}, 0, 0, 0, + TaskScheduler::RunMode::Forever + | TaskScheduler::RunMode::Once), + std::invalid_argument); + } + SECTION("when multiple tasks are scheduled then all execute") + { + // Given + auto threadHandle = std::make_unique(); + std::function threadFn; + bool task1Executed = false; + bool task2Executed = false; + + // Set up thread handle expectations before moving it + ALLOW_CALL(*threadHandle, join()); + ALLOW_CALL(*threadHandle, joinable()).RETURN(true); + + // Expect createThread to be called, save thread function + REQUIRE_CALL(*threadMgr, createThread(_)) + .LR_RETURN(std::move(threadHandle)) + .LR_SIDE_EFFECT(threadFn = std::move(_1)); + + // Mock time provider calls + ALLOW_CALL(*timeProvider, now()).LR_RETURN(test::TIMEPOINT_NOW); + + // Allow blocker calls + ALLOW_CALL(*blocker, blockFor(_)); + ALLOW_CALL(*blocker, notify()); + + TaskScheduler scheduler(logger, timeProvider, threadMgr, blocker); + + auto taskFunction1 = [&]() { task1Executed = true; }; + + auto taskFunction2 = [&]() { + task2Executed = true; + scheduler.stop(); // stop after both tasks have had a chance to execute + }; + + // When + scheduler.schedule(taskFunction1, 0, 0, 0, TaskScheduler::RunMode::OnStart); + scheduler.schedule(taskFunction2, 0, 0, 0, TaskScheduler::RunMode::OnStart); + scheduler.start(); + + // Execute the thread function to simulate the scheduler thread + threadFn(); + + // Then + REQUIRE(task1Executed); + REQUIRE(task2Executed); + } + // } + + SECTION("when task is scheduled with Forever mode then it repeats") + { + // Given + auto threadHandle = std::make_unique(); + std::function threadFn; + int executionCount = 0; + auto currentTime = test::TIMEPOINT_NOW; + + // Set up thread handle expectations before moving it + ALLOW_CALL(*threadHandle, join()); + ALLOW_CALL(*threadHandle, joinable()).RETURN(true); + + // Expect createThread to be called, save thread function + REQUIRE_CALL(*threadMgr, createThread(_)) + .LR_RETURN(std::move(threadHandle)) + .LR_SIDE_EFFECT(threadFn = std::move(_1)); + + // Mock time provider calls - simulate time advancing + ALLOW_CALL(*timeProvider, now()).LR_RETURN(currentTime); + + // Allow blocker calls and simulate time passage + ALLOW_CALL(*blocker, blockFor(_)).LR_SIDE_EFFECT(currentTime += _1); + ALLOW_CALL(*blocker, notify()); + + TaskScheduler scheduler(logger, timeProvider, threadMgr, blocker); + + auto taskFunction = [&]() { + executionCount++; + if (executionCount >= 2) { + scheduler.stop(); // stop after 2 executions + } + }; + + // Schedule task to run at a specific time (not immediately) and repeat + // forever This ensures the task doesn't get stuck in an infinite OnStart + // loop + scheduler.schedule(taskFunction, 12, 0, 1, TaskScheduler::RunMode::Forever); + + // When + scheduler.start(); + + // Execute the thread function to simulate the scheduler thread + threadFn(); + + // Then + REQUIRE(executionCount >= 2); + } + // } +} diff --git a/cpp17/vcpkg.json b/cpp17/vcpkg.json index 97781ea..5c81417 100644 --- a/cpp17/vcpkg.json +++ b/cpp17/vcpkg.json @@ -4,7 +4,9 @@ "dependencies": [ "cpp-httplib", "nlohmann-json", + "jwt-cpp", "spdlog", - "catch2" + "catch2", + "trompeloeil" ] } diff --git a/openapi.yaml b/openapi.yaml index a76e96a..a93f4a1 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -7,56 +7,6 @@ servers: - url: http://localhost:3000/api/v1 description: Development server paths: - /register: - post: - summary: Register a new user - description: Creates a new user account and returns a JWT token - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - username - - password - properties: - username: - type: string - description: User's username or email - password: - type: string - description: User's password - responses: - '201': - description: User successfully registered - content: - application/json: - schema: - allOf: - - $ref: '#/components/schemas/JsendSuccess' - - type: object - properties: - data: - type: object - properties: - user: - $ref: '#/components/schemas/User' - token: - type: string - description: JWT token for authentication - '400': - description: Invalid input - content: - application/json: - schema: - $ref: '#/components/schemas/JsendError' - '409': - description: Username already exists - content: - application/json: - schema: - $ref: '#/components/schemas/JsendError' /login: post: @@ -103,193 +53,6 @@ paths: schema: $ref: '#/components/schemas/JsendError' - /users: - get: - summary: Get list of users - description: Returns a list of all users (requires authentication) - security: - - bearerAuth: [] - responses: - '200': - description: List of users - content: - application/json: - schema: - allOf: - - $ref: '#/components/schemas/JsendSuccess' - - type: object - properties: - data: - type: array - items: - $ref: '#/components/schemas/User' - '401': - description: Unauthorized - content: - application/json: - schema: - $ref: '#/components/schemas/JsendError' - - /users/{id}: - get: - summary: Get user by ID - description: Returns a specific user by their ID (requires authentication) - security: - - bearerAuth: [] - parameters: - - name: id - in: path - required: true - description: User ID - schema: - type: string - responses: - '200': - description: User details - content: - application/json: - schema: - allOf: - - $ref: '#/components/schemas/JsendSuccess' - - type: object - properties: - data: - $ref: '#/components/schemas/User' - '401': - description: Unauthorized - content: - application/json: - schema: - $ref: '#/components/schemas/JsendError' - '404': - description: User not found - content: - application/json: - schema: - $ref: '#/components/schemas/JsendError' - post: - summary: Create a new user - description: Creates a new user (admin functionality, requires authentication) - security: - - bearerAuth: [] - parameters: - - name: id - in: path - required: true - description: User ID - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UserInput' - responses: - '201': - description: User created successfully - content: - application/json: - schema: - allOf: - - $ref: '#/components/schemas/JsendSuccess' - - type: object - properties: - data: - $ref: '#/components/schemas/User' - '400': - description: Invalid input - content: - application/json: - schema: - $ref: '#/components/schemas/JsendError' - '401': - description: Unauthorized - content: - application/json: - schema: - $ref: '#/components/schemas/JsendError' - '409': - description: User already exists - content: - application/json: - schema: - $ref: '#/components/schemas/JsendError' - put: - summary: Update a user - description: Updates an existing user (requires authentication) - security: - - bearerAuth: [] - parameters: - - name: id - in: path - required: true - description: User ID - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UserInput' - responses: - '200': - description: User updated successfully - content: - application/json: - schema: - allOf: - - $ref: '#/components/schemas/JsendSuccess' - - type: object - properties: - data: - $ref: '#/components/schemas/User' - '400': - description: Invalid input - content: - application/json: - schema: - $ref: '#/components/schemas/JsendError' - '401': - description: Unauthorized - content: - application/json: - schema: - $ref: '#/components/schemas/JsendError' - '404': - description: User not found - content: - application/json: - schema: - $ref: '#/components/schemas/JsendError' - delete: - summary: Delete a user - description: Deletes an existing user (requires authentication) - security: - - bearerAuth: [] - parameters: - - name: id - in: path - required: true - description: User ID - schema: - type: string - responses: - '204': - description: User deleted successfully - '401': - description: Unauthorized - content: - application/json: - schema: - $ref: '#/components/schemas/JsendError' - '404': - description: User not found - content: - application/json: - schema: - $ref: '#/components/schemas/JsendError' /items: get: @@ -510,19 +273,6 @@ components: type: string description: User's username or email - UserInput: - type: object - required: - - username - - password - properties: - username: - type: string - description: User's username or email - password: - type: string - description: User's password - Item: type: object properties: diff --git a/testing/http-echo-server/docker-compose.yml b/testing/http-echo-server/docker-compose.yml new file mode 100644 index 0000000..3c18857 --- /dev/null +++ b/testing/http-echo-server/docker-compose.yml @@ -0,0 +1,8 @@ +version: '3' +services: + http-echo-listener: + image: mendhak/http-https-echo:37 + environment: + - HTTP_PORT=8888 + ports: + - "8888:8888" \ No newline at end of file diff --git a/testing/tavern/export.sh b/testing/tavern/export.sh new file mode 100644 index 0000000..30d3ed5 --- /dev/null +++ b/testing/tavern/export.sh @@ -0,0 +1,22 @@ + +if [ -d .venv ]; then + source .venv/bin/activate +else + python -m venv .venv + source .venv/bin/activate + pip install -r requirements.txt +fi + +export TEST_SERVER_ADDRESS="127.0.0.1" +export TEST_SERVER_PORT="8080" +export TEST_API_BASE="api/v1" +export TEST_ORDER_URL="http://192.168.20.2:8888/" + +export TEST_USER1_ID="1000" +export TEST_USER1_LOGIN="admin" +export TEST_USER1_PASSWORD="admin" +export TEST_USER2_ID="1001" +export TEST_USER2_LOGIN="user" +export TEST_USER2_PASSWORD="user" +export TEST_WRONG_USER_ID="999" +export TEST_ITEM_ID="secret" diff --git a/testing/tavern/requirements.txt b/testing/tavern/requirements.txt new file mode 100644 index 0000000..2e6d331 --- /dev/null +++ b/testing/tavern/requirements.txt @@ -0,0 +1,5 @@ +pyyaml +tavern +tavern[mqtt] +tavern[grpc] +allure-pytest diff --git a/testing/tavern/tavern-run-all.sh b/testing/tavern/tavern-run-all.sh new file mode 100755 index 0000000..fb61f60 --- /dev/null +++ b/testing/tavern/tavern-run-all.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +if [ -z "$TEST_SERVER_ADDRESS" ]; then + source export.sh +fi + +# tavern-ci --alluredir=reports test_plans/users_api.tavern.yaml + +tavern-ci --alluredir=reports test_plans/items_api.tavern.yaml + +allure generate --clean --single-file --output /tmp/vm-allure-report --name index.html reports + +# allure package: https://github.com/allure-framework/allure2/releases/download/2.34.0/allure_2.34.0-1_all.deb diff --git a/testing/tavern/tavern-run-single.sh b/testing/tavern/tavern-run-single.sh new file mode 100755 index 0000000..51c1a00 --- /dev/null +++ b/testing/tavern/tavern-run-single.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +if [ -z "$1" ]; then + echo "Usage: $0 " + exit 1 +fi + +if [ -z "$TEST_SERVER_ADDRESS" ]; then + source export.sh +fi + +tavern-ci --alluredir=reports $1 + +allure generate --clean --single-file --output /tmp/vm-allure-report --name index.html reports + +# allure package: https://github.com/allure-framework/allure2/releases/download/2.34.0/allure_2.34.0-1_all.deb diff --git a/testing/tavern/test_plans/includes.yaml b/testing/tavern/test_plans/includes.yaml new file mode 100644 index 0000000..e74010f --- /dev/null +++ b/testing/tavern/test_plans/includes.yaml @@ -0,0 +1,14 @@ +variables: + server_address: "{tavern.env_vars.TEST_SERVER_ADDRESS}" + server_port: "{tavern.env_vars.TEST_SERVER_PORT}" + api_base: "{tavern.env_vars.TEST_API_BASE}" + order_url: "{tavern.env_vars.TEST_ORDER_URL}" + + user1_id: "{tavern.env_vars.TEST_USER1_ID}" + user1_login: "{tavern.env_vars.TEST_USER1_LOGIN}" + user1_password: "{tavern.env_vars.TEST_USER1_PASSWORD}" + user2_id: "{tavern.env_vars.TEST_USER2_ID}" + user2_login: "{tavern.env_vars.TEST_USER2_LOGIN}" + user2_password: "{tavern.env_vars.TEST_USER2_PASSWORD}" + wrong_user_id: "{tavern.env_vars.TEST_WRONG_USER_ID}" + item_id: "{tavern.env_vars.TEST_ITEM_ID}" diff --git a/testing/tavern/test_plans/items_api.tavern.yaml b/testing/tavern/test_plans/items_api.tavern.yaml new file mode 100644 index 0000000..b7b5c2e --- /dev/null +++ b/testing/tavern/test_plans/items_api.tavern.yaml @@ -0,0 +1,200 @@ +test_name: "Items management" + +includes: + - !include includes.yaml + +strict: + - headers:off + - json:off + +stages: + + - name: "Login as user1 and get JWT token" + request: + url: "http://{server_address}:{server_port}/{api_base}/login" + method: POST + json: + username: "{user1_login}" + password: "{user1_password}" + response: + status_code: 200 + json: + status: success + data: + token: !anything + save: + json: + user_token: "data.token" + + - name: "Add non-expired item 1" + request: + url: "http://{server_address}:{server_port}/{api_base}/items" + method: POST + headers: + Authorization: "Bearer {user_token}" + json: + name: "Tavern Test Item" + expirationDate: "2050-08-10T14:00:00" + orderUrl: "{order_url}" + response: + status_code: 201 + json: + status: success + data: + id: !anything + save: + json: + item_id: "data.id" + + - name: "Add non-expired item 2" + request: + url: "http://{server_address}:{server_port}/{api_base}/items" + method: POST + headers: + Authorization: "Bearer {user_token}" + json: + name: "Tavern Test Item" + expirationDate: "2050-08-10T14:00:00" + orderUrl: "{order_url}" + response: + status_code: 201 + json: + status: success + data: + id: !anything + save: + json: + item_id2: "data.id" + + - name: "Add expired item" + request: + url: "http://{server_address}:{server_port}/{api_base}/items" + method: POST + headers: + Authorization: "Bearer {user_token}" + json: + name: "Tavern Test Item" + expirationDate: "2000-08-10T14:00:00" + orderUrl: "{order_url}" + response: + status_code: 201 + json: + status: success + data: + id: !anything + + - name: "Get item list" + request: + url: "http://{server_address}:{server_port}/{api_base}/items" + method: GET + headers: + Authorization: "Bearer {user_token}" + response: + status_code: 200 + json: + status: "success" + data: !anylist + + - name: "Get single existing item" + request: + url: "http://{server_address}:{server_port}/{api_base}/items/{item_id}" + method: GET + headers: + Authorization: "Bearer {user_token}" + response: + status_code: 200 + json: + status: "success" + data: + id: !anything + + - name: "Get single non-existing item" + request: + url: "http://{server_address}:{server_port}/{api_base}/items/9999" + method: GET + headers: + Authorization: "Bearer {user_token}" + response: + status_code: 404 + + - name: "Delete item" + request: + url: "http://{server_address}:{server_port}/{api_base}/items/{item_id}" + method: DELETE + headers: + Authorization: "Bearer {user_token}" + response: + status_code: 204 + + # login as user2 and test item access restrictions + + - name: "Login as user2 and get JWT token" + request: + url: "http://{server_address}:{server_port}/{api_base}/login" + method: POST + json: + username: "{user2_login}" + password: "{user2_password}" + response: + status_code: 200 + json: + status: success + data: + token: !anything + save: + json: + user2_token: "data.token" + + - name: "User2 tries to access item2 created by user1 (should fail)" + request: + url: "http://{server_address}:{server_port}/{api_base}/items/{item_id2}" + method: GET + headers: + Authorization: "Bearer {user2_token}" + response: + status_code: 404 + + - name: "User2 tries to delete item2 created by user1 (should fail)" + request: + url: "http://{server_address}:{server_port}/{api_base}/items/{item_id2}" + method: DELETE + headers: + Authorization: "Bearer {user2_token}" + response: + status_code: 404 + + - name: "User2 adds own item" + request: + url: "http://{server_address}:{server_port}/{api_base}/items" + method: POST + headers: + Authorization: "Bearer {user2_token}" + json: + name: "User2 Tavern Test Item" + expirationDate: "2050-08-10T14:00:00" + orderUrl: "{order_url}" + response: + status_code: 201 + json: + status: success + data: + id: !anything + save: + json: + user2_item_id: "data.id" + + - name: "User2 gets item list (should only see own items)" + request: + url: "http://{server_address}:{server_port}/{api_base}/items" + method: GET + headers: + Authorization: "Bearer {user2_token}" + response: + status_code: 200 + json: + status: "success" + data: + - !anydict + id: "{user2_item_id}" + name: "User2 Tavern Test Item" +