From 3aa3176b18cece845ba8f14ed448eab81fd24287 Mon Sep 17 00:00:00 2001 From: tr00d Date: Mon, 27 Jul 2026 08:15:54 +0200 Subject: [PATCH] refactor: test-suite rework --- .editorconfig | 86 +++++++++++ Core/Core.csproj | 4 + Core/Util.cs | 41 +++-- CoreTests/Attributes/MapToAttributeTests.cs | 34 +++++ CoreTests/CoreTests.csproj | 4 + .../Diagnostics/ActivityExtensionsTests.cs | 132 ++++++++++++++++ CoreTests/Diagnostics/InstrumentationTests.cs | 75 +++++++--- CoreTests/Diagnostics/UrlSanitizerTests.cs | 94 +++++++----- .../Extensions/DictionaryExtensionsTests.cs | 35 +++++ CoreTests/HelpersTests.cs | 54 +++++++ CoreTests/TestConventions.cs | 55 +++++++ CoreTests/TestDoubles/FakeAssembly.cs | 25 ++++ .../UnityApiCompatibilityVersionAttribute.cs | 18 +++ CoreTests/UtilTests.cs | 141 +++++++++++++----- 14 files changed, 690 insertions(+), 108 deletions(-) create mode 100644 .editorconfig create mode 100644 CoreTests/Attributes/MapToAttributeTests.cs create mode 100644 CoreTests/Diagnostics/ActivityExtensionsTests.cs create mode 100644 CoreTests/Extensions/DictionaryExtensionsTests.cs create mode 100644 CoreTests/HelpersTests.cs create mode 100644 CoreTests/TestConventions.cs create mode 100644 CoreTests/TestDoubles/FakeAssembly.cs create mode 100644 CoreTests/TestDoubles/UnityApiCompatibilityVersionAttribute.cs 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; }