import Testing import Foundation import Network @testable import Music @MainActor struct HostServerIntegrationTests { // Starts a HostServer, connects via TCP, sends GET /db, // verifies the response contains valid SQLite data. @Test(.timeLimit(.minutes(1))) func dbDownloadReturnsValidSQLite() async throws { // 1. Create a temp directory and a SQLite database with one track let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let dbPath = tempDir.appendingPathComponent("db.sqlite").path let db = try DatabaseService(path: dbPath) var track = Track.fixture(fileURL: "/test.mp3") try db.insert(&track) // 2. Start the HostServer (configured with db for WAL checkpoint) and wait for the listener to be ready let server = HostServer(dbPath: dbPath) server.configure(player: nil, db: db) try server.start() try await Task.sleep(for: .milliseconds(200)) let port = server.actualPort! // 3. Perform an HTTP GET /db request to download the database let responseData = try await httpGet(host: "127.0.0.1", port: port, path: "/db") // 4. Verify the response starts with the SQLite magic header let header = String(data: responseData.prefix(16), encoding: .utf8) ?? "" #expect(header.hasPrefix("SQLite format 3")) // 5. Write the downloaded data to disk and verify it contains the inserted track let downloadedPath = tempDir.appendingPathComponent("downloaded.sqlite").path try responseData.write(to: URL(fileURLWithPath: downloadedPath)) let downloadedDb = try DatabaseService(path: downloadedPath) #expect(try downloadedDb.trackCount() == 1) // 6. Clean up server.stop() try? FileManager.default.removeItem(at: tempDir) } // Connects to /cmd, sends a pause command, verifies a playbackState event comes back. @Test(.timeLimit(.minutes(1))) func commandChannelRoundTrip() async throws { // 1. Create a temp directory and an empty SQLite database let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let dbPath = tempDir.appendingPathComponent("db.sqlite").path _ = try DatabaseService(path: dbPath) // 2. Set up the player and server with command dispatch configured let audio = AudioService() let player = PlayerViewModel(audio: audio, db: nil) let server = HostServer(dbPath: dbPath) server.configure(player: player, db: nil) try server.start() try await Task.sleep(for: .milliseconds(200)) let port = server.actualPort! // 3. Open a command channel connection via GET /cmd let connection = try await connectCommandChannel(host: "127.0.0.1", port: port) // 4. Send a pause command as NDJSON let pauseCmd = try JSONEncoder().encode(RemoteCommand.pause) var lineData = pauseCmd lineData.append(contentsOf: "\n".utf8) connection.send(content: lineData, completion: .contentProcessed { _ in }) // 5. Wait for and decode the playbackState response event let responseLine = try await receiveOneLine(on: connection) let event = try JSONDecoder().decode(HostEvent.self, from: Data(responseLine.utf8)) // 6. Verify it is a playbackState event with isPlaying == false if case .playbackState(let payload) = event { #expect(payload.isPlaying == false) } else { Issue.record("Expected playbackState, got \(event)") } // 7. Clean up connection.cancel() server.stop() try? FileManager.default.removeItem(at: tempDir) } // MARK: - Helpers /// Performs a simple HTTP GET using NWConnection and returns the response body. private func httpGet(host: String, port: UInt16, path: String) async throws -> Data { try await withCheckedThrowingContinuation { continuation in let connection = NWConnection( host: NWEndpoint.Host(host), port: NWEndpoint.Port(rawValue: port)!, using: .tcp ) connection.stateUpdateHandler = { state in if case .ready = state { // Send the HTTP request let request = "GET \(path) HTTP/1.1\r\nHost: \(host)\r\nConnection: close\r\n\r\n" connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in }) // Receive the full response (Connection: close ensures we get everything) connection.receiveMessage { data, _, _, error in if let error { continuation.resume(throwing: error) } else if let data, let range = data.range(of: Data("\r\n\r\n".utf8)) { // Strip HTTP headers, return just the body continuation.resume(returning: Data(data[range.upperBound...])) } else { continuation.resume(returning: data ?? Data()) } connection.cancel() } } else if case .failed(let error) = state { continuation.resume(throwing: error) } } connection.start(queue: .main) } } /// Opens a TCP connection to the /cmd endpoint and waits for the HTTP response header. private func connectCommandChannel(host: String, port: UInt16) async throws -> NWConnection { try await withCheckedThrowingContinuation { continuation in let connection = NWConnection( host: NWEndpoint.Host(host), port: NWEndpoint.Port(rawValue: port)!, using: .tcp ) connection.stateUpdateHandler = { state in if case .ready = state { // Send the HTTP upgrade request for the command channel let request = "GET /cmd HTTP/1.1\r\nHost: \(host)\r\nConnection: keep-alive\r\n\r\n" connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in }) // Consume the HTTP 200 response header connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { _, _, _, _ in continuation.resume(returning: connection) } } else if case .failed(let error) = state { continuation.resume(throwing: error) } } connection.start(queue: .main) } } /// Reads one newline-delimited line from a connection. private func receiveOneLine(on connection: NWConnection) async throws -> String { try await withCheckedThrowingContinuation { continuation in connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, error in if let error { continuation.resume(throwing: error) } else { let text = data.flatMap { String(data: $0, encoding: .utf8) } ?? "" // Extract the first complete line from the received data continuation.resume(returning: text.split(separator: "\n").first.map(String.init) ?? text) } } } } }