import Foundation import Testing @testable import PlexKit /// Lädt eine Antwort-Vorlage aus dem Test-Bundle. private func fixture(_ name: String) throws -> Data { let url = try #require( Bundle.module.url(forResource: "Fixtures/\(name)", withExtension: "json"), "Vorlage \(name).json nicht im Bundle — steht sie in Package.swift unter resources?" ) return try Data(contentsOf: url) } @Suite("Antworten lesen") struct PlexDecodingTests { @Test("Ein Film mit zwei Fassungen wird vollständig gelesen") func decodesMovieWithTwoVersions() throws { let container = try PlexResponse.decode( PlexMediaContainer.self, from: try fixture("movie-with-two-versions") ) let movie = try #require(container.content.first) #expect(movie.ratingKey == "45231") #expect(movie.kind == .movie) #expect(movie.title == "Blade Runner 2049") #expect(movie.year == 2017) #expect(movie.hasMultipleVersions) #expect(movie.media.count == 2) } @Test("Die Fassung mit der höchsten Bitrate wird vorgewählt") func prefersHighestBitrateVersion() throws { let container = try PlexResponse.decode( PlexMediaContainer.self, from: try fixture("movie-with-two-versions") ) let movie = try #require(container.content.first) let preferred = try #require(movie.preferredMedia) #expect(preferred.videoResolution == "4k") #expect(preferred.bitrate == 58420) } @Test("Der Fortschritt wird als Anteil berechnet") func computesWatchProgress() throws { let container = try PlexResponse.decode( PlexMediaContainer.self, from: try fixture("movie-with-two-versions") ) let movie = try #require(container.content.first) let progress = try #require(movie.watchProgress) // 4 892 000 von 9 784 000 Millisekunden — genau die Hälfte. #expect(abs(progress - 0.5) < 0.001) } @Test("Spuren werden nach Art getrennt") func separatesStreamsByKind() throws { let container = try PlexResponse.decode( PlexMediaContainer.self, from: try fixture("movie-with-two-versions") ) let part = try #require(container.content.first?.preferredMedia?.parts.first) #expect(part.videoStream?.codec == "hevc") #expect(part.audioStreams.count == 2) #expect(part.subtitleStreams.count == 2) #expect(part.selectedAudioStream?.language == "Deutsch") } @Test("Untertitel als eigene Datei werden erkannt") func detectsExternalSubtitles() throws { let container = try PlexResponse.decode( PlexMediaContainer.self, from: try fixture("movie-with-two-versions") ) let part = try #require(container.content.first?.preferredMedia?.parts.first) let subtitles = part.subtitleStreams // Die SRT-Spur liegt als Datei daneben und lässt sich deshalb auch bei // DirectPlay dazuschalten; die PGS-Spur steckt im Container. #expect(subtitles.first { $0.codec == "subrip" }?.isExternal == true) #expect(subtitles.first { $0.codec == "pgs" }?.isExternal == false) #expect(subtitles.first { $0.codec == "pgs" }?.isForced == true) } @Test("Folgen ohne analysierte Datei brechen das Lesen nicht ab") func toleratesUnanalyzedEpisodes() throws { // Der wichtigste Fall aus der Praxis: Plex liefert Einträge aus, bevor // es die Dateien durchgesehen hat. Ein nicht-optionales Feld würde // hier die ganze Staffel unlesbar machen statt nur eine Angabe. let container = try PlexResponse.decode( PlexMediaContainer.self, from: try fixture("episodes-unanalyzed") ) #expect(container.content.count == 2) let first = try #require(container.content.first) #expect(first.media.first?.parts.first?.streams.isEmpty == true) // Die zweite Folge hat gar keinen `Media`-Block. let second = try #require(container.content.last) #expect(second.media.isEmpty) #expect(second.title == "Die zweite Folge") #expect(second.index == 2) } @Test("Serienzugehörigkeit von Folgen wird gelesen") func decodesEpisodeHierarchy() throws { let container = try PlexResponse.decode( PlexMediaContainer.self, from: try fixture("episodes-unanalyzed") ) let episode = try #require(container.content.first) #expect(episode.kind == .episode) #expect(episode.index == 1) #expect(episode.parentIndex == 1) #expect(episode.grandparentTitle == "Eine Serie") #expect(episode.grandparentRatingKey == "77700") } @Test("Ungesehene Titel haben keinen Fortschritt") func unwatchedItemsHaveNoProgress() throws { let container = try PlexResponse.decode( PlexMediaContainer.self, from: try fixture("episodes-unanalyzed") ) // Ohne diese Unterscheidung bekäme jede ungesehene Kachel einen // Fortschrittsbalken der Breite null — sichtbarer Strich, keine Aussage. #expect(container.content.allSatisfy { $0.watchProgress == nil }) #expect(container.content.allSatisfy { !$0.isWatched }) } @Test("Kaputte Antworten melden, woran es lag") func decodingErrorsCarryDetail() { let garbage = Data(#"{"MediaContainer":{"Metadata":[{"title":"ohne ratingKey"}]}}"#.utf8) #expect(throws: PlexError.self) { try PlexResponse.decode(PlexMediaContainer.self, from: garbage) } do { _ = try PlexResponse.decode(PlexMediaContainer.self, from: garbage) Issue.record("Hätte scheitern müssen") } catch let error as PlexError { guard case .decoding(let description) = error else { Issue.record("Falsche Fehlerart: \(error)") return } // Der Schlüsselpfad muss drinstehen — sonst ist beim Nachziehen // einer geänderten Plex-Version nicht zu erkennen, welches Feld // gewandert ist. #expect(description.contains("ratingKey")) } catch { Issue.record("Unerwarteter Fehler: \(error)") } } } @Suite("HTTP-Status übersetzen") struct PlexResponseValidationTests { private func response(_ status: Int) -> HTTPURLResponse { HTTPURLResponse( url: URL(string: "http://server:32400/library/sections")!, statusCode: status, httpVersion: nil, headerFields: nil )! } @Test("Erfolgsstatus geben die Daten durch", arguments: [200, 201, 204]) func successPassesDataThrough(status: Int) throws { let payload = Data("inhalt".utf8) #expect(try PlexResponse.validate(data: payload, response: response(status)) == payload) } @Test("401 und 403 führen zurück zur Anmeldung", arguments: [401, 403]) func authenticationFailuresMapToUnauthorized(status: Int) { #expect(throws: PlexError.unauthorized) { try PlexResponse.validate(data: Data(), response: response(status)) } } @Test("404 ist ein fehlender Eintrag") func notFoundMaps() { #expect(throws: PlexError.notFound) { try PlexResponse.validate(data: Data(), response: response(404)) } } @Test("Serverfehler dürfen wiederholt werden, Anfragefehler nicht") func retryabilityFollowsStatusClass() { #expect(PlexError.server(status: 503).isRetryable) #expect(PlexError.server(status: 500).isRetryable) #expect(!PlexError.server(status: 400).isRetryable) #expect(!PlexError.unauthorized.isRetryable) #expect(PlexError.transport(description: "Zeitüberschreitung").isRetryable) } }