// // AccountFeedlySyncTest.swift // AccountTests // // Created by Kiel Gillard on 30/9/19. // Copyright © 2019 Ranchero Software, LLC. All rights reserved. // import XCTest @testable import Account import Articles class AccountFeedlySyncTest: XCTestCase { private let testTransport = TestTransport() private var account: Account! override func setUp() { super.setUp() account = TestAccountManager.shared.createAccount(type: .feedly, transport: testTransport) do { let username = UUID().uuidString let credentials = Credentials(type: .oauthAccessToken, username: username, secret: "test") try account.storeCredentials(credentials) } catch { XCTFail("Unable to register mock credentials because \(error)") } } override func tearDown() { // Clean up do { try account.removeCredentials(type: .oauthAccessToken) } catch { XCTFail("Unable to clean up mock credentials because \(error)") } TestAccountManager.shared.deleteAccount(account) super.tearDown() } // MARK: Initial Sync func testInitialSync() { XCTAssertTrue(account.idToFeedDictionary.isEmpty, "Expected to be testing a fresh account without any existing feeds.") XCTAssertTrue((account.folders ?? Set()).isEmpty, "Expected to be testing a fresh account without any existing folders.") set(testFiles: .initial, with: testTransport) // Test initial folders for collections and feeds for collection feeds. let initialExpection = self.expectation(description: "Initial feeds") account.refreshAll() { _ in initialExpection.fulfill() } waitForExpectations(timeout: 5) checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_initial") checkArticles(againstItemsInStreamInJSONNamed: "macintosh_initial") checkArticles(againstItemsInStreamInJSONNamed: "mustread_initial") checkArticles(againstItemsInStreamInJSONNamed: "programming_initial") checkArticles(againstItemsInStreamInJSONNamed: "uncategorized_initial") checkArticles(againstItemsInStreamInJSONNamed: "weblogs_initial") } // MARK: Add Collection func testAddsFoldersForCollections() { prepareBaseline(.initial) checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_initial") set(testFiles: .addCollection, with: testTransport) let addCollectionExpectation = self.expectation(description: "Adds NewCollection") account.refreshAll() { _ in addCollectionExpectation.fulfill() } waitForExpectations(timeout: 5) checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_addcollection") checkArticles(againstItemsInStreamInJSONNamed: "newcollection_addcollection") } // MARK: Add Feed func testAddsFeeds() { prepareBaseline(.addCollection) checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_addcollection") checkArticles(againstItemsInStreamInJSONNamed: "mustread_initial") set(testFiles: .addFeed, with: testTransport) let addFeedExpectation = self.expectation(description: "Add Feed To Must Read (hey, that rhymes!)") account.refreshAll() { _ in addFeedExpectation.fulfill() } waitForExpectations(timeout: 5) checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_addfeed") checkArticles(againstItemsInStreamInJSONNamed: "mustread_addfeed") } // MARK: Remove Feed func testRemovesFeeds() { prepareBaseline(.addFeed) checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_addfeed") checkArticles(againstItemsInStreamInJSONNamed: "mustread_addfeed") set(testFiles: .removeFeed, with: testTransport) let removeFeedExpectation = self.expectation(description: "Remove Feed from Must Read") account.refreshAll() { _ in removeFeedExpectation.fulfill() } waitForExpectations(timeout: 5) checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_addcollection") checkArticles(againstItemsInStreamInJSONNamed: "mustread_initial") } func testRemoveCollection() { prepareBaseline(.addFeed) checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_addfeed") set(testFiles: .removeCollection, with: testTransport) let removeCollectionExpectation = self.expectation(description: "Remove Collection") account.refreshAll() { _ in removeCollectionExpectation.fulfill() } waitForExpectations(timeout: 5) checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed: "feedly_collections_initial") } // MARK: Utility func prepareBaseline(_ testFiles: TestFiles) { XCTAssertTrue(account.idToFeedDictionary.isEmpty, "Expected to be testing a fresh accout.") set(testFiles: testFiles, with: testTransport) // Test initial folders for collections and feeds for collection feeds. let preparationExpectation = self.expectation(description: "Prepare Account") account.refreshAll() { _ in preparationExpectation.fulfill() } waitForExpectations(timeout: 5) } func checkFoldersAndFeeds(againstCollectionsAndFeedsInJSONNamed name: String) { let collections = testJSON(named: name) 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(againstOneCollectionAndFeedsInJSONPayload: collection) } } func checkSingleFolderAndFeeds(againstOneCollectionAndFeedsInJSONNamed name: String) { let collection = testJSON(named: name) as! [String:Any] checkSingleFolderAndFeeds(againstOneCollectionAndFeedsInJSONPayload: collection) } func checkSingleFolderAndFeeds(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.topLevelFeeds XCTAssertEqual(collectionFeeds.count, folderFeeds.count) let collectionFeedIds = Set(collectionFeeds.map { $0["id"] as! String }) let folderFeedIds = Set(folderFeeds.map { $0.feedID }) let missingFeedIds = collectionFeedIds.subtracting(folderFeedIds) XCTAssertTrue(missingFeedIds.isEmpty, "Feeds with these ids were not found in the \"\(label)\" folder.") } func checkArticles(againstItemsInStreamInJSONNamed name: String) { let stream = testJSON(named: name) as! [String:Any] checkArticles(againstItemsInStreamInJSONPayload: stream) } func checkArticles(againstItemsInStreamInJSONPayload stream: [String: Any]) { 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 } } let items = stream["items"] as! [[String: Any]] let articleItems = items.map { ArticleItem(item: $0) } let itemIds = Set(articleItems.map { $0.id }) let articles = account.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.feedID, item.feedId) XCTAssertEqual(article.externalURL, item.externalUrl) // XCTAssertEqual(article.status.boolStatus(forKey: .read), item.unread) } } } func testJSON(named: String) -> Any { let bundle = Bundle(for: TestTransport.self) let url = bundle.url(forResource: named, withExtension: "json")! let data = try! Data(contentsOf: url) let json = try! JSONSerialization.jsonObject(with: data) return json } enum TestFiles { case initial case addCollection case addFeed case removeFeed case removeCollection } func set(testFiles: TestFiles, with transport: TestTransport) { // TestTransport blacklists certain query items to make mocking responses easier. let endpoint = "https://sandbox7.feedly.com/v3" let category = "\(endpoint)/streams/contents?streamId=user/f2f031bd-f3e3-4893-a447-467a291c6d1e/category" switch testFiles { case .initial: let dict = [ "\(endpoint)/collections": "feedly_collections_initial.json", "\(category)/5ca4d61d-e55d-4999-a8d1-c3b9d8789815": "macintosh_initial.json", "\(category)/global.must": "mustread_initial.json", "\(category)/885f2e01-d314-4e63-abac-17dcb063f5b5": "programming_initial.json", "\(category)/66132046-6f14-488d-b590-8e93422723c8": "uncategorized_initial.json", "\(category)/e31b3fcb-27f6-4f3e-b96c-53902586e366": "weblogs_initial.json", ] transport.testFiles = dict case .addCollection: set(testFiles: .initial, with: transport) var dict = transport.testFiles dict["\(endpoint)/collections"] = "feedly_collections_addcollection.json" dict["\(category)/fc09f383-5a9a-4daa-a575-3efc1733b173"] = "newcollection_addcollection.json" transport.testFiles = dict case .addFeed: set(testFiles: .addCollection, with: transport) var dict = transport.testFiles dict["\(endpoint)/collections"] = "feedly_collections_addfeed.json" dict["\(category)/global.must"] = "mustread_addfeed.json" transport.testFiles = dict case .removeFeed: set(testFiles: .addCollection, with: transport) case .removeCollection: set(testFiles: .initial, with: transport) } } }