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) }