From 47b1d2259f2db0a18504262f9159f73a5e31013a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 8 Jun 2026 12:35:58 -0400 Subject: [PATCH] feat(uns): area + line CRUD with #122 reassignment guard --- .../Uns/IUnsTreeService.cs | 74 ++++ .../Uns/UnsMutationResult.cs | 11 + .../Uns/UnsTreeService.cs | 215 ++++++++++++ .../Uns/UnsTreeServiceAreaLineTests.cs | 328 ++++++++++++++++++ 4 files changed, 628 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsMutationResult.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAreaLineTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs index fdf1c147..b3ba077b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs @@ -26,4 +26,78 @@ public interface IUnsTreeService /// A token to cancel the load. /// Tag nodes followed by VirtualTag nodes; empty if the equipment has none. Task> LoadEquipmentChildrenAsync(string equipmentId, CancellationToken ct = default); + + /// + /// Creates a new UNS area under a cluster. Fails if an area with the same id already exists. + /// Whitespace-only notes are stored as null. + /// + /// The owning cluster. + /// The unique area id to create. + /// The area name. + /// Optional notes; whitespace collapses to null. + /// A token to cancel the operation. + /// Success, or a duplicate-id failure. + Task CreateAreaAsync(string clusterId, string unsAreaId, string name, string? notes, CancellationToken ct = default); + + /// + /// Updates a UNS area's name, notes, and owning cluster. When the cluster changes, the + /// decision-#122 reassignment guard blocks the move if any driver-bound equipment under the + /// area is bound to a driver in a different cluster than the target. Uses last-write-wins + /// optimistic concurrency on . + /// + /// The area to update. + /// The new name. + /// The new notes; whitespace collapses to null. + /// The target cluster (may equal the current one). + /// The concurrency token the caller last read. + /// A token to cancel the operation. + /// Success, a missing-row failure, a #122 guard failure, or a concurrency failure. + Task UpdateAreaAsync(string unsAreaId, string name, string? notes, string newClusterId, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Deletes a UNS area. A missing row is treated as success (already gone). Uses last-write-wins + /// optimistic concurrency; a delete that fails because lines still reference the area surfaces a + /// guidance message. + /// + /// The area to delete. + /// The concurrency token the caller last read. + /// A token to cancel the operation. + /// Success, a concurrency failure, or a delete-failed failure. + Task DeleteAreaAsync(string unsAreaId, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Creates a new UNS line under an area. Fails if a line with the same id already exists. + /// Whitespace-only notes are stored as null. + /// + /// The owning area. + /// The unique line id to create. + /// The line name. + /// Optional notes; whitespace collapses to null. + /// A token to cancel the operation. + /// Success, or a duplicate-id failure. + Task CreateLineAsync(string unsAreaId, string unsLineId, string name, string? notes, CancellationToken ct = default); + + /// + /// Updates a UNS line's owning area, name, and notes. Uses last-write-wins optimistic + /// concurrency on . + /// + /// The line to update. + /// The new name. + /// The new notes; whitespace collapses to null. + /// The target parent area. + /// The concurrency token the caller last read. + /// A token to cancel the operation. + /// Success, a missing-row failure, or a concurrency failure. + Task UpdateLineAsync(string unsLineId, string name, string? notes, string newUnsAreaId, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Deletes a UNS line. A missing row is treated as success (already gone). Uses last-write-wins + /// optimistic concurrency; a delete that fails because equipment still references the line + /// surfaces a guidance message. + /// + /// The line to delete. + /// The concurrency token the caller last read. + /// A token to cancel the operation. + /// Success, a concurrency failure, or a delete-failed failure. + Task DeleteLineAsync(string unsLineId, byte[] rowVersion, CancellationToken ct = default); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsMutationResult.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsMutationResult.cs new file mode 100644 index 00000000..ff5be32b --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsMutationResult.cs @@ -0,0 +1,11 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// Outcome of a UNS structural mutation (create/update/delete of an area or line). +/// On success is true and is null; +/// on a guarded or concurrency failure is false and +/// carries the operator-facing message the caller should surface. +/// +/// Whether the mutation was applied. +/// The operator-facing failure message, or null on success. +public readonly record struct UnsMutationResult(bool Ok, string? Error); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs index 7096688b..ce1bfc82 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; @@ -121,4 +122,218 @@ public sealed class UnsTreeService(IDbContextFactory dbF result.AddRange(vtagNodes); return result; } + + /// + public async Task CreateAreaAsync( + string clusterId, + string unsAreaId, + string name, + string? notes, + CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + if (await db.UnsAreas.AnyAsync(a => a.UnsAreaId == unsAreaId, ct)) + { + return new UnsMutationResult(false, $"Area '{unsAreaId}' already exists."); + } + + db.UnsAreas.Add(new UnsArea + { + UnsAreaId = unsAreaId, + ClusterId = clusterId, + Name = name, + Notes = string.IsNullOrWhiteSpace(notes) ? null : notes, + }); + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + + /// + public async Task UpdateAreaAsync( + string unsAreaId, + string name, + string? notes, + string newClusterId, + byte[] rowVersion, + CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == unsAreaId, ct); + if (entity is null) + { + return new UnsMutationResult(false, "Row no longer exists."); + } + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + + // Decision #122: a cluster move must not orphan driver-bound equipment from its driver's + // cluster. Any equipment under this area that is bound to a driver in a different cluster + // than the target blocks the move. + if (newClusterId != entity.ClusterId) + { + var lineIds = await db.UnsLines + .Where(l => l.UnsAreaId == unsAreaId) + .Select(l => l.UnsLineId) + .ToListAsync(ct); + + var boundEquipment = await db.Equipment + .Where(eq => lineIds.Contains(eq.UnsLineId) && eq.DriverInstanceId != null) + .Select(eq => new { eq.EquipmentId, eq.DriverInstanceId }) + .ToListAsync(ct); + + foreach (var eq in boundEquipment) + { + var driverCluster = await db.DriverInstances + .Where(d => d.DriverInstanceId == eq.DriverInstanceId) + .Select(d => d.ClusterId) + .FirstOrDefaultAsync(ct); + + if (driverCluster is not null && driverCluster != newClusterId) + { + return new UnsMutationResult( + false, + $"Cannot move area to '{newClusterId}': equipment '{eq.EquipmentId}' is bound to a driver in cluster '{driverCluster}' (decision #122). Re-home or unbind it first."); + } + } + } + + entity.Name = name; + entity.Notes = string.IsNullOrWhiteSpace(notes) ? null : notes; + entity.ClusterId = newClusterId; + + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (DbUpdateConcurrencyException) + { + return new UnsMutationResult(false, "Another user changed this area while you were editing. Reload to see the latest values."); + } + } + + /// + public async Task DeleteAreaAsync( + string unsAreaId, + byte[] rowVersion, + CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == unsAreaId, ct); + if (entity is null) + { + return new UnsMutationResult(true, null); + } + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + db.UnsAreas.Remove(entity); + + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (DbUpdateConcurrencyException) + { + return new UnsMutationResult(false, "Another user changed this area while you were viewing it."); + } + catch (Exception ex) + { + return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because lines still reference this area — remove them first."); + } + } + + /// + public async Task CreateLineAsync( + string unsAreaId, + string unsLineId, + string name, + string? notes, + CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + if (await db.UnsLines.AnyAsync(l => l.UnsLineId == unsLineId, ct)) + { + return new UnsMutationResult(false, $"Line '{unsLineId}' already exists."); + } + + db.UnsLines.Add(new UnsLine + { + UnsLineId = unsLineId, + UnsAreaId = unsAreaId, + Name = name, + Notes = string.IsNullOrWhiteSpace(notes) ? null : notes, + }); + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + + /// + public async Task UpdateLineAsync( + string unsLineId, + string name, + string? notes, + string newUnsAreaId, + byte[] rowVersion, + CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == unsLineId, ct); + if (entity is null) + { + return new UnsMutationResult(false, "Row no longer exists."); + } + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + entity.UnsAreaId = newUnsAreaId; + entity.Name = name; + entity.Notes = string.IsNullOrWhiteSpace(notes) ? null : notes; + + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (DbUpdateConcurrencyException) + { + return new UnsMutationResult(false, "Another user changed this line while you were editing."); + } + } + + /// + public async Task DeleteLineAsync( + string unsLineId, + byte[] rowVersion, + CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == unsLineId, ct); + if (entity is null) + { + return new UnsMutationResult(true, null); + } + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + db.UnsLines.Remove(entity); + + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (DbUpdateConcurrencyException) + { + return new UnsMutationResult(false, "Another user changed this line while you were viewing it."); + } + catch (Exception ex) + { + return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because equipment still references this line — remove or re-home it first."); + } + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAreaLineTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAreaLineTests.cs new file mode 100644 index 00000000..e08036a0 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAreaLineTests.cs @@ -0,0 +1,328 @@ +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; + +/// +/// Verifies the Area/Line CRUD mutations on , including the +/// decision-#122 area-reassignment guard that blocks moving an area to a different cluster +/// when its driver-bound equipment would be orphaned from its driver's cluster. +/// +/// +/// The EF InMemory provider does not enforce RowVersion concurrency, so the +/// DbUpdateConcurrencyException branches are not exercised here by design. +/// +[Trait("Category", "Unit")] +public sealed class UnsTreeServiceAreaLineTests +{ + private static (UnsTreeService Service, string DbName) Fresh() + { + var dbName = $"uns-crud-{Guid.NewGuid():N}"; + return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName); + } + + // ----- CreateArea ----- + + /// A new area is persisted and visible on a fresh context. + [Fact] + public async Task CreateArea_then_load_shows_it() + { + var (service, dbName) = Fresh(); + + var result = await service.CreateAreaAsync("MAIN", "AREA-NEW", "assembly", " "); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + + using var db = UnsTreeTestDb.CreateNamed(dbName); + var area = db.UnsAreas.Single(a => a.UnsAreaId == "AREA-NEW"); + area.ClusterId.ShouldBe("MAIN"); + area.Name.ShouldBe("assembly"); + area.Notes.ShouldBeNull(); // whitespace-only notes collapse to null + } + + /// Creating an area whose id already exists returns the duplicate error. + [Fact] + public async Task CreateArea_duplicate_id_returns_error() + { + var (service, dbName) = Fresh(); + await service.CreateAreaAsync("MAIN", "AREA-DUP", "first", null); + + var result = await service.CreateAreaAsync("MAIN", "AREA-DUP", "second", null); + + result.Ok.ShouldBeFalse(); + result.Error.ShouldBe("Area 'AREA-DUP' already exists."); + + using var db = UnsTreeTestDb.CreateNamed(dbName); + db.UnsAreas.Single(a => a.UnsAreaId == "AREA-DUP").Name.ShouldBe("first"); + } + + // ----- UpdateArea ----- + + /// Updating an area changes its name and notes (notes whitespace collapses to null). + [Fact] + public async Task UpdateArea_changes_name_and_notes() + { + var (service, dbName) = Fresh(); + await service.CreateAreaAsync("MAIN", "AREA-1", "old", "old notes"); + + byte[] rv; + using (var db = UnsTreeTestDb.CreateNamed(dbName)) + { + rv = db.UnsAreas.Single(a => a.UnsAreaId == "AREA-1").RowVersion; + } + + var result = await service.UpdateAreaAsync("AREA-1", "new", " ", "MAIN", rv); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + + using var verify = UnsTreeTestDb.CreateNamed(dbName); + var area = verify.UnsAreas.Single(a => a.UnsAreaId == "AREA-1"); + area.Name.ShouldBe("new"); + area.Notes.ShouldBeNull(); + area.ClusterId.ShouldBe("MAIN"); + } + + /// The #122 guard blocks moving an area to a new cluster when driver-bound + /// equipment would be orphaned from its driver's cluster. + [Fact] + public async Task UpdateArea_reassign_cluster_blocked_when_driver_bound_equipment_would_orphan() + { + var (service, dbName) = Fresh(); + + byte[] rv; + using (var db = UnsTreeTestDb.CreateNamed(dbName)) + { + db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-X", ClusterId = "MAIN", Name = "a" }); + db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-X", UnsAreaId = "AREA-X", Name = "l" }); + db.Equipment.Add(new Equipment + { + EquipmentId = "EQ-BOUND", + EquipmentUuid = Guid.NewGuid(), + UnsLineId = "LINE-X", + Name = "m", + MachineCode = "machine_x", + DriverInstanceId = "DRV-MAIN", + }); + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = "DRV-MAIN", + ClusterId = "MAIN", + NamespaceId = "NS-1", + Name = "drv", + DriverType = "ModbusTcp", + DriverConfig = "{}", + }); + db.SaveChanges(); + rv = db.UnsAreas.Single(a => a.UnsAreaId == "AREA-X").RowVersion; + } + + var result = await service.UpdateAreaAsync("AREA-X", "a", null, "SITE-A", rv); + + result.Ok.ShouldBeFalse(); + result.Error.ShouldNotBeNull(); + result.Error.ShouldContain("decision #122"); + result.Error.ShouldContain("EQ-BOUND"); + result.Error.ShouldContain("SITE-A"); + result.Error.ShouldContain("MAIN"); + + // The area must not have been moved. + using var verify = UnsTreeTestDb.CreateNamed(dbName); + verify.UnsAreas.Single(a => a.UnsAreaId == "AREA-X").ClusterId.ShouldBe("MAIN"); + } + + /// The #122 guard allows the move when the equipment under the area is driver-less + /// (DriverInstanceId == null). + [Fact] + public async Task UpdateArea_reassign_cluster_allowed_when_equipment_driverless() + { + var (service, dbName) = Fresh(); + + byte[] rv; + using (var db = UnsTreeTestDb.CreateNamed(dbName)) + { + db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-Y", ClusterId = "MAIN", Name = "a" }); + db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-Y", UnsAreaId = "AREA-Y", Name = "l" }); + db.Equipment.Add(new Equipment + { + EquipmentId = "EQ-FREE", + EquipmentUuid = Guid.NewGuid(), + UnsLineId = "LINE-Y", + Name = "m", + MachineCode = "machine_y", + DriverInstanceId = null, + }); + db.SaveChanges(); + rv = db.UnsAreas.Single(a => a.UnsAreaId == "AREA-Y").RowVersion; + } + + var result = await service.UpdateAreaAsync("AREA-Y", "a", null, "SITE-A", rv); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + + using var verify = UnsTreeTestDb.CreateNamed(dbName); + verify.UnsAreas.Single(a => a.UnsAreaId == "AREA-Y").ClusterId.ShouldBe("SITE-A"); + } + + /// Updating an area that no longer exists returns the row-gone error. + [Fact] + public async Task UpdateArea_missing_row_returns_error() + { + var (service, _) = Fresh(); + + var result = await service.UpdateAreaAsync("NOPE", "n", null, "MAIN", []); + + result.Ok.ShouldBeFalse(); + result.Error.ShouldBe("Row no longer exists."); + } + + // ----- DeleteArea ----- + + /// Deleting an area with no lines removes the row. + [Fact] + public async Task DeleteArea_removes_row() + { + var (service, dbName) = Fresh(); + await service.CreateAreaAsync("MAIN", "AREA-DEL", "a", null); + + byte[] rv; + using (var db = UnsTreeTestDb.CreateNamed(dbName)) + { + rv = db.UnsAreas.Single(a => a.UnsAreaId == "AREA-DEL").RowVersion; + } + + var result = await service.DeleteAreaAsync("AREA-DEL", rv); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + + using var verify = UnsTreeTestDb.CreateNamed(dbName); + verify.UnsAreas.Any(a => a.UnsAreaId == "AREA-DEL").ShouldBeFalse(); + } + + /// Deleting an area that is already gone is a no-op success. + [Fact] + public async Task DeleteArea_already_gone_returns_ok() + { + var (service, _) = Fresh(); + + var result = await service.DeleteAreaAsync("GHOST", []); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + } + + // ----- CreateLine ----- + + /// A new line is persisted under its area. + [Fact] + public async Task CreateLine_then_load_shows_it() + { + var (service, dbName) = Fresh(); + await service.CreateAreaAsync("MAIN", "AREA-1", "a", null); + + var result = await service.CreateLineAsync("AREA-1", "LINE-NEW", "line-a", " "); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + + using var db = UnsTreeTestDb.CreateNamed(dbName); + var line = db.UnsLines.Single(l => l.UnsLineId == "LINE-NEW"); + line.UnsAreaId.ShouldBe("AREA-1"); + line.Name.ShouldBe("line-a"); + line.Notes.ShouldBeNull(); + } + + /// Creating a line whose id already exists returns the duplicate error. + [Fact] + public async Task CreateLine_duplicate_id_returns_error() + { + var (service, _) = Fresh(); + await service.CreateLineAsync("AREA-1", "LINE-DUP", "first", null); + + var result = await service.CreateLineAsync("AREA-1", "LINE-DUP", "second", null); + + result.Ok.ShouldBeFalse(); + result.Error.ShouldBe("Line 'LINE-DUP' already exists."); + } + + // ----- UpdateLine ----- + + /// Updating a line moves it to a new area and changes its name and notes. + [Fact] + public async Task UpdateLine_changes_area_name_and_notes() + { + var (service, dbName) = Fresh(); + await service.CreateLineAsync("AREA-1", "LINE-1", "old", "old notes"); + + byte[] rv; + using (var db = UnsTreeTestDb.CreateNamed(dbName)) + { + rv = db.UnsLines.Single(l => l.UnsLineId == "LINE-1").RowVersion; + } + + var result = await service.UpdateLineAsync("LINE-1", "new", " ", "AREA-2", rv); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + + using var verify = UnsTreeTestDb.CreateNamed(dbName); + var line = verify.UnsLines.Single(l => l.UnsLineId == "LINE-1"); + line.UnsAreaId.ShouldBe("AREA-2"); + line.Name.ShouldBe("new"); + line.Notes.ShouldBeNull(); + } + + /// Updating a line that no longer exists returns the row-gone error. + [Fact] + public async Task UpdateLine_missing_row_returns_error() + { + var (service, _) = Fresh(); + + var result = await service.UpdateLineAsync("NOPE", "n", null, "AREA-1", []); + + result.Ok.ShouldBeFalse(); + result.Error.ShouldBe("Row no longer exists."); + } + + // ----- DeleteLine ----- + + /// Deleting a line removes the row. + [Fact] + public async Task DeleteLine_removes_row() + { + var (service, dbName) = Fresh(); + await service.CreateLineAsync("AREA-1", "LINE-DEL", "l", null); + + byte[] rv; + using (var db = UnsTreeTestDb.CreateNamed(dbName)) + { + rv = db.UnsLines.Single(l => l.UnsLineId == "LINE-DEL").RowVersion; + } + + var result = await service.DeleteLineAsync("LINE-DEL", rv); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + + using var verify = UnsTreeTestDb.CreateNamed(dbName); + verify.UnsLines.Any(l => l.UnsLineId == "LINE-DEL").ShouldBeFalse(); + } + + /// Deleting a line that is already gone is a no-op success. + [Fact] + public async Task DeleteLine_already_gone_returns_ok() + { + var (service, _) = Fresh(); + + var result = await service.DeleteLineAsync("GHOST", []); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + } +}