using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Implementation;
namespace SeaHavenIndustries.Tests;
///
/// Provider-backed media concurrency: two independently tracked contexts share one SQLite
/// connection so EF concurrency tokens and competing writes are exercised (not InMemory).
///
public class WorkOrderMediaConcurrencyRelationalTests
{
[Fact]
public async Task CategorizeVsCategorize_SecondWriter_GetsConcurrencyConflict_WithoutPartialAudit()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var options = CreateOptions(connection);
await using (var seed = new SqliteMediaTestDbContext(options))
{
await seed.Database.EnsureCreatedAsync();
seed.Users.AddRange(
new ApplicationUser
{
Id = "actor-a",
UserName = "actor-a",
NormalizedUserName = "ACTOR-A",
Email = "a@test.local",
NormalizedEmail = "A@TEST.LOCAL"
},
new ApplicationUser
{
Id = "actor-b",
UserName = "actor-b",
NormalizedUserName = "ACTOR-B",
Email = "b@test.local",
NormalizedEmail = "B@TEST.LOCAL"
});
seed.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
seed.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 1,
Attachments = "https://example.com/photo.jpg",
Category = WorkOrderMediaCategory.Extra
});
await seed.SaveChangesAsync();
}
await using var contextA = new SqliteMediaTestDbContext(options);
await using var contextB = new SqliteMediaTestDbContext(options);
var serviceA = CreateService(contextA);
var serviceB = CreateService(contextB);
var versionA = await LoadVersionAsync(contextA, workOrderId: 1);
var versionB = await LoadVersionAsync(contextB, workOrderId: 1);
Assert.Equal(versionA, versionB);
var winner = await serviceA.UpdateMediaCategoryAsync(
1, 10, WorkOrderMediaCategory.Before, versionA, Admin("actor-a"), "actor-a");
Assert.Equal(WorkOrderMediaCategory.Before, winner.Category);
var loser = await Assert.ThrowsAsync(() =>
serviceB.UpdateMediaCategoryAsync(
1, 10, WorkOrderMediaCategory.After, versionB, Admin("actor-b"), "actor-b"));
Assert.Equal("ConcurrencyConflict", loser.Code);
await using var verify = new SqliteMediaTestDbContext(options);
var wo = await verify.workOrders.AsNoTracking().SingleAsync(w => w.Id == 1);
var attachment = await verify.workOrderAttachments.AsNoTracking().SingleAsync(a => a.Id == 10);
var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync();
Assert.Equal("https://example.com/photo.jpg", wo.BeforPhotoAttachment);
Assert.True(string.IsNullOrEmpty(wo.AfterPhotoAttachment));
Assert.True(attachment.IsDeleted);
Assert.Single(audits);
Assert.Contains(audits, a => a.NewValue == "10:Before");
Assert.DoesNotContain(audits, a => a.NewValue == "10:After");
}
[Fact]
public async Task CategorizeVsDelete_SecondWriter_GetsConcurrencyConflict_WithoutPartialState()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var options = CreateOptions(connection);
await using (var seed = new SqliteMediaTestDbContext(options))
{
await seed.Database.EnsureCreatedAsync();
seed.Users.AddRange(
new ApplicationUser
{
Id = "actor-a",
UserName = "actor-a",
NormalizedUserName = "ACTOR-A",
Email = "a@test.local",
NormalizedEmail = "A@TEST.LOCAL"
},
new ApplicationUser
{
Id = "actor-b",
UserName = "actor-b",
NormalizedUserName = "ACTOR-B",
Email = "b@test.local",
NormalizedEmail = "B@TEST.LOCAL"
});
seed.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
seed.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 1,
Attachments = "https://example.com/photo.jpg",
Category = WorkOrderMediaCategory.Extra
});
await seed.SaveChangesAsync();
}
await using var categorizeContext = new SqliteMediaTestDbContext(options);
await using var deleteContext = new SqliteMediaTestDbContext(options);
var categorizeService = CreateService(categorizeContext);
var deleteService = CreateService(deleteContext);
var categorizeVersion = await LoadVersionAsync(categorizeContext, workOrderId: 1);
var deleteVersion = await LoadVersionAsync(deleteContext, workOrderId: 1);
await categorizeService.UpdateMediaCategoryAsync(
1, 10, WorkOrderMediaCategory.Before, categorizeVersion, Admin("actor-a"), "actor-a");
var loser = await Assert.ThrowsAsync(() =>
deleteService.DeleteMediaAsync(
1, 10, deleteVersion, Admin("actor-b"), "actor-b"));
Assert.Equal("ConcurrencyConflict", loser.Code);
await using var verify = new SqliteMediaTestDbContext(options);
var wo = await verify.workOrders.AsNoTracking().SingleAsync(w => w.Id == 1);
var attachment = await verify.workOrderAttachments.AsNoTracking().SingleAsync(a => a.Id == 10);
var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync();
Assert.Equal("https://example.com/photo.jpg", wo.BeforPhotoAttachment);
Assert.True(attachment.IsDeleted);
Assert.Single(audits);
Assert.Contains(audits, a => a.NewValue == "10:Before");
Assert.DoesNotContain(audits, a => a.NewValue != null && a.NewValue.EndsWith(":Deleted", StringComparison.Ordinal));
}
private static DbContextOptions CreateOptions(SqliteConnection connection)
=> new DbContextOptionsBuilder()
.UseSqlite(connection)
.Options;
private static WorkOrderMediaService CreateService(ApplicationDbContext context)
{
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
return new WorkOrderMediaService(
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit,
new TestFileStoragePort());
}
private static async Task LoadVersionAsync(ApplicationDbContext context, int workOrderId)
{
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrderId);
return Convert.ToBase64String(wo.RowVersion!);
}
private static ClaimsPrincipal Admin(string actorId)
=> new(new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.NameIdentifier, actorId),
new Claim(ClaimTypes.Role, "Admin"),
new Claim(SeaHaven.Services.Helpers.SeaHavenClaimTypes.OrgScope, SeaHaven.Services.Helpers.SeaHavenClaimTypes.OrgScopeAll)
},
"Test"));
private sealed class SqliteMediaTestDbContext : ApplicationDbContext
{
public SqliteMediaTestDbContext(DbContextOptions options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes()))
{
if (index.GetFilter() != null)
index.SetFilter(null);
}
// SQLite has no rowversion type; keep a byte[] concurrency token and bump on save.
var property = builder.Entity().Property(w => w.RowVersion).Metadata;
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
property.IsConcurrencyToken = true;
}
public override int SaveChanges()
{
BumpWorkOrderRowVersions();
return base.SaveChanges();
}
public override Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
BumpWorkOrderRowVersions();
return base.SaveChangesAsync(cancellationToken);
}
private void BumpWorkOrderRowVersions()
{
foreach (var entry in ChangeTracker.Entries())
{
if (entry.State != EntityState.Modified)
continue;
var current = entry.Entity.RowVersion != null && entry.Entity.RowVersion.Length > 0
? (byte[])entry.Entity.RowVersion.Clone()
: new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 };
for (var i = 0; i < current.Length; i++)
{
if (++current[i] != 0)
break;
}
entry.Entity.RowVersion = current;
}
}
}
}