From 769ea801e1e05d3aca4e509dee5c7da433197601 Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Mon, 23 Feb 2026 16:08:42 +1300 Subject: [PATCH] refactor: browser communication --- .../Runtime/Scripts/Private/AssemblyInfo.cs | 3 + .../Scripts/Private/AssemblyInfo.cs.meta | 11 + .../Core/BrowserCommunicationsManager.cs | 239 ++++++++++-------- .../Private/Core/BrowserMessageCodec.cs | 37 +++ .../Private/Core/BrowserMessageCodec.cs.meta | 11 + .../Core/BrowserResponseErrorMapper.cs | 37 +++ .../Core/BrowserResponseErrorMapper.cs.meta | 11 + .../Private/Core/PendingRequestRegistry.cs | 47 ++++ .../Core/PendingRequestRegistry.cs.meta | 11 + .../Core/BrowserCommunicationsManagerTests.cs | 94 ++++++- .../Scripts/Core/BrowserMessageCodecTests.cs | 114 +++++++++ .../Core/BrowserMessageCodecTests.cs.meta | 11 + .../Core/BrowserResponseErrorMapperTests.cs | 83 ++++++ .../BrowserResponseErrorMapperTests.cs.meta | 11 + .../Core/PendingRequestRegistryTests.cs | 80 ++++++ .../Core/PendingRequestRegistryTests.cs.meta | 11 + 16 files changed, 708 insertions(+), 103 deletions(-) create mode 100644 src/Packages/Passport/Runtime/Scripts/Private/AssemblyInfo.cs create mode 100644 src/Packages/Passport/Runtime/Scripts/Private/AssemblyInfo.cs.meta create mode 100644 src/Packages/Passport/Runtime/Scripts/Private/Core/BrowserMessageCodec.cs create mode 100644 src/Packages/Passport/Runtime/Scripts/Private/Core/BrowserMessageCodec.cs.meta create mode 100644 src/Packages/Passport/Runtime/Scripts/Private/Core/BrowserResponseErrorMapper.cs create mode 100644 src/Packages/Passport/Runtime/Scripts/Private/Core/BrowserResponseErrorMapper.cs.meta create mode 100644 src/Packages/Passport/Runtime/Scripts/Private/Core/PendingRequestRegistry.cs create mode 100644 src/Packages/Passport/Runtime/Scripts/Private/Core/PendingRequestRegistry.cs.meta create mode 100644 src/Packages/Passport/Tests/Runtime/Scripts/Core/BrowserMessageCodecTests.cs create mode 100644 src/Packages/Passport/Tests/Runtime/Scripts/Core/BrowserMessageCodecTests.cs.meta create mode 100644 src/Packages/Passport/Tests/Runtime/Scripts/Core/BrowserResponseErrorMapperTests.cs create mode 100644 src/Packages/Passport/Tests/Runtime/Scripts/Core/BrowserResponseErrorMapperTests.cs.meta create mode 100644 src/Packages/Passport/Tests/Runtime/Scripts/Core/PendingRequestRegistryTests.cs create mode 100644 src/Packages/Passport/Tests/Runtime/Scripts/Core/PendingRequestRegistryTests.cs.meta diff --git a/src/Packages/Passport/Runtime/Scripts/Private/AssemblyInfo.cs b/src/Packages/Passport/Runtime/Scripts/Private/AssemblyInfo.cs new file mode 100644 index 000000000..9b1d6096f --- /dev/null +++ b/src/Packages/Passport/Runtime/Scripts/Private/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Immutable.Passport.Runtime.Tests")] diff --git a/src/Packages/Passport/Runtime/Scripts/Private/AssemblyInfo.cs.meta b/src/Packages/Passport/Runtime/Scripts/Private/AssemblyInfo.cs.meta new file mode 100644 index 000000000..760d64c86 --- /dev/null +++ b/src/Packages/Passport/Runtime/Scripts/Private/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 09b97b9a0dc404f6b9407b9f7a15acba +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Passport/Runtime/Scripts/Private/Core/BrowserCommunicationsManager.cs b/src/Packages/Passport/Runtime/Scripts/Private/Core/BrowserCommunicationsManager.cs index 214fd5f72..8a5bebbb0 100644 --- a/src/Packages/Passport/Runtime/Scripts/Private/Core/BrowserCommunicationsManager.cs +++ b/src/Packages/Passport/Runtime/Scripts/Private/Core/BrowserCommunicationsManager.cs @@ -1,9 +1,7 @@ using System; using Cysharp.Threading.Tasks; -using System.Collections.Generic; using Immutable.Browser.Core; using Immutable.Passport.Model; -using UnityEngine; using UnityEngine.Scripting; using Immutable.Passport.Helpers; using Immutable.Passport.Core.Logging; @@ -16,228 +14,267 @@ namespace Immutable.Passport.Core public interface IBrowserCommunicationsManager { #if (UNITY_ANDROID && !UNITY_EDITOR_WIN) || (UNITY_IPHONE && !UNITY_EDITOR_WIN) || UNITY_STANDALONE_OSX || UNITY_WEBGL + /// + /// Raised when auth-specific messages are received from the game bridge. + /// event OnUnityPostMessageDelegate OnAuthPostMessage; + + /// + /// Raised when the game bridge reports a post-message error. + /// event OnUnityPostMessageErrorDelegate OnPostMessageError; #endif + + /// + /// Sets the default timeout used by calls. + /// void SetCallTimeout(int ms); + + /// + /// Opens the auth URL in the platform browser. + /// void LaunchAuthURL(string url, string redirectUri); + + /// + /// Sends a function call to the game bridge. + /// UniTask Call(string fxName, string? data = null, bool ignoreTimeout = false, Nullable timeoutMs = null); #if (UNITY_IPHONE && !UNITY_EDITOR) || (UNITY_ANDROID && !UNITY_EDITOR) + /// + /// Clears browser cache used by the embedded web view. + /// void ClearCache(bool includeDiskFiles); + + /// + /// Clears browser storage used by the embedded web view. + /// void ClearStorage(); #endif } + /// + /// Orchestrates communication between Unity and the game bridge. + /// Delegates serialization to , + /// error mapping to , + /// and request lifecycle to . + /// [Preserve] public class BrowserCommunicationsManager : IBrowserCommunicationsManager { private const string TAG = "[Browser Communications Manager]"; + private const int DEFAULT_CALL_TIMEOUT_MS = 60000; + + internal const string INIT = "init"; + internal const string INIT_REQUEST_ID = "1"; - // Used to notify that index.js file is loaded - public const string INIT = "init"; - public const string INIT_REQUEST_ID = "1"; + private readonly PendingRequestRegistry _pendingRequests = new PendingRequestRegistry(); + private readonly IWebBrowserClient _webBrowserClient; - private readonly IDictionary> requestTaskMap = new Dictionary>(); - private readonly IWebBrowserClient webBrowserClient; + /// + /// Raised when the game bridge signals that it is ready. + /// public event OnBrowserReadyDelegate? OnReady; /// - /// PKCE in some platforms such as iOS and macOS will not trigger a deeplink and a proper callback needs to be - /// setup. + /// PKCE in some platforms such as iOS and macOS will not trigger a deeplink + /// and a proper callback needs to be setup. /// public event OnUnityPostMessageDelegate? OnAuthPostMessage; public event OnUnityPostMessageErrorDelegate? OnPostMessageError; /// - /// Timeout time for waiting for each call to respond in milliseconds - /// Default value: 1 minute + /// Timeout for waiting for each call to respond in milliseconds. Default: 1 minute. /// - private int callTimeout = 60000; + private int _callTimeout = DEFAULT_CALL_TIMEOUT_MS; + /// + /// Wires browser client callbacks into this manager. + /// public BrowserCommunicationsManager(IWebBrowserClient webBrowserClient) { - this.webBrowserClient = webBrowserClient; - this.webBrowserClient.OnUnityPostMessage += InvokeOnUnityPostMessage; + _webBrowserClient = webBrowserClient; + _webBrowserClient.OnUnityPostMessage += InvokeOnUnityPostMessage; #if (UNITY_ANDROID && !UNITY_EDITOR_WIN) || (UNITY_IPHONE && !UNITY_EDITOR_WIN) || UNITY_STANDALONE_OSX || UNITY_WEBGL - this.webBrowserClient.OnAuthPostMessage += InvokeOnAuthPostMessage; - this.webBrowserClient.OnPostMessageError += InvokeOnPostMessageError; + _webBrowserClient.OnAuthPostMessage += InvokeOnAuthPostMessage; + _webBrowserClient.OnPostMessageError += InvokeOnPostMessageError; #endif } - #region Unity to Browser + #region Unity to Game Bridge + /// + /// Updates the call timeout. + /// public void SetCallTimeout(int ms) { - callTimeout = ms; + _callTimeout = ms; } + /// + /// Calls a function in the game bridge. + /// public UniTask Call(string fxName, string? data = null, bool ignoreTimeout = false, long? timeoutMs = null) { - var t = new UniTaskCompletionSource(); var requestId = Guid.NewGuid().ToString(); - // Add task completion source to the map so we can return the response - requestTaskMap.Add(requestId, t); - CallFunction(requestId, fxName, data); - return ignoreTimeout ? t.Task : t.Task.Timeout(TimeSpan.FromMilliseconds(timeoutMs ?? callTimeout)); - } + var completion = _pendingRequests.Register(requestId); - private void CallFunction(string requestId, string fxName, string? data = null) - { var request = new BrowserRequest(fxName, requestId, data); - var requestJson = JsonUtility.ToJson(request).Replace("\\", "\\\\").Replace("\"", "\\\""); + var js = BrowserMessageCodec.BuildJsCall(request); - // Call the function on the JS side - var js = $"callFunction(\"{requestJson}\")"; + LogOutgoingCall(fxName, requestId, data, js); - if (fxName != PassportAnalytics.TRACK) - { - string dataString = data != null ? $": {data}" : ""; - PassportLogger.Info($"{TAG} Call {fxName} (request ID: {requestId}){dataString}"); - } - else - { - PassportLogger.Debug($"{TAG} Call {fxName} (request ID: {requestId}): {js}"); - } + _webBrowserClient.ExecuteJs(js); - webBrowserClient.ExecuteJs(js); + return ignoreTimeout + ? completion.Task + : completion.Task.Timeout(TimeSpan.FromMilliseconds(timeoutMs ?? _callTimeout)); } + /// + /// Opens the external auth flow using the game bridge. + /// public void LaunchAuthURL(string url, string redirectUri) { PassportLogger.Info($"{TAG} LaunchAuthURL : {url}"); - webBrowserClient.LaunchAuthURL(url, redirectUri); + _webBrowserClient.LaunchAuthURL(url, redirectUri); } #if (UNITY_IPHONE && !UNITY_EDITOR) || (UNITY_ANDROID && !UNITY_EDITOR) + /// + /// Clears cache in mobile runtime environments. + /// public void ClearCache(bool includeDiskFiles) { - webBrowserClient.ClearCache(includeDiskFiles); + _webBrowserClient.ClearCache(includeDiskFiles); } + /// + /// Clears persisted storage in mobile runtime environments. + /// public void ClearStorage() { - webBrowserClient.ClearStorage(); + _webBrowserClient.ClearStorage(); } #endif #endregion - #region Browser to Unity + #region Game Bridge to Unity + /// + /// Entry point for generic post messages from the game bridge. + /// private void InvokeOnUnityPostMessage(string message) { HandleResponse(message); } + /// + /// Forwards auth-specific game bridge messages to consumers. + /// private void InvokeOnAuthPostMessage(string message) { PassportLogger.Info($"{TAG} Auth message received: {message}"); OnAuthPostMessage?.Invoke(message); } + /// + /// Forwards game bridge post-message errors to consumers. + /// private void InvokeOnPostMessageError(string id, string message) { PassportLogger.Info($"{TAG} Error message received ({id}): {message}"); OnPostMessageError?.Invoke(id, message); } + /// + /// Handles a game bridge response message. + /// private void HandleResponse(string message) { PassportLogger.Debug($"{TAG} Handle response message: " + message); - var response = message.OptDeserializeObject(); + var response = BrowserMessageCodec.ParseAndValidateResponse(message); - // Validate the deserialised response object - if (response == null || string.IsNullOrEmpty(response.responseFor) || string.IsNullOrEmpty(response.requestId)) - { - throw new PassportException("Response from browser is incorrect. Check game bridge file."); - } + LogIncomingResponse(response, message); - string logMessage = $"{TAG} Response for: {response.responseFor} (request ID: {response.requestId}) : {message}"; - if (response.responseFor != PassportAnalytics.TRACK) - { - // Log info messages for valid responses not related to tracking - PassportLogger.Info(logMessage); - } - else - { - PassportLogger.Debug(logMessage); - } - - // Handle special case where the response indicates that the browser is ready if (response.responseFor == INIT && response.requestId == INIT_REQUEST_ID) { - PassportLogger.Info($"{TAG} Browser is ready"); + PassportLogger.Info($"{TAG} Game bridge is ready"); OnReady?.Invoke(); return; } - // Handle the response if a matching task exists for the request ID string requestId = response.requestId; - if (requestTaskMap.ContainsKey(requestId)) - { - NotifyRequestResult(requestId, message); - } - else + if (!_pendingRequests.Contains(requestId)) { string errorMsg = $"No TaskCompletionSource for request id {requestId} found."; PassportLogger.Error(errorMsg); throw new PassportException(errorMsg); } + + NotifyRequestResult(requestId, response, message); } - private PassportException ParseError(BrowserResponse response) + /// + /// Completes the pending request task with success or failure. + /// + private void NotifyRequestResult(string requestId, BrowserResponse response, string rawMessage) { - // Failed or error occured + var completion = _pendingRequests.Get(requestId); try { - if (!String.IsNullOrEmpty(response.error) && !String.IsNullOrEmpty(response.errorType)) + if (response.success == false || !string.IsNullOrEmpty(response.error)) { - PassportErrorType type = (PassportErrorType)System.Enum.Parse(typeof(PassportErrorType), response.errorType); - return new PassportException(response.error, type); - } - else if (!String.IsNullOrEmpty(response.error)) - { - return new PassportException(response.error); + var exception = BrowserResponseErrorMapper.MapToException(response); + if (!completion.TrySetException(exception)) + throw new PassportException($"Unable to set exception for request id {requestId}. Task has already been completed."); } else { - return new PassportException("Unknown error"); + if (!completion.TrySetResult(rawMessage)) + throw new PassportException($"Unable to set result for request id {requestId}. Task has already been completed."); } } - catch (Exception ex) + catch (ObjectDisposedException) { - PassportLogger.Error($"{TAG} Parse passport type error: {ex.Message}"); + throw new PassportException($"Task for request id {requestId} has already been disposed and can't be updated."); } - return new PassportException(response.error ?? "Failed to parse error"); + + _pendingRequests.Remove(requestId); } - private void NotifyRequestResult(string requestId, string result) + /// + /// Logs game bridge responses, using debug level for analytics tracking calls. + /// + private static void LogIncomingResponse(BrowserResponse response, string rawMessage) { - var response = result.OptDeserializeObject(); - var completion = requestTaskMap[requestId] as UniTaskCompletionSource; - try + string logMessage = $"{TAG} Response for: {response.responseFor} (request ID: {response.requestId}) : {rawMessage}"; + if (response.responseFor != PassportAnalytics.TRACK) { - if (response?.success == false || !string.IsNullOrEmpty(response?.error)) - { - var exception = ParseError(response); - if (!completion.TrySetException(exception)) - throw new PassportException($"Unable to set exception for for request id {requestId}. Task has already been completed."); - } - else - { - if (!completion.TrySetResult(result)) - throw new PassportException($"Unable to set result for for request id {requestId}. Task has already been completed."); - } + PassportLogger.Info(logMessage); } - catch (ObjectDisposedException) + else { - throw new PassportException($"Task for request id {requestId} has already been disposed and can't be updated."); + PassportLogger.Debug(logMessage); } + } - requestTaskMap.Remove(requestId); + /// + /// Logs outgoing calls, using debug level for analytics tracking calls. + /// + private static void LogOutgoingCall(string fxName, string requestId, string? data, string js) + { + if (fxName != PassportAnalytics.TRACK) + { + string dataString = data != null ? $": {data}" : ""; + PassportLogger.Info($"{TAG} Call {fxName} (request ID: {requestId}){dataString}"); + } + else + { + PassportLogger.Debug($"{TAG} Call {fxName} (request ID: {requestId}): {js}"); + } } #endregion - } -} \ No newline at end of file +} diff --git a/src/Packages/Passport/Runtime/Scripts/Private/Core/BrowserMessageCodec.cs b/src/Packages/Passport/Runtime/Scripts/Private/Core/BrowserMessageCodec.cs new file mode 100644 index 000000000..7b01fe0aa --- /dev/null +++ b/src/Packages/Passport/Runtime/Scripts/Private/Core/BrowserMessageCodec.cs @@ -0,0 +1,37 @@ +using Immutable.Passport.Helpers; +using Immutable.Passport.Model; +using UnityEngine; + +namespace Immutable.Passport.Core +{ + /// + /// Handles serialization of outgoing requests and deserialization/validation + /// of incoming responses for the game bridge. + /// + internal static class BrowserMessageCodec + { + /// + /// Serializes a request into a JavaScript function call string. + /// + internal static string BuildJsCall(BrowserRequest request) + { + var escapedJson = JsonUtility.ToJson(request).Replace("\\", "\\\\").Replace("\"", "\\\""); + return $"callFunction(\"{escapedJson}\")"; + } + + /// + /// Deserializes and validates a raw game bridge response message. + /// + internal static BrowserResponse ParseAndValidateResponse(string message) + { + var response = message.OptDeserializeObject(); + + if (response == null || string.IsNullOrEmpty(response.responseFor) || string.IsNullOrEmpty(response.requestId)) + { + throw new PassportException("Response from game bridge is incorrect. Check game bridge file."); + } + + return response; + } + } +} diff --git a/src/Packages/Passport/Runtime/Scripts/Private/Core/BrowserMessageCodec.cs.meta b/src/Packages/Passport/Runtime/Scripts/Private/Core/BrowserMessageCodec.cs.meta new file mode 100644 index 000000000..7dc8d0a3c --- /dev/null +++ b/src/Packages/Passport/Runtime/Scripts/Private/Core/BrowserMessageCodec.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9e5c3770cdd854060a86f24d44d88bf9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Passport/Runtime/Scripts/Private/Core/BrowserResponseErrorMapper.cs b/src/Packages/Passport/Runtime/Scripts/Private/Core/BrowserResponseErrorMapper.cs new file mode 100644 index 000000000..d01ed7d2f --- /dev/null +++ b/src/Packages/Passport/Runtime/Scripts/Private/Core/BrowserResponseErrorMapper.cs @@ -0,0 +1,37 @@ +using System; +using Immutable.Passport.Core.Logging; +using Immutable.Passport.Model; + +namespace Immutable.Passport.Core +{ + /// + /// Maps game bridge error responses into typed Passport exceptions. + /// + internal static class BrowserResponseErrorMapper + { + private const string TAG = "[Browser Response Error Mapper]"; + + /// + /// Converts a failed BrowserResponse into the appropriate PassportException. + /// + internal static PassportException MapToException(BrowserResponse response) + { + try + { + if (!string.IsNullOrEmpty(response.error) && !string.IsNullOrEmpty(response.errorType)) + { + var type = (PassportErrorType)Enum.Parse(typeof(PassportErrorType), response.errorType); + return new PassportException(response.error, type); + } + + return new PassportException(!string.IsNullOrEmpty(response.error) ? response.error : "Unknown error"); + } + catch (Exception ex) + { + PassportLogger.Error($"{TAG} Parse passport type error: {ex.Message}"); + } + + return new PassportException(response.error ?? "Failed to parse error"); + } + } +} diff --git a/src/Packages/Passport/Runtime/Scripts/Private/Core/BrowserResponseErrorMapper.cs.meta b/src/Packages/Passport/Runtime/Scripts/Private/Core/BrowserResponseErrorMapper.cs.meta new file mode 100644 index 000000000..e03290d44 --- /dev/null +++ b/src/Packages/Passport/Runtime/Scripts/Private/Core/BrowserResponseErrorMapper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 57e2d1a1978b0439096e6bf8ccab77ef +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Passport/Runtime/Scripts/Private/Core/PendingRequestRegistry.cs b/src/Packages/Passport/Runtime/Scripts/Private/Core/PendingRequestRegistry.cs new file mode 100644 index 000000000..57ec6e6bc --- /dev/null +++ b/src/Packages/Passport/Runtime/Scripts/Private/Core/PendingRequestRegistry.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using Cysharp.Threading.Tasks; + +namespace Immutable.Passport.Core +{ + /// + /// Tracks in-flight game bridge requests by mapping request IDs to their completion sources. + /// + internal class PendingRequestRegistry + { + private readonly Dictionary> _requests = new Dictionary>(); + + /// + /// Registers a new pending request and returns its completion source. + /// + internal UniTaskCompletionSource Register(string requestId) + { + var completion = new UniTaskCompletionSource(); + _requests.Add(requestId, completion); + return completion; + } + + /// + /// Returns true if a pending request exists for the given ID. + /// + internal bool Contains(string requestId) + { + return _requests.ContainsKey(requestId); + } + + /// + /// Retrieves the completion source for a pending request. + /// + internal UniTaskCompletionSource Get(string requestId) + { + return _requests[requestId]; + } + + /// + /// Removes a completed request from the registry. + /// + internal void Remove(string requestId) + { + _requests.Remove(requestId); + } + } +} diff --git a/src/Packages/Passport/Runtime/Scripts/Private/Core/PendingRequestRegistry.cs.meta b/src/Packages/Passport/Runtime/Scripts/Private/Core/PendingRequestRegistry.cs.meta new file mode 100644 index 000000000..7f7b9b846 --- /dev/null +++ b/src/Packages/Passport/Runtime/Scripts/Private/Core/PendingRequestRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8e11978deedd844e88fe3395bc9706dd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Passport/Tests/Runtime/Scripts/Core/BrowserCommunicationsManagerTests.cs b/src/Packages/Passport/Tests/Runtime/Scripts/Core/BrowserCommunicationsManagerTests.cs index 21655dfff..70c7892dd 100644 --- a/src/Packages/Passport/Tests/Runtime/Scripts/Core/BrowserCommunicationsManagerTests.cs +++ b/src/Packages/Passport/Tests/Runtime/Scripts/Core/BrowserCommunicationsManagerTests.cs @@ -3,6 +3,7 @@ using Immutable.Browser.Core; using Immutable.Passport.Model; using UnityEngine; +using UnityEngine.TestTools; using System.Text.RegularExpressions; using System.Threading.Tasks; using Immutable.Passport.Helpers; @@ -78,7 +79,7 @@ public async Task CallAndResponse_Failed_NoRequestId() } Assert.NotNull(e); - Assert.IsTrue(e.Message.Contains("Response from browser is incorrect") == true); + Assert.IsTrue(e.Message.Contains("Response from game bridge is incorrect") == true); } [Test] @@ -149,6 +150,91 @@ public void CallAndResponse_Success_BrowserReady() Assert.True(onReadyCalled); } + + [Test] + public void SetCallTimeout_DoesNotThrow() + { + Assert.DoesNotThrow(() => manager.SetCallTimeout(5000)); + } + + [Test] + public void LaunchAuthURL_ForwardsUrlAndRedirectUri() + { + manager.LaunchAuthURL("https://auth.example.com", "myapp://callback"); + + Assert.AreEqual("https://auth.example.com", mockClient.lastLaunchedUrl); + Assert.AreEqual("myapp://callback", mockClient.lastLaunchedRedirectUri); + } + + [Test] + public async Task CallAndResponse_Failed_ErrorFieldSet_SuccessTrue_ThrowsException() + { + // success=true but an error field is present — should still be treated as failure + mockClient.browserResponse = new BrowserResponse + { + responseFor = FUNCTION_NAME, + error = ERROR, + success = true + }; + + PassportException e = null; + try + { + await manager.Call(FUNCTION_NAME); + } + catch (PassportException ex) + { + e = ex; + } + + Assert.NotNull(e); + Assert.AreEqual(ERROR, e.Message); + } + + [Test] + public void HandleResponse_UnknownRequestId_Throws() + { + // A well-formed response whose requestId was never registered via Call() + var response = new BrowserResponse + { + responseFor = FUNCTION_NAME, + requestId = "unknown-request-id", + success = true + }; + + LogAssert.Expect(LogType.Error, new Regex("No TaskCompletionSource for request id")); + + var ex = Assert.Throws( + () => mockClient.InvokeUnityPostMessage(JsonUtility.ToJson(response)) + ); + + Assert.IsTrue(ex.Message.Contains("No TaskCompletionSource for request id")); + } + + [Test] + public async Task CallAndResponse_Failed_ClientError_NoErrorField() + { + // success=false but no error or errorType - should get "Unknown error" + mockClient.browserResponse = new BrowserResponse + { + responseFor = FUNCTION_NAME, + success = false + }; + + PassportException e = null; + try + { + await manager.Call(FUNCTION_NAME); + } + catch (PassportException ex) + { + e = ex; + } + + Assert.NotNull(e); + Assert.Null(e.Type); + Assert.AreEqual("Unknown error", e.Message); + } } internal class MockBrowserClient : IWebBrowserClient @@ -200,9 +286,13 @@ private string Between(string value, string a, string b) return value.Substring(adjustedPosA, posB - adjustedPosA); } + public string lastLaunchedUrl; + public string lastLaunchedRedirectUri; + public void LaunchAuthURL(string url, string redirectUri) { - throw new NotImplementedException(); + lastLaunchedUrl = url; + lastLaunchedRedirectUri = redirectUri; } public void Dispose() diff --git a/src/Packages/Passport/Tests/Runtime/Scripts/Core/BrowserMessageCodecTests.cs b/src/Packages/Passport/Tests/Runtime/Scripts/Core/BrowserMessageCodecTests.cs new file mode 100644 index 000000000..59957df43 --- /dev/null +++ b/src/Packages/Passport/Tests/Runtime/Scripts/Core/BrowserMessageCodecTests.cs @@ -0,0 +1,114 @@ +using NUnit.Framework; +using Immutable.Passport.Model; +using UnityEngine; + +namespace Immutable.Passport.Core +{ + [TestFixture] + public class BrowserMessageCodecTests + { + private const string FUNCTION_NAME = "someFunction"; + private const string REQUEST_ID = "test-request-id"; + + [Test] + public void BuildJsCall_ProducesCallFunctionInvocation() + { + var request = new BrowserRequest(FUNCTION_NAME, REQUEST_ID, null); + + var js = BrowserMessageCodec.BuildJsCall(request); + + Assert.IsTrue(js.StartsWith("callFunction(\"")); + Assert.IsTrue(js.EndsWith("\")")); + } + + [Test] + public void BuildJsCall_ContainsFunctionNameAndRequestId() + { + var request = new BrowserRequest(FUNCTION_NAME, REQUEST_ID, null); + + var js = BrowserMessageCodec.BuildJsCall(request); + + Assert.IsTrue(js.Contains(FUNCTION_NAME)); + Assert.IsTrue(js.Contains(REQUEST_ID)); + } + + [Test] + public void BuildJsCall_EscapesQuotesAndBackslashes() + { + // data containing quotes and backslashes that must survive JSON-in-JS embedding + var request = new BrowserRequest(FUNCTION_NAME, REQUEST_ID, "{\"key\":\"value\"}"); + + var js = BrowserMessageCodec.BuildJsCall(request); + + // The raw JS string must not contain unescaped quotes that would break callFunction("...") + // Verify it round-trips cleanly through the mock browser client extraction logic + var json = ExtractJson(js); + var roundTripped = Immutable.Passport.Helpers.JsonExtensions.OptDeserializeObject(json); + Assert.AreEqual(FUNCTION_NAME, roundTripped.fxName); + Assert.AreEqual("{\"key\":\"value\"}", roundTripped.data); + } + + [Test] + public void ParseAndValidateResponse_ValidMessage_ReturnsResponse() + { + var response = new BrowserResponse + { + responseFor = FUNCTION_NAME, + requestId = REQUEST_ID, + success = true + }; + var json = JsonUtility.ToJson(response); + + var result = BrowserMessageCodec.ParseAndValidateResponse(json); + + Assert.AreEqual(FUNCTION_NAME, result.responseFor); + Assert.AreEqual(REQUEST_ID, result.requestId); + } + + [Test] + public void ParseAndValidateResponse_MissingResponseFor_Throws() + { + var response = new BrowserResponse { requestId = REQUEST_ID }; + var json = JsonUtility.ToJson(response); + + var ex = Assert.Throws( + () => BrowserMessageCodec.ParseAndValidateResponse(json) + ); + + Assert.IsTrue(ex.Message.Contains("Response from game bridge is incorrect")); + } + + [Test] + public void ParseAndValidateResponse_MissingRequestId_Throws() + { + var response = new BrowserResponse { responseFor = FUNCTION_NAME }; + var json = JsonUtility.ToJson(response); + + var ex = Assert.Throws( + () => BrowserMessageCodec.ParseAndValidateResponse(json) + ); + + Assert.IsTrue(ex.Message.Contains("Response from game bridge is incorrect")); + } + + [Test] + public void ParseAndValidateResponse_InvalidJson_Throws() + { + var ex = Assert.Throws( + () => BrowserMessageCodec.ParseAndValidateResponse("not valid json") + ); + + Assert.IsTrue(ex.Message.Contains("Response from game bridge is incorrect")); + } + + // Mirrors the extraction logic in MockBrowserClient to round-trip test BuildJsCall output + private static string ExtractJson(string js) + { + const string prefix = "callFunction(\""; + const string suffix = "\")"; + int start = js.IndexOf(prefix) + prefix.Length; + int end = js.LastIndexOf(suffix); + return js.Substring(start, end - start).Replace("\\\\", "\\").Replace("\\\"", "\""); + } + } +} diff --git a/src/Packages/Passport/Tests/Runtime/Scripts/Core/BrowserMessageCodecTests.cs.meta b/src/Packages/Passport/Tests/Runtime/Scripts/Core/BrowserMessageCodecTests.cs.meta new file mode 100644 index 000000000..2bcecfb58 --- /dev/null +++ b/src/Packages/Passport/Tests/Runtime/Scripts/Core/BrowserMessageCodecTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d51e5d35ab6546f0a143121285a66e7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Passport/Tests/Runtime/Scripts/Core/BrowserResponseErrorMapperTests.cs b/src/Packages/Passport/Tests/Runtime/Scripts/Core/BrowserResponseErrorMapperTests.cs new file mode 100644 index 000000000..d34f97a9e --- /dev/null +++ b/src/Packages/Passport/Tests/Runtime/Scripts/Core/BrowserResponseErrorMapperTests.cs @@ -0,0 +1,83 @@ +using NUnit.Framework; +using Immutable.Passport.Model; +using UnityEngine; +using UnityEngine.TestTools; + +namespace Immutable.Passport.Core +{ + [TestFixture] + public class BrowserResponseErrorMapperTests + { + private const string ERROR_MESSAGE = "something went wrong"; + + [Test] + public void MapToException_ErrorWithValidType_ReturnsTypedException() + { + var response = new BrowserResponse + { + error = ERROR_MESSAGE, + errorType = "WALLET_CONNECTION_ERROR" + }; + + var ex = BrowserResponseErrorMapper.MapToException(response); + + Assert.AreEqual(ERROR_MESSAGE, ex.Message); + Assert.AreEqual(PassportErrorType.WALLET_CONNECTION_ERROR, ex.Type); + } + + [Test] + public void MapToException_ErrorWithInvalidType_FallsBackToErrorMessage() + { + // An unrecognised errorType string should not throw — falls back gracefully + var response = new BrowserResponse + { + error = ERROR_MESSAGE, + errorType = "NOT_A_REAL_ERROR_TYPE" + }; + + LogAssert.Expect(LogType.Error, new System.Text.RegularExpressions.Regex("Parse passport type error")); + + var ex = BrowserResponseErrorMapper.MapToException(response); + + Assert.AreEqual(ERROR_MESSAGE, ex.Message); + Assert.IsNull(ex.Type); + } + + [Test] + public void MapToException_ErrorWithoutType_ReturnsExceptionWithMessage() + { + var response = new BrowserResponse + { + error = ERROR_MESSAGE + }; + + var ex = BrowserResponseErrorMapper.MapToException(response); + + Assert.AreEqual(ERROR_MESSAGE, ex.Message); + Assert.IsNull(ex.Type); + } + + [Test] + public void MapToException_NoErrorOrType_ReturnsUnknownError() + { + var response = new BrowserResponse(); + + var ex = BrowserResponseErrorMapper.MapToException(response); + + Assert.AreEqual("Unknown error", ex.Message); + Assert.IsNull(ex.Type); + } + + [Test] + public void MapToException_ErrorTypeSetButNoError_ReturnsUnknownError() + { + // errorType alone is not enough — the typed path requires both fields + var response = new BrowserResponse { errorType = "WALLET_CONNECTION_ERROR" }; + + var ex = BrowserResponseErrorMapper.MapToException(response); + + Assert.AreEqual("Unknown error", ex.Message); + Assert.IsNull(ex.Type); + } + } +} diff --git a/src/Packages/Passport/Tests/Runtime/Scripts/Core/BrowserResponseErrorMapperTests.cs.meta b/src/Packages/Passport/Tests/Runtime/Scripts/Core/BrowserResponseErrorMapperTests.cs.meta new file mode 100644 index 000000000..364d1f05f --- /dev/null +++ b/src/Packages/Passport/Tests/Runtime/Scripts/Core/BrowserResponseErrorMapperTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9d723198675964296875ec19b7a0debd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/Packages/Passport/Tests/Runtime/Scripts/Core/PendingRequestRegistryTests.cs b/src/Packages/Passport/Tests/Runtime/Scripts/Core/PendingRequestRegistryTests.cs new file mode 100644 index 000000000..1cc58d8d5 --- /dev/null +++ b/src/Packages/Passport/Tests/Runtime/Scripts/Core/PendingRequestRegistryTests.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using NUnit.Framework; + +namespace Immutable.Passport.Core +{ + [TestFixture] + public class PendingRequestRegistryTests + { + private PendingRequestRegistry _registry; + + [SetUp] + public void Init() + { + _registry = new PendingRequestRegistry(); + } + + [Test] + public void Register_ReturnsCompletionSource() + { + var completion = _registry.Register("req-1"); + + Assert.IsNotNull(completion); + } + + [Test] + public void Contains_AfterRegister_ReturnsTrue() + { + _registry.Register("req-1"); + + Assert.IsTrue(_registry.Contains("req-1")); + } + + [Test] + public void Contains_UnregisteredId_ReturnsFalse() + { + Assert.IsFalse(_registry.Contains("does-not-exist")); + } + + [Test] + public void Get_ReturnsTheSameCompletionSource() + { + var registered = _registry.Register("req-1"); + var retrieved = _registry.Get("req-1"); + + Assert.AreSame(registered, retrieved); + } + + [Test] + public void Remove_AfterRegister_ContainsReturnsFalse() + { + _registry.Register("req-1"); + _registry.Remove("req-1"); + + Assert.IsFalse(_registry.Contains("req-1")); + } + + [Test] + public void Register_MultipleRequests_TracksAllIndependently() + { + var c1 = _registry.Register("req-1"); + var c2 = _registry.Register("req-2"); + + Assert.IsTrue(_registry.Contains("req-1")); + Assert.IsTrue(_registry.Contains("req-2")); + Assert.AreNotSame(c1, c2); + } + + [Test] + public void Remove_OneRequest_DoesNotAffectOthers() + { + _registry.Register("req-1"); + _registry.Register("req-2"); + + _registry.Remove("req-1"); + + Assert.IsFalse(_registry.Contains("req-1")); + Assert.IsTrue(_registry.Contains("req-2")); + } + } +} diff --git a/src/Packages/Passport/Tests/Runtime/Scripts/Core/PendingRequestRegistryTests.cs.meta b/src/Packages/Passport/Tests/Runtime/Scripts/Core/PendingRequestRegistryTests.cs.meta new file mode 100644 index 000000000..ee36e78bc --- /dev/null +++ b/src/Packages/Passport/Tests/Runtime/Scripts/Core/PendingRequestRegistryTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: af3640902ec3b435a881b101c608f43c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: