Skip to content

Commit 6f54061

Browse files
authored
feat: API clients will now return Requestable type instead of Request making writing tests easy (#1094)
1 parent 3d50822 commit 6f54061

22 files changed

+736
-195
lines changed

App/ContentView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import Auth0
1010

1111

1212
struct ContentView: View {
13-
@StateObject private var viewModel = ContentViewModel()
13+
@StateObject private var viewModel = ContentViewModel(authenticationClient: Auth0.authentication())
1414

1515
#if os(macOS)
1616
@State private var currentWindow: Auth0WindowRepresentable?
@@ -23,6 +23,7 @@ struct ContentView: View {
2323

2424
Button {
2525
Task {
26+
2627
#if WEB_AUTH_PLATFORM
2728
#if os(macOS)
2829
await viewModel.webLogin(presentationWindow: currentWindow)
@@ -45,7 +46,6 @@ struct ContentView: View {
4546
Button {
4647
Task {
4748
#if WEB_AUTH_PLATFORM
48-
4949
#if os(macOS)
5050
await viewModel.logout(presentationWindow: currentWindow)
5151
#else

App/ContentViewModel.swift

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,49 @@ import Combine
44

55
@MainActor
66
final class ContentViewModel: ObservableObject {
7+
@Published var email: String = ""
8+
@Published var password: String = ""
79
@Published var isLoading: Bool = false
810
@Published var errorMessage: String?
911
@Published var isAuthenticated: Bool = false
10-
private let credentialsManager = CredentialsManager(authentication: Auth0.authentication())
12+
private let credentialsManager: CredentialsManager
1113

12-
#if WEB_AUTH_PLATFORM
14+
private let authenticationClient: Authentication
15+
init(email: String = "",
16+
password: String = "",
17+
isLoading: Bool = false,
18+
errorMessage: String? = nil,
19+
isAuthenticated: Bool = false,
20+
authenticationClient: Authentication,
21+
credentialsManager: CredentialsManager? = nil) {
22+
self.email = email
23+
self.password = password
24+
self.isLoading = isLoading
25+
self.errorMessage = errorMessage
26+
self.isAuthenticated = isAuthenticated
27+
self.authenticationClient = authenticationClient
28+
self.credentialsManager = credentialsManager ?? CredentialsManager(authentication: Auth0.authentication())
29+
}
30+
31+
func login() async {
32+
isLoading = true
33+
do {
34+
let credentials = try await authenticationClient
35+
.login(usernameOrEmail: email, password: password, realmOrConnection: "Username-Password-Authentication", audience: nil, scope: "openid profile offline_access")
36+
.start()
37+
isAuthenticated = true
38+
} catch {
39+
errorMessage = error.localizedDescription
40+
}
41+
isLoading = false
42+
}
43+
44+
#if WEB_AUTH_PLATFORM
1345
func webLogin(presentationWindow window: Auth0WindowRepresentable? = nil) async {
1446
isLoading = true
1547
errorMessage = nil
1648

17-
#if !os(tvOS) && !os(watchOS)
1849
do {
19-
2050
let credentials = try await Auth0
2151
.webAuth()
2252
.scope("openid profile email offline_access")
@@ -33,23 +63,17 @@ final class ContentViewModel: ObservableObject {
3363
} catch {
3464
errorMessage = "Unexpected error: \(error.localizedDescription)"
3565
}
36-
#endif
3766

3867
isLoading = false
3968
}
69+
#endif
4070

71+
#if WEB_AUTH_PLATFORM
4172
func logout(presentationWindow window: Auth0WindowRepresentable? = nil) async {
4273
isLoading = true
4374
errorMessage = nil
44-
#if !os(tvOS) && !os(watchOS)
4575
do {
46-
var webAuth = Auth0.webAuth()
47-
48-
if let window = window {
49-
webAuth = webAuth.presentationWindow(window)
50-
}
51-
52-
try await webAuth.logout()
76+
try await Auth0.webAuth().logout()
5377

5478
let cleared = credentialsManager.clear()
5579
if cleared {
@@ -62,13 +86,11 @@ final class ContentViewModel: ObservableObject {
6286
} catch {
6387
errorMessage = "Unexpected error: \(error.localizedDescription)"
6488
}
65-
#endif
6689

6790
isLoading = false
6891
}
69-
7092
#endif
71-
93+
7294
func checkAuthentication() async {
7395
do {
7496
let credentials = try await credentialsManager.credentials()

AppTests/AppTests.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import Testing
2+
3+
struct AppTests {
4+
5+
@Test func example() async throws {
6+
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
7+
}
8+
9+
}
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import Testing
2+
import Foundation
3+
import Combine
4+
@testable import Auth0
5+
@testable import OAuth2
6+
7+
// MARK: - MockRequestable
8+
9+
/// Generic test double for `Requestable`. Immediately delivers a predetermined result.
10+
struct MockRequestable<T: Sendable, E: Auth0APIError>: Requestable {
11+
let result: Result<T, E>
12+
13+
func start(_ callback: @escaping (Result<T, E>) -> Void) {
14+
callback(result)
15+
}
16+
17+
func parameters(_ extraParameters: [String: Any]) -> any Requestable<T, E> { self }
18+
func headers(_ extraHeaders: [String: String]) -> any Requestable<T, E> { self }
19+
func requestValidators(_ extraValidators: [RequestValidator]) -> any Requestable<T, E> { self }
20+
}
21+
22+
// MARK: - StubRequestable
23+
24+
/// A no-op requestable used for Authentication protocol methods not under test.
25+
private struct StubRequestable<T: Sendable, E: Auth0APIError>: Requestable {
26+
func start(_ callback: @escaping (Result<T, E>) -> Void) {}
27+
func parameters(_ extraParameters: [String: Any]) -> any Requestable<T, E> { self }
28+
func headers(_ extraHeaders: [String: String]) -> any Requestable<T, E> { self }
29+
func requestValidators(_ extraValidators: [RequestValidator]) -> any Requestable<T, E> { self }
30+
}
31+
32+
// MARK: - MockAuthentication
33+
34+
/// Test double for `Authentication`. Injects a `Requestable` for the login method under test;
35+
/// all other protocol methods return no-op stubs.
36+
struct MockAuthentication: Authentication {
37+
var clientId: String
38+
var url: URL
39+
var dpop: DPoP?
40+
var telemetry: Telemetry
41+
var logger: (any Logger)?
42+
43+
/// The request returned by `login(usernameOrEmail:password:realmOrConnection:audience:scope:)`.
44+
let loginRequest: any Requestable<Credentials, AuthenticationError>
45+
46+
init(loginRequest: any Requestable<Credentials, AuthenticationError>,
47+
clientId: String = "mock-client",
48+
url: URL = URL(string: "https://mock.auth0.com")!) {
49+
self.loginRequest = loginRequest
50+
self.clientId = clientId
51+
self.url = url
52+
self.dpop = nil
53+
self.telemetry = Telemetry()
54+
self.logger = nil
55+
}
56+
57+
// MARK: Method under test
58+
59+
func login(usernameOrEmail username: String, password: String,
60+
realmOrConnection realm: String,
61+
audience: String?, scope: String) -> any Requestable<Credentials, AuthenticationError> {
62+
loginRequest
63+
}
64+
65+
// MARK: Required stubs
66+
67+
func login(email: String, code: String, audience: String?, scope: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
68+
func login(phoneNumber: String, code: String, audience: String?, scope: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
69+
func login(withOTP otp: String, mfaToken: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
70+
func login(withOOBCode oobCode: String, mfaToken: String, bindingCode: String?) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
71+
func login(withRecoveryCode recoveryCode: String, mfaToken: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
72+
func multifactorChallenge(mfaToken: String, types: [String]?, authenticatorId: String?) -> any Requestable<Challenge, AuthenticationError> { StubRequestable() }
73+
func login(appleAuthorizationCode authorizationCode: String, fullName: PersonNameComponents?, profile: [String: Any]?, audience: String?, scope: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
74+
func login(facebookSessionAccessToken sessionAccessToken: String, profile: [String: Any], audience: String?, scope: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
75+
func loginDefaultDirectory(withUsername username: String, password: String, audience: String?, scope: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
76+
func signup(email: String, username: String?, password: String, connection: String, userMetadata: [String: Any]?, rootAttributes: [String: Any]?) -> any Requestable<DatabaseUser, AuthenticationError> { StubRequestable() }
77+
78+
#if PASSKEYS_PLATFORM
79+
@available(iOS 16.6, macOS 13.5, visionOS 1.0, *)
80+
func login(passkey: LoginPasskey, challenge: PasskeyLoginChallenge, connection: String?, audience: String?, scope: String, organization: String?) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
81+
82+
@available(iOS 16.6, macOS 13.5, visionOS 1.0, *)
83+
func passkeyLoginChallenge(connection: String?, organization: String?) -> any Requestable<PasskeyLoginChallenge, AuthenticationError> { StubRequestable() }
84+
85+
@available(iOS 16.6, macOS 13.5, visionOS 1.0, *)
86+
func login(passkey: SignupPasskey, challenge: PasskeySignupChallenge, connection: String?, audience: String?, scope: String, organization: String?) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
87+
88+
@available(iOS 16.6, macOS 13.5, visionOS 1.0, *)
89+
func passkeySignupChallenge(email: String?, phoneNumber: String?, username: String?, name: String?, connection: String?, organization: String?) -> any Requestable<PasskeySignupChallenge, AuthenticationError> { StubRequestable() }
90+
#endif
91+
92+
func resetPassword(email: String, connection: String) -> any Requestable<Void, AuthenticationError> { StubRequestable() }
93+
func startPasswordless(email: String, type: PasswordlessType, connection: String) -> any Requestable<Void, AuthenticationError> { StubRequestable() }
94+
func startPasswordless(phoneNumber: String, type: PasswordlessType, connection: String) -> any Requestable<Void, AuthenticationError> { StubRequestable() }
95+
func userInfo(withAccessToken accessToken: String, tokenType: String) -> any Requestable<UserProfile, AuthenticationError> { StubRequestable() }
96+
func codeExchange(withCode code: String, codeVerifier: String, redirectURI: String) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
97+
func ssoExchange(withRefreshToken refreshToken: String) -> any Requestable<SSOCredentials, AuthenticationError> { StubRequestable() }
98+
func renew(withRefreshToken refreshToken: String, audience: String?, scope: String?) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
99+
func revoke(refreshToken: String) -> any Requestable<Void, AuthenticationError> { StubRequestable() }
100+
func jwks() -> any Requestable<JWKS, AuthenticationError> { StubRequestable() }
101+
func customTokenExchange(subjectToken: String, subjectTokenType: String, audience: String?, scope: String, organization: String?, parameters: [String: Any]) -> any Requestable<Credentials, AuthenticationError> { StubRequestable() }
102+
}
103+
104+
// MARK: - Stubs
105+
106+
private extension CredentialsManager {
107+
static var dummy: CredentialsManager {
108+
CredentialsManager(authentication: Auth0.authentication(clientId: "test-client", domain: "test.auth0.com"))
109+
}
110+
}
111+
112+
private extension Credentials {
113+
static var stub: Credentials {
114+
Credentials(accessToken: "access-token", tokenType: "Bearer", idToken: "id-token")
115+
}
116+
}
117+
118+
private extension AuthenticationError {
119+
static var stub: AuthenticationError {
120+
AuthenticationError(info: ["error": "access_denied", "error_description": "Access denied"], statusCode: 401)
121+
}
122+
}
123+
124+
// MARK: - ContentViewModelTests
125+
126+
@MainActor
127+
@Suite("ContentViewModel")
128+
struct ContentViewModelTests {
129+
130+
private func makeViewModel(
131+
email: String = "",
132+
password: String = "",
133+
isAuthenticated: Bool = false,
134+
loginRequest: any Requestable<Credentials, AuthenticationError>
135+
) -> ContentViewModel {
136+
ContentViewModel(
137+
email: email,
138+
password: password,
139+
isAuthenticated: isAuthenticated,
140+
authenticationClient: MockAuthentication(loginRequest: loginRequest),
141+
credentialsManager: .dummy
142+
)
143+
}
144+
145+
// MARK: Initial state
146+
147+
@Test("Default initial state has empty fields and no error")
148+
func defaultInitialState() {
149+
let viewModel = makeViewModel(loginRequest: MockRequestable(result: .success(.stub)))
150+
151+
#expect(viewModel.email == "")
152+
#expect(viewModel.password == "")
153+
#expect(viewModel.isLoading == false)
154+
#expect(viewModel.isAuthenticated == false)
155+
#expect(viewModel.errorMessage == nil)
156+
}
157+
158+
@Test("Custom initial state is reflected in published properties")
159+
func customInitialState() {
160+
let viewModel = makeViewModel(
161+
email: "user@example.com",
162+
password: "secret",
163+
isAuthenticated: true,
164+
loginRequest: MockRequestable(result: .success(.stub))
165+
)
166+
167+
#expect(viewModel.email == "user@example.com")
168+
#expect(viewModel.password == "secret")
169+
#expect(viewModel.isAuthenticated == true)
170+
}
171+
172+
// MARK: login() — success
173+
174+
@Test("login() sets isAuthenticated to true on success")
175+
func loginSuccessSetsAuthenticated() async {
176+
let viewModel = makeViewModel(loginRequest: MockRequestable(result: .success(.stub)))
177+
178+
await viewModel.login()
179+
180+
#expect(viewModel.isAuthenticated == true)
181+
#expect(viewModel.errorMessage == nil)
182+
}
183+
184+
@Test("login() clears isLoading after success")
185+
func loginSuccessClearsLoading() async {
186+
let viewModel = makeViewModel(loginRequest: MockRequestable(result: .success(.stub)))
187+
188+
await viewModel.login()
189+
190+
#expect(viewModel.isLoading == false)
191+
}
192+
193+
// MARK: login() — failure
194+
195+
@Test("login() sets errorMessage and keeps isAuthenticated false on failure")
196+
func loginFailureSetsErrorMessage() async {
197+
let viewModel = makeViewModel(loginRequest: MockRequestable(result: .failure(.stub)))
198+
199+
await viewModel.login()
200+
201+
#expect(viewModel.isAuthenticated == false)
202+
#expect(viewModel.errorMessage == AuthenticationError.stub.localizedDescription)
203+
}
204+
205+
@Test("login() clears isLoading after failure")
206+
func loginFailureClearsLoading() async {
207+
let viewModel = makeViewModel(loginRequest: MockRequestable(result: .failure(.stub)))
208+
209+
await viewModel.login()
210+
211+
#expect(viewModel.isLoading == false)
212+
}
213+
214+
// MARK: login() — uses injected request
215+
216+
@Test("login() uses the injected Authentication client, not a live network call")
217+
func loginUsesInjectedAuthenticationClient() async {
218+
// The mock always succeeds; a live Auth0 call would fail without network/credentials.
219+
let viewModel = makeViewModel(
220+
email: "any@example.com",
221+
password: "any",
222+
loginRequest: MockRequestable(result: .success(.stub))
223+
)
224+
225+
await viewModel.login()
226+
227+
#expect(viewModel.isAuthenticated == true)
228+
}
229+
}

0 commit comments

Comments
 (0)