Files
punktfunk/clients/apple/Tests/PunktfunkKitTests/LibraryClientTests.swift
T
enricobuehler 7798401f06
ci / rust-arm64 (pull_request) Successful in 1m36s
ci / bun-nix (pull_request) Successful in 24s
ci / web (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m12s
ci / rust (pull_request) Successful in 13m3s
perf(apple): cache posters on disk and pool the mgmt connections
Moving the management API onto Network.framework left one request per
connection, so a library grid paid a TLS handshake per poster where the pooled
URLSession had shared one. And the Apple client -- unlike Windows -- never
cached art at all, so it re-fetched every poster on every visit.

ArtCache: a size- and age-bounded blob cache in the CACHES directory (every byte
is re-derivable from the host, so the system is welcome to evict it). Keyed by
the SHA-256 of the absolute URL, so host-proxy paths and store CDN URLs share
one cache without colliding. Reads touch the entry, so eviction is by last USE,
not last write. Empty bodies and data: URLs are refused -- neither is worth a
file. Defaults: 128 MB, 30 days.

Connection pooling: MgmtConnectionPool keeps up to four keep-alive connections
per host and makes further callers wait rather than opening more, which is the
part that matters -- a grid can ask for dozens of posters at once. A connection
the host dropped since we last used it is indistinguishable from a live one
until we write, so a REUSED connection that fails is retried once on a fresh
one; a fresh failure is a real failure.

Keep-alive means a response can no longer be delimited by the peer hanging up,
so HTTPResponseParser.messageLength finds the end from the framing itself --
Content-Length or the chunked terminal chunk plus trailers. Getting that wrong
would truncate a response or bleed one into the next, silently, so it carries
the bulk of the new tests. A connection with bytes left over after a response is
dropped rather than reused: we never pipeline, so anything trailing means we are
out of sync.

LibraryView closes the loader's pooled connections on disappear instead of
leaving sockets open on a screen the user has left.

16 new tests: message framing (both encodings, partial reads, back-to-back
responses, close detection) and the cache (binary round trip, key separation,
refusals, expiry, LRU eviction).
2026-08-08 01:09:25 +02:00

312 lines
15 KiB
Swift

// Unit tests for the game-library models — decoding the management API's GET /api/v1/library
// payload and the poster-art fallback order. (The network fetch itself isn't unit-tested; it's
// exercised live against a host.)
import XCTest
@testable import PunktfunkKit
final class LibraryClientTests: XCTestCase {
func testDecodesLibraryPayload() throws {
// A Steam entry (full art + launch) and a custom entry (sparse art, no launch) — the two
// shapes the host's `GameEntry` serializes (note the host omits null fields).
let json = """
[
{
"id": "steam:570",
"store": "steam",
"title": "Dota 2",
"art": {
"portrait": "https://cdn.cloudflare.steamstatic.com/steam/apps/570/library_600x900.jpg",
"hero": "https://cdn.cloudflare.steamstatic.com/steam/apps/570/library_hero.jpg",
"logo": "https://cdn.cloudflare.steamstatic.com/steam/apps/570/logo.png",
"header": "https://cdn.cloudflare.steamstatic.com/steam/apps/570/header.jpg"
},
"launch": { "kind": "steam_appid", "value": "570" }
},
{
"id": "custom:abc123",
"store": "custom",
"title": "Dolphin",
"art": { "header": "https://example.com/dolphin.jpg" }
}
]
""".data(using: .utf8)!
let games = try JSONDecoder().decode([GameEntry].self, from: json)
XCTAssertEqual(games.count, 2)
let steam = games[0]
XCTAssertEqual(steam.id, "steam:570")
XCTAssertFalse(steam.isCustom)
XCTAssertEqual(steam.launch?.kind, "steam_appid")
XCTAssertEqual(steam.launch?.value, "570")
let custom = games[1]
XCTAssertTrue(custom.isCustom)
XCTAssertNil(custom.launch)
XCTAssertNil(custom.art.portrait)
}
func testPosterCandidatesPreferPortraitThenHeader() {
let full = Artwork(
portrait: "https://x/p.jpg", hero: "https://x/hero.jpg",
logo: "https://x/logo.png", header: "https://x/h.jpg")
XCTAssertEqual(full.posterCandidates.map(\.absoluteString),
["https://x/p.jpg", "https://x/h.jpg", "https://x/hero.jpg"])
// No portrait → header leads; absent fields are skipped, not nil-padded.
let sparse = Artwork(portrait: nil, hero: nil, logo: nil, header: "https://x/h.jpg")
XCTAssertEqual(sparse.posterCandidates.map(\.absoluteString), ["https://x/h.jpg"])
XCTAssertTrue(Artwork().posterCandidates.isEmpty)
}
func testArtworkResolvedRewritesOnlyHostRelativePaths() {
let base = URL(string: "https://192.168.1.70:47990/api/v1/library")!
// Steam art now comes back as host-relative proxy paths; external CDN URLs (GOG/Heroic/Xbox)
// and `data:` URLs (Lutris) are untouched.
let art = Artwork(
portrait: "/api/v1/library/art/steam:3527290/portrait",
hero: "https://cdn.example.com/hero.jpg",
logo: nil,
header: "/api/v1/library/art/steam:3527290/header")
let resolved = art.resolved(against: base)
XCTAssertEqual(
resolved.portrait, "https://192.168.1.70:47990/api/v1/library/art/steam:3527290/portrait")
XCTAssertEqual(
resolved.header, "https://192.168.1.70:47990/api/v1/library/art/steam:3527290/header")
XCTAssertEqual(resolved.hero, "https://cdn.example.com/hero.jpg") // unchanged
XCTAssertNil(resolved.logo)
}
// MARK: - HTTP response parsing (MgmtTransport)
// The management API is reached over Network.framework rather than URLSession (ATS cannot be
// relaxed for the arbitrary addresses a host lives at — see MgmtTransport), so we parse HTTP
// ourselves. These cover the framings hyper actually emits, plus the failure modes where
// getting it wrong would be silent.
private func raw(_ text: String) -> Data { Data(text.utf8) }
func testParsesContentLengthFramedJSON() throws {
let body = #"[{"id":"steam:570"}]"#
let response = try HTTPResponseParser.parse(raw(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
+ "Content-Length: \(body.utf8.count)\r\n\r\n\(body)"))
XCTAssertEqual(response.status, 200)
XCTAssertEqual(String(decoding: response.body, as: UTF8.self), body)
// Field names are case-insensitive per RFC 9110.
XCTAssertEqual(response.header("CONTENT-TYPE"), "application/json")
}
func testParsesChunkedBody() throws {
// How hyper streams the art proxy.
let response = try HTTPResponseParser.parse(raw(
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
+ "5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"))
XCTAssertEqual(String(decoding: response.body, as: UTF8.self), "hello world")
}
func testChunkExtensionsAndTrailersAreIgnored() throws {
let response = try HTTPResponseParser.parse(raw(
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
+ "3;foo=bar\r\nabc\r\n0\r\nX-Trailer: 1\r\n\r\n"))
XCTAssertEqual(String(decoding: response.body, as: UTF8.self), "abc")
}
func testUnauthorizedStatusSurvives() throws {
// What an unpaired certificate gets from the host — the status is the whole signal.
let response = try HTTPResponseParser.parse(
raw("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"))
XCTAssertEqual(response.status, 401)
XCTAssertTrue(response.body.isEmpty)
}
func testBodyRunsToEOFWithoutFramingHeaders() throws {
let response = try HTTPResponseParser.parse(raw("HTTP/1.1 200 OK\r\n\r\nraw-to-eof"))
XCTAssertEqual(String(decoding: response.body, as: UTF8.self), "raw-to-eof")
}
func testTruncatedBodyThrowsRatherThanReturningPartialJSON() {
// The one that matters: a body cut short must NOT come back as success. A clipped JSON
// array would decode to fewer games — "this host has no games" — instead of an error.
XCTAssertThrowsError(
try HTTPResponseParser.parse(raw("HTTP/1.1 200 OK\r\nContent-Length: 99\r\n\r\nshort")))
}
func testOverLongBodyIsClippedToContentLength() throws {
let response = try HTTPResponseParser.parse(
raw("HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabcdef"))
XCTAssertEqual(String(decoding: response.body, as: UTF8.self), "abc")
}
func testMalformedResponsesThrow() {
// Header block never terminated (peer hung up), a non-HTTP greeting, and a chunked stream
// cut mid-chunk.
XCTAssertThrowsError(
try HTTPResponseParser.parse(raw("HTTP/1.1 200 OK\r\nContent-Length: 3\r\n")))
XCTAssertThrowsError(try HTTPResponseParser.parse(raw("NOT-HTTP\r\n\r\n")))
XCTAssertThrowsError(try HTTPResponseParser.parse(raw(
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n9\r\nabc")))
}
func testMultiWordReasonPhraseParses() throws {
let response = try HTTPResponseParser.parse(
raw("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"))
XCTAssertEqual(response.status, 404)
}
func testBinaryBodySurvivesByteForByte() throws {
// Posters are PNG/JPEG: the body must never be round-tripped through a String.
let png: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0xFF, 0x0D, 0x0A]
var message = raw("HTTP/1.1 200 OK\r\nContent-Length: \(png.count)\r\n\r\n")
message.append(contentsOf: png)
let response = try HTTPResponseParser.parse(message)
XCTAssertEqual([UInt8](response.body), png)
}
// MARK: - Message framing (keep-alive)
// Connections are pooled and reused, so a response has to be delimited WITHOUT waiting for
// the peer to hang up. Getting this wrong either truncates a response or bleeds one response
// into the next, and both would be silent.
func testMessageLengthDelimitsContentLengthFraming() throws {
let complete = raw("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello")
XCTAssertEqual(try HTTPResponseParser.messageLength(in: complete), complete.count)
XCTAssertNil(try HTTPResponseParser.messageLength(
in: raw("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhel")))
}
func testMessageLengthDelimitsChunkedFraming() throws {
let complete = raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n")
XCTAssertEqual(try HTTPResponseParser.messageLength(in: complete), complete.count)
// Mid-chunk, and terminal chunk without its closing blank line.
XCTAssertNil(try HTTPResponseParser.messageLength(
in: raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhel")))
XCTAssertNil(try HTTPResponseParser.messageLength(
in: raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n")))
// Trailers belong to the message and must be consumed with it.
let trailered = raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
+ "3\r\nabc\r\n0\r\nX-Trailer: 1\r\n\r\n")
XCTAssertEqual(try HTTPResponseParser.messageLength(in: trailered), trailered.count)
}
func testMessageLengthIsNilWithoutFraming() throws {
// No Content-Length and not chunked ⇒ the body runs to EOF and the connection can't be
// reused. Partial header blocks are likewise "not yet".
XCTAssertNil(try HTTPResponseParser.messageLength(in: raw("HTTP/1.1 200 OK\r\n\r\nto-eof")))
XCTAssertNil(try HTTPResponseParser.messageLength(in: raw("HTTP/1.1 200 OK\r\nContent-Len")))
}
func testBackToBackResponsesSplitExactly() throws {
// The reuse case that matters: two responses arriving in one read.
let first = raw("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello")
let second = raw("HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nbye")
let both = first + second
XCTAssertEqual(try HTTPResponseParser.messageLength(in: both), first.count)
XCTAssertEqual(
String(decoding: try HTTPResponseParser.parse(both.prefix(first.count)).body,
as: UTF8.self), "hello")
XCTAssertEqual(
String(decoding: try HTTPResponseParser.parse(both.dropFirst(first.count)).body,
as: UTF8.self), "bye")
}
func testConnectionCloseIsDetected() throws {
let response = try HTTPResponseParser.parse(
raw("HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"))
XCTAssertTrue(response.wantsClose)
// HTTP/1.1 keeps the connection open unless told otherwise.
XCTAssertFalse(try HTTPResponseParser.parse(
raw("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")).wantsClose)
}
// MARK: - Art cache
private func temporaryCacheDirectory() -> URL {
URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("pf-art-test-\(UUID().uuidString)", isDirectory: true)
}
func testArtCacheRoundTripsBinaryAndSeparatesKeys() async throws {
let directory = temporaryCacheDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let cache = ArtCache(directory: directory)
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF, 0x0D, 0x0A])
let portrait = URL(string: "https://100.64.1.2:47990/api/v1/library/art/steam:570/portrait")!
let header = URL(string: "https://100.64.1.2:47990/api/v1/library/art/steam:570/header")!
var hit = await cache.data(for: portrait)
XCTAssertNil(hit, "cold cache must miss")
await cache.store(png, for: portrait)
hit = await cache.data(for: portrait)
XCTAssertEqual(hit, png, "posters are binary; the body must survive byte-for-byte")
// Sibling art of the same title must not collide.
let sibling = await cache.data(for: header)
XCTAssertNil(sibling)
}
func testArtCacheRefusesEmptyAndInlineData() async throws {
let directory = temporaryCacheDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let cache = ArtCache(directory: directory)
let empty = URL(string: "https://cdn.example.com/empty.jpg")!
await cache.store(Data(), for: empty)
let emptyHit = await cache.data(for: empty)
XCTAssertNil(emptyHit, "an empty body is not art")
let inline = URL(string: "data:image/png;base64,iVBORw0KGgo=")!
await cache.store(Data("x".utf8), for: inline)
let inlineHit = await cache.data(for: inline)
XCTAssertNil(inlineHit, "a data: URL is already inline — caching it is a pure loss")
}
func testArtCacheAgesEntriesOut() async throws {
let directory = temporaryCacheDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let cache = ArtCache(directory: directory, maxAge: 0.4)
let url = URL(string: "https://cdn.example.com/stale.jpg")!
await cache.store(Data("stale".utf8), for: url)
let fresh = await cache.data(for: url)
XCTAssertNotNil(fresh)
try await Task.sleep(nanoseconds: 700_000_000)
let expired = await cache.data(for: url)
XCTAssertNil(expired)
}
func testArtCacheEvictsLeastRecentlyUsedOverBudget() async throws {
let directory = temporaryCacheDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
// Budget holds three of these; the fourth store must evict.
let cache = ArtCache(directory: directory, maxBytes: 300)
let blob = Data(repeating: 0x41, count: 100)
var urls: [URL] = []
for i in 0..<4 {
let url = URL(string: "https://cdn.example.com/blob\(i).jpg")!
urls.append(url)
await cache.store(blob, for: url)
try await Task.sleep(nanoseconds: 60_000_000) // distinct mtimes for LRU ordering
}
let evicted = await cache.data(for: urls[0])
XCTAssertNil(evicted, "the oldest entry should have been evicted")
let newest = await cache.data(for: urls[3])
XCTAssertEqual(newest, blob)
}
func testBaseURLBracketsIPv6Only() {
XCTAssertEqual(LibraryClient.baseURL(address: "192.168.1.70", port: 47990),
"https://192.168.1.70:47990")
XCTAssertEqual(LibraryClient.baseURL(address: "100.101.102.103", port: 47990),
"https://100.101.102.103:47990")
XCTAssertEqual(LibraryClient.baseURL(address: "fd7a:115c::1", port: 47990),
"https://[fd7a:115c::1]:47990")
// An address the user pasted already bracketed must not end up double-bracketed.
XCTAssertEqual(LibraryClient.baseURL(address: "[fd7a:115c::1]", port: 47990),
"https://[fd7a:115c::1]:47990")
}
}