Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions Core/Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,8 @@
<ItemGroup>
<PackageReference Include="System.Diagnostics.DiagnosticSource" Version="8.0.1" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="CoreTests" />
</ItemGroup>
</Project>
41 changes: 25 additions & 16 deletions Core/Util.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
Expand All @@ -21,13 +22,21 @@ public static class Util
/// <param name="clientType">A type belonging to the client assembly, used to resolve the assembly name and version.</param>
/// <returns>A structured header value identifying the client library and its host environment.</returns>
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<OSPlatform, bool> isOsPlatform, IReadOnlyCollection<Assembly> 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<OSPlatform, bool> isOsPlatform, IReadOnlyCollection<Assembly> loadedAssemblies) =>
string.Concat(GetPlatformInfo(osDescription, isOsPlatform).ToString(), GetRuntimeInfo().ToString(), GetFrameworkInfo(loadedAssemblies).ToString());

private sealed class MetadataEntry
{
Expand All @@ -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<OSPlatform, bool> 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<OSPlatform, bool> 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<Assembly> 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");
Expand All @@ -90,9 +99,9 @@ private static CustomAttributeData[] SafeGetCustomAttributesData(Assembly assemb
catch { return Array.Empty<CustomAttributeData>(); }
}

private static string? GetUnityVersion()
private static string? GetUnityVersion(IReadOnlyCollection<Assembly> 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;
Expand Down
34 changes: 34 additions & 0 deletions CoreTests/Attributes/MapToAttributeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using FluentAssertions;
using FluentAssertions.Execution;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Supabase.Core.Attributes;

namespace CoreTests.Attributes;

/// <summary>
/// Covers <see cref="MapToAttribute"/>: the mapping and optional formatter captured at construction,
/// with the formatter defaulting to null when omitted.
/// </summary>
[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");
}
}
}
4 changes: 4 additions & 0 deletions CoreTests/CoreTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AnalysisLevel>latest</AnalysisLevel>
</PropertyGroup>

<ItemGroup>
Expand All @@ -12,6 +15,7 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="[7.2.2]" />
<PackageReference Include="NSubstitute" Version="[6.0.0]" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="MSTest.TestAdapter" Version="4.3.2" />
<PackageReference Include="MSTest.TestFramework" Version="4.3.2" />
Expand Down
132 changes: 132 additions & 0 deletions CoreTests/Diagnostics/ActivityExtensionsTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Covers <see cref="ActivityExtensions"/>: the OpenTelemetry HTTP tags it writes onto a listened
/// <see cref="Activity"/>, 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
/// <see cref="UrlSanitizer"/>.
/// </summary>
[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<ActivityContext> _) => 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();
}
Loading
Loading