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
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).
312 lines
15 KiB
Swift
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")
|
|
}
|
|
}
|