mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Residual round after the macOS fix verification, five closures:
A connection refusal or a failed DNS lookup arrived as APIError.unknown,
which no classifier reads — so an account pointed at a downed instance
never went unreachable, never fell back to local data, never armed a
retry. New APIError.connectionFailed carries the system's own sentence
and lands as unreachable/offline in AccountsManager, InstancesManager
and the credential probe; a refused host now classifies in seconds
instead of never.
Subscriptions' periodic retry armed only for networkError, while the
local fallback's yatteeServerRequired — shown exactly when the account's
server could not be reached — waited on a status expiry nothing was
watching. FeedLoadState.warrantsPeriodicRetry gates on the context's
unavailability, so that screen heals on its own too, and an account that
genuinely has no Yattee Server still never loops.
The switcher dropped every context of a switched-off source, leaving the
toolbar badge pointing at a menu its own selection had vanished from.
listedContexts keeps the rows with their status in the title ("— Source
switched off"), isSwitchable refuses the pick, and the current context
keeps its checkmark.
The sidebar footer's switcher row overflowed the 140 pt column minimum:
the width floor now sits on the menu itself and ViewThatFits drops the
caption — the part the icon already says — before the account name loses
characters.
The channel menu exposed a raw SF Symbol name as its accessibility
title; the label is now a real Label rendered iconOnly, so the AX bridge
hears "Subscription Options".
398 lines
16 KiB
Swift
398 lines
16 KiB
Swift
//
|
|
// NetworkingTests.swift
|
|
// YatteeTests
|
|
//
|
|
// Tests for networking layer components.
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
@testable import Yattee
|
|
|
|
// MARK: - Endpoint Tests
|
|
|
|
@Suite("Endpoint Tests")
|
|
@MainActor
|
|
struct EndpointTests {
|
|
|
|
@Test("GET endpoint construction")
|
|
func getEndpoint() {
|
|
let endpoint = GenericEndpoint.get("/api/v1/videos")
|
|
#expect(endpoint.path == "/api/v1/videos")
|
|
#expect(endpoint.method == .get)
|
|
#expect(endpoint.queryItems == nil)
|
|
#expect(endpoint.body == nil)
|
|
}
|
|
|
|
@Test("GET endpoint with query parameters")
|
|
func getEndpointWithQuery() {
|
|
let endpoint = GenericEndpoint.get("/api/v1/search", query: [
|
|
"q": "test",
|
|
"page": "1"
|
|
])
|
|
#expect(endpoint.path == "/api/v1/search")
|
|
#expect(endpoint.queryItems?.count == 2)
|
|
|
|
let queryDict = Dictionary(uniqueKeysWithValues: endpoint.queryItems!.map { ($0.name, $0.value) })
|
|
#expect(queryDict["q"] == "test")
|
|
#expect(queryDict["page"] == "1")
|
|
}
|
|
|
|
@Test("POST endpoint with body")
|
|
func postEndpointWithBody() throws {
|
|
struct TestBody: Encodable {
|
|
let name: String
|
|
}
|
|
let body = TestBody(name: "test")
|
|
let endpoint = GenericEndpoint.post("/api/v1/create", body: body)
|
|
#expect(endpoint.path == "/api/v1/create")
|
|
#expect(endpoint.method == .post)
|
|
#expect(endpoint.body != nil)
|
|
}
|
|
|
|
@Test("Generic endpoint with custom timeout")
|
|
func endpointWithTimeout() {
|
|
let endpoint = GenericEndpoint(path: "/slow", timeout: 60)
|
|
#expect(endpoint.timeout == 60)
|
|
}
|
|
|
|
@Test("Default endpoint timeout is 30 seconds")
|
|
func defaultTimeout() {
|
|
let endpoint = GenericEndpoint.get("/fast")
|
|
#expect(endpoint.timeout == 30)
|
|
}
|
|
}
|
|
|
|
// MARK: - APIError Tests
|
|
|
|
@Suite("APIError Tests")
|
|
@MainActor
|
|
struct APIErrorTests {
|
|
|
|
@Test("APIError descriptions")
|
|
func errorDescriptions() {
|
|
let invalidURL = APIError.invalidURL
|
|
#expect(invalidURL.localizedDescription.contains("URL"))
|
|
|
|
let httpError = APIError.httpError(statusCode: 404, message: nil)
|
|
#expect(httpError.localizedDescription.contains("404"))
|
|
|
|
let timeout = APIError.timeout
|
|
#expect(timeout.localizedDescription.contains("timed out"))
|
|
|
|
let notFound = APIError.notFound(nil)
|
|
#expect(notFound.localizedDescription.contains("not found"))
|
|
}
|
|
|
|
@Test("All simple error descriptions")
|
|
func allSimpleErrorDescriptions() {
|
|
#expect(APIError.invalidURL.errorDescription == "Invalid URL")
|
|
#expect(APIError.timeout.errorDescription == "Request timed out")
|
|
#expect(APIError.noConnection.errorDescription == "No network connection")
|
|
#expect(APIError.cancelled.errorDescription == "Request was cancelled")
|
|
#expect(APIError.unauthorized.errorDescription == "Authentication required")
|
|
#expect(APIError.notFound(nil).errorDescription == "Resource not found")
|
|
#expect(APIError.commentsDisabled.errorDescription == "Comments are disabled")
|
|
#expect(APIError.noInstance.errorDescription == "No suitable instance available")
|
|
#expect(APIError.noStreams.errorDescription == "No playable streams available")
|
|
#expect(APIError.invalidRequest.errorDescription == "Invalid request")
|
|
}
|
|
|
|
@Test("Decoding error description")
|
|
func decodingErrorDescription() {
|
|
let error = APIError.decodingError("Missing key 'title'")
|
|
#expect(error.errorDescription?.contains("Missing key 'title'") == true)
|
|
}
|
|
|
|
@Test("Server error description")
|
|
func serverErrorDescription() {
|
|
let error = APIError.serverError("Internal server error")
|
|
#expect(error.errorDescription?.contains("Internal server error") == true)
|
|
}
|
|
|
|
@Test("Rate limited description with retry after")
|
|
func rateLimitedWithRetry() {
|
|
let error = APIError.rateLimited(retryAfter: 60)
|
|
#expect(error.errorDescription?.contains("60") == true)
|
|
}
|
|
|
|
@Test("Rate limited description without retry after")
|
|
func rateLimitedWithoutRetry() {
|
|
let error = APIError.rateLimited(retryAfter: nil)
|
|
#expect(error.errorDescription == "Rate limited")
|
|
}
|
|
|
|
@Test("Connection failed description is the system's own sentence")
|
|
func connectionFailedDescription() {
|
|
// Passed through rather than replaced: "could not connect" and "no such
|
|
// host" are different things to tell a user, and the OS already says
|
|
// which one happened.
|
|
let refused = APIError.connectionFailed("Could not connect to the server.")
|
|
#expect(refused.errorDescription == "Could not connect to the server.")
|
|
|
|
// Emphatically not the offline message: the device's network is fine.
|
|
#expect(refused.errorDescription != APIError.noConnection.errorDescription)
|
|
}
|
|
|
|
@Test("Unknown error description")
|
|
func unknownErrorDescription() {
|
|
let error = APIError.unknown("Something went wrong")
|
|
#expect(error.errorDescription == "Something went wrong")
|
|
}
|
|
|
|
@Test("APIError equality")
|
|
func errorEquality() {
|
|
#expect(APIError.invalidURL == APIError.invalidURL)
|
|
#expect(APIError.httpError(statusCode: 404, message: nil) == APIError.httpError(statusCode: 404, message: nil))
|
|
#expect(APIError.httpError(statusCode: 404, message: nil) != APIError.httpError(statusCode: 500, message: nil))
|
|
#expect(APIError.timeout == APIError.timeout)
|
|
#expect(APIError.notFound(nil) == APIError.notFound(nil))
|
|
#expect(APIError.unauthorized == APIError.unauthorized)
|
|
}
|
|
|
|
@Test("APIError equality for parameterized errors")
|
|
func parameterizedErrorEquality() {
|
|
#expect(APIError.decodingError("msg") == APIError.decodingError("msg"))
|
|
#expect(APIError.decodingError("msg1") != APIError.decodingError("msg2"))
|
|
|
|
#expect(APIError.serverError("msg") == APIError.serverError("msg"))
|
|
#expect(APIError.serverError("msg1") != APIError.serverError("msg2"))
|
|
|
|
#expect(APIError.rateLimited(retryAfter: 30) == APIError.rateLimited(retryAfter: 30))
|
|
#expect(APIError.rateLimited(retryAfter: nil) == APIError.rateLimited(retryAfter: nil))
|
|
#expect(APIError.rateLimited(retryAfter: 30) != APIError.rateLimited(retryAfter: 60))
|
|
|
|
#expect(APIError.connectionFailed("refused") == APIError.connectionFailed("refused"))
|
|
#expect(APIError.connectionFailed("refused") != APIError.connectionFailed("no such host"))
|
|
#expect(APIError.connectionFailed("refused") != APIError.noConnection)
|
|
|
|
#expect(APIError.unknown("msg") == APIError.unknown("msg"))
|
|
#expect(APIError.unknown("msg1") != APIError.unknown("msg2"))
|
|
|
|
#expect(APIError.notFound(nil) == APIError.notFound(nil))
|
|
#expect(APIError.notFound("detail") == APIError.notFound("detail"))
|
|
#expect(APIError.notFound("detail1") != APIError.notFound("detail2"))
|
|
#expect(APIError.notFound(nil) != APIError.notFound("detail"))
|
|
}
|
|
|
|
@Test("notFound error with detail message")
|
|
func notFoundWithDetail() {
|
|
let noDetail = APIError.notFound(nil)
|
|
#expect(noDetail.errorDescription == "Resource not found")
|
|
|
|
let withDetail = APIError.notFound("Video not found: This live event will begin in 11 days.")
|
|
#expect(withDetail.errorDescription == "Video not found: This live event will begin in 11 days.")
|
|
}
|
|
|
|
@Test("Different error types are not equal")
|
|
func differentTypesNotEqual() {
|
|
#expect(APIError.invalidURL != APIError.timeout)
|
|
#expect(APIError.notFound(nil) != APIError.unauthorized)
|
|
#expect(APIError.commentsDisabled != APIError.noStreams)
|
|
}
|
|
|
|
@Test("APIError isRetryable")
|
|
func retryableErrors() {
|
|
#expect(APIError.timeout.isRetryable == true)
|
|
#expect(APIError.noConnection.isRetryable == true)
|
|
// A refused connection is a moment, not a verdict: the server may be
|
|
// restarting, the lookup may have missed.
|
|
#expect(APIError.connectionFailed("Could not connect to the server.").isRetryable == true)
|
|
#expect(APIError.rateLimited(retryAfter: 60).isRetryable == true)
|
|
#expect(APIError.httpError(statusCode: 500, message: nil).isRetryable == true)
|
|
#expect(APIError.httpError(statusCode: 429, message: nil).isRetryable == true)
|
|
|
|
#expect(APIError.invalidURL.isRetryable == false)
|
|
#expect(APIError.notFound(nil).isRetryable == false)
|
|
#expect(APIError.unauthorized.isRetryable == false)
|
|
#expect(APIError.httpError(statusCode: 400, message: nil).isRetryable == false)
|
|
}
|
|
|
|
@Test("All non-retryable errors")
|
|
func allNonRetryableErrors() {
|
|
#expect(APIError.invalidURL.isRetryable == false)
|
|
#expect(APIError.decodingError("").isRetryable == false)
|
|
#expect(APIError.cancelled.isRetryable == false)
|
|
#expect(APIError.serverError("").isRetryable == false)
|
|
#expect(APIError.unauthorized.isRetryable == false)
|
|
#expect(APIError.notFound(nil).isRetryable == false)
|
|
#expect(APIError.commentsDisabled.isRetryable == false)
|
|
#expect(APIError.noInstance.isRetryable == false)
|
|
#expect(APIError.noStreams.isRetryable == false)
|
|
#expect(APIError.invalidRequest.isRetryable == false)
|
|
#expect(APIError.unknown("").isRetryable == false)
|
|
}
|
|
|
|
@Test("Server errors (5xx) are retryable")
|
|
func serverErrorsRetryable() {
|
|
#expect(APIError.httpError(statusCode: 500, message: nil).isRetryable == true)
|
|
#expect(APIError.httpError(statusCode: 502, message: nil).isRetryable == true)
|
|
#expect(APIError.httpError(statusCode: 503, message: nil).isRetryable == true)
|
|
#expect(APIError.httpError(statusCode: 504, message: nil).isRetryable == true)
|
|
}
|
|
|
|
@Test("Decoding error from Swift DecodingError types")
|
|
func decodingErrorFactory() {
|
|
// Test typeMismatch
|
|
let typeMismatchContext = DecodingError.Context(codingPath: [], debugDescription: "Expected String")
|
|
let typeMismatch = DecodingError.typeMismatch(String.self, typeMismatchContext)
|
|
let apiError1 = APIError.decodingError(typeMismatch)
|
|
#expect(apiError1.errorDescription?.contains("Type mismatch") == true)
|
|
|
|
// Test valueNotFound
|
|
let valueNotFoundContext = DecodingError.Context(codingPath: [], debugDescription: "No value")
|
|
let valueNotFound = DecodingError.valueNotFound(Int.self, valueNotFoundContext)
|
|
let apiError2 = APIError.decodingError(valueNotFound)
|
|
#expect(apiError2.errorDescription?.contains("Value not found") == true)
|
|
|
|
// Test dataCorrupted
|
|
let dataCorruptedContext = DecodingError.Context(codingPath: [], debugDescription: "Corrupted")
|
|
let dataCorrupted = DecodingError.dataCorrupted(dataCorruptedContext)
|
|
let apiError3 = APIError.decodingError(dataCorrupted)
|
|
#expect(apiError3.errorDescription?.contains("Data corrupted") == true)
|
|
}
|
|
}
|
|
|
|
// MARK: - URL Building Tests
|
|
|
|
@Suite("URL Building Tests")
|
|
@MainActor
|
|
struct URLBuildingTests {
|
|
|
|
@Test("Build URL from base and endpoint")
|
|
func buildURL() throws {
|
|
let baseURL = URL(string: "https://api.example.com")!
|
|
let endpoint = GenericEndpoint.get("/v1/videos")
|
|
|
|
let request = try endpoint.urlRequest(baseURL: baseURL)
|
|
#expect(request.url?.absoluteString == "https://api.example.com/v1/videos")
|
|
}
|
|
|
|
@Test("Build URL with query parameters")
|
|
func buildURLWithQuery() throws {
|
|
let baseURL = URL(string: "https://api.example.com")!
|
|
let endpoint = GenericEndpoint.get("/search", query: [
|
|
"q": "hello world",
|
|
"limit": "10"
|
|
])
|
|
|
|
let request = try endpoint.urlRequest(baseURL: baseURL)
|
|
let urlString = request.url?.absoluteString ?? ""
|
|
|
|
#expect(urlString.contains("q=hello%20world"))
|
|
#expect(urlString.contains("limit=10"))
|
|
}
|
|
|
|
@Test("URLRequest has correct method")
|
|
func requestMethod() throws {
|
|
let baseURL = URL(string: "https://api.example.com")!
|
|
|
|
let getEndpoint = GenericEndpoint.get("/resource")
|
|
let getRequest = try getEndpoint.urlRequest(baseURL: baseURL)
|
|
#expect(getRequest.httpMethod == "GET")
|
|
|
|
let postEndpoint = GenericEndpoint.post("/resource", body: ["key": "value"])
|
|
let postRequest = try postEndpoint.urlRequest(baseURL: baseURL)
|
|
#expect(postRequest.httpMethod == "POST")
|
|
}
|
|
|
|
@Test("URLRequest has JSON Accept header")
|
|
func acceptHeader() throws {
|
|
let baseURL = URL(string: "https://api.example.com")!
|
|
let endpoint = GenericEndpoint.get("/resource")
|
|
let request = try endpoint.urlRequest(baseURL: baseURL)
|
|
|
|
#expect(request.value(forHTTPHeaderField: "Accept") == "application/json")
|
|
}
|
|
}
|
|
|
|
// MARK: - URLError Mapping Tests
|
|
|
|
/// Fails every request with a prepared `URLError`, so what a test measures is
|
|
/// the client's own mapping and nothing else.
|
|
private final class FailingURLProtocol: URLProtocol, @unchecked Sendable {
|
|
nonisolated(unsafe) static var failure = URLError(.cannotConnectToHost)
|
|
|
|
override class func canInit(with request: URLRequest) -> Bool { true }
|
|
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
|
|
|
override func startLoading() {
|
|
client?.urlProtocol(self, didFailWithError: Self.failure)
|
|
}
|
|
|
|
override func stopLoading() {}
|
|
}
|
|
|
|
@Suite("HTTPClient URLError mapping", .serialized)
|
|
struct URLErrorMappingTests {
|
|
|
|
private func mappedError(for code: URLError.Code) async -> APIError? {
|
|
FailingURLProtocol.failure = URLError(code)
|
|
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.protocolClasses = [FailingURLProtocol.self]
|
|
let client = HTTPClient(session: URLSession(configuration: configuration))
|
|
|
|
do {
|
|
_ = try await client.fetchData(
|
|
GenericEndpoint.get("/api/v1/stats"),
|
|
baseURL: URL(string: "https://refusing.example.com")!
|
|
)
|
|
return nil
|
|
} catch let error as APIError {
|
|
return error
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
@Test("A server that refuses or cannot be found maps to connectionFailed")
|
|
func unreachableHostsMapToConnectionFailed() async {
|
|
// All three used to fall through to `unknown`, which no status
|
|
// classifier recognises — so an account pointed at a downed instance
|
|
// never went unreachable, never fell back to local data and never got
|
|
// its retry.
|
|
for code in [URLError.Code.cannotConnectToHost, .cannotFindHost, .dnsLookupFailed] {
|
|
let error = await mappedError(for: code)
|
|
guard case .connectionFailed = error else {
|
|
Issue.record("\(code) mapped to \(String(describing: error)), not connectionFailed")
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test("The mapping still separates offline, timed out and cancelled")
|
|
func otherCodesKeepTheirMeaning() async {
|
|
#expect(await mappedError(for: .timedOut) == .timeout)
|
|
#expect(await mappedError(for: .notConnectedToInternet) == .noConnection)
|
|
#expect(await mappedError(for: .networkConnectionLost) == .noConnection)
|
|
#expect(await mappedError(for: .cancelled) == .cancelled)
|
|
}
|
|
|
|
@Test("Unreachable hosts are recorded against the account, offline is too")
|
|
@MainActor
|
|
func mappedErrorsReachTheStatusClassifiers() async throws {
|
|
let error = try #require(await mappedError(for: .cannotConnectToHost))
|
|
|
|
// The whole point of the new case: both classifiers have to see it.
|
|
#expect(CredentialProbeService.classify(error) == .unreachable)
|
|
#expect(error.isRetryable)
|
|
}
|
|
}
|
|
|
|
// MARK: - HTTPMethod Tests
|
|
|
|
@Suite("HTTPMethod Tests")
|
|
@MainActor
|
|
struct HTTPMethodTests {
|
|
|
|
@Test("HTTPMethod raw values")
|
|
func rawValues() {
|
|
#expect(HTTPMethod.get.rawValue == "GET")
|
|
#expect(HTTPMethod.post.rawValue == "POST")
|
|
#expect(HTTPMethod.put.rawValue == "PUT")
|
|
#expect(HTTPMethod.patch.rawValue == "PATCH")
|
|
#expect(HTTPMethod.delete.rawValue == "DELETE")
|
|
}
|
|
}
|