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));