196 lines
7.4 KiB
Swift
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)
|
|
}
|