|
|
@@ -0,0 +1,433 @@
|
|
|
+import XCTest
|
|
|
+@testable import MixBoard
|
|
|
+
|
|
|
+// MARK: - Thread-Safe Progress Collector
|
|
|
+
|
|
|
+/// Collects progress values from arbitrary threads safely.
|
|
|
+/// Used by tests to accumulate onProgress callbacks without data races.
|
|
|
+final class ProgressCollector: @unchecked Sendable {
|
|
|
+ private let lock = NSLock()
|
|
|
+ private var _values: [Double] = []
|
|
|
+
|
|
|
+ func append(_ value: Double) {
|
|
|
+ lock.lock()
|
|
|
+ _values.append(value)
|
|
|
+ lock.unlock()
|
|
|
+ }
|
|
|
+
|
|
|
+ var values: [Double] {
|
|
|
+ lock.lock()
|
|
|
+ defer { lock.unlock() }
|
|
|
+ return _values
|
|
|
+ }
|
|
|
+
|
|
|
+ var last: Double? {
|
|
|
+ lock.lock()
|
|
|
+ defer { lock.unlock() }
|
|
|
+ return _values.last
|
|
|
+ }
|
|
|
+
|
|
|
+ var count: Int {
|
|
|
+ lock.lock()
|
|
|
+ defer { lock.unlock() }
|
|
|
+ return _values.count
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/// Thread-safe flag for tracking whether progress was received.
|
|
|
+final class AtomicFlag: @unchecked Sendable {
|
|
|
+ private let lock = NSLock()
|
|
|
+ private var _value: Bool = false
|
|
|
+
|
|
|
+ var value: Bool {
|
|
|
+ get { lock.lock(); defer { lock.unlock() }; return _value }
|
|
|
+ set { lock.lock(); _value = newValue; lock.unlock() }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// MARK: - URLProtocol Stub for Simulating Downloads with Progress
|
|
|
+
|
|
|
+/// A URLProtocol subclass that simulates a download by delivering data in configurable chunks.
|
|
|
+/// This lets us verify that ProgressDownloader reports intermediate progress values
|
|
|
+/// without hitting any real network.
|
|
|
+final class MockDownloadProtocol: URLProtocol {
|
|
|
+
|
|
|
+ // MARK: - Configuration (set per-test)
|
|
|
+
|
|
|
+ /// Total response body. Delivered in chunks of `chunkSize` bytes.
|
|
|
+ nonisolated(unsafe) static var responseData: Data = Data(repeating: 0x42, count: 1024)
|
|
|
+
|
|
|
+ /// Bytes per chunk delivered to the client.
|
|
|
+ nonisolated(unsafe) static var chunkSize: Int = 256
|
|
|
+
|
|
|
+ /// HTTP status code of the simulated response.
|
|
|
+ nonisolated(unsafe) static var statusCode: Int = 200
|
|
|
+
|
|
|
+ /// Optional error to return instead of data (simulates network failure).
|
|
|
+ nonisolated(unsafe) static var simulatedError: Error?
|
|
|
+
|
|
|
+ /// Whether to omit Content-Length header (simulates unknown transfer size).
|
|
|
+ nonisolated(unsafe) static var omitContentLength: Bool = false
|
|
|
+
|
|
|
+ /// Delay between chunks in seconds (0 = no delay).
|
|
|
+ nonisolated(unsafe) static var interChunkDelay: TimeInterval = 0
|
|
|
+
|
|
|
+ /// Reset all configuration to defaults.
|
|
|
+ static func resetDefaults() {
|
|
|
+ responseData = Data(repeating: 0x42, count: 1024)
|
|
|
+ chunkSize = 256
|
|
|
+ statusCode = 200
|
|
|
+ simulatedError = nil
|
|
|
+ omitContentLength = false
|
|
|
+ interChunkDelay = 0
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - URLProtocol overrides
|
|
|
+
|
|
|
+ override class func canInit(with request: URLRequest) -> Bool { true }
|
|
|
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
|
|
+
|
|
|
+ override func startLoading() {
|
|
|
+ if let error = Self.simulatedError {
|
|
|
+ client?.urlProtocol(self, didFailWithError: error)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // Build the HTTP response
|
|
|
+ var headers: [String: String] = ["Content-Type": "application/octet-stream"]
|
|
|
+ if !Self.omitContentLength {
|
|
|
+ headers["Content-Length"] = "\(Self.responseData.count)"
|
|
|
+ }
|
|
|
+
|
|
|
+ guard let response = HTTPURLResponse(
|
|
|
+ url: request.url ?? URL(string: "https://test.local/file")!,
|
|
|
+ statusCode: Self.statusCode,
|
|
|
+ httpVersion: "HTTP/1.1",
|
|
|
+ headerFields: headers
|
|
|
+ ) else {
|
|
|
+ client?.urlProtocol(self, didFailWithError: URLError(.cannotParseResponse))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
|
|
+
|
|
|
+ // Deliver data in chunks to trigger progress callbacks
|
|
|
+ let data = Self.responseData
|
|
|
+ var offset = 0
|
|
|
+ while offset < data.count {
|
|
|
+ let end = min(offset + Self.chunkSize, data.count)
|
|
|
+ let chunk = data[offset..<end]
|
|
|
+ client?.urlProtocol(self, didLoad: Data(chunk))
|
|
|
+ offset = end
|
|
|
+
|
|
|
+ if Self.interChunkDelay > 0 {
|
|
|
+ Thread.sleep(forTimeInterval: Self.interChunkDelay)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ client?.urlProtocolDidFinishLoading(self)
|
|
|
+ }
|
|
|
+
|
|
|
+ override func stopLoading() {
|
|
|
+ // No-op: data delivery is synchronous in startLoading
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// MARK: - Tests
|
|
|
+
|
|
|
+/// Tests for ProgressDownloader — the URLSessionDownloadDelegate bridge
|
|
|
+/// that provides real-time download progress to the UI.
|
|
|
+///
|
|
|
+/// These tests use MockDownloadProtocol to simulate network I/O without
|
|
|
+/// hitting any server. Each test configures the mock, creates a URLSession
|
|
|
+/// with the custom protocol, and verifies that ProgressDownloader correctly
|
|
|
+/// reports progress and delivers results.
|
|
|
+final class DownloadProgressTests: XCTestCase {
|
|
|
+
|
|
|
+ override func setUp() {
|
|
|
+ super.setUp()
|
|
|
+ MockDownloadProtocol.resetDefaults()
|
|
|
+ }
|
|
|
+
|
|
|
+ override func tearDown() {
|
|
|
+ MockDownloadProtocol.resetDefaults()
|
|
|
+ super.tearDown()
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Helper: create a URLSessionConfiguration that routes through MockDownloadProtocol.
|
|
|
+ private func mockSessionConfig() -> URLSessionConfiguration {
|
|
|
+ let config = URLSessionConfiguration.ephemeral
|
|
|
+ config.protocolClasses = [MockDownloadProtocol.self]
|
|
|
+ return config
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Helper: build a test URLRequest.
|
|
|
+ private func testRequest() -> URLRequest {
|
|
|
+ URLRequest(url: URL(string: "https://test.local/music/track.flac")!)
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Progress Reporting
|
|
|
+
|
|
|
+ /// ProgressDownloader should report intermediate progress values between 0 and 1
|
|
|
+ /// as the download progresses, not just 0 then 1.
|
|
|
+ func testProgressReportsIntermediateValues() async throws {
|
|
|
+ // 1 KB file in 256-byte chunks = 4 chunks = 4 progress reports
|
|
|
+ MockDownloadProtocol.responseData = Data(repeating: 0xAA, count: 1024)
|
|
|
+ MockDownloadProtocol.chunkSize = 256
|
|
|
+
|
|
|
+ let collector = ProgressCollector()
|
|
|
+
|
|
|
+ let result = try await ProgressDownloader.download(
|
|
|
+ request: testRequest(),
|
|
|
+ sessionConfiguration: mockSessionConfig()
|
|
|
+ ) { progress in
|
|
|
+ collector.append(progress)
|
|
|
+ }
|
|
|
+
|
|
|
+ let progressValues = collector.values
|
|
|
+
|
|
|
+ // We should get multiple intermediate progress reports
|
|
|
+ XCTAssertGreaterThan(progressValues.count, 1,
|
|
|
+ "Expected multiple progress callbacks, got \(progressValues.count)")
|
|
|
+
|
|
|
+ // Progress should increase monotonically
|
|
|
+ for i in 1..<progressValues.count {
|
|
|
+ XCTAssertGreaterThanOrEqual(progressValues[i], progressValues[i - 1],
|
|
|
+ "Progress should increase monotonically: \(progressValues)")
|
|
|
+ }
|
|
|
+
|
|
|
+ // Final value should reach 1.0
|
|
|
+ if let lastValue = progressValues.last {
|
|
|
+ XCTAssertEqual(lastValue, 1.0, accuracy: 0.01,
|
|
|
+ "Final progress should be 1.0")
|
|
|
+ } else {
|
|
|
+ XCTFail("Expected at least one progress value")
|
|
|
+ }
|
|
|
+
|
|
|
+ // Result file should exist
|
|
|
+ XCTAssertTrue(FileManager.default.fileExists(atPath: result.fileURL.path),
|
|
|
+ "Downloaded file should exist at \(result.fileURL.path)")
|
|
|
+
|
|
|
+ // Clean up
|
|
|
+ try? FileManager.default.removeItem(at: result.fileURL)
|
|
|
+ }
|
|
|
+
|
|
|
+ /// The final progress callback should always be 1.0, even for very small files.
|
|
|
+ func testProgressReachesOneOnCompletion() async throws {
|
|
|
+ // Tiny file: 64 bytes
|
|
|
+ MockDownloadProtocol.responseData = Data(repeating: 0xBB, count: 64)
|
|
|
+ MockDownloadProtocol.chunkSize = 64
|
|
|
+
|
|
|
+ let collector = ProgressCollector()
|
|
|
+
|
|
|
+ let result = try await ProgressDownloader.download(
|
|
|
+ request: testRequest(),
|
|
|
+ sessionConfiguration: mockSessionConfig()
|
|
|
+ ) { progress in
|
|
|
+ collector.append(progress)
|
|
|
+ }
|
|
|
+
|
|
|
+ if let lastValue = collector.last {
|
|
|
+ XCTAssertEqual(lastValue, 1.0, accuracy: 0.01,
|
|
|
+ "Final progress should be 1.0 for completed download")
|
|
|
+ } else {
|
|
|
+ XCTFail("Expected at least one progress value")
|
|
|
+ }
|
|
|
+
|
|
|
+ try? FileManager.default.removeItem(at: result.fileURL)
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Result Validation
|
|
|
+
|
|
|
+ /// A successful download should return a file URL that exists on disk
|
|
|
+ /// and an HTTP response with the expected status code.
|
|
|
+ func testResultContainsFileAndResponse() async throws {
|
|
|
+ let testData = Data(repeating: 0xCC, count: 512)
|
|
|
+ MockDownloadProtocol.responseData = testData
|
|
|
+ MockDownloadProtocol.statusCode = 200
|
|
|
+
|
|
|
+ let result = try await ProgressDownloader.download(
|
|
|
+ request: testRequest(),
|
|
|
+ sessionConfiguration: mockSessionConfig()
|
|
|
+ ) { _ in }
|
|
|
+
|
|
|
+ // File exists and has correct size
|
|
|
+ XCTAssertTrue(FileManager.default.fileExists(atPath: result.fileURL.path))
|
|
|
+ let attrs = try FileManager.default.attributesOfItem(atPath: result.fileURL.path)
|
|
|
+ let fileSize = attrs[.size] as? Int64 ?? 0
|
|
|
+ XCTAssertEqual(fileSize, Int64(testData.count),
|
|
|
+ "Downloaded file size should match response data size")
|
|
|
+
|
|
|
+ // HTTP response has expected status
|
|
|
+ XCTAssertEqual(result.response.statusCode, 200)
|
|
|
+
|
|
|
+ try? FileManager.default.removeItem(at: result.fileURL)
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Error Handling
|
|
|
+
|
|
|
+ /// When the network fails mid-download, ProgressDownloader should throw
|
|
|
+ /// the underlying network error.
|
|
|
+ func testErrorPropagatesOnNetworkFailure() async {
|
|
|
+ MockDownloadProtocol.simulatedError = URLError(.networkConnectionLost)
|
|
|
+
|
|
|
+ do {
|
|
|
+ _ = try await ProgressDownloader.download(
|
|
|
+ request: testRequest(),
|
|
|
+ sessionConfiguration: mockSessionConfig()
|
|
|
+ ) { _ in }
|
|
|
+ XCTFail("Expected download to throw on network failure")
|
|
|
+ } catch {
|
|
|
+ // Verify it's the right error type
|
|
|
+ XCTAssertTrue(error is URLError,
|
|
|
+ "Expected URLError, got \(type(of: error)): \(error)")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// When a download task is cancelled, ProgressDownloader should throw
|
|
|
+ /// a cancellation error.
|
|
|
+ func testCancelledDownloadThrows() async {
|
|
|
+ // Large file with delay so we have time to cancel
|
|
|
+ MockDownloadProtocol.responseData = Data(repeating: 0xDD, count: 10_000_000)
|
|
|
+ MockDownloadProtocol.chunkSize = 1024
|
|
|
+ MockDownloadProtocol.interChunkDelay = 0.01
|
|
|
+
|
|
|
+ let task = Task {
|
|
|
+ try await ProgressDownloader.download(
|
|
|
+ request: testRequest(),
|
|
|
+ sessionConfiguration: mockSessionConfig()
|
|
|
+ ) { _ in }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Give the download a moment to start, then cancel
|
|
|
+ try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
|
|
|
+ task.cancel()
|
|
|
+
|
|
|
+ do {
|
|
|
+ _ = try await task.value
|
|
|
+ // Cancellation may race — if download completed before cancel,
|
|
|
+ // that's acceptable behavior
|
|
|
+ } catch is CancellationError {
|
|
|
+ // Expected
|
|
|
+ } catch let error as URLError where error.code == .cancelled {
|
|
|
+ // Also acceptable — URLSession reports cancellation this way
|
|
|
+ } catch {
|
|
|
+ // Other errors during cancellation are acceptable too,
|
|
|
+ // as long as the download didn't silently succeed
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Content-Length Absent
|
|
|
+
|
|
|
+ /// When the server doesn't send Content-Length, the download should still
|
|
|
+ /// succeed. Progress callbacks may not fire (totalBytesExpectedToWrite is unknown).
|
|
|
+ func testNoProgressWhenContentLengthUnknown() async throws {
|
|
|
+ MockDownloadProtocol.responseData = Data(repeating: 0xEE, count: 512)
|
|
|
+ MockDownloadProtocol.chunkSize = 128
|
|
|
+ MockDownloadProtocol.omitContentLength = true
|
|
|
+
|
|
|
+ let collector = ProgressCollector()
|
|
|
+
|
|
|
+ let result = try await ProgressDownloader.download(
|
|
|
+ request: testRequest(),
|
|
|
+ sessionConfiguration: mockSessionConfig()
|
|
|
+ ) { progress in
|
|
|
+ collector.append(progress)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Download should succeed regardless
|
|
|
+ XCTAssertTrue(FileManager.default.fileExists(atPath: result.fileURL.path))
|
|
|
+ XCTAssertEqual(result.response.statusCode, 200)
|
|
|
+
|
|
|
+ // Without Content-Length, the delegate can't compute fraction.
|
|
|
+ // ProgressDownloader guards totalBytesExpectedToWrite > 0, so
|
|
|
+ // intermediate progress callbacks should NOT fire (or fire with 0).
|
|
|
+ // The final yield(1.0) in didFinishDownloadingTo always fires.
|
|
|
+ // We don't assert an exact count because URLSession behavior varies,
|
|
|
+ // but we verify the download completed successfully.
|
|
|
+
|
|
|
+ try? FileManager.default.removeItem(at: result.fileURL)
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Integration: DownloadService.downloadPersistent Uses Progress
|
|
|
+
|
|
|
+ /// After the fix, DownloadService.downloadPersistent should invoke the
|
|
|
+ /// onProgress callback with values > 0 before completion.
|
|
|
+ /// This test verifies the integration between DownloadService and ProgressDownloader.
|
|
|
+ ///
|
|
|
+ /// NOTE: This test requires ProgressDownloader to be wired into downloadPersistent.
|
|
|
+ /// It will fail until the implementation is complete.
|
|
|
+ func testDownloadPersistentCallsOnProgress() async throws {
|
|
|
+ // Configure mock for a chunked download
|
|
|
+ MockDownloadProtocol.responseData = Data(repeating: 0xFF, count: 2048)
|
|
|
+ MockDownloadProtocol.chunkSize = 512
|
|
|
+ MockDownloadProtocol.statusCode = 200
|
|
|
+
|
|
|
+ // Create a cloud track with the necessary fields
|
|
|
+ let track = Track(title: "Progress Test", artist: "Test Artist", filePath: "")
|
|
|
+ track.isCloud = true
|
|
|
+ track.cloudStreamPath = "/music/Test/Album/track.flac"
|
|
|
+ track.cloudTrackId = "test-progress-\(UUID().uuidString.prefix(8))"
|
|
|
+
|
|
|
+ // We can't easily mock ChadMusicAPIClient (it reads UserDefaults),
|
|
|
+ // but we can verify the onProgress contract is honored by checking
|
|
|
+ // that DownloadService passes the callback through to ProgressDownloader.
|
|
|
+ //
|
|
|
+ // For a full integration test, we'd need to:
|
|
|
+ // 1. Configure a mock URLSession on DownloadService
|
|
|
+ // 2. Set up ChadMusicAPIClient with test credentials
|
|
|
+ //
|
|
|
+ // This is a placeholder for the integration test — the unit tests
|
|
|
+ // above (testProgressReportsIntermediateValues et al.) cover the
|
|
|
+ // core ProgressDownloader behavior.
|
|
|
+
|
|
|
+ // Verify the onProgress parameter signature is callable
|
|
|
+ let flag = AtomicFlag()
|
|
|
+ let onProgress: @Sendable (Double) -> Void = { value in
|
|
|
+ if value > 0 { flag.value = true }
|
|
|
+ }
|
|
|
+
|
|
|
+ // We can at least verify the callback type matches what downloadPersistent expects
|
|
|
+ let _: @Sendable (Double) -> Void = onProgress
|
|
|
+ XCTAssertFalse(flag.value, "Sanity: no progress should be received without a download")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// MARK: - ProgressDownloader Contract Tests
|
|
|
+
|
|
|
+/// These tests verify the public API contract of ProgressDownloader
|
|
|
+/// independent of URLSession internals. They document expected behavior
|
|
|
+/// that the implementation must satisfy.
|
|
|
+final class ProgressDownloaderContractTests: XCTestCase {
|
|
|
+
|
|
|
+ /// ProgressDownloader.Result should contain both a file URL and an HTTP response.
|
|
|
+ func testResultTypeHasExpectedProperties() async throws {
|
|
|
+ // This test documents the expected Result struct shape.
|
|
|
+ // It compiles only if ProgressDownloader.Result has the right fields.
|
|
|
+ let tempFile = FileManager.default.temporaryDirectory
|
|
|
+ .appendingPathComponent("contract-test-\(UUID().uuidString)")
|
|
|
+ FileManager.default.createFile(atPath: tempFile.path, contents: Data([0x00]))
|
|
|
+ defer { try? FileManager.default.removeItem(at: tempFile) }
|
|
|
+
|
|
|
+ let response = HTTPURLResponse(
|
|
|
+ url: URL(string: "https://test.local/file")!,
|
|
|
+ statusCode: 200,
|
|
|
+ httpVersion: nil,
|
|
|
+ headerFields: nil
|
|
|
+ )!
|
|
|
+
|
|
|
+ let result = ProgressDownloader.Result(fileURL: tempFile, response: response)
|
|
|
+
|
|
|
+ XCTAssertEqual(result.fileURL, tempFile)
|
|
|
+ XCTAssertEqual(result.response.statusCode, 200)
|
|
|
+ }
|
|
|
+
|
|
|
+ /// ProgressDownloader.download should be a static async throwing method
|
|
|
+ /// that accepts a URLRequest and onProgress callback.
|
|
|
+ func testDownloadMethodSignatureExists() {
|
|
|
+ // This test verifies the method signature compiles.
|
|
|
+ // It references the method without calling it.
|
|
|
+ let _: (URLRequest, URLSessionConfiguration, @escaping @Sendable (Double) -> Void) async throws -> ProgressDownloader.Result
|
|
|
+ = ProgressDownloader.download(request:sessionConfiguration:onProgress:)
|
|
|
+ }
|
|
|
+}
|