feat(uns): equipment CRUD with #122 driver-cluster guard
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Parameter object carrying the operator-editable fields for an equipment create or update,
|
||||
/// so <see cref="IUnsTreeService.CreateEquipmentAsync"/> and
|
||||
/// <see cref="IUnsTreeService.UpdateEquipmentAsync"/> avoid an unwieldy positional signature.
|
||||
/// The <c>EquipmentId</c> and <c>EquipmentUuid</c> are system-generated (decision #125) and are
|
||||
/// therefore not part of this input. Optional string fields that arrive whitespace-only are
|
||||
/// collapsed to <c>null</c> by the service.
|
||||
/// </summary>
|
||||
/// <param name="Name">UNS level-5 segment; matches <c>^[a-z0-9-]{1,32}$</c>.</param>
|
||||
/// <param name="MachineCode">Operator colloquial id; unique fleet-wide.</param>
|
||||
/// <param name="UnsLineId">Logical FK to the owning <see cref="Configuration.Entities.UnsLine"/>.</param>
|
||||
/// <param name="DriverInstanceId">Optional driver binding; whitespace/empty means driver-less.</param>
|
||||
/// <param name="ZTag">Optional ERP equipment id.</param>
|
||||
/// <param name="SAPID">Optional SAP PM equipment id.</param>
|
||||
/// <param name="Manufacturer">Optional OPC 40010 manufacturer name.</param>
|
||||
/// <param name="Model">Optional OPC 40010 model designation.</param>
|
||||
/// <param name="SerialNumber">Optional OPC 40010 serial number.</param>
|
||||
/// <param name="HardwareRevision">Optional OPC 40010 hardware revision.</param>
|
||||
/// <param name="SoftwareRevision">Optional OPC 40010 software revision.</param>
|
||||
/// <param name="YearOfConstruction">Optional OPC 40010 year of construction.</param>
|
||||
/// <param name="AssetLocation">Optional OPC 40010 asset location.</param>
|
||||
/// <param name="ManufacturerUri">Optional OPC 40010 manufacturer URI.</param>
|
||||
/// <param name="DeviceManualUri">Optional OPC 40010 device-manual URI.</param>
|
||||
/// <param name="Enabled">Whether the equipment is surfaced in deployments.</param>
|
||||
public sealed record EquipmentInput(
|
||||
string Name,
|
||||
string MachineCode,
|
||||
string UnsLineId,
|
||||
string? DriverInstanceId,
|
||||
string? ZTag,
|
||||
string? SAPID,
|
||||
string? Manufacturer,
|
||||
string? Model,
|
||||
string? SerialNumber,
|
||||
string? HardwareRevision,
|
||||
string? SoftwareRevision,
|
||||
short? YearOfConstruction,
|
||||
string? AssetLocation,
|
||||
string? ManufacturerUri,
|
||||
string? DeviceManualUri,
|
||||
bool Enabled);
|
||||
@@ -100,4 +100,40 @@ public interface IUnsTreeService
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
|
||||
Task<UnsMutationResult> DeleteLineAsync(string unsLineId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new equipment under a UNS line. The <c>EquipmentId</c> is system-generated
|
||||
/// (decision #125: <c>EQ-</c> + the first 12 hex chars of a fresh <c>EquipmentUuid</c>).
|
||||
/// Fails if the line is unset, if the MachineCode is already used fleet-wide, or if the
|
||||
/// decision-#122 driver-cluster guard trips. Whitespace-only DriverInstanceId/ZTag/SAPID
|
||||
/// collapse to <c>null</c>.
|
||||
/// </summary>
|
||||
/// <param name="input">The operator-editable equipment fields.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, a missing-line failure, a duplicate-MachineCode failure, or a #122 guard failure.</returns>
|
||||
Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an equipment's mutable fields (driver binding, line, name, MachineCode, external
|
||||
/// ids, and the OPC 40010 identification fields). The decision-#122 driver-cluster guard blocks
|
||||
/// binding to a driver in a different cluster than the equipment's line. Uses last-write-wins
|
||||
/// optimistic concurrency on <see cref="Configuration.Entities.Equipment.RowVersion"/>.
|
||||
/// </summary>
|
||||
/// <param name="equipmentId">The equipment to update.</param>
|
||||
/// <param name="input">The new operator-editable equipment fields.</param>
|
||||
/// <param name="rowVersion">The concurrency token the caller last read.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, a missing-row failure, a #122 guard failure, or a concurrency failure.</returns>
|
||||
Task<UnsMutationResult> UpdateEquipmentAsync(string equipmentId, EquipmentInput input, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an equipment. A missing row is treated as success (already gone). Uses last-write-wins
|
||||
/// optimistic concurrency; a delete that fails because tags or virtual tags still reference the
|
||||
/// equipment surfaces a guidance message.
|
||||
/// </summary>
|
||||
/// <param name="equipmentId">The equipment to delete.</param>
|
||||
/// <param name="rowVersion">The concurrency token the caller last read.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
|
||||
Task<UnsMutationResult> DeleteEquipmentAsync(string equipmentId, byte[] rowVersion, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -336,4 +336,171 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because equipment still references this line — remove or re-home it first.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input.UnsLineId))
|
||||
{
|
||||
return new UnsMutationResult(false, "Pick a UNS line.");
|
||||
}
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
if (await db.Equipment.AnyAsync(e => e.MachineCode == input.MachineCode, ct))
|
||||
{
|
||||
return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet.");
|
||||
}
|
||||
|
||||
var guard = await CheckDriverClusterGuardAsync(db, input, ct);
|
||||
if (guard is not null)
|
||||
{
|
||||
return guard.Value;
|
||||
}
|
||||
|
||||
var uuid = Guid.NewGuid();
|
||||
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
|
||||
|
||||
db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentId = equipmentId,
|
||||
EquipmentUuid = uuid,
|
||||
DriverInstanceId = string.IsNullOrWhiteSpace(input.DriverInstanceId) ? null : input.DriverInstanceId,
|
||||
UnsLineId = input.UnsLineId,
|
||||
Name = input.Name,
|
||||
MachineCode = input.MachineCode,
|
||||
ZTag = string.IsNullOrWhiteSpace(input.ZTag) ? null : input.ZTag,
|
||||
SAPID = string.IsNullOrWhiteSpace(input.SAPID) ? null : input.SAPID,
|
||||
Manufacturer = input.Manufacturer,
|
||||
Model = input.Model,
|
||||
SerialNumber = input.SerialNumber,
|
||||
HardwareRevision = input.HardwareRevision,
|
||||
SoftwareRevision = input.SoftwareRevision,
|
||||
YearOfConstruction = input.YearOfConstruction,
|
||||
AssetLocation = input.AssetLocation,
|
||||
ManufacturerUri = input.ManufacturerUri,
|
||||
DeviceManualUri = input.DeviceManualUri,
|
||||
Enabled = input.Enabled,
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> UpdateEquipmentAsync(
|
||||
string equipmentId,
|
||||
EquipmentInput input,
|
||||
byte[] rowVersion,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == equipmentId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return new UnsMutationResult(false, "Row no longer exists.");
|
||||
}
|
||||
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
|
||||
var guard = await CheckDriverClusterGuardAsync(db, input, ct);
|
||||
if (guard is not null)
|
||||
{
|
||||
return guard.Value;
|
||||
}
|
||||
|
||||
entity.DriverInstanceId = string.IsNullOrWhiteSpace(input.DriverInstanceId) ? null : input.DriverInstanceId;
|
||||
entity.UnsLineId = input.UnsLineId;
|
||||
entity.Name = input.Name;
|
||||
entity.MachineCode = input.MachineCode;
|
||||
entity.ZTag = string.IsNullOrWhiteSpace(input.ZTag) ? null : input.ZTag;
|
||||
entity.SAPID = string.IsNullOrWhiteSpace(input.SAPID) ? null : input.SAPID;
|
||||
entity.Manufacturer = input.Manufacturer;
|
||||
entity.Model = input.Model;
|
||||
entity.SerialNumber = input.SerialNumber;
|
||||
entity.HardwareRevision = input.HardwareRevision;
|
||||
entity.SoftwareRevision = input.SoftwareRevision;
|
||||
entity.YearOfConstruction = input.YearOfConstruction;
|
||||
entity.AssetLocation = input.AssetLocation;
|
||||
entity.ManufacturerUri = input.ManufacturerUri;
|
||||
entity.DeviceManualUri = input.DeviceManualUri;
|
||||
entity.Enabled = input.Enabled;
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return new UnsMutationResult(false, "Another user changed this equipment while you were editing.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> DeleteEquipmentAsync(
|
||||
string equipmentId,
|
||||
byte[] rowVersion,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == equipmentId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
db.Equipment.Remove(entity);
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return new UnsMutationResult(false, "Another user changed this equipment while you were viewing it.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because tags or virtual tags reference this equipment — remove them first.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decision #122: an equipment may only bind to a driver in the same cluster as its line.
|
||||
/// Only runs when a driver is requested. Resolves the line's cluster (line → area → cluster)
|
||||
/// and the driver's cluster; when both are known and differ, returns the guard failure.
|
||||
/// Returns <c>null</c> when the bind is allowed (driver-less, unresolvable cluster, or match).
|
||||
/// </summary>
|
||||
private static async Task<UnsMutationResult?> CheckDriverClusterGuardAsync(
|
||||
OtOpcUaConfigDbContext db,
|
||||
EquipmentInput input,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.DriverInstanceId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var line = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == input.UnsLineId, ct);
|
||||
var area = line is null ? null : await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == line.UnsAreaId, ct);
|
||||
var lineCluster = area?.ClusterId;
|
||||
|
||||
var driverCluster = await db.DriverInstances
|
||||
.Where(d => d.DriverInstanceId == input.DriverInstanceId)
|
||||
.Select(d => d.ClusterId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (driverCluster is not null && lineCluster is not null && driverCluster != lineCluster)
|
||||
{
|
||||
return new UnsMutationResult(
|
||||
false,
|
||||
$"Driver '{input.DriverInstanceId}' is in cluster '{driverCluster}' but the line is in cluster '{lineCluster}' (decision #122).");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the Equipment CRUD mutations on <see cref="UnsTreeService"/>, including the
|
||||
/// system-generated <c>EQ-</c> id, fleet-wide MachineCode uniqueness, and the decision-#122
|
||||
/// driver-cluster guard that blocks binding equipment to a driver in a different cluster than
|
||||
/// the equipment's line.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The EF InMemory provider does not enforce <c>RowVersion</c> concurrency, so the
|
||||
/// <c>DbUpdateConcurrencyException</c> branches are not exercised here by design.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnsTreeServiceEquipmentTests
|
||||
{
|
||||
private static (UnsTreeService Service, string DbName) Fresh()
|
||||
{
|
||||
var dbName = $"uns-equip-{Guid.NewGuid():N}";
|
||||
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a line under an area in <paramref name="lineCluster"/>, plus an optional driver in
|
||||
/// <paramref name="driverCluster"/>. The line id is always <c>LINE-1</c>; the driver (when
|
||||
/// requested) is always <c>DRV-1</c>.
|
||||
/// </summary>
|
||||
private static void SeedLineAndDriver(string dbName, string lineCluster, string? driverCluster)
|
||||
{
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = lineCluster, Name = "a" });
|
||||
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
|
||||
if (driverCluster is not null)
|
||||
{
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "DRV-1",
|
||||
ClusterId = driverCluster,
|
||||
NamespaceId = "NS-1",
|
||||
Name = "drv",
|
||||
DriverType = "ModbusTcp",
|
||||
DriverConfig = "{}",
|
||||
});
|
||||
}
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static EquipmentInput Input(string name, string machineCode, string unsLineId, string? driverInstanceId) =>
|
||||
new(name, machineCode, unsLineId, driverInstanceId,
|
||||
ZTag: null, SAPID: null, Manufacturer: null, Model: null, SerialNumber: null,
|
||||
HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: null,
|
||||
AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: true);
|
||||
|
||||
// ----- CreateEquipment -----
|
||||
|
||||
/// <summary>A new equipment gets a system-generated EQ- id (EQ- + 12 hex chars) and persists.</summary>
|
||||
[Fact]
|
||||
public async Task CreateEquipment_generates_EQ_id_and_persists()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
|
||||
|
||||
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
var eq = db.Equipment.Single(e => e.MachineCode == "machine_001");
|
||||
eq.EquipmentId.ShouldStartWith("EQ-");
|
||||
eq.EquipmentId.Length.ShouldBe(15); // "EQ-" + 12 hex chars
|
||||
eq.Name.ShouldBe("machine-1");
|
||||
eq.UnsLineId.ShouldBe("LINE-1");
|
||||
eq.Enabled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Creating equipment with a MachineCode already in the fleet is blocked.</summary>
|
||||
[Fact]
|
||||
public async Task CreateEquipment_duplicate_machinecode_blocked()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
|
||||
await service.CreateEquipmentAsync(Input("machine-1", "machine_dup", "LINE-1", null));
|
||||
|
||||
var result = await service.CreateEquipmentAsync(Input("machine-2", "machine_dup", "LINE-1", null));
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldBe("MachineCode 'machine_dup' already exists in this fleet.");
|
||||
}
|
||||
|
||||
/// <summary>Creating equipment with no UNS line returns the pick-a-line error.</summary>
|
||||
[Fact]
|
||||
public async Task CreateEquipment_missing_line_blocked()
|
||||
{
|
||||
var (service, _) = Fresh();
|
||||
|
||||
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "", null));
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldBe("Pick a UNS line.");
|
||||
}
|
||||
|
||||
/// <summary>The #122 guard blocks binding equipment to a driver in a different cluster than the line.</summary>
|
||||
[Fact]
|
||||
public async Task CreateEquipment_driver_in_other_cluster_blocked()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "SITE-A");
|
||||
|
||||
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", "DRV-1"));
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error.ShouldContain("decision #122");
|
||||
result.Error.ShouldContain("DRV-1");
|
||||
result.Error.ShouldContain("SITE-A");
|
||||
result.Error.ShouldContain("MAIN");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Equipment.Any(e => e.MachineCode == "machine_001").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Binding equipment to a driver in the same cluster as the line is allowed.</summary>
|
||||
[Fact]
|
||||
public async Task CreateEquipment_driver_in_same_cluster_allowed()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "MAIN");
|
||||
|
||||
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", "DRV-1"));
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Equipment.Single(e => e.MachineCode == "machine_001").DriverInstanceId.ShouldBe("DRV-1");
|
||||
}
|
||||
|
||||
/// <summary>Driver-less equipment is allowed regardless of cluster (no #122 guard applies).</summary>
|
||||
[Fact]
|
||||
public async Task CreateEquipment_driverless_allowed()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
|
||||
|
||||
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Equipment.Single(e => e.MachineCode == "machine_001").DriverInstanceId.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ----- UpdateEquipment -----
|
||||
|
||||
/// <summary>Updating equipment changes its mutable fields (name, MachineCode, a 40010 field).</summary>
|
||||
[Fact]
|
||||
public async Task UpdateEquipment_changes_fields()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
|
||||
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
|
||||
|
||||
string equipmentId;
|
||||
byte[] rv;
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
var eq = db.Equipment.Single(e => e.MachineCode == "machine_001");
|
||||
equipmentId = eq.EquipmentId;
|
||||
rv = eq.RowVersion;
|
||||
}
|
||||
|
||||
var updated = new EquipmentInput("machine-renamed", "machine_renamed", "LINE-1", null,
|
||||
ZTag: null, SAPID: null, Manufacturer: "Acme", Model: null, SerialNumber: null,
|
||||
HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: (short)2021,
|
||||
AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: false);
|
||||
|
||||
var result = await service.UpdateEquipmentAsync(equipmentId, updated, rv);
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
||||
var after = verify.Equipment.Single(e => e.EquipmentId == equipmentId);
|
||||
after.Name.ShouldBe("machine-renamed");
|
||||
after.MachineCode.ShouldBe("machine_renamed");
|
||||
after.Manufacturer.ShouldBe("Acme");
|
||||
after.YearOfConstruction.ShouldBe((short)2021);
|
||||
after.Enabled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Updating equipment that no longer exists returns the row-gone error.</summary>
|
||||
[Fact]
|
||||
public async Task UpdateEquipment_missing_row_returns_error()
|
||||
{
|
||||
var (service, _) = Fresh();
|
||||
|
||||
var result = await service.UpdateEquipmentAsync("EQ-nope", Input("n", "mc", "LINE-1", null), []);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldBe("Row no longer exists.");
|
||||
}
|
||||
|
||||
/// <summary>The #122 guard blocks an update that binds equipment to a driver in another cluster.</summary>
|
||||
[Fact]
|
||||
public async Task UpdateEquipment_driver_in_other_cluster_blocked()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "SITE-A");
|
||||
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
|
||||
|
||||
string equipmentId;
|
||||
byte[] rv;
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
var eq = db.Equipment.Single(e => e.MachineCode == "machine_001");
|
||||
equipmentId = eq.EquipmentId;
|
||||
rv = eq.RowVersion;
|
||||
}
|
||||
|
||||
var result = await service.UpdateEquipmentAsync(
|
||||
equipmentId, Input("machine-1", "machine_001", "LINE-1", "DRV-1"), rv);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error.ShouldContain("decision #122");
|
||||
|
||||
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
||||
verify.Equipment.Single(e => e.EquipmentId == equipmentId).DriverInstanceId.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ----- DeleteEquipment -----
|
||||
|
||||
/// <summary>Deleting equipment removes the row.</summary>
|
||||
[Fact]
|
||||
public async Task DeleteEquipment_removes_row()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
|
||||
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
|
||||
|
||||
string equipmentId;
|
||||
byte[] rv;
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
var eq = db.Equipment.Single(e => e.MachineCode == "machine_001");
|
||||
equipmentId = eq.EquipmentId;
|
||||
rv = eq.RowVersion;
|
||||
}
|
||||
|
||||
var result = await service.DeleteEquipmentAsync(equipmentId, rv);
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
using var verify = UnsTreeTestDb.CreateNamed(dbName);
|
||||
verify.Equipment.Any(e => e.EquipmentId == equipmentId).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Deleting equipment that is already gone is a no-op success.</summary>
|
||||
[Fact]
|
||||
public async Task DeleteEquipment_already_gone_returns_ok()
|
||||
{
|
||||
var (service, _) = Fresh();
|
||||
|
||||
var result = await service.DeleteEquipmentAsync("EQ-ghost", []);
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user