mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 09:33:13 +00:00
254 lines
10 KiB
C#
254 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());
|
|
}
|
|
|
|
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.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;
|
|
}
|
|
}
|
|
}
|
|
}
|