diff --git a/test/SeederApi.IntegrationTest/HttpClientExtensions.cs b/test/SeederApi.IntegrationTest/HttpClientExtensions.cs new file mode 100644 index 0000000000..6a7fa8b552 --- /dev/null +++ b/test/SeederApi.IntegrationTest/HttpClientExtensions.cs @@ -0,0 +1,41 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net.Http.Json; +using System.Text.Json; + +namespace Bit.SeederApi.IntegrationTest; + +public static class HttpClientExtensions +{ + /// + /// Sends a POST request with JSON content and attaches the x-play-id header. + /// + /// The type of the value to serialize. + /// The HTTP client. + /// The URI the request is sent to. + /// The value to serialize. + /// The play ID to attach as x-play-id header. + /// Options to control the behavior during serialization. + /// A cancellation token that can be used to cancel the operation. + /// The task object representing the asynchronous operation. + public static Task PostAsJsonAsync( + this HttpClient client, + [StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri, + TValue value, + string playId, + JsonSerializerOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(client); + + if (string.IsNullOrWhiteSpace(playId)) + { + throw new ArgumentException("Play ID cannot be null or whitespace.", nameof(playId)); + } + + var content = JsonContent.Create(value, mediaType: null, options); + content.Headers.Remove("x-play-id"); + content.Headers.Add("x-play-id", playId); + + return client.PostAsync(requestUri, content, cancellationToken); + } +} diff --git a/test/SeederApi.IntegrationTest/QueryControllerTest.cs b/test/SeederApi.IntegrationTest/QueryControllerTest.cs index a453b2febb..2bba92786d 100644 --- a/test/SeederApi.IntegrationTest/QueryControllerTest.cs +++ b/test/SeederApi.IntegrationTest/QueryControllerTest.cs @@ -4,12 +4,12 @@ using Xunit; namespace Bit.SeederApi.IntegrationTest; -public class EmergencyAccessInviteQueryTests : IClassFixture, IAsyncLifetime +public class QueryControllerTests : IClassFixture, IAsyncLifetime { private readonly HttpClient _client; private readonly SeederApiApplicationFactory _factory; - public EmergencyAccessInviteQueryTests(SeederApiApplicationFactory factory) + public QueryControllerTests(SeederApiApplicationFactory factory) { _factory = factory; _client = _factory.CreateClient(); @@ -38,16 +38,11 @@ public class EmergencyAccessInviteQueryTests : IClassFixture(); + var result = await response.Content.ReadAsStringAsync(); Assert.NotNull(result); - Assert.NotNull(result.Result); - // The result should be a JSON array (even if empty for non-existent email) - var resultElement = result.Result as System.Text.Json.JsonElement?; - Assert.NotNull(resultElement); - - var urls = System.Text.Json.JsonSerializer.Deserialize>(resultElement.Value.GetRawText()); + var urls = System.Text.Json.JsonSerializer.Deserialize>(result); Assert.NotNull(urls); // For a non-existent email, we expect an empty list Assert.Empty(urls); @@ -90,19 +85,9 @@ public class EmergencyAccessInviteQueryTests : IClassFixture(); + var result = await response.Content.ReadAsStringAsync(); - Assert.NotNull(result); - - // Verify the response only has Result field, not SeedId - // (Queries are read-only and don't track entities) - var jsonString = await response.Content.ReadAsStringAsync(); - Assert.DoesNotContain("seedId", jsonString, StringComparison.OrdinalIgnoreCase); - Assert.Contains("result", jsonString, StringComparison.OrdinalIgnoreCase); + Assert.Equal("[]", result); } - private class QueryResponse - { - public object? Result { get; set; } - } } diff --git a/test/SeederApi.IntegrationTest/SeedControllerTest.cs b/test/SeederApi.IntegrationTest/SeedControllerTest.cs index ced27d9f55..d989574fb4 100644 --- a/test/SeederApi.IntegrationTest/SeedControllerTest.cs +++ b/test/SeederApi.IntegrationTest/SeedControllerTest.cs @@ -29,21 +29,21 @@ public class SeedControllerTests : IClassFixture, I } [Fact] - public async Task SeedEndpoint_WithValidScene_ReturnsOkWithSeedId() + public async Task SeedEndpoint_WithValidScene_ReturnsOk() { var testEmail = $"seed-test-{Guid.NewGuid()}@bitwarden.com"; + var playId = Guid.NewGuid().ToString(); var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel { Template = "SingleUserScene", Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) - }); + }, playId); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadFromJsonAsync(); Assert.NotNull(result); - Assert.NotEqual(Guid.Empty, result.SeedId); Assert.NotNull(result.MangleMap); Assert.Null(result.Result); } @@ -74,60 +74,63 @@ public class SeedControllerTests : IClassFixture, I } [Fact] - public async Task DeleteEndpoint_WithValidSeedId_ReturnsOk() + public async Task DeleteEndpoint_WithValidPlayId_ReturnsOk() { var testEmail = $"delete-test-{Guid.NewGuid()}@bitwarden.com"; + var playId = Guid.NewGuid().ToString(); + var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel { Template = "SingleUserScene", Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) - }); + }, playId); seedResponse.EnsureSuccessStatusCode(); var seedResult = await seedResponse.Content.ReadFromJsonAsync(); Assert.NotNull(seedResult); - var deleteResponse = await _client.DeleteAsync($"/seed/{seedResult.SeedId}"); + var deleteResponse = await _client.DeleteAsync($"/seed/{playId}"); deleteResponse.EnsureSuccessStatusCode(); } [Fact] - public async Task DeleteEndpoint_WithInvalidSeedId_ReturnsOkWithNull() + public async Task DeleteEndpoint_WithInvalidPlayId_ReturnsOk() { - // DestroyRecipe is idempotent - returns null for non-existent seeds - var nonExistentSeedId = Guid.NewGuid(); - var response = await _client.DeleteAsync($"/seed/{nonExistentSeedId}"); + // DestroyRecipe is idempotent - returns null for non-existent play IDs + var nonExistentPlayId = Guid.NewGuid().ToString(); + var response = await _client.DeleteAsync($"/seed/{nonExistentPlayId}"); response.EnsureSuccessStatusCode(); var content = await response.Content.ReadAsStringAsync(); - Assert.Equal("null", content); + Assert.Equal($$"""{"playId":"{{nonExistentPlayId}}"}""", content); } [Fact] - public async Task DeleteBatchEndpoint_WithValidSeedIds_ReturnsOk() + public async Task DeleteBatchEndpoint_WithValidPlayIds_ReturnsOk() { - // Create multiple seeds - var seedIds = new List(); + // Create multiple seeds with different play IDs + var playIds = new List(); for (var i = 0; i < 3; i++) { + var playId = Guid.NewGuid().ToString(); + playIds.Add(playId); + var testEmail = $"batch-test-{Guid.NewGuid()}@bitwarden.com"; var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel { Template = "SingleUserScene", Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) - }); + }, playId); seedResponse.EnsureSuccessStatusCode(); var seedResult = await seedResponse.Content.ReadFromJsonAsync(); Assert.NotNull(seedResult); - Assert.NotNull(seedResult.SeedId); - seedIds.Add(seedResult.SeedId.Value); } // Delete them in batch var request = new HttpRequestMessage(HttpMethod.Delete, "/seed/batch") { - Content = JsonContent.Create(seedIds) + Content = JsonContent.Create(playIds) }; var deleteResponse = await _client.SendAsync(request); deleteResponse.EnsureSuccessStatusCode(); @@ -141,24 +144,25 @@ public class SeedControllerTests : IClassFixture, I public async Task DeleteBatchEndpoint_WithSomeInvalidIds_ReturnsOk() { // DestroyRecipe is idempotent - batch delete succeeds even with non-existent IDs - // Create one valid seed + // Create one valid seed with a play ID + var validPlayId = Guid.NewGuid().ToString(); var testEmail = $"batch-partial-test-{Guid.NewGuid()}@bitwarden.com"; + var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel { Template = "SingleUserScene", Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) - }); + }, validPlayId); seedResponse.EnsureSuccessStatusCode(); var seedResult = await seedResponse.Content.ReadFromJsonAsync(); Assert.NotNull(seedResult); // Try to delete with mix of valid and invalid IDs - Assert.NotNull(seedResult.SeedId); - var seedIds = new List { seedResult.SeedId.Value, Guid.NewGuid(), Guid.NewGuid() }; + var playIds = new List { validPlayId, Guid.NewGuid().ToString(), Guid.NewGuid().ToString() }; var request = new HttpRequestMessage(HttpMethod.Delete, "/seed/batch") { - Content = JsonContent.Create(seedIds) + Content = JsonContent.Create(playIds) }; var deleteResponse = await _client.SendAsync(request); @@ -174,13 +178,17 @@ public class SeedControllerTests : IClassFixture, I // Create multiple seeds for (var i = 0; i < 2; i++) { + var playId = Guid.NewGuid().ToString(); var testEmail = $"deleteall-test-{Guid.NewGuid()}@bitwarden.com"; + var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel { Template = "SingleUserScene", Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) - }); + }, playId); + var body = await seedResponse.Content.ReadAsStringAsync(); + Console.WriteLine(body); seedResponse.EnsureSuccessStatusCode(); } @@ -190,21 +198,22 @@ public class SeedControllerTests : IClassFixture, I } [Fact] - public async Task SeedEndpoint_VerifyResponseContainsSeedIdAndResult() + public async Task SeedEndpoint_VerifyResponseContainsMangleMapAndResult() { var testEmail = $"verify-response-{Guid.NewGuid()}@bitwarden.com"; + var playId = Guid.NewGuid().ToString(); var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel { Template = "SingleUserScene", Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) - }); + }, playId); response.EnsureSuccessStatusCode(); var jsonString = await response.Content.ReadAsStringAsync(); - // Verify the response contains both SeedId and Result fields - Assert.Contains("seedId", jsonString, StringComparison.OrdinalIgnoreCase); + // Verify the response contains MangleMap and Result fields + Assert.Contains("mangleMap", jsonString, StringComparison.OrdinalIgnoreCase); Assert.Contains("result", jsonString, StringComparison.OrdinalIgnoreCase); } diff --git a/util/SeederApi/Controllers/QueryController.cs b/util/SeederApi/Controllers/QueryController.cs index 45eeccfaed..ac07981b95 100644 --- a/util/SeederApi/Controllers/QueryController.cs +++ b/util/SeederApi/Controllers/QueryController.cs @@ -18,11 +18,11 @@ public class QueryController(ILogger logger, IQueryService quer return Json(result); } - catch (SceneNotFoundException ex) + catch (QueryNotFoundException ex) { return NotFound(new { Error = ex.Message }); } - catch (SceneExecutionException ex) + catch (QueryExecutionException ex) { logger.LogError(ex, "Error executing query: {Query}", request.Template); return BadRequest(new { Error = ex.Message, Details = ex.InnerException?.Message }); diff --git a/util/SeederApi/Program.cs b/util/SeederApi/Program.cs index 9e8574656f..f88c17b5db 100644 --- a/util/SeederApi/Program.cs +++ b/util/SeederApi/Program.cs @@ -21,6 +21,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(_ => new MangleId()); builder.Services.AddScenes(); builder.Services.AddQueries(); @@ -41,3 +42,6 @@ app.UseRouting(); app.MapControllerRoute(name: "default", pattern: "{controller=Seed}/{action=Index}/{id?}"); app.Run(); + +// Make Program class accessible for integration tests +public partial class Program { } diff --git a/util/SeederApi/Services/SceneService.cs b/util/SeederApi/Services/SceneService.cs index 5cfda54a0e..4f012102c5 100644 --- a/util/SeederApi/Services/SceneService.cs +++ b/util/SeederApi/Services/SceneService.cs @@ -43,12 +43,13 @@ public class SceneService( var userIds = playData.Select(pd => pd.UserId).Distinct().ToList(); var organizationIds = playData.Select(pd => pd.OrganizationId).Distinct().ToList(); - // Delete Users before Oraganizations to respect foreign key constraints + // Delete Users before Organizations to respect foreign key constraints if (userIds.Count > 0) { var users = databaseContext.Users.Where(u => userIds.Contains(u.Id)); await userRepository.DeleteManyAsync(users); } + if (organizationIds.Count > 0) { var organizations = databaseContext.Organizations.Where(o => organizationIds.Contains(o.Id));