Compare commits

...

2 Commits

Author SHA1 Message Date
Mike Kell 9a66a8a2b0 Merge feat/orders-domain into main (Stage 5C complete)
Publish Docs / publish-docs (push) Successful in 50s Details
2026-07-10 19:04:49 -04:00
Mike Kell b2bec44fcb feat/orders-domain: expand orders domain with write operations and 90 tests 2026-07-10 19:04:39 -04:00
19 changed files with 936 additions and 14 deletions

View File

@ -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

View File

@ -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** |

View File

@ -69,6 +69,16 @@ class _StubOrdersRepository implements OrdersRepository {
@override
Future<List<Order>> getOrders() async => orders;
@override
Future<OrderActionResult> updateOrderStatus(String id, OrderStatus newStatus) =>
throw UnimplementedError();
@override
Future<OrderActionResult> addOrderNote(String id, String note) => throw UnimplementedError();
@override
Future<OrderActionResult> createOrder(Order order) => throw UnimplementedError();
}
class _FailingInventoryRepository implements InventoryRepository {

View File

@ -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';

View File

@ -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<OrderActionResult> call(String id, String note) => repository.addOrderNote(id, note);
}

View File

@ -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<OrderActionResult> call(Order order) => repository.createOrder(order);
}

View File

@ -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<Order> _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<void> 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<void> 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<void> 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<void> 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<void> _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;

View File

@ -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<OrderActionResult> call(String id, OrderStatus newStatus) =>
repository.updateOrderStatus(id, newStatus);
}

View File

@ -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<void>.delayed(const Duration(milliseconds: 300));
return List.unmodifiable(_orders);
}
@override
Future<OrderActionResult> updateOrderStatus(String id, OrderStatus newStatus) async {
await Future<void>.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<OrderActionResult> addOrderNote(String id, String note) async {
await Future<void>.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<OrderActionResult> createOrder(Order order) async {
await Future<void>.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);
}
}

View File

@ -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<OrderItem> 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<String> 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<OrderItem>? items,
String? shippingAddress,
String? notes,
List<String>? 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,
);
}
}

View File

@ -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;
}

View File

@ -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<List<Order>> getOrders();
/// Updates the status of the order identified by [id].
///
/// Returns an [OrderActionResult] indicating success or failure.
Future<OrderActionResult> updateOrderStatus(String id, OrderStatus newStatus);
/// Appends a note to the order identified by [id].
///
/// Returns an [OrderActionResult] with the updated order on success.
Future<OrderActionResult> addOrderNote(String id, String note);
/// Creates a new order and persists it.
///
/// Returns an [OrderActionResult] with the created order on success.
Future<OrderActionResult> createOrder(Order order);
}

View File

@ -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);
});
});
}

View File

@ -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',

View File

@ -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);
});
});
}

View File

@ -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)