feat(integrations): Stage 8A - integration abstractions contracts
- Add ApiException base class (statusCode, message, body) - Add ApiClient abstract class (initialize, dispose, isReady, execute<T>) - Add RetryPolicy abstraction (NoRetryPolicy, ExponentialBackoffRetryPolicy) - Add RateLimiter abstraction (NoRateLimiter, TokenBucketRateLimiter) - Add IntegrationHealthCheck contract (HealthCheckResult value object) - Add WebhookHandler contract (WebhookEvent, WebhookResult, WebhookProcessingStatus) - Add ChannelAdapter contract (ChannelProduct, ChannelOrder, ChannelOrderItem, ChannelInventoryCount) - Replace Calculator placeholder with barrel exports for all 7 contract files - Refactor WooCommerceApiClient to implement ApiClient contract - Refactor WooCommerceApiException to extend ApiException - Add integrations dependency to feature_wordpress pubspec - Add packages/integrations to TESTABLE and ANALYZABLE in run_all_tests.sh - Add 38 tests for all contracts and value objects - dart analyze clean (no issues in integrations and feature_wordpress) - Tests: 38/38 integrations, 319/319 feature_wordpress
This commit is contained in:
parent
c67f92f844
commit
ec66626074
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
- main baseline updated through: bulk-operator-workflows (Stage 7B complete)
|
- main baseline updated through: integrations-contracts (Stage 8A complete)
|
||||||
- next branch: feat/integrations-contracts (Stage 8A)
|
- next branch: feat/data-layer-contracts (Stage 8B)
|
||||||
- current stage: Stage 7 complete — proceeding to infrastructure activation
|
- current stage: Stage 8A complete — integration abstractions activated
|
||||||
|
|
||||||
## Slice tracker
|
## Slice tracker
|
||||||
|
|
||||||
|
|
@ -378,6 +378,30 @@
|
||||||
- analyze: passed (dart analyze --fatal-infos — no issues found across all packages)
|
- analyze: passed (dart analyze --fatal-infos — no issues found across all packages)
|
||||||
- brief updated: yes
|
- brief updated: yes
|
||||||
|
|
||||||
|
### feat/integrations-contracts
|
||||||
|
|
||||||
|
- status: merged to main
|
||||||
|
- date: 2026-07-11
|
||||||
|
- inspection: complete
|
||||||
|
- implementation: complete
|
||||||
|
- files changed:
|
||||||
|
- `integrations/lib/src/api_exception.dart` — new `ApiException` base class with `statusCode`, `message`, `body`; replaces placeholder `Calculator` class
|
||||||
|
- `integrations/lib/src/api_client.dart` — new `ApiClient` abstract class with `initialize()`, `dispose()`, `isReady`, `execute<T>()` lifecycle contract
|
||||||
|
- `integrations/lib/src/retry_policy.dart` — new `RetryPolicy` abstract class; `NoRetryPolicy` (never retries); `ExponentialBackoffRetryPolicy` (doubles delay per attempt, capped at `maxDelay`, retries on 5xx and network errors)
|
||||||
|
- `integrations/lib/src/rate_limiter.dart` — new `RateLimiter` abstract class; `NoRateLimiter` (no-op); `TokenBucketRateLimiter` (maxRequests per window, queues excess requests)
|
||||||
|
- `integrations/lib/src/integration_health_check.dart` — new `HealthCheckResult` value object with `healthy`/`unhealthy` named constructors; `IntegrationHealthCheck` abstract contract
|
||||||
|
- `integrations/lib/src/webhook_handler.dart` — new `WebhookEvent`, `WebhookProcessingStatus` enum, `WebhookResult` with `processed`/`ignored`/`failed` constructors; `WebhookHandler` abstract contract
|
||||||
|
- `integrations/lib/src/channel_adapter.dart` — new `ChannelProduct`, `ChannelOrder`, `ChannelOrderItem`, `ChannelInventoryCount` neutral value objects; `ChannelAdapter` abstract contract with products/orders/inventory operations
|
||||||
|
- `integrations/lib/integrations.dart` — replaced `Calculator` placeholder with barrel exports for all 7 new contract files
|
||||||
|
- `integrations/test/integrations_test.dart` — replaced placeholder test with 38 tests covering all contracts and value objects
|
||||||
|
- `feature_wordpress/lib/src/data/woo_commerce_api_client.dart` — `WooCommerceApiClient` now implements `ApiClient`; added `initialize()`, `isReady`, `execute<T>()`; `dispose()` also resets `_ready`
|
||||||
|
- `feature_wordpress/lib/src/data/woo_commerce_api_client.dart` — `WooCommerceApiException` now extends `ApiException` instead of implementing `Exception` directly
|
||||||
|
- `feature_wordpress/pubspec.yaml` — added `integrations: path: ../integrations` dependency
|
||||||
|
- `kell_creations_apps/tools/run_all_tests.sh` — added `packages/integrations` to both `TESTABLE` and `ANALYZABLE` lists
|
||||||
|
- tests: passed (38/38 integrations, 319/319 feature_wordpress — 357 total for affected packages)
|
||||||
|
- analyze: passed (dart analyze --fatal-infos — no issues found in integrations and feature_wordpress)
|
||||||
|
- brief updated: yes
|
||||||
|
|
||||||
### feat/bulk-operator-workflows
|
### feat/bulk-operator-workflows
|
||||||
|
|
||||||
- status: merged to main
|
- status: merged to main
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ Rules:
|
||||||
- Scaffolded but **empty stub** packages (created, no implementation yet):
|
- Scaffolded but **empty stub** packages (created, no implementation yet):
|
||||||
- `auth` — authentication/authorization (stub only)
|
- `auth` — authentication/authorization (stub only)
|
||||||
- `data` — shared data layer (stub only)
|
- `data` — shared data layer (stub only)
|
||||||
- `integrations` — shared integration abstractions (stub only)
|
- `integrations` — ✅ integration contracts activated (Stage 8A complete); `ApiClient`, `ApiException`, `RetryPolicy`, `RateLimiter`, `IntegrationHealthCheck`, `WebhookHandler`, `ChannelAdapter` contracts defined; `WooCommerceApiClient` and `WooCommerceApiException` refactored to implement shared contracts
|
||||||
- `feature_finance` — financial analysis feature (stub only)
|
- `feature_finance` — financial analysis feature (stub only)
|
||||||
- `feature_mrp` — craft manufacturing/MRP feature (stub only)
|
- `feature_mrp` — craft manufacturing/MRP feature (stub only)
|
||||||
- `feature_social` — social media management feature (stub only)
|
- `feature_social` — social media management feature (stub only)
|
||||||
|
|
@ -122,6 +122,7 @@ Rules:
|
||||||
- ✅ Orders mobile surface landed (Stage 6B-orders complete — merged `feat/orders-mobile-surface` → `main`, 2026-07-10).
|
- ✅ Orders mobile surface landed (Stage 6B-orders complete — merged `feat/orders-mobile-surface` → `main`, 2026-07-10).
|
||||||
- ✅ Inventory mobile surface landed (Stage 6C complete — merged `feat/inventory-mobile-surface` → `main`, 2026-07-10). Stage 6 complete.
|
- ✅ Inventory mobile surface landed (Stage 6C complete — merged `feat/inventory-mobile-surface` → `main`, 2026-07-10). Stage 6 complete.
|
||||||
- ✅ Bulk publish and bulk submit-for-review actions landed (Stage 7B complete — merged `feat/bulk-operator-workflows` → `main`, 2026-07-11). Stage 7 complete.
|
- ✅ Bulk publish and bulk submit-for-review actions landed (Stage 7B complete — merged `feat/bulk-operator-workflows` → `main`, 2026-07-11). Stage 7 complete.
|
||||||
|
- ✅ Integration abstractions activated (Stage 8A complete — merged `feat/integrations-contracts` → `main`, 2026-07-11).
|
||||||
|
|
||||||
### Current narrow edit capabilities on `main`
|
### Current narrow edit capabilities on `main`
|
||||||
|
|
||||||
|
|
@ -145,9 +146,10 @@ Rules:
|
||||||
- latest reported count for `feature_orders`: `115/115 passed`
|
- latest reported count for `feature_orders`: `115/115 passed`
|
||||||
- latest reported count for `feature_wordpress`: `319/319 passed`
|
- latest reported count for `feature_wordpress`: `319/319 passed`
|
||||||
- latest reported count for `kell_web`: `24/24 passed`
|
- latest reported count for `kell_web`: `24/24 passed`
|
||||||
|
- latest reported count for `integrations`: `38/38 passed`
|
||||||
- latest reported count for `kell_mobile`: `26/26 passed`
|
- latest reported count for `kell_mobile`: `26/26 passed`
|
||||||
- total: `620/620 passed`
|
- total: `658/658 passed`
|
||||||
- baseline commit: merge of `feat/bulk-operator-workflows` into `main` (Stage 7B complete, 2026-07-11)
|
- baseline commit: merge of `feat/integrations-contracts` into `main` (Stage 8A complete, 2026-07-11)
|
||||||
|
|
||||||
#### Baseline test coverage (established 2026-05-22)
|
#### Baseline test coverage (established 2026-05-22)
|
||||||
|
|
||||||
|
|
@ -163,8 +165,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag
|
||||||
|
|
||||||
### Next recommended branch
|
### Next recommended branch
|
||||||
|
|
||||||
**`feat/integrations-contracts`** — Stage 8A: Integration abstractions.
|
**`feat/data-layer-contracts`** — Stage 8B: Shared data layer abstractions.
|
||||||
Branch from latest `main`. Stage 7 (bulk actions) is complete. Stage 8A begins infrastructure activation required for all subsequent feature work (Stages 9–16).
|
Branch from latest `main`. Stage 8A (integration abstractions) is complete. Stage 8B provides shared data abstractions for local caching, offline storage, and cross-feature data patterns required by Stages 9–16.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:integrations/integrations.dart';
|
||||||
|
|
||||||
/// Lightweight HTTP client for the WooCommerce REST API v3.
|
/// Lightweight HTTP client for the WooCommerce REST API v3.
|
||||||
///
|
///
|
||||||
|
/// Implements [ApiClient] so it satisfies the shared integration contract.
|
||||||
/// Handles authentication (Basic Auth over HTTPS) and pagination.
|
/// Handles authentication (Basic Auth over HTTPS) and pagination.
|
||||||
/// Only read-only product listing is implemented for now.
|
class WooCommerceApiClient implements ApiClient {
|
||||||
class WooCommerceApiClient {
|
|
||||||
/// Base URL of the WordPress site (e.g. `https://store.kellcreations.com`).
|
/// Base URL of the WordPress site (e.g. `https://store.kellcreations.com`).
|
||||||
final String siteUrl;
|
final String siteUrl;
|
||||||
|
|
||||||
|
|
@ -19,6 +20,8 @@ class WooCommerceApiClient {
|
||||||
/// Optional [http.Client] for testing / injection.
|
/// Optional [http.Client] for testing / injection.
|
||||||
final http.Client _httpClient;
|
final http.Client _httpClient;
|
||||||
|
|
||||||
|
bool _ready = false;
|
||||||
|
|
||||||
WooCommerceApiClient({
|
WooCommerceApiClient({
|
||||||
required this.siteUrl,
|
required this.siteUrl,
|
||||||
required this.consumerKey,
|
required this.consumerKey,
|
||||||
|
|
@ -26,6 +29,31 @@ class WooCommerceApiClient {
|
||||||
http.Client? httpClient,
|
http.Client? httpClient,
|
||||||
}) : _httpClient = httpClient ?? http.Client();
|
}) : _httpClient = httpClient ?? http.Client();
|
||||||
|
|
||||||
|
// ── ApiClient contract ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> initialize() async {
|
||||||
|
// No async setup required for Basic Auth; mark ready immediately.
|
||||||
|
_ready = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isReady => _ready;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T> execute<T>(Future<T> Function() request) async {
|
||||||
|
if (!_ready) await initialize();
|
||||||
|
return request();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_httpClient.close();
|
||||||
|
_ready = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WooCommerce-specific API methods ──────────────────────────────────────
|
||||||
|
|
||||||
/// The base endpoint for WooCommerce REST API v3.
|
/// The base endpoint for WooCommerce REST API v3.
|
||||||
String get _baseEndpoint => '$siteUrl/wp-json/wc/v3';
|
String get _baseEndpoint => '$siteUrl/wp-json/wc/v3';
|
||||||
|
|
||||||
|
|
@ -112,23 +140,18 @@ class WooCommerceApiClient {
|
||||||
|
|
||||||
return decoded;
|
return decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Releases the underlying HTTP client resources.
|
|
||||||
void dispose() {
|
|
||||||
_httpClient.close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exception thrown when the WooCommerce API returns an error.
|
/// Exception thrown when the WooCommerce API returns an error.
|
||||||
class WooCommerceApiException implements Exception {
|
///
|
||||||
final int statusCode;
|
/// Extends the shared [ApiException] contract so callers can catch either
|
||||||
final String message;
|
/// [WooCommerceApiException] for WooCommerce-specific handling or
|
||||||
final String body;
|
/// [ApiException] for generic error handling.
|
||||||
|
class WooCommerceApiException extends ApiException {
|
||||||
const WooCommerceApiException({
|
const WooCommerceApiException({
|
||||||
required this.statusCode,
|
required super.statusCode,
|
||||||
required this.message,
|
required super.message,
|
||||||
required this.body,
|
super.body = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ dependencies:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
design_system:
|
design_system:
|
||||||
path: ../design_system
|
path: ../design_system
|
||||||
|
integrations:
|
||||||
|
path: ../integrations
|
||||||
http: ^1.4.0
|
http: ^1.4.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
/// A Calculator.
|
/// Shared integration abstractions for the Kell Creations platform.
|
||||||
class Calculator {
|
///
|
||||||
/// Returns [value] plus 1.
|
/// This library defines the contracts that all external channel integrations
|
||||||
int addOne(int value) => value + 1;
|
/// (WooCommerce, Square, etc.) must implement. Concrete implementations live
|
||||||
}
|
/// in their respective feature or data packages.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/api_client.dart';
|
||||||
|
export 'src/api_exception.dart';
|
||||||
|
export 'src/channel_adapter.dart';
|
||||||
|
export 'src/integration_health_check.dart';
|
||||||
|
export 'src/rate_limiter.dart';
|
||||||
|
export 'src/retry_policy.dart';
|
||||||
|
export 'src/webhook_handler.dart';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import 'api_exception.dart';
|
||||||
|
|
||||||
|
/// Abstract contract for all external API clients.
|
||||||
|
///
|
||||||
|
/// Defines the lifecycle, authentication, and error-handling surface that
|
||||||
|
/// every channel integration (WooCommerce, Square, etc.) must satisfy.
|
||||||
|
/// Concrete implementations live in their respective feature or integration
|
||||||
|
/// packages and are selected at runtime via `--dart-define`.
|
||||||
|
abstract class ApiClient {
|
||||||
|
/// Performs any async initialisation required before the client can be used
|
||||||
|
/// (e.g. token refresh, connectivity check).
|
||||||
|
///
|
||||||
|
/// Callers should await [initialize] before issuing requests.
|
||||||
|
Future<void> initialize();
|
||||||
|
|
||||||
|
/// Releases all resources held by this client (HTTP connections, timers,
|
||||||
|
/// stream subscriptions, etc.).
|
||||||
|
///
|
||||||
|
/// After [dispose] is called the client must not be used again.
|
||||||
|
void dispose();
|
||||||
|
|
||||||
|
/// Returns `true` if the client has been initialised and is ready to
|
||||||
|
/// accept requests.
|
||||||
|
bool get isReady;
|
||||||
|
|
||||||
|
/// Executes [request] and returns its result.
|
||||||
|
///
|
||||||
|
/// Implementations are expected to:
|
||||||
|
/// - Inject authentication headers.
|
||||||
|
/// - Apply the configured [RetryPolicy] on transient failures.
|
||||||
|
/// - Respect the configured [RateLimiter] before each attempt.
|
||||||
|
/// - Translate HTTP error responses into [ApiException] subclasses.
|
||||||
|
Future<T> execute<T>(Future<T> Function() request);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
/// Base exception for all API integration errors.
|
||||||
|
///
|
||||||
|
/// Concrete channel implementations (WooCommerce, Square, etc.) extend this
|
||||||
|
/// class to add channel-specific context while preserving a common error
|
||||||
|
/// contract for callers.
|
||||||
|
class ApiException implements Exception {
|
||||||
|
/// HTTP status code returned by the remote API, or -1 for network-level
|
||||||
|
/// errors where no HTTP response was received.
|
||||||
|
final int statusCode;
|
||||||
|
|
||||||
|
/// Human-readable description of the error.
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
/// Raw response body from the server, if available.
|
||||||
|
final String body;
|
||||||
|
|
||||||
|
const ApiException({required this.statusCode, required this.message, this.body = ''});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'ApiException($statusCode): $message';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
/// A generic product representation used for cross-channel data exchange.
|
||||||
|
///
|
||||||
|
/// This is intentionally minimal — channel adapters map between this
|
||||||
|
/// neutral format and their platform-specific models.
|
||||||
|
class ChannelProduct {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final double price;
|
||||||
|
final String status;
|
||||||
|
final String? description;
|
||||||
|
final String? sku;
|
||||||
|
final String? imageUrl;
|
||||||
|
final String? category;
|
||||||
|
|
||||||
|
const ChannelProduct({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.price,
|
||||||
|
required this.status,
|
||||||
|
this.description,
|
||||||
|
this.sku,
|
||||||
|
this.imageUrl,
|
||||||
|
this.category,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A generic order representation used for cross-channel data exchange.
|
||||||
|
class ChannelOrder {
|
||||||
|
final String id;
|
||||||
|
final String status;
|
||||||
|
final double total;
|
||||||
|
final String? customerName;
|
||||||
|
final String? customerEmail;
|
||||||
|
final List<ChannelOrderItem> items;
|
||||||
|
|
||||||
|
const ChannelOrder({
|
||||||
|
required this.id,
|
||||||
|
required this.status,
|
||||||
|
required this.total,
|
||||||
|
this.customerName,
|
||||||
|
this.customerEmail,
|
||||||
|
this.items = const [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single line item within a [ChannelOrder].
|
||||||
|
class ChannelOrderItem {
|
||||||
|
final String productId;
|
||||||
|
final String name;
|
||||||
|
final int quantity;
|
||||||
|
final double unitPrice;
|
||||||
|
|
||||||
|
const ChannelOrderItem({
|
||||||
|
required this.productId,
|
||||||
|
required this.name,
|
||||||
|
required this.quantity,
|
||||||
|
required this.unitPrice,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A generic inventory count used for cross-channel data exchange.
|
||||||
|
class ChannelInventoryCount {
|
||||||
|
final String productId;
|
||||||
|
final String? sku;
|
||||||
|
final int quantity;
|
||||||
|
|
||||||
|
const ChannelInventoryCount({required this.productId, this.sku, required this.quantity});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The abstraction for a sales channel (WooCommerce, Square, future channels).
|
||||||
|
///
|
||||||
|
/// Each channel adapter provides a standard set of operations — products,
|
||||||
|
/// orders, and inventory — so the application layer can interact with any
|
||||||
|
/// channel through a single interface without knowing platform-specific
|
||||||
|
/// details.
|
||||||
|
///
|
||||||
|
/// Concrete implementations live in their respective data layers and are
|
||||||
|
/// selected at runtime via `--dart-define`.
|
||||||
|
abstract class ChannelAdapter {
|
||||||
|
/// A stable identifier for this channel (e.g. `'woocommerce'`, `'square'`).
|
||||||
|
String get channelId;
|
||||||
|
|
||||||
|
/// Human-readable display name for this channel.
|
||||||
|
String get displayName;
|
||||||
|
|
||||||
|
// ── Products ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Fetches all products from the channel.
|
||||||
|
Future<List<ChannelProduct>> getProducts();
|
||||||
|
|
||||||
|
/// Fetches a single product by its channel-specific [id].
|
||||||
|
Future<ChannelProduct> getProduct(String id);
|
||||||
|
|
||||||
|
/// Updates a product on the channel.
|
||||||
|
///
|
||||||
|
/// Only the fields present in [updates] are changed; others are left as-is.
|
||||||
|
Future<ChannelProduct> updateProduct(String id, Map<String, dynamic> updates);
|
||||||
|
|
||||||
|
// ── Orders ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Fetches all orders from the channel.
|
||||||
|
Future<List<ChannelOrder>> getOrders();
|
||||||
|
|
||||||
|
/// Fetches a single order by its channel-specific [id].
|
||||||
|
Future<ChannelOrder> getOrder(String id);
|
||||||
|
|
||||||
|
/// Updates the status of an order on the channel.
|
||||||
|
Future<ChannelOrder> updateOrderStatus(String id, String status);
|
||||||
|
|
||||||
|
// ── Inventory ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Fetches inventory counts for all products on the channel.
|
||||||
|
Future<List<ChannelInventoryCount>> getInventoryCounts();
|
||||||
|
|
||||||
|
/// Updates the stock quantity for a product on the channel.
|
||||||
|
Future<ChannelInventoryCount> updateInventoryCount(String productId, int quantity);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
/// The result of an [IntegrationHealthCheck].
|
||||||
|
class HealthCheckResult {
|
||||||
|
/// Whether the integration is reachable and responding correctly.
|
||||||
|
final bool isHealthy;
|
||||||
|
|
||||||
|
/// Human-readable description of the health status.
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
/// Optional latency measurement for the health-check request.
|
||||||
|
final Duration? latency;
|
||||||
|
|
||||||
|
const HealthCheckResult({required this.isHealthy, required this.message, this.latency});
|
||||||
|
|
||||||
|
/// Convenience constructor for a healthy result.
|
||||||
|
const HealthCheckResult.healthy({this.message = 'OK', this.latency}) : isHealthy = true;
|
||||||
|
|
||||||
|
/// Convenience constructor for an unhealthy result.
|
||||||
|
const HealthCheckResult.unhealthy({required this.message}) : isHealthy = false, latency = null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'HealthCheckResult(healthy=$isHealthy, message=$message)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contract for verifying that an external integration is reachable and
|
||||||
|
/// correctly configured.
|
||||||
|
///
|
||||||
|
/// Each channel adapter (WooCommerce, Square, etc.) implements this interface
|
||||||
|
/// so the application can surface connectivity status on the dashboard and
|
||||||
|
/// before critical operations.
|
||||||
|
abstract class IntegrationHealthCheck {
|
||||||
|
/// Performs a lightweight connectivity check against the remote API.
|
||||||
|
///
|
||||||
|
/// Implementations should use the cheapest available endpoint (e.g. a
|
||||||
|
/// system-status or ping endpoint) and return within a reasonable timeout.
|
||||||
|
Future<HealthCheckResult> check();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
/// Controls the rate at which API requests are issued.
|
||||||
|
///
|
||||||
|
/// Implementations can enforce per-second, per-minute, or token-bucket limits
|
||||||
|
/// to stay within the quotas imposed by external APIs.
|
||||||
|
abstract class RateLimiter {
|
||||||
|
/// Waits until a request token is available, then returns.
|
||||||
|
///
|
||||||
|
/// Callers must await [acquire] before issuing each request.
|
||||||
|
Future<void> acquire();
|
||||||
|
|
||||||
|
/// Releases any resources held by this limiter (timers, streams, etc.).
|
||||||
|
void dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [RateLimiter] that imposes no rate limit.
|
||||||
|
///
|
||||||
|
/// Suitable for development, testing, or APIs with no documented quota.
|
||||||
|
class NoRateLimiter implements RateLimiter {
|
||||||
|
const NoRateLimiter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> acquire() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A simple token-bucket [RateLimiter] that allows at most [maxRequests]
|
||||||
|
/// requests per [window].
|
||||||
|
///
|
||||||
|
/// Requests that arrive when the bucket is empty are queued and released
|
||||||
|
/// as tokens refill at the start of each window.
|
||||||
|
class TokenBucketRateLimiter implements RateLimiter {
|
||||||
|
/// Maximum number of requests allowed per [window].
|
||||||
|
final int maxRequests;
|
||||||
|
|
||||||
|
/// The time window over which [maxRequests] are allowed.
|
||||||
|
final Duration window;
|
||||||
|
|
||||||
|
int _tokens;
|
||||||
|
DateTime _windowStart;
|
||||||
|
|
||||||
|
TokenBucketRateLimiter({required this.maxRequests, this.window = const Duration(seconds: 1)})
|
||||||
|
: _tokens = maxRequests,
|
||||||
|
_windowStart = DateTime.now();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> acquire() async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (now.difference(_windowStart) >= window) {
|
||||||
|
_tokens = maxRequests;
|
||||||
|
_windowStart = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_tokens > 0) {
|
||||||
|
_tokens--;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the remainder of the current window, then retry.
|
||||||
|
final remaining = window - now.difference(_windowStart);
|
||||||
|
await Future<void>.delayed(remaining);
|
||||||
|
return acquire();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import 'api_exception.dart';
|
||||||
|
|
||||||
|
/// Determines whether and how a failed API request should be retried.
|
||||||
|
///
|
||||||
|
/// Implementations can encode strategies such as exponential back-off,
|
||||||
|
/// fixed-interval retry, or no-retry at all.
|
||||||
|
abstract class RetryPolicy {
|
||||||
|
/// Maximum number of retry attempts (not counting the initial attempt).
|
||||||
|
int get maxAttempts;
|
||||||
|
|
||||||
|
/// Returns the [Duration] to wait before attempt number [attempt].
|
||||||
|
///
|
||||||
|
/// [attempt] is 1-based: the first retry is attempt 1.
|
||||||
|
Duration delayFor(int attempt);
|
||||||
|
|
||||||
|
/// Returns `true` if the [exception] is considered retryable.
|
||||||
|
///
|
||||||
|
/// Typically only transient errors (5xx, network timeouts) are retried;
|
||||||
|
/// client errors (4xx) are not.
|
||||||
|
bool shouldRetry(ApiException exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [RetryPolicy] that never retries.
|
||||||
|
class NoRetryPolicy implements RetryPolicy {
|
||||||
|
const NoRetryPolicy();
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get maxAttempts => 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Duration delayFor(int attempt) => Duration.zero;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRetry(ApiException exception) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [RetryPolicy] with exponential back-off for transient server errors.
|
||||||
|
///
|
||||||
|
/// Retries up to [maxAttempts] times on 5xx responses or network-level errors
|
||||||
|
/// (status code -1). The delay doubles with each attempt, starting at
|
||||||
|
/// [initialDelay], capped at [maxDelay].
|
||||||
|
class ExponentialBackoffRetryPolicy implements RetryPolicy {
|
||||||
|
@override
|
||||||
|
final int maxAttempts;
|
||||||
|
|
||||||
|
/// Delay before the first retry.
|
||||||
|
final Duration initialDelay;
|
||||||
|
|
||||||
|
/// Upper bound on the computed delay.
|
||||||
|
final Duration maxDelay;
|
||||||
|
|
||||||
|
const ExponentialBackoffRetryPolicy({
|
||||||
|
this.maxAttempts = 3,
|
||||||
|
this.initialDelay = const Duration(milliseconds: 500),
|
||||||
|
this.maxDelay = const Duration(seconds: 30),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Duration delayFor(int attempt) {
|
||||||
|
final ms = initialDelay.inMilliseconds * (1 << (attempt - 1));
|
||||||
|
return Duration(milliseconds: ms.clamp(0, maxDelay.inMilliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRetry(ApiException exception) {
|
||||||
|
// Retry on network-level errors or 5xx server errors.
|
||||||
|
return exception.statusCode == -1 || exception.statusCode >= 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
/// Represents an incoming webhook event from an external platform.
|
||||||
|
class WebhookEvent {
|
||||||
|
/// The event type as reported by the source platform
|
||||||
|
/// (e.g. `'woocommerce.order.created'`, `'square.payment.completed'`).
|
||||||
|
final String eventType;
|
||||||
|
|
||||||
|
/// The source channel that emitted the event (e.g. `'woocommerce'`, `'square'`).
|
||||||
|
final String source;
|
||||||
|
|
||||||
|
/// The raw payload delivered with the webhook.
|
||||||
|
final Map<String, dynamic> payload;
|
||||||
|
|
||||||
|
/// When the event was received by the application.
|
||||||
|
final DateTime receivedAt;
|
||||||
|
|
||||||
|
const WebhookEvent({
|
||||||
|
required this.eventType,
|
||||||
|
required this.source,
|
||||||
|
required this.payload,
|
||||||
|
required this.receivedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome of processing a [WebhookEvent].
|
||||||
|
enum WebhookProcessingStatus {
|
||||||
|
/// The event was handled successfully.
|
||||||
|
processed,
|
||||||
|
|
||||||
|
/// The event type is not handled by this handler — it should be forwarded
|
||||||
|
/// to another handler or silently ignored.
|
||||||
|
ignored,
|
||||||
|
|
||||||
|
/// Processing failed; the event may be retried depending on the delivery
|
||||||
|
/// guarantee of the source platform.
|
||||||
|
failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result returned by [WebhookHandler.handle].
|
||||||
|
class WebhookResult {
|
||||||
|
final WebhookProcessingStatus status;
|
||||||
|
|
||||||
|
/// Optional human-readable detail about the outcome.
|
||||||
|
final String? message;
|
||||||
|
|
||||||
|
const WebhookResult({required this.status, this.message});
|
||||||
|
|
||||||
|
const WebhookResult.processed({this.message}) : status = WebhookProcessingStatus.processed;
|
||||||
|
|
||||||
|
const WebhookResult.ignored({this.message}) : status = WebhookProcessingStatus.ignored;
|
||||||
|
|
||||||
|
const WebhookResult.failed({this.message}) : status = WebhookProcessingStatus.failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contract for processing incoming webhook events from external platforms.
|
||||||
|
///
|
||||||
|
/// Each channel integration registers one or more [WebhookHandler]s that
|
||||||
|
/// understand its event schema. A dispatcher routes incoming events to the
|
||||||
|
/// appropriate handler based on [canHandle].
|
||||||
|
abstract class WebhookHandler {
|
||||||
|
/// Returns `true` if this handler is capable of processing [event].
|
||||||
|
bool canHandle(WebhookEvent event);
|
||||||
|
|
||||||
|
/// Processes [event] and returns the outcome.
|
||||||
|
///
|
||||||
|
/// Implementations should be idempotent where possible, as webhook
|
||||||
|
/// delivery is at-least-once in most platforms.
|
||||||
|
Future<WebhookResult> handle(WebhookEvent event);
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,366 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:integrations/integrations.dart';
|
import 'package:integrations/integrations.dart';
|
||||||
|
|
||||||
|
// ── Test doubles ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _AlwaysHealthyCheck implements IntegrationHealthCheck {
|
||||||
|
@override
|
||||||
|
Future<HealthCheckResult> check() async => const HealthCheckResult.healthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AlwaysUnhealthyCheck implements IntegrationHealthCheck {
|
||||||
|
@override
|
||||||
|
Future<HealthCheckResult> check() async =>
|
||||||
|
const HealthCheckResult.unhealthy(message: 'Connection refused');
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EchoWebhookHandler implements WebhookHandler {
|
||||||
|
final String _handledSource;
|
||||||
|
|
||||||
|
_EchoWebhookHandler(this._handledSource);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool canHandle(WebhookEvent event) => event.source == _handledSource;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<WebhookResult> handle(WebhookEvent event) async =>
|
||||||
|
WebhookResult.processed(message: 'handled ${event.eventType}');
|
||||||
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('adds one to input values', () {
|
// ── ApiException ────────────────────────────────────────────────────────
|
||||||
final calculator = Calculator();
|
|
||||||
expect(calculator.addOne(2), 3);
|
group('ApiException', () {
|
||||||
expect(calculator.addOne(-7), -6);
|
test('stores statusCode, message, and body', () {
|
||||||
expect(calculator.addOne(0), 1);
|
const ex = ApiException(statusCode: 404, message: 'Not found', body: '{}');
|
||||||
|
expect(ex.statusCode, 404);
|
||||||
|
expect(ex.message, 'Not found');
|
||||||
|
expect(ex.body, '{}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('body defaults to empty string', () {
|
||||||
|
const ex = ApiException(statusCode: 500, message: 'Server error');
|
||||||
|
expect(ex.body, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toString includes statusCode and message', () {
|
||||||
|
const ex = ApiException(statusCode: 401, message: 'Unauthorized');
|
||||||
|
expect(ex.toString(), contains('401'));
|
||||||
|
expect(ex.toString(), contains('Unauthorized'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is an Exception', () {
|
||||||
|
const ex = ApiException(statusCode: 400, message: 'Bad request');
|
||||||
|
expect(ex, isA<Exception>());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── RetryPolicy ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('NoRetryPolicy', () {
|
||||||
|
const policy = NoRetryPolicy();
|
||||||
|
|
||||||
|
test('maxAttempts is 0', () => expect(policy.maxAttempts, 0));
|
||||||
|
|
||||||
|
test('delayFor returns zero', () => expect(policy.delayFor(1), Duration.zero));
|
||||||
|
|
||||||
|
test('shouldRetry always returns false', () {
|
||||||
|
const ex = ApiException(statusCode: 503, message: 'Service unavailable');
|
||||||
|
expect(policy.shouldRetry(ex), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ExponentialBackoffRetryPolicy', () {
|
||||||
|
const policy = ExponentialBackoffRetryPolicy(
|
||||||
|
maxAttempts: 3,
|
||||||
|
initialDelay: Duration(milliseconds: 100),
|
||||||
|
maxDelay: Duration(seconds: 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
test('maxAttempts is 3', () => expect(policy.maxAttempts, 3));
|
||||||
|
|
||||||
|
test('delay doubles with each attempt', () {
|
||||||
|
expect(policy.delayFor(1), const Duration(milliseconds: 100));
|
||||||
|
expect(policy.delayFor(2), const Duration(milliseconds: 200));
|
||||||
|
expect(policy.delayFor(3), const Duration(milliseconds: 400));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delay is capped at maxDelay', () {
|
||||||
|
const bigPolicy = ExponentialBackoffRetryPolicy(
|
||||||
|
maxAttempts: 10,
|
||||||
|
initialDelay: Duration(seconds: 5),
|
||||||
|
maxDelay: Duration(seconds: 10),
|
||||||
|
);
|
||||||
|
expect(bigPolicy.delayFor(5).inSeconds, lessThanOrEqualTo(10));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRetry returns true for 5xx errors', () {
|
||||||
|
const ex500 = ApiException(statusCode: 500, message: 'Internal server error');
|
||||||
|
const ex503 = ApiException(statusCode: 503, message: 'Service unavailable');
|
||||||
|
expect(policy.shouldRetry(ex500), isTrue);
|
||||||
|
expect(policy.shouldRetry(ex503), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRetry returns true for network-level errors (statusCode -1)', () {
|
||||||
|
const ex = ApiException(statusCode: -1, message: 'Network error');
|
||||||
|
expect(policy.shouldRetry(ex), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRetry returns false for 4xx client errors', () {
|
||||||
|
const ex = ApiException(statusCode: 404, message: 'Not found');
|
||||||
|
expect(policy.shouldRetry(ex), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRetry returns false for 2xx (should not happen but safe)', () {
|
||||||
|
const ex = ApiException(statusCode: 200, message: 'OK');
|
||||||
|
expect(policy.shouldRetry(ex), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── RateLimiter ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('NoRateLimiter', () {
|
||||||
|
test('acquire completes immediately', () async {
|
||||||
|
const limiter = NoRateLimiter();
|
||||||
|
await expectLater(limiter.acquire(), completes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dispose does not throw', () {
|
||||||
|
const limiter = NoRateLimiter();
|
||||||
|
expect(() => limiter.dispose(), returnsNormally);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('TokenBucketRateLimiter', () {
|
||||||
|
test('allows up to maxRequests without delay', () async {
|
||||||
|
final limiter = TokenBucketRateLimiter(maxRequests: 3);
|
||||||
|
// All three should complete without waiting.
|
||||||
|
await limiter.acquire();
|
||||||
|
await limiter.acquire();
|
||||||
|
await limiter.acquire();
|
||||||
|
limiter.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dispose does not throw', () {
|
||||||
|
final limiter = TokenBucketRateLimiter(maxRequests: 5);
|
||||||
|
expect(() => limiter.dispose(), returnsNormally);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── HealthCheckResult ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('HealthCheckResult', () {
|
||||||
|
test('healthy constructor sets isHealthy true and default message', () {
|
||||||
|
const result = HealthCheckResult.healthy();
|
||||||
|
expect(result.isHealthy, isTrue);
|
||||||
|
expect(result.message, 'OK');
|
||||||
|
expect(result.latency, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('healthy constructor accepts custom message and latency', () {
|
||||||
|
const result = HealthCheckResult.healthy(
|
||||||
|
message: 'Ping 42ms',
|
||||||
|
latency: Duration(milliseconds: 42),
|
||||||
|
);
|
||||||
|
expect(result.isHealthy, isTrue);
|
||||||
|
expect(result.message, 'Ping 42ms');
|
||||||
|
expect(result.latency, const Duration(milliseconds: 42));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unhealthy constructor sets isHealthy false', () {
|
||||||
|
const result = HealthCheckResult.unhealthy(message: 'Timeout');
|
||||||
|
expect(result.isHealthy, isFalse);
|
||||||
|
expect(result.message, 'Timeout');
|
||||||
|
expect(result.latency, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toString includes healthy status and message', () {
|
||||||
|
const result = HealthCheckResult.healthy(message: 'OK');
|
||||||
|
expect(result.toString(), contains('true'));
|
||||||
|
expect(result.toString(), contains('OK'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── IntegrationHealthCheck ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('IntegrationHealthCheck', () {
|
||||||
|
test('healthy implementation returns healthy result', () async {
|
||||||
|
final check = _AlwaysHealthyCheck();
|
||||||
|
final result = await check.check();
|
||||||
|
expect(result.isHealthy, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unhealthy implementation returns unhealthy result', () async {
|
||||||
|
final check = _AlwaysUnhealthyCheck();
|
||||||
|
final result = await check.check();
|
||||||
|
expect(result.isHealthy, isFalse);
|
||||||
|
expect(result.message, 'Connection refused');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── WebhookEvent ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('WebhookEvent', () {
|
||||||
|
test('stores all fields', () {
|
||||||
|
final now = DateTime(2026, 7, 11);
|
||||||
|
final event = WebhookEvent(
|
||||||
|
eventType: 'order.created',
|
||||||
|
source: 'woocommerce',
|
||||||
|
payload: {'id': 42},
|
||||||
|
receivedAt: now,
|
||||||
|
);
|
||||||
|
expect(event.eventType, 'order.created');
|
||||||
|
expect(event.source, 'woocommerce');
|
||||||
|
expect(event.payload['id'], 42);
|
||||||
|
expect(event.receivedAt, now);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── WebhookResult ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('WebhookResult', () {
|
||||||
|
test('processed constructor sets correct status', () {
|
||||||
|
const result = WebhookResult.processed(message: 'done');
|
||||||
|
expect(result.status, WebhookProcessingStatus.processed);
|
||||||
|
expect(result.message, 'done');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignored constructor sets correct status', () {
|
||||||
|
const result = WebhookResult.ignored();
|
||||||
|
expect(result.status, WebhookProcessingStatus.ignored);
|
||||||
|
expect(result.message, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed constructor sets correct status', () {
|
||||||
|
const result = WebhookResult.failed(message: 'error');
|
||||||
|
expect(result.status, WebhookProcessingStatus.failed);
|
||||||
|
expect(result.message, 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── WebhookHandler ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
group('WebhookHandler', () {
|
||||||
|
final handler = _EchoWebhookHandler('woocommerce');
|
||||||
|
final now = DateTime(2026, 7, 11);
|
||||||
|
|
||||||
|
test('canHandle returns true for matching source', () {
|
||||||
|
final event = WebhookEvent(
|
||||||
|
eventType: 'product.updated',
|
||||||
|
source: 'woocommerce',
|
||||||
|
payload: {},
|
||||||
|
receivedAt: now,
|
||||||
|
);
|
||||||
|
expect(handler.canHandle(event), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canHandle returns false for non-matching source', () {
|
||||||
|
final event = WebhookEvent(
|
||||||
|
eventType: 'payment.completed',
|
||||||
|
source: 'square',
|
||||||
|
payload: {},
|
||||||
|
receivedAt: now,
|
||||||
|
);
|
||||||
|
expect(handler.canHandle(event), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handle returns processed result', () async {
|
||||||
|
final event = WebhookEvent(
|
||||||
|
eventType: 'order.created',
|
||||||
|
source: 'woocommerce',
|
||||||
|
payload: {},
|
||||||
|
receivedAt: now,
|
||||||
|
);
|
||||||
|
final result = await handler.handle(event);
|
||||||
|
expect(result.status, WebhookProcessingStatus.processed);
|
||||||
|
expect(result.message, contains('order.created'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ChannelAdapter value objects ─────────────────────────────────────────
|
||||||
|
|
||||||
|
group('ChannelProduct', () {
|
||||||
|
test('stores required and optional fields', () {
|
||||||
|
const product = ChannelProduct(
|
||||||
|
id: 'p1',
|
||||||
|
name: 'Test Product',
|
||||||
|
price: 9.99,
|
||||||
|
status: 'publish',
|
||||||
|
description: 'A test product',
|
||||||
|
sku: 'SKU-001',
|
||||||
|
imageUrl: 'https://example.com/img.jpg',
|
||||||
|
category: 'Candles',
|
||||||
|
);
|
||||||
|
expect(product.id, 'p1');
|
||||||
|
expect(product.name, 'Test Product');
|
||||||
|
expect(product.price, 9.99);
|
||||||
|
expect(product.status, 'publish');
|
||||||
|
expect(product.description, 'A test product');
|
||||||
|
expect(product.sku, 'SKU-001');
|
||||||
|
expect(product.imageUrl, 'https://example.com/img.jpg');
|
||||||
|
expect(product.category, 'Candles');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('optional fields default to null', () {
|
||||||
|
const product = ChannelProduct(id: 'p2', name: 'Minimal', price: 0, status: 'draft');
|
||||||
|
expect(product.description, isNull);
|
||||||
|
expect(product.sku, isNull);
|
||||||
|
expect(product.imageUrl, isNull);
|
||||||
|
expect(product.category, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ChannelOrder', () {
|
||||||
|
test('stores required and optional fields', () {
|
||||||
|
const order = ChannelOrder(
|
||||||
|
id: 'o1',
|
||||||
|
status: 'processing',
|
||||||
|
total: 29.99,
|
||||||
|
customerName: 'Jane Doe',
|
||||||
|
customerEmail: 'jane@example.com',
|
||||||
|
items: [ChannelOrderItem(productId: 'p1', name: 'Candle', quantity: 2, unitPrice: 14.99)],
|
||||||
|
);
|
||||||
|
expect(order.id, 'o1');
|
||||||
|
expect(order.status, 'processing');
|
||||||
|
expect(order.total, 29.99);
|
||||||
|
expect(order.customerName, 'Jane Doe');
|
||||||
|
expect(order.customerEmail, 'jane@example.com');
|
||||||
|
expect(order.items.length, 1);
|
||||||
|
expect(order.items.first.productId, 'p1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('items defaults to empty list', () {
|
||||||
|
const order = ChannelOrder(id: 'o2', status: 'pending', total: 0);
|
||||||
|
expect(order.items, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ChannelOrderItem', () {
|
||||||
|
test('stores all fields', () {
|
||||||
|
const item = ChannelOrderItem(
|
||||||
|
productId: 'p1',
|
||||||
|
name: 'Wax Melt',
|
||||||
|
quantity: 3,
|
||||||
|
unitPrice: 5.00,
|
||||||
|
);
|
||||||
|
expect(item.productId, 'p1');
|
||||||
|
expect(item.name, 'Wax Melt');
|
||||||
|
expect(item.quantity, 3);
|
||||||
|
expect(item.unitPrice, 5.00);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ChannelInventoryCount', () {
|
||||||
|
test('stores required fields', () {
|
||||||
|
const count = ChannelInventoryCount(productId: 'p1', quantity: 10);
|
||||||
|
expect(count.productId, 'p1');
|
||||||
|
expect(count.quantity, 10);
|
||||||
|
expect(count.sku, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stores optional sku', () {
|
||||||
|
const count = ChannelInventoryCount(productId: 'p1', sku: 'SKU-001', quantity: 5);
|
||||||
|
expect(count.sku, 'SKU-001');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
TESTABLE=(
|
TESTABLE=(
|
||||||
packages/core
|
packages/core
|
||||||
packages/design_system
|
packages/design_system
|
||||||
|
packages/integrations
|
||||||
packages/feature_wordpress
|
packages/feature_wordpress
|
||||||
packages/feature_inventory
|
packages/feature_inventory
|
||||||
packages/feature_orders
|
packages/feature_orders
|
||||||
|
|
@ -30,6 +31,7 @@ TESTABLE=(
|
||||||
ANALYZABLE=(
|
ANALYZABLE=(
|
||||||
packages/core
|
packages/core
|
||||||
packages/design_system
|
packages/design_system
|
||||||
|
packages/integrations
|
||||||
packages/feature_wordpress
|
packages/feature_wordpress
|
||||||
packages/feature_inventory
|
packages/feature_inventory
|
||||||
packages/feature_orders
|
packages/feature_orders
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue