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,187 @@
import Foundation
import SwiftData
import Testing
@testable import GasTankAR
@MainActor
struct ARProjectsTests {
private func detail(_ id: String = "L-240318") throws -> LeadDetail {
let lead = try #require(LeadSampleData.records.first { $0.id == id }).toDomain()
return LeadDetailSampleData.make(for: lead)
}
private func container() throws -> ModelContainer {
try ModelContainer(for: Project.self, LeadARProjectLink.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true))
}
@Test func suppliedProjectsAreIdempotentAndExcludeServiceOffers() throws {
let container = try container()
let detail = try detail()
let service = LeadARProjectsService(detail: detail, container: container)
let first = try service.loadProjects()
let second = try LeadARProjectsService(detail: detail, container: container).loadProjects()
#expect(first.count == 2)
#expect(Set(first.map(\.id)) == Set(second.map(\.id)))
#expect(first.allSatisfy { $0.source == .supplied })
#expect(try ModelContext(container).fetchCount(FetchDescriptor<Project>()) == 2)
#expect(try ModelContext(container).fetchCount(FetchDescriptor<LeadARProjectLink>()) == 2)
}
@Test func customProjectSurvivesReloadAndIsScopedToItsLead() throws {
let container = try container()
let detail = try detail()
let service = LeadARProjectsService(detail: detail, container: container)
let project = try service.createProject(name: " Hinter dem Haus ", tank: Tank())
let reopened = LeadARProjectsService(detail: detail, container: container)
#expect(try reopened.loadProjects().contains { $0.id == project.id && $0.name == "Hinter dem Haus" })
#expect(try reopened.resolveProject(id: project.id).tank == Tank())
let otherDetail = try self.detail("L-240330")
let other = LeadARProjectsService(detail: otherDetail, container: container)
#expect(try !other.loadProjects().contains { $0.id == project.id })
#expect(throws: LeadARProjectsService.ProjectError.self) { try other.resolveProject(id: project.id) }
}
@Test func legacySessionIsLinkedWithoutLosingARData() throws {
let container = try container()
let detail = try detail()
let context = ModelContext(container)
let legacy = Project(leadID: detail.lead.id, customerName: detail.lead.displayName,
creationDate: .now, tank: Tank(), worldMapData: Data([1, 2, 3]),
tankTransform: [1, 2, 3], measurementsData: Data([4, 5]))
context.insert(legacy)
try context.save()
let service = LeadARProjectsService(detail: detail, container: container)
let projects = try service.loadProjects()
#expect(projects.contains { $0.id == legacy.projectID && $0.source == .supplied })
let restored = try service.resolveProject(id: legacy.projectID)
#expect(restored.worldMapData == Data([1, 2, 3]))
#expect(restored.measurementsData == Data([4, 5]))
#expect(restored.tankTransform == [1, 2, 3])
#expect(try context.fetchCount(FetchDescriptor<Project>()) == 2)
}
@Test func invalidCreationDoesNotLeaveProjectsOrLinks() throws {
let container = try container()
let service = LeadARProjectsService(detail: try detail(), container: container)
#expect(throws: LeadARProjectsService.ProjectError.self) {
try service.createProject(name: " ", tank: Tank())
}
#expect(throws: LeadARProjectsService.ProjectError.self) {
try service.createProject(name: "Invalid", tank: Tank(color: .white, placement: .belowGround))
}
#expect(try ModelContext(container).fetchCount(FetchDescriptor<Project>()) == 0)
#expect(try ModelContext(container).fetchCount(FetchDescriptor<LeadARProjectLink>()) == 0)
}
@Test func deletingCustomProjectRemovesAssociationAndSurvivesReload() throws {
let container = try container()
let detail = try detail()
let service = LeadARProjectsService(detail: detail, container: container)
let project = try service.createProject(name: "Löschen", tank: Tank())
try service.deleteProject(id: project.id)
let reloaded = LeadARProjectsService(detail: detail, container: container)
#expect(try !reloaded.loadProjects().contains { $0.id == project.id })
#expect(try ModelContext(container).fetchCount(FetchDescriptor<Project>()) == 2)
#expect(try ModelContext(container).fetchCount(FetchDescriptor<LeadARProjectLink>()) == 2)
}
@Test func suppliedProjectsCannotBeDeleted() throws {
let container = try container()
let service = LeadARProjectsService(detail: try detail(), container: container)
let project = try #require(service.loadProjects().first)
#expect(throws: LeadARProjectsService.ProjectError.self) {
try service.deleteProject(id: project.id)
}
#expect(try service.loadProjects().contains { $0.id == project.id })
}
@Test func editingUpdatesExistingProjectAndPreservesSession() throws {
let container = try container()
let detail = try detail()
let service = LeadARProjectsService(detail: detail, container: container)
let original = try service.createProject(name: "Alt", tank: Tank())
let stored = try service.resolveProject(id: original.id)
stored.worldMapData = Data([1, 2, 3])
stored.tankTransform = [1, 2, 3]
try stored.modelContext?.save()
let tank = Tank(size: .t2_1, color: .olive, placement: .belowGround)
let updated = try service.updateProject(id: original.id, name: " Neu ", tank: tank)
#expect(updated.id == original.id)
#expect(updated.createdAt == original.createdAt)
let reopened = LeadARProjectsService(detail: detail, container: container)
let project = try reopened.resolveProject(id: original.id)
#expect(project.projectName == "Neu")
#expect(project.tank == tank)
#expect(project.worldMapData == Data([1, 2, 3]))
#expect(project.tankTransform == [1, 2, 3])
#expect(try ModelContext(container).fetchCount(FetchDescriptor<Project>()) == 1)
}
@Test func editingRejectsSuppliedForeignAndInvalidProjects() throws {
let container = try container()
let service = LeadARProjectsService(detail: try detail(), container: container)
let supplied = try #require(service.loadProjects().first)
#expect(throws: LeadARProjectsService.ProjectError.self) {
try service.updateProject(id: supplied.id, name: "Changed", tank: Tank())
}
let own = try service.createProject(name: "Original", tank: Tank())
#expect(throws: LeadARProjectsService.ProjectError.self) {
try service.updateProject(id: own.id, name: " ", tank: Tank())
}
#expect(throws: LeadARProjectsService.ProjectError.self) {
try service.updateProject(id: own.id, name: "Invalid", tank: Tank(color: .white, placement: .belowGround))
}
let other = LeadARProjectsService(detail: try detail("L-240330"), container: container)
#expect(throws: LeadARProjectsService.ProjectError.self) {
try other.updateProject(id: own.id, name: "Foreign", tank: Tank())
}
#expect(try service.resolveProject(id: own.id).projectName == "Original")
}
@Test func galleryLoadsNewARPhotosWithoutDuplicatesAndExcludesOtherLeads() throws {
let container = try container()
let detail = try detail()
let service = LeadARProjectsService(detail: detail, container: container)
let own = try service.createProject(name: "Garten", tank: Tank())
let ownProject = try service.resolveProject(id: own.id)
let context = try #require(ownProject.modelContext)
let adapter = ProjectPersistenceAdapter(project: ownProject, modelContext: context)
adapter.savePhoto(Data([1]), date: Date(timeIntervalSince1970: 1), coordinate: nil)
let supplied = try #require(service.loadProjects().first { $0.source == .supplied })
let suppliedProject = try service.resolveProject(id: supplied.id)
ProjectPersistenceAdapter(project: suppliedProject, modelContext: context)
.savePhoto(Data([2]), date: Date(timeIntervalSince1970: 2), coordinate: nil)
let other = LeadARProjectsService(detail: try self.detail("L-240330"), container: container)
let otherValue = try other.createProject(name: "Anderer Kunde", tank: Tank())
let otherProject = try other.resolveProject(id: otherValue.id)
ProjectPersistenceAdapter(project: otherProject, modelContext: try #require(otherProject.modelContext))
.savePhoto(Data([9]), date: .now, coordinate: nil)
let initial = try LeadARPhotoStore(container: container).photos(for: detail.lead.id)
#expect(initial.map(\.imageData) == [Data([2]), Data([1])])
adapter.savePhoto(Data([3]), date: Date(timeIntervalSince1970: 3), coordinate: nil)
let refreshed = try LeadARPhotoStore(container: container).photos(for: detail.lead.id)
#expect(refreshed.map(\.imageData) == [Data([3]), Data([2]), Data([1])])
try service.deleteProject(id: own.id)
#expect(try LeadARPhotoStore(container: container).photos(for: detail.lead.id).count == 1)
}
@Test func associationPersistsWhenDiskStoreReopens() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("projects.store")
let detail = try detail()
let projectID: UUID
do {
let store = try ModelContainer(for: Project.self, LeadARProjectLink.self,
configurations: ModelConfiguration(url: url))
projectID = try LeadARProjectsService(detail: detail, container: store)
.createProject(name: "Persistiert", tank: Tank()).id
}
let reopened = try ModelContainer(for: Project.self, LeadARProjectLink.self,
configurations: ModelConfiguration(url: url))
let projects = try LeadARProjectsService(detail: detail, container: reopened).loadProjects()
#expect(projects.contains { $0.id == projectID && $0.name == "Persistiert" })
}
}

