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(for request: URLRequest, statusCode: Int = 200) -> HTTPURLResponse {42 HTTPURLResponse(43 url: request.url!,44 statusCode: statusCode,45 httpVersion: "HTTP/1.1",46 headerFields: ["Content-Type": "application/json"]47 )!48 }4950 @Test("Search sends every selected filter and decodes the response")51 func searchBuildsExpectedRequest() async throws {52 MockURLProtocol.requestHandler = { request in53 let url = try #require(request.url)54 let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false))55 let items = Dictionary(56 uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") })5758 #expect(components.path == "/api/v1/search")59 #expect(items["q"] == "mountain lake")60 #expect(items["categories"] == "101")61 #expect(items["purity"] == "110")62 #expect(items["sorting"] == "toplist")63 #expect(items["resolutions"] == "1920x1080,3840x2160")64 #expect(items["ratios"] == "16x9,21x9")65 #expect(items["limit"] == "12")66 #expect(items["page"] == "3")67 return (response(for: request), try encodedSearchResponse(currentPage: 3, lastPage: 9))68 }6970 let (wallpapers, meta) = try await makeAPI().searchWallpapers(71 query: "mountain lake",72 categories: "101",73 purity: "110",74 sorting: .toplist,75 resolutions: ["1920x1080", "3840x2160"],76 ratios: ["16x9", "21x9"],77 limit: 12,78 page: 379 )8081 #expect(wallpapers == [makeWallpaper()])82 #expect(meta?.current_page == 3)83 #expect(meta?.last_page == 9)84 }8586 @Test("Empty filters are omitted and defaults are stable")87 func emptyFiltersAreOmitted() async throws {88 MockURLProtocol.requestHandler = { request in89 let url = try #require(request.url)90 let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false))91 let items = Dictionary(92 uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") })9394 #expect(items["categories"] == "111")95 #expect(items["purity"] == "100")96 #expect(items["sorting"] == "date_added")97 #expect(items["limit"] == "24")98 #expect(items["page"] == "1")99 #expect(items["resolutions"] == nil)100 #expect(items["ratios"] == nil)101 return (response(for: request), try encodedSearchResponse())102 }103104 _ = try await makeAPI().searchWallpapers(query: "")105 }106107 @Test("Random mode maps to the random sorting query")108 func randomSortingIsEncoded() async throws {109 MockURLProtocol.requestHandler = { request in110 let url = try #require(request.url)111 let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false))112 #expect(components.queryItems?.first(where: { $0.name == "sorting" })?.value == "random")113 return (response(for: request), try encodedSearchResponse())114 }115116 _ = try await makeAPI().searchWallpapers(query: "", sorting: .random)117 }118119 @Test("Non-success HTTP responses become API errors")120 func nonSuccessStatusThrowsAPIError() async {121 MockURLProtocol.requestHandler = { request in122 (response(for: request, statusCode: 503), Data("unavailable".utf8))123 }124125 do {126 _ = try await makeAPI().searchWallpapers(query: "")127 Issue.record("Expected a WallhavenError.apiError")128 } catch WallhavenError.apiError {129 130 } catch {131 Issue.record("Unexpected error: \(error)")132 }133 }134135 @Test("Failures name the cause instead of an opaque error code")136 func failuresDescribeTheirCause() async {137 let cases: [(Int, String)] = [138 (521, "Wallhaven is unavailable right now (HTTP 521)."),139 (429, "Wallhaven rate limit reached (HTTP 429). Try again in a minute."),140 (404, "Wallhaven rejected the request (HTTP 404)."),141 ]142143 for (status, expected) in cases {144 MockURLProtocol.requestHandler = { request in145 (response(for: request, statusCode: status), Data())146 }147148 do {149 _ = try await makeAPI().searchWallpapers(query: "")150 Issue.record("Expected HTTP \(status) to throw")151 } catch WallhavenError.apiError(let message) {152 #expect(message == expected)153 } catch {154 Issue.record("Unexpected error: \(error)")155 }156 }157 }158159 @Test("A body-less response reports an unexpected response")160 func nonHTTPResponseDescribesItself() async {161 MockURLProtocol.requestHandler = { request in162 (163 URLResponse(164 url: request.url!, mimeType: nil, expectedContentLength: 0, textEncodingName: nil),165 Data()166 )167 }168169 do {170 _ = try await makeAPI().searchWallpapers(query: "")171 Issue.record("Expected a non-HTTP response to throw")172 } catch WallhavenError.apiError(let message) {173 #expect(message == "Wallhaven returned an unexpected response.")174 } catch {175 Issue.record("Unexpected error: \(error)")176 }177 }178179 @Test("Every error case carries a human-readable description")180 func errorCasesLocalizeTheirDescriptions() {181 #expect(WallhavenError.invalidURL.localizedDescription == "That isn't a valid Wallhaven URL.")182 #expect(WallhavenError.noData.localizedDescription == "Wallhaven returned no data.")183 #expect(184 WallhavenError.apiError("Wallhaven is unavailable right now (HTTP 521).").localizedDescription185 == "Wallhaven is unavailable right now (HTTP 521).")186 #expect(187 WallhavenError.networkError(URLError(.notConnectedToInternet)).localizedDescription188 .hasPrefix("Couldn't reach Wallhaven: "))189 #expect(190 WallhavenError.decodingError(URLError(.cannotParseResponse)).localizedDescription191 == "Wallhaven sent a response Haven couldn't read.")192 }193194 @Test("Malformed success payloads become decoding errors")195 func malformedPayloadThrowsDecodingError() async {196 MockURLProtocol.requestHandler = { request in197 (response(for: request), Data(#"{"unexpected":true}"#.utf8))198 }199200 do {201 _ = try await makeAPI().searchWallpapers(query: "")202 Issue.record("Expected a WallhavenError.decodingError")203 } catch WallhavenError.decodingError {204 205 } catch {206 Issue.record("Unexpected error: \(error)")207 }208 }209210 @Test("Transport failures propagate to the caller")211 func transportFailurePropagates() async {212 MockURLProtocol.requestHandler = { _ in throw URLError(.notConnectedToInternet) }213214 do {215 _ = try await makeAPI().searchWallpapers(query: "")216 Issue.record("Expected a transport error")217 } catch let error as URLError {218 #expect(error.code == .notConnectedToInternet)219 } catch {220 Issue.record("Unexpected error: \(error)")221 }222 }223}