feat(stage-10b): Square catalog and inventory sync mappers + ProductIdMapping

This commit is contained in:
Mike Kell 2026-07-25 07:59:13 -04:00
parent 4b1168dbfa
commit a6f888c672
9 changed files with 1308 additions and 0 deletions

View File

@ -1,6 +1,7 @@
library; library;
// Data // Data
export 'src/data/fake_product_id_mapping_repository.dart';
export 'src/data/fake_product_publishing_repository.dart'; export 'src/data/fake_product_publishing_repository.dart';
export 'src/data/woo_commerce_api_client.dart'; export 'src/data/woo_commerce_api_client.dart';
export 'src/data/wordpress_product_mapper.dart'; export 'src/data/wordpress_product_mapper.dart';
@ -9,6 +10,8 @@ export 'src/data/wordpress_product_publishing_repository.dart';
// Domain // Domain
export 'src/domain/product_category.dart'; export 'src/domain/product_category.dart';
export 'src/domain/product_draft.dart'; export 'src/domain/product_draft.dart';
export 'src/domain/product_id_mapping.dart';
export 'src/domain/product_id_mapping_repository.dart';
export 'src/domain/product_image.dart'; export 'src/domain/product_image.dart';
export 'src/domain/product_publishing_repository.dart'; export 'src/domain/product_publishing_repository.dart';
export 'src/domain/publish_status.dart'; export 'src/domain/publish_status.dart';

View File

@ -0,0 +1,94 @@
import '../domain/product_id_mapping.dart';
import '../domain/product_id_mapping_repository.dart';
/// In-memory implementation of [ProductIdMappingRepository] for use in
/// `FAKE` mode and unit tests.
///
/// Seeded with mappings that correspond to the products in
/// [FakeProductPublishingRepository] and the inventory items in
/// [FakeInventoryRepository].
class FakeProductIdMappingRepository implements ProductIdMappingRepository {
final Map<String, ProductIdMapping> _store = {};
FakeProductIdMappingRepository() {
// Seed mappings for the 6 fake products.
// Products 13 have both WooCommerce and Square IDs (fully synced).
// Products 45 have WooCommerce IDs only (not yet pushed to Square).
// Product 6 has no channel IDs (draft, not synced anywhere).
final now = DateTime(2026, 7, 1);
final seeds = [
ProductIdMapping(
localId: '1',
wooCommerceId: '101',
squareCatalogObjectId: 'SQ-ITEM-001',
squareVariationId: 'SQ-VAR-001',
lastUpdated: now,
),
ProductIdMapping(
localId: '2',
wooCommerceId: '102',
squareCatalogObjectId: 'SQ-ITEM-002',
squareVariationId: 'SQ-VAR-002',
lastUpdated: now,
),
ProductIdMapping(
localId: '3',
wooCommerceId: '103',
squareCatalogObjectId: 'SQ-ITEM-003',
squareVariationId: 'SQ-VAR-003',
lastUpdated: now,
),
ProductIdMapping(localId: '4', wooCommerceId: '104', lastUpdated: now),
ProductIdMapping(localId: '5', wooCommerceId: '105', lastUpdated: now),
ProductIdMapping(localId: '6', lastUpdated: now),
];
for (final m in seeds) {
_store[m.localId] = m;
}
}
@override
Future<List<ProductIdMapping>> getAllMappings() async {
await Future<void>.delayed(const Duration(milliseconds: 50));
return List.unmodifiable(_store.values.toList());
}
@override
Future<ProductIdMapping?> getMappingByLocalId(String localId) async {
await Future<void>.delayed(const Duration(milliseconds: 50));
return _store[localId];
}
@override
Future<ProductIdMapping?> getMappingByWooCommerceId(String wooCommerceId) async {
await Future<void>.delayed(const Duration(milliseconds: 50));
try {
return _store.values.firstWhere((m) => m.wooCommerceId == wooCommerceId);
} catch (_) {
return null;
}
}
@override
Future<ProductIdMapping?> getMappingBySquareId(String squareCatalogObjectId) async {
await Future<void>.delayed(const Duration(milliseconds: 50));
try {
return _store.values.firstWhere((m) => m.squareCatalogObjectId == squareCatalogObjectId);
} catch (_) {
return null;
}
}
@override
Future<ProductIdMapping> saveMapping(ProductIdMapping mapping) async {
await Future<void>.delayed(const Duration(milliseconds: 50));
_store[mapping.localId] = mapping;
return mapping;
}
@override
Future<void> deleteMapping(String localId) async {
await Future<void>.delayed(const Duration(milliseconds: 50));
_store.remove(localId);
}
}

View File

