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: