From 74955aa3f98e99c987b2be0ca1d32c0c879e097f Mon Sep 17 00:00:00 2001 From: chodak166 Date: Sun, 3 Aug 2025 13:59:50 +0200 Subject: [PATCH] WIP: cpp17 --- README.md | 101 ++-- cpp17/CMakeLists.txt | 1 + cpp17/README.md | 81 +-- cpp17/TODO.md | 2 +- cpp17/lib/CMakeLists.txt | 2 - cpp17/tests/CMakeLists.txt | 30 + .../integration/FileItemRepository.test.cpp | 365 ++++++++++++ .../integration/FileUserRepository.test.cpp | 312 ++++++++++ openapi.yaml | 564 ++++++++++++++++++ 9 files changed, 1377 insertions(+), 81 deletions(-) create mode 100644 cpp17/tests/CMakeLists.txt create mode 100644 cpp17/tests/integration/FileItemRepository.test.cpp create mode 100644 cpp17/tests/integration/FileUserRepository.test.cpp create mode 100644 openapi.yaml diff --git a/README.md b/README.md index ac53306..18aebdb 100644 --- a/README.md +++ b/README.md @@ -55,45 +55,48 @@ AutoStore/ ├── Extern │ ├── │ └── <...downloaded libraries and git submodules> -└── Src - ├── Domain/ - │ ├── Entities/ - │ │ ├── User - │ │ └── Item - │ └── Services/ - │ └── ExpirationPolicy - ├── Application/ - │ ├── UseCases/ - │ │ ├── RegisterUser - │ │ ├── LoginUser - │ │ ├── AddItem - │ │ ├── GetItem - │ │ ├── DeleteItem - │ │ └── HandleExpiredItems - │ ├── Interfaces/ - │ │ ├── IUserRepository - │ │ ├── IItemRepository - │ │ ├── IAuthService - │ │ └── IClock - │ ├── Dto/ - │ └── Services/ - ├── Infrastructure/ - │ ├── Repositories/ - │ │ ├── FileUserRepository - │ │ └── FileItemRepository - │ ├── Adapters/ - │ │ ├── JwtAuthAdapter - │ │ ├── OrderUrlHttpClient - │ │ ├── SystemClockImpl - │ │ └── <... some extern lib adapters> - │ └── Helpers/ - │ └── <... DRY helpers> - └── WebApi/ - ├── Controllers/ - │ ├── StoreController - │ └── UserController - └── Auth/ - └── JwtMiddleware +├── Src +│ ├── Domain/ +│ │ ├── Entities/ +│ │ │ ├── User +│ │ │ └── Item +│ │ └── Services/ +│ │ └── ExpirationPolicy +│ ├── Application/ +│ │ ├── UseCases/ +│ │ │ ├── RegisterUser +│ │ │ ├── LoginUser +│ │ │ ├── AddItem +│ │ │ ├── GetItem +│ │ │ ├── DeleteItem +│ │ │ └── HandleExpiredItems +│ │ ├── Interfaces/ +│ │ │ ├── IUserRepository +│ │ │ ├── IItemRepository +│ │ │ ├── IAuthService +│ │ │ └── IClock +│ │ ├── Dto/ +│ │ └── Services/ +│ ├── Infrastructure/ +│ │ ├── Repositories/ +│ │ │ ├── FileUserRepository +│ │ │ └── FileItemRepository +│ │ ├── Adapters/ +│ │ │ ├── JwtAuthAdapter +│ │ │ ├── OrderUrlHttpClient +│ │ │ ├── SystemClockImpl +│ │ │ └── <... some extern lib adapters> +│ │ └── Helpers/ +│ │ └── <... DRY helpers> +│ └── WebApi/ +│ ├── Controllers/ +│ │ ├── StoreController +│ │ └── UserController +│ └── Auth/ +│ └── JwtMiddleware +└── Tests + ├── Unit/ + └── Integration/ ``` ## Build and Run @@ -106,3 +109,23 @@ 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 | +|-------------------------|--------|--------------------------------------| +| `/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 | +| `/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 | diff --git a/cpp17/CMakeLists.txt b/cpp17/CMakeLists.txt index 7b8c37c..63a317d 100644 --- a/cpp17/CMakeLists.txt +++ b/cpp17/CMakeLists.txt @@ -11,3 +11,4 @@ set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR}/lib) add_subdirectory(lib) add_subdirectory(app) +add_subdirectory(tests) diff --git a/cpp17/README.md b/cpp17/README.md index ac53306..0e27a23 100644 --- a/cpp17/README.md +++ b/cpp17/README.md @@ -55,45 +55,48 @@ AutoStore/ ├── Extern │ ├── │ └── <...downloaded libraries and git submodules> -└── Src - ├── Domain/ - │ ├── Entities/ - │ │ ├── User - │ │ └── Item - │ └── Services/ - │ └── ExpirationPolicy - ├── Application/ - │ ├── UseCases/ - │ │ ├── RegisterUser - │ │ ├── LoginUser - │ │ ├── AddItem - │ │ ├── GetItem - │ │ ├── DeleteItem - │ │ └── HandleExpiredItems - │ ├── Interfaces/ - │ │ ├── IUserRepository - │ │ ├── IItemRepository - │ │ ├── IAuthService - │ │ └── IClock - │ ├── Dto/ - │ └── Services/ - ├── Infrastructure/ - │ ├── Repositories/ - │ │ ├── FileUserRepository - │ │ └── FileItemRepository - │ ├── Adapters/ - │ │ ├── JwtAuthAdapter - │ │ ├── OrderUrlHttpClient - │ │ ├── SystemClockImpl - │ │ └── <... some extern lib adapters> - │ └── Helpers/ - │ └── <... DRY helpers> - └── WebApi/ - ├── Controllers/ - │ ├── StoreController - │ └── UserController - └── Auth/ - └── JwtMiddleware +├── Src +│ ├── Domain/ +│ │ ├── Entities/ +│ │ │ ├── User +│ │ │ └── Item +│ │ └── Services/ +│ │ └── ExpirationPolicy +│ ├── Application/ +│ │ ├── UseCases/ +│ │ │ ├── RegisterUser +│ │ │ ├── LoginUser +│ │ │ ├── AddItem +│ │ │ ├── GetItem +│ │ │ ├── DeleteItem +│ │ │ └── HandleExpiredItems +│ │ ├── Interfaces/ +│ │ │ ├── IUserRepository +│ │ │ ├── IItemRepository +│ │ │ ├── IAuthService +│ │ │ └── IClock +│ │ ├── Dto/ +│ │ └── Services/ +│ ├── Infrastructure/ +│ │ ├── Repositories/ +│ │ │ ├── FileUserRepository +│ │ │ └── FileItemRepository +│ │ ├── Adapters/ +│ │ │ ├── JwtAuthAdapter +│ │ │ ├── OrderUrlHttpClient +│ │ │ ├── SystemClockImpl +│ │ │ └── <... some extern lib adapters> +│ │ └── Helpers/ +│ │ └── <... DRY helpers> +│ └── WebApi/ +│ ├── Controllers/ +│ │ ├── StoreController +│ │ └── UserController +│ └── Auth/ +│ └── JwtMiddleware +└── Tests + ├── Unit/ + └── Integration/ ``` ## Build and Run diff --git a/cpp17/TODO.md b/cpp17/TODO.md index cb7910a..c0dc4d8 100644 --- a/cpp17/TODO.md +++ b/cpp17/TODO.md @@ -18,7 +18,7 @@ This document outlines the steps to implement the C++17 version of the AutoStore - [x] Create `lib/CMakeLists.txt` to build a static library. - [x] In `lib/include/autostore`, define the public interface for the `App` to use. - [x] Create a dummy `AutoStore` class in `lib/include/autostore/AutoStore.h` and a source file in `lib/src/AutoStore.cpp`. -- [ ] Define initail classes for core domain and application logic inside the library (e.g., `ItemRepository`, `UserService`, etc.) to establish the architecture. These will be mostly private to the library initially and implemented later. +- [ ] Define initial classes for core domain and application logic inside the library (e.g., `ItemRepository`, `UserService`, etc.) to establish the architecture. These will be mostly private to the library initially and implemented later. - [ ] Ensure the project compiles and links successfully with the dummy implementations. ## Phase 3: Application (`app`) - Dummy Implementation diff --git a/cpp17/lib/CMakeLists.txt b/cpp17/lib/CMakeLists.txt index 0e62e19..4f504eb 100644 --- a/cpp17/lib/CMakeLists.txt +++ b/cpp17/lib/CMakeLists.txt @@ -32,12 +32,10 @@ target_sources(${TARGET_NAME} # Find dependencies find_package(httplib CONFIG REQUIRED) -find_package(Catch2 CONFIG REQUIRED) find_package(nlohmann_json CONFIG REQUIRED) target_link_libraries(${TARGET_NAME} PUBLIC httplib::httplib - Catch2::Catch2WithMain nlohmann_json::nlohmann_json ) \ No newline at end of file diff --git a/cpp17/tests/CMakeLists.txt b/cpp17/tests/CMakeLists.txt new file mode 100644 index 0000000..6c96b08 --- /dev/null +++ b/cpp17/tests/CMakeLists.txt @@ -0,0 +1,30 @@ +cmake_minimum_required(VERSION 3.20) + +enable_testing() + +find_package(Catch2 CONFIG REQUIRED) + +# Macro to create a test executable +function(add_integration_test TEST_NAME SOURCE_FILE) + add_executable(${TEST_NAME} + ${SOURCE_FILE} + ) + + target_link_libraries(${TEST_NAME} + PRIVATE + AutoStoreLib + Catch2::Catch2WithMain + ) + + target_include_directories(${TEST_NAME} + PRIVATE + ${PROJECT_SOURCE_DIR}/lib/include + ) + + # Add test to CTest + add_test(NAME ${TEST_NAME} COMMAND ${TEST_NAME}) +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 diff --git a/cpp17/tests/integration/FileItemRepository.test.cpp b/cpp17/tests/integration/FileItemRepository.test.cpp new file mode 100644 index 0000000..fe38b7b --- /dev/null +++ b/cpp17/tests/integration/FileItemRepository.test.cpp @@ -0,0 +1,365 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace nxl::autostore; +using Catch::Matchers::Equals; + +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_ITEM_NAME_2 = "anotheritem"; +constexpr const char* TEST_ORDER_URL_1 = "https://example.com/order1"; +constexpr const char* TEST_ORDER_URL_2 = "https://example.com/order2"; +constexpr const char* TEST_USER_ID_1 = "user123"; +constexpr const char* TEST_USER_ID_2 = "user456"; +constexpr const char* NON_EXISTENT_ID = "nonexistent"; +constexpr const char* NON_EXISTENT_USER_ID = "nonexistentuser"; +constexpr const char* TEST_DIR_NAME = "autostore_test"; +constexpr const char* TEST_DB_FILE_NAME = "test_items.json"; + +// Helper function to create a test item with default values +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)) +{ + domain::Item item; + item.id = id; + item.name = name; + item.orderUrl = orderUrl; + item.userId = userId; + item.expirationDate = expirationDate; + return item; +} + +// Helper function to create a second test item +domain::Item createSecondTestItem() +{ + return createTestItem( + TEST_ITEM_ID_2, TEST_ITEM_NAME_2, TEST_ORDER_URL_2, TEST_USER_ID_2, + std::chrono::system_clock::now() + std::chrono::hours(48)); +} + +// 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 item properties match expected values +void verifyItemProperties(const domain::Item& item, + const std::string& expectedId, + const std::string& expectedName, + const std::string& expectedOrderUrl, + const std::string& expectedUserId) +{ + REQUIRE(item.id == expectedId); + REQUIRE(item.name == expectedName); + REQUIRE(item.orderUrl == expectedOrderUrl); + REQUIRE(item.userId == expectedUserId); +} + +// Helper function to verify item properties match default test item values +void verifyDefaultTestItem(const domain::Item& item) +{ + verifyItemProperties(item, TEST_ITEM_ID_1, TEST_ITEM_NAME_1, TEST_ORDER_URL_1, + TEST_USER_ID_1); +} + +// Helper function to verify item properties match second test item values +void verifySecondTestItem(const domain::Item& item) +{ + verifyItemProperties(item, TEST_ITEM_ID_2, TEST_ITEM_NAME_2, TEST_ORDER_URL_2, + TEST_USER_ID_2); +} +} // namespace Test + +TEST_CASE("FileItemRepository Integration Tests", + "[integration][FileItemRepository]") +{ + // Setup test environment + std::string testDbPath = Test::setupTestEnvironment(); + + SECTION("when a new item is saved then it can be found by id") + { + // Given + infrastructure::FileItemRepository repository(testDbPath); + domain::Item testItem = Test::createTestItem(); + + // When + repository.save(testItem); + + // Then + auto foundItem = repository.findById(Test::TEST_ITEM_ID_1); + REQUIRE(foundItem.has_value()); + Test::verifyDefaultTestItem(*foundItem); + } + + SECTION("when a new item is saved then it can be found by user") + { + // Given + infrastructure::FileItemRepository repository(testDbPath); + domain::Item testItem = Test::createTestItem(); + + // When + repository.save(testItem); + + // Then + auto userItems = repository.findByUser(Test::TEST_USER_ID_1); + REQUIRE(userItems.size() == 1); + Test::verifyDefaultTestItem(userItems[0]); + } + + SECTION("when multiple items are saved then findAll returns all items") + { + // Given + infrastructure::FileItemRepository repository(testDbPath); + domain::Item firstItem = Test::createTestItem(); + domain::Item secondItem = Test::createSecondTestItem(); + + // When + repository.save(firstItem); + repository.save(secondItem); + + // Then + auto allItems = repository.findAll(); + REQUIRE(allItems.size() == 2); + + // Verify both items are present (order doesn't matter) + bool foundFirst = false; + bool foundSecond = false; + + for (const auto& item : allItems) { + if (item.id == Test::TEST_ITEM_ID_1) { + Test::verifyDefaultTestItem(item); + foundFirst = true; + } else if (item.id == Test::TEST_ITEM_ID_2) { + Test::verifySecondTestItem(item); + foundSecond = true; + } + } + + REQUIRE(foundFirst); + REQUIRE(foundSecond); + } + + SECTION("when multiple items for same user are saved then findByUser returns " + "all user items") + { + // Given + infrastructure::FileItemRepository repository(testDbPath); + domain::Item firstItem = Test::createTestItem(); + domain::Item secondItem = + Test::createTestItem("item789", "thirditem", "https://example.com/order3", + Test::TEST_USER_ID_1); + + // When + repository.save(firstItem); + repository.save(secondItem); + + // Then + auto userItems = repository.findByUser(Test::TEST_USER_ID_1); + REQUIRE(userItems.size() == 2); + + // Verify both items are present (order doesn't matter) + bool foundFirst = false; + bool foundSecond = false; + + for (const auto& item : userItems) { + if (item.id == Test::TEST_ITEM_ID_1) { + Test::verifyDefaultTestItem(item); + foundFirst = true; + } else if (item.id == "item789") { + REQUIRE(item.name == "thirditem"); + REQUIRE(item.orderUrl == "https://example.com/order3"); + REQUIRE(item.userId == Test::TEST_USER_ID_1); + foundSecond = true; + } + } + + REQUIRE(foundFirst); + REQUIRE(foundSecond); + } + + SECTION("when an existing item is saved then it is updated") + { + // Given + infrastructure::FileItemRepository repository(testDbPath); + domain::Item testItem = Test::createTestItem(); + repository.save(testItem); + + // When + testItem.name = "updateditemname"; + testItem.orderUrl = "https://updated.example.com/order"; + testItem.userId = Test::TEST_USER_ID_2; + repository.save(testItem); + + // Then + auto foundItem = repository.findById(Test::TEST_ITEM_ID_1); + REQUIRE(foundItem.has_value()); + REQUIRE(foundItem->id == Test::TEST_ITEM_ID_1); + REQUIRE(foundItem->name == "updateditemname"); + REQUIRE(foundItem->orderUrl == "https://updated.example.com/order"); + REQUIRE(foundItem->userId == Test::TEST_USER_ID_2); + } + + SECTION("when an item is removed then it cannot be found by id") + { + // Given + infrastructure::FileItemRepository repository(testDbPath); + domain::Item testItem = Test::createTestItem(); + repository.save(testItem); + + // When + repository.remove(Test::TEST_ITEM_ID_1); + + // Then + auto foundItem = repository.findById(Test::TEST_ITEM_ID_1); + REQUIRE_FALSE(foundItem.has_value()); + } + + SECTION("when an item is removed then it is not in findByUser") + { + // Given + infrastructure::FileItemRepository repository(testDbPath); + domain::Item testItem = Test::createTestItem(); + repository.save(testItem); + + // When + repository.remove(Test::TEST_ITEM_ID_1); + + // Then + auto userItems = repository.findByUser(Test::TEST_USER_ID_1); + REQUIRE(userItems.empty()); + } + + SECTION("when an item is removed then it is not in findAll") + { + // Given + infrastructure::FileItemRepository repository(testDbPath); + domain::Item firstItem = Test::createTestItem(); + domain::Item secondItem = Test::createSecondTestItem(); + repository.save(firstItem); + repository.save(secondItem); + + // When + repository.remove(Test::TEST_ITEM_ID_1); + + // Then + auto allItems = repository.findAll(); + REQUIRE(allItems.size() == 1); + Test::verifySecondTestItem(allItems[0]); + } + + SECTION( + "when findById is called with non-existent id then it returns nullopt") + { + // Given + infrastructure::FileItemRepository repository(testDbPath); + + // When + auto foundItem = repository.findById(Test::NON_EXISTENT_ID); + + // Then + REQUIRE_FALSE(foundItem.has_value()); + } + + SECTION("when findByUser is called with non-existent user id then it returns " + "empty vector") + { + // Given + infrastructure::FileItemRepository repository(testDbPath); + domain::Item testItem = Test::createTestItem(); + repository.save(testItem); + + // When + auto userItems = repository.findByUser(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); + + // When + repository.remove(Test::NON_EXISTENT_ID); + + // Then + auto allItems = repository.findAll(); + REQUIRE(allItems.size() == 1); + Test::verifyDefaultTestItem(allItems[0]); + } + + SECTION( + "when repository is created with existing data file then it loads the data") + { + // Given + { + infrastructure::FileItemRepository firstRepository(testDbPath); + domain::Item testItem = Test::createTestItem(); + firstRepository.save(testItem); + } + + // When + infrastructure::FileItemRepository secondRepository(testDbPath); + + // Then + auto foundItem = secondRepository.findById(Test::TEST_ITEM_ID_1); + REQUIRE(foundItem.has_value()); + Test::verifyDefaultTestItem(*foundItem); + } + + 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::FileItemRepository repository(nonExistentDbPath); + + // Then + auto allItems = repository.findAll(); + REQUIRE(allItems.empty()); + } + + // Clean up test environment + Test::cleanupTestEnvironment(); +} \ No newline at end of file diff --git a/cpp17/tests/integration/FileUserRepository.test.cpp b/cpp17/tests/integration/FileUserRepository.test.cpp new file mode 100644 index 0000000..e43716d --- /dev/null +++ b/cpp17/tests/integration/FileUserRepository.test.cpp @@ -0,0 +1,312 @@ +#include +#include +#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/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..a76e96a --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,564 @@ +openapi: 3.0.3 +info: + title: AutoStore API + description: API for the AutoStore system - a system to store items with expiration dates that automatically orders new items when they expire. + version: 1.0.0 +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: + summary: User login + description: Authenticates a user 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: + '200': + description: Login successful + 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 + '401': + description: Invalid credentials + content: + application/json: + 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: + summary: Get list of items + description: Returns a list of all items for the authenticated user + security: + - bearerAuth: [] + responses: + '200': + description: List of items + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/JsendSuccess' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Item' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/JsendError' + post: + summary: Create a new item + description: Creates a new item for the authenticated user + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ItemInput' + responses: + '201': + description: Item created successfully + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/JsendSuccess' + - type: object + properties: + data: + $ref: '#/components/schemas/Item' + '400': + description: Invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/JsendError' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/JsendError' + + /items/{id}: + get: + summary: Get item by ID + description: Returns a specific item by its ID + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + description: Item ID + schema: + type: string + responses: + '200': + description: Item details + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/JsendSuccess' + - type: object + properties: + data: + $ref: '#/components/schemas/Item' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/JsendError' + '404': + description: Item not found + content: + application/json: + schema: + $ref: '#/components/schemas/JsendError' + put: + summary: Update an item + description: Updates an existing item + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + description: Item ID + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ItemInput' + responses: + '200': + description: Item updated successfully + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/JsendSuccess' + - type: object + properties: + data: + $ref: '#/components/schemas/Item' + '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: Item not found + content: + application/json: + schema: + $ref: '#/components/schemas/JsendError' + delete: + summary: Delete an item + description: Deletes an existing item + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + description: Item ID + schema: + type: string + responses: + '204': + description: Item deleted successfully + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/JsendError' + '404': + description: Item not found + content: + application/json: + schema: + $ref: '#/components/schemas/JsendError' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + schemas: + JsendSuccess: + type: object + properties: + status: + type: string + example: success + data: + type: object + description: Response data + + JsendError: + type: object + properties: + status: + type: string + example: error + message: + type: string + description: Error message + code: + type: integer + description: Error code + data: + type: object + description: Additional error data + + User: + type: object + properties: + id: + type: string + description: User ID + username: + 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: + id: + type: string + description: Item ID + name: + type: string + description: Item name + expirationDate: + type: string + format: date-time + description: Item expiration date + orderUrl: + type: string + format: uri + description: URL to send order request when item expires + userId: + type: string + description: ID of the user who owns this item + + ItemInput: + type: object + required: + - name + - expirationDate + - orderUrl + properties: + name: + type: string + description: Item name + expirationDate: + type: string + format: date-time + description: Item expiration date + orderUrl: + type: string + format: uri + description: URL to send order request when item expires