diff --git a/extra/admin-api/Models/Spacebar.Models.Api/CreateWebhookRequest.cs b/extra/admin-api/Models/Spacebar.Models.Api/CreateWebhookRequest.cs new file mode 100644 index 000000000..19361b9f6 --- /dev/null +++ b/extra/admin-api/Models/Spacebar.Models.Api/CreateWebhookRequest.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace Spacebar.Models.Api; + +public class CreateWebhookRequest { + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("avatar")] + public string? AvatarData { get; set; } +} \ No newline at end of file diff --git a/extra/admin-api/Models/Spacebar.Models.Api/SpacebarApiError.cs b/extra/admin-api/Models/Spacebar.Models.Api/SpacebarApiError.cs index 27ef7d140..bfbda6f5a 100644 --- a/extra/admin-api/Models/Spacebar.Models.Api/SpacebarApiError.cs +++ b/extra/admin-api/Models/Spacebar.Models.Api/SpacebarApiError.cs @@ -14,6 +14,8 @@ public class SpacebarApiException : Exception { public JsonObject? Errors { get; set; } public JsonObject?[]? AjvErrors { get; set; } + + public JsonObject? OriginalErrorData { get; init; } public class FieldErrorList { // public @@ -32,6 +34,7 @@ public class SpacebarApiException : Exception { } var ex = new SpacebarApiException(msg) { + OriginalErrorData = resp, Code = resp["code"]!.GetValue(), ErrorMessage = resp["message"]!.GetValue(), Request = resp["request"]?.GetValue(), @@ -42,7 +45,7 @@ public class SpacebarApiException : Exception { return ex; } - public JsonObject AsJsonObject() => new() { + public JsonObject AsJsonObject() => OriginalErrorData?.DeepClone().AsObject() ?? new() { { "message", Message }, { "code", Code }, { "request", Request }, diff --git a/extra/admin-api/Models/Spacebar.Models.Generic/Webhook.cs b/extra/admin-api/Models/Spacebar.Models.Generic/Webhook.cs new file mode 100644 index 000000000..ba48649fc --- /dev/null +++ b/extra/admin-api/Models/Spacebar.Models.Generic/Webhook.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Spacebar.Models.Generic; + +public class Webhook { + [JsonPropertyName("id"), JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + public required long Id { get; set; } + + [JsonPropertyName("type")] + public WebhookType WebhookType { get; set; } + + [JsonPropertyName("guild_id"), JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + public long? GuildId { get; set; } + + [JsonPropertyName("channel_id"), JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + public long? ChannelId { get; set; } + + [JsonPropertyName("user")] + public PartialUser? User { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("avatar")] + public string? AvatarData { get; set; } + + [JsonPropertyName("token")] + public string? Token { get; set; } + + [JsonPropertyName("application_id"), JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] + public long? ApplicationId { get; set; } + + [JsonPropertyName("source_guild")] + public JsonObject? SourceGuild { get; set; } // TODO type + + [JsonPropertyName("source_channel")] + public JsonObject? SourceChannel { get; set; } // TODO type + + [JsonPropertyName("url")] + public string? Url { get; set; } +} + +public enum WebhookType : byte { + Incomming = 1, + ChannelFollower = 2, + Application = 3 +} \ No newline at end of file diff --git a/extra/admin-api/Tests/Spacebar.Tests/Abstractions/UserAbstraction.cs b/extra/admin-api/Tests/Spacebar.Tests/Abstractions/UserAbstraction.cs index b6cef1ac9..300e8035f 100644 --- a/extra/admin-api/Tests/Spacebar.Tests/Abstractions/UserAbstraction.cs +++ b/extra/admin-api/Tests/Spacebar.Tests/Abstractions/UserAbstraction.cs @@ -15,10 +15,12 @@ public class UserAbstraction(Config _config, SpacebarClientProviderService _clie var client = await _clientProvider.GetAuthenticatedClientAsync(_config.TestInstance, tokenResponse.Token); if (!withAutojoinGuilds) { - + await Task.Delay(1000); + var leaves = (await client.GetJoinedGuilds()).Select(x => client.GetGuild(x.Id).LeaveAsync()).ToList(); + await Task.WhenAll(leaves); + await Task.Delay(1000); } return client; } - } \ No newline at end of file diff --git a/extra/admin-api/Tests/Spacebar.Tests/Tests/AuthenticationTests.cs b/extra/admin-api/Tests/Spacebar.Tests/Tests/AuthenticationTests.cs index 04bd5f912..f927d7c59 100644 --- a/extra/admin-api/Tests/Spacebar.Tests/Tests/AuthenticationTests.cs +++ b/extra/admin-api/Tests/Spacebar.Tests/Tests/AuthenticationTests.cs @@ -1,4 +1,5 @@ -using System.Net.Http.Json; +using System.Diagnostics; +using System.Net.Http.Json; using System.Text.Json.Nodes; using ArcaneLibs.Extensions; using Spacebar.Models.Api; @@ -33,6 +34,7 @@ public class AuthenticationTests(ITestOutputHelper testOutputHelper, TestFixture [Fact] public async Task ConcurrentRegister50Users() { var tasks = Enumerable.Range(0, 50).Select(async _ => { + var sw = Stopwatch.StartNew(); var rr = new RegisterRequest() { Email = $"{Guid.NewGuid().ToString()}@{Guid.NewGuid().ToString()}.tld", Username = Guid.NewGuid().ToString(), @@ -40,7 +42,10 @@ public class AuthenticationTests(ITestOutputHelper testOutputHelper, TestFixture DateOfBirth = new(), Consent = true }; - return (rr, await Assert.SuccessfullyHttpPostAsJsonAsync($"{_config.TestInstance}/api/v9/auth/register", rr)); + + var result = await Assert.SuccessfullyHttpPostAsJsonAsync($"{_config.TestInstance}/api/v9/auth/register", rr); + testOutputHelper.WriteLine($"Registered {rr.Email} in {sw.Elapsed}..."); + return (rr, result); }).ToList(); await Task.WhenAll(tasks); diff --git a/extra/admin-api/Tests/Spacebar.Tests/Tests/ChannelTests.cs b/extra/admin-api/Tests/Spacebar.Tests/Tests/ChannelTests.cs index d04ee994b..cdee86b49 100644 --- a/extra/admin-api/Tests/Spacebar.Tests/Tests/ChannelTests.cs +++ b/extra/admin-api/Tests/Spacebar.Tests/Tests/ChannelTests.cs @@ -23,7 +23,7 @@ public class ChannelTests(ITestOutputHelper testOutputHelper, TestFixture fixtur [Fact] public async Task CreateChannel() { - var client = await _userAbstraction.GetFreshUser(); + var client = await _userAbstraction.GetFreshUser(withAutojoinGuilds: true); var guild = await client.CreateGuild(new() { Name = "Test guild" }); @@ -40,13 +40,14 @@ public class ChannelTests(ITestOutputHelper testOutputHelper, TestFixture fixtur [Fact] public async Task GetChannel() { - var client = await _userAbstraction.GetFreshUser(); + var client = await _userAbstraction.GetFreshUser(withAutojoinGuilds: true); var guild = await client.CreateGuild(new() { Name = "Test guild" }); Assert.Equal("Test guild", guild.Name); - + + // await Task.Delay(1000, TestContext.Current.CancellationToken); // TODO: unflake var channel = await client.GetGuild(guild.Id).CreateChannelAsync(new() { Name = "test", Type = 0 @@ -54,6 +55,7 @@ public class ChannelTests(ITestOutputHelper testOutputHelper, TestFixture fixtur Assert.Equal("test", channel.Name); + // await Task.Delay(1000, TestContext.Current.CancellationToken); // TODO: unflake var res = await client.ApiHttpClient.GetAsync("channels/" + channel.Id, TestContext.Current.CancellationToken); await Assert.HttpSuccess(res); diff --git a/extra/admin-api/Tests/Spacebar.Tests/Tests/GuildTests.cs b/extra/admin-api/Tests/Spacebar.Tests/Tests/GuildTests.cs index 12e566f03..88490e0de 100644 --- a/extra/admin-api/Tests/Spacebar.Tests/Tests/GuildTests.cs +++ b/extra/admin-api/Tests/Spacebar.Tests/Tests/GuildTests.cs @@ -21,7 +21,7 @@ public class GuildTests(ITestOutputHelper testOutputHelper, TestFixture fixture) [Fact] public async Task CreateGuild() { - var client = await _userAbstraction.GetFreshUser(); + var client = await _userAbstraction.GetFreshUser(withAutojoinGuilds: true); var guild = await client.CreateGuild(new() { Name = "Test guild" }); @@ -31,7 +31,7 @@ public class GuildTests(ITestOutputHelper testOutputHelper, TestFixture fixture) [Fact] public async Task GetChannels() { - var client = await _userAbstraction.GetFreshUser(); + var client = await _userAbstraction.GetFreshUser(withAutojoinGuilds: true); var guild = await client.CreateGuild(new() { Name = "Test guild" }); diff --git a/extra/admin-api/Tests/Spacebar.Tests/Tests/Meta/UserAbstractionTests.cs b/extra/admin-api/Tests/Spacebar.Tests/Tests/Meta/UserAbstractionTests.cs index 81f53f7f3..64f80d0a9 100644 --- a/extra/admin-api/Tests/Spacebar.Tests/Tests/Meta/UserAbstractionTests.cs +++ b/extra/admin-api/Tests/Spacebar.Tests/Tests/Meta/UserAbstractionTests.cs @@ -10,7 +10,7 @@ public class UserAbstractionTests(ITestOutputHelper testOutputHelper, TestFixtur [Fact] public async Task CanGetUser() { - var res = await _config.GetFreshUser(); + var res = await _config.GetFreshUser(withAutojoinGuilds: true); Assert.StringNotNullOrWhitespace(res.ApiHttpClient.BaseAddress!.ToString()); } } \ No newline at end of file diff --git a/extra/admin-api/Tests/Spacebar.Tests/Tests/WebhookTests.cs b/extra/admin-api/Tests/Spacebar.Tests/Tests/WebhookTests.cs new file mode 100644 index 000000000..a03ee6334 --- /dev/null +++ b/extra/admin-api/Tests/Spacebar.Tests/Tests/WebhookTests.cs @@ -0,0 +1,137 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json.Nodes; +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 WebhookTests(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 CreateWebhook() { + var client = await _userAbstraction.GetFreshUser(withAutojoinGuilds: true); + 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 cChannel = client.GetChannel(channel.Id); + var wh = await cChannel.CreateWebhookAsync(new() { + Name = "meow" + }); + + Assert.Equal("meow", wh.Name); + Assert.StringNotNullOrWhitespace(wh.Url); + } + + [Fact] + public async Task CreateMultipleWebhooks() { + var client = await _userAbstraction.GetFreshUser(withAutojoinGuilds: true); + 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 cChannel = client.GetChannel(channel.Id); + + var count = Random.Shared.Next(10); + testOutputHelper.WriteLine($"Creating {count} webhooks..."); + await Task.WhenAll(Enumerable.Range(0, count).Select(i => cChannel.CreateWebhookAsync(new() { + Name = "meow" + i + })).ToList()); + + var wh = await cChannel.GetWebhooksAsync(); + Assert.All(wh, h => Assert.StartsWith("meow", h.Name)); + Assert.All(wh, h => Assert.StringNotNullOrWhitespace(h.Url)); + } + + [Fact] + public async Task SendWebhookMessageWithWait() { + var client = await _userAbstraction.GetFreshUser(withAutojoinGuilds: true); + 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 cChannel = client.GetChannel(channel.Id); + var wh = await cChannel.CreateWebhookAsync(new() { + Name = "meow" + }); + + Assert.Equal("meow", wh.Name); + Assert.StringNotNullOrWhitespace(wh.Url); + + await Assert.SuccessfullyHttpPostAsJsonAsync(wh.Url + "?wait=true", new JsonObject() { + { "content", "meow" } + }); + } + + [Fact] + public async Task SendWebhookMessage() { + var client = await _userAbstraction.GetFreshUser(withAutojoinGuilds: true); + 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 cChannel = client.GetChannel(channel.Id); + var wh = await cChannel.CreateWebhookAsync(new() { + Name = "meow" + }); + + Assert.Equal("meow", wh.Name); + Assert.StringNotNullOrWhitespace(wh.Url); + + await Assert.SuccessfullyHttpPostAsJsonAsync(wh.Url, new JsonObject() { + { "content", "meow" } + }); + } +} \ No newline at end of file diff --git a/extra/admin-api/Utilities/Spacebar.Sdk/Core/SpacebarClient.cs b/extra/admin-api/Utilities/Spacebar.Sdk/Core/SpacebarClient.cs index 5843b2710..2b4a4ba47 100644 --- a/extra/admin-api/Utilities/Spacebar.Sdk/Core/SpacebarClient.cs +++ b/extra/admin-api/Utilities/Spacebar.Sdk/Core/SpacebarClient.cs @@ -73,6 +73,13 @@ public class AuthenticatedSpacebarClient { if (!resp.IsSuccessStatusCode) throw SpacebarApiException.FromJson((await resp.Content.ReadFromJsonAsync())!); return (await resp.Content.ReadFromJsonAsync())!; } + + public async Task> GetJoinedGuilds() { + var resp = await ApiHttpClient.GetAsync("users/@me/guilds"); + // TODO: abstract out + if (!resp.IsSuccessStatusCode) throw SpacebarApiException.FromJson((await resp.Content.ReadFromJsonAsync())!); + return (await resp.Content.ReadFromJsonAsync>())!; + } } public class SpacebarClientChannel(AuthenticatedSpacebarClient client, long channelId) { @@ -91,6 +98,22 @@ public class SpacebarClientChannel(AuthenticatedSpacebarClient client, long chan Console.WriteLine(data.ToJson(indent: false, ignoreNull: true)); return data.Select(x => x.Deserialize()).ToList(); } + + public async Task CreateWebhookAsync(CreateWebhookRequest req) { + var resp = await client.ApiHttpClient.PostAsJsonAsync($"channels/{channelId}/webhooks", req, new JsonSerializerOptions() { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }); + // TODO: abstract out + if (!resp.IsSuccessStatusCode) throw SpacebarApiException.FromJson((await resp.Content.ReadFromJsonAsync())!); + return (await resp.Content.ReadFromJsonAsync())!; + } + + public async Task> GetWebhooksAsync() { + var resp = await client.ApiHttpClient.GetAsync($"channels/{channelId}/webhooks"); + // TODO: abstract out + if (!resp.IsSuccessStatusCode) throw SpacebarApiException.FromJson((await resp.Content.ReadFromJsonAsync())!); + return (await resp.Content.ReadFromJsonAsync>())!; + } } public class SpacebarClientGuild(AuthenticatedSpacebarClient client, long guildId) { @@ -115,6 +138,19 @@ public class SpacebarClientGuild(AuthenticatedSpacebarClient client, long guildI if (!resp.IsSuccessStatusCode) throw SpacebarApiException.FromJson((await resp.Content.ReadFromJsonAsync())!); return (await resp.Content.ReadFromJsonAsync())!; } + + public async Task LeaveAsync(bool lurking = false) { + var req = new HttpRequestMessage(HttpMethod.Delete, $"users/@me/guilds/{guildId}") { + Content = new StringContent(new JsonObject() { + { "lurking", lurking } + }.ToJsonString(new JsonSerializerOptions() { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + })) + }; + var resp = await client.ApiHttpClient.SendAsync(req); + // TODO: abstract out + if (!resp.IsSuccessStatusCode) throw SpacebarApiException.FromJson((await resp.Content.ReadFromJsonAsync())!); + } } public class AuthenticatedSpacebarGatewayClient(ILogger logger, SpacebarClientWellKnown wellKnown, string token) {