From 9f59a22a61c4252780f10ad734d1c9c7431194de Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 20:03:38 +0000 Subject: [PATCH 1/2] test: add coverage for repositories, services and helpers Add unit tests for previously untested pure-logic units and fill branch gaps in existing domain coverage: - TastingLogService, ApiDrinkRepository, ApiFestivalRepository - ABVStrength, BeverageType, CategoryColor, StringFormatting helpers - FestivalService non-200 response handling - Festival single-day isLive, hasEnded default time, and sortByDate with undated festivals - BeerProvider toggleTasted, stale-data refresh, and favourite-filter re-application Also extend the .gitignore mock-keep rule to nested test directories so generated repository mocks are tracked. All 705 tests pass; overall line coverage rises to ~81%. https://claude.ai/code/session_019w4vSjQKXKNBisd3Uzp4Ya --- .gitignore | 1 + test/abv_strength_helper_test.dart | 86 +++ test/beer_provider_test.dart | 111 ++++ test/beverage_type_helper_test.dart | 53 ++ test/category_color_helper_test.dart | 85 +++ .../api_drink_repository_test.dart | 154 ++++++ .../api_drink_repository_test.mocks.dart | 92 ++++ .../api_festival_repository_test.dart | 80 +++ .../api_festival_repository_test.mocks.dart | 96 ++++ test/models_test.dart | 59 +++ test/services_test.dart | 17 + test/string_formatting_helper_test.dart | 30 ++ test/tasting_log_service_test.dart | 148 ++++++ .../festival_menu_sheets_test.mocks.dart | 494 ++++++++++++++++++ 14 files changed, 1506 insertions(+) create mode 100644 test/abv_strength_helper_test.dart create mode 100644 test/beverage_type_helper_test.dart create mode 100644 test/category_color_helper_test.dart create mode 100644 test/domain/repositories/api_drink_repository_test.dart create mode 100644 test/domain/repositories/api_drink_repository_test.mocks.dart create mode 100644 test/domain/repositories/api_festival_repository_test.dart create mode 100644 test/domain/repositories/api_festival_repository_test.mocks.dart create mode 100644 test/string_formatting_helper_test.dart create mode 100644 test/tasting_log_service_test.dart create mode 100644 test/widgets/festival_menu_sheets_test.mocks.dart diff --git a/.gitignore b/.gitignore index 87563772..be028964 100644 --- a/.gitignore +++ b/.gitignore @@ -142,5 +142,6 @@ app.*.symbols # Keep manually created test mocks (build_runner has version compatibility issues) !test/*.mocks.dart +!test/**/*.mocks.dart screenshots/ test/failures/ diff --git a/test/abv_strength_helper_test.dart b/test/abv_strength_helper_test.dart new file mode 100644 index 00000000..a7306f78 --- /dev/null +++ b/test/abv_strength_helper_test.dart @@ -0,0 +1,86 @@ +import 'package:cambridge_beer_festival/utils/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ABVStrengthHelper', () { + group('getABVStrengthLabel', () { + test('returns (Low) below 4.0%', () { + expect(ABVStrengthHelper.getABVStrengthLabel(0.0), '(Low)'); + expect(ABVStrengthHelper.getABVStrengthLabel(3.9), '(Low)'); + }); + + test('returns (Medium) from 4.0% up to but not including 7.0%', () { + expect(ABVStrengthHelper.getABVStrengthLabel(4.0), '(Medium)'); + expect(ABVStrengthHelper.getABVStrengthLabel(6.9), '(Medium)'); + }); + + test('returns (High) at 7.0% and above', () { + expect(ABVStrengthHelper.getABVStrengthLabel(7.0), '(High)'); + expect(ABVStrengthHelper.getABVStrengthLabel(12.0), '(High)'); + }); + }); + + group('getABVColor', () { + /// Pumps a [Builder] under [brightness] and captures the colour the + /// helper returns for [abv] alongside the active colour scheme. + Future<(Color result, ColorScheme scheme)> resolve( + WidgetTester tester, + Brightness brightness, + double abv, + ) async { + late Color result; + late ColorScheme scheme; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: brightness), + home: Builder( + builder: (context) { + scheme = Theme.of(context).colorScheme; + result = ABVStrengthHelper.getABVColor(context, abv); + return const SizedBox(); + }, + ), + ), + ); + await tester.pumpAndSettle(); + return (result, scheme); + } + + testWidgets('low ABV uses the primary colour (light theme)', + (tester) async { + final (result, scheme) = await resolve(tester, Brightness.light, 3.0); + expect(result, scheme.primary); + }); + + testWidgets('low ABV dims the primary colour (dark theme)', + (tester) async { + final (result, scheme) = await resolve(tester, Brightness.dark, 3.0); + expect(result, scheme.primary.withValues(alpha: 0.7)); + }); + + testWidgets('medium ABV uses the secondary colour (light theme)', + (tester) async { + final (result, scheme) = await resolve(tester, Brightness.light, 5.0); + expect(result, scheme.secondary); + }); + + testWidgets('medium ABV dims the secondary colour (dark theme)', + (tester) async { + final (result, scheme) = await resolve(tester, Brightness.dark, 5.0); + expect(result, scheme.secondary.withValues(alpha: 0.8)); + }); + + testWidgets('high ABV uses deep orange (light theme)', (tester) async { + final (result, _) = await resolve(tester, Brightness.light, 8.0); + expect(result, const Color(0xFFE64A19)); + }); + + testWidgets('high ABV uses translucent deep orange (dark theme)', + (tester) async { + final (result, _) = await resolve(tester, Brightness.dark, 8.0); + expect(result, const Color(0xFFFF5722).withValues(alpha: 0.85)); + }); + }); + }); +} diff --git a/test/beer_provider_test.dart b/test/beer_provider_test.dart index bdedd7b2..cad89cb9 100644 --- a/test/beer_provider_test.dart +++ b/test/beer_provider_test.dart @@ -1717,5 +1717,116 @@ void main() { expect(provider.isDrinksDataStale, isFalse); }); }); + + group('tasted, refresh and favourite filtering', () { + test('toggleTasted updates the drink and logs the change', () async { + provider = BeerProvider( + drinkRepository: mockDrinkRepository, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, + ); + await provider.initialize(); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => createSampleDrinks()); + await provider.loadDrinks(); + final drink = provider.allDrinks.first; + + when(mockDrinkRepository.toggleTasted(any, any)) + .thenAnswer((_) async => true); + await provider.toggleTasted(drink); + expect(drink.isTasted, isTrue); + verify(mockAnalyticsService.logTastedAdded(drink)).called(1); + + when(mockDrinkRepository.toggleTasted(any, any)) + .thenAnswer((_) async => false); + await provider.toggleTasted(drink); + expect(drink.isTasted, isFalse); + verify(mockAnalyticsService.logTastedRemoved(drink)).called(1); + }); + + test('refreshIfStale reloads festivals and drinks when both are stale', + () async { + provider = BeerProvider( + drinkRepository: mockDrinkRepository, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, + ); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => createSampleDrinks()); + + // Without initialize()/loadDrinks() both refresh timestamps are null, + // so both data sets report as stale. + expect(provider.isFestivalsDataStale, isTrue); + expect(provider.isDrinksDataStale, isTrue); + + await provider.refreshIfStale(); + + verify(mockFestivalRepository.getFestivals()).called(1); + verify(mockDrinkRepository.getDrinks(any)).called(1); + expect(provider.allDrinks, isNotEmpty); + }); + + test('toggleFavorite re-applies filters when showing favourites only', + () async { + provider = BeerProvider( + drinkRepository: mockDrinkRepository, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, + ); + await provider.initialize(); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => createSampleDrinks()); + await provider.loadDrinks(); + + provider.setShowFavoritesOnly(true); + expect(provider.showFavoritesOnly, isTrue); + expect(provider.drinks, isEmpty); + + final drink = provider.allDrinks.first; + when(mockDrinkRepository.toggleFavorite(any, any)) + .thenAnswer((_) async => true); + await provider.toggleFavorite(drink); + + // The favourites-only list is refreshed in place by toggleFavorite. + expect(provider.drinks, contains(drink)); + }); + + test('styleCountsMap is scoped to the selected category', () async { + provider = BeerProvider( + drinkRepository: mockDrinkRepository, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, + ); + await provider.initialize(); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => createSampleDrinks()); + await provider.loadDrinks(); + + expect( + provider.styleCountsMap.keys, + containsAll(['IPA', 'Bitter', 'Dry', 'Sweet']), + ); + + provider.setCategory('cider'); + expect(provider.styleCountsMap.keys, containsAll(['Dry', 'Sweet'])); + expect(provider.styleCountsMap.containsKey('IPA'), isFalse); + }); + + test('lastDrinksRefresh is set only after a successful load', () async { + provider = BeerProvider( + drinkRepository: mockDrinkRepository, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, + ); + await provider.initialize(); + expect(provider.lastDrinksRefresh, isNull); + + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => createSampleDrinks()); + await provider.loadDrinks(); + + expect(provider.lastDrinksRefresh, isNotNull); + }); + }); }); } diff --git a/test/beverage_type_helper_test.dart b/test/beverage_type_helper_test.dart new file mode 100644 index 00000000..6a5c9d8a --- /dev/null +++ b/test/beverage_type_helper_test.dart @@ -0,0 +1,53 @@ +import 'package:cambridge_beer_festival/utils/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('BeverageTypeHelper', () { + group('formatBeverageType', () { + test('title-cases a single word', () { + expect(BeverageTypeHelper.formatBeverageType('beer'), 'Beer'); + }); + + test('title-cases each dash-separated segment', () { + expect( + BeverageTypeHelper.formatBeverageType('international-beer'), + 'International Beer', + ); + expect(BeverageTypeHelper.formatBeverageType('low-no'), 'Low No'); + }); + + test('ignores empty segments from leading/trailing/double dashes', () { + expect(BeverageTypeHelper.formatBeverageType('--beer--'), 'Beer'); + expect(BeverageTypeHelper.formatBeverageType('cider--perry'), + 'Cider Perry'); + }); + + test('returns an empty string for empty input', () { + expect(BeverageTypeHelper.formatBeverageType(''), ''); + }); + }); + + group('getBeverageIcon', () { + test('maps each known beverage type to a distinct icon', () { + expect(BeverageTypeHelper.getBeverageIcon('beer'), Icons.sports_bar); + expect(BeverageTypeHelper.getBeverageIcon('international-beer'), + Icons.public); + expect(BeverageTypeHelper.getBeverageIcon('cider'), Icons.local_drink); + expect(BeverageTypeHelper.getBeverageIcon('perry'), Icons.eco); + expect( + BeverageTypeHelper.getBeverageIcon('mead'), Icons.emoji_nature); + expect(BeverageTypeHelper.getBeverageIcon('wine'), Icons.wine_bar); + expect(BeverageTypeHelper.getBeverageIcon('low-no'), Icons.no_drinks); + }); + + test('falls back to a generic icon for unknown types', () { + expect( + BeverageTypeHelper.getBeverageIcon('apple-juice'), + Icons.local_drink, + ); + expect(BeverageTypeHelper.getBeverageIcon(''), Icons.local_drink); + }); + }); + }); +} diff --git a/test/category_color_helper_test.dart b/test/category_color_helper_test.dart new file mode 100644 index 00000000..d6d5ad5d --- /dev/null +++ b/test/category_color_helper_test.dart @@ -0,0 +1,85 @@ +import 'package:cambridge_beer_festival/utils/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('CategoryColorHelper', () { + /// Pumps a [Builder] under [brightness] and captures the colour the helper + /// returns for [category] alongside the active colour scheme. + Future<(Color result, ColorScheme scheme)> resolve( + WidgetTester tester, + Brightness brightness, + String category, + ) async { + late Color result; + late ColorScheme scheme; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(brightness: brightness), + home: Builder( + builder: (context) { + scheme = Theme.of(context).colorScheme; + result = CategoryColorHelper.getCategoryColor(context, category); + return const SizedBox(); + }, + ), + ), + ); + await tester.pumpAndSettle(); + return (result, scheme); + } + + testWidgets('beer categories use the secondary colour', (tester) async { + final (result, scheme) = await resolve(tester, Brightness.light, 'beer'); + expect(result, scheme.secondary); + }); + + testWidgets('international-beer still matches the beer branch', + (tester) async { + final (result, scheme) = + await resolve(tester, Brightness.light, 'international-beer'); + expect(result, scheme.secondary); + }); + + testWidgets('cider has a dedicated colour per theme', (tester) async { + final (light, _) = await resolve(tester, Brightness.light, 'cider'); + expect(light, const Color(0xFF689F38)); + + final (dark, _) = await resolve(tester, Brightness.dark, 'cider'); + expect(dark, const Color(0xFF8BC34A).withValues(alpha: 0.8)); + }); + + testWidgets('perry has a dedicated colour', (tester) async { + final (result, _) = await resolve(tester, Brightness.light, 'perry'); + expect(result, const Color(0xFFAFB42B)); + }); + + testWidgets('mead has a dedicated colour', (tester) async { + final (result, _) = await resolve(tester, Brightness.light, 'mead'); + expect(result, const Color(0xFFF9A825)); + }); + + testWidgets('wine has a dedicated colour', (tester) async { + final (result, _) = await resolve(tester, Brightness.light, 'wine'); + expect(result, const Color(0xFF7B1FA2)); + }); + + testWidgets('low-no categories use the primary colour', (tester) async { + final (result, scheme) = + await resolve(tester, Brightness.light, 'low-no'); + expect(result, scheme.primary); + }); + + testWidgets('matching is case-insensitive', (tester) async { + final (result, scheme) = await resolve(tester, Brightness.light, 'BEER'); + expect(result, scheme.secondary); + }); + + testWidgets('unknown categories fall back to the outline colour', + (tester) async { + final (result, scheme) = + await resolve(tester, Brightness.light, 'spirits'); + expect(result, scheme.outline); + }); + }); +} diff --git a/test/domain/repositories/api_drink_repository_test.dart b/test/domain/repositories/api_drink_repository_test.dart new file mode 100644 index 00000000..5bec404c --- /dev/null +++ b/test/domain/repositories/api_drink_repository_test.dart @@ -0,0 +1,154 @@ +import 'package:cambridge_beer_festival/domain/repositories/repositories.dart'; +import 'package:cambridge_beer_festival/models/models.dart'; +import 'package:cambridge_beer_festival/services/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'api_drink_repository_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + const festival = Festival( + id: 'cbf2025', + name: 'Cambridge Beer Festival 2025', + dataBaseUrl: 'https://example.com/cbf2025', + ); + + Drink makeDrink(String id) => Drink( + product: Product( + id: id, + name: 'Drink $id', + category: 'beer', + dispense: 'cask', + abv: 4.0, + ), + producer: const Producer( + id: 'brewery-1', + name: 'Test Brewery', + location: 'Cambridge', + products: [], + ), + festivalId: festival.id, + ); + + group('ApiDrinkRepository', () { + late MockBeerApiService apiService; + late FavoritesService favoritesService; + late RatingsService ratingsService; + late TastingLogService tastingLogService; + late ApiDrinkRepository repository; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + apiService = MockBeerApiService(); + favoritesService = FavoritesService(prefs); + ratingsService = RatingsService(prefs); + tastingLogService = TastingLogService(prefs); + repository = ApiDrinkRepository( + apiService: apiService, + favoritesService: favoritesService, + ratingsService: ratingsService, + tastingLogService: tastingLogService, + ); + }); + + group('getDrinks', () { + test('populates favourite, rating and tasted state in one pass', + () async { + when(apiService.fetchAllDrinks(festival)).thenAnswer( + (_) async => [makeDrink('d1'), makeDrink('d2'), makeDrink('d3')], + ); + await favoritesService.addFavorite(festival.id, 'd1'); + await ratingsService.setRating(festival.id, 'd2', 4); + await tastingLogService.markAsTasted(festival.id, 'd3'); + + final drinks = await repository.getDrinks(festival); + + final byId = {for (final d in drinks) d.id: d}; + expect(byId['d1']!.isFavorite, isTrue); + expect(byId['d1']!.rating, isNull); + expect(byId['d1']!.isTasted, isFalse); + expect(byId['d2']!.rating, 4); + expect(byId['d2']!.isFavorite, isFalse); + expect(byId['d3']!.isTasted, isTrue); + }); + + test('leaves all state unset when nothing is stored', () async { + when(apiService.fetchAllDrinks(festival)) + .thenAnswer((_) async => [makeDrink('d1')]); + + final drinks = await repository.getDrinks(festival); + + expect(drinks.single.isFavorite, isFalse); + expect(drinks.single.rating, isNull); + expect(drinks.single.isTasted, isFalse); + }); + + test('returns an empty list when the API returns no drinks', () async { + when(apiService.fetchAllDrinks(festival)) + .thenAnswer((_) async => []); + + expect(await repository.getDrinks(festival), isEmpty); + }); + }); + + group('favourite delegation', () { + test('getFavorites returns stored favourites', () async { + await favoritesService.addFavorite(festival.id, 'd1'); + + expect(await repository.getFavorites(festival.id), equals(['d1'])); + }); + + test('toggleFavorite adds then removes a favourite', () async { + expect(await repository.toggleFavorite(festival.id, 'd1'), isTrue); + expect(favoritesService.isFavorite(festival.id, 'd1'), isTrue); + + expect(await repository.toggleFavorite(festival.id, 'd1'), isFalse); + expect(favoritesService.isFavorite(festival.id, 'd1'), isFalse); + }); + }); + + group('rating delegation', () { + test('setRating then getRating round-trips the value', () async { + await repository.setRating(festival.id, 'd1', 5); + + expect(await repository.getRating(festival.id, 'd1'), 5); + }); + + test('removeRating clears a stored rating', () async { + await repository.setRating(festival.id, 'd1', 3); + await repository.removeRating(festival.id, 'd1'); + + expect(await repository.getRating(festival.id, 'd1'), isNull); + }); + }); + + group('tasted delegation', () { + test('hasTasted reflects the tasting log', () async { + expect(await repository.hasTasted(festival.id, 'd1'), isFalse); + + await tastingLogService.markAsTasted(festival.id, 'd1'); + + expect(await repository.hasTasted(festival.id, 'd1'), isTrue); + }); + + test('toggleTasted returns the resulting tasted state', () async { + expect(await repository.toggleTasted(festival.id, 'd1'), isTrue); + expect(await repository.toggleTasted(festival.id, 'd1'), isFalse); + }); + + test('getTastedDrinks lists tasted drink IDs', () async { + await tastingLogService.markAsTasted(festival.id, 'd1'); + await tastingLogService.markAsTasted(festival.id, 'd2'); + + expect( + await repository.getTastedDrinks(festival.id), + containsAll(['d1', 'd2']), + ); + }); + }); + }); +} diff --git a/test/domain/repositories/api_drink_repository_test.mocks.dart b/test/domain/repositories/api_drink_repository_test.mocks.dart new file mode 100644 index 00000000..88868d61 --- /dev/null +++ b/test/domain/repositories/api_drink_repository_test.mocks.dart @@ -0,0 +1,92 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in cambridge_beer_festival/test/domain/repositories/api_drink_repository_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:cambridge_beer_festival/models/models.dart' as _i4; +import 'package:cambridge_beer_festival/services/beer_api_service.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeDuration_0 extends _i1.SmartFake implements Duration { + _FakeDuration_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [BeerApiService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockBeerApiService extends _i1.Mock implements _i2.BeerApiService { + @override + Duration get timeout => (super.noSuchMethod( + Invocation.getter(#timeout), + returnValue: _FakeDuration_0( + this, + Invocation.getter(#timeout), + ), + returnValueForMissingStub: _FakeDuration_0( + this, + Invocation.getter(#timeout), + ), + ) as Duration); + + @override + _i3.Future> fetchDrinks( + _i4.Festival? festival, + String? beverageType, + ) => + (super.noSuchMethod( + Invocation.method( + #fetchDrinks, + [ + festival, + beverageType, + ], + ), + returnValue: _i3.Future>.value(<_i4.Drink>[]), + returnValueForMissingStub: + _i3.Future>.value(<_i4.Drink>[]), + ) as _i3.Future>); + + @override + _i3.Future> fetchAllDrinks(_i4.Festival? festival) => + (super.noSuchMethod( + Invocation.method( + #fetchAllDrinks, + [festival], + ), + returnValue: _i3.Future>.value(<_i4.Drink>[]), + returnValueForMissingStub: + _i3.Future>.value(<_i4.Drink>[]), + ) as _i3.Future>); + + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); +} diff --git a/test/domain/repositories/api_festival_repository_test.dart b/test/domain/repositories/api_festival_repository_test.dart new file mode 100644 index 00000000..75f443b7 --- /dev/null +++ b/test/domain/repositories/api_festival_repository_test.dart @@ -0,0 +1,80 @@ +import 'package:cambridge_beer_festival/domain/repositories/repositories.dart'; +import 'package:cambridge_beer_festival/services/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'api_festival_repository_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + group('ApiFestivalRepository', () { + late MockFestivalService festivalService; + late FestivalStorageService storageService; + late ApiFestivalRepository repository; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + festivalService = MockFestivalService(); + storageService = FestivalStorageService(prefs); + repository = ApiFestivalRepository( + festivalService: festivalService, + festivalStorageService: storageService, + ); + }); + + test('getFestivals delegates to the festival service', () async { + final response = FestivalsResponse.fromJson( + { + 'festivals': [ + { + 'id': 'cbf2025', + 'name': 'Cambridge Beer Festival 2025', + 'data_base_url': 'https://example.com/cbf2025', + }, + ], + 'default_festival_id': 'cbf2025', + }, + 'https://example.com', + ); + when(festivalService.fetchFestivals()) + .thenAnswer((_) async => response); + + final result = await repository.getFestivals(); + + expect(result, same(response)); + verify(festivalService.fetchFestivals()).called(1); + }); + + test('getFestivals propagates festival service failures', () async { + when(festivalService.fetchFestivals()) + .thenThrow(FestivalServiceException('boom', 500)); + + expect( + () => repository.getFestivals(), + throwsA(isA()), + ); + }); + + test('getSelectedFestivalId returns null before any selection', () async { + expect(await repository.getSelectedFestivalId(), isNull); + }); + + test('setSelectedFestivalId then getSelectedFestivalId round-trips', + () async { + await repository.setSelectedFestivalId('cbf2025'); + + expect(await repository.getSelectedFestivalId(), 'cbf2025'); + expect(storageService.getSelectedFestivalId(), 'cbf2025'); + }); + + test('setSelectedFestivalId overwrites a previous selection', () async { + await repository.setSelectedFestivalId('cbf2024'); + await repository.setSelectedFestivalId('cbf2025'); + + expect(await repository.getSelectedFestivalId(), 'cbf2025'); + }); + }); +} diff --git a/test/domain/repositories/api_festival_repository_test.mocks.dart b/test/domain/repositories/api_festival_repository_test.mocks.dart new file mode 100644 index 00000000..c3b91a4e --- /dev/null +++ b/test/domain/repositories/api_festival_repository_test.mocks.dart @@ -0,0 +1,96 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in cambridge_beer_festival/test/domain/repositories/api_festival_repository_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:cambridge_beer_festival/services/festival_service.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeDuration_0 extends _i1.SmartFake implements Duration { + _FakeDuration_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeFestivalsResponse_1 extends _i1.SmartFake + implements _i2.FestivalsResponse { + _FakeFestivalsResponse_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [FestivalService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFestivalService extends _i1.Mock implements _i2.FestivalService { + @override + Duration get timeout => (super.noSuchMethod( + Invocation.getter(#timeout), + returnValue: _FakeDuration_0( + this, + Invocation.getter(#timeout), + ), + returnValueForMissingStub: _FakeDuration_0( + this, + Invocation.getter(#timeout), + ), + ) as Duration); + + @override + _i3.Future<_i2.FestivalsResponse> fetchFestivals() => (super.noSuchMethod( + Invocation.method( + #fetchFestivals, + [], + ), + returnValue: + _i3.Future<_i2.FestivalsResponse>.value(_FakeFestivalsResponse_1( + this, + Invocation.method( + #fetchFestivals, + [], + ), + )), + returnValueForMissingStub: + _i3.Future<_i2.FestivalsResponse>.value(_FakeFestivalsResponse_1( + this, + Invocation.method( + #fetchFestivals, + [], + ), + )), + ) as _i3.Future<_i2.FestivalsResponse>); + + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); +} diff --git a/test/models_test.dart b/test/models_test.dart index d52185dd..553892f4 100644 --- a/test/models_test.dart +++ b/test/models_test.dart @@ -1423,6 +1423,65 @@ void main() { expect(Festival.getStatusInContext(past1, sorted, now), FestivalStatus.mostRecent); expect(Festival.getStatusInContext(past2, sorted, now), FestivalStatus.past); }); + + test('isLive treats a festival with no end date as a single day', () { + final festival = Festival( + id: 'oneday', + name: 'One Day Festival', + startDate: DateTime(2025, 5, 19), + dataBaseUrl: 'https://example.com/oneday', + ); + + expect(festival.isLive(DateTime(2025, 5, 19, 12)), isTrue); + expect(festival.isLive(DateTime(2025, 5, 19, 23, 59)), isTrue); + expect(festival.isLive(DateTime(2025, 5, 20)), isFalse); + expect(festival.isLive(DateTime(2025, 5, 18, 23, 59)), isFalse); + }); + + test('hasEnded falls back to the current time when none is given', () { + final longPast = Festival( + id: 'past', + name: 'Long Past Festival', + startDate: DateTime(2000, 1, 1), + endDate: DateTime(2000, 1, 3), + dataBaseUrl: 'https://example.com/past', + ); + final farFuture = Festival( + id: 'future', + name: 'Far Future Festival', + startDate: DateTime(2999, 1, 1), + endDate: DateTime(2999, 1, 3), + dataBaseUrl: 'https://example.com/future', + ); + + expect(longPast.hasEnded(), isTrue); + expect(farFuture.hasEnded(), isFalse); + }); + + test('sortByDate sorts dated past festivals ahead of undated ones', () { + final datedPast = Festival( + id: 'dated', + name: 'Dated Past Festival', + startDate: DateTime(2025, 4, 1), + endDate: DateTime(2025, 4, 5), + dataBaseUrl: 'https://example.com/dated', + ); + const undated = Festival( + id: 'undated', + name: 'Undated Festival', + dataBaseUrl: 'https://example.com/undated', + ); + final now = DateTime(2025, 6, 1); + + expect( + Festival.sortByDate([undated, datedPast], now).map((f) => f.id), + equals(['dated', 'undated']), + ); + expect( + Festival.sortByDate([datedPast, undated], now).map((f) => f.id), + equals(['dated', 'undated']), + ); + }); }); }); diff --git a/test/services_test.dart b/test/services_test.dart index a15ca132..f73756ba 100644 --- a/test/services_test.dart +++ b/test/services_test.dart @@ -378,5 +378,22 @@ void main() { service.dispose(); }); + + test('throws FestivalServiceException for a non-200 response', () async { + final mockClient = MockClient(); + final service = FestivalService(client: mockClient); + + when(mockClient.get(any)).thenAnswer( + (_) async => http.Response('Service unavailable', 503), + ); + + await expectLater( + service.fetchFestivals(), + throwsA(isA() + .having((e) => e.statusCode, 'statusCode', 503)), + ); + + service.dispose(); + }); }); } diff --git a/test/string_formatting_helper_test.dart b/test/string_formatting_helper_test.dart new file mode 100644 index 00000000..f785e80c --- /dev/null +++ b/test/string_formatting_helper_test.dart @@ -0,0 +1,30 @@ +import 'package:cambridge_beer_festival/utils/utils.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('StringFormattingHelper', () { + group('capitalizeFirst', () { + test('uppercases the first character', () { + expect(StringFormattingHelper.capitalizeFirst('cask'), 'Cask'); + expect(StringFormattingHelper.capitalizeFirst('keg'), 'Keg'); + }); + + test('leaves the remaining characters untouched', () { + expect(StringFormattingHelper.capitalizeFirst('bag in box'), + 'Bag in box'); + }); + + test('is a no-op for an already capitalised string', () { + expect(StringFormattingHelper.capitalizeFirst('Cask'), 'Cask'); + }); + + test('returns an empty string unchanged', () { + expect(StringFormattingHelper.capitalizeFirst(''), ''); + }); + + test('handles a single character', () { + expect(StringFormattingHelper.capitalizeFirst('a'), 'A'); + }); + }); + }); +} diff --git a/test/tasting_log_service_test.dart b/test/tasting_log_service_test.dart new file mode 100644 index 00000000..cfc90951 --- /dev/null +++ b/test/tasting_log_service_test.dart @@ -0,0 +1,148 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:cambridge_beer_festival/services/tasting_log_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + group('TastingLogService', () { + late TastingLogService service; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + service = TastingLogService(prefs); + }); + + test('hasTasted returns false for an untasted drink', () { + expect(service.hasTasted('cbf2025', 'drink-1'), isFalse); + }); + + test('markAsTasted records a drink as tasted', () async { + await service.markAsTasted('cbf2025', 'drink-1'); + + expect(service.hasTasted('cbf2025', 'drink-1'), isTrue); + }); + + test('unmarkAsTasted clears a tasted drink', () async { + await service.markAsTasted('cbf2025', 'drink-1'); + await service.unmarkAsTasted('cbf2025', 'drink-1'); + + expect(service.hasTasted('cbf2025', 'drink-1'), isFalse); + }); + + test('unmarkAsTasted is a no-op for an untasted drink', () async { + await service.unmarkAsTasted('cbf2025', 'drink-1'); + + expect(service.hasTasted('cbf2025', 'drink-1'), isFalse); + }); + + group('getTastedTimestamp', () { + test('returns null for an untasted drink', () { + expect(service.getTastedTimestamp('cbf2025', 'drink-1'), isNull); + }); + + test('returns the time the drink was marked', () async { + // Stored timestamps are truncated to whole milliseconds, so compare + // against millisecond bounds rather than the raw DateTime instances. + final beforeMs = DateTime.now().millisecondsSinceEpoch; + await service.markAsTasted('cbf2025', 'drink-1'); + final afterMs = DateTime.now().millisecondsSinceEpoch; + + final timestamp = service.getTastedTimestamp('cbf2025', 'drink-1'); + + expect(timestamp, isNotNull); + expect( + timestamp!.millisecondsSinceEpoch, + inInclusiveRange(beforeMs, afterMs), + ); + }); + }); + + group('toggleTasted', () { + test('marks an untasted drink as tasted', () async { + await service.toggleTasted('cbf2025', 'drink-1'); + + expect(service.hasTasted('cbf2025', 'drink-1'), isTrue); + }); + + test('unmarks an already tasted drink', () async { + await service.markAsTasted('cbf2025', 'drink-1'); + await service.toggleTasted('cbf2025', 'drink-1'); + + expect(service.hasTasted('cbf2025', 'drink-1'), isFalse); + }); + }); + + group('getTastedDrinkIds', () { + test('returns an empty list when nothing is tasted', () { + expect(service.getTastedDrinkIds('cbf2025'), isEmpty); + }); + + test('returns all tasted drink IDs for a festival', () async { + await service.markAsTasted('cbf2025', 'drink-1'); + await service.markAsTasted('cbf2025', 'drink-2'); + + final ids = service.getTastedDrinkIds('cbf2025'); + + expect(ids, hasLength(2)); + expect(ids, containsAll(['drink-1', 'drink-2'])); + }); + + test('strips the storage prefix from returned IDs', () async { + await service.markAsTasted('cbf2025', 'drink-with-dashes-1'); + + expect( + service.getTastedDrinkIds('cbf2025'), + equals(['drink-with-dashes-1']), + ); + }); + }); + + test('getTastedCount reflects the number of tasted drinks', () async { + expect(service.getTastedCount('cbf2025'), 0); + + await service.markAsTasted('cbf2025', 'drink-1'); + await service.markAsTasted('cbf2025', 'drink-2'); + + expect(service.getTastedCount('cbf2025'), 2); + }); + + test('tasting logs are scoped per festival', () async { + await service.markAsTasted('cbf2025', 'drink-1'); + await service.markAsTasted('cbf2024', 'drink-2'); + + expect(service.hasTasted('cbf2025', 'drink-1'), isTrue); + expect(service.hasTasted('cbf2025', 'drink-2'), isFalse); + expect(service.hasTasted('cbf2024', 'drink-2'), isTrue); + expect(service.getTastedDrinkIds('cbf2025'), equals(['drink-1'])); + expect(service.getTastedDrinkIds('cbf2024'), equals(['drink-2'])); + }); + + group('clearFestivalLog', () { + test('removes only the targeted festival\'s logs', () async { + await service.markAsTasted('cbf2025', 'drink-1'); + await service.markAsTasted('cbf2024', 'drink-2'); + + await service.clearFestivalLog('cbf2025'); + + expect(service.getTastedCount('cbf2025'), 0); + expect(service.hasTasted('cbf2024', 'drink-2'), isTrue); + }); + + test('is a no-op when the festival has no logs', () async { + await service.clearFestivalLog('cbf2025'); + + expect(service.getTastedCount('cbf2025'), 0); + }); + }); + + test('clearAllLogs removes logs across every festival', () async { + await service.markAsTasted('cbf2025', 'drink-1'); + await service.markAsTasted('cbf2024', 'drink-2'); + + await service.clearAllLogs(); + + expect(service.getTastedCount('cbf2025'), 0); + expect(service.getTastedCount('cbf2024'), 0); + }); + }); +} diff --git a/test/widgets/festival_menu_sheets_test.mocks.dart b/test/widgets/festival_menu_sheets_test.mocks.dart new file mode 100644 index 00000000..d4143008 --- /dev/null +++ b/test/widgets/festival_menu_sheets_test.mocks.dart @@ -0,0 +1,494 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in cambridge_beer_festival/test/widgets/festival_menu_sheets_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i6; + +import 'package:cambridge_beer_festival/domain/repositories/drink_repository.dart' + as _i5; +import 'package:cambridge_beer_festival/domain/repositories/festival_repository.dart' + as _i8; +import 'package:cambridge_beer_festival/models/models.dart' as _i7; +import 'package:cambridge_beer_festival/services/analytics_service.dart' as _i9; +import 'package:cambridge_beer_festival/services/festival_service.dart' as _i2; +import 'package:firebase_analytics/firebase_analytics.dart' as _i3; +import 'package:firebase_crashlytics/firebase_crashlytics.dart' as _i4; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeFestivalsResponse_0 extends _i1.SmartFake + implements _i2.FestivalsResponse { + _FakeFestivalsResponse_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeFirebaseAnalytics_1 extends _i1.SmartFake + implements _i3.FirebaseAnalytics { + _FakeFirebaseAnalytics_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeFirebaseCrashlytics_2 extends _i1.SmartFake + implements _i4.FirebaseCrashlytics { + _FakeFirebaseCrashlytics_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [DrinkRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDrinkRepository extends _i1.Mock implements _i5.DrinkRepository { + @override + _i6.Future> getDrinks(_i7.Festival? festival) => + (super.noSuchMethod( + Invocation.method( + #getDrinks, + [festival], + ), + returnValue: _i6.Future>.value(<_i7.Drink>[]), + returnValueForMissingStub: + _i6.Future>.value(<_i7.Drink>[]), + ) as _i6.Future>); + + @override + _i6.Future> getFavorites(String? festivalId) => + (super.noSuchMethod( + Invocation.method( + #getFavorites, + [festivalId], + ), + returnValue: _i6.Future>.value([]), + returnValueForMissingStub: _i6.Future>.value([]), + ) as _i6.Future>); + + @override + _i6.Future toggleFavorite( + String? festivalId, + String? drinkId, + ) => + (super.noSuchMethod( + Invocation.method( + #toggleFavorite, + [ + festivalId, + drinkId, + ], + ), + returnValue: _i6.Future.value(false), + returnValueForMissingStub: _i6.Future.value(false), + ) as _i6.Future); + + @override + _i6.Future getRating( + String? festivalId, + String? drinkId, + ) => + (super.noSuchMethod( + Invocation.method( + #getRating, + [ + festivalId, + drinkId, + ], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future setRating( + String? festivalId, + String? drinkId, + int? rating, + ) => + (super.noSuchMethod( + Invocation.method( + #setRating, + [ + festivalId, + drinkId, + rating, + ], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future removeRating( + String? festivalId, + String? drinkId, + ) => + (super.noSuchMethod( + Invocation.method( + #removeRating, + [ + festivalId, + drinkId, + ], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future hasTasted( + String? festivalId, + String? drinkId, + ) => + (super.noSuchMethod( + Invocation.method( + #hasTasted, + [ + festivalId, + drinkId, + ], + ), + returnValue: _i6.Future.value(false), + returnValueForMissingStub: _i6.Future.value(false), + ) as _i6.Future); + + @override + _i6.Future toggleTasted( + String? festivalId, + String? drinkId, + ) => + (super.noSuchMethod( + Invocation.method( + #toggleTasted, + [ + festivalId, + drinkId, + ], + ), + returnValue: _i6.Future.value(false), + returnValueForMissingStub: _i6.Future.value(false), + ) as _i6.Future); + + @override + _i6.Future> getTastedDrinks(String? festivalId) => + (super.noSuchMethod( + Invocation.method( + #getTastedDrinks, + [festivalId], + ), + returnValue: _i6.Future>.value([]), + returnValueForMissingStub: _i6.Future>.value([]), + ) as _i6.Future>); +} + +/// A class which mocks [FestivalRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFestivalRepository extends _i1.Mock + implements _i8.FestivalRepository { + @override + _i6.Future<_i2.FestivalsResponse> getFestivals() => (super.noSuchMethod( + Invocation.method( + #getFestivals, + [], + ), + returnValue: + _i6.Future<_i2.FestivalsResponse>.value(_FakeFestivalsResponse_0( + this, + Invocation.method( + #getFestivals, + [], + ), + )), + returnValueForMissingStub: + _i6.Future<_i2.FestivalsResponse>.value(_FakeFestivalsResponse_0( + this, + Invocation.method( + #getFestivals, + [], + ), + )), + ) as _i6.Future<_i2.FestivalsResponse>); + + @override + _i6.Future getSelectedFestivalId() => (super.noSuchMethod( + Invocation.method( + #getSelectedFestivalId, + [], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future setSelectedFestivalId(String? festivalId) => + (super.noSuchMethod( + Invocation.method( + #setSelectedFestivalId, + [festivalId], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); +} + +/// A class which mocks [AnalyticsService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockAnalyticsService extends _i1.Mock implements _i9.AnalyticsService { + @override + _i3.FirebaseAnalytics get analytics => (super.noSuchMethod( + Invocation.getter(#analytics), + returnValue: _FakeFirebaseAnalytics_1( + this, + Invocation.getter(#analytics), + ), + returnValueForMissingStub: _FakeFirebaseAnalytics_1( + this, + Invocation.getter(#analytics), + ), + ) as _i3.FirebaseAnalytics); + + @override + _i4.FirebaseCrashlytics get crashlytics => (super.noSuchMethod( + Invocation.getter(#crashlytics), + returnValue: _FakeFirebaseCrashlytics_2( + this, + Invocation.getter(#crashlytics), + ), + returnValueForMissingStub: _FakeFirebaseCrashlytics_2( + this, + Invocation.getter(#crashlytics), + ), + ) as _i4.FirebaseCrashlytics); + + @override + _i6.Future logAppLaunch() => (super.noSuchMethod( + Invocation.method( + #logAppLaunch, + [], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future logFestivalSelected(_i7.Festival? festival) => + (super.noSuchMethod( + Invocation.method( + #logFestivalSelected, + [festival], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future logSearch(String? query) => (super.noSuchMethod( + Invocation.method( + #logSearch, + [query], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future logCategoryFilter(String? category) => (super.noSuchMethod( + Invocation.method( + #logCategoryFilter, + [category], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future logStyleFilter(Set? styles) => (super.noSuchMethod( + Invocation.method( + #logStyleFilter, + [styles], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future logSortChange(String? sortType) => (super.noSuchMethod( + Invocation.method( + #logSortChange, + [sortType], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future logFavoriteAdded(_i7.Drink? drink) => (super.noSuchMethod( + Invocation.method( + #logFavoriteAdded, + [drink], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future logFavoriteRemoved(_i7.Drink? drink) => (super.noSuchMethod( + Invocation.method( + #logFavoriteRemoved, + [drink], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future logTastedAdded(_i7.Drink? drink) => (super.noSuchMethod( + Invocation.method( + #logTastedAdded, + [drink], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future logTastedRemoved(_i7.Drink? drink) => (super.noSuchMethod( + Invocation.method( + #logTastedRemoved, + [drink], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future logDrinkViewed(_i7.Drink? drink) => (super.noSuchMethod( + Invocation.method( + #logDrinkViewed, + [drink], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future logBreweryViewed(String? breweryName) => (super.noSuchMethod( + Invocation.method( + #logBreweryViewed, + [breweryName], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future logStyleViewed(String? style) => (super.noSuchMethod( + Invocation.method( + #logStyleViewed, + [style], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future logRatingGiven( + _i7.Drink? drink, + int? rating, + ) => + (super.noSuchMethod( + Invocation.method( + #logRatingGiven, + [ + drink, + rating, + ], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future logDrinkShared(_i7.Drink? drink) => (super.noSuchMethod( + Invocation.method( + #logDrinkShared, + [drink], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future logError( + Object? error, + StackTrace? stackTrace, { + String? reason, + }) => + (super.noSuchMethod( + Invocation.method( + #logError, + [ + error, + stackTrace, + ], + {#reason: reason}, + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future setUserProperty( + String? name, + String? value, + ) => + (super.noSuchMethod( + Invocation.method( + #setUserProperty, + [ + name, + value, + ], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future setUserId(String? userId) => (super.noSuchMethod( + Invocation.method( + #setUserId, + [userId], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); +} From 04f617930a4cb9ed4d384de681dc33f521d49460 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 20:10:22 +0000 Subject: [PATCH 2/2] fix(tasting-log): prevent prefix-collision bug in festival ID isolation The getTastedDrinkIds and clearFestivalLog methods used simple string prefix matching (startsWith), which caused keys for festivals with overlapping IDs like 'cbf2025' and 'cbf2025-extra' to be conflated. Replaced underscore separator with pipe character in storage keys to prevent false positives. This fixes a data-isolation bug where logs for one festival could leak into or be cleared from another festival if their IDs shared a prefix. Also add test cases for the shared-prefix edge case to verify isolation. https://claude.ai/code/session_019w4vSjQKXKNBisd3Uzp4Ya --- lib/services/tasting_log_service.dart | 8 ++++---- test/tasting_log_service_test.dart | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/lib/services/tasting_log_service.dart b/lib/services/tasting_log_service.dart index 9c98c130..a5651113 100644 --- a/lib/services/tasting_log_service.dart +++ b/lib/services/tasting_log_service.dart @@ -13,7 +13,7 @@ class TastingLogService { /// Get the storage key for a drink's tasting log String _getKey(String festivalId, String drinkId) { - return '$_tastingLogPrefix${festivalId}_$drinkId'; + return '$_tastingLogPrefix${festivalId}|$drinkId'; } /// Check if a drink has been tasted at a specific festival @@ -54,9 +54,9 @@ class TastingLogService { /// Get all tasted drink IDs for a specific festival List getTastedDrinkIds(String festivalId) { - final prefix = '$_tastingLogPrefix$festivalId'; + final prefix = '$_tastingLogPrefix$festivalId|'; final keys = _prefs.getKeys().where((k) => k.startsWith(prefix)); - return keys.map((k) => k.replaceFirst('${prefix}_', '')).toList(); + return keys.map((k) => k.replaceFirst(prefix, '')).toList(); } /// Get count of tasted drinks for a festival @@ -66,7 +66,7 @@ class TastingLogService { /// Clear all tasting logs for a specific festival Future clearFestivalLog(String festivalId) async { - final prefix = '$_tastingLogPrefix$festivalId'; + final prefix = '$_tastingLogPrefix$festivalId|'; final keys = _prefs.getKeys().where((k) => k.startsWith(prefix)).toList(); for (final key in keys) { await _prefs.remove(key); diff --git a/test/tasting_log_service_test.dart b/test/tasting_log_service_test.dart index cfc90951..ce2217d0 100644 --- a/test/tasting_log_service_test.dart +++ b/test/tasting_log_service_test.dart @@ -117,6 +117,17 @@ void main() { expect(service.getTastedDrinkIds('cbf2024'), equals(['drink-2'])); }); + test('tasting logs isolate festivals with overlapping prefixes', () async { + await service.markAsTasted('cbf2025', 'drink-1'); + await service.markAsTasted('cbf2025-extra', 'drink-2'); + + expect(service.hasTasted('cbf2025', 'drink-1'), isTrue); + expect(service.hasTasted('cbf2025', 'drink-2'), isFalse); + expect(service.hasTasted('cbf2025-extra', 'drink-2'), isTrue); + expect(service.getTastedDrinkIds('cbf2025'), equals(['drink-1'])); + expect(service.getTastedDrinkIds('cbf2025-extra'), equals(['drink-2'])); + }); + group('clearFestivalLog', () { test('removes only the targeted festival\'s logs', () async { await service.markAsTasted('cbf2025', 'drink-1'); @@ -128,6 +139,17 @@ void main() { expect(service.hasTasted('cbf2024', 'drink-2'), isTrue); }); + test('does not clear logs from festivals with overlapping prefixes', + () async { + await service.markAsTasted('cbf2025', 'drink-1'); + await service.markAsTasted('cbf2025-extra', 'drink-2'); + + await service.clearFestivalLog('cbf2025'); + + expect(service.getTastedCount('cbf2025'), 0); + expect(service.hasTasted('cbf2025-extra', 'drink-2'), isTrue); + }); + test('is a no-op when the festival has no logs', () async { await service.clearFestivalLog('cbf2025');