Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentImportBatchServiceTests.cs
Joseph Doherty ad131932d3 Phase 6.4 Stream B.2-B.4 server-side — EquipmentImportBatch staging + FinaliseBatch transaction
Closes the server-side/data-layer piece of Phase 6.4 Stream B.2-B.4. The
CSV-import preview + modal UI (Stream B.3/B.5) still belongs to the Admin
UI follow-up — this PR owns the staging tables + atomic finalise alone.

Configuration:
- New EquipmentImportBatch entity (Id, ClusterId, CreatedBy, CreatedAtUtc,
  RowsStaged/Accepted/Rejected, FinalisedAtUtc?). Composite index on
  (CreatedBy, FinalisedAtUtc) powers the Admin preview modal's "my open
  batches" query.
- New EquipmentImportRow entity — one row per CSV row, 8 required columns
  from decision #117 + 9 optional from decision #139 + IsAccepted flag +
  RejectReason. FK to EquipmentImportBatch with cascade delete so
  DropBatch collapses the whole tree.
- EF migration 20260419_..._AddEquipmentImportBatch.
- SchemaComplianceTests expected tables list gains the two new tables.

Admin.Services.EquipmentImportBatchService:
- CreateBatchAsync — new header row, caller-supplied ClusterId + CreatedBy.
- StageRowsAsync(batchId, acceptedRows, rejectedRows) — bulk-inserts the
  parsed CSV rows into staging. Rejected rows carry LineNumberInFile +
  RejectReason for the preview modal. Throws when the batch is finalised.
- DropBatchAsync — removes batch + cascaded rows. Throws when the batch
  was already finalised (rollback via staging is not a time machine).
- FinaliseBatchAsync(batchId, generationId, driverInstanceId, unsLineId) —
  atomic apply. Opens an EF transaction when the provider supports it
  (SQL Server in prod; InMemory in tests skips the tx), bulk-inserts
  every accepted staging row into Equipment, stamps
  EquipmentImportBatch.FinalisedAtUtc, commits. Failure rolls back so
  Equipment never partially mutates. Idempotent-under-double-call:
  second finalise throws ImportBatchAlreadyFinalisedException.
- ListByUserAsync(createdBy, includeFinalised) — the Admin preview modal's
  backing query. OrderByDescending on CreatedAtUtc so the most-recent
  batch shows first.
- Two exception types: ImportBatchNotFoundException +
  ImportBatchAlreadyFinalisedException.

ExternalIdReservation merging (ZTag + SAPID fleet-wide uniqueness) is NOT
done here — a narrower follow-up wires it once the concurrent-insert test
matrix is green.

Tests (10 new EquipmentImportBatchServiceTests, all pass):
- CreateBatch populates Id + CreatedAtUtc + zero-ed counters.
- StageRows accepted + rejected both persist; counters advance.
- DropBatch cascades row delete.
- DropBatch after finalise throws.
- Finalise translates accepted staging rows → Equipment under the target
  GenerationId + DriverInstanceId + UnsLineId.
- Finalise twice throws.
- Finalise of unknown batch throws.
- Stage after finalise throws.
- ListByUserAsync filters by creator + finalised flag.
- Drop of unknown batch is a no-op (idempotent rollback).

Full solution dotnet test: 1235 passing (was 1225, +10). Pre-existing
Client.CLI Subscribe flake unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:55:39 -04:00

166 lines
6.5 KiB
C#

using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class EquipmentImportBatchServiceTests : IDisposable
{
private readonly OtOpcUaConfigDbContext _db;
private readonly EquipmentImportBatchService _svc;
public EquipmentImportBatchServiceTests()
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase($"import-batch-{Guid.NewGuid():N}")
.Options;
_db = new OtOpcUaConfigDbContext(options);
_svc = new EquipmentImportBatchService(_db);
}
public void Dispose() => _db.Dispose();
private static EquipmentCsvRow Row(string zTag, string name = "eq-1") => new()
{
ZTag = zTag,
MachineCode = "mc",
SAPID = "sap",
EquipmentId = "eq-id",
EquipmentUuid = Guid.NewGuid().ToString(),
Name = name,
UnsAreaName = "area",
UnsLineName = "line",
};
[Fact]
public async Task CreateBatch_PopulatesId_AndTimestamp()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
batch.Id.ShouldNotBe(Guid.Empty);
batch.CreatedAtUtc.ShouldBeGreaterThan(DateTime.UtcNow.AddMinutes(-1));
batch.RowsStaged.ShouldBe(0);
}
[Fact]
public async Task StageRows_AcceptedAndRejected_AllPersist()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id,
acceptedRows: [Row("z-1"), Row("z-2")],
rejectedRows: [new EquipmentCsvRowError(LineNumber: 5, Reason: "duplicate ZTag")],
CancellationToken.None);
var reloaded = await _db.EquipmentImportBatches.Include(b => b.Rows).FirstAsync(b => b.Id == batch.Id);
reloaded.RowsStaged.ShouldBe(3);
reloaded.RowsAccepted.ShouldBe(2);
reloaded.RowsRejected.ShouldBe(1);
reloaded.Rows.Count.ShouldBe(3);
reloaded.Rows.Count(r => r.IsAccepted).ShouldBe(2);
reloaded.Rows.Single(r => !r.IsAccepted).RejectReason.ShouldBe("duplicate ZTag");
}
[Fact]
public async Task DropBatch_RemovesBatch_AndCascades_Rows()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None);
await _svc.DropBatchAsync(batch.Id, CancellationToken.None);
(await _db.EquipmentImportBatches.AnyAsync(b => b.Id == batch.Id)).ShouldBeFalse();
(await _db.EquipmentImportRows.AnyAsync(r => r.BatchId == batch.Id)).ShouldBeFalse("cascaded delete clears rows");
}
[Fact]
public async Task DropBatch_AfterFinalise_Throws()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batch.Id, generationId: 1, driverInstanceIdForRows: "drv-1", unsLineIdForRows: "line-1", CancellationToken.None);
await Should.ThrowAsync<ImportBatchAlreadyFinalisedException>(
() => _svc.DropBatchAsync(batch.Id, CancellationToken.None));
}
[Fact]
public async Task Finalise_AcceptedRows_BecomeEquipment()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id,
[Row("z-1", name: "alpha"), Row("z-2", name: "beta")],
rejectedRows: [new EquipmentCsvRowError(1, "rejected")],
CancellationToken.None);
await _svc.FinaliseBatchAsync(batch.Id, 5, "drv-modbus", "line-warsaw", CancellationToken.None);
var equipment = await _db.Equipment.Where(e => e.GenerationId == 5).ToListAsync();
equipment.Count.ShouldBe(2);
equipment.Select(e => e.Name).ShouldBe(["alpha", "beta"], ignoreOrder: true);
equipment.All(e => e.DriverInstanceId == "drv-modbus").ShouldBeTrue();
equipment.All(e => e.UnsLineId == "line-warsaw").ShouldBeTrue();
var reloaded = await _db.EquipmentImportBatches.FirstAsync(b => b.Id == batch.Id);
reloaded.FinalisedAtUtc.ShouldNotBeNull();
}
[Fact]
public async Task Finalise_Twice_Throws()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None);
await Should.ThrowAsync<ImportBatchAlreadyFinalisedException>(
() => _svc.FinaliseBatchAsync(batch.Id, 2, "drv", "line", CancellationToken.None));
}
[Fact]
public async Task Finalise_MissingBatch_Throws()
{
await Should.ThrowAsync<ImportBatchNotFoundException>(
() => _svc.FinaliseBatchAsync(Guid.NewGuid(), 1, "drv", "line", CancellationToken.None));
}
[Fact]
public async Task Stage_After_Finalise_Throws()
{
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None);
await Should.ThrowAsync<ImportBatchAlreadyFinalisedException>(
() => _svc.StageRowsAsync(batch.Id, [Row("z-2")], [], CancellationToken.None));
}
[Fact]
public async Task ListByUser_FiltersByCreator_AndFinalised()
{
var a = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
var b = await _svc.CreateBatchAsync("c1", "bob", CancellationToken.None);
await _svc.StageRowsAsync(a.Id, [Row("z-a")], [], CancellationToken.None);
await _svc.FinaliseBatchAsync(a.Id, 1, "d", "l", CancellationToken.None);
_ = b;
var aliceOpen = await _svc.ListByUserAsync("alice", includeFinalised: false, CancellationToken.None);
aliceOpen.ShouldBeEmpty("alice's only batch is finalised");
var aliceAll = await _svc.ListByUserAsync("alice", includeFinalised: true, CancellationToken.None);
aliceAll.Count.ShouldBe(1);
var bobOpen = await _svc.ListByUserAsync("bob", includeFinalised: false, CancellationToken.None);
bobOpen.Count.ShouldBe(1);
}
[Fact]
public async Task DropBatch_Unknown_IsNoOp()
{
await _svc.DropBatchAsync(Guid.NewGuid(), CancellationToken.None);
// no throw
}
}