feat(orders): Stage 9A - WooCommerce Orders API integration

- Extend OrderStatus with onHold, refunded, failed
- Extend OrderItem with productId, variationId, customizations
- Extend Order with paymentMethod, source, customFields
- Add OrderSource enum (online, market, manual)
- Add getOrder(id) to OrdersRepository contract and FakeOrdersRepository
- Add WooCommerceOrderMapper (WooCommerce JSON <-> Order domain)
- Add WooCommerceOrdersRepository implementing full OrdersRepository
- Add orders API methods to WooCommerceApiClient (getOrders, getOrder,
  updateOrderStatus, createOrder, addOrderNote)
- Export WooCommerceApiClient/WooCommerceApiException from feature_wordpress
- Add feature_wordpress + http dev deps to feature_orders pubspec
- 83 new tests: mapper (25), repository+API client (23), FakeRepo.getOrder (3),
  plus existing suite grows to 198/198 feature_orders
- All packages analyze clean; 847/847 total tests passing
- Update master_development_brief.md and build_execution_tracker.md
This commit is contained in:
Mike Kell 2026-07-11 09:45:36 -04:00
parent 003347c89c
commit e92788038a
22 changed files with 1458 additions and 9 deletions

View File

@ -2,9 +2,9 @@
## Current status ## Current status
- main baseline updated through: data-layer-contracts (Stage 8B complete) - main baseline updated through: feat/wc-orders-integration (Stage 9A complete)
- next branch: feat/auth-foundation (Stage 8C) - next branch: feat/wc-inventory-sync (Stage 9B)
- current stage: Stage 8B complete — shared data layer abstractions activated - current stage: Stage 9A complete — WooCommerce Orders API integration activated
## Slice tracker ## Slice tracker
@ -421,6 +421,53 @@
- 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/auth-foundation
- status: merged to main
- date: 2026-07-11
- inspection: complete
- implementation: complete
- files changed:
- `auth/lib/src/domain/kc_user.dart``KcUser` value object with `id`, `displayName`, `email`, `role`; `KcUserRole` enum (`admin`, `operator`, `viewer`)
- `auth/lib/src/domain/auth_state.dart``AuthState` sealed class hierarchy: `AuthStateUnauthenticated`, `AuthStateAuthenticated`, `AuthStateLoading`, `AuthStateError`
- `auth/lib/src/domain/auth_exception.dart``AuthException` with `code` and `message`; named constructors `invalidCredentials`, `sessionExpired`, `networkError`, `unknown`
- `auth/lib/src/domain/auth_service.dart``AuthService` abstract contract with `login()`, `logout()`, `currentUser`, `authStateStream`, `isAuthenticated`
- `auth/lib/src/domain/credential_store.dart``CredentialKeys` constants; `CredentialStore` abstract contract with `save()`, `load()`, `delete()`, `clear()`
- `auth/lib/src/data/in_memory_credential_store.dart``InMemoryCredentialStore` implementation
- `auth/lib/src/data/fake_auth_service.dart``FakeAuthService` with configurable users, delay simulation, and stream broadcasting
- `auth/lib/auth.dart` — barrel exports for all auth types
- `auth/test/auth_test.dart` — 42 tests covering all domain models, contracts, and implementations
- `core/lib/src/app/kc_app_environment.dart` — added `KcAppEnvironment.square` value
- `kell_creations_apps/tools/run_all_tests.sh` — added `packages/auth` to `TESTABLE` and `ANALYZABLE` lists
- tests: passed (42/42 auth, 21/21 core — all passing)
- analyze: passed (dart analyze --fatal-infos — no issues found)
- brief updated: yes
### feat/wc-orders-integration
- status: merged to main
- date: 2026-07-11
- inspection: complete
- implementation: complete
- files changed:
- `feature_orders/lib/src/domain/order_status.dart` — added `onHold`, `refunded`, `failed` values
- `feature_orders/lib/src/domain/order_item.dart` — added `productId`, `variationId`, `customizations` fields
- `feature_orders/lib/src/domain/order.dart` — added `paymentMethod`, `source`, `customFields` fields
- `feature_orders/lib/src/domain/orders_repository.dart` — added `getOrder(id)` to contract
- `feature_orders/lib/src/domain/order_source.dart` — new `OrderSource` enum (`online`, `market`, `manual`)
- `feature_orders/lib/src/data/fake_orders_repository.dart` — implemented `getOrder(id)` with null-safe lookup
- `feature_orders/lib/src/data/woo_commerce_order_mapper.dart` — new `WooCommerceOrderMapper` mapping WooCommerce JSON ↔ `Order` domain model
- `feature_orders/lib/src/data/woo_commerce_orders_repository.dart` — new `WooCommerceOrdersRepository` implementing full `OrdersRepository` contract via `WooCommerceApiClient`
- `feature_orders/lib/feature_orders.dart` — expanded barrel exports for all new types
- `feature_orders/pubspec.yaml` — added `feature_wordpress` dependency; added `http` dev dependency
- `feature_wordpress/lib/src/data/woo_commerce_api_client.dart` — added `getOrders()`, `getOrder(id)`, `updateOrderStatus()`, `createOrder()`, `addOrderNote()` methods
- `feature_wordpress/lib/feature_wordpress.dart` — exported `WooCommerceApiClient` and `WooCommerceApiException`
- `feature_orders/test/woo_commerce_order_mapper_test.dart` — 25 mapper tests covering full JSON → domain mapping, status mapping, item mapping, customizations, edge cases
- `feature_orders/test/woo_commerce_orders_repository_test.dart` — 23 repository and API client tests covering all CRUD operations, error handling, empty-note guard, and FakeOrdersRepository.getOrder
- tests: passed (198/198 feature_orders, 24/24 kell_web, 26/26 kell_mobile — 248 total for affected packages; 847/847 total across all packages)
- analyze: passed (dart analyze --fatal-infos — no issues found in feature_orders, feature_wordpress, kell_web, kell_mobile)
- brief updated: yes
### feat/bulk-operator-workflows ### feat/bulk-operator-workflows
- status: merged to main - status: merged to main

View File

