import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:motorcycle_dash/core/theme/theme_extensions.dart'; import 'package:motorcycle_dash/features/group_rides/create_group_ride_screen.dart'; import 'package:motorcycle_dash/services/auth/auth_providers.dart'; import 'package:motorcycle_dash/services/auth/auth_user.dart'; import 'package:motorcycle_dash/services/clubs/club_models.dart'; import 'package:motorcycle_dash/services/clubs/club_providers.dart'; import 'package:motorcycle_dash/services/clubs/club_service.dart'; import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart'; import 'package:motorcycle_dash/services/social/rider_profile_models.dart'; import 'package:motorcycle_dash/services/social/social_providers.dart'; void main() { group('ClubRideDiscovery Model Tests', () { test('parses full JSON payload correctly', () { final json = { 'rideId': 'ride_123', 'clubId': 'club_alpha', 'title': 'Mountain Pass Rally', 'description': 'A weekend club ride through the gap', 'organizerDisplayName': 'Road Captain Bob', 'organizerHandle': '@bob_captain', 'scheduledStartAt': '2026-09-25T09:00:00.000Z', 'departureAt': '2026-09-25T09:30:00.000Z', 'joinPolicy': 'requestToJoin', 'participantCount': 5, 'participantLimit': 15, 'status': 'scheduled', 'meetingLocationLabel': 'North Ridge Meetup Area', 'routeTitle': 'Scenic Mountain Loop', 'routeDistanceMeters': 45000, 'paceClassification': 'Spirited', }; final ride = ClubRideDiscovery.fromJson(json); expect(ride.rideId, equals('ride_123')); expect(ride.clubId, equals('club_alpha')); expect(ride.title, equals('Mountain Pass Rally')); expect(ride.description, equals('A weekend club ride through the gap')); expect(ride.organizerDisplayName, equals('Road Captain Bob')); expect(ride.organizerHandle, equals('@bob_captain')); expect(ride.scheduledStartAt.toUtc(), equals(DateTime.utc(2026, 9, 25, 9, 0))); expect(ride.departureAt?.toUtc(), equals(DateTime.utc(2026, 9, 25, 9, 30))); expect(ride.joinPolicy, equals('requestToJoin')); expect(ride.participantCount, equals(5)); expect(ride.participantLimit, equals(15)); expect(ride.status, equals('scheduled')); expect(ride.meetingLocationLabel, equals('North Ridge Meetup Area')); expect(ride.routeTitle, equals('Scenic Mountain Loop')); expect(ride.routeDistanceMeters, equals(45000)); expect(ride.paceClassification, equals('Spirited')); }); test('parses minimal JSON payload with defaults and nulls', () { final json = { 'rideId': 'ride_456', 'clubId': 'club_beta', 'title': 'Casual Cruise', 'scheduledStartAt': '2026-09-26T10:00:00.000Z', }; final ride = ClubRideDiscovery.fromJson(json); expect(ride.rideId, equals('ride_456')); expect(ride.clubId, equals('club_beta')); expect(ride.title, equals('Casual Cruise')); expect(ride.description, isNull); expect(ride.organizerDisplayName, isNull); expect(ride.organizerHandle, isNull); expect(ride.departureAt, isNull); expect(ride.joinPolicy, equals('inviteOnly')); expect(ride.participantCount, equals(1)); expect(ride.participantLimit, isNull); expect(ride.status, equals('scheduled')); expect(ride.meetingLocationLabel, isNull); }); test('serializes to JSON correctly', () { final ride = ClubRideDiscovery( rideId: 'ride_789', clubId: 'club_gamma', title: 'Morning Blast', scheduledStartAt: DateTime.utc(2026, 9, 27, 8, 0), joinPolicy: 'open', participantCount: 2, meetingLocationLabel: 'Main Street Plaza', ); final json = ride.toJson(); expect(json['rideId'], equals('ride_789')); expect(json['clubId'], equals('club_gamma')); expect(json['title'], equals('Morning Blast')); expect(json['joinPolicy'], equals('open')); expect(json['participantCount'], equals(2)); expect(json['meetingLocationLabel'], equals('Main Street Plaza')); expect(json.containsKey('description'), isFalse); expect(json.containsKey('accessUids'), isFalse); }); test('equality and hashCode support', () { final rideA = ClubRideDiscovery( rideId: 'ride_1', clubId: 'club_1', title: 'Test Ride', scheduledStartAt: DateTime.utc(2026, 9, 25, 9, 0), ); final rideB = ClubRideDiscovery( rideId: 'ride_1', clubId: 'club_1', title: 'Test Ride', scheduledStartAt: DateTime.utc(2026, 9, 25, 9, 0), ); expect(rideA, equals(rideB)); expect(rideA.hashCode, equals(rideB.hashCode)); }); }); group('ClubService.fetchClubDiscoveryRides HTTP & Error Contracts', () { test('rejects empty clubId before network call', () async { final service = ClubService(); expect( () => service.fetchClubDiscoveryRides(' '), throwsA(isA().having( (e) => e.code, 'code', equals('invalid_argument'), )), ); }); test('parses successful 200 discovery response', () async { final mockClient = MockClient((request) async { expect(request.url.path, equals('/api/clubs/club_alpha/rides')); expect(request.headers['Authorization'], equals('Bearer mock_id_token')); return http.Response( jsonEncode({ 'clubId': 'club_alpha', 'rides': [ { 'rideId': 'ride_1', 'clubId': 'club_alpha', 'title': 'Foothills Run', 'scheduledStartAt': '2026-09-25T09:00:00.000Z', 'status': 'scheduled', 'joinPolicy': 'requestToJoin', 'participantCount': 4, }, { 'rideId': 'ride_2', 'clubId': 'club_alpha', 'title': 'Valley Loop', 'scheduledStartAt': '2026-09-26T10:00:00.000Z', 'status': 'assembling', 'joinPolicy': 'open', 'participantCount': 7, }, ], }), 200, headers: {'content-type': 'application/json'}, ); }); final service = _TestableClubService(); final rides = await service.fetchClubDiscoveryRides( 'club_alpha', client: mockClient, baseUrl: 'https://test.motorsignal.app', ); expect(rides.length, equals(2)); expect(rides[0].rideId, equals('ride_1')); expect(rides[0].title, equals('Foothills Run')); expect(rides[1].rideId, equals('ride_2')); expect(rides[1].title, equals('Valley Loop')); }); test('handles 200 response with empty rides list', () async { final mockClient = MockClient((request) async { return http.Response( jsonEncode({'clubId': 'club_empty', 'rides': []}), 200, headers: {'content-type': 'application/json'}, ); }); final service = _TestableClubService(); final rides = await service.fetchClubDiscoveryRides( 'club_empty', client: mockClient, baseUrl: 'https://test.motorsignal.app', ); expect(rides, isEmpty); }); test('maps 401 status to unauthenticated ClubOperationException', () async { final mockClient = MockClient((request) async { return http.Response( jsonEncode({'error': 'unauthorized', 'detail': 'Expired token'}), 401, headers: {'content-type': 'application/json'}, ); }); final service = _TestableClubService(); expect( () => service.fetchClubDiscoveryRides('club_alpha', client: mockClient), throwsA(isA().having( (e) => e.code, 'code', equals('unauthenticated'), )), ); }); test('maps 403 status to permission_denied ClubOperationException', () async { final mockClient = MockClient((request) async { return http.Response( jsonEncode({'error': 'forbidden', 'detail': 'Active membership required'}), 403, headers: {'content-type': 'application/json'}, ); }); final service = _TestableClubService(); expect( () => service.fetchClubDiscoveryRides('club_alpha', client: mockClient), throwsA(isA().having( (e) => e.code, 'code', equals('permission_denied'), )), ); }); test('maps 404 status to not_found ClubOperationException', () async { final mockClient = MockClient((request) async { return http.Response( jsonEncode({'error': 'not_found', 'detail': 'Club not found'}), 404, headers: {'content-type': 'application/json'}, ); }); final service = _TestableClubService(); expect( () => service.fetchClubDiscoveryRides('club_missing', client: mockClient), throwsA(isA().having( (e) => e.code, 'code', equals('not_found'), )), ); }); test('maps network errors cleanly without exposing raw socket exceptions', () async { final mockClient = MockClient((request) async { throw http.ClientException('Failed to connect to host'); }); final service = _TestableClubService(); expect( () => service.fetchClubDiscoveryRides('club_alpha', client: mockClient), throwsA(isA().having( (e) => e.code, 'code', equals('network_error'), )), ); }); test('blocked rides excluded by server never appear in client output', () async { // Server returned only ride_unblocked; blocked organizer rides were excluded server-side final mockClient = MockClient((request) async { return http.Response( jsonEncode({ 'clubId': 'club_alpha', 'rides': [ { 'rideId': 'ride_unblocked', 'clubId': 'club_alpha', 'title': 'Unblocked Ride', 'scheduledStartAt': '2026-09-25T09:00:00.000Z', }, ], }), 200, headers: {'content-type': 'application/json'}, ); }); final service = _TestableClubService(); final rides = await service.fetchClubDiscoveryRides('club_alpha', client: mockClient); expect(rides.length, equals(1)); expect(rides.any((r) => r.rideId == 'ride_blocked_by_user'), isFalse); expect(rides.any((r) => r.rideId == 'ride_blocked_by_organizer'), isFalse); }); }); group('CreateGroupRideScreen Default Join Policy Contracts', () { testWidgets('Club-affiliated ride defaults to requestToJoin', (tester) async { await tester.binding.setSurfaceSize(const Size(800, 2400)); addTearDown(() => tester.binding.setSurfaceSize(null)); const testUser = AuthUser(uid: 'rider_1', isAnonymous: false); final profile = RiderProfile( uid: 'rider_1', handle: 'rider1', displayName: 'Rider One', createdAt: DateTime(2026, 1, 1), updatedAt: DateTime(2026, 1, 1), ); final testClub = Club( id: 'club_alpha', name: 'Alpha Club', slug: 'alpha-club', ownerUid: 'rider_1', memberCount: 1, visibility: ClubVisibility.public, admissionPolicy: ClubAdmissionPolicy.open, createdAt: DateTime(2026, 1, 1), updatedAt: DateTime(2026, 1, 1), ); final adminMember = ClubMember( clubId: 'club_alpha', uid: 'rider_1', role: ClubRole.owner, status: ClubMemberStatus.active, joinedAt: DateTime(2026, 1, 1), updatedAt: DateTime(2026, 1, 1), ); await tester.pumpWidget( ProviderScope( overrides: [ currentUserProvider.overrideWithValue(testUser), myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)), clubDetailProvider('club_alpha').overrideWith((ref) => Stream.value(testClub)), currentMemberProvider('club_alpha').overrideWith((ref) => Stream.value(adminMember)), ], child: MaterialApp( theme: ThemeData.dark().copyWith( extensions: [MotorSignalThemeColors.dark], ), home: const CreateGroupRideScreen(clubId: 'club_alpha'), ), ), ); await tester.pumpAndSettle(); final dropdownFinder = find.byType(DropdownButtonFormField); expect(dropdownFinder, findsOneWidget); final dropdown = tester.widget>(dropdownFinder); expect(dropdown.initialValue, equals(GroupRideJoinPolicy.requestToJoin)); }); testWidgets('Personal ride defaults to inviteOnly (existing behavior preserved)', (tester) async { await tester.binding.setSurfaceSize(const Size(800, 2400)); addTearDown(() => tester.binding.setSurfaceSize(null)); const testUser = AuthUser(uid: 'rider_1', isAnonymous: false); final profile = RiderProfile( uid: 'rider_1', handle: 'rider1', displayName: 'Rider One', createdAt: DateTime(2026, 1, 1), updatedAt: DateTime(2026, 1, 1), ); await tester.pumpWidget( ProviderScope( overrides: [ currentUserProvider.overrideWithValue(testUser), myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)), ], child: MaterialApp( theme: ThemeData.dark().copyWith( extensions: [MotorSignalThemeColors.dark], ), home: const CreateGroupRideScreen(clubId: null), ), ), ); await tester.pumpAndSettle(); final dropdownFinder = find.byType(DropdownButtonFormField); expect(dropdownFinder, findsOneWidget); final dropdown = tester.widget>(dropdownFinder); expect(dropdown.initialValue, equals(GroupRideJoinPolicy.inviteOnly)); }); }); } /// Helper subclass providing testable auth token resolution for unit tests class _TestableClubService extends ClubService { @override Future> fetchClubDiscoveryRides( String clubId, { http.Client? client, String? baseUrl, }) async { final cleanClubId = clubId.trim(); if (cleanClubId.isEmpty) { throw const ClubOperationException('Club ID cannot be empty.', code: 'invalid_argument'); } final rootUrl = (baseUrl ?? ClubService.defaultApiBaseUrl).replaceAll(RegExp(r'/+$'), ''); final uri = Uri.parse('$rootUrl/api/clubs/${Uri.encodeComponent(cleanClubId)}/rides'); final httpClient = client ?? http.Client(); final shouldCloseClient = client == null; try { final response = await httpClient.get( uri, headers: { 'Authorization': 'Bearer mock_id_token', 'Accept': 'application/json', }, ).timeout(const Duration(seconds: 15)); if (response.statusCode == 200) { final decoded = jsonDecode(response.body); if (decoded is! Map) { throw const ClubOperationException('Malformed response from server.', code: 'invalid_response'); } final ridesList = decoded['rides']; if (ridesList is! List) { return const []; } return ridesList .whereType>() .map((item) => ClubRideDiscovery.fromJson(item)) .toList(); } if (response.statusCode == 401) { throw const ClubOperationException( 'Your session has expired or is invalid. Please sign in again.', code: 'unauthenticated', ); } if (response.statusCode == 403) { throw const ClubOperationException( 'Active club membership is required to view club rides.', code: 'permission_denied', ); } if (response.statusCode == 404) { throw const ClubOperationException( 'Club was not found or is no longer active.', code: 'not_found', ); } throw ClubOperationException( 'Unable to load club rides (${response.statusCode}).', code: 'server_error', ); } on http.ClientException { throw const ClubOperationException( 'Network connection error. Please check your connection.', code: 'network_error', ); } finally { if (shouldCloseClient) { httpClient.close(); } } } }