316 lines
12 KiB
Swift
316 lines
12 KiB
Swift
//
|
|
// FeedlyTestSupport.swift
|
|
// AccountTests
|
|
//
|
|
// Created by Kiel Gillard on 22/10/19.
|
|
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
|
//
|
|
|
|
import XCTest
|
|
import RSParser
|
|
import Secrets
|
|
@testable import Account
|
|
import os.log
|
|
import SyncDatabase
|
|
|
|
class FeedlyTestSupport {
|
|
var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "FeedlyTests")
|
|
var accessToken = Credentials(type: .oauthAccessToken, username: "Test", secret: "t3st-access-tok3n")
|
|
var refreshToken = Credentials(type: .oauthRefreshToken, username: "Test", secret: "t3st-refresh-tok3n")
|
|
var transport = TestTransport()
|
|
|
|
init() {
|
|
SecretsManager.provider = FeedlyTestSecrets()
|
|
}
|
|
|
|
func makeMockNetworkStack() -> (TestTransport, FeedlyAPICaller) {
|
|
let caller = FeedlyAPICaller(transport: transport, api: .sandbox)
|
|
caller.credentials = accessToken
|
|
return (transport, caller)
|
|
}
|
|
|
|
func makeTestAccount() -> Account {
|
|
let manager = TestAccountManager()
|
|
let account = manager.createAccount(type: .feedly, transport: transport)
|
|
do {
|
|
try account.storeCredentials(refreshToken)
|
|
// This must be done last or the account uses the refresh token for request Authorization!
|
|
try account.storeCredentials(accessToken)
|
|
} catch {
|
|
XCTFail("Unable to register mock credentials because \(error)")
|
|
}
|
|
return account
|
|
}
|
|
|
|
func makeMockOAuthClient() -> OAuthAuthorizationClient {
|
|
return OAuthAuthorizationClient(id: "test", redirectUri: "test://test/auth", state: nil, secret: "password")
|
|
}
|
|
|
|
func removeCredentials(matching type: CredentialsType, from account: Account) {
|
|
do {
|
|
try account.removeCredentials(type: type)
|
|
} catch {
|
|
XCTFail("Unable to remove \(type)")
|
|
}
|
|
}
|
|
|
|
func makeTestDatabaseContainer() -> TestDatabaseContainer {
|
|
return TestDatabaseContainer()
|
|
}
|
|
|
|
class TestDatabaseContainer {
|
|
private let path: String
|
|
private(set) var database: SyncDatabase!
|
|
|
|
init() {
|
|
let dataFolder = try! FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
path = dataFolder.appendingPathComponent("\(UUID().uuidString)-Sync.sqlite3").path
|
|
database = SyncDatabase(databaseFilePath: path)
|
|
}
|
|
|
|
deinit {
|
|
// We should close the database before removing the database.
|
|
database = nil
|
|
do {
|
|
try FileManager.default.removeItem(atPath: path)
|
|
print("Removed database at \(path)")
|
|
} catch {
|
|
print("Unable to remove database owned by \(self) because \(error).")
|
|
}
|
|
}
|
|
}
|
|
|
|
func destroy(_ testAccount: Account) {
|
|
do {
|
|
// These should not throw when the keychain items are not found.
|
|
try testAccount.removeCredentials(type: .oauthAccessToken)
|
|
try testAccount.removeCredentials(type: .oauthRefreshToken)
|
|
} catch {
|
|
XCTFail("Unable to clean up mock credentials because \(error)")
|
|
}
|
|
|
|
let manager = TestAccountManager()
|
|
manager.deleteAccount(testAccount)
|
|
}
|
|
|
|
func testJSON(named: String, subdirectory: String? = nil) -> Any {
|
|
let url = Bundle.module.url(forResource: named, withExtension: "json", subdirectory: subdirectory)!
|
|
let data = try! Data(contentsOf: url)
|
|
let json = try! JSONSerialization.jsonObject(with: data)
|
|
return json
|
|
}
|
|
|
|
func checkFoldersAndFeeds(in account: Account, againstCollectionsAndFeedsInJSONNamed name: String, subdirectory: String? = nil) {
|
|
let collections = testJSON(named: name, subdirectory: subdirectory) as! [[String:Any]]
|
|
let collectionNames = Set(collections.map { $0["label"] as! String })
|
|
let collectionIds = Set(collections.map { $0["id"] as! String })
|
|
|
|
let folders = account.folders ?? Set()
|
|
let folderNames = Set(folders.compactMap { $0.name })
|
|
let folderIds = Set(folders.compactMap { $0.externalID })
|
|
|
|
let missingNames = collectionNames.subtracting(folderNames)
|
|
let missingIds = collectionIds.subtracting(folderIds)
|
|
|
|
XCTAssertEqual(folders.count, collections.count, "Mismatch between collections and folders.")
|
|
XCTAssertTrue(missingNames.isEmpty, "Collections with these names did not have a corresponding folder with the same name.")
|
|
XCTAssertTrue(missingIds.isEmpty, "Collections with these ids did not have a corresponding folder with the same id.")
|
|
|
|
for collection in collections {
|
|
checkSingleFolderAndFeeds(in: account, againstOneCollectionAndFeedsInJSONPayload: collection)
|
|
}
|
|
}
|
|
|
|
func checkSingleFolderAndFeeds(in account: Account, againstOneCollectionAndFeedsInJSONNamed name: String) {
|
|
let collection = testJSON(named: name) as! [String:Any]
|
|
checkSingleFolderAndFeeds(in: account, againstOneCollectionAndFeedsInJSONPayload: collection)
|
|
}
|
|
|
|
func checkSingleFolderAndFeeds(in account: Account, againstOneCollectionAndFeedsInJSONPayload collection: [String: Any]) {
|
|
let label = collection["label"] as! String
|
|
guard let folder = account.existingFolder(with: label) else {
|
|
// due to a previous test failure?
|
|
XCTFail("Could not find the \"\(label)\" folder.")
|
|
return
|
|
}
|
|
let collectionFeeds = collection["feeds"] as! [[String: Any]]
|
|
let folderFeeds = folder.topLevelWebFeeds
|
|
|
|
XCTAssertEqual(collectionFeeds.count, folderFeeds.count)
|
|
|
|
let collectionFeedIds = Set(collectionFeeds.map { $0["id"] as! String })
|
|
let folderFeedIds = Set(folderFeeds.map { $0.webFeedID })
|
|
let missingFeedIds = collectionFeedIds.subtracting(folderFeedIds)
|
|
|
|
XCTAssertTrue(missingFeedIds.isEmpty, "Feeds with these ids were not found in the \"\(label)\" folder.")
|
|
}
|
|
|
|
func checkArticles(in account: Account, againstItemsInStreamInJSONNamed name: String, subdirectory: String? = nil) throws {
|
|
let stream = testJSON(named: name, subdirectory: subdirectory) as! [String:Any]
|
|
try checkArticles(in: account, againstItemsInStreamInJSONPayload: stream)
|
|
}
|
|
|
|
func checkArticles(in account: Account, againstItemsInStreamInJSONPayload stream: [String: Any]) throws {
|
|
try checkArticles(in: account, correspondToStreamItemsIn: stream)
|
|
}
|
|
|
|
private struct ArticleItem {
|
|
var id: String
|
|
var feedId: String
|
|
var content: String
|
|
var JSON: [String: Any]
|
|
var unread: Bool
|
|
|
|
/// Convoluted external URL logic "documented" here:
|
|
/// https://groups.google.com/forum/#!searchin/feedly-cloud/feed$20url%7Csort:date/feedly-cloud/Rx3dVd4aTFQ/Hf1ZfLJoCQAJ
|
|
var externalUrl: String? {
|
|
return ((JSON["canonical"] as? [[String: Any]]) ?? (JSON["alternate"] as? [[String: Any]]))?.compactMap { link -> String? in
|
|
let href = link["href"] as? String
|
|
if let type = link["type"] as? String {
|
|
if type == "text/html" {
|
|
return href
|
|
}
|
|
return nil
|
|
}
|
|
return href
|
|
}.first
|
|
}
|
|
|
|
init(item: [String: Any]) {
|
|
self.JSON = item
|
|
self.id = item["id"] as! String
|
|
|
|
let origin = item["origin"] as! [String: Any]
|
|
self.feedId = origin["streamId"] as! String
|
|
|
|
let content = item["content"] as? [String: Any]
|
|
let summary = item["summary"] as? [String: Any]
|
|
self.content = ((content ?? summary)?["content"] as? String) ?? ""
|
|
|
|
self.unread = item["unread"] as! Bool
|
|
}
|
|
}
|
|
|
|
/// Awkwardly titled to make it clear the JSON given is from a stream response.
|
|
func checkArticles(in testAccount: Account, correspondToStreamItemsIn stream: [String: Any]) throws {
|
|
|
|
let items = stream["items"] as! [[String: Any]]
|
|
let articleItems = items.map { ArticleItem(item: $0) }
|
|
let itemIds = Set(articleItems.map { $0.id })
|
|
|
|
let articles = try testAccount.fetchArticles(.articleIDs(itemIds))
|
|
let articleIds = Set(articles.map { $0.articleID })
|
|
|
|
let missing = itemIds.subtracting(articleIds)
|
|
|
|
XCTAssertEqual(items.count, articles.count)
|
|
XCTAssertTrue(missing.isEmpty, "Items with these ids did not have a corresponding article with the same id.")
|
|
|
|
for article in articles {
|
|
for item in articleItems where item.id == article.articleID {
|
|
XCTAssertEqual(article.uniqueID, item.id)
|
|
XCTAssertEqual(article.contentHTML, item.content)
|
|
XCTAssertEqual(article.webFeedID, item.feedId)
|
|
XCTAssertEqual(article.externalURL, item.externalUrl)
|
|
}
|
|
}
|
|
}
|
|
|
|
func checkUnreadStatuses(in account: Account, againstIdsInStreamInJSONNamed name: String, subdirectory: String? = nil, testCase: XCTestCase) {
|
|
let streamIds = testJSON(named: name, subdirectory: subdirectory) as! [String:Any]
|
|
checkUnreadStatuses(in: account, correspondToIdsInJSONPayload: streamIds, testCase: testCase)
|
|
}
|
|
|
|
func checkUnreadStatuses(in testAccount: Account, correspondToIdsInJSONPayload streamIds: [String: Any], testCase: XCTestCase) {
|
|
let ids = Set(streamIds["ids"] as! [String])
|
|
let fetchIdsExpectation = testCase.expectation(description: "Fetch Article Ids")
|
|
testAccount.fetchUnreadArticleIDs { articleIdsResult in
|
|
do {
|
|
let articleIds = try articleIdsResult.get()
|
|
// Unread statuses can be paged from Feedly.
|
|
// Instead of joining test data, the best we can do is
|
|
// make sure that these ids are marked as unread (a subset of the total).
|
|
XCTAssertTrue(ids.isSubset(of: articleIds), "Some articles in `ids` are not marked as unread.")
|
|
fetchIdsExpectation.fulfill()
|
|
} catch {
|
|
XCTFail("Error unwrapping article IDs: \(error)")
|
|
}
|
|
}
|
|
testCase.wait(for: [fetchIdsExpectation], timeout: 2)
|
|
}
|
|
|
|
func checkStarredStatuses(in account: Account, againstItemsInStreamInJSONNamed name: String, subdirectory: String? = nil, testCase: XCTestCase) {
|
|
let streamIds = testJSON(named: name, subdirectory: subdirectory) as! [String:Any]
|
|
checkStarredStatuses(in: account, correspondToStreamItemsIn: streamIds, testCase: testCase)
|
|
}
|
|
|
|
func checkStarredStatuses(in testAccount: Account, correspondToStreamItemsIn stream: [String: Any], testCase: XCTestCase) {
|
|
let items = stream["items"] as! [[String: Any]]
|
|
let ids = Set(items.map { $0["id"] as! String })
|
|
let fetchIdsExpectation = testCase.expectation(description: "Fetch Article Ids")
|
|
testAccount.fetchStarredArticleIDs { articleIdsResult in
|
|
do {
|
|
let articleIds = try articleIdsResult.get()
|
|
// Starred articles can be paged from Feedly.
|
|
// Instead of joining test data, the best we can do is
|
|
// make sure that these articles are marked as starred (a subset of the total).
|
|
XCTAssertTrue(ids.isSubset(of: articleIds), "Some articles in `ids` are not marked as starred.")
|
|
fetchIdsExpectation.fulfill()
|
|
} catch {
|
|
XCTFail("Error unwrapping article IDs: \(error)")
|
|
}
|
|
}
|
|
testCase.wait(for: [fetchIdsExpectation], timeout: 2)
|
|
}
|
|
|
|
func check(_ entries: [FeedlyEntry], correspondToStreamItemsIn stream: [String: Any]) {
|
|
|
|
let items = stream["items"] as! [[String: Any]]
|
|
let itemIds = Set(items.map { $0["id"] as! String })
|
|
|
|
let articleIds = Set(entries.map { $0.id })
|
|
|
|
let missing = itemIds.subtracting(articleIds)
|
|
|
|
XCTAssertEqual(items.count, entries.count)
|
|
XCTAssertTrue(missing.isEmpty, "Failed to create \(FeedlyEntry.self) values from objects in the JSON with these ids.")
|
|
}
|
|
|
|
func makeParsedItemTestDataFor(numberOfFeeds: Int, numberOfItemsInFeeds: Int) -> [String: Set<ParsedItem>] {
|
|
let ids = (0..<numberOfFeeds).map { "feed/\($0)" }
|
|
let feedIdsAndItemCounts = ids.map { ($0, numberOfItemsInFeeds) }
|
|
|
|
let entries = feedIdsAndItemCounts.map { (feedId, count) -> (String, [Int]) in
|
|
return (feedId, (0..<count).map { $0 })
|
|
|
|
}.map { pair -> (String, Set<ParsedItem>) in
|
|
let items = pair.1.map { index -> ParsedItem in
|
|
ParsedItem(syncServiceID: "\(pair.0)/articles/\(index)",
|
|
uniqueID: UUID().uuidString,
|
|
feedURL: pair.0,
|
|
url: "http://localhost/",
|
|
externalURL: "http://localhost/\(pair.0)/articles/\(index).html",
|
|
title: "Title\(index)",
|
|
language: nil,
|
|
contentHTML: "Content \(index) HTML.",
|
|
contentText: "Content \(index) Text",
|
|
summary: nil,
|
|
imageURL: nil,
|
|
bannerImageURL: nil,
|
|
datePublished: nil,
|
|
dateModified: nil,
|
|
authors: nil,
|
|
tags: nil,
|
|
attachments: nil)
|
|
}
|
|
return (pair.0, Set(items))
|
|
}.reduce([String: Set<ParsedItem>](minimumCapacity: feedIdsAndItemCounts.count)) { (dict, pair) in
|
|
var mutant = dict
|
|
mutant[pair.0] = pair.1
|
|
return mutant
|
|
}
|
|
|
|
return entries
|
|
}
|
|
}
|