Update main with product publishing
Publish Docs / publish-docs (push) Successful in 1m57s Details

This commit is contained in:
Mike Kell 2026-04-05 20:03:10 -04:00
parent d8f8fb6797
commit 039612cb6e
17 changed files with 685 additions and 31 deletions

View File

@ -25,6 +25,10 @@ class _StubProductPublishingRepository implements ProductPublishingRepository {
@override
Future<ProductDraft> publishDraft(String id) => throw UnimplementedError();
@override
Future<ProductDraft> updateProductStatus(String id, PublishStatus status) =>
throw UnimplementedError();
}
class _StubOrdersRepository implements OrdersRepository {

View File

@ -0,0 +1 @@
{"version":2,"entries":[{"package":"design_system","rootUri":"../../design_system/","packageUri":"lib/"},{"package":"feature_orders","rootUri":"../","packageUri":"lib/"}]}

View File

@ -1,10 +1,18 @@
library;
// Data
export 'src/data/fake_product_publishing_repository.dart';
export 'src/data/woo_commerce_api_client.dart';
export 'src/data/wordpress_product_mapper.dart';
export 'src/data/wordpress_product_publishing_repository.dart';
// Domain
export 'src/domain/product_draft.dart';
export 'src/domain/product_publishing_repository.dart';
export 'src/domain/publish_status.dart';
// Application
export 'src/application/update_product_status.dart';
// Presentation
export 'src/presentation/product_publishing_page.dart';

View File

@ -4,14 +4,22 @@ import '../domain/product_draft.dart';
import '../domain/publish_status.dart';
import 'get_product_drafts.dart';
import 'publish_product.dart';
import 'update_product_status.dart';
/// Controller that manages the product publishing workspace state, including
/// filtering by publish status, free-text search, and draft selection.
class ProductPublishingController extends ChangeNotifier {
final GetProductDrafts _getProductDrafts;
final PublishProduct _publishProduct;
final UpdateProductStatus _updateProductStatus;
ProductPublishingController(this._getProductDrafts, this._publishProduct);
ProductPublishingController(
this._getProductDrafts,
this._publishProduct,
this._updateProductStatus,
);
bool _disposed = false;
bool isLoading = false;
Object? error;
@ -33,22 +41,33 @@ class ProductPublishingController extends ChangeNotifier {
/// The current free-text search query applied to name / SKU.
String searchQuery = '';
/// Product IDs that currently have an in-flight status update.
///
/// Used to prevent duplicate clicks and to let the UI show per-row
/// progress indicators without locking the entire page.
final Set<String> updatingIds = {};
/// Whether the product with [id] is currently being updated.
bool isUpdating(String id) => updatingIds.contains(id);
/// Loads all product drafts and applies any current filter / search.
Future<void> load() async {
isLoading = true;
error = null;
notifyListeners();
_safeNotify();
try {
_allDrafts = await _getProductDrafts();
if (_disposed) return;
_applyFilters();
// Auto-select the first draft if nothing is selected.
selectedDraft ??= drafts.isNotEmpty ? drafts.first : null;
} catch (e) {
if (_disposed) return;
error = e;
} finally {
isLoading = false;
notifyListeners();
_safeNotify();
}
}
@ -56,20 +75,20 @@ class ProductPublishingController extends ChangeNotifier {
void setFilter(String? filter) {
activeFilter = filter;
_applyFilters();
notifyListeners();
_safeNotify();
}
/// Sets the search query and recomputes the visible list.
void setSearchQuery(String query) {
searchQuery = query;
_applyFilters();
notifyListeners();
_safeNotify();
}
/// Selects a draft for preview.
void selectDraft(ProductDraft draft) {
selectedDraft = draft;
notifyListeners();
_safeNotify();
}
/// Attempts to select a draft by SKU. Returns `true` if found.
@ -77,7 +96,7 @@ class ProductPublishingController extends ChangeNotifier {
final match = _allDrafts.where((d) => d.sku == sku).firstOrNull;
if (match != null) {
selectedDraft = match;
notifyListeners();
_safeNotify();
return true;
}
return false;
@ -87,15 +106,56 @@ class ProductPublishingController extends ChangeNotifier {
Future<void> publish(String id) async {
try {
await _publishProduct(id);
if (_disposed) return;
await load();
} catch (e) {
if (_disposed) return;
error = e;
notifyListeners();
_safeNotify();
}
}
/// Updates the publishing status of the product with [id].
///
/// Tracks per-row updating state via [updatingIds] so the UI can show
/// a spinner on the affected row without locking the entire page.
/// Silently ignores duplicate calls while [id] is already in flight.
Future<void> updateStatus(String id, PublishStatus status) async {
// Prevent duplicate clicks while this row is already updating.
if (updatingIds.contains(id)) return;
updatingIds.add(id);
_safeNotify();
try {
await _updateProductStatus(id, status);
if (_disposed) return;
updatingIds.remove(id);
await load();
} catch (e) {
if (_disposed) return;
updatingIds.remove(id);
error = e;
_safeNotify();
}
}
// Lifecycle
@override
void dispose() {
if (_disposed) return;
_disposed = true;
super.dispose();
}
// Private helpers
/// Calls [notifyListeners] only if the controller has not been disposed.
void _safeNotify() {
if (!_disposed) notifyListeners();
}
void _applyFilters() {
var result = _allDrafts;

View File

@ -0,0 +1,15 @@
import '../domain/product_draft.dart';
import '../domain/product_publishing_repository.dart';
import '../domain/publish_status.dart';
/// Use case: update the publishing status of a single product by its [id].
///
/// This is a narrow status mutation not a generic product edit.
class UpdateProductStatus {
final ProductPublishingRepository repository;
UpdateProductStatus(this.repository);
Future<ProductDraft> call(String id, PublishStatus status) =>
repository.updateProductStatus(id, status);
}

View File

@ -117,4 +117,29 @@ class FakeProductPublishingRepository implements ProductPublishingRepository {
_drafts[index] = updated;
return updated;
}
@override
Future<ProductDraft> updateProductStatus(String id, PublishStatus status) 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');
}
final original = _drafts[index];
final updated = ProductDraft(
id: original.id,
name: original.name,
description: original.description,
price: original.price,
sku: original.sku,
category: original.category,
imageUrl: original.imageUrl,
status: status,
lastModified: DateTime.now(),
);
_drafts[index] = updated;
return updated;
}
}

View File

@ -84,6 +84,35 @@ class WooCommerceApiClient {
return {'Authorization': 'Basic $credentials', 'Content-Type': 'application/json'};
}
/// Sends a partial update (PUT) to a single WooCommerce product.
///
/// Only the fields present in [body] are changed on the remote product.
/// Returns the full product JSON that WooCommerce echoes back.
Future<Map<String, dynamic>> updateProduct(String productId, Map<String, dynamic> body) async {
final uri = Uri.parse('$_baseEndpoint/products/$productId');
final response = await _httpClient.put(uri, headers: _authHeaders, body: jsonEncode(body));
if (response.statusCode != 200) {
throw WooCommerceApiException(
statusCode: response.statusCode,
message: 'Failed to update 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;
}
/// Releases the underlying HTTP client resources.
void dispose() {
_httpClient.close();

View File

@ -29,6 +29,29 @@ class WordPressProductMapper {
return jsonList.map(fromJson).toList();
}
/// Converts a [PublishStatus] domain value to the corresponding
/// WooCommerce REST API status string.
///
/// Only [PublishStatus.draft] and [PublishStatus.published] are supported
/// in the first pass. Throws [ArgumentError] for unsupported values.
///
/// WooCommerce accepts: `publish`, `draft`, `pending`, `private`, `trash`.
static String toWooCommerceStatus(PublishStatus status) {
switch (status) {
case PublishStatus.draft:
return 'draft';
case PublishStatus.published:
return 'publish';
case PublishStatus.pendingReview:
case PublishStatus.unpublished:
throw ArgumentError.value(
status,
'status',
'Unsupported status for WooCommerce update in the first pass',
);
}
}
// Private helpers
/// Maps the WooCommerce `status` string to our [PublishStatus] enum.

View File

@ -1,13 +1,15 @@
import '../domain/product_draft.dart';
import '../domain/product_publishing_repository.dart';
import '../domain/publish_status.dart';
import 'woo_commerce_api_client.dart';
import 'wordpress_product_mapper.dart';
/// Real [ProductPublishingRepository] backed by the WooCommerce REST API.
///
/// Currently implements **read-only** product retrieval. The [publishDraft]
/// method throws [UnimplementedError] full publishing support will be
/// added in a future iteration.
/// Supports read-only product retrieval and status updates via
/// `PUT /wp-json/wc/v3/products/{id}`. The [publishDraft] method throws
/// [UnimplementedError] full publishing support will be added in a future
/// iteration.
class WordPressProductPublishingRepository implements ProductPublishingRepository {
final WooCommerceApiClient _apiClient;
final WordPressProductMapper _mapper;
@ -34,4 +36,11 @@ class WordPressProductPublishingRepository implements ProductPublishingRepositor
'Use FakeProductPublishingRepository for development.',
);
}
@override
Future<ProductDraft> updateProductStatus(String id, PublishStatus status) async {
final wooStatus = WordPressProductMapper.toWooCommerceStatus(status);
final json = await _apiClient.updateProduct(id, {'status': wooStatus});
return _mapper.fromJson(json);
}
}

View File

@ -1,4 +1,5 @@
import 'product_draft.dart';
import 'publish_status.dart';
/// Contract for fetching and managing product drafts.
abstract class ProductPublishingRepository {
@ -7,4 +8,13 @@ abstract class ProductPublishingRepository {
/// Publishes a draft by [id]. Returns the updated draft.
Future<ProductDraft> publishDraft(String id);
/// Updates the publishing status of the product identified by [id].
///
/// Only [PublishStatus.draft] and [PublishStatus.published] are supported
/// in the first pass. Implementations may throw [ArgumentError] for
/// unsupported statuses.
///
/// Returns the updated [ProductDraft] reflecting the new status.
Future<ProductDraft> updateProductStatus(String id, PublishStatus status);
}

View File

@ -4,7 +4,9 @@ import 'package:flutter/material.dart';
import '../application/get_product_drafts.dart';
import '../application/product_publishing_controller.dart';
import '../application/publish_product.dart';
import '../application/update_product_status.dart';
import '../domain/product_publishing_repository.dart';
import '../domain/publish_status.dart';
import 'widgets/product_draft_card.dart';
import 'widgets/product_preview_panel.dart';
@ -48,7 +50,11 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
void initState() {
super.initState();
final repo = widget.repository;
controller = ProductPublishingController(GetProductDrafts(repo), PublishProduct(repo));
controller = ProductPublishingController(
GetProductDrafts(repo),
PublishProduct(repo),
UpdateProductStatus(repo),
);
// Apply any initial filter / query before loading.
if (widget.initialFilter != null) {
@ -131,7 +137,9 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
}
return ProductPreviewPanel(
draft: selected,
onPublish: () => controller.publish(selected.id),
isUpdating: controller.isUpdating(selected.id),
onPublish: () => controller.updateStatus(selected.id, PublishStatus.published),
onMoveToDraft: () => controller.updateStatus(selected.id, PublishStatus.draft),
onViewPolicy: widget.onViewPolicy,
);
}

View File

@ -7,16 +7,33 @@ import 'publish_status_chip.dart';
/// A detail panel that shows a full preview of the selected [ProductDraft].
///
/// Includes product image placeholder, description, metadata, and a
/// publish action button when the draft is not yet published.
/// Includes product image placeholder, description, metadata, and
/// status-aware action buttons:
/// - **Publish to Store** when the draft status is [PublishStatus.draft].
/// - **Move to Draft** when the draft status is [PublishStatus.published].
/// - No action button for other statuses.
class ProductPreviewPanel extends StatelessWidget {
final ProductDraft draft;
final VoidCallback? onPublish;
/// Callback to revert a published product back to draft status.
final VoidCallback? onMoveToDraft;
/// Whether this product currently has an in-flight status update.
/// When true, the action button is disabled and a progress indicator is shown.
final bool isUpdating;
/// Optional callback to navigate to the Policy page.
final VoidCallback? onViewPolicy;
const ProductPreviewPanel({super.key, required this.draft, this.onPublish, this.onViewPolicy});
const ProductPreviewPanel({
super.key,
required this.draft,
this.onPublish,
this.onMoveToDraft,
this.isUpdating = false,
this.onViewPolicy,
});
@override
Widget build(BuildContext context) {
@ -47,6 +64,13 @@ class ProductPreviewPanel extends StatelessWidget {
children: [
Expanded(child: Text(draft.name, style: theme.textTheme.headlineMedium)),
const SizedBox(width: KcSpacing.sm),
if (isUpdating)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
if (isUpdating) const SizedBox(width: KcSpacing.sm),
PublishStatusChip(status: draft.status),
],
),
@ -84,16 +108,25 @@ class ProductPreviewPanel extends StatelessWidget {
const SizedBox(height: KcSpacing.md),
],
// Publish button
if (draft.status != PublishStatus.published)
// Status action buttons
if (draft.status == PublishStatus.draft)
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: onPublish,
onPressed: isUpdating ? null : onPublish,
icon: const Icon(Icons.publish),
label: const Text('Publish to Store'),
),
),
if (draft.status == PublishStatus.published)
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: isUpdating ? null : onMoveToDraft,
icon: const Icon(Icons.unpublished_outlined),
label: const Text('Move to Draft'),
),
),
],
),
),

View File

@ -45,5 +45,81 @@ void main() {
test('publishDraft throws for unknown id', () async {
expect(() => repository.publishDraft('unknown'), throwsA(isA<StateError>()));
});
group('updateProductStatus', () {
test('draft to published updates only the target product', () async {
// Product 4 starts as draft.
final updated = await repository.updateProductStatus('4', PublishStatus.published);
expect(updated.id, '4');
expect(updated.status, PublishStatus.published);
expect(updated.name, 'Fabric Jar Gripper');
// Verify the change is persisted in the list.
final drafts = await repository.getProductDrafts();
final product4 = drafts.firstWhere((d) => d.id == '4');
expect(product4.status, PublishStatus.published);
});
test('published to draft updates only the target product', () async {
// Product 1 starts as published.
final updated = await repository.updateProductStatus('1', PublishStatus.draft);
expect(updated.id, '1');
expect(updated.status, PublishStatus.draft);
expect(updated.name, 'Floral Bowl Cozy');
// Verify the change is persisted in the list.
final drafts = await repository.getProductDrafts();
final product1 = drafts.firstWhere((d) => d.id == '1');
expect(product1.status, PublishStatus.draft);
});
test('preserves other products unchanged', () async {
final draftsBefore = await repository.getProductDrafts();
// Update product 4 only.
await repository.updateProductStatus('4', PublishStatus.published);
final draftsAfter = await repository.getProductDrafts();
// All other products should be identical.
for (final before in draftsBefore) {
if (before.id == '4') continue;
final after = draftsAfter.firstWhere((d) => d.id == before.id);
expect(after.name, before.name);
expect(after.status, before.status);
expect(after.price, before.price);
expect(after.sku, before.sku);
expect(after.category, before.category);
expect(after.description, before.description);
expect(after.imageUrl, before.imageUrl);
}
});
test('preserves all fields of the updated product except status and lastModified', () async {
final draftsBefore = await repository.getProductDrafts();
final before = draftsBefore.firstWhere((d) => d.id == '4');
final updated = await repository.updateProductStatus('4', PublishStatus.published);
expect(updated.name, before.name);
expect(updated.price, before.price);
expect(updated.sku, before.sku);
expect(updated.category, before.category);
expect(updated.description, before.description);
expect(updated.imageUrl, before.imageUrl);
// Status changed, lastModified updated.
expect(updated.status, PublishStatus.published);
expect(updated.lastModified.isAfter(before.lastModified), isTrue);
});
test('throws StateError for unknown id', () async {
expect(
() => repository.updateProductStatus('unknown', PublishStatus.draft),
throwsA(isA<StateError>()),
);
});
});
});
}

View File

@ -14,6 +14,7 @@ void main() {
controller = ProductPublishingController(
GetProductDrafts(repository),
PublishProduct(repository),
UpdateProductStatus(repository),
);
});
@ -29,6 +30,7 @@ void main() {
expect(controller.activeFilter, isNull);
expect(controller.searchQuery, '');
expect(controller.error, isNull);
expect(controller.updatingIds, isEmpty);
});
test('load populates drafts and auto-selects first', () async {
@ -129,5 +131,103 @@ void main() {
controller.setFilter('draft');
expect(controller.selectedDraft, isNull);
});
group('updateStatus', () {
test('draft to published updates status and reloads', () async {
await controller.load();
// Product 4 starts as draft.
await controller.updateStatus('4', PublishStatus.published);
final updated = controller.drafts.firstWhere((d) => d.id == '4');
expect(updated.status, PublishStatus.published);
expect(controller.error, isNull);
expect(controller.updatingIds, isEmpty);
});
test('published to draft updates status and reloads', () async {
await controller.load();
// Product 1 starts as published.
await controller.updateStatus('1', PublishStatus.draft);
final updated = controller.drafts.firstWhere((d) => d.id == '1');
expect(updated.status, PublishStatus.draft);
expect(controller.error, isNull);
expect(controller.updatingIds, isEmpty);
});
test('sets error on failed update', () async {
await controller.load();
// Unknown id triggers StateError in the fake repository.
await controller.updateStatus('unknown', PublishStatus.draft);
expect(controller.error, isA<StateError>());
expect(controller.updatingIds, isEmpty);
});
test('prevents duplicate calls while row is already updating', () async {
await controller.load();
// Start the first update but don't await it yet.
final first = controller.updateStatus('4', PublishStatus.published);
// While the first is in flight, the id should be tracked.
expect(controller.isUpdating('4'), isTrue);
// A second call for the same id should be silently ignored.
final second = controller.updateStatus('4', PublishStatus.published);
// Wait for both to complete.
await first;
await second;
// The product should be updated exactly once.
final updated = controller.drafts.firstWhere((d) => d.id == '4');
expect(updated.status, PublishStatus.published);
expect(controller.updatingIds, isEmpty);
});
test('isUpdating returns false for non-updating ids', () async {
await controller.load();
expect(controller.isUpdating('4'), isFalse);
});
test('does not notify after disposal on async completion', () async {
await controller.load();
// Start an update, then immediately dispose.
final future = controller.updateStatus('4', PublishStatus.published);
controller.dispose();
// This should complete without throwing (no notifyListeners after
// dispose).
await future;
// No error should be thrown the test itself passing is the assertion.
});
});
group('disposed guard', () {
test('load does not notify after disposal', () async {
// Start load, then immediately dispose.
final future = controller.load();
controller.dispose();
// Should complete without throwing.
await future;
});
test('publish does not notify after disposal', () async {
await controller.load();
final future = controller.publish('4');
controller.dispose();
// Should complete without throwing.
await future;
});
});
});
}

