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= 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//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) } // Reproduces the "seeking does not work in streaming mode" bug. // // The client plays the /file endpoint through a plain AVURLAsset/AVPlayer // with no custom resource loader, so AVPlayer relies on the OS's default // HTTP transport. AVPlayer only treats a progressive HTTP asset as // *seekable* when the server honors HTTP byte-range requests: a `Range` // request must be answered with `206 Partial Content`, a matching // `Content-Range`, `Accept-Ranges: bytes`, a `Content-Length` equal to the // slice length, and a body containing ONLY the requested slice. The /file // endpoint currently ignores the Range header and always returns the whole // file with `200 OK`, so AVPlayer concludes the stream has no random access // and silently refuses to seek. // // Steps: // 1. Insert a track pointing at a real audio fixture and start the server. // 2. Download the full file once to learn its total byte length and exact // bytes (the ground truth we slice against). // 3. Request a middle byte range (Range: bytes=10-19) from /file. // 4. Expect 206 Partial Content, Content-Range: bytes 10-19/, // Accept-Ranges: bytes, Content-Length: 10, and a body byte-for-byte // equal to fullBytes[10...19]. @Test(.timeLimit(.minutes(1))) func fileEndpointHonorsRangeRequests() async throws { // 1. Insert a track + start the server. let db = try DatabaseService(inMemory: true) let fixtureURL = try TestFixtures.shortMP3URL() var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "Range Test") try db.insert(&track) let trackId = try #require(track.id) 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)" let fileURL = URL(string: "\(baseURL)/file?id=\(trackId)&token=\(Self.testAPIKey)")! // 2. Download the full file to establish ground-truth length + bytes. let (fullData, fullResp) = try await URLSession.shared.data(for: URLRequest(url: fileURL)) let fullHTTP = try #require(fullResp as? HTTPURLResponse) #expect(fullHTTP.statusCode == 200) let total = fullData.count #expect(total > 20) // fixture must be large enough to slice a middle range // 3. Request bytes 10-19 (10 bytes, inclusive range). var rangeReq = URLRequest(url: fileURL) rangeReq.setValue("bytes=10-19", forHTTPHeaderField: "Range") let (rangeData, rangeResp) = try await URLSession.shared.data(for: rangeReq) let rangeHTTP = try #require(rangeResp as? HTTPURLResponse) // 4. Assert proper Partial Content semantics. #expect(rangeHTTP.statusCode == 206) #expect(rangeHTTP.value(forHTTPHeaderField: "Accept-Ranges") == "bytes") #expect(rangeHTTP.value(forHTTPHeaderField: "Content-Range") == "bytes 10-19/\(total)") #expect(rangeHTTP.value(forHTTPHeaderField: "Content-Length") == "10") #expect(rangeData.count == 10) #expect(rangeData == fullData.subdata(in: 10..<20)) } // A plain GET with no Range header must still succeed AND advertise // `Accept-Ranges: bytes` up front, so AVPlayer knows before scrubbing that // the stream supports random access. Without this header AVPlayer never // enables seeking even if it would have gotten 206s. // // Steps: // 1. Insert a track + start the server. // 2. GET /file with no Range header. // 3. Expect 200 OK, Accept-Ranges: bytes, and the full body. @Test(.timeLimit(.minutes(1))) func fileEndpointAdvertisesByteRangesWithoutRangeHeader() async throws { // 1. Insert a track + start the server. let db = try DatabaseService(inMemory: true) let fixtureURL = try TestFixtures.shortMP3URL() var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "Accept-Ranges Test") try db.insert(&track) let trackId = try #require(track.id) 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)" // 2. Plain GET, no Range header. let fileURL = URL(string: "\(baseURL)/file?id=\(trackId)&token=\(Self.testAPIKey)")! let (data, resp) = try await URLSession.shared.data(for: URLRequest(url: fileURL)) let http = try #require(resp as? HTTPURLResponse) // 3. Full body returned, but server advertises range support. #expect(http.statusCode == 200) #expect(http.value(forHTTPHeaderField: "Accept-Ranges") == "bytes") #expect(data.count > 0) } // An open-ended range (Range: bytes=N-) means "from byte N to the end" — // the form AVPlayer most commonly issues while streaming forward. The // server must return 206 with the tail of the file and a Content-Range // whose end is total-1. // // Steps: // 1. Insert a track + start the server. // 2. Download the full file for ground-truth length + bytes. // 3. Request Range: bytes=10- (byte 10 through EOF). // 4. Expect 206, Content-Range: bytes 10-/, and a body // equal to fullBytes[10...]. @Test(.timeLimit(.minutes(1))) func fileEndpointHonorsOpenEndedRange() async throws { // 1. Insert a track + start the server. let db = try DatabaseService(inMemory: true) let fixtureURL = try TestFixtures.shortMP3URL() var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "Open Range Test") try db.insert(&track) let trackId = try #require(track.id) 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)" let fileURL = URL(string: "\(baseURL)/file?id=\(trackId)&token=\(Self.testAPIKey)")! // 2. Ground-truth full file. let (fullData, _) = try await URLSession.shared.data(for: URLRequest(url: fileURL)) let total = fullData.count #expect(total > 10) // 3. Open-ended range from byte 10. var rangeReq = URLRequest(url: fileURL) rangeReq.setValue("bytes=10-", forHTTPHeaderField: "Range") let (rangeData, rangeResp) = try await URLSession.shared.data(for: rangeReq) let rangeHTTP = try #require(rangeResp as? HTTPURLResponse) // 4. Tail of the file, correctly described. #expect(rangeHTTP.statusCode == 206) #expect(rangeHTTP.value(forHTTPHeaderField: "Content-Range") == "bytes 10-\(total - 1)/\(total)") #expect(rangeData.count == total - 10) #expect(rangeData == fullData.subdata(in: 10..