diff --git a/docs/development/build_execution_tracker.md b/docs/development/build_execution_tracker.md index 710f9fe..41afac0 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/inventory-page-ui (Stage 5B complete) -- next branch: feat/orders-domain (Stage 5C) or feat/bulk-operator-workflows (Stage 7B) -- current stage: Stage 5B complete — inventory page UI with search, filter chips, detail panel, quantity adjust dialog, and snack bar feedback +- main baseline updated through: feat/orders-domain (Stage 5C complete) +- next branch: feat/bulk-operator-workflows (Stage 7B) or feat/orders-page-ui (Stage 5D) +- current stage: Stage 5C complete — orders domain expansion with write operations (updateOrderStatus, addOrderNote, createOrder), OrderActionResult, copyWith, and 90 tests ## Slice tracker @@ -285,6 +285,31 @@ - analyze: passed (dart analyze --fatal-infos — no issues found) - brief updated: yes +### feat/orders-domain + +- status: merged to main +- date: 2026-07-10 +- inspection: complete +- implementation: complete +- files changed: + - `feature_orders/lib/src/domain/order.dart` — added `customerPhone`, `notes`, `orderNotes` fields; added `copyWith()` + - `feature_orders/lib/src/domain/order_action_result.dart` — new value object with `success`, `updatedOrder`, `errorMessage`; named constructors `success()` and `failure()` + - `feature_orders/lib/src/domain/orders_repository.dart` — expanded contract with `updateOrderStatus()`, `addOrderNote()`, `createOrder()` + - `feature_orders/lib/src/data/fake_orders_repository.dart` — added `customerPhone` to seed data; implemented all write methods with duplicate-check, empty-note guard, and status auto-update + - `feature_orders/lib/src/application/update_order_status.dart` — new use case wrapping `updateOrderStatus()` + - `feature_orders/lib/src/application/add_order_note.dart` — new use case wrapping `addOrderNote()` + - `feature_orders/lib/src/application/create_order.dart` — new use case wrapping `createOrder()` + - `feature_orders/lib/src/application/orders_controller.dart` — added `isUpdating`, `lastActionResult`, `updateOrderStatus()`, `addOrderNote()`, `createOrder()`, `consumeActionResult()`, `_reloadAndRefresh()` with filter/search persistence after writes; write use cases are optional named params for backward compatibility + - `feature_orders/lib/feature_orders.dart` — expanded barrel exports for all new types and use cases + - `feature_orders/test/feature_orders_test.dart` — expanded domain tests: Order (copyWith, defaults, computed props), OrderItem, OrderStatus, OrderActionResult, barrel smoke test + - `feature_orders/test/fake_orders_repository_test.dart` — expanded with write method tests: updateOrderStatus (4), addOrderNote (6), createOrder (4) + - `feature_orders/test/orders_controller_test.dart` — expanded with write-side tests: updateOrderStatus (6), addOrderNote (6), createOrder (4), consumeActionResult (3), filter/search persistence (2); read-side tests preserved + - `kell_web/test/dashboard/application/dashboard_controller_test.dart` — updated `_StubOrdersRepository` to implement expanded `OrdersRepository` contract + - `kell_creations_apps/tools/run_all_tests.sh` — added `feature_orders` and `kell_mobile` to `TESTABLE` list +- tests: passed (90/90 feature_orders, 24/24 kell_web — all passing) +- analyze: passed (dart analyze --fatal-infos — no issues found in feature_orders and kell_web) +- brief updated: yes + ### chore/analyze-cleanup-and-tracker-sync - status: merged to main diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index 406d621..4c3dad5 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -152,8 +152,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag ### Next recommended branch -**`feat/orders-domain`** — Stage 5C: Orders domain expansion (read + write operations, matching the pattern established by `feat/inventory-domain-expansion`), or **`feat/bulk-operator-workflows`** — Stage 7B: Additional bulk actions (bulk publish, bulk move to pending review). -Branch from latest `main`. Stage 5B (inventory page UI) is complete. Stage 5C adds write operations and domain expansion to the orders feature. Stage 7B adds incremental bulk actions to the publishing workflow. +**`feat/orders-page-ui`** — Stage 5D: Orders page UI hardening (status update action from detail panel, add note UI, snack bar feedback — matching the pattern established by `feat/inventory-page-ui`), or **`feat/bulk-operator-workflows`** — Stage 7B: Additional bulk actions (bulk publish, bulk move to pending review). +Branch from latest `main`. Stage 5C (orders domain expansion) is complete. Stage 5D adds write-action UI to the orders page. Stage 7B adds incremental bulk actions to the publishing workflow. --- @@ -1251,7 +1251,7 @@ Working rules: | ------------------- | ------------ | ----------------- | ------------ | -------------- | -------------------------------------------------- | ------- | -------------------- | | `feature_wordpress` | ✅ Complete | ✅ Complete | ✅ Complete | ✅ WooCommerce | ✅ Complete | 294 | **Production-ready** | | `feature_inventory` | ⚠️ Read-only | ⚠️ Read-only | ⚠️ Read-only | ❌ None | ⚠️ Read-only | Minimal | **Fake-only MVP** | -| `feature_orders` | ⚠️ Read-only | ⚠️ Read-only | ⚠️ Read-only | ❌ None | ⚠️ Read-only | Some | **Fake-only MVP** | +| `feature_orders` | ✅ Write ops | ✅ Write ops | ✅ Write ops | ❌ None | ⚠️ Read-only UI | 90 | **Fake write-ready** | | `feature_policy` | ✅ Complete | ✅ Complete | ✅ Complete | ❌ None | ✅ Complete | Minimal | **Fake-only MVP** | | `feature_finance` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** | | `feature_mrp` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** | 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 705f6e3..51df208 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 @@ -69,6 +69,16 @@ class _StubOrdersRepository implements OrdersRepository { @override Future> getOrders() async => orders; + + @override + Future updateOrderStatus(String id, OrderStatus newStatus) => + throw UnimplementedError(); + + @override + Future addOrderNote(String id, String note) => throw UnimplementedError(); + + @override + Future createOrder(Order order) => throw UnimplementedError(); } class _FailingInventoryRepository implements InventoryRepository { diff --git a/kell_creations_apps/packages/feature_orders/build/unit_test_assets/NOTICES.Z b/kell_creations_apps/packages/feature_orders/build/unit_test_assets/NOTICES.Z index 817dfc1..2fe8afc 100644 Binary files a/kell_creations_apps/packages/feature_orders/build/unit_test_assets/NOTICES.Z and b/kell_creations_apps/packages/feature_orders/build/unit_test_assets/NOTICES.Z differ diff --git a/kell_creations_apps/packages/feature_orders/build/unit_test_assets/shaders/ink_sparkle.frag b/kell_creations_apps/packages/feature_orders/build/unit_test_assets/shaders/ink_sparkle.frag index 794ba24..ebd8066 100644 Binary files a/kell_creations_apps/packages/feature_orders/build/unit_test_assets/shaders/ink_sparkle.frag and b/kell_creations_apps/packages/feature_orders/build/unit_test_assets/shaders/ink_sparkle.frag differ diff --git a/kell_creations_apps/packages/feature_orders/build/unit_test_assets/shaders/stretch_effect.frag b/kell_creations_apps/packages/feature_orders/build/unit_test_assets/shaders/stretch_effect.frag index c014a19..3c938de 100644 Binary files a/kell_creations_apps/packages/feature_orders/build/unit_test_assets/shaders/stretch_effect.frag and b/kell_creations_apps/packages/feature_orders/build/unit_test_assets/shaders/stretch_effect.frag differ diff --git a/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart b/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart index df547cb..0a301e7 100644 --- a/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart +++ b/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart @@ -1,7 +1,12 @@ library; +export 'src/application/add_order_note.dart'; +export 'src/application/create_order.dart'; +export 'src/application/get_orders.dart'; +export 'src/application/update_order_status.dart'; export 'src/data/fake_orders_repository.dart'; export 'src/domain/order.dart'; +export 'src/domain/order_action_result.dart'; export 'src/domain/order_item.dart'; export 'src/domain/order_status.dart'; export 'src/domain/orders_repository.dart'; diff --git a/kell_creations_apps/packages/feature_orders/lib/src/application/add_order_note.dart b/kell_creations_apps/packages/feature_orders/lib/src/application/add_order_note.dart new file mode 100644 index 0000000..f8f566c --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/application/add_order_note.dart @@ -0,0 +1,11 @@ +import '../domain/order_action_result.dart'; +import '../domain/orders_repository.dart'; + +/// Use case: append a note to an existing order. +class AddOrderNote { + final OrdersRepository repository; + + AddOrderNote(this.repository); + + Future call(String id, String note) => repository.addOrderNote(id, note); +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/application/create_order.dart b/kell_creations_apps/packages/feature_orders/lib/src/application/create_order.dart new file mode 100644 index 0000000..6397716 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/application/create_order.dart @@ -0,0 +1,12 @@ +import '../domain/order.dart'; +import '../domain/order_action_result.dart'; +import '../domain/orders_repository.dart'; + +/// Use case: create a new order and persist it. +class CreateOrder { + final OrdersRepository repository; + + CreateOrder(this.repository); + + Future call(Order order) => repository.createOrder(order); +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/application/orders_controller.dart b/kell_creations_apps/packages/feature_orders/lib/src/application/orders_controller.dart index c151fd3..c34e0cf 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/application/orders_controller.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/application/orders_controller.dart @@ -1,19 +1,42 @@ import 'package:flutter/foundation.dart'; import '../domain/order.dart'; +import '../domain/order_action_result.dart'; import '../domain/order_status.dart'; +import 'add_order_note.dart'; +import 'create_order.dart'; import 'get_orders.dart'; +import 'update_order_status.dart'; /// Controller that manages the orders workspace state, including -/// filtering by order status, free-text search, and order selection. +/// filtering by order status, free-text search, order selection, +/// and write operations (status update, note addition, order creation). class OrdersController extends ChangeNotifier { final GetOrders _getOrders; + final UpdateOrderStatus? _updateOrderStatus; + final AddOrderNote? _addOrderNote; + final CreateOrder? _createOrder; - OrdersController(this._getOrders); + OrdersController( + this._getOrders, { + UpdateOrderStatus? updateOrderStatus, + AddOrderNote? addOrderNote, + CreateOrder? createOrder, + }) : _updateOrderStatus = updateOrderStatus, + _addOrderNote = addOrderNote, + _createOrder = createOrder; bool isLoading = false; + + /// Whether a write operation is in progress for the selected order. + bool isUpdating = false; + Object? error; + /// The result of the last write action, or `null` if none has been performed + /// or the result has been consumed. + OrderActionResult? lastActionResult; + /// All orders returned by the repository (unfiltered). List _allOrders = []; @@ -32,6 +55,8 @@ class OrdersController extends ChangeNotifier { /// The current free-text search query applied to customer name / order ID. String searchQuery = ''; + // ── Read operations ──────────────────────────────────────────────────── + /// Loads all orders and applies any current filter / search. Future load() async { isLoading = true; @@ -82,8 +107,100 @@ class OrdersController extends ChangeNotifier { return false; } + // ── Write operations ─────────────────────────────────────────────────── + + /// Updates the status of the order identified by [id]. + /// + /// Sets [isUpdating] during the operation and stores the result in + /// [lastActionResult]. Reloads the order list on success and refreshes + /// [selectedOrder] if it was the updated order. + Future updateOrderStatus(String id, OrderStatus newStatus) async { + if (_updateOrderStatus == null) return; + isUpdating = true; + lastActionResult = null; + notifyListeners(); + + try { + final result = await _updateOrderStatus(id, newStatus); + lastActionResult = result; + if (result.success) { + await _reloadAndRefresh(id); + } + } finally { + isUpdating = false; + notifyListeners(); + } + } + + /// Appends a note to the order identified by [id]. + /// + /// Sets [isUpdating] during the operation and stores the result in + /// [lastActionResult]. Refreshes [selectedOrder] on success. + Future addOrderNote(String id, String note) async { + if (_addOrderNote == null) return; + isUpdating = true; + lastActionResult = null; + notifyListeners(); + + try { + final result = await _addOrderNote(id, note); + lastActionResult = result; + if (result.success) { + await _reloadAndRefresh(id); + } + } finally { + isUpdating = false; + notifyListeners(); + } + } + + /// Creates a new order and adds it to the list. + /// + /// Sets [isUpdating] during the operation and stores the result in + /// [lastActionResult]. Reloads the order list on success. + Future createOrder(Order order) async { + if (_createOrder == null) return; + isUpdating = true; + lastActionResult = null; + notifyListeners(); + + try { + final result = await _createOrder(order); + lastActionResult = result; + if (result.success) { + await _reloadAndRefresh(null); + } + } finally { + isUpdating = false; + notifyListeners(); + } + } + + /// Consumes and returns [lastActionResult], clearing it to `null`. + OrderActionResult? consumeActionResult() { + final result = lastActionResult; + lastActionResult = null; + return result; + } + // ── Private helpers ──────────────────────────────────────────────────── + /// Reloads all orders from the repository, re-applies filters, and + /// refreshes [selectedOrder] by [selectedId] if provided. + Future _reloadAndRefresh(String? selectedId) async { + _allOrders = await _getOrders(); + _applyFilters(); + + // Refresh the selected order from the updated list. + final refreshId = selectedId ?? selectedOrder?.id; + if (refreshId != null) { + final refreshed = _allOrders.where((o) => o.id == refreshId).firstOrNull; + if (refreshed != null) { + selectedOrder = refreshed; + } + } + } + void _applyFilters() { var result = _allOrders; diff --git a/kell_creations_apps/packages/feature_orders/lib/src/application/update_order_status.dart b/kell_creations_apps/packages/feature_orders/lib/src/application/update_order_status.dart new file mode 100644 index 0000000..2159e35 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/application/update_order_status.dart @@ -0,0 +1,13 @@ +import '../domain/order_action_result.dart'; +import '../domain/order_status.dart'; +import '../domain/orders_repository.dart'; + +/// Use case: update the status of an order. +class UpdateOrderStatus { + final OrdersRepository repository; + + UpdateOrderStatus(this.repository); + + Future call(String id, OrderStatus newStatus) => + repository.updateOrderStatus(id, newStatus); +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/data/fake_orders_repository.dart b/kell_creations_apps/packages/feature_orders/lib/src/data/fake_orders_repository.dart index 57f6575..6af90c9 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/data/fake_orders_repository.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/data/fake_orders_repository.dart @@ -1,4 +1,5 @@ import '../domain/order.dart'; +import '../domain/order_action_result.dart'; import '../domain/order_item.dart'; import '../domain/order_status.dart'; import '../domain/orders_repository.dart'; @@ -11,6 +12,7 @@ class FakeOrdersRepository implements OrdersRepository { id: 'KC-1001', customerName: 'Sarah Mitchell', customerEmail: 'sarah.mitchell@example.com', + customerPhone: '828-555-0101', orderDate: DateTime(2026, 4, 1), status: OrderStatus.delivered, shippingAddress: '123 Maple St, Asheville, NC 28801', @@ -55,6 +57,7 @@ class FakeOrdersRepository implements OrdersRepository { id: 'KC-1003', customerName: 'Emily Chen', customerEmail: 'emily.chen@example.com', + customerPhone: '704-555-0303', orderDate: DateTime(2026, 4, 3), status: OrderStatus.processing, shippingAddress: '789 Pine Rd, Charlotte, NC 28202', @@ -109,6 +112,7 @@ class FakeOrdersRepository implements OrdersRepository { id: 'KC-1006', customerName: 'Maria Gonzalez', customerEmail: 'maria.gonzalez@example.com', + customerPhone: '404-555-0606', orderDate: DateTime(2026, 4, 4), status: OrderStatus.pending, shippingAddress: '987 Cedar Dr, Atlanta, GA 30301', @@ -141,4 +145,42 @@ class FakeOrdersRepository implements OrdersRepository { await Future.delayed(const Duration(milliseconds: 300)); return List.unmodifiable(_orders); } + + @override + Future updateOrderStatus(String id, OrderStatus newStatus) async { + await Future.delayed(const Duration(milliseconds: 200)); + final index = _orders.indexWhere((o) => o.id == id); + if (index == -1) { + return const OrderActionResult.failure('Order not found.'); + } + final updated = _orders[index].copyWith(status: newStatus); + _orders[index] = updated; + return OrderActionResult.success(updated); + } + + @override + Future addOrderNote(String id, String note) async { + await Future.delayed(const Duration(milliseconds: 200)); + if (note.trim().isEmpty) { + return const OrderActionResult.failure('Note cannot be empty.'); + } + final index = _orders.indexWhere((o) => o.id == id); + if (index == -1) { + return const OrderActionResult.failure('Order not found.'); + } + final existing = _orders[index]; + final updated = existing.copyWith(orderNotes: [...existing.orderNotes, note.trim()]); + _orders[index] = updated; + return OrderActionResult.success(updated); + } + + @override + Future createOrder(Order order) async { + await Future.delayed(const Duration(milliseconds: 200)); + if (_orders.any((o) => o.id == order.id)) { + return OrderActionResult.failure('Order with ID ${order.id} already exists.'); + } + _orders.add(order); + return OrderActionResult.success(order); + } } diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/order.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/order.dart index b9d66bf..c7c903f 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/domain/order.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/order.dart @@ -6,19 +6,32 @@ class Order { final String id; final String customerName; final String customerEmail; + + /// Optional customer phone number. + final String? customerPhone; + final DateTime orderDate; final OrderStatus status; final List items; final String shippingAddress; + /// Optional internal operator notes about this order. + final String? notes; + + /// Customer-visible order notes (e.g. gift message, special instructions). + final List orderNotes; + const Order({ required this.id, required this.customerName, required this.customerEmail, + this.customerPhone, required this.orderDate, required this.status, required this.items, required this.shippingAddress, + this.notes, + this.orderNotes = const [], }); /// The total value of the order. @@ -26,4 +39,31 @@ class Order { /// The number of individual items in the order. int get itemCount => items.fold(0, (sum, item) => sum + item.quantity); + + /// Returns a copy of this order with the given fields replaced. + Order copyWith({ + String? id, + String? customerName, + String? customerEmail, + String? customerPhone, + DateTime? orderDate, + OrderStatus? status, + List? items, + String? shippingAddress, + String? notes, + List? orderNotes, + }) { + return Order( + id: id ?? this.id, + customerName: customerName ?? this.customerName, + customerEmail: customerEmail ?? this.customerEmail, + customerPhone: customerPhone ?? this.customerPhone, + orderDate: orderDate ?? this.orderDate, + status: status ?? this.status, + items: items ?? this.items, + shippingAddress: shippingAddress ?? this.shippingAddress, + notes: notes ?? this.notes, + orderNotes: orderNotes ?? this.orderNotes, + ); + } } diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/order_action_result.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/order_action_result.dart new file mode 100644 index 0000000..6ded9e6 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/order_action_result.dart @@ -0,0 +1,23 @@ +import 'order.dart'; + +/// The result of an order write operation (status update, note addition, etc.). +class OrderActionResult { + /// Whether the operation succeeded. + final bool success; + + /// The updated [Order] after the operation, or `null` on failure. + final Order? updatedOrder; + + /// A human-readable error message when [success] is `false`. + final String? errorMessage; + + const OrderActionResult.success(Order order) + : success = true, + updatedOrder = order, + errorMessage = null; + + const OrderActionResult.failure(String message) + : success = false, + updatedOrder = null, + errorMessage = message; +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/orders_repository.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/orders_repository.dart index e6be39b..dfe9166 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/domain/orders_repository.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/orders_repository.dart @@ -1,7 +1,24 @@ import 'order.dart'; +import 'order_action_result.dart'; +import 'order_status.dart'; /// Contract for fetching and managing customer orders. abstract class OrdersRepository { /// Returns all orders. Future> getOrders(); + + /// Updates the status of the order identified by [id]. + /// + /// Returns an [OrderActionResult] indicating success or failure. + Future updateOrderStatus(String id, OrderStatus newStatus); + + /// Appends a note to the order identified by [id]. + /// + /// Returns an [OrderActionResult] with the updated order on success. + Future addOrderNote(String id, String note); + + /// Creates a new order and persists it. + /// + /// Returns an [OrderActionResult] with the created order on success. + Future createOrder(Order order); } diff --git a/kell_creations_apps/packages/feature_orders/test/fake_orders_repository_test.dart b/kell_creations_apps/packages/feature_orders/test/fake_orders_repository_test.dart index 3658985..8f315c9 100644 --- a/kell_creations_apps/packages/feature_orders/test/fake_orders_repository_test.dart +++ b/kell_creations_apps/packages/feature_orders/test/fake_orders_repository_test.dart @@ -9,7 +9,7 @@ void main() { repository = FakeOrdersRepository(); }); - group('FakeOrdersRepository', () { + group('FakeOrdersRepository — read', () { test('getOrders returns six sample orders', () async { final orders = await repository.getOrders(); expect(orders.length, 6); @@ -49,5 +49,170 @@ void main() { final order1001 = orders.firstWhere((o) => o.id == 'KC-1001'); expect(order1001.itemCount, 3); }); + + test('some orders have customerPhone set', () async { + final orders = await repository.getOrders(); + final withPhone = orders.where((o) => o.customerPhone != null).toList(); + expect(withPhone, isNotEmpty); + }); + }); + + group('FakeOrdersRepository — updateOrderStatus', () { + test('updates status of existing order', () async { + final result = await repository.updateOrderStatus('KC-1004', OrderStatus.processing); + + expect(result.success, true); + expect(result.updatedOrder!.id, 'KC-1004'); + expect(result.updatedOrder!.status, OrderStatus.processing); + }); + + test('persists status change across subsequent getOrders calls', () async { + await repository.updateOrderStatus('KC-1004', OrderStatus.shipped); + + final orders = await repository.getOrders(); + final updated = orders.firstWhere((o) => o.id == 'KC-1004'); + expect(updated.status, OrderStatus.shipped); + }); + + test('returns failure for unknown order ID', () async { + final result = await repository.updateOrderStatus('UNKNOWN', OrderStatus.processing); + + expect(result.success, false); + expect(result.errorMessage, isNotNull); + }); + + test('can update to any valid status', () async { + for (final status in OrderStatus.values) { + final result = await repository.updateOrderStatus('KC-1001', status); + expect(result.success, true); + expect(result.updatedOrder!.status, status); + } + }); + }); + + group('FakeOrdersRepository — addOrderNote', () { + test('adds note to existing order', () async { + final result = await repository.addOrderNote('KC-1001', 'Please gift wrap.'); + + expect(result.success, true); + expect(result.updatedOrder!.orderNotes, contains('Please gift wrap.')); + }); + + test('trims whitespace from note', () async { + final result = await repository.addOrderNote('KC-1001', ' Trimmed note. '); + + expect(result.updatedOrder!.orderNotes, contains('Trimmed note.')); + }); + + test('persists note across subsequent getOrders calls', () async { + await repository.addOrderNote('KC-1001', 'Persistent note.'); + + final orders = await repository.getOrders(); + final order = orders.firstWhere((o) => o.id == 'KC-1001'); + expect(order.orderNotes, contains('Persistent note.')); + }); + + test('multiple notes accumulate in order', () async { + await repository.addOrderNote('KC-1001', 'First.'); + await repository.addOrderNote('KC-1001', 'Second.'); + + final orders = await repository.getOrders(); + final order = orders.firstWhere((o) => o.id == 'KC-1001'); + expect(order.orderNotes.length, 2); + expect(order.orderNotes[0], 'First.'); + expect(order.orderNotes[1], 'Second.'); + }); + + test('returns failure for empty note', () async { + final result = await repository.addOrderNote('KC-1001', ' '); + + expect(result.success, false); + expect(result.errorMessage, isNotNull); + }); + + test('returns failure for unknown order ID', () async { + final result = await repository.addOrderNote('UNKNOWN', 'Some note.'); + + expect(result.success, false); + expect(result.errorMessage, isNotNull); + }); + }); + + group('FakeOrdersRepository — createOrder', () { + test('creates a new order and returns it', () async { + final newOrder = Order( + id: 'KC-9999', + customerName: 'New Customer', + customerEmail: 'new@example.com', + orderDate: DateTime(2026, 7, 1), + status: OrderStatus.pending, + items: const [ + OrderItem(productName: 'Test Product', sku: 'TP-001', quantity: 1, unitPrice: 15.00), + ], + shippingAddress: '1 New St, Test City, TC 00000', + ); + + final result = await repository.createOrder(newOrder); + + expect(result.success, true); + expect(result.updatedOrder!.id, 'KC-9999'); + }); + + test('new order appears in subsequent getOrders calls', () async { + final newOrder = Order( + id: 'KC-8888', + customerName: 'Another Customer', + customerEmail: 'another@example.com', + orderDate: DateTime(2026, 7, 2), + status: OrderStatus.pending, + items: const [], + shippingAddress: '', + ); + + await repository.createOrder(newOrder); + + final orders = await repository.getOrders(); + expect(orders.length, 7); + expect(orders.any((o) => o.id == 'KC-8888'), true); + }); + + test('returns failure for duplicate order ID', () async { + final duplicate = Order( + id: 'KC-1001', + customerName: 'Duplicate', + customerEmail: 'dup@example.com', + orderDate: DateTime(2026, 7, 1), + status: OrderStatus.pending, + items: const [], + shippingAddress: '', + ); + + final result = await repository.createOrder(duplicate); + + expect(result.success, false); + expect(result.errorMessage, isNotNull); + }); + + test('duplicate rejection does not alter existing order', () async { + final orders = await repository.getOrders(); + final original = orders.firstWhere((o) => o.id == 'KC-1001'); + + final duplicate = Order( + id: 'KC-1001', + customerName: 'Imposter', + customerEmail: 'imposter@example.com', + orderDate: DateTime(2026, 7, 1), + status: OrderStatus.cancelled, + items: const [], + shippingAddress: '', + ); + + await repository.createOrder(duplicate); + + final ordersAfter = await repository.getOrders(); + final unchanged = ordersAfter.firstWhere((o) => o.id == 'KC-1001'); + expect(unchanged.customerName, original.customerName); + expect(unchanged.status, original.status); + }); }); } diff --git a/kell_creations_apps/packages/feature_orders/test/feature_orders_test.dart b/kell_creations_apps/packages/feature_orders/test/feature_orders_test.dart index 7b65c2c..4e88bea 100644 --- a/kell_creations_apps/packages/feature_orders/test/feature_orders_test.dart +++ b/kell_creations_apps/packages/feature_orders/test/feature_orders_test.dart @@ -1,11 +1,185 @@ -// This file ensures the barrel export compiles correctly. +// This file covers domain model tests for the feature_orders package. import 'package:flutter_test/flutter_test.dart'; import 'package:feature_orders/feature_orders.dart'; void main() { + // ── Order domain model ───────────────────────────────────────────────── + + group('Order', () { + final baseOrder = Order( + id: 'KC-0001', + customerName: 'Test Customer', + customerEmail: 'test@example.com', + customerPhone: '555-0100', + orderDate: DateTime(2026, 1, 1), + status: OrderStatus.pending, + items: const [ + OrderItem(productName: 'Widget', sku: 'WG-001', quantity: 2, unitPrice: 10.00), + OrderItem(productName: 'Gadget', sku: 'GD-002', quantity: 1, unitPrice: 5.50), + ], + shippingAddress: '1 Test St, Test City, TC 00000', + notes: 'Internal note', + orderNotes: const ['Customer note 1'], + ); + + test('total is sum of all line totals', () { + // 2 * 10.00 + 1 * 5.50 = 25.50 + expect(baseOrder.total, closeTo(25.50, 0.001)); + }); + + test('itemCount is sum of all quantities', () { + // 2 + 1 = 3 + expect(baseOrder.itemCount, 3); + }); + + test('copyWith replaces status', () { + final updated = baseOrder.copyWith(status: OrderStatus.processing); + expect(updated.status, OrderStatus.processing); + expect(updated.id, baseOrder.id); + expect(updated.customerName, baseOrder.customerName); + }); + + test('copyWith replaces orderNotes', () { + final updated = baseOrder.copyWith(orderNotes: ['New note']); + expect(updated.orderNotes, ['New note']); + expect(updated.status, baseOrder.status); + }); + + test('copyWith replaces notes', () { + final updated = baseOrder.copyWith(notes: 'Updated internal note'); + expect(updated.notes, 'Updated internal note'); + }); + + test('copyWith replaces customerPhone', () { + final updated = baseOrder.copyWith(customerPhone: '555-9999'); + expect(updated.customerPhone, '555-9999'); + }); + + test('copyWith with no arguments returns equivalent order', () { + final copy = baseOrder.copyWith(); + expect(copy.id, baseOrder.id); + expect(copy.customerName, baseOrder.customerName); + expect(copy.status, baseOrder.status); + expect(copy.orderNotes, baseOrder.orderNotes); + }); + + test('default orderNotes is empty list', () { + final order = Order( + id: 'KC-0002', + customerName: 'Minimal', + customerEmail: 'min@example.com', + orderDate: DateTime(2026, 1, 1), + status: OrderStatus.pending, + items: const [], + shippingAddress: '', + ); + expect(order.orderNotes, isEmpty); + }); + + test('customerPhone defaults to null', () { + final order = Order( + id: 'KC-0003', + customerName: 'No Phone', + customerEmail: 'nophone@example.com', + orderDate: DateTime(2026, 1, 1), + status: OrderStatus.pending, + items: const [], + shippingAddress: '', + ); + expect(order.customerPhone, isNull); + }); + + test('notes defaults to null', () { + final order = Order( + id: 'KC-0004', + customerName: 'No Notes', + customerEmail: 'nonotes@example.com', + orderDate: DateTime(2026, 1, 1), + status: OrderStatus.pending, + items: const [], + shippingAddress: '', + ); + expect(order.notes, isNull); + }); + + test('total is zero for empty items list', () { + final order = Order( + id: 'KC-0005', + customerName: 'Empty', + customerEmail: 'empty@example.com', + orderDate: DateTime(2026, 1, 1), + status: OrderStatus.pending, + items: const [], + shippingAddress: '', + ); + expect(order.total, 0.0); + expect(order.itemCount, 0); + }); + }); + + // ── OrderItem ────────────────────────────────────────────────────────── + + group('OrderItem', () { + test('lineTotal is quantity * unitPrice', () { + const item = OrderItem(productName: 'Test', sku: 'T-001', quantity: 3, unitPrice: 7.50); + expect(item.lineTotal, closeTo(22.50, 0.001)); + }); + + test('lineTotal is zero for zero quantity', () { + const item = OrderItem(productName: 'Test', sku: 'T-001', quantity: 0, unitPrice: 10.00); + expect(item.lineTotal, 0.0); + }); + }); + + // ── OrderStatus ──────────────────────────────────────────────────────── + + group('OrderStatus', () { + test('all statuses are distinct', () { + final statuses = OrderStatus.values.toSet(); + expect(statuses.length, OrderStatus.values.length); + }); + + test('contains expected values', () { + expect(OrderStatus.values, contains(OrderStatus.pending)); + expect(OrderStatus.values, contains(OrderStatus.processing)); + expect(OrderStatus.values, contains(OrderStatus.shipped)); + expect(OrderStatus.values, contains(OrderStatus.delivered)); + expect(OrderStatus.values, contains(OrderStatus.cancelled)); + }); + }); + + // ── OrderActionResult ────────────────────────────────────────────────── + + group('OrderActionResult', () { + final sampleOrder = Order( + id: 'KC-0001', + customerName: 'Test', + customerEmail: 'test@example.com', + orderDate: DateTime(2026, 1, 1), + status: OrderStatus.pending, + items: const [], + shippingAddress: '', + ); + + test('success result has success=true and updatedOrder set', () { + final result = OrderActionResult.success(sampleOrder); + expect(result.success, true); + expect(result.updatedOrder, sampleOrder); + expect(result.errorMessage, isNull); + }); + + test('failure result has success=false and errorMessage set', () { + const result = OrderActionResult.failure('Something went wrong.'); + expect(result.success, false); + expect(result.updatedOrder, isNull); + expect(result.errorMessage, 'Something went wrong.'); + }); + }); + + // ── Barrel export smoke test ─────────────────────────────────────────── + test('barrel export exposes Order', () { - // Verify the domain model is accessible through the barrel export. final order = Order( id: 'test', customerName: 'Test', diff --git a/kell_creations_apps/packages/feature_orders/test/orders_controller_test.dart b/kell_creations_apps/packages/feature_orders/test/orders_controller_test.dart index 7aa7106..d5013c9 100644 --- a/kell_creations_apps/packages/feature_orders/test/orders_controller_test.dart +++ b/kell_creations_apps/packages/feature_orders/test/orders_controller_test.dart @@ -1,7 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:feature_orders/feature_orders.dart'; -import 'package:feature_orders/src/application/get_orders.dart'; import 'package:feature_orders/src/application/orders_controller.dart'; void main() { @@ -10,21 +9,30 @@ void main() { setUp(() { repository = FakeOrdersRepository(); - controller = OrdersController(GetOrders(repository)); + controller = OrdersController( + GetOrders(repository), + updateOrderStatus: UpdateOrderStatus(repository), + addOrderNote: AddOrderNote(repository), + createOrder: CreateOrder(repository), + ); }); tearDown(() { controller.dispose(); }); - group('OrdersController', () { + // ── Read-side tests (preserved from original) ────────────────────────── + + group('OrdersController — read', () { test('starts with empty state', () { expect(controller.isLoading, false); + expect(controller.isUpdating, false); expect(controller.orders, isEmpty); expect(controller.selectedOrder, isNull); expect(controller.activeFilter, isNull); expect(controller.searchQuery, ''); expect(controller.error, isNull); + expect(controller.lastActionResult, isNull); }); test('load populates orders and auto-selects first', () async { @@ -116,4 +124,261 @@ void main() { expect(controller.selectedOrder, isNull); }); }); + + // ── Write-side tests ─────────────────────────────────────────────────── + + group('OrdersController — updateOrderStatus', () { + test('updates status and sets success result', () async { + await controller.load(); + controller.selectById('KC-1004'); + + await controller.updateOrderStatus('KC-1004', OrderStatus.processing); + + expect(controller.isUpdating, false); + expect(controller.lastActionResult, isNotNull); + expect(controller.lastActionResult!.success, true); + expect(controller.lastActionResult!.updatedOrder!.status, OrderStatus.processing); + }); + + test('refreshes selectedOrder after status update', () async { + await controller.load(); + controller.selectById('KC-1004'); + + await controller.updateOrderStatus('KC-1004', OrderStatus.processing); + + expect(controller.selectedOrder!.status, OrderStatus.processing); + }); + + test('reloads order list after status update', () async { + await controller.load(); + + // Initially 2 pending orders. + controller.setFilter('pending'); + expect(controller.orders.length, 2); + controller.setFilter(null); + + await controller.updateOrderStatus('KC-1004', OrderStatus.processing); + + // Now only 1 pending order. + controller.setFilter('pending'); + expect(controller.orders.length, 1); + }); + + test('sets failure result for unknown order ID', () async { + await controller.load(); + + await controller.updateOrderStatus('UNKNOWN', OrderStatus.processing); + + expect(controller.lastActionResult!.success, false); + expect(controller.lastActionResult!.errorMessage, isNotNull); + }); + + test('isUpdating is false after completion', () async { + await controller.load(); + + await controller.updateOrderStatus('KC-1004', OrderStatus.processing); + + expect(controller.isUpdating, false); + }); + + test('no-op when updateOrderStatus use case is not provided', () async { + final readOnlyController = OrdersController(GetOrders(repository)); + await readOnlyController.load(); + + await readOnlyController.updateOrderStatus('KC-1004', OrderStatus.processing); + + // No result set, no error. + expect(readOnlyController.lastActionResult, isNull); + readOnlyController.dispose(); + }); + }); + + group('OrdersController — addOrderNote', () { + test('adds note and sets success result', () async { + await controller.load(); + controller.selectById('KC-1001'); + + await controller.addOrderNote('KC-1001', 'Please gift wrap.'); + + expect(controller.lastActionResult!.success, true); + expect(controller.lastActionResult!.updatedOrder!.orderNotes, contains('Please gift wrap.')); + }); + + test('refreshes selectedOrder with new note', () async { + await controller.load(); + controller.selectById('KC-1001'); + + await controller.addOrderNote('KC-1001', 'Fragile item.'); + + expect(controller.selectedOrder!.orderNotes, contains('Fragile item.')); + }); + + test('multiple notes accumulate', () async { + await controller.load(); + + await controller.addOrderNote('KC-1001', 'First note.'); + await controller.addOrderNote('KC-1001', 'Second note.'); + + controller.selectById('KC-1001'); + expect(controller.selectedOrder!.orderNotes.length, 2); + }); + + test('sets failure result for empty note', () async { + await controller.load(); + + await controller.addOrderNote('KC-1001', ' '); + + expect(controller.lastActionResult!.success, false); + expect(controller.lastActionResult!.errorMessage, isNotNull); + }); + + test('sets failure result for unknown order ID', () async { + await controller.load(); + + await controller.addOrderNote('UNKNOWN', 'Some note.'); + + expect(controller.lastActionResult!.success, false); + }); + + test('no-op when addOrderNote use case is not provided', () async { + final readOnlyController = OrdersController(GetOrders(repository)); + await readOnlyController.load(); + + await readOnlyController.addOrderNote('KC-1001', 'Note.'); + + expect(readOnlyController.lastActionResult, isNull); + readOnlyController.dispose(); + }); + }); + + group('OrdersController — createOrder', () { + test('creates order and adds it to the list', () async { + await controller.load(); + expect(controller.orders.length, 6); + + final newOrder = Order( + id: 'KC-9999', + customerName: 'New Customer', + customerEmail: 'new@example.com', + orderDate: DateTime(2026, 7, 1), + status: OrderStatus.pending, + items: const [ + OrderItem(productName: 'Test Product', sku: 'TP-001', quantity: 1, unitPrice: 15.00), + ], + shippingAddress: '1 New St, Test City, TC 00000', + ); + + await controller.createOrder(newOrder); + + expect(controller.lastActionResult!.success, true); + expect(controller.orders.length, 7); + }); + + test('new order appears in list after creation', () async { + await controller.load(); + + final newOrder = Order( + id: 'KC-8888', + customerName: 'Another Customer', + customerEmail: 'another@example.com', + orderDate: DateTime(2026, 7, 2), + status: OrderStatus.pending, + items: const [ + OrderItem(productName: 'Widget', sku: 'WG-001', quantity: 2, unitPrice: 9.99), + ], + shippingAddress: '2 Another St, Test City, TC 00000', + ); + + await controller.createOrder(newOrder); + + final ids = controller.orders.map((o) => o.id).toList(); + expect(ids, contains('KC-8888')); + }); + + test('sets failure result for duplicate order ID', () async { + await controller.load(); + + final duplicate = Order( + id: 'KC-1001', + customerName: 'Duplicate', + customerEmail: 'dup@example.com', + orderDate: DateTime(2026, 7, 1), + status: OrderStatus.pending, + items: const [], + shippingAddress: '', + ); + + await controller.createOrder(duplicate); + + expect(controller.lastActionResult!.success, false); + expect(controller.lastActionResult!.errorMessage, isNotNull); + }); + + test('no-op when createOrder use case is not provided', () async { + final readOnlyController = OrdersController(GetOrders(repository)); + await readOnlyController.load(); + + final newOrder = Order( + id: 'KC-7777', + customerName: 'Test', + customerEmail: 'test@example.com', + orderDate: DateTime(2026, 7, 1), + status: OrderStatus.pending, + items: const [], + shippingAddress: '', + ); + + await readOnlyController.createOrder(newOrder); + + expect(readOnlyController.lastActionResult, isNull); + expect(readOnlyController.orders.length, 6); + readOnlyController.dispose(); + }); + }); + + group('OrdersController — consumeActionResult', () { + test('consume clears lastActionResult', () async { + await controller.load(); + await controller.updateOrderStatus('KC-1004', OrderStatus.processing); + + expect(controller.lastActionResult, isNotNull); + final consumed = controller.consumeActionResult(); + expect(consumed, isNotNull); + expect(controller.lastActionResult, isNull); + }); + + test('consume returns null when no result pending', () async { + final result = controller.consumeActionResult(); + expect(result, isNull); + }); + + test('lastActionResult starts null', () { + expect(controller.lastActionResult, isNull); + }); + }); + + group('OrdersController — filter/search persistence after writes', () { + test('active filter is preserved after status update', () async { + await controller.load(); + controller.setFilter('pending'); + expect(controller.orders.length, 2); + + await controller.updateOrderStatus('KC-1004', OrderStatus.processing); + + // Filter should still be active; now only 1 pending order. + expect(controller.activeFilter, 'pending'); + expect(controller.orders.length, 1); + }); + + test('search query is preserved after note addition', () async { + await controller.load(); + controller.setSearchQuery('sarah'); + expect(controller.orders.length, 1); + + await controller.addOrderNote('KC-1001', 'Test note.'); + + expect(controller.searchQuery, 'sarah'); + expect(controller.orders.length, 1); + }); + }); } diff --git a/kell_creations_apps/tools/run_all_tests.sh b/kell_creations_apps/tools/run_all_tests.sh index 0c7206b..580b917 100644 --- a/kell_creations_apps/tools/run_all_tests.sh +++ b/kell_creations_apps/tools/run_all_tests.sh @@ -20,7 +20,10 @@ TESTABLE=( packages/core packages/design_system packages/feature_wordpress + packages/feature_inventory + packages/feature_orders apps/kell_web + apps/kell_mobile ) # All packages/apps to analyze (includes those without tests)