import XCTest import SwiftUI @testable import Lendair // MARK: - Mock Club Service final class MockClubService: ClubServiceProtocol { var clubs: [Club] = [] var selectedClub: (club: Club, members: [ClubMember])? var joinCalledIds: [String] = [] var leaveCalledIds: [String] = [] var createCalled = false var updateCalled = false var listCallCount = 0 var listError: Error? func listClubs(filter: ClubFilter = ClubFilter()) async throws -> [Club] { listCallCount += 1 if let error = listError { throw error } return clubs } func getClub(id: String) async throws -> (club: Club, members: [ClubMember]) { if let selected = selectedClub { return selected } throw ClubError.notFound } func createClub(request: CreateClubRequest) async throws -> Club { createCalled = true return Club( id: "new-1", name: request.name, description: request.description, clubType: request.clubType, privacy: request.privacy, location: request.location, latitude: request.latitude, longitude: request.longitude, memberCount: 1, maxMembers: request.maxMembers, imageUrl: nil, rules: request.rules, ownerId: "current-user", ownerName: "Current User", membershipStatus: .active, createdAt: Date() ) } func updateClub(id: String, request: UpdateClubRequest) async throws -> Club { updateCalled = true return Club( id: id, name: request.name ?? "Updated", description: request.description ?? "", clubType: request.clubType ?? .running, privacy: request.privacy ?? .publicPrivacy, location: request.location ?? "", latitude: request.latitude, longitude: request.longitude, memberCount: 0, maxMembers: request.maxMembers, imageUrl: nil, rules: request.rules, ownerId: "current-user", ownerName: "Current User", membershipStatus: .active, createdAt: Date() ) } func joinClub(id: String) async throws { joinCalledIds.append(id) } func leaveClub(id: String) async throws { leaveCalledIds.append(id) } func inviteMember(clubId: String, email: String) async throws {} func removeMember(clubId: String, memberId: String) async throws {} } // MARK: - Helper: Sample Clubs extension Club { static func sample( id: String = "test-1", name: String = "Test Club", clubType: ClubType = .running, privacy: ClubPrivacy = .publicPrivacy, membershipStatus: MembershipStatus = .active ) -> Club { Club( id: id, name: name, description: "Test description", clubType: clubType, privacy: privacy, location: "Test Location", latitude: nil, longitude: nil, memberCount: 10, maxMembers: 50, imageUrl: nil, rules: nil, ownerId: "owner-1", ownerName: "Test Owner", membershipStatus: membershipStatus, createdAt: Date() ) } } // MARK: - ClubServiceTests final class ClubServiceTests: XCTestCase { // MARK: - Fetch Clubs @MainActor func testFetchClubsLoadsData() async { let mock = MockClubService() mock.clubs = [.sample(id: "1"), .sample(id: "2")] let viewModel = ClubViewModel(service: mock) await viewModel.fetchClubs() XCTAssertEqual(viewModel.clubs.count, 2) XCTAssertFalse(viewModel.isLoading) XCTAssertEqual(mock.listCallCount, 1) } @MainActor func testFetchClubsHandlesError() async { let mock = MockClubService() mock.listError = ClubError.unauthorized let viewModel = ClubViewModel(service: mock) await viewModel.fetchClubs() XCTAssertTrue(viewModel.clubs.isEmpty) XCTAssertFalse(viewModel.isLoading) XCTAssertEqual(viewModel.error, .unauthorized) } // MARK: - Club Types func testClubTypeDisplayNames() { XCTAssertEqual(ClubType.running.displayName, "Running") XCTAssertEqual(ClubType.walking.displayName, "Walking") XCTAssertEqual(ClubType.cycling.displayName, "Cycling") XCTAssertEqual(ClubType.triathlon.displayName, "Triathlon") XCTAssertEqual(ClubType.crossfit.displayName, "CrossFit") XCTAssertEqual(ClubType.general.displayName, "General Fitness") } func testClubTypeIcons() { XCTAssertEqual(ClubType.running.icon, "figure.run") XCTAssertEqual(ClubType.walking.icon, "figure.walk") XCTAssertEqual(ClubType.cycling.icon, "bicycle") XCTAssertEqual(ClubType.triathlon.icon, "triangle.fill") XCTAssertEqual(ClubType.crossfit.icon, "dumbbell.fill") XCTAssertEqual(ClubType.general.icon, "heart.fill") } func testClubPrivacyDisplayNames() { XCTAssertEqual(ClubPrivacy.publicPrivacy.displayName, "Public") XCTAssertEqual(ClubPrivacy.privateClub.displayName, "Private") XCTAssertEqual(ClubPrivacy.invitationOnly.displayName, "Invitation Only") } // MARK: - Club Computed Properties @MainActor func testPublicClubsFiltersCorrectly() async { let mock = MockClubService() mock.clubs = [ .sample(id: "1", privacy: .publicPrivacy), .sample(id: "2", privacy: .privateClub), .sample(id: "3", privacy: .publicPrivacy), ] let viewModel = ClubViewModel(service: mock) await viewModel.fetchClubs() XCTAssertEqual(viewModel.publicClubs.count, 2) XCTAssertEqual(viewModel.publicClubs.first?.id, "1") XCTAssertEqual(viewModel.publicClubs.last?.id, "3") } @MainActor func testUserClubsFiltersActiveMembers() async { let mock = MockClubService() mock.clubs = [ .sample(id: "1", membershipStatus: .active), .sample(id: "2", membershipStatus: .pending), .sample(id: "3", membershipStatus: .active), ] let viewModel = ClubViewModel(service: mock) await viewModel.fetchClubs() XCTAssertEqual(viewModel.userClubs.count, 2) XCTAssertTrue(viewModel.userClubs.allSatisfy { $0.membershipStatus == .active }) } @MainActor func testPendingClubsFiltersCorrectly() async { let mock = MockClubService() mock.clubs = [ .sample(id: "1", membershipStatus: .active), .sample(id: "2", membershipStatus: .pending), .sample(id: "3", membershipStatus: .pending), ] let viewModel = ClubViewModel(service: mock) await viewModel.fetchClubs() XCTAssertEqual(viewModel.pendingClubs.count, 2) } // MARK: - Join and Leave @MainActor func testJoinClubUpdatesLocalState() async { let mock = MockClubService() let club = Club.sample(id: "1", membershipStatus: .left) mock.clubs = [club] let viewModel = ClubViewModel(service: mock) viewModel.clubs = [club] await viewModel.joinClub(id: "1") XCTAssertEqual(viewModel.clubs.first?.membershipStatus, .active) XCTAssertEqual(viewModel.clubs.first?.memberCount, 11) XCTAssertEqual(mock.joinCalledIds, ["1"]) } @MainActor func testLeaveClubUpdatesLocalState() async { let mock = MockClubService() let club = Club.sample(id: "1", membershipStatus: .active, memberCount: 10) mock.clubs = [club] let viewModel = ClubViewModel(service: mock) viewModel.clubs = [club] await viewModel.leaveClub(id: "1") XCTAssertEqual(viewModel.clubs.first?.membershipStatus, .left) XCTAssertEqual(viewModel.clubs.first?.memberCount, 9) XCTAssertEqual(mock.leaveCalledIds, ["1"]) } // MARK: - Club Equality func testClubEquality() { let a = Club.sample(id: "1", membershipStatus: .active) let b = Club.sample(id: "1", membershipStatus: .active) let c = Club.sample(id: "1", membershipStatus: .pending) XCTAssertEqual(a, b) XCTAssertNotEqual(a, c) } // MARK: - Club Capacity func testAvailableSpots() { let club = Club.sample(id: "1", memberCount: 10, maxMembers: 50) XCTAssertEqual(club.availableSpots, 40) } func testIsFull() { let club = Club.sample(id: "1", memberCount: 50, maxMembers: 50) XCTAssertTrue(club.isFull) } func testUnlimitedCapacity() { let club = Club.sample(id: "1", memberCount: 100, maxMembers: nil) XCTAssertFalse(club.isFull) XCTAssertNil(club.availableSpots) } // MARK: - Create Club @MainActor func testCreateClubAddsToList() async { let mock = MockClubService() let viewModel = ClubViewModel(service: mock) let request = CreateClubRequest( name: "New Club", description: "A new club", clubType: .running, privacy: .publicPrivacy, location: "Test Location", latitude: nil, longitude: nil, maxMembers: 50, rules: nil ) let result = await viewModel.createClub(request: request) XCTAssertNotNil(result) XCTAssertEqual(viewModel.clubs.count, 1) XCTAssertEqual(viewModel.clubs.first?.name, "New Club") XCTAssertTrue(mock.createCalled) } // MARK: - Member Role func testMemberRoleDisplayNames() { XCTAssertEqual(MemberRole.owner.displayName, "Owner") XCTAssertEqual(MemberRole.admin.displayName, "Admin") XCTAssertEqual(MemberRole.member.displayName, "Member") } // MARK: - Club Filter Defaults func testClubFilterDefaults() { let filter = ClubFilter() XCTAssertEqual(filter.limit, 20) XCTAssertEqual(filter.offset, 0) XCTAssertNil(filter.clubType) XCTAssertNil(filter.privacy) } }