diff --git a/CHANGELOG.md b/CHANGELOG.md index 31b653f4..c0533377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ _None._ ### New Features -_None._ +- Add Reader discover streams endpoint. [#744] ### Bug Fixes diff --git a/WordPressKit/ReaderPostServiceRemote+Cards.swift b/WordPressKit/ReaderPostServiceRemote+Cards.swift index e48ba4ee..36ae18df 100644 --- a/WordPressKit/ReaderPostServiceRemote+Cards.swift +++ b/WordPressKit/ReaderPostServiceRemote+Cards.swift @@ -12,7 +12,7 @@ public enum ReaderSortingOption: String, CaseIterable { } extension ReaderPostServiceRemote { - /// Returns a collection of RemoteReaderCard + /// Returns a collection of RemoteReaderCard using the tags API /// a Reader Card can represent an item for the reader feed, such as /// - Reader Post /// - Topics you may like @@ -29,37 +29,70 @@ extension ReaderPostServiceRemote { refreshCount: Int? = nil, success: @escaping ([RemoteReaderCard], String?) -> Void, failure: @escaping (Error) -> Void) { - guard let requestUrl = cardsEndpoint(for: topics, page: page, sortingOption: sortingOption, refreshCount: refreshCount) else { + let path = "read/tags/cards" + guard let requestUrl = cardsEndpoint(with: path, + topics: topics, + page: page, + sortingOption: sortingOption, + refreshCount: refreshCount) else { return } + fetch(requestUrl, success: success, failure: failure) + } - wordPressComRestApi.GET(requestUrl, - parameters: nil, - success: { response, _ in - - do { - let decoder = JSONDecoder() - let data = try JSONSerialization.data(withJSONObject: response, options: []) - let envelope = try decoder.decode(ReaderCardEnvelope.self, from: data) - - success(envelope.cards, envelope.nextPageHandle) - } catch { - WPKitLogError("Error parsing the reader cards response: \(error)") - failure(error) - } - }, failure: { error, _ in - WPKitLogError("Error fetching reader cards: \(error)") + /// Returns a collection of RemoteReaderCard using the discover streams API + /// a Reader Card can represent an item for the reader feed, such as + /// - Reader Post + /// - Topics you may like + /// - Blogs you may like and so on + /// + /// - Parameter topics: an array of String representing the topics + /// - Parameter page: a String that represents a page handle + /// - Parameter sortingOption: a ReaderSortingOption that represents a sorting option + /// - Parameter count: the number of cards to fetch. Warning: This also changes the number of objects returned for recommended sites/tags. + /// - Parameter success: Called when the request succeeds and the data returned is valid + /// - Parameter failure: Called if the request fails for any reason, or the response data is invalid + public func fetchStreamCards(for topics: [String], + page: String? = nil, + sortingOption: ReaderSortingOption = .noSorting, + refreshCount: Int? = nil, + count: Int? = nil, + success: @escaping ([RemoteReaderCard], String?) -> Void, + failure: @escaping (Error) -> Void) { + let path = "read/streams/discover" + guard let requestUrl = cardsEndpoint(with: path, + topics: topics, + page: page, + sortingOption: sortingOption, + count: count, + refreshCount: refreshCount) else { + return + } + fetch(requestUrl, success: success, failure: failure) + } - failure(error) - }) + private func fetch(_ endpoint: String, + success: @escaping ([RemoteReaderCard], String?) -> Void, + failure: @escaping (Error) -> Void) { + Task { @MainActor [wordPressComRestApi] in + await wordPressComRestApi.perform(.get, URLString: endpoint, type: ReaderCardEnvelope.self) + .map { ($0.body.cards, $0.body.nextPageHandle) } + .mapError { error -> Error in error.asNSError() } + .execute(onSuccess: success, onFailure: failure) + } } - private func cardsEndpoint(for topics: [String], page: String? = nil, sortingOption: ReaderSortingOption = .noSorting, refreshCount: Int? = nil) -> String? { - var path = URLComponents(string: "read/tags/cards") + private func cardsEndpoint(with path: String, + topics: [String], + page: String? = nil, + sortingOption: ReaderSortingOption = .noSorting, + count: Int? = nil, + refreshCount: Int? = nil) -> String? { + var path = URLComponents(string: path) path?.queryItems = topics.map { URLQueryItem(name: "tags[]", value: $0) } - if let page = page { + if let page { path?.queryItems?.append(URLQueryItem(name: "page_handle", value: page)) } @@ -67,7 +100,11 @@ extension ReaderPostServiceRemote { path?.queryItems?.append(URLQueryItem(name: "sort", value: sortingOption)) } - if let refreshCount = refreshCount { + if let count { + path?.queryItems?.append(URLQueryItem(name: "count", value: String(count))) + } + + if let refreshCount { path?.queryItems?.append(URLQueryItem(name: "refresh", value: String(refreshCount))) } diff --git a/WordPressKitTests/MockWordPressComRestApi.swift b/WordPressKitTests/MockWordPressComRestApi.swift index b1a369e5..9b552f7f 100644 --- a/WordPressKitTests/MockWordPressComRestApi.swift +++ b/WordPressKitTests/MockWordPressComRestApi.swift @@ -9,6 +9,8 @@ class MockWordPressComRestApi: WordPressComRestApi { @objc var successBlockPassedIn: ((AnyObject, HTTPURLResponse?) -> Void)? @objc var failureBlockPassedIn: ((NSError, HTTPURLResponse?) -> Void)? + var performMethodCall: HTTPRequestBuilder.Method? + override func GET(_ URLString: String?, parameters: [String: AnyObject]?, success: @escaping ((AnyObject, HTTPURLResponse?) -> Void), failure: @escaping ((NSError, HTTPURLResponse?) -> Void)) -> Progress? { getMethodCalled = true URLStringPassedIn = URLString @@ -44,6 +46,19 @@ class MockWordPressComRestApi: WordPressComRestApi { return Progress() } + override func perform( + _ method: HTTPRequestBuilder.Method, + URLString: String, + parameters: [String: AnyObject]? = nil, + fulfilling progress: Progress? = nil, + jsonDecoder: JSONDecoder? = nil, + type: T.Type = T.self + ) async -> APIResult { + performMethodCall = method + URLStringPassedIn = URLString + return .failure(.connection(.init(.cancelled))) + } + @objc func methodCalled() -> String { var method = "Unknown" diff --git a/WordPressKitTests/ReaderPostServiceRemote+CardsTests.swift b/WordPressKitTests/ReaderPostServiceRemote+CardsTests.swift index a20f9ad0..d87d8dde 100644 --- a/WordPressKitTests/ReaderPostServiceRemote+CardsTests.swift +++ b/WordPressKitTests/ReaderPostServiceRemote+CardsTests.swift @@ -11,6 +11,8 @@ class ReaderPostServiceRemoteCardTests: RemoteTestCase, RESTTestable { readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: getRestApi()) } + // MARK: - Tags fetch cards + // Return an array of cards // func testReturnCards() { @@ -84,51 +86,66 @@ class ReaderPostServiceRemoteCardTests: RemoteTestCase, RESTTestable { waitForExpectations(timeout: timeout, handler: nil) } + func testHTTPMethod() { + let expect = expectation(description: "Executes fetch call") + let failure: (Error) -> Void = { _ in expect.fulfill() } + let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi) + + readerPostServiceRemote.fetchCards(for: ["dogs"], success: { _, _ in }, failure: failure) + + waitForExpectations(timeout: timeout) + XCTAssertEqual(mockRemoteApi.performMethodCall, .get) + } + // Calls the API with the given page handle // func testCallAPIWithTheGivenPageHandle() { + let expect = expectation(description: "Executes fetch call") + let failure: (Error) -> Void = { _ in expect.fulfill() } let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi) - readerPostServiceRemote.fetchCards(for: ["dogs"], page: "foobar", success: { _, _ in }, failure: { _ in }) - - XCTAssertTrue(mockRemoteApi.getMethodCalled) + readerPostServiceRemote.fetchCards(for: ["dogs"], page: "foobar", success: { _, _ in }, failure: failure) + waitForExpectations(timeout: timeout) XCTAssertTrue(mockRemoteApi.URLStringPassedIn?.contains("&page_handle=foobar") ?? false) } // Calls the API with .popularity as the given sorting option // func testCallAPIWithPopularityAsTheGivenSortingOption() { + let expect = expectation(description: "Executes fetch call") + let failure: (Error) -> Void = { _ in expect.fulfill() } let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi) - readerPostServiceRemote.fetchCards(for: [], sortingOption: .popularity, success: { _, _ in }, failure: { _ in }) - - XCTAssertTrue(mockRemoteApi.getMethodCalled) + readerPostServiceRemote.fetchCards(for: [], sortingOption: .popularity, success: { _, _ in }, failure: failure) + waitForExpectations(timeout: timeout) XCTAssertTrue(mockRemoteApi.URLStringPassedIn?.contains("sort=popularity") ?? false) } // Calls the API with .date as the given sorting option // func testCallAPIWithDateAsTheGivenSortingOption() { + let expect = expectation(description: "Executes fetch call") + let failure: (Error) -> Void = { _ in expect.fulfill() } let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi) - readerPostServiceRemote.fetchCards(for: [], sortingOption: .date, success: { _, _ in }, failure: { _ in }) - - XCTAssertTrue(mockRemoteApi.getMethodCalled) + readerPostServiceRemote.fetchCards(for: [], sortingOption: .date, success: { _, _ in }, failure: failure) + waitForExpectations(timeout: timeout) XCTAssertTrue(mockRemoteApi.URLStringPassedIn?.contains("sort=date") ?? false) } // Calls the API without the given sorting option // func testCallAPIWithoutTheGivenSortingOption() { + let expect = expectation(description: "Executes fetch call") + let failure: (Error) -> Void = { _ in expect.fulfill() } let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi) - readerPostServiceRemote.fetchCards(for: [], success: { _, _ in }, failure: { _ in }) - - XCTAssertTrue(mockRemoteApi.getMethodCalled) + readerPostServiceRemote.fetchCards(for: [], success: { _, _ in }, failure: failure) + waitForExpectations(timeout: timeout) XCTAssertFalse(mockRemoteApi.URLStringPassedIn?.contains("sort=") ?? true) } @@ -153,4 +170,156 @@ class ReaderPostServiceRemoteCardTests: RemoteTestCase, RESTTestable { waitForExpectations(timeout: timeout, handler: nil) } + + // MARK: - Streams fetch cards + + func testStreamsReturnCards() { + let expect = expectation(description: "Get cards successfully") + stubRemoteResponse("read/streams/discover?tags%5B%5D=dogs", filename: "reader-cards-success.json", contentType: .ApplicationJSON) + + readerPostServiceRemote.fetchStreamCards(for: ["dogs"], success: { cards, _ in + XCTAssertTrue(cards.count == 10) + expect.fulfill() + }, failure: { _ in }) + + waitForExpectations(timeout: timeout, handler: nil) + } + + func testStreamsReturnPosts() { + let expect = expectation(description: "Get cards successfully") + stubRemoteResponse("read/streams/discover?tags%5B%5D=cats", filename: "reader-cards-success.json", contentType: .ApplicationJSON) + + readerPostServiceRemote.fetchStreamCards(for: ["cats"], success: { cards, _ in + let postCards = cards.filter { $0.type == .post } + XCTAssertTrue(postCards.allSatisfy { $0.post != nil }) + expect.fulfill() + }, failure: { _ in }) + + waitForExpectations(timeout: timeout, handler: nil) + } + + func testStreamsReturnCorrectCardType() { + let expect = expectation(description: "Get cards successfully") + stubRemoteResponse("read/streams/discover?tags%5B%5D=cats", filename: "reader-cards-success.json", contentType: .ApplicationJSON) + + readerPostServiceRemote.fetchStreamCards(for: ["cats"], success: { cards, _ in + let postTypes = cards.map { $0.type } + let expectedPostTypes: [RemoteReaderCard.CardType] = [.interests, .sites, .post, .post, .post, .post, .post, .post, .post, .post] + XCTAssertTrue(postTypes == expectedPostTypes) + expect.fulfill() + }, failure: { _ in }) + + waitForExpectations(timeout: timeout, handler: nil) + } + + func testStreamsReturnError() { + let expect = expectation(description: "Get cards successfully") + stubRemoteResponse("read/streams/discover?tags%5B%5D=cats", filename: "reader-cards-success.json", contentType: .ApplicationJSON, status: 503) + + readerPostServiceRemote.fetchStreamCards(for: ["cats"], success: { _, _ in }, failure: { error in + XCTAssertNotNil(error) + expect.fulfill() + }) + + waitForExpectations(timeout: timeout, handler: nil) + } + + func testStreamsReturnNextPageHandle() { + let expect = expectation(description: "Returns next page handle") + stubRemoteResponse("read/streams/discover?tags%5B%5D=dogs", filename: "reader-cards-success.json", contentType: .ApplicationJSON) + + readerPostServiceRemote.fetchStreamCards(for: ["dogs"], success: { _, nextPageHandle in + XCTAssertTrue(nextPageHandle == "ZnJvbT0xMCZiZWZvcmU9MjAyMC0wNy0yNlQxMyUzQTU1JTNBMDMlMkIwMSUzQTAw") + expect.fulfill() + }, failure: { _ in }) + + waitForExpectations(timeout: timeout, handler: nil) + } + + func testStreamsHTTPMethod() { + let expect = expectation(description: "Executes fetch call") + let failure: (Error) -> Void = { _ in expect.fulfill() } + let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi) + + readerPostServiceRemote.fetchStreamCards(for: ["dogs"], success: { _, _ in }, failure: failure) + + waitForExpectations(timeout: timeout) + XCTAssertEqual(mockRemoteApi.performMethodCall, .get) + } + + func testStreamsCallAPIWithTheGivenPageHandle() { + let expect = expectation(description: "Executes fetch call") + let failure: (Error) -> Void = { _ in expect.fulfill() } + let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi) + + readerPostServiceRemote.fetchStreamCards(for: ["dogs"], page: "foobar", success: { _, _ in }, failure: failure) + + waitForExpectations(timeout: timeout) + XCTAssertTrue(mockRemoteApi.URLStringPassedIn?.contains("&page_handle=foobar") ?? false) + } + + func testStreamsCallAPIWithPopularityAsTheGivenSortingOption() { + let expect = expectation(description: "Executes fetch call") + let failure: (Error) -> Void = { _ in expect.fulfill() } + let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi) + + readerPostServiceRemote.fetchStreamCards(for: [], sortingOption: .popularity, success: { _, _ in }, failure: failure) + + waitForExpectations(timeout: timeout) + XCTAssertTrue(mockRemoteApi.URLStringPassedIn?.contains("sort=popularity") ?? false) + } + + func testStreamsCallAPIWithDateAsTheGivenSortingOption() { + let expect = expectation(description: "Executes fetch call") + let failure: (Error) -> Void = { _ in expect.fulfill() } + let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi) + + readerPostServiceRemote.fetchStreamCards(for: [], sortingOption: .date, success: { _, _ in }, failure: failure) + + waitForExpectations(timeout: timeout) + XCTAssertTrue(mockRemoteApi.URLStringPassedIn?.contains("sort=date") ?? false) + } + + func testStreamsCallAPIWithoutTheGivenSortingOption() { + let expect = expectation(description: "Executes fetch call") + let failure: (Error) -> Void = { _ in expect.fulfill() } + let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi) + + readerPostServiceRemote.fetchStreamCards(for: [], success: { _, _ in }, failure: failure) + + waitForExpectations(timeout: timeout) + XCTAssertFalse(mockRemoteApi.URLStringPassedIn?.contains("sort=") ?? true) + } + + func testStreamsCallAPIWithCountValue() { + let expect = expectation(description: "Executes fetch call") + let failure: (Error) -> Void = { _ in expect.fulfill() } + let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi) + let expectedCount = 5 + + readerPostServiceRemote.fetchStreamCards(for: ["dogs"], count: expectedCount, success: { _, _ in }, failure: failure) + + waitForExpectations(timeout: timeout) + XCTAssertTrue(mockRemoteApi.URLStringPassedIn?.contains("&count=\(expectedCount)") ?? false) + } + + func testStreamsPostsInCallAPIWithDateAsGivenSortOption() { + let expect = expectation(description: "Get cards sorted by date") + stubRemoteResponse("read/streams/discover?tags%5B%5D=cats&sort=date", filename: "reader-cards-success.json", contentType: .ApplicationJSON) + + readerPostServiceRemote.fetchStreamCards(for: ["cats"], sortingOption: .date, success: { cards, _ in + let posts = cards.filter { $0.type == .post } + for i in 1.. secondPostDate else { + XCTFail("Posts should be sorted by date, starting with most recent post") + return + } + } + expect.fulfill() + }, failure: { _ in }) + + waitForExpectations(timeout: timeout, handler: nil) + } }