367 lines
13 KiB
Dart
367 lines
13 KiB
Dart
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() {
|
|
// ── 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');
|
|
});
|
|
});
|
|
}
|