View File

@ -12,9 +12,10 @@ import SwiftData
// MARK: - Test-Doubles
/// Konfigurierbarer Mock des ``DocuframeService`` (Actor → threadsicher & Sendable),
/// Konfigurierbarer Mock mit derselben MainActor-Isolation wie ``DocuframeService``,
/// der Aufrufe zählt und wahlweise einen Fehler wirft.
private actor MockDocuframeService: DocuframeService {
@MainActor
private final class MockDocuframeService: DocuframeService {
enum Behavior: Sendable {
case success
case failure(DocuframeError)

View File

@ -0,0 +1,146 @@
import Foundation
import Testing
import PDFKit
@testable import GasTankAR
@MainActor
struct LeadDetailDownloadTests {
private func fixture() async throws -> (DefaultLeadDetailRepository, StubLeadDetailRemoteService, LeadDetail) {
let service = StubLeadDetailRemoteService(delay: .zero)
let repository = DefaultLeadDetailRepository(
remoteService: service,
cacheDirectory: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
)
let detail = try await repository.detail(id: "L-240318", salesCode: "MKR")
return (repository, service, detail)
}
@Test
func metadataDoesNotDownloadPDFsAndProgressMatchesDocuments() async throws {
let (_, service, detail) = try await fixture()
#expect(service.downloadCount == 0)
#expect(detail.offers.count == 3)
#expect(detail.offers[0].documents.count == 6)
#expect(detail.offers[0].signedCount == 2)
#expect(detail.offers[1].documents.count == 5)
#expect(detail.offers[2].isComplete)
#expect(detail.photos.count == 10)
}
@Test
func downloadsActualPDFAndReusesCachedFile() async throws {
let (repository, service, detail) = try await fixture()
let document = try #require(detail.offers.first?.documents.first)
let useCase = DownloadLeadDocumentUseCase(repository: repository)
let first = try await useCase.execute(document, leadID: detail.lead.id, salesCode: "MKR")
let pdf = try #require(PDFDocument(url: first))
#expect(pdf.pageCount == document.pageCount)
#expect(pdf.string?.contains("Beispieldokument") == true)
let second = try await useCase.execute(document, leadID: detail.lead.id, salesCode: "MKR")
#expect(first == second)
#expect(service.downloadCount == 1)
try FileManager.default.removeItem(at: first)
let third = try await useCase.execute(document, leadID: detail.lead.id, salesCode: "MKR")
#expect(FileManager.default.fileExists(atPath: third.path))
#expect(service.downloadCount == 2)
}
@Test
func concurrentDownloadsShareOneRequest() async throws {
let (repository, service, detail) = try await fixture()
service.delay = .milliseconds(50)
let file = try #require(detail.offers.first?.documents.first?.file)
let first = Task { try await repository.file(file, leadID: detail.lead.id, salesCode: "MKR") }
let second = Task { try await repository.file(file, leadID: detail.lead.id, salesCode: "MKR") }
let firstURL = try await first.value
let secondURL = try await second.value
#expect(firstURL == secondURL)
#expect(service.downloadCount == 1)
}
@Test
func cacheIsScopedToUserAndDocumentVersion() async throws {
let (repository, service, detail) = try await fixture()
let file = try #require(detail.offers.first?.documents.first?.file)
let first = try await repository.file(file, leadID: detail.lead.id, salesCode: "MKR")
let otherUser = try await repository.file(file, leadID: detail.lead.id, salesCode: "ABC")
#expect(first != otherUser)
let nextVersion = LeadFileReference(id: file.id, version: "2", kind: .pdf)
await #expect(throws: LeadFileError.notFound) {
try await repository.file(nextVersion, leadID: detail.lead.id, salesCode: "MKR")
}
#expect(service.downloadCount == 3)
}
@Test
func invalidPDFIsNotCachedAndRetrySucceeds() async throws {
let (repository, service, detail) = try await fixture()
let file = try #require(detail.offers.first?.documents.first?.file)
service.fileScenario = .invalidFile
await #expect(throws: LeadFileError.invalidFile) {
try await repository.file(file, leadID: detail.lead.id, salesCode: "MKR")
}
service.fileScenario = .success
let url = try await repository.file(file, leadID: detail.lead.id, salesCode: "MKR")
#expect(PDFDocument(url: url) != nil)
#expect(service.downloadCount == 2)
}
@Test
func fileErrorsPreserveLoadedDetailAndMapCorrectly() async throws {
let (repository, service, detail) = try await fixture()
let file = try #require(detail.offers.first?.documents.first?.file)
for (scenario, expected): (StubLeadDetailRemoteService.Scenario, LeadError) in [
(.offline, .offline), (.timeout, .timeout), (.http(500), .serviceUnavailable),
(.http(401), .unauthorized), (.http(403), .forbidden)
] {
service.fileScenario = scenario
await #expect(throws: expected) {
try await repository.file(file, leadID: detail.lead.id, salesCode: "MKR")
}
}
service.fileScenario = .http(404)
await #expect(throws: LeadFileError.notFound) {
try await repository.file(file, leadID: detail.lead.id, salesCode: "MKR")
}
#expect(detail.lead.id == "L-240318")
}
@Test
func treeExpandsCollapsesAndKeepsStateWhenSelectingPDF() async throws {
let (repository, _, _) = try await fixture()
let model = LeadDetailViewModel(leadID: "L-240318", salesCode: "MKR", repository: repository)
await model.load()
let detail = try #require(model.detail)
#expect(model.expandedOffers == [detail.offers[0].id])
model.toggleAll()
#expect(model.isAllExpanded)
model.selectedDocument = detail.offers[0].documents[0]
model.selectedDocument = nil
#expect(model.isAllExpanded)
model.toggleAll()
#expect(model.expandedOffers.isEmpty)
#expect(model.expandedGroups.isEmpty)
model.toggleOffer(detail.offers[1].id)
#expect(model.expandedOffers == [detail.offers[1].id])
}
@Test
func anotherLeadKeepsItsOwnIdentityAndEmptyOffers() async throws {
let (repository, _, _) = try await fixture()
let detail = try await repository.detail(id: "L-240326", salesCode: "MKR")
#expect(detail.lead.companyName == "Schreinerei Vogt")
#expect(detail.offers.isEmpty)
#expect(detail.email == nil)
}
@Test
func photoDownloadProducesAnImage() async throws {
let (repository, service, detail) = try await fixture()
let photo = try #require(detail.photos.first)
let url = try await LoadLeadPhotoUseCase(repository: repository)
.execute(photo, leadID: detail.lead.id, salesCode: "MKR")
#expect(FileManager.default.fileExists(atPath: url.path))
#expect(service.downloadCount == 1)
}
}

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)
}
}