@ -140,20 +140,21 @@ Rules:
- `core` tests passing - `core` tests passing
- `design_system` tests passing - `design_system` tests passing
- `feature_wordpress` tests passing - `feature_wordpress` tests passing
- `feature_orders` tests passing
- `kell_web` tests passing - `kell_web` tests passing
- `kell_mobile` tests passing - `kell_mobile` tests passing
- latest reported count for `core`: `21/21 passed` - latest reported count for `core`: `21/21 passed`
- latest reported count for `design_system`: `41/41 passed` - latest reported count for `design_system`: `41/41 passed`
- latest reported count for `feature_inventory`: `75/75 passed` - latest reported count for `feature_inventory`: `75/75 passed`
- latest reported count for `feature_orders`: `115/115 passed` - latest reported count for `feature_orders`: `198/198 passed`
- latest reported count for `feature_wordpress`: `319/319 passed` - latest reported count for `feature_wordpress`: `319/319 passed`
- latest reported count for `kell_web`: `24/24 passed` - latest reported count for `kell_web`: `24/24 passed`
- latest reported count for `integrations`: `38/38 passed` - latest reported count for `integrations`: `38/38 passed`
- latest reported count for `data`: `63/63 passed` - latest reported count for `data`: `63/63 passed`
- latest reported count for `auth`: `42/42 passed` - latest reported count for `auth`: `42/42 passed`
- latest reported count for `kell_mobile`: `26/26 passed` - latest reported count for `kell_mobile`: `26/26 passed`
- total: `764/764 passed` - total: `847/847 passed`
- baseline commit: merge of `feat/auth-foundation` into `main` (Stage 8C complete, 2026-07-11) - baseline commit: merge of `feat/wc-orders-integration` into `main` (Stage 9A complete, 2026-07-11)
#### Baseline test coverage (established 2026-05-22) #### Baseline test coverage (established 2026-05-22)
@ -169,8 +170,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag
### Next recommended branch ### Next recommended branch
**`feat/wc-orders-integration`** — Stage 9A: WooCommerce Orders API integration. **`feat/wc-inventory-sync`** — Stage 9B: WooCommerce stock/inventory sync.
Branch from latest `main`. Stage 8 (infrastructure package activation) is complete. Stage 9A connects the orders feature to WooCommerce so orders placed online appear in the app and order status can be updated. Branch from latest `main`. Stage 9A (WooCommerce Orders API integration) is complete. Stage 9B adds bidirectional stock quantity synchronization between the app's inventory and WooCommerce product stock levels.
--- ---
@ -1289,7 +1290,7 @@ Working rules:
| Integration | Package/Location | Protocol | Current Status | Target Stage | | Integration | Package/Location | Protocol | Current Status | Target Stage |
| -------------------- | ------------------- | ------------- | ------------------- | ------------ | | -------------------- | ------------------- | ------------- | ------------------- | ------------ |
| WooCommerce Products | `feature_wordpress` | REST API v3 | ✅ Production-ready | — | | WooCommerce Products | `feature_wordpress` | REST API v3 | ✅ Production-ready | — |
| WooCommerce Orders | `feature_wordpress` | REST API v3 | ❌ Not implemented | Stage 9A | | WooCommerce Orders | `feature_orders` | REST API v3 | ✅ Production-ready | Stage 9A ✅ |
| WooCommerce Stock | `feature_wordpress` | REST API v3 | ❌ Not implemented | Stage 9B | | WooCommerce Stock | `feature_wordpress` | REST API v3 | ❌ Not implemented | Stage 9B |
| WooCommerce Catalog | `feature_wordpress` | REST API v3 | ⚠️ Read + edit only | Stage 9C | | WooCommerce Catalog | `feature_wordpress` | REST API v3 | ⚠️ Read + edit only | Stage 9C |
| WooCommerce Media | `feature_wordpress` | REST API v3 | ❌ Not implemented | Stage 9C | | WooCommerce Media | `feature_wordpress` | REST API v3 | ❌ Not implemented | Stage 9C |

View File

@ -131,6 +131,9 @@ class DashboardSummary {
case OrderStatus.delivered: case OrderStatus.delivered:
deliveredRevenue += order.total; deliveredRevenue += order.total;
case OrderStatus.cancelled: case OrderStatus.cancelled:
case OrderStatus.onHold:
case OrderStatus.refunded:
case OrderStatus.failed:
break; break;
} }
} }

View File

@ -134,6 +134,12 @@ class _MobileOrderDetailPageState extends State<MobileOrderDetailPage> {
return 'Delivered'; return 'Delivered';
case OrderStatus.cancelled: case OrderStatus.cancelled:
return 'Cancelled'; return 'Cancelled';
case OrderStatus.onHold:
return 'On Hold';
case OrderStatus.refunded:
return 'Refunded';
case OrderStatus.failed:
return 'Failed';
} }
} }

View File

@ -129,6 +129,9 @@ class DashboardSummary {
case OrderStatus.delivered: case OrderStatus.delivered:
deliveredRevenue += order.total; deliveredRevenue += order.total;
case OrderStatus.cancelled: case OrderStatus.cancelled:
case OrderStatus.onHold:
case OrderStatus.refunded:
case OrderStatus.failed:
break; break;
} }
} }

View File

