Skip to content
This repository was archived by the owner on Sep 15, 2025. It is now read-only.

Commit c2e8e48

Browse files
authored
Merge pull request #624 from wordpress-mobile/load-media-library-tests
Add unit tests for the MediaServiceRemote.getMediaLibrary API
2 parents 8955556 + 022ddbf commit c2e8e48

File tree

4 files changed

+323
-1
lines changed

4 files changed

+323
-1
lines changed

WordPressKit.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@
145145
4A68E3DD294070A7004AC3DC /* RemoteReaderSite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A68E3DC294070A7004AC3DC /* RemoteReaderSite.swift */; };
146146
4A68E3DF29407100004AC3DC /* RemoteReaderTopic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A68E3DE29407100004AC3DC /* RemoteReaderTopic.swift */; };
147147
4A68E3E1294076C1004AC3DC /* RemoteReaderSiteInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A68E3E0294076C1004AC3DC /* RemoteReaderSiteInfo.swift */; };
148+
4AA5A1A32AA68F6B00969464 /* MediaLibraryTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA5A1A22AA68F6B00969464 /* MediaLibraryTestSupport.swift */; };
149+
4AA5A1A52AA695D700969464 /* LoadMediaLibraryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA5A1A42AA695D700969464 /* LoadMediaLibraryTests.swift */; };
148150
57BCD3D426209D9500292CB3 /* AppTransportSecuritySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57BCD3D326209D9500292CB3 /* AppTransportSecuritySettings.swift */; };
149151
730E869F21E44EFD00753E1A /* WordPressComServiceRemote+SiteVerticals.swift in Sources */ = {isa = PBXBuildFile; fileRef = 730E869E21E44EFD00753E1A /* WordPressComServiceRemote+SiteVerticals.swift */; };
150152
731BA83621DECD61000FDFCD /* SiteCreationRequestEncodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731BA83521DECD61000FDFCD /* SiteCreationRequestEncodingTests.swift */; };
@@ -827,6 +829,8 @@
827829
4A68E3DC294070A7004AC3DC /* RemoteReaderSite.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteReaderSite.swift; sourceTree = "<group>"; };
828830
4A68E3DE29407100004AC3DC /* RemoteReaderTopic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteReaderTopic.swift; sourceTree = "<group>"; };
829831
4A68E3E0294076C1004AC3DC /* RemoteReaderSiteInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteReaderSiteInfo.swift; sourceTree = "<group>"; };
832+
4AA5A1A22AA68F6B00969464 /* MediaLibraryTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLibraryTestSupport.swift; sourceTree = "<group>"; };
833+
4AA5A1A42AA695D700969464 /* LoadMediaLibraryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMediaLibraryTests.swift; sourceTree = "<group>"; };
830834
57BCD3D326209D9500292CB3 /* AppTransportSecuritySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTransportSecuritySettings.swift; sourceTree = "<group>"; };
831835
6C2A33D76FD1052D6F30466D /* Pods-WordPressKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-WordPressKit/Pods-WordPressKit.debug.xcconfig"; sourceTree = "<group>"; };
832836
6F2E0CC4FA01B5475A378DA2 /* Pods-WordPressKitTests.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressKitTests.release-alpha.xcconfig"; path = "Pods/Target Support Files/Pods-WordPressKitTests/Pods-WordPressKitTests.release-alpha.xcconfig"; sourceTree = "<group>"; };
@@ -1667,6 +1671,8 @@
16671671
isa = PBXGroup;
16681672
children = (
16691673
74FA25F61F1FDA200044BC54 /* MediaServiceRemoteRESTTests.swift */,
1674+
4AA5A1A22AA68F6B00969464 /* MediaLibraryTestSupport.swift */,
1675+
4AA5A1A42AA695D700969464 /* LoadMediaLibraryTests.swift */,
16701676
);
16711677
name = Media;
16721678
sourceTree = "<group>";
@@ -3371,6 +3377,7 @@
33713377
E1E89C6A1FD6BDB1006E7A33 /* PluginDirectoryTests.swift in Sources */,
33723378
9F3E0BAA20873773009CB5BA /* MockServiceRequest.swift in Sources */,
33733379
8B2F4BE524ABB3C70056C08A /* RemoteReaderPostTests.m in Sources */,
3380+
4AA5A1A32AA68F6B00969464 /* MediaLibraryTestSupport.swift in Sources */,
33743381
748437EF1F1D4D8B00E8DDAF /* MenusServiceRemoteTests.m in Sources */,
33753382
73D5930121E550F500E4CF84 /* SiteVerticalsResponseDecodingTests.swift in Sources */,
33763383
FE5096682A309E4600DDD071 /* JetpackSocialServiceRemoteTests.swift in Sources */,
@@ -3453,6 +3460,7 @@
34533460
73D5930521E5541200E4CF84 /* WordPressComServiceRemoteTests+SiteVerticals.swift in Sources */,
34543461
8BB5F62427A9A5D100B2FFAF /* DashboardServiceRemoteTests.swift in Sources */,
34553462
0152100C28EDA9E400DD6783 /* StatsAnnualAndMostPopularTimeInsightDecodingTests.swift in Sources */,
3463+
4AA5A1A52AA695D700969464 /* LoadMediaLibraryTests.swift in Sources */,
34563464
17CE77F420C701C8001DEA5A /* ReaderSiteSearchServiceRemoteTests.swift in Sources */,
34573465
73A2F38D21E7FC8200388609 /* WordPressComServiceRemoteTests+SiteVerticalsPrompt.swift in Sources */,
34583466
74C473AF1EF2F7D1009918F2 /* SiteManagementServiceRemoteTests.swift in Sources */,

