Add lead workflows, PDF signing, AR projects and photo gallery integration
This commit is contained in:
187
GasTankARTests/ARProjectsTests.swift
Normal file
187
GasTankARTests/ARProjectsTests.swift
Normal 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" })
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
146
GasTankARTests/LeadDetailTests.swift
Normal file
146
GasTankARTests/LeadDetailTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
434
GasTankARTests/LeadTests.swift
Normal file
434
GasTankARTests/LeadTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
137
GasTankARTests/LoginTests.swift
Normal file
137
GasTankARTests/LoginTests.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
266
GasTankARTests/PDFSigningTests.swift
Normal file
266
GasTankARTests/PDFSigningTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user