From b69edd3e4a349a6afb441768f5cdb4fb1ae985d0 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sat, 11 Apr 2026 15:54:51 -0400 Subject: [PATCH] feat(wordpress): add category-only product edit (Stage 1B) --- docs/development/master_development_brief.md | 16 +-- .../dashboard_controller_test.dart | 4 + .../lib/feature_wordpress.dart | 1 + .../product_publishing_controller.dart | 66 +++++++++++ .../application/update_product_category.dart | 14 +++ .../fake_product_publishing_repository.dart | 15 +++ ...rdpress_product_publishing_repository.dart | 10 ++ .../domain/product_publishing_repository.dart | 5 + .../presentation/product_publishing_page.dart | 9 ++ .../widgets/product_preview_panel.dart | 110 +++++++++++++++++- .../widgets/status_action_snack_bar.dart | 36 ++++++ ...ke_product_publishing_repository_test.dart | 63 ++++++++++ .../product_publishing_controller_test.dart | 65 +++++++++++ .../widgets/product_publishing_page_test.dart | 4 + 14 files changed, 410 insertions(+), 8 deletions(-) create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/application/update_product_category.dart diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index ae4aad4..84b3e72 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -78,6 +78,7 @@ Rules: - Search/filter/sort refinement landed. - Name-only product edit landed. - ✅ Description-only product edit landed (Stage 1A complete — merged `feat/description-only-edit` → `main` at `cebac4c`, 2026-04-11). +- ✅ Category-only product edit implemented (Stage 1B — `feat/category-only-edit`, pending merge to `main`, 2026-04-11). ### Current narrow edit capabilities on `main` @@ -85,20 +86,21 @@ Rules: - update product price only - update product name only - update product description only +- update product category only _(on `feat/category-only-edit`, pending merge)_ -### Latest known validation state on `main` +### Latest known validation state on `feat/category-only-edit` -- `flutter analyze` clean +- `dart analyze` clean - `feature_wordpress` tests passing - `kell_web` dashboard tests passing -- latest reported count for `feature_wordpress`: `212/212 passed` -- latest reported count for `kell_web` dashboard tests: `10/10 passed` -- baseline commit: `cebac4c` (2026-04-11) +- latest reported count for `feature_wordpress`: `223/223 passed` +- latest reported count for `kell_web` dashboard tests: `5/5 passed` +- baseline: branched from `main` at `cebac4c` (2026-04-11) ### Next recommended branch -**`feat/category-only-edit`** — Stage 1B: Category-only product edit. -Branch from `main` at `cebac4c`. Follow the same narrow single-field edit pattern established by price, name, and description edits. +**`feat/post-write-consistency`** — Stage 2A: Post-write consistency hardening. +Branch from `main` after merging `feat/category-only-edit`. This completes Stage 1 (controlled product editing) and moves to Stage 2 (operational hardening). --- 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 95c0007..d8f22f9 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 @@ -39,6 +39,10 @@ class _StubProductPublishingRepository implements ProductPublishingRepository { @override Future updateProductDescription(String id, String description) => throw UnimplementedError(); + + @override + Future updateProductCategory(String id, String category) => + 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 3d157f9..6260d41 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart @@ -13,6 +13,7 @@ export 'src/domain/publish_status.dart'; // Application export 'src/application/product_publishing_controller.dart' show ProductSortField; +export 'src/application/update_product_category.dart'; export 'src/application/update_product_description.dart'; export 'src/application/update_product_name.dart'; export 'src/application/update_product_price.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 b9198b9..261ac60 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,6 +4,7 @@ import '../domain/product_draft.dart'; import '../domain/publish_status.dart'; import 'get_product_drafts.dart'; import 'publish_product.dart'; +import 'update_product_category.dart'; import 'update_product_description.dart'; import 'update_product_name.dart'; import 'update_product_price.dart'; @@ -87,6 +88,23 @@ class DescriptionActionResult { }); } +/// The outcome of a category-update action. +/// +/// Consumed once by the UI to show a SnackBar, then cleared. +class CategoryActionResult { + final bool success; + final String productName; + final String newCategory; + final String? errorMessage; + + const CategoryActionResult({ + required this.success, + required this.productName, + required this.newCategory, + this.errorMessage, + }); +} + /// Controller that manages the product publishing workspace state, including /// filtering by publish status, free-text search, and draft selection. class ProductPublishingController extends ChangeNotifier { @@ -96,6 +114,7 @@ class ProductPublishingController extends ChangeNotifier { final UpdateProductPrice _updateProductPrice; final UpdateProductName _updateProductName; final UpdateProductDescription _updateProductDescription; + final UpdateProductCategory _updateProductCategory; ProductPublishingController( this._getProductDrafts, @@ -104,6 +123,7 @@ class ProductPublishingController extends ChangeNotifier { this._updateProductPrice, this._updateProductName, this._updateProductDescription, + this._updateProductCategory, ); bool _disposed = false; @@ -174,6 +194,13 @@ class ProductPublishingController extends ChangeNotifier { /// to clear it. DescriptionActionResult? lastDescriptionResult; + /// The result of the last category-update action. + /// + /// Set after [updateCategory] completes. The UI should read this once to + /// show feedback (e.g. a SnackBar) and then call [consumeCategoryResult] + /// to clear it. + CategoryActionResult? lastCategoryResult; + /// Clears [lastActionResult] so the same result is not shown twice. void consumeActionResult() { lastActionResult = null; @@ -194,6 +221,11 @@ class ProductPublishingController extends ChangeNotifier { lastDescriptionResult = null; } + /// Clears [lastCategoryResult] so the same result is not shown twice. + void consumeCategoryResult() { + lastCategoryResult = null; + } + /// Loads all product drafts and applies any current filter / search. Future load() async { isLoading = true; @@ -415,6 +447,40 @@ class ProductPublishingController extends ChangeNotifier { } } + /// Updates only the category of the product with [id]. + /// + /// Follows the same per-row updating pattern as [updateStatus]. + Future updateCategory(String id, String category) async { + if (updatingIds.contains(id)) return; + + final productName = _productNameById(id); + + updatingIds.add(id); + _safeNotify(); + + try { + await _updateProductCategory(id, category); + if (_disposed) return; + updatingIds.remove(id); + lastCategoryResult = CategoryActionResult( + success: true, + productName: productName, + newCategory: category, + ); + await load(); + } catch (e) { + if (_disposed) return; + updatingIds.remove(id); + lastCategoryResult = CategoryActionResult( + success: false, + productName: productName, + newCategory: category, + errorMessage: e.toString(), + ); + _safeNotify(); + } + } + // ── Lifecycle ─────────────────────────────────────────────────────────── @override diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/application/update_product_category.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/application/update_product_category.dart new file mode 100644 index 0000000..64ac962 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/application/update_product_category.dart @@ -0,0 +1,14 @@ +import '../domain/product_draft.dart'; +import '../domain/product_publishing_repository.dart'; + +/// Use case: update only the category of a single product by its [id]. +/// +/// This is a narrow category mutation — not a generic product edit. +class UpdateProductCategory { + final ProductPublishingRepository repository; + + UpdateProductCategory(this.repository); + + Future call(String id, String category) => + repository.updateProductCategory(id, category); +} 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 fc64f64..67fc5dc 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 @@ -170,4 +170,19 @@ class FakeProductPublishingRepository implements ProductPublishingRepository { _drafts[index] = updated; return updated; } + + @override + Future updateProductCategory(String id, String category) 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 = original.copyWith(category: category, lastModified: DateTime.now()); + _drafts[index] = updated; + return updated; + } } 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 cd42f39..a63fd27 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 @@ -54,4 +54,14 @@ class WordPressProductPublishingRepository implements ProductPublishingRepositor final json = await _apiClient.updateProduct(id, {'description': description}); return _mapper.fromJson(json); } + + @override + Future updateProductCategory(String id, String category) async { + final json = await _apiClient.updateProduct(id, { + 'categories': [ + {'name': category}, + ], + }); + 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 faef746..264705f 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 @@ -32,4 +32,9 @@ abstract class ProductPublishingRepository { /// /// Returns the updated [ProductDraft] reflecting the new description. Future updateProductDescription(String id, String description); + + /// Updates only the category of the product identified by [id]. + /// + /// Returns the updated [ProductDraft] reflecting the new category. + Future updateProductCategory(String id, String category); } 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 0da7eef..4d6449a 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,6 +4,7 @@ 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_category.dart'; import '../application/update_product_description.dart'; import '../application/update_product_name.dart'; import '../application/update_product_price.dart'; @@ -61,6 +62,7 @@ class _ProductPublishingPageState extends State { UpdateProductPrice(repo), UpdateProductName(repo), UpdateProductDescription(repo), + UpdateProductCategory(repo), ); controller.addListener(_onControllerChanged); @@ -112,6 +114,12 @@ class _ProductPublishingPageState extends State { controller.consumeDescriptionResult(); showDescriptionActionSnackBar(context, descriptionResult); } + + final categoryResult = controller.lastCategoryResult; + if (categoryResult != null) { + controller.consumeCategoryResult(); + showCategoryActionSnackBar(context, categoryResult); + } } @override @@ -249,6 +257,7 @@ class _ProductPublishingPageState extends State { onPriceChanged: (price) => controller.updatePrice(selected.id, price), onNameChanged: (name) => controller.updateName(selected.id, name), onDescriptionChanged: (desc) => controller.updateDescription(selected.id, desc), + onCategoryChanged: (cat) => controller.updateCategory(selected.id, cat), 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 3e0582b..23ebff7 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 @@ -29,6 +29,9 @@ class ProductPreviewPanel extends StatefulWidget { /// Callback to update the product description. Receives the new description value. final ValueChanged? onDescriptionChanged; + /// Callback to update the product category. Receives the new category value. + final ValueChanged? onCategoryChanged; + /// 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; @@ -44,6 +47,7 @@ class ProductPreviewPanel extends StatefulWidget { this.onPriceChanged, this.onNameChanged, this.onDescriptionChanged, + this.onCategoryChanged, this.isUpdating = false, this.onViewPolicy, }); @@ -62,12 +66,17 @@ class _ProductPreviewPanelState extends State { bool _editingDescription = false; late TextEditingController _descriptionController; + // ignore: prefer_final_fields + bool _editingCategory = false; + late TextEditingController _categoryController; + @override void initState() { super.initState(); _priceController = TextEditingController(text: widget.draft.price.toStringAsFixed(2)); _nameController = TextEditingController(text: widget.draft.name); _descriptionController = TextEditingController(text: widget.draft.description); + _categoryController = TextEditingController(text: widget.draft.category); } @override @@ -86,6 +95,11 @@ class _ProductPreviewPanelState extends State { _editingDescription = false; _descriptionController.text = widget.draft.description; } + if (oldWidget.draft.id != widget.draft.id || + oldWidget.draft.category != widget.draft.category) { + _editingCategory = false; + _categoryController.text = widget.draft.category; + } } @override @@ -93,6 +107,7 @@ class _ProductPreviewPanelState extends State { _priceController.dispose(); _nameController.dispose(); _descriptionController.dispose(); + _categoryController.dispose(); super.dispose(); } @@ -135,6 +150,19 @@ class _ProductPreviewPanelState extends State { setState(() => _editingDescription = false); } + void _submitCategory() { + final trimmed = _categoryController.text.trim(); + if (trimmed.isNotEmpty) { + widget.onCategoryChanged?.call(trimmed); + setState(() => _editingCategory = false); + } + } + + void _cancelCategoryEdit() { + _categoryController.text = widget.draft.category; + setState(() => _editingCategory = false); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -156,7 +184,7 @@ class _ProductPreviewPanelState extends State { // ── Metadata ─────────────────────────────────────────────── _MetadataRow(label: 'SKU', value: draft.sku), _buildPriceRow(context), - _MetadataRow(label: 'Category', value: draft.category), + _buildCategoryRow(context), _MetadataRow( label: 'Last Modified', value: @@ -361,6 +389,86 @@ class _ProductPreviewPanelState extends State { ); } + /// Builds the Category metadata row — either a static display with an edit + /// icon, or an inline text field with save/cancel actions. + Widget _buildCategoryRow(BuildContext context) { + if (_editingCategory) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs), + child: Row( + children: [ + SizedBox( + width: 120, + child: Text( + 'Category', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: KcColors.neutral, + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + child: TextField( + controller: _categoryController, + decoration: const InputDecoration( + isDense: true, + contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), + ), + onSubmitted: (_) => _submitCategory(), + autofocus: true, + ), + ), + const SizedBox(width: KcSpacing.xs), + IconButton( + icon: const Icon(Icons.check, size: 20), + onPressed: _submitCategory, + tooltip: 'Save category', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + const SizedBox(width: KcSpacing.xs), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: _cancelCategoryEdit, + tooltip: 'Cancel', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs), + child: Row( + children: [ + SizedBox( + width: 120, + child: Text( + 'Category', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: KcColors.neutral, + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + child: Text(widget.draft.category, style: Theme.of(context).textTheme.bodyMedium), + ), + if (widget.onCategoryChanged != null && !widget.isUpdating) + IconButton( + icon: const Icon(Icons.edit, size: 18), + onPressed: () => setState(() => _editingCategory = true), + tooltip: 'Edit category', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ); + } + /// Builds the Description section — either a static display with an edit /// icon, or a multi-line text field with save/cancel actions. Widget _buildDescriptionSection(BuildContext context) { diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/status_action_snack_bar.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/status_action_snack_bar.dart index d55bf5e..a8c51fc 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/status_action_snack_bar.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/status_action_snack_bar.dart @@ -145,6 +145,42 @@ void showPriceActionSnackBar(BuildContext context, PriceActionResult result) { ); } +/// Shows a [SnackBar] for the given [CategoryActionResult]. +void showCategoryActionSnackBar(BuildContext context, CategoryActionResult result) { + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) return; + + final String message; + final Color backgroundColor; + final IconData icon; + + if (result.success) { + message = '${result.productName} category updated to ${result.newCategory}.'; + backgroundColor = KcColors.success; + icon = Icons.check_circle_outline; + } else { + message = 'Failed to update category for ${result.productName}.'; + backgroundColor = KcColors.danger; + icon = Icons.error_outline; + } + + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + content: Row( + children: [ + Icon(icon, color: Colors.white, size: 20), + const SizedBox(width: 8), + Expanded(child: Text(message)), + ], + ), + backgroundColor: backgroundColor, + behavior: SnackBarBehavior.floating, + duration: result.success ? const Duration(seconds: 3) : const Duration(seconds: 5), + ), + ); +} + /// Shows a [SnackBar] for the given [NameActionResult]. void showNameActionSnackBar(BuildContext context, NameActionResult result) { final messenger = ScaffoldMessenger.maybeOf(context); 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 b1d7592..e154a15 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 @@ -299,5 +299,68 @@ void main() { ); }); }); + + group('updateProductCategory', () { + test('updates category of existing product', () async { + final before = (await repository.getProductDrafts()).firstWhere((d) => d.id == '4'); + expect(before.category, 'Kitchen Accessories'); + + final updated = await repository.updateProductCategory('4', 'Grippers'); + expect(updated.category, 'Grippers'); + expect(updated.id, '4'); + }); + + test('persists category change across subsequent reads', () async { + await repository.updateProductCategory('4', 'Grippers'); + + final drafts = await repository.getProductDrafts(); + final product = drafts.firstWhere((d) => d.id == '4'); + expect(product.category, 'Grippers'); + }); + + test('updates lastModified timestamp', () async { + final before = (await repository.getProductDrafts()).firstWhere((d) => d.id == '4'); + + final updated = await repository.updateProductCategory('4', 'Grippers'); + expect(updated.lastModified.isAfter(before.lastModified), isTrue); + }); + + test('preserves other fields unchanged', () async { + final before = (await repository.getProductDrafts()).firstWhere((d) => d.id == '4'); + + final updated = await repository.updateProductCategory('4', 'Grippers'); + expect(updated.name, before.name); + expect(updated.description, before.description); + expect(updated.price, before.price); + expect(updated.sku, before.sku); + expect(updated.imageUrl, before.imageUrl); + expect(updated.status, before.status); + expect(updated.category, 'Grippers'); + expect(updated.lastModified.isAfter(before.lastModified), isTrue); + }); + + test('preserves other products unchanged', () async { + final draftsBefore = await repository.getProductDrafts(); + + await repository.updateProductCategory('4', 'Grippers'); + + final draftsAfter = await repository.getProductDrafts(); + for (final before in draftsBefore) { + if (before.id == '4') continue; + final after = draftsAfter.firstWhere((d) => d.id == before.id); + expect(after.category, before.category); + expect(after.name, before.name); + expect(after.price, before.price); + expect(after.status, before.status); + } + }); + + test('throws StateError for unknown id', () async { + expect( + () => repository.updateProductCategory('unknown', 'New Cat'), + 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 b88d320..bfc0055 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 @@ -18,6 +18,7 @@ void main() { UpdateProductPrice(repository), UpdateProductName(repository), UpdateProductDescription(repository), + UpdateProductCategory(repository), ); }); @@ -492,6 +493,70 @@ void main() { }); }); + group('updateCategory', () { + test('updates category and reloads', () async { + await controller.load(); + + // Product 4 starts with category 'Kitchen Accessories'. + await controller.updateCategory('4', 'Grippers'); + + final updated = controller.drafts.firstWhere((d) => d.id == '4'); + expect(updated.category, 'Grippers'); + expect(controller.error, isNull); + expect(controller.updatingIds, isEmpty); + }); + + test('sets lastCategoryResult on success', () async { + await controller.load(); + + await controller.updateCategory('4', 'Grippers'); + + expect(controller.lastCategoryResult, isNotNull); + expect(controller.lastCategoryResult!.success, isTrue); + expect(controller.lastCategoryResult!.productName, 'Fabric Jar Gripper'); + expect(controller.lastCategoryResult!.newCategory, 'Grippers'); + expect(controller.lastCategoryResult!.errorMessage, isNull); + }); + + test('sets lastCategoryResult on failure', () async { + await controller.load(); + + await controller.updateCategory('unknown', 'New Cat'); + + expect(controller.lastCategoryResult, isNotNull); + expect(controller.lastCategoryResult!.success, isFalse); + expect(controller.lastCategoryResult!.errorMessage, isNotNull); + expect(controller.updatingIds, isEmpty); + expect(controller.error, isNull); + }); + + test('consumeCategoryResult clears the result', () async { + await controller.load(); + + await controller.updateCategory('4', 'Grippers'); + expect(controller.lastCategoryResult, isNotNull); + + controller.consumeCategoryResult(); + expect(controller.lastCategoryResult, isNull); + }); + + test('prevents duplicate calls while row is already updating', () async { + await controller.load(); + + final first = controller.updateCategory('4', 'First Category'); + expect(controller.isUpdating('4'), isTrue); + + final second = controller.updateCategory('4', 'Second Category'); + await first; + await second; + + // Only the first category should have been applied. + final updated = controller.drafts.firstWhere((d) => d.id == '4'); + expect(updated.category, 'First Category'); + expect(controller.updatingIds, isEmpty); + }); + }); + // ── Search refinements ────────────────────────────────────────────── group('search: category matching', () { 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 8ae3c63..029ceb2 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 @@ -33,6 +33,10 @@ class _FailingRepository implements ProductPublishingRepository { @override Future updateProductDescription(String id, String description) async => throw UnimplementedError(); + + @override + Future updateProductCategory(String id, String category) async => + throw UnimplementedError(); } void main() {