WallhavenAPITests.swift

7.8 KB · raw

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      // Expected.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      // Expected.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}