mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 11:53:12 +00:00
The 10-photo / 3-video cap was a check-then-insert with no lock on both upload surfaces, so two overlapping uploads could both take the last slot. The board media upload and the vendor portal upload now run count, insert and save inside ExecuteWorkOrderMutationAsync. GetMediaContent maps .heic to image/heic.
255 lines
10 KiB
C#
255 lines
10 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Provider-backed media concurrency: two independently tracked contexts share one SQLite
|
|
/// connection so EF concurrency tokens and competing writes are exercised (not InMemory).
|
|
/// </summary>
|
|
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<WorkOrderBoardValidationException>(() =>
|
|
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<WorkOrderBoardValidationException>(() =>
|
|
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<ApplicationDbContext> CreateOptions(SqliteConnection connection)
|
|
=> new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
.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(),
|
|
new UpliftDataService(context));
|
|
}
|
|
|
|
private static async Task<string> 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<ApplicationDbContext> 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.GetDatabaseName() != "IX_AspNetUsers_IsAccountOwner")
|
|
index.SetFilter(null);
|
|
}
|
|
|
|
// SQLite has no rowversion type; keep a byte[] concurrency token and bump on save.
|
|
var property = builder.Entity<WorkOrder>().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<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
BumpWorkOrderRowVersions();
|
|
return base.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
private void BumpWorkOrderRowVersions()
|
|
{
|
|
foreach (var entry in ChangeTracker.Entries<WorkOrder>())
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|