diff --git a/extra/admin-api/SpacebarAdminAPI.slnx b/extra/admin-api/SpacebarAdminAPI.slnx
index 44498b0f6..491568cd5 100644
--- a/extra/admin-api/SpacebarAdminAPI.slnx
+++ b/extra/admin-api/SpacebarAdminAPI.slnx
@@ -25,6 +25,9 @@
+
+
+
diff --git a/extra/admin-api/Tests/Spacebar.Tests/Abstractions/UserAbstraction.cs b/extra/admin-api/Tests/Spacebar.Tests/Abstractions/UserAbstraction.cs
new file mode 100644
index 000000000..b6cef1ac9
--- /dev/null
+++ b/extra/admin-api/Tests/Spacebar.Tests/Abstractions/UserAbstraction.cs
@@ -0,0 +1,24 @@
+using Spacebar.Sdk.Core;
+
+namespace Spacebar.Tests.Abstractions;
+
+public class UserAbstraction(Config _config, SpacebarClientProviderService _clientProvider) {
+ public async Task GetFreshUser(bool withAutojoinGuilds = false) {
+ var ua = await _clientProvider.GetUnauthenticatedClientAsync(_config.TestInstance);
+ var tokenResponse = await ua.RegisterAsync(new() {
+ Email = $"{Guid.NewGuid().ToString()}@{Guid.NewGuid().ToString()}.tld",
+ Username = Guid.NewGuid().ToString(),
+ Password = Guid.NewGuid().ToString(),
+ DateOfBirth = new(),
+ Consent = true
+ });
+ var client = await _clientProvider.GetAuthenticatedClientAsync(_config.TestInstance, tokenResponse.Token);
+
+ if (!withAutojoinGuilds) {
+
+ }
+
+ return client;
+ }
+
+}
\ No newline at end of file
diff --git a/extra/admin-api/Tests/Spacebar.Tests/Config.cs b/extra/admin-api/Tests/Spacebar.Tests/Config.cs
new file mode 100644
index 000000000..d1aa9de04
--- /dev/null
+++ b/extra/admin-api/Tests/Spacebar.Tests/Config.cs
@@ -0,0 +1,11 @@
+using Microsoft.Extensions.Configuration;
+
+namespace Spacebar.Tests;
+
+public class Config {
+ public Config(IConfiguration? config) {
+ config.GetSection("Configuration").Bind(this);
+ }
+
+ public string TestInstance { get; set; }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Tests/Spacebar.Tests/Extensions/AssertExtensions.cs b/extra/admin-api/Tests/Spacebar.Tests/Extensions/AssertExtensions.cs
new file mode 100644
index 000000000..fa146f786
--- /dev/null
+++ b/extra/admin-api/Tests/Spacebar.Tests/Extensions/AssertExtensions.cs
@@ -0,0 +1,15 @@
+namespace Spacebar.Tests.Extensions;
+
+public static class AssertExtensions {
+ extension(Assert) {
+ public static void StringNotNullOrEmpty(string? str) {
+ Assert.NotNull(str);
+ Assert.NotEqual("", str);
+ }
+
+ public static void StringNotNullOrWhitespace(string? str) {
+ StringNotNullOrEmpty(str);
+ Assert.Matches(".+", str);
+ }
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Tests/Spacebar.Tests/Extensions/AssertHttpExtensions.cs b/extra/admin-api/Tests/Spacebar.Tests/Extensions/AssertHttpExtensions.cs
new file mode 100644
index 000000000..f5e99dc98
--- /dev/null
+++ b/extra/admin-api/Tests/Spacebar.Tests/Extensions/AssertHttpExtensions.cs
@@ -0,0 +1,38 @@
+using System.Net.Http.Json;
+using System.Text.Json.Nodes;
+
+namespace Spacebar.Tests.Extensions;
+
+public static class AssertHttpExtensions {
+ private static readonly HttpClient Hc = new();
+
+ public static async Task GetFormattedErrorDetails(HttpResponseMessage res) {
+ return res.Content.Headers.ContentType?.MediaType == "application/json"
+ ? (await res.Content.ReadFromJsonAsync())!.ToJsonString(new() {
+ WriteIndented = true
+ })
+ : await res.Content.ReadAsStringAsync();
+ }
+
+ extension(Assert) {
+ public static async Task SuccessfullyHttpGetAsync(string url) {
+ var res = await Hc.GetAsync(url);
+ Assert.True(res.IsSuccessStatusCode, $"Could not get {url}: {res.StatusCode}\n{await GetFormattedErrorDetails(res)}");
+ return res;
+ }
+
+ public static async Task SuccessfullyHttpPostAsJsonAsync(string url, TValue obj) {
+ var res = await Hc.PostAsJsonAsync(url, obj);
+ if (!res.IsSuccessStatusCode)
+ Assert.True(res.IsSuccessStatusCode, $"Could not POST JSON to {url}: {(int)res.StatusCode} {res.StatusCode}\n{await GetFormattedErrorDetails(res)}");
+ return res;
+ }
+
+ public static async Task HttpSuccess(HttpResponseMessage res) {
+ if (!res.IsSuccessStatusCode)
+ Assert.True(res.IsSuccessStatusCode,
+ $"Could not {res.RequestMessage!.Method.Method.ToUpper()} to {res.RequestMessage!.RequestUri!.ToString()}: {(int)res.StatusCode} {res.StatusCode}\n{await GetFormattedErrorDetails(res)}");
+ return res;
+ }
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Tests/Spacebar.Tests/Fixtures/TestFixture.cs b/extra/admin-api/Tests/Spacebar.Tests/Fixtures/TestFixture.cs
new file mode 100644
index 000000000..cc9ce056f
--- /dev/null
+++ b/extra/admin-api/Tests/Spacebar.Tests/Fixtures/TestFixture.cs
@@ -0,0 +1,29 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Spacebar.Sdk.Core;
+using Spacebar.Tests.Abstractions;
+using Xunit.Microsoft.DependencyInjection;
+using Xunit.Microsoft.DependencyInjection.Abstracts;
+
+namespace Spacebar.Tests.Fixtures;
+
+public class TestFixture : TestBedFixture {
+ protected override void AddServices(IServiceCollection services, IConfiguration configuration) {
+ services.AddSingleton(configuration);
+ services.AddLogging();
+
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddSingleton();
+ services.AddSingleton();
+
+ }
+
+ protected override ValueTask DisposeAsyncCore()
+ => new();
+
+ protected override IEnumerable GetTestAppSettings() {
+ yield return new TestAppSettings { Filename = "appsettings.json", IsOptional = true };
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Tests/Spacebar.Tests/GlobalUsings.cs b/extra/admin-api/Tests/Spacebar.Tests/GlobalUsings.cs
new file mode 100644
index 000000000..8c927eb74
--- /dev/null
+++ b/extra/admin-api/Tests/Spacebar.Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
\ No newline at end of file
diff --git a/extra/admin-api/Tests/Spacebar.Tests/Spacebar.Tests.csproj b/extra/admin-api/Tests/Spacebar.Tests/Spacebar.Tests.csproj
new file mode 100644
index 000000000..b521b61c8
--- /dev/null
+++ b/extra/admin-api/Tests/Spacebar.Tests/Spacebar.Tests.csproj
@@ -0,0 +1,44 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
+
diff --git a/extra/admin-api/Tests/Spacebar.Tests/Tests/AuthenticationTests.cs b/extra/admin-api/Tests/Spacebar.Tests/Tests/AuthenticationTests.cs
new file mode 100644
index 000000000..04bd5f912
--- /dev/null
+++ b/extra/admin-api/Tests/Spacebar.Tests/Tests/AuthenticationTests.cs
@@ -0,0 +1,95 @@
+using System.Net.Http.Json;
+using System.Text.Json.Nodes;
+using ArcaneLibs.Extensions;
+using Spacebar.Models.Api;
+using Spacebar.Sdk.Core;
+using Spacebar.Tests.Extensions;
+using Spacebar.Tests.Fixtures;
+using Xunit.Microsoft.DependencyInjection.Abstracts;
+
+namespace Spacebar.Tests.Tests;
+
+public class AuthenticationTests(ITestOutputHelper testOutputHelper, TestFixture fixture) : TestBed(testOutputHelper, fixture) {
+ private readonly Config _config = fixture.GetService(testOutputHelper) ?? throw new InvalidOperationException($"Failed to get {nameof(Config)}");
+
+ private readonly SpacebarClientWellKnownResolverService _wellKnownResolver = fixture.GetService(testOutputHelper) ??
+ throw new InvalidOperationException(
+ $"Failed to get {nameof(SpacebarClientWellKnownResolverService)}");
+
+ private readonly SpacebarClientProviderService _clientProvider = fixture.GetService(testOutputHelper) ??
+ throw new InvalidOperationException($"Failed to get {nameof(SpacebarClientProviderService)}");
+
+ [Fact]
+ public async Task RegisterUser() {
+ var res = await Assert.SuccessfullyHttpPostAsJsonAsync($"{_config.TestInstance}/api/v9/auth/register", new RegisterRequest() {
+ Email = $"{Guid.NewGuid().ToString()}@{Guid.NewGuid().ToString()}.tld",
+ Username = Guid.NewGuid().ToString(),
+ Password = Guid.NewGuid().ToString(),
+ DateOfBirth = new(),
+ Consent = true
+ });
+ }
+
+ [Fact]
+ public async Task ConcurrentRegister50Users() {
+ var tasks = Enumerable.Range(0, 50).Select(async _ => {
+ var rr = new RegisterRequest() {
+ Email = $"{Guid.NewGuid().ToString()}@{Guid.NewGuid().ToString()}.tld",
+ Username = Guid.NewGuid().ToString(),
+ Password = "password",
+ DateOfBirth = new(),
+ Consent = true
+ };
+ return (rr, await Assert.SuccessfullyHttpPostAsJsonAsync($"{_config.TestInstance}/api/v9/auth/register", rr));
+ }).ToList();
+ await Task.WhenAll(tasks);
+
+ testOutputHelper.WriteLine("Waiting for server to settle...");
+ await Task.Delay(2500, TestContext.Current.CancellationToken);
+
+ testOutputHelper.WriteLine("Cleaning up users...");
+ var cleanupTasks = tasks.Select(x => x.Result).Select(async res => {
+ var resp = await res.Item2.Content.ReadFromJsonAsync();
+ var c = await _clientProvider.GetAuthenticatedClientAsync(_config.TestInstance, resp.Token);
+ var dresp = (await c.ApiHttpClient.PostAsJsonAsync("/api/v9/users/@me/delete", new JsonObject() {
+ { "password", "password" }
+ }, cancellationToken: TestContext.Current.CancellationToken));
+ // TODO: figure out why this fails with "invalid password"
+ if (!dresp.IsSuccessStatusCode)
+ testOutputHelper.WriteLine("Failed to delete user: " + await AssertHttpExtensions.GetFormattedErrorDetails(dresp));
+ }).ToList();
+ await Task.WhenAll(cleanupTasks);
+ }
+
+ [Fact]
+ public async Task LoginUser() {
+ var rr = new RegisterRequest() {
+ Email = $"{Guid.NewGuid().ToString()}@{Guid.NewGuid().ToString()}.tld",
+ Username = Guid.NewGuid().ToString(),
+ Password = Guid.NewGuid().ToString(),
+ DateOfBirth = new(),
+ Consent = true
+ };
+ var rrRes = await Assert.SuccessfullyHttpPostAsJsonAsync($"{_config.TestInstance}/api/v9/auth/register", rr);
+ var loginRes = await Assert.SuccessfullyHttpPostAsJsonAsync($"{_config.TestInstance}/api/v9/auth/login", new LoginRequest() {
+ Login = rr.Email,
+ Password = rr.Password
+ });
+ }
+
+ [Fact]
+ public async Task WhoAmI() {
+ var rr = new RegisterRequest() {
+ Email = $"{Guid.NewGuid().ToString()}@{Guid.NewGuid().ToString()}.tld",
+ Username = Guid.NewGuid().ToString(),
+ Password = Guid.NewGuid().ToString(),
+ DateOfBirth = new(),
+ Consent = true
+ };
+ var rrRes = await Assert.SuccessfullyHttpPostAsJsonAsync($"{_config.TestInstance}/api/v9/auth/register", rr);
+ var res = await rrRes.Content.ReadFromJsonAsync();
+ var client = await _clientProvider.GetAuthenticatedClientAsync(_config.TestInstance, res.Token);
+ var waRes = await Assert.HttpSuccess(await client.ApiHttpClient.GetAsync("/api/v9/auth/whoami"));
+ // TODO: finish test once model exists
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Tests/Spacebar.Tests/Tests/BasicWellKnownTests.cs b/extra/admin-api/Tests/Spacebar.Tests/Tests/BasicWellKnownTests.cs
new file mode 100644
index 000000000..56254a627
--- /dev/null
+++ b/extra/admin-api/Tests/Spacebar.Tests/Tests/BasicWellKnownTests.cs
@@ -0,0 +1,44 @@
+using Spacebar.Sdk.Core;
+using Spacebar.Tests.Extensions;
+using Spacebar.Tests.Fixtures;
+using Xunit.Microsoft.DependencyInjection.Abstracts;
+
+namespace Spacebar.Tests.Tests;
+
+public class BasicWellKnownTests(ITestOutputHelper testOutputHelper, TestFixture fixture) : TestBed(testOutputHelper, fixture) {
+ private readonly Config _config = fixture.GetService(testOutputHelper) ?? throw new InvalidOperationException($"Failed to get {nameof(Config)}");
+
+ private readonly SpacebarClientWellKnownResolverService _wellKnownResolver = fixture.GetService(testOutputHelper) ??
+ throw new InvalidOperationException($"Failed to get {nameof(Config)}");
+
+ [Fact]
+ public async Task ValidateTestConfig() {
+ Assert.NotNull(_config.TestInstance);
+ Assert.NotEmpty(_config.TestInstance);
+ }
+
+ [Fact]
+ public async Task CanReachInstance() => await Assert.SuccessfullyHttpGetAsync($"{_config.TestInstance}/api/v9/ping");
+
+ [Fact]
+ public async Task CanGetOldWellknown() {
+ await Assert.SuccessfullyHttpGetAsync($"{_config.TestInstance}/.well-known/spacebar");
+ await Assert.SuccessfullyHttpGetAsync($"{_config.TestInstance}/api/v9/policies/instance/domains");
+ }
+
+ [Fact]
+ public async Task CanGetNewWellknown() => await Assert.SuccessfullyHttpGetAsync($"{_config.TestInstance}/.well-known/spacebar/client");
+
+ [Fact]
+ public async Task SdkCanGetWellKnown() {
+ testOutputHelper.WriteLine("instance: " + _config.TestInstance);
+ var res = await _wellKnownResolver.ResolveClientWellKnown(_config.TestInstance);
+ Assert.StringNotNullOrWhitespace(res.Api.BaseUrl);
+ Assert.StringNotNullOrWhitespace(res.Cdn.BaseUrl);
+ Assert.StringNotNullOrWhitespace(res.Gateway.BaseUrl);
+ Assert.NotEmpty(res.Api.ApiVersions.Active);
+ Assert.StringNotNullOrWhitespace(res.Api.ApiVersions.Default);
+ Assert.NotEmpty(res.Gateway.Compression);
+ Assert.NotEmpty(res.Gateway.Encoding);
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Tests/Spacebar.Tests/Tests/ChannelTests.cs b/extra/admin-api/Tests/Spacebar.Tests/Tests/ChannelTests.cs
new file mode 100644
index 000000000..d04ee994b
--- /dev/null
+++ b/extra/admin-api/Tests/Spacebar.Tests/Tests/ChannelTests.cs
@@ -0,0 +1,65 @@
+using System.Net.Http.Json;
+using Spacebar.Models.Generic;
+using Spacebar.Sdk.Core;
+using Spacebar.Tests.Abstractions;
+using Spacebar.Tests.Extensions;
+using Spacebar.Tests.Fixtures;
+using Xunit.Microsoft.DependencyInjection.Abstracts;
+
+namespace Spacebar.Tests.Tests;
+
+public class ChannelTests(ITestOutputHelper testOutputHelper, TestFixture fixture) : TestBed(testOutputHelper, fixture) {
+ private readonly Config _config = fixture.GetService(testOutputHelper) ?? throw new InvalidOperationException($"Failed to get {nameof(Config)}");
+
+ private readonly SpacebarClientWellKnownResolverService _wellKnownResolver = fixture.GetService(testOutputHelper) ??
+ throw new InvalidOperationException(
+ $"Failed to get {nameof(SpacebarClientWellKnownResolverService)}");
+
+ private readonly SpacebarClientProviderService _clientProvider = fixture.GetService(testOutputHelper) ??
+ throw new InvalidOperationException($"Failed to get {nameof(SpacebarClientProviderService)}");
+
+ private readonly UserAbstraction _userAbstraction = fixture.GetService(testOutputHelper) ??
+ throw new InvalidOperationException($"Failed to get {nameof(SpacebarClientProviderService)}");
+
+ [Fact]
+ public async Task CreateChannel() {
+ var client = await _userAbstraction.GetFreshUser();
+ var guild = await client.CreateGuild(new() {
+ Name = "Test guild"
+ });
+
+ Assert.Equal("Test guild", guild.Name);
+
+ var channel = await client.GetGuild(guild.Id).CreateChannelAsync(new() {
+ Name = "test",
+ Type = 0
+ });
+
+ Assert.Equal("test", channel.Name);
+ }
+
+ [Fact]
+ public async Task GetChannel() {
+ var client = await _userAbstraction.GetFreshUser();
+ var guild = await client.CreateGuild(new() {
+ Name = "Test guild"
+ });
+
+ Assert.Equal("Test guild", guild.Name);
+
+ var channel = await client.GetGuild(guild.Id).CreateChannelAsync(new() {
+ Name = "test",
+ Type = 0
+ });
+
+ Assert.Equal("test", channel.Name);
+
+ var res = await client.ApiHttpClient.GetAsync("channels/" + channel.Id, TestContext.Current.CancellationToken);
+ await Assert.HttpSuccess(res);
+
+ var channelResp = await res.Content.ReadFromJsonAsync(cancellationToken: TestContext.Current.CancellationToken);
+ Assert.Equal(channel.Name, channelResp!.Name);
+ Assert.Equal(channel.Id, channelResp!.Id);
+
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Tests/Spacebar.Tests/Tests/GuildTests.cs b/extra/admin-api/Tests/Spacebar.Tests/Tests/GuildTests.cs
new file mode 100644
index 000000000..12e566f03
--- /dev/null
+++ b/extra/admin-api/Tests/Spacebar.Tests/Tests/GuildTests.cs
@@ -0,0 +1,47 @@
+using Spacebar.Sdk.Core;
+using Spacebar.Tests.Abstractions;
+using Spacebar.Tests.Extensions;
+using Spacebar.Tests.Fixtures;
+using Xunit.Microsoft.DependencyInjection.Abstracts;
+
+namespace Spacebar.Tests.Tests;
+
+public class GuildTests(ITestOutputHelper testOutputHelper, TestFixture fixture) : TestBed(testOutputHelper, fixture) {
+ private readonly Config _config = fixture.GetService(testOutputHelper) ?? throw new InvalidOperationException($"Failed to get {nameof(Config)}");
+
+ private readonly SpacebarClientWellKnownResolverService _wellKnownResolver = fixture.GetService(testOutputHelper) ??
+ throw new InvalidOperationException(
+ $"Failed to get {nameof(SpacebarClientWellKnownResolverService)}");
+
+ private readonly SpacebarClientProviderService _clientProvider = fixture.GetService(testOutputHelper) ??
+ throw new InvalidOperationException($"Failed to get {nameof(SpacebarClientProviderService)}");
+
+ private readonly UserAbstraction _userAbstraction = fixture.GetService(testOutputHelper) ??
+ throw new InvalidOperationException($"Failed to get {nameof(SpacebarClientProviderService)}");
+
+ [Fact]
+ public async Task CreateGuild() {
+ var client = await _userAbstraction.GetFreshUser();
+ var guild = await client.CreateGuild(new() {
+ Name = "Test guild"
+ });
+
+ Assert.Equal("Test guild", guild.Name);
+ }
+
+ [Fact]
+ public async Task GetChannels() {
+ var client = await _userAbstraction.GetFreshUser();
+ var guild = await client.CreateGuild(new() {
+ Name = "Test guild"
+ });
+
+ Assert.Equal("Test guild", guild.Name);
+
+ var channels = await client.GetGuild(guild.Id).GetChannelsAsync();
+ Assert.NotEmpty(channels);
+ foreach (var channel in channels) {
+ Assert.StringNotNullOrWhitespace(channel.Name);
+ }
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Tests/Spacebar.Tests/Tests/Meta/UserAbstractionTests.cs b/extra/admin-api/Tests/Spacebar.Tests/Tests/Meta/UserAbstractionTests.cs
new file mode 100644
index 000000000..81f53f7f3
--- /dev/null
+++ b/extra/admin-api/Tests/Spacebar.Tests/Tests/Meta/UserAbstractionTests.cs
@@ -0,0 +1,16 @@
+using Spacebar.Tests.Abstractions;
+using Spacebar.Tests.Extensions;
+using Spacebar.Tests.Fixtures;
+using Xunit.Microsoft.DependencyInjection.Abstracts;
+
+namespace Spacebar.Tests.Tests.Meta;
+
+public class UserAbstractionTests(ITestOutputHelper testOutputHelper, TestFixture fixture) : TestBed(testOutputHelper, fixture) {
+ private readonly UserAbstraction _config = fixture.GetService(testOutputHelper) ?? throw new InvalidOperationException($"Failed to get {nameof(UserAbstraction)}");
+
+ [Fact]
+ public async Task CanGetUser() {
+ var res = await _config.GetFreshUser();
+ Assert.StringNotNullOrWhitespace(res.ApiHttpClient.BaseAddress!.ToString());
+ }
+}
\ No newline at end of file
diff --git a/extra/admin-api/Tests/Spacebar.Tests/appsettings.json b/extra/admin-api/Tests/Spacebar.Tests/appsettings.json
new file mode 100644
index 000000000..d1d45a93a
--- /dev/null
+++ b/extra/admin-api/Tests/Spacebar.Tests/appsettings.json
@@ -0,0 +1,5 @@
+{
+ "Configuration": {
+ "TestInstance": "http://localhost:3001"
+ }
+}
\ No newline at end of file