WallhavenAPITests.swift

5.6 KB · raw

1import Foundation2import Testing34@testable import Haven56final class MockURLProtocol: URLProtocol, @unchecked Sendable {7  nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, 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("Malformed success payloads become decoding errors")136  func malformedPayloadThrowsDecodingError() async {137    MockURLProtocol.requestHandler = { request in138      (response(for: request), Data(#"{"unexpected":true}"#.utf8))139    }140141    do {142      _ = try await makeAPI().searchWallpapers(query: "")143      Issue.record("Expected a WallhavenError.decodingError")144    } catch WallhavenError.decodingError {145      // Expected.146    } catch {147      Issue.record("Unexpected error: \(error)")148    }149  }150151  @Test("Transport failures propagate to the caller")152  func transportFailurePropagates() async {153    MockURLProtocol.requestHandler = { _ in throw URLError(.notConnectedToInternet) }154155    do {156      _ = try await makeAPI().searchWallpapers(query: "")157      Issue.record("Expected a transport error")158    } catch let error as URLError {159      #expect(error.code == .notConnectedToInternet)160    } catch {161      Issue.record("Unexpected error: \(error)")162    }163  }164}