@ -0,0 +1,83 @@
/// A cross-channel product ID mapping that links a product's identity across
/// WooCommerce and Square.
///
/// The app treats WooCommerce as the primary catalog source. When a product is
/// synced to Square, the resulting Square catalog object ID is stored here so
/// that future sync operations can locate the correct remote record without
/// performing an expensive search.
///
/// Either [wooCommerceId] or [squareCatalogObjectId] may be `null` when a
/// product exists in only one channel (e.g. a Square-only in-person item that
/// has not yet been published to WooCommerce, or a WooCommerce product that has
/// not yet been pushed to Square).
class ProductIdMapping {
/// The local app-internal product identifier (matches [ProductDraft.id]).
final String localId;
/// The WooCommerce product ID (integer stored as string for consistency).
///
/// `null` if this product has no WooCommerce counterpart.
final String? wooCommerceId;
/// The Square CatalogObject ID for the ITEM-level object.
///
/// `null` if this product has not been pushed to Square.
final String? squareCatalogObjectId;
/// The Square CatalogObject ID for the primary ITEM_VARIATION.
///
/// Square products have at least one variation. This stores the ID of the
/// default/primary variation so inventory counts can be fetched directly.
///
/// `null` if the product has not been pushed to Square.
final String? squareVariationId;
/// The timestamp when this mapping was last updated.
final DateTime lastUpdated;
const ProductIdMapping({
required this.localId,
this.wooCommerceId,
this.squareCatalogObjectId,
this.squareVariationId,
required this.lastUpdated,
});
/// Returns a copy of this [ProductIdMapping] with the given fields replaced.
ProductIdMapping copyWith({
String? localId,
String? wooCommerceId,
String? squareCatalogObjectId,
String? squareVariationId,
DateTime? lastUpdated,
}) {
return ProductIdMapping(
localId: localId ?? this.localId,
wooCommerceId: wooCommerceId ?? this.wooCommerceId,
squareCatalogObjectId: squareCatalogObjectId ?? this.squareCatalogObjectId,
squareVariationId: squareVariationId ?? this.squareVariationId,
lastUpdated: lastUpdated ?? this.lastUpdated,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ProductIdMapping &&
runtimeType == other.runtimeType &&
localId == other.localId &&
wooCommerceId == other.wooCommerceId &&
squareCatalogObjectId == other.squareCatalogObjectId &&
squareVariationId == other.squareVariationId &&
lastUpdated == other.lastUpdated;
@override
int get hashCode =>
Object.hash(localId, wooCommerceId, squareCatalogObjectId, squareVariationId, lastUpdated);
@override
String toString() =>
'ProductIdMapping(localId: $localId, wooCommerceId: $wooCommerceId, '
'squareCatalogObjectId: $squareCatalogObjectId, '
'squareVariationId: $squareVariationId)';
}

View File

@ -0,0 +1,35 @@
import 'product_id_mapping.dart';
/// Contract for persisting and querying cross-channel product ID mappings.
///
/// Implementations may store mappings in memory (fake), SQLite (local
/// persistent), or a remote database. The contract is channel-agnostic
/// callers look up by local ID, WooCommerce ID, or Square catalog object ID.
abstract class ProductIdMappingRepository {
/// Returns all stored [ProductIdMapping] records.
Future<List<ProductIdMapping>> getAllMappings();
/// Returns the [ProductIdMapping] for the given [localId], or `null` if
/// no mapping exists for that local product.
Future<ProductIdMapping?> getMappingByLocalId(String localId);
/// Returns the [ProductIdMapping] whose [ProductIdMapping.wooCommerceId]
/// matches [wooCommerceId], or `null` if not found.
Future<ProductIdMapping?> getMappingByWooCommerceId(String wooCommerceId);
/// Returns the [ProductIdMapping] whose
/// [ProductIdMapping.squareCatalogObjectId] matches [squareCatalogObjectId],
/// or `null` if not found.
Future<ProductIdMapping?> getMappingBySquareId(String squareCatalogObjectId);
/// Saves a [ProductIdMapping].
///
/// If a mapping with the same [ProductIdMapping.localId] already exists it
/// is replaced; otherwise a new record is inserted.
Future<ProductIdMapping> saveMapping(ProductIdMapping mapping);
/// Deletes the mapping for the given [localId].
///
/// No-ops silently if no mapping exists for that ID.
Future<void> deleteMapping(String localId);
}

View File

@ -0,0 +1,248 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:feature_wordpress/feature_wordpress.dart';
void main() {
// ProductIdMapping domain model
group('ProductIdMapping', () {
final baseTime = DateTime(2026, 7, 1);
test('stores all fields', () {
final mapping = ProductIdMapping(
localId: '1',
wooCommerceId: '101',
squareCatalogObjectId: 'SQ-ITEM-001',
squareVariationId: 'SQ-VAR-001',
lastUpdated: baseTime,
);
expect(mapping.localId, '1');
expect(mapping.wooCommerceId, '101');
expect(mapping.squareCatalogObjectId, 'SQ-ITEM-001');
expect(mapping.squareVariationId, 'SQ-VAR-001');
expect(mapping.lastUpdated, baseTime);
});
test('optional fields default to null', () {
final mapping = ProductIdMapping(localId: '6', lastUpdated: baseTime);
expect(mapping.wooCommerceId, isNull);
expect(mapping.squareCatalogObjectId, isNull);
expect(mapping.squareVariationId, isNull);
});
test('copyWith replaces specified fields', () {
final original = ProductIdMapping(localId: '1', wooCommerceId: '101', lastUpdated: baseTime);
final updated = original.copyWith(
squareCatalogObjectId: 'SQ-ITEM-001',
squareVariationId: 'SQ-VAR-001',
lastUpdated: DateTime(2026, 8, 1),
);
expect(updated.localId, '1');
expect(updated.wooCommerceId, '101');
expect(updated.squareCatalogObjectId, 'SQ-ITEM-001');
expect(updated.squareVariationId, 'SQ-VAR-001');
expect(updated.lastUpdated, DateTime(2026, 8, 1));
});
test('copyWith preserves unchanged fields', () {
final original = ProductIdMapping(
localId: '2',
wooCommerceId: '102',
squareCatalogObjectId: 'SQ-ITEM-002',
squareVariationId: 'SQ-VAR-002',
lastUpdated: baseTime,
);
final copy = original.copyWith();
expect(copy.localId, original.localId);
expect(copy.wooCommerceId, original.wooCommerceId);
expect(copy.squareCatalogObjectId, original.squareCatalogObjectId);
expect(copy.squareVariationId, original.squareVariationId);
expect(copy.lastUpdated, original.lastUpdated);
});
test('equality holds for identical values', () {
final a = ProductIdMapping(
localId: '1',
wooCommerceId: '101',
squareCatalogObjectId: 'SQ-ITEM-001',
squareVariationId: 'SQ-VAR-001',
lastUpdated: baseTime,
);
final b = ProductIdMapping(
localId: '1',
wooCommerceId: '101',
squareCatalogObjectId: 'SQ-ITEM-001',
squareVariationId: 'SQ-VAR-001',
lastUpdated: baseTime,
);
expect(a, equals(b));
expect(a.hashCode, equals(b.hashCode));
});
test('equality fails when localId differs', () {
final a = ProductIdMapping(localId: '1', lastUpdated: baseTime);
final b = ProductIdMapping(localId: '2', lastUpdated: baseTime);
expect(a, isNot(equals(b)));
});
test('toString includes localId and channel IDs', () {
final mapping = ProductIdMapping(
localId: '1',
wooCommerceId: '101',
squareCatalogObjectId: 'SQ-ITEM-001',
squareVariationId: 'SQ-VAR-001',
lastUpdated: baseTime,
);
final str = mapping.toString();
expect(str, contains('1'));
expect(str, contains('101'));
expect(str, contains('SQ-ITEM-001'));
});
});
// FakeProductIdMappingRepository
group('FakeProductIdMappingRepository', () {
late FakeProductIdMappingRepository repo;
setUp(() {
repo = FakeProductIdMappingRepository();
});
test('getAllMappings returns 6 seeded mappings', () async {
final mappings = await repo.getAllMappings();
expect(mappings.length, 6);
});
test('getMappingByLocalId returns correct mapping', () async {
final mapping = await repo.getMappingByLocalId('1');
expect(mapping, isNotNull);
expect(mapping!.localId, '1');
expect(mapping.wooCommerceId, '101');
expect(mapping.squareCatalogObjectId, 'SQ-ITEM-001');
expect(mapping.squareVariationId, 'SQ-VAR-001');
});
test('getMappingByLocalId returns null for unknown id', () async {
final mapping = await repo.getMappingByLocalId('999');
expect(mapping, isNull);
});
test('getMappingByWooCommerceId returns correct mapping', () async {
final mapping = await repo.getMappingByWooCommerceId('102');
expect(mapping, isNotNull);
expect(mapping!.localId, '2');
expect(mapping.squareCatalogObjectId, 'SQ-ITEM-002');
});
test('getMappingByWooCommerceId returns null for unknown id', () async {
final mapping = await repo.getMappingByWooCommerceId('999');
expect(mapping, isNull);
});
test('getMappingBySquareId returns correct mapping', () async {
final mapping = await repo.getMappingBySquareId('SQ-ITEM-003');
expect(mapping, isNotNull);
expect(mapping!.localId, '3');
expect(mapping.wooCommerceId, '103');
});
test('getMappingBySquareId returns null for unknown id', () async {
final mapping = await repo.getMappingBySquareId('SQ-ITEM-UNKNOWN');
expect(mapping, isNull);
});
test('products 4 and 5 have WooCommerce IDs but no Square IDs', () async {
final m4 = await repo.getMappingByLocalId('4');
final m5 = await repo.getMappingByLocalId('5');
expect(m4!.wooCommerceId, '104');
expect(m4.squareCatalogObjectId, isNull);
expect(m5!.wooCommerceId, '105');
expect(m5.squareCatalogObjectId, isNull);
});
test('product 6 has no channel IDs', () async {
final m6 = await repo.getMappingByLocalId('6');
expect(m6!.wooCommerceId, isNull);
expect(m6.squareCatalogObjectId, isNull);
expect(m6.squareVariationId, isNull);
});
test('saveMapping inserts a new mapping', () async {
final newMapping = ProductIdMapping(
localId: '99',
wooCommerceId: '999',
squareCatalogObjectId: 'SQ-ITEM-999',
squareVariationId: 'SQ-VAR-999',
lastUpdated: DateTime(2026, 7, 25),
);
final saved = await repo.saveMapping(newMapping);
expect(saved.localId, '99');
final retrieved = await repo.getMappingByLocalId('99');
expect(retrieved, isNotNull);
expect(retrieved!.squareCatalogObjectId, 'SQ-ITEM-999');
});
test('saveMapping replaces an existing mapping', () async {
final updated = ProductIdMapping(
localId: '1',
wooCommerceId: '101',
squareCatalogObjectId: 'SQ-ITEM-001-UPDATED',
squareVariationId: 'SQ-VAR-001-UPDATED',
lastUpdated: DateTime(2026, 7, 25),
);
await repo.saveMapping(updated);
final retrieved = await repo.getMappingByLocalId('1');
expect(retrieved!.squareCatalogObjectId, 'SQ-ITEM-001-UPDATED');
expect(retrieved.squareVariationId, 'SQ-VAR-001-UPDATED');
});
test('saveMapping does not increase count when replacing', () async {
final before = await repo.getAllMappings();
await repo.saveMapping(ProductIdMapping(localId: '1', lastUpdated: DateTime(2026, 7, 25)));
final after = await repo.getAllMappings();
expect(after.length, before.length);
});
test('deleteMapping removes the mapping', () async {
await repo.deleteMapping('1');
final mapping = await repo.getMappingByLocalId('1');
expect(mapping, isNull);
});
test('deleteMapping reduces total count by one', () async {
final before = await repo.getAllMappings();
await repo.deleteMapping('2');
final after = await repo.getAllMappings();
expect(after.length, before.length - 1);
});
test('deleteMapping is a no-op for unknown id', () async {
final before = await repo.getAllMappings();
await repo.deleteMapping('999'); // does not exist
final after = await repo.getAllMappings();
expect(after.length, before.length);
});
test('getAllMappings returns unmodifiable list', () async {
final mappings = await repo.getAllMappings();
final extra = ProductIdMapping(localId: 'x', lastUpdated: DateTime(2026, 1, 1));
expect(() => (mappings as dynamic).add(extra), throwsUnsupportedError);
});
});
}

View File

@ -12,4 +12,6 @@ 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/square_api_client.dart';
export 'src/square_inventory_mapper.dart';
export 'src/square_product_mapper.dart';
export 'src/webhook_handler.dart'; export 'src/webhook_handler.dart';

View File

@ -0,0 +1,100 @@
import 'channel_adapter.dart';
/// Maps between Square InventoryCount JSON maps and the channel-neutral
/// [ChannelInventoryCount] representation.
///
/// Square inventory counts are returned from the
/// `GET /v2/inventory/{catalog_object_id}` endpoint as:
/// ```
/// {
/// "catalog_object_id": "SQ-VAR-001",
/// "catalog_object_type": "ITEM_VARIATION",
/// "state": "IN_STOCK",
/// "location_id": "LOC123",
/// "quantity": "18",
/// "calculated_at": "2026-07-01T00:00:00.000Z"
/// }
/// ```
///
/// Note: Square returns `quantity` as a **string**, not an integer.
/// This mapper parses it safely, defaulting to 0 on parse failure.
///
/// Only counts with `state == 'IN_STOCK'` represent sellable on-hand
/// inventory. Other states (`WASTE`, `UNLINKED_RETURN`, etc.) are ignored
/// when converting to a domain quantity.
class SquareInventoryMapper {
const SquareInventoryMapper();
// Square InventoryCount ChannelInventoryCount
/// Maps a single Square InventoryCount [json] map to a
/// [ChannelInventoryCount].
///
/// Returns `null` if the count is not in `IN_STOCK` state or is missing
/// required fields.
ChannelInventoryCount? fromSquareInventoryCount(Map<String, dynamic> json) {
final catalogObjectId = json['catalog_object_id'] as String?;
if (catalogObjectId == null || catalogObjectId.isEmpty) return null;
// Only map IN_STOCK counts to on-hand quantity.
final state = json['state'] as String? ?? '';
if (state != 'IN_STOCK') return null;
final quantityStr = json['quantity'] as String? ?? '0';
final quantity = int.tryParse(quantityStr) ?? 0;
return ChannelInventoryCount(productId: catalogObjectId, quantity: quantity);
}
/// Maps a list of Square InventoryCount [jsonList] maps to
/// [ChannelInventoryCount]s, skipping non-IN_STOCK entries and invalid
/// records.
List<ChannelInventoryCount> fromSquareInventoryCountList(List<Map<String, dynamic>> jsonList) {
final results = <ChannelInventoryCount>[];
for (final json in jsonList) {
final count = fromSquareInventoryCount(json);
if (count != null) results.add(count);
}
return results;
}
// ChannelInventoryCount Square batch-change entry
/// Converts a [ChannelInventoryCount] to a Square inventory batch-change
/// entry suitable for use with [SquareApiClient.batchChangeInventory].
///
/// Uses `PHYSICAL_COUNT` type to set an absolute quantity (not a delta).
///
/// [locationId] is the Square location ID for the count.
/// [occurredAt] defaults to the current UTC time in RFC 3339 format.
Map<String, dynamic> toSquareBatchChangeEntry(
ChannelInventoryCount count, {
required String locationId,
DateTime? occurredAt,
}) {
final timestamp = (occurredAt ?? DateTime.now().toUtc()).toIso8601String();
return {
'type': 'PHYSICAL_COUNT',
'physical_count': {
'catalog_object_id': count.productId,
'state': 'IN_STOCK',
'location_id': locationId,
'quantity': count.quantity.toString(),
'occurred_at': timestamp,
},
};
}
/// Converts a list of [ChannelInventoryCount]s to Square batch-change
/// entries.
List<Map<String, dynamic>> toSquareBatchChangeEntries(
List<ChannelInventoryCount> counts, {
required String locationId,
DateTime? occurredAt,
}) {
return counts
.map((c) => toSquareBatchChangeEntry(c, locationId: locationId, occurredAt: occurredAt))
.toList();
}
}

View File

@ -0,0 +1,150 @@
import 'channel_adapter.dart';
/// Maps between Square CatalogObject JSON maps and the channel-neutral
/// [ChannelProduct] representation.
///
/// Square catalog items have a nested structure:
/// ```
/// {
/// "type": "ITEM",
/// "id": "SQ-ITEM-001",
/// "item_data": {
/// "name": "Floral Bowl Cozy",
/// "description": "...",
/// "variations": [
/// {
/// "type": "ITEM_VARIATION",
/// "id": "SQ-VAR-001",
/// "item_variation_data": {
/// "name": "Regular",
/// "price_money": { "amount": 1299, "currency": "USD" },
/// "sku": "BC-FLR-001"
/// }
/// }
/// ],
/// "category_id": "SQ-CAT-001"
/// }
/// }
/// ```
///
/// Price is stored in **cents** by Square; this mapper converts to/from
/// dollars (double) for the domain layer.
///
/// Only the first variation is used for price and SKU when mapping from
/// Square domain. When mapping domain Square, a single default variation
/// is generated.
class SquareProductMapper {
const SquareProductMapper();
// Square ChannelProduct
/// Maps a Square CatalogObject [json] map (type `ITEM`) to a
/// [ChannelProduct].
///
/// Returns `null` if the object is not a valid ITEM type or is missing
/// required fields.
ChannelProduct? fromSquareCatalogObject(Map<String, dynamic> json) {
if (json['type'] != 'ITEM') return null;
final id = json['id'] as String?;
if (id == null || id.isEmpty) return null;
final itemData = json['item_data'];
if (itemData is! Map<String, dynamic>) return null;
final name = itemData['name'] as String? ?? '';
final description = itemData['description'] as String?;
// Extract price and SKU from the first variation.
double price = 0.0;
String? sku;
final variations = itemData['variations'];
if (variations is List && variations.isNotEmpty) {
final firstVariation = variations.first;
if (firstVariation is Map<String, dynamic>) {
final varData = firstVariation['item_variation_data'];
if (varData is Map<String, dynamic>) {
sku = varData['sku'] as String?;
final priceMoney = varData['price_money'];
if (priceMoney is Map<String, dynamic>) {
final amountCents = priceMoney['amount'];
if (amountCents is int) {
price = amountCents / 100.0;
} else if (amountCents is double) {
price = amountCents / 100.0;
}
}
}
}
}
// Map Square present_at_all_locations / is_deleted to a status string.
final isDeleted = json['is_deleted'] as bool? ?? false;
final status = isDeleted ? 'archived' : 'active';
return ChannelProduct(
id: id,
name: name,
price: price,
status: status,
description: description,
sku: sku,
);
}
/// Maps a list of Square CatalogObject [jsonList] maps to [ChannelProduct]s,
/// skipping any entries that cannot be parsed.
List<ChannelProduct> fromSquareCatalogList(List<Map<String, dynamic>> jsonList) {
final results = <ChannelProduct>[];
for (final json in jsonList) {
final product = fromSquareCatalogObject(json);
if (product != null) results.add(product);
}
return results;
}
// ChannelProduct Square CatalogObject
/// Converts a [ChannelProduct] to a Square CatalogObject map suitable for
/// use with [SquareApiClient.upsertCatalogItem].
///
/// [squareItemId] is the existing Square ITEM object ID for updates, or a
/// temporary ID (e.g. `'#new-item'`) for creates.
///
/// [squareVariationId] is the existing variation ID for updates, or a
/// temporary ID (e.g. `'#new-variation'`) for creates.
///
/// [locationId] is required by Square for variation pricing.
Map<String, dynamic> toSquareCatalogObject(
ChannelProduct product, {
String squareItemId = '#new-item',
String squareVariationId = '#new-variation',
required String locationId,
}) {
final amountCents = (product.price * 100).round();
final variationData = <String, dynamic>{
'name': 'Regular',
'pricing_type': 'FIXED_PRICING',
'price_money': {'amount': amountCents, 'currency': 'USD'},
'location_overrides': [
{'location_id': locationId, 'track_inventory': true},
],
};
if (product.sku != null && product.sku!.isNotEmpty) {
variationData['sku'] = product.sku;
}
final itemData = <String, dynamic>{
'name': product.name,
'variations': [
{'type': 'ITEM_VARIATION', 'id': squareVariationId, 'item_variation_data': variationData},
],
};
if (product.description != null && product.description!.isNotEmpty) {
itemData['description'] = product.description;
}
return {'type': 'ITEM', 'id': squareItemId, 'item_data': itemData};
}
}

View File

@ -0,0 +1,593 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:integrations/integrations.dart';
void main() {
// SquareProductMapper
group('SquareProductMapper', () {
const mapper = SquareProductMapper();
// fromSquareCatalogObject
group('fromSquareCatalogObject', () {
test('maps a full ITEM object to ChannelProduct', () {
final json = {
'type': 'ITEM',
'id': 'SQ-ITEM-001',
'item_data': {
'name': 'Floral Bowl Cozy',
'description': 'A beautifully crafted fabric bowl cozy.',
'variations': [
{
'type': 'ITEM_VARIATION',
'id': 'SQ-VAR-001',
'item_variation_data': {
'name': 'Regular',
'sku': 'BC-FLR-001',
'price_money': {'amount': 1299, 'currency': 'USD'},
},
},
],
},
};
final product = mapper.fromSquareCatalogObject(json);
expect(product, isNotNull);
expect(product!.id, 'SQ-ITEM-001');
expect(product.name, 'Floral Bowl Cozy');
expect(product.description, 'A beautifully crafted fabric bowl cozy.');
expect(product.price, closeTo(12.99, 0.001));
expect(product.sku, 'BC-FLR-001');
expect(product.status, 'active');
});
test('maps price in cents to dollars correctly', () {
final json = {
'type': 'ITEM',
'id': 'SQ-ITEM-002',
'item_data': {
'name': 'Coaster Set',
'variations': [
{
'type': 'ITEM_VARIATION',
'id': 'SQ-VAR-002',
'item_variation_data': {
'price_money': {'amount': 1650, 'currency': 'USD'},
},
},
],
},
};
final product = mapper.fromSquareCatalogObject(json);
expect(product!.price, closeTo(16.50, 0.001));
});
test('returns null for non-ITEM type', () {
final json = {
'type': 'CATEGORY',
'id': 'SQ-CAT-001',
'category_data': {'name': 'Kitchen'},
};
expect(mapper.fromSquareCatalogObject(json), isNull);
});
test('returns null when id is missing', () {
final json = {
'type': 'ITEM',
'item_data': {'name': 'No ID Item'},
};
expect(mapper.fromSquareCatalogObject(json), isNull);
});
test('returns null when item_data is missing', () {
final json = {'type': 'ITEM', 'id': 'SQ-ITEM-003'};
expect(mapper.fromSquareCatalogObject(json), isNull);
});
test('maps is_deleted=true to archived status', () {
final json = {
'type': 'ITEM',
'id': 'SQ-ITEM-004',
'is_deleted': true,
'item_data': {
'name': 'Archived Item',
'variations': [
{
'type': 'ITEM_VARIATION',
'id': 'SQ-VAR-004',
'item_variation_data': {
'price_money': {'amount': 999, 'currency': 'USD'},
},
},
],
},
};
final product = mapper.fromSquareCatalogObject(json);
expect(product!.status, 'archived');
});
test('defaults price to 0.0 when no variations present', () {
final json = {
'type': 'ITEM',
'id': 'SQ-ITEM-005',
'item_data': {'name': 'No Price Item', 'variations': []},
};
final product = mapper.fromSquareCatalogObject(json);
expect(product!.price, 0.0);
});
test('defaults price to 0.0 when price_money is absent', () {
final json = {
'type': 'ITEM',
'id': 'SQ-ITEM-006',
'item_data': {
'name': 'No Price Money',
'variations': [
{
'type': 'ITEM_VARIATION',
'id': 'SQ-VAR-006',
'item_variation_data': {'name': 'Regular'},
},
],
},
};
final product = mapper.fromSquareCatalogObject(json);
expect(product!.price, 0.0);
});
test('sku is null when not present in variation', () {
final json = {
'type': 'ITEM',
'id': 'SQ-ITEM-007',
'item_data': {
'name': 'No SKU Item',
'variations': [
{
'type': 'ITEM_VARIATION',
'id': 'SQ-VAR-007',
'item_variation_data': {
'price_money': {'amount': 500, 'currency': 'USD'},
},
},
],
},
};
final product = mapper.fromSquareCatalogObject(json);
expect(product!.sku, isNull);
});
test('description is null when not present in item_data', () {
final json = {
'type': 'ITEM',
'id': 'SQ-ITEM-008',
'item_data': {
'name': 'No Description',
'variations': [
{
'type': 'ITEM_VARIATION',
'id': 'SQ-VAR-008',
'item_variation_data': {
'price_money': {'amount': 800, 'currency': 'USD'},
},
},
],
},
};
final product = mapper.fromSquareCatalogObject(json);
expect(product!.description, isNull);
});
});
// fromSquareCatalogList
group('fromSquareCatalogList', () {
test('maps a list of ITEM objects', () {
final jsonList = [
{
'type': 'ITEM',
'id': 'SQ-ITEM-A',
'item_data': {
'name': 'Item A',
'variations': [
{
'type': 'ITEM_VARIATION',
'id': 'SQ-VAR-A',
'item_variation_data': {
'price_money': {'amount': 1000, 'currency': 'USD'},
},
},
],
},
},
{
'type': 'ITEM',
'id': 'SQ-ITEM-B',
'item_data': {
'name': 'Item B',
'variations': [
{
'type': 'ITEM_VARIATION',
'id': 'SQ-VAR-B',
'item_variation_data': {
'price_money': {'amount': 2000, 'currency': 'USD'},
},
},
],
},
},
];
final products = mapper.fromSquareCatalogList(jsonList);
expect(products.length, 2);
expect(products[0].id, 'SQ-ITEM-A');
expect(products[1].id, 'SQ-ITEM-B');
});
test('skips non-ITEM entries in a mixed list', () {
final jsonList = [
{
'type': 'ITEM',
'id': 'SQ-ITEM-A',
'item_data': {
'name': 'Item A',
'variations': [
{
'type': 'ITEM_VARIATION',
'id': 'SQ-VAR-A',
'item_variation_data': {
'price_money': {'amount': 1000, 'currency': 'USD'},
},
},
],
},
},
{'type': 'CATEGORY', 'id': 'SQ-CAT-001', 'category_data': {}},
{'type': 'TAX', 'id': 'SQ-TAX-001'},
];
final products = mapper.fromSquareCatalogList(jsonList);
expect(products.length, 1);
expect(products.first.id, 'SQ-ITEM-A');
});
test('returns empty list for empty input', () {
expect(mapper.fromSquareCatalogList([]), isEmpty);
});
});
// toSquareCatalogObject
group('toSquareCatalogObject', () {
test('converts ChannelProduct to Square CatalogObject', () {
const product = ChannelProduct(
id: 'local-1',
name: 'Floral Bowl Cozy',
price: 12.99,
status: 'active',
description: 'A fabric bowl cozy.',
sku: 'BC-FLR-001',
);
final json = mapper.toSquareCatalogObject(
product,
squareItemId: 'SQ-ITEM-001',
squareVariationId: 'SQ-VAR-001',
locationId: 'LOC123',
);
expect(json['type'], 'ITEM');
expect(json['id'], 'SQ-ITEM-001');
final itemData = json['item_data'] as Map<String, dynamic>;
expect(itemData['name'], 'Floral Bowl Cozy');
expect(itemData['description'], 'A fabric bowl cozy.');
final variations = itemData['variations'] as List;
expect(variations.length, 1);
final variation = variations.first as Map<String, dynamic>;
expect(variation['type'], 'ITEM_VARIATION');
expect(variation['id'], 'SQ-VAR-001');
final varData = variation['item_variation_data'] as Map<String, dynamic>;
expect(varData['sku'], 'BC-FLR-001');
final priceMoney = varData['price_money'] as Map<String, dynamic>;
expect(priceMoney['amount'], 1299);
expect(priceMoney['currency'], 'USD');
});
test('converts price to cents correctly', () {
const product = ChannelProduct(
id: 'local-2',
name: 'Coaster Set',
price: 16.50,
status: 'active',
);
final json = mapper.toSquareCatalogObject(product, locationId: 'LOC123');
final itemData = json['item_data'] as Map<String, dynamic>;
final variations = itemData['variations'] as List;
final varData =
(variations.first as Map<String, dynamic>)['item_variation_data']
as Map<String, dynamic>;
final priceMoney = varData['price_money'] as Map<String, dynamic>;
expect(priceMoney['amount'], 1650);
});
test('uses default temporary IDs when not provided', () {
const product = ChannelProduct(
id: 'local-3',
name: 'New Item',
price: 9.99,
status: 'active',
);
final json = mapper.toSquareCatalogObject(product, locationId: 'LOC123');
expect(json['id'], '#new-item');
final itemData = json['item_data'] as Map<String, dynamic>;
final variations = itemData['variations'] as List;
expect((variations.first as Map<String, dynamic>)['id'], '#new-variation');
});
test('omits description when null', () {
const product = ChannelProduct(
id: 'local-4',
name: 'No Description',
price: 5.00,
status: 'active',
);
final json = mapper.toSquareCatalogObject(product, locationId: 'LOC123');
final itemData = json['item_data'] as Map<String, dynamic>;
expect(itemData.containsKey('description'), isFalse);
});
test('omits sku from variation when null', () {
const product = ChannelProduct(
id: 'local-5',
name: 'No SKU',
price: 5.00,
status: 'active',
);
final json = mapper.toSquareCatalogObject(product, locationId: 'LOC123');
final itemData = json['item_data'] as Map<String, dynamic>;
final variations = itemData['variations'] as List;
final varData =
(variations.first as Map<String, dynamic>)['item_variation_data']
as Map<String, dynamic>;
expect(varData.containsKey('sku'), isFalse);
});
test('includes location_overrides with track_inventory', () {
const product = ChannelProduct(
id: 'local-6',
name: 'Track Me',
price: 10.00,
status: 'active',
);
final json = mapper.toSquareCatalogObject(product, locationId: 'LOC456');
final itemData = json['item_data'] as Map<String, dynamic>;
final variations = itemData['variations'] as List;
final varData =
(variations.first as Map<String, dynamic>)['item_variation_data']
as Map<String, dynamic>;
final overrides = varData['location_overrides'] as List;
expect(overrides.length, 1);
final override = overrides.first as Map<String, dynamic>;
expect(override['location_id'], 'LOC456');
expect(override['track_inventory'], isTrue);
});
});
});
// SquareInventoryMapper
group('SquareInventoryMapper', () {
const mapper = SquareInventoryMapper();
// fromSquareInventoryCount
group('fromSquareInventoryCount', () {
test('maps a valid IN_STOCK count to ChannelInventoryCount', () {
final json = {
'catalog_object_id': 'SQ-VAR-001',
'catalog_object_type': 'ITEM_VARIATION',
'state': 'IN_STOCK',
'location_id': 'LOC123',
'quantity': '18',
'calculated_at': '2026-07-01T00:00:00.000Z',
};
final count = mapper.fromSquareInventoryCount(json);
expect(count, isNotNull);
expect(count!.productId, 'SQ-VAR-001');
expect(count.quantity, 18);
});
test('returns null for non-IN_STOCK state', () {
final json = {'catalog_object_id': 'SQ-VAR-001', 'state': 'WASTE', 'quantity': '5'};
expect(mapper.fromSquareInventoryCount(json), isNull);
});
test('returns null when catalog_object_id is missing', () {
final json = {'state': 'IN_STOCK', 'quantity': '10'};
expect(mapper.fromSquareInventoryCount(json), isNull);
});
test('returns null when catalog_object_id is empty string', () {
final json = {'catalog_object_id': '', 'state': 'IN_STOCK', 'quantity': '10'};
expect(mapper.fromSquareInventoryCount(json), isNull);
});
test('defaults quantity to 0 when quantity field is missing', () {
final json = {'catalog_object_id': 'SQ-VAR-002', 'state': 'IN_STOCK'};
final count = mapper.fromSquareInventoryCount(json);
expect(count!.quantity, 0);
});
test('defaults quantity to 0 when quantity is not parseable', () {
final json = {
'catalog_object_id': 'SQ-VAR-003',
'state': 'IN_STOCK',
'quantity': 'not-a-number',
};
final count = mapper.fromSquareInventoryCount(json);
expect(count!.quantity, 0);
});
test('parses quantity of zero correctly', () {
final json = {'catalog_object_id': 'SQ-VAR-004', 'state': 'IN_STOCK', 'quantity': '0'};
final count = mapper.fromSquareInventoryCount(json);
expect(count!.quantity, 0);
});
test('returns null for UNLINKED_RETURN state', () {
final json = {
'catalog_object_id': 'SQ-VAR-005',
'state': 'UNLINKED_RETURN',
'quantity': '3',
};
expect(mapper.fromSquareInventoryCount(json), isNull);
});
});
// fromSquareInventoryCountList
group('fromSquareInventoryCountList', () {
test('maps a list of IN_STOCK counts', () {
final jsonList = [
{'catalog_object_id': 'SQ-VAR-A', 'state': 'IN_STOCK', 'quantity': '10'},
{'catalog_object_id': 'SQ-VAR-B', 'state': 'IN_STOCK', 'quantity': '5'},
];
final counts = mapper.fromSquareInventoryCountList(jsonList);
expect(counts.length, 2);
expect(counts[0].productId, 'SQ-VAR-A');
expect(counts[0].quantity, 10);
expect(counts[1].productId, 'SQ-VAR-B');
expect(counts[1].quantity, 5);
});
test('skips non-IN_STOCK entries in a mixed list', () {
final jsonList = [
{'catalog_object_id': 'SQ-VAR-A', 'state': 'IN_STOCK', 'quantity': '10'},
{'catalog_object_id': 'SQ-VAR-B', 'state': 'WASTE', 'quantity': '2'},
{'catalog_object_id': 'SQ-VAR-C', 'state': 'IN_STOCK', 'quantity': '7'},
];
final counts = mapper.fromSquareInventoryCountList(jsonList);
expect(counts.length, 2);
expect(counts.map((c) => c.productId), containsAll(['SQ-VAR-A', 'SQ-VAR-C']));
});
test('returns empty list for empty input', () {
expect(mapper.fromSquareInventoryCountList([]), isEmpty);
});
});
// toSquareBatchChangeEntry
group('toSquareBatchChangeEntry', () {
test('converts ChannelInventoryCount to PHYSICAL_COUNT entry', () {
const count = ChannelInventoryCount(productId: 'SQ-VAR-001', quantity: 18);
final fixedTime = DateTime.utc(2026, 7, 1, 12, 0, 0);
final entry = mapper.toSquareBatchChangeEntry(
count,
locationId: 'LOC123',
occurredAt: fixedTime,
);
expect(entry['type'], 'PHYSICAL_COUNT');
final physicalCount = entry['physical_count'] as Map<String, dynamic>;
expect(physicalCount['catalog_object_id'], 'SQ-VAR-001');
expect(physicalCount['state'], 'IN_STOCK');
expect(physicalCount['location_id'], 'LOC123');
expect(physicalCount['quantity'], '18');
expect(physicalCount['occurred_at'], '2026-07-01T12:00:00.000Z');
});
test('quantity is serialised as a string', () {
const count = ChannelInventoryCount(productId: 'SQ-VAR-002', quantity: 0);
final entry = mapper.toSquareBatchChangeEntry(
count,
locationId: 'LOC123',
occurredAt: DateTime.utc(2026, 1, 1),
);
final physicalCount = entry['physical_count'] as Map<String, dynamic>;
expect(physicalCount['quantity'], isA<String>());
expect(physicalCount['quantity'], '0');
});
test('uses current time when occurredAt is not provided', () {
const count = ChannelInventoryCount(productId: 'SQ-VAR-003', quantity: 5);
final before = DateTime.now().toUtc();
final entry = mapper.toSquareBatchChangeEntry(count, locationId: 'LOC123');
final after = DateTime.now().toUtc();
final physicalCount = entry['physical_count'] as Map<String, dynamic>;
final timestamp = DateTime.parse(physicalCount['occurred_at'] as String);
expect(timestamp.isAfter(before.subtract(const Duration(seconds: 1))), isTrue);
expect(timestamp.isBefore(after.add(const Duration(seconds: 1))), isTrue);
});
});
// toSquareBatchChangeEntries
group('toSquareBatchChangeEntries', () {
test('converts a list of counts to batch-change entries', () {
const counts = [
ChannelInventoryCount(productId: 'SQ-VAR-A', quantity: 10),
ChannelInventoryCount(productId: 'SQ-VAR-B', quantity: 3),
];
final fixedTime = DateTime.utc(2026, 7, 1);
final entries = mapper.toSquareBatchChangeEntries(
counts,
locationId: 'LOC123',
occurredAt: fixedTime,
);
expect(entries.length, 2);
expect(
(entries[0]['physical_count'] as Map<String, dynamic>)['catalog_object_id'],
'SQ-VAR-A',
);
expect(
(entries[1]['physical_count'] as Map<String, dynamic>)['catalog_object_id'],
'SQ-VAR-B',
);
});
test('returns empty list for empty input', () {
expect(mapper.toSquareBatchChangeEntries([], locationId: 'LOC123'), isEmpty);
});
});
});
}