435 lines
17 KiB
Swift
435 lines
17 KiB
Swift
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<Void, Never>)] = []
|
|
|
|
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)
|
|
}
|
|
}
|