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
This commit is contained in:
329
LendairTests/ClubServiceTests.swift
Normal file
329
LendairTests/ClubServiceTests.swift
Normal file
@@ -0,0 +1,329 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user