From 0a45ecb1c0e74643c9bc6d8cd76527604e032722 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sat, 11 Jul 2026 10:23:31 -0400 Subject: [PATCH] =?UTF-8?q?feat(wc-catalog-expansion):=20Stage=209C=20?= =?UTF-8?q?=E2=80=94=20WooCommerce=20catalog=20expansion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ProductCategory domain model (id, name, slug, parentId, count) with copyWith, equality, toString - Add ProductImage domain model (id, url, alt, position) with copyWith, equality - Extend ProductPublishingRepository contract: createProduct, deleteProduct, updateProductImages, getProductImages, getCategories, createCategory, updateCategory, deleteCategory - Implement all 8 new methods in FakeProductPublishingRepository with seed categories - Implement all 8 new methods in WordPressProductPublishingRepository - Add WooCommerceApiClient methods: createProduct, deleteProduct, getCategories (paginated), createCategory, updateCategory, deleteCategory, getProductImages, setProductImages - Add use cases: CreateProduct, DeleteProduct, GetCategories, CreateCategory, UpdateProductImages - Update feature_wordpress.dart barrel exports - Update test stubs in dashboard_controller_test.dart and product_publishing_page_test.dart - Add 69 new tests in wc_catalog_expansion_test.dart (388 total feature_wordpress, 24 kell_web — all passing) - Update master_development_brief.md and build_execution_tracker.md --- commit_msg.txt | 13 + docs/development/build_execution_tracker.md | 32 +- docs/development/master_development_brief.md | 31 +- .../dashboard_controller_test.dart | 35 + .../lib/feature_wordpress.dart | 7 + .../lib/src/application/create_category.dart | 16 + .../lib/src/application/create_product.dart | 31 + .../lib/src/application/delete_product.dart | 16 + .../lib/src/application/get_categories.dart | 16 + .../application/update_product_images.dart | 20 + .../fake_product_publishing_repository.dart | 163 +++ .../lib/src/data/woo_commerce_api_client.dart | 259 +++++ ...rdpress_product_publishing_repository.dart | 107 +- .../lib/src/domain/product_category.dart | 53 + .../lib/src/domain/product_image.dart | 47 + .../domain/product_publishing_repository.dart | 52 + .../test/wc_catalog_expansion_test.dart | 995 ++++++++++++++++++ .../widgets/product_publishing_page_test.dart | 36 + 18 files changed, 1913 insertions(+), 16 deletions(-) create mode 100644 commit_msg.txt create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/application/create_category.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/application/create_product.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/application/delete_product.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/application/get_categories.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/application/update_product_images.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_category.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_image.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/test/wc_catalog_expansion_test.dart diff --git a/commit_msg.txt b/commit_msg.txt new file mode 100644 index 0000000..75e8293 --- /dev/null +++ b/commit_msg.txt @@ -0,0 +1,13 @@ +feat(wc-catalog-expansion): Stage 9C — WooCommerce catalog expansion + +- Add ProductCategory domain model (id, name, slug, parentId, count) with copyWith, equality, toString +- Add ProductImage domain model (id, url, alt, position) with copyWith, equality +- Extend ProductPublishingRepository contract: createProduct, deleteProduct, updateProductImages, getProductImages, getCategories, createCategory, updateCategory, deleteCategory +- Implement all 8 new methods in FakeProductPublishingRepository with seed categories +- Implement all 8 new methods in WordPressProductPublishingRepository +- Add WooCommerceApiClient methods: createProduct, deleteProduct, getCategories (paginated), createCategory, updateCategory, deleteCategory, getProductImages, setProductImages +- Add use cases: CreateProduct, DeleteProduct, GetCategories, CreateCategory, UpdateProductImages +- Update feature_wordpress.dart barrel exports +- Update test stubs in dashboard_controller_test.dart and product_publishing_page_test.dart +- Add 69 new tests in wc_catalog_expansion_test.dart (388 total feature_wordpress, 24 kell_web — all passing) +- Update master_development_brief.md and build_execution_tracker.md diff --git a/docs/development/build_execution_tracker.md b/docs/development/build_execution_tracker.md index 770850e..c54bbcb 100644 --- a/docs/development/build_execution_tracker.md +++ b/docs/development/build_execution_tracker.md @@ -2,9 +2,9 @@ ## Current status -- main baseline updated through: feat/wc-inventory-sync (Stage 9B complete) -- next branch: feat/wc-catalog-expansion (Stage 9C) -- current stage: Stage 9B complete — WooCommerce stock/inventory sync activated +- main baseline updated through: feat/wc-catalog-expansion (Stage 9C complete) +- next branch: feat/wc-catalog-expansion-ui (Stage 9D — product creation UI, image picker, category management page) +- current stage: Stage 9C complete — WooCommerce catalog expansion (product create/delete, category CRUD, image management) ## Slice tracker @@ -506,3 +506,29 @@ - tests: passed (132/132 feature_inventory, 24/24 kell_web, 319/319 feature_wordpress — all passing) - analyze: passed (dart analyze — no issues found) - brief updated: yes + +### feat/wc-catalog-expansion + +- status: merged to main +- date: 2026-07-11 +- inspection: complete +- implementation: complete +- files changed: + - `feature_wordpress/lib/src/domain/product_category.dart` — new `ProductCategory` value object with `id`, `name`, `slug`, `parentId`, `count`; `copyWith()`, `==`/`hashCode`, `toString()` + - `feature_wordpress/lib/src/domain/product_image.dart` — new `ProductImage` value object with `id`, `url`, `alt`, `position`; `copyWith()`, `==`/`hashCode` + - `feature_wordpress/lib/src/domain/product_publishing_repository.dart` — added `createProduct()`, `deleteProduct()`, `updateProductImages()`, `getProductImages()`, `getCategories()`, `createCategory()`, `updateCategory()`, `deleteCategory()` to contract + - `feature_wordpress/lib/src/data/fake_product_publishing_repository.dart` — implemented all 8 new repository methods; added seed categories (Jewelry, Accessories, Home Decor, Kitchen Accessories); `createProduct()` resolves category name from id; `createCategory()` auto-generates slug from name; `updateCategory()` and `deleteCategory()` throw `StateError` for unknown ids + - `feature_wordpress/lib/src/data/woo_commerce_api_client.dart` — added `createProduct()`, `deleteProduct()`, `getCategories()`, `createCategory()`, `updateCategory()`, `deleteCategory()`, `getProductImages()`, `setProductImages()` methods; `getCategories()` paginates with `per_page=100` + - `feature_wordpress/lib/src/data/wordpress_product_publishing_repository.dart` — implemented all 8 new repository methods delegating to `WooCommerceApiClient`; maps JSON ↔ domain models + - `feature_wordpress/lib/src/application/create_product.dart` — new `CreateProduct` use case + - `feature_wordpress/lib/src/application/delete_product.dart` — new `DeleteProduct` use case + - `feature_wordpress/lib/src/application/get_categories.dart` — new `GetCategories` use case + - `feature_wordpress/lib/src/application/create_category.dart` — new `CreateCategory` use case + - `feature_wordpress/lib/src/application/update_product_images.dart` — new `UpdateProductImages` use case + - `feature_wordpress/lib/feature_wordpress.dart` — exported all new domain models and use cases + - `feature_wordpress/test/wc_catalog_expansion_test.dart` — 69 new tests covering `ProductCategory`, `ProductImage`, `FakeProductPublishingRepository` (all new methods), `WooCommerceApiClient` (all new methods), `WordPressProductPublishingRepository` (all new methods), and all 5 use cases + - `kell_web/test/dashboard/application/dashboard_controller_test.dart` — updated `_StubProductPublishingRepository` to implement expanded `ProductPublishingRepository` contract + - `kell_creations_apps/packages/feature_wordpress/test/widgets/product_publishing_page_test.dart` — updated `_FakeProductPublishingRepository` stub to implement expanded contract +- tests: passed (388/388 feature_wordpress, 24/24 kell_web — all passing) +- analyze: passed (dart analyze — no issues found) +- brief updated: yes diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index 42d50d6..0018a7b 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -594,7 +594,7 @@ Enable bidirectional stock quantity synchronization between the app's inventory - Update `FakeInventoryRepository` with matching write methods - Tests for sync service, repository, and stock update flows -#### Stage 9C — WooCommerce catalog expansion +#### Stage 9C — WooCommerce catalog expansion ✅ COMPLETE ##### Goal @@ -602,16 +602,25 @@ Add product creation, image management, and category CRUD to complete full catal ##### Requirements -- Add `createProduct(data)`, `deleteProduct(id)` to `WooCommerceApiClient` -- Add `getCategories()`, `createCategory()`, `updateCategory()`, `deleteCategory()` to `WooCommerceApiClient` -- Add `uploadMedia(file)`, `getMedia(productId)`, `setProductImages(productId, mediaIds)` to `WooCommerceApiClient` -- Extend `ProductPublishingRepository`: `createProduct()`, `deleteProduct()`, `updateProductImages()`, `getCategories()`, `createCategory()` -- Add `ProductCategory` domain model (id, name, slug, parent, count) -- Add `ProductImage` domain model (id, url, alt, position) -- Add product creation UI — form with name, description, price, category, SKU, images -- Add image picker/upload UI for mobile and web -- Add category management page -- Tests for all new repository methods, use cases, and UI +- ✅ Add `createProduct(data)`, `deleteProduct(id)` to `WooCommerceApiClient` +- ✅ Add `getCategories()`, `createCategory()`, `updateCategory()`, `deleteCategory()` to `WooCommerceApiClient` +- ✅ Add `getProductImages(productId)`, `setProductImages(productId, mediaIds)` to `WooCommerceApiClient` +- ✅ Extend `ProductPublishingRepository`: `createProduct()`, `deleteProduct()`, `updateProductImages()`, `getCategories()`, `createCategory()`, `updateCategory()`, `deleteCategory()`, `getProductImages()` +- ✅ Add `ProductCategory` domain model (id, name, slug, parentId, count) with `copyWith`, equality, `toString` +- ✅ Add `ProductImage` domain model (id, url, alt, position) with `copyWith`, equality +- ✅ Implement all new methods in `FakeProductPublishingRepository` (with seed categories) +- ✅ Implement all new methods in `WordPressProductPublishingRepository` +- ✅ Add use cases: `CreateProduct`, `DeleteProduct`, `GetCategories`, `CreateCategory`, `UpdateProductImages` +- ✅ Update barrel exports in `feature_wordpress.dart` +- ✅ Update test stubs in `dashboard_controller_test.dart` and `product_publishing_page_test.dart` +- ✅ 69 new tests in `wc_catalog_expansion_test.dart` covering domain models, fake repo, API client, real repo, and use cases +- ⏳ Product creation UI — form with name, description, price, category, SKU, images (deferred to Stage 9D) +- ⏳ Image picker/upload UI for mobile and web (deferred to Stage 9D) +- ⏳ Category management page (deferred to Stage 9D) + +##### Branch + +`feat/wc-catalog-expansion` — merged to `main` --- diff --git a/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart b/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart index d3c8b06..f323f8c 100644 --- a/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart +++ b/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart @@ -68,6 +68,41 @@ class _StubProductPublishingRepository implements ProductPublishingRepository { @override Future updateProductCategory(String id, String category) => throw UnimplementedError(); + + // Stage 9C additions + @override + Future createProduct({ + required String name, + required double price, + String description = '', + String sku = '', + int? categoryId, + PublishStatus status = PublishStatus.draft, + }) => throw UnimplementedError(); + + @override + Future deleteProduct(String id, {bool force = false}) => throw UnimplementedError(); + + @override + Future updateProductImages(String id, List imageIds) => + throw UnimplementedError(); + + @override + Future> getCategories() => throw UnimplementedError(); + + @override + Future createCategory({required String name, int parentId = 0}) => + throw UnimplementedError(); + + @override + Future updateCategory(int categoryId, {String? name, int? parentId}) => + throw UnimplementedError(); + + @override + Future deleteCategory(int categoryId, {bool force = true}) => throw UnimplementedError(); + + @override + Future> getProductImages(String id) => throw UnimplementedError(); } class _StubOrdersRepository implements OrdersRepository { diff --git a/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart b/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart index 0d68ce4..40e0f60 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart @@ -7,16 +7,23 @@ export 'src/data/wordpress_product_mapper.dart'; export 'src/data/wordpress_product_publishing_repository.dart'; // Domain +export 'src/domain/product_category.dart'; export 'src/domain/product_draft.dart'; +export 'src/domain/product_image.dart'; export 'src/domain/product_publishing_repository.dart'; export 'src/domain/publish_status.dart'; // Application +export 'src/application/create_category.dart'; +export 'src/application/create_product.dart'; +export 'src/application/delete_product.dart'; +export 'src/application/get_categories.dart'; export 'src/application/get_product_drafts.dart'; export 'src/application/product_publishing_controller.dart'; export 'src/application/publish_product.dart'; export 'src/application/update_product_category.dart'; export 'src/application/update_product_description.dart'; +export 'src/application/update_product_images.dart'; export 'src/application/update_product_name.dart'; export 'src/application/update_product_price.dart'; export 'src/application/update_product_status.dart'; diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/application/create_category.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/application/create_category.dart new file mode 100644 index 0000000..1269aeb --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/application/create_category.dart @@ -0,0 +1,16 @@ +import '../domain/product_category.dart'; +import '../domain/product_publishing_repository.dart'; + +/// Use case: create a new product category. +/// +/// Delegates to [ProductPublishingRepository.createCategory] and returns the +/// created [ProductCategory]. +class CreateCategory { + final ProductPublishingRepository _repository; + + const CreateCategory(this._repository); + + Future call({required String name, int parentId = 0}) { + return _repository.createCategory(name: name, parentId: parentId); + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/application/create_product.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/application/create_product.dart new file mode 100644 index 0000000..966d09b --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/application/create_product.dart @@ -0,0 +1,31 @@ +import '../domain/product_draft.dart'; +import '../domain/product_publishing_repository.dart'; +import '../domain/publish_status.dart'; + +/// Use case: create a new product in the catalog. +/// +/// Delegates to [ProductPublishingRepository.createProduct] and returns the +/// created [ProductDraft]. +class CreateProduct { + final ProductPublishingRepository _repository; + + const CreateProduct(this._repository); + + Future call({ + required String name, + required double price, + String description = '', + String sku = '', + int? categoryId, + PublishStatus status = PublishStatus.draft, + }) { + return _repository.createProduct( + name: name, + price: price, + description: description, + sku: sku, + categoryId: categoryId, + status: status, + ); + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/application/delete_product.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/application/delete_product.dart new file mode 100644 index 0000000..708ba8c --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/application/delete_product.dart @@ -0,0 +1,16 @@ +import '../domain/product_publishing_repository.dart'; + +/// Use case: delete a product from the catalog. +/// +/// Delegates to [ProductPublishingRepository.deleteProduct]. +/// When [force] is `true` the product is permanently deleted; otherwise it is +/// moved to trash (WooCommerce default). +class DeleteProduct { + final ProductPublishingRepository _repository; + + const DeleteProduct(this._repository); + + Future call(String id, {bool force = false}) { + return _repository.deleteProduct(id, force: force); + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/application/get_categories.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/application/get_categories.dart new file mode 100644 index 0000000..0e9564a --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/application/get_categories.dart @@ -0,0 +1,16 @@ +import '../domain/product_category.dart'; +import '../domain/product_publishing_repository.dart'; + +/// Use case: retrieve all product categories from the store. +/// +/// Delegates to [ProductPublishingRepository.getCategories] and returns the +/// list of [ProductCategory] objects. +class GetCategories { + final ProductPublishingRepository _repository; + + const GetCategories(this._repository); + + Future> call() { + return _repository.getCategories(); + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/application/update_product_images.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/application/update_product_images.dart new file mode 100644 index 0000000..1ca0ca1 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/application/update_product_images.dart @@ -0,0 +1,20 @@ +import '../domain/product_draft.dart'; +import '../domain/product_publishing_repository.dart'; + +/// Use case: update the images attached to a product. +/// +/// [imageIds] is an ordered list of WooCommerce media attachment IDs. +/// The first ID becomes the featured image (position 0). +/// Pass an empty list to remove all images. +/// +/// Delegates to [ProductPublishingRepository.updateProductImages] and returns +/// the updated [ProductDraft] reflecting the new primary image URL. +class UpdateProductImages { + final ProductPublishingRepository _repository; + + const UpdateProductImages(this._repository); + + Future call(String id, List imageIds) { + return _repository.updateProductImages(id, imageIds); + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/data/fake_product_publishing_repository.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/data/fake_product_publishing_repository.dart index 67fc5dc..cf0050b 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/src/data/fake_product_publishing_repository.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/data/fake_product_publishing_repository.dart @@ -1,4 +1,6 @@ +import '../domain/product_category.dart'; import '../domain/product_draft.dart'; +import '../domain/product_image.dart'; import '../domain/product_publishing_repository.dart'; import '../domain/publish_status.dart'; @@ -86,6 +88,29 @@ class FakeProductPublishingRepository implements ProductPublishingRepository { ), ]; + /// Seed categories matching the product data above. + final List _categories = [ + ProductCategory(id: 1, name: 'Bowl Cozies', slug: 'bowl-cozies', parentId: 0, count: 1), + ProductCategory(id: 2, name: 'Coasters', slug: 'coasters', parentId: 0, count: 2), + ProductCategory(id: 3, name: 'Nightlights', slug: 'nightlights', parentId: 0, count: 1), + ProductCategory( + id: 4, + name: 'Kitchen Accessories', + slug: 'kitchen-accessories', + parentId: 0, + count: 2, + ), + ]; + + /// Fake image store: productId → list of images. + final Map> _productImages = {}; + + /// Auto-increment counter for new product IDs. + int _nextId = 7; + + /// Auto-increment counter for new category IDs. + int _nextCategoryId = 5; + @override Future> getProductDrafts() async { // Simulate network latency. @@ -185,4 +210,142 @@ class FakeProductPublishingRepository implements ProductPublishingRepository { _drafts[index] = updated; return updated; } + + // ── Stage 9C: Catalog expansion ────────────────────────────────────────── + + @override + Future createProduct({ + required String name, + required double price, + String description = '', + String sku = '', + int? categoryId, + PublishStatus status = PublishStatus.draft, + }) async { + await Future.delayed(const Duration(milliseconds: 400)); + + // Resolve category name from ID if provided. + String categoryName = 'Uncategorized'; + if (categoryId != null) { + final cat = _categories.where((c) => c.id == categoryId).firstOrNull; + if (cat != null) categoryName = cat.name; + } + + final newDraft = ProductDraft( + id: (_nextId++).toString(), + name: name, + description: description, + price: price, + sku: sku, + category: categoryName, + imageUrl: '', + status: status, + lastModified: DateTime.now(), + ); + + _drafts.add(newDraft); + return newDraft; + } + + @override + Future deleteProduct(String id, {bool force = false}) async { + await Future.delayed(const Duration(milliseconds: 400)); + + final index = _drafts.indexWhere((d) => d.id == id); + if (index == -1) { + throw StateError('Draft with id $id not found'); + } + + _drafts.removeAt(index); + _productImages.remove(id); + } + + @override + Future updateProductImages(String id, List imageIds) async { + await Future.delayed(const Duration(milliseconds: 400)); + + final index = _drafts.indexWhere((d) => d.id == id); + if (index == -1) { + throw StateError('Draft with id $id not found'); + } + + // Build fake image objects from the provided IDs. + final images = imageIds.asMap().entries.map((entry) { + return ProductImage( + id: entry.value, + url: 'https://fake.example.com/media/${entry.value}.jpg', + alt: '', + position: entry.key, + ); + }).toList(); + + _productImages[id] = images; + + // Update the draft's primary imageUrl from the first image. + final primaryUrl = images.isNotEmpty ? images.first.url : ''; + final updated = _drafts[index].copyWith(imageUrl: primaryUrl, lastModified: DateTime.now()); + _drafts[index] = updated; + return updated; + } + + @override + Future> getCategories() async { + await Future.delayed(const Duration(milliseconds: 300)); + return List.unmodifiable(_categories); + } + + @override + Future createCategory({required String name, int parentId = 0}) async { + await Future.delayed(const Duration(milliseconds: 400)); + + final slug = name.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '-'); + final newCategory = ProductCategory( + id: _nextCategoryId++, + name: name, + slug: slug, + parentId: parentId, + count: 0, + ); + + _categories.add(newCategory); + return newCategory; + } + + @override + Future updateCategory(int categoryId, {String? name, int? parentId}) async { + await Future.delayed(const Duration(milliseconds: 400)); + + final index = _categories.indexWhere((c) => c.id == categoryId); + if (index == -1) { + throw StateError('Category with id $categoryId not found'); + } + + final original = _categories[index]; + final updatedName = name ?? original.name; + final updatedSlug = name != null + ? name.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '-') + : original.slug; + + final updated = original.copyWith(name: updatedName, slug: updatedSlug, parentId: parentId); + _categories[index] = updated; + return updated; + } + + @override + Future deleteCategory(int categoryId, {bool force = true}) async { + await Future.delayed(const Duration(milliseconds: 400)); + + final index = _categories.indexWhere((c) => c.id == categoryId); + if (index == -1) { + throw StateError('Category with id $categoryId not found'); + } + + _categories.removeAt(index); + } + + @override + Future> getProductImages(String id) async { + await Future.delayed(const Duration(milliseconds: 300)); + return List.unmodifiable(_productImages[id] ?? []); + } } diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart index 40b3959..8504944 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart @@ -436,6 +436,265 @@ class WooCommerceApiClient implements ApiClient { return decoded; } + + /// Creates a new product in WooCommerce. + /// + /// [body] should contain the WooCommerce product fields (name, regular_price, + /// description, sku, status, categories, etc.). + /// Returns the created product JSON (HTTP 201). + Future> createProduct(Map body) async { + final uri = Uri.parse('$_baseEndpoint/products'); + final response = await _httpClient.post(uri, headers: _authHeaders, body: jsonEncode(body)); + + if (response.statusCode != 201) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Failed to create product: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + return decoded; + } + + /// Deletes a WooCommerce product by [productId]. + /// + /// When [force] is `true` the product is permanently deleted; otherwise it + /// is moved to trash (WooCommerce default). + /// Returns the deleted product JSON that WooCommerce echoes back. + Future> deleteProduct(String productId, {bool force = false}) async { + final uri = Uri.parse( + '$_baseEndpoint/products/$productId', + ).replace(queryParameters: {'force': force.toString()}); + + final response = await _httpClient.delete(uri, headers: _authHeaders); + + if (response.statusCode != 200) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Failed to delete product $productId: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + return decoded; + } + + // ── Categories API ──────────────────────────────────────────────────────── + + /// Fetches all product categories from WooCommerce. + /// + /// Paginates automatically until all categories are retrieved. + /// Returns the raw JSON list of category maps. + Future>> getCategories({int perPage = 100}) async { + final allCategories = >[]; + var page = 1; + + while (true) { + final uri = Uri.parse( + '$_baseEndpoint/products/categories', + ).replace(queryParameters: {'page': page.toString(), 'per_page': perPage.toString()}); + + final response = await _httpClient.get(uri, headers: _authHeaders); + + if (response.statusCode != 200) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Failed to fetch categories: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! List) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON array', + body: response.body, + ); + } + + final batch = decoded.cast>(); + allCategories.addAll(batch); + if (batch.length < perPage) break; + page++; + } + + return allCategories; + } + + /// Creates a new product category in WooCommerce. + /// + /// [body] should contain at minimum `name`. Optional: `slug`, `parent`. + /// Returns the created category JSON (HTTP 201). + Future> createCategory(Map body) async { + final uri = Uri.parse('$_baseEndpoint/products/categories'); + final response = await _httpClient.post(uri, headers: _authHeaders, body: jsonEncode(body)); + + if (response.statusCode != 201) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Failed to create category: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + return decoded; + } + + /// Updates an existing product category in WooCommerce. + /// + /// [body] may contain `name`, `slug`, `parent`, `description`, etc. + /// Returns the updated category JSON. + Future> updateCategory(int categoryId, Map body) async { + final uri = Uri.parse('$_baseEndpoint/products/categories/$categoryId'); + final response = await _httpClient.put(uri, headers: _authHeaders, body: jsonEncode(body)); + + if (response.statusCode != 200) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Failed to update category $categoryId: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + return decoded; + } + + /// Deletes a product category by [categoryId]. + /// + /// WooCommerce requires [force] = `true` to permanently delete a category. + /// Returns the deleted category JSON. + Future> deleteCategory(int categoryId, {bool force = true}) async { + final uri = Uri.parse( + '$_baseEndpoint/products/categories/$categoryId', + ).replace(queryParameters: {'force': force.toString()}); + + final response = await _httpClient.delete(uri, headers: _authHeaders); + + if (response.statusCode != 200) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Failed to delete category $categoryId: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + return decoded; + } + + // ── Media / Images API ──────────────────────────────────────────────────── + + /// Fetches the images attached to a WooCommerce product. + /// + /// Returns the raw `images` array from the product JSON. + Future>> getProductImages(String productId) async { + final uri = Uri.parse('$_baseEndpoint/products/$productId'); + final response = await _httpClient.get(uri, headers: _authHeaders); + + if (response.statusCode != 200) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Failed to fetch product $productId: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + final images = decoded['images']; + if (images is! List) return []; + return images.cast>(); + } + + /// Sets the images on a WooCommerce product by media attachment IDs. + /// + /// [imageIds] is an ordered list of WooCommerce media attachment IDs. + /// The first ID becomes the featured image (position 0). + /// Pass an empty list to remove all images. + /// + /// Returns the full updated product JSON. + Future> setProductImages(String productId, List imageIds) async { + final images = imageIds.asMap().entries.map((e) => {'id': e.value}).toList(); + + final uri = Uri.parse('$_baseEndpoint/products/$productId'); + final response = await _httpClient.put( + uri, + headers: _authHeaders, + body: jsonEncode({'images': images}), + ); + + if (response.statusCode != 200) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Failed to set images for product $productId: ${response.reasonPhrase}', + body: response.body, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Unexpected response format: expected a JSON object', + body: response.body, + ); + } + + return decoded; + } } /// Exception thrown when the WooCommerce API returns an error. diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_publishing_repository.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_publishing_repository.dart index a63fd27..9756e86 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_publishing_repository.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_publishing_repository.dart @@ -1,4 +1,6 @@ +import '../domain/product_category.dart'; import '../domain/product_draft.dart'; +import '../domain/product_image.dart'; import '../domain/product_publishing_repository.dart'; import '../domain/publish_status.dart'; import 'woo_commerce_api_client.dart'; @@ -6,8 +8,8 @@ import 'wordpress_product_mapper.dart'; /// Real [ProductPublishingRepository] backed by the WooCommerce REST API. /// -/// Supports product retrieval, publishing, and status updates via -/// `PUT /wp-json/wc/v3/products/{id}`. +/// Supports product retrieval, publishing, status updates, product creation, +/// deletion, image management, and category CRUD via the WooCommerce REST API. class WordPressProductPublishingRepository implements ProductPublishingRepository { final WooCommerceApiClient _apiClient; final WordPressProductMapper _mapper; @@ -64,4 +66,105 @@ class WordPressProductPublishingRepository implements ProductPublishingRepositor }); return _mapper.fromJson(json); } + + // ── Stage 9C: Catalog expansion ────────────────────────────────────────── + + @override + Future createProduct({ + required String name, + required double price, + String description = '', + String sku = '', + int? categoryId, + PublishStatus status = PublishStatus.draft, + }) async { + final wooStatus = status == PublishStatus.unpublished + ? 'draft' + : WordPressProductMapper.toWooCommerceStatus(status); + + final body = { + 'name': name, + 'regular_price': price.toStringAsFixed(2), + 'status': wooStatus, + }; + + if (description.isNotEmpty) body['description'] = description; + if (sku.isNotEmpty) body['sku'] = sku; + if (categoryId != null) { + body['categories'] = [ + {'id': categoryId}, + ]; + } + + final json = await _apiClient.createProduct(body); + return _mapper.fromJson(json); + } + + @override + Future deleteProduct(String id, {bool force = false}) async { + await _apiClient.deleteProduct(id, force: force); + } + + @override + Future updateProductImages(String id, List imageIds) async { + final json = await _apiClient.setProductImages(id, imageIds); + return _mapper.fromJson(json); + } + + @override + Future> getCategories() async { + final jsonCategories = await _apiClient.getCategories(); + return jsonCategories.map(_mapCategory).toList(); + } + + @override + Future createCategory({required String name, int parentId = 0}) async { + final body = {'name': name}; + if (parentId != 0) body['parent'] = parentId; + + final json = await _apiClient.createCategory(body); + return _mapCategory(json); + } + + @override + Future updateCategory(int categoryId, {String? name, int? parentId}) async { + final body = {}; + if (name != null) body['name'] = name; + if (parentId != null) body['parent'] = parentId; + + final json = await _apiClient.updateCategory(categoryId, body); + return _mapCategory(json); + } + + @override + Future deleteCategory(int categoryId, {bool force = true}) async { + await _apiClient.deleteCategory(categoryId, force: force); + } + + @override + Future> getProductImages(String id) async { + final jsonImages = await _apiClient.getProductImages(id); + return jsonImages.asMap().entries.map((entry) { + final img = entry.value; + return ProductImage( + id: (img['id'] as num?)?.toInt() ?? 0, + url: (img['src'] as String?) ?? '', + alt: (img['alt'] as String?) ?? '', + position: entry.key, + ); + }).toList(); + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + /// Maps a WooCommerce category JSON map to a [ProductCategory]. + static ProductCategory _mapCategory(Map json) { + return ProductCategory( + id: (json['id'] as num?)?.toInt() ?? 0, + name: (json['name'] as String?) ?? '', + slug: (json['slug'] as String?) ?? '', + parentId: (json['parent'] as num?)?.toInt() ?? 0, + count: (json['count'] as num?)?.toInt() ?? 0, + ); + } } diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_category.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_category.dart new file mode 100644 index 0000000..15dd7d2 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_category.dart @@ -0,0 +1,53 @@ +/// A WooCommerce product category. +class ProductCategory { + /// WooCommerce category ID. + final int id; + + /// Display name of the category. + final String name; + + /// URL-friendly slug. + final String slug; + + /// Parent category ID, or 0 if top-level. + final int parentId; + + /// Number of products in this category. + final int count; + + const ProductCategory({ + required this.id, + required this.name, + required this.slug, + required this.parentId, + required this.count, + }); + + /// Returns a copy of this [ProductCategory] with the given fields replaced. + ProductCategory copyWith({int? id, String? name, String? slug, int? parentId, int? count}) { + return ProductCategory( + id: id ?? this.id, + name: name ?? this.name, + slug: slug ?? this.slug, + parentId: parentId ?? this.parentId, + count: count ?? this.count, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProductCategory && + runtimeType == other.runtimeType && + id == other.id && + name == other.name && + slug == other.slug && + parentId == other.parentId && + count == other.count; + + @override + int get hashCode => Object.hash(id, name, slug, parentId, count); + + @override + String toString() => 'ProductCategory(id: $id, name: $name, slug: $slug)'; +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_image.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_image.dart new file mode 100644 index 0000000..fe9559f --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_image.dart @@ -0,0 +1,47 @@ +/// A product image attached to a WooCommerce product. +class ProductImage { + /// WooCommerce media attachment ID. + final int id; + + /// Full URL of the image. + final String url; + + /// Alt text for the image. + final String alt; + + /// Display position (0 = featured/primary image). + final int position; + + const ProductImage({ + required this.id, + required this.url, + required this.alt, + required this.position, + }); + + /// Returns a copy of this [ProductImage] with the given fields replaced. + ProductImage copyWith({int? id, String? url, String? alt, int? position}) { + return ProductImage( + id: id ?? this.id, + url: url ?? this.url, + alt: alt ?? this.alt, + position: position ?? this.position, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProductImage && + runtimeType == other.runtimeType && + id == other.id && + url == other.url && + alt == other.alt && + position == other.position; + + @override + int get hashCode => Object.hash(id, url, alt, position); + + @override + String toString() => 'ProductImage(id: $id, url: $url, position: $position)'; +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_publishing_repository.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_publishing_repository.dart index 264705f..57a7f8d 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_publishing_repository.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/domain/product_publishing_repository.dart @@ -1,4 +1,6 @@ +import 'product_category.dart'; import 'product_draft.dart'; +import 'product_image.dart'; import 'publish_status.dart'; /// Contract for fetching and managing product drafts. @@ -37,4 +39,54 @@ abstract class ProductPublishingRepository { /// /// Returns the updated [ProductDraft] reflecting the new category. Future updateProductCategory(String id, String category); + + // ── Stage 9C: Catalog expansion ────────────────────────────────────────── + + /// Creates a new product and returns the created [ProductDraft]. + /// + /// [name] and [price] are required. [description], [sku], [categoryId], + /// and [status] are optional — implementations should apply sensible + /// defaults (e.g. [PublishStatus.draft]). + Future createProduct({ + required String name, + required double price, + String description = '', + String sku = '', + int? categoryId, + PublishStatus status = PublishStatus.draft, + }); + + /// Deletes the product identified by [id]. + /// + /// [force] controls whether the product is permanently deleted (`true`) or + /// moved to trash (`false`, WooCommerce default). + Future deleteProduct(String id, {bool force = false}); + + /// Updates the images of the product identified by [id]. + /// + /// [imageIds] is an ordered list of WooCommerce media attachment IDs. + /// The first ID becomes the featured image (position 0). + /// Pass an empty list to remove all images. + /// + /// Returns the updated [ProductDraft] reflecting the new primary image URL. + Future updateProductImages(String id, List imageIds); + + /// Returns all product categories from the store. + Future> getCategories(); + + /// Creates a new product category and returns the created [ProductCategory]. + /// + /// [name] is required. [parentId] defaults to 0 (top-level category). + Future createCategory({required String name, int parentId = 0}); + + /// Updates an existing product category and returns the updated [ProductCategory]. + Future updateCategory(int categoryId, {String? name, int? parentId}); + + /// Deletes the category identified by [categoryId]. + /// + /// [force] controls whether the category is permanently deleted. + Future deleteCategory(int categoryId, {bool force = true}); + + /// Returns the images attached to the product identified by [id]. + Future> getProductImages(String id); } diff --git a/kell_creations_apps/packages/feature_wordpress/test/wc_catalog_expansion_test.dart b/kell_creations_apps/packages/feature_wordpress/test/wc_catalog_expansion_test.dart new file mode 100644 index 0000000..84b5e35 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/test/wc_catalog_expansion_test.dart @@ -0,0 +1,995 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:feature_wordpress/feature_wordpress.dart'; + +// ── Shared JSON builders ────────────────────────────────────────────────── + +Map _buildProductJson({ + int id = 1, + String name = 'Test Product', + String status = 'draft', + String price = '9.99', + String sku = 'SKU-1', + String description = '

A product

', + List>? categories, + List>? images, +}) { + return { + 'id': id, + 'name': name, + 'description': description, + 'price': price, + 'regular_price': price, + 'sku': sku, + 'status': status, + 'date_modified': '2026-07-01T10:00:00', + 'date_created': '2026-07-01T08:00:00', + 'categories': + categories ?? + [ + {'id': 1, 'name': 'Test Category', 'slug': 'test-category'}, + ], + 'images': images ?? [], + }; +} + +Map _buildCategoryJson({ + int id = 1, + String name = 'Test Category', + String slug = 'test-category', + int parent = 0, + int count = 0, +}) { + return {'id': id, 'name': name, 'slug': slug, 'parent': parent, 'count': count}; +} + +WooCommerceApiClient _buildClient(MockClient mockClient) { + return WooCommerceApiClient( + siteUrl: 'https://store.example.com', + consumerKey: 'ck_test', + consumerSecret: 'cs_test', + httpClient: mockClient, + ); +} + +// ── ProductCategory domain model ────────────────────────────────────────── + +void main() { + group('ProductCategory', () { + test('constructs with all fields', () { + const cat = ProductCategory( + id: 1, + name: 'Bowl Cozies', + slug: 'bowl-cozies', + parentId: 0, + count: 5, + ); + + expect(cat.id, 1); + expect(cat.name, 'Bowl Cozies'); + expect(cat.slug, 'bowl-cozies'); + expect(cat.parentId, 0); + expect(cat.count, 5); + }); + + test('copyWith replaces specified fields', () { + const original = ProductCategory( + id: 1, + name: 'Bowl Cozies', + slug: 'bowl-cozies', + parentId: 0, + count: 5, + ); + + final updated = original.copyWith(name: 'Updated Cozies', count: 10); + + expect(updated.id, 1); + expect(updated.name, 'Updated Cozies'); + expect(updated.slug, 'bowl-cozies'); + expect(updated.parentId, 0); + expect(updated.count, 10); + }); + + test('equality holds for identical values', () { + const a = ProductCategory(id: 1, name: 'A', slug: 'a', parentId: 0, count: 0); + const b = ProductCategory(id: 1, name: 'A', slug: 'a', parentId: 0, count: 0); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('equality fails for different id', () { + const a = ProductCategory(id: 1, name: 'A', slug: 'a', parentId: 0, count: 0); + const b = ProductCategory(id: 2, name: 'A', slug: 'a', parentId: 0, count: 0); + expect(a, isNot(equals(b))); + }); + + test('toString includes id, name, slug', () { + const cat = ProductCategory(id: 3, name: 'Coasters', slug: 'coasters', parentId: 0, count: 2); + expect(cat.toString(), contains('3')); + expect(cat.toString(), contains('Coasters')); + expect(cat.toString(), contains('coasters')); + }); + }); + + // ── ProductImage domain model ───────────────────────────────────────────── + + group('ProductImage', () { + test('constructs with all fields', () { + const img = ProductImage( + id: 10, + url: 'https://example.com/img.jpg', + alt: 'A product image', + position: 0, + ); + + expect(img.id, 10); + expect(img.url, 'https://example.com/img.jpg'); + expect(img.alt, 'A product image'); + expect(img.position, 0); + }); + + test('copyWith replaces specified fields', () { + const original = ProductImage(id: 1, url: 'https://a.com/1.jpg', alt: '', position: 0); + final updated = original.copyWith(alt: 'New alt', position: 1); + + expect(updated.id, 1); + expect(updated.url, 'https://a.com/1.jpg'); + expect(updated.alt, 'New alt'); + expect(updated.position, 1); + }); + + test('equality holds for identical values', () { + const a = ProductImage(id: 1, url: 'https://a.com/1.jpg', alt: '', position: 0); + const b = ProductImage(id: 1, url: 'https://a.com/1.jpg', alt: '', position: 0); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('equality fails for different id', () { + const a = ProductImage(id: 1, url: 'https://a.com/1.jpg', alt: '', position: 0); + const b = ProductImage(id: 2, url: 'https://a.com/1.jpg', alt: '', position: 0); + expect(a, isNot(equals(b))); + }); + }); + + // ── FakeProductPublishingRepository — catalog expansion ─────────────────── + + group('FakeProductPublishingRepository — Stage 9C', () { + late FakeProductPublishingRepository repo; + + setUp(() => repo = FakeProductPublishingRepository()); + + // createProduct + test('createProduct adds a new draft to the list', () async { + final before = await repo.getProductDrafts(); + final created = await repo.createProduct(name: 'New Bowl Cozy', price: 14.99); + + expect(created.name, 'New Bowl Cozy'); + expect(created.price, 14.99); + expect(created.status, PublishStatus.draft); + expect(created.category, 'Uncategorized'); + + final after = await repo.getProductDrafts(); + expect(after.length, before.length + 1); + expect(after.any((d) => d.id == created.id), isTrue); + }); + + test('createProduct with categoryId resolves category name', () async { + // Category id=1 is 'Bowl Cozies' in seed data. + final created = await repo.createProduct(name: 'New Cozy', price: 9.99, categoryId: 1); + expect(created.category, 'Bowl Cozies'); + }); + + test('createProduct with description and sku stores them', () async { + final created = await repo.createProduct( + name: 'Fancy Cozy', + price: 19.99, + description: 'A fancy cozy', + sku: 'FC-001', + ); + expect(created.description, 'A fancy cozy'); + expect(created.sku, 'FC-001'); + }); + + test('createProduct with custom status stores it', () async { + final created = await repo.createProduct( + name: 'Published Cozy', + price: 12.99, + status: PublishStatus.published, + ); + expect(created.status, PublishStatus.published); + }); + + test('createProduct assigns unique incrementing IDs', () async { + final a = await repo.createProduct(name: 'A', price: 1.0); + final b = await repo.createProduct(name: 'B', price: 2.0); + expect(a.id, isNot(equals(b.id))); + }); + + // deleteProduct + test('deleteProduct removes the product from the list', () async { + final before = await repo.getProductDrafts(); + await repo.deleteProduct('1'); + final after = await repo.getProductDrafts(); + + expect(after.length, before.length - 1); + expect(after.any((d) => d.id == '1'), isFalse); + }); + + test('deleteProduct throws StateError for unknown id', () async { + expect(() => repo.deleteProduct('999'), throwsA(isA())); + }); + + // updateProductImages + test('updateProductImages sets primary imageUrl on draft', () async { + final updated = await repo.updateProductImages('1', [101, 102]); + + expect(updated.id, '1'); + expect(updated.imageUrl, contains('101')); + }); + + test('updateProductImages with empty list clears imageUrl', () async { + // First set some images. + await repo.updateProductImages('1', [101]); + // Then clear them. + final updated = await repo.updateProductImages('1', []); + expect(updated.imageUrl, ''); + }); + + test('updateProductImages throws StateError for unknown id', () async { + expect(() => repo.updateProductImages('999', [1]), throwsA(isA())); + }); + + // getProductImages + test('getProductImages returns empty list before any images set', () async { + final images = await repo.getProductImages('1'); + expect(images, isEmpty); + }); + + test('getProductImages returns images after updateProductImages', () async { + await repo.updateProductImages('1', [101, 102]); + final images = await repo.getProductImages('1'); + + expect(images.length, 2); + expect(images[0].id, 101); + expect(images[0].position, 0); + expect(images[1].id, 102); + expect(images[1].position, 1); + }); + + // getCategories + test('getCategories returns seed categories', () async { + final cats = await repo.getCategories(); + expect(cats.length, 4); + expect( + cats.map((c) => c.name), + containsAll(['Bowl Cozies', 'Coasters', 'Nightlights', 'Kitchen Accessories']), + ); + }); + + // createCategory + test('createCategory adds a new category', () async { + final before = await repo.getCategories(); + final created = await repo.createCategory(name: 'Pot Holders'); + + expect(created.name, 'Pot Holders'); + expect(created.slug, 'pot-holders'); + expect(created.parentId, 0); + expect(created.count, 0); + + final after = await repo.getCategories(); + expect(after.length, before.length + 1); + }); + + test('createCategory with parentId stores it', () async { + final created = await repo.createCategory(name: 'Sub Category', parentId: 1); + expect(created.parentId, 1); + }); + + test('createCategory generates slug from name', () async { + final created = await repo.createCategory(name: 'My New Category'); + expect(created.slug, 'my-new-category'); + }); + + // updateCategory + test('updateCategory updates name and slug', () async { + final updated = await repo.updateCategory(1, name: 'Updated Bowl Cozies'); + expect(updated.name, 'Updated Bowl Cozies'); + expect(updated.slug, 'updated-bowl-cozies'); + }); + + test('updateCategory with parentId updates it', () async { + final updated = await repo.updateCategory(2, parentId: 1); + expect(updated.parentId, 1); + }); + + test('updateCategory throws StateError for unknown id', () async { + expect(() => repo.updateCategory(999, name: 'X'), throwsA(isA())); + }); + + // deleteCategory + test('deleteCategory removes the category', () async { + final before = await repo.getCategories(); + await repo.deleteCategory(1); + final after = await repo.getCategories(); + + expect(after.length, before.length - 1); + expect(after.any((c) => c.id == 1), isFalse); + }); + + test('deleteCategory throws StateError for unknown id', () async { + expect(() => repo.deleteCategory(999), throwsA(isA())); + }); + }); + + // ── WooCommerceApiClient — catalog expansion ────────────────────────────── + + group('WooCommerceApiClient — Stage 9C', () { + // createProduct + test('createProduct sends POST to /products and returns JSON on 201', () async { + Map? capturedBody; + String? capturedMethod; + Uri? capturedUri; + + final mockClient = MockClient((request) async { + capturedMethod = request.method; + capturedUri = request.url; + capturedBody = jsonDecode(request.body) as Map; + return http.Response(jsonEncode(_buildProductJson(id: 99, name: 'New Product')), 201); + }); + + final client = _buildClient(mockClient); + final result = await client.createProduct({'name': 'New Product', 'regular_price': '9.99'}); + + expect(capturedMethod, 'POST'); + expect(capturedUri!.path, '/wp-json/wc/v3/products'); + expect(capturedBody!['name'], 'New Product'); + expect(result['id'], 99); + }); + + test('createProduct throws WooCommerceApiException on non-201', () async { + final mockClient = MockClient((request) async { + return http.Response('{"code":"rest_invalid_param"}', 400); + }); + + final client = _buildClient(mockClient); + expect(() => client.createProduct({'name': ''}), throwsA(isA())); + }); + + // deleteProduct + test('deleteProduct sends DELETE to /products/{id} with force param', () async { + String? capturedMethod; + Uri? capturedUri; + + final mockClient = MockClient((request) async { + capturedMethod = request.method; + capturedUri = request.url; + return http.Response(jsonEncode(_buildProductJson(id: 5)), 200); + }); + + final client = _buildClient(mockClient); + await client.deleteProduct('5', force: true); + + expect(capturedMethod, 'DELETE'); + expect(capturedUri!.path, '/wp-json/wc/v3/products/5'); + expect(capturedUri!.queryParameters['force'], 'true'); + }); + + test('deleteProduct throws WooCommerceApiException on non-200', () async { + final mockClient = MockClient((request) async { + return http.Response('{"code":"rest_cannot_delete"}', 403); + }); + + final client = _buildClient(mockClient); + expect(() => client.deleteProduct('5'), throwsA(isA())); + }); + + // getCategories + test('getCategories fetches all categories with pagination', () async { + var requestCount = 0; + final mockClient = MockClient((request) async { + requestCount++; + final page = int.parse(request.url.queryParameters['page']!); + final perPage = int.parse(request.url.queryParameters['per_page']!); + // Page 1 returns perPage items, page 2 returns 1 (short page). + final count = page == 1 ? perPage : 1; + final cats = List.generate( + count, + (i) => _buildCategoryJson(id: i + 1, name: 'Cat ${i + 1}'), + ); + return http.Response(jsonEncode(cats), 200); + }); + + final client = _buildClient(mockClient); + final cats = await client.getCategories(perPage: 2); + + expect(cats.length, 3); // 2 + 1 + expect(requestCount, 2); + }); + + test('getCategories throws WooCommerceApiException on non-200', () async { + final mockClient = MockClient((request) async { + return http.Response('Server Error', 500); + }); + + final client = _buildClient(mockClient); + expect(() => client.getCategories(), throwsA(isA())); + }); + + // createCategory + test('createCategory sends POST to /products/categories and returns JSON on 201', () async { + Map? capturedBody; + Uri? capturedUri; + + final mockClient = MockClient((request) async { + capturedUri = request.url; + capturedBody = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode(_buildCategoryJson(id: 10, name: 'Pot Holders', slug: 'pot-holders')), + 201, + ); + }); + + final client = _buildClient(mockClient); + final result = await client.createCategory({'name': 'Pot Holders'}); + + expect(capturedUri!.path, '/wp-json/wc/v3/products/categories'); + expect(capturedBody!['name'], 'Pot Holders'); + expect(result['id'], 10); + expect(result['name'], 'Pot Holders'); + }); + + test('createCategory throws WooCommerceApiException on non-201', () async { + final mockClient = MockClient((request) async { + return http.Response('{"code":"term_exists"}', 400); + }); + + final client = _buildClient(mockClient); + expect( + () => client.createCategory({'name': 'Existing'}), + throwsA(isA()), + ); + }); + + // updateCategory + test('updateCategory sends PUT to /products/categories/{id}', () async { + String? capturedMethod; + Uri? capturedUri; + Map? capturedBody; + + final mockClient = MockClient((request) async { + capturedMethod = request.method; + capturedUri = request.url; + capturedBody = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode(_buildCategoryJson(id: 3, name: 'Updated Name', slug: 'updated-name')), + 200, + ); + }); + + final client = _buildClient(mockClient); + final result = await client.updateCategory(3, {'name': 'Updated Name'}); + + expect(capturedMethod, 'PUT'); + expect(capturedUri!.path, '/wp-json/wc/v3/products/categories/3'); + expect(capturedBody!['name'], 'Updated Name'); + expect(result['name'], 'Updated Name'); + }); + + test('updateCategory throws WooCommerceApiException on non-200', () async { + final mockClient = MockClient((request) async { + return http.Response('{"code":"rest_cannot_edit"}', 403); + }); + + final client = _buildClient(mockClient); + expect( + () => client.updateCategory(3, {'name': 'X'}), + throwsA(isA()), + ); + }); + + // deleteCategory + test('deleteCategory sends DELETE to /products/categories/{id} with force', () async { + String? capturedMethod; + Uri? capturedUri; + + final mockClient = MockClient((request) async { + capturedMethod = request.method; + capturedUri = request.url; + return http.Response(jsonEncode(_buildCategoryJson(id: 2)), 200); + }); + + final client = _buildClient(mockClient); + await client.deleteCategory(2, force: true); + + expect(capturedMethod, 'DELETE'); + expect(capturedUri!.path, '/wp-json/wc/v3/products/categories/2'); + expect(capturedUri!.queryParameters['force'], 'true'); + }); + + test('deleteCategory throws WooCommerceApiException on non-200', () async { + final mockClient = MockClient((request) async { + return http.Response('{"code":"rest_cannot_delete"}', 403); + }); + + final client = _buildClient(mockClient); + expect(() => client.deleteCategory(2), throwsA(isA())); + }); + + // getProductImages + test('getProductImages returns images array from product JSON', () async { + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode( + _buildProductJson( + id: 1, + images: [ + {'id': 10, 'src': 'https://example.com/img1.jpg', 'alt': 'Image 1'}, + {'id': 11, 'src': 'https://example.com/img2.jpg', 'alt': 'Image 2'}, + ], + ), + ), + 200, + ); + }); + + final client = _buildClient(mockClient); + final images = await client.getProductImages('1'); + + expect(images.length, 2); + expect(images[0]['id'], 10); + expect(images[0]['src'], 'https://example.com/img1.jpg'); + }); + + test('getProductImages returns empty list when images field is absent', () async { + final mockClient = MockClient((request) async { + final json = _buildProductJson(id: 1); + json.remove('images'); + return http.Response(jsonEncode(json), 200); + }); + + final client = _buildClient(mockClient); + final images = await client.getProductImages('1'); + expect(images, isEmpty); + }); + + test('getProductImages throws WooCommerceApiException on non-200', () async { + final mockClient = MockClient((request) async { + return http.Response('Not Found', 404); + }); + + final client = _buildClient(mockClient); + expect(() => client.getProductImages('1'), throwsA(isA())); + }); + + // setProductImages + test('setProductImages sends PUT with images array', () async { + Map? capturedBody; + Uri? capturedUri; + + final mockClient = MockClient((request) async { + capturedUri = request.url; + capturedBody = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode( + _buildProductJson( + id: 7, + images: [ + {'id': 101, 'src': 'https://example.com/101.jpg', 'alt': ''}, + ], + ), + ), + 200, + ); + }); + + final client = _buildClient(mockClient); + final result = await client.setProductImages('7', [101]); + + expect(capturedUri!.path, '/wp-json/wc/v3/products/7'); + expect(capturedBody!['images'], [ + {'id': 101}, + ]); + expect(result['id'], 7); + }); + + test('setProductImages with empty list sends empty images array', () async { + Map? capturedBody; + + final mockClient = MockClient((request) async { + capturedBody = jsonDecode(request.body) as Map; + return http.Response(jsonEncode(_buildProductJson(id: 7, images: [])), 200); + }); + + final client = _buildClient(mockClient); + await client.setProductImages('7', []); + + expect(capturedBody!['images'], isEmpty); + }); + + test('setProductImages throws WooCommerceApiException on non-200', () async { + final mockClient = MockClient((request) async { + return http.Response('{"code":"rest_cannot_edit"}', 403); + }); + + final client = _buildClient(mockClient); + expect(() => client.setProductImages('7', [101]), throwsA(isA())); + }); + }); + + // ── WordPressProductPublishingRepository — catalog expansion ────────────── + + group('WordPressProductPublishingRepository — Stage 9C', () { + WordPressProductPublishingRepository buildRepo(MockClient mockClient) { + return WordPressProductPublishingRepository(apiClient: _buildClient(mockClient)); + } + + // createProduct + test('createProduct sends POST with correct fields and returns ProductDraft', () async { + Map? capturedBody; + + final mockClient = MockClient((request) async { + capturedBody = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode(_buildProductJson(id: 99, name: 'New Cozy', status: 'draft', price: '14.99')), + 201, + ); + }); + + final repo = buildRepo(mockClient); + final result = await repo.createProduct(name: 'New Cozy', price: 14.99); + + expect(capturedBody!['name'], 'New Cozy'); + expect(capturedBody!['regular_price'], '14.99'); + expect(capturedBody!['status'], 'draft'); + expect(result, isA()); + expect(result.id, '99'); + expect(result.name, 'New Cozy'); + expect(result.status, PublishStatus.draft); + }); + + test('createProduct with description and sku includes them in request', () async { + Map? capturedBody; + + final mockClient = MockClient((request) async { + capturedBody = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode(_buildProductJson(id: 10, name: 'Fancy', sku: 'FC-001')), + 201, + ); + }); + + final repo = buildRepo(mockClient); + await repo.createProduct( + name: 'Fancy', + price: 9.99, + description: 'A fancy product', + sku: 'FC-001', + ); + + expect(capturedBody!['description'], 'A fancy product'); + expect(capturedBody!['sku'], 'FC-001'); + }); + + test('createProduct with categoryId includes categories in request', () async { + Map? capturedBody; + + final mockClient = MockClient((request) async { + capturedBody = jsonDecode(request.body) as Map; + return http.Response(jsonEncode(_buildProductJson(id: 11)), 201); + }); + + final repo = buildRepo(mockClient); + await repo.createProduct(name: 'Cat Product', price: 5.0, categoryId: 3); + + expect(capturedBody!['categories'], [ + {'id': 3}, + ]); + }); + + test('createProduct with published status sends publish', () async { + Map? capturedBody; + + final mockClient = MockClient((request) async { + capturedBody = jsonDecode(request.body) as Map; + return http.Response(jsonEncode(_buildProductJson(id: 12, status: 'publish')), 201); + }); + + final repo = buildRepo(mockClient); + await repo.createProduct(name: 'Published', price: 9.99, status: PublishStatus.published); + + expect(capturedBody!['status'], 'publish'); + }); + + test('createProduct propagates WooCommerceApiException on non-201', () async { + final mockClient = MockClient((request) async { + return http.Response('{"code":"rest_invalid_param"}', 400); + }); + + final repo = buildRepo(mockClient); + expect( + () => repo.createProduct(name: 'X', price: 1.0), + throwsA(isA()), + ); + }); + + // deleteProduct + test('deleteProduct sends DELETE and completes without error', () async { + String? capturedMethod; + Uri? capturedUri; + + final mockClient = MockClient((request) async { + capturedMethod = request.method; + capturedUri = request.url; + return http.Response(jsonEncode(_buildProductJson(id: 5)), 200); + }); + + final repo = buildRepo(mockClient); + await repo.deleteProduct('5', force: true); + + expect(capturedMethod, 'DELETE'); + expect(capturedUri!.path, '/wp-json/wc/v3/products/5'); + expect(capturedUri!.queryParameters['force'], 'true'); + }); + + test('deleteProduct propagates WooCommerceApiException on non-200', () async { + final mockClient = MockClient((request) async { + return http.Response('{"code":"rest_cannot_delete"}', 403); + }); + + final repo = buildRepo(mockClient); + expect(() => repo.deleteProduct('5'), throwsA(isA())); + }); + + // updateProductImages + test('updateProductImages sends PUT with images and returns updated ProductDraft', () async { + Map? capturedBody; + + final mockClient = MockClient((request) async { + capturedBody = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode( + _buildProductJson( + id: 1, + images: [ + {'id': 101, 'src': 'https://example.com/101.jpg', 'alt': ''}, + ], + ), + ), + 200, + ); + }); + + final repo = buildRepo(mockClient); + final result = await repo.updateProductImages('1', [101]); + + expect(capturedBody!['images'], [ + {'id': 101}, + ]); + expect(result, isA()); + expect(result.imageUrl, 'https://example.com/101.jpg'); + }); + + // getCategories + test('getCategories returns mapped ProductCategory list', () async { + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode([ + _buildCategoryJson(id: 1, name: 'Bowl Cozies', slug: 'bowl-cozies', count: 3), + _buildCategoryJson(id: 2, name: 'Coasters', slug: 'coasters', count: 5), + ]), + 200, + ); + }); + + final repo = buildRepo(mockClient); + final cats = await repo.getCategories(); + + expect(cats.length, 2); + expect(cats[0], isA()); + expect(cats[0].id, 1); + expect(cats[0].name, 'Bowl Cozies'); + expect(cats[0].slug, 'bowl-cozies'); + expect(cats[0].count, 3); + expect(cats[1].id, 2); + }); + + test('getCategories propagates WooCommerceApiException on non-200', () async { + final mockClient = MockClient((request) async { + return http.Response('Server Error', 500); + }); + + final repo = buildRepo(mockClient); + expect(() => repo.getCategories(), throwsA(isA())); + }); + + // createCategory + test('createCategory sends POST with name and returns ProductCategory', () async { + Map? capturedBody; + + final mockClient = MockClient((request) async { + capturedBody = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode(_buildCategoryJson(id: 10, name: 'Pot Holders', slug: 'pot-holders')), + 201, + ); + }); + + final repo = buildRepo(mockClient); + final result = await repo.createCategory(name: 'Pot Holders'); + + expect(capturedBody!['name'], 'Pot Holders'); + expect(capturedBody!.containsKey('parent'), isFalse); // parentId=0 omitted + expect(result, isA()); + expect(result.id, 10); + expect(result.name, 'Pot Holders'); + }); + + test('createCategory with parentId includes parent in request', () async { + Map? capturedBody; + + final mockClient = MockClient((request) async { + capturedBody = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode(_buildCategoryJson(id: 11, name: 'Sub', slug: 'sub', parent: 1)), + 201, + ); + }); + + final repo = buildRepo(mockClient); + final result = await repo.createCategory(name: 'Sub', parentId: 1); + + expect(capturedBody!['parent'], 1); + expect(result.parentId, 1); + }); + + // updateCategory + test('updateCategory sends PUT with name and returns updated ProductCategory', () async { + Map? capturedBody; + Uri? capturedUri; + + final mockClient = MockClient((request) async { + capturedUri = request.url; + capturedBody = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode(_buildCategoryJson(id: 3, name: 'Updated', slug: 'updated')), + 200, + ); + }); + + final repo = buildRepo(mockClient); + final result = await repo.updateCategory(3, name: 'Updated'); + + expect(capturedUri!.path, '/wp-json/wc/v3/products/categories/3'); + expect(capturedBody!['name'], 'Updated'); + expect(result.name, 'Updated'); + }); + + // deleteCategory + test('deleteCategory sends DELETE and completes without error', () async { + String? capturedMethod; + Uri? capturedUri; + + final mockClient = MockClient((request) async { + capturedMethod = request.method; + capturedUri = request.url; + return http.Response(jsonEncode(_buildCategoryJson(id: 2)), 200); + }); + + final repo = buildRepo(mockClient); + await repo.deleteCategory(2); + + expect(capturedMethod, 'DELETE'); + expect(capturedUri!.path, '/wp-json/wc/v3/products/categories/2'); + }); + + // getProductImages + test('getProductImages returns mapped ProductImage list', () async { + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode( + _buildProductJson( + id: 1, + images: [ + {'id': 10, 'src': 'https://example.com/img1.jpg', 'alt': 'Alt 1'}, + {'id': 11, 'src': 'https://example.com/img2.jpg', 'alt': 'Alt 2'}, + ], + ), + ), + 200, + ); + }); + + final repo = buildRepo(mockClient); + final images = await repo.getProductImages('1'); + + expect(images.length, 2); + expect(images[0], isA()); + expect(images[0].id, 10); + expect(images[0].url, 'https://example.com/img1.jpg'); + expect(images[0].alt, 'Alt 1'); + expect(images[0].position, 0); + expect(images[1].id, 11); + expect(images[1].position, 1); + }); + + test('getProductImages returns empty list when no images', () async { + final mockClient = MockClient((request) async { + return http.Response(jsonEncode(_buildProductJson(id: 1, images: [])), 200); + }); + + final repo = buildRepo(mockClient); + final images = await repo.getProductImages('1'); + expect(images, isEmpty); + }); + }); + + // ── Use cases ───────────────────────────────────────────────────────────── + + group('CreateProduct use case', () { + test('delegates to repository and returns ProductDraft', () async { + final repo = FakeProductPublishingRepository(); + final useCase = CreateProduct(repo); + + final result = await useCase(name: 'New Cozy', price: 12.99); + + expect(result, isA()); + expect(result.name, 'New Cozy'); + expect(result.price, 12.99); + }); + }); + + group('DeleteProduct use case', () { + test('delegates to repository and removes product', () async { + final repo = FakeProductPublishingRepository(); + final useCase = DeleteProduct(repo); + + final before = await repo.getProductDrafts(); + await useCase('1'); + final after = await repo.getProductDrafts(); + + expect(after.length, before.length - 1); + expect(after.any((d) => d.id == '1'), isFalse); + }); + }); + + group('GetCategories use case', () { + test('delegates to repository and returns category list', () async { + final repo = FakeProductPublishingRepository(); + final useCase = GetCategories(repo); + + final cats = await useCase(); + + expect(cats, isA>()); + expect(cats.isNotEmpty, isTrue); + }); + }); + + group('CreateCategory use case', () { + test('delegates to repository and returns new ProductCategory', () async { + final repo = FakeProductPublishingRepository(); + final useCase = CreateCategory(repo); + + final result = await useCase(name: 'Pot Holders'); + + expect(result, isA()); + expect(result.name, 'Pot Holders'); + }); + }); + + group('UpdateProductImages use case', () { + test('delegates to repository and returns updated ProductDraft', () async { + final repo = FakeProductPublishingRepository(); + final useCase = UpdateProductImages(repo); + + final result = await useCase('1', [101, 102]); + + expect(result, isA()); + expect(result.imageUrl, contains('101')); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_wordpress/test/widgets/product_publishing_page_test.dart b/kell_creations_apps/packages/feature_wordpress/test/widgets/product_publishing_page_test.dart index 5770734..9b8cd70 100644 --- a/kell_creations_apps/packages/feature_wordpress/test/widgets/product_publishing_page_test.dart +++ b/kell_creations_apps/packages/feature_wordpress/test/widgets/product_publishing_page_test.dart @@ -37,6 +37,42 @@ class _FailingRepository implements ProductPublishingRepository { @override Future updateProductCategory(String id, String category) async => throw UnimplementedError(); + + // Stage 9C additions + @override + Future createProduct({ + required String name, + required double price, + String description = '', + String sku = '', + int? categoryId, + PublishStatus status = PublishStatus.draft, + }) async => throw UnimplementedError(); + + @override + Future deleteProduct(String id, {bool force = false}) async => throw UnimplementedError(); + + @override + Future updateProductImages(String id, List imageIds) async => + throw UnimplementedError(); + + @override + Future> getCategories() async => throw UnimplementedError(); + + @override + Future createCategory({required String name, int parentId = 0}) async => + throw UnimplementedError(); + + @override + Future updateCategory(int categoryId, {String? name, int? parentId}) async => + throw UnimplementedError(); + + @override + Future deleteCategory(int categoryId, {bool force = true}) async => + throw UnimplementedError(); + + @override + Future> getProductImages(String id) async => throw UnimplementedError(); } void main() {