import XCTest import SwiftUI @testable import Lendair // MARK: - Mock Challenge Service final class MockChallengeService: ChallengeServiceProtocol { var challenges: [Challenge] = [] var selectedChallenge: (challenge: Challenge, participants: [ChallengeParticipant])? var joinCalledIds: [String] = [] var leaveCalledIds: [String] = [] var createCalled = false var leaderboard: [LeaderboardEntry] = [] var listCallCount = 0 var listError: Error? func listChallenges(filter: ChallengeFilter = ChallengeFilter()) async throws -> [Challenge] { listCallCount += 1 if let error = listError { throw error } return challenges } func getChallenge(id: String) async throws -> (challenge: Challenge, participants: [ChallengeParticipant]) { if let selected = selectedChallenge { return selected } throw ChallengeError.notFound } func createChallenge(request: CreateChallengeRequest) async throws -> Challenge { createCalled = true return Challenge( id: "new-1", title: request.title, description: request.description, challengeType: request.challengeType, status: .active, startDate: request.startDate, endDate: request.endDate, targetMetric: request.targetMetric, targetValue: request.targetValue, targetUnit: request.targetMetric.unit, participantCount: 1, rules: request.rules, imageUrl: nil, createdBy: "current-user", createdByName: "Current User", clubId: request.clubId, participationStatus: .participating, userProgress: 0, createdAt: Date() ) } func updateChallenge(id: String, request: UpdateChallengeRequest) async throws -> Challenge { throw ChallengeError.notFound } func joinChallenge(id: String) async throws { joinCalledIds.append(id) } func leaveChallenge(id: String) async throws { leaveCalledIds.append(id) } func getLeaderboard(challengeId: String) async throws -> [LeaderboardEntry] { return leaderboard } func submitProgress(challengeId: String, progress: ProgressSubmission) async throws -> (progress: Double, percentage: Double) { return (progress.value, min((progress.value / 100) * 100, 100)) } } // MARK: - Helper: Sample Challenges extension Challenge { static func sample( id: String = "test-1", title: String = "Test Challenge", challengeType: ChallengeType = .distance, status: ChallengeStatus = .active, participationStatus: ParticipationStatus = .participating, userProgress: Double = 0, targetValue: Double = 100, startDate: Date = Date().addingTimeInterval(-7 * 24 * 3600), endDate: Date = Date().addingTimeInterval(23 * 24 * 3600) ) -> Challenge { Challenge( id: id, title: title, description: "Test description", challengeType: challengeType, status: status, startDate: startDate, endDate: endDate, targetMetric: .distance, targetValue: targetValue, targetUnit: "km", participantCount: 10, rules: nil, imageUrl: nil, createdBy: "user-1", createdByName: "Test User", clubId: nil, participationStatus: participationStatus, userProgress: userProgress, createdAt: Date() ) } } // MARK: - ChallengeServiceTests final class ChallengeServiceTests: XCTestCase { // MARK: - Fetch Challenges @MainActor func testFetchChallengesLoadsData() async { let mock = MockChallengeService() mock.challenges = [.sample(id: "1"), .sample(id: "2")] let viewModel = ChallengeViewModel(service: mock) await viewModel.fetchChallenges() XCTAssertEqual(viewModel.challenges.count, 2) XCTAssertFalse(viewModel.isLoading) XCTAssertEqual(mock.listCallCount, 1) } @MainActor func testFetchChallengesHandlesError() async { let mock = MockChallengeService() mock.listError = ChallengeError.unauthorized let viewModel = ChallengeViewModel(service: mock) await viewModel.fetchChallenges() XCTAssertTrue(viewModel.challenges.isEmpty) XCTAssertFalse(viewModel.isLoading) XCTAssertEqual(viewModel.error, .unauthorized) } // MARK: - Challenge Type Display func testChallengeTypeDisplayNames() { XCTAssertEqual(ChallengeType.distance.displayName, "Distance") XCTAssertEqual(ChallengeType.time.displayName, "Time") XCTAssertEqual(ChallengeType.frequency.displayName, "Frequency") XCTAssertEqual(ChallengeType.elevation.displayName, "Elevation") XCTAssertEqual(ChallengeType.calories.displayName, "Calories") XCTAssertEqual(ChallengeType.streak.displayName, "Streak") } func testChallengeTypeIcons() { XCTAssertEqual(ChallengeType.distance.icon, "arrow.right.arrow.left") XCTAssertEqual(ChallengeType.time.icon, "stopwatch.fill") XCTAssertEqual(ChallengeType.frequency.icon, "repeat") XCTAssertEqual(ChallengeType.elevation.icon, "mountain.2.fill") XCTAssertEqual(ChallengeType.calories.icon, "flame.fill") XCTAssertEqual(ChallengeType.streak.icon, "calendar.badge.clock") } func testChallengeMetricUnits() { XCTAssertEqual(ChallengeMetric.distance.unit, "km") XCTAssertEqual(ChallengeMetric.time.unit, "min") XCTAssertEqual(ChallengeMetric.frequency.unit, "sessions") XCTAssertEqual(ChallengeMetric.elevation.unit, "m") XCTAssertEqual(ChallengeMetric.calories.unit, "kcal") } // MARK: - Challenge Time States func testChallengeIsUpcoming() { let future = Challenge.sample( id: "1", startDate: Date().addingTimeInterval(7 * 24 * 3600), endDate: Date().addingTimeInterval(37 * 24 * 3600) ) XCTAssertTrue(future.isUpcoming) } func testChallengeIsActive() { let active = Challenge.sample(id: "1") XCTAssertTrue(active.isActive) } func testChallengeIsCompleted() { let past = Challenge.sample( id: "1", startDate: Date().addingTimeInterval(-30 * 24 * 3600), endDate: Date().addingTimeInterval(-7 * 24 * 3600) ) XCTAssertTrue(past.isCompleted) } // MARK: - Progress Percentage func testProgressPercentage() { var challenge = Challenge.sample(id: "1", userProgress: 50, targetValue: 100) XCTAssertEqual(challenge.progressPercentage, 50) } func testProgressPercentageOverTarget() { var challenge = Challenge.sample(id: "1", userProgress: 120, targetValue: 100) XCTAssertEqual(challenge.progressPercentage, 100) } func testProgressPercentageNoProgress() { var challenge = Challenge.sample(id: "1", userProgress: 0, targetValue: 100) XCTAssertEqual(challenge.progressPercentage, 0) } func testProgressPercentageNilProgress() { var challenge = Challenge.sample(id: "1", userProgress: nil, targetValue: 100) XCTAssertEqual(challenge.progressPercentage, 0) } // MARK: - Days Remaining func testDaysRemaining() { let challenge = Challenge.sample( id: "1", endDate: Date().addingTimeInterval(5 * 24 * 3600) ) XCTAssertGreaterThan(challenge.daysRemaining, 0) } // MARK: - Computed Filters @MainActor func testActiveChallengesFiltersCorrectly() async { let mock = MockChallengeService() mock.challenges = [ Challenge.sample(id: "1", startDate: Date().addingTimeInterval(-1 * 86400), endDate: Date().addingTimeInterval(7 * 86400)), Challenge.sample(id: "2", startDate: Date().addingTimeInterval(7 * 86400), endDate: Date().addingTimeInterval(37 * 86400)), Challenge.sample(id: "3", startDate: Date().addingTimeInterval(-10 * 86400), endDate: Date().addingTimeInterval(-1 * 86400)), ] let viewModel = ChallengeViewModel(service: mock) await viewModel.fetchChallenges() XCTAssertEqual(viewModel.activeChallenges.count, 1) XCTAssertEqual(viewModel.activeChallenges.first?.id, "1") } @MainActor func testUpcomingChallengesFiltersCorrectly() async { let mock = MockChallengeService() mock.challenges = [ Challenge.sample(id: "1", startDate: Date().addingTimeInterval(-1 * 86400), endDate: Date().addingTimeInterval(7 * 86400)), Challenge.sample(id: "2", startDate: Date().addingTimeInterval(7 * 86400), endDate: Date().addingTimeInterval(37 * 86400)), ] let viewModel = ChallengeViewModel(service: mock) await viewModel.fetchChallenges() XCTAssertEqual(viewModel.upcomingChallenges.count, 1) XCTAssertEqual(viewModel.upcomingChallenges.first?.id, "2") } @MainActor func testCompletedChallengesFiltersCorrectly() async { let mock = MockChallengeService() mock.challenges = [ Challenge.sample(id: "1", startDate: Date().addingTimeInterval(-10 * 86400), endDate: Date().addingTimeInterval(-1 * 86400)), Challenge.sample(id: "2", startDate: Date().addingTimeInterval(-1 * 86400), endDate: Date().addingTimeInterval(7 * 86400)), ] let viewModel = ChallengeViewModel(service: mock) await viewModel.fetchChallenges() XCTAssertEqual(viewModel.completedChallenges.count, 1) XCTAssertEqual(viewModel.completedChallenges.first?.id, "1") } @MainActor func testUserChallengesFiltersParticipating() async { let mock = MockChallengeService() mock.challenges = [ Challenge.sample(id: "1", participationStatus: .participating), Challenge.sample(id: "2", participationStatus: .notParticipating), Challenge.sample(id: "3", participationStatus: .participating), ] let viewModel = ChallengeViewModel(service: mock) await viewModel.fetchChallenges() XCTAssertEqual(viewModel.userChallenges.count, 2) XCTAssertTrue(viewModel.userChallenges.allSatisfy { $0.participationStatus == .participating }) } // MARK: - Join and Leave @MainActor func testJoinChallengeUpdatesLocalState() async { let mock = MockChallengeService() let challenge = Challenge.sample(id: "1", participationStatus: .notParticipating) mock.challenges = [challenge] let viewModel = ChallengeViewModel(service: mock) viewModel.challenges = [challenge] await viewModel.joinChallenge(id: "1") XCTAssertEqual(viewModel.challenges.first?.participationStatus, .participating) XCTAssertEqual(viewModel.challenges.first?.participantCount, 11) XCTAssertEqual(mock.joinCalledIds, ["1"]) } @MainActor func testLeaveChallengeUpdatesLocalState() async { let mock = MockChallengeService() let challenge = Challenge.sample(id: "1", participationStatus: .participating) mock.challenges = [challenge] let viewModel = ChallengeViewModel(service: mock) viewModel.challenges = [challenge] await viewModel.leaveChallenge(id: "1") XCTAssertEqual(viewModel.challenges.first?.participationStatus, .notParticipating) XCTAssertEqual(viewModel.challenges.first?.participantCount, 9) XCTAssertEqual(mock.leaveCalledIds, ["1"]) } // MARK: - Challenge Equality func testChallengeEquality() { let a = Challenge.sample(id: "1", participationStatus: .participating) let b = Challenge.sample(id: "1", participationStatus: .participating) let c = Challenge.sample(id: "1", participationStatus: .notParticipating) XCTAssertEqual(a, b) XCTAssertNotEqual(a, c) } // MARK: - Create Challenge @MainActor func testCreateChallengeAddsToList() async { let mock = MockChallengeService() let viewModel = ChallengeViewModel(service: mock) let request = CreateChallengeRequest( title: "New Challenge", description: "A new challenge", challengeType: .distance, startDate: Date(), endDate: Date().addingTimeInterval(30 * 24 * 3600), targetMetric: .distance, targetValue: 50, rules: nil, clubId: nil ) let result = await viewModel.createChallenge(request: request) XCTAssertNotNil(result) XCTAssertEqual(viewModel.challenges.count, 1) XCTAssertEqual(viewModel.challenges.first?.title, "New Challenge") XCTAssertTrue(mock.createCalled) } // MARK: - Leaderboard @MainActor func testFetchLeaderboardLoadsData() async { let mock = MockChallengeService() mock.leaderboard = [ LeaderboardEntry( id: "1", position: 1, participantId: "user1", participantName: "Alice", participantAvatarUrl: nil, progress: 100, progressPercentage: 100 ), LeaderboardEntry( id: "2", position: 2, participantId: "user2", participantName: "Bob", participantAvatarUrl: nil, progress: 75, progressPercentage: 75 ), ] let viewModel = ChallengeViewModel(service: mock) await viewModel.fetchLeaderboard(challengeId: "1") XCTAssertEqual(viewModel.leaderboard.count, 2) XCTAssertEqual(viewModel.leaderboard.first?.position, 1) } // MARK: - Submit Progress @MainActor func testSubmitProgressUpdatesChallenge() async { let mock = MockChallengeService() let challenge = Challenge.sample(id: "1", userProgress: 0) mock.challenges = [challenge] let viewModel = ChallengeViewModel(service: mock) viewModel.challenges = [challenge] let progress = ProgressSubmission(metric: .distance, value: 50, activityDate: Date()) await viewModel.submitProgress(challengeId: "1", progress: progress) XCTAssertEqual(viewModel.challenges.first?.userProgress, 50) } // MARK: - Challenge Filter Defaults func testChallengeFilterDefaults() { let filter = ChallengeFilter() XCTAssertEqual(filter.limit, 20) XCTAssertEqual(filter.offset, 0) XCTAssertNil(filter.challengeType) XCTAssertNil(filter.status) } // MARK: - Challenge Status Cases func testChallengeStatusCases() { XCTAssertEqual(ChallengeStatus.allCases.count, 4) XCTAssertEqual(ChallengeStatus.upcoming.rawValue, "upcoming") XCTAssertEqual(ChallengeStatus.active.rawValue, "active") XCTAssertEqual(ChallengeStatus.completed.rawValue, "completed") XCTAssertEqual(ChallengeStatus.cancelled.rawValue, "cancelled") } }