import Foundation import Testing @testable import Haven final class MockURLProtocol: URLProtocol, @unchecked Sendable { nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (URLResponse, Data))? override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func startLoading() { guard let handler = Self.requestHandler else { client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) return } do { let (response, data) = try handler(request) client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: data) client?.urlProtocolDidFinishLoading(self) } catch { client?.urlProtocol(self, didFailWithError: error) } } override func stopLoading() {} } @Suite("Wallhaven API", .serialized) @MainActor struct WallhavenAPITests { private func makeAPI() -> WallhavenAPI { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [MockURLProtocol.self] return WallhavenAPI(session: URLSession(configuration: configuration)) } private func response( for request: URLRequest, statusCode: Int = 200, contentType: String = "application/json" ) -> HTTPURLResponse { HTTPURLResponse( url: request.url!, statusCode: statusCode, httpVersion: "HTTP/1.1", headerFields: ["Content-Type": contentType] )! } @Test("Search sends every selected filter and decodes the response") func searchBuildsExpectedRequest() async throws { MockURLProtocol.requestHandler = { request in let url = try #require(request.url) let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) let items = Dictionary( uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") }) #expect(components.path == "/api/v1/search") #expect(items["q"] == "mountain lake") #expect(items["categories"] == "101") #expect(items["purity"] == "110") #expect(items["sorting"] == "toplist") #expect(items["resolutions"] == "1920x1080,3840x2160") #expect(items["ratios"] == "16x9,21x9") #expect(items["limit"] == "12") #expect(items["page"] == "3") return (response(for: request), try encodedSearchResponse(currentPage: 3, lastPage: 9)) } let (wallpapers, meta) = try await makeAPI().searchWallpapers( query: "mountain lake", categories: "101", purity: "110", sorting: .toplist, resolutions: ["1920x1080", "3840x2160"], ratios: ["16x9", "21x9"], limit: 12, page: 3 ) #expect(wallpapers == [makeWallpaper()]) #expect(meta?.current_page == 3) #expect(meta?.last_page == 9) } @Test("Empty filters are omitted and defaults are stable") func emptyFiltersAreOmitted() async throws { MockURLProtocol.requestHandler = { request in let url = try #require(request.url) let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) let items = Dictionary( uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") }) #expect(items["categories"] == "111") #expect(items["purity"] == "100") #expect(items["sorting"] == "date_added") #expect(items["limit"] == "24") #expect(items["page"] == "1") #expect(items["resolutions"] == nil) #expect(items["ratios"] == nil) return (response(for: request), try encodedSearchResponse()) } _ = try await makeAPI().searchWallpapers(query: "") } @Test("Random mode maps to the random sorting query") func randomSortingIsEncoded() async throws { MockURLProtocol.requestHandler = { request in let url = try #require(request.url) let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) #expect(components.queryItems?.first(where: { $0.name == "sorting" })?.value == "random") return (response(for: request), try encodedSearchResponse()) } _ = try await makeAPI().searchWallpapers(query: "", sorting: .random) } @Test("Failures name the cause instead of an opaque error code") func failuresDescribeTheirCause() async { let rejectionCases: [(Int, String)] = [ (429, "Wallhaven rate limit reached (HTTP 429). Try again in a minute."), (404, "Wallhaven rejected the request (HTTP 404)."), ] for (status, expected) in rejectionCases { MockURLProtocol.requestHandler = { request in (response(for: request, statusCode: status), Data()) } do { _ = try await makeAPI().searchWallpapers(query: "") Issue.record("Expected HTTP \(status) to throw") } catch WallhavenError.apiError(let message) { #expect(message == expected) } catch { Issue.record("Unexpected error: \(error)") } } // 5xx is Wallhaven's own outage, so it is reported as one. MockURLProtocol.requestHandler = { request in (response(for: request, statusCode: 521), Data()) } do { _ = try await makeAPI().searchWallpapers(query: "") Issue.record("Expected HTTP 521 to throw") } catch WallhavenError.unavailable(let message) { #expect(message == "Wallhaven is unavailable right now (HTTP 521).") } catch { Issue.record("Unexpected error: \(error)") } } @Test("Wallhaven's HTML status page is reported as an outage, not a bad response") func statusPageIsReportedAsOffline() async { MockURLProtocol.requestHandler = { request in ( response( for: request, contentType: "text/html; charset=utf-8"), Data("