Merge feat/square-api-client into main (Stage 10A complete)
This commit is contained in:
commit
3ea1d66477
|
|
@ -3,8 +3,8 @@
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
- main baseline updated through: feat/wc-catalog-expansion-ui (Stage 9D complete)
|
- main baseline updated through: feat/wc-catalog-expansion-ui (Stage 9D complete)
|
||||||
- next branch: TBD (Stage 10 — real WooCommerce API integration wiring)
|
- next branch: feat/square-catalog-sync (Stage 10B — Square catalog and inventory sync)
|
||||||
- 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
|
- 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
|
## Slice tracker
|
||||||
|
|
||||||
|
|
@ -553,3 +553,22 @@
|
||||||
- tests: passed (424/424 feature_wordpress — all passing)
|
- tests: passed (424/424 feature_wordpress — all passing)
|
||||||
- analyze: passed (dart analyze --fatal-infos — no issues found)
|
- analyze: passed (dart analyze --fatal-infos — no issues found)
|
||||||
- brief updated: yes
|
- brief updated: yes
|
||||||
|
|
||||||
|
### feat/square-api-client
|
||||||
|
|
||||||
|
- status: ready to merge 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-catalog-sync`
|
||||||
- `feat/square-payments`
|
- `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
|
`feat/square-api-client` — merged to `main`
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
#### Stage 10B — Square catalog and inventory sync
|
#### Stage 10B — Square catalog and inventory sync
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,9 @@ class _EnvironmentBadge extends StatelessWidget {
|
||||||
case KcAppEnvironment.square:
|
case KcAppEnvironment.square:
|
||||||
backgroundColor = Colors.blue.shade100;
|
backgroundColor = Colors.blue.shade100;
|
||||||
foregroundColor = Colors.blue.shade900;
|
foregroundColor = Colors.blue.shade900;
|
||||||
|
case KcAppEnvironment.multi:
|
||||||
|
backgroundColor = Colors.purple.shade100;
|
||||||
|
foregroundColor = Colors.purple.shade900;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,9 @@ class _EnvironmentBadge extends StatelessWidget {
|
||||||
case AppEnvironment.square:
|
case AppEnvironment.square:
|
||||||
backgroundColor = Colors.blue.shade100;
|
backgroundColor = Colors.blue.shade100;
|
||||||
foregroundColor = Colors.blue.shade900;
|
foregroundColor = Colors.blue.shade900;
|
||||||
|
case AppEnvironment.multi:
|
||||||
|
backgroundColor = Colors.purple.shade100;
|
||||||
|
foregroundColor = Colors.purple.shade900;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,14 @@
|
||||||
/// The app reads the following compile-time constants:
|
/// The app reads the following compile-time constants:
|
||||||
///
|
///
|
||||||
/// | Key | Description | Default |
|
/// | 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_SITE_URL` | WordPress site URL | (empty) |
|
||||||
/// | `KC_WC_CONSUMER_KEY` | WooCommerce REST API consumer key | (empty) |
|
/// | `KC_WC_CONSUMER_KEY` | WooCommerce REST API consumer key | (empty) |
|
||||||
/// | `KC_WC_CONSUMER_SECRET` | WooCommerce REST API consumer secret | (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:
|
/// Usage:
|
||||||
/// ```sh
|
/// ```sh
|
||||||
|
|
@ -20,11 +23,21 @@
|
||||||
/// --dart-define=KC_WC_CONSUMER_KEY=ck_xxx \
|
/// --dart-define=KC_WC_CONSUMER_KEY=ck_xxx \
|
||||||
/// --dart-define=KC_WC_CONSUMER_SECRET=cs_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 {
|
class KcAppConfig {
|
||||||
/// The environment mode: `fake`, `wordpress`, or `square`.
|
/// The environment mode: `fake`, `wordpress`, `square`, or `multi`.
|
||||||
final KcAppEnvironment environment;
|
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;
|
final String wcSiteUrl;
|
||||||
|
|
||||||
/// WooCommerce REST API consumer key.
|
/// WooCommerce REST API consumer key.
|
||||||
|
|
@ -33,11 +46,29 @@ class KcAppConfig {
|
||||||
/// WooCommerce REST API consumer secret.
|
/// WooCommerce REST API consumer secret.
|
||||||
final String wcConsumerSecret;
|
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({
|
const KcAppConfig({
|
||||||
required this.environment,
|
required this.environment,
|
||||||
required this.wcSiteUrl,
|
required this.wcSiteUrl,
|
||||||
required this.wcConsumerKey,
|
required this.wcConsumerKey,
|
||||||
required this.wcConsumerSecret,
|
required this.wcConsumerSecret,
|
||||||
|
this.squareAccessToken = '',
|
||||||
|
this.squareLocationId = '',
|
||||||
|
this.squareEnvironment = 'sandbox',
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Reads configuration from compile-time `--dart-define` constants.
|
/// Reads configuration from compile-time `--dart-define` constants.
|
||||||
|
|
@ -46,6 +77,12 @@ class KcAppConfig {
|
||||||
const siteUrl = String.fromEnvironment('KC_WC_SITE_URL');
|
const siteUrl = String.fromEnvironment('KC_WC_SITE_URL');
|
||||||
const consumerKey = String.fromEnvironment('KC_WC_CONSUMER_KEY');
|
const consumerKey = String.fromEnvironment('KC_WC_CONSUMER_KEY');
|
||||||
const consumerSecret = String.fromEnvironment('KC_WC_CONSUMER_SECRET');
|
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);
|
final environment = KcAppEnvironment.fromString(envString);
|
||||||
|
|
||||||
|
|
@ -54,6 +91,9 @@ class KcAppConfig {
|
||||||
wcSiteUrl: siteUrl,
|
wcSiteUrl: siteUrl,
|
||||||
wcConsumerKey: consumerKey,
|
wcConsumerKey: consumerKey,
|
||||||
wcConsumerSecret: consumerSecret,
|
wcConsumerSecret: consumerSecret,
|
||||||
|
squareAccessToken: squareAccessToken,
|
||||||
|
squareLocationId: squareLocationId,
|
||||||
|
squareEnvironment: squareEnvironment,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,6 +101,12 @@ class KcAppConfig {
|
||||||
bool get hasWordPressConfig =>
|
bool get hasWordPressConfig =>
|
||||||
wcSiteUrl.isNotEmpty && wcConsumerKey.isNotEmpty && wcConsumerSecret.isNotEmpty;
|
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).
|
/// A human-readable label for the current environment (e.g. for badges).
|
||||||
String get environmentLabel => environment.label;
|
String get environmentLabel => environment.label;
|
||||||
|
|
||||||
|
|
@ -71,15 +117,27 @@ class KcAppConfig {
|
||||||
environment == other.environment &&
|
environment == other.environment &&
|
||||||
wcSiteUrl == other.wcSiteUrl &&
|
wcSiteUrl == other.wcSiteUrl &&
|
||||||
wcConsumerKey == other.wcConsumerKey &&
|
wcConsumerKey == other.wcConsumerKey &&
|
||||||
wcConsumerSecret == other.wcConsumerSecret;
|
wcConsumerSecret == other.wcConsumerSecret &&
|
||||||
|
squareAccessToken == other.squareAccessToken &&
|
||||||
|
squareLocationId == other.squareLocationId &&
|
||||||
|
squareEnvironment == other.squareEnvironment;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(environment, wcSiteUrl, wcConsumerKey, wcConsumerSecret);
|
int get hashCode => Object.hash(
|
||||||
|
environment,
|
||||||
|
wcSiteUrl,
|
||||||
|
wcConsumerKey,
|
||||||
|
wcConsumerSecret,
|
||||||
|
squareAccessToken,
|
||||||
|
squareLocationId,
|
||||||
|
squareEnvironment,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
String toString() =>
|
||||||
'KcAppConfig(environment: ${environment.key}, '
|
'KcAppConfig(environment: ${environment.key}, '
|
||||||
'hasWordPressConfig: $hasWordPressConfig)';
|
'hasWordPressConfig: $hasWordPressConfig, '
|
||||||
|
'hasSquareConfig: $hasSquareConfig)';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The supported runtime environments.
|
/// The supported runtime environments.
|
||||||
|
|
@ -92,9 +150,15 @@ enum KcAppEnvironment {
|
||||||
|
|
||||||
/// Real Square backend (catalog, inventory, payments).
|
/// Real Square backend (catalog, inventory, payments).
|
||||||
///
|
///
|
||||||
/// Planned for Stage 10A. Selecting this mode before the Square integration
|
/// Activated in Stage 10A. Selecting this mode wires the Square API client
|
||||||
/// is implemented falls back to [fake] mode at runtime.
|
/// for catalog, inventory, and payment operations.
|
||||||
square('square', 'SQ');
|
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 key;
|
||||||
final String label;
|
final String label;
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,9 @@ abstract class KcAppServices {
|
||||||
/// A factory that constructs the appropriate [KcAppServices] subclass
|
/// A factory that constructs the appropriate [KcAppServices] subclass
|
||||||
/// from a [KcAppConfig].
|
/// from a [KcAppConfig].
|
||||||
///
|
///
|
||||||
/// Each app target provides two factories — one for fake mode and one for
|
/// Each app target provides factories for each supported environment mode.
|
||||||
/// the real backend — via [KcServiceFactory].
|
/// [createSquare] and [createMulti] are optional — if not provided,
|
||||||
|
/// [KcBootstrap] falls back to [createFake] for those modes.
|
||||||
///
|
///
|
||||||
/// ```dart
|
/// ```dart
|
||||||
/// final factory = KcServiceFactory<WebAppServices>(
|
/// final factory = KcServiceFactory<WebAppServices>(
|
||||||
|
|
@ -52,6 +53,10 @@ abstract class KcAppServices {
|
||||||
/// consumerKey: config.wcConsumerKey,
|
/// consumerKey: config.wcConsumerKey,
|
||||||
/// consumerSecret: config.wcConsumerSecret,
|
/// consumerSecret: config.wcConsumerSecret,
|
||||||
/// ),
|
/// ),
|
||||||
|
/// createSquare: (config) => WebAppServices.square(
|
||||||
|
/// accessToken: config.squareAccessToken,
|
||||||
|
/// locationId: config.squareLocationId,
|
||||||
|
/// ),
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
class KcServiceFactory<T extends KcAppServices> {
|
class KcServiceFactory<T extends KcAppServices> {
|
||||||
|
|
@ -63,5 +68,28 @@ class KcServiceFactory<T extends KcAppServices> {
|
||||||
/// Receives the full [KcAppConfig] so the factory can extract credentials.
|
/// Receives the full [KcAppConfig] so the factory can extract credentials.
|
||||||
final T Function(KcAppConfig config) createWordPress;
|
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);
|
return (services: factory.createWordPress(config), config: config);
|
||||||
|
|
||||||
case KcAppEnvironment.square:
|
case KcAppEnvironment.square:
|
||||||
// Square integration is planned for Stage 10A. Until then, fall back
|
if (!config.hasSquareConfig) {
|
||||||
// to fake mode so the app remains functional when KC_ENV=square is set.
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ KC_ENV=square is not yet implemented.\n'
|
'⚠️ KC_ENV=square but Square credentials are missing.\n'
|
||||||
' Falling back to fake mode until Stage 10A is complete.',
|
' Falling back to fake mode.\n'
|
||||||
|
' Set KC_SQUARE_ACCESS_TOKEN and KC_SQUARE_LOCATION_ID via --dart-define.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final fallbackConfig = KcAppConfig(
|
final fallbackConfig = KcAppConfig(
|
||||||
|
|
@ -83,5 +83,61 @@ class KcBootstrap {
|
||||||
);
|
);
|
||||||
return (services: factory.createFake(), config: fallbackConfig);
|
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('fake'));
|
||||||
expect(config.toString(), contains('hasWordPressConfig: false'));
|
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', () {
|
group('KcAppEnvironment', () {
|
||||||
|
|
@ -84,12 +195,14 @@ void main() {
|
||||||
expect(KcAppEnvironment.fromString('fake'), KcAppEnvironment.fake);
|
expect(KcAppEnvironment.fromString('fake'), KcAppEnvironment.fake);
|
||||||
expect(KcAppEnvironment.fromString('wordpress'), KcAppEnvironment.wordpress);
|
expect(KcAppEnvironment.fromString('wordpress'), KcAppEnvironment.wordpress);
|
||||||
expect(KcAppEnvironment.fromString('square'), KcAppEnvironment.square);
|
expect(KcAppEnvironment.fromString('square'), KcAppEnvironment.square);
|
||||||
|
expect(KcAppEnvironment.fromString('multi'), KcAppEnvironment.multi);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fromString is case-insensitive', () {
|
test('fromString is case-insensitive', () {
|
||||||
expect(KcAppEnvironment.fromString('FAKE'), KcAppEnvironment.fake);
|
expect(KcAppEnvironment.fromString('FAKE'), KcAppEnvironment.fake);
|
||||||
expect(KcAppEnvironment.fromString('WordPress'), KcAppEnvironment.wordpress);
|
expect(KcAppEnvironment.fromString('WordPress'), KcAppEnvironment.wordpress);
|
||||||
expect(KcAppEnvironment.fromString('SQUARE'), KcAppEnvironment.square);
|
expect(KcAppEnvironment.fromString('SQUARE'), KcAppEnvironment.square);
|
||||||
|
expect(KcAppEnvironment.fromString('MULTI'), KcAppEnvironment.multi);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fromString defaults to fake for unknown values', () {
|
test('fromString defaults to fake for unknown values', () {
|
||||||
|
|
@ -101,12 +214,14 @@ void main() {
|
||||||
expect(KcAppEnvironment.fake.label, 'FAKE');
|
expect(KcAppEnvironment.fake.label, 'FAKE');
|
||||||
expect(KcAppEnvironment.wordpress.label, 'WP');
|
expect(KcAppEnvironment.wordpress.label, 'WP');
|
||||||
expect(KcAppEnvironment.square.label, 'SQ');
|
expect(KcAppEnvironment.square.label, 'SQ');
|
||||||
|
expect(KcAppEnvironment.multi.label, 'MULTI');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('key returns expected raw string', () {
|
test('key returns expected raw string', () {
|
||||||
expect(KcAppEnvironment.fake.key, 'fake');
|
expect(KcAppEnvironment.fake.key, 'fake');
|
||||||
expect(KcAppEnvironment.wordpress.key, 'wordpress');
|
expect(KcAppEnvironment.wordpress.key, 'wordpress');
|
||||||
expect(KcAppEnvironment.square.key, 'square');
|
expect(KcAppEnvironment.square.key, 'square');
|
||||||
|
expect(KcAppEnvironment.multi.key, 'multi');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -212,12 +327,14 @@ void main() {
|
||||||
expect(result.config.wcSiteUrl, 'https://example.com');
|
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(
|
const config = KcAppConfig(
|
||||||
environment: KcAppEnvironment.square,
|
environment: KcAppEnvironment.square,
|
||||||
wcSiteUrl: '',
|
wcSiteUrl: '',
|
||||||
wcConsumerKey: '',
|
wcConsumerKey: '',
|
||||||
wcConsumerSecret: '',
|
wcConsumerSecret: '',
|
||||||
|
squareAccessToken: '',
|
||||||
|
squareLocationId: '',
|
||||||
);
|
);
|
||||||
|
|
||||||
final result = KcBootstrap.run(config, factory);
|
final result = KcBootstrap.run(config, factory);
|
||||||
|
|
@ -225,6 +342,103 @@ void main() {
|
||||||
expect(result.services.mode, 'fake');
|
expect(result.services.mode, 'fake');
|
||||||
expect(result.config.environment, KcAppEnvironment.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', () {
|
group('KcAppScope', () {
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,5 @@ export 'src/channel_adapter.dart';
|
||||||
export 'src/integration_health_check.dart';
|
export 'src/integration_health_check.dart';
|
||||||
export 'src/rate_limiter.dart';
|
export 'src/rate_limiter.dart';
|
||||||
export 'src/retry_policy.dart';
|
export 'src/retry_policy.dart';
|
||||||
|
export 'src/square_api_client.dart';
|
||||||
export 'src/webhook_handler.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:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
http: ^1.4.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
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