From ec6662607441b3579d502b928509ef860d9bcaa3 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sat, 11 Jul 2026 08:21:03 -0400 Subject: [PATCH] feat(integrations): Stage 8A - integration abstractions contracts - Add ApiException base class (statusCode, message, body) - Add ApiClient abstract class (initialize, dispose, isReady, execute) - 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 --- docs/development/build_execution_tracker.md | 30 +- docs/development/master_development_brief.md | 12 +- .../lib/src/data/woo_commerce_api_client.dart | 53 ++- .../packages/feature_wordpress/pubspec.yaml | 2 + .../integrations/lib/integrations.dart | 19 +- .../integrations/lib/src/api_client.dart | 34 ++ .../integrations/lib/src/api_exception.dart | 21 + .../integrations/lib/src/channel_adapter.dart | 117 ++++++ .../lib/src/integration_health_check.dart | 36 ++ .../integrations/lib/src/rate_limiter.dart | 68 ++++ .../integrations/lib/src/retry_policy.dart | 69 ++++ .../integrations/lib/src/webhook_handler.dart | 68 ++++ .../integrations/test/integrations_test.dart | 366 +++++++++++++++++- kell_creations_apps/tools/run_all_tests.sh | 2 + 14 files changed, 863 insertions(+), 34 deletions(-) create mode 100644 kell_creations_apps/packages/integrations/lib/src/api_client.dart create mode 100644 kell_creations_apps/packages/integrations/lib/src/api_exception.dart create mode 100644 kell_creations_apps/packages/integrations/lib/src/channel_adapter.dart create mode 100644 kell_creations_apps/packages/integrations/lib/src/integration_health_check.dart create mode 100644 kell_creations_apps/packages/integrations/lib/src/rate_limiter.dart create mode 100644 kell_creations_apps/packages/integrations/lib/src/retry_policy.dart create mode 100644 kell_creations_apps/packages/integrations/lib/src/webhook_handler.dart diff --git a/docs/development/build_execution_tracker.md b/docs/development/build_execution_tracker.md index ff773ca..aae0039 100644 --- a/docs/development/build_execution_tracker.md +++ b/docs/development/build_execution_tracker.md @@ -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()` 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()`; `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 diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index 75e32c6..a860d2d 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -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. --- diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart index 8e6b7b8..08ccd83 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/data/woo_commerce_api_client.dart @@ -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 initialize() async { + // No async setup required for Basic Auth; mark ready immediately. + _ready = true; + } + + @override + bool get isReady => _ready; + + @override + Future execute(Future 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 diff --git a/kell_creations_apps/packages/feature_wordpress/pubspec.yaml b/kell_creations_apps/packages/feature_wordpress/pubspec.yaml index fef4bf1..6da87b8 100644 --- a/kell_creations_apps/packages/feature_wordpress/pubspec.yaml +++ b/kell_creations_apps/packages/feature_wordpress/pubspec.yaml @@ -13,6 +13,8 @@ dependencies: sdk: flutter design_system: path: ../design_system + integrations: + path: ../integrations http: ^1.4.0 dev_dependencies: diff --git a/kell_creations_apps/packages/integrations/lib/integrations.dart b/kell_creations_apps/packages/integrations/lib/integrations.dart index 298576d..7df9b27 100644 --- a/kell_creations_apps/packages/integrations/lib/integrations.dart +++ b/kell_creations_apps/packages/integrations/lib/integrations.dart @@ -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'; diff --git a/kell_creations_apps/packages/integrations/lib/src/api_client.dart b/kell_creations_apps/packages/integrations/lib/src/api_client.dart new file mode 100644 index 0000000..b31c2eb --- /dev/null +++ b/kell_creations_apps/packages/integrations/lib/src/api_client.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 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 execute(Future Function() request); +} diff --git a/kell_creations_apps/packages/integrations/lib/src/api_exception.dart b/kell_creations_apps/packages/integrations/lib/src/api_exception.dart new file mode 100644 index 0000000..f13776f --- /dev/null +++ b/kell_creations_apps/packages/integrations/lib/src/api_exception.dart @@ -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'; +} diff --git a/kell_creations_apps/packages/integrations/lib/src/channel_adapter.dart b/kell_creations_apps/packages/integrations/lib/src/channel_adapter.dart new file mode 100644 index 0000000..ccfa759 --- /dev/null +++ b/kell_creations_apps/packages/integrations/lib/src/channel_adapter.dart @@ -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 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> getProducts(); + + /// Fetches a single product by its channel-specific [id]. + Future getProduct(String id); + + /// Updates a product on the channel. + /// + /// Only the fields present in [updates] are changed; others are left as-is. + Future updateProduct(String id, Map updates); + + // ── Orders ──────────────────────────────────────────────────────────────── + + /// Fetches all orders from the channel. + Future> getOrders(); + + /// Fetches a single order by its channel-specific [id]. + Future getOrder(String id); + + /// Updates the status of an order on the channel. + Future updateOrderStatus(String id, String status); + + // ── Inventory ───────────────────────────────────────────────────────────── + + /// Fetches inventory counts for all products on the channel. + Future> getInventoryCounts(); + + /// Updates the stock quantity for a product on the channel. + Future updateInventoryCount(String productId, int quantity); +} diff --git a/kell_creations_apps/packages/integrations/lib/src/integration_health_check.dart b/kell_creations_apps/packages/integrations/lib/src/integration_health_check.dart new file mode 100644 index 0000000..e975ee1 --- /dev/null +++ b/kell_creations_apps/packages/integrations/lib/src/integration_health_check.dart @@ -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 check(); +} diff --git a/kell_creations_apps/packages/integrations/lib/src/rate_limiter.dart b/kell_creations_apps/packages/integrations/lib/src/rate_limiter.dart new file mode 100644 index 0000000..0cf9285 --- /dev/null +++ b/kell_creations_apps/packages/integrations/lib/src/rate_limiter.dart @@ -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 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 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 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.delayed(remaining); + return acquire(); + } + + @override + void dispose() {} +} diff --git a/kell_creations_apps/packages/integrations/lib/src/retry_policy.dart b/kell_creations_apps/packages/integrations/lib/src/retry_policy.dart new file mode 100644 index 0000000..40b75a1 --- /dev/null +++ b/kell_creations_apps/packages/integrations/lib/src/retry_policy.dart @@ -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; + } +} diff --git a/kell_creations_apps/packages/integrations/lib/src/webhook_handler.dart b/kell_creations_apps/packages/integrations/lib/src/webhook_handler.dart new file mode 100644 index 0000000..0096c6b --- /dev/null +++ b/kell_creations_apps/packages/integrations/lib/src/webhook_handler.dart @@ -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 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 handle(WebhookEvent event); +} diff --git a/kell_creations_apps/packages/integrations/test/integrations_test.dart b/kell_creations_apps/packages/integrations/test/integrations_test.dart index 883bc82..d941337 100644 --- a/kell_creations_apps/packages/integrations/test/integrations_test.dart +++ b/kell_creations_apps/packages/integrations/test/integrations_test.dart @@ -1,12 +1,366 @@ import 'package:flutter_test/flutter_test.dart'; - import 'package:integrations/integrations.dart'; +// ── Test doubles ───────────────────────────────────────────────────────────── + +class _AlwaysHealthyCheck implements IntegrationHealthCheck { + @override + Future check() async => const HealthCheckResult.healthy(); +} + +class _AlwaysUnhealthyCheck implements IntegrationHealthCheck { + @override + Future 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 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()); + }); + }); + + // ── 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'); + }); }); } diff --git a/kell_creations_apps/tools/run_all_tests.sh b/kell_creations_apps/tools/run_all_tests.sh index 580b917..80aa2ec 100644 --- a/kell_creations_apps/tools/run_all_tests.sh +++ b/kell_creations_apps/tools/run_all_tests.sh @@ -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