@ -70,6 +70,9 @@ class _StubOrdersRepository implements OrdersRepository {
@override @override
Future<List<Order>> getOrders() async => orders; Future<List<Order>> getOrders() async => orders;
@override
Future<Order?> getOrder(String id) async => orders.where((o) => o.id == id).firstOrNull;
@override @override
Future<OrderActionResult> updateOrderStatus(String id, OrderStatus newStatus) => Future<OrderActionResult> updateOrderStatus(String id, OrderStatus newStatus) =>
throw UnimplementedError(); throw UnimplementedError();

View File

@ -6,9 +6,12 @@ export 'src/application/get_orders.dart';
export 'src/application/orders_controller.dart'; export 'src/application/orders_controller.dart';
export 'src/application/update_order_status.dart'; export 'src/application/update_order_status.dart';
export 'src/data/fake_orders_repository.dart'; export 'src/data/fake_orders_repository.dart';
export 'src/data/woo_commerce_order_mapper.dart';
export 'src/data/woo_commerce_orders_repository.dart';
export 'src/domain/order.dart'; export 'src/domain/order.dart';
export 'src/domain/order_action_result.dart'; export 'src/domain/order_action_result.dart';
export 'src/domain/order_item.dart'; export 'src/domain/order_item.dart';
export 'src/domain/order_source.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';
export 'src/presentation/orders_action_snack_bar.dart'; export 'src/presentation/orders_action_snack_bar.dart';

View File

@ -146,6 +146,12 @@ class FakeOrdersRepository implements OrdersRepository {
return List.unmodifiable(_orders); return List.unmodifiable(_orders);
} }
@override
Future<Order?> getOrder(String id) async {
await Future<void>.delayed(const Duration(milliseconds: 150));
return _orders.where((o) => o.id == id).firstOrNull;
}
@override @override
Future<OrderActionResult> updateOrderStatus(String id, OrderStatus newStatus) async { Future<OrderActionResult> updateOrderStatus(String id, OrderStatus newStatus) async {
await Future<void>.delayed(const Duration(milliseconds: 200)); await Future<void>.delayed(const Duration(milliseconds: 200));

View File

@ -0,0 +1,213 @@
import '../domain/order.dart';
import '../domain/order_item.dart';
import '../domain/order_source.dart';
import '../domain/order_status.dart';
/// Maps raw WooCommerce REST API v3 order JSON into [Order] domain objects.
///
/// See https://woocommerce.github.io/woocommerce-rest-api-docs/#order-properties
class WooCommerceOrderMapper {
const WooCommerceOrderMapper();
/// Converts a single WooCommerce order JSON map to an [Order].
Order fromJson(Map<String, dynamic> json) {
final billing = json['billing'] as Map<String, dynamic>? ?? {};
final shipping = json['shipping'] as Map<String, dynamic>? ?? {};
final customerName = _buildName(billing);
final customerEmail = (billing['email'] as String?) ?? '';
final customerPhone = (billing['phone'] as String?);
final shippingAddress = _buildAddress(shipping.isNotEmpty ? shipping : billing);
final rawLineItems = json['line_items'];
final items = rawLineItems is List
? rawLineItems.cast<Map<String, dynamic>>().map(_mapLineItem).toList()
: <OrderItem>[];
// Collect order notes from the customer_note field.
final customerNote = (json['customer_note'] as String?) ?? '';
final orderNotes = customerNote.isNotEmpty ? [customerNote] : <String>[];
// Collect custom fields from meta_data.
final customFields = _extractMetaData(json['meta_data']);
return Order(
id: json['id']?.toString() ?? '',
customerName: customerName,
customerEmail: customerEmail,
customerPhone: customerPhone,
orderDate: _parseDate(json['date_created']),
status: _mapStatus(json['status'] as String?),
items: items,
shippingAddress: shippingAddress,
orderNotes: orderNotes,
paymentMethod: (json['payment_method_title'] as String?),
source: OrderSource.online,
customFields: customFields,
);
}
/// Converts a list of WooCommerce order JSON maps to [Order]s.
List<Order> fromJsonList(List<Map<String, dynamic>> jsonList) {
return jsonList.map(fromJson).toList();
}
/// Converts an [OrderStatus] domain value to the corresponding
/// WooCommerce REST API status string.
///
/// WooCommerce order statuses: `pending`, `processing`, `on-hold`,
/// `completed`, `cancelled`, `refunded`, `failed`, `trash`.
static String toWooCommerceStatus(OrderStatus status) {
switch (status) {
case OrderStatus.pending:
return 'pending';
case OrderStatus.processing:
return 'processing';
case OrderStatus.shipped:
// WooCommerce has no native "shipped" status; map to "completed"
// as the closest equivalent for fulfilled orders.
return 'completed';
case OrderStatus.delivered:
return 'completed';
case OrderStatus.cancelled:
return 'cancelled';
case OrderStatus.onHold:
return 'on-hold';
case OrderStatus.refunded:
return 'refunded';
case OrderStatus.failed:
return 'failed';
}
}
/// Converts a WooCommerce order JSON map to the body needed for createOrder.
Map<String, dynamic> toCreateJson(Order order) {
return {
'status': toWooCommerceStatus(order.status),
'billing': {
'first_name': order.customerName.split(' ').first,
'last_name': order.customerName.split(' ').skip(1).join(' '),
'email': order.customerEmail,
'phone': order.customerPhone ?? '',
},
'customer_note': order.orderNotes.isNotEmpty ? order.orderNotes.first : '',
'payment_method_title': order.paymentMethod ?? '',
'line_items': order.items
.map(
(item) => {
'name': item.productName,
'quantity': item.quantity,
'price': item.unitPrice.toStringAsFixed(2),
if (item.productId != null) 'product_id': int.tryParse(item.productId!) ?? 0,
if (item.variationId != null) 'variation_id': int.tryParse(item.variationId!) ?? 0,
},
)
.toList(),
};
}
// Private helpers
/// Maps the WooCommerce order `status` string to our [OrderStatus] enum.
static OrderStatus _mapStatus(String? status) {
switch (status) {
case 'pending':
return OrderStatus.pending;
case 'processing':
return OrderStatus.processing;
case 'completed':
// WooCommerce "completed" maps to delivered (fully fulfilled).
return OrderStatus.delivered;
case 'cancelled':
return OrderStatus.cancelled;
case 'on-hold':
return OrderStatus.onHold;
case 'refunded':
return OrderStatus.refunded;
case 'failed':
return OrderStatus.failed;
default:
return OrderStatus.pending;
}
}
/// Maps a WooCommerce line item JSON to an [OrderItem].
static OrderItem _mapLineItem(Map<String, dynamic> json) {
final productId = json['product_id']?.toString();
final variationId = json['variation_id']?.toString();
// Extract customizations from meta_data (e.g. product attributes).
final customizations = <String, String>{};
final meta = json['meta_data'];
if (meta is List) {
for (final entry in meta.cast<Map<String, dynamic>>()) {
final key = entry['key'] as String?;
final value = entry['value'];
if (key != null && value != null && !key.startsWith('_')) {
customizations[key] = value.toString();
}
}
}
return OrderItem(
productName: (json['name'] as String?) ?? '',
sku: (json['sku'] as String?) ?? '',
quantity: (json['quantity'] as int?) ?? 1,
unitPrice: _parsePrice(json['price']),
productId: (productId != null && productId != '0') ? productId : null,
variationId: (variationId != null && variationId != '0') ? variationId : null,
customizations: customizations,
);
}
/// Builds a full name from a WooCommerce billing/shipping address map.
static String _buildName(Map<String, dynamic> address) {
final first = (address['first_name'] as String?) ?? '';
final last = (address['last_name'] as String?) ?? '';
return '$first $last'.trim();
}
/// Builds a single-line address string from a WooCommerce address map.
static String _buildAddress(Map<String, dynamic> address) {
final parts = <String>[
(address['address_1'] as String?) ?? '',
(address['address_2'] as String?) ?? '',
(address['city'] as String?) ?? '',
(address['state'] as String?) ?? '',
(address['postcode'] as String?) ?? '',
(address['country'] as String?) ?? '',
].where((s) => s.isNotEmpty).toList();
return parts.join(', ');
}
/// Parses a price value that may arrive as a String or num.
static double _parsePrice(dynamic value) {
if (value == null) return 0.0;
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value) ?? 0.0;
return 0.0;
}
/// Parses an ISO 8601 date string, falling back to [DateTime.now].
static DateTime _parseDate(dynamic value) {
if (value is String && value.isNotEmpty) {
return DateTime.tryParse(value) ?? DateTime.now();
}
return DateTime.now();
}
/// Extracts non-private meta_data entries as a StringString map.
static Map<String, String> _extractMetaData(dynamic metaData) {
final result = <String, String>{};
if (metaData is! List) return result;
for (final entry in metaData.cast<Map<String, dynamic>>()) {
final key = entry['key'] as String?;
final value = entry['value'];
// Skip WooCommerce internal meta keys (prefixed with '_').
if (key != null && !key.startsWith('_') && value != null) {
result[key] = value.toString();
}
}
return result;
}
}

View File

@ -0,0 +1,89 @@
import 'package:feature_wordpress/feature_wordpress.dart';
import '../domain/order.dart';
import '../domain/order_action_result.dart';
import '../domain/order_status.dart';
import '../domain/orders_repository.dart';
import 'woo_commerce_order_mapper.dart';
/// Real [OrdersRepository] backed by the WooCommerce REST API v3.
///
/// Fetches orders from WooCommerce, maps them to domain [Order] objects,
/// and supports status updates, note additions, and order creation.
///
/// Depends on [WooCommerceApiClient] from `feature_wordpress` for HTTP
/// transport and authentication.
class WooCommerceOrdersRepository implements OrdersRepository {
final WooCommerceApiClient _apiClient;
final WooCommerceOrderMapper _mapper;
WooCommerceOrdersRepository({
required WooCommerceApiClient apiClient,
WooCommerceOrderMapper mapper = const WooCommerceOrderMapper(),
}) : _apiClient = apiClient,
_mapper = mapper;
@override
Future<List<Order>> getOrders() async {
final jsonOrders = await _apiClient.getAllOrders();
return _mapper.fromJsonList(jsonOrders);
}
@override
Future<Order?> getOrder(String id) async {
try {
final json = await _apiClient.getOrder(id);
return _mapper.fromJson(json);
} on WooCommerceApiException catch (e) {
if (e.statusCode == 404) return null;
rethrow;
}
}
@override
Future<OrderActionResult> updateOrderStatus(String id, OrderStatus newStatus) async {
try {
final wooStatus = WooCommerceOrderMapper.toWooCommerceStatus(newStatus);
final json = await _apiClient.updateOrderStatus(id, wooStatus);
final updated = _mapper.fromJson(json);
return OrderActionResult.success(updated);
} on WooCommerceApiException catch (e) {
return OrderActionResult.failure('WooCommerce error ${e.statusCode}: ${e.message}');
} catch (e) {
return OrderActionResult.failure('Unexpected error: $e');
}
}
@override
Future<OrderActionResult> addOrderNote(String id, String note) async {
if (note.trim().isEmpty) {
return const OrderActionResult.failure('Note cannot be empty.');
}
try {
// Add the note via the WooCommerce notes endpoint.
await _apiClient.addOrderNote(id, note.trim());
// Fetch the updated order to return the refreshed domain object.
final json = await _apiClient.getOrder(id);
final updated = _mapper.fromJson(json);
return OrderActionResult.success(updated);
} on WooCommerceApiException catch (e) {
return OrderActionResult.failure('WooCommerce error ${e.statusCode}: ${e.message}');
} catch (e) {
return OrderActionResult.failure('Unexpected error: $e');
}
}
@override
Future<OrderActionResult> createOrder(Order order) async {
try {
final body = _mapper.toCreateJson(order);
final json = await _apiClient.createOrder(body);
final created = _mapper.fromJson(json);
return OrderActionResult.success(created);
} on WooCommerceApiException catch (e) {
return OrderActionResult.failure('WooCommerce error ${e.statusCode}: ${e.message}');
} catch (e) {
return OrderActionResult.failure('Unexpected error: $e');
}
}
}

View File

@ -1,4 +1,5 @@
import 'order_item.dart'; import 'order_item.dart';
import 'order_source.dart';
import 'order_status.dart'; import 'order_status.dart';
/// A customer order placed through the Kell Creations store. /// A customer order placed through the Kell Creations store.
@ -21,6 +22,16 @@ class Order {
/// Customer-visible order notes (e.g. gift message, special instructions). /// Customer-visible order notes (e.g. gift message, special instructions).
final List<String> orderNotes; final List<String> orderNotes;
/// The payment method used for this order (e.g. `'Credit Card'`, `'Cash'`).
final String? paymentMethod;
/// The channel through which this order was placed.
final OrderSource source;
/// Arbitrary key/value metadata for custom order specifications or
/// channel-specific fields (e.g. WooCommerce order meta).
final Map<String, String> customFields;
const Order({ const Order({
required this.id, required this.id,
required this.customerName, required this.customerName,
@ -32,6 +43,9 @@ class Order {
required this.shippingAddress, required this.shippingAddress,
this.notes, this.notes,
this.orderNotes = const [], this.orderNotes = const [],
this.paymentMethod,
this.source = OrderSource.online,
this.customFields = const {},
}); });
/// The total value of the order. /// The total value of the order.
@ -52,6 +66,9 @@ class Order {
String? shippingAddress, String? shippingAddress,
String? notes, String? notes,
List<String>? orderNotes, List<String>? orderNotes,
String? paymentMethod,
OrderSource? source,
Map<String, String>? customFields,
}) { }) {
return Order( return Order(
id: id ?? this.id, id: id ?? this.id,
@ -64,6 +81,9 @@ class Order {
shippingAddress: shippingAddress ?? this.shippingAddress, shippingAddress: shippingAddress ?? this.shippingAddress,
notes: notes ?? this.notes, notes: notes ?? this.notes,
orderNotes: orderNotes ?? this.orderNotes, orderNotes: orderNotes ?? this.orderNotes,
paymentMethod: paymentMethod ?? this.paymentMethod,
source: source ?? this.source,
customFields: customFields ?? this.customFields,
); );
} }
} }

View File

@ -5,13 +5,47 @@ class OrderItem {
final int quantity; final int quantity;
final double unitPrice; final double unitPrice;
/// Optional WooCommerce product ID for linking back to the catalog.
final String? productId;
/// Optional WooCommerce variation ID (for variable products).
final String? variationId;
/// Optional map of customization key/value pairs for custom order specs
/// (e.g. `{'color': 'blue', 'size': 'large', 'material': 'cotton'}`).
final Map<String, String> customizations;
const OrderItem({ const OrderItem({
required this.productName, required this.productName,
required this.sku, required this.sku,
required this.quantity, required this.quantity,
required this.unitPrice, required this.unitPrice,
this.productId,
this.variationId,
this.customizations = const {},
}); });
/// The total price for this line item. /// The total price for this line item.
double get lineTotal => quantity * unitPrice; double get lineTotal => quantity * unitPrice;
/// Returns a copy of this item with the given fields replaced.
OrderItem copyWith({
String? productName,
String? sku,
int? quantity,
double? unitPrice,
String? productId,
String? variationId,
Map<String, String>? customizations,
}) {
return OrderItem(
productName: productName ?? this.productName,
sku: sku ?? this.sku,
quantity: quantity ?? this.quantity,
unitPrice: unitPrice ?? this.unitPrice,
productId: productId ?? this.productId,
variationId: variationId ?? this.variationId,
customizations: customizations ?? this.customizations,
);
}
} }

View File

@ -0,0 +1,11 @@
/// The channel through which an order was placed.
enum OrderSource {
/// Order placed through the WooCommerce online store.
online,
/// Order placed in-person at a market or show via the mobile POS.
market,
/// Order created manually by an operator.
manual,
}

View File

@ -14,4 +14,13 @@ enum OrderStatus {
/// Order was cancelled before fulfilment. /// Order was cancelled before fulfilment.
cancelled, cancelled,
/// Order is on hold pending payment or operator action.
onHold,
/// Order was refunded after fulfilment.
refunded,
/// Order payment failed.
failed,
} }

View File

@ -7,6 +7,9 @@ abstract class OrdersRepository {
/// Returns all orders. /// Returns all orders.
Future<List<Order>> getOrders(); Future<List<Order>> getOrders();
/// Returns a single order by [id], or `null` if not found.
Future<Order?> getOrder(String id);
/// Updates the status of the order identified by [id]. /// Updates the status of the order identified by [id].
/// ///
/// Returns an [OrderActionResult] indicating success or failure. /// Returns an [OrderActionResult] indicating success or failure.

View File

@ -28,6 +28,12 @@ class OrderStatusChip extends StatelessWidget {
return ('Delivered', const Color(0xFFE8F5E9), KcColors.success); return ('Delivered', const Color(0xFFE8F5E9), KcColors.success);
case OrderStatus.cancelled: case OrderStatus.cancelled:
return ('Cancelled', const Color(0xFFFFEBEE), KcColors.danger); return ('Cancelled', const Color(0xFFFFEBEE), KcColors.danger);
case OrderStatus.onHold:
return ('On Hold', const Color(0xFFF3E5F5), const Color(0xFF7B1FA2));
case OrderStatus.refunded:
return ('Refunded', const Color(0xFFFCE4EC), const Color(0xFFC62828));
case OrderStatus.failed:
return ('Failed', const Color(0xFFFFEBEE), KcColors.danger);
} }
} }
} }

