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
+239 -5
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
@@ -11,8 +12,10 @@ import 'package:kooltap/models/product.dart';
import 'package:kooltap/models/remote_command.dart';
import 'package:kooltap/services/bar_tab_service.dart';
import 'package:kooltap/services/kooltap_api_client.dart';
import 'package:kooltap/services/product_image_sync.dart';
import 'package:kooltap/services/product_service.dart';
import 'package:kooltap/services/remote_sync_service.dart';
import 'package:path/path.dart' as path;
import 'helpers/memory_secure_storage.dart';
@@ -36,15 +39,27 @@ class _FakeServer {
/// While set, `/sync` responses wait for it to complete.
Completer<void>? holdSync;
late final client = MockClient((request) async {
final body = jsonDecode(request.body) as Map<String, dynamic>;
/// Pictures uploaded by terminals, by the id the server gave them.
final Map<String, int> uploadedImages = {};
if (request.url.path.endsWith('/sync')) {
late final client = MockClient((request) async {
final path = request.url.path;
if (path.endsWith('/images') && request.method == 'POST') {
final imageId = 'img${uploadedImages.length + 1}'.padRight(64, '0');
uploadedImages[imageId] = request.bodyBytes.length;
return http.Response(jsonEncode({'imageId': imageId}), 201);
}
if (path.contains('/images/')) {
return http.Response.bytes([1, 2, 3], 200);
}
final body = jsonDecode(request.body) as Map<String, dynamic>;
if (path.endsWith('/sync')) {
syncRequests.add(body);
await holdSync?.future;
return syncResponse;
}
if (request.url.path.endsWith('/ack')) {
if (path.endsWith('/ack')) {
acks.add(body);
return http.Response('', 200);
}
@@ -1460,7 +1475,8 @@ void main() {
.having((p) => p.priceInCents, 'price', 450)
.having((p) => p.category, 'category', '')
.having((p) => p.lowStockThreshold, 'threshold', 6)
.having((p) => p.imagePath, 'image', '/beer.png')
// The website has no picture: the old local one is dropped
.having((p) => p.imagePath, 'image', isNull)
.having((p) => p.active, 'active', isTrue),
);
// New, and its stock arrives in the same sync
@@ -1764,6 +1780,224 @@ void main() {
});
});
group('products edited on this tablet', () {
late DriftProductService products;
setUp(() {
products = DriftProductService(database: database);
});
Map<String, dynamic> beer({
String name = 'Beer',
int price = 400,
bool archived = false,
String? imageId,
}) {
return {
'id': 'prod-1',
'name': name,
'category': 'Drinks',
'priceInCents': price,
'lowStockThreshold': 10,
'archived': archived,
'imageId': imageId,
};
}
Future<ProductRow?> row(String id) => (database.select(
database.products,
)..where((row) => row.id.equals(id))).getSingleOrNull();
test('an offline rename stays until the server has it', () async {
await products.updateProduct(_beer.copyWith(name: 'Pils'));
// The server hasn't seen the rename yet: its catalog still says Beer
server.respondWith({
'catalog': [beer()],
});
await service.sync();
expect((await row('prod-1'))!.name, 'Pils');
// Once acknowledged, the server's catalog has it
server.respondWith({
'acknowledgedEventIds': [
for (final event in server.syncRequests.last['events'] as List)
event['id'],
],
'catalog': [beer(name: 'Pils')],
});
await service.sync();
expect((await row('prod-1'))!.name, 'Pils');
});
test('a product created offline stays on the bar screen', () async {
await products.createProduct(
name: 'Ice Coffee',
category: 'Koffie',
stockQuantity: 12,
lowStockThreshold: 3,
priceInCents: 350,
imagePath: null,
);
server.respondWith({
'catalog': [beer()],
});
await service.sync();
expect(
(await products.getProducts()).map((p) => p.name),
containsAll(['Beer', 'Ice Coffee']),
);
});
test('a product deleted offline stays hidden', () async {
await products.deleteProduct('prod-1');
server.respondWith({
'catalog': [beer()],
});
await service.sync();
expect(await products.getProducts(), isEmpty);
});
test(
'a website change of another field is kept next to the offline one',
() async {
await products.updateProduct(_beer.copyWith(name: 'Pils'));
server.respondWith({
'catalog': [beer(price: 450)],
});
await service.sync();
final pils = (await row('prod-1'))!;
expect(pils.name, 'Pils');
expect(pils.priceInCents, 450);
},
);
test('a changed picture keeps the old one until downloaded', () async {
await (database.update(
database.products,
)..where((row) => row.id.equals('prod-1'))).write(
ProductsCompanion(
imageId: Value('o' * 64),
imagePath: const Value('/pictures/old.webp'),
),
);
server.respondWith({
'catalog': [beer(imageId: 'n' * 64)],
});
await service.sync();
final row1 = (await row('prod-1'))!;
expect(row1.imageId, 'n' * 64);
expect(row1.imagePath, '/pictures/old.webp');
});
test(
'a picture waiting for upload is kept, also when the website has none',
() async {
await products.updateProduct(
_beer.copyWith(imagePath: '/pictures/taken.jpg'),
);
server.respondWith({
'catalog': [beer()],
});
await service.sync();
expect((await row('prod-1'))!.imagePath, '/pictures/taken.jpg');
},
);
});
group('pictures through the sync', () {
late Directory directory;
late RemoteSyncService withPictures;
setUp(() async {
directory = await Directory.systemTemp.createTemp('kooltap_sync_images_');
final api = KoolTapApiClient(client: server.client, baseUrl: _baseUrl);
withPictures = RemoteSyncService(
database: database,
barTabService: barTabService,
apiClient: api,
secureStorage: storage,
imageSync: ProductImageSync(
database: database,
apiClient: api,
imagesDirectory: () async => directory,
),
);
});
tearDown(() async {
withPictures.dispose();
await directory.delete(recursive: true);
});
test(
'a picture taken here is uploaded and linked in the same sync',
() async {
final taken = File(path.join(directory.path, 'taken.jpg'));
await taken.writeAsBytes([9, 9, 9]);
await DriftProductService(
database: database,
).updateProduct(_beer.copyWith(imagePath: taken.path));
await withPictures.sync();
final imageId = server.uploadedImages.keys.single;
expect(
server.syncRequests.single['events'],
contains(
allOf(
containsPair('type', 'product_updated'),
containsPair('product', {'imageId': imageId}),
),
),
);
},
);
test("the server's picture is downloaded after the sync", () async {
final imageId = 'f' * 64;
server.respondWith({
'catalog': [
{
'id': 'prod-1',
'name': 'Beer',
'category': 'Drinks',
'priceInCents': 400,
'lowStockThreshold': 10,
'archived': false,
'imageId': imageId,
},
],
});
await withPictures.sync();
// The download runs in the background
for (var i = 0; i < 50; i++) {
final row = await (database.select(
database.products,
)..where((row) => row.id.equals('prod-1'))).getSingle();
if (row.imagePath != null) break;
await Future<void>.delayed(const Duration(milliseconds: 20));
}
final beer = await (database.select(
database.products,
)..where((row) => row.id.equals('prod-1'))).getSingle();
expect(beer.imagePath, path.join(directory.path, '$imageId.webp'));
expect(File(beer.imagePath!).readAsBytesSync(), [1, 2, 3]);
});
});
group('a part of the response that fails', () {
Map<String, dynamic> brokenTab() {
// Two lines claiming the same unit: the database refuses the second