import Foundation import Testing @testable import GasTankAR @MainActor struct LeadRepositoryTests { @Test func downloadsTenLeadsAndSortsNewestFirst() async throws { let repository = DefaultLeadRepository(remoteService: StubLeadRemoteService(delay: .zero)) let leads = try await LoadLeadsUseCase(repository: repository).execute(salesCode: "MKR") #expect(leads.count == 10) #expect(leads.first?.id == "L-240330") #expect(leads.filter(\.isBookmarked).count == 3) } @Test(arguments: ["hoffmann", " SABINE ", "kirchzarten", "l-240330"]) func searchesCompanyPersonCityAndID(_ term: String) async throws { let repository = DefaultLeadRepository(remoteService: StubLeadRemoteService(delay: .zero)) let leads = try await SearchLeadsUseCase(repository: repository).execute( salesCode: "MKR", query: LeadQuery(searchText: term) ) #expect(leads.count == 1) } @Test func filtersOpenContractsAndSortsNames() async throws { let repository = DefaultLeadRepository(remoteService: StubLeadRemoteService(delay: .zero)) let leads = try await repository.leads(for: "MKR", query: LeadQuery(sort: .nameAscending, filter: .unsignedContracts)) #expect(!leads.isEmpty) #expect(leads.allSatisfy { $0.unsignedContractCount > 0 }) #expect(leads.first?.companyName == "Autohaus Kessler KG") } @Test func noMatchesIsAnEmptySuccess() async throws { let repository = DefaultLeadRepository(remoteService: StubLeadRemoteService(delay: .zero)) let leads = try await repository.leads(for: "MKR", query: LeadQuery(searchText: "nichtvorhanden")) #expect(leads.isEmpty) } @Test func bookmarkIsScopedToEmployee() async throws { let repository = DefaultLeadRepository(remoteService: StubLeadRemoteService(delay: .zero)) let updated = try await repository.setBookmark(false, leadID: "L-240318", salesCode: "MKR") let otherUser = try await repository.lead(id: "L-240318", salesCode: "ABC") let sameUser = try await repository.lead(id: "L-240318", salesCode: "MKR") #expect(!updated.isBookmarked) #expect(!sameUser.isBookmarked) #expect(otherUser.isBookmarked) } @Test func missingLeadDiffersFromMissingListEndpoint() async { let service = StubLeadRemoteService(delay: .zero) let repository = DefaultLeadRepository(remoteService: service) await #expect(throws: LeadError.notFound(leadID: "missing")) { try await repository.lead(id: "missing", salesCode: "MKR") } await #expect(throws: LeadError.notFound(leadID: "missing")) { try await repository.setBookmark(true, leadID: "missing", salesCode: "MKR") } service.scenario = .http(404) await #expect(throws: LeadError.serviceUnavailable) { try await repository.leads(for: "MKR", query: LeadQuery()) } } @Test(arguments: [500, 502, 503]) func mapsServerFailures(_ status: Int) async { let repository = DefaultLeadRepository(remoteService: StubLeadRemoteService(scenario: .http(status), delay: .zero)) await #expect(throws: LeadError.serviceUnavailable) { try await repository.leads(for: "MKR", query: LeadQuery()) } } @Test func mapsOfflineTimeoutAuthenticationAndMalformedResponses() async { let cases: [(StubLeadRemoteService.Scenario, LeadError)] = [ (.offline, .offline), (.timeout, .timeout), (.http(401), .unauthorized), (.http(403), .forbidden), (.malformedResponse, .invalidResponse) ] for (scenario, expected) in cases { let repository = DefaultLeadRepository(remoteService: StubLeadRemoteService(scenario: scenario, delay: .zero)) await #expect(throws: expected) { try await repository.leads(for: "MKR", query: LeadQuery()) } } } @Test func rejectsDuplicateServerIDs() async throws { let record = try #require(LeadSampleData.records.first) let repository = DefaultLeadRepository(remoteService: StubLeadRemoteService(delay: .zero, records: [record, record])) await #expect(throws: LeadError.invalidResponse) { try await repository.leads(for: "MKR", query: LeadQuery()) } } @Test func rejectsInvalidDomainData() { let invalid = LeadDTO(id: "ID", companyName: " ", contactTitle: nil, firstName: nil, lastName: nil, street: nil, postalCode: "12345", city: "Ort", phoneNumber: nil, createdAt: .now, offerCount: -1, unsignedContractCount: 0, isBookmarked: false) #expect(throws: LeadError.invalidResponse) { try invalid.toDomain() } } } @MainActor struct LeadOverviewViewModelTests { @Test func clearingSearchRestoresCachedListWithoutRequest() async throws { let repository = ControlledLeadRepository() let model = LeadOverviewViewModel(salesCode: "MKR", repository: repository) let records = try LeadSampleData.records.map { try $0.toDomain() } let initial = Task { await model.reload() } await repository.waitForRequests(1) repository.finish(0, with: records) await initial.value let originalLeads = model.leads let timestamp = model.lastUpdated model.searchText = "Hoffmann" let search = Task { await model.reload() } await repository.waitForRequests(2) repository.finish(1, with: Array(records.prefix(1))) await search.value #expect(model.leads.count == 1) model.clearSearchText() model.searchText = "" #expect(model.query == LeadQuery()) #expect(model.leads == originalLeads) #expect(model.hasCurrentResults) #expect(!model.isLoading) #expect(model.lastUpdated == timestamp) await model.reload(debounce: true, useCachedList: true) #expect(repository.requestCount == 2) // Explicit refresh must still fetch fresh data. let refresh = Task { await model.reload() } await repository.waitForRequests(3) repository.finish(2, with: []) await refresh.value #expect(model.leads.isEmpty) } @Test func clearingSearchRejectsLateResponseAndKeepsBookmarkChanges() async throws { let repository = ControlledLeadRepository() let model = LeadOverviewViewModel(salesCode: "MKR", repository: repository) let records = try LeadSampleData.records.map { try $0.toDomain() } let initial = Task { await model.reload() } await repository.waitForRequests(1) repository.finish(0, with: records) await initial.value model.searchText = "Hoffmann" let search = Task { await model.reload() } await repository.waitForRequests(2) var updated = try #require(records.first) updated.isBookmarked.toggle() model.applyBookmark(updated) model.clearSearchText() #expect(model.leads.count == records.count) #expect(model.leads.first(where: { $0.id == updated.id }) == updated) repository.finish(1, with: []) await search.value #expect(model.leads.count == records.count) #expect(model.hasCurrentResults) #expect(!model.isLoading) #expect(model.error == nil) } @Test func bookmarkConfirmationWaitsForAnimationCommitWithoutReloading() async throws { let model = LeadOverviewViewModel(salesCode: "MKR", repository: DefaultLeadRepository( remoteService: StubLeadRemoteService(delay: .zero) )) await model.reload() let lead = try #require(model.bookmarkedLeads.first) let timestamp = model.lastUpdated let confirmed = try #require(await model.saveBookmark(lead)) #expect(model.bookmarkedLeads.contains(lead)) #expect(!model.isLoading) model.applyBookmark(confirmed) #expect(!model.bookmarkedLeads.contains(where: { $0.id == lead.id })) #expect(model.lastUpdated == timestamp) } @Test func sortingAndFilteringReuseLoadedResults() async throws { let repository = ControlledLeadRepository() let model = LeadOverviewViewModel(salesCode: "MKR", repository: repository) let request = Task { await model.reload() } await repository.waitForRequests(1) let records = try LeadSampleData.records.map { try $0.toDomain() } repository.finish(0, with: records) await request.value let timestamp = model.lastUpdated let query = model.query model.selection = .alphabetical #expect(model.leads.first?.displayName == "Anna Schneider") #expect(model.hasCurrentResults) #expect(model.query == query) #expect(!model.isLoading) model.selection = .open #expect(model.leads.count == 6) #expect(model.leads.allSatisfy { $0.unsignedContractCount > 0 }) #expect(model.hasCurrentResults) #expect(model.query == query) #expect(!model.isLoading) model.selection = .newest #expect(model.leads.count == 10) #expect(model.leads.first?.id == "L-240330") #expect(model.bookmarkedLeads.count == 3) #expect(model.lastUpdated == timestamp) #expect(repository.requestCount == 1) } @Test func refreshDuringExistingLoadFetchesAgainAndKeepsNewestResponse() async throws { let repository = ControlledLeadRepository() let model = LeadOverviewViewModel(salesCode: "MKR", repository: repository) let initial = Task { await model.reload() } await repository.waitForRequests(1) let refresh = Task { await model.reload() } await repository.waitForRequests(2) let expected = try #require(LeadSampleData.records.first).toDomain() repository.finish(1, with: [expected]) await refresh.value let refreshedAt = try #require(model.lastUpdated) repository.finish(0, with: []) await initial.value #expect(repository.requestCount == 2) #expect(model.leads == [expected]) #expect(model.lastUpdated == refreshedAt) #expect(model.error == nil) #expect(!model.isLoading) } @Test func refreshFailureKeepsDataAndSuccessfulTimestamp() async { let service = StubLeadRemoteService(delay: .zero) let model = LeadOverviewViewModel(salesCode: "MKR", repository: DefaultLeadRepository(remoteService: service)) await model.reload() let leads = model.leads let timestamp = model.lastUpdated service.scenario = .http(500) await model.reload() #expect(model.leads == leads) #expect(model.lastUpdated == timestamp) #expect(model.hasCurrentResults) #expect(model.error == .serviceUnavailable) #expect(!model.isLoading) service.scenario = .success await model.reload() #expect(model.error == nil) } @Test func failedSearchDoesNotPresentOldCardsAsNewResults() async { let service = StubLeadRemoteService(delay: .zero) let model = LeadOverviewViewModel(salesCode: "MKR", repository: DefaultLeadRepository(remoteService: service)) await model.reload() model.searchText = "Hoffmann" #expect(!model.hasCurrentResults) service.scenario = .timeout await model.reload() #expect(!model.hasCurrentResults) #expect(model.searchText == "Hoffmann") #expect(model.error == .timeout) #expect(!model.isLoading) } @Test func unsuccessfulBookmarkPreservesState() async throws { let service = StubLeadRemoteService(delay: .zero) let model = LeadOverviewViewModel(salesCode: "MKR", repository: DefaultLeadRepository(remoteService: service)) await model.reload() let lead = try #require(model.bookmarkedLeads.first) service.scenario = .http(500) await model.toggleBookmark(lead) #expect(model.bookmarkedLeads.contains(lead)) #expect(model.actionError == .serviceUnavailable) #expect(model.bookmarkingIDs.isEmpty) } @Test func bookmarkSuccessUpdatesBothSections() async throws { let model = LeadOverviewViewModel(salesCode: "MKR", repository: DefaultLeadRepository(remoteService: StubLeadRemoteService(delay: .zero))) await model.reload() let lead = try #require(model.bookmarkedLeads.first) await model.toggleBookmark(lead) #expect(!model.bookmarkedLeads.contains(where: { $0.id == lead.id })) #expect(model.leads.first(where: { $0.id == lead.id })?.isBookmarked == false) } @Test func expiredSessionIsExposedForLoginAction() async { let model = LeadOverviewViewModel(salesCode: "MKR", repository: DefaultLeadRepository( remoteService: StubLeadRemoteService(scenario: .http(401), delay: .zero) )) await model.reload() #expect(model.isSessionExpired) #expect(!model.isLoading) } @Test func lateSearchResponseCannotOverwriteNewerResults() async throws { let repository = ControlledLeadRepository() let model = LeadOverviewViewModel(salesCode: "MKR", repository: repository) model.searchText = "old" let oldRequest = Task { await model.reload() } await repository.waitForRequests(1) model.searchText = "new" let newRequest = Task { await model.reload() } await repository.waitForRequests(2) let expected = try #require(LeadSampleData.records.first).toDomain() repository.finish(1, with: [expected]) await newRequest.value repository.finish(0, with: []) await oldRequest.value #expect(model.leads == [expected]) #expect(model.hasCurrentResults) #expect(model.error == nil) #expect(!model.isLoading) } @Test func cancellationIsNotDisplayedAsFailure() async { let repository = ControlledLeadRepository() let model = LeadOverviewViewModel(salesCode: "MKR", repository: repository) let task = Task { await model.reload() } await repository.waitForRequests(1) task.cancel() repository.finish(0, with: []) await task.value #expect(model.error == nil) #expect(model.lastUpdated == nil) #expect(!model.isLoading) } } @MainActor private final class ControlledLeadRepository: LeadRepository { private var requests: [CheckedContinuation<[Lead], Never>] = [] var requestCount: Int { requests.count } private var observers: [(Int, CheckedContinuation)] = [] func leads(for salesCode: String, query: LeadQuery) async throws -> [Lead] { await withCheckedContinuation { continuation in requests.append(continuation) let ready = observers.filter { $0.0 <= requests.count } observers.removeAll { $0.0 <= requests.count } for observer in ready { observer.1.resume() } } } func waitForRequests(_ count: Int) async { if requests.count >= count { return } await withCheckedContinuation { observers.append((count, $0)) } } func finish(_ index: Int, with leads: [Lead]) { requests[index].resume(returning: leads) } func lead(id: String, salesCode: String) async throws -> Lead { throw LeadError.notFound(leadID: id) } func setBookmark(_ bookmarked: Bool, leadID: String, salesCode: String) async throws -> Lead { throw LeadError.notFound(leadID: leadID) } } @MainActor struct LeadDetailViewModelTests { @Test func startsWithPlaceholdersThenLoadsSelectedLead() async { let model = LeadDetailViewModel(leadID: "L-240330", salesCode: "MKR", repository: DefaultLeadDetailRepository(remoteService: StubLeadDetailRemoteService(delay: .zero))) #expect(model.lead == nil) #expect(model.error == nil) await model.load() #expect(model.lead?.id == "L-240330") #expect(model.error == nil) } @Test func missingLeadShowsDetailError() async { let model = LeadDetailViewModel(leadID: "missing", salesCode: "MKR", repository: DefaultLeadDetailRepository(remoteService: StubLeadDetailRemoteService(delay: .zero))) await model.load() #expect(model.lead == nil) #expect(model.error == .notFound(leadID: "missing")) } @Test func retryRecoversFromTimeout() async { let service = StubLeadDetailRemoteService(delay: .zero) service.detailScenario = .timeout let model = LeadDetailViewModel(leadID: "L-240330", salesCode: "MKR", repository: DefaultLeadDetailRepository(remoteService: service)) await model.load() #expect(model.lead == nil) #expect(model.error == .timeout) service.detailScenario = .success await model.load() #expect(model.lead?.id == "L-240330") #expect(model.error == nil) } @Test func cancelledLoadDoesNotPublishDataOrError() async { let model = LeadDetailViewModel(leadID: "L-240330", salesCode: "MKR", repository: DefaultLeadDetailRepository(remoteService: StubLeadDetailRemoteService(delay: .zero))) let task = Task { await model.load() } task.cancel() await task.value #expect(model.lead == nil) #expect(model.error == nil) } }