View File

@ -13,10 +13,13 @@ dependencies:
sdk: flutter sdk: flutter
design_system: design_system:
path: ../design_system path: ../design_system
feature_wordpress:
path: ../feature_wordpress
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
http: ^1.4.0
flutter: flutter:

View File

@ -64,6 +64,9 @@ class _EmptyOrdersRepository implements OrdersRepository {
@override @override
Future<List<Order>> getOrders() async => []; Future<List<Order>> getOrders() async => [];
@override
Future<Order?> getOrder(String id) async => null;
@override @override
Future<OrderActionResult> updateOrderStatus(String id, OrderStatus newStatus) async => Future<OrderActionResult> updateOrderStatus(String id, OrderStatus newStatus) async =>
const OrderActionResult.failure('Not implemented.'); const OrderActionResult.failure('Not implemented.');

View File

@ -0,0 +1,474 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:feature_orders/feature_orders.dart';
void main() {
const mapper = WooCommerceOrderMapper();
// Sample WooCommerce order JSON
Map<String, dynamic> sampleOrderJson({
String id = '1001',
String status = 'processing',
String firstName = 'Jane',
String lastName = 'Doe',
String email = 'jane.doe@example.com',
String phone = '555-0100',
String customerNote = '',
String paymentMethodTitle = 'Credit Card',
List<Map<String, dynamic>>? lineItems,
List<Map<String, dynamic>>? metaData,
}) {
return {
'id': int.parse(id),
'status': status,
'date_created': '2026-04-01T10:00:00',
'customer_note': customerNote,
'payment_method_title': paymentMethodTitle,
'billing': {
'first_name': firstName,
'last_name': lastName,
'email': email,
'phone': phone,
'address_1': '123 Main St',
'city': 'Asheville',
'state': 'NC',
'postcode': '28801',
'country': 'US',
},
'shipping': {
'first_name': firstName,
'last_name': lastName,
'address_1': '123 Main St',
'city': 'Asheville',
'state': 'NC',
'postcode': '28801',
'country': 'US',
},
'line_items':
lineItems ??
[
{
'name': 'Floral Bowl Cozy',
'sku': 'BC-FLR-001',
'quantity': 2,
'price': '12.99',
'product_id': 42,
'variation_id': 0,
'meta_data': [],
},
],
'meta_data': metaData ?? [],
};
}
// fromJson
group('WooCommerceOrderMapper.fromJson', () {
test('maps id from integer field', () {
final order = mapper.fromJson(sampleOrderJson(id: '1001'));
expect(order.id, '1001');
});
test('maps customer name from billing first_name + last_name', () {
final order = mapper.fromJson(sampleOrderJson(firstName: 'Jane', lastName: 'Doe'));
expect(order.customerName, 'Jane Doe');
});
test('maps customer email from billing email', () {
final order = mapper.fromJson(sampleOrderJson(email: 'jane@example.com'));
expect(order.customerEmail, 'jane@example.com');
});
test('maps customer phone from billing phone', () {
final order = mapper.fromJson(sampleOrderJson(phone: '828-555-0101'));
expect(order.customerPhone, '828-555-0101');
});
test('maps payment method title', () {
final order = mapper.fromJson(sampleOrderJson(paymentMethodTitle: 'PayPal'));
expect(order.paymentMethod, 'PayPal');
});
test('maps source to online', () {
final order = mapper.fromJson(sampleOrderJson());
expect(order.source, OrderSource.online);
});
test('maps order date from date_created', () {
final order = mapper.fromJson(sampleOrderJson());
expect(order.orderDate, DateTime(2026, 4, 1, 10, 0, 0));
});
test('maps customer_note to orderNotes when non-empty', () {
final order = mapper.fromJson(sampleOrderJson(customerNote: 'Please gift wrap.'));
expect(order.orderNotes, ['Please gift wrap.']);
});
test('orderNotes is empty when customer_note is empty', () {
final order = mapper.fromJson(sampleOrderJson(customerNote: ''));
expect(order.orderNotes, isEmpty);
});
test('maps shipping address from shipping fields', () {
final order = mapper.fromJson(sampleOrderJson());
expect(order.shippingAddress, contains('123 Main St'));
expect(order.shippingAddress, contains('Asheville'));
expect(order.shippingAddress, contains('NC'));
});
test('maps line item product name', () {
final order = mapper.fromJson(sampleOrderJson());
expect(order.items.first.productName, 'Floral Bowl Cozy');
});
test('maps line item sku', () {
final order = mapper.fromJson(sampleOrderJson());
expect(order.items.first.sku, 'BC-FLR-001');
});
test('maps line item quantity', () {
final order = mapper.fromJson(sampleOrderJson());
expect(order.items.first.quantity, 2);
});
test('maps line item unit price from string', () {
final order = mapper.fromJson(sampleOrderJson());
expect(order.items.first.unitPrice, closeTo(12.99, 0.001));
});
test('maps line item productId when non-zero', () {
final order = mapper.fromJson(sampleOrderJson());
expect(order.items.first.productId, '42');
});
test('line item variationId is null when variation_id is 0', () {
final order = mapper.fromJson(sampleOrderJson());
expect(order.items.first.variationId, isNull);
});
test('maps line item variationId when non-zero', () {
final json = sampleOrderJson(
lineItems: [
{
'name': 'Variant Product',
'sku': 'VP-001',
'quantity': 1,
'price': '9.99',
'product_id': 10,
'variation_id': 5,
'meta_data': [],
},
],
);
final order = mapper.fromJson(json);
expect(order.items.first.variationId, '5');
});
test('maps line item customizations from non-private meta_data', () {
final json = sampleOrderJson(
lineItems: [
{
'name': 'Custom Bowl',
'sku': 'CB-001',
'quantity': 1,
'price': '15.00',
'product_id': 0,
'variation_id': 0,
'meta_data': [
{'key': 'color', 'value': 'blue'},
{'key': '_private', 'value': 'hidden'},
],
},
],
);
final order = mapper.fromJson(json);
expect(order.items.first.customizations['color'], 'blue');
expect(order.items.first.customizations.containsKey('_private'), false);
});
test('extracts non-private meta_data into customFields', () {
final json = sampleOrderJson(
metaData: [
{'key': 'gift_message', 'value': 'Happy Birthday!'},
{'key': '_wc_internal', 'value': 'skip'},
],
);
final order = mapper.fromJson(json);
expect(order.customFields['gift_message'], 'Happy Birthday!');
expect(order.customFields.containsKey('_wc_internal'), false);
});
test('handles missing line_items gracefully', () {
final json = Map<String, dynamic>.from(sampleOrderJson());
json['line_items'] = null;
final order = mapper.fromJson(json);
expect(order.items, isEmpty);
});
});
// fromJsonList
group('WooCommerceOrderMapper.fromJsonList', () {
test('maps a list of order JSON maps', () {
final jsonList = [
sampleOrderJson(id: '1'),
sampleOrderJson(id: '2'),
sampleOrderJson(id: '3'),
];
final orders = mapper.fromJsonList(jsonList);
expect(orders.length, 3);
expect(orders.map((o) => o.id).toList(), ['1', '2', '3']);
});
test('returns empty list for empty input', () {
expect(mapper.fromJsonList([]), isEmpty);
});
});
// toWooCommerceStatus
group('WooCommerceOrderMapper.toWooCommerceStatus', () {
test('pending maps to pending', () {
expect(WooCommerceOrderMapper.toWooCommerceStatus(OrderStatus.pending), 'pending');
});
test('processing maps to processing', () {
expect(WooCommerceOrderMapper.toWooCommerceStatus(OrderStatus.processing), 'processing');
});
test('shipped maps to completed', () {
expect(WooCommerceOrderMapper.toWooCommerceStatus(OrderStatus.shipped), 'completed');
});
test('delivered maps to completed', () {
expect(WooCommerceOrderMapper.toWooCommerceStatus(OrderStatus.delivered), 'completed');
});
test('cancelled maps to cancelled', () {
expect(WooCommerceOrderMapper.toWooCommerceStatus(OrderStatus.cancelled), 'cancelled');
});
test('onHold maps to on-hold', () {
expect(WooCommerceOrderMapper.toWooCommerceStatus(OrderStatus.onHold), 'on-hold');
});
test('refunded maps to refunded', () {
expect(WooCommerceOrderMapper.toWooCommerceStatus(OrderStatus.refunded), 'refunded');
});
test('failed maps to failed', () {
expect(WooCommerceOrderMapper.toWooCommerceStatus(OrderStatus.failed), 'failed');
});
});
// _mapStatus (via fromJson)
group('WooCommerceOrderMapper status mapping (fromJson)', () {
test('processing status maps to OrderStatus.processing', () {
final order = mapper.fromJson(sampleOrderJson(status: 'processing'));
expect(order.status, OrderStatus.processing);
});
test('completed status maps to OrderStatus.delivered', () {
final order = mapper.fromJson(sampleOrderJson(status: 'completed'));
expect(order.status, OrderStatus.delivered);
});
test('cancelled status maps to OrderStatus.cancelled', () {
final order = mapper.fromJson(sampleOrderJson(status: 'cancelled'));
expect(order.status, OrderStatus.cancelled);
});
test('on-hold status maps to OrderStatus.onHold', () {
final order = mapper.fromJson(sampleOrderJson(status: 'on-hold'));
expect(order.status, OrderStatus.onHold);
});
test('refunded status maps to OrderStatus.refunded', () {
final order = mapper.fromJson(sampleOrderJson(status: 'refunded'));
expect(order.status, OrderStatus.refunded);
});
test('failed status maps to OrderStatus.failed', () {
final order = mapper.fromJson(sampleOrderJson(status: 'failed'));
expect(order.status, OrderStatus.failed);
});
test('unknown status defaults to OrderStatus.pending', () {
final order = mapper.fromJson(sampleOrderJson(status: 'trash'));
expect(order.status, OrderStatus.pending);
});
});
// toCreateJson
group('WooCommerceOrderMapper.toCreateJson', () {
final sampleOrder = Order(
id: 'KC-9999',
customerName: 'Jane Doe',
customerEmail: 'jane@example.com',
customerPhone: '555-0100',
orderDate: DateTime(2026, 4, 1),
status: OrderStatus.pending,
items: const [
OrderItem(
productName: 'Bowl Cozy',
sku: 'BC-001',
quantity: 2,
unitPrice: 12.99,
productId: '42',
),
],
shippingAddress: '123 Main St, Asheville, NC 28801',
orderNotes: ['Please gift wrap.'],
paymentMethod: 'Cash',
);
test('includes status as WooCommerce string', () {
final json = mapper.toCreateJson(sampleOrder);
expect(json['status'], 'pending');
});
test('includes billing email', () {
final json = mapper.toCreateJson(sampleOrder);
expect((json['billing'] as Map)['email'], 'jane@example.com');
});
test('includes billing phone', () {
final json = mapper.toCreateJson(sampleOrder);
expect((json['billing'] as Map)['phone'], '555-0100');
});
test('includes customer_note from first orderNote', () {
final json = mapper.toCreateJson(sampleOrder);
expect(json['customer_note'], 'Please gift wrap.');
});
test('customer_note is empty when orderNotes is empty', () {
final order = sampleOrder.copyWith(orderNotes: []);
final json = mapper.toCreateJson(order);
expect(json['customer_note'], '');
});
test('includes line items', () {
final json = mapper.toCreateJson(sampleOrder);
final lineItems = json['line_items'] as List;
expect(lineItems.length, 1);
expect(lineItems.first['name'], 'Bowl Cozy');
expect(lineItems.first['quantity'], 2);
});
test('includes product_id in line item when set', () {
final json = mapper.toCreateJson(sampleOrder);
final lineItems = json['line_items'] as List;
expect(lineItems.first['product_id'], 42);
});
});
// OrderStatus new values
group('OrderStatus new values', () {
test('onHold is a valid OrderStatus', () {
expect(OrderStatus.values, contains(OrderStatus.onHold));
});
test('refunded is a valid OrderStatus', () {
expect(OrderStatus.values, contains(OrderStatus.refunded));
});
test('failed is a valid OrderStatus', () {
expect(OrderStatus.values, contains(OrderStatus.failed));
});
test('OrderStatus has 8 values total', () {
expect(OrderStatus.values.length, 8);
});
});
// OrderItem new fields
group('OrderItem new fields', () {
test('productId defaults to null', () {
const item = OrderItem(productName: 'Test', sku: 'T-001', quantity: 1, unitPrice: 5.0);
expect(item.productId, isNull);
});
test('variationId defaults to null', () {
const item = OrderItem(productName: 'Test', sku: 'T-001', quantity: 1, unitPrice: 5.0);
expect(item.variationId, isNull);
});
test('customizations defaults to empty map', () {
const item = OrderItem(productName: 'Test', sku: 'T-001', quantity: 1, unitPrice: 5.0);
expect(item.customizations, isEmpty);
});
test('copyWith replaces productId', () {
const item = OrderItem(productName: 'Test', sku: 'T-001', quantity: 1, unitPrice: 5.0);
final updated = item.copyWith(productId: '99');
expect(updated.productId, '99');
});
test('copyWith replaces customizations', () {
const item = OrderItem(productName: 'Test', sku: 'T-001', quantity: 1, unitPrice: 5.0);
final updated = item.copyWith(customizations: {'color': 'red'});
expect(updated.customizations['color'], 'red');
});
});
// Order new fields
group('Order new fields', () {
final baseOrder = Order(
id: 'KC-0001',
customerName: 'Test',
customerEmail: 'test@example.com',
orderDate: DateTime(2026, 1, 1),
status: OrderStatus.pending,
items: const [],
shippingAddress: '',
);
test('paymentMethod defaults to null', () {
expect(baseOrder.paymentMethod, isNull);
});
test('source defaults to online', () {
expect(baseOrder.source, OrderSource.online);
});
test('customFields defaults to empty map', () {
expect(baseOrder.customFields, isEmpty);
});
test('copyWith replaces paymentMethod', () {
final updated = baseOrder.copyWith(paymentMethod: 'Cash');
expect(updated.paymentMethod, 'Cash');
});
test('copyWith replaces source', () {
final updated = baseOrder.copyWith(source: OrderSource.market);
expect(updated.source, OrderSource.market);
});
test('copyWith replaces customFields', () {
final updated = baseOrder.copyWith(customFields: {'gift': 'true'});
expect(updated.customFields['gift'], 'true');
});
});
// OrderSource
group('OrderSource', () {
test('has online, market, and manual values', () {
expect(OrderSource.values, contains(OrderSource.online));
expect(OrderSource.values, contains(OrderSource.market));
expect(OrderSource.values, contains(OrderSource.manual));
});
test('has 3 values total', () {
expect(OrderSource.values.length, 3);
});
});
}

View File

@ -0,0 +1,345 @@
import 'dart:convert';
import 'package:feature_orders/feature_orders.dart';
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
// Helpers
Map<String, dynamic> _orderJson({String id = '1001', String status = 'processing'}) {
return {
'id': int.parse(id),
'status': status,
'date_created': '2026-04-01T10:00:00',
'customer_note': '',
'payment_method_title': 'Credit Card',
'billing': {
'first_name': 'Jane',
'last_name': 'Doe',
'email': 'jane@example.com',
'phone': '555-0100',
'address_1': '123 Main St',
'city': 'Asheville',
'state': 'NC',
'postcode': '28801',
'country': 'US',
},
'shipping': {
'first_name': 'Jane',
'last_name': 'Doe',
'address_1': '123 Main St',
'city': 'Asheville',
'state': 'NC',
'postcode': '28801',
'country': 'US',
},
'line_items': [
{
'name': 'Floral Bowl Cozy',
'sku': 'BC-FLR-001',
'quantity': 2,
'price': '12.99',
'product_id': 42,
'variation_id': 0,
'meta_data': [],
},
],
'meta_data': [],
};
}
WooCommerceApiClient _clientWith(MockClient mockClient) {
return WooCommerceApiClient(
siteUrl: 'https://example.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
httpClient: mockClient,
);
}
WooCommerceOrdersRepository _repoWith(MockClient mockClient) {
return WooCommerceOrdersRepository(apiClient: _clientWith(mockClient));
}
// Tests
void main() {
// WooCommerceApiClient orders endpoints
group('WooCommerceApiClient — getOrders', () {
test('returns list of order maps on 200', () async {
final client = MockClient((request) async {
expect(request.url.path, contains('/orders'));
return http.Response(jsonEncode([_orderJson(id: '1'), _orderJson(id: '2')]), 200);
});
final apiClient = _clientWith(client);
final orders = await apiClient.getOrders();
expect(orders.length, 2);
expect(orders.first['id'], 1);
});
test('throws WooCommerceApiException on non-200', () async {
final client = MockClient((_) async => http.Response('{"message":"Unauthorized"}', 401));
final apiClient = _clientWith(client);
expect(() => apiClient.getOrders(), throwsA(isA<WooCommerceApiException>()));
});
});
group('WooCommerceApiClient — getOrder', () {
test('returns single order map on 200', () async {
final client = MockClient((request) async {
expect(request.url.path, contains('/orders/1001'));
return http.Response(jsonEncode(_orderJson(id: '1001')), 200);
});
final apiClient = _clientWith(client);
final order = await apiClient.getOrder('1001');
expect(order['id'], 1001);
});
test('throws WooCommerceApiException on 404', () async {
final client = MockClient((_) async => http.Response('{"message":"Not found"}', 404));
final apiClient = _clientWith(client);
expect(() => apiClient.getOrder('9999'), throwsA(isA<WooCommerceApiException>()));
});
});
group('WooCommerceApiClient — updateOrderStatus', () {
test('sends PUT with status body and returns updated order', () async {
final client = MockClient((request) async {
expect(request.method, 'PUT');
expect(request.url.path, contains('/orders/1001'));
final body = jsonDecode(request.body) as Map<String, dynamic>;
expect(body['status'], 'completed');
return http.Response(jsonEncode(_orderJson(id: '1001', status: 'completed')), 200);
});
final apiClient = _clientWith(client);
final result = await apiClient.updateOrderStatus('1001', 'completed');
expect(result['status'], 'completed');
});
test('throws WooCommerceApiException on non-200', () async {
final client = MockClient((_) async => http.Response('{"message":"Error"}', 500));
final apiClient = _clientWith(client);
expect(
() => apiClient.updateOrderStatus('1001', 'completed'),
throwsA(isA<WooCommerceApiException>()),
);
});
});
group('WooCommerceApiClient — createOrder', () {
test('sends POST and returns created order on 201', () async {
final client = MockClient((request) async {
expect(request.method, 'POST');
expect(request.url.path, contains('/orders'));
return http.Response(jsonEncode(_orderJson(id: '2000', status: 'pending')), 201);
});
final apiClient = _clientWith(client);
final result = await apiClient.createOrder({'status': 'pending'});
expect(result['id'], 2000);
});
test('throws WooCommerceApiException on non-201', () async {
final client = MockClient((_) async => http.Response('{"message":"Error"}', 400));
final apiClient = _clientWith(client);
expect(
() => apiClient.createOrder({'status': 'pending'}),
throwsA(isA<WooCommerceApiException>()),
);
});
});
group('WooCommerceApiClient — addOrderNote', () {
test('sends POST to notes endpoint and returns note on 201', () async {
final client = MockClient((request) async {
expect(request.method, 'POST');
expect(request.url.path, contains('/orders/1001/notes'));
final body = jsonDecode(request.body) as Map<String, dynamic>;
expect(body['note'], 'Please gift wrap.');
return http.Response(jsonEncode({'id': 1, 'note': 'Please gift wrap.'}), 201);
});
final apiClient = _clientWith(client);
final result = await apiClient.addOrderNote('1001', 'Please gift wrap.');
expect(result['note'], 'Please gift wrap.');
});
test('throws WooCommerceApiException on non-201', () async {
final client = MockClient((_) async => http.Response('{"message":"Error"}', 400));
final apiClient = _clientWith(client);
expect(() => apiClient.addOrderNote('1001', 'Note'), throwsA(isA<WooCommerceApiException>()));
});
});
// WooCommerceOrdersRepository
group('WooCommerceOrdersRepository — getOrders', () {
test('returns mapped orders from API', () async {
final client = MockClient(
(_) async => http.Response(jsonEncode([_orderJson(id: '1'), _orderJson(id: '2')]), 200),
);
final repo = _repoWith(client);
final orders = await repo.getOrders();
expect(orders.length, 2);
expect(orders.first.id, '1');
});
});
group('WooCommerceOrdersRepository — getOrder', () {
test('returns mapped order when found', () async {
final client = MockClient(
(_) async => http.Response(jsonEncode(_orderJson(id: '1001')), 200),
);
final repo = _repoWith(client);
final order = await repo.getOrder('1001');
expect(order, isNotNull);
expect(order!.id, '1001');
});
test('returns null on 404', () async {
final client = MockClient((_) async => http.Response('{"message":"Not found"}', 404));
final repo = _repoWith(client);
final order = await repo.getOrder('9999');
expect(order, isNull);
});
});
group('WooCommerceOrdersRepository — updateOrderStatus', () {
test('returns success result with updated order', () async {
final client = MockClient(
(_) async => http.Response(jsonEncode(_orderJson(id: '1001', status: 'completed')), 200),
);
final repo = _repoWith(client);
final result = await repo.updateOrderStatus('1001', OrderStatus.delivered);
expect(result.success, true);
expect(result.updatedOrder!.id, '1001');
expect(result.updatedOrder!.status, OrderStatus.delivered);
});
test('returns failure result on API error', () async {
final client = MockClient((_) async => http.Response('{"message":"Error"}', 500));
final repo = _repoWith(client);
final result = await repo.updateOrderStatus('1001', OrderStatus.delivered);
expect(result.success, false);
expect(result.errorMessage, isNotNull);
});
});
group('WooCommerceOrdersRepository — addOrderNote', () {
test('returns failure for empty note without calling API', () async {
var apiCalled = false;
final client = MockClient((_) async {
apiCalled = true;
return http.Response('{}', 201);
});
final repo = _repoWith(client);
final result = await repo.addOrderNote('1001', ' ');
expect(result.success, false);
expect(result.errorMessage, isNotNull);
expect(apiCalled, false);
});
test('returns success result after adding note and fetching updated order', () async {
var callCount = 0;
final client = MockClient((request) async {
callCount++;
if (request.method == 'POST') {
return http.Response(jsonEncode({'id': 1, 'note': 'Gift wrap please.'}), 201);
}
// GET order after note added
return http.Response(jsonEncode(_orderJson(id: '1001')), 200);
});
final repo = _repoWith(client);
final result = await repo.addOrderNote('1001', 'Gift wrap please.');
expect(result.success, true);
expect(callCount, 2); // POST note + GET order
});
});
group('WooCommerceOrdersRepository — createOrder', () {
test('returns success result with created order', () async {
final client = MockClient(
(_) async => http.Response(jsonEncode(_orderJson(id: '2000', status: 'pending')), 201),
);
final repo = _repoWith(client);
final newOrder = Order(
id: 'KC-NEW',
customerName: 'New Customer',
customerEmail: 'new@example.com',
orderDate: DateTime(2026, 7, 1),
status: OrderStatus.pending,
items: const [],
shippingAddress: '',
);
final result = await repo.createOrder(newOrder);
expect(result.success, true);
expect(result.updatedOrder!.id, '2000');
});
test('returns failure result on API error', () async {
final client = MockClient((_) async => http.Response('{"message":"Error"}', 400));
final repo = _repoWith(client);
final newOrder = Order(
id: 'KC-NEW',
customerName: 'New Customer',
customerEmail: 'new@example.com',
orderDate: DateTime(2026, 7, 1),
status: OrderStatus.pending,
items: const [],
shippingAddress: '',
);
final result = await repo.createOrder(newOrder);
expect(result.success, false);
expect(result.errorMessage, isNotNull);
});
});
// FakeOrdersRepository getOrder
group('FakeOrdersRepository — getOrder', () {
late FakeOrdersRepository repository;
setUp(() {
repository = FakeOrdersRepository();
});
test('returns order for known ID', () async {
final order = await repository.getOrder('KC-1001');
expect(order, isNotNull);
expect(order!.id, 'KC-1001');
});
test('returns null for unknown ID', () async {
final order = await repository.getOrder('UNKNOWN');
expect(order, isNull);
});
test('returns updated order after status change', () async {
await repository.updateOrderStatus('KC-1001', OrderStatus.processing);
final order = await repository.getOrder('KC-1001');
expect(order!.status, OrderStatus.processing);
});
});
}

View File

@ -112,6 +112,173 @@ class WooCommerceApiClient implements ApiClient {
return {'Authorization': 'Basic $credentials', 'Content-Type': 'application/json'}; return {'Authorization': 'Basic $credentials', 'Content-Type': 'application/json'};
} }
// Orders API
/// Fetches a paginated list of orders from WooCommerce.
///
/// [page] is 1-based. [perPage] defaults to 100 (WooCommerce max).
/// Returns the raw JSON list of order maps.
Future<List<Map<String, dynamic>>> getOrders({int page = 1, int perPage = 100}) async {
final uri = Uri.parse(
'$_baseEndpoint/orders',
).replace(queryParameters: {'page': page.toString(), 'per_page': perPage.toString()});
final response = await _httpClient.get(uri, headers: _authHeaders);
if (response.statusCode != 200) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Failed to fetch orders: ${response.reasonPhrase}',
body: response.body,
);
}
final decoded = jsonDecode(response.body);
if (decoded is! List) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Unexpected response format: expected a JSON array',
body: response.body,
);
}
return decoded.cast<Map<String, dynamic>>();
}
/// Fetches all orders by paginating through the WooCommerce API.
Future<List<Map<String, dynamic>>> getAllOrders({int perPage = 100}) async {
final allOrders = <Map<String, dynamic>>[];
var page = 1;
while (true) {
final batch = await getOrders(page: page, perPage: perPage);
allOrders.addAll(batch);
if (batch.length < perPage) break;
page++;
}
return allOrders;
}
/// Fetches a single order by [orderId].
///
/// Returns the raw JSON order map.
Future<Map<String, dynamic>> getOrder(String orderId) async {
final uri = Uri.parse('$_baseEndpoint/orders/$orderId');
final response = await _httpClient.get(uri, headers: _authHeaders);
if (response.statusCode != 200) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Failed to fetch order $orderId: ${response.reasonPhrase}',
body: response.body,
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Unexpected response format: expected a JSON object',
body: response.body,
);
}
return decoded;
}
/// Updates the status of a WooCommerce order.
///
/// Returns the full updated order JSON.
Future<Map<String, dynamic>> updateOrderStatus(String orderId, String status) async {
final uri = Uri.parse('$_baseEndpoint/orders/$orderId');
final response = await _httpClient.put(
uri,
headers: _authHeaders,
body: jsonEncode({'status': status}),
);
if (response.statusCode != 200) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Failed to update order $orderId status: ${response.reasonPhrase}',
body: response.body,
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Unexpected response format: expected a JSON object',
body: response.body,
);
}
return decoded;
}
/// Creates a new order in WooCommerce.
///
/// [body] should contain the WooCommerce order fields.
/// Returns the created order JSON.
Future<Map<String, dynamic>> createOrder(Map<String, dynamic> body) async {
final uri = Uri.parse('$_baseEndpoint/orders');
final response = await _httpClient.post(uri, headers: _authHeaders, body: jsonEncode(body));
if (response.statusCode != 201) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Failed to create order: ${response.reasonPhrase}',
body: response.body,
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Unexpected response format: expected a JSON object',
body: response.body,
);
}
return decoded;
}
/// Adds a note to a WooCommerce order.
///
/// Returns the created note JSON.
Future<Map<String, dynamic>> addOrderNote(String orderId, String note) async {
final uri = Uri.parse('$_baseEndpoint/orders/$orderId/notes');
final response = await _httpClient.post(
uri,
headers: _authHeaders,
body: jsonEncode({'note': note}),
);
if (response.statusCode != 201) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Failed to add note to order $orderId: ${response.reasonPhrase}',
body: response.body,
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Unexpected response format: expected a JSON object',
body: response.body,
);
}
return decoded;
}
// Products API
/// Sends a partial update (PUT) to a single WooCommerce product. /// Sends a partial update (PUT) to a single WooCommerce product.
/// ///
/// Only the fields present in [body] are changed on the remote product. /// Only the fields present in [body] are changed on the remote product.