Compare commits

..

No commits in common. "204bd3b118c6e5d36807d22deda399a9d7cc40ca" and "9564329283bad6eb2ab0f4e5928f8f2fa9a47fbd" have entirely different histories.

17 changed files with 16 additions and 1900 deletions

View File

@ -2,9 +2,9 @@
## Current status ## Current status
- main baseline updated through: feat/wc-catalog-expansion (Stage 9C complete) - main baseline updated through: feat/wc-inventory-sync (Stage 9B complete)
- next branch: feat/wc-catalog-expansion-ui (Stage 9D — product creation UI, image picker, category management page) - next branch: feat/wc-catalog-expansion (Stage 9C)
- current stage: Stage 9C complete — WooCommerce catalog expansion (product create/delete, category CRUD, image management) - current stage: Stage 9B complete — WooCommerce stock/inventory sync activated
## Slice tracker ## Slice tracker
@ -506,29 +506,3 @@
- tests: passed (132/132 feature_inventory, 24/24 kell_web, 319/319 feature_wordpress — all passing) - tests: passed (132/132 feature_inventory, 24/24 kell_web, 319/319 feature_wordpress — all passing)
- analyze: passed (dart analyze — no issues found) - analyze: passed (dart analyze — no issues found)
- brief updated: yes - brief updated: yes
### feat/wc-catalog-expansion
- status: merged to main
- date: 2026-07-11
- inspection: complete
- implementation: complete
- files changed:
- `feature_wordpress/lib/src/domain/product_category.dart` — new `ProductCategory` value object with `id`, `name`, `slug`, `parentId`, `count`; `copyWith()`, `==`/`hashCode`, `toString()`
- `feature_wordpress/lib/src/domain/product_image.dart` — new `ProductImage` value object with `id`, `url`, `alt`, `position`; `copyWith()`, `==`/`hashCode`
- `feature_wordpress/lib/src/domain/product_publishing_repository.dart` — added `createProduct()`, `deleteProduct()`, `updateProductImages()`, `getProductImages()`, `getCategories()`, `createCategory()`, `updateCategory()`, `deleteCategory()` to contract
- `feature_wordpress/lib/src/data/fake_product_publishing_repository.dart` — implemented all 8 new repository methods; added seed categories (Jewelry, Accessories, Home Decor, Kitchen Accessories); `createProduct()` resolves category name from id; `createCategory()` auto-generates slug from name; `updateCategory()` and `deleteCategory()` throw `StateError` for unknown ids
- `feature_wordpress/lib/src/data/woo_commerce_api_client.dart` — added `createProduct()`, `deleteProduct()`, `getCategories()`, `createCategory()`, `updateCategory()`, `deleteCategory()`, `getProductImages()`, `setProductImages()` methods; `getCategories()` paginates with `per_page=100`
- `feature_wordpress/lib/src/data/wordpress_product_publishing_repository.dart` — implemented all 8 new repository methods delegating to `WooCommerceApiClient`; maps JSON ↔ domain models
- `feature_wordpress/lib/src/application/create_product.dart` — new `CreateProduct` use case
- `feature_wordpress/lib/src/application/delete_product.dart` — new `DeleteProduct` use case
- `feature_wordpress/lib/src/application/get_categories.dart` — new `GetCategories` use case
- `feature_wordpress/lib/src/application/create_category.dart` — new `CreateCategory` use case
- `feature_wordpress/lib/src/application/update_product_images.dart` — new `UpdateProductImages` use case
- `feature_wordpress/lib/feature_wordpress.dart` — exported all new domain models and use cases
- `feature_wordpress/test/wc_catalog_expansion_test.dart` — 69 new tests covering `ProductCategory`, `ProductImage`, `FakeProductPublishingRepository` (all new methods), `WooCommerceApiClient` (all new methods), `WordPressProductPublishingRepository` (all new methods), and all 5 use cases
- `kell_web/test/dashboard/application/dashboard_controller_test.dart` — updated `_StubProductPublishingRepository` to implement expanded `ProductPublishingRepository` contract
- `kell_creations_apps/packages/feature_wordpress/test/widgets/product_publishing_page_test.dart` — updated `_FakeProductPublishingRepository` stub to implement expanded contract
- tests: passed (388/388 feature_wordpress, 24/24 kell_web — all passing)
- analyze: passed (dart analyze — no issues found)
- brief updated: yes

View File

@ -594,7 +594,7 @@ Enable bidirectional stock quantity synchronization between the app's inventory
- Update `FakeInventoryRepository` with matching write methods - Update `FakeInventoryRepository` with matching write methods
- Tests for sync service, repository, and stock update flows - Tests for sync service, repository, and stock update flows
#### Stage 9C — WooCommerce catalog expansion ✅ COMPLETE #### Stage 9C — WooCommerce catalog expansion
##### Goal ##### Goal
@ -602,25 +602,16 @@ Add product creation, image management, and category CRUD to complete full catal
##### Requirements ##### Requirements
- ✅ Add `createProduct(data)`, `deleteProduct(id)` to `WooCommerceApiClient` - Add `createProduct(data)`, `deleteProduct(id)` to `WooCommerceApiClient`
- ✅ Add `getCategories()`, `createCategory()`, `updateCategory()`, `deleteCategory()` to `WooCommerceApiClient` - Add `getCategories()`, `createCategory()`, `updateCategory()`, `deleteCategory()` to `WooCommerceApiClient`
- ✅ Add `getProductImages(productId)`, `setProductImages(productId, mediaIds)` to `WooCommerceApiClient` - Add `uploadMedia(file)`, `getMedia(productId)`, `setProductImages(productId, mediaIds)` to `WooCommerceApiClient`
- ✅ Extend `ProductPublishingRepository`: `createProduct()`, `deleteProduct()`, `updateProductImages()`, `getCategories()`, `createCategory()`, `updateCategory()`, `deleteCategory()`, `getProductImages()` - Extend `ProductPublishingRepository`: `createProduct()`, `deleteProduct()`, `updateProductImages()`, `getCategories()`, `createCategory()`
- ✅ Add `ProductCategory` domain model (id, name, slug, parentId, count) with `copyWith`, equality, `toString` - Add `ProductCategory` domain model (id, name, slug, parent, count)
- ✅ Add `ProductImage` domain model (id, url, alt, position) with `copyWith`, equality - Add `ProductImage` domain model (id, url, alt, position)
- ✅ Implement all new methods in `FakeProductPublishingRepository` (with seed categories) - Add product creation UI — form with name, description, price, category, SKU, images
- ✅ Implement all new methods in `WordPressProductPublishingRepository` - Add image picker/upload UI for mobile and web
- ✅ Add use cases: `CreateProduct`, `DeleteProduct`, `GetCategories`, `CreateCategory`, `UpdateProductImages` - Add category management page
- ✅ Update barrel exports in `feature_wordpress.dart` - Tests for all new repository methods, use cases, and UI
- ✅ Update test stubs in `dashboard_controller_test.dart` and `product_publishing_page_test.dart`
- ✅ 69 new tests in `wc_catalog_expansion_test.dart` covering domain models, fake repo, API client, real repo, and use cases
- ⏳ Product creation UI — form with name, description, price, category, SKU, images (deferred to Stage 9D)
- ⏳ Image picker/upload UI for mobile and web (deferred to Stage 9D)
- ⏳ Category management page (deferred to Stage 9D)
##### Branch
`feat/wc-catalog-expansion` — merged to `main`
--- ---

View File

