Files
plezy/tvos/RunnerTests/TopShelfFetchContractTests.swift
T
edde746 291a22a4a4 feat(tvos): fetch Top Shelf content live and show poster art
The Top Shelf extension now fetches Continue Watching directly from
Plex/Jellyfin/Emby instead of replaying a cache the app wrote on its
last foreground Discover pass. The app publishes per-profile server
descriptors on every shelf sync (`updateSources`): token-free metadata
in the app group, tokens in an app-group-shared keychain item, both
wiped by `clear`. On success the extension rewrites the cached payload
as the offline fallback; any fetch failure falls back to the previous
cache-replay behavior. Poster images are passed as remote URLs, so the
extension no longer depends on app-side artwork downloads.

Episodes now render season/series poster art (2:3, `.poster` shape)
instead of 16:9 episode stills, and labels lead with the S/E marker so
long titles no longer hide it behind the focused-item marquee. Shelf
schema v3 (Dart, Android, tvOS envelopes bumped together) discards
stale wide-art caches instead of letterboxing them into poster slots.

close #1474
close #1835
2026-08-09 10:59:16 +02:00

410 lines
14 KiB
Swift

import Foundation
import XCTest
@testable import Runner
// Contract tests for the Top Shelf live-fetch pipeline. The extension
// sources under test (ShelfSources/ShelfItemMapper/ShelfFetcher) also compile
// into the Runner app target, which this hosted bundle imports testably.
// No network is touched.
final class TopShelfFetchContractTests: XCTestCase {
private let token = "secret-token-123"
private let baseUrl = "https://media.example.com:32400"
private let serverId = "srv-1"
// MARK: - Plex mapping
private static let plexContinueWatchingFixture = """
{
"MediaContainer": {
"Metadata": [
{
"ratingKey": "101",
"type": "episode",
"title": "The Beach",
"grandparentTitle": "Lost",
"parentIndex": 2,
"index": 5,
"duration": 3600000,
"viewOffset": 900000,
"lastViewedAt": 1700000100,
"parentThumb": "/library/metadata/55/thumb/111",
"grandparentThumb": "/library/metadata/50/thumb/99",
"thumb": "/library/metadata/101/thumb/33"
},
{
"ratingKey": "202",
"type": "movie",
"title": "Heat",
"duration": 10200000,
"viewOffset": 5100000,
"lastViewedAt": 1700000200,
"thumb": "/library/metadata/202/thumb/77"
}
]
}
}
"""
func testPlexEpisodeMappingUsesSeriesTitleAndSeasonPoster() throws {
let items = ShelfItemMapper.plexItems(
fromResponse: try fixture(Self.plexContinueWatchingFixture),
serverId: serverId,
baseUrl: baseUrl,
token: token
)
XCTAssertEqual(items.count, 2)
let episode = items[0]
XCTAssertEqual(episode.contentId, "plezy_srv-1_101")
XCTAssertEqual(episode.title, "Lost")
XCTAssertEqual(episode.episodeTitle, "The Beach")
XCTAssertEqual(episode.seasonNumber, 2)
XCTAssertEqual(episode.episodeNumber, 5)
XCTAssertEqual(episode.durationMilliseconds, 3_600_000)
XCTAssertEqual(episode.lastPlaybackPositionMilliseconds, 900_000)
XCTAssertEqual(try XCTUnwrap(episode.playbackProgress), 0.25, accuracy: 0.0001)
XCTAssertEqual(episode.recency, 1_700_000_100)
// Season poster (parentThumb) wins the chain.
XCTAssertEqual(
episode.posterUrl,
"\(baseUrl)/photo/:/transcode?width=600&height=900&minSize=1&upscale=1"
+ "&url=%2Flibrary%2Fmetadata%2F55%2Fthumb%2F111&X-Plex-Token=\(token)"
)
let movie = items[1]
XCTAssertEqual(movie.contentId, "plezy_srv-1_202")
XCTAssertEqual(movie.title, "Heat")
XCTAssertNil(movie.episodeTitle)
XCTAssertNil(movie.seasonNumber)
XCTAssertNil(movie.episodeNumber)
XCTAssertEqual(try XCTUnwrap(movie.playbackProgress), 0.5, accuracy: 0.0001)
XCTAssertEqual(
movie.posterUrl,
"\(baseUrl)/photo/:/transcode?width=600&height=900&minSize=1&upscale=1"
+ "&url=%2Flibrary%2Fmetadata%2F202%2Fthumb%2F77&X-Plex-Token=\(token)"
)
}
func testPlexPosterChainFallsBackThroughSeriesThenOwnThumb() {
let base: [String: Any] = ["ratingKey": "1", "type": "episode", "title": "Ep", "grandparentTitle": "Show"]
var metadata = base
metadata["grandparentThumb"] = "/library/metadata/50/thumb/99"
metadata["thumb"] = "/library/metadata/1/thumb/1"
let seriesPoster = ShelfItemMapper.plexItem(metadata, serverId: serverId, baseUrl: baseUrl, token: token)
XCTAssertEqual(
seriesPoster?.posterUrl?.contains("url=%2Flibrary%2Fmetadata%2F50%2Fthumb%2F99"),
true
)
metadata = base
metadata["thumb"] = "/library/metadata/1/thumb/1"
let ownThumb = ShelfItemMapper.plexItem(metadata, serverId: serverId, baseUrl: baseUrl, token: token)
XCTAssertEqual(ownThumb?.posterUrl?.contains("url=%2Flibrary%2Fmetadata%2F1%2Fthumb%2F1"), true)
let bare = ShelfItemMapper.plexItem(base, serverId: serverId, baseUrl: baseUrl, token: token)
XCTAssertNotNil(bare)
XCTAssertNil(bare?.posterUrl)
}
func testPlexHubWrappedResponseIsTolerated() throws {
let wrapped = """
{
"MediaContainer": {
"Hub": [
{"Metadata": [{"ratingKey": "7", "type": "movie", "title": "Alien", "thumb": "/library/metadata/7/thumb/1"}]},
{"Metadata": [{"ratingKey": "8", "type": "movie", "title": "Aliens"}]}
]
}
}
"""
let items = ShelfItemMapper.plexItems(
fromResponse: try fixture(wrapped),
serverId: serverId,
baseUrl: baseUrl,
token: token
)
XCTAssertEqual(items.map(\.contentId), ["plezy_srv-1_7", "plezy_srv-1_8"])
}
// MARK: - MediaBrowser mapping
private static let mediaBrowserFixture = """
{
"Items": [
{
"Id": "ep1",
"Type": "Episode",
"Name": "Winter Is Coming",
"SeriesName": "Game of Thrones",
"ParentIndexNumber": 1,
"IndexNumber": 3,
"RunTimeTicks": 36000000000,
"SeasonId": "season-9",
"SeriesId": "series-4",
"UserData": {
"PlaybackPositionTicks": 9000000000,
"LastPlayedDate": "2023-11-14T22:13:20.0000000Z"
}
}
]
}
"""
func testMediaBrowserEpisodeMappingConvertsTicksAndPrefersSeasonPoster() throws {
let items = ShelfItemMapper.mediaBrowserItems(
fromResponse: try fixture(Self.mediaBrowserFixture),
serverId: serverId,
baseUrl: baseUrl,
token: token
)
let episode = try XCTUnwrap(items.first)
XCTAssertEqual(episode.contentId, "plezy_srv-1_ep1")
XCTAssertEqual(episode.title, "Game of Thrones")
XCTAssertEqual(episode.episodeTitle, "Winter Is Coming")
XCTAssertEqual(episode.seasonNumber, 1)
XCTAssertEqual(episode.episodeNumber, 3)
// RunTimeTicks / PlaybackPositionTicks are 100 ns ticks; divide by 10_000
// for milliseconds.
XCTAssertEqual(episode.durationMilliseconds, 3_600_000)
XCTAssertEqual(episode.lastPlaybackPositionMilliseconds, 900_000)
XCTAssertEqual(try XCTUnwrap(episode.playbackProgress), 0.25, accuracy: 0.0001)
XCTAssertEqual(episode.recency, 1_700_000_000)
XCTAssertEqual(
episode.posterUrl,
"\(baseUrl)/Items/season-9/Images/Primary?maxWidth=600&maxHeight=900&api_key=\(token)"
)
}
func testMediaBrowserPosterFallsBackToSeriesThenOwnImage() {
let base: [String: Any] = ["Id": "ep1", "Type": "Episode", "Name": "Ep", "SeriesName": "Show"]
var item = base
item["SeriesId"] = "series-4"
let seriesPoster = ShelfItemMapper.mediaBrowserItem(item, serverId: serverId, baseUrl: baseUrl, token: token)
XCTAssertEqual(seriesPoster?.posterUrl?.contains("/Items/series-4/Images/Primary"), true)
let ownPoster = ShelfItemMapper.mediaBrowserItem(base, serverId: serverId, baseUrl: baseUrl, token: token)
XCTAssertEqual(ownPoster?.posterUrl?.contains("/Items/ep1/Images/Primary"), true)
}
func testMediaBrowserDateParsingToleratesSevenDigitFractions() {
XCTAssertEqual(ShelfItemMapper.parseMediaBrowserDate("2023-11-14T22:13:20.0000000Z"), 1_700_000_000)
XCTAssertEqual(ShelfItemMapper.parseMediaBrowserDate("2023-11-14T22:13:20Z"), 1_700_000_000)
XCTAssertNil(ShelfItemMapper.parseMediaBrowserDate("not a date"))
}
// MARK: - Source descriptors
private static let sourcesFixture = """
{
"schemaVersion": 3,
"ownerId": "profile-a",
"maxItems": 20,
"servers": [
{"serverId": "srv-plex", "kind": "plex", "name": "Den", "baseUrl": "https://plex.example.com"},
{"serverId": "srv-jelly", "kind": "jellyfin", "name": "Attic", "baseUrl": "https://jf.example.com", "userId": "user-1"}
]
}
"""
func testSourcesResolveJoinsKeychainTokens() throws {
let sources = try XCTUnwrap(
ShelfSourceStore.resolve(
payloadData: Data(Self.sourcesFixture.utf8),
tokensByServerId: ["srv-plex": "tok-a", "srv-jelly": "tok-b"]
)
)
XCTAssertEqual(sources.ownerId, "profile-a")
XCTAssertEqual(sources.maxItems, 20)
XCTAssertEqual(sources.servers.count, 2)
XCTAssertEqual(sources.servers[0].descriptor.kind, .plex)
XCTAssertEqual(sources.servers[0].token, "tok-a")
XCTAssertEqual(sources.servers[1].descriptor.userId, "user-1")
}
func testSourcesResolveRejectsWrongSchemaAndEmptyOwner() {
let wrongSchema = Self.sourcesFixture.replacingOccurrences(
of: "\"schemaVersion\": 3",
with: "\"schemaVersion\": 2"
)
XCTAssertNil(
ShelfSourceStore.resolve(
payloadData: Data(wrongSchema.utf8),
tokensByServerId: ["srv-plex": "tok-a"]
)
)
let emptyOwner = Self.sourcesFixture.replacingOccurrences(
of: "\"ownerId\": \"profile-a\"",
with: "\"ownerId\": \"\""
)
XCTAssertNil(
ShelfSourceStore.resolve(
payloadData: Data(emptyOwner.utf8),
tokensByServerId: ["srv-plex": "tok-a"]
)
)
}
func testSourcesResolveSkipsServersWithoutTokensAndMalformedSiblings() throws {
// A server with no keychain token is dropped; all dropped means nil.
let partial = try XCTUnwrap(
ShelfSourceStore.resolve(
payloadData: Data(Self.sourcesFixture.utf8),
tokensByServerId: ["srv-jelly": "tok-b"]
)
)
XCTAssertEqual(partial.servers.map(\.descriptor.serverId), ["srv-jelly"])
XCTAssertNil(
ShelfSourceStore.resolve(payloadData: Data(Self.sourcesFixture.utf8), tokensByServerId: [:])
)
// An unknown kind only sinks its own entry, not the whole payload.
let malformedSibling = Self.sourcesFixture.replacingOccurrences(
of: "\"kind\": \"plex\"",
with: "\"kind\": \"unknown\""
)
let survivors = try XCTUnwrap(
ShelfSourceStore.resolve(
payloadData: Data(malformedSibling.utf8),
tokensByServerId: ["srv-plex": "tok-a", "srv-jelly": "tok-b"]
)
)
XCTAssertEqual(survivors.servers.map(\.descriptor.serverId), ["srv-jelly"])
}
// MARK: - Merge
func testMergeOrdersByRecencyDescendingDeduplicatesAndCaps() {
let merged = ShelfItemMapper.merge(
[
[makeItem("a", recency: 10), makeItem("b", recency: 5)],
[makeItem("c", recency: 20), makeItem("a", recency: 99)],
],
maxItems: 2
)
// "a" keeps its first (resume-ordered) occurrence, then recency sorts.
XCTAssertEqual(merged.map(\.contentId), ["c", "a"])
XCTAssertEqual(merged[1].recency, 10)
let uncapped = ShelfItemMapper.merge([[makeItem("b", recency: 0), makeItem("a", recency: 0)]], maxItems: 20)
// Equal recency keeps input order.
XCTAssertEqual(uncapped.map(\.contentId), ["b", "a"])
}
// MARK: - Request construction
func testPlexContinueWatchingRequestCarriesTokenOnlyAsHeader() throws {
let request = try XCTUnwrap(
ShelfFetcher.plexContinueWatchingRequest(baseUrl: baseUrl + "/", token: token, maxItems: 20)
)
let url = try XCTUnwrap(request.url?.absoluteString)
XCTAssertEqual(url, "\(baseUrl)/hubs/continueWatching?count=20&includeGuids=1")
XCTAssertFalse(url.contains(token))
let headers = request.allHTTPHeaderFields ?? [:]
XCTAssertEqual(headers["X-Plex-Token"], token)
XCTAssertEqual(headers["Accept"], "application/json")
XCTAssertEqual(headers.values.filter { $0.contains(token) }.count, 1)
}
func testMediaBrowserRequestsCarryTokenOnlyAsHeader() throws {
let resume = try XCTUnwrap(
ShelfFetcher.mediaBrowserResumeRequest(baseUrl: baseUrl, token: token, userId: "user-1", maxItems: 20)
)
XCTAssertEqual(
resume.url?.absoluteString,
"\(baseUrl)/UserItems/Resume?userId=user-1&Limit=20&MediaTypes=Video"
+ "&Recursive=true&EnableTotalRecordCount=false"
)
let nextUp = try XCTUnwrap(
ShelfFetcher.mediaBrowserNextUpRequest(baseUrl: baseUrl, token: token, userId: "user-1", maxItems: 20)
)
XCTAssertEqual(
nextUp.url?.absoluteString,
"\(baseUrl)/Shows/NextUp?userId=user-1&Limit=20"
+ "&EnableResumable=false&EnableTotalRecordCount=false"
)
for request in [resume, nextUp] {
let url = try XCTUnwrap(request.url?.absoluteString)
XCTAssertFalse(url.contains(token))
let headers = request.allHTTPHeaderFields ?? [:]
XCTAssertEqual(headers["X-Emby-Token"], token)
XCTAssertEqual(headers.values.filter { $0.contains(token) }.count, 1)
}
}
func testPosterUrlsCarryTokenAsQueryParameterExactlyOnce() throws {
let plex = try XCTUnwrap(
ShelfItemMapper.plexPosterUrl(path: "/library/metadata/55/thumb/1", baseUrl: baseUrl, token: token)
)
XCTAssertEqual(occurrences(of: token, in: plex), 1)
XCTAssertEqual(occurrences(of: "X-Plex-Token=", in: plex), 1)
let mediaBrowser = try XCTUnwrap(
ShelfItemMapper.mediaBrowserPosterUrl(itemId: "season-9", baseUrl: baseUrl, token: token)
)
XCTAssertEqual(occurrences(of: token, in: mediaBrowser), 1)
XCTAssertEqual(occurrences(of: "api_key=", in: mediaBrowser), 1)
}
// MARK: - Token redaction
func testTokenNeverAppearsInDescriptionOrDebugStrings() throws {
let source = ShelfServerSource(
descriptor: ShelfServerDescriptor(
serverId: "srv-plex",
kind: .plex,
name: "Den",
baseUrl: baseUrl,
userId: nil
),
token: token
)
XCTAssertFalse(String(describing: source).contains(token))
XCTAssertFalse(String(reflecting: source).contains(token))
XCTAssertFalse(source.description.contains(token))
XCTAssertFalse(source.debugDescription.contains(token))
let item = try XCTUnwrap(
ShelfItemMapper.plexItem(
["ratingKey": "1", "title": "Heat", "thumb": "/library/metadata/1/thumb/1"],
serverId: serverId,
baseUrl: baseUrl,
token: token
)
)
XCTAssertEqual(item.posterUrl?.contains(token), true)
XCTAssertFalse(String(describing: item).contains(token))
XCTAssertFalse(String(reflecting: item).contains(token))
}
// MARK: - Helpers
private func fixture(_ json: String) throws -> Any {
try JSONSerialization.jsonObject(with: Data(json.utf8))
}
private func makeItem(_ contentId: String, recency: Double) -> ShelfFetchedItem {
ShelfFetchedItem(
contentId: contentId,
title: contentId,
episodeTitle: nil,
type: "movie",
seasonNumber: nil,
episodeNumber: nil,
durationMilliseconds: nil,
lastPlaybackPositionMilliseconds: nil,
posterUrl: nil,
recency: recency
)
}
private func occurrences(of needle: String, in haystack: String) -> Int {
haystack.components(separatedBy: needle).count - 1
}
}