feat(stage-10b): Square catalog and inventory sync mappers + ProductIdMapping
This commit is contained in:
parent
4b1168dbfa
commit
a6f888c672
|
|
@ -1,6 +1,7 @@
|
|||
library;
|
||||
|
||||
// Data
|
||||
export 'src/data/fake_product_id_mapping_repository.dart';
|
||||
export 'src/data/fake_product_publishing_repository.dart';
|
||||
export 'src/data/woo_commerce_api_client.dart';
|
||||
export 'src/data/wordpress_product_mapper.dart';
|
||||
|
|
@ -9,6 +10,8 @@ export 'src/data/wordpress_product_publishing_repository.dart';
|
|||
// Domain
|
||||
export 'src/domain/product_category.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_publishing_repository.dart';
|
||||
export 'src/domain/publish_status.dart';
|
||||
|
|
|
|||
|
|
@ -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 1–3 have both WooCommerce and Square IDs (fully synced).
|
||||
// Products 4–5 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)';
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -12,4 +12,6 @@ export 'src/integration_health_check.dart';
|
|||
export 'src/rate_limiter.dart';
|
||||
export 'src/retry_policy.dart';
|
||||
export 'src/square_api_client.dart';
|
||||
export 'src/square_inventory_mapper.dart';
|
||||
export 'src/square_product_mapper.dart';
|
||||
export 'src/webhook_handler.dart';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue