[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
This commit is contained in:
58
Tests/LCEssentialsTests/HTTPBodyTests.swift
Normal file
58
Tests/LCEssentialsTests/HTTPBodyTests.swift
Normal file
@@ -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 = "" }
|
||||
}
|
||||
}
|
||||
26
Tests/LCEssentialsTests/JSONDecoderDecodeTests.swift
Normal file
26
Tests/LCEssentialsTests/JSONDecoderDecodeTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
106
Tests/LCEssentialsTests/MultipartFormTests.swift
Normal file
106
Tests/LCEssentialsTests/MultipartFormTests.swift
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
8
Tests/LCEssentialsTests/SmokeTests.swift
Normal file
8
Tests/LCEssentialsTests/SmokeTests.swift
Normal file
@@ -0,0 +1,8 @@
|
||||
import XCTest
|
||||
@testable import LCEssentials
|
||||
|
||||
final class SmokeTests: XCTestCase {
|
||||
func testTargetBuildsAndRuns() {
|
||||
XCTAssertTrue(true)
|
||||
}
|
||||
}
|
||||
116
Tests/LCEssentialsTests/Support/StubURLProtocol.swift
Normal file
116
Tests/LCEssentialsTests/Support/StubURLProtocol.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
47
Tests/LCEssentialsTests/Support/StubURLProtocolTests.swift
Normal file
47
Tests/LCEssentialsTests/Support/StubURLProtocolTests.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user