diff --git a/docs/development/build_execution_tracker.md b/docs/development/build_execution_tracker.md index 46928af..979cd66 100644 --- a/docs/development/build_execution_tracker.md +++ b/docs/development/build_execution_tracker.md @@ -2,9 +2,9 @@ ## Current status -- main baseline updated through: data-layer-contracts (Stage 8B complete) -- next branch: feat/auth-foundation (Stage 8C) -- current stage: Stage 8B complete — shared data layer abstractions activated +- main baseline updated through: feat/wc-orders-integration (Stage 9A complete) +- next branch: feat/wc-inventory-sync (Stage 9B) +- current stage: Stage 9A complete — WooCommerce Orders API integration activated ## Slice tracker @@ -421,6 +421,53 @@ - analyze: passed (dart analyze --fatal-infos — no issues found) - 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 - status: merged to main diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index 39ffd7d..42d50d6 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -140,20 +140,21 @@ Rules: - `core` tests passing - `design_system` tests passing - `feature_wordpress` tests passing +- `feature_orders` tests passing - `kell_web` tests passing - `kell_mobile` tests passing - latest reported count for `core`: `21/21 passed` - latest reported count for `design_system`: `41/41 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 `kell_web`: `24/24 passed` - latest reported count for `integrations`: `38/38 passed` - latest reported count for `data`: `63/63 passed` - latest reported count for `auth`: `42/42 passed` - latest reported count for `kell_mobile`: `26/26 passed` -- total: `764/764 passed` -- baseline commit: merge of `feat/auth-foundation` into `main` (Stage 8C complete, 2026-07-11) +- total: `847/847 passed` +- baseline commit: merge of `feat/wc-orders-integration` into `main` (Stage 9A complete, 2026-07-11) #### 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 -**`feat/wc-orders-integration`** — Stage 9A: WooCommerce Orders API integration. -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. +**`feat/wc-inventory-sync`** — Stage 9B: WooCommerce stock/inventory sync. +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 | | -------------------- | ------------------- | ------------- | ------------------- | ------------ | | 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 Catalog | `feature_wordpress` | REST API v3 | ⚠️ Read + edit only | Stage 9C | | WooCommerce Media | `feature_wordpress` | REST API v3 | ❌ Not implemented | Stage 9C | diff --git a/kell_creations_apps/apps/kell_mobile/lib/dashboard/domain/dashboard_summary.dart b/kell_creations_apps/apps/kell_mobile/lib/dashboard/domain/dashboard_summary.dart index 8adff78..b2c9392 100644 --- a/kell_creations_apps/apps/kell_mobile/lib/dashboard/domain/dashboard_summary.dart +++ b/kell_creations_apps/apps/kell_mobile/lib/dashboard/domain/dashboard_summary.dart @@ -131,6 +131,9 @@ class DashboardSummary { case OrderStatus.delivered: deliveredRevenue += order.total; case OrderStatus.cancelled: + case OrderStatus.onHold: + case OrderStatus.refunded: + case OrderStatus.failed: break; } } diff --git a/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_order_detail_page.dart b/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_order_detail_page.dart index 996b0d9..cb20ba6 100644 --- a/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_order_detail_page.dart +++ b/kell_creations_apps/apps/kell_mobile/lib/pages/mobile_order_detail_page.dart @@ -134,6 +134,12 @@ class _MobileOrderDetailPageState extends State { return 'Delivered'; case OrderStatus.cancelled: return 'Cancelled'; + case OrderStatus.onHold: + return 'On Hold'; + case OrderStatus.refunded: + return 'Refunded'; + case OrderStatus.failed: + return 'Failed'; } } diff --git a/kell_creations_apps/apps/kell_web/lib/dashboard/domain/dashboard_summary.dart b/kell_creations_apps/apps/kell_web/lib/dashboard/domain/dashboard_summary.dart index 572db6d..42746ae 100644 --- a/kell_creations_apps/apps/kell_web/lib/dashboard/domain/dashboard_summary.dart +++ b/kell_creations_apps/apps/kell_web/lib/dashboard/domain/dashboard_summary.dart @@ -129,6 +129,9 @@ class DashboardSummary { case OrderStatus.delivered: deliveredRevenue += order.total; case OrderStatus.cancelled: + case OrderStatus.onHold: + case OrderStatus.refunded: + case OrderStatus.failed: break; } } diff --git a/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart b/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart index 51df208..5c50087 100644 --- a/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart +++ b/kell_creations_apps/apps/kell_web/test/dashboard/application/dashboard_controller_test.dart @@ -70,6 +70,9 @@ class _StubOrdersRepository implements OrdersRepository { @override Future> getOrders() async => orders; + @override + Future getOrder(String id) async => orders.where((o) => o.id == id).firstOrNull; + @override Future updateOrderStatus(String id, OrderStatus newStatus) => throw UnimplementedError(); diff --git a/kell_creations_apps/packages/feature_orders/build/unit_test_assets/NOTICES.Z b/kell_creations_apps/packages/feature_orders/build/unit_test_assets/NOTICES.Z index 2fe8afc..5cd4c1c 100644 Binary files a/kell_creations_apps/packages/feature_orders/build/unit_test_assets/NOTICES.Z and b/kell_creations_apps/packages/feature_orders/build/unit_test_assets/NOTICES.Z differ diff --git a/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart b/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart index 328945b..475dd5a 100644 --- a/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart +++ b/kell_creations_apps/packages/feature_orders/lib/feature_orders.dart @@ -6,9 +6,12 @@ export 'src/application/get_orders.dart'; export 'src/application/orders_controller.dart'; export 'src/application/update_order_status.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_action_result.dart'; export 'src/domain/order_item.dart'; +export 'src/domain/order_source.dart'; export 'src/domain/order_status.dart'; export 'src/domain/orders_repository.dart'; export 'src/presentation/orders_action_snack_bar.dart'; diff --git a/kell_creations_apps/packages/feature_orders/lib/src/data/fake_orders_repository.dart b/kell_creations_apps/packages/feature_orders/lib/src/data/fake_orders_repository.dart index 6af90c9..54fd96e 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/data/fake_orders_repository.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/data/fake_orders_repository.dart @@ -146,6 +146,12 @@ class FakeOrdersRepository implements OrdersRepository { return List.unmodifiable(_orders); } + @override + Future getOrder(String id) async { + await Future.delayed(const Duration(milliseconds: 150)); + return _orders.where((o) => o.id == id).firstOrNull; + } + @override Future updateOrderStatus(String id, OrderStatus newStatus) async { await Future.delayed(const Duration(milliseconds: 200)); diff --git a/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_order_mapper.dart b/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_order_mapper.dart new file mode 100644 index 0000000..a9532be --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_order_mapper.dart @@ -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 json) { + final billing = json['billing'] as Map? ?? {}; + final shipping = json['shipping'] as Map? ?? {}; + + 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(_mapLineItem).toList() + : []; + + // Collect order notes from the customer_note field. + final customerNote = (json['customer_note'] as String?) ?? ''; + final orderNotes = customerNote.isNotEmpty ? [customerNote] : []; + + // 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 fromJsonList(List> 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 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 json) { + final productId = json['product_id']?.toString(); + final variationId = json['variation_id']?.toString(); + + // Extract customizations from meta_data (e.g. product attributes). + final customizations = {}; + final meta = json['meta_data']; + if (meta is List) { + for (final entry in meta.cast>()) { + 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 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 address) { + final parts = [ + (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 _extractMetaData(dynamic metaData) { + final result = {}; + if (metaData is! List) return result; + for (final entry in metaData.cast>()) { + 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; + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_orders_repository.dart b/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_orders_repository.dart new file mode 100644 index 0000000..9a35a8d --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/data/woo_commerce_orders_repository.dart @@ -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> getOrders() async { + final jsonOrders = await _apiClient.getAllOrders(); + return _mapper.fromJsonList(jsonOrders); + } + + @override + Future 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 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 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 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'); + } + } +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/order.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/order.dart index c7c903f..886845c 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/domain/order.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/order.dart @@ -1,4 +1,5 @@ import 'order_item.dart'; +import 'order_source.dart'; import 'order_status.dart'; /// 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). final List 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 customFields; + const Order({ required this.id, required this.customerName, @@ -32,6 +43,9 @@ class Order { required this.shippingAddress, this.notes, this.orderNotes = const [], + this.paymentMethod, + this.source = OrderSource.online, + this.customFields = const {}, }); /// The total value of the order. @@ -52,6 +66,9 @@ class Order { String? shippingAddress, String? notes, List? orderNotes, + String? paymentMethod, + OrderSource? source, + Map? customFields, }) { return Order( id: id ?? this.id, @@ -64,6 +81,9 @@ class Order { shippingAddress: shippingAddress ?? this.shippingAddress, notes: notes ?? this.notes, orderNotes: orderNotes ?? this.orderNotes, + paymentMethod: paymentMethod ?? this.paymentMethod, + source: source ?? this.source, + customFields: customFields ?? this.customFields, ); } } diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/order_item.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/order_item.dart index 67609dd..c161815 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/domain/order_item.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/order_item.dart @@ -5,13 +5,47 @@ class OrderItem { final int quantity; 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 customizations; + const OrderItem({ required this.productName, required this.sku, required this.quantity, required this.unitPrice, + this.productId, + this.variationId, + this.customizations = const {}, }); /// The total price for this line item. 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? 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, + ); + } } diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/order_source.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/order_source.dart new file mode 100644 index 0000000..127911d --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/order_source.dart @@ -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, +} diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/order_status.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/order_status.dart index 4844ed7..a688912 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/domain/order_status.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/order_status.dart @@ -14,4 +14,13 @@ enum OrderStatus { /// Order was cancelled before fulfilment. cancelled, + + /// Order is on hold pending payment or operator action. + onHold, + + /// Order was refunded after fulfilment. + refunded, + + /// Order payment failed. + failed, } diff --git a/kell_creations_apps/packages/feature_orders/lib/src/domain/orders_repository.dart b/kell_creations_apps/packages/feature_orders/lib/src/domain/orders_repository.dart index dfe9166..40c8c22 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/domain/orders_repository.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/domain/orders_repository.dart @@ -7,6 +7,9 @@ abstract class OrdersRepository { /// Returns all orders. Future> getOrders(); + /// Returns a single order by [id], or `null` if not found. + Future getOrder(String id); + /// Updates the status of the order identified by [id]. /// /// Returns an [OrderActionResult] indicating success or failure. diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/order_status_chip.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/order_status_chip.dart index 102453e..b63bf02 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/order_status_chip.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/widgets/order_status_chip.dart @@ -28,6 +28,12 @@ class OrderStatusChip extends StatelessWidget { return ('Delivered', const Color(0xFFE8F5E9), KcColors.success); case OrderStatus.cancelled: 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); } } } diff --git a/kell_creations_apps/packages/feature_orders/pubspec.yaml b/kell_creations_apps/packages/feature_orders/pubspec.yaml index f82bf53..7bc6cc7 100644 --- a/kell_creations_apps/packages/feature_orders/pubspec.yaml +++ b/kell_creations_apps/packages/feature_orders/pubspec.yaml @@ -13,10 +13,13 @@ dependencies: sdk: flutter design_system: path: ../design_system + feature_wordpress: + path: ../feature_wordpress dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + http: ^1.4.0 flutter: diff --git a/kell_creations_apps/packages/feature_orders/test/widgets/orders_page_test.dart b/kell_creations_apps/packages/feature_orders/test/widgets/orders_page_test.dart index 37b963c..b6008c9 100644 --- a/kell_creations_apps/packages/feature_orders/test/widgets/orders_page_test.dart +++ b/kell_creations_apps/packages/feature_orders/test/widgets/orders_page_test.dart @@ -64,6 +64,9 @@ class _EmptyOrdersRepository implements OrdersRepository { @override Future> getOrders() async => []; + @override + Future getOrder(String id) async => null; + @override Future updateOrderStatus(String id, OrderStatus newStatus) async => const OrderActionResult.failure('Not implemented.'); diff --git a/kell_creations_apps/packages/feature_orders/test/woo_commerce_order_mapper_test.dart b/kell_creations_apps/packages/feature_orders/test/woo_commerce_order_mapper_test.dart new file mode 100644 index 0000000..9052f98 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/woo_commerce_order_mapper_test.dart @@ -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 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>? lineItems, + List>? 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.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); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_orders/test/woo_commerce_orders_repository_test.dart b/kell_creations_apps/packages/feature_orders/test/woo_commerce_orders_repository_test.dart new file mode 100644 index 0000000..1571053 --- /dev/null +++ b/kell_creations_apps/packages/feature_orders/test/woo_commerce_orders_repository_test.dart @@ -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 _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())); + }); + }); + + 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())); + }); + }); + + 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; + 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()), + ); + }); + }); + + 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()), + ); + }); + }); + + 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; + 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())); + }); + }); + + // ── 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); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart index 08ccd83..018254b 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart @@ -112,6 +112,173 @@ class WooCommerceApiClient implements ApiClient { 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>> 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>(); + } + + /// Fetches all orders by paginating through the WooCommerce API. + Future>> getAllOrders({int perPage = 100}) async { + final allOrders = >[]; + 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> 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) { + 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> 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) { + 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> createOrder(Map 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) { + 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> 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) { + 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. /// /// Only the fields present in [body] are changed on the remote product.