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