WallhavenAPITests.swift

8.7 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(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    // 5xx is Wallhaven's own outage, so it is reported as one.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      // Expected.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}