View File

@ -0,0 +1,137 @@
import Foundation
import Testing
@testable import GasTankAR
@MainActor
struct LoginTests {
@Test(arguments: ["", " ", "AB", " AB\n"])
func invalidCodeDoesNotReachService(_ code: String) async {
let service = RecordingAuthenticationService()
let useCase = DefaultLoginUseCase(authenticationService: service)
await #expect(throws: LoginError.invalidSalesCode) {
try await useCase.execute(salesCode: code)
}
#expect(service.receivedCodes.isEmpty)
}
@Test
func normalizesCodeBeforeAuthentication() async throws {
let service = RecordingAuthenticationService()
let useCase = DefaultLoginUseCase(authenticationService: service)
let session = try await useCase.execute(salesCode: " mkr\n")
#expect(service.receivedCodes == ["MKR"])
#expect(session.salesCode == "MKR")
}
@Test
func serviceFailurePropagates() async {
let useCase = DefaultLoginUseCase(
authenticationService: StubAuthenticationService(simulatedError: .rejected, delay: .zero)
)
await #expect(throws: LoginError.rejected) {
try await useCase.execute(salesCode: "MKR")
}
}
@Test
func successfulLoginReturnsSessionAndResetsLoading() async {
let model = LoginViewModel(loginUseCase: StubLoginUseCase(error: nil))
model.salesCode = "MKR"
let session = await model.signIn()
#expect(session == LoginSession(salesCode: "MKR"))
#expect(!model.isLoading)
#expect(model.errorMessage == nil)
}
@Test
func failureIsDisplayedAndEditingClearsIt() async {
let model = LoginViewModel(loginUseCase: StubLoginUseCase(error: .rejected))
model.salesCode = "MKR"
let session = await model.signIn()
#expect(session == nil)
#expect(model.errorMessage != nil)
#expect(!model.isLoading)
model.salesCode = "ABC"
#expect(model.errorMessage == nil)
#expect(model.canSubmit)
}
@Test
func concurrentSubmissionIsIgnored() async {
let useCase = SuspendedLoginUseCase()
let model = LoginViewModel(loginUseCase: useCase)
model.salesCode = "MKR"
let first = Task { await model.signIn() }
// Wait for a deterministic suspension point instead of a timed delay.
while useCase.continuation == nil {
await Task.yield()
}
#expect(model.isLoading)
#expect(!model.canSubmit)
let duplicate = await model.signIn()
#expect(duplicate == nil)
#expect(useCase.callCount == 1)
useCase.continuation?.resume(returning: LoginSession(salesCode: "MKR"))
let result = await first.value
#expect(result != nil)
#expect(!model.isLoading)
}
@Test
func cancelledLoginDoesNotReturnSessionOrShowError() async {
let useCase = SuspendedLoginUseCase()
let model = LoginViewModel(loginUseCase: useCase)
model.salesCode = "MKR"
let task = Task { await model.signIn() }
while useCase.continuation == nil {
await Task.yield()
}
task.cancel()
useCase.continuation?.resume(returning: LoginSession(salesCode: "MKR"))
let result = await task.value
#expect(result == nil)
#expect(model.errorMessage == nil)
#expect(!model.isLoading)
}
}
@MainActor
private final class RecordingAuthenticationService: AuthenticationService {
private(set) var receivedCodes: [String] = []
func signIn(salesCode: String) async throws -> LoginSession {
receivedCodes.append(salesCode)
return LoginSession(salesCode: salesCode)
}
}
private struct StubLoginUseCase: LoginUseCase {
let error: LoginError?
func execute(salesCode: String) async throws -> LoginSession {
if let error { throw error }
return LoginSession(salesCode: salesCode)
}
}
@MainActor
private final class SuspendedLoginUseCase: LoginUseCase {
var continuation: CheckedContinuation<LoginSession, Never>?
private(set) var callCount = 0
func execute(salesCode: String) async throws -> LoginSession {
callCount += 1
return await withCheckedContinuation { continuation = $0 }
}
}

