feat: add remote products and qr code for payconic

This commit is contained in:
2026-10-22 00:10:26 +02:00
parent b15d94a805
commit 71af6f9ffe
31 changed files with 2053 additions and 179 deletions
+175 -13
View File
@@ -433,6 +433,159 @@ void main() {
});
});
group('product events', () {
late DriftProductService products;
setUp(() {
products = DriftProductService(
database: database,
eventFactory: TabEventFactory(
now: () => DateTime.utc(2026, 10, 24, 15, 3, 40),
),
);
});
Future<List<Map<String, dynamic>>> productEvents() async => [
for (final event in await outbox())
if ((event['type'] as String).startsWith('product_')) event,
];
Future<List<PendingImageUploadRow>> uploads() =>
database.select(database.pendingImageUploads).get();
test('a new product goes out with its fields, then its stock', () async {
await products.createProduct(
name: 'Ice Coffee',
category: 'Koffie',
stockQuantity: 12,
lowStockThreshold: 3,
priceInCents: 350,
imagePath: null,
);
final id = (await products.getProducts())
.firstWhere((p) => p.name == 'Ice Coffee')
.id;
final events = await outbox();
expect(events.map((e) => e['type']), ['product_created', 'stock_set']);
expect(events.first, {
'id': events.first['id'],
'type': 'product_created',
'productId': id,
'product': {
'name': 'Ice Coffee',
'category': 'Koffie',
'priceInCents': 350,
'lowStockThreshold': 3,
},
'occurredAt': '2026-10-24T15:03:40.000Z',
});
expect(events.first.containsKey('tabId'), isFalse);
});
test(
'an untracked new product has no count, and no category is null',
() async {
await products.createProduct(
name: 'Tap water',
category: ' ',
stockQuantity: 0,
lowStockThreshold: 0,
infiniteStock: true,
priceInCents: 0,
imagePath: null,
);
final events = await outbox();
expect(events.map((e) => e['type']), ['product_created']);
expect(events.single['product'], containsPair('category', null));
},
);
test('a change sends only the fields that changed', () async {
final cola = (await products.getProductById('prod-1'))!;
await products.updateProduct(
cola.copyWith(name: 'Coca-Cola', priceInCents: 270),
);
expect(
(await productEvents()).single,
containsPair('product', {'name': 'Coca-Cola', 'priceInCents': 270}),
);
});
test('saving without changes sends nothing', () async {
final cola = (await products.getProductById('prod-1'))!;
await products.updateProduct(cola);
expect(await outbox(), isEmpty);
});
test('deleting archives it for every tablet', () async {
await products.deleteProduct('prod-1');
expect(
(await productEvents()).single,
containsPair('type', 'product_archived'),
);
expect(await products.getProducts(), isEmpty);
});
test(
'a picked picture waits for upload, a newer one replaces it',
() async {
final cola = (await products.getProductById('prod-1'))!;
await products.updateProduct(
cola.copyWith(imagePath: '/pictures/a.jpg'),
);
await products.updateProduct(
cola.copyWith(imagePath: '/pictures/b.jpg'),
);
// Saving again with the same picture uploads nothing new
await products.updateProduct(
cola.copyWith(imagePath: '/pictures/b.jpg', name: 'Coke'),
);
final waiting = await uploads();
expect(waiting.single.productId, 'prod-1');
expect(waiting.single.localPath, '/pictures/b.jpg');
// The picture itself is linked once uploaded, not now
expect(
(await productEvents()).where(
(e) => (e['product'] as Map?)?.containsKey('imageId') ?? false,
),
isEmpty,
);
},
);
test('a new product with a picture queues it for upload', () async {
await products.createProduct(
name: 'Ice Coffee',
category: 'Koffie',
stockQuantity: 12,
lowStockThreshold: 3,
priceInCents: 350,
imagePath: '/pictures/ice.jpg',
);
expect((await uploads()).single.localPath, '/pictures/ice.jpg');
});
test('are skipped when replaying the outbox onto server tabs', () async {
final tab = await openTab('Jan');
await products.deleteProduct('prod-1');
await service.applyServerState([]);
expect(await service.getTabById(tab.id), isNull);
expect(await productEvents(), hasLength(1));
});
});
group('stock_set events', () {
late DriftProductService products;
@@ -481,27 +634,36 @@ void main() {
final water = (await products.getProducts()).firstWhere(
(product) => product.name == 'Water',
);
final events = await outbox();
expect(events, hasLength(1));
expect(events.single, containsPair('type', 'stock_set'));
expect(events.single, containsPair('productId', water.id));
expect(events.single, containsPair('quantity', 24));
final counts = [
for (final event in await outbox())
if (event['type'] == 'stock_set') event,
];
expect(counts, hasLength(1));
expect(counts.single, containsPair('productId', water.id));
expect(counts.single, containsPair('quantity', 24));
});
test('a stock or tracking change queues a count', () async {
final cola = (await products.getProductById('prod-1'))!;
await products.updateProduct(cola.copyWith(name: 'Coca-Cola'));
expect(await outbox(), isEmpty);
await products.updateProduct(cola.copyWith(stockQuantity: 40));
await products.updateProduct(
cola.copyWith(stockQuantity: 40, infiniteStock: true),
final renamed = cola.copyWith(name: 'Coca-Cola');
await products.updateProduct(renamed);
expect(
(await outbox()).where((event) => event['type'] == 'stock_set'),
isEmpty,
);
await products.updateProduct(cola.copyWith(stockQuantity: 40));
await products.updateProduct(renamed.copyWith(stockQuantity: 40));
await products.updateProduct(
renamed.copyWith(stockQuantity: 40, infiniteStock: true),
);
await products.updateProduct(renamed.copyWith(stockQuantity: 40));
expect(
[for (final event in await outbox()) event['quantity']],
[
for (final event in await outbox())
if (event['type'] == 'stock_set') event['quantity'],
],
[40, null, 40],
);
});