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.
165 lines
7.6 KiB
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|