Files

196 lines
7.4 KiB
Swift

import Domain
import Foundation
@testable import SourceSubsonic
import Synchronization
import Testing
private let source = SubsonicSource.fixture()
@Test func requestsCarryTokenAuth() async throws {
let request = try await source.stream(for: EntityID(source: source.id, remote: "abc"))
let (method, params) = FixtureTransport.method(of: URLRequest(url: request.resource.url))
#expect(method == "stream")
#expect(params["id"] == "abc")
#expect(params["format"] == "raw")
#expect(params["u"] == "h")
#expect(params["t"]?.count == 32)
#expect(params["s"]?.count == 12)
#expect(params["v"] == "1.16.1")
#expect(params["c"] == "Amphlify")
#expect(params["f"] == "json")
#expect(params["p"] == nil)
#expect(request.resource.headers.isEmpty)
}
@Test func saltIsFreshPerRequest() {
let first = FixtureTransport.method(of: URLRequest(url: source.client.url("ping"))).1
let second = FixtureTransport.method(of: URLRequest(url: source.client.url("ping"))).1
#expect(first["s"] != second["s"])
#expect(first["t"] != second["t"])
}
@Test func errorEnvelopeThrows() {
let json = Data(
#"{"subsonic-response":{"status":"failed","version":"1.16.1","error":{"code":40,"message":"Wrong username or password"}}}"#
.utf8
)
#expect(throws: SubsonicError.server(code: 40, message: "Wrong username or password")) {
try SubsonicClient.decode(json, as: EmptyPayload.self)
}
}
@Test func httpErrorThrows() async throws {
let client = try SubsonicClient(baseURL: #require(URL(string: "https://x.test")), username: "h", password: "p") { request in
(Data(), HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)!)
}
await #expect(throws: SubsonicError.http(401)) {
try await client.call("ping")
}
}
@Test func verifyPings() async throws {
try await source.verify()
}
@Test func fullCrawlEmitsWholeLibrary() async throws {
let changes = try await SubsonicSource.fixture().collect()
var artists = 0, albums = 0, tracks = 0, playlists = 0, stations = 0, annotations = 0, removals = 0
var entries: [PlaylistEntry] = []
for change in changes {
switch change {
case .upsert(.artist): artists += 1
case .upsert(.album): albums += 1
case .upsert(.track): tracks += 1
case let .upsert(.playlist(_, list)):
playlists += 1
entries = list
case .upsert(.station): stations += 1
case .annotate: annotations += 1
case .remove: removals += 1
case .checkpoint: break
}
}
#expect(artists == 83)
#expect(tracks == 3)
#expect(albums == 207)
#expect(playlists == 5)
#expect(entries.count == 29)
#expect(entries.first?.id == "SxiV9RaUyXY7MN8nYsWZxG:0")
#expect(stations == 1)
#expect(annotations == 1)
#expect(removals == 0)
guard case .checkpoint = changes.last else {
Issue.record("no checkpoint")
return
}
}
@Test func crawlOrderAndAnnotation() async throws {
let changes = try await SubsonicSource.fixture().collect()
guard case .upsert(.artist) = changes[0], case .upsert(.track) = changes[83], case .upsert(.album) = changes[86] else {
Issue.record("unexpected order")
return
}
let annotation = changes.compactMap { change -> Annotation? in
if case let .annotate(id, annotation) = change, id.remote == "gldIYgc7CP9Qk2i6C5jYsl" {
return annotation
}
return nil
}.first
#expect(annotation?.playCount == 1)
#expect(annotation?.favoritedAt == nil)
#expect(annotation?.lastPlayedAt?.timeIntervalSince1970.rounded() == 1_789_931_942)
}
@Test func secondCrawlHasNoRemovals() async throws {
let source = SubsonicSource.fixture()
_ = try await source.collect()
let second = try await source.collect()
#expect(second.filter {
if case .remove = $0 {
true
} else {
false
}
}.isEmpty)
}
@Test func removalAfterAlbumDisappears() async throws {
let base = FixtureTransport()
let dropped = FixtureTransport(droppedAlbum: "0OpO6hd2TejwnaHREMESA6")
let flag = Mutex(false)
let client = try SubsonicClient(baseURL: #require(URL(string: "https://x.test")), username: "h", password: "p") { request in
try await (flag.withLock { $0 } ? dropped : base)(request)
}
let source = SubsonicSource(id: SourceID(), name: "Test", client: client)
_ = try await source.collect()
flag.withLock { $0 = true }
let removals = try await source.collect().compactMap { change -> (EntityKind, EntityID)? in
if case let .remove(kind, id) = change {
return (kind, id)
}
return nil
}
#expect(removals.count == 1)
#expect(removals.first?.0 == .album)
#expect(removals.first?.1.remote == "0OpO6hd2TejwnaHREMESA6")
}
@Test func flacFormatMapping() throws {
let payload = try SubsonicClient.decode(FixtureTransport.fixture("album"), as: AlbumPayload.self)
let song = try #require(payload.album.song?.first)
let format = try #require(AudioFormat(song: song))
#expect(format.codec == .flac)
#expect(format.isLossless)
#expect(format.sampleRate == 44100)
#expect(format.bitDepth == 16)
#expect(format.channels == 2)
#expect(format.bitrateKbps == 173)
#expect(format.isBetter(than: AudioFormat(codec: .mp3, bitrateKbps: 320, sampleRate: 48000)))
}
@Test func lyricsParsing() throws {
let json = Data(#"""
{"subsonic-response":{"status":"ok","version":"1.16.1","lyricsList":{"structuredLyrics":[
{"lang":"xxx","synced":false,"displayArtist":"A","displayTitle":"T","line":[{"value":"plain"}]},
{"lang":"xxx","synced":true,"displayArtist":"A","displayTitle":"T","offset":100,
"line":[{"start":1000,"value":"one"},{"start":2500,"value":"two"}]}
]}}}
"""#.utf8)
let payload = try SubsonicClient.decode(json, as: LyricsPayload.self)
let lyrics = try #require(SubsonicMapper(source: SourceID()).lyrics(payload.lyricsList.structuredLyrics ?? []))
#expect(lyrics.isSynced)
#expect(lyrics.lines.map(\.text) == ["one", "two"])
#expect(lyrics.lines.map(\.time) == [1.1, 2.6])
}
@Test func emptyLyricsIsNil() async throws {
#expect(try await source.lyrics(for: EntityID(source: source.id, remote: "x")) == nil)
}
@Test func artworkRequest() async throws {
let request = try await source.artwork(EntityID(source: source.id, remote: "al-1"), pixelSize: 300)
let (method, params) = FixtureTransport.method(of: URLRequest(url: request.url))
#expect(method == "getCoverArt")
#expect(params["id"] == "al-1")
#expect(params["size"] == "300")
#expect(params["t"] != nil)
}
@Test func streamRemembersFormat() async throws {
let source = SubsonicSource.fixture()
_ = try await source.collect()
let request = try await source.stream(for: EntityID(source: source.id, remote: "gldIYgc7CP9Qk2i6C5jYsl"))
#expect(request.format?.codec == .mp3)
#expect(request.format?.bitrateKbps == 192)
#expect(request.resource.url.absoluteString.contains("format=raw"))
}
@Test func nanosecondDatesParse() {
let date = SubsonicDate.parse("2026-09-21T13:33:43.30709994Z")
#expect(date.map { abs($0.timeIntervalSince1970 - 1_789_997_623.307) < 0.001 } == true)
#expect(SubsonicDate.parse("2026-08-20T13:57:31.661Z") != nil)
#expect(SubsonicDate.parse("2026-08-20T13:57:31Z") != nil)
#expect(SubsonicDate.parse("garbage") == nil)
}