Files
Gas-Tank-AR-App/GasTankARTests/PDFSigningTests.swift

267 lines
12 KiB
Swift

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