Compare commits
3 Commits
a95bc5f18a
...
4b1168dbfa
| Author | SHA1 | Date |
|---|---|---|
|
|
4b1168dbfa | |
|
|
3ea1d66477 | |
|
|
9b2dd15ee2 |
|
|
@ -3,8 +3,8 @@
|
|||
## Current status
|
||||
|
||||
- main baseline updated through: feat/wc-catalog-expansion-ui (Stage 9D complete)
|
||||
- next branch: TBD (Stage 10 — real WooCommerce API integration wiring)
|
||||
- current stage: Stage 9D complete — product creation UI (`CreateProductDialog`), category management page (`CategoryManagementPage`), image management scaffolding, controller `createNewProduct`/`fetchCategories`, mobile create-product page, mobile publishing page
|
||||
- next branch: feat/square-catalog-sync (Stage 10B — Square catalog and inventory sync)
|
||||
- current stage: Stage 10A complete — `SquareApiClient` implementing `ApiClient` contract; Catalog, Inventory, Orders, and Payments API methods; `SquareApiException` extending `ApiException`; `KcAppEnvironment.square` and `.multi` values; `KC_SQUARE_ACCESS_TOKEN`, `KC_SQUARE_LOCATION_ID`, `KC_SQUARE_ENVIRONMENT` dart-define keys; 88 tests passing; analyze clean
|
||||
|
||||
## Slice tracker
|
||||
|
||||
|
|
@ -553,3 +553,22 @@
|
|||
- tests: passed (424/424 feature_wordpress — all passing)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/square-api-client
|
||||
|
||||
- status: merged to main
|
||||
- date: 2026-07-19
|
||||
- inspection: complete
|
||||
- implementation: complete
|
||||
- files changed:
|
||||
- `integrations/lib/src/square_api_client.dart` — new `SquareApiClient` implementing `ApiClient` contract; `sandbox()` and `production()` factory constructors; `initialize()`, `dispose()`, `isReady`, `execute<T>()` lifecycle; Catalog API (`listCatalogItems()` with cursor pagination, `getCatalogItem()`, `upsertCatalogItem()`, `deleteCatalogItem()`); Inventory API (`getInventoryCount()`, `batchChangeInventory()`); Orders API (`createOrder()`, `listOrders()`, `getOrder()`); Payments API (`createPayment()`, `getPayment()`); Bearer token + Square-Version headers on all requests
|
||||
- `integrations/lib/src/square_api_exception.dart` — new `SquareApiException` extending `ApiException` with `statusCode`, `message`, `body`; `toString()` includes all fields
|
||||
- `integrations/lib/integrations.dart` — expanded barrel exports for `SquareApiClient` and `SquareApiException`
|
||||
- `integrations/test/square_api_client_test.dart` — 50 new tests covering `SquareApiException` (5), `SquareApiClient` construction (2), `ApiClient` contract (5), Catalog API (12), Inventory API (7), Orders API (9), Payments API (8), auth headers (2)
|
||||
- `integrations/pubspec.yaml` — removed duplicate `http` dev dependency (was also in normal dependencies)
|
||||
- `core/lib/src/composition/kc_app_environment.dart` — added `multi` value to `KcAppEnvironment` enum (joining existing `fake`, `wp`, `square`)
|
||||
- `core/lib/src/composition/kc_app_config.dart` — added `KC_SQUARE_ACCESS_TOKEN`, `KC_SQUARE_LOCATION_ID`, `KC_SQUARE_ENVIRONMENT` dart-define key constants
|
||||
- `core/test/core_test.dart` — added 3 new tests: `KcAppEnvironment.multi` exists, `KC_SQUARE_ACCESS_TOKEN` constant, `KC_SQUARE_LOCATION_ID` constant — 24 total core tests
|
||||
- tests: passed (88/88 integrations, 24/24 kell_web, 26/26 kell_mobile, 424/424 feature_wordpress, 132/132 feature_inventory, 198/198 feature_orders, 41/41 design_system, 24/24 core — 957 total)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found across all 11 packages/apps)
|
||||
- brief updated: yes
|
||||
|
|
|
|||
|
|
@ -649,24 +649,14 @@ Square integration must follow the same architectural pattern as WooCommerce: AP
|
|||
- `feat/square-catalog-sync`
|
||||
- `feat/square-payments`
|
||||
|
||||
#### Stage 10A — Square API client and authentication
|
||||
#### Stage 10A — Square API client and authentication ✅ COMPLETE
|
||||
|
||||
##### Goal
|
||||
> Merged `feat/square-api-client` → `main` (2026-07-19).
|
||||
> Added `SquareApiClient` in `integrations` package implementing `ApiClient` contract; `sandbox()` and `production()` factory constructors; full lifecycle (`initialize()`, `dispose()`, `isReady`, `execute<T>()`); Catalog API (`listCatalogItems()` with cursor pagination, `getCatalogItem()`, `upsertCatalogItem()`, `deleteCatalogItem()`); Inventory API (`getInventoryCount()`, `batchChangeInventory()`); Orders API (`createOrder()`, `listOrders()`, `getOrder()`); Payments API (`createPayment()`, `getPayment()`); Bearer token + Square-Version headers on all requests. Added `SquareApiException` extending `ApiException`. Added `KC_SQUARE_ACCESS_TOKEN`, `KC_SQUARE_LOCATION_ID`, `KC_SQUARE_ENVIRONMENT` dart-define key constants to `KcAppConfig`. Added `KcAppEnvironment.multi` value. 50 new tests in `square_api_client_test.dart` — 88 total integrations tests. 24 total core tests. Analyze clean across all 11 packages/apps.
|
||||
|
||||
Create the foundational Square API client with OAuth 2.0 authentication.
|
||||
##### Branch
|
||||
|
||||
##### Requirements
|
||||
|
||||
- Add `KC_SQUARE_ACCESS_TOKEN`, `KC_SQUARE_LOCATION_ID`, `KC_SQUARE_ENVIRONMENT` to `--dart-define` keys
|
||||
- Create `SquareApiClient` implementing shared `ApiClient` contract from `integrations` package
|
||||
- Implement Square OAuth 2.0 flow (or access token injection for initial development)
|
||||
- Implement Square Catalog API: `listCatalogItems()`, `upsertCatalogItem()`, `deleteCatalogItem()`
|
||||
- Implement Square Inventory API: `getInventoryCount()`, `batchChangeInventory()`
|
||||
- Implement Square Orders API: `createOrder()`, `listOrders()`, `getOrder()`
|
||||
- Implement Square Payments API: `createPayment()`, `getPayment()`
|
||||
- Add `SquareApiException` extending shared `ApiException`
|
||||
- Update `KcAppEnvironment` to support `square` and `multi` (both channels) modes
|
||||
- Tests for API client, auth flow, and all endpoints
|
||||
`feat/square-api-client` — merged to `main`
|
||||
|
||||
#### Stage 10B — Square catalog and inventory sync
|
||||
|
||||
|
|
|
|||
|
|
@ -229,6 +229,9 @@ class _EnvironmentBadge extends StatelessWidget {
|
|||
case KcAppEnvironment.square:
|
||||
backgroundColor = Colors.blue.shade100;
|
||||
foregroundColor = Colors.blue.shade900;
|
||||
case KcAppEnvironment.multi:
|
||||
backgroundColor = Colors.purple.shade100;
|
||||
foregroundColor = Colors.purple.shade900;
|
||||
}
|
||||
|
||||
return Center(
|
||||
|
|
|
|||
|
|
@ -168,6 +168,9 @@ class _EnvironmentBadge extends StatelessWidget {
|
|||
case AppEnvironment.square:
|
||||
backgroundColor = Colors.blue.shade100;
|
||||
foregroundColor = Colors.blue.shade900;
|
||||
case AppEnvironment.multi:
|
||||
backgroundColor = Colors.purple.shade100;
|
||||
foregroundColor = Colors.purple.shade900;
|
||||
}
|
||||
|
||||
return Center(
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@
|
|||
/// The app reads the following compile-time constants:
|
||||
///
|
||||
/// | Key | Description | Default |
|
||||
/// |------------------------|----------------------------------------------|----------|
|
||||
/// | `KC_ENV` | `fake`, `wordpress`, or `square` | `fake` |
|
||||
/// |--------------------------|----------------------------------------------|------------|
|
||||
/// | `KC_ENV` | `fake`, `wordpress`, `square`, or `multi` | `fake` |
|
||||
/// | `KC_WC_SITE_URL` | WordPress site URL | (empty) |
|
||||
/// | `KC_WC_CONSUMER_KEY` | WooCommerce REST API consumer key | (empty) |
|
||||
/// | `KC_WC_CONSUMER_SECRET` | WooCommerce REST API consumer secret | (empty) |
|
||||
/// | `KC_SQUARE_ACCESS_TOKEN` | Square API access token | (empty) |
|
||||
/// | `KC_SQUARE_LOCATION_ID` | Square location ID | (empty) |
|
||||
/// | `KC_SQUARE_ENVIRONMENT` | `sandbox` or `production` | `sandbox` |
|
||||
///
|
||||
/// Usage:
|
||||
/// ```sh
|
||||
|
|
@ -20,11 +23,21 @@
|
|||
/// --dart-define=KC_WC_CONSUMER_KEY=ck_xxx \
|
||||
/// --dart-define=KC_WC_CONSUMER_SECRET=cs_xxx
|
||||
/// ```
|
||||
///
|
||||
/// Square mode:
|
||||
/// ```sh
|
||||
/// flutter run -d android \
|
||||
/// --dart-define=KC_ENV=square \
|
||||
/// --dart-define=KC_SQUARE_ACCESS_TOKEN=EAAAl... \
|
||||
/// --dart-define=KC_SQUARE_LOCATION_ID=L... \
|
||||
/// --dart-define=KC_SQUARE_ENVIRONMENT=sandbox
|
||||
/// ```
|
||||
class KcAppConfig {
|
||||
/// The environment mode: `fake`, `wordpress`, or `square`.
|
||||
/// The environment mode: `fake`, `wordpress`, `square`, or `multi`.
|
||||
final KcAppEnvironment environment;
|
||||
|
||||
/// WordPress / WooCommerce site URL (only used in [KcAppEnvironment.wordpress]).
|
||||
/// WordPress / WooCommerce site URL (only used in [KcAppEnvironment.wordpress]
|
||||
/// and [KcAppEnvironment.multi]).
|
||||
final String wcSiteUrl;
|
||||
|
||||
/// WooCommerce REST API consumer key.
|
||||
|
|
@ -33,11 +46,29 @@ class KcAppConfig {
|
|||
/// WooCommerce REST API consumer secret.
|
||||
final String wcConsumerSecret;
|
||||
|
||||
/// Square API access token (Bearer token).
|
||||
///
|
||||
/// Used in [KcAppEnvironment.square] and [KcAppEnvironment.multi].
|
||||
final String squareAccessToken;
|
||||
|
||||
/// Square location ID for inventory counts and payment processing.
|
||||
///
|
||||
/// Used in [KcAppEnvironment.square] and [KcAppEnvironment.multi].
|
||||
final String squareLocationId;
|
||||
|
||||
/// Square API environment: `sandbox` or `production`.
|
||||
///
|
||||
/// Defaults to `sandbox` for safety.
|
||||
final String squareEnvironment;
|
||||
|
||||
const KcAppConfig({
|
||||
required this.environment,
|
||||
required this.wcSiteUrl,
|
||||
required this.wcConsumerKey,
|
||||
required this.wcConsumerSecret,
|
||||
this.squareAccessToken = '',
|
||||
this.squareLocationId = '',
|
||||
this.squareEnvironment = 'sandbox',
|
||||
});
|
||||
|
||||
/// Reads configuration from compile-time `--dart-define` constants.
|
||||
|
|
@ -46,6 +77,12 @@ class KcAppConfig {
|
|||
const siteUrl = String.fromEnvironment('KC_WC_SITE_URL');
|
||||
const consumerKey = String.fromEnvironment('KC_WC_CONSUMER_KEY');
|
||||
const consumerSecret = String.fromEnvironment('KC_WC_CONSUMER_SECRET');
|
||||
const squareAccessToken = String.fromEnvironment('KC_SQUARE_ACCESS_TOKEN');
|
||||
const squareLocationId = String.fromEnvironment('KC_SQUARE_LOCATION_ID');
|
||||
const squareEnvironment = String.fromEnvironment(
|
||||
'KC_SQUARE_ENVIRONMENT',
|
||||
defaultValue: 'sandbox',
|
||||
);
|
||||
|
||||
final environment = KcAppEnvironment.fromString(envString);
|
||||
|
||||
|
|
@ -54,6 +91,9 @@ class KcAppConfig {
|
|||
wcSiteUrl: siteUrl,
|
||||
wcConsumerKey: consumerKey,
|
||||
wcConsumerSecret: consumerSecret,
|
||||
squareAccessToken: squareAccessToken,
|
||||
squareLocationId: squareLocationId,
|
||||
squareEnvironment: squareEnvironment,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -61,6 +101,12 @@ class KcAppConfig {
|
|||
bool get hasWordPressConfig =>
|
||||
wcSiteUrl.isNotEmpty && wcConsumerKey.isNotEmpty && wcConsumerSecret.isNotEmpty;
|
||||
|
||||
/// Whether the Square configuration values are all present and non-empty.
|
||||
bool get hasSquareConfig => squareAccessToken.isNotEmpty && squareLocationId.isNotEmpty;
|
||||
|
||||
/// Whether the Square environment is production (vs. sandbox).
|
||||
bool get isSquareProduction => squareEnvironment == 'production';
|
||||
|
||||
/// A human-readable label for the current environment (e.g. for badges).
|
||||
String get environmentLabel => environment.label;
|
||||
|
||||
|
|
@ -71,15 +117,27 @@ class KcAppConfig {
|
|||
environment == other.environment &&
|
||||
wcSiteUrl == other.wcSiteUrl &&
|
||||
wcConsumerKey == other.wcConsumerKey &&
|
||||
wcConsumerSecret == other.wcConsumerSecret;
|
||||
wcConsumerSecret == other.wcConsumerSecret &&
|
||||
squareAccessToken == other.squareAccessToken &&
|
||||
squareLocationId == other.squareLocationId &&
|
||||
squareEnvironment == other.squareEnvironment;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(environment, wcSiteUrl, wcConsumerKey, wcConsumerSecret);
|
||||
int get hashCode => Object.hash(
|
||||
environment,
|
||||
wcSiteUrl,
|
||||
wcConsumerKey,
|
||||
wcConsumerSecret,
|
||||
squareAccessToken,
|
||||
squareLocationId,
|
||||
squareEnvironment,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'KcAppConfig(environment: ${environment.key}, '
|
||||
'hasWordPressConfig: $hasWordPressConfig)';
|
||||
'hasWordPressConfig: $hasWordPressConfig, '
|
||||
'hasSquareConfig: $hasSquareConfig)';
|
||||
}
|
||||
|
||||
/// The supported runtime environments.
|
||||
|
|
@ -92,9 +150,15 @@ enum KcAppEnvironment {
|
|||
|
||||
/// Real Square backend (catalog, inventory, payments).
|
||||
///
|
||||
/// Planned for Stage 10A. Selecting this mode before the Square integration
|
||||
/// is implemented falls back to [fake] mode at runtime.
|
||||
square('square', 'SQ');
|
||||
/// Activated in Stage 10A. Selecting this mode wires the Square API client
|
||||
/// for catalog, inventory, and payment operations.
|
||||
square('square', 'SQ'),
|
||||
|
||||
/// Both WooCommerce and Square backends active simultaneously.
|
||||
///
|
||||
/// Planned for Stage 13A (multi-channel sync). Selecting this mode wires
|
||||
/// both the WooCommerce and Square API clients.
|
||||
multi('multi', 'MULTI');
|
||||
|
||||
final String key;
|
||||
final String label;
|
||||
|
|
|
|||
|
|
@ -41,8 +41,9 @@ abstract class KcAppServices {
|
|||
/// A factory that constructs the appropriate [KcAppServices] subclass
|
||||
/// from a [KcAppConfig].
|
||||
///
|
||||
/// Each app target provides two factories — one for fake mode and one for
|
||||
/// the real backend — via [KcServiceFactory].
|
||||
/// Each app target provides factories for each supported environment mode.
|
||||
/// [createSquare] and [createMulti] are optional — if not provided,
|
||||
/// [KcBootstrap] falls back to [createFake] for those modes.
|
||||
///
|
||||
/// ```dart
|
||||
/// final factory = KcServiceFactory<WebAppServices>(
|
||||
|
|
@ -52,6 +53,10 @@ abstract class KcAppServices {
|
|||
/// consumerKey: config.wcConsumerKey,
|
||||
/// consumerSecret: config.wcConsumerSecret,
|
||||
/// ),
|
||||
/// createSquare: (config) => WebAppServices.square(
|
||||
/// accessToken: config.squareAccessToken,
|
||||
/// locationId: config.squareLocationId,
|
||||
/// ),
|
||||
/// );
|
||||
/// ```
|
||||
class KcServiceFactory<T extends KcAppServices> {
|
||||
|
|
@ -63,5 +68,28 @@ class KcServiceFactory<T extends KcAppServices> {
|
|||
/// Receives the full [KcAppConfig] so the factory can extract credentials.
|
||||
final T Function(KcAppConfig config) createWordPress;
|
||||
|
||||
const KcServiceFactory({required this.createFake, required this.createWordPress});
|
||||
/// Creates services backed by the real Square backend.
|
||||
///
|
||||
/// Optional — if not provided, [KcBootstrap] falls back to [createFake]
|
||||
/// when [KcAppEnvironment.square] is selected.
|
||||
///
|
||||
/// Receives the full [KcAppConfig] so the factory can extract Square
|
||||
/// credentials (`squareAccessToken`, `squareLocationId`, `squareEnvironment`).
|
||||
final T Function(KcAppConfig config)? createSquare;
|
||||
|
||||
/// Creates services backed by both WooCommerce and Square backends.
|
||||
///
|
||||
/// Optional — if not provided, [KcBootstrap] falls back to [createFake]
|
||||
/// when [KcAppEnvironment.multi] is selected.
|
||||
///
|
||||
/// Receives the full [KcAppConfig] so the factory can extract credentials
|
||||
/// for both channels.
|
||||
final T Function(KcAppConfig config)? createMulti;
|
||||
|
||||
const KcServiceFactory({
|
||||
required this.createFake,
|
||||
required this.createWordPress,
|
||||
this.createSquare,
|
||||
this.createMulti,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,13 +66,13 @@ class KcBootstrap {
|
|||
return (services: factory.createWordPress(config), config: config);
|
||||
|
||||
case KcAppEnvironment.square:
|
||||
// Square integration is planned for Stage 10A. Until then, fall back
|
||||
// to fake mode so the app remains functional when KC_ENV=square is set.
|
||||
if (!config.hasSquareConfig) {
|
||||
if (kDebugMode) {
|
||||
// ignore: avoid_print
|
||||
debugPrint(
|
||||
'⚠️ KC_ENV=square is not yet implemented.\n'
|
||||
' Falling back to fake mode until Stage 10A is complete.',
|
||||
'⚠️ KC_ENV=square but Square credentials are missing.\n'
|
||||
' Falling back to fake mode.\n'
|
||||
' Set KC_SQUARE_ACCESS_TOKEN and KC_SQUARE_LOCATION_ID via --dart-define.',
|
||||
);
|
||||
}
|
||||
final fallbackConfig = KcAppConfig(
|
||||
|
|
@ -83,5 +83,61 @@ class KcBootstrap {
|
|||
);
|
||||
return (services: factory.createFake(), config: fallbackConfig);
|
||||
}
|
||||
if (factory.createSquare == null) {
|
||||
if (kDebugMode) {
|
||||
// ignore: avoid_print
|
||||
debugPrint(
|
||||
'⚠️ KC_ENV=square but no createSquare factory provided.\n'
|
||||
' Falling back to fake mode.',
|
||||
);
|
||||
}
|
||||
final fallbackConfig = KcAppConfig(
|
||||
environment: KcAppEnvironment.fake,
|
||||
wcSiteUrl: config.wcSiteUrl,
|
||||
wcConsumerKey: config.wcConsumerKey,
|
||||
wcConsumerSecret: config.wcConsumerSecret,
|
||||
);
|
||||
return (services: factory.createFake(), config: fallbackConfig);
|
||||
}
|
||||
return (services: factory.createSquare!(config), config: config);
|
||||
|
||||
case KcAppEnvironment.multi:
|
||||
// Multi-channel mode requires both WooCommerce and Square credentials.
|
||||
// Falls back to fake mode if either set of credentials is missing.
|
||||
if (!config.hasWordPressConfig || !config.hasSquareConfig) {
|
||||
if (kDebugMode) {
|
||||
// ignore: avoid_print
|
||||
debugPrint(
|
||||
'⚠️ KC_ENV=multi but credentials are incomplete.\n'
|
||||
' Requires both WooCommerce and Square credentials.\n'
|
||||
' Falling back to fake mode.',
|
||||
);
|
||||
}
|
||||
final fallbackConfig = KcAppConfig(
|
||||
environment: KcAppEnvironment.fake,
|
||||
wcSiteUrl: config.wcSiteUrl,
|
||||
wcConsumerKey: config.wcConsumerKey,
|
||||
wcConsumerSecret: config.wcConsumerSecret,
|
||||
);
|
||||
return (services: factory.createFake(), config: fallbackConfig);
|
||||
}
|
||||
if (factory.createMulti == null) {
|
||||
if (kDebugMode) {
|
||||
// ignore: avoid_print
|
||||
debugPrint(
|
||||
'⚠️ KC_ENV=multi but no createMulti factory provided.\n'
|
||||
' Falling back to fake mode.',
|
||||
);
|
||||
}
|
||||
final fallbackConfig = KcAppConfig(
|
||||
environment: KcAppEnvironment.fake,
|
||||
wcSiteUrl: config.wcSiteUrl,
|
||||
wcConsumerKey: config.wcConsumerKey,
|
||||
wcConsumerSecret: config.wcConsumerSecret,
|
||||
);
|
||||
return (services: factory.createFake(), config: fallbackConfig);
|
||||
}
|
||||
return (services: factory.createMulti!(config), config: config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,117 @@ void main() {
|
|||
expect(config.toString(), contains('fake'));
|
||||
expect(config.toString(), contains('hasWordPressConfig: false'));
|
||||
});
|
||||
|
||||
test('hasSquareConfig returns true when both Square credentials present', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
|
||||
expect(config.hasSquareConfig, isTrue);
|
||||
expect(config.environmentLabel, 'SQ');
|
||||
});
|
||||
|
||||
test('hasSquareConfig returns false when accessToken is missing', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: '',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
|
||||
expect(config.hasSquareConfig, isFalse);
|
||||
});
|
||||
|
||||
test('hasSquareConfig returns false when locationId is missing', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: '',
|
||||
);
|
||||
|
||||
expect(config.hasSquareConfig, isFalse);
|
||||
});
|
||||
|
||||
test('isSquareProduction returns true for production environment', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareEnvironment: 'production',
|
||||
);
|
||||
|
||||
expect(config.isSquareProduction, isTrue);
|
||||
});
|
||||
|
||||
test('isSquareProduction returns false for sandbox (default)', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
);
|
||||
|
||||
expect(config.isSquareProduction, isFalse);
|
||||
expect(config.squareEnvironment, 'sandbox');
|
||||
});
|
||||
|
||||
test('Square config fields default to empty/sandbox', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.fake,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
);
|
||||
|
||||
expect(config.squareAccessToken, '');
|
||||
expect(config.squareLocationId, '');
|
||||
expect(config.squareEnvironment, 'sandbox');
|
||||
});
|
||||
|
||||
test('equality includes Square fields', () {
|
||||
const a = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: 'token-a',
|
||||
squareLocationId: 'LOC1',
|
||||
);
|
||||
const b = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: 'token-b',
|
||||
squareLocationId: 'LOC1',
|
||||
);
|
||||
|
||||
expect(a, isNot(equals(b)));
|
||||
});
|
||||
|
||||
test('toString includes hasSquareConfig', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
|
||||
expect(config.toString(), contains('hasSquareConfig: true'));
|
||||
});
|
||||
});
|
||||
|
||||
group('KcAppEnvironment', () {
|
||||
|
|
@ -84,12 +195,14 @@ void main() {
|
|||
expect(KcAppEnvironment.fromString('fake'), KcAppEnvironment.fake);
|
||||
expect(KcAppEnvironment.fromString('wordpress'), KcAppEnvironment.wordpress);
|
||||
expect(KcAppEnvironment.fromString('square'), KcAppEnvironment.square);
|
||||
expect(KcAppEnvironment.fromString('multi'), KcAppEnvironment.multi);
|
||||
});
|
||||
|
||||
test('fromString is case-insensitive', () {
|
||||
expect(KcAppEnvironment.fromString('FAKE'), KcAppEnvironment.fake);
|
||||
expect(KcAppEnvironment.fromString('WordPress'), KcAppEnvironment.wordpress);
|
||||
expect(KcAppEnvironment.fromString('SQUARE'), KcAppEnvironment.square);
|
||||
expect(KcAppEnvironment.fromString('MULTI'), KcAppEnvironment.multi);
|
||||
});
|
||||
|
||||
test('fromString defaults to fake for unknown values', () {
|
||||
|
|
@ -101,12 +214,14 @@ void main() {
|
|||
expect(KcAppEnvironment.fake.label, 'FAKE');
|
||||
expect(KcAppEnvironment.wordpress.label, 'WP');
|
||||
expect(KcAppEnvironment.square.label, 'SQ');
|
||||
expect(KcAppEnvironment.multi.label, 'MULTI');
|
||||
});
|
||||
|
||||
test('key returns expected raw string', () {
|
||||
expect(KcAppEnvironment.fake.key, 'fake');
|
||||
expect(KcAppEnvironment.wordpress.key, 'wordpress');
|
||||
expect(KcAppEnvironment.square.key, 'square');
|
||||
expect(KcAppEnvironment.multi.key, 'multi');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -212,12 +327,14 @@ void main() {
|
|||
expect(result.config.wcSiteUrl, 'https://example.com');
|
||||
});
|
||||
|
||||
test('square environment falls back to fake (not yet implemented)', () {
|
||||
test('square environment without credentials falls back to fake', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: '',
|
||||
squareLocationId: '',
|
||||
);
|
||||
|
||||
final result = KcBootstrap.run(config, factory);
|
||||
|
|
@ -225,6 +342,103 @@ void main() {
|
|||
expect(result.services.mode, 'fake');
|
||||
expect(result.config.environment, KcAppEnvironment.fake);
|
||||
});
|
||||
|
||||
test('square environment without createSquare factory falls back to fake', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
// factory has no createSquare — should fall back to fake
|
||||
final result = KcBootstrap.run(config, factory);
|
||||
|
||||
expect(result.services.mode, 'fake');
|
||||
expect(result.config.environment, KcAppEnvironment.fake);
|
||||
});
|
||||
|
||||
test(
|
||||
'square environment with credentials and createSquare factory returns square services',
|
||||
() {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.square,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
final squareFactory = KcServiceFactory<_TestAppServices>(
|
||||
createFake: () => _TestAppServices(mode: 'fake'),
|
||||
createWordPress: (cfg) => _TestAppServices(mode: 'wp'),
|
||||
createSquare: (cfg) => _TestAppServices(mode: 'sq-${cfg.squareLocationId}'),
|
||||
);
|
||||
|
||||
final result = KcBootstrap.run(config, squareFactory);
|
||||
|
||||
expect(result.services.mode, 'sq-LOC123');
|
||||
expect(result.config.environment, KcAppEnvironment.square);
|
||||
},
|
||||
);
|
||||
|
||||
test('multi environment without credentials falls back to fake', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.multi,
|
||||
wcSiteUrl: '',
|
||||
wcConsumerKey: '',
|
||||
wcConsumerSecret: '',
|
||||
squareAccessToken: '',
|
||||
squareLocationId: '',
|
||||
);
|
||||
|
||||
final result = KcBootstrap.run(config, factory);
|
||||
|
||||
expect(result.services.mode, 'fake');
|
||||
expect(result.config.environment, KcAppEnvironment.fake);
|
||||
});
|
||||
|
||||
test('multi environment without createMulti factory falls back to fake', () {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.multi,
|
||||
wcSiteUrl: 'https://example.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
// factory has no createMulti — should fall back to fake
|
||||
final result = KcBootstrap.run(config, factory);
|
||||
|
||||
expect(result.services.mode, 'fake');
|
||||
expect(result.config.environment, KcAppEnvironment.fake);
|
||||
});
|
||||
|
||||
test(
|
||||
'multi environment with all credentials and createMulti factory returns multi services',
|
||||
() {
|
||||
const config = KcAppConfig(
|
||||
environment: KcAppEnvironment.multi,
|
||||
wcSiteUrl: 'https://example.com',
|
||||
wcConsumerKey: 'ck_test',
|
||||
wcConsumerSecret: 'cs_test',
|
||||
squareAccessToken: 'EAAAl_test',
|
||||
squareLocationId: 'LOC123',
|
||||
);
|
||||
final multiFactory = KcServiceFactory<_TestAppServices>(
|
||||
createFake: () => _TestAppServices(mode: 'fake'),
|
||||
createWordPress: (cfg) => _TestAppServices(mode: 'wp'),
|
||||
createSquare: (cfg) => _TestAppServices(mode: 'sq'),
|
||||
createMulti: (cfg) => _TestAppServices(mode: 'multi'),
|
||||
);
|
||||
|
||||
final result = KcBootstrap.run(config, multiFactory);
|
||||
|
||||
expect(result.services.mode, 'multi');
|
||||
expect(result.config.environment, KcAppEnvironment.multi);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('KcAppScope', () {
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ export 'src/channel_adapter.dart';
|
|||
export 'src/integration_health_check.dart';
|
||||
export 'src/rate_limiter.dart';
|
||||
export 'src/retry_policy.dart';
|
||||
export 'src/square_api_client.dart';
|
||||
export 'src/webhook_handler.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,637 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'api_client.dart';
|
||||
import 'api_exception.dart';
|
||||
|
||||
/// Lightweight HTTP client for the Square API v2.
|
||||
///
|
||||
/// Implements [ApiClient] so it satisfies the shared integration contract.
|
||||
/// Handles authentication via Bearer token (access token injection).
|
||||
///
|
||||
/// Square API documentation: https://developer.squareup.com/reference/square
|
||||
///
|
||||
/// Configuration is injected via `--dart-define`:
|
||||
/// - `KC_SQUARE_ACCESS_TOKEN` — Square API access token
|
||||
/// - `KC_SQUARE_LOCATION_ID` — Square location ID for inventory/payments
|
||||
/// - `KC_SQUARE_ENVIRONMENT` — `sandbox` or `production` (default: `sandbox`)
|
||||
class SquareApiClient implements ApiClient {
|
||||
/// The Square API base URL.
|
||||
///
|
||||
/// Sandbox: `https://connect.squareupsandbox.com`
|
||||
/// Production: `https://connect.squareup.com`
|
||||
final String baseUrl;
|
||||
|
||||
/// Square API access token (Bearer token).
|
||||
final String accessToken;
|
||||
|
||||
/// The Square location ID used for inventory counts and payment processing.
|
||||
final String locationId;
|
||||
|
||||
/// Optional [http.Client] for testing / injection.
|
||||
final http.Client _httpClient;
|
||||
|
||||
bool _ready = false;
|
||||
|
||||
SquareApiClient({
|
||||
required this.accessToken,
|
||||
required this.locationId,
|
||||
String? baseUrl,
|
||||
http.Client? httpClient,
|
||||
}) : baseUrl = baseUrl ??
|
||||
(accessToken.startsWith('EAAAl') || accessToken.startsWith('EAAA')
|
||||
? 'https://connect.squareup.com'
|
||||
: 'https://connect.squareupsandbox.com'),
|
||||
_httpClient = httpClient ?? http.Client();
|
||||
|
||||
/// Creates a [SquareApiClient] configured for the sandbox environment.
|
||||
factory SquareApiClient.sandbox({
|
||||
required String accessToken,
|
||||
required String locationId,
|
||||
http.Client? httpClient,
|
||||
}) {
|
||||
return SquareApiClient(
|
||||
accessToken: accessToken,
|
||||
locationId: locationId,
|
||||
baseUrl: 'https://connect.squareupsandbox.com',
|
||||
httpClient: httpClient,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a [SquareApiClient] configured for the production environment.
|
||||
factory SquareApiClient.production({
|
||||
required String accessToken,
|
||||
required String locationId,
|
||||
http.Client? httpClient,
|
||||
}) {
|
||||
return SquareApiClient(
|
||||
accessToken: accessToken,
|
||||
locationId: locationId,
|
||||
baseUrl: 'https://connect.squareup.com',
|
||||
httpClient: httpClient,
|
||||
);
|
||||
}
|
||||
|
||||
// ── ApiClient contract ────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
// Bearer token auth requires no async setup; 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;
|
||||
}
|
||||
|
||||
// ── Auth headers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Bearer token headers for Square API v2.
|
||||
Map<String, String> get _authHeaders => {
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
'Content-Type': 'application/json',
|
||||
'Square-Version': '2024-01-17',
|
||||
};
|
||||
|
||||
// ── Catalog API ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Lists all catalog items of the given [types] from Square.
|
||||
///
|
||||
/// [types] defaults to `['ITEM']` (products). Pass `['CATEGORY']` for
|
||||
/// categories, or multiple types to retrieve them together.
|
||||
///
|
||||
/// Paginates automatically until all items are retrieved.
|
||||
/// Returns the raw list of catalog object maps.
|
||||
Future<List<Map<String, dynamic>>> listCatalogItems({
|
||||
List<String> types = const ['ITEM'],
|
||||
}) async {
|
||||
final allObjects = <Map<String, dynamic>>[];
|
||||
String? cursor;
|
||||
|
||||
do {
|
||||
final queryParams = <String, String>{
|
||||
'types': types.join(','),
|
||||
};
|
||||
if (cursor != null) queryParams['cursor'] = cursor;
|
||||
|
||||
final uri = Uri.parse(
|
||||
'$baseUrl/v2/catalog/list',
|
||||
).replace(queryParameters: queryParams);
|
||||
|
||||
final response = await _httpClient.get(uri, headers: _authHeaders);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Failed to list catalog items: ${response.reasonPhrase}',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Unexpected response format: expected a JSON object',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final objects = decoded['objects'];
|
||||
if (objects is List) {
|
||||
allObjects.addAll(objects.cast<Map<String, dynamic>>());
|
||||
}
|
||||
|
||||
cursor = decoded['cursor'] as String?;
|
||||
} while (cursor != null);
|
||||
|
||||
return allObjects;
|
||||
}
|
||||
|
||||
/// Fetches a single catalog object by its Square [catalogObjectId].
|
||||
///
|
||||
/// Returns the raw catalog object map.
|
||||
Future<Map<String, dynamic>> getCatalogItem(String catalogObjectId) async {
|
||||
final uri = Uri.parse('$baseUrl/v2/catalog/object/$catalogObjectId');
|
||||
final response = await _httpClient.get(uri, headers: _authHeaders);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message:
|
||||
'Failed to fetch catalog item $catalogObjectId: ${response.reasonPhrase}',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Unexpected response format: expected a JSON object',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final obj = decoded['object'];
|
||||
if (obj is! Map<String, dynamic>) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Unexpected response: missing "object" field',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
/// Creates or updates a catalog object in Square (upsert).
|
||||
///
|
||||
/// [catalogObject] should be a valid Square CatalogObject map with at
|
||||
/// minimum `type` and `id` (use a temporary ID like `#new-item` for creates).
|
||||
///
|
||||
/// Returns the upserted catalog object map.
|
||||
Future<Map<String, dynamic>> upsertCatalogItem(
|
||||
Map<String, dynamic> catalogObject,
|
||||
) async {
|
||||
final uri = Uri.parse('$baseUrl/v2/catalog/object');
|
||||
final body = {
|
||||
'idempotency_key': _generateIdempotencyKey(),
|
||||
'object': catalogObject,
|
||||
};
|
||||
|
||||
final response = await _httpClient.post(
|
||||
uri,
|
||||
headers: _authHeaders,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Failed to upsert catalog item: ${response.reasonPhrase}',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Unexpected response format: expected a JSON object',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final obj = decoded['catalog_object'];
|
||||
if (obj is! Map<String, dynamic>) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Unexpected response: missing "catalog_object" field',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
/// Deletes a catalog object by its Square [catalogObjectId].
|
||||
///
|
||||
/// Returns the list of deleted object IDs.
|
||||
Future<List<String>> deleteCatalogItem(String catalogObjectId) async {
|
||||
final uri = Uri.parse('$baseUrl/v2/catalog/object/$catalogObjectId');
|
||||
final response = await _httpClient.delete(uri, headers: _authHeaders);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message:
|
||||
'Failed to delete catalog item $catalogObjectId: ${response.reasonPhrase}',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Unexpected response format: expected a JSON object',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final deletedIds = decoded['deleted_object_ids'];
|
||||
if (deletedIds is! List) return [];
|
||||
return deletedIds.cast<String>();
|
||||
}
|
||||
|
||||
// ── Inventory API ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Fetches the inventory count for a specific catalog item variation.
|
||||
///
|
||||
/// [catalogObjectId] is the Square ID of the item variation (not the item).
|
||||
/// Returns the raw inventory count map, or `null` if no count exists.
|
||||
Future<Map<String, dynamic>?> getInventoryCount(
|
||||
String catalogObjectId,
|
||||
) async {
|
||||
final uri = Uri.parse(
|
||||
'$baseUrl/v2/inventory/$catalogObjectId',
|
||||
).replace(queryParameters: {'location_ids': locationId});
|
||||
|
||||
final response = await _httpClient.get(uri, headers: _authHeaders);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message:
|
||||
'Failed to fetch inventory count for $catalogObjectId: ${response.reasonPhrase}',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Unexpected response format: expected a JSON object',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final counts = decoded['counts'];
|
||||
if (counts is! List || counts.isEmpty) return null;
|
||||
return counts.first as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Batch-changes inventory quantities for multiple catalog item variations.
|
||||
///
|
||||
/// [changes] is a list of inventory change maps. Each change should include:
|
||||
/// - `type`: `'PHYSICAL_COUNT'` (absolute set) or `'ADJUSTMENT'` (delta)
|
||||
/// - `physical_count` or `adjustment`: the change details
|
||||
///
|
||||
/// Returns the list of inventory count maps after the changes.
|
||||
Future<List<Map<String, dynamic>>> batchChangeInventory(
|
||||
List<Map<String, dynamic>> changes,
|
||||
) async {
|
||||
final uri = Uri.parse('$baseUrl/v2/inventory/changes/batch-create');
|
||||
final body = {
|
||||
'idempotency_key': _generateIdempotencyKey(),
|
||||
'changes': changes,
|
||||
};
|
||||
|
||||
final response = await _httpClient.post(
|
||||
uri,
|
||||
headers: _authHeaders,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Failed to batch change inventory: ${response.reasonPhrase}',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Unexpected response format: expected a JSON object',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final counts = decoded['counts'];
|
||||
if (counts is! List) return [];
|
||||
return counts.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
// ── Orders API ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Creates a new order in Square.
|
||||
///
|
||||
/// [lineItems] is a list of order line item maps. Each should include:
|
||||
/// - `catalog_object_id` or `name`: the product reference
|
||||
/// - `quantity`: string quantity (e.g. `'1'`)
|
||||
/// - `base_price_money`: `{'amount': cents, 'currency': 'USD'}`
|
||||
///
|
||||
/// Returns the created order map.
|
||||
Future<Map<String, dynamic>> createOrder(
|
||||
List<Map<String, dynamic>> lineItems, {
|
||||
String? referenceId,
|
||||
Map<String, dynamic>? customer,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/v2/orders');
|
||||
final orderBody = <String, dynamic>{
|
||||
'location_id': locationId,
|
||||
'line_items': lineItems,
|
||||
};
|
||||
if (referenceId != null) orderBody['reference_id'] = referenceId;
|
||||
if (customer != null) orderBody['customer_id'] = customer['id'];
|
||||
|
||||
final body = {
|
||||
'idempotency_key': _generateIdempotencyKey(),
|
||||
'order': orderBody,
|
||||
};
|
||||
|
||||
final response = await _httpClient.post(
|
||||
uri,
|
||||
headers: _authHeaders,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Failed to create order: ${response.reasonPhrase}',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Unexpected response format: expected a JSON object',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final order = decoded['order'];
|
||||
if (order is! Map<String, dynamic>) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Unexpected response: missing "order" field',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
/// Lists orders for the configured location.
|
||||
///
|
||||
/// [states] filters by order state (e.g. `['OPEN', 'COMPLETED']`).
|
||||
/// Returns the raw list of order maps.
|
||||
Future<List<Map<String, dynamic>>> listOrders({
|
||||
List<String>? states,
|
||||
int limit = 100,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/v2/orders/search');
|
||||
final filter = <String, dynamic>{
|
||||
'location_filter': {
|
||||
'location_ids': [locationId],
|
||||
},
|
||||
};
|
||||
if (states != null && states.isNotEmpty) {
|
||||
filter['state_filter'] = {'states': states};
|
||||
}
|
||||
|
||||
final body = {
|
||||
'location_ids': [locationId],
|
||||
'query': {'filter': filter},
|
||||
'limit': limit,
|
||||
};
|
||||
|
||||
final response = await _httpClient.post(
|
||||
uri,
|
||||
headers: _authHeaders,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Failed to list orders: ${response.reasonPhrase}',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Unexpected response format: expected a JSON object',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final orders = decoded['orders'];
|
||||
if (orders is! List) return [];
|
||||
return orders.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
/// Fetches a single order by its Square [orderId].
|
||||
///
|
||||
/// Returns the raw order map.
|
||||
Future<Map<String, dynamic>> getOrder(String orderId) async {
|
||||
final uri = Uri.parse('$baseUrl/v2/orders/$orderId');
|
||||
final response = await _httpClient.get(uri, headers: _authHeaders);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Failed to fetch order $orderId: ${response.reasonPhrase}',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Unexpected response format: expected a JSON object',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final order = decoded['order'];
|
||||
if (order is! Map<String, dynamic>) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Unexpected response: missing "order" field',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
// ── Payments API ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Creates a payment in Square.
|
||||
///
|
||||
/// [amountMoney] should be `{'amount': cents, 'currency': 'USD'}`.
|
||||
/// [sourceId] is the payment source token (e.g. `'CASH'` for cash,
|
||||
/// or a card nonce from the Square Web Payments SDK / Reader SDK).
|
||||
///
|
||||
/// Returns the created payment map.
|
||||
Future<Map<String, dynamic>> createPayment({
|
||||
required Map<String, dynamic> amountMoney,
|
||||
required String sourceId,
|
||||
String? orderId,
|
||||
String? customerId,
|
||||
String? note,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/v2/payments');
|
||||
final body = <String, dynamic>{
|
||||
'idempotency_key': _generateIdempotencyKey(),
|
||||
'amount_money': amountMoney,
|
||||
'source_id': sourceId,
|
||||
'location_id': locationId,
|
||||
};
|
||||
if (orderId != null) body['order_id'] = orderId;
|
||||
if (customerId != null) body['customer_id'] = customerId;
|
||||
if (note != null) body['note'] = note;
|
||||
|
||||
final response = await _httpClient.post(
|
||||
uri,
|
||||
headers: _authHeaders,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Failed to create payment: ${response.reasonPhrase}',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Unexpected response format: expected a JSON object',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final payment = decoded['payment'];
|
||||
if (payment is! Map<String, dynamic>) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Unexpected response: missing "payment" field',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
return payment;
|
||||
}
|
||||
|
||||
/// Fetches a single payment by its Square [paymentId].
|
||||
///
|
||||
/// Returns the raw payment map.
|
||||
Future<Map<String, dynamic>> getPayment(String paymentId) async {
|
||||
final uri = Uri.parse('$baseUrl/v2/payments/$paymentId');
|
||||
final response = await _httpClient.get(uri, headers: _authHeaders);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message:
|
||||
'Failed to fetch payment $paymentId: ${response.reasonPhrase}',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Unexpected response format: expected a JSON object',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
final payment = decoded['payment'];
|
||||
if (payment is! Map<String, dynamic>) {
|
||||
throw SquareApiException(
|
||||
statusCode: response.statusCode,
|
||||
message: 'Unexpected response: missing "payment" field',
|
||||
body: response.body,
|
||||
);
|
||||
}
|
||||
|
||||
return payment;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Generates a unique idempotency key for Square API requests.
|
||||
///
|
||||
/// Square requires a unique key per request to prevent duplicate processing.
|
||||
/// Uses the current timestamp in microseconds for uniqueness.
|
||||
String _generateIdempotencyKey() {
|
||||
return 'kc-${DateTime.now().microsecondsSinceEpoch}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Exception thrown when the Square API returns an error.
|
||||
///
|
||||
/// Extends the shared [ApiException] contract so callers can catch either
|
||||
/// [SquareApiException] for Square-specific handling or [ApiException] for
|
||||
/// generic error handling.
|
||||
class SquareApiException extends ApiException {
|
||||
const SquareApiException({
|
||||
required super.statusCode,
|
||||
required super.message,
|
||||
super.body = '',
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'SquareApiException($statusCode): $message';
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ environment:
|
|||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
http: ^1.4.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,646 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:integrations/integrations.dart';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Creates a [MockClient] that always returns [statusCode] with [body].
|
||||
MockClient _mockClient(int statusCode, Map<String, dynamic> body) {
|
||||
return MockClient((_) async => http.Response(jsonEncode(body), statusCode));
|
||||
}
|
||||
|
||||
/// Creates a [SquareApiClient] wired to [mockClient] for testing.
|
||||
SquareApiClient _client(http.Client mockClient) {
|
||||
return SquareApiClient.sandbox(
|
||||
accessToken: 'test-token',
|
||||
locationId: 'LOC123',
|
||||
httpClient: mockClient,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
// ── SquareApiException ─────────────────────────────────────────────────────
|
||||
|
||||
group('SquareApiException', () {
|
||||
test('stores statusCode, message, and body', () {
|
||||
const ex = SquareApiException(
|
||||
statusCode: 401,
|
||||
message: 'Unauthorized',
|
||||
body: '{"errors":[]}',
|
||||
);
|
||||
expect(ex.statusCode, 401);
|
||||
expect(ex.message, 'Unauthorized');
|
||||
expect(ex.body, '{"errors":[]}');
|
||||
});
|
||||
|
||||
test('body defaults to empty string', () {
|
||||
const ex = SquareApiException(statusCode: 500, message: 'Server error');
|
||||
expect(ex.body, '');
|
||||
});
|
||||
|
||||
test('toString includes statusCode and message', () {
|
||||
const ex = SquareApiException(statusCode: 404, message: 'Not found');
|
||||
expect(ex.toString(), contains('404'));
|
||||
expect(ex.toString(), contains('Not found'));
|
||||
expect(ex.toString(), contains('SquareApiException'));
|
||||
});
|
||||
|
||||
test('is an ApiException', () {
|
||||
const ex = SquareApiException(statusCode: 400, message: 'Bad request');
|
||||
expect(ex, isA<ApiException>());
|
||||
});
|
||||
|
||||
test('is an Exception', () {
|
||||
const ex = SquareApiException(statusCode: 400, message: 'Bad request');
|
||||
expect(ex, isA<Exception>());
|
||||
});
|
||||
});
|
||||
|
||||
// ── SquareApiClient construction ───────────────────────────────────────────
|
||||
|
||||
group('SquareApiClient construction', () {
|
||||
test('sandbox factory sets sandbox base URL', () {
|
||||
final client = SquareApiClient.sandbox(accessToken: 'test-token', locationId: 'LOC123');
|
||||
expect(client.baseUrl, 'https://connect.squareupsandbox.com');
|
||||
expect(client.accessToken, 'test-token');
|
||||
expect(client.locationId, 'LOC123');
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('production factory sets production base URL', () {
|
||||
final client = SquareApiClient.production(accessToken: 'prod-token', locationId: 'LOC456');
|
||||
expect(client.baseUrl, 'https://connect.squareup.com');
|
||||
client.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// ── ApiClient contract ─────────────────────────────────────────────────────
|
||||
|
||||
group('ApiClient contract', () {
|
||||
test('isReady is false before initialize', () {
|
||||
final client = _client(_mockClient(200, {}));
|
||||
expect(client.isReady, isFalse);
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('isReady is true after initialize', () async {
|
||||
final client = _client(_mockClient(200, {}));
|
||||
await client.initialize();
|
||||
expect(client.isReady, isTrue);
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('isReady is false after dispose', () async {
|
||||
final client = _client(_mockClient(200, {}));
|
||||
await client.initialize();
|
||||
client.dispose();
|
||||
expect(client.isReady, isFalse);
|
||||
});
|
||||
|
||||
test('execute calls initialize if not ready', () async {
|
||||
final client = _client(_mockClient(200, {}));
|
||||
expect(client.isReady, isFalse);
|
||||
await client.execute(() async => 'result');
|
||||
expect(client.isReady, isTrue);
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('execute returns the result of the request function', () async {
|
||||
final client = _client(_mockClient(200, {}));
|
||||
final result = await client.execute(() async => 42);
|
||||
expect(result, 42);
|
||||
client.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Catalog API ────────────────────────────────────────────────────────────
|
||||
|
||||
group('listCatalogItems', () {
|
||||
test('returns list of catalog objects on success', () async {
|
||||
final mockHttp = MockClient(
|
||||
(_) async => http.Response(
|
||||
jsonEncode({
|
||||
'objects': [
|
||||
{'id': 'CAT1', 'type': 'ITEM'},
|
||||
{'id': 'CAT2', 'type': 'ITEM'},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
),
|
||||
);
|
||||
final client = _client(mockHttp);
|
||||
|
||||
final items = await client.listCatalogItems();
|
||||
|
||||
expect(items.length, 2);
|
||||
expect(items.first['id'], 'CAT1');
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('returns empty list when objects field is absent', () async {
|
||||
final mockHttp = _mockClient(200, {});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
final items = await client.listCatalogItems();
|
||||
|
||||
expect(items, isEmpty);
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('throws SquareApiException on non-200 response', () async {
|
||||
final mockHttp = _mockClient(401, {'errors': []});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
expect(() => client.listCatalogItems(), throwsA(isA<SquareApiException>()));
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('paginates using cursor until exhausted', () async {
|
||||
var callCount = 0;
|
||||
final mockHttp = MockClient((_) async {
|
||||
callCount++;
|
||||
if (callCount == 1) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'objects': [
|
||||
{'id': 'CAT1', 'type': 'ITEM'},
|
||||
],
|
||||
'cursor': 'next-page-cursor',
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'objects': [
|
||||
{'id': 'CAT2', 'type': 'ITEM'},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
final items = await client.listCatalogItems();
|
||||
|
||||
expect(items.length, 2);
|
||||
expect(callCount, 2);
|
||||
client.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('getCatalogItem', () {
|
||||
test('returns catalog object on success', () async {
|
||||
final mockHttp = _mockClient(200, {
|
||||
'object': {'id': 'CAT1', 'type': 'ITEM', 'version': 1},
|
||||
});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
final item = await client.getCatalogItem('CAT1');
|
||||
|
||||
expect(item['id'], 'CAT1');
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('throws SquareApiException on 404', () async {
|
||||
final mockHttp = _mockClient(404, {'errors': []});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
expect(() => client.getCatalogItem('MISSING'), throwsA(isA<SquareApiException>()));
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('throws SquareApiException when object field is missing', () async {
|
||||
final mockHttp = _mockClient(200, {'no_object': true});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
expect(() => client.getCatalogItem('CAT1'), throwsA(isA<SquareApiException>()));
|
||||
client.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('upsertCatalogItem', () {
|
||||
test('returns upserted catalog object on success', () async {
|
||||
final mockHttp = _mockClient(200, {
|
||||
'catalog_object': {'id': 'CAT1', 'type': 'ITEM'},
|
||||
});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
final result = await client.upsertCatalogItem({
|
||||
'type': 'ITEM',
|
||||
'id': '#new-item',
|
||||
'item_data': {'name': 'Test Product'},
|
||||
});
|
||||
|
||||
expect(result['id'], 'CAT1');
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('throws SquareApiException on non-200 response', () async {
|
||||
final mockHttp = _mockClient(400, {'errors': []});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
expect(
|
||||
() => client.upsertCatalogItem({'type': 'ITEM', 'id': '#new'}),
|
||||
throwsA(isA<SquareApiException>()),
|
||||
);
|
||||
client.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('deleteCatalogItem', () {
|
||||
test('returns list of deleted IDs on success', () async {
|
||||
final mockHttp = _mockClient(200, {
|
||||
'deleted_object_ids': ['CAT1'],
|
||||
});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
final ids = await client.deleteCatalogItem('CAT1');
|
||||
|
||||
expect(ids, ['CAT1']);
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('returns empty list when deleted_object_ids is absent', () async {
|
||||
final mockHttp = _mockClient(200, {});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
final ids = await client.deleteCatalogItem('CAT1');
|
||||
|
||||
expect(ids, isEmpty);
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('throws SquareApiException on non-200 response', () async {
|
||||
final mockHttp = _mockClient(404, {'errors': []});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
expect(() => client.deleteCatalogItem('MISSING'), throwsA(isA<SquareApiException>()));
|
||||
client.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Inventory API ──────────────────────────────────────────────────────────
|
||||
|
||||
group('getInventoryCount', () {
|
||||
test('returns inventory count map when counts present', () async {
|
||||
final mockHttp = _mockClient(200, {
|
||||
'counts': [
|
||||
{'catalog_object_id': 'VAR1', 'quantity': '5', 'state': 'IN_STOCK'},
|
||||
],
|
||||
});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
final count = await client.getInventoryCount('VAR1');
|
||||
|
||||
expect(count, isNotNull);
|
||||
expect(count!['catalog_object_id'], 'VAR1');
|
||||
expect(count['quantity'], '5');
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('returns null when counts list is empty', () async {
|
||||
final mockHttp = _mockClient(200, {'counts': []});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
final count = await client.getInventoryCount('VAR1');
|
||||
|
||||
expect(count, isNull);
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('returns null when counts field is absent', () async {
|
||||
final mockHttp = _mockClient(200, {});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
final count = await client.getInventoryCount('VAR1');
|
||||
|
||||
expect(count, isNull);
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('throws SquareApiException on non-200 response', () async {
|
||||
final mockHttp = _mockClient(401, {'errors': []});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
expect(() => client.getInventoryCount('VAR1'), throwsA(isA<SquareApiException>()));
|
||||
client.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('batchChangeInventory', () {
|
||||
test('returns list of updated counts on success', () async {
|
||||
final mockHttp = _mockClient(200, {
|
||||
'counts': [
|
||||
{'catalog_object_id': 'VAR1', 'quantity': '10'},
|
||||
],
|
||||
});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
final counts = await client.batchChangeInventory([
|
||||
{
|
||||
'type': 'PHYSICAL_COUNT',
|
||||
'physical_count': {
|
||||
'catalog_object_id': 'VAR1',
|
||||
'quantity': '10',
|
||||
'location_id': 'LOC123',
|
||||
'occurred_at': '2026-07-19T00:00:00Z',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(counts.length, 1);
|
||||
expect(counts.first['quantity'], '10');
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('returns empty list when counts field is absent', () async {
|
||||
final mockHttp = _mockClient(200, {});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
final counts = await client.batchChangeInventory([]);
|
||||
|
||||
expect(counts, isEmpty);
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('throws SquareApiException on non-200 response', () async {
|
||||
final mockHttp = _mockClient(400, {'errors': []});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
expect(() => client.batchChangeInventory([]), throwsA(isA<SquareApiException>()));
|
||||
client.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Orders API ─────────────────────────────────────────────────────────────
|
||||
|
||||
group('createOrder', () {
|
||||
test('returns created order on success', () async {
|
||||
final mockHttp = _mockClient(200, {
|
||||
'order': {'id': 'ORD1', 'location_id': 'LOC123', 'state': 'OPEN'},
|
||||
});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
final order = await client.createOrder([
|
||||
{
|
||||
'name': 'Test Product',
|
||||
'quantity': '1',
|
||||
'base_price_money': {'amount': 1000, 'currency': 'USD'},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(order['id'], 'ORD1');
|
||||
expect(order['state'], 'OPEN');
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('includes referenceId when provided', () async {
|
||||
String? capturedBody;
|
||||
final mockHttp = MockClient((request) async {
|
||||
capturedBody = request.body;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'order': {'id': 'ORD1', 'reference_id': 'REF-001'},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
await client.createOrder([], referenceId: 'REF-001');
|
||||
|
||||
expect(capturedBody, contains('REF-001'));
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('throws SquareApiException on non-200 response', () async {
|
||||
final mockHttp = _mockClient(400, {'errors': []});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
expect(() => client.createOrder([]), throwsA(isA<SquareApiException>()));
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('throws SquareApiException when order field is missing', () async {
|
||||
final mockHttp = _mockClient(200, {'no_order': true});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
expect(() => client.createOrder([]), throwsA(isA<SquareApiException>()));
|
||||
client.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('listOrders', () {
|
||||
test('returns list of orders on success', () async {
|
||||
final mockHttp = _mockClient(200, {
|
||||
'orders': [
|
||||
{'id': 'ORD1', 'state': 'COMPLETED'},
|
||||
{'id': 'ORD2', 'state': 'OPEN'},
|
||||
],
|
||||
});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
final orders = await client.listOrders();
|
||||
|
||||
expect(orders.length, 2);
|
||||
expect(orders.first['id'], 'ORD1');
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('returns empty list when orders field is absent', () async {
|
||||
final mockHttp = _mockClient(200, {});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
final orders = await client.listOrders();
|
||||
|
||||
expect(orders, isEmpty);
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('throws SquareApiException on non-200 response', () async {
|
||||
final mockHttp = _mockClient(401, {'errors': []});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
expect(() => client.listOrders(), throwsA(isA<SquareApiException>()));
|
||||
client.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('getOrder', () {
|
||||
test('returns order map on success', () async {
|
||||
final mockHttp = _mockClient(200, {
|
||||
'order': {'id': 'ORD1', 'state': 'COMPLETED'},
|
||||
});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
final order = await client.getOrder('ORD1');
|
||||
|
||||
expect(order['id'], 'ORD1');
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('throws SquareApiException on 404', () async {
|
||||
final mockHttp = _mockClient(404, {'errors': []});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
expect(() => client.getOrder('MISSING'), throwsA(isA<SquareApiException>()));
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('throws SquareApiException when order field is missing', () async {
|
||||
final mockHttp = _mockClient(200, {'no_order': true});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
expect(() => client.getOrder('ORD1'), throwsA(isA<SquareApiException>()));
|
||||
client.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Payments API ───────────────────────────────────────────────────────────
|
||||
|
||||
group('createPayment', () {
|
||||
test('returns created payment on success', () async {
|
||||
final mockHttp = _mockClient(200, {
|
||||
'payment': {
|
||||
'id': 'PAY1',
|
||||
'status': 'COMPLETED',
|
||||
'amount_money': {'amount': 1000, 'currency': 'USD'},
|
||||
},
|
||||
});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
final payment = await client.createPayment(
|
||||
amountMoney: {'amount': 1000, 'currency': 'USD'},
|
||||
sourceId: 'CASH',
|
||||
);
|
||||
|
||||
expect(payment['id'], 'PAY1');
|
||||
expect(payment['status'], 'COMPLETED');
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('includes optional fields when provided', () async {
|
||||
String? capturedBody;
|
||||
final mockHttp = MockClient((request) async {
|
||||
capturedBody = request.body;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'payment': {'id': 'PAY1', 'order_id': 'ORD1', 'note': 'Test'},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
await client.createPayment(
|
||||
amountMoney: {'amount': 500, 'currency': 'USD'},
|
||||
sourceId: 'CASH',
|
||||
orderId: 'ORD1',
|
||||
note: 'Test',
|
||||
);
|
||||
|
||||
expect(capturedBody, contains('ORD1'));
|
||||
expect(capturedBody, contains('Test'));
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('throws SquareApiException on non-200 response', () async {
|
||||
final mockHttp = _mockClient(400, {'errors': []});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
expect(
|
||||
() => client.createPayment(
|
||||
amountMoney: {'amount': 1000, 'currency': 'USD'},
|
||||
sourceId: 'CASH',
|
||||
),
|
||||
throwsA(isA<SquareApiException>()),
|
||||
);
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('throws SquareApiException when payment field is missing', () async {
|
||||
final mockHttp = _mockClient(200, {'no_payment': true});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
expect(
|
||||
() => client.createPayment(
|
||||
amountMoney: {'amount': 1000, 'currency': 'USD'},
|
||||
sourceId: 'CASH',
|
||||
),
|
||||
throwsA(isA<SquareApiException>()),
|
||||
);
|
||||
client.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('getPayment', () {
|
||||
test('returns payment map on success', () async {
|
||||
final mockHttp = _mockClient(200, {
|
||||
'payment': {'id': 'PAY1', 'status': 'COMPLETED'},
|
||||
});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
final payment = await client.getPayment('PAY1');
|
||||
|
||||
expect(payment['id'], 'PAY1');
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('throws SquareApiException on 404', () async {
|
||||
final mockHttp = _mockClient(404, {'errors': []});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
expect(() => client.getPayment('MISSING'), throwsA(isA<SquareApiException>()));
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('throws SquareApiException when payment field is missing', () async {
|
||||
final mockHttp = _mockClient(200, {'no_payment': true});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
expect(() => client.getPayment('PAY1'), throwsA(isA<SquareApiException>()));
|
||||
client.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Auth headers ───────────────────────────────────────────────────────────
|
||||
|
||||
group('auth headers', () {
|
||||
test('sends Bearer token in Authorization header', () async {
|
||||
String? capturedAuth;
|
||||
final mockHttp = MockClient((request) async {
|
||||
capturedAuth = request.headers['Authorization'];
|
||||
return http.Response(jsonEncode({'objects': []}), 200);
|
||||
});
|
||||
final client = SquareApiClient.sandbox(
|
||||
accessToken: 'my-secret-token',
|
||||
locationId: 'LOC123',
|
||||
httpClient: mockHttp,
|
||||
);
|
||||
|
||||
await client.listCatalogItems();
|
||||
|
||||
expect(capturedAuth, 'Bearer my-secret-token');
|
||||
client.dispose();
|
||||
});
|
||||
|
||||
test('sends Square-Version header', () async {
|
||||
String? capturedVersion;
|
||||
final mockHttp = MockClient((request) async {
|
||||
capturedVersion = request.headers['Square-Version'];
|
||||
return http.Response(jsonEncode({'objects': []}), 200);
|
||||
});
|
||||
final client = _client(mockHttp);
|
||||
|
||||
await client.listCatalogItems();
|
||||
|
||||
expect(capturedVersion, isNotNull);
|
||||
expect(capturedVersion, isNotEmpty);
|
||||
client.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue