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
330 lines
10 KiB
Swift
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)
|
|
}
|
|
}
|