import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:archive/archive.dart'; import 'package:cookit/src/bundle/bundle_codec.dart'; import 'package:cookit/src/bundle/bundle_service.dart'; import 'package:cookit/src/database/database.dart'; import 'package:cookit/src/recipes/course_repository.dart'; import 'package:cookit/src/recipes/models/recipe.dart'; import 'package:cookit/src/recipes/photo_store.dart'; import 'package:cookit/src/recipes/recipe_repository.dart'; import 'package:cookit/src/recipes/unit_repository.dart'; import 'package:flutter_test/flutter_test.dart'; import '../support/fixtures.dart'; import '../support/test_database.dart'; class _App { _App(this.db, this.photos) : recipes = RecipeRepository(db, photos), courses = CourseRepository(db), units = UnitRepository(db) { bundles = BundleService(recipes, courses, units, photos, clock: () => testTime); } final AppDatabase db; final PhotoStore photos; final RecipeRepository recipes; final CourseRepository courses; final UnitRepository units; late final BundleService bundles; static Future<_App> create() async => _App(testDatabase(), await tempPhotoStore()); Future close() async { await db.close(); await photos.directory.delete(recursive: true); } } Uint8List _zip(Map> files) { final archive = Archive(); files.forEach((name, bytes) => archive.add(ArchiveFile.bytes(name, bytes))); return ZipEncoder().encodeBytes(archive); } void main() { late _App source; late _App target; setUp(() async { source = await _App.create(); target = await _App.create(); }); tearDown(() async { await source.close(); await target.close(); }); test('export then import reproduces the collection with Photos and Courses', () async { final course = await source.courses.addCourse('Suppe', emoji: '🍲'); final photo = await source.photos.saveBytes(Uint8List.fromList([9, 8, 7])); await source.recipes.saveRecipe(recipe(courseId: course.id, photos: [photo], favourite: true)); await source.recipes.saveRecipe(recipe(id: 'r2', name: 'Brot')); final bytes = await source.bundles.exportBundle(); final result = await target.bundles.importBundle(bytes); expect((result.added, result.skipped), (2, 0)); final imported = (await target.recipes.watchRecipe('r1').first)!; expect(imported.favourite, isTrue); expect(imported.photos, hasLength(1)); expect(await target.photos.read(imported.photos.single), [9, 8, 7]); final targetCourse = (await target.courses.allCourses()).firstWhere((c) => c.id == imported.courseId); expect(targetCourse.name, 'Suppe'); expect( [for (final g in imported.ingredientGroups) g.toJson()], [for (final g in recipe().ingredientGroups) g.toJson()], ); }); test('own units travel with the export and are created on import', () async { final cube = await source.units.addOwn('Würfel'); await source.units.setDirect(cube.key, false); await source.recipes.saveRecipe( recipe( ingredientGroups: [ IngredientGroup( lines: [IngredientLine(amount: 2, unitKey: cube.key, name: 'Brühe')], ), ], ), ); await target.bundles.importBundle(await source.bundles.exportBundle()); final imported = (await target.units.allUnits()).firstWhere((u) => u.key == cube.key); expect((imported.label, imported.direct, imported.builtIn), ('Würfel', false, false)); final line = (await target.recipes.getRecipe('r1'))!.ingredientGroups.single.lines.single; expect(line.unitKey, cube.key); }); test('importing again skips Recipes that are already there', () async { await source.recipes.saveRecipe(recipe()); final bytes = await source.bundles.exportBundle(); await target.bundles.importBundle(bytes); final again = await target.bundles.importBundle(bytes); expect((again.added, again.skipped), (0, 1)); expect(await target.recipes.allRecipes(), hasLength(1)); }); test('a Course named like an existing one is reused', () async { final manifest = BundleCodec.encodeManifest([ BundleCodec.encodeRecipe(recipe(), courseName: 'Dessert', photoPaths: const []), ], createdAt: testTime); await target.bundles.importBundle(_zip({'bundle.json': utf8.encode(jsonEncode(manifest))})); final courses = await target.courses.allCourses(); expect(courses.where((c) => c.name == 'Dessert'), hasLength(1)); expect((await target.recipes.allRecipes()).single.courseId, courses.firstWhere((c) => c.name == 'Dessert').id); }); test('rejects files that are not Recipe Bundles', () async { expect( () => target.bundles.importBundle(Uint8List.fromList(utf8.encode('hello'))), throwsA(isA()), ); expect( () => target.bundles.importBundle( _zip({ 'other.txt': [1], }), ), throwsA(isA()), ); expect( () => target.bundles.importBundle( _zip({ 'bundle.json': utf8.encode(jsonEncode({'format': 'cookit.recipe-bundle', 'version': 99, 'recipes': []})), }), ), throwsA(isA()), ); }); // Contract test against the Excel converter's real output, when it has been generated. final converted = File('tools/excel-import/out/cookit-rezepte.zip'); test( 'imports the converted Excel workbook', () async { final result = await target.bundles.importBundle(await converted.readAsBytes()); expect(result.added, greaterThan(200)); final all = await target.recipes.allRecipes(); final focaccia = all.firstWhere((r) => r.name == 'Focaccia'); expect(focaccia.stepSections.map((s) => s.title).take(2), ['Vorteig', 'Teig']); final shake = all.firstWhere((r) => r.name == 'Melonen-Minze-Shake'); expect(shake.photos, hasLength(3)); expect(target.photos.file(shake.photos.first).existsSync(), isTrue); expect(all.where((r) => r.needsReview), isNotEmpty); }, skip: converted.existsSync() ? false : 'run tools/excel-import/convert.py first', timeout: const Timeout(Duration(minutes: 2)), ); }