import XCTest import os.signpost @testable import RSSuper final class FeedFetcherTests: XCTestCase { private var fetcher: FeedFetcher! private var session: MockURLSession! override func setUp() { super.setUp() session = MockURLSession() fetcher = FeedFetcher(session: session) } override func tearDown() { fetcher = nil session = nil super.tearDown() } func testFetchFeedSuccess() async throws { let url = URL(string: "https://example.com/feed.xml")! let feedData = Data(rssSample.utf8) session.response = (feedData, HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!) let result = try await fetcher.fetchFeed(url: url) XCTAssertEqual(result.feedData, feedData) XCTAssertEqual(result.url, url) XCTAssertFalse(result.cached) } func testFetchFeedWithAuthentication() async throws { let url = URL(string: "https://example.com/feed.xml")! let feedData = Data(rssSample.utf8) let credentials = HTTPAuthCredentials(username: "user", password: "pass") session.response = (feedData, HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!) let result = try await fetcher.fetchFeed(url: url, credentials: credentials) XCTAssertEqual(result.feedData, feedData) let authHeader = session.lastAuthHeader XCTAssertTrue(authHeader?.starts(with: "Basic ") ?? false) } func testFetchFeedTimeoutError() async throws { let url = URL(string: "https://example.com/feed.xml")! session.error = URLError(.timedOut) do { _ = try await fetcher.fetchFeed(url: url) XCTFail("Expected error to be thrown") } catch { XCTAssertTrue(true) } } func testFetchFeed404Error() async throws { let url = URL(string: "https://example.com/feed.xml")! session.response = (Data(), HTTPURLResponse(url: url, statusCode: 404, httpVersion: "HTTP/1.1", headerFields: nil)!) do { _ = try await fetcher.fetchFeed(url: url) XCTFail("Expected error to be thrown") } catch { XCTAssertTrue(true) } } func testFetchFeedAuthenticationError() async throws { let url = URL(string: "https://example.com/feed.xml")! session.response = (Data(), HTTPURLResponse(url: url, statusCode: 401, httpVersion: "HTTP/1.1", headerFields: nil)!) do { _ = try await fetcher.fetchFeed(url: url) XCTFail("Expected error to be thrown") } catch { XCTAssertTrue(true) } } func testFetchFeedRetriesOnTimeout() async throws { let url = URL(string: "https://example.com/feed.xml")! let feedData = Data(rssSample.utf8) var callCount = 0 session.onRequest = { [unowned self] in callCount += 1 if callCount < 3 { self.session.error = URLError(.timedOut) } } session.response = (feedData, HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!) let result = try await fetcher.fetchFeed(url: url) XCTAssertEqual(result.feedData, feedData) XCTAssertEqual(callCount, 3) } func testFetchFeedCaching() async throws { let url = URL(string: "https://example.com/feed.xml")! let feedData = Data(rssSample.utf8) session.response = (feedData, HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!) let result1 = try await fetcher.fetchFeed(url: url) XCTAssertFalse(result1.cached) let result2 = try await fetcher.fetchFeed(url: url) XCTAssertTrue(result2.cached) XCTAssertEqual(result1.feedData, result2.feedData) } func testFetchFeedRespectsCacheControlNoStore() async throws { let url = URL(string: "https://example.com/feed.xml")! let feedData = Data(rssSample.utf8) session.response = (feedData, HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: ["Cache-Control": "no-store"])!) let result1 = try await fetcher.fetchFeed(url: url) XCTAssertFalse(result1.cached) let result2 = try await fetcher.fetchFeed(url: url) XCTAssertFalse(result2.cached) } func testHTTPAuthAuthorizationHeader() { let credentials = HTTPAuthCredentials(username: "user", password: "pass") let authHeader = credentials.authorizationHeader() XCTAssertEqual(authHeader, "Basic dXNlcjpwYXNz") } func testHTTPAuthAuthorizationHeaderWithSpecialCharacters() { let credentials = HTTPAuthCredentials(username: "user@domain", password: "p@ss:w0rd") let authHeader = credentials.authorizationHeader() let expectedData = "user@domain:p@ss:w0rd".data(using: .utf8)! let expectedBase64 = expectedData.base64EncodedString() XCTAssertEqual(authHeader, "Basic \(expectedBase64)") } } private let rssSample = """ Test Feed https://example.com A test feed Test Item https://example.com/item1 item-1 Test item description """ final class MockURLSession: URLSession { var response: (Data, URLResponse)? var error: Error? var onRequest: (() -> Void)? var lastAuthHeader: String? private var requestCounter = 0 override func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask { requestCounter += 1 if let auth = request.allHTTPHeaderFields?["Authorization"] { lastAuthHeader = auth } onRequest?() if let error { completionHandler(nil, nil, error) return MockURLSessionDataTask(error: error) } if let (data, response) = response { completionHandler(data, response, nil) return MockURLSessionDataTask() } completionHandler(nil, nil, nil) return MockURLSessionDataTask() } func reset() { requestCounter = 0 lastAuthHeader = nil } } final class MockURLSessionDataTask: URLSessionDataTask { private let mockError: Error? init(error: Error? = nil) { self.mockError = error super.init() } override func resume() { // No-op for testing } }