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