Update main with product publishing
Publish Docs / publish-docs (push) Successful in 1m57s
Details
Publish Docs / publish-docs (push) Successful in 1m57s
Details
This commit is contained in:
parent
d8f8fb6797
commit
039612cb6e
|
|
@ -25,6 +25,10 @@ class _StubProductPublishingRepository implements ProductPublishingRepository {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ProductDraft> publishDraft(String id) => throw UnimplementedError();
|
Future<ProductDraft> publishDraft(String id) => throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProductDraft> updateProductStatus(String id, PublishStatus status) =>
|
||||||
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StubOrdersRepository implements OrdersRepository {
|
class _StubOrdersRepository implements OrdersRepository {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"version":2,"entries":[{"package":"design_system","rootUri":"../../design_system/","packageUri":"lib/"},{"package":"feature_orders","rootUri":"../","packageUri":"lib/"}]}
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
// Data
|
||||||
export 'src/data/fake_product_publishing_repository.dart';
|
export 'src/data/fake_product_publishing_repository.dart';
|
||||||
export 'src/data/woo_commerce_api_client.dart';
|
export 'src/data/woo_commerce_api_client.dart';
|
||||||
export 'src/data/wordpress_product_mapper.dart';
|
export 'src/data/wordpress_product_mapper.dart';
|
||||||
export 'src/data/wordpress_product_publishing_repository.dart';
|
export 'src/data/wordpress_product_publishing_repository.dart';
|
||||||
|
|
||||||
|
// Domain
|
||||||
export 'src/domain/product_draft.dart';
|
export 'src/domain/product_draft.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
|
||||||
|
export 'src/application/update_product_status.dart';
|
||||||
|
|
||||||
|
// Presentation
|
||||||
export 'src/presentation/product_publishing_page.dart';
|
export 'src/presentation/product_publishing_page.dart';
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,22 @@ import '../domain/product_draft.dart';
|
||||||
import '../domain/publish_status.dart';
|
import '../domain/publish_status.dart';
|
||||||
import 'get_product_drafts.dart';
|
import 'get_product_drafts.dart';
|
||||||
import 'publish_product.dart';
|
import 'publish_product.dart';
|
||||||
|
import 'update_product_status.dart';
|
||||||
|
|
||||||
/// Controller that manages the product publishing workspace state, including
|
/// Controller that manages the product publishing workspace state, including
|
||||||
/// filtering by publish status, free-text search, and draft selection.
|
/// filtering by publish status, free-text search, and draft selection.
|
||||||
class ProductPublishingController extends ChangeNotifier {
|
class ProductPublishingController extends ChangeNotifier {
|
||||||
final GetProductDrafts _getProductDrafts;
|
final GetProductDrafts _getProductDrafts;
|
||||||
final PublishProduct _publishProduct;
|
final PublishProduct _publishProduct;
|
||||||
|
final UpdateProductStatus _updateProductStatus;
|
||||||
|
|
||||||
ProductPublishingController(this._getProductDrafts, this._publishProduct);
|
ProductPublishingController(
|
||||||
|
this._getProductDrafts,
|
||||||
|
this._publishProduct,
|
||||||
|
this._updateProductStatus,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
Object? error;
|
Object? error;
|
||||||
|
|
@ -33,22 +41,33 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
/// The current free-text search query applied to name / SKU.
|
/// The current free-text search query applied to name / SKU.
|
||||||
String searchQuery = '';
|
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.
|
/// Loads all product drafts and applies any current filter / search.
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
error = null;
|
error = null;
|
||||||
notifyListeners();
|
_safeNotify();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_allDrafts = await _getProductDrafts();
|
_allDrafts = await _getProductDrafts();
|
||||||
|
if (_disposed) return;
|
||||||
_applyFilters();
|
_applyFilters();
|
||||||
// Auto-select the first draft if nothing is selected.
|
// Auto-select the first draft if nothing is selected.
|
||||||
selectedDraft ??= drafts.isNotEmpty ? drafts.first : null;
|
selectedDraft ??= drafts.isNotEmpty ? drafts.first : null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (_disposed) return;
|
||||||
error = e;
|
error = e;
|
||||||
} finally {
|
} finally {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
notifyListeners();
|
_safeNotify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,20 +75,20 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
void setFilter(String? filter) {
|
void setFilter(String? filter) {
|
||||||
activeFilter = filter;
|
activeFilter = filter;
|
||||||
_applyFilters();
|
_applyFilters();
|
||||||
notifyListeners();
|
_safeNotify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the search query and recomputes the visible list.
|
/// Sets the search query and recomputes the visible list.
|
||||||
void setSearchQuery(String query) {
|
void setSearchQuery(String query) {
|
||||||
searchQuery = query;
|
searchQuery = query;
|
||||||
_applyFilters();
|
_applyFilters();
|
||||||
notifyListeners();
|
_safeNotify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Selects a draft for preview.
|
/// Selects a draft for preview.
|
||||||
void selectDraft(ProductDraft draft) {
|
void selectDraft(ProductDraft draft) {
|
||||||
selectedDraft = draft;
|
selectedDraft = draft;
|
||||||
notifyListeners();
|
_safeNotify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attempts to select a draft by SKU. Returns `true` if found.
|
/// 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;
|
final match = _allDrafts.where((d) => d.sku == sku).firstOrNull;
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
selectedDraft = match;
|
selectedDraft = match;
|
||||||
notifyListeners();
|
_safeNotify();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -87,15 +106,56 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
Future<void> publish(String id) async {
|
Future<void> publish(String id) async {
|
||||||
try {
|
try {
|
||||||
await _publishProduct(id);
|
await _publishProduct(id);
|
||||||
|
if (_disposed) return;
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (_disposed) return;
|
||||||
error = e;
|
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 ────────────────────────────────────────────────────
|
// ── Private helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Calls [notifyListeners] only if the controller has not been disposed.
|
||||||
|
void _safeNotify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
void _applyFilters() {
|
void _applyFilters() {
|
||||||
var result = _allDrafts;
|
var result = _allDrafts;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -117,4 +117,29 @@ class FakeProductPublishingRepository implements ProductPublishingRepository {
|
||||||
_drafts[index] = updated;
|
_drafts[index] = updated;
|
||||||
return 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,35 @@ class WooCommerceApiClient {
|
||||||
return {'Authorization': 'Basic $credentials', 'Content-Type': 'application/json'};
|
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.
|
/// Releases the underlying HTTP client resources.
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_httpClient.close();
|
_httpClient.close();
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,29 @@ class WordPressProductMapper {
|
||||||
return jsonList.map(fromJson).toList();
|
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 ──────────────────────────────────────────────────
|
// ── Private helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Maps the WooCommerce `status` string to our [PublishStatus] enum.
|
/// Maps the WooCommerce `status` string to our [PublishStatus] enum.
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import '../domain/product_draft.dart';
|
import '../domain/product_draft.dart';
|
||||||
import '../domain/product_publishing_repository.dart';
|
import '../domain/product_publishing_repository.dart';
|
||||||
|
import '../domain/publish_status.dart';
|
||||||
import 'woo_commerce_api_client.dart';
|
import 'woo_commerce_api_client.dart';
|
||||||
import 'wordpress_product_mapper.dart';
|
import 'wordpress_product_mapper.dart';
|
||||||
|
|
||||||
/// Real [ProductPublishingRepository] backed by the WooCommerce REST API.
|
/// Real [ProductPublishingRepository] backed by the WooCommerce REST API.
|
||||||
///
|
///
|
||||||
/// Currently implements **read-only** product retrieval. The [publishDraft]
|
/// Supports read-only product retrieval and status updates via
|
||||||
/// method throws [UnimplementedError] — full publishing support will be
|
/// `PUT /wp-json/wc/v3/products/{id}`. The [publishDraft] method throws
|
||||||
/// added in a future iteration.
|
/// [UnimplementedError] — full publishing support will be added in a future
|
||||||
|
/// iteration.
|
||||||
class WordPressProductPublishingRepository implements ProductPublishingRepository {
|
class WordPressProductPublishingRepository implements ProductPublishingRepository {
|
||||||
final WooCommerceApiClient _apiClient;
|
final WooCommerceApiClient _apiClient;
|
||||||
final WordPressProductMapper _mapper;
|
final WordPressProductMapper _mapper;
|
||||||
|
|
@ -34,4 +36,11 @@ class WordPressProductPublishingRepository implements ProductPublishingRepositor
|
||||||
'Use FakeProductPublishingRepository for development.',
|
'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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'product_draft.dart';
|
import 'product_draft.dart';
|
||||||
|
import 'publish_status.dart';
|
||||||
|
|
||||||
/// Contract for fetching and managing product drafts.
|
/// Contract for fetching and managing product drafts.
|
||||||
abstract class ProductPublishingRepository {
|
abstract class ProductPublishingRepository {
|
||||||
|
|
@ -7,4 +8,13 @@ abstract class ProductPublishingRepository {
|
||||||
|
|
||||||
/// Publishes a draft by [id]. Returns the updated draft.
|
/// Publishes a draft by [id]. Returns the updated draft.
|
||||||
Future<ProductDraft> publishDraft(String id);
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ import 'package:flutter/material.dart';
|
||||||
import '../application/get_product_drafts.dart';
|
import '../application/get_product_drafts.dart';
|
||||||
import '../application/product_publishing_controller.dart';
|
import '../application/product_publishing_controller.dart';
|
||||||
import '../application/publish_product.dart';
|
import '../application/publish_product.dart';
|
||||||
|
import '../application/update_product_status.dart';
|
||||||
import '../domain/product_publishing_repository.dart';
|
import '../domain/product_publishing_repository.dart';
|
||||||
|
import '../domain/publish_status.dart';
|
||||||
import 'widgets/product_draft_card.dart';
|
import 'widgets/product_draft_card.dart';
|
||||||
import 'widgets/product_preview_panel.dart';
|
import 'widgets/product_preview_panel.dart';
|
||||||
|
|
||||||
|
|
@ -48,7 +50,11 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final repo = widget.repository;
|
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.
|
// Apply any initial filter / query before loading.
|
||||||
if (widget.initialFilter != null) {
|
if (widget.initialFilter != null) {
|
||||||
|
|
@ -131,7 +137,9 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
||||||
}
|
}
|
||||||
return ProductPreviewPanel(
|
return ProductPreviewPanel(
|
||||||
draft: selected,
|
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,
|
onViewPolicy: widget.onViewPolicy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,33 @@ import 'publish_status_chip.dart';
|
||||||
|
|
||||||
/// A detail panel that shows a full preview of the selected [ProductDraft].
|
/// A detail panel that shows a full preview of the selected [ProductDraft].
|
||||||
///
|
///
|
||||||
/// Includes product image placeholder, description, metadata, and a
|
/// Includes product image placeholder, description, metadata, and
|
||||||
/// publish action button when the draft is not yet published.
|
/// 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 {
|
class ProductPreviewPanel extends StatelessWidget {
|
||||||
final ProductDraft draft;
|
final ProductDraft draft;
|
||||||
final VoidCallback? onPublish;
|
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.
|
/// Optional callback to navigate to the Policy page.
|
||||||
final VoidCallback? onViewPolicy;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -47,6 +64,13 @@ class ProductPreviewPanel extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: Text(draft.name, style: theme.textTheme.headlineMedium)),
|
Expanded(child: Text(draft.name, style: theme.textTheme.headlineMedium)),
|
||||||
const SizedBox(width: KcSpacing.sm),
|
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),
|
PublishStatusChip(status: draft.status),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -84,16 +108,25 @@ class ProductPreviewPanel extends StatelessWidget {
|
||||||
const SizedBox(height: KcSpacing.md),
|
const SizedBox(height: KcSpacing.md),
|
||||||
],
|
],
|
||||||
|
|
||||||
// ── Publish button ─────────────────────────────────────────
|
// ── Status action buttons ────────────────────────────────
|
||||||
if (draft.status != PublishStatus.published)
|
if (draft.status == PublishStatus.draft)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: onPublish,
|
onPressed: isUpdating ? null : onPublish,
|
||||||
icon: const Icon(Icons.publish),
|
icon: const Icon(Icons.publish),
|
||||||
label: const Text('Publish to Store'),
|
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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -45,5 +45,81 @@ void main() {
|
||||||
test('publishDraft throws for unknown id', () async {
|
test('publishDraft throws for unknown id', () async {
|
||||||
expect(() => repository.publishDraft('unknown'), throwsA(isA<StateError>()));
|
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>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ void main() {
|
||||||
controller = ProductPublishingController(
|
controller = ProductPublishingController(
|
||||||
GetProductDrafts(repository),
|
GetProductDrafts(repository),
|
||||||
PublishProduct(repository),
|
PublishProduct(repository),
|
||||||
|
UpdateProductStatus(repository),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -29,6 +30,7 @@ void main() {
|
||||||
expect(controller.activeFilter, isNull);
|
expect(controller.activeFilter, isNull);
|
||||||
expect(controller.searchQuery, '');
|
expect(controller.searchQuery, '');
|
||||||
expect(controller.error, isNull);
|
expect(controller.error, isNull);
|
||||||
|
expect(controller.updatingIds, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('load populates drafts and auto-selects first', () async {
|
test('load populates drafts and auto-selects first', () async {
|
||||||
|
|
@ -129,5 +131,103 @@ void main() {
|
||||||
controller.setFilter('draft');
|
controller.setFilter('draft');
|
||||||
expect(controller.selectedDraft, isNull);
|
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;
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,34 @@ void main() {
|
||||||
lastModified: DateTime(2026, 3, 28),
|
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(
|
return MaterialApp(
|
||||||
theme: buildKcTheme(),
|
theme: buildKcTheme(),
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
body: SingleChildScrollView(
|
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);
|
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 {
|
testWidgets('calls onPublish when button is tapped', (tester) async {
|
||||||
var published = false;
|
var published = false;
|
||||||
await tester.pumpWidget(buildTestWidget(sampleDraft, onPublish: () => published = true));
|
await tester.pumpWidget(buildTestWidget(sampleDraft, onPublish: () => published = true));
|
||||||
|
|
@ -84,4 +96,69 @@ void main() {
|
||||||
expect(published, true);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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', () {
|
group('WordPressProductMapper.fromJsonList', () {
|
||||||
test('maps a list of JSON objects', () {
|
test('maps a list of JSON objects', () {
|
||||||
final jsonList = [
|
final jsonList = [
|
||||||
|
|
|
||||||
|
|
@ -153,5 +153,157 @@ void main() {
|
||||||
|
|
||||||
expect(() => repository.getProductDrafts(), throwsA(isA<WooCommerceApiException>()));
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue