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/StreamingIntegrationTests.s...

343 lines
17 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)
}
// 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/<total>,
// 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-<total-1>/<total>, 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..<total))
}
}