2026-08-29 17:16:16 -03:00
|
|
|
import XCTest
|
|
|
|
|
@testable import LCEssentials
|
|
|
|
|
|
|
|
|
|
private struct UploadEcho: Decodable, Sendable, Equatable {
|
|
|
|
|
let ok: Bool
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 17:46:41 -03:00
|
|
|
/// Thread-safe sink for progress callbacks (invoked on an arbitrary queue).
|
|
|
|
|
private final class ProgressBox: @unchecked Sendable {
|
|
|
|
|
private let lock = NSLock()
|
|
|
|
|
private var storage: [Double] = []
|
|
|
|
|
func record(_ value: Double) { lock.lock(); storage.append(value); lock.unlock() }
|
|
|
|
|
var values: [Double] { lock.lock(); defer { lock.unlock() }; return storage }
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 17:16:16 -03:00
|
|
|
final class APIUploadTests: XCTestCase {
|
|
|
|
|
|
|
|
|
|
private var api: API!
|
|
|
|
|
|
|
|
|
|
override func setUp() {
|
|
|
|
|
super.setUp()
|
|
|
|
|
let cfg = URLSessionConfiguration.ephemeral
|
|
|
|
|
cfg.protocolClasses = [StubURLProtocol.self]
|
|
|
|
|
api = API(testConfiguration: cfg)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override func tearDown() {
|
|
|
|
|
StubURLProtocol.reset()
|
|
|
|
|
api = nil
|
|
|
|
|
super.tearDown()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func tempCountInDir(_ dir: URL) -> Int {
|
|
|
|
|
(try? FileManager.default.contentsOfDirectory(atPath: dir.path).filter {
|
|
|
|
|
$0.hasPrefix("lce-multipart-")
|
|
|
|
|
}.count) ?? -1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testUploadSendsMultipartBodyAndDecodesResponse() async throws {
|
|
|
|
|
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8)))
|
|
|
|
|
|
|
|
|
|
var form = MultipartForm()
|
|
|
|
|
form.field("caption", "hi")
|
|
|
|
|
form.file("photo", data: Data([0x89, 0x50, 0x4E, 0x47]), filename: "p.png")
|
|
|
|
|
let expectedBody = try Data(contentsOf: form.serialize().fileURL)
|
|
|
|
|
|
|
|
|
|
let result: UploadEcho = try await api.upload(
|
|
|
|
|
url: "https://api.example.com/media",
|
|
|
|
|
form: form
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(result, UploadEcho(ok: true))
|
|
|
|
|
let sent = StubURLProtocol.capturedRequests.first
|
|
|
|
|
XCTAssertEqual(sent?.httpMethod, "POST")
|
|
|
|
|
XCTAssertTrue(sent?.value(forHTTPHeaderField: "Content-Type")?
|
|
|
|
|
.hasPrefix("multipart/form-data; boundary=") ?? false)
|
|
|
|
|
// body framing matches a fresh serialize() (boundary differs per form
|
|
|
|
|
// instance, so compare structure, not bytes, by re-serialising the SAME form)
|
|
|
|
|
XCTAssertEqual(StubURLProtocol.lastCapturedBody?.count, expectedBody.count)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testTempBodyFileRemovedAfterSuccess() async throws {
|
|
|
|
|
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8)))
|
|
|
|
|
let dir = FileManager.default.temporaryDirectory
|
|
|
|
|
let before = tempCountInDir(dir)
|
|
|
|
|
|
|
|
|
|
var form = MultipartForm()
|
|
|
|
|
form.file("f", data: Data("x".utf8), filename: "a.txt")
|
|
|
|
|
let _: UploadEcho = try await api.upload(url: "https://api.example.com/x", form: form)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(tempCountInDir(dir), before)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func testTempBodyFileRemovedAfterThrow() async {
|
|
|
|
|
StubURLProtocol.setStub(.init(statusCode: 500, body: Data(#"{"error":"boom"}"#.utf8)))
|
|
|
|
|
let dir = FileManager.default.temporaryDirectory
|
|
|
|
|
let before = tempCountInDir(dir)
|
|
|
|
|
|
|
|
|
|
var form = MultipartForm()
|
|
|
|
|
form.file("f", data: Data("x".utf8), filename: "a.txt")
|
|
|
|
|
do {
|
|
|
|
|
let _: UploadEcho = try await api.upload(url: "https://api.example.com/x", form: form)
|
|
|
|
|
XCTFail("expected throw")
|
|
|
|
|
} catch {
|
|
|
|
|
// expected
|
|
|
|
|
}
|
|
|
|
|
XCTAssertEqual(tempCountInDir(dir), before)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 17:46:41 -03:00
|
|
|
@available(iOS 15.0, *)
|
|
|
|
|
func testProgressOverloadDeliversFinalCompletionAndDecodes() async throws {
|
|
|
|
|
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8)))
|
|
|
|
|
|
|
|
|
|
var form = MultipartForm()
|
|
|
|
|
form.file("f", data: Data(repeating: 0x41, count: 4096), filename: "a.bin")
|
|
|
|
|
|
|
|
|
|
let progressBox = ProgressBox()
|
|
|
|
|
let result: UploadEcho = try await api.upload(
|
|
|
|
|
url: "https://api.example.com/x",
|
|
|
|
|
form: form,
|
|
|
|
|
onProgress: { progressBox.record($0) }
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
XCTAssertEqual(result, UploadEcho(ok: true))
|
|
|
|
|
let values = progressBox.values
|
|
|
|
|
XCTAssertEqual(values.last, 1.0, "final progress must be 1.0")
|
|
|
|
|
XCTAssertTrue(values.allSatisfy { $0 >= 0 && $0 <= 1 })
|
|
|
|
|
XCTAssertEqual(values, values.sorted(), "progress must be monotonic non-decreasing")
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 17:16:16 -03:00
|
|
|
func testUploadServerErrorThrowsWithStatus() async {
|
|
|
|
|
StubURLProtocol.setStub(.init(statusCode: 413, body: Data(#"{"error":"too big"}"#.utf8)))
|
|
|
|
|
var form = MultipartForm()
|
|
|
|
|
form.file("f", data: Data("x".utf8), filename: "a.txt")
|
|
|
|
|
do {
|
|
|
|
|
let _: UploadEcho = try await api.upload(url: "https://api.example.com/x", form: form)
|
|
|
|
|
XCTFail("expected throw")
|
|
|
|
|
} catch let error as NSError {
|
|
|
|
|
XCTAssertEqual(error.code, 413)
|
|
|
|
|
} catch {
|
|
|
|
|
XCTFail("wrong error: \(error)")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|