WordPressKit/MediaServiceRemote.h

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,14 @@
3838
failure:(void (^)(NSError *error))failure;
3939

4040
/**
41-
* Get Media items from blog using the options parameter.
41+
* Get all WordPress Media Library items in batches.
42+
*
43+
* The `pageLoad` block is called with media items in each page, except the last page. If there is only one page of media
44+
* items, the `pageLoad` block will not be called.
45+
*
46+
* The `success` block is called with all media items in the Media Library. Calling this block marks the end of the loading.
47+
*
48+
* The `failure` block is called when any API call fails. Calling this block marks the end of the loading.
4249
*
4350
* @param pageLoad a block to be executed when each page of media is loaded.
4451
* @param success a block to be executed when the request finishes with success.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import Foundation
2+
import XCTest
3+
4+
@testable import WordPressKit
5+
6+
private enum Kind {
7+
case wpcom
8+
case xmlrpc
9+
}
10+
11+
// This test case is for the `-[MediaServiceRemote getMediaLibraryWithPageLoad:success:failure:]` method, which is
12+
// implemented in `MediaServiceRemoteREST` and `MediaServiceRemoteXMLRPC`. The test functions in this test case are
13+
// created to ensure both implementation shares the same behaviour.
14+
//
15+
// See the `-[MediaServiceRemote getMediaLibraryWithPageLoad:success:failure:]` API doc for its expected behaviours.
16+
class LoadMediaLibraryTests: XCTestCase {
17+
18+
fileprivate var kind: Kind = .wpcom
19+
20+
func testSmallLibrary() {
21+
let mediaLibrary = MediaLibraryTestSupport(totalMedia: 50)
22+
let (pageLoad, success, failure) = load(mediaLibrary: mediaLibrary, failAtPage: -1)
23+
XCTAssertEqual(pageLoad.count, 0)
24+
XCTAssertEqual(success?.count, 50)
25+
XCTAssertNil(failure)
26+
}
27+
28+
func testTwoPageLibrary() {
29+
let mediaLibrary = MediaLibraryTestSupport(totalMedia: 120)
30+
let (pageLoad, success, failure) = load(mediaLibrary: mediaLibrary, failAtPage: -1)
31+
XCTAssertEqual(pageLoad.count, 1)
32+
XCTAssertEqual(success?.count, 120)
33+
XCTAssertNil(failure)
34+
}
35+
36+
func testLargeLibrary() {
37+
let mediaLibrary = MediaLibraryTestSupport(totalMedia: 650)
38+
let (pageLoad, success, failure) = load(mediaLibrary: mediaLibrary, failAtPage: -1)
39+
XCTAssertEqual(pageLoad.count, 6)
40+
XCTAssertEqual(success?.count, 650)
41+
XCTAssertNil(failure)
42+
}
43+
44+
func testFailure() {
45+
let mediaLibrary = MediaLibraryTestSupport(totalMedia: 550)
46+
let (pageLoad, success, failure) = load(mediaLibrary: mediaLibrary, failAtPage: 3)
47+
XCTAssertEqual(pageLoad.count, 2)
48+
XCTAssertNil(success)
49+
XCTAssertNotNil(failure)
50+
51+
let loaded = pageLoad.map { $0?.count ?? 0 }.reduce(0, +)
52+
XCTAssertEqual(loaded, 200)
53+
}
54+
55+
}
56+
57+
private extension LoadMediaLibraryTests {
58+
59+
func load(mediaLibrary: MediaLibraryTestSupport, failAtPage: Int) -> MediaLibraryResult {
60+
let remote: MediaServiceRemote
61+
62+
switch kind {
63+
case .wpcom:
64+
remote = MediaServiceRemoteREST(wordPressComRestApi: WordPressComRestApi(), siteID: 42)
65+
mediaLibrary.stubREST(siteID: 42, failAtPage: failAtPage)
66+
case .xmlrpc:
67+
let rpcURL = URL(string: "https://site.com/xmlrpc")!
68+
remote = MediaServiceRemoteXMLRPC(api: WordPressOrgXMLRPCApi(endpoint: rpcURL), username: "user", password: "pass")
69+
mediaLibrary.stubRPC(endpoint: rpcURL, failAtPage: failAtPage)
70+
}
71+
72+
return waitForLoadingMediaLibrary(using: remote)
73+
}
74+
75+
/// Wait for the `getMediaLibrary` API call to finish, and return all the potential results.
76+
func waitForLoadingMediaLibrary(using remote: MediaServiceRemote) -> MediaLibraryResult {
77+
var result: MediaLibraryResult = ([], nil, nil)
78+
79+
let finished = expectation(description: "Finish loading WordPress Media Library")
80+
remote.getMediaLibrary {
81+
result.pageLoad.append($0)
82+
} success: {
83+
result.success = $0
84+
finished.fulfill()
85+
} failure: { error in
86+
result.failure = error
87+
finished.fulfill()
88+
}
89+
90+
wait(for: [finished], timeout: 0.5)
91+
92+
return result
93+
}
94+
}
95+
96+
/// Each tuple element contains the arguments passed to their corresponding block that's passed to the
97+
/// `-[MediaServiceRemote getMediaLibraryWithPageLoad:success:failure:]` method.
98+
///
99+
/// `pageLoad` is a list of lists, because the `pageLoad` block may gets called multiple times.
100+
///
101+
/// - SeeAlso `-[MediaServiceRemote getMediaLibraryWithPageLoad:success:failure:]`
102+
private typealias MediaLibraryResult = (pageLoad: [[Any]?], success: [Any]?, failure: Error?)
103+
104+
class LoadMediaLibraryRPCTests: LoadMediaLibraryTests {
105+
106+
override func setUp() {
107+
kind = .xmlrpc
108+
}
109+
110+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import Foundation
2+
import XCTest
3+
import UniformTypeIdentifiers
4+
import OHHTTPStubs
5+
import wpxmlrpc
6+
7+
/// This type acts like a WordPress Media Library. It can be used in test cases to stub loading media library content
8+
/// API calls and provides a close-to-production API responses, which relieves test cases from the burden of creating
9+
/// appropriate responses.
10+
///
11+
/// This class creates many dummy media items upon initlisation. Test cases can call the `stubREST` or `stubRPC`
12+
/// function to create an HTTP stub for WordPress.com REST API or WordPress XML-RPC API that are used in the
13+
/// `-[MediaServiceRemote getMediaLibraryWithPageLoad:success:failure:]` method. The stubs parse the pagination
14+
/// parameters in the API requests and returns appropriate media items accordingly.
15+
class MediaLibraryTestSupport {
16+
private let media: [Media]
17+
18+
private var restStub: HTTPStubsDescriptor? {
19+
didSet {
20+
if let oldValue {
21+
HTTPStubs.removeStub(oldValue)
22+
}
23+
}
24+
}
25+
26+
private var rpcStub: HTTPStubsDescriptor? {
27+
didSet {
28+
if let oldValue {
29+
HTTPStubs.removeStub(oldValue)
30+
}
31+
}
32+
}
33+
34+
init(totalMedia: Int) {
35+
media = (1...totalMedia).map { id in
36+
Media(
37+
mediaID: id,
38+
postID: (1...12345).randomElement()!,
39+
mimeType: ["image/png", "audio/mp3", "video/mp4"].randomElement()!,
40+
cusor: UUID().uuidString
41+
)
42+
}
43+
}
44+
45+
deinit {
46+
restStub = nil
47+
rpcStub = nil
48+
}
49+
}
50+
51+
extension MediaLibraryTestSupport {
52+
53+
func stubREST(siteID: Int, failAtPage pageToFail: Int) {
54+
restStub = stub(condition: isPath("/rest/v1.1/sites/\(siteID)/media")) { [weak self] request in
55+
self?.handleREST(request: request, failAtPage: pageToFail) ?? .init(error: URLError(.networkConnectionLost))
56+
}
57+
}
58+
59+
private func handleREST(request: URLRequest, failAtPage pageToFail: Int) -> HTTPStubsResponse {
60+
let cursor = request.url?.query("page_handle") ?? nil
61+
let number = request.url?.query("number").flatMap(Int.init(_:)) ?? 100
62+
63+
let cursorIndex = media.firstIndex { $0.cusor == cursor } ?? 0
64+
let requestPage = (cursorIndex / number) + 1
65+
66+
if pageToFail == requestPage {
67+
return .init(error: URLError(.cannotFindHost))
68+
}
69+
70+
let range = cursorIndex...min(cursorIndex + number - 1, media.count - 1)
71+
let json: [String: Any] = [
72+
"media": media[range].map { $0.asRESTResponse() },
73+
"meta": [
74+
"next_page": range.upperBound + 1 < media.count ? media[range.upperBound + 1].cusor : ""
75+
]
76+
]
77+
78+
return .init(jsonObject: json, statusCode: 200, headers: nil)
79+
}
80+
81+
}
82+
83+
extension MediaLibraryTestSupport {
84+
85+
func stubRPC(endpoint: URL, failAtPage pageToFail: Int) {
86+
rpcStub = stub(condition: isMethodPOST() && isAbsoluteURLString(endpoint.absoluteString)) { [weak self] request in
87+
self?.handleRPC(request: request, failAtPage: pageToFail) ?? .init(error: URLError(.networkConnectionLost))
88+
}
89+
}
90+
91+
private func handleRPC(request: URLRequest, failAtPage pageToFail: Int) -> HTTPStubsResponse {
92+
let parser: XMLParser
93+
if let stream = request.httpBodyStream {
94+
parser = XMLParser(stream: stream)
95+
} else if let body = request.httpBody {
96+
parser = XMLParser(data: body)
97+
} else {
98+
return .init(error: URLError(.cannotDecodeContentData))
99+
}
100+
101+
let delegate = RequestParser()
102+
parser.delegate = delegate
103+
guard parser.parse() else {
104+
return .init(data: Data(), statusCode: 200, headers: nil)
105+
}
106+
107+
XCTAssertEqual(delegate.methodName, "wp.getMediaLibrary")
108+
109+
let number = delegate.params["number"] as? Int ?? 100
110+
let offset = delegate.params["offset"] as? Int ?? 0
111+
let requestPage = (offset / number) + 1
112+
113+
if pageToFail == requestPage {
114+
return .init(error: URLError(.cannotFindHost))
115+
}
116+
117+
let range = offset...min(offset + number - 1, media.count - 1)
118+
119+
do {
120+
let data = try WPXMLRPCEncoder(responseParams: [media[range].map { $0.asRPCResponse() }]).dataEncoded()
121+
return .init(data: data, statusCode: 200, headers: ["Content-Type": "application/xml"])
122+
} catch {
123+
return .init(error: error)
124+
}
125+
}
126+
127+
private class RequestParser: NSObject, XMLParserDelegate {
128+
var elementPath: [String] = []
129+
var methodName: String?
130+
var params: [String: Any] = [:]
131+
var content: String?
132+
var paramName: String?
133+
134+
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
135+
elementPath.append(elementName)
136+
}
137+
138+
func parser(_ parser: XMLParser, foundCharacters string: String) {
139+
self.content = string
140+
}
141+
142+
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
143+
assert(elementName == elementPath.last)
144+
145+
defer {
146+
self.content = nil
147+
elementPath.removeLast()
148+
}
149+
150+
switch elementPath {
151+
case ["methodCall", "methodName"]:
152+
self.methodName = self.content
153+
case ["methodCall", "params", "param", "value", "struct", "member", "name"]:
154+
self.paramName = self.content
155+
case ["methodCall", "params", "param", "value", "struct", "member", "value", "i4"]:
156+
self.params[self.paramName!] = Int(self.content!)
157+
self.paramName = nil
158+
default:
159+
break
160+
}
161+
}
162+
}
163+
164+
}
165+
166+
private struct Media: Codable {
167+
var mediaID: Int
168+
var postID: Int
169+
var mimeType: String
170+
171+
var cusor: String
172+
173+
func asRESTResponse() -> [String: Any] {
174+
[
175+
"ID": mediaID,
176+
"post_ID": postID,
177+
"mime_type": mimeType
178+
]
179+
}
180+
181+
func asRPCResponse() -> [String: Any] {
182+
[
183+
"id": mediaID,
184+
"parent": postID,
185+
"type": mimeType
186+
]
187+
}
188+
}
189+
190+
private extension URL {
191+
func query(_ name: String) -> String? {
192+
URLComponents(url: self, resolvingAgainstBaseURL: true)?
193+
.queryItems?
194+
.first { $0.name == name }?
195+
.value
196+
}
197+
}

0 commit comments

Comments
 (0)