@ -68,41 +68,6 @@ class _StubProductPublishingRepository implements ProductPublishingRepository {
@override @override
Future<ProductDraft> updateProductCategory(String id, String category) => Future<ProductDraft> updateProductCategory(String id, String category) =>
throw UnimplementedError(); throw UnimplementedError();
// Stage 9C additions
@override
Future<ProductDraft> createProduct({
required String name,
required double price,
String description = '',
String sku = '',
int? categoryId,
PublishStatus status = PublishStatus.draft,
}) => throw UnimplementedError();
@override
Future<void> deleteProduct(String id, {bool force = false}) => throw UnimplementedError();
@override
Future<ProductDraft> updateProductImages(String id, List<int> imageIds) =>
throw UnimplementedError();
@override
Future<List<ProductCategory>> getCategories() => throw UnimplementedError();
@override
Future<ProductCategory> createCategory({required String name, int parentId = 0}) =>
throw UnimplementedError();
@override
Future<ProductCategory> updateCategory(int categoryId, {String? name, int? parentId}) =>
throw UnimplementedError();
@override
Future<void> deleteCategory(int categoryId, {bool force = true}) => throw UnimplementedError();
@override
Future<List<ProductImage>> getProductImages(String id) => throw UnimplementedError();
} }
class _StubOrdersRepository implements OrdersRepository { class _StubOrdersRepository implements OrdersRepository {

View File

@ -7,23 +7,16 @@ export 'src/data/wordpress_product_mapper.dart';
export 'src/data/wordpress_product_publishing_repository.dart'; export 'src/data/wordpress_product_publishing_repository.dart';
// Domain // Domain
export 'src/domain/product_category.dart';
export 'src/domain/product_draft.dart'; export 'src/domain/product_draft.dart';
export 'src/domain/product_image.dart';
export 'src/domain/product_publishing_repository.dart'; export 'src/domain/product_publishing_repository.dart';
export 'src/domain/publish_status.dart'; export 'src/domain/publish_status.dart';
// Application // Application
export 'src/application/create_category.dart';
export 'src/application/create_product.dart';
export 'src/application/delete_product.dart';
export 'src/application/get_categories.dart';
export 'src/application/get_product_drafts.dart'; export 'src/application/get_product_drafts.dart';
export 'src/application/product_publishing_controller.dart'; export 'src/application/product_publishing_controller.dart';
export 'src/application/publish_product.dart'; export 'src/application/publish_product.dart';
export 'src/application/update_product_category.dart'; export 'src/application/update_product_category.dart';
export 'src/application/update_product_description.dart'; export 'src/application/update_product_description.dart';
export 'src/application/update_product_images.dart';
export 'src/application/update_product_name.dart'; export 'src/application/update_product_name.dart';
export 'src/application/update_product_price.dart'; export 'src/application/update_product_price.dart';
export 'src/application/update_product_status.dart'; export 'src/application/update_product_status.dart';

View File

@ -1,16 +0,0 @@
import '../domain/product_category.dart';
import '../domain/product_publishing_repository.dart';
/// Use case: create a new product category.
///
/// Delegates to [ProductPublishingRepository.createCategory] and returns the
/// created [ProductCategory].
class CreateCategory {
final ProductPublishingRepository _repository;
const CreateCategory(this._repository);
Future<ProductCategory> call({required String name, int parentId = 0}) {
return _repository.createCategory(name: name, parentId: parentId);
}
}

View File

@ -1,31 +0,0 @@
import '../domain/product_draft.dart';
import '../domain/product_publishing_repository.dart';
import '../domain/publish_status.dart';
/// Use case: create a new product in the catalog.
///
/// Delegates to [ProductPublishingRepository.createProduct] and returns the
/// created [ProductDraft].
class CreateProduct {
final ProductPublishingRepository _repository;
const CreateProduct(this._repository);
Future<ProductDraft> call({
required String name,
required double price,
String description = '',
String sku = '',
int? categoryId,
PublishStatus status = PublishStatus.draft,
}) {
return _repository.createProduct(
name: name,
price: price,
description: description,
sku: sku,
categoryId: categoryId,
status: status,
);
}
}

View File

@ -1,16 +0,0 @@
import '../domain/product_publishing_repository.dart';
/// Use case: delete a product from the catalog.
///
/// Delegates to [ProductPublishingRepository.deleteProduct].
/// When [force] is `true` the product is permanently deleted; otherwise it is
/// moved to trash (WooCommerce default).
class DeleteProduct {
final ProductPublishingRepository _repository;
const DeleteProduct(this._repository);
Future<void> call(String id, {bool force = false}) {
return _repository.deleteProduct(id, force: force);
}
}

View File

@ -1,16 +0,0 @@
import '../domain/product_category.dart';
import '../domain/product_publishing_repository.dart';
/// Use case: retrieve all product categories from the store.
///
/// Delegates to [ProductPublishingRepository.getCategories] and returns the
/// list of [ProductCategory] objects.
class GetCategories {
final ProductPublishingRepository _repository;
const GetCategories(this._repository);
Future<List<ProductCategory>> call() {
return _repository.getCategories();
}
}

View File

@ -1,20 +0,0 @@
import '../domain/product_draft.dart';
import '../domain/product_publishing_repository.dart';
/// Use case: update the images attached to a product.
///
/// [imageIds] is an ordered list of WooCommerce media attachment IDs.
/// The first ID becomes the featured image (position 0).
/// Pass an empty list to remove all images.
///
/// Delegates to [ProductPublishingRepository.updateProductImages] and returns
/// the updated [ProductDraft] reflecting the new primary image URL.
class UpdateProductImages {
final ProductPublishingRepository _repository;
const UpdateProductImages(this._repository);
Future<ProductDraft> call(String id, List<int> imageIds) {
return _repository.updateProductImages(id, imageIds);
}
}

View File

@ -1,6 +1,4 @@
import '../domain/product_category.dart';
import '../domain/product_draft.dart'; import '../domain/product_draft.dart';
import '../domain/product_image.dart';
import '../domain/product_publishing_repository.dart'; import '../domain/product_publishing_repository.dart';
import '../domain/publish_status.dart'; import '../domain/publish_status.dart';
@ -88,29 +86,6 @@ class FakeProductPublishingRepository implements ProductPublishingRepository {
), ),
]; ];
/// Seed categories matching the product data above.
final List<ProductCategory> _categories = [
ProductCategory(id: 1, name: 'Bowl Cozies', slug: 'bowl-cozies', parentId: 0, count: 1),
ProductCategory(id: 2, name: 'Coasters', slug: 'coasters', parentId: 0, count: 2),
ProductCategory(id: 3, name: 'Nightlights', slug: 'nightlights', parentId: 0, count: 1),
ProductCategory(
id: 4,
name: 'Kitchen Accessories',
slug: 'kitchen-accessories',
parentId: 0,
count: 2,
),
];
/// Fake image store: productId list of images.
final Map<String, List<ProductImage>> _productImages = {};
/// Auto-increment counter for new product IDs.
int _nextId = 7;
/// Auto-increment counter for new category IDs.
int _nextCategoryId = 5;
@override @override
Future<List<ProductDraft>> getProductDrafts() async { Future<List<ProductDraft>> getProductDrafts() async {
// Simulate network latency. // Simulate network latency.
@ -210,142 +185,4 @@ class FakeProductPublishingRepository implements ProductPublishingRepository {
_drafts[index] = updated; _drafts[index] = updated;
return updated; return updated;
} }
// Stage 9C: Catalog expansion
@override
Future<ProductDraft> createProduct({
required String name,
required double price,
String description = '',
String sku = '',
int? categoryId,
PublishStatus status = PublishStatus.draft,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 400));
// Resolve category name from ID if provided.
String categoryName = 'Uncategorized';
if (categoryId != null) {
final cat = _categories.where((c) => c.id == categoryId).firstOrNull;
if (cat != null) categoryName = cat.name;
}
final newDraft = ProductDraft(
id: (_nextId++).toString(),
name: name,
description: description,
price: price,
sku: sku,
category: categoryName,
imageUrl: '',
status: status,
lastModified: DateTime.now(),
);
_drafts.add(newDraft);
return newDraft;
}
@override
Future<void> deleteProduct(String id, {bool force = false}) async {
await Future<void>.delayed(const Duration(milliseconds: 400));
final index = _drafts.indexWhere((d) => d.id == id);
if (index == -1) {
throw StateError('Draft with id $id not found');
}
_drafts.removeAt(index);
_productImages.remove(id);
}
@override
Future<ProductDraft> updateProductImages(String id, List<int> imageIds) async {
await Future<void>.delayed(const Duration(milliseconds: 400));
final index = _drafts.indexWhere((d) => d.id == id);
if (index == -1) {
throw StateError('Draft with id $id not found');
}
// Build fake image objects from the provided IDs.
final images = imageIds.asMap().entries.map((entry) {
return ProductImage(
id: entry.value,
url: 'https://fake.example.com/media/${entry.value}.jpg',
alt: '',
position: entry.key,
);
}).toList();
_productImages[id] = images;
// Update the draft's primary imageUrl from the first image.
final primaryUrl = images.isNotEmpty ? images.first.url : '';
final updated = _drafts[index].copyWith(imageUrl: primaryUrl, lastModified: DateTime.now());
_drafts[index] = updated;
return updated;
}
@override
Future<List<ProductCategory>> getCategories() async {
await Future<void>.delayed(const Duration(milliseconds: 300));
return List.unmodifiable(_categories);
}
@override
Future<ProductCategory> createCategory({required String name, int parentId = 0}) async {
await Future<void>.delayed(const Duration(milliseconds: 400));
final slug = name.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '-');
final newCategory = ProductCategory(
id: _nextCategoryId++,
name: name,
slug: slug,
parentId: parentId,
count: 0,
);
_categories.add(newCategory);
return newCategory;
}
@override
Future<ProductCategory> updateCategory(int categoryId, {String? name, int? parentId}) async {
await Future<void>.delayed(const Duration(milliseconds: 400));
final index = _categories.indexWhere((c) => c.id == categoryId);
if (index == -1) {
throw StateError('Category with id $categoryId not found');
}
final original = _categories[index];
final updatedName = name ?? original.name;
final updatedSlug = name != null
? name.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '-')
: original.slug;
final updated = original.copyWith(name: updatedName, slug: updatedSlug, parentId: parentId);
_categories[index] = updated;
return updated;
}
@override
Future<void> deleteCategory(int categoryId, {bool force = true}) async {
await Future<void>.delayed(const Duration(milliseconds: 400));
final index = _categories.indexWhere((c) => c.id == categoryId);
if (index == -1) {
throw StateError('Category with id $categoryId not found');
}
_categories.removeAt(index);
}
@override
Future<List<ProductImage>> getProductImages(String id) async {
await Future<void>.delayed(const Duration(milliseconds: 300));
return List.unmodifiable(_productImages[id] ?? []);
}
} }

View File

@ -436,265 +436,6 @@ class WooCommerceApiClient implements ApiClient {
return decoded; return decoded;
} }
/// Creates a new product in WooCommerce.
///
/// [body] should contain the WooCommerce product fields (name, regular_price,
/// description, sku, status, categories, etc.).
/// Returns the created product JSON (HTTP 201).
Future<Map<String, dynamic>> createProduct(Map<String, dynamic> body) async {
final uri = Uri.parse('$_baseEndpoint/products');
final response = await _httpClient.post(uri, headers: _authHeaders, body: jsonEncode(body));
if (response.statusCode != 201) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Failed to create product: ${response.reasonPhrase}',
body: response.body,
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Unexpected response format: expected a JSON object',
body: response.body,
);
}
return decoded;
}
/// Deletes a WooCommerce product by [productId].
///
/// When [force] is `true` the product is permanently deleted; otherwise it
/// is moved to trash (WooCommerce default).
/// Returns the deleted product JSON that WooCommerce echoes back.
Future<Map<String, dynamic>> deleteProduct(String productId, {bool force = false}) async {
final uri = Uri.parse(
'$_baseEndpoint/products/$productId',
).replace(queryParameters: {'force': force.toString()});
final response = await _httpClient.delete(uri, headers: _authHeaders);
if (response.statusCode != 200) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Failed to delete product $productId: ${response.reasonPhrase}',
body: response.body,
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Unexpected response format: expected a JSON object',
body: response.body,
);
}
return decoded;
}
// Categories API
/// Fetches all product categories from WooCommerce.
///
/// Paginates automatically until all categories are retrieved.
/// Returns the raw JSON list of category maps.
Future<List<Map<String, dynamic>>> getCategories({int perPage = 100}) async {
final allCategories = <Map<String, dynamic>>[];
var page = 1;
while (true) {
final uri = Uri.parse(
'$_baseEndpoint/products/categories',
).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 categories: ${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,
);
}
final batch = decoded.cast<Map<String, dynamic>>();
allCategories.addAll(batch);
if (batch.length < perPage) break;
page++;
}
return allCategories;
}
/// Creates a new product category in WooCommerce.
///
/// [body] should contain at minimum `name`. Optional: `slug`, `parent`.
/// Returns the created category JSON (HTTP 201).
Future<Map<String, dynamic>> createCategory(Map<String, dynamic> body) async {
final uri = Uri.parse('$_baseEndpoint/products/categories');
final response = await _httpClient.post(uri, headers: _authHeaders, body: jsonEncode(body));
if (response.statusCode != 201) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Failed to create category: ${response.reasonPhrase}',
body: response.body,
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Unexpected response format: expected a JSON object',
body: response.body,
);
}
return decoded;
}
/// Updates an existing product category in WooCommerce.
///
/// [body] may contain `name`, `slug`, `parent`, `description`, etc.
/// Returns the updated category JSON.
Future<Map<String, dynamic>> updateCategory(int categoryId, Map<String, dynamic> body) async {
final uri = Uri.parse('$_baseEndpoint/products/categories/$categoryId');
final response = await _httpClient.put(uri, headers: _authHeaders, body: jsonEncode(body));
if (response.statusCode != 200) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Failed to update category $categoryId: ${response.reasonPhrase}',
body: response.body,
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Unexpected response format: expected a JSON object',
body: response.body,
);
}
return decoded;
}
/// Deletes a product category by [categoryId].
///
/// WooCommerce requires [force] = `true` to permanently delete a category.
/// Returns the deleted category JSON.
Future<Map<String, dynamic>> deleteCategory(int categoryId, {bool force = true}) async {
final uri = Uri.parse(
'$_baseEndpoint/products/categories/$categoryId',
).replace(queryParameters: {'force': force.toString()});
final response = await _httpClient.delete(uri, headers: _authHeaders);
if (response.statusCode != 200) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Failed to delete category $categoryId: ${response.reasonPhrase}',
body: response.body,
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Unexpected response format: expected a JSON object',
body: response.body,
);
}
return decoded;
}
// Media / Images API
/// Fetches the images attached to a WooCommerce product.
///
/// Returns the raw `images` array from the product JSON.
Future<List<Map<String, dynamic>>> getProductImages(String productId) async {
final uri = Uri.parse('$_baseEndpoint/products/$productId');
final response = await _httpClient.get(uri, headers: _authHeaders);
if (response.statusCode != 200) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Failed to fetch product $productId: ${response.reasonPhrase}',
body: response.body,
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Unexpected response format: expected a JSON object',
body: response.body,
);
}
final images = decoded['images'];
if (images is! List) return [];
return images.cast<Map<String, dynamic>>();
}
/// Sets the images on a WooCommerce product by media attachment IDs.
///
/// [imageIds] is an ordered list of WooCommerce media attachment IDs.
/// The first ID becomes the featured image (position 0).
/// Pass an empty list to remove all images.
///
/// Returns the full updated product JSON.
Future<Map<String, dynamic>> setProductImages(String productId, List<int> imageIds) async {
final images = imageIds.asMap().entries.map((e) => {'id': e.value}).toList();
final uri = Uri.parse('$_baseEndpoint/products/$productId');
final response = await _httpClient.put(
uri,
headers: _authHeaders,
body: jsonEncode({'images': images}),
);
if (response.statusCode != 200) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Failed to set images for product $productId: ${response.reasonPhrase}',
body: response.body,
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Unexpected response format: expected a JSON object',
body: response.body,
);
}
return decoded;
}
} }
/// Exception thrown when the WooCommerce API returns an error. /// Exception thrown when the WooCommerce API returns an error.

View File

@ -1,6 +1,4 @@
import '../domain/product_category.dart';
import '../domain/product_draft.dart'; import '../domain/product_draft.dart';
import '../domain/product_image.dart';
import '../domain/product_publishing_repository.dart'; import '../domain/product_publishing_repository.dart';
import '../domain/publish_status.dart'; import '../domain/publish_status.dart';
import 'woo_commerce_api_client.dart'; import 'woo_commerce_api_client.dart';
@ -8,8 +6,8 @@ import 'wordpress_product_mapper.dart';
/// Real [ProductPublishingRepository] backed by the WooCommerce REST API. /// Real [ProductPublishingRepository] backed by the WooCommerce REST API.
/// ///
/// Supports product retrieval, publishing, status updates, product creation, /// Supports product retrieval, publishing, and status updates via
/// deletion, image management, and category CRUD via the WooCommerce REST API. /// `PUT /wp-json/wc/v3/products/{id}`.
class WordPressProductPublishingRepository implements ProductPublishingRepository { class WordPressProductPublishingRepository implements ProductPublishingRepository {
final WooCommerceApiClient _apiClient; final WooCommerceApiClient _apiClient;
final WordPressProductMapper _mapper; final WordPressProductMapper _mapper;
@ -66,105 +64,4 @@ class WordPressProductPublishingRepository implements ProductPublishingRepositor
}); });
return _mapper.fromJson(json); return _mapper.fromJson(json);
} }
// Stage 9C: Catalog expansion
@override
Future<ProductDraft> createProduct({
required String name,
required double price,
String description = '',
String sku = '',
int? categoryId,
PublishStatus status = PublishStatus.draft,
}) async {
final wooStatus = status == PublishStatus.unpublished
? 'draft'
: WordPressProductMapper.toWooCommerceStatus(status);
final body = <String, dynamic>{
'name': name,
'regular_price': price.toStringAsFixed(2),
'status': wooStatus,
};
if (description.isNotEmpty) body['description'] = description;
if (sku.isNotEmpty) body['sku'] = sku;
if (categoryId != null) {
body['categories'] = [
{'id': categoryId},
];
}
final json = await _apiClient.createProduct(body);
return _mapper.fromJson(json);
}
@override
Future<void> deleteProduct(String id, {bool force = false}) async {
await _apiClient.deleteProduct(id, force: force);
}
@override
Future<ProductDraft> updateProductImages(String id, List<int> imageIds) async {
final json = await _apiClient.setProductImages(id, imageIds);
return _mapper.fromJson(json);
}
@override
Future<List<ProductCategory>> getCategories() async {
final jsonCategories = await _apiClient.getCategories();
return jsonCategories.map(_mapCategory).toList();
}
@override
Future<ProductCategory> createCategory({required String name, int parentId = 0}) async {
final body = <String, dynamic>{'name': name};
if (parentId != 0) body['parent'] = parentId;
final json = await _apiClient.createCategory(body);
return _mapCategory(json);
}
@override
Future<ProductCategory> updateCategory(int categoryId, {String? name, int? parentId}) async {
final body = <String, dynamic>{};
if (name != null) body['name'] = name;
if (parentId != null) body['parent'] = parentId;
final json = await _apiClient.updateCategory(categoryId, body);
return _mapCategory(json);
}
@override
Future<void> deleteCategory(int categoryId, {bool force = true}) async {
await _apiClient.deleteCategory(categoryId, force: force);
}
@override
Future<List<ProductImage>> getProductImages(String id) async {
final jsonImages = await _apiClient.getProductImages(id);
return jsonImages.asMap().entries.map((entry) {
final img = entry.value;
return ProductImage(
id: (img['id'] as num?)?.toInt() ?? 0,
url: (img['src'] as String?) ?? '',
alt: (img['alt'] as String?) ?? '',
position: entry.key,
);
}).toList();
}
// Private helpers
/// Maps a WooCommerce category JSON map to a [ProductCategory].
static ProductCategory _mapCategory(Map<String, dynamic> json) {
return ProductCategory(
id: (json['id'] as num?)?.toInt() ?? 0,
name: (json['name'] as String?) ?? '',
slug: (json['slug'] as String?) ?? '',
parentId: (json['parent'] as num?)?.toInt() ?? 0,
count: (json['count'] as num?)?.toInt() ?? 0,
);
}
} }

View File

