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