Files
kooltab/test/product_image_sync_test.dart

274 lines
8.3 KiB
Dart

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);
},
);
});
}