@ -1,53 +0,0 @@
/// A WooCommerce product category.
class ProductCategory {
/// WooCommerce category ID.
final int id;
/// Display name of the category.
final String name;
/// URL-friendly slug.
final String slug;
/// Parent category ID, or 0 if top-level.
final int parentId;
/// Number of products in this category.
final int count;
const ProductCategory({
required this.id,
required this.name,
required this.slug,
required this.parentId,
required this.count,
});
/// Returns a copy of this [ProductCategory] with the given fields replaced.
ProductCategory copyWith({int? id, String? name, String? slug, int? parentId, int? count}) {
return ProductCategory(
id: id ?? this.id,
name: name ?? this.name,
slug: slug ?? this.slug,
parentId: parentId ?? this.parentId,
count: count ?? this.count,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ProductCategory &&
runtimeType == other.runtimeType &&
id == other.id &&
name == other.name &&
slug == other.slug &&
parentId == other.parentId &&
count == other.count;
@override
int get hashCode => Object.hash(id, name, slug, parentId, count);
@override
String toString() => 'ProductCategory(id: $id, name: $name, slug: $slug)';
}

View File

@ -1,47 +0,0 @@
/// A product image attached to a WooCommerce product.
class ProductImage {
/// WooCommerce media attachment ID.
final int id;
/// Full URL of the image.
final String url;
/// Alt text for the image.
final String alt;
/// Display position (0 = featured/primary image).
final int position;
const ProductImage({
required this.id,
required this.url,
required this.alt,
required this.position,
});
/// Returns a copy of this [ProductImage] with the given fields replaced.
ProductImage copyWith({int? id, String? url, String? alt, int? position}) {
return ProductImage(
id: id ?? this.id,
url: url ?? this.url,
alt: alt ?? this.alt,
position: position ?? this.position,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ProductImage &&
runtimeType == other.runtimeType &&
id == other.id &&
url == other.url &&
alt == other.alt &&
position == other.position;
@override
int get hashCode => Object.hash(id, url, alt, position);
@override
String toString() => 'ProductImage(id: $id, url: $url, position: $position)';
}

View File

@ -1,6 +1,4 @@
import 'product_category.dart';
import 'product_draft.dart'; import 'product_draft.dart';
import 'product_image.dart';
import 'publish_status.dart'; import 'publish_status.dart';
/// Contract for fetching and managing product drafts. /// Contract for fetching and managing product drafts.
@ -39,54 +37,4 @@ abstract class ProductPublishingRepository {
/// ///
/// Returns the updated [ProductDraft] reflecting the new category. /// Returns the updated [ProductDraft] reflecting the new category.
Future<ProductDraft> updateProductCategory(String id, String category); Future<ProductDraft> updateProductCategory(String id, String category);
// Stage 9C: Catalog expansion
/// Creates a new product and returns the created [ProductDraft].
///
/// [name] and [price] are required. [description], [sku], [categoryId],
/// and [status] are optional implementations should apply sensible
/// defaults (e.g. [PublishStatus.draft]).
Future<ProductDraft> createProduct({
required String name,
required double price,
String description = '',
String sku = '',
int? categoryId,
PublishStatus status = PublishStatus.draft,
});
/// Deletes the product identified by [id].
///
/// [force] controls whether the product is permanently deleted (`true`) or
/// moved to trash (`false`, WooCommerce default).
Future<void> deleteProduct(String id, {bool force = false});
/// Updates the images of the product identified by [id].
///
/// [imageIds] is an ordered list of WooCommerce media attachment IDs.
/// The first ID becomes the featured image (position 0).
/// Pass an empty list to remove all images.
///
/// Returns the updated [ProductDraft] reflecting the new primary image URL.
Future<ProductDraft> updateProductImages(String id, List<int> imageIds);
/// Returns all product categories from the store.
Future<List<ProductCategory>> getCategories();
/// Creates a new product category and returns the created [ProductCategory].
///
/// [name] is required. [parentId] defaults to 0 (top-level category).
Future<ProductCategory> createCategory({required String name, int parentId = 0});
/// Updates an existing product category and returns the updated [ProductCategory].
Future<ProductCategory> updateCategory(int categoryId, {String? name, int? parentId});
/// Deletes the category identified by [categoryId].
///
/// [force] controls whether the category is permanently deleted.
Future<void> deleteCategory(int categoryId, {bool force = true});
/// Returns the images attached to the product identified by [id].
Future<List<ProductImage>> getProductImages(String id);
} }

View File

@ -1,995 +0,0 @@
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';
// Shared JSON builders
Map<String, dynamic> _buildProductJson({
int id = 1,
String name = 'Test Product',
String status = 'draft',
String price = '9.99',
String sku = 'SKU-1',
String description = '<p>A product</p>',
List<Map<String, dynamic>>? categories,
List<Map<String, dynamic>>? images,
}) {
return {
'id': id,
'name': name,
'description': description,
'price': price,
'regular_price': price,
'sku': sku,
'status': status,
'date_modified': '2026-07-01T10:00:00',
'date_created': '2026-07-01T08:00:00',
'categories':
categories ??
[
{'id': 1, 'name': 'Test Category', 'slug': 'test-category'},
],
'images': images ?? [],
};
}
Map<String, dynamic> _buildCategoryJson({
int id = 1,
String name = 'Test Category',
String slug = 'test-category',
int parent = 0,
int count = 0,
}) {
return {'id': id, 'name': name, 'slug': slug, 'parent': parent, 'count': count};
}
WooCommerceApiClient _buildClient(MockClient mockClient) {
return WooCommerceApiClient(
siteUrl: 'https://store.example.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
httpClient: mockClient,
);
}
// ProductCategory domain model
void main() {
group('ProductCategory', () {
test('constructs with all fields', () {
const cat = ProductCategory(
id: 1,
name: 'Bowl Cozies',
slug: 'bowl-cozies',
parentId: 0,
count: 5,
);
expect(cat.id, 1);
expect(cat.name, 'Bowl Cozies');
expect(cat.slug, 'bowl-cozies');
expect(cat.parentId, 0);
expect(cat.count, 5);
});
test('copyWith replaces specified fields', () {
const original = ProductCategory(
id: 1,
name: 'Bowl Cozies',
slug: 'bowl-cozies',
parentId: 0,
count: 5,
);
final updated = original.copyWith(name: 'Updated Cozies', count: 10);
expect(updated.id, 1);
expect(updated.name, 'Updated Cozies');
expect(updated.slug, 'bowl-cozies');
expect(updated.parentId, 0);
expect(updated.count, 10);
});
test('equality holds for identical values', () {
const a = ProductCategory(id: 1, name: 'A', slug: 'a', parentId: 0, count: 0);
const b = ProductCategory(id: 1, name: 'A', slug: 'a', parentId: 0, count: 0);
expect(a, equals(b));
expect(a.hashCode, equals(b.hashCode));
});
test('equality fails for different id', () {
const a = ProductCategory(id: 1, name: 'A', slug: 'a', parentId: 0, count: 0);
const b = ProductCategory(id: 2, name: 'A', slug: 'a', parentId: 0, count: 0);
expect(a, isNot(equals(b)));
});
test('toString includes id, name, slug', () {
const cat = ProductCategory(id: 3, name: 'Coasters', slug: 'coasters', parentId: 0, count: 2);
expect(cat.toString(), contains('3'));
expect(cat.toString(), contains('Coasters'));
expect(cat.toString(), contains('coasters'));
});
});
// ProductImage domain model
group('ProductImage', () {
test('constructs with all fields', () {
const img = ProductImage(
id: 10,
url: 'https://example.com/img.jpg',
alt: 'A product image',
position: 0,
);
expect(img.id, 10);
expect(img.url, 'https://example.com/img.jpg');
expect(img.alt, 'A product image');
expect(img.position, 0);
});
test('copyWith replaces specified fields', () {
const original = ProductImage(id: 1, url: 'https://a.com/1.jpg', alt: '', position: 0);
final updated = original.copyWith(alt: 'New alt', position: 1);
expect(updated.id, 1);
expect(updated.url, 'https://a.com/1.jpg');
expect(updated.alt, 'New alt');
expect(updated.position, 1);
});
test('equality holds for identical values', () {
const a = ProductImage(id: 1, url: 'https://a.com/1.jpg', alt: '', position: 0);
const b = ProductImage(id: 1, url: 'https://a.com/1.jpg', alt: '', position: 0);
expect(a, equals(b));
expect(a.hashCode, equals(b.hashCode));
});
test('equality fails for different id', () {
const a = ProductImage(id: 1, url: 'https://a.com/1.jpg', alt: '', position: 0);
const b = ProductImage(id: 2, url: 'https://a.com/1.jpg', alt: '', position: 0);
expect(a, isNot(equals(b)));
});
});
// FakeProductPublishingRepository catalog expansion
group('FakeProductPublishingRepository — Stage 9C', () {
late FakeProductPublishingRepository repo;
setUp(() => repo = FakeProductPublishingRepository());
// createProduct
test('createProduct adds a new draft to the list', () async {
final before = await repo.getProductDrafts();
final created = await repo.createProduct(name: 'New Bowl Cozy', price: 14.99);
expect(created.name, 'New Bowl Cozy');
expect(created.price, 14.99);
expect(created.status, PublishStatus.draft);
expect(created.category, 'Uncategorized');
final after = await repo.getProductDrafts();
expect(after.length, before.length + 1);
expect(after.any((d) => d.id == created.id), isTrue);
});
test('createProduct with categoryId resolves category name', () async {
// Category id=1 is 'Bowl Cozies' in seed data.
final created = await repo.createProduct(name: 'New Cozy', price: 9.99, categoryId: 1);
expect(created.category, 'Bowl Cozies');
});
test('createProduct with description and sku stores them', () async {
final created = await repo.createProduct(
name: 'Fancy Cozy',
price: 19.99,
description: 'A fancy cozy',
sku: 'FC-001',
);
expect(created.description, 'A fancy cozy');
expect(created.sku, 'FC-001');
});
test('createProduct with custom status stores it', () async {
final created = await repo.createProduct(
name: 'Published Cozy',
price: 12.99,
status: PublishStatus.published,
);
expect(created.status, PublishStatus.published);
});
test('createProduct assigns unique incrementing IDs', () async {
final a = await repo.createProduct(name: 'A', price: 1.0);
final b = await repo.createProduct(name: 'B', price: 2.0);
expect(a.id, isNot(equals(b.id)));
});
// deleteProduct
test('deleteProduct removes the product from the list', () async {
final before = await repo.getProductDrafts();
await repo.deleteProduct('1');
final after = await repo.getProductDrafts();
expect(after.length, before.length - 1);
expect(after.any((d) => d.id == '1'), isFalse);
});
test('deleteProduct throws StateError for unknown id', () async {
expect(() => repo.deleteProduct('999'), throwsA(isA<StateError>()));
});
// updateProductImages
test('updateProductImages sets primary imageUrl on draft', () async {
final updated = await repo.updateProductImages('1', [101, 102]);
expect(updated.id, '1');
expect(updated.imageUrl, contains('101'));
});
test('updateProductImages with empty list clears imageUrl', () async {
// First set some images.
await repo.updateProductImages('1', [101]);
// Then clear them.
final updated = await repo.updateProductImages('1', []);
expect(updated.imageUrl, '');
});
test('updateProductImages throws StateError for unknown id', () async {
expect(() => repo.updateProductImages('999', [1]), throwsA(isA<StateError>()));
});
// getProductImages
test('getProductImages returns empty list before any images set', () async {
final images = await repo.getProductImages('1');
expect(images, isEmpty);
});
test('getProductImages returns images after updateProductImages', () async {
await repo.updateProductImages('1', [101, 102]);
final images = await repo.getProductImages('1');
expect(images.length, 2);
expect(images[0].id, 101);
expect(images[0].position, 0);
expect(images[1].id, 102);
expect(images[1].position, 1);
});
// getCategories
test('getCategories returns seed categories', () async {
final cats = await repo.getCategories();
expect(cats.length, 4);
expect(
cats.map((c) => c.name),
containsAll(['Bowl Cozies', 'Coasters', 'Nightlights', 'Kitchen Accessories']),
);
});
// createCategory
test('createCategory adds a new category', () async {
final before = await repo.getCategories();
final created = await repo.createCategory(name: 'Pot Holders');
expect(created.name, 'Pot Holders');
expect(created.slug, 'pot-holders');
expect(created.parentId, 0);
expect(created.count, 0);
final after = await repo.getCategories();
expect(after.length, before.length + 1);
});
test('createCategory with parentId stores it', () async {
final created = await repo.createCategory(name: 'Sub Category', parentId: 1);
expect(created.parentId, 1);
});
test('createCategory generates slug from name', () async {
final created = await repo.createCategory(name: 'My New Category');
expect(created.slug, 'my-new-category');
});
// updateCategory
test('updateCategory updates name and slug', () async {
final updated = await repo.updateCategory(1, name: 'Updated Bowl Cozies');
expect(updated.name, 'Updated Bowl Cozies');
expect(updated.slug, 'updated-bowl-cozies');
});
test('updateCategory with parentId updates it', () async {
final updated = await repo.updateCategory(2, parentId: 1);
expect(updated.parentId, 1);
});
test('updateCategory throws StateError for unknown id', () async {
expect(() => repo.updateCategory(999, name: 'X'), throwsA(isA<StateError>()));
});
// deleteCategory
test('deleteCategory removes the category', () async {
final before = await repo.getCategories();
await repo.deleteCategory(1);
final after = await repo.getCategories();
expect(after.length, before.length - 1);
expect(after.any((c) => c.id == 1), isFalse);
});
test('deleteCategory throws StateError for unknown id', () async {
expect(() => repo.deleteCategory(999), throwsA(isA<StateError>()));
});
});
// WooCommerceApiClient catalog expansion
group('WooCommerceApiClient — Stage 9C', () {
// createProduct
test('createProduct sends POST to /products and returns JSON on 201', () async {
Map<String, dynamic>? capturedBody;
String? capturedMethod;
Uri? capturedUri;
final mockClient = MockClient((request) async {
capturedMethod = request.method;
capturedUri = request.url;
capturedBody = jsonDecode(request.body) as Map<String, dynamic>;
return http.Response(jsonEncode(_buildProductJson(id: 99, name: 'New Product')), 201);
});
final client = _buildClient(mockClient);
final result = await client.createProduct({'name': 'New Product', 'regular_price': '9.99'});
expect(capturedMethod, 'POST');
expect(capturedUri!.path, '/wp-json/wc/v3/products');
expect(capturedBody!['name'], 'New Product');
expect(result['id'], 99);
});
test('createProduct throws WooCommerceApiException on non-201', () async {
final mockClient = MockClient((request) async {
return http.Response('{"code":"rest_invalid_param"}', 400);
});
final client = _buildClient(mockClient);
expect(() => client.createProduct({'name': ''}), throwsA(isA<WooCommerceApiException>()));
});
// deleteProduct
test('deleteProduct sends DELETE to /products/{id} with force param', () async {
String? capturedMethod;
Uri? capturedUri;
final mockClient = MockClient((request) async {
capturedMethod = request.method;
capturedUri = request.url;
return http.Response(jsonEncode(_buildProductJson(id: 5)), 200);
});
final client = _buildClient(mockClient);
await client.deleteProduct('5', force: true);
expect(capturedMethod, 'DELETE');
expect(capturedUri!.path, '/wp-json/wc/v3/products/5');
expect(capturedUri!.queryParameters['force'], 'true');
});
test('deleteProduct throws WooCommerceApiException on non-200', () async {
final mockClient = MockClient((request) async {
return http.Response('{"code":"rest_cannot_delete"}', 403);
});
final client = _buildClient(mockClient);
expect(() => client.deleteProduct('5'), throwsA(isA<WooCommerceApiException>()));
});
// getCategories
test('getCategories fetches all categories with pagination', () async {
var requestCount = 0;
final mockClient = MockClient((request) async {
requestCount++;
final page = int.parse(request.url.queryParameters['page']!);
final perPage = int.parse(request.url.queryParameters['per_page']!);
// Page 1 returns perPage items, page 2 returns 1 (short page).
final count = page == 1 ? perPage : 1;
final cats = List.generate(
count,
(i) => _buildCategoryJson(id: i + 1, name: 'Cat ${i + 1}'),
);
return http.Response(jsonEncode(cats), 200);
});
final client = _buildClient(mockClient);
final cats = await client.getCategories(perPage: 2);
expect(cats.length, 3); // 2 + 1
expect(requestCount, 2);
});
test('getCategories throws WooCommerceApiException on non-200', () async {
final mockClient = MockClient((request) async {
return http.Response('Server Error', 500);
});
final client = _buildClient(mockClient);
expect(() => client.getCategories(), throwsA(isA<WooCommerceApiException>()));
});
// createCategory
test('createCategory sends POST to /products/categories and returns JSON on 201', () async {
Map<String, dynamic>? capturedBody;
Uri? capturedUri;
final mockClient = MockClient((request) async {
capturedUri = request.url;
capturedBody = jsonDecode(request.body) as Map<String, dynamic>;
return http.Response(
jsonEncode(_buildCategoryJson(id: 10, name: 'Pot Holders', slug: 'pot-holders')),
201,
);
});
final client = _buildClient(mockClient);
final result = await client.createCategory({'name': 'Pot Holders'});
expect(capturedUri!.path, '/wp-json/wc/v3/products/categories');
expect(capturedBody!['name'], 'Pot Holders');
expect(result['id'], 10);
expect(result['name'], 'Pot Holders');
});
test('createCategory throws WooCommerceApiException on non-201', () async {
final mockClient = MockClient((request) async {
return http.Response('{"code":"term_exists"}', 400);
});
final client = _buildClient(mockClient);
expect(
() => client.createCategory({'name': 'Existing'}),
throwsA(isA<WooCommerceApiException>()),
);
});
// updateCategory
test('updateCategory sends PUT to /products/categories/{id}', () async {
String? capturedMethod;
Uri? capturedUri;
Map<String, dynamic>? capturedBody;
final mockClient = MockClient((request) async {
capturedMethod = request.method;
capturedUri = request.url;
capturedBody = jsonDecode(request.body) as Map<String, dynamic>;
return http.Response(
jsonEncode(_buildCategoryJson(id: 3, name: 'Updated Name', slug: 'updated-name')),
200,
);
});
final client = _buildClient(mockClient);
final result = await client.updateCategory(3, {'name': 'Updated Name'});
expect(capturedMethod, 'PUT');
expect(capturedUri!.path, '/wp-json/wc/v3/products/categories/3');
expect(capturedBody!['name'], 'Updated Name');
expect(result['name'], 'Updated Name');
});
test('updateCategory throws WooCommerceApiException on non-200', () async {
final mockClient = MockClient((request) async {
return http.Response('{"code":"rest_cannot_edit"}', 403);
});
final client = _buildClient(mockClient);
expect(
() => client.updateCategory(3, {'name': 'X'}),
throwsA(isA<WooCommerceApiException>()),
);
});
// deleteCategory
test('deleteCategory sends DELETE to /products/categories/{id} with force', () async {
String? capturedMethod;
Uri? capturedUri;
final mockClient = MockClient((request) async {
capturedMethod = request.method;
capturedUri = request.url;
return http.Response(jsonEncode(_buildCategoryJson(id: 2)), 200);
});
final client = _buildClient(mockClient);
await client.deleteCategory(2, force: true);
expect(capturedMethod, 'DELETE');
expect(capturedUri!.path, '/wp-json/wc/v3/products/categories/2');
expect(capturedUri!.queryParameters['force'], 'true');
});
test('deleteCategory throws WooCommerceApiException on non-200', () async {
final mockClient = MockClient((request) async {
return http.Response('{"code":"rest_cannot_delete"}', 403);
});
final client = _buildClient(mockClient);
expect(() => client.deleteCategory(2), throwsA(isA<WooCommerceApiException>()));
});
// getProductImages
test('getProductImages returns images array from product JSON', () async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode(
_buildProductJson(
id: 1,
images: [
{'id': 10, 'src': 'https://example.com/img1.jpg', 'alt': 'Image 1'},
{'id': 11, 'src': 'https://example.com/img2.jpg', 'alt': 'Image 2'},
],
),
),
200,
);
});
final client = _buildClient(mockClient);
final images = await client.getProductImages('1');
expect(images.length, 2);
expect(images[0]['id'], 10);
expect(images[0]['src'], 'https://example.com/img1.jpg');
});
test('getProductImages returns empty list when images field is absent', () async {
final mockClient = MockClient((request) async {
final json = _buildProductJson(id: 1);
json.remove('images');
return http.Response(jsonEncode(json), 200);
});
final client = _buildClient(mockClient);
final images = await client.getProductImages('1');
expect(images, isEmpty);
});
test('getProductImages throws WooCommerceApiException on non-200', () async {
final mockClient = MockClient((request) async {
return http.Response('Not Found', 404);
});
final client = _buildClient(mockClient);
expect(() => client.getProductImages('1'), throwsA(isA<WooCommerceApiException>()));
});
// setProductImages
test('setProductImages sends PUT with images array', () async {
Map<String, dynamic>? capturedBody;
Uri? capturedUri;
final mockClient = MockClient((request) async {
capturedUri = request.url;
capturedBody = jsonDecode(request.body) as Map<String, dynamic>;
return http.Response(
jsonEncode(
_buildProductJson(
id: 7,
images: [
{'id': 101, 'src': 'https://example.com/101.jpg', 'alt': ''},
],
),
),
200,
);
});
final client = _buildClient(mockClient);
final result = await client.setProductImages('7', [101]);
expect(capturedUri!.path, '/wp-json/wc/v3/products/7');
expect(capturedBody!['images'], [
{'id': 101},
]);
expect(result['id'], 7);
});
test('setProductImages with empty list sends empty images array', () async {
Map<String, dynamic>? capturedBody;
final mockClient = MockClient((request) async {
capturedBody = jsonDecode(request.body) as Map<String, dynamic>;
return http.Response(jsonEncode(_buildProductJson(id: 7, images: [])), 200);
});
final client = _buildClient(mockClient);
await client.setProductImages('7', []);
expect(capturedBody!['images'], isEmpty);
});
test('setProductImages throws WooCommerceApiException on non-200', () async {
final mockClient = MockClient((request) async {
return http.Response('{"code":"rest_cannot_edit"}', 403);
});
final client = _buildClient(mockClient);
expect(() => client.setProductImages('7', [101]), throwsA(isA<WooCommerceApiException>()));
});
});
// WordPressProductPublishingRepository catalog expansion
group('WordPressProductPublishingRepository — Stage 9C', () {
WordPressProductPublishingRepository buildRepo(MockClient mockClient) {
return WordPressProductPublishingRepository(apiClient: _buildClient(mockClient));
}
// createProduct
test('createProduct sends POST with correct fields and returns ProductDraft', () async {
Map<String, dynamic>? capturedBody;
final mockClient = MockClient((request) async {
capturedBody = jsonDecode(request.body) as Map<String, dynamic>;
return http.Response(
jsonEncode(_buildProductJson(id: 99, name: 'New Cozy', status: 'draft', price: '14.99')),
201,
);
});
final repo = buildRepo(mockClient);
final result = await repo.createProduct(name: 'New Cozy', price: 14.99);
expect(capturedBody!['name'], 'New Cozy');
expect(capturedBody!['regular_price'], '14.99');
expect(capturedBody!['status'], 'draft');
expect(result, isA<ProductDraft>());
expect(result.id, '99');
expect(result.name, 'New Cozy');
expect(result.status, PublishStatus.draft);
});
test('createProduct with description and sku includes them in request', () async {
Map<String, dynamic>? capturedBody;
final mockClient = MockClient((request) async {
capturedBody = jsonDecode(request.body) as Map<String, dynamic>;
return http.Response(
jsonEncode(_buildProductJson(id: 10, name: 'Fancy', sku: 'FC-001')),
201,
);
});
final repo = buildRepo(mockClient);
await repo.createProduct(
name: 'Fancy',
price: 9.99,
description: 'A fancy product',
sku: 'FC-001',
);
expect(capturedBody!['description'], 'A fancy product');
expect(capturedBody!['sku'], 'FC-001');
});
test('createProduct with categoryId includes categories in request', () async {
Map<String, dynamic>? capturedBody;
final mockClient = MockClient((request) async {
capturedBody = jsonDecode(request.body) as Map<String, dynamic>;
return http.Response(jsonEncode(_buildProductJson(id: 11)), 201);
});
final repo = buildRepo(mockClient);
await repo.createProduct(name: 'Cat Product', price: 5.0, categoryId: 3);
expect(capturedBody!['categories'], [
{'id': 3},
]);
});
test('createProduct with published status sends publish', () async {
Map<String, dynamic>? capturedBody;
final mockClient = MockClient((request) async {
capturedBody = jsonDecode(request.body) as Map<String, dynamic>;
return http.Response(jsonEncode(_buildProductJson(id: 12, status: 'publish')), 201);
});
final repo = buildRepo(mockClient);
await repo.createProduct(name: 'Published', price: 9.99, status: PublishStatus.published);
expect(capturedBody!['status'], 'publish');
});
test('createProduct propagates WooCommerceApiException on non-201', () async {
final mockClient = MockClient((request) async {
return http.Response('{"code":"rest_invalid_param"}', 400);
});
final repo = buildRepo(mockClient);
expect(
() => repo.createProduct(name: 'X', price: 1.0),
throwsA(isA<WooCommerceApiException>()),
);
});
// deleteProduct
test('deleteProduct sends DELETE and completes without error', () async {
String? capturedMethod;
Uri? capturedUri;
final mockClient = MockClient((request) async {
capturedMethod = request.method;
capturedUri = request.url;
return http.Response(jsonEncode(_buildProductJson(id: 5)), 200);
});
final repo = buildRepo(mockClient);
await repo.deleteProduct('5', force: true);
expect(capturedMethod, 'DELETE');
expect(capturedUri!.path, '/wp-json/wc/v3/products/5');
expect(capturedUri!.queryParameters['force'], 'true');
});
test('deleteProduct propagates WooCommerceApiException on non-200', () async {
final mockClient = MockClient((request) async {
return http.Response('{"code":"rest_cannot_delete"}', 403);
});
final repo = buildRepo(mockClient);
expect(() => repo.deleteProduct('5'), throwsA(isA<WooCommerceApiException>()));
});
// updateProductImages
test('updateProductImages sends PUT with images and returns updated ProductDraft', () async {
Map<String, dynamic>? capturedBody;
final mockClient = MockClient((request) async {
capturedBody = jsonDecode(request.body) as Map<String, dynamic>;
return http.Response(
jsonEncode(
_buildProductJson(
id: 1,
images: [
{'id': 101, 'src': 'https://example.com/101.jpg', 'alt': ''},
],
),
),
200,
);
});
final repo = buildRepo(mockClient);
final result = await repo.updateProductImages('1', [101]);
expect(capturedBody!['images'], [
{'id': 101},
]);
expect(result, isA<ProductDraft>());
expect(result.imageUrl, 'https://example.com/101.jpg');
});
// getCategories
test('getCategories returns mapped ProductCategory list', () async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode([
_buildCategoryJson(id: 1, name: 'Bowl Cozies', slug: 'bowl-cozies', count: 3),
_buildCategoryJson(id: 2, name: 'Coasters', slug: 'coasters', count: 5),
]),
200,
);
});
final repo = buildRepo(mockClient);
final cats = await repo.getCategories();
expect(cats.length, 2);
expect(cats[0], isA<ProductCategory>());
expect(cats[0].id, 1);
expect(cats[0].name, 'Bowl Cozies');
expect(cats[0].slug, 'bowl-cozies');
expect(cats[0].count, 3);
expect(cats[1].id, 2);
});
test('getCategories propagates WooCommerceApiException on non-200', () async {
final mockClient = MockClient((request) async {
return http.Response('Server Error', 500);
});
final repo = buildRepo(mockClient);
expect(() => repo.getCategories(), throwsA(isA<WooCommerceApiException>()));
});
// createCategory
test('createCategory sends POST with name and returns ProductCategory', () async {
Map<String, dynamic>? capturedBody;
final mockClient = MockClient((request) async {
capturedBody = jsonDecode(request.body) as Map<String, dynamic>;
return http.Response(
jsonEncode(_buildCategoryJson(id: 10, name: 'Pot Holders', slug: 'pot-holders')),
201,
);
});
final repo = buildRepo(mockClient);
final result = await repo.createCategory(name: 'Pot Holders');
expect(capturedBody!['name'], 'Pot Holders');
expect(capturedBody!.containsKey('parent'), isFalse); // parentId=0 omitted
expect(result, isA<ProductCategory>());
expect(result.id, 10);
expect(result.name, 'Pot Holders');
});
test('createCategory with parentId includes parent in request', () async {
Map<String, dynamic>? capturedBody;
final mockClient = MockClient((request) async {
capturedBody = jsonDecode(request.body) as Map<String, dynamic>;
return http.Response(
jsonEncode(_buildCategoryJson(id: 11, name: 'Sub', slug: 'sub', parent: 1)),
201,
);
});
final repo = buildRepo(mockClient);
final result = await repo.createCategory(name: 'Sub', parentId: 1);
expect(capturedBody!['parent'], 1);
expect(result.parentId, 1);
});
// updateCategory
test('updateCategory sends PUT with name and returns updated ProductCategory', () async {
Map<String, dynamic>? capturedBody;
Uri? capturedUri;
final mockClient = MockClient((request) async {
capturedUri = request.url;
capturedBody = jsonDecode(request.body) as Map<String, dynamic>;
return http.Response(
jsonEncode(_buildCategoryJson(id: 3, name: 'Updated', slug: 'updated')),
200,
);
});
final repo = buildRepo(mockClient);
final result = await repo.updateCategory(3, name: 'Updated');
expect(capturedUri!.path, '/wp-json/wc/v3/products/categories/3');
expect(capturedBody!['name'], 'Updated');
expect(result.name, 'Updated');
});
// deleteCategory
test('deleteCategory sends DELETE and completes without error', () async {
String? capturedMethod;
Uri? capturedUri;
final mockClient = MockClient((request) async {
capturedMethod = request.method;
capturedUri = request.url;
return http.Response(jsonEncode(_buildCategoryJson(id: 2)), 200);
});
final repo = buildRepo(mockClient);
await repo.deleteCategory(2);
expect(capturedMethod, 'DELETE');
expect(capturedUri!.path, '/wp-json/wc/v3/products/categories/2');
});
// getProductImages
test('getProductImages returns mapped ProductImage list', () async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode(
_buildProductJson(
id: 1,
images: [
{'id': 10, 'src': 'https://example.com/img1.jpg', 'alt': 'Alt 1'},
{'id': 11, 'src': 'https://example.com/img2.jpg', 'alt': 'Alt 2'},
],
),
),
200,
);
});
final repo = buildRepo(mockClient);
final images = await repo.getProductImages('1');
expect(images.length, 2);
expect(images[0], isA<ProductImage>());
expect(images[0].id, 10);
expect(images[0].url, 'https://example.com/img1.jpg');
expect(images[0].alt, 'Alt 1');
expect(images[0].position, 0);
expect(images[1].id, 11);
expect(images[1].position, 1);
});
test('getProductImages returns empty list when no images', () async {
final mockClient = MockClient((request) async {
return http.Response(jsonEncode(_buildProductJson(id: 1, images: [])), 200);
});
final repo = buildRepo(mockClient);
final images = await repo.getProductImages('1');
expect(images, isEmpty);
});
});
// Use cases
group('CreateProduct use case', () {
test('delegates to repository and returns ProductDraft', () async {
final repo = FakeProductPublishingRepository();
final useCase = CreateProduct(repo);
final result = await useCase(name: 'New Cozy', price: 12.99);
expect(result, isA<ProductDraft>());
expect(result.name, 'New Cozy');
expect(result.price, 12.99);
});
});
group('DeleteProduct use case', () {
test('delegates to repository and removes product', () async {
final repo = FakeProductPublishingRepository();
final useCase = DeleteProduct(repo);
final before = await repo.getProductDrafts();
await useCase('1');
final after = await repo.getProductDrafts();
expect(after.length, before.length - 1);
expect(after.any((d) => d.id == '1'), isFalse);
});
});
group('GetCategories use case', () {
test('delegates to repository and returns category list', () async {
final repo = FakeProductPublishingRepository();
final useCase = GetCategories(repo);
final cats = await useCase();
expect(cats, isA<List<ProductCategory>>());
expect(cats.isNotEmpty, isTrue);
});
});
group('CreateCategory use case', () {
test('delegates to repository and returns new ProductCategory', () async {
final repo = FakeProductPublishingRepository();
final useCase = CreateCategory(repo);
final result = await useCase(name: 'Pot Holders');
expect(result, isA<ProductCategory>());
expect(result.name, 'Pot Holders');
});
});
group('UpdateProductImages use case', () {
test('delegates to repository and returns updated ProductDraft', () async {
final repo = FakeProductPublishingRepository();
final useCase = UpdateProductImages(repo);
final result = await useCase('1', [101, 102]);
expect(result, isA<ProductDraft>());
expect(result.imageUrl, contains('101'));
});
});
}

