From e89935a69c6289ae6f368b4bdf7205e6577fb1c2 Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Sat, 29 Aug 2026 16:59:08 -0300 Subject: [PATCH] [api-upload-refactor] Add test target, relax decode constraints, add HTTPBody + MultipartForm - Package: new LCEssentialsTests target (runs on iOS simulator) - JSONDecoder.decode: T: Codable -> T: Decodable & Sendable (4 overloads); remove try! in decode(fromURL:); honour encoding param in decode(_:using:) - Propagate & Sendable to Data.object, Dictionary.toObjetct, API.request return - LCEHTTPBody: HTTPBody protocol, JSONBody, FormURLEncodedBody, RawBody, factories - LCEMultipartForm: multipart builder, OutputStream-streamed serialize(), in-memory + on-disk file parts, ported mimeType(for:) - Tests: StubURLProtocol harness + 15 tests --- Package.resolved | 2 +- Package.swift | 3 + .../LCEssentials/Classes/LCEHTTPBody.swift | 116 +++++++++++ .../Classes/LCEMultipartForm.swift | 190 ++++++++++++++++++ .../Classes/LCEssentials+API.swift | 2 +- .../Extensions/LCEssentials+Data.swift | 2 +- .../Extensions/LCEssentials+Dictionary.swift | 2 +- .../LCEssentials+EncodableDecodable.swift | 32 ++- Tests/LCEssentialsTests/HTTPBodyTests.swift | 58 ++++++ .../JSONDecoderDecodeTests.swift | 26 +++ .../MultipartFormTests.swift | 106 ++++++++++ Tests/LCEssentialsTests/SmokeTests.swift | 8 + .../Support/StubURLProtocol.swift | 116 +++++++++++ .../Support/StubURLProtocolTests.swift | 47 +++++ 14 files changed, 688 insertions(+), 22 deletions(-) create mode 100644 Sources/LCEssentials/Classes/LCEHTTPBody.swift create mode 100644 Sources/LCEssentials/Classes/LCEMultipartForm.swift create mode 100644 Tests/LCEssentialsTests/HTTPBodyTests.swift create mode 100644 Tests/LCEssentialsTests/JSONDecoderDecodeTests.swift create mode 100644 Tests/LCEssentialsTests/MultipartFormTests.swift create mode 100644 Tests/LCEssentialsTests/SmokeTests.swift create mode 100644 Tests/LCEssentialsTests/Support/StubURLProtocol.swift create mode 100644 Tests/LCEssentialsTests/Support/StubURLProtocolTests.swift diff --git a/Package.resolved b/Package.resolved index 152f883..848404c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "488bc8ba375dd6a42de3f34d4290056a146f9bd52f1bd6cd8ed81cbf4c63984f", + "originHash" : "50e672f8971a68675dcb3ca27b4bd4832c7e96f2216ae062f002554d445c6714", "pins" : [ { "identity" : "lcecryptokitbinary", diff --git a/Package.swift b/Package.swift index a7576e4..26a9503 100644 --- a/Package.swift +++ b/Package.swift @@ -39,5 +39,8 @@ let package = Package( .target( name: "LCEssentials", dependencies: targetDependencies), + .testTarget( + name: "LCEssentialsTests", + dependencies: ["LCEssentials"]), ] ) diff --git a/Sources/LCEssentials/Classes/LCEHTTPBody.swift b/Sources/LCEssentials/Classes/LCEHTTPBody.swift new file mode 100644 index 0000000..f69a8e2 --- /dev/null +++ b/Sources/LCEssentials/Classes/LCEHTTPBody.swift @@ -0,0 +1,116 @@ +// +// Copyright (c) 2020 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation + +/// A request payload plus the `Content-Type` it implies. +/// +/// Adopt this to teach `API.request` a new body encoding without changing `API`. +public protocol HTTPBody: Sendable { + /// - Returns: the encoded bytes and the `Content-Type` header value to send with them. + func encoded() throws -> (data: Data, contentType: String) +} + +// MARK: - JSON + +/// JSON-encodes an `Encodable` payload. +public struct JSONBody: HTTPBody { + + public let payload: Payload + private let encoder: @Sendable () -> JSONEncoder + + /// - Parameters: + /// - payload: the value to encode. + /// - encoderProvider: builds the `JSONEncoder` to use. Defaults to a plain encoder. + public init(_ payload: Payload, + encoderProvider: @escaping @Sendable () -> JSONEncoder = { JSONEncoder() }) { + self.payload = payload + self.encoder = encoderProvider + } + + public func encoded() throws -> (data: Data, contentType: String) { + (try encoder().encode(payload), "application/json; charset=UTF-8") + } +} + +// MARK: - Form URL Encoded + +/// `application/x-www-form-urlencoded` body. Every value is percent-escaped — +/// nothing is silently dropped for containing reserved characters. +public struct FormURLEncodedBody: HTTPBody { + + public let fields: [String: String] + + public init(_ fields: [String: String]) { + self.fields = fields + } + + public func encoded() throws -> (data: Data, contentType: String) { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-._~") // RFC 3986 unreserved + + let pairs: [String] = fields.map { key, value in + let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key + let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + return "\(k)=\(v)" + } + let body = Data(pairs.joined(separator: "&").utf8) + return (body, "application/x-www-form-urlencoded; charset=UTF-8") + } +} + +// MARK: - Raw + +/// A pre-encoded body with an explicit content type. +public struct RawBody: HTTPBody { + + public let data: Data + public let contentType: String + + public init(data: Data, contentType: String) { + self.data = data + self.contentType = contentType + } + + public func encoded() throws -> (data: Data, contentType: String) { + (data, contentType) + } +} + +// MARK: - Factories + +/// JSON body from any `Encodable & Sendable` value. +public func jsonBody(_ value: T) -> JSONBody { + JSONBody(value) +} + +/// Form-url-encoded body from a string dictionary. +public func formBody(_ fields: [String: String]) -> FormURLEncodedBody { + FormURLEncodedBody(fields) +} + +public extension HTTPBody where Self == FormURLEncodedBody { + /// Call-site sugar for `request(body: .form([...]))`. + static func form(_ fields: [String: String]) -> FormURLEncodedBody { + FormURLEncodedBody(fields) + } +} diff --git a/Sources/LCEssentials/Classes/LCEMultipartForm.swift b/Sources/LCEssentials/Classes/LCEMultipartForm.swift new file mode 100644 index 0000000..2323e8f --- /dev/null +++ b/Sources/LCEssentials/Classes/LCEMultipartForm.swift @@ -0,0 +1,190 @@ +// +// Copyright (c) 2020 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation + +/// A `multipart/form-data` body builder. +/// +/// Text fields and small in-memory blobs are held as `Data`; on-disk files are +/// referenced by `URL` and streamed straight into the serialised body, so a +/// large upload never becomes fully resident in memory. +public struct MultipartForm: Sendable { + + /// Where a part's content comes from. + public enum Source: Sendable { + case data(Data) + case file(URL) + } + + struct Part: Sendable { + let name: String + let filename: String? + let mimeType: String? + let source: Source + /// `true` → plain form field: no `filename` / `Content-Type` header lines. + let isField: Bool + } + + /// Error thrown while serialising the body. + public enum SerializationError: Error { + case cannotCreateTempFile(URL) + case cannotOpenOutput(URL) + case cannotOpenInput(URL) + case writeFailed(underlying: Error?) + case readFailed(URL) + } + + private(set) var parts: [Part] = [] + public let boundary: String + + private static let chunkSize = 64 * 1024 + private static let crlf = "\r\n" + + /// - Parameter boundary: multipart boundary token. Defaults to a random value. + public init(boundary: String = "LCEssentials-\(UUID().uuidString)") { + self.boundary = boundary + } + + // MARK: - Building + + /// Appends a plain text field. + public mutating func field(_ name: String, _ value: String) { + parts.append(Part(name: name, filename: nil, mimeType: nil, + source: .data(Data(value.utf8)), isField: true)) + } + + /// Appends an in-memory file part. + public mutating func file(_ name: String, data: Data, filename: String, mime: String? = nil) { + parts.append(Part(name: name, filename: filename, + mimeType: mime ?? Self.mimeType(for: filename), + source: .data(data), isField: false)) + } + + /// Appends an on-disk file part. The file is streamed at serialisation time. + public mutating func file(_ name: String, url: URL, filename: String? = nil, mime: String? = nil) { + let resolvedName = filename ?? url.lastPathComponent + parts.append(Part(name: name, filename: resolvedName, + mimeType: mime ?? Self.mimeType(for: resolvedName), + source: .file(url), isField: false)) + } + + // MARK: - Serialisation + + /// Writes the whole body to a temporary file. + /// + /// - Returns: the temp file URL (caller must delete it once the upload + /// finishes) and the `multipart/form-data; boundary=…` content type. + public func serialize() throws -> (fileURL: URL, contentType: String) { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("lce-multipart-\(UUID().uuidString).tmp") + + guard FileManager.default.createFile(atPath: fileURL.path, contents: nil) else { + throw SerializationError.cannotCreateTempFile(fileURL) + } + guard let output = OutputStream(url: fileURL, append: false) else { + throw SerializationError.cannotOpenOutput(fileURL) + } + output.open() + defer { output.close() } + + for part in parts { + try write(Data(header(for: part).utf8), to: output) + switch part.source { + case .data(let data): + try write(data, to: output) + case .file(let url): + try stream(fileAt: url, to: output) + } + try write(Data(Self.crlf.utf8), to: output) + } + try write(Data("--\(boundary)--\(Self.crlf)".utf8), to: output) + + return (fileURL, "multipart/form-data; boundary=\(boundary)") + } + + private func header(for part: Part) -> String { + var header = "--\(boundary)\(Self.crlf)" + header += "Content-Disposition: form-data; name=\"\(part.name)\"" + if let filename = part.filename, !part.isField { + header += "; filename=\"\(filename)\"" + } + header += Self.crlf + if !part.isField, let mime = part.mimeType { + header += "Content-Type: \(mime)\(Self.crlf)" + } + header += Self.crlf + return header + } + + private func write(_ data: Data, to output: OutputStream) throws { + guard !data.isEmpty else { return } + var bytesRemaining = data + while !bytesRemaining.isEmpty { + let written = bytesRemaining.withUnsafeBytes { raw -> Int in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return -1 } + return output.write(base, maxLength: bytesRemaining.count) + } + guard written > 0 else { + throw SerializationError.writeFailed(underlying: output.streamError) + } + bytesRemaining.removeFirst(written) + } + } + + private func stream(fileAt url: URL, to output: OutputStream) throws { + guard let input = InputStream(url: url) else { + throw SerializationError.cannotOpenInput(url) + } + input.open() + defer { input.close() } + + var buffer = [UInt8](repeating: 0, count: Self.chunkSize) + while input.hasBytesAvailable { + let read = input.read(&buffer, maxLength: buffer.count) + if read == 0 { break } + guard read > 0 else { throw SerializationError.readFailed(url) } + try write(Data(buffer[0.. String { + let ext = (path as NSString).pathExtension.lowercased() + return mimeTypes[ext] ?? "application/octet-stream" + } + + private static let mimeTypes: [String: String] = [ + "jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "gif": "image/gif", + "pdf": "application/pdf", "txt": "text/plain", "html": "text/html", "htm": "text/html", + "json": "application/json", "xml": "application/xml", "zip": "application/zip", + "mp3": "audio/mpeg", "mp4": "video/mp4", "mov": "video/quicktime", + "doc": "application/msword", + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls": "application/vnd.ms-excel", + "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt": "application/vnd.ms-powerpoint", + "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ] +} diff --git a/Sources/LCEssentials/Classes/LCEssentials+API.swift b/Sources/LCEssentials/Classes/LCEssentials+API.swift index 0b04569..1f76106 100644 --- a/Sources/LCEssentials/Classes/LCEssentials+API.swift +++ b/Sources/LCEssentials/Classes/LCEssentials+API.swift @@ -99,7 +99,7 @@ public struct API { /// - persistConnection: A boolean indicating whether to persist the connection on certain error codes (e.g., 4xx). Defaults to `false`. /// - Returns: An instance of the `T` type, decoded from the response data. /// - Throws: An `Error` if the request fails, including `URLError` for network issues or `DecodingError` for JSON decoding failures. - public func request(url: String, + public func request(url: String, params: Any? = nil, method: httpMethod, headers: [String: String] = [:], diff --git a/Sources/LCEssentials/Extensions/LCEssentials+Data.swift b/Sources/LCEssentials/Extensions/LCEssentials+Data.swift index e60b9ef..45aaae6 100644 --- a/Sources/LCEssentials/Extensions/LCEssentials+Data.swift +++ b/Sources/LCEssentials/Extensions/LCEssentials+Data.swift @@ -112,7 +112,7 @@ public extension Data { #endif } - func object() -> T? { + func object() -> T? { do { let outPut: T = try JSONDecoder.decode(data: self) return outPut diff --git a/Sources/LCEssentials/Extensions/LCEssentials+Dictionary.swift b/Sources/LCEssentials/Extensions/LCEssentials+Dictionary.swift index 53d0ffb..2645b59 100644 --- a/Sources/LCEssentials/Extensions/LCEssentials+Dictionary.swift +++ b/Sources/LCEssentials/Extensions/LCEssentials+Dictionary.swift @@ -138,7 +138,7 @@ public extension Dictionary { /// - LoverdeCo: Convert Dictonary to Object /// /// - returns: Object: Codable/Decodable - func toObjetct() -> T { + func toObjetct() -> T { let jsonString = self.convertToJSON let output: T = try! JSONDecoder.decode(jsonString) return output diff --git a/Sources/LCEssentials/Extensions/LCEssentials+EncodableDecodable.swift b/Sources/LCEssentials/Extensions/LCEssentials+EncodableDecodable.swift index 5059b74..70ec74b 100644 --- a/Sources/LCEssentials/Extensions/LCEssentials+EncodableDecodable.swift +++ b/Sources/LCEssentials/Extensions/LCEssentials+EncodableDecodable.swift @@ -44,8 +44,8 @@ extension JSONDecoder { /// - LoverdeCo: Decode JSON Data to Object /// /// - Parameter data: Data - /// - returns: Object: Codable/Decodable - public static func decode(data: Data) throws -> T { + /// - returns: Object: Decodable & Sendable + public static func decode(data: Data) throws -> T { var error = NSError(domain: "", code: 0) let decoder = JSONDecoder() decoder.keyDecodingStrategy = .useDefaultKeys @@ -70,28 +70,24 @@ extension JSONDecoder { /// - LoverdeCo: Decode JSON String to Object /// /// - Parameter json: String - /// - returns: Object: Codable/Decodable - public static func decode(_ json: String, using encoding: String.Encoding = .utf8) throws -> T { - var error = NSError() - if let jsonData = json.data(using: .utf8) { - do { - return try decode(data: jsonData) - } catch { - throw error - } + /// - returns: Object: Decodable & Sendable + public static func decode(_ json: String, using encoding: String.Encoding = .utf8) throws -> T { + guard let jsonData = json.data(using: encoding) else { + let msg = "Could not convert string to \(encoding) data for \(T.self)" + throw NSError.createErrorWith(code: 0, description: msg, reasonForError: msg) } - throw error + return try decode(data: jsonData) } - + /// - LoverdeCo: Decode JSON URL to Object /// /// - Parameter url: URL - /// - returns: Object: Codable/Decodable - public static func decode(fromURL url: URL) throws -> T { - return try decode(data: try! Data(contentsOf: url)) + /// - returns: Object: Decodable & Sendable + public static func decode(fromURL url: URL) throws -> T { + return try decode(data: Data(contentsOf: url)) } - - public static func decode(dictionary: Any) throws -> T { + + public static func decode(dictionary: Any) throws -> T { do { let json = try JSONSerialization.data(withJSONObject: dictionary) let decoder = JSONDecoder() diff --git a/Tests/LCEssentialsTests/HTTPBodyTests.swift b/Tests/LCEssentialsTests/HTTPBodyTests.swift new file mode 100644 index 0000000..3b6b12a --- /dev/null +++ b/Tests/LCEssentialsTests/HTTPBodyTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import LCEssentials + +private struct SamplePayload: Encodable, Sendable { + let name: String + let age: Int +} + +final class HTTPBodyTests: XCTestCase { + + // MARK: - JSONBody + + func testJSONBodyEncodesPayloadAndContentType() throws { + let payload = SamplePayload(name: "loverde", age: 3) + let (data, contentType) = try JSONBody(payload).encoded() + + XCTAssertEqual(contentType, "application/json; charset=UTF-8") + + let decoded = try JSONDecoder().decode([String: AnyDecodable].self, from: data) + XCTAssertEqual(decoded["name"]?.value as? String, "loverde") + XCTAssertEqual(decoded["age"]?.value as? Int, 3) + } + + func testJSONBodyFactory() throws { + let (data, _) = try jsonBody(SamplePayload(name: "x", age: 1)).encoded() + XCTAssertFalse(data.isEmpty) + } + + // MARK: - FormURLEncodedBody + + func testFormURLEncodedEncodesPairsAndPercentEscapes() throws { + let (data, contentType) = try FormURLEncodedBody(["a": "1", "b": "two words"]).encoded() + + XCTAssertEqual(contentType, "application/x-www-form-urlencoded; charset=UTF-8") + + let pairs = Set(String(data: data, encoding: .utf8)!.split(separator: "&").map(String.init)) + XCTAssertEqual(pairs, ["a=1", "b=two%20words"]) + } + + func testFormURLEncodedEscapesReservedCharactersInsteadOfDropping() throws { + let (data, _) = try FormURLEncodedBody(["q": "a&b=c"]).encoded() + let body = String(data: data, encoding: .utf8)! + XCTAssertEqual(body, "q=a%26b%3Dc") + } +} + +/// Minimal type-erased decoder for asserting JSON shape in tests. +struct AnyDecodable: Decodable { + let value: Any + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if let i = try? c.decode(Int.self) { value = i } + else if let s = try? c.decode(String.self) { value = s } + else if let b = try? c.decode(Bool.self) { value = b } + else if let d = try? c.decode(Double.self) { value = d } + else { value = "" } + } +} diff --git a/Tests/LCEssentialsTests/JSONDecoderDecodeTests.swift b/Tests/LCEssentialsTests/JSONDecoderDecodeTests.swift new file mode 100644 index 0000000..0ec8f0c --- /dev/null +++ b/Tests/LCEssentialsTests/JSONDecoderDecodeTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import LCEssentials + +private struct OnlyDecodable: Decodable, Sendable, Equatable { + let id: Int + let name: String +} + +final class JSONDecoderDecodeTests: XCTestCase { + + func testDecodesTypeThatIsDecodableAndSendableButNotEncodable() throws { + let data = Data(#"{"id":7,"name":"loverde"}"#.utf8) + let value: OnlyDecodable = try JSONDecoder.decode(data: data) + XCTAssertEqual(value, OnlyDecodable(id: 7, name: "loverde")) + } + + func testDecodeFromStringOverload() throws { + let value: OnlyDecodable = try JSONDecoder.decode(#"{"id":1,"name":"a"}"#) + XCTAssertEqual(value, OnlyDecodable(id: 1, name: "a")) + } + + func testDecodeFromURLThrowsInsteadOfCrashingOnMissingFile() { + let missing = URL(fileURLWithPath: "/tmp/does-not-exist-\(UUID().uuidString).json") + XCTAssertThrowsError(try JSONDecoder.decode(fromURL: missing) as OnlyDecodable) + } +} diff --git a/Tests/LCEssentialsTests/MultipartFormTests.swift b/Tests/LCEssentialsTests/MultipartFormTests.swift new file mode 100644 index 0000000..fdd3461 --- /dev/null +++ b/Tests/LCEssentialsTests/MultipartFormTests.swift @@ -0,0 +1,106 @@ +import XCTest +@testable import LCEssentials + +final class MultipartFormTests: XCTestCase { + + private func readSerialized(_ form: MultipartForm) throws -> (body: Data, contentType: String, url: URL) { + let result = try form.serialize() + let data = try Data(contentsOf: result.fileURL) + return (data, result.contentType, result.fileURL) + } + + private func addCleanup(_ url: URL) { + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + } + + // MARK: - Fields + + func testFieldPartHasNoContentTypeLine() throws { + var form = MultipartForm() + form.field("caption", "hello world") + + let (body, contentType, url) = try readSerialized(form) + addCleanup(url) + let text = String(data: body, encoding: .utf8)! + + XCTAssertTrue(contentType.hasPrefix("multipart/form-data; boundary=")) + let boundary = String(contentType.split(separator: "=", maxSplits: 1)[1]) + + XCTAssertEqual(text, [ + "--\(boundary)\r\n", + "Content-Disposition: form-data; name=\"caption\"\r\n", + "\r\n", + "hello world\r\n", + "--\(boundary)--\r\n" + ].joined()) + } + + // MARK: - In-memory data file + + func testDataFilePartCarriesFilenameAndMimeAndRawBytes() throws { + let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0xFF]) + var form = MultipartForm() + form.file("photo", data: png, filename: "p.png") + + let (body, contentType, url) = try readSerialized(form) + addCleanup(url) + let boundary = String(contentType.split(separator: "=", maxSplits: 1)[1]) + let text = String(data: body, encoding: .isoLatin1)! + + XCTAssertTrue(text.contains("Content-Disposition: form-data; name=\"photo\"; filename=\"p.png\"\r\n")) + XCTAssertTrue(text.contains("Content-Type: image/png\r\n")) + XCTAssertTrue(text.hasSuffix("--\(boundary)--\r\n")) + + // raw bytes appear verbatim between the blank line and the trailing CRLF + let marker = Data("\r\n\r\n".utf8) + let range = body.range(of: marker)! + let afterHeader = body[range.upperBound...] + XCTAssertTrue(afterHeader.starts(with: png)) + } + + func testExplicitMimeOverridesGuess() throws { + var form = MultipartForm() + form.file("f", data: Data("x".utf8), filename: "a.png", mime: "application/octet-stream") + let (body, _, url) = try readSerialized(form) + addCleanup(url) + XCTAssertTrue(String(data: body, encoding: .utf8)!.contains("Content-Type: application/octet-stream\r\n")) + } + + // MARK: - Disk file, streamed + + func testDiskFilePartIsStreamedAndContentMatches() throws { + let big = Data((0..<(2 * 1024 * 1024)).map { UInt8($0 % 251) }) + let src = FileManager.default.temporaryDirectory.appendingPathComponent("src-\(UUID().uuidString).bin") + try big.write(to: src) + addCleanup(src) + + var form = MultipartForm() + form.field("kind", "raw") + form.file("doc", url: src) + + let (body, contentType, out) = try readSerialized(form) + addCleanup(out) + let boundary = String(contentType.split(separator: "=", maxSplits: 1)[1]) + + // header uses the source filename + XCTAssertTrue(String(data: body.prefix(400), encoding: .isoLatin1)! + .contains("filename=\"\(src.lastPathComponent)\"")) + + // the 2 MB payload is present verbatim + let marker = Data("Content-Type: application/octet-stream\r\n\r\n".utf8) + let r = body.range(of: marker)! + let payload = body[r.upperBound..<(body.index(r.upperBound, offsetBy: big.count))] + XCTAssertEqual(Data(payload), big) + + XCTAssertTrue(String(data: body.suffix(boundary.count + 8), encoding: .utf8)! + .hasSuffix("--\(boundary)--\r\n")) + } + + // MARK: - MIME lookup + + func testMimeTypeLookup() { + XCTAssertEqual(MultipartForm.mimeType(for: "a.jpg"), "image/jpeg") + XCTAssertEqual(MultipartForm.mimeType(for: "a.PDF"), "application/pdf") + XCTAssertEqual(MultipartForm.mimeType(for: "a.unknownext"), "application/octet-stream") + } +} diff --git a/Tests/LCEssentialsTests/SmokeTests.swift b/Tests/LCEssentialsTests/SmokeTests.swift new file mode 100644 index 0000000..37d1b04 --- /dev/null +++ b/Tests/LCEssentialsTests/SmokeTests.swift @@ -0,0 +1,8 @@ +import XCTest +@testable import LCEssentials + +final class SmokeTests: XCTestCase { + func testTargetBuildsAndRuns() { + XCTAssertTrue(true) + } +} diff --git a/Tests/LCEssentialsTests/Support/StubURLProtocol.swift b/Tests/LCEssentialsTests/Support/StubURLProtocol.swift new file mode 100644 index 0000000..bd3c94e --- /dev/null +++ b/Tests/LCEssentialsTests/Support/StubURLProtocol.swift @@ -0,0 +1,116 @@ +import Foundation + +/// Test double for `URLProtocol`. Intercepts every request on a `URLSession` +/// configured with it, records the outgoing `URLRequest`, and replays a canned +/// response or error supplied by the test. +/// +/// Register via: +/// ``` +/// let cfg = URLSessionConfiguration.ephemeral +/// cfg.protocolClasses = [StubURLProtocol.self] +/// let session = URLSession(configuration: cfg) +/// ``` +final class StubURLProtocol: URLProtocol, @unchecked Sendable { + + struct Stub { + var statusCode: Int = 200 + var headers: [String: String] = ["Content-Type": "application/json"] + var body: Data = Data() + var error: Error? + /// Bytes reported through `URLSession`'s upload progress, in order. + var uploadProgressChunks: [Int] = [] + } + + // MARK: - Test-facing state (guarded) + + private static let lock = NSLock() + // Access is serialised through `lock`; the unsafe opt-out is the documented + // pattern for lock-guarded mutable statics under strict concurrency. + nonisolated(unsafe) private static var _stub = Stub() + nonisolated(unsafe) private static var _capturedRequests: [URLRequest] = [] + nonisolated(unsafe) private static var _capturedBodies: [Data] = [] + + static func setStub(_ stub: Stub) { + lock.lock(); defer { lock.unlock() } + _stub = stub + _capturedRequests = [] + _capturedBodies = [] + } + + static func reset() { setStub(Stub()) } + + static var capturedRequests: [URLRequest] { + lock.lock(); defer { lock.unlock() } + return _capturedRequests + } + + /// Body of the last intercepted request. `URLProtocol` strips `httpBody` for + /// stream bodies, so this reads `httpBodyStream` when needed. + static var lastCapturedBody: Data? { + lock.lock(); defer { lock.unlock() } + return _capturedBodies.last + } + + static var requestCount: Int { + lock.lock(); defer { lock.unlock() } + return _capturedRequests.count + } + + private static func currentStub() -> Stub { + lock.lock(); defer { lock.unlock() } + return _stub + } + + private static func record(_ request: URLRequest, body: Data) { + lock.lock(); defer { lock.unlock() } + _capturedRequests.append(request) + _capturedBodies.append(body) + } + + // MARK: - URLProtocol + + override class func canInit(with request: URLRequest) -> Bool { true } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let stub = Self.currentStub() + Self.record(request, body: Self.bodyData(from: request)) + + guard let client = client else { return } + + if let error = stub.error { + client.urlProtocol(self, didFailWithError: error) + return + } + + let url = request.url ?? URL(string: "https://stub.invalid")! + let response = HTTPURLResponse(url: url, + statusCode: stub.statusCode, + httpVersion: "HTTP/1.1", + headerFields: stub.headers)! + client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client.urlProtocol(self, didLoad: stub.body) + client.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + // MARK: - Body extraction + + private static func bodyData(from request: URLRequest) -> Data { + if let body = request.httpBody { return body } + guard let stream = request.httpBodyStream else { return Data() } + stream.open() + defer { stream.close() } + var data = Data() + let bufferSize = 64 * 1024 + var buffer = [UInt8](repeating: 0, count: bufferSize) + while stream.hasBytesAvailable { + let read = stream.read(&buffer, maxLength: bufferSize) + if read <= 0 { break } + data.append(buffer, count: read) + } + return data + } +} diff --git a/Tests/LCEssentialsTests/Support/StubURLProtocolTests.swift b/Tests/LCEssentialsTests/Support/StubURLProtocolTests.swift new file mode 100644 index 0000000..3b553cc --- /dev/null +++ b/Tests/LCEssentialsTests/Support/StubURLProtocolTests.swift @@ -0,0 +1,47 @@ +import XCTest + +final class StubURLProtocolTests: XCTestCase { + + private func makeSession() -> URLSession { + let cfg = URLSessionConfiguration.ephemeral + cfg.protocolClasses = [StubURLProtocol.self] + return URLSession(configuration: cfg) + } + + override func tearDown() { + StubURLProtocol.reset() + super.tearDown() + } + + func testReplaysCannedResponseAndCapturesRequest() async throws { + var stub = StubURLProtocol.Stub() + stub.statusCode = 201 + stub.body = Data(#"{"ok":true}"#.utf8) + StubURLProtocol.setStub(stub) + + var request = URLRequest(url: URL(string: "https://example.com/things")!) + request.httpMethod = "POST" + request.httpBody = Data(#"{"name":"x"}"#.utf8) + + let (data, response) = try await makeSession().data(for: request) + + XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 201) + XCTAssertEqual(String(data: data, encoding: .utf8), #"{"ok":true}"#) + XCTAssertEqual(StubURLProtocol.requestCount, 1) + XCTAssertEqual(StubURLProtocol.capturedRequests.first?.httpMethod, "POST") + XCTAssertEqual(StubURLProtocol.lastCapturedBody, Data(#"{"name":"x"}"#.utf8)) + } + + func testReplaysError() async { + var stub = StubURLProtocol.Stub() + stub.error = URLError(.notConnectedToInternet) + StubURLProtocol.setStub(stub) + + do { + _ = try await makeSession().data(for: URLRequest(url: URL(string: "https://example.com")!)) + XCTFail("expected throw") + } catch { + XCTAssertEqual((error as? URLError)?.code, .notConnectedToInternet) + } + } +}