Files
FrenoCorp/LendairTests/ClubServiceTests.swift
Senior Engineer 88d57a3389 Add Phase 2 community features: clubs and challenges (FRE-4664)
Implement full MVVM stack for two new community features:

Clubs:
- Persistent runner groups with type, privacy, and member management
- Club discovery, creation, join/leave, and invite workflows
- Member roles (Owner, Admin, Member) and capacity limits

Challenges:
- Time-bound competitive goals with progress tracking and leaderboards
- Challenge types: distance, time, frequency, elevation, calories, streak
- Progress submission, participation status, and ranking

Files:
- Models: Club.swift, Challenge.swift
- Services: ClubService.swift, ChallengeService.swift
- ViewModels: ClubViewModel.swift, ChallengeViewModel.swift
- Views: ClubsView.swift, ClubDetailView.swift, ChallengesView.swift, ChallengeDetailView.swift
- Tests: ClubServiceTests.swift, ChallengeServiceTests.swift
- Updated README.md with new feature documentation
2026-05-03 19:10:34 -04:00

330 lines
10 KiB
Swift

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)
}
}