You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
Music/MusicTests/HostServerIntegrationTests....

165 lines
7.6 KiB

import Testing
import Foundation
import MusicShared
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(provider: 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)
}
}
}
}
}