MotorSignal-App/test/club_ride_discovery_test.dart

486 lines
17 KiB
Dart

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<ClubOperationException>().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<ClubOperationException>().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<ClubOperationException>().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<ClubOperationException>().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<ClubOperationException>().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<GroupRideJoinPolicy>);
expect(dropdownFinder, findsOneWidget);
final dropdown = tester.widget<DropdownButtonFormField<GroupRideJoinPolicy>>(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<GroupRideJoinPolicy>);
expect(dropdownFinder, findsOneWidget);
final dropdown = tester.widget<DropdownButtonFormField<GroupRideJoinPolicy>>(dropdownFinder);
expect(dropdown.initialValue, equals(GroupRideJoinPolicy.inviteOnly));
});
});
}
/// Helper subclass providing testable auth token resolution for unit tests
class _TestableClubService extends ClubService {
@override
Future<List<ClubRideDiscovery>> 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<String, dynamic>) {
throw const ClubOperationException('Malformed response from server.', code: 'invalid_response');
}
final ridesList = decoded['rides'];
if (ridesList is! List) {
return const [];
}
return ridesList
.whereType<Map<String, dynamic>>()
.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();
}
}
}
}