fix(templates): cascade delete through nested derived templates

DeleteCompositionAsync only dropped the top-level derived template — the
cascaded inner derived rows (created when composing a composite source)
were left orphaned with dangling OwnerCompositionId references. Any
subsequent attempt to recompose the same source hit the name-collision
guard ('Motor Controller.Pump.TempSensor' already exists).

New CascadeDeleteDerivedAsync walks each composition on the derived
template, recursively removes the slot-owned child derived first, then
the composition row, then the derived itself. Mirrors the recursive
shape of CreateCascadedCompositionAsync.
This commit is contained in:
Joseph Doherty
2026-05-12 10:34:55 -04:00
parent 85769486df
commit 57f477fd28
2 changed files with 49 additions and 2 deletions

View File

@@ -436,6 +436,33 @@ public class TemplateServiceTests
Assert.Contains("already exists", result.Error);
}
[Fact]
public async Task DeleteComposition_CascadesNestedDerivedTemplates()
{
// Pump.TempSensor is a cascaded derived under Pump (outer derived) that
// is owned by composition 50. Deleting composition 50 must drop:
// - the outer derived (Pump)
// - its nested composition (TempSensor on Pump)
// - the nested derived (Pump.TempSensor)
var nestedComp = new TemplateComposition("TempSensor") { Id = 51, TemplateId = 77, ComposedTemplateId = 78 };
var nestedDerived = new Template("Tank.Pump.TempSensor") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 3 };
var outerComposition = new TemplateComposition("Pump") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
var outerDerived = new Template("Tank.Pump") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
outerDerived.Compositions.Add(nestedComp);
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(outerComposition);
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(outerDerived);
_repoMock.Setup(r => r.GetTemplateByIdAsync(78, It.IsAny<CancellationToken>())).ReturnsAsync(nestedDerived);
var result = await _service.DeleteCompositionAsync(50, "admin");
Assert.True(result.IsSuccess);
_repoMock.Verify(r => r.DeleteTemplateCompositionAsync(50, It.IsAny<CancellationToken>()), Times.Once);
_repoMock.Verify(r => r.DeleteTemplateCompositionAsync(51, It.IsAny<CancellationToken>()), Times.Once);
_repoMock.Verify(r => r.DeleteTemplateAsync(77, It.IsAny<CancellationToken>()), Times.Once);
_repoMock.Verify(r => r.DeleteTemplateAsync(78, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task DeleteComposition_CascadesDerivedTemplate()
{