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