Compare commits

..

2 Commits

Author SHA1 Message Date
Mike Kell 7bd4db6a95 Merge feat/integrations-contracts into main (Stage 8A complete)
Publish Docs / publish-docs (push) Successful in 44s Details
2026-07-11 08:21:14 -04:00
Mike Kell ec66626074 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
2026-07-11 08:21:03 -04:00
14 changed files with 863 additions and 34 deletions

View File

@ -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

View File

@ -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 916). 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 916.
--- ---

View File

@ -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

View File

@ -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:

View File

@ -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';

View File

@ -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);
}

View File

@ -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';
}

View File

@ -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);
}

View File

@ -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();
}

View File

@ -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() {}
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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');
});
}); });
} }

View File

@ -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