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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -407,5 +407,6 @@ ASALocalRun/
# Supabase
.supabase
.temp
.branches

launchSettings.json
209 changes: 209 additions & 0 deletions FunctionsTests/ClientContractTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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 <see cref="FunctionsException"/>
/// carrying its status, content, and detected reason).
/// </summary>
[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<Dictionary<string, string>>(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<string, object> { { "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<string, string> { { "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<string, string> { { "x-shared", "from-callback" } };
this.RespondWith(200, "ok");
await this.client.Invoke(FunctionName, options: new InvokeFunctionOptions
{
Headers = new Dictionary<string, string> { { "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<FunctionsException>()).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<FunctionsException>()).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<FunctionsException>()).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];
}
}
Loading
Loading