feat: add remote products and qr code for payconic
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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],
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user