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 requests; late Future 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 cola() => (database.select( database.products, )..where((row) => row.id.equals('cola'))).getSingle(); Future>> outbox() async => [ for (final row in await database.select(database.tabEvents).get()) jsonDecode(row.payload) as Map, ]; Future 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()), ); 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 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); }, ); }); }