Merge feat/integrations-contracts into main (Stage 8A complete)
Publish Docs / publish-docs (push) Successful in 44s
Details
Publish Docs / publish-docs (push) Successful in 44s
Details
This commit is contained in:
commit
7bd4db6a95
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
## Current status
|
||||
|
||||
- main baseline updated through: bulk-operator-workflows (Stage 7B complete)
|
||||
- next branch: feat/integrations-contracts (Stage 8A)
|
||||
- current stage: Stage 7 complete — proceeding to infrastructure activation
|
||||
- main baseline updated through: integrations-contracts (Stage 8A complete)
|
||||
- next branch: feat/data-layer-contracts (Stage 8B)
|
||||
- current stage: Stage 8A complete — integration abstractions activated
|
||||
|
||||
## Slice tracker
|
||||
|
||||
|
|
@ -378,6 +378,30 @@
|
|||
- analyze: passed (dart analyze --fatal-infos — no issues found across all packages)
|
||||
- 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
|
||||
|
||||
- status: merged to main
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ Rules:
|
|||
- Scaffolded but **empty stub** packages (created, no implementation yet):
|
||||
- `auth` — authentication/authorization (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_mrp` — craft manufacturing/MRP 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).
|
||||
- ✅ 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.
|
||||
- ✅ Integration abstractions activated (Stage 8A complete — merged `feat/integrations-contracts` → `main`, 2026-07-11).
|
||||
|
||||
### 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_wordpress`: `319/319 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`
|
||||
- total: `620/620 passed`
|
||||
- baseline commit: merge of `feat/bulk-operator-workflows` into `main` (Stage 7B complete, 2026-07-11)
|
||||
- total: `658/658 passed`
|
||||
- baseline commit: merge of `feat/integrations-contracts` into `main` (Stage 8A complete, 2026-07-11)
|
||||
|
||||
#### 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
|
||||
|
||||
**`feat/integrations-contracts`** — Stage 8A: Integration 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).
|
||||
**`feat/data-layer-contracts`** — Stage 8B: Shared data layer abstractions.
|
||||
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 'package:http/http.dart' as http;
|
||||
import 'package:integrations/integrations.dart';
|
||||
|
||||
/// 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.
|
||||
/// Only read-only product listing is implemented for now.
|
||||
class WooCommerceApiClient {
|
||||
class WooCommerceApiClient implements ApiClient {
|
||||
/// Base URL of the WordPress site (e.g. `https://store.kellcreations.com`).
|
||||
final String siteUrl;
|
||||
|
||||
|
|
@ -19,6 +20,8 @@ class WooCommerceApiClient {
|
|||
/// Optional [http.Client] for testing / injection.
|
||||
final http.Client _httpClient;
|
||||
|
||||
bool _ready = false;
|
||||
|
||||
WooCommerceApiClient({
|
||||
required this.siteUrl,
|
||||
required this.consumerKey,
|
||||
|
|
@ -26,6 +29,31 @@ class WooCommerceApiClient {
|
|||
http.Client? httpClient,
|
||||
}) : _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.
|
||||
String get _baseEndpoint => '$siteUrl/wp-json/wc/v3';
|
||||
|
||||
|
|
@ -112,23 +140,18 @@ class WooCommerceApiClient {
|
|||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/// Releases the underlying HTTP client resources.
|
||||
void dispose() {
|
||||
_httpClient.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Exception thrown when the WooCommerce API returns an error.
|
||||
class WooCommerceApiException implements Exception {
|
||||
final int statusCode;
|
||||
final String message;
|
||||
final String body;
|
||||
|
||||
///
|
||||
/// Extends the shared [ApiException] contract so callers can catch either
|
||||
/// [WooCommerceApiException] for WooCommerce-specific handling or
|
||||
/// [ApiException] for generic error handling.
|
||||
class WooCommerceApiException extends ApiException {
|
||||
const WooCommerceApiException({
|
||||
required this.statusCode,
|
||||
required this.message,
|
||||
required this.body,
|
||||
required super.statusCode,
|
||||
required super.message,
|
||||
super.body = '',
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ dependencies:
|
|||
sdk: flutter
|
||||
design_system:
|
||||
path: ../design_system
|
||||
integrations:
|
||||
path: ../integrations
|
||||
http: ^1.4.0
|
||||
|
||||
dev_dependencies:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
/// A Calculator.
|
||||
class Calculator {
|
||||
/// Returns [value] plus 1.
|
||||
int addOne(int value) => value + 1;
|
||||
}
|
||||
/// Shared integration abstractions for the Kell Creations platform.
|
||||
///
|
||||
/// This library defines the contracts that all external channel integrations
|
||||
/// (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: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() {
|
||||
test('adds one to input values', () {
|
||||
final calculator = Calculator();
|
||||
expect(calculator.addOne(2), 3);
|
||||
expect(calculator.addOne(-7), -6);
|
||||
expect(calculator.addOne(0), 1);
|
||||
// ── ApiException ────────────────────────────────────────────────────────
|
||||
|
||||
group('ApiException', () {
|
||||
test('stores statusCode, message, and body', () {
|
||||
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=(
|
||||
packages/core
|
||||
packages/design_system
|
||||
packages/integrations
|
||||
packages/feature_wordpress
|
||||
packages/feature_inventory
|
||||
packages/feature_orders
|
||||
|
|
@ -30,6 +31,7 @@ TESTABLE=(
|
|||
ANALYZABLE=(
|
||||
packages/core
|
||||
packages/design_system
|
||||
packages/integrations
|
||||
packages/feature_wordpress
|
||||
packages/feature_inventory
|
||||
packages/feature_orders
|
||||
|
|
|
|||
Loading…
Reference in New Issue