From 039612cb6e9b5817744df98f5fc50e8d467b8a0a Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sun, 5 Apr 2026 20:03:10 -0400 Subject: [PATCH] Update main with product publishing --- .../dashboard_controller_test.dart | 4 + .../extension_discovery/vs_code.json | 1 + .../lib/feature_wordpress.dart | 8 + .../product_publishing_controller.dart | 76 ++++++++- .../application/update_product_status.dart | 15 ++ .../fake_product_publishing_repository.dart | 25 +++ .../lib/src/data/woo_commerce_api_client.dart | 29 ++++ .../src/data/wordpress_product_mapper.dart | 23 +++ ...rdpress_product_publishing_repository.dart | 15 +- .../domain/product_publishing_repository.dart | 10 ++ .../presentation/product_publishing_page.dart | 12 +- .../widgets/product_preview_panel.dart | 45 +++++- ...ke_product_publishing_repository_test.dart | 76 +++++++++ .../product_publishing_controller_test.dart | 100 ++++++++++++ .../widgets/product_preview_panel_test.dart | 101 ++++++++++-- .../test/wordpress_product_mapper_test.dart | 24 +++ ...ss_product_publishing_repository_test.dart | 152 ++++++++++++++++++ 17 files changed, 685 insertions(+), 31 deletions(-) create mode 100644 kell_creations_apps/packages/feature_orders/.dart_tool/extension_discovery/vs_code.json create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/application/update_product_status.dart 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 e8f8298..401f26b 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 @@ -25,6 +25,10 @@ class _StubProductPublishingRepository implements ProductPublishingRepository { @override Future publishDraft(String id) => throw UnimplementedError(); + + @override + Future updateProductStatus(String id, PublishStatus status) => + throw UnimplementedError(); } class _StubOrdersRepository implements OrdersRepository { diff --git a/kell_creations_apps/packages/feature_orders/.dart_tool/extension_discovery/vs_code.json b/kell_creations_apps/packages/feature_orders/.dart_tool/extension_discovery/vs_code.json new file mode 100644 index 0000000..7192e77 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/.dart_tool/extension_discovery/vs_code.json @@ -0,0 +1 @@ +{"version":2,"entries":[{"package":"design_system","rootUri":"../../design_system/","packageUri":"lib/"},{"package":"feature_orders","rootUri":"../","packageUri":"lib/"}]} \ No newline at end of file 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 2595219..1ba2688 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart @@ -1,10 +1,18 @@ library; +// Data export 'src/data/fake_product_publishing_repository.dart'; export 'src/data/woo_commerce_api_client.dart'; export 'src/data/wordpress_product_mapper.dart'; export 'src/data/wordpress_product_publishing_repository.dart'; + +// Domain export 'src/domain/product_draft.dart'; export 'src/domain/product_publishing_repository.dart'; export 'src/domain/publish_status.dart'; + +// Application +export 'src/application/update_product_status.dart'; + +// Presentation export 'src/presentation/product_publishing_page.dart'; diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/application/product_publishing_controller.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/application/product_publishing_controller.dart index 69ff0a5..d2ad510 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/src/application/product_publishing_controller.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/application/product_publishing_controller.dart @@ -4,14 +4,22 @@ import '../domain/product_draft.dart'; import '../domain/publish_status.dart'; import 'get_product_drafts.dart'; import 'publish_product.dart'; +import 'update_product_status.dart'; /// Controller that manages the product publishing workspace state, including /// filtering by publish status, free-text search, and draft selection. class ProductPublishingController extends ChangeNotifier { final GetProductDrafts _getProductDrafts; final PublishProduct _publishProduct; + final UpdateProductStatus _updateProductStatus; - ProductPublishingController(this._getProductDrafts, this._publishProduct); + ProductPublishingController( + this._getProductDrafts, + this._publishProduct, + this._updateProductStatus, + ); + + bool _disposed = false; bool isLoading = false; Object? error; @@ -33,22 +41,33 @@ class ProductPublishingController extends ChangeNotifier { /// The current free-text search query applied to name / SKU. String searchQuery = ''; + /// Product IDs that currently have an in-flight status update. + /// + /// Used to prevent duplicate clicks and to let the UI show per-row + /// progress indicators without locking the entire page. + final Set updatingIds = {}; + + /// Whether the product with [id] is currently being updated. + bool isUpdating(String id) => updatingIds.contains(id); + /// Loads all product drafts and applies any current filter / search. Future load() async { isLoading = true; error = null; - notifyListeners(); + _safeNotify(); try { _allDrafts = await _getProductDrafts(); + if (_disposed) return; _applyFilters(); // Auto-select the first draft if nothing is selected. selectedDraft ??= drafts.isNotEmpty ? drafts.first : null; } catch (e) { + if (_disposed) return; error = e; } finally { isLoading = false; - notifyListeners(); + _safeNotify(); } } @@ -56,20 +75,20 @@ class ProductPublishingController extends ChangeNotifier { void setFilter(String? filter) { activeFilter = filter; _applyFilters(); - notifyListeners(); + _safeNotify(); } /// Sets the search query and recomputes the visible list. void setSearchQuery(String query) { searchQuery = query; _applyFilters(); - notifyListeners(); + _safeNotify(); } /// Selects a draft for preview. void selectDraft(ProductDraft draft) { selectedDraft = draft; - notifyListeners(); + _safeNotify(); } /// Attempts to select a draft by SKU. Returns `true` if found. @@ -77,7 +96,7 @@ class ProductPublishingController extends ChangeNotifier { final match = _allDrafts.where((d) => d.sku == sku).firstOrNull; if (match != null) { selectedDraft = match; - notifyListeners(); + _safeNotify(); return true; } return false; @@ -87,15 +106,56 @@ class ProductPublishingController extends ChangeNotifier { Future publish(String id) async { try { await _publishProduct(id); + if (_disposed) return; await load(); } catch (e) { + if (_disposed) return; error = e; - notifyListeners(); + _safeNotify(); } } + /// Updates the publishing status of the product with [id]. + /// + /// Tracks per-row updating state via [updatingIds] so the UI can show + /// a spinner on the affected row without locking the entire page. + /// Silently ignores duplicate calls while [id] is already in flight. + Future updateStatus(String id, PublishStatus status) async { + // Prevent duplicate clicks while this row is already updating. + if (updatingIds.contains(id)) return; + + updatingIds.add(id); + _safeNotify(); + + try { + await _updateProductStatus(id, status); + if (_disposed) return; + updatingIds.remove(id); + await load(); + } catch (e) { + if (_disposed) return; + updatingIds.remove(id); + error = e; + _safeNotify(); + } + } + + // ── Lifecycle ─────────────────────────────────────────────────────────── + + @override + void dispose() { + if (_disposed) return; + _disposed = true; + super.dispose(); + } + // ── Private helpers ──────────────────────────────────────────────────── + /// Calls [notifyListeners] only if the controller has not been disposed. + void _safeNotify() { + if (!_disposed) notifyListeners(); + } + void _applyFilters() { var result = _allDrafts; diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/application/update_product_status.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/application/update_product_status.dart new file mode 100644 index 0000000..15ef8b1 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/application/update_product_status.dart @@ -0,0 +1,15 @@ +import '../domain/product_draft.dart'; +import '../domain/product_publishing_repository.dart'; +import '../domain/publish_status.dart'; + +/// Use case: update the publishing status of a single product by its [id]. +/// +/// This is a narrow status mutation — not a generic product edit. +class UpdateProductStatus { + final ProductPublishingRepository repository; + + UpdateProductStatus(this.repository); + + Future call(String id, PublishStatus status) => + repository.updateProductStatus(id, status); +} 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 7f27e2f..62fe4e1 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 @@ -117,4 +117,29 @@ class FakeProductPublishingRepository implements ProductPublishingRepository { _drafts[index] = updated; return updated; } + + @override + Future updateProductStatus(String id, PublishStatus status) 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'); + } + + final original = _drafts[index]; + final updated = ProductDraft( + id: original.id, + name: original.name, + description: original.description, + price: original.price, + sku: original.sku, + category: original.category, + imageUrl: original.imageUrl, + status: status, + lastModified: DateTime.now(), + ); + _drafts[index] = updated; + return updated; + } } 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 caaa55a..8e6b7b8 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 @@ -84,6 +84,35 @@ class WooCommerceApiClient { return {'Authorization': 'Basic $credentials', 'Content-Type': 'application/json'}; } + /// Sends a partial update (PUT) to a single WooCommerce product. + /// + /// Only the fields present in [body] are changed on the remote product. + /// Returns the full product JSON that WooCommerce echoes back. + Future> updateProduct(String productId, Map body) async { + final uri = Uri.parse('$_baseEndpoint/products/$productId'); + + final response = await _httpClient.put(uri, headers: _authHeaders, body: jsonEncode(body)); + + if (response.statusCode != 200) { + throw WooCommerceApiException( + statusCode: response.statusCode, + message: 'Failed to update 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; + } + /// Releases the underlying HTTP client resources. void dispose() { _httpClient.close(); diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_mapper.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_mapper.dart index 372a386..602c019 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_mapper.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_mapper.dart @@ -29,6 +29,29 @@ class WordPressProductMapper { return jsonList.map(fromJson).toList(); } + /// Converts a [PublishStatus] domain value to the corresponding + /// WooCommerce REST API status string. + /// + /// Only [PublishStatus.draft] and [PublishStatus.published] are supported + /// in the first pass. Throws [ArgumentError] for unsupported values. + /// + /// WooCommerce accepts: `publish`, `draft`, `pending`, `private`, `trash`. + static String toWooCommerceStatus(PublishStatus status) { + switch (status) { + case PublishStatus.draft: + return 'draft'; + case PublishStatus.published: + return 'publish'; + case PublishStatus.pendingReview: + case PublishStatus.unpublished: + throw ArgumentError.value( + status, + 'status', + 'Unsupported status for WooCommerce update in the first pass', + ); + } + } + // ── Private helpers ────────────────────────────────────────────────── /// Maps the WooCommerce `status` string to our [PublishStatus] enum. 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 094894a..80e47ff 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,13 +1,15 @@ import '../domain/product_draft.dart'; import '../domain/product_publishing_repository.dart'; +import '../domain/publish_status.dart'; import 'woo_commerce_api_client.dart'; import 'wordpress_product_mapper.dart'; /// Real [ProductPublishingRepository] backed by the WooCommerce REST API. /// -/// Currently implements **read-only** product retrieval. The [publishDraft] -/// method throws [UnimplementedError] — full publishing support will be -/// added in a future iteration. +/// Supports read-only product retrieval and status updates via +/// `PUT /wp-json/wc/v3/products/{id}`. The [publishDraft] method throws +/// [UnimplementedError] — full publishing support will be added in a future +/// iteration. class WordPressProductPublishingRepository implements ProductPublishingRepository { final WooCommerceApiClient _apiClient; final WordPressProductMapper _mapper; @@ -34,4 +36,11 @@ class WordPressProductPublishingRepository implements ProductPublishingRepositor 'Use FakeProductPublishingRepository for development.', ); } + + @override + Future updateProductStatus(String id, PublishStatus status) async { + final wooStatus = WordPressProductMapper.toWooCommerceStatus(status); + final json = await _apiClient.updateProduct(id, {'status': wooStatus}); + return _mapper.fromJson(json); + } } 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 0106749..d22ceab 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,5 @@ import 'product_draft.dart'; +import 'publish_status.dart'; /// Contract for fetching and managing product drafts. abstract class ProductPublishingRepository { @@ -7,4 +8,13 @@ abstract class ProductPublishingRepository { /// Publishes a draft by [id]. Returns the updated draft. Future publishDraft(String id); + + /// Updates the publishing status of the product identified by [id]. + /// + /// Only [PublishStatus.draft] and [PublishStatus.published] are supported + /// in the first pass. Implementations may throw [ArgumentError] for + /// unsupported statuses. + /// + /// Returns the updated [ProductDraft] reflecting the new status. + Future updateProductStatus(String id, PublishStatus status); } diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/product_publishing_page.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/product_publishing_page.dart index f2694f6..ad32dc4 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/product_publishing_page.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/product_publishing_page.dart @@ -4,7 +4,9 @@ import 'package:flutter/material.dart'; import '../application/get_product_drafts.dart'; import '../application/product_publishing_controller.dart'; import '../application/publish_product.dart'; +import '../application/update_product_status.dart'; import '../domain/product_publishing_repository.dart'; +import '../domain/publish_status.dart'; import 'widgets/product_draft_card.dart'; import 'widgets/product_preview_panel.dart'; @@ -48,7 +50,11 @@ class _ProductPublishingPageState extends State { void initState() { super.initState(); final repo = widget.repository; - controller = ProductPublishingController(GetProductDrafts(repo), PublishProduct(repo)); + controller = ProductPublishingController( + GetProductDrafts(repo), + PublishProduct(repo), + UpdateProductStatus(repo), + ); // Apply any initial filter / query before loading. if (widget.initialFilter != null) { @@ -131,7 +137,9 @@ class _ProductPublishingPageState extends State { } return ProductPreviewPanel( draft: selected, - onPublish: () => controller.publish(selected.id), + isUpdating: controller.isUpdating(selected.id), + onPublish: () => controller.updateStatus(selected.id, PublishStatus.published), + onMoveToDraft: () => controller.updateStatus(selected.id, PublishStatus.draft), onViewPolicy: widget.onViewPolicy, ); } diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_preview_panel.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_preview_panel.dart index 40c2d30..278293d 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_preview_panel.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/product_preview_panel.dart @@ -7,16 +7,33 @@ import 'publish_status_chip.dart'; /// A detail panel that shows a full preview of the selected [ProductDraft]. /// -/// Includes product image placeholder, description, metadata, and a -/// publish action button when the draft is not yet published. +/// Includes product image placeholder, description, metadata, and +/// status-aware action buttons: +/// - **Publish to Store** when the draft status is [PublishStatus.draft]. +/// - **Move to Draft** when the draft status is [PublishStatus.published]. +/// - No action button for other statuses. class ProductPreviewPanel extends StatelessWidget { final ProductDraft draft; final VoidCallback? onPublish; + /// Callback to revert a published product back to draft status. + final VoidCallback? onMoveToDraft; + + /// Whether this product currently has an in-flight status update. + /// When true, the action button is disabled and a progress indicator is shown. + final bool isUpdating; + /// Optional callback to navigate to the Policy page. final VoidCallback? onViewPolicy; - const ProductPreviewPanel({super.key, required this.draft, this.onPublish, this.onViewPolicy}); + const ProductPreviewPanel({ + super.key, + required this.draft, + this.onPublish, + this.onMoveToDraft, + this.isUpdating = false, + this.onViewPolicy, + }); @override Widget build(BuildContext context) { @@ -47,6 +64,13 @@ class ProductPreviewPanel extends StatelessWidget { children: [ Expanded(child: Text(draft.name, style: theme.textTheme.headlineMedium)), const SizedBox(width: KcSpacing.sm), + if (isUpdating) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + if (isUpdating) const SizedBox(width: KcSpacing.sm), PublishStatusChip(status: draft.status), ], ), @@ -84,16 +108,25 @@ class ProductPreviewPanel extends StatelessWidget { const SizedBox(height: KcSpacing.md), ], - // ── Publish button ───────────────────────────────────────── - if (draft.status != PublishStatus.published) + // ── Status action buttons ──────────────────────────────── + if (draft.status == PublishStatus.draft) SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: onPublish, + onPressed: isUpdating ? null : onPublish, icon: const Icon(Icons.publish), label: const Text('Publish to Store'), ), ), + if (draft.status == PublishStatus.published) + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: isUpdating ? null : onMoveToDraft, + icon: const Icon(Icons.unpublished_outlined), + label: const Text('Move to Draft'), + ), + ), ], ), ), diff --git a/kell_creations_apps/packages/feature_wordpress/test/fake_product_publishing_repository_test.dart b/kell_creations_apps/packages/feature_wordpress/test/fake_product_publishing_repository_test.dart index 70a8ff3..40e878d 100644 --- a/kell_creations_apps/packages/feature_wordpress/test/fake_product_publishing_repository_test.dart +++ b/kell_creations_apps/packages/feature_wordpress/test/fake_product_publishing_repository_test.dart @@ -45,5 +45,81 @@ void main() { test('publishDraft throws for unknown id', () async { expect(() => repository.publishDraft('unknown'), throwsA(isA())); }); + + group('updateProductStatus', () { + test('draft to published updates only the target product', () async { + // Product 4 starts as draft. + final updated = await repository.updateProductStatus('4', PublishStatus.published); + + expect(updated.id, '4'); + expect(updated.status, PublishStatus.published); + expect(updated.name, 'Fabric Jar Gripper'); + + // Verify the change is persisted in the list. + final drafts = await repository.getProductDrafts(); + final product4 = drafts.firstWhere((d) => d.id == '4'); + expect(product4.status, PublishStatus.published); + }); + + test('published to draft updates only the target product', () async { + // Product 1 starts as published. + final updated = await repository.updateProductStatus('1', PublishStatus.draft); + + expect(updated.id, '1'); + expect(updated.status, PublishStatus.draft); + expect(updated.name, 'Floral Bowl Cozy'); + + // Verify the change is persisted in the list. + final drafts = await repository.getProductDrafts(); + final product1 = drafts.firstWhere((d) => d.id == '1'); + expect(product1.status, PublishStatus.draft); + }); + + test('preserves other products unchanged', () async { + final draftsBefore = await repository.getProductDrafts(); + + // Update product 4 only. + await repository.updateProductStatus('4', PublishStatus.published); + + final draftsAfter = await repository.getProductDrafts(); + + // All other products should be identical. + for (final before in draftsBefore) { + if (before.id == '4') continue; + final after = draftsAfter.firstWhere((d) => d.id == before.id); + expect(after.name, before.name); + expect(after.status, before.status); + expect(after.price, before.price); + expect(after.sku, before.sku); + expect(after.category, before.category); + expect(after.description, before.description); + expect(after.imageUrl, before.imageUrl); + } + }); + + test('preserves all fields of the updated product except status and lastModified', () async { + final draftsBefore = await repository.getProductDrafts(); + final before = draftsBefore.firstWhere((d) => d.id == '4'); + + final updated = await repository.updateProductStatus('4', PublishStatus.published); + + expect(updated.name, before.name); + expect(updated.price, before.price); + expect(updated.sku, before.sku); + expect(updated.category, before.category); + expect(updated.description, before.description); + expect(updated.imageUrl, before.imageUrl); + // Status changed, lastModified updated. + expect(updated.status, PublishStatus.published); + expect(updated.lastModified.isAfter(before.lastModified), isTrue); + }); + + test('throws StateError for unknown id', () async { + expect( + () => repository.updateProductStatus('unknown', PublishStatus.draft), + throwsA(isA()), + ); + }); + }); }); } diff --git a/kell_creations_apps/packages/feature_wordpress/test/product_publishing_controller_test.dart b/kell_creations_apps/packages/feature_wordpress/test/product_publishing_controller_test.dart index f1708bf..af08c1e 100644 --- a/kell_creations_apps/packages/feature_wordpress/test/product_publishing_controller_test.dart +++ b/kell_creations_apps/packages/feature_wordpress/test/product_publishing_controller_test.dart @@ -14,6 +14,7 @@ void main() { controller = ProductPublishingController( GetProductDrafts(repository), PublishProduct(repository), + UpdateProductStatus(repository), ); }); @@ -29,6 +30,7 @@ void main() { expect(controller.activeFilter, isNull); expect(controller.searchQuery, ''); expect(controller.error, isNull); + expect(controller.updatingIds, isEmpty); }); test('load populates drafts and auto-selects first', () async { @@ -129,5 +131,103 @@ void main() { controller.setFilter('draft'); expect(controller.selectedDraft, isNull); }); + + group('updateStatus', () { + test('draft to published updates status and reloads', () async { + await controller.load(); + + // Product 4 starts as draft. + await controller.updateStatus('4', PublishStatus.published); + + final updated = controller.drafts.firstWhere((d) => d.id == '4'); + expect(updated.status, PublishStatus.published); + expect(controller.error, isNull); + expect(controller.updatingIds, isEmpty); + }); + + test('published to draft updates status and reloads', () async { + await controller.load(); + + // Product 1 starts as published. + await controller.updateStatus('1', PublishStatus.draft); + + final updated = controller.drafts.firstWhere((d) => d.id == '1'); + expect(updated.status, PublishStatus.draft); + expect(controller.error, isNull); + expect(controller.updatingIds, isEmpty); + }); + + test('sets error on failed update', () async { + await controller.load(); + + // Unknown id triggers StateError in the fake repository. + await controller.updateStatus('unknown', PublishStatus.draft); + + expect(controller.error, isA()); + expect(controller.updatingIds, isEmpty); + }); + + test('prevents duplicate calls while row is already updating', () async { + await controller.load(); + + // Start the first update but don't await it yet. + final first = controller.updateStatus('4', PublishStatus.published); + + // While the first is in flight, the id should be tracked. + expect(controller.isUpdating('4'), isTrue); + + // A second call for the same id should be silently ignored. + final second = controller.updateStatus('4', PublishStatus.published); + + // Wait for both to complete. + await first; + await second; + + // The product should be updated exactly once. + final updated = controller.drafts.firstWhere((d) => d.id == '4'); + expect(updated.status, PublishStatus.published); + expect(controller.updatingIds, isEmpty); + }); + + test('isUpdating returns false for non-updating ids', () async { + await controller.load(); + expect(controller.isUpdating('4'), isFalse); + }); + + test('does not notify after disposal on async completion', () async { + await controller.load(); + + // Start an update, then immediately dispose. + final future = controller.updateStatus('4', PublishStatus.published); + controller.dispose(); + + // This should complete without throwing (no notifyListeners after + // dispose). + await future; + + // No error should be thrown — the test itself passing is the assertion. + }); + }); + + group('disposed guard', () { + test('load does not notify after disposal', () async { + // Start load, then immediately dispose. + final future = controller.load(); + controller.dispose(); + + // Should complete without throwing. + await future; + }); + + test('publish does not notify after disposal', () async { + await controller.load(); + + final future = controller.publish('4'); + controller.dispose(); + + // Should complete without throwing. + await future; + }); + }); }); } diff --git a/kell_creations_apps/packages/feature_wordpress/test/widgets/product_preview_panel_test.dart b/kell_creations_apps/packages/feature_wordpress/test/widgets/product_preview_panel_test.dart index c2ac569..03a4c24 100644 --- a/kell_creations_apps/packages/feature_wordpress/test/widgets/product_preview_panel_test.dart +++ b/kell_creations_apps/packages/feature_wordpress/test/widgets/product_preview_panel_test.dart @@ -30,12 +30,34 @@ void main() { lastModified: DateTime(2026, 3, 28), ); - Widget buildTestWidget(ProductDraft draft, {VoidCallback? onPublish}) { + final pendingDraft = ProductDraft( + id: '3', + name: 'Pending Product', + description: 'Awaiting review.', + price: 14.99, + sku: 'PR-001', + category: 'Nightlights', + imageUrl: '', + status: PublishStatus.pendingReview, + lastModified: DateTime(2026, 4, 1), + ); + + Widget buildTestWidget( + ProductDraft draft, { + VoidCallback? onPublish, + VoidCallback? onMoveToDraft, + bool isUpdating = false, + }) { return MaterialApp( theme: buildKcTheme(), home: Scaffold( body: SingleChildScrollView( - child: ProductPreviewPanel(draft: draft, onPublish: onPublish), + child: ProductPreviewPanel( + draft: draft, + onPublish: onPublish, + onMoveToDraft: onMoveToDraft, + isUpdating: isUpdating, + ), ), ), ); @@ -67,16 +89,6 @@ void main() { expect(find.text('Bowl Cozies'), findsOneWidget); }); - testWidgets('shows publish button for non-published drafts', (tester) async { - await tester.pumpWidget(buildTestWidget(sampleDraft)); - expect(find.text('Publish to Store'), findsOneWidget); - }); - - testWidgets('hides publish button for published drafts', (tester) async { - await tester.pumpWidget(buildTestWidget(publishedDraft)); - expect(find.text('Publish to Store'), findsNothing); - }); - testWidgets('calls onPublish when button is tapped', (tester) async { var published = false; await tester.pumpWidget(buildTestWidget(sampleDraft, onPublish: () => published = true)); @@ -84,4 +96,69 @@ void main() { expect(published, true); }); }); + + group('status-aware action buttons', () { + testWidgets('draft row shows Publish to Store button', (tester) async { + await tester.pumpWidget(buildTestWidget(sampleDraft)); + expect(find.text('Publish to Store'), findsOneWidget); + expect(find.text('Move to Draft'), findsNothing); + }); + + testWidgets('published row shows Move to Draft button', (tester) async { + await tester.pumpWidget(buildTestWidget(publishedDraft)); + expect(find.text('Move to Draft'), findsOneWidget); + expect(find.text('Publish to Store'), findsNothing); + }); + + testWidgets('pending review row shows no action button', (tester) async { + await tester.pumpWidget(buildTestWidget(pendingDraft)); + expect(find.text('Publish to Store'), findsNothing); + expect(find.text('Move to Draft'), findsNothing); + }); + + testWidgets('calls onMoveToDraft when Move to Draft is tapped', (tester) async { + var movedToDraft = false; + await tester.pumpWidget( + buildTestWidget(publishedDraft, onMoveToDraft: () => movedToDraft = true), + ); + await tester.tap(find.text('Move to Draft')); + expect(movedToDraft, true); + }); + }); + + group('updating state', () { + testWidgets('Publish button is disabled while updating', (tester) async { + var tapped = false; + await tester.pumpWidget( + buildTestWidget(sampleDraft, isUpdating: true, onPublish: () => tapped = true), + ); + + // The button should be present but disabled. + expect(find.text('Publish to Store'), findsOneWidget); + await tester.tap(find.text('Publish to Store')); + expect(tapped, false); + }); + + testWidgets('Move to Draft button is disabled while updating', (tester) async { + var tapped = false; + await tester.pumpWidget( + buildTestWidget(publishedDraft, isUpdating: true, onMoveToDraft: () => tapped = true), + ); + + // The button should be present but disabled. + expect(find.text('Move to Draft'), findsOneWidget); + await tester.tap(find.text('Move to Draft')); + expect(tapped, false); + }); + + testWidgets('shows progress indicator while updating', (tester) async { + await tester.pumpWidget(buildTestWidget(sampleDraft, isUpdating: true)); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('hides progress indicator when not updating', (tester) async { + await tester.pumpWidget(buildTestWidget(sampleDraft, isUpdating: false)); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + }); } diff --git a/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_mapper_test.dart b/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_mapper_test.dart index 62b97c9..faf40dc 100644 --- a/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_mapper_test.dart +++ b/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_mapper_test.dart @@ -166,6 +166,30 @@ void main() { }); }); + group('WordPressProductMapper.toWooCommerceStatus', () { + test('draft -> "draft"', () { + expect(WordPressProductMapper.toWooCommerceStatus(PublishStatus.draft), 'draft'); + }); + + test('published -> "publish"', () { + expect(WordPressProductMapper.toWooCommerceStatus(PublishStatus.published), 'publish'); + }); + + test('pendingReview throws ArgumentError', () { + expect( + () => WordPressProductMapper.toWooCommerceStatus(PublishStatus.pendingReview), + throwsA(isA()), + ); + }); + + test('unpublished throws ArgumentError', () { + expect( + () => WordPressProductMapper.toWooCommerceStatus(PublishStatus.unpublished), + throwsA(isA()), + ); + }); + }); + group('WordPressProductMapper.fromJsonList', () { test('maps a list of JSON objects', () { final jsonList = [ diff --git a/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_publishing_repository_test.dart b/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_publishing_repository_test.dart index fc0e378..6698eeb 100644 --- a/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_publishing_repository_test.dart +++ b/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_publishing_repository_test.dart @@ -153,5 +153,157 @@ void main() { expect(() => repository.getProductDrafts(), throwsA(isA())); }); + + group('updateProductStatus', () { + /// Builds a single WooCommerce product JSON response with the given + /// [id] and [status]. + Map buildSingleProductJson({required int id, required String status}) { + return { + 'id': id, + 'name': 'Product $id', + 'description': '

Description

', + 'price': '10.99', + 'sku': 'SKU-$id', + 'status': status, + 'date_modified': '2026-04-01T10:00:00', + 'date_created': '2026-03-01T08:00:00', + 'categories': [ + {'id': 1, 'name': 'Test Category', 'slug': 'test-category'}, + ], + 'images': [ + {'id': 1, 'src': 'https://example.com/img$id.jpg'}, + ], + }; + } + + test('sends PUT with minimal status-only payload and returns mapped ProductDraft', () async { + Map? capturedBody; + Uri? capturedUri; + String? capturedMethod; + + final mockClient = MockClient((request) async { + capturedMethod = request.method; + capturedUri = request.url; + capturedBody = jsonDecode(request.body) as Map; + + return http.Response(jsonEncode(buildSingleProductJson(id: 7, status: 'publish')), 200); + }); + + final apiClient = WooCommerceApiClient( + siteUrl: 'https://store.example.com', + consumerKey: 'ck_test', + consumerSecret: 'cs_test', + httpClient: mockClient, + ); + + final repository = WordPressProductPublishingRepository(apiClient: apiClient); + final result = await repository.updateProductStatus('7', PublishStatus.published); + + // Verify the HTTP request shape. + expect(capturedMethod, 'PUT'); + expect(capturedUri!.path, '/wp-json/wc/v3/products/7'); + expect(capturedBody, {'status': 'publish'}); + + // Verify the returned domain object is cleanly mapped. + expect(result, isA()); + expect(result.id, '7'); + expect(result.name, 'Product 7'); + expect(result.status, PublishStatus.published); + }); + + test('maps PublishStatus.draft to WooCommerce "draft" in the request', () async { + Map? capturedBody; + + final mockClient = MockClient((request) async { + capturedBody = jsonDecode(request.body) as Map; + return http.Response(jsonEncode(buildSingleProductJson(id: 3, status: 'draft')), 200); + }); + + final apiClient = WooCommerceApiClient( + siteUrl: 'https://store.example.com', + consumerKey: 'ck_test', + consumerSecret: 'cs_test', + httpClient: mockClient, + ); + + final repository = WordPressProductPublishingRepository(apiClient: apiClient); + final result = await repository.updateProductStatus('3', PublishStatus.draft); + + expect(capturedBody, {'status': 'draft'}); + expect(result.status, PublishStatus.draft); + }); + + test('throws WooCommerceApiException on non-200 response', () async { + final mockClient = MockClient((request) async { + return http.Response('{"code":"rest_cannot_edit"}', 403); + }); + + final apiClient = WooCommerceApiClient( + siteUrl: 'https://store.example.com', + consumerKey: 'ck_test', + consumerSecret: 'cs_test', + httpClient: mockClient, + ); + + final repository = WordPressProductPublishingRepository(apiClient: apiClient); + + expect( + () => repository.updateProductStatus('7', PublishStatus.published), + throwsA(isA()), + ); + }); + + test('throws ArgumentError for unsupported status values', () { + final mockClient = MockClient((request) async { + return http.Response('{}', 200); + }); + + final apiClient = WooCommerceApiClient( + siteUrl: 'https://store.example.com', + consumerKey: 'ck_test', + consumerSecret: 'cs_test', + httpClient: mockClient, + ); + + final repository = WordPressProductPublishingRepository(apiClient: apiClient); + + expect( + () => repository.updateProductStatus('1', PublishStatus.pendingReview), + throwsA(isA()), + ); + expect( + () => repository.updateProductStatus('1', PublishStatus.unpublished), + throwsA(isA()), + ); + }); + + test('returns ProductDraft, not raw WooCommerce JSON (package boundary)', () async { + final mockClient = MockClient((request) async { + // WooCommerce echoes back many extra fields that should NOT leak. + final json = buildSingleProductJson(id: 5, status: 'publish'); + json['permalink'] = 'https://store.example.com/product/5'; + json['meta_data'] = [ + {'id': 1, 'key': '_internal', 'value': 'secret'}, + ]; + return http.Response(jsonEncode(json), 200); + }); + + final apiClient = WooCommerceApiClient( + siteUrl: 'https://store.example.com', + consumerKey: 'ck_test', + consumerSecret: 'cs_test', + httpClient: mockClient, + ); + + final repository = WordPressProductPublishingRepository(apiClient: apiClient); + final result = await repository.updateProductStatus('5', PublishStatus.published); + + // The result is a domain ProductDraft — no WooCommerce-specific + // fields like permalink or meta_data are exposed. + expect(result, isA()); + expect(result.id, '5'); + expect(result.status, PublishStatus.published); + }); + }); }); }