From 9dc2b2284efaec304be304e615403e0326a73a62 Mon Sep 17 00:00:00 2001 From: Babak Jahangiri Date: Mon, 3 Nov 2025 02:15:10 +0000 Subject: [PATCH 01/18] docs(product): update product use case stories and add SKU existence check doc --- docs/user-stories/product/create_product.md | 26 ++++++ docs/user-stories/product/delete_product.md | 28 ++++++ docs/user-stories/product/edit_product.md | 77 +++++++++++++++++ docs/user-stories/product/get_product.md | 74 ++++++++++++++++ docs/user-stories/product/list_product.md | 86 +++++++++++++++++++ .../product/product_sku_exists.md | 58 +++++++++++++ 6 files changed, 349 insertions(+) create mode 100644 docs/user-stories/product/product_sku_exists.md diff --git a/docs/user-stories/product/create_product.md b/docs/user-stories/product/create_product.md index e69de29..7e6b2bf 100644 --- a/docs/user-stories/product/create_product.md +++ b/docs/user-stories/product/create_product.md @@ -0,0 +1,26 @@ +# User Story: Create a New Product + +**As a** system administrator or merchant +**I want** to create a new product by providing all required product details +**So that** the product is added to the catalog and available for sale or management + +## Acceptance Criteria +- [x] Product creation validates all required fields (e.g., name, SKU, price, etc.) +- [x] SKU uniqueness is checked before saving; duplicates raise a `DuplicateEntryException` +- [x] A successful creation returns a `ProductRead` object with the new product data +- [x] If a database error occurs, a `DatabaseOperationException` is raised +- [x] The use case integrates with the `ProductRepositoryInterface` for persistence +- [x] Input is validated according to the `ProductInCreate` schema +- [ ] Comprehensive tests are written to verify all success and failure scenarios + +## Notes +- Depends on `ProductRepositoryInterface` and `ProductSkuExists` use cases +- Handles `DuplicateEntryException` and `DatabaseOperationException` +- Response format is standardized via `ProductRead` schema + +## Related Links +- [Get Product by ID](get_product.md) +- [Update Product](edit_product.md) +- [Delete Product](delete_product.md) +- [List All Products](list_product.md) +- [Check Product SKU Exists](product_sku_exists.md) diff --git a/docs/user-stories/product/delete_product.md b/docs/user-stories/product/delete_product.md index e69de29..3c8b696 100644 --- a/docs/user-stories/product/delete_product.md +++ b/docs/user-stories/product/delete_product.md @@ -0,0 +1,28 @@ +# User Story: Delete an Existing Product + +**As a** system administrator or merchant +**I want** to delete an existing product by its unique ID +**So that** it can be removed from the product catalog when it is no longer needed or available + +## Acceptance Criteria +- [x] The system receives a valid `product_id` for deletion +- [x] If the product exists, it is deleted successfully from the repository +- [x] Upon successful deletion, the use case returns a `ProductRead` object representing the deleted product +- [x] If the product does not exist, an `EntityNotFoundException` is raised with a meaningful message +- [x] If a database error occurs during deletion, a `DatabaseOperationException` is raised with error context +- [ ] HTTP response for successful deletion must return status code `200` +- [ ] Comprehensive tests are written to verify all success and failure scenarios + +## Notes +- Depends on `ProductRepositoryInterface` for data persistence +- Handles: + - `DatabaseOperationException` → raised for database-level issues + - `EntityNotFoundException` → raised when the specified product ID does not exist +- Returns a validated `ProductRead` schema of the deleted product + +## Related Links +- [Create Product](create_product.md) +- [Get Product by ID](get_product.md) +- [Update Product](edit_product.md) +- [List All Products](list_product.md) +- [Check Product SKU Exists](product_sku_exists.md) diff --git a/docs/user-stories/product/edit_product.md b/docs/user-stories/product/edit_product.md index e69de29..5a66d6c 100644 --- a/docs/user-stories/product/edit_product.md +++ b/docs/user-stories/product/edit_product.md @@ -0,0 +1,77 @@ +# User Story: Edit an Existing Product + +**As a** system administrator or merchant +**I want** to update an existing product’s details +**So that** I can correct information or modify product attributes such as price, name, or stock + +## Acceptance Criteria +- [x] The system accepts a valid `product_id` and a `ProductUpdate` payload +- [x] The repository updates the product record with the provided fields +- [x] If the update is successful, the use case returns a `ProductRead` object containing updated data +- [x] If the product does not exist, an `EntityNotFoundException` is raised with a clear message +- [x] If a database error occurs during update, a `DatabaseOperationException` is raised with error details +- [x] All required fields in `ProductUpdate` are validated before execution +- [ ] Comprehensive unit and integration tests are implemented to cover all success and failure paths + +## Notes +- Depends on `ProductRepositoryInterface` for persistence operations +- Handles: + - `EntityNotFoundException` → when the product ID does not exist + - `DatabaseOperationException` → for update failures at the repository or database level +- Returns a `ProductRead` schema object that reflects the updated product +- (Minor issue to fix): The `DatabaseOperationException` currently uses `"delete"` as operation name; should be `"update"` + +## Related Links +- [Create Product](create_product.md) +- [Get Product by ID](get_product.md) +- [Delete Product](delete_product.md) +- [List All Products](list_product.md) +- [Check Product SKU Exists](product_sku_exists.md) + + +--- + +## 🧪 Test Scenarios + +### ✅ Successful Update +**Given** a valid `product_id` and a valid `ProductUpdate` object +**When** `ProductEdit.execute()` is called +**Then** the product record is updated and returned as a `ProductRead` object + +**Expected:** +- Updated fields reflect new values +- No exceptions are raised +- HTTP response (in API layer) should return status code `200 OK` + +--- + +### ⚠️ Product Not Found +**Given** a `product_id` that does not exist in the repository +**When** `ProductEdit.execute()` is called +**Then** an `EntityNotFoundException` is raised + +**Expected:** +- Exception message: `"Product not found"` +- No database changes occur + +--- + +### ❌ Database Error +**Given** a valid `product_id` and update data +**When** a database failure occurs during update +**Then** a `DatabaseOperationException` is raised + +**Expected:** +- Exception includes operation `"update"` +- Error message describes the database error cause +- The original data remains unchanged + +--- + +## 🧭 Test Coverage Checklist +- [ ] Unit test for successful update +- [ ] Unit test for `EntityNotFoundException` (product not found) +- [ ] Unit test for `DatabaseOperationException` (database failure) +- [ ] Validation test ensuring input matches `ProductUpdate` schema +- [ ] Integration test confirming returned `ProductRead` structure +- [ ] Ensure HTTP 200 is returned from API layer on success diff --git a/docs/user-stories/product/get_product.md b/docs/user-stories/product/get_product.md index e69de29..0c84e0d 100644 --- a/docs/user-stories/product/get_product.md +++ b/docs/user-stories/product/get_product.md @@ -0,0 +1,74 @@ +# User Story: Get Product by ID + +**As a** system administrator or merchant +**I want** to retrieve a product’s details using its unique ID +**So that** I can view the product information for management or display purposes + +## Acceptance Criteria +- [x] The system accepts a valid `product_id` as input +- [x] The use case retrieves the corresponding product from the repository +- [x] If the product exists, it returns a `ProductRead` object with all product details +- [x] If the product does not exist, an `EntityNotFoundException` is raised with a clear message +- [x] If a database or query error occurs, a `DatabaseOperationException` is raised with error details +- [ ] Comprehensive unit and integration tests are implemented to verify all behaviors + +## Notes +- Depends on `ProductRepositoryInterface` for data access +- Handles: + - `EntityNotFoundException` → raised when the product ID is not found + - `DatabaseOperationException` → raised when a database query fails +- Returns a validated `ProductRead` schema containing full product details +- Operation type in exception: `"select"` + +## Related Links +- [Create Product](create_product.md) +- [Update Product](edit_product.md) +- [Delete Product](delete_product.md) +- [List All Products](list_product.md) +- [Check Product SKU Exists](product_sku_exists.md) + +--- + +## 🧪 Test Scenarios + +### ✅ Successful Retrieval +**Given** a valid `product_id` that exists in the repository +**When** `ProductGetById.execute()` is called +**Then** the product is retrieved successfully and returned as a `ProductRead` object + +**Expected:** +- Returned data matches the stored product +- No exceptions are raised +- HTTP response (in API layer) should return status code `200 OK` + +--- + +### ⚠️ Product Not Found +**Given** a `product_id` that does not exist in the database +**When** `ProductGetById.execute()` is called +**Then** an `EntityNotFoundException` is raised + +**Expected:** +- Exception message: `"Product not found"` +- Response should contain appropriate HTTP error code (`404 Not Found`) + +--- + +### ❌ Database Error +**Given** a valid `product_id` +**When** a database failure occurs during lookup +**Then** a `DatabaseOperationException` is raised + +**Expected:** +- Exception includes operation `"select"` +- Error message provides details of the failure +- No partial or invalid data is returned + +--- + +## 🧭 Test Coverage Checklist +- [ ] Unit test for successful retrieval of existing product +- [ ] Unit test for `EntityNotFoundException` when product does not exist +- [ ] Unit test for `DatabaseOperationException` when repository raises an error +- [ ] Validation test ensuring output conforms to `ProductRead` schema +- [ ] Integration test verifying correct API response (`200 OK` / `404 Not Found`) diff --git a/docs/user-stories/product/list_product.md b/docs/user-stories/product/list_product.md index e69de29..ac91fe5 100644 --- a/docs/user-stories/product/list_product.md +++ b/docs/user-stories/product/list_product.md @@ -0,0 +1,86 @@ +# User Story: List All Products + +**As a** system administrator or merchant +**I want** to retrieve a list of products, optionally filtered by category and paginated +**So that** I can view or manage multiple products efficiently + +## Acceptance Criteria +- [x] The system supports optional filtering by `category_id` +- [x] Pagination parameters `page` and `per_page` control the returned product range +- [x] The use case retrieves products via the repository’s `list_all` method +- [x] If products exist, a list of `ProductRead` objects is returned +- [x] If no products are found, an `EntityNotFoundException` is raised with a clear message +- [ ] If a database error occurs, a `DatabaseOperationException` is raised with detailed context +- [ ] Endpoint (E2E) test verifies correct pagination, filtering, and response structure + +## Notes +- Depends on `ProductRepositoryInterface` for data access +- Handles: + - `EntityNotFoundException` → raised when no products are found for the given query + - `DatabaseOperationException` → raised when a database or query operation fails +- Returns a list of validated `ProductRead` objects +- Pagination is controlled by parameters `page` (default 1) and `per_page` (default 10) + +## Related Links +- [Create Product](create_product.md) +- [Get Product by ID](get_product.md) +- [Update Product](edit_product.md) +- [Delete Product](delete_product.md) +- [Check Product SKU Exists](product_sku_exists.md) + +--- + +## 🧪 Test Scenarios + +### ✅ Successful Listing +**Given** existing products in the database +**When** `ProductListAll.execute()` is called with default pagination +**Then** a list of `ProductRead` objects is returned + +**Expected:** +- Response includes up to `per_page` items +- Each item matches the expected product schema +- HTTP response code (in API layer): `200 OK` + +--- + +### ⚙️ Filter by Category +**Given** products belonging to multiple categories +**When** `ProductListAll.execute()` is called with a specific `category_id` +**Then** only products belonging to that category are returned + +**Expected:** +- Response includes only filtered items +- Pagination and limit rules still apply + +--- + +### ⚠️ No Products Found +**Given** an empty database or no products in the selected category +**When** `ProductListAll.execute()` is called +**Then** an `EntityNotFoundException` is raised + +**Expected:** +- Exception message: `"No products found"` +- Response should use HTTP code `404 Not Found` in the API layer + +--- + +### ❌ Database Error +**Given** a database connection failure or query issue +**When** `ProductListAll.execute()` is called +**Then** a `DatabaseOperationException` is raised + +**Expected:** +- Exception includes operation `"select"` +- Error message describes the database failure +- No partial or invalid data is returned + +--- + +## 🧭 Test Coverage Checklist +- [ ] E2E test for successful product listing with pagination +- [ ] E2E test for category-based filtering +- [ ] E2E test for empty result set (`EntityNotFoundException`) +- [ ] E2E test for `DatabaseOperationException` handling +- [ ] Validation of response structure and list schema (`ProductRead`) diff --git a/docs/user-stories/product/product_sku_exists.md b/docs/user-stories/product/product_sku_exists.md new file mode 100644 index 0000000..e854344 --- /dev/null +++ b/docs/user-stories/product/product_sku_exists.md @@ -0,0 +1,58 @@ +# User Story 1033: Check Product SKU Exists + +**As a** system or service component +**I want** to verify whether a product with a specific SKU already exists +**So that** I can prevent duplicate product creation and maintain SKU uniqueness + +## Acceptance Criteria +- [x] The use case accepts a SKU string as input +- [x] The repository method `exists_by_field("sku", sku)` is called to check existence +- [x] Returns `True` if a product with the given SKU exists, otherwise `False` +- [x] Raises `DatabaseOperationException` if any repository or database error occurs +- [ ] Unit test confirms correct results for existing and non-existing SKUs + +## Notes +- This use case is intended for **internal validation** within other product operations (e.g., `ProductCreate`) +- It does **not** expose any API endpoint +- Depends on `ProductRepositoryInterface` for the data access layer +- Handles: + - `DatabaseOperationException` → raised when the repository query fails +- No data mutation or transformation happens here; the logic is read-only + +## Related Links +- [Create Product](create_product.md) +- [Get Product by ID](get_product.md) +- [Update Product](edit_product.md) +- [Delete Product](delete_product.md) +- [List All Products](list_product.md) + +--- + +## 🧪 Test Scenarios + +### ✅ SKU Exists +**Given** a product with SKU `ABC123` exists in the database +**When** `ProductSkuExists.execute("ABC123")` is called +**Then** the method returns `True` + +--- + +### ⚙️ SKU Does Not Exist +**Given** no product with SKU `XYZ999` exists in the repository +**When** `ProductSkuExists.execute("XYZ999")` is called +**Then** the method returns `False` + +--- + +### ❌ Repository or Database Error +**Given** the repository layer raises an exception (e.g., connection or query failure) +**When** `ProductSkuExists.execute("ANY_SKU")` is called +**Then** a `DatabaseOperationException` is raised +**And** the exception data contains the SKU value used in the query + +--- + +## 🧭 Test Coverage Checklist +- [ ] Unit test for existing SKU → returns `True` +- [ ] Unit test for non-existing SKU → returns `False` +- [ ] Unit test for `DatabaseOperationException` on repository failure From 9114408b9df6016d444ca67b1a18cb3aaa1ebb91 Mon Sep 17 00:00:00 2001 From: Babak Jahangiri Date: Tue, 4 Nov 2025 02:08:08 +0000 Subject: [PATCH 02/18] test(e2e): add global_setup with clear_database and logger --- app/core/logger/get_logger.py | 2 +- tests/e2e/conftest.py | 24 ++++++++++++ tests/e2e/fixtures/fxt_base.py | 2 +- tests/e2e/global_setup.py | 72 ++++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/global_setup.py diff --git a/app/core/logger/get_logger.py b/app/core/logger/get_logger.py index 12dfc9f..1c9ca57 100644 --- a/app/core/logger/get_logger.py +++ b/app/core/logger/get_logger.py @@ -9,7 +9,7 @@ class ServiceName(StrEnum): AUTH_SERVICE = "auth_service" API_ROUTERS = "api_routers" REDIS_SERVICE = "redis_service" - TEST_SERVICE = "test_service" + E2E_TEST_SERVICE = "e2e_test_service" # Add more services as needed diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 19c6d0a..b44065d 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,5 +1,8 @@ +import asyncio import logging +from tests.e2e.global_setup import clear_database + pytest_plugins = [ "tests.e2e.fixtures.fxt_base", "tests.e2e.fixtures.users", @@ -7,4 +10,25 @@ ] +def pytest_sessionstart(session): + """ + Called after the Session object has been created and configured. + This runs before any test collection or execution. + """ + print("Setting up test environment...") + + # Run async function from sync context + try: + # Create a new event loop for the async operation + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(clear_database()) + loop.close() + except Exception as e: + print(f"Failed to clear database: {e}") + raise + + print("Test environment ready!") + + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") diff --git a/tests/e2e/fixtures/fxt_base.py b/tests/e2e/fixtures/fxt_base.py index 9042997..e038a90 100644 --- a/tests/e2e/fixtures/fxt_base.py +++ b/tests/e2e/fixtures/fxt_base.py @@ -17,7 +17,7 @@ def api_request_context() -> Generator[APIRequestContext, Any, None]: @pytest.fixture(autouse=True, scope="session") def logger() -> Logger: - return get_logger(service=ServiceName.TEST_SERVICE) + return get_logger(service=ServiceName.E2E_TEST_SERVICE) # login and get user tokens diff --git a/tests/e2e/global_setup.py b/tests/e2e/global_setup.py new file mode 100644 index 0000000..ba3bf94 --- /dev/null +++ b/tests/e2e/global_setup.py @@ -0,0 +1,72 @@ +from sqlalchemy import text + +from app.core.logger.config import configure_logger +from app.core.logger.get_logger import ServiceName, get_logger +from app.db.session import sessionmanager + +# Configure logging for tests +configure_logger() + +logger = get_logger(service=ServiceName.E2E_TEST_SERVICE) + + +async def clear_database(): + """ + Clear all data from the database tables for clean test environment. + + This method truncates all tables to ensure each test run starts with a clean state. + It preserves table structure while removing all data. + """ + async with sessionmanager.session() as session: + try: + # Get all table names from the database + result = await session.execute( + text("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") + ) + tables = result.fetchall() + + if not tables: + logger.info("No tables found to clear") + return + + logger.debug(f"Found {len(tables)} tables to clear: {[t[0] for t in tables]}") + + # Disable foreign key constraints temporarily + await session.execute(text("PRAGMA foreign_keys = OFF")) + + # Clear all tables + for table in tables: + table_name = table[0] + await session.execute(text(f"DELETE FROM {table_name}")) + logger.info(f"Cleared table: {table_name}") + + # Reset auto-increment counters only if sqlite_sequence table exists + sequence_check = await session.execute( + text("SELECT name FROM sqlite_master WHERE type='table' AND name='sqlite_sequence'") + ) + if sequence_check.fetchone(): + logger.info("sqlite_sequence table found, resetting auto-increment counters...") + # Only reset sequences for tables that exist in sqlite_sequence + for table in tables: + table_name = table[0] + sequence_exists = await session.execute( + text("SELECT name FROM sqlite_sequence WHERE name=:table_name"), {"table_name": table_name} + ) + if sequence_exists.fetchone(): + await session.execute( + text("DELETE FROM sqlite_sequence WHERE name=:table_name"), {"table_name": table_name} + ) + logger.info(f"Reset sequence for: {table_name}") + else: + logger.info("No sqlite_sequence table found, skipping sequence reset") + + # Re-enable foreign key constraints + await session.execute(text("PRAGMA foreign_keys = ON")) + + await session.commit() + logger.info("Database cleared successfully") + + except Exception as e: + await session.rollback() + logger.error(f"Error clearing database: {e}") + raise From e917108590c64c8e66ad8ac228946846253f3603 Mon Sep 17 00:00:00 2001 From: Babak Jahangiri Date: Tue, 4 Nov 2025 02:31:16 +0000 Subject: [PATCH 03/18] users: fix responses --- app/modules/user/routers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/modules/user/routers.py b/app/modules/user/routers.py index 66e2037..4b4da9f 100644 --- a/app/modules/user/routers.py +++ b/app/modules/user/routers.py @@ -84,6 +84,7 @@ async def get_user_by_id( responses={ **ResponseSuccessDoc.HTTP_200_OK("User fetched successfully", UserRead), **ResponseErrorDoc.HTTP_404_NOT_FOUND("User not found"), + **ResponseErrorDoc.HTTP_500_INTERNAL_SERVER_ERROR("Internal server error"), }, ) async def get_user_by_email( @@ -110,6 +111,7 @@ async def get_user_by_email( responses={ **ResponseSuccessDoc.HTTP_200_OK("User deleted successfully", UserRead), **ResponseErrorDoc.HTTP_404_NOT_FOUND("User not found"), + **ResponseErrorDoc.HTTP_500_INTERNAL_SERVER_ERROR("Internal server error"), }, ) async def user_delete( @@ -136,6 +138,7 @@ async def user_delete( responses={ **ResponseSuccessDoc.HTTP_200_OK("User deleted successfully", UserRead), **ResponseErrorDoc.HTTP_404_NOT_FOUND("User not found"), + **ResponseErrorDoc.HTTP_500_INTERNAL_SERVER_ERROR("Internal server error"), }, ) async def user_delete_by_email( From ebed68024ad015b2b4d11b31bfb646e1db61dfb7 Mon Sep 17 00:00:00 2001 From: Babak Jahangiri Date: Tue, 4 Nov 2025 20:16:05 +0000 Subject: [PATCH 04/18] Remove obsolete function --- app/common/http_response/success_result.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/app/common/http_response/success_result.py b/app/common/http_response/success_result.py index 8d0a647..9ba7325 100644 --- a/app/common/http_response/success_result.py +++ b/app/common/http_response/success_result.py @@ -41,12 +41,3 @@ def to_json_response(self, request: Request) -> JSONResponse: status_code=self.status_code, content=model.model_dump(mode="json"), ) - - -#! TODO: Remove this function and use the to_json_response method instead -def success_response_builder(result: SuccessResult[T], request: Request) -> JSONResponse: - model = result.to_response_model(path=request.url.path) - return JSONResponse( - status_code=result.status_code, - content=model.model_dump(mode="json"), - ) From 591d9f67f6bc47ce2d6b43174df18fab1bb2643f Mon Sep 17 00:00:00 2001 From: Babak Jahangiri Date: Tue, 4 Nov 2025 22:13:35 +0000 Subject: [PATCH 05/18] add pagination schema --- app/common/schemas/__init__.py | 0 app/common/schemas/pagination.py | 15 +++++++++++++++ app/modules/product/schemas.py | 21 +++++++++++++++++---- 3 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 app/common/schemas/__init__.py create mode 100644 app/common/schemas/pagination.py diff --git a/app/common/schemas/__init__.py b/app/common/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/common/schemas/pagination.py b/app/common/schemas/pagination.py new file mode 100644 index 0000000..a40ab5f --- /dev/null +++ b/app/common/schemas/pagination.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, Field + + +class PaginationInfo(BaseModel): + """ + Standard pagination information for list responses. + Reusable across all modules (products, users, categories, orders, etc.). + """ + + page: int = Field(..., ge=1, description="Current page number", example=1) + limit: int = Field(..., ge=1, le=100, description="Items per page", example=10) + total_items: int = Field(..., ge=0, description="Total number of items", example=21) + total_pages: int = Field(..., ge=0, description="Total number of pages", example=3) + has_next: bool = Field(..., description="Whether there is a next page", example=True) + has_prev: bool = Field(..., description="Whether there is a previous page", example=False) diff --git a/app/modules/product/schemas.py b/app/modules/product/schemas.py index 068a83b..1ca9fb8 100644 --- a/app/modules/product/schemas.py +++ b/app/modules/product/schemas.py @@ -2,6 +2,8 @@ from pydantic import BaseModel, Field +from app.common.schemas.pagination import PaginationInfo + class ProductBase(BaseModel): category_id: int = Field(..., example=1) @@ -22,7 +24,7 @@ class ProductInCreate(ProductBase): pass -class ProductUpdate(ProductBase): +class ProductInUpdate(ProductBase): """ Used for updating a product. Typically, the `id` is passed via path param, not the schema body. @@ -31,7 +33,7 @@ class ProductUpdate(ProductBase): pass -class ProductRead(ProductBase): +class ProductOutRead(ProductBase): """ Used for reading product data. Includes `id`, `date_created`, and `date_modified`. @@ -50,5 +52,16 @@ class ProductRead(ProductBase): examples=["2023-01-01T12:30:00"], ) - class Config: - from_attributes = True + model_config = {"from_attributes": True} + + +class ProductOutList(BaseModel): + """ + Used for listing products with pagination support. + Contains a list of ProductOutRead items and pagination metadata. + """ + + items: list[ProductOutRead] = Field(..., description="List of products") + pagination: PaginationInfo = Field(..., description="Pagination information") + + model_config = {"from_attributes": True} From e87c88a564e9ed4b1cb2ccffe9c73b1ee8f3808d Mon Sep 17 00:00:00 2001 From: Babak Jahangiri Date: Tue, 4 Nov 2025 22:14:23 +0000 Subject: [PATCH 06/18] impr:fix product crud --- app/modules/product/repository.py | 9 +-- app/modules/product/repository_interface.py | 4 +- app/modules/product/routers.py | 75 ++++++++++++------- app/modules/product/usecases/create.py | 8 +- app/modules/product/usecases/delete.py | 11 ++- app/modules/product/usecases/get_by_id.py | 6 +- app/modules/product/usecases/list_all.py | 6 +- .../product/usecases/{edit.py => update.py} | 10 +-- 8 files changed, 75 insertions(+), 54 deletions(-) rename app/modules/product/usecases/{edit.py => update.py} (72%) diff --git a/app/modules/product/repository.py b/app/modules/product/repository.py index 0826125..36f785e 100644 --- a/app/modules/product/repository.py +++ b/app/modules/product/repository.py @@ -17,7 +17,7 @@ async def create(self, product: Product) -> Product: return product - async def update(self, product_id: int, updated_product: Product) -> Product | None: + async def update_by_id(self, product_id: int, updated_product: Product) -> Product | None: product = await self.session.get(Product, product_id) if not product: return None @@ -30,7 +30,7 @@ async def update(self, product_id: int, updated_product: Product) -> Product | N await self.session.refresh(product) return product - async def delete(self, product_id: int) -> Product | None: + async def delete_by_id(self, product_id: int) -> Product | None: product = await self.session.get(Product, product_id) if not product: @@ -41,10 +41,7 @@ async def delete(self, product_id: int) -> Product | None: return product async def get_by_id(self, product_id: int) -> Product | None: - # More explicit query with all columns - stmt = select(Product).where(Product.id == product_id) - result = await self.session.execute(stmt) - return result.scalar_one_or_none() + return await self.session.get(Product, product_id) async def list_all(self, category_id: int | None = None, skip: int = 0, limit: int = 10) -> list[Product]: query = select(Product) diff --git a/app/modules/product/repository_interface.py b/app/modules/product/repository_interface.py index 5b14007..49bd3f7 100644 --- a/app/modules/product/repository_interface.py +++ b/app/modules/product/repository_interface.py @@ -10,11 +10,11 @@ async def create(self, product: Product) -> Product: pass @abstractmethod - async def update(self, product_id: int, updated_product: Product) -> Product: + async def update_by_id(self, product_id: int, updated_product: Product) -> Product: pass @abstractmethod - async def delete(self, product_id: int) -> Product | None: + async def delete_by_id(self, product_id: int) -> Product | None: pass @abstractmethod diff --git a/app/modules/product/routers.py b/app/modules/product/routers.py index 93c4136..d752997 100644 --- a/app/modules/product/routers.py +++ b/app/modules/product/routers.py @@ -1,21 +1,19 @@ from typing import Annotated -import orjson -from fastapi import APIRouter, Request, Response, status -from fastapi.params import Depends +from fastapi import APIRouter, Depends, Request, status -from app.common.http_response.doc_reponses import ResponseErrorDoc, ResponseSuccessDoc +from app.common.http_response.doc_responses import ResponseErrorDoc, ResponseSuccessDoc from app.common.http_response.success_response import SuccessCodes, SuccessResponse from app.common.http_response.success_result import SuccessResult +from app.modules.product.schemas import ProductInCreate, ProductInUpdate, ProductOutRead from app.modules.product.usecases.create import ProductCreate from app.modules.product.usecases.delete import ProductDelete -from app.modules.product.usecases.edit import ProductEdit from app.modules.product.usecases.get_by_id import ProductGetById from app.modules.product.usecases.list_all import ProductListAll +from app.modules.product.usecases.update import ProductUpdate from .depends import get_product_repository from .repository_interface import ProductRepositoryInterface -from .schemas import ProductInCreate, ProductRead, ProductUpdate router = APIRouter( prefix="/products", @@ -28,7 +26,7 @@ "/", status_code=status.HTTP_201_CREATED, responses={ - **ResponseSuccessDoc.HTTP_201_CREATED("Product created successfully", ProductRead), + **ResponseSuccessDoc.HTTP_201_CREATED("Product created successfully", ProductOutRead), **ResponseErrorDoc.HTTP_500_INTERNAL_SERVER_ERROR("Internal server error"), }, ) @@ -36,60 +34,87 @@ async def create_product( request: Request, product_create: ProductInCreate, product_repository: Annotated[ProductRepositoryInterface, Depends(get_product_repository)], -) -> SuccessResponse[ProductRead]: +) -> SuccessResponse[ProductOutRead]: product_read = await ProductCreate(product_repository).execute(product_create) - result = SuccessResult[ProductRead]( + result = SuccessResult[ProductOutRead]( code=SuccessCodes.CREATED, message="Product created successfully", status_code=status.HTTP_201_CREATED, data=product_read, ) + return result.to_json_response(request) -@router.get("/{product_id}", response_model=ProductRead) +@router.get("/{product_id}", response_model=ProductOutRead) async def get_product( - product_id: int, product_repository: Annotated[ProductRepositoryInterface, Depends(get_product_repository)] -) -> ProductRead: + request: Request, + product_id: int, + product_repository: Annotated[ProductRepositoryInterface, Depends(get_product_repository)], +) -> SuccessResponse[ProductOutRead]: product_read = await ProductGetById(product_repository).execute(product_id) - return product_read + result = SuccessResult[ProductOutRead]( + code=SuccessCodes.SUCCESS, + message="Product fetched successfully", + status_code=status.HTTP_200_OK, + data=product_read, + ) + + return result.to_json_response(request) -@router.put("/{product_id}", response_model=ProductRead) +@router.put("/{product_id}", response_model=ProductOutRead) async def update_product( + request: Request, product_id: int, - product_update: ProductUpdate, + product_update: ProductInUpdate, product_repository: Annotated[ProductRepositoryInterface, Depends(get_product_repository)], -) -> ProductRead: - product_read = await ProductEdit(product_repository).execute(product_id, product_update) - return product_read +) -> SuccessResponse[ProductOutRead]: + product_read = await ProductUpdate(product_repository).execute(product_id, product_update) + + result = SuccessResult[ProductOutRead]( + code=SuccessCodes.SUCCESS, + message="Product updated successfully", + status_code=status.HTTP_200_OK, + data=product_read, + ) + + return result.to_json_response(request) @router.delete( "/{product_id}", - status_code=status.HTTP_204_NO_CONTENT, + status_code=status.HTTP_200_OK, responses={ + **ResponseSuccessDoc.HTTP_200_OK("Product deleted successfully", ProductOutRead), **ResponseErrorDoc.HTTP_404_NOT_FOUND("Product not found"), **ResponseErrorDoc.HTTP_500_INTERNAL_SERVER_ERROR("Internal server error"), }, ) async def delete_product( + request: Request, product_id: int, product_repository: Annotated[ProductRepositoryInterface, Depends(get_product_repository)], - response: Response, -): +) -> SuccessResponse[ProductOutRead]: product_read = await ProductDelete(product_repository).execute(product_id) - response.headers["X-Deleted-Product"] = orjson.dumps(product_read.model_dump()).decode() - return None + + result = SuccessResult[ProductOutRead]( + code=SuccessCodes.SUCCESS, + message=f"Product with ID {product_id} deleted successfully", + status_code=status.HTTP_200_OK, + data=product_read, + ) + + return result.to_json_response(request) -@router.get("/", response_model=list[ProductRead]) +@router.get("/", response_model=list[ProductOutRead]) async def list_all_products( product_repository: Annotated[ProductRepositoryInterface, Depends(get_product_repository)], category_id: int | None = None, page: int = 1, per_page: int = 10, -) -> list[ProductRead]: +) -> list[ProductOutRead]: products = await ProductListAll(product_repository).execute(category_id=category_id, page=page, per_page=per_page) return products diff --git a/app/modules/product/usecases/create.py b/app/modules/product/usecases/create.py index a892f28..7765da7 100644 --- a/app/modules/product/usecases/create.py +++ b/app/modules/product/usecases/create.py @@ -1,7 +1,7 @@ from app.common.exceptions.app_exceptions import DatabaseOperationException, DuplicateEntryException from app.modules.product.models import Product from app.modules.product.repository_interface import ProductRepositoryInterface -from app.modules.product.schemas import ProductInCreate, ProductRead +from app.modules.product.schemas import ProductInCreate, ProductOutRead from .sku_exists import ProductSkuExists @@ -11,7 +11,7 @@ def __init__(self, product_repository: ProductRepositoryInterface) -> None: self.product_repository = product_repository self.sku_exists_uc = ProductSkuExists(product_repository) - async def execute(self, product_create: ProductInCreate) -> ProductRead | None: + async def execute(self, product_create: ProductInCreate) -> ProductOutRead | None: # Check for duplicate SKU before insert if await self.sku_exists_uc.execute(product_create.sku): raise DuplicateEntryException("sku", product_create.sku) @@ -19,10 +19,10 @@ async def execute(self, product_create: ProductInCreate) -> ProductRead | None: product_data = Product(**product_create.model_dump()) try: - product = await self.product_repository.create(product_data) + product: Product = await self.product_repository.create(product_data) except Exception as e: raise DatabaseOperationException( operation="insert", message=str(e), data={"product": product_create.model_dump()} ) - return ProductRead.model_validate(product) + return ProductOutRead.model_validate(product) diff --git a/app/modules/product/usecases/delete.py b/app/modules/product/usecases/delete.py index 1f7ec1d..a42a22c 100644 --- a/app/modules/product/usecases/delete.py +++ b/app/modules/product/usecases/delete.py @@ -1,17 +1,16 @@ from app.common.exceptions.app_exceptions import DatabaseOperationException, EntityNotFoundException +from app.modules.product.models import Product from app.modules.product.repository_interface import ProductRepositoryInterface -from app.modules.product.schemas import ProductRead +from app.modules.product.schemas import ProductOutRead class ProductDelete: def __init__(self, product_repository: ProductRepositoryInterface) -> None: self.product_repository = product_repository - async def execute(self, product_id: int) -> ProductRead: - product = None - + async def execute(self, product_id: int) -> ProductOutRead: try: - product = await self.product_repository.delete(product_id) + product: Product | None = await self.product_repository.delete_by_id(product_id) except Exception as e: raise DatabaseOperationException(operation="delete", message=str(e), data={"product_id": product_id}) @@ -20,4 +19,4 @@ async def execute(self, product_id: int) -> ProductRead: data={"product_id": product_id}, message=f"Product with ID {product_id} not found." ) - return ProductRead.model_validate(product) + return ProductOutRead.model_validate(product, by_name=True) diff --git a/app/modules/product/usecases/get_by_id.py b/app/modules/product/usecases/get_by_id.py index 0f21977..901330e 100644 --- a/app/modules/product/usecases/get_by_id.py +++ b/app/modules/product/usecases/get_by_id.py @@ -1,14 +1,14 @@ from app.common.exceptions.app_exceptions import DatabaseOperationException, EntityNotFoundException from app.modules.product.models import Product from app.modules.product.repository_interface import ProductRepositoryInterface -from app.modules.product.schemas import ProductRead +from app.modules.product.schemas import ProductOutRead class ProductGetById: def __init__(self, product_repository: ProductRepositoryInterface) -> None: self.product_repository = product_repository - async def execute(self, product_id: int) -> ProductRead: + async def execute(self, product_id: int) -> ProductOutRead: try: product: Product = await self.product_repository.get_by_id(product_id) except Exception as e: @@ -17,4 +17,4 @@ async def execute(self, product_id: int) -> ProductRead: if not product: raise EntityNotFoundException(data={"product_id": product_id}, message="Product not found") - return ProductRead.model_validate(product) + return ProductOutRead.model_validate(product) diff --git a/app/modules/product/usecases/list_all.py b/app/modules/product/usecases/list_all.py index 2c64f31..da63f60 100644 --- a/app/modules/product/usecases/list_all.py +++ b/app/modules/product/usecases/list_all.py @@ -1,14 +1,14 @@ from app.common.exceptions.app_exceptions import DatabaseOperationException, EntityNotFoundException from app.modules.product.models import Product from app.modules.product.repository_interface import ProductRepositoryInterface -from app.modules.product.schemas import ProductRead +from app.modules.product.schemas import ProductOutRead class ProductListAll: def __init__(self, product_repository: ProductRepositoryInterface) -> None: self.product_repository = product_repository - async def execute(self, category_id: int | None = None, page: int = 1, per_page: int = 10) -> list[ProductRead]: + async def execute(self, category_id: int | None = None, page: int = 1, per_page: int = 10) -> list[ProductOutRead]: try: skip = (page - 1) * per_page products: list[Product] = await self.product_repository.list_all( @@ -25,4 +25,4 @@ async def execute(self, category_id: int | None = None, page: int = 1, per_page: data={"category_id": category_id, "page": page, "per_page": per_page}, ) - return [ProductRead.model_validate(product) for product in products] + return [ProductOutRead.model_validate(product) for product in products] diff --git a/app/modules/product/usecases/edit.py b/app/modules/product/usecases/update.py similarity index 72% rename from app/modules/product/usecases/edit.py rename to app/modules/product/usecases/update.py index 895130e..c10d6bf 100644 --- a/app/modules/product/usecases/edit.py +++ b/app/modules/product/usecases/update.py @@ -1,15 +1,15 @@ from app.common.exceptions.app_exceptions import DatabaseOperationException, EntityNotFoundException from app.modules.product.repository_interface import ProductRepositoryInterface -from app.modules.product.schemas import ProductRead, ProductUpdate +from app.modules.product.schemas import ProductInUpdate, ProductOutRead -class ProductEdit: +class ProductUpdate: def __init__(self, product_repository: ProductRepositoryInterface) -> None: self.product_repository = product_repository - async def execute(self, product_id: int, product_update: ProductUpdate) -> ProductRead: + async def execute(self, product_id: int, product_update: ProductInUpdate) -> ProductOutRead: try: - product = await self.product_repository.update(product_id, product_update) + product = await self.product_repository.update_by_id(product_id, product_update) if not product: raise EntityNotFoundException( data={"product_id": product_id}, @@ -18,4 +18,4 @@ async def execute(self, product_id: int, product_update: ProductUpdate) -> Produ except Exception as e: raise DatabaseOperationException(operation="delete", message=str(e), data={"product_id": product_id}) - return ProductRead.model_validate(product) + return ProductOutRead.model_validate(product) From 5df19679e01f79124eb60797e9c970dd0929d38e Mon Sep 17 00:00:00 2001 From: Babak Jahangiri Date: Tue, 4 Nov 2025 22:17:05 +0000 Subject: [PATCH 07/18] fix typo --- app/common/http_response/base.py | 8 +- app/common/http_response/doc_reponses.py | 99 ------------------- app/modules/auth/routers.py | 12 +-- app/modules/cart/routers.py | 2 +- app/modules/category/routers.py | 2 +- .../category/routers/category_create.py | 2 +- app/modules/category/routers/category_list.py | 2 +- .../category/routers/category_retrieve.py | 15 +-- app/modules/user/routers.py | 14 +-- 9 files changed, 30 insertions(+), 126 deletions(-) delete mode 100644 app/common/http_response/doc_reponses.py diff --git a/app/common/http_response/base.py b/app/common/http_response/base.py index 5b9bc17..34c6b7c 100644 --- a/app/common/http_response/base.py +++ b/app/common/http_response/base.py @@ -1,6 +1,6 @@ -from datetime import datetime +from datetime import datetime, timezone -from pydantic import BaseModel +from pydantic import BaseModel, Field class BaseResponse(BaseModel): @@ -16,5 +16,5 @@ class BaseResponse(BaseModel): status: int message: str - timestamp: datetime - path: str + timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + path: str = Field(default="/") diff --git a/app/common/http_response/doc_reponses.py b/app/common/http_response/doc_reponses.py deleted file mode 100644 index 2716241..0000000 --- a/app/common/http_response/doc_reponses.py +++ /dev/null @@ -1,99 +0,0 @@ -from typing import Any - -from pydantic import BaseModel - -from .error_response import ErrorResponseWrapper -from .success_response import SuccessResponse - - -class ResponseSuccessDoc: - @staticmethod - def HTTP_200_OK(description: str, response_type: type[BaseModel]) -> dict: - return {200: {"model": SuccessResponse[response_type], "description": description}} - - @staticmethod - def HTTP_201_CREATED(description: str, response_type: type[BaseModel]) -> dict: - return {201: {"model": SuccessResponse[response_type], "description": description}} - - @staticmethod - def HTTP_202_ACCEPTED(description: str) -> dict: - return {202: {"model": SuccessResponse, "description": description}} - - @staticmethod - def HTTP_203_NON_AUTHORITATIVE_INFORMATION(description: str) -> dict: - return {203: {"model": SuccessResponse, "description": description}} - - @staticmethod - def HTTP_204_NO_CONTENT(description: str, headers: dict[str, Any]) -> dict: - return {204: {"description": description, "headers": headers}} - - -class ResponseErrorDoc: - @staticmethod - def HTTP_400_BAD_REQUEST(description: str) -> dict: - return {400: {"model": ErrorResponseWrapper, "description": description}} - - @staticmethod - def HTTP_401_UNAUTHORIZED(description: str) -> dict: - return {401: {"model": ErrorResponseWrapper, "description": description}} - - @staticmethod - def HTTP_402_PAYMENT_REQUIRED(description: str) -> dict: - return {402: {"model": ErrorResponseWrapper, "description": description}} - - @staticmethod - def HTTP_403_FORBIDDEN(description: str) -> dict: - return {403: {"model": ErrorResponseWrapper, "description": description}} - - @staticmethod - def HTTP_404_NOT_FOUND(description: str) -> dict: - return {404: {"model": ErrorResponseWrapper, "description": description}} - - @staticmethod - def HTTP_405_METHOD_NOT_ALLOWED(description: str) -> dict: - return {405: {"model": ErrorResponseWrapper, "description": description}} - - @staticmethod - def HTTP_409_CONFLICT(description: str) -> dict: - return {409: {"model": ErrorResponseWrapper, "description": description}} - - @staticmethod - def HTTP_429_TOO_MANY_REQUESTS(description: str) -> dict: - return {429: {"model": ErrorResponseWrapper, "description": description}} - - @staticmethod - def HTTP_422_UNPROCESSABLE_ENTITY(description: str) -> dict: - return {422: {"model": ErrorResponseWrapper, "description": description}} - - @staticmethod - def HTTP_498_INVALID_TOKEN(description: str) -> dict: - return {498: {"model": ErrorResponseWrapper, "description": description}} - - @staticmethod - def HTTP_500_INTERNAL_SERVER_ERROR(description: str) -> dict: - return {500: {"model": ErrorResponseWrapper, "description": description}} - - -### 4xx Error Status Codes - -# HTTP_405_METHOD_NOT_ALLOWED -# HTTP_406_NOT_ACCEPTABLE -# HTTP_407_PROXY_AUTHENTICATION_REQUIRED -# HTTP_408_REQUEST_TIMEOUT -# HTTP_409_CONFLICT -# HTTP_410_GONE -# HTTP_411_LENGTH_REQUIRED -# HTTP_412_PRECONDITION_FAILED -# HTTP_413_PAYLOAD_TOO_LARGE -# HTTP_414_URI_TOO_LONG -# HTTP_415_UNSUPPORTED_MEDIA_TYPE -# HTTP_416_RANGE_NOT_SATISFINSUPPORTED_MEDIA_TYPEABLE -# HTTP_417_EXPECTATION_FAILED - - -# ### 5xx Server Error Status Codes -# HTTP_500_INTERNAL_SERVER_ERROR -# HTTP_501_NOT_IMPLEMENTED -# HTTP_502_BAD_GATEWAY -# HTTP_503_SERVICE_UNAVAILABLE -# HTTP_504_GATEWAY_TIMEOUT diff --git a/app/modules/auth/routers.py b/app/modules/auth/routers.py index 9c67bfd..fafcdf7 100644 --- a/app/modules/auth/routers.py +++ b/app/modules/auth/routers.py @@ -4,9 +4,9 @@ from fastapi.params import Depends from fastapi.security import OAuth2PasswordRequestForm -from app.common.http_response.doc_reponses import ResponseErrorDoc, ResponseSuccessDoc +from app.common.http_response.doc_responses import ResponseErrorDoc, ResponseSuccessDoc from app.common.http_response.success_response import SuccessCodes, SuccessResponse -from app.common.http_response.success_result import SuccessResult, success_response_builder +from app.common.http_response.success_result import SuccessResult from app.modules.user.depends import get_user_repository from app.modules.user.models import UserRole from app.modules.user.repository_interface import UserRepositoryInterface @@ -72,7 +72,7 @@ async def auth_get_token( data=tokens, ) - return success_response_builder(result, request) + return result.to_json_response(request) @router.post( @@ -143,7 +143,7 @@ async def auth_get_me( status_code=status.HTTP_200_OK, data=user, ) - return success_response_builder(result, request) + return result.to_json_response(request) @router.post( @@ -187,7 +187,7 @@ async def auth_refresh_tokens( data=tokens, ) - return success_response_builder(result, request) + return result.to_json_response(request) @router.get( @@ -228,4 +228,4 @@ async def auth_verify_refresh_token( data=user_read, ) - return success_response_builder(result, request) + return result.to_json_response(request) diff --git a/app/modules/cart/routers.py b/app/modules/cart/routers.py index 59de8e4..d881f6e 100644 --- a/app/modules/cart/routers.py +++ b/app/modules/cart/routers.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Request, status -from app.common.http_response.doc_reponses import ResponseErrorDoc, ResponseSuccessDoc +from app.common.http_response.doc_responses import ResponseErrorDoc, ResponseSuccessDoc from app.common.http_response.success_response import SuccessResponse from app.common.http_response.success_result import SuccessCodes, SuccessResult from app.modules.product.depends import get_product_repository diff --git a/app/modules/category/routers.py b/app/modules/category/routers.py index 9ab45f5..838ec33 100644 --- a/app/modules/category/routers.py +++ b/app/modules/category/routers.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Request, status from fastapi.responses import JSONResponse -from app.common.http_response.doc_reponses import ResponseErrorDoc, ResponseSuccessDoc +from app.common.http_response.doc_responses import ResponseErrorDoc, ResponseSuccessDoc from app.common.http_response.success_response import SuccessCodes, SuccessResponse from app.common.http_response.success_result import SuccessResult from app.modules.category.depends import get_category_repository diff --git a/app/modules/category/routers/category_create.py b/app/modules/category/routers/category_create.py index fb1e96e..f9d9816 100644 --- a/app/modules/category/routers/category_create.py +++ b/app/modules/category/routers/category_create.py @@ -3,7 +3,7 @@ from fastapi import Depends, Request, status from fastapi.responses import JSONResponse -from app.common.http_response.doc_reponses import ResponseErrorDoc, ResponseSuccessDoc +from app.common.http_response.doc_responses import ResponseErrorDoc, ResponseSuccessDoc from app.common.http_response.success_response import SuccessCodes, SuccessResponse from app.common.http_response.success_result import SuccessResult from app.modules.category.depends import get_category_repository diff --git a/app/modules/category/routers/category_list.py b/app/modules/category/routers/category_list.py index dd64a4e..23ec177 100644 --- a/app/modules/category/routers/category_list.py +++ b/app/modules/category/routers/category_list.py @@ -4,7 +4,7 @@ from fastapi.params import Query from fastapi.responses import JSONResponse -from app.common.http_response.doc_reponses import ResponseErrorDoc, ResponseSuccessDoc +from app.common.http_response.doc_responses import ResponseErrorDoc, ResponseSuccessDoc from app.common.http_response.success_response import SuccessCodes, SuccessResponse from app.common.http_response.success_result import SuccessResult from app.modules.category.depends import get_category_repository diff --git a/app/modules/category/routers/category_retrieve.py b/app/modules/category/routers/category_retrieve.py index b1b120e..0695ee7 100644 --- a/app/modules/category/routers/category_retrieve.py +++ b/app/modules/category/routers/category_retrieve.py @@ -2,8 +2,10 @@ from fastapi import Depends, status from fastapi.requests import Request +from fastapi.responses import JSONResponse -from app.common.http_response.doc_reponses import ResponseErrorDoc, ResponseSuccessDoc +from app.common.http_response.doc_responses import ResponseErrorDoc, ResponseSuccessDoc +from app.common.http_response.success_response import SuccessCodes, SuccessResponse from app.common.http_response.success_result import SuccessResult from app.modules.category.depends import get_category_repository from app.modules.category.repository_interface import CategoryRepositoryInterface @@ -14,24 +16,25 @@ @router.get( "/{category_id}", - response_model=CategoryRead, + response_model=SuccessResponse[CategoryRead], responses={ - **ResponseSuccessDoc.HTTP_200_OK("Category retrieved successfully", CategoryRead), + **ResponseSuccessDoc.HTTP_200_OK("Category fetched successfully", CategoryRead), **ResponseErrorDoc.HTTP_404_NOT_FOUND("Category not found"), + **ResponseErrorDoc.HTTP_500_INTERNAL_SERVER_ERROR("Internal server error"), }, ) async def category_get_by_id( request: Request, category_id: int, category_repository: Annotated[CategoryRepositoryInterface, Depends(get_category_repository)], -) -> CategoryRead: +) -> JSONResponse: category_by_id_usecase = CategoryGetById(category_repository) category_read = await category_by_id_usecase.execute(category_id) return SuccessResult[CategoryRead]( - code=status.HTTP_200_OK, - message="Category retrieved successfully", + code=SuccessCodes.SUCCESS, + message="Category fetched successfully", status_code=status.HTTP_200_OK, data=category_read, ).to_json_response(request) diff --git a/app/modules/user/routers.py b/app/modules/user/routers.py index 4b4da9f..75e8f50 100644 --- a/app/modules/user/routers.py +++ b/app/modules/user/routers.py @@ -3,9 +3,9 @@ from fastapi import APIRouter, Request, status from fastapi.params import Depends -from app.common.http_response.doc_reponses import ResponseErrorDoc, ResponseSuccessDoc +from app.common.http_response.doc_responses import ResponseErrorDoc, ResponseSuccessDoc from app.common.http_response.success_response import SuccessCodes, SuccessResponse -from app.common.http_response.success_result import SuccessResult, success_response_builder +from app.common.http_response.success_result import SuccessResult from app.modules.user.usecases.user_get_by_id import UserGetById from .depends import get_user_repository @@ -48,7 +48,7 @@ async def user_register( data=user_read, ) - return success_response_builder(result, request) + return result.to_json_response(request) @router.get( @@ -74,7 +74,7 @@ async def get_user_by_id( status_code=status.HTTP_200_OK, data=user_read, ) - return success_response_builder(result, request) + return result.to_json_response(request) @router.get( @@ -101,7 +101,7 @@ async def get_user_by_email( status_code=status.HTTP_200_OK, data=user_read, ) - return success_response_builder(result, request) + return result.to_json_response(request) @router.delete( @@ -128,7 +128,7 @@ async def user_delete( data=UserRead.model_validate(user_read), ) - return success_response_builder(result, request) + return result.to_json_response(request) @router.delete( @@ -155,4 +155,4 @@ async def user_delete_by_email( data=UserRead.model_validate(user_read), ) - return success_response_builder(result, request) + return result.to_json_response(request) From f0c12f22500025427873426af26db8bf27df2e25 Mon Sep 17 00:00:00 2001 From: Babak Jahangiri Date: Tue, 4 Nov 2025 22:18:39 +0000 Subject: [PATCH 08/18] change name --- .../{1_register_user_tests.py => 1_user_crud_tests.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename tests/e2e/test_cases/{1_register_user_tests.py => 1_user_crud_tests.py} (96%) diff --git a/tests/e2e/test_cases/1_register_user_tests.py b/tests/e2e/test_cases/1_user_crud_tests.py similarity index 96% rename from tests/e2e/test_cases/1_register_user_tests.py rename to tests/e2e/test_cases/1_user_crud_tests.py index c7e9443..0dbe6a5 100644 --- a/tests/e2e/test_cases/1_register_user_tests.py +++ b/tests/e2e/test_cases/1_user_crud_tests.py @@ -8,12 +8,12 @@ @pytest.mark.order(1) -def test_create_user_success(api_request_context: APIRequestContext, logger: Logger): +def test_register_user_success(api_request_context: APIRequestContext, logger: Logger): response = post_register_user(api_request_context, USER_NORMAL) code = response.json().get("code") - logger.info(response.json().get("message")) assert response.status == 201, f"Expected 201 Created, got {response.status}" assert code == "CREATED", f"Expected code 'CREATED', got {code}" + logger.info(response.json().get("message")) @pytest.mark.order(2) From 096cacec949e55a2bb8c4dfe8f28ca98ca1d3b8a Mon Sep 17 00:00:00 2001 From: Babak Jahangiri Date: Tue, 4 Nov 2025 22:19:32 +0000 Subject: [PATCH 09/18] preapare test structure for pagination --- tests/e2e/data/product.py | 213 +++++++++++++++++- tests/e2e/routes_api_v1/product.py | 10 +- tests/e2e/test_cases/30_product_crud_tests.py | 89 ++++---- 3 files changed, 259 insertions(+), 53 deletions(-) diff --git a/tests/e2e/data/product.py b/tests/e2e/data/product.py index 458726e..789fb86 100644 --- a/tests/e2e/data/product.py +++ b/tests/e2e/data/product.py @@ -1,8 +1,211 @@ -PRODUCT_DATA = {"title": "Product 1", "description": "Test product description 1", "price": 100_000, "sku": "sku1234"} +SAMPLE_PRODUCT = { + "category_id": 1, + "title": "Product Title for Testing", + "description": "This is a sample product used for testing purposes.", + "sku": "SKU-001", + "price": 100_000, + "is_available": True, + "is_visible": True, +} -UPDATE_PRODUCT_DATA = { - "title": "Product 2", - "description": "Test product description 2", +UPDATED_SAMPLE_PRODUCT = { + "category_id": 2, + "title": "Updated Product Title for Testing", + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + "sku": "SKU-002", "price": 200_000, - "sku": "sku5678", + "is_available": False, + "is_visible": False, } + +LIST_PRODUCT = [ + { + "category_id": 1, + "title": "Wireless Headphones", + "description": "High-quality Bluetooth headphones with noise cancellation.", + "sku": "SKU-P001", + "price": 250, + "is_available": True, + "is_visible": True, + }, + { + "category_id": 3, + "title": "Smartphone Stand", + "description": "Adjustable aluminum stand for mobile phones and tablets.", + "sku": "SKU-P002", + "price": 45, + "is_available": True, + "is_visible": True, + }, + { + "category_id": 2, + "title": "Mechanical Keyboard", + "description": "RGB backlit mechanical keyboard with tactile switches.", + "sku": "SKU-P003", + "price": 120, + "is_available": True, + "is_visible": True, + }, + { + "category_id": 5, + "title": "Gaming Mouse", + "description": "Ergonomic gaming mouse with adjustable DPI settings.", + "sku": "SKU-P004", + "price": 60, + "is_available": True, + "is_visible": True, + }, + { + "category_id": 1, + "title": "USB-C Hub", + "description": "7-in-1 USB-C hub with HDMI and card reader support.", + "sku": "SKU-P005", + "price": 75, + "is_available": True, + "is_visible": True, + }, + { + "category_id": 4, + "title": "Portable Charger", + "description": "10000mAh power bank with fast charging capability.", + "sku": "SKU-P006", + "price": 90, + "is_available": True, + "is_visible": True, + }, + { + "category_id": 2, + "title": "Wireless Mouse", + "description": "Compact wireless mouse with long battery life.", + "sku": "SKU-P007", + "price": 35, + "is_available": True, + "is_visible": True, + }, + { + "category_id": 3, + "title": "Bluetooth Speaker", + "description": "Portable Bluetooth speaker with rich bass and stereo sound.", + "sku": "SKU-P008", + "price": 180, + "is_available": True, + "is_visible": True, + }, + { + "category_id": 5, + "title": "Webcam 1080p", + "description": "Full HD webcam with built-in microphone and privacy cover.", + "sku": "SKU-P009", + "price": 95, + "is_available": True, + "is_visible": True, + }, + { + "category_id": 4, + "title": "LED Desk Lamp", + "description": "Adjustable LED desk lamp with touch control and USB port.", + "sku": "SKU-P010", + "price": 55, + "is_available": True, + "is_visible": True, + }, + { + "category_id": 1, + "title": "External SSD", + "description": "500GB portable SSD with USB 3.2 interface.", + "sku": "SKU-P011", + "price": 130, + "is_available": True, + "is_visible": True, + }, + { + "category_id": 3, + "title": "Noise Cancelling Earbuds", + "description": "Compact True wireless earbuds with ANC support.", + "sku": "SKU-P012", + "price": 200, + "is_available": True, + "is_visible": True, + }, + { + "category_id": 5, + "title": "Laptop Sleeve", + "description": "Water-resistant sleeve suitable for 13-inch laptops.", + "sku": "SKU-P013", + "price": 40, + "is_available": True, + "is_visible": True, + }, + { + "category_id": 2, + "title": "Smartwatch", + "description": "Fitness tracking smartwatch with heart rate monitor.", + "sku": "SKU-P014", + "price": 300, + "is_available": True, + "is_visible": True, + }, + { + "category_id": 4, + "title": "HDMI Cable", + "description": "2-meter high-speed HDMI 2.1 cable supporting 8K video.", + "sku": "SKU-P015", + "price": 25, + "is_available": True, + "is_visible": True, + }, + { + "category_id": 3, + "title": "Wireless Charger Pad", + "description": "Fast wireless charger for Qi-compatible devices.", + "sku": "SKU-P016", + "price": 65, + "is_available": True, + "is_visible": True, + }, + { + "category_id": 2, + "title": "4K Monitor", + "description": "27-inch 4K UHD monitor with HDR10 support.", + "sku": "SKU-P017", + "price": 950, + "is_available": True, + "is_visible": True, + }, + { + "category_id": 5, + "title": "Smart Light Bulb", + "description": "Wi-Fi smart bulb with color changing and voice control.", + "sku": "SKU-P018", + "price": 30, + "is_available": True, + "is_visible": True, + }, + { + "category_id": 1, + "title": "Gaming Chair", + "description": "Ergonomic gaming chair with adjustable height and recline.", + "sku": "SKU-P019", + "price": 420, + "is_available": True, + "is_visible": True, + }, + { + "category_id": 2, + "title": "Wireless Router", + "description": "Dual-band gigabit Wi-Fi 6 router with MU-MIMO.", + "sku": "SKU-P020", + "price": 220, + "is_available": True, + "is_visible": True, + }, + { + "category_id": 4, + "title": "Action Camera", + "description": "4K waterproof action camera with image stabilization.", + "sku": "SKU-P021", + "price": 499, + "is_available": True, + "is_visible": True, + }, +] diff --git a/tests/e2e/routes_api_v1/product.py b/tests/e2e/routes_api_v1/product.py index 02c8a56..1d8d8b6 100644 --- a/tests/e2e/routes_api_v1/product.py +++ b/tests/e2e/routes_api_v1/product.py @@ -8,9 +8,17 @@ def post_product(api_request_context: APIRequestContext, data) -> APIResponse: return api_request_context.post(f"{API_URL_V1}/products", headers=HEADER_JSON, data=data) +def get_product(api_request_context: APIRequestContext, product_id: int) -> APIResponse: + return api_request_context.get(f"{API_URL_V1}/products/{product_id}", headers=HEADER_JSON) + + def update_product(api_request_context: APIRequestContext, data, product_id) -> APIResponse: - return api_request_context.patch(f"{API_URL_V1}/products/{product_id}", headers=HEADER_JSON, data=data) + return api_request_context.put(f"{API_URL_V1}/products/{product_id}", headers=HEADER_JSON, data=data) def delete_product(api_request_context: APIRequestContext, product_id) -> APIResponse: return api_request_context.delete(f"{API_URL_V1}/products/{product_id}", headers=HEADER_JSON) + + +def list_products(api_request_context: APIRequestContext) -> APIResponse: + return api_request_context.get(f"{API_URL_V1}/products", headers=HEADER_JSON) diff --git a/tests/e2e/test_cases/30_product_crud_tests.py b/tests/e2e/test_cases/30_product_crud_tests.py index a6cae2c..136f50a 100644 --- a/tests/e2e/test_cases/30_product_crud_tests.py +++ b/tests/e2e/test_cases/30_product_crud_tests.py @@ -1,66 +1,61 @@ -import json from logging import Logger import pytest from playwright.sync_api import APIRequestContext -from tests.e2e.data.product import PRODUCT_DATA, UPDATE_PRODUCT_DATA -from tests.e2e.routes_api_v1.product import delete_product, post_product, update_product +from tests.e2e.data.product import LIST_PRODUCT, SAMPLE_PRODUCT, UPDATED_SAMPLE_PRODUCT +from tests.e2e.routes_api_v1.product import delete_product, get_product, list_products, post_product, update_product -@pytest.mark.order(1) -def _test_product_creation_returns_expected_fields( - api_request_context: APIRequestContext, logger: Logger, create_category_fixture -): - category_id = create_category_fixture["id"] +@pytest.mark.order(30) +def test_create_product_success(api_request_context: APIRequestContext, logger: Logger): + response = post_product(api_request_context, data=SAMPLE_PRODUCT) + code = response.json().get("code") + assert response.status == 201, f"Expected 201 Created, got {response.status}" + assert code == "CREATED", f"Expected code 'CREATED', got {code}" + logger.info(response.json().get("message")) - create_response = post_product(api_request_context, {**PRODUCT_DATA, "category_id": category_id}) - assert create_response.status == 201, f"Expected 201 Created, got {create_response.status}" - created_product = create_response.json()["data"] - # logger.info(created_product) +@pytest.mark.order(31) +def test_get_product_success(api_request_context: APIRequestContext, logger: Logger): + response = get_product(api_request_context, product_id=1) + code = response.json().get("code") + data = response.json().get("data") + assert response.status == 200, f"Expected 200 OK, got {response.status}" + assert code == "SUCCESS", f"Expected code 'SUCCESS', got {code}" - assert created_product["title"] == PRODUCT_DATA["title"] - assert created_product["price"] == PRODUCT_DATA["price"] - assert created_product["sku"] == PRODUCT_DATA["sku"] + # Check that all fields from SAMPLE_PRODUCT are present in the response data + for key, expected_value in SAMPLE_PRODUCT.items(): + assert key in data, f"Expected key '{key}' to be in response data" + actual_value = data[key] + assert actual_value == expected_value, f"Expected {key}='{expected_value}', got '{actual_value}'" + logger.info(response.json().get("message")) -@pytest.mark.order(2) -def _test_product_update_applies_new_values( - api_request_context: APIRequestContext, logger: Logger, create_category_fixture -): - category_id = create_category_fixture["id"] - create_response = post_product(api_request_context, {**PRODUCT_DATA, "category_id": category_id}) - assert create_response.status == 201, f"Expected 201 Created, got {create_response.status}" - created_product = create_response.json()["data"] +@pytest.mark.order(32) +def test_update_product_success(api_request_context: APIRequestContext, logger: Logger): + response = update_product(api_request_context, data=UPDATED_SAMPLE_PRODUCT, product_id=1) + code = response.json().get("code") + assert response.status == 200, f"Expected 200 OK, got {response.status}" + assert code == "SUCCESS", f"Expected code 'SUCCESS', got {code}" + logger.info(response.json().get("message")) - update_response = update_product(api_request_context, created_product["id"], UPDATE_PRODUCT_DATA) - assert update_response.status == 200, f"Expected 200 OK, got {update_response.status}" - updated_product = update_response.json()["data"] - # logger.info(updated_product) +@pytest.mark.order(33) +def test_delete_product_success(api_request_context: APIRequestContext, logger: Logger): + response = delete_product(api_request_context, product_id=1) + code = response.json().get("code") + assert response.status == 200, f"Expected 200 OK, got {response.status}" + assert code == "SUCCESS", f"Expected code 'SUCCESS', got {code}" + logger.info(response.json().get("message")) - assert updated_product["title"] == UPDATE_PRODUCT_DATA["title"] - assert updated_product["price"] == UPDATE_PRODUCT_DATA["price"] - assert updated_product["sku"] == UPDATE_PRODUCT_DATA["sku"] +@pytest.mark.order(34) +def test_get_list_products(api_request_context: APIRequestContext, logger: Logger): + for product in LIST_PRODUCT: + post_product(api_request_context, data=product) -@pytest.mark.order(3) -def _test_product_deletion_returns_deleted_data( - api_request_context: APIRequestContext, logger: Logger, create_category_fixture -): - category_id = create_category_fixture["id"] + response = list_products(api_request_context) - create_response = post_product(api_request_context, {**PRODUCT_DATA, "category_id": category_id}) - assert create_response.status == 201, f"Expected 201 Created, got {create_response.status}" - created_product = create_response.json()["data"] - - delete_response = delete_product(api_request_context, created_product["id"]) - assert delete_response.status == 200, f"Expected 200 OK, got {delete_response.status}" - - deleted_product_data = json.loads(delete_response.headers["x-deleted-product"]) - # logger.info(deleted_product_data) - - assert deleted_product_data["title"] == PRODUCT_DATA["title"] - assert deleted_product_data["sku"] == PRODUCT_DATA["sku"] + logger.info(response.json()) From 871f7e8b259c1533269c22a7d740f426975aaa57 Mon Sep 17 00:00:00 2001 From: Babak Jahangiri Date: Tue, 4 Nov 2025 23:31:49 +0000 Subject: [PATCH 10/18] product pagination with test --- app/common/schemas/pagination.py | 4 +- app/modules/product/repository.py | 12 ++++- app/modules/product/repository_interface.py | 4 ++ app/modules/product/routers.py | 25 +++++++--- app/modules/product/schemas.py | 2 +- app/modules/product/usecases/list_all.py | 28 ----------- .../product/usecases/list_paginated.py | 46 +++++++++++++++++++ tests/e2e/routes_api_v1/product.py | 12 ++++- tests/e2e/test_cases/30_product_crud_tests.py | 38 +++++++++++++-- 9 files changed, 126 insertions(+), 45 deletions(-) delete mode 100644 app/modules/product/usecases/list_all.py create mode 100644 app/modules/product/usecases/list_paginated.py diff --git a/app/common/schemas/pagination.py b/app/common/schemas/pagination.py index a40ab5f..5788d57 100644 --- a/app/common/schemas/pagination.py +++ b/app/common/schemas/pagination.py @@ -11,5 +11,5 @@ class PaginationInfo(BaseModel): limit: int = Field(..., ge=1, le=100, description="Items per page", example=10) total_items: int = Field(..., ge=0, description="Total number of items", example=21) total_pages: int = Field(..., ge=0, description="Total number of pages", example=3) - has_next: bool = Field(..., description="Whether there is a next page", example=True) - has_prev: bool = Field(..., description="Whether there is a previous page", example=False) + has_next: bool = Field(default=False, description="Whether there is a next page", example=True) + has_prev: bool = Field(default=False, description="Whether there is a previous page", example=False) diff --git a/app/modules/product/repository.py b/app/modules/product/repository.py index 36f785e..9e7db8c 100644 --- a/app/modules/product/repository.py +++ b/app/modules/product/repository.py @@ -1,6 +1,6 @@ from typing import Any -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from .models import Product @@ -58,3 +58,13 @@ async def exists_by_field(self, field: str, value: Any) -> bool: stmt = select(Product).where(getattr(Product, field) == value) result = await self.session.execute(stmt) return result.scalar_one_or_none() is not None + + async def count_all(self, category_id: int | None = None) -> int: + """Count total products with optional category filter.""" + query = select(func.count(Product.id)) + + if category_id is not None: + query = query.where(Product.category_id == category_id) + + result = await self.session.execute(query) + return result.scalar() or 0 diff --git a/app/modules/product/repository_interface.py b/app/modules/product/repository_interface.py index 49bd3f7..e84a825 100644 --- a/app/modules/product/repository_interface.py +++ b/app/modules/product/repository_interface.py @@ -28,3 +28,7 @@ async def list_all(self, category_id: int | None = None, skip: int = 0, limit: i @abstractmethod async def exists_by_field(self, field: str, value: Any) -> bool: pass + + @abstractmethod + async def count_all(self, category_id: int | None = None) -> int: + pass diff --git a/app/modules/product/routers.py b/app/modules/product/routers.py index d752997..e03e21d 100644 --- a/app/modules/product/routers.py +++ b/app/modules/product/routers.py @@ -5,11 +5,11 @@ from app.common.http_response.doc_responses import ResponseErrorDoc, ResponseSuccessDoc from app.common.http_response.success_response import SuccessCodes, SuccessResponse from app.common.http_response.success_result import SuccessResult -from app.modules.product.schemas import ProductInCreate, ProductInUpdate, ProductOutRead +from app.modules.product.schemas import ProductInCreate, ProductInUpdate, ProductOutPaginated, ProductOutRead from app.modules.product.usecases.create import ProductCreate from app.modules.product.usecases.delete import ProductDelete from app.modules.product.usecases.get_by_id import ProductGetById -from app.modules.product.usecases.list_all import ProductListAll +from app.modules.product.usecases.list_paginated import ProductListPaginated from app.modules.product.usecases.update import ProductUpdate from .depends import get_product_repository @@ -109,12 +109,23 @@ async def delete_product( return result.to_json_response(request) -@router.get("/", response_model=list[ProductOutRead]) +@router.get("/", response_model=SuccessResponse[ProductOutPaginated]) async def list_all_products( + request: Request, product_repository: Annotated[ProductRepositoryInterface, Depends(get_product_repository)], category_id: int | None = None, page: int = 1, - per_page: int = 10, -) -> list[ProductOutRead]: - products = await ProductListAll(product_repository).execute(category_id=category_id, page=page, per_page=per_page) - return products + limit: int = 10, +) -> SuccessResponse[ProductOutPaginated]: + product_list_paginated = await ProductListPaginated(product_repository).execute( + category_id=category_id, page=page, limit=limit + ) + + result = SuccessResult[ProductOutPaginated]( + code=SuccessCodes.SUCCESS, + message=f"{product_list_paginated.pagination.total_items} item(s) are listed successfully", + status_code=status.HTTP_200_OK, + data=product_list_paginated, + ) + + return result.to_json_response(request) diff --git a/app/modules/product/schemas.py b/app/modules/product/schemas.py index 1ca9fb8..c1dbd2e 100644 --- a/app/modules/product/schemas.py +++ b/app/modules/product/schemas.py @@ -55,7 +55,7 @@ class ProductOutRead(ProductBase): model_config = {"from_attributes": True} -class ProductOutList(BaseModel): +class ProductOutPaginated(BaseModel): """ Used for listing products with pagination support. Contains a list of ProductOutRead items and pagination metadata. diff --git a/app/modules/product/usecases/list_all.py b/app/modules/product/usecases/list_all.py deleted file mode 100644 index da63f60..0000000 --- a/app/modules/product/usecases/list_all.py +++ /dev/null @@ -1,28 +0,0 @@ -from app.common.exceptions.app_exceptions import DatabaseOperationException, EntityNotFoundException -from app.modules.product.models import Product -from app.modules.product.repository_interface import ProductRepositoryInterface -from app.modules.product.schemas import ProductOutRead - - -class ProductListAll: - def __init__(self, product_repository: ProductRepositoryInterface) -> None: - self.product_repository = product_repository - - async def execute(self, category_id: int | None = None, page: int = 1, per_page: int = 10) -> list[ProductOutRead]: - try: - skip = (page - 1) * per_page - products: list[Product] = await self.product_repository.list_all( - category_id=category_id, skip=skip, limit=per_page - ) - if not products: - raise EntityNotFoundException( - data={"category_id": category_id, "page": page, "per_page": per_page}, message="No products found" - ) - except Exception as e: - raise DatabaseOperationException( - operation="select", - message=str(e), - data={"category_id": category_id, "page": page, "per_page": per_page}, - ) - - return [ProductOutRead.model_validate(product) for product in products] diff --git a/app/modules/product/usecases/list_paginated.py b/app/modules/product/usecases/list_paginated.py new file mode 100644 index 0000000..db1c642 --- /dev/null +++ b/app/modules/product/usecases/list_paginated.py @@ -0,0 +1,46 @@ +from app.common.exceptions.app_exceptions import DatabaseOperationException +from app.common.schemas.pagination import PaginationInfo +from app.modules.product.models import Product +from app.modules.product.repository_interface import ProductRepositoryInterface +from app.modules.product.schemas import ProductOutPaginated, ProductOutRead + + +class ProductListPaginated: + def __init__(self, product_repository: ProductRepositoryInterface) -> None: + self.product_repository = product_repository + + async def execute(self, category_id: int | None = None, page: int = 1, limit: int = 10) -> ProductOutPaginated: + try: + skip = (page - 1) * limit + + # Get total count for pagination + total_items = await self.product_repository.count_all(category_id=category_id) + + # Get products for current page + products: list[Product] = await self.product_repository.list_all( + category_id=category_id, skip=skip, limit=limit + ) + + except Exception as e: + raise DatabaseOperationException( + operation="select", + message=str(e), + data={"category_id": category_id, "page": page, "limit": limit}, + ) + + # Calculate pagination info + total_pages = (total_items + limit - 1) // limit # Ceiling division + has_next = page < total_pages + has_prev = page > 1 + + return ProductOutPaginated( + items=[ProductOutRead.model_validate(product) for product in products], + pagination=PaginationInfo( + page=page, + limit=limit, + total_items=total_items, + total_pages=total_pages, + has_next=has_next, + has_prev=has_prev, + ), + ) diff --git a/tests/e2e/routes_api_v1/product.py b/tests/e2e/routes_api_v1/product.py index 1d8d8b6..7aab9a4 100644 --- a/tests/e2e/routes_api_v1/product.py +++ b/tests/e2e/routes_api_v1/product.py @@ -20,5 +20,13 @@ def delete_product(api_request_context: APIRequestContext, product_id) -> APIRes return api_request_context.delete(f"{API_URL_V1}/products/{product_id}", headers=HEADER_JSON) -def list_products(api_request_context: APIRequestContext) -> APIResponse: - return api_request_context.get(f"{API_URL_V1}/products", headers=HEADER_JSON) +def list_paginated_products(api_request_context: APIRequestContext, page: int, limit: int) -> APIResponse: + return api_request_context.get(f"{API_URL_V1}/products", headers=HEADER_JSON, params={"page": page, "limit": limit}) + + +def list_paginated_products_per_category( + api_request_context: APIRequestContext, category_id: int, page: int, limit: int +) -> APIResponse: + return api_request_context.get( + f"{API_URL_V1}/products", headers=HEADER_JSON, params={"category_id": category_id, "page": page, "limit": limit} + ) diff --git a/tests/e2e/test_cases/30_product_crud_tests.py b/tests/e2e/test_cases/30_product_crud_tests.py index 136f50a..8126345 100644 --- a/tests/e2e/test_cases/30_product_crud_tests.py +++ b/tests/e2e/test_cases/30_product_crud_tests.py @@ -4,7 +4,13 @@ from playwright.sync_api import APIRequestContext from tests.e2e.data.product import LIST_PRODUCT, SAMPLE_PRODUCT, UPDATED_SAMPLE_PRODUCT -from tests.e2e.routes_api_v1.product import delete_product, get_product, list_products, post_product, update_product +from tests.e2e.routes_api_v1.product import ( + delete_product, + get_product, + list_paginated_products, + post_product, + update_product, +) @pytest.mark.order(30) @@ -52,10 +58,34 @@ def test_delete_product_success(api_request_context: APIRequestContext, logger: @pytest.mark.order(34) -def test_get_list_products(api_request_context: APIRequestContext, logger: Logger): +def test_get_paginated_list_products(api_request_context: APIRequestContext, logger: Logger): + # Create all products from LIST_PRODUCT for product in LIST_PRODUCT: post_product(api_request_context, data=product) - response = list_products(api_request_context) + # Test pagination with page=1, per_page=5 + response = list_paginated_products(api_request_context, page=1, limit=5) + code = response.json().get("code") + data = response.json().get("data") - logger.info(response.json()) + # Validate response structure + assert response.status == 200, f"Expected 200 OK, got {response.status}" + assert code == "SUCCESS", f"Expected code 'SUCCESS', got {code}" + assert "items" in data, "Expected 'items' in response data" + assert "pagination" in data, "Expected 'pagination' in response data" + + # Validate items + items = data["items"] + assert len(items) == 5, f"Expected 5 items on page 1, got {len(items)}" + + # Validate pagination info + pagination = data["pagination"] + assert pagination["page"] == 1, f"Expected page=1, got {pagination['page']}" + assert pagination["limit"] == 5, f"Expected limit=5, got {pagination['limit']}" + assert pagination["total_items"] == 21, f"Expected total_items=21, got {pagination['total_items']}" + assert pagination["total_pages"] == 5, f"Expected total_pages=5, got {pagination['total_pages']}" + assert not pagination["has_prev"], f"Expected has_prev=False for page 1, got {pagination['has_prev']}" + assert pagination["has_next"], f"Expected has_next=True for page 1, got {pagination['has_next']}" + + logger.info(f"Successfully validated pagination: {pagination}") + logger.info(f"Retrieved {len(items)} items for page 1") From 1df321519bd8e3d6e5ba81ccd36eaf7c98d96671 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Nov 2025 23:40:42 +0000 Subject: [PATCH 11/18] Initial plan From ee7dccf13ea31fbb7c40027ba6c2ab4bf7cffffa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Nov 2025 23:41:12 +0000 Subject: [PATCH 12/18] Initial plan From 29139d130c418335d2611257122af94b0e37e4eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Nov 2025 23:46:10 +0000 Subject: [PATCH 13/18] Update datetime usage to use datetime.UTC for Python 3.13+ Co-authored-by: babakjahan <5642363+babakjahan@users.noreply.github.com> --- app/common/http_response/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/common/http_response/base.py b/app/common/http_response/base.py index 34c6b7c..6b31a91 100644 --- a/app/common/http_response/base.py +++ b/app/common/http_response/base.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import datetime from pydantic import BaseModel, Field @@ -16,5 +16,5 @@ class BaseResponse(BaseModel): status: int message: str - timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + timestamp: datetime = Field(default_factory=lambda: datetime.now(datetime.UTC)) path: str = Field(default="/") From 344fa46afb4813b7d7e905a5e55fd093b54d2af4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Nov 2025 23:46:13 +0000 Subject: [PATCH 14/18] Fix capitalization: Change 'True wireless' to 'true wireless' for consistency Co-authored-by: babakjahan <5642363+babakjahan@users.noreply.github.com> --- tests/e2e/data/product.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/data/product.py b/tests/e2e/data/product.py index 789fb86..562047e 100644 --- a/tests/e2e/data/product.py +++ b/tests/e2e/data/product.py @@ -121,7 +121,7 @@ { "category_id": 3, "title": "Noise Cancelling Earbuds", - "description": "Compact True wireless earbuds with ANC support.", + "description": "Compact true wireless earbuds with ANC support.", "sku": "SKU-P012", "price": 200, "is_available": True, From 70ecd3e98a4be84f73fd2ebfa6297e129e79986a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Nov 2025 23:55:08 +0000 Subject: [PATCH 15/18] Initial plan From 0be0c639ed7d25ec8d3b2923436d70b0c5c9b7c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Nov 2025 23:59:12 +0000 Subject: [PATCH 16/18] Remove redundant check before DELETE in sqlite_sequence Co-authored-by: babakjahan <5642363+babakjahan@users.noreply.github.com> --- tests/e2e/global_setup.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/e2e/global_setup.py b/tests/e2e/global_setup.py index ba3bf94..188d733 100644 --- a/tests/e2e/global_setup.py +++ b/tests/e2e/global_setup.py @@ -46,17 +46,13 @@ async def clear_database(): ) if sequence_check.fetchone(): logger.info("sqlite_sequence table found, resetting auto-increment counters...") - # Only reset sequences for tables that exist in sqlite_sequence + # Delete sequences for all tables (DELETE succeeds even if row doesn't exist) for table in tables: table_name = table[0] - sequence_exists = await session.execute( - text("SELECT name FROM sqlite_sequence WHERE name=:table_name"), {"table_name": table_name} + await session.execute( + text("DELETE FROM sqlite_sequence WHERE name=:table_name"), {"table_name": table_name} ) - if sequence_exists.fetchone(): - await session.execute( - text("DELETE FROM sqlite_sequence WHERE name=:table_name"), {"table_name": table_name} - ) - logger.info(f"Reset sequence for: {table_name}") + logger.info(f"Reset sequence for: {table_name}") else: logger.info("No sqlite_sequence table found, skipping sequence reset") From c589a18e6c322316013f9a61d9074ce9218ddd89 Mon Sep 17 00:00:00 2001 From: Babak Date: Wed, 5 Nov 2025 00:06:00 +0000 Subject: [PATCH 17/18] Update app/modules/product/usecases/delete.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/modules/product/usecases/delete.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/modules/product/usecases/delete.py b/app/modules/product/usecases/delete.py index a42a22c..96fd073 100644 --- a/app/modules/product/usecases/delete.py +++ b/app/modules/product/usecases/delete.py @@ -19,4 +19,4 @@ async def execute(self, product_id: int) -> ProductOutRead: data={"product_id": product_id}, message=f"Product with ID {product_id} not found." ) - return ProductOutRead.model_validate(product, by_name=True) + return ProductOutRead.model_validate(product) From 9bb2c5fd8401787b56232849db1cb42dc64d7eb6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 00:07:47 +0000 Subject: [PATCH 18/18] Initial plan