Add lead workflows, PDF signing, AR projects and photo gallery integration

This commit is contained in:
Dennis Nemec
2026-11-08 17:49:39 +01:00
parent a343bde741
commit 8d18873412
82 changed files with 6401 additions and 34 deletions

View File

@ -0,0 +1,434 @@
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)
}
}