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:
parent
003347c89c
commit
e92788038a
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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 String→String 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.');
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue