From 7ab526f083c06d6b478a22b649ce212cbb48ba5d Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sat, 4 Apr 2026 15:14:38 -0400 Subject: [PATCH] feat(wordpress): add real WooCommerce read-only product repository --- .../lib/composition/app_services.dart | 31 ++- .../apps/kell_web/pubspec.lock | 32 +++ .../lib/feature_wordpress.dart | 3 + .../lib/src/data/woo_commerce_api_client.dart | 107 +++++++++ .../src/data/wordpress_product_mapper.dart | 114 +++++++++ ...rdpress_product_publishing_repository.dart | 37 +++ .../packages/feature_wordpress/pubspec.yaml | 1 + .../test/wordpress_product_mapper_test.dart | 217 ++++++++++++++++++ ...ss_product_publishing_repository_test.dart | 157 +++++++++++++ 9 files changed, 697 insertions(+), 2 deletions(-) create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_mapper.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_publishing_repository.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/test/wordpress_product_mapper_test.dart create mode 100644 kell_creations_apps/packages/feature_wordpress/test/wordpress_product_publishing_repository_test.dart diff --git a/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart b/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart index 9e45b87..d0f9b34 100644 --- a/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart +++ b/kell_creations_apps/apps/kell_web/lib/composition/app_services.dart @@ -6,8 +6,9 @@ import 'package:feature_wordpress/feature_wordpress.dart'; /// Holds the concrete service implementations used by the app. /// /// The [AppServices.fake] factory wires up the in-memory fakes that live -/// inside each feature package. Swap this factory for a real one when -/// production backends are ready. +/// inside each feature package. The [AppServices.wordpress] factory wires up +/// a real WooCommerce-backed product repository while keeping other services +/// fake until their backends are ready. class AppServices { final InventoryRepository inventoryRepository; final OrdersRepository ordersRepository; @@ -30,4 +31,30 @@ class AppServices { productPublishingRepository: FakeProductPublishingRepository(), ); } + + /// Creates an [AppServices] with a real WooCommerce-backed product + /// repository. Other repositories remain fake until their backends are + /// ready. + /// + /// [siteUrl] – the WordPress site URL (e.g. `https://store.kellcreations.com`). + /// [consumerKey] – WooCommerce REST API consumer key. + /// [consumerSecret] – WooCommerce REST API consumer secret. + factory AppServices.wordpress({ + required String siteUrl, + required String consumerKey, + required String consumerSecret, + }) { + final apiClient = WooCommerceApiClient( + siteUrl: siteUrl, + consumerKey: consumerKey, + consumerSecret: consumerSecret, + ); + + return AppServices( + inventoryRepository: FakeInventoryRepository(), + ordersRepository: FakeOrdersRepository(), + policyRepository: FakePolicyRepository(), + productPublishingRepository: WordPressProductPublishingRepository(apiClient: apiClient), + ); + } } diff --git a/kell_creations_apps/apps/kell_web/pubspec.lock b/kell_creations_apps/apps/kell_web/pubspec.lock index 6b87a10..79fa06e 100644 --- a/kell_creations_apps/apps/kell_web/pubspec.lock +++ b/kell_creations_apps/apps/kell_web/pubspec.lock @@ -117,6 +117,22 @@ packages: description: flutter source: sdk version: "0.0.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" leak_tracker: dependency: transitive description: @@ -234,6 +250,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.10" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -250,6 +274,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" sdks: dart: ">=3.11.4 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart b/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart index 95b62b8..2595219 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/feature_wordpress.dart @@ -1,6 +1,9 @@ library; export 'src/data/fake_product_publishing_repository.dart'; +export 'src/data/woo_commerce_api_client.dart'; +export 'src/data/wordpress_product_mapper.dart'; +export 'src/data/wordpress_product_publishing_repository.dart'; export 'src/domain/product_draft.dart'; export 'src/domain/product_publishing_repository.dart'; export 'src/domain/publish_status.dart'; 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 new file mode 100644 index 0000000..caaa55a --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart @@ -0,0 +1,107 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// Lightweight HTTP client for the WooCommerce REST API v3. +/// +/// Handles authentication (Basic Auth over HTTPS) and pagination. +/// Only read-only product listing is implemented for now. +class WooCommerceApiClient { + /// Base URL of the WordPress site (e.g. `https://store.kellcreations.com`). + final String siteUrl; + + /// WooCommerce REST API consumer key. + final String consumerKey; + + /// WooCommerce REST API consumer secret. + final String consumerSecret; + + /// Optional [http.Client] for testing / injection. + final http.Client _httpClient; + + WooCommerceApiClient({ + required this.siteUrl, + required this.consumerKey, + required this.consumerSecret, + http.Client? httpClient, + }) : _httpClient = httpClient ?? http.Client(); + + /// The base endpoint for WooCommerce REST API v3. + String get _baseEndpoint => '$siteUrl/wp-json/wc/v3'; + + /// Fetches a paginated list of products from WooCommerce. + /// + /// [page] is 1-based. [perPage] defaults to 100 (WooCommerce max). + /// Returns the raw JSON list of product maps. + Future>> getProducts({int page = 1, int perPage = 100}) async { + final uri = Uri.parse( + '$_baseEndpoint/products', + ).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 products: ${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 products by paginating through the WooCommerce API. + /// + /// Keeps requesting pages until a page returns fewer items than [perPage]. + Future>> getAllProducts({int perPage = 100}) async { + final allProducts = >[]; + var page = 1; + + while (true) { + final batch = await getProducts(page: page, perPage: perPage); + allProducts.addAll(batch); + + if (batch.length < perPage) break; + page++; + } + + return allProducts; + } + + /// Basic Auth headers for WooCommerce REST API. + Map get _authHeaders { + final credentials = base64Encode(utf8.encode('$consumerKey:$consumerSecret')); + return {'Authorization': 'Basic $credentials', 'Content-Type': 'application/json'}; + } + + /// Releases the underlying HTTP client resources. + void dispose() { + _httpClient.close(); + } +} + +/// Exception thrown when the WooCommerce API returns an error. +class WooCommerceApiException implements Exception { + final int statusCode; + final String message; + final String body; + + const WooCommerceApiException({ + required this.statusCode, + required this.message, + required this.body, + }); + + @override + String toString() => 'WooCommerceApiException($statusCode): $message'; +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_mapper.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_mapper.dart new file mode 100644 index 0000000..372a386 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_mapper.dart @@ -0,0 +1,114 @@ +import '../domain/product_draft.dart'; +import '../domain/publish_status.dart'; + +/// Maps raw WooCommerce REST API v3 product JSON into [ProductDraft] domain +/// objects. +/// +/// Only the fields needed for read-only product retrieval are mapped. +/// See https://woocommerce.github.io/woocommerce-rest-api-docs/#product-properties +class WordPressProductMapper { + const WordPressProductMapper(); + + /// Converts a single WooCommerce product JSON map to a [ProductDraft]. + ProductDraft fromJson(Map json) { + return ProductDraft( + id: json['id']?.toString() ?? '', + name: (json['name'] as String?) ?? '', + description: _stripHtml((json['description'] as String?) ?? ''), + price: _parsePrice(json['price']), + sku: (json['sku'] as String?) ?? '', + category: _firstCategoryName(json['categories']), + imageUrl: _firstImageUrl(json['images']), + status: _mapStatus(json['status'] as String?), + lastModified: _parseDate(json['date_modified'] ?? json['date_created']), + ); + } + + /// Converts a list of WooCommerce product JSON maps to [ProductDraft]s. + List fromJsonList(List> jsonList) { + return jsonList.map(fromJson).toList(); + } + + // ── Private helpers ────────────────────────────────────────────────── + + /// Maps the WooCommerce `status` string to our [PublishStatus] enum. + /// + /// WooCommerce statuses: `publish`, `draft`, `pending`, `private`, `trash`. + static PublishStatus _mapStatus(String? status) { + switch (status) { + case 'publish': + return PublishStatus.published; + case 'draft': + return PublishStatus.draft; + case 'pending': + return PublishStatus.pendingReview; + case 'private': + case 'trash': + return PublishStatus.unpublished; + default: + return PublishStatus.draft; + } + } + + /// 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; + } + + /// Extracts the name of the first category, or `'Uncategorized'`. + /// + /// WooCommerce returns categories as: + /// ```json + /// [{"id": 1, "name": "Bowl Cozies", "slug": "bowl-cozies"}] + /// ``` + static String _firstCategoryName(dynamic categories) { + if (categories is List && categories.isNotEmpty) { + final first = categories.first; + if (first is Map) { + return (first['name'] as String?) ?? 'Uncategorized'; + } + } + return 'Uncategorized'; + } + + /// Extracts the `src` URL of the first product image, or empty string. + /// + /// WooCommerce returns images as: + /// ```json + /// [{"id": 1, "src": "https://...", "name": "image.jpg", ...}] + /// ``` + static String _firstImageUrl(dynamic images) { + if (images is List && images.isNotEmpty) { + final first = images.first; + if (first is Map) { + return (first['src'] as String?) ?? ''; + } + } + return ''; + } + + /// 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(); + } + + /// Strips basic HTML tags from a string. + /// + /// WooCommerce descriptions often contain `