View File

@ -37,42 +37,6 @@ class _FailingRepository implements ProductPublishingRepository {
@override @override
Future<ProductDraft> updateProductCategory(String id, String category) async => Future<ProductDraft> updateProductCategory(String id, String category) async =>
throw UnimplementedError(); throw UnimplementedError();
// Stage 9C additions
@override
Future<ProductDraft> createProduct({
required String name,
required double price,
String description = '',
String sku = '',
int? categoryId,
PublishStatus status = PublishStatus.draft,
}) async => throw UnimplementedError();
@override
Future<void> deleteProduct(String id, {bool force = false}) async => throw UnimplementedError();
@override
Future<ProductDraft> updateProductImages(String id, List<int> imageIds) async =>
throw UnimplementedError();
@override
Future<List<ProductCategory>> getCategories() async => throw UnimplementedError();
@override
Future<ProductCategory> createCategory({required String name, int parentId = 0}) async =>
throw UnimplementedError();
@override
Future<ProductCategory> updateCategory(int categoryId, {String? name, int? parentId}) async =>
throw UnimplementedError();
@override
Future<void> deleteCategory(int categoryId, {bool force = true}) async =>
throw UnimplementedError();
@override
Future<List<ProductImage>> getProductImages(String id) async => throw UnimplementedError();
} }
void main() { void main() {