[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:
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user