`, `
`, etc. + static String _stripHtml(String html) { + return html + .replaceAll(RegExp(r'<[^>]*>'), '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .trim(); + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_publishing_repository.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_publishing_repository.dart new file mode 100644 index 0000000..094894a --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_publishing_repository.dart @@ -0,0 +1,37 @@ +import '../domain/product_draft.dart'; +import '../domain/product_publishing_repository.dart'; +import 'woo_commerce_api_client.dart'; +import 'wordpress_product_mapper.dart'; + +/// Real [ProductPublishingRepository] backed by the WooCommerce REST API. +/// +/// Currently implements **read-only** product retrieval. The [publishDraft] +/// method throws [UnimplementedError] — full publishing support will be +/// added in a future iteration. +class WordPressProductPublishingRepository implements ProductPublishingRepository { + final WooCommerceApiClient _apiClient; + final WordPressProductMapper _mapper; + + WordPressProductPublishingRepository({ + required WooCommerceApiClient apiClient, + WordPressProductMapper mapper = const WordPressProductMapper(), + }) : _apiClient = apiClient, + _mapper = mapper; + + @override + Future> getProductDrafts() async { + final jsonProducts = await _apiClient.getAllProducts(); + return _mapper.fromJsonList(jsonProducts); + } + + @override + Future publishDraft(String id) { + // Publishing is not yet implemented for the real backend. + // This will be added in a future iteration alongside media upload + // and order sync. + throw UnimplementedError( + 'WordPressProductPublishingRepository.publishDraft is not yet implemented. ' + 'Use FakeProductPublishingRepository for development.', + ); + } +} diff --git a/kell_creations_apps/packages/feature_wordpress/pubspec.yaml b/kell_creations_apps/packages/feature_wordpress/pubspec.yaml index 35a031a..526e61a 100644 --- a/kell_creations_apps/packages/feature_wordpress/pubspec.yaml +++ b/kell_creations_apps/packages/feature_wordpress/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: sdk: flutter design_system: path: ../design_system + http: ^1.4.0 dev_dependencies: flutter_test: diff --git a/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_mapper_test.dart b/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_mapper_test.dart new file mode 100644 index 0000000..62b97c9 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_mapper_test.dart @@ -0,0 +1,217 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:feature_wordpress/feature_wordpress.dart'; + +void main() { + const mapper = WordPressProductMapper(); + + /// A minimal WooCommerce product JSON payload with all fields populated. + Map buildProductJson({ + int id = 42, + String name = 'Floral Bowl Cozy', + String description = '

A beautiful bowl cozy.

', + String price = '12.99', + String sku = 'BC-FLR-001', + String status = 'publish', + String dateModified = '2026-03-28T10:30:00', + String? dateCreated = '2026-03-20T08:00:00', + List>? categories, + List>? images, + }) { + return { + 'id': id, + 'name': name, + 'description': description, + 'price': price, + 'sku': sku, + 'status': status, + 'date_modified': dateModified, + 'date_created': dateCreated, + 'categories': + categories ?? + [ + {'id': 1, 'name': 'Bowl Cozies', 'slug': 'bowl-cozies'}, + ], + 'images': + images ?? + [ + {'id': 10, 'src': 'https://example.com/image.jpg', 'name': 'image.jpg'}, + ], + }; + } + + group('WordPressProductMapper.fromJson', () { + test('maps id as string', () { + final draft = mapper.fromJson(buildProductJson(id: 99)); + expect(draft.id, '99'); + }); + + test('maps name', () { + final draft = mapper.fromJson(buildProductJson(name: 'Test Product')); + expect(draft.name, 'Test Product'); + }); + + test('strips HTML from description', () { + final draft = mapper.fromJson( + buildProductJson(description: '

Hello world

'), + ); + expect(draft.description, 'Hello world'); + }); + + test('decodes HTML entities in description', () { + final draft = mapper.fromJson(buildProductJson(description: 'Tom & Jerry <3>')); + expect(draft.description, 'Tom & Jerry <3>'); + }); + + test('maps price from string', () { + final draft = mapper.fromJson(buildProductJson(price: '19.50')); + expect(draft.price, 19.50); + }); + + test('maps price from null to 0.0', () { + final json = buildProductJson(); + json['price'] = null; + final draft = mapper.fromJson(json); + expect(draft.price, 0.0); + }); + + test('maps sku', () { + final draft = mapper.fromJson(buildProductJson(sku: 'NL-OCN-003')); + expect(draft.sku, 'NL-OCN-003'); + }); + + test('maps first category name', () { + final draft = mapper.fromJson( + buildProductJson( + categories: [ + {'id': 1, 'name': 'Coasters', 'slug': 'coasters'}, + {'id': 2, 'name': 'Kitchen', 'slug': 'kitchen'}, + ], + ), + ); + expect(draft.category, 'Coasters'); + }); + + test('defaults to Uncategorized when categories is empty', () { + final draft = mapper.fromJson(buildProductJson(categories: [])); + expect(draft.category, 'Uncategorized'); + }); + + test('defaults to Uncategorized when categories is null', () { + final json = buildProductJson(); + json['categories'] = null; + final draft = mapper.fromJson(json); + expect(draft.category, 'Uncategorized'); + }); + + test('maps first image URL', () { + final draft = mapper.fromJson( + buildProductJson( + images: [ + {'id': 1, 'src': 'https://example.com/a.jpg'}, + {'id': 2, 'src': 'https://example.com/b.jpg'}, + ], + ), + ); + expect(draft.imageUrl, 'https://example.com/a.jpg'); + }); + + test('defaults to empty string when images is empty', () { + final draft = mapper.fromJson(buildProductJson(images: [])); + expect(draft.imageUrl, ''); + }); + + test('maps date_modified', () { + final draft = mapper.fromJson(buildProductJson(dateModified: '2026-04-01T14:00:00')); + expect(draft.lastModified, DateTime(2026, 4, 1, 14, 0, 0)); + }); + + test('falls back to date_created when date_modified is null', () { + final json = buildProductJson(dateCreated: '2026-03-15T09:00:00'); + json['date_modified'] = null; + final draft = mapper.fromJson(json); + expect(draft.lastModified, DateTime(2026, 3, 15, 9, 0, 0)); + }); + + group('status mapping', () { + test('publish -> PublishStatus.published', () { + final draft = mapper.fromJson(buildProductJson(status: 'publish')); + expect(draft.status, PublishStatus.published); + }); + + test('draft -> PublishStatus.draft', () { + final draft = mapper.fromJson(buildProductJson(status: 'draft')); + expect(draft.status, PublishStatus.draft); + }); + + test('pending -> PublishStatus.pendingReview', () { + final draft = mapper.fromJson(buildProductJson(status: 'pending')); + expect(draft.status, PublishStatus.pendingReview); + }); + + test('private -> PublishStatus.unpublished', () { + final draft = mapper.fromJson(buildProductJson(status: 'private')); + expect(draft.status, PublishStatus.unpublished); + }); + + test('trash -> PublishStatus.unpublished', () { + final draft = mapper.fromJson(buildProductJson(status: 'trash')); + expect(draft.status, PublishStatus.unpublished); + }); + + test('unknown status defaults to PublishStatus.draft', () { + final draft = mapper.fromJson(buildProductJson(status: 'future')); + expect(draft.status, PublishStatus.draft); + }); + }); + }); + + group('WordPressProductMapper.fromJsonList', () { + test('maps a list of JSON objects', () { + final jsonList = [ + buildProductJson(id: 1, name: 'Product A'), + buildProductJson(id: 2, name: 'Product B'), + buildProductJson(id: 3, name: 'Product C'), + ]; + + final drafts = mapper.fromJsonList(jsonList); + expect(drafts.length, 3); + expect(drafts[0].name, 'Product A'); + expect(drafts[1].name, 'Product B'); + expect(drafts[2].name, 'Product C'); + }); + + test('returns empty list for empty input', () { + final drafts = mapper.fromJsonList([]); + expect(drafts, isEmpty); + }); + }); + + group('Edge cases', () { + test('handles completely empty JSON map gracefully', () { + final draft = mapper.fromJson({}); + expect(draft.id, ''); + expect(draft.name, ''); + expect(draft.description, ''); + expect(draft.price, 0.0); + expect(draft.sku, ''); + expect(draft.category, 'Uncategorized'); + expect(draft.imageUrl, ''); + expect(draft.status, PublishStatus.draft); + }); + + test('handles numeric price value', () { + final json = buildProductJson(); + json['price'] = 25.0; + final draft = mapper.fromJson(json); + expect(draft.price, 25.0); + }); + + test('handles integer price value', () { + final json = buildProductJson(); + json['price'] = 10; + final draft = mapper.fromJson(json); + expect(draft.price, 10.0); + }); + }); +} diff --git a/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_publishing_repository_test.dart b/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_publishing_repository_test.dart new file mode 100644 index 0000000..fc0e378 --- /dev/null +++ b/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_publishing_repository_test.dart @@ -0,0 +1,157 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:feature_wordpress/feature_wordpress.dart'; + +void main() { + /// Builds a minimal WooCommerce product JSON list response. + List> buildProductsJson(int count) { + return List.generate(count, (i) { + return { + 'id': i + 1, + 'name': 'Product ${i + 1}', + 'description': '

Description ${i + 1}

', + 'price': '${(i + 1) * 10}.99', + 'sku': 'SKU-${i + 1}', + 'status': 'publish', + 'date_modified': '2026-04-01T10:00:00', + 'date_created': '2026-03-01T08:00:00', + 'categories': [ + {'id': 1, 'name': 'Test Category', 'slug': 'test-category'}, + ], + 'images': [ + {'id': 1, 'src': 'https://example.com/img${i + 1}.jpg'}, + ], + }; + }); + } + + group('WooCommerceApiClient', () { + test('getProducts returns parsed product list on 200', () async { + final mockClient = MockClient((request) async { + expect(request.url.path, '/wp-json/wc/v3/products'); + expect(request.url.queryParameters['page'], '1'); + expect(request.url.queryParameters['per_page'], '100'); + expect(request.headers['Authorization'], startsWith('Basic ')); + + return http.Response(jsonEncode(buildProductsJson(3)), 200); + }); + + final apiClient = WooCommerceApiClient( + siteUrl: 'https://store.example.com', + consumerKey: 'ck_test', + consumerSecret: 'cs_test', + httpClient: mockClient, + ); + + final products = await apiClient.getProducts(); + expect(products.length, 3); + expect(products[0]['name'], 'Product 1'); + expect(products[2]['name'], 'Product 3'); + }); + + test('getProducts throws WooCommerceApiException on non-200', () async { + final mockClient = MockClient((request) async { + return http.Response('{"code":"unauthorized"}', 401); + }); + + final apiClient = WooCommerceApiClient( + siteUrl: 'https://store.example.com', + consumerKey: 'ck_bad', + consumerSecret: 'cs_bad', + httpClient: mockClient, + ); + + expect(() => apiClient.getProducts(), throwsA(isA())); + }); + + test('getAllProducts paginates until a short page', () async { + var requestCount = 0; + final mockClient = MockClient((request) async { + requestCount++; + final page = int.parse(request.url.queryParameters['page']!); + // Page 1 returns 2 items (perPage=2), page 2 returns 1 item (short). + final count = page == 1 ? 2 : 1; + return http.Response(jsonEncode(buildProductsJson(count)), 200); + }); + + final apiClient = WooCommerceApiClient( + siteUrl: 'https://store.example.com', + consumerKey: 'ck_test', + consumerSecret: 'cs_test', + httpClient: mockClient, + ); + + final products = await apiClient.getAllProducts(perPage: 2); + expect(products.length, 3); + expect(requestCount, 2); + }); + }); + + group('WordPressProductPublishingRepository', () { + test('getProductDrafts returns mapped ProductDraft list', () async { + final mockClient = MockClient((request) async { + return http.Response(jsonEncode(buildProductsJson(2)), 200); + }); + + final apiClient = WooCommerceApiClient( + siteUrl: 'https://store.example.com', + consumerKey: 'ck_test', + consumerSecret: 'cs_test', + httpClient: mockClient, + ); + + final repository = WordPressProductPublishingRepository(apiClient: apiClient); + + final drafts = await repository.getProductDrafts(); + expect(drafts.length, 2); + expect(drafts[0].id, '1'); + expect(drafts[0].name, 'Product 1'); + expect(drafts[0].description, 'Description 1'); + expect(drafts[0].price, 10.99); + expect(drafts[0].sku, 'SKU-1'); + expect(drafts[0].category, 'Test Category'); + expect(drafts[0].imageUrl, 'https://example.com/img1.jpg'); + expect(drafts[0].status, PublishStatus.published); + expect(drafts[1].id, '2'); + expect(drafts[1].name, 'Product 2'); + }); + + test('publishDraft throws UnimplementedError', () { + final mockClient = MockClient((request) async { + return http.Response('[]', 200); + }); + + final apiClient = WooCommerceApiClient( + siteUrl: 'https://store.example.com', + consumerKey: 'ck_test', + consumerSecret: 'cs_test', + httpClient: mockClient, + ); + + final repository = WordPressProductPublishingRepository(apiClient: apiClient); + + expect(() => repository.publishDraft('1'), throwsA(isA())); + }); + + test('getProductDrafts propagates API errors', () async { + final mockClient = MockClient((request) async { + return http.Response('Server Error', 500); + }); + + final apiClient = WooCommerceApiClient( + siteUrl: 'https://store.example.com', + consumerKey: 'ck_test', + consumerSecret: 'cs_test', + httpClient: mockClient, + ); + + final repository = WordPressProductPublishingRepository(apiClient: apiClient); + + expect(() => repository.getProductDrafts(), throwsA(isA())); + }); + }); +}