diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..35161b2
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,86 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+trim_trailing_whitespace = true
+insert_final_newline = true
+indent_style = space
+indent_size = 4
+
+[*.cs]
+# --- Language style ---
+csharp_preferred_modifier_order = public, private, protected, internal, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async, file, required:suggestion
+csharp_space_after_cast = true
+csharp_new_line_before_members_in_object_initializers = false
+csharp_style_var_for_built_in_types = true:suggestion
+csharp_style_var_when_type_is_apparent = true:suggestion
+csharp_style_var_elsewhere = true:suggestion
+
+dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
+dotnet_style_predefined_type_for_member_access = true:suggestion
+dotnet_style_qualification_for_field = true:suggestion
+dotnet_style_qualification_for_property = true:suggestion
+dotnet_style_qualification_for_method = true:suggestion
+dotnet_style_qualification_for_event = true:suggestion
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion
+
+# --- Expression-bodied members: use an expression body when the member is a single statement ---
+csharp_style_expression_bodied_methods = when_on_single_line:suggestion
+csharp_style_expression_bodied_constructors = when_on_single_line:suggestion
+csharp_style_expression_bodied_operators = when_on_single_line:suggestion
+csharp_style_expression_bodied_local_functions = when_on_single_line:suggestion
+csharp_style_expression_bodied_lambdas = when_on_single_line:suggestion
+csharp_style_expression_bodied_properties = when_on_single_line:suggestion
+csharp_style_expression_bodied_indexers = when_on_single_line:suggestion
+csharp_style_expression_bodied_accessors = when_on_single_line:suggestion
+
+# --- Naming (warning-level) ---
+# private instance / static fields: camelCase, NO underscore prefix (CONVENTIONS ยง4)
+dotnet_naming_rule.private_fields_camel_case.severity = warning
+dotnet_naming_rule.private_fields_camel_case.symbols = private_fields
+dotnet_naming_rule.private_fields_camel_case.style = camel_case
+dotnet_naming_symbols.private_fields.applicable_kinds = field
+dotnet_naming_symbols.private_fields.applicable_accessibilities = private
+
+# private const: PascalCase (declared before the general rule so it wins)
+dotnet_naming_rule.private_const_pascal_case.severity = warning
+dotnet_naming_rule.private_const_pascal_case.symbols = private_const_fields
+dotnet_naming_rule.private_const_pascal_case.style = pascal_case
+dotnet_naming_symbols.private_const_fields.applicable_kinds = field
+dotnet_naming_symbols.private_const_fields.applicable_accessibilities = private
+dotnet_naming_symbols.private_const_fields.required_modifiers = const
+
+# private static readonly: PascalCase
+dotnet_naming_rule.private_static_readonly_pascal_case.severity = warning
+dotnet_naming_rule.private_static_readonly_pascal_case.symbols = private_static_readonly_fields
+dotnet_naming_rule.private_static_readonly_pascal_case.style = pascal_case
+dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field
+dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private
+dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = static, readonly
+
+dotnet_naming_style.camel_case.capitalization = camel_case
+dotnet_naming_style.pascal_case.capitalization = pascal_case
+
+# --- ReSharper / Rider hints (IDE-only; not enforced by dotnet build) ---
+# Collapse blank lines in method bodies to 0 โ backs the "no blank lines between arrange/act/assert"
+# rule. Rider-enforced; CI cannot enforce this via editorconfig alone.
+resharper_csharp_keep_blank_lines_in_code = 0
+resharper_csharp_keep_blank_lines_in_declarations = 1
+resharper_braces_for_ifelse = required
+resharper_braces_for_for = required
+resharper_braces_for_foreach = required
+resharper_braces_for_while = required
+resharper_trailing_comma_in_multiline_lists = true
+
+[*.{csproj,props,targets,nuspec}]
+indent_size = 2
+
+[*.{json,jsonc}]
+indent_size = 2
+
+[*.{yaml,yml}]
+indent_size = 2
+
+[*.{bash,sh,zsh}]
+indent_size = 2
diff --git a/Core/Core.csproj b/Core/Core.csproj
index 21fceda..394ec12 100644
--- a/Core/Core.csproj
+++ b/Core/Core.csproj
@@ -44,4 +44,8 @@
+
+
+
+
diff --git a/Core/Util.cs b/Core/Util.cs
index 5761c54..3ad8f02 100644
--- a/Core/Util.cs
+++ b/Core/Util.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
@@ -21,13 +22,21 @@ public static class Util
/// A type belonging to the client assembly, used to resolve the assembly name and version.
/// A structured header value identifying the client library and its host environment.
public static string GetAssemblyVersion(Type clientType) =>
- $"{GetClientName(clientType)}-csharp/{GetClientVersion(clientType)}{BuildMetadata()}";
+ GetAssemblyVersion(clientType, RuntimeInformation.OSDescription, RuntimeInformation.IsOSPlatform, AppDomain.CurrentDomain.GetAssemblies());
+
+ // Testability seam: the platform and framework probes read process-wide ambient state
+ // (RuntimeInformation, the loaded assembly set) that a host cannot vary at runtime. This
+ // overload takes those reads as parameters so every branch is reachable hermetically; the
+ // public entry point above supplies the real values. Internal โ not part of the public API.
+ internal static string GetAssemblyVersion(Type clientType, string osDescription, Func isOsPlatform, IReadOnlyCollection loadedAssemblies) =>
+ $"{GetClientName(clientType)}-csharp/{GetClientVersion(clientType)}{BuildMetadata(osDescription, isOsPlatform, loadedAssemblies)}";
private static string GetClientName(Type clientType) => clientType.Assembly.GetName().Name.ToLower();
private static string? GetClientVersion(Type clientType) => GetInformationalVersion(clientType.Assembly);
- private static string BuildMetadata() => string.Concat(GetPlatformInfo().ToString(), GetRuntimeInfo().ToString(), GetFrameworkInfo().ToString());
+ private static string BuildMetadata(string osDescription, Func isOsPlatform, IReadOnlyCollection loadedAssemblies) =>
+ string.Concat(GetPlatformInfo(osDescription, isOsPlatform).ToString(), GetRuntimeInfo().ToString(), GetFrameworkInfo(loadedAssemblies).ToString());
private sealed class MetadataEntry
{
@@ -49,33 +58,33 @@ public override string ToString() => string.IsNullOrEmpty(version)
internal static MetadataEntry Unknown(string key) => new MetadataEntry(key, "unknown");
}
- private static string GetPlatform()
+ private static string GetPlatform(string osDescription, Func isOsPlatform)
{
- if (RuntimeInformation.OSDescription == "Browser") return "browser";
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return "Windows";
- if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return "macOS";
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Create("iOS"))) return "iOS";
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return "Linux";
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Create("Android"))) return "Android";
- return RuntimeInformation.OSDescription;
+ if (osDescription == "Browser") return "browser";
+ if (isOsPlatform(OSPlatform.Windows)) return "Windows";
+ if (isOsPlatform(OSPlatform.OSX)) return "macOS";
+ if (isOsPlatform(OSPlatform.Create("iOS"))) return "iOS";
+ if (isOsPlatform(OSPlatform.Linux)) return "Linux";
+ if (isOsPlatform(OSPlatform.Create("Android"))) return "Android";
+ return osDescription;
}
- private static MetadataEntry GetPlatformInfo() => new MetadataEntry("platform", GetPlatform(), Environment.OSVersion.Version.ToString());
+ private static MetadataEntry GetPlatformInfo(string osDescription, Func isOsPlatform) => new MetadataEntry("platform", GetPlatform(osDescription, isOsPlatform), Environment.OSVersion.Version.ToString());
private static MetadataEntry GetRuntimeInfo() => new MetadataEntry("runtime", "dotnet", Environment.Version.ToString());
// Priority is explicit: MAUI wins over Blazor in hybrid apps where both assemblies are present.
// Unity version uses GetCustomAttributesData() rather than member reflection โ safe under IL2CPP.
- private static MetadataEntry GetFrameworkInfo()
+ private static MetadataEntry GetFrameworkInfo(IReadOnlyCollection loadedAssemblies)
{
- var assemblies = AppDomain.CurrentDomain.GetAssemblies()
+ var assemblies = loadedAssemblies
.GroupBy(a => a.GetName().Name)
.ToDictionary(g => g.Key, g => g.First());
if (assemblies.TryGetValue("Microsoft.Maui", out var maui))
return new MetadataEntry("framework", "maui", GetInformationalVersion(maui));
if (assemblies.ContainsKey("UnityEngine.CoreModule"))
- return new MetadataEntry("framework", "unity", GetUnityVersion());
+ return new MetadataEntry("framework", "unity", GetUnityVersion(loadedAssemblies));
if (assemblies.TryGetValue("Microsoft.AspNetCore.Components", out var blazor))
return new MetadataEntry("framework", "blazor", GetInformationalVersion(blazor));
return MetadataEntry.Unknown("framework");
@@ -90,9 +99,9 @@ private static CustomAttributeData[] SafeGetCustomAttributesData(Assembly assemb
catch { return Array.Empty(); }
}
- private static string? GetUnityVersion()
+ private static string? GetUnityVersion(IReadOnlyCollection loadedAssemblies)
{
- var attr = AppDomain.CurrentDomain.GetAssemblies()
+ var attr = loadedAssemblies
.SelectMany(SafeGetCustomAttributesData)
.FirstOrDefault(d => d.AttributeType.Name == "UnityAPICompatibilityVersionAttribute");
return attr?.ConstructorArguments.Count > 0 ? attr.ConstructorArguments[0].Value as string : null;
diff --git a/CoreTests/Attributes/MapToAttributeTests.cs b/CoreTests/Attributes/MapToAttributeTests.cs
new file mode 100644
index 0000000..ba5f5dc
--- /dev/null
+++ b/CoreTests/Attributes/MapToAttributeTests.cs
@@ -0,0 +1,34 @@
+using FluentAssertions;
+using FluentAssertions.Execution;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Supabase.Core.Attributes;
+
+namespace CoreTests.Attributes;
+
+///
+/// Covers : the mapping and optional formatter captured at construction,
+/// with the formatter defaulting to null when omitted.
+///
+[TestClass]
+[TestCategory("Unit")]
+public class MapToAttributeTests
+{
+ [TestMethod]
+ public void Constructor_ShouldCaptureTheMapping() =>
+ new MapToAttribute("refresh_token").Mapping.Should().Be("refresh_token");
+
+ [TestMethod]
+ public void Constructor_ShouldDefaultFormatterToNull() =>
+ new MapToAttribute("refresh_token").Formatter.Should().BeNull();
+
+ [TestMethod]
+ public void Constructor_ShouldCaptureTheFormatter_GivenOne()
+ {
+ var attribute = new MapToAttribute("created_at", "O");
+ using (new AssertionScope())
+ {
+ attribute.Mapping.Should().Be("created_at");
+ attribute.Formatter.Should().Be("O");
+ }
+ }
+}
diff --git a/CoreTests/CoreTests.csproj b/CoreTests/CoreTests.csproj
index 3e6e369..4d629ac 100644
--- a/CoreTests/CoreTests.csproj
+++ b/CoreTests/CoreTests.csproj
@@ -4,6 +4,9 @@
net8.0
false
enable
+ true
+ true
+ latest
@@ -12,6 +15,7 @@
all
+
diff --git a/CoreTests/Diagnostics/ActivityExtensionsTests.cs b/CoreTests/Diagnostics/ActivityExtensionsTests.cs
new file mode 100644
index 0000000..4541003
--- /dev/null
+++ b/CoreTests/Diagnostics/ActivityExtensionsTests.cs
@@ -0,0 +1,132 @@
+using System;
+using System.Diagnostics;
+using FluentAssertions;
+using FluentAssertions.Execution;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Supabase.Core.Diagnostics;
+
+namespace CoreTests.Diagnostics;
+
+///
+/// Covers : the OpenTelemetry HTTP tags it writes onto a listened
+/// , the error status it raises for failing responses and exceptions, and its
+/// no-op contract when nothing is listening (a null activity). URLs must reach tags only through
+/// .
+///
+[TestClass]
+[TestCategory("Unit")]
+public class ActivityExtensionsTests
+{
+ private readonly ActivitySource source = new("CoreTests.ActivityExtensions");
+ private readonly ActivityListener listener;
+
+ public ActivityExtensionsTests()
+ {
+ listener = new ActivityListener
+ {
+ ShouldListenTo = candidate => candidate == source,
+ Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded
+ };
+ ActivitySource.AddActivityListener(listener);
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ listener.Dispose();
+ source.Dispose();
+ }
+
+ private Activity StartActivity() => source.StartActivity("operation")!;
+
+ [TestMethod]
+ public void SetHttpRequestTags_ShouldTagMethodHostAndSanitizedUrl()
+ {
+ using var activity = StartActivity();
+ activity.SetHttpRequestTags("POST", new Uri("https://project.supabase.co/auth/v1/token?apikey=secret"));
+ using (new AssertionScope())
+ {
+ activity.GetTagItem("http.request.method").Should().Be("POST");
+ activity.GetTagItem("server.address").Should().Be("project.supabase.co");
+ activity.GetTagItem("url.full").Should().Be("https://project.supabase.co/auth/v1/token",
+ "the query string may carry an api key and must never be tagged");
+ }
+ }
+
+ [TestMethod]
+ public void SetHttpRequestTags_ShouldTagPort_GivenNonDefaultPort()
+ {
+ using var activity = StartActivity();
+ activity.SetHttpRequestTags("GET", new Uri("http://127.0.0.1:54321/auth/v1/token"));
+ activity.GetTagItem("server.port").Should().Be(54321);
+ }
+
+ [TestMethod]
+ public void SetHttpRequestTags_ShouldNotTagPort_GivenDefaultPort()
+ {
+ using var activity = StartActivity();
+ activity.SetHttpRequestTags("GET", new Uri("https://project.supabase.co/auth/v1/token"));
+ activity.GetTagItem("server.port").Should().BeNull();
+ }
+
+ [TestMethod]
+ public void SetHttpRequestTags_ShouldReturnNull_GivenNullActivity() =>
+ ((Activity?) null).SetHttpRequestTags("GET", new Uri("https://project.supabase.co")).Should().BeNull();
+
+ [TestMethod]
+ public void SetHttpResponseTags_ShouldTagStatusCode()
+ {
+ using var activity = StartActivity();
+ activity.SetHttpResponseTags(200);
+ using (new AssertionScope())
+ {
+ activity.GetTagItem("http.response.status_code").Should().Be(200);
+ activity.Status.Should().Be(ActivityStatusCode.Unset);
+ }
+ }
+
+ [TestMethod]
+ public void SetHttpResponseTags_ShouldMarkError_GivenStatusAtErrorBoundary()
+ {
+ using var activity = StartActivity();
+ activity.SetHttpResponseTags(400);
+ using (new AssertionScope())
+ {
+ activity.GetTagItem("error.type").Should().Be("400");
+ activity.Status.Should().Be(ActivityStatusCode.Error);
+ }
+ }
+
+ [TestMethod]
+ public void SetHttpResponseTags_ShouldNotMarkError_GivenLastSuccessStatus()
+ {
+ using var activity = StartActivity();
+ activity.SetHttpResponseTags(399);
+ using (new AssertionScope())
+ {
+ activity.GetTagItem("error.type").Should().BeNull();
+ activity.Status.Should().Be(ActivityStatusCode.Unset);
+ }
+ }
+
+ [TestMethod]
+ public void SetHttpResponseTags_ShouldReturnNull_GivenNullActivity() =>
+ ((Activity?) null).SetHttpResponseTags(500).Should().BeNull();
+
+ [TestMethod]
+ public void SetFailure_ShouldTagExceptionTypeAndErrorStatus()
+ {
+ using var activity = StartActivity();
+ activity.SetFailure(new InvalidOperationException("boom"));
+ using (new AssertionScope())
+ {
+ activity.GetTagItem("error.type").Should().Be(typeof(InvalidOperationException).FullName);
+ activity.Status.Should().Be(ActivityStatusCode.Error);
+ activity.StatusDescription.Should().Be("boom");
+ }
+ }
+
+ [TestMethod]
+ public void SetFailure_ShouldReturnNull_GivenNullActivity() =>
+ ((Activity?) null).SetFailure(new InvalidOperationException()).Should().BeNull();
+}
diff --git a/CoreTests/Diagnostics/InstrumentationTests.cs b/CoreTests/Diagnostics/InstrumentationTests.cs
index c0d055e..e2f19a7 100644
--- a/CoreTests/Diagnostics/InstrumentationTests.cs
+++ b/CoreTests/Diagnostics/InstrumentationTests.cs
@@ -1,34 +1,73 @@
+using System;
+using CoreTests.TestDoubles;
using FluentAssertions;
+using FluentAssertions.Execution;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Supabase.Core.Diagnostics;
-namespace CoreTests.Diagnostics
+namespace CoreTests.Diagnostics;
+
+///
+/// Covers : the version resolved from an assembly (informational version
+/// preferred, build metadata stripped, assembly version as fallback) and the naming/versioning of the
+/// and
+/// it produces.
+///
+[TestClass]
+[TestCategory("Unit")]
+public class InstrumentationTests
{
- [TestClass]
- public class InstrumentationTests
- {
- [TestMethod]
- public void GetVersion_ReturnsTheAssemblyVersion() =>
- Instrumentation.GetVersion(typeof(Instrumentation).Assembly).Should().MatchRegex(@"^\d+\.\d+\.\d+");
+ [TestMethod]
+ public void GetVersion_ShouldReturnInformationalVersion() =>
+ Instrumentation.GetVersion(FakeAssembly.Named("Lib", informationalVersion: "1.2.3"))
+ .Should().Be("1.2.3");
+
+ [TestMethod]
+ public void GetVersion_ShouldStripBuildMetadata_GivenInformationalVersionWithMetadata() =>
+ Instrumentation.GetVersion(FakeAssembly.Named("Lib", informationalVersion: "1.2.3+abc1234"))
+ .Should().Be("1.2.3");
+
+ [TestMethod]
+ public void GetVersion_ShouldReturnVerbatim_GivenMetadataDelimiterAtStart() =>
+ Instrumentation.GetVersion(FakeAssembly.Named("Lib", informationalVersion: "+onlymetadata"))
+ .Should().Be("+onlymetadata", "the '+' at index 0 is not a metadata separator to strip");
+
+ [TestMethod]
+ public void GetVersion_ShouldFallBackToAssemblyVersion_GivenNoInformationalVersion() =>
+ Instrumentation.GetVersion(FakeAssembly.Named("Lib", version: new Version(2, 5, 9)))
+ .Should().Be("2.5.9");
- [TestMethod]
- public void GetVersion_StripsBuildMetadata() =>
- Instrumentation.GetVersion(typeof(Instrumentation).Assembly).Should().NotContain("+");
+ [TestMethod]
+ public void GetVersion_ShouldFallBackToAssemblyVersion_GivenEmptyInformationalVersion() =>
+ Instrumentation.GetVersion(FakeAssembly.Named("Lib", informationalVersion: "", version: new Version(2, 5, 9)))
+ .Should().Be("2.5.9");
- [TestMethod]
- public void CreateActivitySource_UsesTheGivenNameAndAssemblyVersion()
+ [TestMethod]
+ public void GetVersion_ShouldReturnZeroDefault_GivenNoVersionInformationAtAll() =>
+ Instrumentation.GetVersion(FakeAssembly.Named("Lib"))
+ .Should().Be("0.0.0");
+
+ [TestMethod]
+ public void CreateActivitySource_ShouldUseTheGivenNameAndAssemblyVersion()
+ {
+ var assembly = FakeAssembly.Named("Lib", informationalVersion: "3.1.4");
+ using var source = Instrumentation.CreateActivitySource(assembly, "Supabase.Test");
+ using (new AssertionScope())
{
- using var source = Instrumentation.CreateActivitySource(typeof(Instrumentation).Assembly, "Supabase.Test");
source.Name.Should().Be("Supabase.Test");
- source.Version.Should().Be(Instrumentation.GetVersion(typeof(Instrumentation).Assembly));
+ source.Version.Should().Be("3.1.4");
}
+ }
- [TestMethod]
- public void CreateMeter_UsesTheGivenNameAndAssemblyVersion()
+ [TestMethod]
+ public void CreateMeter_ShouldUseTheGivenNameAndAssemblyVersion()
+ {
+ var assembly = FakeAssembly.Named("Lib", informationalVersion: "3.1.4");
+ using var meter = Instrumentation.CreateMeter(assembly, "Supabase.Test");
+ using (new AssertionScope())
{
- using var meter = Instrumentation.CreateMeter(typeof(Instrumentation).Assembly, "Supabase.Test");
meter.Name.Should().Be("Supabase.Test");
- meter.Version.Should().Be(Instrumentation.GetVersion(typeof(Instrumentation).Assembly));
+ meter.Version.Should().Be("3.1.4");
}
}
}
diff --git a/CoreTests/Diagnostics/UrlSanitizerTests.cs b/CoreTests/Diagnostics/UrlSanitizerTests.cs
index 3a6b2ed..c243a7b 100644
--- a/CoreTests/Diagnostics/UrlSanitizerTests.cs
+++ b/CoreTests/Diagnostics/UrlSanitizerTests.cs
@@ -3,39 +3,65 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Supabase.Core.Diagnostics;
-namespace CoreTests.Diagnostics
+namespace CoreTests.Diagnostics;
+
+///
+/// Covers , which reduces a URL to scheme://host[:port]/path so no
+/// secret carried in user info, the query string, or the fragment can reach telemetry or logs. Both
+/// the overload and the string overload (including its unparseable/relative
+/// fallback) are exercised.
+///
+[TestClass]
+[TestCategory("Unit")]
+public class UrlSanitizerTests
{
- [TestClass]
- public class UrlSanitizerTests
- {
- [TestMethod]
- public void Sanitize_StripsTheQueryString() =>
- UrlSanitizer.Sanitize(new Uri("https://project.supabase.co/auth/v1/token?grant_type=refresh_token&apikey=secret"))
- .Should().Be("https://project.supabase.co/auth/v1/token");
-
- [TestMethod]
- public void Sanitize_StripsTheFragment() =>
- UrlSanitizer.Sanitize(new Uri("https://project.supabase.co/callback#access_token=secret-jwt"))
- .Should().Be("https://project.supabase.co/callback");
-
- [TestMethod]
- public void Sanitize_StripsUserInfo() =>
- UrlSanitizer.Sanitize(new Uri("https://user:password@project.supabase.co/auth/v1/settings"))
- .Should().Be("https://project.supabase.co/auth/v1/settings");
-
- [TestMethod]
- public void Sanitize_KeepsNonDefaultPorts() =>
- UrlSanitizer.Sanitize(new Uri("http://127.0.0.1:54321/auth/v1/token?grant_type=password"))
- .Should().Be("http://127.0.0.1:54321/auth/v1/token");
-
- [TestMethod]
- public void Sanitize_OmitsDefaultPorts() =>
- UrlSanitizer.Sanitize(new Uri("https://project.supabase.co:443/auth/v1/settings"))
- .Should().Be("https://project.supabase.co/auth/v1/settings");
-
- [TestMethod]
- public void Sanitize_StringOverloadStripsQueryAndFragment() =>
- UrlSanitizer.Sanitize("https://project.supabase.co/auth/v1/verify?token=secret#fragment")
- .Should().Be("https://project.supabase.co/auth/v1/verify");
- }
+ [TestMethod]
+ public void Sanitize_ShouldStripTheQueryString() =>
+ UrlSanitizer.Sanitize(new Uri("https://project.supabase.co/auth/v1/token?grant_type=refresh_token&apikey=secret"))
+ .Should().Be("https://project.supabase.co/auth/v1/token");
+
+ [TestMethod]
+ public void Sanitize_ShouldStripTheFragment() =>
+ UrlSanitizer.Sanitize(new Uri("https://project.supabase.co/callback#access_token=secret-jwt"))
+ .Should().Be("https://project.supabase.co/callback");
+
+ [TestMethod]
+ public void Sanitize_ShouldStripUserInfo() =>
+ UrlSanitizer.Sanitize(new Uri("https://user:password@project.supabase.co/auth/v1/settings"))
+ .Should().Be("https://project.supabase.co/auth/v1/settings");
+
+ [TestMethod]
+ public void Sanitize_ShouldKeepNonDefaultPorts() =>
+ UrlSanitizer.Sanitize(new Uri("http://127.0.0.1:54321/auth/v1/token?grant_type=password"))
+ .Should().Be("http://127.0.0.1:54321/auth/v1/token");
+
+ [TestMethod]
+ public void Sanitize_ShouldOmitDefaultPorts() =>
+ UrlSanitizer.Sanitize(new Uri("https://project.supabase.co:443/auth/v1/settings"))
+ .Should().Be("https://project.supabase.co/auth/v1/settings");
+
+ [TestMethod]
+ public void Sanitize_ShouldStripAfterPath_GivenRelativeUri() =>
+ UrlSanitizer.Sanitize(new Uri("/auth/v1/verify?token=secret#fragment", UriKind.Relative))
+ .Should().Be("/auth/v1/verify");
+
+ [TestMethod]
+ public void Sanitize_ShouldStripQueryAndFragment_GivenAbsoluteUrlString() =>
+ UrlSanitizer.Sanitize("https://project.supabase.co/auth/v1/verify?token=secret#fragment")
+ .Should().Be("https://project.supabase.co/auth/v1/verify");
+
+ [TestMethod]
+ public void Sanitize_ShouldStripQueryAndFragment_GivenUnparseableUrlString() =>
+ UrlSanitizer.Sanitize("not a url?token=secret#fragment")
+ .Should().Be("not a url");
+
+ [TestMethod]
+ public void Sanitize_ShouldReturnInputUnchanged_GivenStringWithoutQueryOrFragment() =>
+ UrlSanitizer.Sanitize("not-a-url")
+ .Should().Be("not-a-url");
+
+ [TestMethod]
+ public void Sanitize_ShouldReturnEmpty_GivenStringThatIsOnlyAQueryString() =>
+ UrlSanitizer.Sanitize("?token=secret")
+ .Should().BeEmpty("a delimiter at index 0 leaves no path to keep");
}
diff --git a/CoreTests/Extensions/DictionaryExtensionsTests.cs b/CoreTests/Extensions/DictionaryExtensionsTests.cs
new file mode 100644
index 0000000..36537ea
--- /dev/null
+++ b/CoreTests/Extensions/DictionaryExtensionsTests.cs
@@ -0,0 +1,35 @@
+using System.Collections.Generic;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Supabase.Core.Extensions;
+
+namespace CoreTests.Extensions;
+
+///
+/// Covers : a new dictionary combining every
+/// source, with later sources overwriting earlier keys, leaving the originals untouched.
+///
+[TestClass]
+[TestCategory("Unit")]
+public class DictionaryExtensionsTests
+{
+ [TestMethod]
+ public void MergeLeft_ShouldCombineEntriesFromEverySource() =>
+ new Dictionary { ["x"] = 1 }
+ .MergeLeft(new Dictionary { ["y"] = 2 })
+ .Should().Equal(new Dictionary { ["x"] = 1, ["y"] = 2 });
+
+ [TestMethod]
+ public void MergeLeft_ShouldPreferLaterSources_GivenOverlappingKeys() =>
+ new Dictionary { ["k"] = 1 }
+ .MergeLeft(new Dictionary { ["k"] = 2 })
+ .Should().Contain("k", 2);
+
+ [TestMethod]
+ public void MergeLeft_ShouldLeaveTheSourceUnchanged()
+ {
+ var source = new Dictionary { ["x"] = 1 };
+ source.MergeLeft(new Dictionary { ["y"] = 2 });
+ source.Should().Equal(new Dictionary { ["x"] = 1 });
+ }
+}
diff --git a/CoreTests/HelpersTests.cs b/CoreTests/HelpersTests.cs
new file mode 100644
index 0000000..7345e45
--- /dev/null
+++ b/CoreTests/HelpersTests.cs
@@ -0,0 +1,54 @@
+using System;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Supabase.Core;
+using Supabase.Core.Attributes;
+
+namespace CoreTests;
+
+///
+/// Covers : reflective reads of a property value, a type-level custom attribute
+/// (from an instance or a type), and the mapped onto an enum member.
+///
+[TestClass]
+[TestCategory("Unit")]
+public class HelpersTests
+{
+ [TestMethod]
+ public void GetPropertyValue_ShouldReturnThePropertyValue() =>
+ Helpers.GetPropertyValue(new Decorated(), nameof(Decorated.Name)).Should().Be("value");
+
+ [TestMethod]
+ public void GetCustomAttribute_ShouldReturnTheAttribute_GivenAnInstance() =>
+ Helpers.GetCustomAttribute(new Decorated()).Should().NotBeNull();
+
+ [TestMethod]
+ public void GetCustomAttribute_ShouldReturnTheAttribute_GivenAType() =>
+ Helpers.GetCustomAttribute(typeof(Decorated)).Should().NotBeNull();
+
+ [TestMethod]
+ public void GetMappedToAttr_ShouldReturnTheMapping_GivenAMappedEnumMember() =>
+ Helpers.GetMappedToAttr(Grant.RefreshToken).Mapping.Should().Be("refresh_token");
+
+ [TestMethod]
+ public void GetMappedToAttr_ShouldReturnNull_GivenAnUnmappedEnumMember() =>
+ Helpers.GetMappedToAttr(Grant.Password).Should().BeNull();
+
+ [AttributeUsage(AttributeTargets.Class)]
+ private sealed class DescriptorAttribute : Attribute
+ {
+ }
+
+ [Descriptor]
+ private sealed class Decorated
+ {
+ public string Name { get; } = "value";
+ }
+
+ private enum Grant
+ {
+ [MapTo("refresh_token")]
+ RefreshToken,
+ Password
+ }
+}
diff --git a/CoreTests/TestConventions.cs b/CoreTests/TestConventions.cs
new file mode 100644
index 0000000..5e67db2
--- /dev/null
+++ b/CoreTests/TestConventions.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Linq;
+using System.Reflection;
+using System.Text.RegularExpressions;
+using FluentAssertions;
+using FluentAssertions.Execution;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace CoreTests;
+
+///
+/// Mechanized guardrails for the suite itself: these fail the build when a test class or method drifts
+/// from the documented conventions, so the rules are enforced deterministically instead of relying on
+/// review.
+///
+[TestClass]
+[TestCategory("Unit")]
+public class TestConventions
+{
+ private static readonly Regex NamePattern =
+ new("^[A-Za-z][A-Za-z0-9]*_Should[A-Za-z0-9]+(_Given[A-Za-z0-9]+)?$", RegexOptions.Compiled);
+
+ private static readonly Type[] TestClasses = typeof(TestConventions).Assembly.GetTypes()
+ .Where(type => type.GetCustomAttribute() != null)
+ .ToArray();
+
+ [TestMethod]
+ public void TestClass_ShouldDeclareTestCategory()
+ {
+ using (new AssertionScope())
+ {
+ foreach (var type in TestClasses)
+ {
+ type.GetCustomAttributes().Should().ContainSingle(
+ $"{type.Name} must carry exactly one [TestCategory] tier (Unit/Contract/E2E)");
+ }
+ }
+ }
+
+ [TestMethod]
+ public void TestMethod_ShouldFollowNaming()
+ {
+ using (new AssertionScope())
+ {
+ var testMethods = TestClasses
+ .SelectMany(type => type.GetMethods())
+ .Where(method => method.GetCustomAttribute() != null);
+ foreach (var method in testMethods)
+ {
+ method.Name.Should().MatchRegex(NamePattern,
+ $"{method.DeclaringType!.Name}.{method.Name} must read Sut_ShouldConsequence[_GivenScenario]");
+ }
+ }
+ }
+}
diff --git a/CoreTests/TestDoubles/FakeAssembly.cs b/CoreTests/TestDoubles/FakeAssembly.cs
new file mode 100644
index 0000000..8ab907b
--- /dev/null
+++ b/CoreTests/TestDoubles/FakeAssembly.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Reflection;
+using NSubstitute;
+
+namespace CoreTests.TestDoubles;
+
+///
+/// Builds substitutes with a controlled name, version, and informational
+/// version, so the assembly-probing helpers in Supabase.Core can be driven down every branch
+/// without depending on which assemblies happen to be loaded in the test host.
+///
+internal static class FakeAssembly
+{
+ internal static Assembly Named(string name, string? informationalVersion = null, Version? version = null)
+ {
+ var assembly = Substitute.For();
+ assembly.GetName().Returns(new AssemblyName(name) { Version = version });
+ var attributes = informationalVersion is null
+ ? Array.Empty()
+ : new Attribute[] { new AssemblyInformationalVersionAttribute(informationalVersion) };
+ assembly.GetCustomAttributes(typeof(AssemblyInformationalVersionAttribute), Arg.Any())
+ .Returns(attributes);
+ return assembly;
+ }
+}
diff --git a/CoreTests/TestDoubles/UnityApiCompatibilityVersionAttribute.cs b/CoreTests/TestDoubles/UnityApiCompatibilityVersionAttribute.cs
new file mode 100644
index 0000000..3eccdee
--- /dev/null
+++ b/CoreTests/TestDoubles/UnityApiCompatibilityVersionAttribute.cs
@@ -0,0 +1,18 @@
+using System;
+using CoreTests.TestDoubles;
+
+// Util.GetUnityVersion scans loaded assemblies for a custom attribute whose type name is exactly
+// "UnityAPICompatibilityVersionAttribute" (Unity stamps this on its player assemblies). Declaring the
+// same shape here and applying it to the test assembly lets the unity branch resolve a real version
+// hermetically, without a Unity install.
+[assembly: UnityAPICompatibilityVersion("2022.3.5f1")]
+
+namespace CoreTests.TestDoubles;
+
+[AttributeUsage(AttributeTargets.Assembly)]
+internal sealed class UnityAPICompatibilityVersionAttribute : Attribute
+{
+ public UnityAPICompatibilityVersionAttribute(string version) => Version = version;
+
+ public string Version { get; }
+}
diff --git a/CoreTests/UtilTests.cs b/CoreTests/UtilTests.cs
index 73c4bae..b992f51 100644
--- a/CoreTests/UtilTests.cs
+++ b/CoreTests/UtilTests.cs
@@ -1,55 +1,116 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using CoreTests.TestDoubles;
using FluentAssertions;
+using FluentAssertions.Execution;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Supabase.Core;
-namespace CoreTests
+namespace CoreTests;
+
+///
+/// Covers , the X-Client-Info header. The public
+/// entry point is asserted against the real host; the platform and framework branches are driven
+/// through the internal seam that takes the ambient OS/assembly probes as parameters, so every OS and
+/// framework arm is reachable without running on that OS or loading that framework.
+///
+[TestClass]
+[TestCategory("Unit")]
+public class UtilTests
{
- [TestClass]
- public class UtilTests
- {
- private static string Result => Util.GetAssemblyVersion(typeof(Util));
+ private static readonly Func NoPlatform = _ => false;
+ private static readonly IReadOnlyCollection NoFrameworks = Array.Empty();
- [TestMethod]
- public void GetAssemblyVersion_BaseFormatIsCorrect()
- {
- Result.Should().MatchRegex(@"^[\w.]+-csharp/[\w.+\-]+");
- }
+ [TestMethod]
+ public void GetAssemblyVersion_ShouldStartWithClientNameAndVersion() =>
+ Util.GetAssemblyVersion(typeof(Util)).Should().MatchRegex(@"^supabase\.core-csharp/\S+");
- [TestMethod]
- public void GetAssemblyVersion_ShouldContainRuntime()
- {
- Result.Should().Contain("; runtime=dotnet");
- }
+ [TestMethod]
+ public void GetAssemblyVersion_ShouldReportDotnetRuntime() =>
+ Util.GetAssemblyVersion(typeof(Util)).Should().Contain("; runtime=dotnet");
- [TestMethod]
- public void GetAssemblyVersion_ShouldContainRuntimeVersion()
- {
- Result.Should().MatchRegex(@"; runtime-version=\S+");
- }
+ [TestMethod]
+ public void GetAssemblyVersion_ShouldReportRuntimeVersion() =>
+ Util.GetAssemblyVersion(typeof(Util)).Should().MatchRegex(@"; runtime-version=\S+");
- [TestMethod]
- public void GetAssemblyVersion_ContainsKnownPlatform()
- {
- Result.Should().MatchRegex(@"; platform=(Windows|Linux|macOS|browser)");
- }
+ [TestMethod]
+ public void GetAssemblyVersion_ShouldReportPlatformVersion() =>
+ Util.GetAssemblyVersion(typeof(Util)).Should().MatchRegex(@"; platform-version=\S+");
- [TestMethod]
- public void GetAssemblyVersion_IncludesPlatformVersion()
+ [TestMethod]
+ public void GetAssemblyVersion_ShouldNotEmitEmptyValues()
+ {
+ var result = Util.GetAssemblyVersion(typeof(Util));
+ using (new AssertionScope())
{
- Result.Should().MatchRegex(@"; platform-version=\S+");
+ result.Should().NotMatchRegex(@"=\s*;", "every metadata key must carry a value");
+ result.Should().NotMatchRegex(@"=$", "the header must not end on an empty value");
}
+ }
- [TestMethod]
- public void GetAssemblyVersion_HasNoEmptyValues()
- {
- Result.Should().NotMatchRegex(@"=\s*;");
- Result.Should().NotMatchRegex(@"=$");
- }
+ [TestMethod]
+ public void GetAssemblyVersion_ShouldReportBrowserPlatform_GivenBrowserOsDescription() =>
+ HeaderFor("Browser", NoPlatform).Should().Contain("; platform=browser;");
- [TestMethod]
- public void GetAssemblyVersion_IncludeUnknownFrameworkForPlainDotnet()
- {
- Result.Should().Contain("; framework=unknown");
- }
- }
+ [TestMethod]
+ public void GetAssemblyVersion_ShouldReportWindowsPlatform_GivenWindows() =>
+ HeaderFor("any", OnlyPlatform(OSPlatform.Windows)).Should().Contain("; platform=Windows;");
+
+ [TestMethod]
+ public void GetAssemblyVersion_ShouldReportMacOSPlatform_GivenOSX() =>
+ HeaderFor("any", OnlyPlatform(OSPlatform.OSX)).Should().Contain("; platform=macOS;");
+
+ [TestMethod]
+ public void GetAssemblyVersion_ShouldReportIOSPlatform_GivenIOS() =>
+ HeaderFor("any", OnlyPlatform(OSPlatform.Create("iOS"))).Should().Contain("; platform=iOS;");
+
+ [TestMethod]
+ public void GetAssemblyVersion_ShouldReportLinuxPlatform_GivenLinux() =>
+ HeaderFor("any", OnlyPlatform(OSPlatform.Linux)).Should().Contain("; platform=Linux;");
+
+ [TestMethod]
+ public void GetAssemblyVersion_ShouldReportAndroidPlatform_GivenAndroid() =>
+ HeaderFor("any", OnlyPlatform(OSPlatform.Create("Android"))).Should().Contain("; platform=Android;");
+
+ [TestMethod]
+ public void GetAssemblyVersion_ShouldFallBackToOsDescription_GivenUnrecognizedPlatform() =>
+ HeaderFor("Solaris 11", NoPlatform).Should().Contain("; platform=Solaris 11;");
+
+ [TestMethod]
+ public void GetAssemblyVersion_ShouldReportUnknownFramework_GivenNoKnownFrameworkAssembly() =>
+ HeaderWith(NoFrameworks).Should().Contain("; framework=unknown");
+
+ [TestMethod]
+ public void GetAssemblyVersion_ShouldReportMauiFramework_GivenMauiAssembly() =>
+ HeaderWith(new[] { FakeAssembly.Named("Microsoft.Maui") }).Should().Contain("; framework=maui");
+
+ [TestMethod]
+ public void GetAssemblyVersion_ShouldReportBlazorFramework_GivenBlazorAssembly() =>
+ HeaderWith(new[] { FakeAssembly.Named("Microsoft.AspNetCore.Components") }).Should().Contain("; framework=blazor");
+
+ [TestMethod]
+ public void GetAssemblyVersion_ShouldPreferMauiOverBlazor_GivenBothAssemblies() =>
+ HeaderWith(new[] { FakeAssembly.Named("Microsoft.AspNetCore.Components"), FakeAssembly.Named("Microsoft.Maui") })
+ .Should().Contain("; framework=maui", "MAUI wins over Blazor in hybrid apps where both are present");
+
+ [TestMethod]
+ public void GetAssemblyVersion_ShouldReportUnityFrameworkWithVersion_GivenUnityAssembly() =>
+ HeaderWith(new[] { FakeAssembly.Named("UnityEngine.CoreModule"), typeof(UtilTests).Assembly })
+ .Should().Contain("; framework=unity; framework-version=2022.3.5f1");
+
+ [TestMethod]
+ public void GetAssemblyVersion_ShouldReportUnityFrameworkWithoutVersion_GivenNoUnityVersionAttribute() =>
+ HeaderWith(new[] { FakeAssembly.Named("UnityEngine.CoreModule") })
+ .Should().Contain("; framework=unity").And.NotContain("framework-version",
+ "an absent Unity version attribute must resolve to no version, not throw");
+
+ private static string HeaderFor(string osDescription, Func isOsPlatform) =>
+ Util.GetAssemblyVersion(typeof(Util), osDescription, isOsPlatform, NoFrameworks);
+
+ private static string HeaderWith(IReadOnlyCollection loadedAssemblies) =>
+ Util.GetAssemblyVersion(typeof(Util), "any", NoPlatform, loadedAssemblies);
+
+ private static Func OnlyPlatform(OSPlatform platform) => p => p == platform;
}