Compare commits
No commits in common. "204bd3b118c6e5d36807d22deda399a9d7cc40ca" and "9564329283bad6eb2ab0f4e5928f8f2fa9a47fbd" have entirely different histories.
204bd3b118
...
9564329283
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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] ?? []);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)';
|
|
||||||
}
|
|
||||||
|
|
@ -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)';
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue