274 lines
8.3 KiB
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);
|
|
},
|
|
);
|
|
});
|
|
}
|