View File

@ -0,0 +1,266 @@
import Testing
import Foundation
import PDFKit
import PencilKit
import UIKit
@testable import GasTankAR
@MainActor
struct PDFSigningTests {
private func handwriting() -> PKDrawing {
let locations = [CGPoint(x: 20, y: 20), CGPoint(x: 50, y: 65), CGPoint(x: 100, y: 25)]
let points = locations.enumerated().map { index, location in
PKStrokePoint(location: location, timeOffset: Double(index) * 0.1,
size: CGSize(width: 2, height: 2), opacity: 1, force: 1,
azimuth: 0, altitude: .pi / 2)
}
return PKDrawing(strokes: [
PKStroke(ink: PKInk(.pen, color: .black),
path: PKStrokePath(controlPoints: points, creationDate: Date()))
])
}
private func directory() throws -> URL {
let url = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
private func source(in directory: URL) throws -> URL {
let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 595, height: 842))
let data = renderer.pdfData { context in
for number in 1...2 {
context.beginPage()
("Original page \(number)" as NSString).draw(
at: CGPoint(x: 40, y: 40), withAttributes: [.font: UIFont.systemFont(ofSize: 16)]
)
}
}
let url = directory.appending(path: "original.pdf")
try data.write(to: url)
return url
}
@Test
func signatureSurvivesReopeningAndPreservesOriginalPages() async throws {
let folder = try directory()
defer { try? FileManager.default.removeItem(at: folder) }
let original = try source(in: folder)
let originalData = try Data(contentsOf: original)
let bounds = CGRect(x: 310, y: 76, width: 237, height: 60)
let url = try await DefaultPDFSigningService(outputDirectory: folder).sign(
source: original, drawing: handwriting(),
placement: PDFSignaturePlacement(pageIndex: 1, bounds: bounds)
)
let result = try #require(PDFDocument(url: url))
#expect(result.pageCount == 2)
#expect(result.page(at: 0)?.string?.contains("Original page 1") == true)
#expect(result.page(at: 1)?.string?.contains("Original page 2") == true)
#expect(result.page(at: 0)?.annotations.isEmpty == true)
let annotation = try #require(result.page(at: 1)?.annotations.first)
#expect(annotation.type == "Ink")
#expect(annotation.bounds == bounds)
let paths = try #require(annotation.paths)
#expect(!paths.isEmpty)
for path in paths {
#expect(bounds.contains(path.bounds))
}
#expect(try Data(contentsOf: original) == originalData)
#expect(url != original)
}
@Test
func rejectsEmptyDrawingAndOutOfPagePlacement() async throws {
let folder = try directory()
defer { try? FileManager.default.removeItem(at: folder) }
let original = try source(in: folder)
let service = DefaultPDFSigningService(outputDirectory: folder)
await #expect(throws: PDFSigningError.self) {
try await service.sign(source: original, drawing: PKDrawing(),
placement: PDFSignaturePlacement(pageIndex: 0, bounds: .zero))
}
await #expect(throws: PDFSigningError.self) {
try await service.sign(source: original, drawing: handwriting(),
placement: PDFSignaturePlacement(pageIndex: 8,
bounds: CGRect(x: 0, y: 0, width: 200, height: 50)))
}
await #expect(throws: PDFSigningError.self) {
try await service.sign(source: original, drawing: handwriting(),
placement: PDFSignaturePlacement(pageIndex: 0,
bounds: CGRect(x: 590, y: 0, width: 200, height: 50)))
}
}
@Test
func failedSigningKeepsHandwritingAndCurrentDocument() async throws {
let folder = try directory()
defer { try? FileManager.default.removeItem(at: folder) }
let repository = DefaultLeadDetailRepository(remoteService: StubLeadDetailRemoteService(delay: .zero))
let detail = try await repository.detail(id: "L-240318", salesCode: "MKR")
let offer = try #require(detail.offers.first)
let documents = offer.documents.filter { $0.status == .pending }
let service = FailingSigningService()
let model = LeadSigningViewModel(
request: LeadSigningRequest(offerID: offer.id, signerName: "Klaus Hoffmann", documents: documents),
leadID: detail.lead.id, salesCode: "MKR", repository: repository,
signingService: service, store: LeadSignedDocumentStore(directory: folder)
)
await model.loadCurrentDocument()
model.drawing = handwriting()
let input = model.drawing
var callbackCount = 0
await model.signAndAdvance { _, _ in callbackCount += 1 }
#expect(service.calls == 1)
#expect(callbackCount == 0)
#expect(model.currentIndex == 0)
#expect(model.drawing == input)
#expect(model.sourceURL != nil)
#expect(model.message != nil)
#expect(model.canSign)
}
@Test
func listsAllDocumentsButSignsAndUploadsOnlyPendingDocuments() async throws {
let folder = try directory()
defer { try? FileManager.default.removeItem(at: folder) }
let repository = DefaultLeadDetailRepository(remoteService: StubLeadDetailRemoteService(delay: .zero))
let detail = try await repository.detail(id: "L-240318", salesCode: "MKR")
var offer = try #require(detail.offers.first)
var documents = Array(offer.documents.prefix(5))
for index in [0, 2, 4] { documents[index].status = .signed }
offer.groups = [LeadDocumentGroup(id: "mixed", title: "Dokumente", documents: documents)]
let detailModel = LeadDetailViewModel(leadID: detail.lead.id, salesCode: "MKR", repository: repository)
detailModel.startSigning(offer: offer)
let request = try #require(detailModel.signingRequest)
#expect(request.documents == documents)
detailModel.startSigning(offer: offer, group: offer.groups.first)
#expect(detailModel.signingRequest?.documents == documents)
let upload = RecordingPDFUploadService()
upload.shouldFail = false
let model = LeadSigningViewModel(
request: request, leadID: detail.lead.id, salesCode: "MKR", repository: repository,
signingService: DefaultPDFSigningService(outputDirectory: folder),
store: LeadSignedDocumentStore(directory: folder), uploadService: upload
)
#expect(model.pendingDocuments.count == 2)
#expect(model.currentDocument?.id == documents[1].id)
#expect(model.currentDocumentNumber == 2)
#expect(!model.isLastPendingDocument)
await model.loadCurrentDocument()
model.drawing = handwriting()
var signedIDs: [String] = []
await model.signAndAdvance { document, _ in signedIDs.append(document.id) }
#expect(model.currentDocument?.id == documents[3].id)
#expect(model.currentDocumentNumber == 4)
#expect(model.isLastPendingDocument)
#expect(!model.isComplete)
model.drawing = handwriting()
await model.signAndAdvance { document, _ in signedIDs.append(document.id) }
#expect(model.isComplete)
#expect(model.canFinish)
#expect(signedIDs == [documents[1].id, documents[3].id])
#expect(upload.calls.count == 1)
#expect(upload.calls.first?.count == 2)
#expect(model.request.documents.count == 5)
}
@Test
func uploadFailureCanBeRetriedWithoutSigningAgain() async throws {
let folder = try directory()
defer { try? FileManager.default.removeItem(at: folder) }
let repository = DefaultLeadDetailRepository(remoteService: StubLeadDetailRemoteService(delay: .zero))
let detail = try await repository.detail(id: "L-240318", salesCode: "MKR")
let offer = try #require(detail.offers.first)
let document = try #require(offer.documents.first)
let upload = RecordingPDFUploadService()
let model = LeadSigningViewModel(
request: LeadSigningRequest(offerID: offer.id, signerName: "Klaus Hoffmann", documents: [document]),
leadID: detail.lead.id, salesCode: "MKR", repository: repository,
signingService: DefaultPDFSigningService(outputDirectory: folder),
store: LeadSignedDocumentStore(directory: folder), uploadService: upload
)
upload.onUpload = { [weak model] in
#expect(model?.isUploading == true)
#expect(model?.canFinish == false)
}
await model.loadCurrentDocument()
model.drawing = handwriting()
var signedCount = 0
await model.signAndAdvance { _, _ in signedCount += 1 }
#expect(model.isComplete)
#expect(model.uploadError != nil)
#expect(!model.canFinish)
let signedURLs = model.signedURLs
upload.shouldFail = false
await model.uploadSignedDocuments()
#expect(model.canFinish)
#expect(model.uploadError == nil)
#expect(model.signedURLs == signedURLs)
#expect(signedCount == 1)
#expect(upload.calls.count == 2)
#expect(upload.calls.first == upload.calls.last)
await model.uploadSignedDocuments()
#expect(upload.calls.count == 2)
}
@Test
func successfulSigningAdvancesAndRestoresOnlyMatchingDocumentVersion() async throws {
let folder = try directory()
defer { try? FileManager.default.removeItem(at: folder) }
let repository = DefaultLeadDetailRepository(remoteService: StubLeadDetailRemoteService(delay: .zero))
let detail = try await repository.detail(id: "L-240318", salesCode: "MKR")
let offer = try #require(detail.offers.first)
let documents = Array(offer.documents.filter { $0.status == .pending }.prefix(2))
let store = LeadSignedDocumentStore(directory: folder)
let model = LeadSigningViewModel(
request: LeadSigningRequest(offerID: offer.id, signerName: "Klaus Hoffmann", documents: documents),
leadID: detail.lead.id, salesCode: "MKR", repository: repository,
signingService: DefaultPDFSigningService(outputDirectory: folder), store: store,
uploadService: StubLeadSignedPDFUploadService(delay: .zero)
)
await model.loadCurrentDocument()
#expect(!model.canSign)
model.drawing = handwriting()
await model.signAndAdvance { _, _ in }
#expect(model.currentIndex == 1)
#expect(model.drawing.strokes.isEmpty)
#expect(!model.isComplete)
model.drawing = handwriting()
await model.signAndAdvance { _, _ in }
#expect(model.isComplete)
#expect(!model.canSign)
#expect(store.savedURLs(documents: documents, leadID: detail.lead.id, salesCode: "MKR").count == 2)
#expect(store.savedURLs(documents: documents, leadID: detail.lead.id, salesCode: "OTHER").isEmpty)
let first = try #require(documents.first)
let updated = LeadDocument(id: first.id, title: first.title, pageCount: first.pageCount,
status: .pending,
file: LeadFileReference(id: first.file.id, version: "2", kind: .pdf))
#expect(store.savedURLs(documents: [updated], leadID: detail.lead.id, salesCode: "MKR").isEmpty)
}
}
@MainActor
private final class RecordingPDFUploadService: LeadSignedPDFUploadService {
let isSimulation = false
var calls: [[URL]] = []
var shouldFail = true
var onUpload: (() -> Void)?
func upload(_ files: [URL], leadID: String, offerID: String, salesCode: String) async throws -> LeadPDFUploadResult {
calls.append(files)
onUpload?()
if shouldFail { throw URLError(.notConnectedToInternet) }
return .uploaded
}
}
@MainActor
private final class FailingSigningService: PDFSigningService {
var calls = 0
func sign(source: URL, drawing: PKDrawing, placement: PDFSignaturePlacement) async throws -> URL {
calls += 1
throw PDFSigningError.saveFailed
}
}