1import Foundation2import Testing34@testable import Haven56final class MockURLProtocol: URLProtocol, @unchecked Sendable {7 nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (URLResponse, Data))?89 override class func canInit(with request: URLRequest) -> Bool { true }1011 override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }1213 override func startLoading() {14 guard let handler = Self.requestHandler else {15 client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))16 return17 }1819 do {20 let (response, data) = try handler(request)21 client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)22 client?.urlProtocol(self, didLoad: data)23 client?.urlProtocolDidFinishLoading(self)24 } catch {25 client?.urlProtocol(self, didFailWithError: error)26 }27 }2829 override func stopLoading() {}30}3132@Suite("Wallhaven API", .serialized)33@MainActor34struct WallhavenAPITests {35 private func makeAPI() -> WallhavenAPI {36 let configuration = URLSessionConfiguration.ephemeral37 configuration.protocolClasses = [MockURLProtocol.self]38 return WallhavenAPI(session: URLSession(configuration: configuration))39 }4041 private func response(42 for request: URLRequest, statusCode: Int = 200, contentType: String = "application/json"43 ) -> HTTPURLResponse {44 HTTPURLResponse(45 url: request.url!,46 statusCode: statusCode,47 httpVersion: "HTTP/1.1",48 headerFields: ["Content-Type": contentType]49 )!50 }5152 @Test("Search sends every selected filter and decodes the response")53 func searchBuildsExpectedRequest() async throws {54 MockURLProtocol.requestHandler = { request in55 let url = try #require(request.url)56 let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false))57 let items = Dictionary(58 uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") })5960 #expect(components.path == "/api/v1/search")61 #expect(items["q"] == "mountain lake")62 #expect(items["categories"] == "101")63 #expect(items["purity"] == "110")64 #expect(items["sorting"] == "toplist")65 #expect(items["resolutions"] == "1920x1080,3840x2160")66 #expect(items["ratios"] == "16x9,21x9")67 #expect(items["limit"] == "12")68 #expect(items["page"] == "3")69 return (response(for: request), try encodedSearchResponse(currentPage: 3, lastPage: 9))70 }7172 let (wallpapers, meta) = try await makeAPI().searchWallpapers(73 query: "mountain lake",74 categories: "101",75 purity: "110",76 sorting: .toplist,77 resolutions: ["1920x1080", "3840x2160"],78 ratios: ["16x9", "21x9"],79 limit: 12,80 page: 381 )8283 #expect(wallpapers == [makeWallpaper()])84 #expect(meta?.current_page == 3)85 #expect(meta?.last_page == 9)86 }8788 @Test("Empty filters are omitted and defaults are stable")89 func emptyFiltersAreOmitted() async throws {90 MockURLProtocol.requestHandler = { request in91 let url = try #require(request.url)92 let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false))93 let items = Dictionary(94 uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") })9596 #expect(items["categories"] == "111")97 #expect(items["purity"] == "100")98 #expect(items["sorting"] == "date_added")99 #expect(items["limit"] == "24")100 #expect(items["page"] == "1")101 #expect(items["resolutions"] == nil)102 #expect(items["ratios"] == nil)103 return (response(for: request), try encodedSearchResponse())104 }105106 _ = try await makeAPI().searchWallpapers(query: "")107 }108109 @Test("Random mode maps to the random sorting query")110 func randomSortingIsEncoded() async throws {111 MockURLProtocol.requestHandler = { request in112 let url = try #require(request.url)113 let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false))114 #expect(components.queryItems?.first(where: { $0.name == "sorting" })?.value == "random")115 return (response(for: request), try encodedSearchResponse())116 }117118 _ = try await makeAPI().searchWallpapers(query: "", sorting: .random)119 }120121 @Test("Failures name the cause instead of an opaque error code")122 func failuresDescribeTheirCause() async {123 let rejectionCases: [(Int, String)] = [124 (429, "Wallhaven rate limit reached (HTTP 429). Try again in a minute."),125 (404, "Wallhaven rejected the request (HTTP 404)."),126 ]127128 for (status, expected) in rejectionCases {129 MockURLProtocol.requestHandler = { request in130 (response(for: request, statusCode: status), Data())131 }132133 do {134 _ = try await makeAPI().searchWallpapers(query: "")135 Issue.record("Expected HTTP \(status) to throw")136 } catch WallhavenError.apiError(let message) {137 #expect(message == expected)138 } catch {139 Issue.record("Unexpected error: \(error)")140 }141 }142143 144 MockURLProtocol.requestHandler = { request in145 (response(for: request, statusCode: 521), Data())146 }147148 do {149 _ = try await makeAPI().searchWallpapers(query: "")150 Issue.record("Expected HTTP 521 to throw")151 } catch WallhavenError.unavailable(let message) {152 #expect(message == "Wallhaven is unavailable right now (HTTP 521).")153 } catch {154 Issue.record("Unexpected error: \(error)")155 }156 }157158 @Test("Wallhaven's HTML status page is reported as an outage, not a bad response")159 func statusPageIsReportedAsOffline() async {160 MockURLProtocol.requestHandler = { request in161 (162 response(163 for: request,164 contentType: "text/html; charset=utf-8"),165 Data("<!DOCTYPE html><title>wallhaven.cc Status</title>".utf8)166 )167 }168169 do {170 _ = try await makeAPI().searchWallpapers(query: "")171 Issue.record("Expected a status page to throw")172 } catch WallhavenError.unavailable(let message) {173 #expect(message == "Wallhaven is offline right now — it's serving its status page.")174 } catch {175 Issue.record("Unexpected error: \(error)")176 }177 }178179 @Test("A body-less response reports an unexpected response")180 func nonHTTPResponseDescribesItself() async {181 MockURLProtocol.requestHandler = { request in182 (183 URLResponse(184 url: request.url!, mimeType: nil, expectedContentLength: 0, textEncodingName: nil),185 Data()186 )187 }188189 do {190 _ = try await makeAPI().searchWallpapers(query: "")191 Issue.record("Expected a non-HTTP response to throw")192 } catch WallhavenError.apiError(let message) {193 #expect(message == "Wallhaven returned an unexpected response.")194 } catch {195 Issue.record("Unexpected error: \(error)")196 }197 }198199 @Test("Every error case carries a human-readable description")200 func errorCasesLocalizeTheirDescriptions() {201 #expect(WallhavenError.invalidURL.localizedDescription == "That isn't a valid Wallhaven URL.")202 #expect(WallhavenError.noData.localizedDescription == "Wallhaven returned no data.")203 #expect(204 WallhavenError.apiError("Wallhaven is unavailable right now (HTTP 521).").localizedDescription205 == "Wallhaven is unavailable right now (HTTP 521).")206 #expect(207 WallhavenError.unavailable("Wallhaven is offline right now — it's serving its status page.")208 .localizedDescription209 == "Wallhaven is offline right now — it's serving its status page.")210 #expect(211 WallhavenError.networkError(URLError(.notConnectedToInternet)).localizedDescription212 .hasPrefix("Couldn't reach Wallhaven: "))213 #expect(214 WallhavenError.decodingError(URLError(.cannotParseResponse)).localizedDescription215 == "Wallhaven sent a response Haven couldn't read.")216 }217218 @Test("Malformed success payloads become decoding errors")219 func malformedPayloadThrowsDecodingError() async {220 MockURLProtocol.requestHandler = { request in221 (response(for: request), Data(#"{"unexpected":true}"#.utf8))222 }223224 do {225 _ = try await makeAPI().searchWallpapers(query: "")226 Issue.record("Expected a WallhavenError.decodingError")227 } catch WallhavenError.decodingError {228 229 } catch {230 Issue.record("Unexpected error: \(error)")231 }232 }233234 @Test("Transport failures propagate to the caller")235 func transportFailurePropagates() async {236 MockURLProtocol.requestHandler = { _ in throw URLError(.notConnectedToInternet) }237238 do {239 _ = try await makeAPI().searchWallpapers(query: "")240 Issue.record("Expected a transport error")241 } catch let error as URLError {242 #expect(error.code == .notConnectedToInternet)243 } catch {244 Issue.record("Unexpected error: \(error)")245 }246 }247}