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.. 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.. 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:) } }