View File

@ -30,12 +30,34 @@ void main() {
lastModified: DateTime(2026, 3, 28),
);
Widget buildTestWidget(ProductDraft draft, {VoidCallback? onPublish}) {
final pendingDraft = ProductDraft(
id: '3',
name: 'Pending Product',
description: 'Awaiting review.',
price: 14.99,
sku: 'PR-001',
category: 'Nightlights',
imageUrl: '',
status: PublishStatus.pendingReview,
lastModified: DateTime(2026, 4, 1),
);
Widget buildTestWidget(
ProductDraft draft, {
VoidCallback? onPublish,
VoidCallback? onMoveToDraft,
bool isUpdating = false,
}) {
return MaterialApp(
theme: buildKcTheme(),
home: Scaffold(
body: SingleChildScrollView(
child: ProductPreviewPanel(draft: draft, onPublish: onPublish),
child: ProductPreviewPanel(
draft: draft,
onPublish: onPublish,
onMoveToDraft: onMoveToDraft,
isUpdating: isUpdating,
),
),
),
);
@ -67,16 +89,6 @@ void main() {
expect(find.text('Bowl Cozies'), findsOneWidget);
});
testWidgets('shows publish button for non-published drafts', (tester) async {
await tester.pumpWidget(buildTestWidget(sampleDraft));
expect(find.text('Publish to Store'), findsOneWidget);
});
testWidgets('hides publish button for published drafts', (tester) async {
await tester.pumpWidget(buildTestWidget(publishedDraft));
expect(find.text('Publish to Store'), findsNothing);
});
testWidgets('calls onPublish when button is tapped', (tester) async {
var published = false;
await tester.pumpWidget(buildTestWidget(sampleDraft, onPublish: () => published = true));
@ -84,4 +96,69 @@ void main() {
expect(published, true);
});
});
group('status-aware action buttons', () {
testWidgets('draft row shows Publish to Store button', (tester) async {
await tester.pumpWidget(buildTestWidget(sampleDraft));
expect(find.text('Publish to Store'), findsOneWidget);
expect(find.text('Move to Draft'), findsNothing);
});
testWidgets('published row shows Move to Draft button', (tester) async {
await tester.pumpWidget(buildTestWidget(publishedDraft));
expect(find.text('Move to Draft'), findsOneWidget);
expect(find.text('Publish to Store'), findsNothing);
});
testWidgets('pending review row shows no action button', (tester) async {
await tester.pumpWidget(buildTestWidget(pendingDraft));
expect(find.text('Publish to Store'), findsNothing);
expect(find.text('Move to Draft'), findsNothing);
});
testWidgets('calls onMoveToDraft when Move to Draft is tapped', (tester) async {
var movedToDraft = false;
await tester.pumpWidget(
buildTestWidget(publishedDraft, onMoveToDraft: () => movedToDraft = true),
);
await tester.tap(find.text('Move to Draft'));
expect(movedToDraft, true);
});
});
group('updating state', () {
testWidgets('Publish button is disabled while updating', (tester) async {
var tapped = false;
await tester.pumpWidget(
buildTestWidget(sampleDraft, isUpdating: true, onPublish: () => tapped = true),
);
// The button should be present but disabled.
expect(find.text('Publish to Store'), findsOneWidget);
await tester.tap(find.text('Publish to Store'));
expect(tapped, false);
});
testWidgets('Move to Draft button is disabled while updating', (tester) async {
var tapped = false;
await tester.pumpWidget(
buildTestWidget(publishedDraft, isUpdating: true, onMoveToDraft: () => tapped = true),
);
// The button should be present but disabled.
expect(find.text('Move to Draft'), findsOneWidget);
await tester.tap(find.text('Move to Draft'));
expect(tapped, false);
});
testWidgets('shows progress indicator while updating', (tester) async {
await tester.pumpWidget(buildTestWidget(sampleDraft, isUpdating: true));
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('hides progress indicator when not updating', (tester) async {
await tester.pumpWidget(buildTestWidget(sampleDraft, isUpdating: false));
expect(find.byType(CircularProgressIndicator), findsNothing);
});
});
}

View File

@ -166,6 +166,30 @@ void main() {
});
});
group('WordPressProductMapper.toWooCommerceStatus', () {
test('draft -> "draft"', () {
expect(WordPressProductMapper.toWooCommerceStatus(PublishStatus.draft), 'draft');
});
test('published -> "publish"', () {
expect(WordPressProductMapper.toWooCommerceStatus(PublishStatus.published), 'publish');
});
test('pendingReview throws ArgumentError', () {
expect(
() => WordPressProductMapper.toWooCommerceStatus(PublishStatus.pendingReview),
throwsA(isA<ArgumentError>()),
);
});
test('unpublished throws ArgumentError', () {
expect(
() => WordPressProductMapper.toWooCommerceStatus(PublishStatus.unpublished),
throwsA(isA<ArgumentError>()),
);
});
});
group('WordPressProductMapper.fromJsonList', () {
test('maps a list of JSON objects', () {
final jsonList = [

View File

@ -153,5 +153,157 @@ void main() {
expect(() => repository.getProductDrafts(), throwsA(isA<WooCommerceApiException>()));
});
group('updateProductStatus', () {
/// Builds a single WooCommerce product JSON response with the given
/// [id] and [status].
Map<String, dynamic> buildSingleProductJson({required int id, required String status}) {
return {
'id': id,
'name': 'Product $id',
'description': '<p>Description</p>',
'price': '10.99',
'sku': 'SKU-$id',
'status': status,
'date_modified': '2026-04-01T10:00:00',
'date_created': '2026-03-01T08:00:00',
'categories': [
{'id': 1, 'name': 'Test Category', 'slug': 'test-category'},
],
'images': [
{'id': 1, 'src': 'https://example.com/img$id.jpg'},
],
};
}
test('sends PUT with minimal status-only payload and returns mapped ProductDraft', () async {
Map<String, dynamic>? capturedBody;
Uri? capturedUri;
String? capturedMethod;
final mockClient = MockClient((request) async {
capturedMethod = request.method;
capturedUri = request.url;
capturedBody = jsonDecode(request.body) as Map<String, dynamic>;
return http.Response(jsonEncode(buildSingleProductJson(id: 7, status: 'publish')), 200);
});
final apiClient = WooCommerceApiClient(
siteUrl: 'https://store.example.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
httpClient: mockClient,
);
final repository = WordPressProductPublishingRepository(apiClient: apiClient);
final result = await repository.updateProductStatus('7', PublishStatus.published);
// Verify the HTTP request shape.
expect(capturedMethod, 'PUT');
expect(capturedUri!.path, '/wp-json/wc/v3/products/7');
expect(capturedBody, {'status': 'publish'});
// Verify the returned domain object is cleanly mapped.
expect(result, isA<ProductDraft>());
expect(result.id, '7');
expect(result.name, 'Product 7');
expect(result.status, PublishStatus.published);
});
test('maps PublishStatus.draft to WooCommerce "draft" in the request', () async {
Map<String, dynamic>? capturedBody;
final mockClient = MockClient((request) async {
capturedBody = jsonDecode(request.body) as Map<String, dynamic>;
return http.Response(jsonEncode(buildSingleProductJson(id: 3, status: 'draft')), 200);
});
final apiClient = WooCommerceApiClient(
siteUrl: 'https://store.example.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
httpClient: mockClient,
);
final repository = WordPressProductPublishingRepository(apiClient: apiClient);
final result = await repository.updateProductStatus('3', PublishStatus.draft);
expect(capturedBody, {'status': 'draft'});
expect(result.status, PublishStatus.draft);
});
test('throws WooCommerceApiException on non-200 response', () async {
final mockClient = MockClient((request) async {
return http.Response('{"code":"rest_cannot_edit"}', 403);
});
final apiClient = WooCommerceApiClient(
siteUrl: 'https://store.example.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
httpClient: mockClient,
);
final repository = WordPressProductPublishingRepository(apiClient: apiClient);
expect(
() => repository.updateProductStatus('7', PublishStatus.published),
throwsA(isA<WooCommerceApiException>()),
);
});
test('throws ArgumentError for unsupported status values', () {
final mockClient = MockClient((request) async {
return http.Response('{}', 200);
});
final apiClient = WooCommerceApiClient(
siteUrl: 'https://store.example.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
httpClient: mockClient,
);
final repository = WordPressProductPublishingRepository(apiClient: apiClient);
expect(
() => repository.updateProductStatus('1', PublishStatus.pendingReview),
throwsA(isA<ArgumentError>()),
);
expect(
() => repository.updateProductStatus('1', PublishStatus.unpublished),
throwsA(isA<ArgumentError>()),
);
});
test('returns ProductDraft, not raw WooCommerce JSON (package boundary)', () async {
final mockClient = MockClient((request) async {
// WooCommerce echoes back many extra fields that should NOT leak.
final json = buildSingleProductJson(id: 5, status: 'publish');
json['permalink'] = 'https://store.example.com/product/5';
json['meta_data'] = [
{'id': 1, 'key': '_internal', 'value': 'secret'},
];
return http.Response(jsonEncode(json), 200);
});
final apiClient = WooCommerceApiClient(
siteUrl: 'https://store.example.com',
consumerKey: 'ck_test',
consumerSecret: 'cs_test',
httpClient: mockClient,
);
final repository = WordPressProductPublishingRepository(apiClient: apiClient);
final result = await repository.updateProductStatus('5', PublishStatus.published);
// The result is a domain ProductDraft no WooCommerce-specific
// fields like permalink or meta_data are exposed.
expect(result, isA<ProductDraft>());
expect(result.id, '5');
expect(result.status, PublishStatus.published);
});
});
});
}