97 lines
4 KiB
C#
97 lines
4 KiB
C#
using D3ROVoice.Api.Data;
|
|
using D3ROVoice.Api.Services;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Xunit;
|
|
|
|
namespace D3ROVoice.Api.Tests;
|
|
|
|
public sealed class AdminOperationServiceTests
|
|
{
|
|
[Fact]
|
|
public async Task MutationIsAtomicAuditedAndIdempotent()
|
|
{
|
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
|
.UseSqlite("Data Source=:memory:")
|
|
.Options;
|
|
await using var db = new AppDbContext(options);
|
|
await db.Database.OpenConnectionAsync();
|
|
await db.Database.EnsureCreatedAsync();
|
|
var service = new AdminOperationService(db);
|
|
var idempotencyKey = Guid.NewGuid().ToString("D");
|
|
var mutationCalls = 0;
|
|
|
|
async Task<object> Mutate()
|
|
{
|
|
mutationCalls += 1;
|
|
var endpoint = new ServiceModelEndpoint
|
|
{
|
|
ModelId = "real-model",
|
|
ModelName = "Real Model",
|
|
Provider = "Custom",
|
|
EndpointUrl = "https://models.example.com/v1",
|
|
ApiKey = "secret-never-in-response"
|
|
};
|
|
db.ModelEndpoints.Add(endpoint);
|
|
await db.SaveChangesAsync();
|
|
return new { endpoint.Id, endpoint.ModelId };
|
|
}
|
|
|
|
var first = await service.ExecuteAsync(
|
|
"ADMIN@EXAMPLE.COM", "model_endpoint.create", idempotencyKey,
|
|
new { modelId = "real-model" }, "model_endpoint", _ => "real-model",
|
|
"approved provider setup", () => Task.FromResult<object?>(null), Mutate);
|
|
var replay = await service.ExecuteAsync(
|
|
"admin@example.com", "model_endpoint.create", idempotencyKey,
|
|
new { modelId = "real-model" }, "model_endpoint", _ => "real-model",
|
|
"approved provider setup", () => Task.FromResult<object?>(null), Mutate);
|
|
|
|
Assert.Equal(1, mutationCalls);
|
|
Assert.Equal(first.GetRawText(), replay.GetRawText());
|
|
Assert.Equal(1, await db.ModelEndpoints.CountAsync());
|
|
Assert.Equal(1, await db.AdminOperationRequests.CountAsync());
|
|
var audit = await db.AdminAuditEntries.SingleAsync();
|
|
Assert.Equal("admin@example.com", audit.ActorEmail);
|
|
Assert.Equal("model_endpoint.create", audit.Action);
|
|
Assert.Equal(idempotencyKey, audit.IdempotencyKey);
|
|
|
|
await Assert.ThrowsAsync<AdminOperationException>(() => service.ExecuteAsync(
|
|
"admin@example.com", "model_endpoint.create", idempotencyKey,
|
|
new { modelId = "different-model" }, "model_endpoint", _ => "different-model",
|
|
"different request", () => Task.FromResult<object?>(null), Mutate));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FailedMutationRollsBackDomainAndAuditWrites()
|
|
{
|
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
|
.UseSqlite("Data Source=:memory:")
|
|
.Options;
|
|
await using var db = new AppDbContext(options);
|
|
await db.Database.OpenConnectionAsync();
|
|
await db.Database.EnsureCreatedAsync();
|
|
var service = new AdminOperationService(db);
|
|
|
|
async Task<object> FailingMutation()
|
|
{
|
|
db.ModelEndpoints.Add(new ServiceModelEndpoint
|
|
{
|
|
ModelId = "rollback-model",
|
|
ModelName = "Rollback Model",
|
|
Provider = "Custom",
|
|
EndpointUrl = "https://models.example.com/v1"
|
|
});
|
|
await db.SaveChangesAsync();
|
|
throw new InvalidOperationException("simulated persistence failure");
|
|
}
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => service.ExecuteAsync(
|
|
"admin@example.com", "model_endpoint.create", Guid.NewGuid().ToString("D"),
|
|
new { modelId = "rollback-model" }, "model_endpoint", _ => "rollback-model",
|
|
"rollback verification", () => Task.FromResult<object?>(null), FailingMutation));
|
|
|
|
db.ChangeTracker.Clear();
|
|
Assert.Empty(await db.ModelEndpoints.ToListAsync());
|
|
Assert.Empty(await db.AdminOperationRequests.ToListAsync());
|
|
Assert.Empty(await db.AdminAuditEntries.ToListAsync());
|
|
}
|
|
}
|