486 lines
17 KiB
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();
|
|
}
|
|
}
|
|
}
|
|
}
|