diff --git a/lib/core/i18n/app_strings.dart b/lib/core/i18n/app_strings.dart index a3aa08e..2332445 100644 --- a/lib/core/i18n/app_strings.dart +++ b/lib/core/i18n/app_strings.dart @@ -71,8 +71,23 @@ class AppStrings { String get offlineIdentityMode => _zh ? '使用離線身份模式' : 'Using offline identity mode'; String get reportSafetyConcern => _zh ? '回報風險事件' : 'Report safety concern'; + String get reportRiskSelectReasonTitle => + _zh ? '請選擇回報原因' : 'Select a reason'; + String get riskReasonPrivateLocation => _zh + ? '要求改到私下地點面交' + : 'Asked to move pickup to a private location'; + String get riskReasonSuspiciousBehavior => + _zh ? '現場行為可疑 / 騷擾' : 'Suspicious behavior / harassment'; + String get riskReasonNoShow => + _zh ? '公開取餐點無人交付' : 'No handoff at the public pickup point'; + String get riskReasonUnsafeCondition => + _zh ? '食物狀態疑似不安全' : 'Food condition appears unsafe'; + String get riskReasonOther => _zh ? '其他風險' : 'Other risk'; String get abuseReported => _zh ? '已送出風險回報。' : 'Safety report submitted.'; String get verifiedEnterprise => _zh ? '已驗證企業' : 'Verified enterprise'; + String get highImpactEnterprise => _zh ? '高量捐贈企業' : 'High-impact donor'; + String get flexiblePickupEnterprise => _zh ? '彈性取餐時段' : 'Flexible pickup window'; + String get stableShelfLifeEnterprise => _zh ? '保存時效較穩定' : 'Stable shelf-life setup'; String get pendingConfirm => _zh ? '待確認' : 'Pending'; String get confirmedFilter => _zh ? '已確認' : 'Confirmed'; String get showPickupCodeHelp => _zh diff --git a/lib/features/surplus/presentation/browse/reservation_confirmation_page.dart b/lib/features/surplus/presentation/browse/reservation_confirmation_page.dart index fa7768c..e351c4b 100644 --- a/lib/features/surplus/presentation/browse/reservation_confirmation_page.dart +++ b/lib/features/surplus/presentation/browse/reservation_confirmation_page.dart @@ -20,15 +20,75 @@ class ReservationConfirmationPage extends StatelessWidget { final String listingId; final String reservationId; + Future _pickRiskReason(BuildContext context) async { + final s = AppStrings.of(context); + final reasons = <({String code, String label})>[ + ( + code: 'recipient_report_private_location_request', + label: s.riskReasonPrivateLocation, + ), + ( + code: 'recipient_report_suspicious_behavior', + label: s.riskReasonSuspiciousBehavior, + ), + ( + code: 'recipient_report_public_handoff_no_show', + label: s.riskReasonNoShow, + ), + ( + code: 'recipient_report_food_condition_unsafe', + label: s.riskReasonUnsafeCondition, + ), + (code: 'recipient_report_other', label: s.riskReasonOther), + ]; + + return showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(s.reportRiskSelectReasonTitle), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: reasons + .map( + (reason) => ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text(reason.label), + onTap: () => Navigator.of(context).pop(reason.code), + ), + ) + .toList(), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(s.cancel), + ), + ], + ); + }, + ); + } + Future _reportAbuse(BuildContext context) async { final deps = AppScope.of(context); final s = AppStrings.of(context); + final reason = await _pickRiskReason(context); + if (reason == null || reason.isEmpty || !context.mounted) { + return; + } try { final uid = await deps.identityService.ensureRecipientUid(); await deps.repository.addAbuseSignal( listingId: listingId, claimerUid: uid, - reason: 'recipient_report_private_location_request', + reason: reason, ); if (!context.mounted) { return; @@ -46,6 +106,32 @@ class ReservationConfirmationPage extends StatelessWidget { } } + List<({IconData icon, String label})> _enterpriseBadges({ + required Listing listing, + required AppStrings s, + required bool isFrequentEnterprise, + }) { + final badges = <({IconData icon, String label})>[]; + if (listing.enterpriseVerified) { + badges.add((icon: Icons.verified, label: s.verifiedEnterprise)); + } + if (isFrequentEnterprise) { + badges.add((icon: Icons.eco_outlined, label: s.frequentEnterprise)); + } + if (listing.quantityTotal >= 20) { + badges.add((icon: Icons.workspace_premium_outlined, label: s.highImpactEnterprise)); + } + if (listing.pickupEndAt.difference(listing.pickupStartAt) >= + const Duration(minutes: 120)) { + badges.add((icon: Icons.schedule_outlined, label: s.flexiblePickupEnterprise)); + } + if (listing.expiresAt.difference(listing.pickupStartAt) >= + const Duration(minutes: 120)) { + badges.add((icon: Icons.inventory_2_outlined, label: s.stableShelfLifeEnterprise)); + } + return badges; + } + @override Widget build(BuildContext context) { final dependencies = AppScope.of(context); @@ -221,8 +307,11 @@ class ReservationConfirmationPage extends StatelessWidget { Text( 'Enterprise: ${listing.displayNameOptional}', ), - if (listing.enterpriseVerified || - isFrequentEnterprise) + if (_enterpriseBadges( + listing: listing, + s: s, + isFrequentEnterprise: isFrequentEnterprise, + ).isNotEmpty) Padding( padding: const EdgeInsets.only( top: 6, @@ -231,26 +320,23 @@ class ReservationConfirmationPage extends StatelessWidget { child: Wrap( spacing: 8, runSpacing: 6, - children: [ - if (listing.enterpriseVerified) - Chip( - avatar: const Icon( - Icons.verified, - size: 16, - color: Color(0xFF2D6A4F), - ), - label: Text(s.verifiedEnterprise), - ), - if (isFrequentEnterprise) - Chip( - avatar: const Icon( - Icons.eco_outlined, - size: 16, - color: Color(0xFF2D6A4F), + children: _enterpriseBadges( + listing: listing, + s: s, + isFrequentEnterprise: + isFrequentEnterprise, + ) + .map( + (badge) => Chip( + avatar: Icon( + badge.icon, + size: 16, + color: const Color(0xFF2D6A4F), + ), + label: Text(badge.label), ), - label: Text(s.frequentEnterprise), - ), - ], + ) + .toList(), ), ), Text( diff --git a/test/core/app_strings_test.dart b/test/core/app_strings_test.dart index 83479d9..48a5df9 100644 --- a/test/core/app_strings_test.dart +++ b/test/core/app_strings_test.dart @@ -31,6 +31,11 @@ void main() { expect(strings.privacyFaqTitle, 'Privacy & FAQ'); expect(strings.privacyNotice, contains('Boxmatch')); expect(strings.faqNotice, contains('Report safety concern')); + expect(strings.reportRiskSelectReasonTitle, 'Select a reason'); + expect(strings.riskReasonSuspiciousBehavior, contains('Suspicious')); + expect(strings.highImpactEnterprise, 'High-impact donor'); + expect(strings.flexiblePickupEnterprise, contains('Flexible')); + expect(strings.stableShelfLifeEnterprise, contains('Stable')); }); testWidgets('app strings returns zh-TW labels', (tester) async { @@ -48,6 +53,8 @@ void main() { Text(strings.navMap), Text(strings.frequentEnterprise), Text(strings.privacyFaqTitle), + Text(strings.reportRiskSelectReasonTitle), + Text(strings.highImpactEnterprise), ], ); }, @@ -59,5 +66,7 @@ void main() { expect(find.text('地圖'), findsOneWidget); expect(find.text('常態捐贈企業'), findsOneWidget); expect(find.text('隱私與常見問題'), findsOneWidget); + expect(find.text('請選擇回報原因'), findsOneWidget); + expect(find.text('高量捐贈企業'), findsOneWidget); }); } diff --git a/test/core/language_menu_button_test.dart b/test/core/language_menu_button_test.dart new file mode 100644 index 0000000..7642beb --- /dev/null +++ b/test/core/language_menu_button_test.dart @@ -0,0 +1,33 @@ +import 'package:boxmatch/app/app_scope.dart'; +import 'package:boxmatch/core/i18n/language_menu_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_helpers.dart'; + +void main() { + testWidgets('language menu button switches locale label', (tester) async { + final dependencies = await buildTestDependencies(language: 'en'); + + await tester.pumpWidget( + AppScope( + dependencies: dependencies, + child: MaterialApp( + home: Scaffold( + appBar: AppBar(actions: const [LanguageMenuButton()]), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('EN'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.language_outlined)); + await tester.pumpAndSettle(); + await tester.tap(find.text('繁體中文').last); + await tester.pumpAndSettle(); + + expect(dependencies.localeController.isZhTw, isTrue); + }); +} diff --git a/test/presentation/browse/browse_pages_additional_test.dart b/test/presentation/browse/browse_pages_additional_test.dart index 66ee888..7a51d6b 100644 --- a/test/presentation/browse/browse_pages_additional_test.dart +++ b/test/presentation/browse/browse_pages_additional_test.dart @@ -1,5 +1,6 @@ import 'package:boxmatch/app/app.dart'; import 'package:boxmatch/app/app_scope.dart'; +import 'package:boxmatch/core/identity/recipient_identity_service.dart'; import 'package:boxmatch/features/surplus/data/in_memory_surplus_repository.dart'; import 'package:boxmatch/features/surplus/domain/listing.dart'; import 'package:boxmatch/features/surplus/domain/listing_input.dart'; @@ -19,6 +20,8 @@ class _InstrumentedRepository extends InMemorySurplusRepository { bool activeListingsStreamError = false; bool reservationStreamError = false; bool throwOnReserve = false; + bool throwOnAbuseSignal = false; + String? lastAbuseReason; int reconcileCalls = 0; final Set listingStreamErrorIds = {}; @@ -88,6 +91,31 @@ class _InstrumentedRepository extends InMemorySurplusRepository { disclaimerAccepted: disclaimerAccepted, ); } + + @override + Future addAbuseSignal({ + required String listingId, + required String claimerUid, + required String reason, + }) async { + if (throwOnAbuseSignal) { + throw const ValidationException('Abuse report failed for test.'); + } + lastAbuseReason = reason; + await super.addAbuseSignal( + listingId: listingId, + claimerUid: claimerUid, + reason: reason, + ); + } +} + +class _LocalFallbackIdentityService implements RecipientIdentityService { + @override + bool get isUsingLocalFallback => true; + + @override + Future ensureRecipientUid() async => 'local-fallback-user'; } ListingInput _input( @@ -194,8 +222,12 @@ Future _pumpConfirmation( _InstrumentedRepository repo, { required String listingId, required String reservationId, + RecipientIdentityService? identityService, }) async { - final dependencies = await buildTestDependencies(repository: repo); + final dependencies = await buildTestDependencies( + repository: repo, + identityService: identityService, + ); await tester.pumpWidget( AppScope( dependencies: dependencies, @@ -511,4 +543,49 @@ void main() { expect(find.text('Cancelled'), findsWidgets); }, ); + + testWidgets( + 'reservation confirmation shows local fallback hint and can go back', + (tester) async { + final now = DateTime.now(); + final repo = _InstrumentedRepository() + ..forcedListings['badge-listing'] = _forcedListing( + now, + id: 'badge-listing', + status: ListingStatus.active, + quantityRemaining: 1, + expiresAt: now.add(const Duration(hours: 2)), + displayNameOptional: 'Trusted Enterprise', + enterpriseVerified: true, + ) + ..forcedListings['badge-listing-2'] = _forcedListing( + now, + id: 'badge-listing-2', + status: ListingStatus.active, + quantityRemaining: 1, + expiresAt: now.add(const Duration(hours: 3)), + displayNameOptional: 'Trusted Enterprise', + enterpriseVerified: false, + ) + ..forcedReservations['badge-reservation'] = _forcedReservation( + now, + id: 'badge-reservation', + listingId: 'badge-listing', + status: ReservationStatus.reserved, + ); + + await _pumpConfirmation( + tester, + repo, + listingId: 'badge-listing', + reservationId: 'badge-reservation', + identityService: _LocalFallbackIdentityService(), + ); + + expect(find.text('Using offline identity mode'), findsOneWidget); + expect(find.text('1234'), findsOneWidget); + expect(find.widgetWithText(OutlinedButton, 'Back to listings'), findsOneWidget); + }, + ); + } diff --git a/test/presentation/browse/my_reservations_page_test.dart b/test/presentation/browse/my_reservations_page_test.dart index 3947414..55de513 100644 --- a/test/presentation/browse/my_reservations_page_test.dart +++ b/test/presentation/browse/my_reservations_page_test.dart @@ -97,4 +97,13 @@ void main() { expect(find.text('Reservation cancelled.'), findsOneWidget); }); + + testWidgets('empty state shows browse CTA', (tester) async { + final repo = InMemorySurplusRepository(); + + await _pumpPage(tester, repo: repo); + + expect(find.text('No reservations yet.'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Browse listings'), findsOneWidget); + }); } diff --git a/test/presentation/enterprise/enterprise_page_test.dart b/test/presentation/enterprise/enterprise_page_test.dart index 73addd6..83dd849 100644 --- a/test/presentation/enterprise/enterprise_page_test.dart +++ b/test/presentation/enterprise/enterprise_page_test.dart @@ -2,12 +2,108 @@ import 'package:boxmatch/app/app_scope.dart'; import 'package:boxmatch/features/surplus/data/in_memory_surplus_repository.dart'; import 'package:boxmatch/features/surplus/domain/listing_input.dart'; import 'package:boxmatch/features/surplus/domain/listing_visibility.dart'; +import 'package:boxmatch/features/surplus/domain/reservation.dart'; +import 'package:boxmatch/features/surplus/domain/surplus_exceptions.dart'; +import 'package:boxmatch/features/surplus/domain/venue.dart'; import 'package:boxmatch/features/surplus/presentation/enterprise/enterprise_listing_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../test_helpers.dart'; +class _EnterpriseInstrumentedRepository extends InMemorySurplusRepository { + bool venuesStreamError = false; + bool reservationsStreamError = false; + bool emptyVenues = false; + bool throwOnCreate = false; + bool throwOnUpdate = false; + bool throwOnRotate = false; + bool throwOnRevoke = false; + bool throwOnConfirmPickup = false; + + @override + Stream> watchVenues() { + if (venuesStreamError) { + return Stream.error(StateError('venue stream failed')); + } + if (emptyVenues) { + return Stream>.value(const []); + } + return super.watchVenues(); + } + + @override + Stream> watchReservationsForListing({ + required String listingId, + required String token, + }) { + if (reservationsStreamError) { + return Stream.error(StateError('reservations stream failed')); + } + return super.watchReservationsForListing(listingId: listingId, token: token); + } + + @override + Future createListing(ListingInput input) { + if (throwOnCreate) { + throw const ValidationException('Create failed for test.'); + } + return super.createListing(input); + } + + @override + Future updateListing({ + required String listingId, + required String token, + required ListingInput input, + }) { + if (throwOnUpdate) { + throw const ValidationException('Update failed for test.'); + } + return super.updateListing(listingId: listingId, token: token, input: input); + } + + @override + Future rotateEditToken({ + required String listingId, + required String token, + }) { + if (throwOnRotate) { + throw const ValidationException('Rotate failed for test.'); + } + return super.rotateEditToken(listingId: listingId, token: token); + } + + @override + Future revokeEditToken({ + required String listingId, + required String token, + }) { + if (throwOnRevoke) { + throw const ValidationException('Revoke failed for test.'); + } + return super.revokeEditToken(listingId: listingId, token: token); + } + + @override + Future confirmPickup({ + required String listingId, + required String reservationId, + required String token, + required String pickupCode, + }) { + if (throwOnConfirmPickup) { + throw const ValidationException('Confirm failed for test.'); + } + return super.confirmPickup( + listingId: listingId, + reservationId: reservationId, + token: token, + pickupCode: pickupCode, + ); + } +} + ListingInput _input(DateTime now) { return ListingInput( venueId: 'taipei-nangang-exhibition-center-hall-1', @@ -30,8 +126,12 @@ Future _pumpPage( required InMemorySurplusRepository repo, String? listingId, String? token, + bool usingFirebase = false, }) async { - final deps = await buildTestDependencies(repository: repo); + final deps = await buildTestDependencies( + repository: repo, + usingFirebase: usingFirebase, + ); tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = const Size(1200, 2400); addTearDown(tester.view.resetPhysicalSize); @@ -238,4 +338,341 @@ void main() { expect(find.text('Listing updated.'), findsOneWidget); }); + + testWidgets('rotate token success shows new secure link card', (tester) async { + final repo = InMemorySurplusRepository(); + final created = await repo.createListing(_input(DateTime.now())); + + await _pumpPage( + tester, + repo: repo, + listingId: created.listingId, + token: created.editToken, + ); + + final scrollable = find.byType(Scrollable).first; + final rotateButton = find.widgetWithText(OutlinedButton, 'Rotate token'); + await tester.scrollUntilVisible(rotateButton, 250, scrollable: scrollable); + await tester.tap(rotateButton); + await tester.pumpAndSettle(); + + expect(find.text('Rotate edit token?'), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, 'Rotate')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Save this edit link securely'), findsOneWidget); + expect(find.widgetWithText(OutlinedButton, 'Copy link'), findsOneWidget); + }); + + testWidgets('rotate token dialog can be cancelled', (tester) async { + final repo = InMemorySurplusRepository(); + final created = await repo.createListing(_input(DateTime.now())); + + await _pumpPage( + tester, + repo: repo, + listingId: created.listingId, + token: created.editToken, + ); + + final scrollable = find.byType(Scrollable).first; + final rotateButton = find.widgetWithText(OutlinedButton, 'Rotate token'); + await tester.scrollUntilVisible(rotateButton, 250, scrollable: scrollable); + await tester.tap(rotateButton); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'Cancel')); + await tester.pumpAndSettle(); + + expect(find.text('Rotate edit token?'), findsNothing); + expect(find.textContaining('Save this edit link securely'), findsNothing); + }); + + testWidgets('confirm pickup success updates reservation state', (tester) async { + final repo = InMemorySurplusRepository(); + final created = await repo.createListing(_input(DateTime.now())); + final reservation = await repo.reserveListing( + listingId: created.listingId, + claimerUid: 'recipient-y', + qty: 1, + disclaimerAccepted: true, + ); + + await _pumpPage( + tester, + repo: repo, + listingId: created.listingId, + token: created.editToken, + ); + + final scrollable = find.byType(Scrollable).first; + final codeField = find.widgetWithText(TextField, 'Enter 4-digit pickup code'); + await tester.scrollUntilVisible(codeField, 250, scrollable: scrollable); + await tester.enterText(codeField.first, reservation.pickupCode); + + final confirmButton = find.widgetWithText(FilledButton, 'Confirm pickup'); + await tester.tap(confirmButton.first); + await tester.pumpAndSettle(); + + expect(find.text('Pickup confirmed.'), findsOneWidget); + expect(find.textContaining('Status: Completed'), findsWidgets); + }); + + testWidgets('reservation admin filter switches pending and confirmed', ( + tester, + ) async { + final repo = InMemorySurplusRepository(); + final created = await repo.createListing(_input(DateTime.now())); + final first = await repo.reserveListing( + listingId: created.listingId, + claimerUid: 'recipient-a', + qty: 1, + disclaimerAccepted: true, + ); + await repo.reserveListing( + listingId: created.listingId, + claimerUid: 'recipient-b', + qty: 1, + disclaimerAccepted: true, + ); + await repo.confirmPickup( + listingId: created.listingId, + reservationId: first.id, + token: created.editToken, + pickupCode: first.pickupCode, + ); + + await _pumpPage( + tester, + repo: repo, + listingId: created.listingId, + token: created.editToken, + ); + + final scrollable = find.byType(Scrollable).first; + final pendingChip = find.widgetWithText(ChoiceChip, 'Pending'); + await tester.scrollUntilVisible(pendingChip, 250, scrollable: scrollable); + await tester.tap(pendingChip); + await tester.pumpAndSettle(); + expect(find.textContaining('Status: Reserved'), findsWidgets); + expect(find.textContaining('Status: Completed'), findsNothing); + + final confirmedChip = find.widgetWithText(ChoiceChip, 'Confirmed'); + await tester.tap(confirmedChip); + await tester.pumpAndSettle(); + expect(find.textContaining('Status: Completed'), findsWidgets); + expect(find.textContaining('Status: Reserved'), findsNothing); + }); + + testWidgets('enterprise page shows load error when venues stream fails', ( + tester, + ) async { + final repo = _EnterpriseInstrumentedRepository()..venuesStreamError = true; + await _pumpPage(tester, repo: repo); + + expect(find.text('Unable to load'), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, 'Retry')); + await tester.pumpAndSettle(); + }); + + testWidgets('reservation admin shows no reservations yet', (tester) async { + final repo = _EnterpriseInstrumentedRepository(); + final created = await repo.createListing(_input(DateTime.now())); + await _pumpPage( + tester, + repo: repo, + listingId: created.listingId, + token: created.editToken, + ); + + expect(find.text('No reservations yet.'), findsOneWidget); + }); + + testWidgets('reservation admin shows stream error text', (tester) async { + final repo = _EnterpriseInstrumentedRepository() + ..reservationsStreamError = true; + final created = await repo.createListing(_input(DateTime.now())); + await _pumpPage( + tester, + repo: repo, + listingId: created.listingId, + token: created.editToken, + ); + + expect(find.textContaining('Unable to load reservations'), findsOneWidget); + }); + + testWidgets('template performance fallback path works in firebase mode', ( + tester, + ) async { + final repo = InMemorySurplusRepository(); + await _pumpPage(tester, repo: repo, usingFirebase: true); + + expect(find.textContaining('Template performance'), findsOneWidget); + expect( + find.textContaining('Not enough sample yet'), + findsOneWidget, + ); + + await tester.tap(find.byIcon(Icons.refresh).first); + await tester.pumpAndSettle(); + expect(find.textContaining('Template performance'), findsOneWidget); + }); + + testWidgets('create mode shows venue-required snackbar when no venue exists', ( + tester, + ) async { + final repo = _EnterpriseInstrumentedRepository()..emptyVenues = true; + await _pumpPage(tester, repo: repo); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Pickup point (booth / gate)'), + 'Service desk', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Simple description'), + 'No venue test', + ); + await tester.tap(find.byType(CheckboxListTile)); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Post listing')); + await tester.pumpAndSettle(); + expect(find.text('Please select a venue.'), findsOneWidget); + }); + + testWidgets('create mode surfaces create error', (tester) async { + final repo = _EnterpriseInstrumentedRepository()..throwOnCreate = true; + await _pumpPage(tester, repo: repo); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Pickup point (booth / gate)'), + 'Hall 1 Gate A', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Simple description'), + 'Create error', + ); + await tester.tap(find.byType(CheckboxListTile).first); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Post listing')); + await tester.pumpAndSettle(); + expect(find.text('Create failed for test.'), findsOneWidget); + }); + + testWidgets('edit mode surfaces update error', (tester) async { + final repo = _EnterpriseInstrumentedRepository()..throwOnUpdate = true; + final created = await repo.createListing(_input(DateTime.now())); + await _pumpPage( + tester, + repo: repo, + listingId: created.listingId, + token: created.editToken, + ); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Simple description'), + 'Update error', + ); + + final scrollable = find.byType(Scrollable).first; + final updateButton = find.widgetWithText(FilledButton, 'Update listing'); + await tester.scrollUntilVisible(updateButton, 250, scrollable: scrollable); + await tester.tap(updateButton); + await tester.pumpAndSettle(); + expect(find.text('Update failed for test.'), findsOneWidget); + }); + + testWidgets('rotate token surfaces error', (tester) async { + final repo = _EnterpriseInstrumentedRepository() + ..throwOnRotate = true + ..throwOnRevoke = true; + final created = await repo.createListing(_input(DateTime.now())); + + await _pumpPage( + tester, + repo: repo, + listingId: created.listingId, + token: created.editToken, + ); + + final scrollable = find.byType(Scrollable).first; + final rotateButton = find.widgetWithText(OutlinedButton, 'Rotate token'); + await tester.scrollUntilVisible(rotateButton, 300, scrollable: scrollable); + await tester.tap(rotateButton); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Rotate')); + await tester.pumpAndSettle(); + expect(find.text('Rotate failed for test.'), findsOneWidget); + }); + + testWidgets('confirm pickup surfaces backend error', (tester) async { + final repo = _EnterpriseInstrumentedRepository()..throwOnConfirmPickup = true; + final created = await repo.createListing(_input(DateTime.now())); + final reservation = await repo.reserveListing( + listingId: created.listingId, + claimerUid: 'recipient-z', + qty: 1, + disclaimerAccepted: true, + ); + + await _pumpPage( + tester, + repo: repo, + listingId: created.listingId, + token: created.editToken, + ); + + final scrollable = find.byType(Scrollable).first; + final codeField = find.widgetWithText(TextField, 'Enter 4-digit pickup code'); + await tester.scrollUntilVisible(codeField, 250, scrollable: scrollable); + await tester.enterText(codeField.first, reservation.pickupCode); + await tester.tap(find.widgetWithText(FilledButton, 'Confirm pickup').first); + await tester.pumpAndSettle(); + expect(find.text('Confirm failed for test.'), findsOneWidget); + }); + + testWidgets('revoke token surfaces error', (tester) async { + final repo = _EnterpriseInstrumentedRepository()..throwOnRevoke = true; + final created = await repo.createListing(_input(DateTime.now())); + + await _pumpPage( + tester, + repo: repo, + listingId: created.listingId, + token: created.editToken, + ); + + final scrollable = find.byType(Scrollable).first; + final revokeButton = find.widgetWithText(OutlinedButton, 'Revoke token'); + await tester.scrollUntilVisible(revokeButton, 300, scrollable: scrollable); + await tester.tap(revokeButton); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Revoke')); + await tester.pumpAndSettle(); + expect(find.text('Revoke failed for test.'), findsOneWidget); + }); + + testWidgets('can pick date and time for all 3 time fields', (tester) async { + final repo = InMemorySurplusRepository(); + await _pumpPage(tester, repo: repo); + + Future pickWithIcon(IconData icon) async { + await tester.tap(find.byIcon(icon).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK').last); + await tester.pumpAndSettle(); + await tester.tap(find.text('OK').last); + await tester.pumpAndSettle(); + } + + await pickWithIcon(Icons.schedule); + await pickWithIcon(Icons.schedule_send_outlined); + await pickWithIcon(Icons.hourglass_bottom_outlined); + + expect(find.text('Pickup start'), findsOneWidget); + expect(find.text('Pickup end'), findsOneWidget); + expect(find.text('Expires at'), findsOneWidget); + }); } diff --git a/test/test_helpers.dart b/test/test_helpers.dart index fd43791..804f0ae 100644 --- a/test/test_helpers.dart +++ b/test/test_helpers.dart @@ -15,7 +15,9 @@ class FakeIdentityService implements RecipientIdentityService { Future buildTestDependencies({ InMemorySurplusRepository? repository, + RecipientIdentityService? identityService, String language = 'en', + bool usingFirebase = false, }) async { SharedPreferences.setMockInitialValues({'boxmatch.language': language}); final prefs = await SharedPreferences.getInstance(); @@ -26,8 +28,8 @@ Future buildTestDependencies({ return AppDependencies( repository: repo, - identityService: FakeIdentityService(), - usingFirebase: false, + identityService: identityService ?? FakeIdentityService(), + usingFirebase: usingFirebase, localeController: localeController, favoritesStore: favoritesStore, );