feat(wordpress): add real WooCommerce read-only product repository
Validate Docs / validate-docs (push) Successful in 1m9s Details

This commit is contained in:
Mike Kell 2026-04-04 15:14:38 -04:00
parent 23ea1bebe1
commit 7ab526f083
9 changed files with 697 additions and 2 deletions

View File

@ -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),
);
}
}

View File

@ -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"

View File

@ -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';

View File

@ -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<List<Map<String, dynamic>>> 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<Map<String, dynamic>>();
}
/// Fetches all products by paginating through the WooCommerce API.
///
/// Keeps requesting pages until a page returns fewer items than [perPage].
Future<List<Map<String, dynamic>>> getAllProducts({int perPage = 100}) async {
final allProducts = <Map<String, dynamic>>[];
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<String, String> 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';
}

View File

@ -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<String, dynamic> 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<ProductDraft> fromJsonList(List<Map<String, dynamic>> 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<String, dynamic>) {
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<String, dynamic>) {
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 `<p>`, `<br>`, etc.
static String _stripHtml(String html) {
return html
.replaceAll(RegExp(r'<[^>]*>'), '')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.trim();
}
}

View File

@ -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<List<ProductDraft>> getProductDrafts() async {
final jsonProducts = await _apiClient.getAllProducts();
return _mapper.fromJsonList(jsonProducts);
}
@override
Future<ProductDraft> 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.',
);
}
}

View File

@ -13,6 +13,7 @@ dependencies:
sdk: flutter
design_system:
path: ../design_system
http: ^1.4.0
dev_dependencies:
flutter_test:

View File

@ -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<String, dynamic> buildProductJson({
int id = 42,
String name = 'Floral Bowl Cozy',
String description = '<p>A beautiful bowl cozy.</p>',
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<Map<String, dynamic>>? categories,
List<Map<String, dynamic>>? 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: '<p>Hello <strong>world</strong></p>'),
);
expect(draft.description, 'Hello world');
});
test('decodes HTML entities in description', () {
final draft = mapper.fromJson(buildProductJson(description: 'Tom &amp; Jerry &lt;3&gt;'));
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(<String, dynamic>{});
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);
});
});
}

View File

@ -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<Map<String, dynamic>> buildProductsJson(int count) {
return List.generate(count, (i) {
return {
'id': i + 1,
'name': 'Product ${i + 1}',
'description': '<p>Description ${i + 1}</p>',
'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<WooCommerceApiException>()));
});
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<UnimplementedError>()));
});
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<WooCommerceApiException>()));
});
});
}