diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 83b3cf4a9..88e481220 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -72,6 +72,9 @@ steps: queue: mac - label: ":swift: :linux: Build and Test" command: | + echo "--- :docker: Setting up Test Server" + make test-server + echo "--- :swift: Building + Testing" make test-swift-linux - label: ":swift: Lint" diff --git a/Makefile b/Makefile index beaf5ff07..19469cd17 100644 --- a/Makefile +++ b/Makefile @@ -185,7 +185,7 @@ test-swift: $(MAKE) test-swift-$(uname) test-swift-linux: docker-image-swift - docker run $(docker_opts_shared) -it wordpress-rs-swift make test-swift-linux-in-docker + docker run $(docker_opts_shared) --network host -it wordpress-rs-swift make test-swift-linux-in-docker test-swift-linux-in-docker: swift-linux-library swift test -Xlinker -Ltarget/swift-bindings/libwordpressFFI-linux -Xlinker -lwp_api diff --git a/Package.swift b/Package.swift index 83313daca..4114a00de 100644 --- a/Package.swift +++ b/Package.swift @@ -2,8 +2,11 @@ // The swift-tools-version declares the minimum version of Swift required to build this package. // Swift Package: WordpressApi +import Foundation import PackageDescription +let isCI = ProcessInfo.processInfo.environment["BUILDKITE"] == "true" + #if os(Linux) let libwordpressFFI: Target = .systemLibrary( name: "libwordpressFFI", @@ -13,6 +16,27 @@ let libwordpressFFI: Target = .systemLibrary( let libwordpressFFI: Target = .binaryTarget(name: "libwordpressFFI", path: "target/libwordpressFFI.xcframework") #endif +#if os(macOS) +let e2eTestsEnabled = !isCI +#elseif os(Linux) +let e2eTestsEnabled = true +#else +let e2eTestsEnabled = false +#endif + +var additionalTestTargets = [Target]() + +if e2eTestsEnabled { + additionalTestTargets.append(.testTarget( + name: "End2EndTests", + dependencies: [ + .target(name: "wordpress-api"), + .target(name: "libwordpressFFI") + ], + path: "native/swift/Tests/End2End" + )) +} + let package = Package( name: "wordpress", platforms: [ @@ -55,5 +79,5 @@ let package = Package( ], path: "native/swift/Tests/wordpress-api" ) - ] + ] + additionalTestTargets ) diff --git a/native/swift/Sources/wordpress-api/Exports.swift b/native/swift/Sources/wordpress-api/Exports.swift new file mode 100644 index 000000000..3617167c5 --- /dev/null +++ b/native/swift/Sources/wordpress-api/Exports.swift @@ -0,0 +1,19 @@ +// Expose necessary Rust APIs as public API to the Swift package's consumers. +// +// We could export all of them using `@_exported import`, but that probably puts +// us in a position where we need to make major releases due to Rust code changes. + +import wordpress_api_wrapper + +public typealias WpApiError = wordpress_api_wrapper.WpApiError + +// MARK: - Users + +public typealias SparseUser = wordpress_api_wrapper.SparseUser + +public extension SparseUser { + typealias ID = UserId + typealias View = UserWithViewContext + typealias Edit = UserWithEditContext + typealias Embed = UserWithEmbedContext +} diff --git a/native/swift/Sources/wordpress-api/Users/Users.swift b/native/swift/Sources/wordpress-api/Users/Users.swift new file mode 100644 index 000000000..54ed4c846 --- /dev/null +++ b/native/swift/Sources/wordpress-api/Users/Users.swift @@ -0,0 +1,66 @@ +import Foundation +import wordpress_api_wrapper + +extension WordPressAPI { + public var users: Namespace { + .init(api: self) + } +} + +extension Namespace where T == SparseUser { + + public func get(id: T.ID) async throws -> T.View { + let request = self.api.helper.retrieveUserRequest(userId: id, context: .view) + let response = try await api.perform(request: request) + return try parseRetrieveUserResponseWithViewContext(response: response) + } + + public func list() async throws -> [T.View] { + let request = self.api.helper.listUsersRequest(context: .view, params: nil) + let response = try await api.perform(request: request) + return try parseListUsersResponseWithViewContext(response: response) + } + + public func delete(id: T.ID, reassignTo userID: T.ID) async throws { + let request = self.api.helper.deleteUserRequest(userId: id, params: .init(reassign: userID)) + let response = try await api.perform(request: request) + // TODO: Missing parse response + return + } + + public func update(id: T.ID, with params: UserUpdateParams) async throws -> T.Edit { + let request = self.api.helper.updateUserRequest(userId: id, params: params) + let response = try await self.api.perform(request: request) + return try parseRetrieveUserResponseWithEditContext(response: response) + } + + public func create(using params: UserCreateParams) async throws -> T.Edit { + let request = self.api.helper.createUserRequest(params: params) + let response = try await self.api.perform(request: request) + return try parseRetrieveUserResponseWithEditContext(response: response) + } + +} + +extension Namespace where T == SparseUser { + + public func getCurrent() async throws -> T.View { + let request = self.api.helper.retrieveCurrentUserRequest(context: .view) + let response = try await api.perform(request: request) + return try parseRetrieveUserResponseWithViewContext(response: response) + } + + public func deleteCurrent(reassignTo userID: T.ID) async throws { + let request = self.api.helper.deleteCurrentUserRequest(params: .init(reassign: userID)) + let response = try await api.perform(request: request) + // TODO: Parse response to check if there is any error + return + } + + public func updateCurrent(with params: UserUpdateParams) async throws -> T.Edit { + let request = self.api.helper.updateCurrentUserRequest(params: params) + let response = try await self.api.perform(request: request) + return try parseRetrieveUserResponseWithEditContext(response: response) + } + +} diff --git a/native/swift/Sources/wordpress-api/WordPressAPI.swift b/native/swift/Sources/wordpress-api/WordPressAPI.swift index 06643d745..1d0460860 100644 --- a/native/swift/Sources/wordpress-api/WordPressAPI.swift +++ b/native/swift/Sources/wordpress-api/WordPressAPI.swift @@ -87,6 +87,7 @@ public extension WpNetworkRequest { var request = URLRequest(url: url) request.httpMethod = self.method.rawValue request.allHTTPHeaderFields = self.headerMap + request.httpBody = self.body return request } } @@ -179,3 +180,7 @@ extension URL { WpRestApiurl(stringValue: self.absoluteString) } } + +public struct Namespace { + public let api: WordPressAPI +} diff --git a/native/swift/Tests/End2End/LocalSite.swift b/native/swift/Tests/End2End/LocalSite.swift new file mode 100644 index 000000000..48f374179 --- /dev/null +++ b/native/swift/Tests/End2End/LocalSite.swift @@ -0,0 +1,86 @@ +#if os(macOS) || os(Linux) + +import XCTest +import wordpress_api + +import wordpress_api_wrapper + +let site = LocalSite() + +final class LocalSite { + + enum Errors: Error { + /// Run `make test-server` before running end to end tests. + case testServerNotRunning(underlyingError: Error) + /// `localhost:80` is not wordpress site. Make sure to run `make test-server` before running end to end tests. + case notWordPressSite + /// Can't read the test credential file for the local test site. + case testCredentialNotFound(underlyingError: Error) + } + + let siteURL = URL(string: "http://localhost")! + let currentUserID: SparseUser.ID = 1 + + private let username = "test@example.com" + + private var _api: WordPressAPI? + + /// Get an authenticationed API client for the admin user. + var api: WordPressAPI { + get async throws { + if _api == nil { + _api = try await createAPIClient() + } + return _api! + } + } + + private func createAPIClient() async throws -> WordPressAPI { + try await ensureTestServerRunning() + let password = try readPassword() + + return WordPressAPI( + urlSession: .shared, + baseUrl: siteURL, + authenticationStategy: .init(username: username, password: password) + ) + } + + private func ensureTestServerRunning() async throws { + let api = WordPressAPI(urlSession: .shared, baseUrl: siteURL, authenticationStategy: .none) + let response: WpNetworkResponse + do { + let request = WpNetworkRequest( + method: .get, url: siteURL.appendingPathComponent("/wp-json").absoluteString, + headerMap: [:], body: nil) + response = try await api.perform(request: request) + } catch { + throw Errors.testServerNotRunning(underlyingError: error) + } + + if response.statusCode != 200 { + throw Errors.notWordPressSite + } + } + + private func readPassword() throws -> String { + #if os(Linux) + let file = URL(fileURLWithPath: #filePath) + #else + let file = URL(filePath: #filePath) + #endif + let testCredentialFile = URL(string: "../../../../test_credentials", relativeTo: file)! + .absoluteURL + let content: String + do { + content = try String(contentsOf: testCredentialFile) + } catch { + throw Errors.testCredentialNotFound(underlyingError: error) + } + + return content.trimmingCharacters(in: .newlines) + } + +} + +#endif diff --git a/native/swift/Tests/End2End/UsersTests.swift b/native/swift/Tests/End2End/UsersTests.swift new file mode 100644 index 000000000..cf74f2f0b --- /dev/null +++ b/native/swift/Tests/End2End/UsersTests.swift @@ -0,0 +1,172 @@ +#if os(macOS) || os(Linux) + +import XCTest +import wordpress_api + +class UsersTests: XCTestCase { + + func testGetCurrentUser() async throws { + let user = try await site.api.users.getCurrent() + XCTAssertEqual(user.id, site.currentUserID) + } + + func testGetUser() async throws { + let user = try await site.api.users.get(id: 2) + XCTAssertEqual(user.name, "Theme Buster") + } + + func testDeleteCurrent() async throws { + throw XCTSkip("Need to create a user with an application password for this test to work") + + let password = "supersecurepassword" + let newUser = try await createUser(password: password) + let newUserSession = WordPressAPI( + urlSession: .shared, baseUrl: site.siteURL, + authenticationStategy: .init(username: newUser.username, password: password)) + + let user = try await newUserSession.users.getCurrent() + XCTAssertEqual(user.id, newUser.id) + try await newUserSession.users.deleteCurrent(reassignTo: site.currentUserID) + + do { + // Should return 404 + _ = try await site.api.users.get(id: newUser.id) + XCTFail("Unexpected successful result. The user \(newUser.id) should have been deleted.") + } catch { + // Do nothing + } + } + + func testCreateAndDeleteUser() async throws { + let newUser = try await createUser() + try await site.api.users.delete(id: newUser.id, reassignTo: site.currentUserID) + } + + func testUpdateCurrentUser() async throws { + let currentUser = try await site.api.users.getCurrent() + let newDescription = currentUser.description + " and more" + let updated = try await site.api.users.updateCurrent( + with: .init( + name: nil, firstName: nil, lastName: nil, email: nil, url: nil, + description: newDescription, locale: nil, nickname: nil, slug: nil, roles: [], + password: nil, meta: nil)) + XCTAssertEqual(updated.description, newDescription) + } + + func testPatchUpdate() async throws { + let newUser = try await createUser() + + let firstUpdate = try await site.api.users.update( + id: newUser.id, + with: .init( + name: nil, firstName: "Adam", lastName: nil, email: nil, url: "https://newurl.com", + description: nil, locale: nil, nickname: nil, slug: nil, roles: [], password: nil, + meta: nil)) + XCTAssertEqual(firstUpdate.firstName, "Adam") + XCTAssertEqual(firstUpdate.url, "https://newurl.com") + + let secondUpdate = try await site.api.users.update( + id: newUser.id, + with: .init( + name: nil, firstName: nil, lastName: nil, email: nil, url: "https://w.org", + description: nil, locale: nil, nickname: nil, slug: nil, roles: [], password: nil, + meta: nil)) + XCTAssertEqual(secondUpdate.firstName, "Adam") + XCTAssertEqual(secondUpdate.url, "https://w.org") + } + + func testListUsers() async throws { + let users = try await site.api.users.list() + XCTAssertTrue(users.count > 0) + } + + private func createUser(password: String? = nil) async throws -> SparseUser.Edit { + let uuid = UUID().uuidString + return try await site.api.users.create( + using: .init( + username: uuid, email: "\(uuid)@swift-test.com", password: password ?? "badpass", + name: nil, firstName: "End2End", lastName: nil, url: "http://example.com", + description: nil, locale: nil, nickname: nil, slug: nil, roles: ["subscriber"], meta: nil) + ) + } +} + +class UserCreationErrorTests: XCTestCase { + + func testUsernameAlreadyExists() async throws { + let uuid = UUID().uuidString + _ = try await site.api.users.create( + using: .init( + username: uuid, email: "\(uuid)@test.com", password: "badpass", name: nil, firstName: nil, + lastName: nil, url: nil, description: nil, locale: nil, nickname: nil, slug: nil, + roles: ["subscriber"], meta: nil)) + + let error = await assertThrow { + _ = try await site.api.users.create( + using: .init( + username: uuid, email: "\(UUID().uuidString)@test.com", password: "badpass", name: nil, + firstName: nil, lastName: nil, url: nil, description: nil, locale: nil, nickname: nil, + slug: nil, roles: ["subscriber"], meta: nil)) + } + + let apiError = try XCTUnwrap(error as? WpApiError, "Error is not `WpApiError` type") + switch apiError { + case let .ServerError(statusCode): + XCTAssertEqual(statusCode, 500) + default: + XCTFail("Unexpected error: \(apiError)") + } + } + + func testIllegalEmail() async throws { + let error = await assertThrow { + _ = try await site.api.users.create( + using: .init( + username: "\(UUID().uuidString)", email: "test.com", password: "badpass", name: nil, + firstName: nil, lastName: nil, url: nil, description: nil, locale: nil, nickname: nil, + slug: nil, roles: ["subscriber"], meta: nil)) + } + + let apiError = try XCTUnwrap(error as? WpApiError, "Error is not `WpApiError` type") + switch apiError { + case let .ClientError(_, statusCode): + XCTAssertEqual(statusCode, 400) + default: + XCTFail("Unexpected error: \(apiError)") + } + } + + func testIllegalRole() async throws { + let error = await assertThrow { + let uuid = UUID().uuidString + _ = try await site.api.users.create( + using: .init( + username: uuid, email: "\(uuid)@test.com", password: "badpass", name: nil, + firstName: nil, lastName: nil, url: nil, description: nil, locale: nil, nickname: nil, + slug: nil, roles: ["sub"], meta: nil)) + } + + let apiError = try XCTUnwrap(error as? WpApiError, "Error is not `WpApiError` type") + switch apiError { + case let .ClientError(_, statusCode): + XCTAssertEqual(statusCode, 400) + default: + XCTFail("Unexpected error: \(apiError)") + } + } + + private func assertThrow( + closure: () async throws -> Void, file: StaticString = #file, line: UInt = #line + ) async -> Error { + do { + try await closure() + XCTFail("Expect an error shown in the above call", file: file, line: line) + throw NSError(domain: "assert-throw", code: 1) + } catch { + return error + } + } + +} + +#endif