Add Kell Creations operations app foundation with feature slices and WooCommerce read-only integration #1
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ dependencies:
|
|||
sdk: flutter
|
||||
design_system:
|
||||
path: ../design_system
|
||||
http: ^1.4.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
|
|
@ -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 & 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(<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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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>()));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue