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
+7
View File
@@ -102,4 +102,11 @@ void main() {
);
expect((await database.select(database.products).get()), hasLength(1));
});
test('gives products no server picture yet and an empty upload queue', () async {
final product = await database.select(database.products).getSingle();
expect(product.imageId, isNull);
expect(await database.select(database.pendingImageUploads).get(), isEmpty);
});
}
+153 -1
View File
@@ -19,8 +19,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';
@@ -74,6 +76,30 @@ class _Website {
return call('PATCH', '/venues/$venueId/stock/products/$id', changes);
}
/// Uploads a picture for a product, as the products page does.
Future<Map<String, dynamic>> uploadImage(
String productId,
List<int> png,
) async {
final request =
http.MultipartRequest(
'POST',
Uri.parse('$_api/venues/$venueId/stock/products/$productId/image'),
)
..headers['Authorization'] = 'Bearer $token'
..files.add(
http.MultipartFile.fromBytes('file', png, filename: 'photo.png'),
);
return _decode(await http.Response.fromStream(await request.send()))
as Map<String, dynamic>;
}
/// The products page: every product with its stock and picture.
Future<List<dynamic>> products() async {
final body = await call('GET', '/venues/$venueId/stock');
return (body as Map<String, dynamic>)['products'] as List<dynamic>;
}
Future<List<dynamic>> tabs() async {
final body = await call('GET', '/venues/$venueId/tabs');
return (body as Map<String, dynamic>)['tabs'] as List<dynamic>;
@@ -93,9 +119,18 @@ class _Tablet {
final DriftBarTabService tabs;
final DriftProductService products;
final RemoteSyncService sync;
final ProductImageSync images;
final Directory pictures;
final List<RemoteSyncEvent> _events = [];
_Tablet._(this.database, this.tabs, this.products, this.sync) {
_Tablet._(
this.database,
this.tabs,
this.products,
this.sync,
this.images,
this.pictures,
) {
sync.events.listen(_events.add);
}
@@ -103,11 +138,18 @@ class _Tablet {
final database = AppDatabase(NativeDatabase.memory());
final tabs = DriftBarTabService(database: database);
final api = KoolTapApiClient(client: http.Client(), baseUrl: _api!);
final pictures = await Directory.systemTemp.createTemp('kooltap_e2e_');
final images = ProductImageSync(
database: database,
apiClient: api,
imagesDirectory: () async => pictures,
);
final sync = RemoteSyncService(
database: database,
barTabService: tabs,
apiClient: api,
secureStorage: MemorySecureStorage(),
imageSync: images,
);
final credentials = await api.registerDevice(
venueId: website.venueId,
@@ -124,6 +166,8 @@ class _Tablet {
tabs,
DriftProductService(database: database),
sync,
images,
pictures,
);
}
@@ -146,9 +190,16 @@ class _Tablet {
return (await products.getProducts()).singleWhere((p) => p.name == name);
}
/// Syncs, then waits for the pictures it started downloading.
Future<void> syncWithPictures() async {
await syncNow();
await images.downloadMissing();
}
Future<void> close() async {
sync.dispose();
await database.close();
await pictures.delete(recursive: true);
}
}
@@ -304,4 +355,105 @@ void main() {
skip: _skip,
timeout: const Timeout(Duration(minutes: 2)),
);
test(
'pictures, and products edited on a tablet',
() async {
final suffix = DateTime.now().millisecondsSinceEpoch;
// A 40x60 red PNG: taller than wide, so fitted whole
final png = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAACgAAAA8CAIAAACb22+3AAAAOElEQVR4nO3NQQkAAAgEsEty/aMYyxgiDPZfpj0RsVgsFovFYrFYLBaLxWKxWCwWi8VisVg8j+IFPRuGCFZP0vAAAAAASUVORK5CYII=',
);
bool isWebp(List<int> bytes) =>
String.fromCharCodes(bytes.sublist(0, 4)) == 'RIFF' &&
String.fromCharCodes(bytes.sublist(8, 12)) == 'WEBP';
// 1. A picture uploaded on the website reaches both tablets, as WebP
final shotName = 'E2E Shot $suffix';
final shot = await website.createProduct({
'name': shotName,
'priceInCents': 300,
'stock': 10,
});
final uploaded = await website.uploadImage(
shot['productId'] as String,
png,
);
expect(uploaded['imageId'], matches(RegExp(r'^[0-9a-f]{64}$')));
for (final tablet in [a, b]) {
await tablet.syncWithPictures();
final product = await tablet.product(shotName);
expect(product.imagePath, endsWith('${uploaded['imageId']}.webp'));
expect(isWebp(File(product.imagePath!).readAsBytesSync()), isTrue);
}
// 2. A product made on tablet A, with a photo, reaches the website and
// tablet B with the server's picture
final drinkName = 'E2E Tablet Drink $suffix';
final photo = File(path.join(a.pictures.path, 'photo.png'))
..writeAsBytesSync(png);
await a.products.createProduct(
name: drinkName,
category: 'Frisdrank',
stockQuantity: 8,
lowStockThreshold: 2,
priceInCents: 220,
imagePath: photo.path,
);
// The tile shows the photo at once, before any sync
expect((await a.product(drinkName)).imagePath, photo.path);
await a.syncWithPictures();
final onWebsite = (await website.products()).singleWhere(
(p) => p['name'] == drinkName,
);
expect(onWebsite, containsPair('stock', 8));
expect(onWebsite['imageId'], isNotNull);
await b.syncWithPictures();
final onB = await b.product(drinkName);
expect(onB.priceInCents, 220);
expect(onB.imagePath, endsWith('${onWebsite['imageId']}.webp'));
// Tablet A now shows the server's cropped copy as well
expect(
(await a.product(drinkName)).imagePath,
endsWith('${onWebsite['imageId']}.webp'),
);
// 3. Tablet A renames it offline while the website changes its price:
// both survive
final drinkId = onWebsite['productId'] as String;
final renamed = 'E2E Renamed $suffix';
await a.products.updateProduct(
(await a.product(drinkName)).copyWith(name: renamed),
);
await website.changeProduct(drinkId, {'priceInCents': 240});
await a.syncNow();
await b.syncNow();
for (final tablet in [a, b]) {
final product = await tablet.product(renamed);
expect(product.priceInCents, 240);
}
// 4. Deleted on tablet A: archived for the website and tablet B
await a.products.deleteProduct(drinkId);
await a.syncNow();
await b.syncNow();
expect(
(await b.products.getProducts()).where((p) => p.id == drinkId),
isEmpty,
);
expect(
(await website.products()).singleWhere(
(p) => p['productId'] == drinkId,
)['archivedAt'],
isNotNull,
);
// Leave the venue as it was
await website.changeProduct(shot['productId'] as String, {
'archived': true,
});
},
skip: _skip,
timeout: const Timeout(Duration(minutes: 2)),
);
}
+273
View File
@@ -0,0 +1,273 @@
import 'dart:convert';
import 'dart:io';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:kooltap/database/app_database.dart';
import 'package:kooltap/database/catalog_queries.dart';
import 'package:kooltap/services/kooltap_api_client.dart';
import 'package:kooltap/services/product_image_sync.dart';
import 'package:path/path.dart' as path;
const _picture = [1, 2, 3, 4];
final _imageId = 'a' * 64;
void main() {
late AppDatabase database;
late Directory directory;
late ProductImageSync images;
late List<http.Request> requests;
late Future<http.Response> Function(http.Request) respond;
setUp(() async {
database = AppDatabase(NativeDatabase.memory());
directory = await Directory.systemTemp.createTemp('kooltap_images_');
requests = [];
respond = (request) async {
if (request.url.path.endsWith('/images') && request.method == 'POST') {
return http.Response(jsonEncode({'imageId': _imageId}), 201);
}
if (request.url.path.endsWith('/images/$_imageId')) {
return http.Response.bytes(_picture, 200);
}
return http.Response('not found', 404);
};
images = ProductImageSync(
database: database,
apiClient: KoolTapApiClient(
client: MockClient((request) {
requests.add(request);
return respond(request);
}),
baseUrl: 'https://api.test',
),
imagesDirectory: () async => directory,
);
await database
.into(database.products)
.insert(
ProductsCompanion.insert(
id: 'cola',
name: 'Cola',
category: 'Frisdrank',
stockQuantity: 10,
lowStockThreshold: 2,
priceInCents: 250,
),
);
});
tearDown(() async {
await database.close();
await directory.delete(recursive: true);
});
Future<ProductRow> cola() => (database.select(
database.products,
)..where((row) => row.id.equals('cola'))).getSingle();
Future<List<Map<String, dynamic>>> outbox() async => [
for (final row in await database.select(database.tabEvents).get())
jsonDecode(row.payload) as Map<String, dynamic>,
];
Future<String> takePicture(String name) async {
final file = File(path.join(directory.path, name));
await file.writeAsBytes([9, 9, 9]);
return file.path;
}
group('uploadPending', () {
test(
'uploads a picture taken here and links it for every tablet',
() async {
final picture = await takePicture('taken.jpg');
await database.queueImageUpload('cola', picture);
await images.uploadPending(token: 'token', deviceId: 'd1');
expect(requests.single.url.path, '/devices/d1/images');
expect(requests.single.headers['Authorization'], 'Bearer token');
expect(
(await outbox()).single,
containsPair('type', 'product_updated'),
);
expect((await outbox()).single['product'], {'imageId': _imageId});
expect((await cola()).imageId, _imageId);
// The tile keeps the picture it shows until the server's copy is here
expect(
await database.select(database.pendingImageUploads).get(),
isEmpty,
);
},
);
test('offline, the picture waits for the next sync', () async {
respond = (_) async => throw http.ClientException('Failed host lookup');
await database.queueImageUpload('cola', await takePicture('taken.jpg'));
await expectLater(
images.uploadPending(token: 'token', deviceId: 'd1'),
throwsA(isA<http.ClientException>()),
);
expect(
await database.select(database.pendingImageUploads).get(),
hasLength(1),
);
expect(await outbox(), isEmpty);
});
test(
'a picture the server cannot read is dropped instead of blocking the queue',
() async {
respond = (_) async => http.Response(
jsonEncode({
'error': {'code': 'invalid_image', 'message': 'Not an image'},
}),
400,
);
await database.queueImageUpload(
'cola',
await takePicture('broken.jpg'),
);
await images.uploadPending(token: 'token', deviceId: 'd1');
expect(
await database.select(database.pendingImageUploads).get(),
isEmpty,
);
expect(await outbox(), isEmpty);
},
);
test('a picture whose file is gone is forgotten', () async {
await database.queueImageUpload(
'cola',
path.join(directory.path, 'gone.jpg'),
);
await images.uploadPending(token: 'token', deviceId: 'd1');
expect(requests, isEmpty);
expect(
await database.select(database.pendingImageUploads).get(),
isEmpty,
);
});
test(
'a picture replaced while uploading is not linked; the newer one goes next',
() async {
final first = await takePicture('first.jpg');
final second = await takePicture('second.jpg');
await database.queueImageUpload('cola', first);
respond = (request) async {
// Another picture is taken while this one uploads
await database.queueImageUpload('cola', second);
return http.Response(jsonEncode({'imageId': _imageId}), 201);
};
await images.uploadPending(token: 'token', deviceId: 'd1');
expect(await outbox(), isEmpty);
expect(
(await database.select(database.pendingImageUploads).getSingle())
.localPath,
second,
);
},
);
});
group('downloadMissing', () {
Future<void> setImage(String? imageId, {String? imagePath}) async {
await (database.update(
database.products,
)..where((row) => row.id.equals('cola'))).write(
ProductsCompanion(imageId: Value(imageId), imagePath: Value(imagePath)),
);
}
test('downloads a new picture and shows it', () async {
await setImage(_imageId);
await images.downloadMissing();
final target = path.join(directory.path, '$_imageId.webp');
expect((await cola()).imagePath, target);
expect(await File(target).readAsBytes(), _picture);
expect(File('$target.part').existsSync(), isFalse);
});
test('downloads a picture once', () async {
await setImage(_imageId);
await images.downloadMissing();
await images.downloadMissing();
expect(requests, hasLength(1));
});
test(
'offline, keeps showing the old picture and tries again later',
() async {
final old = await takePicture('old.webp');
await setImage(_imageId, imagePath: old);
respond = (_) async => throw http.ClientException('Failed host lookup');
await images.downloadMissing();
expect((await cola()).imagePath, old);
respond = (_) async => http.Response.bytes(_picture, 200);
await images.downloadMissing();
expect(
(await cola()).imagePath,
path.join(directory.path, '$_imageId.webp'),
);
},
);
test(
'leaves a product alone while its own picture waits for upload',
() async {
final taken = await takePicture('taken.jpg');
await setImage(_imageId, imagePath: taken);
await database.queueImageUpload('cola', taken);
await images.downloadMissing();
expect(requests, isEmpty);
expect((await cola()).imagePath, taken);
},
);
test(
'deletes old pictures nothing uses, keeps ones in use and just picked ones',
() async {
final unused = File(path.join(directory.path, 'unused.webp'));
await unused.writeAsBytes([1]);
await unused.setLastModified(
DateTime.now().subtract(const Duration(hours: 2)),
);
final justPicked = await takePicture('just_picked.jpg');
final shown = await takePicture('shown.webp');
await File(
shown,
).setLastModified(DateTime.now().subtract(const Duration(hours: 2)));
await setImage(null, imagePath: shown);
await images.downloadMissing();
expect(unused.existsSync(), isFalse);
expect(File(justPicked).existsSync(), isTrue);
expect(File(shown).existsSync(), isTrue);
},
);
});
}
+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
+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],
);
});