kiwix-apple/Tests/LibraryRefreshViewModelTest.swift
2025-07-14 00:26:33 +02:00

311 lines
14 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<AnyCancellable>()
@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
"""
<feed xmlns="http://www.w3.org/2005/Atom"
xmlns:dc="http://purl.org/dc/terms/"
xmlns:opds="http://opds-spec.org/2010/catalog">
<entry>
<id>urn:uuid:\(zimFileID.uuidString.lowercased())</id>
<title>Best of Wikipedia</title>
<updated>2023-01-07T00:00:00Z</updated>
<summary>A selection of the best 50,000 Wikipedia articles</summary>
<language>eng</language>
<name>wikipedia_en_top</name>
<flavour>maxi</flavour>
<category>wikipedia</category>
<tags>wikipedia;_category:wikipedia;_pictures:yes;_videos:no;_details:yes;_ftindex:yes</tags>
<articleCount>50001</articleCount>
<mediaCount>566835</mediaCount>
<link rel="http://opds-spec.org/image/thumbnail"
href="/catalog/v2/illustration/1ec90eab-5724-492b-9529-893959520de4/"
type="image/png;width=48;height=48;scale=1"/>
<link type="text/html" href="/content/wikipedia_en_top_maxi_2023-01"/>
<author><name>Wikipedia</name></author>
<publisher><name>Kiwix</name></publisher>
<dc:issued>2023-01-07T00:00:00Z</dc:issued>
<link rel="http://opds-spec.org/acquisition/open-access" type="application/x-zim"
href="https://download.kiwix.org/zim/wikipedia/wikipedia_en_top_maxi_2023-01.zim.meta4" length="6515656704"/>
</entry>
</feed>
"""
// 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 couldnt 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")!
}
}