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/.gitignore b/.gitignore
index e57ded3..9f728e6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -407,5 +407,6 @@ ASALocalRun/
# Supabase
.supabase
.temp
+.branches
launchSettings.json
\ No newline at end of file
diff --git a/FunctionsTests/ClientContractTests.cs b/FunctionsTests/ClientContractTests.cs
new file mode 100644
index 0000000..011b633
--- /dev/null
+++ b/FunctionsTests/ClientContractTests.cs
@@ -0,0 +1,209 @@
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Threading.Tasks;
+using FluentAssertions;
+using FluentAssertions.Execution;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Supabase.Functions;
+using Supabase.Functions.Exceptions;
+using WireMock;
+using WireMock.RequestBuilders;
+using WireMock.ResponseBuilders;
+using WireMock.Server;
+using static Supabase.Functions.Client;
+
+namespace FunctionsTests
+{
+ ///
+ /// Contract tests for the HTTP request the client builds (path, method, auth, region and dynamic
+ /// headers, JSON body) and for how it interprets the response (success payload shapes, and the
+ /// failures โ error status codes and relay errors โ that surface as a
+ /// carrying its status, content, and detected reason).
+ ///
+ [TestClass]
+ [TestCategory("Contract")]
+ public class ClientContractTests
+ {
+ private const string FunctionName = "hello";
+
+ private WireMockServer server = null!;
+ private Client client = null!;
+
+ [TestInitialize]
+ public void TestInitialize()
+ {
+ this.server = WireMockServer.Start();
+ this.client = new Client($"{this.server.Url}/functions/v1");
+ }
+
+ [TestCleanup]
+ public void TestCleanup() => this.server.Stop();
+
+ [TestMethod]
+ public async Task Invoke_ShouldReturnResponseBody()
+ {
+ this.RespondWith(200, "{\"message\":\"Hello supabase!\"}");
+ var result = await this.client.Invoke(FunctionName);
+ result.Should().Be("{\"message\":\"Hello supabase!\"}");
+ }
+
+ [TestMethod]
+ public async Task Invoke_ShouldDeserializeResponse_GivenTypedInvoke()
+ {
+ this.RespondWith(200, "{\"message\":\"Hello supabase!\"}");
+ var result = await this.client.Invoke>(FunctionName);
+ result.Should().Contain("message", "Hello supabase!");
+ }
+
+ [TestMethod]
+ public async Task RawInvoke_ShouldReturnReadableContent()
+ {
+ this.RespondWith(200, "raw-payload");
+ var content = await this.client.RawInvoke(FunctionName);
+ (await content.ReadAsStringAsync()).Should().Be("raw-payload");
+ }
+
+ [TestMethod]
+ public async Task Invoke_ShouldPostToTheFunctionPath()
+ {
+ this.RespondWith(200, "ok");
+ await this.client.Invoke(FunctionName);
+ var request = this.SingleRequest();
+ using (new AssertionScope())
+ {
+ request.Method.Should().Be("POST");
+ request.Path.Should().Be($"/functions/v1/{FunctionName}");
+ }
+ }
+
+ [TestMethod]
+ public async Task Invoke_ShouldUseTheGivenHttpMethod()
+ {
+ this.RespondWith(200, "ok");
+ await this.client.Invoke(FunctionName, options: new InvokeFunctionOptions { HttpMethod = HttpMethod.Get });
+ this.SingleRequest().Method.Should().Be("GET");
+ }
+
+ [TestMethod]
+ public async Task Invoke_ShouldSerializeBodyAsJson()
+ {
+ this.RespondWith(200, "ok");
+ await this.client.Invoke(FunctionName, options: new InvokeFunctionOptions
+ {
+ Body = new Dictionary { { "name", "supabase" } }
+ });
+ this.SingleRequest().Body.Should().Be("{\"name\":\"supabase\"}");
+ }
+
+ [TestMethod]
+ public async Task Invoke_ShouldSendBearerToken_GivenToken()
+ {
+ this.RespondWith(200, "ok");
+ await this.client.Invoke(FunctionName, "the-token");
+ this.HeaderOf("Authorization").Should().Be("Bearer the-token");
+ }
+
+ [TestMethod]
+ public async Task Invoke_ShouldSendClientInfoHeader()
+ {
+ this.RespondWith(200, "ok");
+ await this.client.Invoke(FunctionName);
+ this.HeaderOf("X-Client-Info").Should().StartWith("supabase.functions-csharp/");
+ }
+
+ [TestMethod]
+ public async Task Invoke_ShouldSendRegionHeader_GivenOptionRegion()
+ {
+ this.RespondWith(200, "ok");
+ await this.client.Invoke(FunctionName, options: new InvokeFunctionOptions { FunctionRegion = FunctionRegion.UsEast1 });
+ this.HeaderOf("x-region").Should().Be("us-east-1");
+ }
+
+ [TestMethod]
+ public async Task Invoke_ShouldNotSendRegionHeader_GivenAnyRegion()
+ {
+ this.RespondWith(200, "ok");
+ await this.client.Invoke(FunctionName, options: new InvokeFunctionOptions { FunctionRegion = FunctionRegion.Any });
+ this.SingleRequest().Headers.Should().NotContainKey("x-region");
+ }
+
+ [TestMethod]
+ public async Task Invoke_ShouldSendConstructorRegion_GivenNoOptionRegion()
+ {
+ this.client = new Client($"{this.server.Url}/functions/v1", FunctionRegion.EuWest2);
+ this.RespondWith(200, "ok");
+ await this.client.Invoke(FunctionName);
+ this.HeaderOf("x-region").Should().Be("eu-west-2");
+ }
+
+ [TestMethod]
+ public async Task Invoke_ShouldPreferOptionRegionOverConstructorRegion()
+ {
+ this.client = new Client($"{this.server.Url}/functions/v1", FunctionRegion.EuWest2);
+ this.RespondWith(200, "ok");
+ await this.client.Invoke(FunctionName, options: new InvokeFunctionOptions { FunctionRegion = FunctionRegion.UsEast1 });
+ this.HeaderOf("x-region").Should().Be("us-east-1");
+ }
+
+ [TestMethod]
+ public async Task Invoke_ShouldMergeDynamicHeaders_GivenGetHeaders()
+ {
+ this.client.GetHeaders = () => new Dictionary { { "x-dynamic", "from-callback" } };
+ this.RespondWith(200, "ok");
+ await this.client.Invoke(FunctionName);
+ this.HeaderOf("x-dynamic").Should().Be("from-callback");
+ }
+
+ [TestMethod]
+ public async Task Invoke_ShouldPreferOptionHeadersOverDynamicHeaders()
+ {
+ this.client.GetHeaders = () => new Dictionary { { "x-shared", "from-callback" } };
+ this.RespondWith(200, "ok");
+ await this.client.Invoke(FunctionName, options: new InvokeFunctionOptions
+ {
+ Headers = new Dictionary { { "x-shared", "from-options" } }
+ });
+ this.HeaderOf("x-shared").Should().Be("from-options");
+ }
+
+ [TestMethod]
+ public async Task Invoke_ShouldThrowFunctionsException_GivenServerError()
+ {
+ this.RespondWith(500, "internal boom");
+ var act = () => this.client.Invoke(FunctionName);
+ var exception = (await act.Should().ThrowAsync()).Which;
+ using (new AssertionScope())
+ {
+ exception.StatusCode.Should().Be(500);
+ exception.Content.Should().Be("internal boom");
+ exception.Response.Should().NotBeNull();
+ exception.Reason.Should().Be(FailureHint.Reason.Internal);
+ }
+ }
+
+ [TestMethod]
+ public async Task Invoke_ShouldThrowNotAuthorized_GivenUnauthorized()
+ {
+ this.RespondWith(401, "no");
+ var act = () => this.client.Invoke(FunctionName);
+ (await act.Should().ThrowAsync()).Which.Reason.Should().Be(FailureHint.Reason.NotAuthorized);
+ }
+
+ [TestMethod]
+ public async Task Invoke_ShouldThrowFunctionsException_GivenRelayErrorOnSuccessStatus()
+ {
+ this.server.Given(Request.Create().WithPath($"/functions/v1/{FunctionName}").UsingAnyMethod())
+ .RespondWith(Response.Create().WithStatusCode(200).WithHeader("x-relay-error", "true").WithBody("relayed"));
+ var act = () => this.client.Invoke(FunctionName);
+ (await act.Should().ThrowAsync()).Which.Content.Should().Be("relayed");
+ }
+
+ private void RespondWith(int statusCode, string body) =>
+ this.server.Given(Request.Create().WithPath($"/functions/v1/{FunctionName}").UsingAnyMethod())
+ .RespondWith(Response.Create().WithStatusCode(statusCode).WithHeader("Content-Type", "application/json").WithBody(body));
+
+ private IRequestMessage SingleRequest() => this.server.LogEntries.Should().ContainSingle().Which.RequestMessage!;
+
+ private string HeaderOf(string name) => this.SingleRequest().Headers![name][0];
+ }
+}
diff --git a/FunctionsTests/ClientTests.cs b/FunctionsTests/ClientTests.cs
index 78bbbcd..aacecaf 100644
--- a/FunctionsTests/ClientTests.cs
+++ b/FunctionsTests/ClientTests.cs
@@ -1,102 +1,90 @@
-using System;
using System.Collections.Generic;
-using System.IdentityModel.Tokens.Jwt;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
+using FluentAssertions;
using Microsoft.IdentityModel.Tokens;
using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System.IdentityModel.Tokens.Jwt;
using Supabase.Functions;
using static Supabase.Functions.Client;
namespace FunctionsTests
{
+ ///
+ /// End-to-end tests that invoke the hello edge function against a running local Supabase
+ /// stack (started with supabase start), exercising the full request/response round trip for
+ /// the string, typed, and raw invocation shapes.
+ ///
[TestClass]
+ [TestCategory("E2E")]
public class ClientTests
{
- private Client _client = null!;
- private string _token = null!;
+ private const string Function = "hello";
+
+ private Client client = null!;
+ private string token = null!;
[TestInitialize]
- public void Initialize()
+ public void TestInitialize()
{
- _token = GenerateToken("super-secret-jwt-token-with-at-least-32-characters-long");
- _client = new Client("http://localhost:54321/functions/v1");
+ this.token = GenerateToken("super-secret-jwt-token-with-at-least-32-characters-long");
+ this.client = new Client("http://localhost:54321/functions/v1");
}
- [TestMethod("Invokes a function.")]
- public async Task Invokes()
+ [TestMethod]
+ public async Task Invoke_ShouldReturnGreetingContainingTheName()
{
- const string function = "hello";
-
- var result = await _client.Invoke(
- function,
- _token,
- new InvokeFunctionOptions
- {
- Body = new Dictionary { { "name", "supabase" } },
- HttpMethod = HttpMethod.Post,
- }
- );
-
- Assert.IsTrue(result.Contains("supabase"));
-
- var result2 = await _client.Invoke>(
- function,
- _token,
- new InvokeFunctionOptions
- {
- Body = new Dictionary { { "name", "functions" } },
- HttpMethod = HttpMethod.Post,
- }
- );
-
- Assert.IsInstanceOfType(result2, typeof(Dictionary));
- Assert.IsTrue(result2.ContainsKey("message"));
- Assert.IsTrue(result2["message"].Contains("functions"));
-
- var result3 = await _client.RawInvoke(
- function,
- _token,
- new InvokeFunctionOptions
- {
- Body = new Dictionary { { "name", "functions" } },
- HttpMethod = HttpMethod.Post,
- }
- );
+ var result = await this.client.Invoke(Function, this.token, new InvokeFunctionOptions
+ {
+ Body = new Dictionary { { "name", "supabase" } },
+ HttpMethod = HttpMethod.Post
+ });
+ result.Should().Contain("supabase");
+ }
- var bytes = await result3.ReadAsByteArrayAsync();
+ [TestMethod]
+ public async Task Invoke_ShouldReturnDeserializedGreeting_GivenTypedInvoke()
+ {
+ var result = await this.client.Invoke>(Function, this.token, new InvokeFunctionOptions
+ {
+ Body = new Dictionary { { "name", "functions" } },
+ HttpMethod = HttpMethod.Post
+ });
+ result.Should().ContainKey("message").WhoseValue.Should().Contain("functions");
+ }
- Assert.IsInstanceOfType(bytes, typeof(byte[]));
-
- var result4 = await _client.Invoke(
- function,
- _token,
- new InvokeFunctionOptions
- {
- Body = [],
- HttpMethod = HttpMethod.Get,
- }
- );
+ [TestMethod]
+ public async Task RawInvoke_ShouldReturnReadableBytes()
+ {
+ var content = await this.client.RawInvoke(Function, this.token, new InvokeFunctionOptions
+ {
+ Body = new Dictionary { { "name", "functions" } },
+ HttpMethod = HttpMethod.Post
+ });
+ (await content.ReadAsByteArrayAsync()).Should().NotBeEmpty();
+ }
- Assert.IsTrue(result4.Contains(function));
+ [TestMethod]
+ public async Task Invoke_ShouldGreetWithFunctionName_GivenGetWithoutBody()
+ {
+ var result = await this.client.Invoke(Function, this.token, new InvokeFunctionOptions
+ {
+ Body = [],
+ HttpMethod = HttpMethod.Get
+ });
+ result.Should().Contain(Function);
}
private static string GenerateToken(string secret)
{
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
-
var tokenDescriptor = new SecurityTokenDescriptor
{
- SigningCredentials = new SigningCredentials(
- signingKey,
- SecurityAlgorithms.HmacSha256Signature
- ),
+ SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256Signature)
};
-
var tokenHandler = new JwtSecurityTokenHandler();
- var securityToken = tokenHandler.CreateToken(tokenDescriptor);
- return tokenHandler.WriteToken(securityToken);
+ return tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor));
}
}
-}
\ No newline at end of file
+}
diff --git a/FunctionsTests/FailureHintTests.cs b/FunctionsTests/FailureHintTests.cs
new file mode 100644
index 0000000..cfa693f
--- /dev/null
+++ b/FunctionsTests/FailureHintTests.cs
@@ -0,0 +1,45 @@
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Supabase.Functions.Exceptions;
+using Reason = Supabase.Functions.Exceptions.FailureHint.Reason;
+
+namespace FunctionsTests
+{
+ ///
+ /// Covers : the mapping from a failed response's status code
+ /// and content onto a . A missing body is always
+ /// , and 403 only counts as an authorization failure when
+ /// the body mentions an API key.
+ ///
+ [TestClass]
+ [TestCategory("Unit")]
+ public class FailureHintTests
+ {
+ [TestMethod]
+ public void DetectReason_ShouldReturnUnknown_GivenNoContent() =>
+ FailureHint.DetectReason(Failure(statusCode: 401, content: null)).Should().Be(Reason.Unknown);
+
+ [TestMethod]
+ public void DetectReason_ShouldReturnNotAuthorized_Given401() =>
+ FailureHint.DetectReason(Failure(statusCode: 401, content: "nope")).Should().Be(Reason.NotAuthorized);
+
+ [TestMethod]
+ public void DetectReason_ShouldReturnNotAuthorized_Given403MentioningApiKey() =>
+ FailureHint.DetectReason(Failure(statusCode: 403, content: "invalid apikey")).Should().Be(Reason.NotAuthorized);
+
+ [TestMethod]
+ public void DetectReason_ShouldReturnUnknown_Given403WithoutApiKey() =>
+ FailureHint.DetectReason(Failure(statusCode: 403, content: "forbidden")).Should().Be(Reason.Unknown);
+
+ [TestMethod]
+ public void DetectReason_ShouldReturnInternal_Given500() =>
+ FailureHint.DetectReason(Failure(statusCode: 500, content: "boom")).Should().Be(Reason.Internal);
+
+ [TestMethod]
+ public void DetectReason_ShouldReturnUnknown_GivenUnmappedStatus() =>
+ FailureHint.DetectReason(Failure(statusCode: 400, content: "bad request")).Should().Be(Reason.Unknown);
+
+ private static FunctionsException Failure(int statusCode, string? content) =>
+ new("failed") { StatusCode = statusCode, Content = content };
+ }
+}
diff --git a/FunctionsTests/FunctionRegionTests.cs b/FunctionsTests/FunctionRegionTests.cs
new file mode 100644
index 0000000..d181955
--- /dev/null
+++ b/FunctionsTests/FunctionRegionTests.cs
@@ -0,0 +1,80 @@
+using FluentAssertions;
+using FluentAssertions.Execution;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using static Supabase.Functions.Client;
+
+namespace FunctionsTests
+{
+ ///
+ /// Covers : the wire string carried by every named region, its value
+ /// semantics (equality, hash code, operators) and the explicit conversions to and from
+ /// . The wire string is what ends up in the x-region header, so each
+ /// constant is pinned to its exact value.
+ ///
+ [TestClass]
+ [TestCategory("Unit")]
+ public class FunctionRegionTests
+ {
+ [TestMethod]
+ public void Region_ShouldExposeItsWireString()
+ {
+ using (new AssertionScope())
+ {
+ FunctionRegion.Any.ToString().Should().Be("any");
+ FunctionRegion.ApNortheast1.ToString().Should().Be("ap-northeast-1");
+ FunctionRegion.ApNortheast2.ToString().Should().Be("ap-northeast-2");
+ FunctionRegion.ApSouth1.ToString().Should().Be("ap-south-1");
+ FunctionRegion.ApSoutheast1.ToString().Should().Be("ap-southeast-1");
+ FunctionRegion.ApSoutheast2.ToString().Should().Be("ap-southeast-2");
+ FunctionRegion.CaCentral1.ToString().Should().Be("ca-central-1");
+ FunctionRegion.EuCentral1.ToString().Should().Be("eu-central-1");
+ FunctionRegion.EuWest1.ToString().Should().Be("eu-west-1");
+ FunctionRegion.EuWest2.ToString().Should().Be("eu-west-2");
+ FunctionRegion.EuWest3.ToString().Should().Be("eu-west-3");
+ FunctionRegion.SaEast1.ToString().Should().Be("sa-east-1");
+ FunctionRegion.UsEast1.ToString().Should().Be("us-east-1");
+ FunctionRegion.UsWest1.ToString().Should().Be("us-west-1");
+ FunctionRegion.UsWest2.ToString().Should().Be("us-west-2");
+ }
+ }
+
+ [TestMethod]
+ public void Region_ShouldConvertToItsWireString_GivenExplicitStringCast() =>
+ ((string) FunctionRegion.UsEast1).Should().Be("us-east-1");
+
+ [TestMethod]
+ public void Region_ShouldCarryTheGivenString_GivenExplicitCastFromString() =>
+ ((FunctionRegion) "custom-region").ToString().Should().Be("custom-region");
+
+ [TestMethod]
+ public void Region_ShouldEqualAnotherRegion_GivenSameWireString()
+ {
+ var left = (FunctionRegion) "us-east-1";
+ var right = (FunctionRegion) "us-east-1";
+ using (new AssertionScope())
+ {
+ left.Equals(right).Should().BeTrue();
+ left.Equals((object) right).Should().BeTrue();
+ (left == right).Should().BeTrue();
+ (left != right).Should().BeFalse();
+ }
+ }
+
+ [TestMethod]
+ public void Region_ShouldNotEqualAnotherRegion_GivenDifferentWireString()
+ {
+ var left = FunctionRegion.UsEast1;
+ var right = FunctionRegion.EuWest1;
+ using (new AssertionScope())
+ {
+ left.Equals(right).Should().BeFalse();
+ (left == right).Should().BeFalse();
+ (left != right).Should().BeTrue();
+ }
+ }
+
+ [TestMethod]
+ public void Region_ShouldShareHashCodeWithItsWireString() =>
+ FunctionRegion.UsEast1.GetHashCode().Should().Be("us-east-1".GetHashCode());
+ }
+}
diff --git a/FunctionsTests/FunctionsTests.csproj b/FunctionsTests/FunctionsTests.csproj
index f354510..6ca7b6e 100644
--- a/FunctionsTests/FunctionsTests.csproj
+++ b/FunctionsTests/FunctionsTests.csproj
@@ -4,9 +4,14 @@
net8.0
false
enable
+ true
+ true
+ latest
+
+
diff --git a/FunctionsTests/ObservabilityContractTests.cs b/FunctionsTests/ObservabilityContractTests.cs
index decda8a..205887d 100644
--- a/FunctionsTests/ObservabilityContractTests.cs
+++ b/FunctionsTests/ObservabilityContractTests.cs
@@ -1,25 +1,27 @@
-using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Linq;
-using System.Net.Http;
using System.Threading.Tasks;
+using FluentAssertions;
+using FluentAssertions.Execution;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Supabase.Functions;
+using Supabase.Functions.Exceptions;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
-using static Supabase.Functions.Client;
namespace FunctionsTests
{
///
- /// Contract tests for the diagnostics the SDK emits through System.Diagnostics
- /// (ActivitySource/Meter "Supabase.Functions") and for the sanitization rule: telemetry must
- /// never contain a query string, the request body, a token, or other secret.
+ /// Contract tests for the diagnostics the client emits through
+ /// (the Supabase.Functions and ): the span's
+ /// OpenTelemetry tags and error status, the invocation duration histogram and its dimensions, and
+ /// the sanitization rule that telemetry must never carry the query string, request body, or a token.
///
[TestClass]
+ [TestCategory("Contract")]
public class ObservabilityContractTests
{
private const string FunctionName = "hello";
@@ -29,135 +31,180 @@ public class ObservabilityContractTests
private readonly List>> measurements = new();
private ActivityListener activityListener = null!;
private MeterListener meterListener = null!;
+ private Instrument? durationInstrument;
private WireMockServer server = null!;
private Client client = null!;
[TestInitialize]
- public void TestInitializer()
+ public void TestInitialize()
{
- server = WireMockServer.Start();
- client = new Client($"{server.Url}/functions/v1");
-
- activityListener = new ActivityListener
+ this.server = WireMockServer.Start();
+ this.client = new Client($"{this.server.Url}/functions/v1");
+ this.activityListener = new ActivityListener
{
ShouldListenTo = source => source.Name == FunctionsDiagnostics.SourceName,
Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData,
- ActivityStopped = activity => activities.Add(activity)
+ ActivityStopped = activity => this.activities.Add(activity)
};
- ActivitySource.AddActivityListener(activityListener);
-
- meterListener = new MeterListener
+ ActivitySource.AddActivityListener(this.activityListener);
+ this.meterListener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
- if (instrument.Meter.Name == FunctionsDiagnostics.SourceName)
- listener.EnableMeasurementEvents(instrument);
+ if (instrument.Meter.Name != FunctionsDiagnostics.SourceName)
+ return;
+ this.durationInstrument = instrument;
+ listener.EnableMeasurementEvents(instrument);
}
};
- meterListener.SetMeasurementEventCallback((_, value, tags, _) =>
+ this.meterListener.SetMeasurementEventCallback((_, value, tags, _) =>
{
var tagValues = new Dictionary();
foreach (var tag in tags)
tagValues[tag.Key] = tag.Value;
- measurements.Add(new KeyValuePair>(value, tagValues));
+ this.measurements.Add(new KeyValuePair>(value, tagValues));
});
- meterListener.Start();
+ this.meterListener.Start();
}
[TestCleanup]
public void TestCleanup()
{
- activityListener.Dispose();
- meterListener.Dispose();
- server.Stop();
+ this.activityListener.Dispose();
+ this.meterListener.Dispose();
+ this.server.Stop();
}
- [TestMethod(DisplayName = "The invoke span records the request URL without its query string")]
- public async Task InvokeSpanRecordsSanitizedUrl()
+ [TestMethod]
+ public async Task InvokeSpan_ShouldRecordUrlWithoutQueryString()
{
- MockInvokeOk();
- await Invoke();
- var span = SingleInvokeSpan();
- Assert.AreEqual($"{server.Url}/functions/v1/{FunctionName}", span.GetTagItem("url.full"),
+ this.MockInvokeOk();
+ await this.Invoke();
+ this.InvokeSpan().GetTagItem("url.full").Should().Be($"{this.server.Url}/functions/v1/{FunctionName}",
"the query string must never be recorded");
}
- [TestMethod(DisplayName = "The invoke span follows OpenTelemetry conventions and tags the function name")]
- public async Task InvokeSpanRecordsMethodStatusAndFunctionName()
+ [TestMethod]
+ public async Task InvokeSpan_ShouldFollowOpenTelemetryConventions()
{
- MockInvokeOk();
- await Invoke();
- var span = SingleInvokeSpan();
- Assert.AreEqual(ActivityKind.Client, span.Kind);
- Assert.AreEqual("POST", span.GetTagItem("http.request.method"));
- Assert.AreEqual(200, span.GetTagItem("http.response.status_code"));
- Assert.AreEqual(FunctionName, span.GetTagItem("faas.invoked_name"));
+ this.MockInvokeOk();
+ await this.Invoke();
+ var span = this.InvokeSpan();
+ using (new AssertionScope())
+ {
+ span.Kind.Should().Be(ActivityKind.Client);
+ span.GetTagItem("http.request.method").Should().Be("POST");
+ span.GetTagItem("http.response.status_code").Should().Be(200);
+ span.GetTagItem("faas.invoked_name").Should().Be(FunctionName);
+ }
}
- [TestMethod(DisplayName = "A failed invocation marks the span as an error")]
- public async Task FailedInvocationMarksTheSpanAsError()
+ [TestMethod]
+ public async Task InvokeSpan_ShouldMarkError_GivenFailedStatus()
{
- server.Given(Request.Create().WithPath($"/functions/v1/{FunctionName}").UsingPost())
+ this.server.Given(Request.Create().WithPath($"/functions/v1/{FunctionName}").UsingPost())
.RespondWith(Response.Create().WithStatusCode(500).WithBody("boom"));
- await Assert.ThrowsAsync(Invoke);
- var span = SingleInvokeSpan();
- Assert.AreEqual(ActivityStatusCode.Error, span.Status);
- Assert.AreEqual(500, span.GetTagItem("http.response.status_code"));
+ var act = this.Invoke;
+ await act.Should().ThrowAsync();
+ var span = this.InvokeSpan();
+ using (new AssertionScope())
+ {
+ span.Status.Should().Be(ActivityStatusCode.Error);
+ span.GetTagItem("http.response.status_code").Should().Be(500);
+ }
}
- [TestMethod(DisplayName = "A relay error marks the span as an error even on a 2xx status")]
- public async Task RelayErrorOnSuccessStatusMarksTheSpanAsError()
+ [TestMethod]
+ public async Task InvokeSpan_ShouldMarkError_GivenRelayErrorOnSuccessStatus()
{
- server.Given(Request.Create().WithPath($"/functions/v1/{FunctionName}").UsingPost())
+ this.server.Given(Request.Create().WithPath($"/functions/v1/{FunctionName}").UsingPost())
.RespondWith(Response.Create().WithStatusCode(200).WithHeader("x-relay-error", "true").WithBody("relayed"));
- await Assert.ThrowsAsync(Invoke);
- var span = SingleInvokeSpan();
- Assert.AreEqual(ActivityStatusCode.Error, span.Status);
- Assert.AreEqual("x-relay-error", span.GetTagItem("error.type"));
+ var act = this.Invoke;
+ await act.Should().ThrowAsync();
+ var span = this.InvokeSpan();
+ using (new AssertionScope())
+ {
+ span.Status.Should().Be(ActivityStatusCode.Error);
+ span.GetTagItem("error.type").Should().Be("x-relay-error");
+ }
}
- [TestMethod(DisplayName = "The invoke duration histogram is recorded per request")]
- public async Task InvokeDurationMetricIsRecorded()
+ [TestMethod]
+ public async Task InvokeDuration_ShouldRecordPositiveDurationPerRequest()
{
- MockInvokeOk();
- await Invoke();
- meterListener.RecordObservableInstruments();
- Assert.AreEqual(1, measurements.Count);
- var measurement = measurements.Single();
- Assert.IsTrue(measurement.Key > 0);
- Assert.AreEqual(200, measurement.Value["http.response.status_code"]);
- Assert.AreEqual($"/functions/v1/{FunctionName}", measurement.Value["url.path"]);
- Assert.AreEqual(FunctionName, measurement.Value["faas.invoked_name"]);
+ this.MockInvokeOk();
+ await this.Invoke();
+ this.meterListener.RecordObservableInstruments();
+ var measurement = this.measurements.Should().ContainSingle().Which;
+ measurement.Key.Should().BeInRange(0, 60, "the value is an elapsed time in seconds, not raw ticks");
+ measurement.Key.Should().BeGreaterThan(0);
+ }
+
+ [TestMethod]
+ public async Task InvokeDuration_ShouldBeNamedAndMeasuredInSeconds()
+ {
+ this.MockInvokeOk();
+ await this.Invoke();
+ using (new AssertionScope())
+ {
+ this.durationInstrument!.Name.Should().Be("supabase.functions.invoke.duration");
+ this.durationInstrument!.Unit.Should().Be("s");
+ }
+ }
+
+ [TestMethod]
+ public async Task InvokeDuration_ShouldTagRequestDimensions()
+ {
+ this.MockInvokeOk();
+ await this.Invoke();
+ var dimensions = this.measurements.Should().ContainSingle().Which.Value;
+ using (new AssertionScope())
+ {
+ dimensions["http.request.method"].Should().Be("POST");
+ dimensions["http.response.status_code"].Should().Be(200);
+ dimensions["server.address"].Should().Be(new System.Uri(this.server.Url!).Host);
+ dimensions["url.path"].Should().Be($"/functions/v1/{FunctionName}");
+ dimensions["faas.invoked_name"].Should().Be(FunctionName);
+ }
+ }
+
+ [TestMethod]
+ public async Task InvokeDuration_ShouldTagErrorType_GivenFailedStatus()
+ {
+ this.server.Given(Request.Create().WithPath($"/functions/v1/{FunctionName}").UsingPost())
+ .RespondWith(Response.Create().WithStatusCode(500).WithBody("boom"));
+ var act = this.Invoke;
+ await act.Should().ThrowAsync();
+ this.measurements.Should().ContainSingle().Which.Value["error.type"].Should().Be("500");
}
- [TestMethod(DisplayName = "Telemetry never contains the request body")]
- public async Task TelemetryDoesNotLeakTheBody()
+ [TestMethod]
+ public async Task Telemetry_ShouldNotLeakTheRequestBody()
{
- MockInvokeOk();
- await Invoke();
- var recorded = activities
+ this.MockInvokeOk();
+ await this.Invoke();
+ var recorded = this.activities
.SelectMany(a => a.TagObjects)
.Select(tag => tag.Value?.ToString() ?? "")
- .Concat(measurements.SelectMany(m => m.Value.Values).Select(v => v?.ToString() ?? ""))
- .Concat(activities.Select(a => a.DisplayName));
- Assert.IsFalse(recorded.Any(value => value.Contains(SecretBodyValue)),
+ .Concat(this.measurements.SelectMany(m => m.Value.Values).Select(v => v?.ToString() ?? ""))
+ .Concat(this.activities.Select(a => a.DisplayName));
+ recorded.Should().NotContain(value => value.Contains(SecretBodyValue),
"no span name, tag, or metric dimension may contain the request body");
}
private Task Invoke() =>
- client.Invoke(FunctionName, options: new InvokeFunctionOptions
+ this.client.Invoke(FunctionName, options: new Client.InvokeFunctionOptions
{
Body = new Dictionary { { "name", SecretBodyValue } }
});
private void MockInvokeOk() =>
- server.Given(Request.Create().WithPath($"/functions/v1/{FunctionName}").UsingPost())
+ this.server.Given(Request.Create().WithPath($"/functions/v1/{FunctionName}").UsingPost())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/json")
.WithBody("{\"message\":\"ok\"}"));
- private Activity SingleInvokeSpan() =>
- activities.Single(a => a.OperationName == $"POST /functions/v1/{FunctionName}");
+ private Activity InvokeSpan() => this.activities.Single(a => a.OperationName == $"POST /functions/v1/{FunctionName}");
}
}
diff --git a/FunctionsTests/TestConventions.cs b/FunctionsTests/TestConventions.cs
new file mode 100644
index 0000000..073969c
--- /dev/null
+++ b/FunctionsTests/TestConventions.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Linq;
+using System.Reflection;
+using System.Text.RegularExpressions;
+using FluentAssertions;
+using FluentAssertions.Execution;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace FunctionsTests
+{
+ ///
+ /// 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]");
+ }
+ }
+ }
+ }
+}