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.
204 lines
9.6 KiB
204 lines
9.6 KiB
import Testing
|
|
import Foundation
|
|
import MusicShared
|
|
@testable import Music
|
|
|
|
@MainActor
|
|
struct StreamingIntegrationTests {
|
|
static let testAPIKey = "integration-test-key"
|
|
|
|
// Full flow: start server, authenticate, download DB, verify track is present.
|
|
// Steps:
|
|
// 1. Create an in-memory DB and insert a test track
|
|
// 2. Start StreamingServer on a random port
|
|
// 3. Authenticate via GET /auth
|
|
// 4. Download DB via GET /db
|
|
// 5. Save downloaded DB to disk and verify the track is present
|
|
@Test(.timeLimit(.minutes(1)))
|
|
func fullConnectionFlow() async throws {
|
|
let db = try DatabaseService(inMemory: true)
|
|
var track = Track.fixture(id: nil, fileURL: "/tmp/test.mp3", title: "Test Song")
|
|
try db.insert(&track)
|
|
|
|
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0)
|
|
try await server.start()
|
|
defer { server.stop() }
|
|
let port = try #require(server.actualPort)
|
|
let baseURL = "http://127.0.0.1:\(port)"
|
|
|
|
// 3. Authenticate
|
|
var authReq = URLRequest(url: URL(string: "\(baseURL)/auth")!)
|
|
authReq.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization")
|
|
let (authData, authResp) = try await URLSession.shared.data(for: authReq)
|
|
let authHTTP = try #require(authResp as? HTTPURLResponse)
|
|
#expect(authHTTP.statusCode == 200)
|
|
let authResponse = try JSONDecoder().decode(AuthResponse.self, from: authData)
|
|
#expect(authResponse.protocolVersion == StreamingConstants.protocolVersion)
|
|
|
|
// 4. Download DB
|
|
var dbReq = URLRequest(url: URL(string: "\(baseURL)/db")!)
|
|
dbReq.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization")
|
|
let (dbData, dbResp) = try await URLSession.shared.data(for: dbReq)
|
|
let dbHTTP = try #require(dbResp as? HTTPURLResponse)
|
|
#expect(dbHTTP.statusCode == 200)
|
|
#expect(dbData.count > 0)
|
|
|
|
// 5. Verify downloaded DB contains the track
|
|
let tempPath = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("integration_test_\(UUID().uuidString).sqlite").path
|
|
defer { try? FileManager.default.removeItem(atPath: tempPath) }
|
|
try dbData.write(to: URL(fileURLWithPath: tempPath))
|
|
let downloadedDb = try DatabaseService(path: tempPath)
|
|
let tracks = try downloadedDb.fetchTracks(search: "", sortColumn: "title", ascending: true)
|
|
#expect(tracks.count == 1)
|
|
#expect(tracks[0].title == "Test Song")
|
|
}
|
|
|
|
// Verifies that requests without auth get 401.
|
|
@Test(.timeLimit(.minutes(1)))
|
|
func unauthenticatedRequestsRejected() async throws {
|
|
let db = try DatabaseService(inMemory: true)
|
|
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0)
|
|
try await server.start()
|
|
defer { server.stop() }
|
|
let port = try #require(server.actualPort)
|
|
|
|
let request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/auth")!)
|
|
let (_, response) = try await URLSession.shared.data(for: request)
|
|
let httpResponse = try #require(response as? HTTPURLResponse)
|
|
#expect(httpResponse.statusCode == 401)
|
|
}
|
|
|
|
// Verifies the /tracks/:trackId/file endpoint serves audio data.
|
|
@Test(.timeLimit(.minutes(1)))
|
|
func fileEndpointServesTrack() async throws {
|
|
// 1. Create DB with a test track pointing to a real audio file
|
|
let db = try DatabaseService(inMemory: true)
|
|
let fixtureURL = try TestFixtures.shortMP3URL()
|
|
var track = Track.fixture(id: nil, fileURL: fixtureURL.path, title: "Stream Test")
|
|
try db.insert(&track)
|
|
let trackId = try #require(track.id)
|
|
|
|
// 2. Start server
|
|
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0)
|
|
try await server.start()
|
|
defer { server.stop() }
|
|
let port = try #require(server.actualPort)
|
|
let baseURL = "http://127.0.0.1:\(port)"
|
|
|
|
// 3. Request via Bearer auth
|
|
var bearerReq = URLRequest(url: URL(string: "\(baseURL)/file?id=\(trackId)")!)
|
|
bearerReq.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization")
|
|
let (bearerData, bearerResp) = try await URLSession.shared.data(for: bearerReq)
|
|
let bearerHTTP = try #require(bearerResp as? HTTPURLResponse)
|
|
#expect(bearerHTTP.statusCode == 200)
|
|
#expect(bearerData.count > 0)
|
|
|
|
// 4. Request via token query param
|
|
let tokenURL = URL(string: "\(baseURL)/file?id=\(trackId)&token=\(Self.testAPIKey)")!
|
|
let (tokenData, tokenResp) = try await URLSession.shared.data(for: URLRequest(url: tokenURL))
|
|
let tokenHTTP = try #require(tokenResp as? HTTPURLResponse)
|
|
#expect(tokenHTTP.statusCode == 200)
|
|
#expect(tokenData.count == bearerData.count)
|
|
|
|
// 5. Unauthenticated request should be 401
|
|
let noAuthURL = URL(string: "\(baseURL)/file?id=\(trackId)")!
|
|
let (_, noAuthResp) = try await URLSession.shared.data(for: URLRequest(url: noAuthURL))
|
|
let noAuthHTTP = try #require(noAuthResp as? HTTPURLResponse)
|
|
#expect(noAuthHTTP.statusCode == 401)
|
|
}
|
|
|
|
// Verifies that wrong API key gets 401.
|
|
@Test(.timeLimit(.minutes(1)))
|
|
func wrongApiKeyRejected() async throws {
|
|
let db = try DatabaseService(inMemory: true)
|
|
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0)
|
|
try await server.start()
|
|
defer { server.stop() }
|
|
let port = try #require(server.actualPort)
|
|
|
|
var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/auth")!)
|
|
request.setValue("Bearer wrong-key", forHTTPHeaderField: "Authorization")
|
|
let (_, response) = try await URLSession.shared.data(for: request)
|
|
let httpResponse = try #require(response as? HTTPURLResponse)
|
|
#expect(httpResponse.statusCode == 401)
|
|
}
|
|
|
|
// Reproduces the real-world "File not found on disk" (HTTP 404) bug.
|
|
//
|
|
// The production scanner (ScannerService) stores `fileURL` as
|
|
// `url.absoluteString` — e.g. "file:///Users/.../song.m4a" — WITH the
|
|
// "file://" scheme and percent-encoding. StreamingServer reconstructed it
|
|
// with `URL(fileURLWithPath:)`, which treats that whole string as a raw
|
|
// (relative) path, prepends the CWD, and skips percent-decoding, so the file
|
|
// is never found on disk. The earlier `fileEndpointServesTrack` test masked
|
|
// this because it stored `fixtureURL.path` (a bare path) instead.
|
|
//
|
|
// Steps:
|
|
// 1. Create a DB with a track whose fileURL is stored EXACTLY as the scanner
|
|
// stores it: `fixtureURL.absoluteString` (not `.path`).
|
|
// 2. Start the streaming server.
|
|
// 3. Request GET /file?id=<trackId> with a valid token.
|
|
// 4. Expect HTTP 200 with the file's bytes. Before the fix this returns 404
|
|
// with body {"error":{"message":"File not found on disk"}}.
|
|
@Test(.timeLimit(.minutes(1)))
|
|
func fileEndpointServesTrackStoredAsAbsoluteString() async throws {
|
|
// 1. Insert a track using the production storage format (absoluteString).
|
|
let db = try DatabaseService(inMemory: true)
|
|
let fixtureURL = try TestFixtures.shortMP3URL()
|
|
var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "Abs Stream Test")
|
|
try db.insert(&track)
|
|
let trackId = try #require(track.id)
|
|
|
|
// 2. Start the server.
|
|
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0)
|
|
try await server.start()
|
|
defer { server.stop() }
|
|
let port = try #require(server.actualPort)
|
|
let baseURL = "http://127.0.0.1:\(port)"
|
|
|
|
// 3. Request the file via the token query parameter.
|
|
let url = URL(string: "\(baseURL)/file?id=\(trackId)&token=\(Self.testAPIKey)")!
|
|
let (data, resp) = try await URLSession.shared.data(for: URLRequest(url: url))
|
|
let http = try #require(resp as? HTTPURLResponse)
|
|
|
|
// 4. Must serve the bytes, not 404.
|
|
#expect(http.statusCode == 200)
|
|
#expect(data.count > 0)
|
|
}
|
|
|
|
// Same root cause as above, exercised through the HLS path (SegmenterCache
|
|
// also used URL(fileURLWithPath:) on the stored fileURL).
|
|
//
|
|
// Steps:
|
|
// 1. Insert a track whose fileURL is stored as `absoluteString`.
|
|
// 2. Start the server.
|
|
// 3. Request GET /tracks/<id>/segments/0.mp3 with a valid token.
|
|
// 4. Expect HTTP 200 with segment bytes (before the fix the segmenter cannot
|
|
// open the file, so this returns a 404/error).
|
|
@Test(.timeLimit(.minutes(1)))
|
|
func segmentEndpointServesTrackStoredAsAbsoluteString() async throws {
|
|
// 1. Insert a track using the production storage format (absoluteString).
|
|
let db = try DatabaseService(inMemory: true)
|
|
let fixtureURL = try TestFixtures.shortMP3URL()
|
|
var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "Abs HLS Test")
|
|
try db.insert(&track)
|
|
let trackId = try #require(track.id)
|
|
|
|
// 2. Start the server.
|
|
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0)
|
|
try await server.start()
|
|
defer { server.stop() }
|
|
let port = try #require(server.actualPort)
|
|
let baseURL = "http://127.0.0.1:\(port)"
|
|
|
|
// 3. Request the first HLS segment via the token query parameter.
|
|
let url = URL(string: "\(baseURL)/tracks/\(trackId)/segments/0.mp3?token=\(Self.testAPIKey)")!
|
|
let (data, resp) = try await URLSession.shared.data(for: URLRequest(url: url))
|
|
let http = try #require(resp as? HTTPURLResponse)
|
|
|
|
// 4. Must serve segment bytes, not 404.
|
|
#expect(http.statusCode == 200)
|
|
#expect(data.count > 0)
|
|
}
|
|
}
|
|
|