Add lead workflows, PDF signing, AR projects and photo gallery integration
This commit is contained in:
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