// This file is part of Kiwix for iOS & macOS. // // Kiwix is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by // the Free Software Foundation; either version 3 of the License, or // any later version. // // Kiwix is distributed in the hope that it will be useful, but // WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU // General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Kiwix; If not, see https://www.gnu.org/licenses/. import CoreData import XCTest import Defaults import Combine @testable import Kiwix private class HTTPTestingURLProtocol: URLProtocol { @MainActor static var handler: ((URLProtocol) -> Void)? override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func stopLoading() { } @MainActor override func startLoading() { if let handler = HTTPTestingURLProtocol.handler { handler(self) } else { client?.urlProtocolDidFinishLoading(self) } } } final class LibraryRefreshViewModelTest: XCTestCase { private var urlSession: URLSession! private var cancellables = Set() @MainActor override func setUpWithError() throws { let config = URLSessionConfiguration.ephemeral config.protocolClasses = [HTTPTestingURLProtocol.self] urlSession = URLSession(configuration: config) HTTPTestingURLProtocol.handler = { urlProtocol in let response = HTTPURLResponse( url: URL.mock(), statusCode: 200, httpVersion: nil, headerFields: [:] )! let data = self.makeOPDSData(zimFileID: UUID()).data(using: .utf8)! urlProtocol.client?.urlProtocol(urlProtocol, didLoad: data) urlProtocol.client?.urlProtocol(urlProtocol, didReceive: response, cacheStoragePolicy: .notAllowed) urlProtocol.client?.urlProtocolDidFinishLoading(urlProtocol) } } @MainActor override func tearDownWithError() throws { HTTPTestingURLProtocol.handler = nil } private func makeOPDSData(zimFileID: UUID) -> String { // swiftlint:disable line_length """ urn:uuid:\(zimFileID.uuidString.lowercased()) Best of Wikipedia 2023-01-07T00:00:00Z A selection of the best 50,000 Wikipedia articles eng wikipedia_en_top maxi wikipedia wikipedia;_category:wikipedia;_pictures:yes;_videos:no;_details:yes;_ftindex:yes 50001 566835 Wikipedia Kiwix 2023-01-07T00:00:00Z """ // swiftlint:enable line_length } /// Test time out fetching library data. @MainActor func testFetchTimeOut() async { HTTPTestingURLProtocol.handler = { urlProtocol in urlProtocol.client?.urlProtocol(urlProtocol, didFailWithError: URLError(URLError.Code.timedOut)) } let testDefaults = TestDefaults() testDefaults.setup() let viewModel = LibraryViewModel(urlSession: urlSession, processFactory: { LibraryProcess(defaultState: .initial) }, defaults: testDefaults, categories: CategoriesToLanguages(withDefaults: testDefaults), database: TestDatabase()) await viewModel.start(isUserInitiated: true) XCTAssert(viewModel.error is LibraryRefreshError) XCTAssertEqual( viewModel.error?.localizedDescription, "Error retrieving catalog data. The operation couldn’t be completed. (NSURLErrorDomain error -1001.)" ) } /// Test fetching library data URL response contains non-success status code. @MainActor func testFetchBadStatusCode() async { HTTPTestingURLProtocol.handler = { urlProtocol in let response = HTTPURLResponse( url: URL.mock(), statusCode: 404, httpVersion: nil, headerFields: [:] )! urlProtocol.client?.urlProtocol(urlProtocol, didReceive: response, cacheStoragePolicy: .notAllowed) urlProtocol.client?.urlProtocolDidFinishLoading(urlProtocol) } let testDefaults = TestDefaults() testDefaults.setup() let viewModel = LibraryViewModel(urlSession: urlSession, processFactory: { LibraryProcess(defaultState: .initial) }, defaults: testDefaults, categories: CategoriesToLanguages(withDefaults: testDefaults), database: TestDatabase()) await viewModel.start(isUserInitiated: true) XCTAssert(viewModel.error is LibraryRefreshError) XCTAssertEqual( viewModel.error?.localizedDescription, "Error retrieving catalog data. HTTP Status 404." ) } /// Test OPDS data is invalid. @MainActor func testInvalidOPDSData() async { HTTPTestingURLProtocol.handler = { urlProtocol in let response = HTTPURLResponse( url: URL.mock(), statusCode: 200, httpVersion: nil, headerFields: [:] )! urlProtocol.client?.urlProtocol(urlProtocol, didLoad: Data("Invalid OPDS Data".utf8)) urlProtocol.client?.urlProtocol(urlProtocol, didReceive: response, cacheStoragePolicy: .notAllowed) urlProtocol.client?.urlProtocolDidFinishLoading(urlProtocol) } let testDefaults = TestDefaults() testDefaults.setup() let viewModel = LibraryViewModel(urlSession: urlSession, processFactory: { LibraryProcess(defaultState: .initial) }, defaults: testDefaults, categories: CategoriesToLanguages(withDefaults: testDefaults), database: TestDatabase()) await viewModel.start(isUserInitiated: true) XCTExpectFailure("Requires work in dependency to resolve the issue.") XCTAssertEqual( viewModel.error?.localizedDescription, "Error parsing library data." ) } /// Test zim file entity is created, and metadata are saved when new zim file becomes available in online catalog. @MainActor // swiftlint:disable:next function_body_length func testNewZimFileAndProperties() async throws { let zimFileID = UUID() HTTPTestingURLProtocol.handler = { urlProtocol in let responseTestURL = URL(string: "https://response-testing.com/catalog/v2/entries?count=-1")! let response = HTTPURLResponse( url: responseTestURL, statusCode: 200, httpVersion: nil, headerFields: [:] )! let data = self.makeOPDSData(zimFileID: zimFileID).data(using: .utf8)! urlProtocol.client?.urlProtocol(urlProtocol, didLoad: data) urlProtocol.client?.urlProtocol(urlProtocol, didReceive: response, cacheStoragePolicy: .notAllowed) urlProtocol.client?.urlProtocolDidFinishLoading(urlProtocol) } let testDefaults = TestDefaults() testDefaults.setup() let database = TestDatabase() let context = database.context let viewModel = LibraryViewModel(urlSession: urlSession, processFactory: { LibraryProcess(defaultState: .initial) }, defaults: testDefaults, categories: CategoriesToLanguages(withDefaults: testDefaults), database: database) await viewModel.start(isUserInitiated: true) // check no error has happened XCTAssertNil(viewModel.error) // check one zim file is in the database let zimFiles = try context.fetchZimFiles() XCTAssertEqual(zimFiles.count, 1) XCTAssertEqual(zimFiles[0].id, zimFileID) // check zim file can be retrieved by id, and properties are populated // swiftlint:disable:next force_try let zimFile = try! XCTUnwrap(zimFiles.first { $0.fileID == zimFileID }) XCTAssertEqual(zimFile.id, zimFileID) XCTAssertEqual(zimFile.articleCount, 50001) XCTAssertEqual(zimFile.category, Category.wikipedia.rawValue) // swiftlint:disable:next force_try XCTAssertEqual(zimFile.created, try! Date("2023-01-07T00:00:00Z", strategy: .iso8601)) XCTAssertEqual( zimFile.downloadURL, URL(string: "https://download.kiwix.org/zim/wikipedia/wikipedia_en_top_maxi_2023-01.zim.meta4") ) XCTAssertNil(zimFile.faviconData) XCTAssertEqual( zimFile.faviconURL, URL(string: "https://response-testing.com/catalog/v2/illustration/1ec90eab-5724-492b-9529-893959520de4/") ) XCTAssertEqual(zimFile.fileDescription, "A selection of the best 50,000 Wikipedia articles") XCTAssertEqual(zimFile.fileID, zimFileID) XCTAssertNil(zimFile.fileURLBookmark) XCTAssertEqual(zimFile.flavor, Flavor.max.rawValue) XCTAssertEqual(zimFile.hasDetails, true) XCTAssertEqual(zimFile.hasPictures, true) XCTAssertEqual(zimFile.hasVideos, false) XCTAssertEqual(zimFile.includedInSearch, true) XCTAssertEqual(zimFile.isMissing, false) // !important make sure the language code is put into the DB as a 3 letter string XCTAssertEqual(zimFile.languageCode, "eng") XCTAssertEqual(zimFile.mediaCount, 566835) XCTAssertEqual(zimFile.name, "Best of Wikipedia") XCTAssertEqual(zimFile.persistentID, "wikipedia_en_top") XCTAssertEqual(zimFile.requiresServiceWorkers, false) XCTAssertEqual(zimFile.size, 6515656704) } /// Test zim file deprecation @MainActor func testZimFileDeprecation() async throws { let testDefaults = TestDefaults() testDefaults.setup() let database = TestDatabase() let context = database.context // refresh library for the first time, which should create one zim file let viewModel = LibraryViewModel(urlSession: urlSession, processFactory: { LibraryProcess(defaultState: .initial) }, defaults: testDefaults, categories: CategoriesToLanguages(withDefaults: testDefaults), database: database) await forceRefresh(viewModel: viewModel) let zimFile1 = try XCTUnwrap(try context.fetchZimFiles().first) // refresh library for the second time, which should replace the old zim file with a new one await forceRefresh(viewModel: viewModel) var zimFiles = try context.fetchZimFiles() XCTAssertEqual(zimFiles.count, 1) let zimFile2 = try XCTUnwrap(zimFiles.first) XCTAssertNotEqual(zimFile1.fileID, zimFile2.fileID) // set fileURLBookmark of zim file 2 zimFile2.fileURLBookmark = Data("/Users/tester/Downloads/file_url.zim".utf8) // refresh library for the third time await forceRefresh(viewModel: viewModel) zimFiles = try context.fetchZimFiles() // check there are two zim files in the database, and zim file 2 is not deprecated XCTAssertEqual(zimFiles.count, 2) XCTAssertEqual(zimFiles.filter({ $0.fileID == zimFile2.fileID }).count, 1) } private func forceRefresh(viewModel: LibraryViewModel) async { let expectationVMComplete = XCTestExpectation(description: "viewModel completed") viewModel.$state.sink { value in switch value { case .complete: expectationVMComplete.fulfill() default: break } }.store(in: &cancellables) await viewModel.start(isUserInitiated: true) await fulfillment(of: [expectationVMComplete], timeout: 2) } } private extension URL { static func mock() -> URL { URL(string: "https://test.com")! } }