MotorSignal-App/test/club_detail_screen_test.dart

603 lines
21 KiB
Dart

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:motorcycle_dash/core/theme/theme_extensions.dart';
import 'package:motorcycle_dash/features/social/clubs/club_detail_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';
class MockClubServiceForDetail extends ClubService {
String? lastAction;
String? lastClubId;
bool? lastAccept;
@override
Future<void> joinOpenClub(String clubId, {String? callerUid}) async {
lastAction = 'joinOpen';
lastClubId = clubId;
}
@override
Future<void> requestToJoinClub(String clubId, {String? callerUid}) async {
lastAction = 'requestToJoin';
lastClubId = clubId;
}
@override
Future<void> withdrawJoinRequest(String clubId, {String? callerUid}) async {
lastAction = 'withdrawRequest';
lastClubId = clubId;
}
@override
Future<void> respondToInvitation(String clubId, {required bool accept, String? callerUid}) async {
lastAction = 'respondInvite';
lastClubId = clubId;
lastAccept = accept;
}
@override
Future<void> leaveClub(String clubId, {String? callerUid}) async {
lastAction = 'leaveClub';
lastClubId = clubId;
}
@override
Future<List<ClubRideDiscovery>> fetchClubDiscoveryRides(
String clubId, {
http.Client? client,
String? baseUrl,
}) async {
return [];
}
}
void main() {
group('ClubDetailScreen Widget Lifecycle Tests', () {
late MockClubServiceForDetail mockService;
const testUser = AuthUser(uid: 'rider_1', isAnonymous: false);
final openClub = Club(
id: 'club_open',
name: 'Open Track Riders',
slug: 'open-track',
description: 'Track day motorcycle community',
ownerUid: 'owner_bob',
memberCount: 12,
visibility: ClubVisibility.public,
admissionPolicy: ClubAdmissionPolicy.open,
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
final requestClub = Club(
id: 'club_request',
name: 'Sierra Twisties',
slug: 'sierra-twisties',
description: 'Mountain pass riding group',
ownerUid: 'owner_charlie',
memberCount: 5,
visibility: ClubVisibility.public,
admissionPolicy: ClubAdmissionPolicy.request,
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
final inviteClub = Club(
id: 'club_invite',
name: 'Elite Racers',
slug: 'elite-racers',
description: 'Strictly invite only',
ownerUid: 'owner_dan',
memberCount: 3,
visibility: ClubVisibility.public,
admissionPolicy: ClubAdmissionPolicy.inviteOnly,
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
setUp(() {
mockService = MockClubServiceForDetail();
});
testWidgets('renders Open Admission state and executes Join Club', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
currentMemberProvider('club_open').overrideWith((ref) => Stream.value(null)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_open'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('OPEN ADMISSION'), findsOneWidget);
expect(find.text('Open Membership'), findsOneWidget);
final joinButton = find.widgetWithText(FilledButton, 'Join Club');
expect(joinButton, findsOneWidget);
await tester.tap(joinButton);
await tester.pumpAndSettle();
expect(mockService.lastAction, 'joinOpen');
expect(mockService.lastClubId, 'club_open');
});
testWidgets('renders Request Admission state and executes Request to Join', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_request').overrideWith((ref) => Stream.value(requestClub)),
currentMemberProvider('club_request').overrideWith((ref) => Stream.value(null)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_request'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('REQUEST REQUIRED'), findsOneWidget);
expect(find.text('Request Admission'), findsOneWidget);
final requestButton = find.widgetWithText(FilledButton, 'Request to Join');
expect(requestButton, findsOneWidget);
await tester.tap(requestButton);
await tester.pumpAndSettle();
expect(mockService.lastAction, 'requestToJoin');
expect(mockService.lastClubId, 'club_request');
});
testWidgets('renders Invite-Only state with no join action', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_invite').overrideWith((ref) => Stream.value(inviteClub)),
currentMemberProvider('club_invite').overrideWith((ref) => Stream.value(null)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_invite'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Invite-Only Club'), findsOneWidget);
expect(find.text('Join Club'), findsNothing);
expect(find.text('Request to Join'), findsNothing);
});
testWidgets('renders Invited state with Accept and Decline actions', (tester) async {
final inviteMember = ClubMember(
uid: 'rider_1',
clubId: 'club_request',
status: ClubMemberStatus.invited,
role: ClubRole.member,
updatedAt: DateTime(2026, 1, 1),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_request').overrideWith((ref) => Stream.value(requestClub)),
currentMemberProvider('club_request').overrideWith((ref) => Stream.value(inviteMember)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_request'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('INVITED'), findsOneWidget);
expect(find.text('You Are Invited'), findsOneWidget);
expect(find.text('Accept'), findsOneWidget);
expect(find.text('Decline'), findsOneWidget);
await tester.tap(find.text('Accept'));
await tester.pumpAndSettle();
expect(mockService.lastAction, 'respondInvite');
expect(mockService.lastAccept, isTrue);
});
testWidgets('renders Requested state with Withdraw Request action', (tester) async {
final requestMember = ClubMember(
uid: 'rider_1',
clubId: 'club_request',
status: ClubMemberStatus.requested,
role: ClubRole.member,
requestedAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_request').overrideWith((ref) => Stream.value(requestClub)),
currentMemberProvider('club_request').overrideWith((ref) => Stream.value(requestMember)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_request'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('REQUEST PENDING'), findsOneWidget);
expect(find.text('Membership Requested'), findsOneWidget);
expect(find.text('Withdraw Request'), findsOneWidget);
await tester.tap(find.text('Withdraw Request'));
await tester.pumpAndSettle();
expect(mockService.lastAction, 'withdrawRequest');
});
testWidgets('renders Active Member state with Leave Club and confirmation dialog', (tester) async {
final activeMember = ClubMember(
uid: 'rider_1',
clubId: 'club_open',
status: ClubMemberStatus.active,
role: ClubRole.member,
joinedAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
currentMemberProvider('club_open').overrideWith((ref) => Stream.value(activeMember)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_open'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('MEMBER'), findsOneWidget);
expect(find.text('Active Member'), findsOneWidget);
expect(find.text('Leave Club'), findsOneWidget);
// Tap Leave Club to open confirmation dialog
await tester.tap(find.text('Leave Club'));
await tester.pumpAndSettle();
expect(find.text('Leave Open Track Riders?'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
// Confirm leaving inside the dialog
final confirmButtonFinder = find.widgetWithText(FilledButton, 'Leave Club');
expect(confirmButtonFinder, findsOneWidget);
await tester.tap(confirmButtonFinder);
await tester.pumpAndSettle();
expect(mockService.lastAction, 'leaveClub');
expect(mockService.lastClubId, 'club_open');
});
testWidgets('renders Owner state with ownership transfer constraint note and no leave button', (tester) async {
const ownerUser = AuthUser(uid: 'owner_bob', isAnonymous: false);
final ownerMember = ClubMember(
uid: 'owner_bob',
clubId: 'club_open',
status: ClubMemberStatus.active,
role: ClubRole.owner,
joinedAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => ownerUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
currentMemberProvider('club_open').overrideWith((ref) => Stream.value(ownerMember)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_open'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Club Owner'), findsOneWidget);
expect(find.textContaining('Ownership must be transferred before leaving'), findsOneWidget);
expect(find.text('Leave Club'), findsNothing);
});
testWidgets('renders loading state and prevents action button clicks while membership is loading', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
currentMemberProvider('club_open').overrideWith((ref) => const Stream.empty()),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_open'),
),
),
);
await tester.pump();
expect(find.text('Checking Membership Status...'), findsOneWidget);
expect(find.text('Join Club'), findsNothing);
expect(find.text('Request to Join'), findsNothing);
expect(find.text('Leave Club'), findsNothing);
});
testWidgets('renders retryable error state when membership stream errors', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
currentMemberProvider('club_open').overrideWith((ref) => Stream.error(Exception('Network error'))),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_open'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Membership Status Unavailable'), findsOneWidget);
expect(find.text('Retry'), findsOneWidget);
expect(find.text('Join Club'), findsNothing);
expect(find.text('Leave Club'), findsNothing);
});
testWidgets('renders clean unavailable screen with back navigation on club stream error', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('private_club').overrideWith((ref) => Stream.error(Exception('permission-denied'))),
currentMemberProvider('private_club').overrideWith((ref) => const Stream.empty()),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'private_club'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Club Unavailable'), findsOneWidget);
expect(find.text('Unable to display this club at this time.'), findsOneWidget);
expect(find.text('Back to Clubs'), findsOneWidget);
});
testWidgets('renders without overflow on portrait 412x915', (tester) async {
tester.view.physicalSize = const Size(412, 915);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
currentMemberProvider('club_open').overrideWith((ref) => Stream.value(null)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_open'),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
// Scroll to bottom
await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -400));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('renders without overflow on landscape 915x412 including dialog', (tester) async {
tester.view.physicalSize = const Size(915, 412);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
final activeMember = ClubMember(
uid: 'rider_1',
clubId: 'club_open',
status: ClubMemberStatus.active,
role: ClubRole.member,
joinedAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
currentMemberProvider('club_open').overrideWith((ref) => Stream.value(activeMember)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_open'),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
// Open leave confirmation dialog
await tester.ensureVisible(find.text('Leave Club'));
await tester.pumpAndSettle();
await tester.tap(find.text('Leave Club'));
await tester.pumpAndSettle();
expect(find.text('Leave Open Track Riders?'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('non-member sees member-only informational card for club rides', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
currentMemberProvider('club_open').overrideWith((ref) => Stream.value(null)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_open'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('CLUB RIDES'), findsOneWidget);
expect(find.text('Club rides are visible to active members only.'), findsOneWidget);
expect(find.text('Plan Club Ride'), findsNothing);
});
testWidgets('active member sees discovery list and admin sees Plan Club Ride button', (tester) async {
final activeAdmin = ClubMember(
uid: 'rider_1',
clubId: 'club_open',
status: ClubMemberStatus.active,
role: ClubRole.admin,
joinedAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
currentMemberProvider('club_open').overrideWith((ref) => Stream.value(activeAdmin)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_open'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('CLUB RIDES'), findsOneWidget);
expect(find.text('No upcoming club rides scheduled.'), findsOneWidget);
expect(find.text('Plan Club Ride'), findsOneWidget);
});
testWidgets('active ordinary member sees discovery list and no Plan button', (tester) async {
final activeMember = ClubMember(
uid: 'rider_2',
clubId: 'club_open',
status: ClubMemberStatus.active,
role: ClubRole.member,
joinedAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => testUser),
clubServiceProvider.overrideWithValue(mockService),
clubDetailProvider('club_open').overrideWith((ref) => Stream.value(openClub)),
currentMemberProvider('club_open').overrideWith((ref) => Stream.value(activeMember)),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubDetailScreen(clubId: 'club_open'),
),
),
);
await tester.pumpAndSettle();
expect(find.text('CLUB RIDES'), findsOneWidget);
expect(find.text('No upcoming club rides scheduled.'), findsOneWidget);
expect(find.text('Plan Club Ride'), findsNothing);
});
});
}