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.
343 lines
17 KiB
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))
|
|
}
|
|
}
|
|
|