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 ## Current status
- main baseline updated through: feat/inventory-page-ui (Stage 5B complete) - main baseline updated through: feat/orders-domain (Stage 5C complete)
- next branch: feat/orders-domain (Stage 5C) or feat/bulk-operator-workflows (Stage 7B) - next branch: feat/bulk-operator-workflows (Stage 7B) or feat/orders-page-ui (Stage 5D)
- current stage: Stage 5B complete — inventory page UI with search, filter chips, detail panel, quantity adjust dialog, and snack bar feedback - current stage: Stage 5C complete — orders domain expansion with write operations (updateOrderStatus, addOrderNote, createOrder), OrderActionResult, copyWith, and 90 tests
## Slice tracker ## Slice tracker
@ -285,6 +285,31 @@
- analyze: passed (dart analyze --fatal-infos — no issues found) - analyze: passed (dart analyze --fatal-infos — no issues found)
- brief updated: yes - 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 ### chore/analyze-cleanup-and-tracker-sync
- status: merged to main - status: merged to main

View File

@ -152,8 +152,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag
### Next recommended branch ### 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). **`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 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. 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_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_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_policy` | ✅ Complete | ✅ Complete | ✅ Complete | ❌ None | ✅ Complete | Minimal | **Fake-only MVP** |
| `feature_finance` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** | | `feature_finance` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** |
| `feature_mrp` | ❌ 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 @override
Future<List<Order>> getOrders() async => orders; 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 { class _FailingInventoryRepository implements InventoryRepository {

View File

@ -1,7 +1,12 @@
library; 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/data/fake_orders_repository.dart';
export 'src/domain/order.dart'; export 'src/domain/order.dart';
export 'src/domain/order_action_result.dart';
export 'src/domain/order_item.dart'; export 'src/domain/order_item.dart';
export 'src/domain/order_status.dart'; export 'src/domain/order_status.dart';
export 'src/domain/orders_repository.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 'package:flutter/foundation.dart';
import '../domain/order.dart'; import '../domain/order.dart';
import '../domain/order_action_result.dart';
import '../domain/order_status.dart'; import '../domain/order_status.dart';
import 'add_order_note.dart';
import 'create_order.dart';
import 'get_orders.dart'; import 'get_orders.dart';
import 'update_order_status.dart';
/// Controller that manages the orders workspace state, including /// 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 { class OrdersController extends ChangeNotifier {
final GetOrders _getOrders; 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; bool isLoading = false;
/// Whether a write operation is in progress for the selected order.
bool isUpdating = false;
Object? error; 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). /// All orders returned by the repository (unfiltered).
List<Order> _allOrders = []; List<Order> _allOrders = [];
@ -32,6 +55,8 @@ class OrdersController extends ChangeNotifier {
/// The current free-text search query applied to customer name / order ID. /// The current free-text search query applied to customer name / order ID.
String searchQuery = ''; String searchQuery = '';
// Read operations
/// Loads all orders and applies any current filter / search. /// Loads all orders and applies any current filter / search.
Future<void> load() async { Future<void> load() async {
isLoading = true; isLoading = true;
@ -82,8 +107,100 @@ class OrdersController extends ChangeNotifier {
return false; 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 // 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() { void _applyFilters() {
var result = _allOrders; 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.dart';
import '../domain/order_action_result.dart';
import '../domain/order_item.dart'; import '../domain/order_item.dart';
import '../domain/order_status.dart'; import '../domain/order_status.dart';
import '../domain/orders_repository.dart'; import '../domain/orders_repository.dart';
@ -11,6 +12,7 @@ class FakeOrdersRepository implements OrdersRepository {
id: 'KC-1001', id: 'KC-1001',
customerName: 'Sarah Mitchell', customerName: 'Sarah Mitchell',
customerEmail: 'sarah.mitchell@example.com', customerEmail: 'sarah.mitchell@example.com',
customerPhone: '828-555-0101',
orderDate: DateTime(2026, 4, 1), orderDate: DateTime(2026, 4, 1),
status: OrderStatus.delivered, status: OrderStatus.delivered,
shippingAddress: '123 Maple St, Asheville, NC 28801', shippingAddress: '123 Maple St, Asheville, NC 28801',
@ -55,6 +57,7 @@ class FakeOrdersRepository implements OrdersRepository {
id: 'KC-1003', id: 'KC-1003',
customerName: 'Emily Chen', customerName: 'Emily Chen',
customerEmail: 'emily.chen@example.com', customerEmail: 'emily.chen@example.com',
customerPhone: '704-555-0303',
orderDate: DateTime(2026, 4, 3), orderDate: DateTime(2026, 4, 3),
status: OrderStatus.processing, status: OrderStatus.processing,
shippingAddress: '789 Pine Rd, Charlotte, NC 28202', shippingAddress: '789 Pine Rd, Charlotte, NC 28202',
@ -109,6 +112,7 @@ class FakeOrdersRepository implements OrdersRepository {
id: 'KC-1006', id: 'KC-1006',
customerName: 'Maria Gonzalez', customerName: 'Maria Gonzalez',
customerEmail: 'maria.gonzalez@example.com', customerEmail: 'maria.gonzalez@example.com',
customerPhone: '404-555-0606',
orderDate: DateTime(2026, 4, 4), orderDate: DateTime(2026, 4, 4),
status: OrderStatus.pending, status: OrderStatus.pending,
shippingAddress: '987 Cedar Dr, Atlanta, GA 30301', shippingAddress: '987 Cedar Dr, Atlanta, GA 30301',
@ -141,4 +145,42 @@ class FakeOrdersRepository implements OrdersRepository {
await Future<void>.delayed(const Duration(milliseconds: 300)); await Future<void>.delayed(const Duration(milliseconds: 300));
return List.unmodifiable(_orders); 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 id;
final String customerName; final String customerName;
final String customerEmail; final String customerEmail;
/// Optional customer phone number.
final String? customerPhone;
final DateTime orderDate; final DateTime orderDate;
final OrderStatus status; final OrderStatus status;
final List<OrderItem> items; final List<OrderItem> items;
final String shippingAddress; 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({ const Order({
required this.id, required this.id,
required this.customerName, required this.customerName,
required this.customerEmail, required this.customerEmail,
this.customerPhone,
required this.orderDate, required this.orderDate,
required this.status, required this.status,
required this.items, required this.items,
required this.shippingAddress, required this.shippingAddress,
this.notes,
this.orderNotes = const [],
}); });
/// The total value of the order. /// The total value of the order.
@ -26,4 +39,31 @@ class Order {
/// The number of individual items in the order. /// The number of individual items in the order.
int get itemCount => items.fold(0, (sum, item) => sum + item.quantity); 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.dart';
import 'order_action_result.dart';
import 'order_status.dart';
/// Contract for fetching and managing customer orders. /// Contract for fetching and managing customer orders.
abstract class OrdersRepository { abstract class OrdersRepository {
/// Returns all orders. /// Returns all orders.
Future<List<Order>> getOrders(); 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(); repository = FakeOrdersRepository();
}); });
group('FakeOrdersRepository', () { group('FakeOrdersRepository — read', () {
test('getOrders returns six sample orders', () async { test('getOrders returns six sample orders', () async {
final orders = await repository.getOrders(); final orders = await repository.getOrders();
expect(orders.length, 6); expect(orders.length, 6);
@ -49,5 +49,170 @@ void main() {
final order1001 = orders.firstWhere((o) => o.id == 'KC-1001'); final order1001 = orders.firstWhere((o) => o.id == 'KC-1001');
expect(order1001.itemCount, 3); 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:flutter_test/flutter_test.dart';
import 'package:feature_orders/feature_orders.dart'; import 'package:feature_orders/feature_orders.dart';
void main() { 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', () { test('barrel export exposes Order', () {
// Verify the domain model is accessible through the barrel export.
final order = Order( final order = Order(
id: 'test', id: 'test',
customerName: 'Test', customerName: 'Test',

View File

@ -1,7 +1,6 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:feature_orders/feature_orders.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'; import 'package:feature_orders/src/application/orders_controller.dart';
void main() { void main() {
@ -10,21 +9,30 @@ void main() {
setUp(() { setUp(() {
repository = FakeOrdersRepository(); repository = FakeOrdersRepository();
controller = OrdersController(GetOrders(repository)); controller = OrdersController(
GetOrders(repository),
updateOrderStatus: UpdateOrderStatus(repository),
addOrderNote: AddOrderNote(repository),
createOrder: CreateOrder(repository),
);
}); });
tearDown(() { tearDown(() {
controller.dispose(); controller.dispose();
}); });
group('OrdersController', () { // Read-side tests (preserved from original)
group('OrdersController — read', () {
test('starts with empty state', () { test('starts with empty state', () {
expect(controller.isLoading, false); expect(controller.isLoading, false);
expect(controller.isUpdating, false);
expect(controller.orders, isEmpty); expect(controller.orders, isEmpty);
expect(controller.selectedOrder, isNull); expect(controller.selectedOrder, isNull);
expect(controller.activeFilter, isNull); expect(controller.activeFilter, isNull);
expect(controller.searchQuery, ''); expect(controller.searchQuery, '');
expect(controller.error, isNull); expect(controller.error, isNull);
expect(controller.lastActionResult, isNull);
}); });
test('load populates orders and auto-selects first', () async { test('load populates orders and auto-selects first', () async {
@ -116,4 +124,261 @@ void main() {
expect(controller.selectedOrder, isNull); 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/core
packages/design_system packages/design_system
packages/feature_wordpress packages/feature_wordpress
packages/feature_inventory
packages/feature_orders
apps/kell_web apps/kell_web
apps/kell_mobile
) )
# All packages/apps to analyze (includes those without tests) # All packages/apps to analyze (includes those without tests)