283 lines
11 KiB
Dart
283 lines
11 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:motorcycle_dash/features/social/clubs/club_membership_controller.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_providers.dart';
|
|
import 'package:motorcycle_dash/services/clubs/club_service.dart';
|
|
|
|
class FakeClubServiceForController extends ClubService {
|
|
String? lastAction;
|
|
String? lastClubId;
|
|
String? lastCallerUid;
|
|
bool? lastAccept;
|
|
bool shouldThrow = false;
|
|
String? throwCode;
|
|
String? throwMessage;
|
|
Future<void> Function()? onJoinAsync;
|
|
|
|
@override
|
|
Future<void> joinOpenClub(String clubId, {String? callerUid}) async {
|
|
if (onJoinAsync != null) {
|
|
await onJoinAsync!();
|
|
}
|
|
if (shouldThrow) {
|
|
throw ClubOperationException(throwMessage ?? 'Error', code: throwCode);
|
|
}
|
|
lastAction = 'joinOpen';
|
|
lastClubId = clubId;
|
|
lastCallerUid = callerUid;
|
|
}
|
|
|
|
@override
|
|
Future<void> requestToJoinClub(String clubId, {String? callerUid}) async {
|
|
if (shouldThrow) {
|
|
throw ClubOperationException(throwMessage ?? 'Error', code: throwCode);
|
|
}
|
|
lastAction = 'requestToJoin';
|
|
lastClubId = clubId;
|
|
lastCallerUid = callerUid;
|
|
}
|
|
|
|
@override
|
|
Future<void> withdrawJoinRequest(String clubId, {String? callerUid}) async {
|
|
if (shouldThrow) {
|
|
throw ClubOperationException(throwMessage ?? 'Error', code: throwCode);
|
|
}
|
|
lastAction = 'withdrawRequest';
|
|
lastClubId = clubId;
|
|
lastCallerUid = callerUid;
|
|
}
|
|
|
|
@override
|
|
Future<void> respondToInvitation(String clubId, {required bool accept, String? callerUid}) async {
|
|
if (shouldThrow) {
|
|
throw ClubOperationException(throwMessage ?? 'Error', code: throwCode);
|
|
}
|
|
lastAction = 'respondInvite';
|
|
lastClubId = clubId;
|
|
lastAccept = accept;
|
|
lastCallerUid = callerUid;
|
|
}
|
|
|
|
@override
|
|
Future<void> leaveClub(String clubId, {String? callerUid}) async {
|
|
if (shouldThrow) {
|
|
throw ClubOperationException(throwMessage ?? 'Error', code: throwCode);
|
|
}
|
|
lastAction = 'leaveClub';
|
|
lastClubId = clubId;
|
|
lastCallerUid = callerUid;
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
group('ClubMembershipController Tests', () {
|
|
late FakeClubServiceForController fakeService;
|
|
late ProviderContainer container;
|
|
|
|
setUp(() {
|
|
fakeService = FakeClubServiceForController();
|
|
container = ProviderContainer(
|
|
overrides: [
|
|
clubServiceProvider.overrideWithValue(fakeService),
|
|
currentUserProvider.overrideWith(
|
|
(ref) => const AuthUser(uid: 'user_rider_1', isAnonymous: false),
|
|
),
|
|
],
|
|
);
|
|
});
|
|
|
|
tearDown(() {
|
|
container.dispose();
|
|
});
|
|
|
|
test('joinOpenClub executes and transitions state to success', () async {
|
|
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
|
|
final success = await controller.joinOpenClub('club_1');
|
|
|
|
expect(success, isTrue);
|
|
expect(fakeService.lastAction, 'joinOpen');
|
|
expect(fakeService.lastClubId, 'club_1');
|
|
expect(fakeService.lastCallerUid, 'user_rider_1');
|
|
|
|
final state = container.read(clubMembershipControllerProvider('club_1'));
|
|
expect(state.isSubmitting, isFalse);
|
|
expect(state.success, 'Successfully joined club!');
|
|
expect(state.error, isNull);
|
|
});
|
|
|
|
test('requestToJoinClub executes and transitions state to success', () async {
|
|
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
|
|
final success = await controller.requestToJoinClub('club_1');
|
|
|
|
expect(success, isTrue);
|
|
expect(fakeService.lastAction, 'requestToJoin');
|
|
expect(fakeService.lastClubId, 'club_1');
|
|
|
|
final state = container.read(clubMembershipControllerProvider('club_1'));
|
|
expect(state.isSubmitting, isFalse);
|
|
expect(state.success, 'Join request submitted to club leadership.');
|
|
expect(state.error, isNull);
|
|
});
|
|
|
|
test('withdrawJoinRequest executes and transitions state to success', () async {
|
|
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
|
|
final success = await controller.withdrawJoinRequest('club_1');
|
|
|
|
expect(success, isTrue);
|
|
expect(fakeService.lastAction, 'withdrawRequest');
|
|
|
|
final state = container.read(clubMembershipControllerProvider('club_1'));
|
|
expect(state.isSubmitting, isFalse);
|
|
expect(state.success, 'Join request withdrawn.');
|
|
});
|
|
|
|
test('acceptInvitation executes and transitions state to success', () async {
|
|
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
|
|
final success = await controller.acceptInvitation('club_1');
|
|
|
|
expect(success, isTrue);
|
|
expect(fakeService.lastAction, 'respondInvite');
|
|
expect(fakeService.lastAccept, isTrue);
|
|
|
|
final state = container.read(clubMembershipControllerProvider('club_1'));
|
|
expect(state.success, contains('Welcome to the club'));
|
|
});
|
|
|
|
test('declineInvitation executes and transitions state to success', () async {
|
|
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
|
|
final success = await controller.declineInvitation('club_1');
|
|
|
|
expect(success, isTrue);
|
|
expect(fakeService.lastAction, 'respondInvite');
|
|
expect(fakeService.lastAccept, isFalse);
|
|
|
|
final state = container.read(clubMembershipControllerProvider('club_1'));
|
|
expect(state.success, 'Invitation declined.');
|
|
});
|
|
|
|
test('leaveClub executes and transitions state to success', () async {
|
|
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
|
|
final success = await controller.leaveClub('club_1');
|
|
|
|
expect(success, isTrue);
|
|
expect(fakeService.lastAction, 'leaveClub');
|
|
|
|
final state = container.read(clubMembershipControllerProvider('club_1'));
|
|
expect(state.success, 'You have left the club.');
|
|
});
|
|
|
|
test('fails gracefully when user is unauthenticated', () async {
|
|
final unauthContainer = ProviderContainer(
|
|
overrides: [
|
|
clubServiceProvider.overrideWithValue(fakeService),
|
|
currentUserProvider.overrideWith((ref) => null),
|
|
],
|
|
);
|
|
|
|
final controller = unauthContainer.read(clubMembershipControllerProvider('club_1').notifier);
|
|
final success = await controller.joinOpenClub('club_1');
|
|
|
|
expect(success, isFalse);
|
|
final state = unauthContainer.read(clubMembershipControllerProvider('club_1'));
|
|
expect(state.error, contains('must be signed in'));
|
|
unauthContainer.dispose();
|
|
});
|
|
|
|
test('maps banned error code to friendly generic message without leaking mod info', () async {
|
|
fakeService.shouldThrow = true;
|
|
fakeService.throwCode = 'banned';
|
|
fakeService.throwMessage = 'User is in bans subcollection.';
|
|
|
|
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
|
|
final success = await controller.joinOpenClub('club_1');
|
|
|
|
expect(success, isFalse);
|
|
final state = container.read(clubMembershipControllerProvider('club_1'));
|
|
expect(state.error, 'Membership is unavailable for this club.');
|
|
});
|
|
|
|
test('maps owner-cannot-leave error code properly', () async {
|
|
fakeService.shouldThrow = true;
|
|
fakeService.throwCode = 'owner-cannot-leave';
|
|
fakeService.throwMessage = 'Owner cannot leave.';
|
|
|
|
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
|
|
final success = await controller.leaveClub('club_1');
|
|
|
|
expect(success, isFalse);
|
|
final state = container.read(clubMembershipControllerProvider('club_1'));
|
|
expect(state.error, contains('transferring ownership first'));
|
|
});
|
|
|
|
test('maps policy-mismatch, not-found, and unavailable error codes', () async {
|
|
fakeService.shouldThrow = true;
|
|
fakeService.throwCode = 'policy-mismatch';
|
|
fakeService.throwMessage = 'Open join not allowed.';
|
|
|
|
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
|
|
final success1 = await controller.joinOpenClub('club_1');
|
|
expect(success1, isFalse);
|
|
expect(container.read(clubMembershipControllerProvider('club_1')).error,
|
|
'This club does not allow open joining. Please submit a request.');
|
|
|
|
fakeService.throwCode = 'not-found';
|
|
fakeService.throwMessage = 'Not found';
|
|
final success2 = await controller.joinOpenClub('club_1');
|
|
expect(success2, isFalse);
|
|
expect(container.read(clubMembershipControllerProvider('club_1')).error,
|
|
'Club or membership record was not found.');
|
|
|
|
fakeService.throwCode = 'unavailable';
|
|
fakeService.throwMessage = 'Firestore down';
|
|
final success3 = await controller.joinOpenClub('club_1');
|
|
expect(success3, isFalse);
|
|
expect(container.read(clubMembershipControllerProvider('club_1')).error,
|
|
'Club services are temporarily unavailable. Please try again later.');
|
|
});
|
|
|
|
test('rejects concurrent duplicate mutation while in-flight', () async {
|
|
final completer = Completer<void>();
|
|
fakeService.onJoinAsync = () => completer.future;
|
|
|
|
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
|
|
final firstFuture = controller.joinOpenClub('club_1');
|
|
expect(container.read(clubMembershipControllerProvider('club_1')).isSubmitting, isTrue);
|
|
|
|
// Attempt second rapid mutation while first is still pending
|
|
final secondSuccess = await controller.joinOpenClub('club_1');
|
|
expect(secondSuccess, isFalse);
|
|
|
|
completer.complete();
|
|
final firstSuccess = await firstFuture;
|
|
expect(firstSuccess, isTrue);
|
|
expect(container.read(clubMembershipControllerProvider('club_1')).isSubmitting, isFalse);
|
|
});
|
|
|
|
test('isolates controller state per club family instance', () async {
|
|
final controllerA = container.read(clubMembershipControllerProvider('club_A').notifier);
|
|
final controllerB = container.read(clubMembershipControllerProvider('club_B').notifier);
|
|
|
|
await controllerA.joinOpenClub('club_A');
|
|
await controllerB.requestToJoinClub('club_B');
|
|
|
|
expect(container.read(clubMembershipControllerProvider('club_A')).success, 'Successfully joined club!');
|
|
expect(container.read(clubMembershipControllerProvider('club_B')).success, 'Join request submitted to club leadership.');
|
|
});
|
|
|
|
test('clearMessages resets state', () async {
|
|
final controller = container.read(clubMembershipControllerProvider('club_1').notifier);
|
|
await controller.joinOpenClub('club_1');
|
|
|
|
expect(container.read(clubMembershipControllerProvider('club_1')).success, isNotNull);
|
|
|
|
controller.clearMessages();
|
|
final cleared = container.read(clubMembershipControllerProvider('club_1'));
|
|
expect(cleared.success, isNull);
|
|
expect(cleared.error, isNull);
|
|
});
|
|
});
|
|
}
|