From 57009eaa47c46ba8df2da4d4df7dd4eb28c25fdd Mon Sep 17 00:00:00 2001 From: lmarceau Date: Thu, 28 Nov 2024 16:25:31 -0500 Subject: [PATCH] Add FXIOS-10715 [sponsored tiles] Get unified ads data with a POST request (#23455) * Get unified ads data with a POST request * Fix context id + clean up * Revert disconnect file change * Fix words --- firefox-ios/Client.xcodeproj/project.pbxproj | 12 +- .../DataManagement/ContileProvider.swift | 2 +- .../UnifiedAds/UnifiedAdsNetwork.swift | 30 --- .../UnifiedAds/UnifiedAdsProvider.swift | 131 +++++++++- .../UnifiedAds/UnifiedTile.swift | 20 ++ .../TopSites/UnifiedAdsProviderTests.swift | 242 ++++++++++++++++++ 6 files changed, 395 insertions(+), 42 deletions(-) delete mode 100644 firefox-ios/Client/Frontend/Home/TopSites/DataManagement/UnifiedAds/UnifiedAdsNetwork.swift create mode 100644 firefox-ios/Client/Frontend/Home/TopSites/DataManagement/UnifiedAds/UnifiedTile.swift create mode 100644 firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Home/TopSites/UnifiedAdsProviderTests.swift diff --git a/firefox-ios/Client.xcodeproj/project.pbxproj b/firefox-ios/Client.xcodeproj/project.pbxproj index 8cf6843cb856..90b8ecde147d 100644 --- a/firefox-ios/Client.xcodeproj/project.pbxproj +++ b/firefox-ios/Client.xcodeproj/project.pbxproj @@ -761,7 +761,6 @@ 8A3345682BA499B7008C52AB /* disconnect-block-social.json in Resources */ = {isa = PBXBuildFile; fileRef = 8A33455E2BA499B7008C52AB /* disconnect-block-social.json */; }; 8A3345692BA499B7008C52AB /* disconnect-block-cookies-social.json in Resources */ = {isa = PBXBuildFile; fileRef = 8A33455F2BA499B7008C52AB /* disconnect-block-cookies-social.json */; }; 8A33456A2BA499B7008C52AB /* disconnect-block-cryptomining.json in Resources */ = {isa = PBXBuildFile; fileRef = 8A3345602BA499B7008C52AB /* disconnect-block-cryptomining.json */; }; - 8A34DD892CF6B31F00DC91FB /* UnifiedAdsNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A34DD882CF6B30F00DC91FB /* UnifiedAdsNetwork.swift */; }; 8A359EF32A1FD449004A5BB7 /* AdjustWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A359EF22A1FD449004A5BB7 /* AdjustWrapper.swift */; }; 8A359EF62A1FE840004A5BB7 /* MockAdjustWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A359EF52A1FE840004A5BB7 /* MockAdjustWrapper.swift */; }; 8A36AC2C2886F27F00CDC0AD /* MockTabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A36AC2B2886F27F00CDC0AD /* MockTabManager.swift */; }; @@ -811,6 +810,8 @@ 8A471185287F6E4800F5A6EA /* SeparatorTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A471184287F6E4800F5A6EA /* SeparatorTableViewCell.swift */; }; 8A4AC0EB28C929D700439F83 /* URLSessionDataTaskProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4AC0E928C929D700439F83 /* URLSessionDataTaskProtocol.swift */; }; 8A4AC0EC28C929D700439F83 /* URLSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4AC0EA28C929D700439F83 /* URLSessionProtocol.swift */; }; + 8A4B14852CF8D67800FCE2D0 /* UnifiedTile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4B14842CF8D67300FCE2D0 /* UnifiedTile.swift */; }; + 8A4B14872CF8D81800FCE2D0 /* UnifiedAdsProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4B14862CF8D80F00FCE2D0 /* UnifiedAdsProviderTests.swift */; }; 8A4EA0D12C010BE700E4E4F1 /* MicrosurveySurfaceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4EA0D02C010BE700E4E4F1 /* MicrosurveySurfaceManager.swift */; }; 8A4EA0D42C01100200E4E4F1 /* MicrosurveySurfaceManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4EA0D22C010BF800E4E4F1 /* MicrosurveySurfaceManagerTests.swift */; }; 8A4EA0D92C01127C00E4E4F1 /* MicrosurveyMockModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4EA0D72C01125100E4E4F1 /* MicrosurveyMockModel.swift */; }; @@ -7358,7 +7359,6 @@ 8A33455E2BA499B7008C52AB /* disconnect-block-social.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; name = "disconnect-block-social.json"; path = "../../../ContentBlockingLists/disconnect-block-social.json"; sourceTree = ""; }; 8A33455F2BA499B7008C52AB /* disconnect-block-cookies-social.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; name = "disconnect-block-cookies-social.json"; path = "../../../ContentBlockingLists/disconnect-block-cookies-social.json"; sourceTree = ""; }; 8A3345602BA499B7008C52AB /* disconnect-block-cryptomining.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; name = "disconnect-block-cryptomining.json"; path = "../../../ContentBlockingLists/disconnect-block-cryptomining.json"; sourceTree = ""; }; - 8A34DD882CF6B30F00DC91FB /* UnifiedAdsNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedAdsNetwork.swift; sourceTree = ""; }; 8A359EF22A1FD449004A5BB7 /* AdjustWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjustWrapper.swift; sourceTree = ""; }; 8A359EF52A1FE840004A5BB7 /* MockAdjustWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAdjustWrapper.swift; sourceTree = ""; }; 8A36AC2B2886F27F00CDC0AD /* MockTabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTabManager.swift; sourceTree = ""; }; @@ -7408,6 +7408,8 @@ 8A471184287F6E4800F5A6EA /* SeparatorTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorTableViewCell.swift; sourceTree = ""; }; 8A4AC0E928C929D700439F83 /* URLSessionDataTaskProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLSessionDataTaskProtocol.swift; sourceTree = ""; }; 8A4AC0EA28C929D700439F83 /* URLSessionProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLSessionProtocol.swift; sourceTree = ""; }; + 8A4B14842CF8D67300FCE2D0 /* UnifiedTile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedTile.swift; sourceTree = ""; }; + 8A4B14862CF8D80F00FCE2D0 /* UnifiedAdsProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedAdsProviderTests.swift; sourceTree = ""; }; 8A4EA0D02C010BE700E4E4F1 /* MicrosurveySurfaceManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MicrosurveySurfaceManager.swift; sourceTree = ""; }; 8A4EA0D22C010BF800E4E4F1 /* MicrosurveySurfaceManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MicrosurveySurfaceManagerTests.swift; sourceTree = ""; }; 8A4EA0D72C01125100E4E4F1 /* MicrosurveyMockModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrosurveyMockModel.swift; sourceTree = ""; }; @@ -12006,6 +12008,7 @@ 8AE80BAB2891955400BC12EA /* TopSites */ = { isa = PBXGroup; children = ( + 8A4B14862CF8D80F00FCE2D0 /* UnifiedAdsProviderTests.swift */, 8A7A93ED2810ADF2005E7E1B /* ContileProviderTests.swift */, 961577932A39008100391E8D /* SponsoredTileDataUtilityTests.swift */, 8A33221E27DFE318008F809E /* TopSitesDataAdaptorTests.swift */, @@ -12047,7 +12050,7 @@ 8AE9FD272CF665C7001053EE /* UnifiedAds */ = { isa = PBXGroup; children = ( - 8A34DD882CF6B30F00DC91FB /* UnifiedAdsNetwork.swift */, + 8A4B14842CF8D67300FCE2D0 /* UnifiedTile.swift */, 8AE9FD252CF662FF001053EE /* UnifiedAdsProvider.swift */, ); path = UnifiedAds; @@ -16681,6 +16684,7 @@ EBC4869D2195F58300CDA48D /* ErrorPageHelper.swift in Sources */, C84655E8288739CB00861B4A /* WallpaperCollectionAvailability.swift in Sources */, 8A454D462CB9C83F009436D9 /* TopSitesMiddleware.swift in Sources */, + 8A4B14852CF8D67800FCE2D0 /* UnifiedTile.swift in Sources */, 8ABC5AEE284532C900FEA552 /* PocketDiscoverCell.swift in Sources */, ABE856AD2C75029F00C56F47 /* TrackingProtectionStatusView.swift in Sources */, C834330026BAD32800ABAAA6 /* EnhancedTrackingProtectionDetailsVM.swift in Sources */, @@ -16998,7 +17002,6 @@ 0BDDB3462CA6E43A00D501DF /* BookmarksSaver.swift in Sources */, EBB89506219398E500EB91A0 /* TabContentBlocker.swift in Sources */, E1B9A2C22CAD91EF00F6A0E9 /* ToolbarTelemetry.swift in Sources */, - 8A34DD892CF6B31F00DC91FB /* UnifiedAdsNetwork.swift in Sources */, 21F2A2D22B0BC85200626AEC /* InactiveTabsModel.swift in Sources */, 966206CD2698DE1E005C0A55 /* BookmarksViewModel.swift in Sources */, D3E8EF101B97BE69001900FB /* ClearPrivateDataTableViewController.swift in Sources */, @@ -17293,6 +17296,7 @@ 8A87B4322CC1A3C0003A9239 /* PocketManagerTests.swift in Sources */, 2F13E79B1AC0C02700D75081 /* StringExtensionsTests.swift in Sources */, CA24B52224ABD7D40093848C /* PasswordManagerViewModelTests.swift in Sources */, + 8A4B14872CF8D81800FCE2D0 /* UnifiedAdsProviderTests.swift in Sources */, E169C6E82979CA0E0017B8D7 /* URLMailTests.swift in Sources */, 8A7A26E129D4785900EA76F1 /* MockRouter.swift in Sources */, 965C3C96293431FC006499ED /* MockLaunchSessionProvider.swift in Sources */, diff --git a/firefox-ios/Client/Frontend/Home/TopSites/DataManagement/ContileProvider.swift b/firefox-ios/Client/Frontend/Home/TopSites/DataManagement/ContileProvider.swift index 4b355bc95f1f..d462f785231e 100644 --- a/firefox-ios/Client/Frontend/Home/TopSites/DataManagement/ContileProvider.swift +++ b/firefox-ios/Client/Frontend/Home/TopSites/DataManagement/ContileProvider.swift @@ -39,7 +39,7 @@ class ContileProvider: ContileProviderInterface, URLCaching, FeatureFlaggable { urlCache: URLCache = URLCache.shared, logger: Logger = DefaultLogger.shared ) { - self.logger = logger + self.logger = logger self.networking = networking self.urlCache = urlCache } diff --git a/firefox-ios/Client/Frontend/Home/TopSites/DataManagement/UnifiedAds/UnifiedAdsNetwork.swift b/firefox-ios/Client/Frontend/Home/TopSites/DataManagement/UnifiedAds/UnifiedAdsNetwork.swift deleted file mode 100644 index 212f39300b48..000000000000 --- a/firefox-ios/Client/Frontend/Home/TopSites/DataManagement/UnifiedAds/UnifiedAdsNetwork.swift +++ /dev/null @@ -1,30 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/ - -import Common -import Foundation - -/// Used only for sponsored tiles content and telemetry. This is aiming to be a temporary API -/// as we'll migrate to using A-S for this at some point in 2025 -protocol UnifiedAdsNetwork { - func data(from request: URLRequest, completion: @escaping (NetworkingContileResult) -> Void) -} - -class DefaultUnifiedAdsNetwork: UnifiedAdsNetwork { - private var urlSession: URLSessionProtocol - private var logger: Logger - - init(with urlSession: URLSessionProtocol, - logger: Logger = DefaultLogger.shared) { - self.urlSession = urlSession - self.logger = logger - } - - func data(from request: URLRequest, completion: @escaping (NetworkingContileResult) -> Void) { - urlSession.dataTaskWith(request: request) { [weak self] data, response, error in - guard self != nil else { return } - // TODO: FXIOS-10715 - }.resume() - } -} diff --git a/firefox-ios/Client/Frontend/Home/TopSites/DataManagement/UnifiedAds/UnifiedAdsProvider.swift b/firefox-ios/Client/Frontend/Home/TopSites/DataManagement/UnifiedAds/UnifiedAdsProvider.swift index ece9834f8aec..a7805277472a 100644 --- a/firefox-ios/Client/Frontend/Home/TopSites/DataManagement/UnifiedAds/UnifiedAdsProvider.swift +++ b/firefox-ios/Client/Frontend/Home/TopSites/DataManagement/UnifiedAds/UnifiedAdsProvider.swift @@ -6,21 +6,38 @@ import Common import Foundation import Shared +typealias UnifiedTileResult = Swift.Result<[UnifiedTile], Error> + /// Used only for sponsored tiles content and telemetry. This is aiming to be a temporary API /// as we'll migrate to using A-S for this at some point in 2025 protocol UnifiedAdsProviderInterface { - func fetchTiles(completion: @escaping (ContileResult) -> Void) + /// Fetch tiles either from cache or backend + /// - Parameters: + /// - timestamp: The timestamp to retrieve from cache, useful for tests. Default is Date.now() + /// - completion: Returns an array of Tiles, can be empty + func fetchTiles(timestamp: Timestamp, completion: @escaping (UnifiedTileResult) -> Void) +} + +extension UnifiedAdsProviderInterface { + func fetchTiles(timestamp: Timestamp = Date.now(), completion: @escaping (UnifiedTileResult) -> Void) { + fetchTiles(timestamp: timestamp, completion: completion) + } } -class UnifiedAdsProvider: UnifiedAdsProviderInterface { - private static let resourceEndpoint = "https://ads.mozilla.org/v1/ads" +class UnifiedAdsProvider: URLCaching, UnifiedAdsProviderInterface, FeatureFlaggable { + private static let prodResourceEndpoint = "https://ads.mozilla.org/v1/ads" + static let stagingResourceEndpoint = "https://ads.allizom.org/v1/ads" var urlCache: URLCache private var logger: Logger - private var networking: UnifiedAdsNetwork + private var networking: ContileNetworking + + enum Error: Swift.Error { + case noDataAvailable + } init( - networking: UnifiedAdsNetwork = DefaultUnifiedAdsNetwork( + networking: ContileNetworking = DefaultContileNetwork( with: makeURLSession(userAgent: UserAgent.mobileUserAgent(), configuration: URLSessionConfiguration.defaultMPTCP)), urlCache: URLCache = URLCache.shared, @@ -31,7 +48,107 @@ class UnifiedAdsProvider: UnifiedAdsProviderInterface { self.urlCache = urlCache } - func fetchTiles(completion: @escaping (ContileResult) -> Void) { - // TODO: FXIOS-10715 + private struct AdPlacement: Codable { + let placement: String + let count: Int + } + + private struct RequestBody: Codable { + let context_id: String + let placements: [AdPlacement] + } + + func fetchTiles(timestamp: Timestamp = Date.now(), completion: @escaping (UnifiedTileResult) -> Void) { + guard let request = buildRequest() else { + completion(.failure(Error.noDataAvailable)) + return + } + + if let cachedData = findCachedData(for: request, timestamp: timestamp) { + decode(data: cachedData, completion: completion) + } else { + fetchTiles(request: request, completion: completion) + } + } + + private func buildRequest() -> URLRequest? { + guard let resourceEndpoint = resourceEndpoint else { + logger.log("The resource URL is invalid: \(String(describing: resourceEndpoint))", + level: .warning, + category: .legacyHomepage) + return nil + } + + guard let contextId = TelemetryContextualIdentifier.contextId else { + logger.log("No context id: \(String(describing: TelemetryContextualIdentifier.contextId))", + level: .warning, + category: .legacyHomepage) + return nil + } + + let requestBody = RequestBody( + context_id: contextId, + placements: [ + AdPlacement(placement: "newtab_mobile_tile_1", count: 1), + AdPlacement(placement: "newtab_mobile_tile_2", count: 1) + ] + ) + + var request = URLRequest(url: resourceEndpoint) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = 5 + request.cachePolicy = .reloadIgnoringLocalCacheData + + do { + let jsonData = try JSONEncoder().encode(requestBody) + request.httpBody = jsonData + } catch { + logger.log("The request body is invalid: \(String(describing: requestBody))", + level: .warning, + category: .legacyHomepage) + return nil + } + return request + } + + private func fetchTiles(request: URLRequest, completion: @escaping (UnifiedTileResult) -> Void) { + networking.data(from: request) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let result): + self.cache(response: result.response, for: request, with: result.data) + self.decode(data: result.data, completion: completion) + case .failure: + completion(.failure(Error.noDataAvailable)) + } + } + } + + private func decode(data: Data, completion: @escaping (UnifiedTileResult) -> Void) { + do { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let tilesDictionary = try decoder.decode([String: [UnifiedTile]].self, from: data) + let tiles = tilesDictionary.values.flatMap { $0 } + + guard !tiles.isEmpty else { + completion(.failure(Error.noDataAvailable)) + return + } + completion(.success(tiles)) + } catch let error { + self.logger.log("Unable to parse with error: \(error)", + level: .warning, + category: .legacyHomepage) + completion(.failure(Error.noDataAvailable)) + } + } + + private var resourceEndpoint: URL? { + if featureFlags.isCoreFeatureEnabled(.useStagingContileAPI) { + return URL(string: UnifiedAdsProvider.stagingResourceEndpoint, invalidCharacters: false) + } + return URL(string: UnifiedAdsProvider.prodResourceEndpoint, invalidCharacters: false) } } diff --git a/firefox-ios/Client/Frontend/Home/TopSites/DataManagement/UnifiedAds/UnifiedTile.swift b/firefox-ios/Client/Frontend/Home/TopSites/DataManagement/UnifiedAds/UnifiedTile.swift new file mode 100644 index 000000000000..5c17fde0c69f --- /dev/null +++ b/firefox-ios/Client/Frontend/Home/TopSites/DataManagement/UnifiedAds/UnifiedTile.swift @@ -0,0 +1,20 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +/// Unified tiles are a type of tiles belonging in the Top sites section on the Firefox home page. +/// See UnifiedAdsProvider and the resource endpoint there for context. +struct UnifiedTile: Decodable { + let format: String + let url: String + let callbacks: UnifiedTileCallback + let imageUrl: String + let name: String + let blockKey: String +} + +// Callbacks for telemetry events +struct UnifiedTileCallback: Decodable { + let click: String + let impression: String +} diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Home/TopSites/UnifiedAdsProviderTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Home/TopSites/UnifiedAdsProviderTests.swift new file mode 100644 index 000000000000..91abcfcae3e7 --- /dev/null +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Home/TopSites/UnifiedAdsProviderTests.swift @@ -0,0 +1,242 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import Foundation +import XCTest + +@testable import Client + +class UnifiedAdsProviderTests: XCTestCase { + private var networking: MockContileNetworking! + + override func setUp() { + super.setUp() + TelemetryContextualIdentifier.setupContextId() + networking = MockContileNetworking() + } + + override func tearDown() { + networking = nil + super.tearDown() + } + + func testFetchTile_givenErrorResponse_thenFailsWithError() { + networking.error = UnifiedAdsProvider.Error.noDataAvailable + let subject = createSubject() + + subject.fetchTiles { result in + switch result { + case let .failure(error as UnifiedAdsProvider.Error): + XCTAssertEqual(error, UnifiedAdsProvider.Error.noDataAvailable) + default: + XCTFail("Expected failure, got \(result) instead") + } + } + } + + func testFetchTile_whenEmptyResponseAndData_thenFailsWithError() { + networking.data = getData(from: emptyResponse) + networking.response = getResponse(from: 200) + let subject = createSubject() + + subject.fetchTiles { result in + switch result { + case let .failure(error as UnifiedAdsProvider.Error): + XCTAssertEqual(error, UnifiedAdsProvider.Error.noDataAvailable) + default: + XCTFail("Expected failure, got \(result) instead") + } + } + } + + func testFetchTile_whenWrongResponseAndData_thenFailsWithError() { + networking.data = getData(from: emptyWrongResponse) + networking.response = getResponse(from: 200) + let subject = createSubject() + + subject.fetchTiles { result in + switch result { + case let .failure(error as UnifiedAdsProvider.Error): + XCTAssertEqual(error, UnifiedAdsProvider.Error.noDataAvailable) + default: + XCTFail("Expected failure, got \(result) instead") + } + } + } + + func testFetchTile_whenEmptyArrayResponseAndData_thenFailsWithError() { + networking.data = getData(from: emptyArrayResponse) + networking.response = getResponse(from: 200) + let subject = createSubject() + + subject.fetchTiles { result in + switch result { + case let .failure(error as UnifiedAdsProvider.Error): + XCTAssertEqual(error, UnifiedAdsProvider.Error.noDataAvailable) + default: + XCTFail("Expected failure, got \(result) instead") + } + } + } + + func testfetchTiles_whenProperTiles_thenSucceedsWithDecodedTiles() { + networking.data = getData(from: tiles) + networking.response = getResponse(from: 200) + let subject = createSubject() + + subject.fetchTiles { result in + switch result { + case let .success(tiles): + XCTAssertEqual(tiles.count, 2) + default: + XCTFail("Expected success, got \(result) instead") + } + } + } + + // MARK: - Cache + + func testCaching_whenCacheData_thenSucceedsFromCache() { + let data = getData(from: tiles) + let response = getResponse(from: 200) + let request = getRequest() + let subject = createSubject() + subject.cache(response: response, for: request, with: data) + + subject.fetchTiles { result in + switch result { + case let .success(tiles): + XCTAssertEqual(tiles.count, 2) + default: + XCTFail("Expected success, got \(result) instead") + } + } + } + + func testCaching_whenEmptyResponse_thenSucceedsFromCache() { + let data = getData(from: emptyResponse) + let response = getResponse(from: 200) + let request = getRequest() + let subject = createSubject() + subject.cache(response: response, for: request, with: data) + + subject.fetchTiles { result in + switch result { + case let .failure(error as UnifiedAdsProvider.Error): + XCTAssertEqual(error, UnifiedAdsProvider.Error.noDataAvailable) + default: + XCTFail("Expected failure, got \(result) instead") + } + } + } + + func testCaching_whenExpiredData_thenFailsWhenCacheIsTooOld() { + let data = getData(from: tiles) + let response = getResponse(from: 200) + let request = getRequest() + let subject = createSubject() + subject.cache(response: response, for: request, with: data) + + subject.fetchTiles(timestamp: Date.tomorrow.toTimestamp()) { result in + switch result { + case let .failure(error as UnifiedAdsProvider.Error): + XCTAssertEqual(error, UnifiedAdsProvider.Error.noDataAvailable) + default: + XCTFail("Expected failure, got \(result) instead") + } + } + } + + func testCaching_whenExpired_thenFailsIfCacheIsNewerThanCurrentDate() { + let data = getData(from: tiles) + let response = getResponse(from: 200) + let request = getRequest() + let subject = createSubject() + subject.cache(response: response, for: request, with: data) + + subject.fetchTiles(timestamp: Date.yesterday.toTimestamp()) { result in + switch result { + case let .failure(error as UnifiedAdsProvider.Error): + XCTAssertEqual(error, UnifiedAdsProvider.Error.noDataAvailable) + default: + XCTFail("Expected failure, got \(result) instead") + } + } + } + + // MARK: - Helper functions + + func createSubject(file: StaticString = #filePath, line: UInt = #line) -> UnifiedAdsProvider { + let cache = URLCache(memoryCapacity: 100000, diskCapacity: 1000, directory: URL(string: "/dev/null")) + let subject = UnifiedAdsProvider(networking: networking, urlCache: cache) + + trackForMemoryLeaks(subject, file: file, line: line) + + return subject + } + + func getData(from string: String) -> Data { + return string.data(using: .utf8)! + } + + func getResponse(from statusCode: Int) -> HTTPURLResponse { + return HTTPURLResponse(url: URL(string: UnifiedAdsProvider.stagingResourceEndpoint)!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil)! + } + + func getRequest() -> URLRequest { + return URLRequest(url: URL(string: UnifiedAdsProvider.stagingResourceEndpoint)!) + } + + // MARK: - Mock responses + + var emptyArrayResponse: String { + return "{\"newtab_mobile_tile_1\":[]}" + } + + var emptyWrongResponse: String { + return "{\"newtab_mobile_tile_1\":[{\"answer\":\"isBad\"}]}" + } + + var emptyResponse: String { + return "{}" + } + + let tiles: String = """ +{ + "newtab_mobile_tile_1": [ + { + "format": "tile", + "url": "https://www.test1.com", + "callbacks": { + "click": "https://www.test2.com", + "impression": "https://www.test3.com" + }, + "image_url": "https://www.test4.com", + "name": "Test1", + "block_key": "12345" + } + ], + "newtab_mobile_tile_2": [ + { + "format": "tile", + "url": "https://www.test5.com", + "callbacks": { + "click": "https://www.test6.com", + "impression": "https://www.test7.com" + }, + "image_url": "https://www.test8.com", + "name": "Test2", + "block_key": "6789" + } + ] +} +""" + + var anError: NSError { + return NSError(domain: "test error", code: 0) + } +}