When the user composes a template that already has compositions of its own (e.g. \$Sensor → Probe1 slot), only the outer derived was created — the source's children weren't replicated. AddCompositionAsync now walks the source's composition graph and creates a parallel derived for every slot it encounters, each linked back through ParentTemplateId so override chains stay intact (\$Probe → \$Sensor.Probe1 → \$Pump.TempSensor.Probe1). The cascade pre-flights every name it would create — a deep collision aborts before any rows mutate. Internal helper CreateCascadedCompositionAsync skips the "base templates only" check since it operates on the source side which may legitimately reference derived rows.
841 lines
39 KiB
C#
841 lines
39 KiB
C#
using Moq;
|
|
using ScadaLink.Commons.Entities.Instances;
|
|
using ScadaLink.Commons.Entities.Scripts;
|
|
using ScadaLink.Commons.Entities.Templates;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.Commons.Types.Enums;
|
|
|
|
namespace ScadaLink.TemplateEngine.Tests;
|
|
|
|
public class TemplateServiceTests
|
|
{
|
|
private readonly Mock<ITemplateEngineRepository> _repoMock;
|
|
private readonly Mock<IAuditService> _auditMock;
|
|
private readonly TemplateService _service;
|
|
|
|
public TemplateServiceTests()
|
|
{
|
|
_repoMock = new Mock<ITemplateEngineRepository>();
|
|
_auditMock = new Mock<IAuditService>();
|
|
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
|
|
_service = new TemplateService(_repoMock.Object, _auditMock.Object);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-1: Template CRUD with Inheritance
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public async Task CreateTemplate_Success()
|
|
{
|
|
var result = await _service.CreateTemplateAsync("Pump", "A pump template", null, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("Pump", result.Value.Name);
|
|
_repoMock.Verify(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
|
|
_auditMock.Verify(a => a.LogAsync("admin", "Create", "Template", It.IsAny<string>(), "Pump", It.IsAny<object?>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateTemplate_EmptyName_Fails()
|
|
{
|
|
var result = await _service.CreateTemplateAsync("", null, null, "admin");
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("required", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateTemplate_WithParent_Success()
|
|
{
|
|
var parent = new Template("Base") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(parent);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { parent });
|
|
|
|
var result = await _service.CreateTemplateAsync("Child", null, 1, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(1, result.Value.ParentTemplateId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateTemplate_NonexistentParent_Fails()
|
|
{
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(999, It.IsAny<CancellationToken>())).ReturnsAsync((Template?)null);
|
|
|
|
var result = await _service.CreateTemplateAsync("Child", null, 999, "admin");
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("not found", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateTemplate_WithFolderId_SetsFolderId()
|
|
{
|
|
var folder = new TemplateFolder("Dev") { Id = 7 };
|
|
_repoMock.Setup(r => r.GetFolderByIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>())).ReturnsAsync(folder);
|
|
|
|
var result = await _service.CreateTemplateAsync("X", null, null, "admin", folderId: 7);
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(7, result.Value.FolderId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteTemplate_Success()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Instance>());
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
|
|
var result = await _service.DeleteTemplateAsync(1, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
_repoMock.Verify(r => r.DeleteTemplateAsync(1, It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteTemplate_ReferencedByInstances_Fails()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Instance> { new Instance("Pump1") { Id = 1, TemplateId = 1, SiteId = 1 } });
|
|
|
|
var result = await _service.DeleteTemplateAsync(1, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("referenced by", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteTemplate_HasChildren_Fails()
|
|
{
|
|
var parent = new Template("Base") { Id = 1 };
|
|
var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(parent);
|
|
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Instance>());
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { parent, child });
|
|
|
|
var result = await _service.DeleteTemplateAsync(1, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("inherited by", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteTemplate_ComposedByOther_Fails()
|
|
{
|
|
var moduleTemplate = new Template("Module") { Id = 1 };
|
|
var composingTemplate = new Template("Composing") { Id = 2 };
|
|
composingTemplate.Compositions.Add(new TemplateComposition("mod1") { Id = 1, TemplateId = 2, ComposedTemplateId = 1 });
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
|
|
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Instance>());
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { moduleTemplate, composingTemplate });
|
|
|
|
var result = await _service.DeleteTemplateAsync(1, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("composed by", result.Error);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-2: Attribute Definitions with Lock Flags
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public async Task AddAttribute_Success()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
|
|
var attr = new TemplateAttribute("Temperature") { DataType = DataType.Float, Value = "0.0" };
|
|
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("Temperature", result.Value.Name);
|
|
Assert.Equal(1, result.Value.TemplateId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddAttribute_DuplicateName_Fails()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
template.Attributes.Add(new TemplateAttribute("Temperature") { Id = 1, TemplateId = 1, DataType = DataType.Float });
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
|
|
var attr = new TemplateAttribute("Temperature") { DataType = DataType.Float };
|
|
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("already exists", result.Error);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-3: Alarm Definitions
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public async Task AddAlarm_Success()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
|
|
var alarm = new TemplateAlarm("HighTemp")
|
|
{
|
|
PriorityLevel = 500,
|
|
TriggerType = AlarmTriggerType.RangeViolation,
|
|
TriggerConfiguration = """{"Max": 100}"""
|
|
};
|
|
var result = await _service.AddAlarmAsync(1, alarm, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("HighTemp", result.Value.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddAlarm_InvalidPriority_Fails()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
|
|
var alarm = new TemplateAlarm("HighTemp") { PriorityLevel = 1001, TriggerType = AlarmTriggerType.ValueMatch };
|
|
var result = await _service.AddAlarmAsync(1, alarm, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("priority", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAlarm_TriggerTypeFixed_Fails()
|
|
{
|
|
var existing = new TemplateAlarm("HighTemp")
|
|
{
|
|
Id = 1,
|
|
TemplateId = 1,
|
|
TriggerType = AlarmTriggerType.ValueMatch,
|
|
PriorityLevel = 500
|
|
};
|
|
_repoMock.Setup(r => r.GetTemplateAlarmByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
|
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
|
|
var proposed = new TemplateAlarm("HighTemp")
|
|
{
|
|
TriggerType = AlarmTriggerType.RangeViolation, // Changed!
|
|
PriorityLevel = 600
|
|
};
|
|
var result = await _service.UpdateAlarmAsync(1, proposed, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("TriggerType", result.Error);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-4: Script Definitions
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public async Task AddScript_Success()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
|
|
var script = new TemplateScript("OnStart", "return true;") { TriggerType = "Startup" };
|
|
var result = await _service.AddScriptAsync(1, script, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("OnStart", result.Value.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateScript_NameFixed_Fails()
|
|
{
|
|
var existing = new TemplateScript("OnStart", "return true;") { Id = 1, TemplateId = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateScriptByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
|
|
var proposed = new TemplateScript("OnStop", "return false;"); // Name changed!
|
|
var result = await _service.UpdateScriptAsync(1, proposed, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("Name", result.Error);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-5: Shared Script CRUD (see SharedScriptServiceTests)
|
|
// ========================================================================
|
|
|
|
// ========================================================================
|
|
// WP-6: Composition with Recursive Nesting
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public async Task AddComposition_Success_DerivesTemplate()
|
|
{
|
|
var moduleTemplate = new Template("Module") { Id = 2 };
|
|
moduleTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { Id = 10, TemplateId = 2, Value = "75", DataType = DataType.Float });
|
|
moduleTemplate.Scripts.Add(new TemplateScript("Compute", "return 1;") { Id = 20, TemplateId = 2 });
|
|
var template = new Template("Parent") { Id = 1 };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template, moduleTemplate });
|
|
|
|
Template? captured = null;
|
|
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
|
|
.Callback<Template, CancellationToken>((t, _) => captured = t)
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("myModule", result.Value.InstanceName);
|
|
Assert.NotNull(captured);
|
|
Assert.True(captured!.IsDerived);
|
|
Assert.Equal("Parent.myModule", captured.Name);
|
|
Assert.Equal(2, captured.ParentTemplateId);
|
|
Assert.Single(captured.Attributes);
|
|
Assert.True(captured.Attributes.First().IsInherited);
|
|
Assert.Equal("SetPoint", captured.Attributes.First().Name);
|
|
Assert.Single(captured.Scripts);
|
|
Assert.True(captured.Scripts.First().IsInherited);
|
|
_repoMock.Verify(r => r.AddTemplateCompositionAsync(It.IsAny<TemplateComposition>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddComposition_CascadesChildCompositions()
|
|
{
|
|
// $Probe (base) → $Sensor.Probe1 (derived) ← $Sensor composes "Probe1"
|
|
// Composing $Sensor into $Pump as "TempSensor" should produce:
|
|
// $Pump.TempSensor (derived from $Sensor)
|
|
// $Pump.TempSensor.Probe1 (derived from $Sensor.Probe1)
|
|
// plus a composition row on $Pump.TempSensor pointing at the new inner derived.
|
|
var probe = new Template("Probe") { Id = 10 };
|
|
var sensorProbe1 = new Template("Sensor.Probe1") { Id = 11, IsDerived = true, ParentTemplateId = 10, OwnerCompositionId = 1 };
|
|
var sensor = new Template("Sensor") { Id = 2 };
|
|
sensor.Compositions.Add(new TemplateComposition("Probe1") { Id = 1, TemplateId = 2, ComposedTemplateId = 11 });
|
|
var pump = new Template("Pump") { Id = 1 };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(pump);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(sensor);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(11, It.IsAny<CancellationToken>())).ReturnsAsync(sensorProbe1);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { pump, sensor, probe, sensorProbe1 });
|
|
|
|
var captured = new List<Template>();
|
|
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
|
|
.Callback<Template, CancellationToken>((t, _) => captured.Add(t))
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var capturedCompositions = new List<TemplateComposition>();
|
|
_repoMock.Setup(r => r.AddTemplateCompositionAsync(It.IsAny<TemplateComposition>(), It.IsAny<CancellationToken>()))
|
|
.Callback<TemplateComposition, CancellationToken>((c, _) => capturedCompositions.Add(c))
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var result = await _service.AddCompositionAsync(1, 2, "TempSensor", "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(2, captured.Count);
|
|
Assert.Equal("Pump.TempSensor", captured[0].Name);
|
|
Assert.Equal(2, captured[0].ParentTemplateId);
|
|
Assert.Equal("Pump.TempSensor.Probe1", captured[1].Name);
|
|
Assert.Equal(11, captured[1].ParentTemplateId);
|
|
Assert.Equal(2, capturedCompositions.Count);
|
|
Assert.Equal("TempSensor", capturedCompositions[0].InstanceName);
|
|
Assert.Equal("Probe1", capturedCompositions[1].InstanceName);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddComposition_BaseIsDerived_Fails()
|
|
{
|
|
var derivedBase = new Template("Sensor") { Id = 2, IsDerived = true };
|
|
var template = new Template("Parent") { Id = 1 };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(derivedBase);
|
|
|
|
var result = await _service.AddCompositionAsync(1, 2, "slot", "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("derived template", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddComposition_DerivedNameCollision_Fails()
|
|
{
|
|
var existing = new Template("Parent.myModule") { Id = 99 };
|
|
var moduleTemplate = new Template("Module") { Id = 2 };
|
|
var template = new Template("Parent") { Id = 1 };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template, moduleTemplate, existing });
|
|
|
|
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("already exists", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RenameComposition_RenamesSlotAndDerivedTemplate()
|
|
{
|
|
var composition = new TemplateComposition("OldSlot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
|
var owner = new Template("Pump") { Id = 1 };
|
|
owner.Compositions.Add(composition);
|
|
var derived = new Template("Pump.OldSlot") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { owner, derived });
|
|
|
|
var result = await _service.RenameCompositionAsync(50, "NewSlot", "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("NewSlot", result.Value.InstanceName);
|
|
Assert.Equal("Pump.NewSlot", derived.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RenameComposition_DuplicateName_Fails()
|
|
{
|
|
var composition = new TemplateComposition("OldSlot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
|
var sibling = new TemplateComposition("NewSlot") { Id = 51, TemplateId = 1, ComposedTemplateId = 78 };
|
|
var owner = new Template("Pump") { Id = 1 };
|
|
owner.Compositions.Add(composition);
|
|
owner.Compositions.Add(sibling);
|
|
|
|
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
|
|
|
|
var result = await _service.RenameCompositionAsync(50, "NewSlot", "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("already exists", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteComposition_CascadesDerivedTemplate()
|
|
{
|
|
var composition = new TemplateComposition("slot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
|
var derived = new Template("Parent.slot") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
|
|
|
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.DeleteTemplateAsync(77, It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteComposition_LegacyDirectBase_DoesNotCascade()
|
|
{
|
|
var composition = new TemplateComposition("slot") { Id = 60, TemplateId = 1, ComposedTemplateId = 5 };
|
|
var baseTemplate = new Template("Module") { Id = 5, IsDerived = false };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(60, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(5, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
|
|
|
var result = await _service.DeleteCompositionAsync(60, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
_repoMock.Verify(r => r.DeleteTemplateCompositionAsync(60, It.IsAny<CancellationToken>()), Times.Once);
|
|
_repoMock.Verify(r => r.DeleteTemplateAsync(5, It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteTemplate_DerivedTemplate_DirectDeleteBlocked()
|
|
{
|
|
var derived = new Template("Parent.slot") { Id = 77, IsDerived = true };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
|
|
|
var result = await _service.DeleteTemplateAsync(77, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("derived template", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAttribute_LockedInDerivedBase_RejectsOnDerived()
|
|
{
|
|
var existing = new TemplateAttribute("SetPoint") { Id = 100, TemplateId = 77, DataType = DataType.Float, IsInherited = true };
|
|
var baseTemplate = new Template("Sensor") { Id = 2 };
|
|
baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { Id = 10, TemplateId = 2, LockedInDerived = true });
|
|
var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(100, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseTemplate, derived });
|
|
|
|
var proposed = new TemplateAttribute("SetPoint") { Value = "99", DataType = DataType.Float, IsInherited = false };
|
|
var result = await _service.UpdateAttributeAsync(100, proposed, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("locked by base template 'Sensor'", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateScript_LockedInDerivedBase_RejectsOnDerived()
|
|
{
|
|
var existing = new TemplateScript("Sample", "stale") { Id = 200, TemplateId = 77, IsInherited = true };
|
|
var baseTemplate = new Template("Sensor") { Id = 2 };
|
|
baseTemplate.Scripts.Add(new TemplateScript("Sample", "return 1;") { Id = 20, TemplateId = 2, LockedInDerived = true });
|
|
var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateScriptByIdAsync(200, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseTemplate, derived });
|
|
|
|
var proposed = new TemplateScript("Sample", "return 2;") { IsInherited = false };
|
|
var result = await _service.UpdateScriptAsync(200, proposed, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("locked by base template 'Sensor'", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAttribute_DerivedOverride_PersistsIsInheritedFalse()
|
|
{
|
|
var existing = new TemplateAttribute("SetPoint") { Id = 100, TemplateId = 77, DataType = DataType.Float, IsInherited = true };
|
|
var baseTemplate = new Template("Sensor") { Id = 2 };
|
|
baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { Id = 10, TemplateId = 2 });
|
|
var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(100, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseTemplate, derived });
|
|
|
|
var proposed = new TemplateAttribute("SetPoint") { Value = "99", DataType = DataType.Float, IsInherited = false };
|
|
var result = await _service.UpdateAttributeAsync(100, proposed, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.False(result.Value.IsInherited);
|
|
Assert.Equal("99", result.Value.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteTemplate_BaseWithDerivatives_BlockedWithSlotNames()
|
|
{
|
|
var baseTemplate = new Template("Sensor") { Id = 5 };
|
|
var parent = new Template("Pump") { Id = 1 };
|
|
var composition = new TemplateComposition("TempSensor") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
|
|
parent.Compositions.Add(composition);
|
|
var derived = new Template("Pump.TempSensor") { Id = 77, ParentTemplateId = 5, IsDerived = true, OwnerCompositionId = 50 };
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(5, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
|
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(5, It.IsAny<CancellationToken>())).ReturnsAsync(new List<Instance>());
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { baseTemplate, parent, derived });
|
|
|
|
var result = await _service.DeleteTemplateAsync(5, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("derived", result.Error);
|
|
Assert.Contains("'Pump' (as 'TempSensor')", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddComposition_DuplicateInstanceName_Fails()
|
|
{
|
|
var moduleTemplate = new Template("Module") { Id = 2 };
|
|
var template = new Template("Parent") { Id = 1 };
|
|
template.Compositions.Add(new TemplateComposition("myModule") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
|
|
|
|
var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("already exists", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddComposition_SelfComposition_Fails()
|
|
{
|
|
var template = new Template("Self") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
|
|
var result = await _service.AddCompositionAsync(1, 1, "self", "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("compose itself", result.Error);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-9: Locking Rules
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public async Task UpdateAttribute_LockedMember_CannotUnlock()
|
|
{
|
|
var existing = new TemplateAttribute("Temperature")
|
|
{
|
|
Id = 1, TemplateId = 1, DataType = DataType.Float, IsLocked = true
|
|
};
|
|
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
|
|
var proposed = new TemplateAttribute("Temperature")
|
|
{
|
|
DataType = DataType.Float, IsLocked = false, Value = "42"
|
|
};
|
|
var result = await _service.UpdateAttributeAsync(1, proposed, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("cannot be unlocked", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAttribute_LockUnlockedMember_Succeeds()
|
|
{
|
|
var existing = new TemplateAttribute("Temperature")
|
|
{
|
|
Id = 1, TemplateId = 1, DataType = DataType.Float, IsLocked = false
|
|
};
|
|
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
|
|
var template = new Template("Pump") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
|
|
var proposed = new TemplateAttribute("Temperature")
|
|
{
|
|
DataType = DataType.Float, IsLocked = true, Value = "42"
|
|
};
|
|
var result = await _service.UpdateAttributeAsync(1, proposed, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.True(result.Value.IsLocked);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAttribute_ParentLocked_CannotOverride()
|
|
{
|
|
// Parent template with locked attribute
|
|
var parentTemplate = new Template("Base") { Id = 1 };
|
|
parentTemplate.Attributes.Add(new TemplateAttribute("Speed")
|
|
{
|
|
Id = 10, TemplateId = 1, DataType = DataType.Float, IsLocked = true
|
|
});
|
|
|
|
// Child template overriding same attribute
|
|
var childTemplate = new Template("Child") { Id = 2, ParentTemplateId = 1 };
|
|
var childAttr = new TemplateAttribute("Speed")
|
|
{
|
|
Id = 20, TemplateId = 2, DataType = DataType.Float, IsLocked = false
|
|
};
|
|
childTemplate.Attributes.Add(childAttr);
|
|
|
|
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(20, It.IsAny<CancellationToken>())).ReturnsAsync(childAttr);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(childTemplate);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { parentTemplate, childTemplate });
|
|
|
|
var proposed = new TemplateAttribute("Speed")
|
|
{
|
|
DataType = DataType.Float, IsLocked = false, Value = "100"
|
|
};
|
|
var result = await _service.UpdateAttributeAsync(20, proposed, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("locked in parent", result.Error);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-10: Inheritance Override Scope — Cannot remove parent members
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public async Task DeleteAttribute_InheritedFromParent_Fails()
|
|
{
|
|
var parentTemplate = new Template("Base") { Id = 1 };
|
|
parentTemplate.Attributes.Add(new TemplateAttribute("Speed")
|
|
{
|
|
Id = 10, TemplateId = 1, DataType = DataType.Float
|
|
});
|
|
|
|
var childTemplate = new Template("Child") { Id = 2, ParentTemplateId = 1 };
|
|
var childAttr = new TemplateAttribute("Speed")
|
|
{
|
|
Id = 20, TemplateId = 2, DataType = DataType.Float
|
|
};
|
|
childTemplate.Attributes.Add(childAttr);
|
|
|
|
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(20, It.IsAny<CancellationToken>())).ReturnsAsync(childAttr);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(childTemplate);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { parentTemplate, childTemplate });
|
|
|
|
var result = await _service.DeleteAttributeAsync(20, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("inherited from parent", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteAttribute_OwnMember_Succeeds()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
var attr = new TemplateAttribute("CustomAttr")
|
|
{
|
|
Id = 1, TemplateId = 1, DataType = DataType.String
|
|
};
|
|
template.Attributes.Add(attr);
|
|
|
|
_repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(attr);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
|
|
var result = await _service.DeleteAttributeAsync(1, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
}
|
|
|
|
// ========================================================================
|
|
// WP-13: Graph Acyclicity
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public async Task UpdateTemplate_ChangeParent_Fails()
|
|
{
|
|
var parentA = new Template("A") { Id = 1 };
|
|
var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
|
|
|
|
// Attempt to re-parent Child from A (id=1) to B (id=3).
|
|
var result = await _service.UpdateTemplateAsync(2, "Child", null, 3, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("cannot be changed", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTemplate_ClearParent_Fails()
|
|
{
|
|
var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
|
|
|
|
// Attempt to clear the parent.
|
|
var result = await _service.UpdateTemplateAsync(2, "Child", null, null, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("cannot be changed", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateTemplate_SameParent_Succeeds()
|
|
{
|
|
var child = new Template("Child") { Id = 2, ParentTemplateId = 1, Description = "old" };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { child });
|
|
|
|
// Idempotent pass — same parent value sent on update should succeed and apply name/description changes.
|
|
var result = await _service.UpdateTemplateAsync(2, "ChildRenamed", "new", 1, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("ChildRenamed", result.Value.Name);
|
|
Assert.Equal("new", result.Value.Description);
|
|
Assert.Equal(1, result.Value.ParentTemplateId);
|
|
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddComposition_CircularChain_Fails()
|
|
{
|
|
// A composes B, B composes C, try to make C compose A => cycle
|
|
var templateC = new Template("C") { Id = 3 };
|
|
var templateB = new Template("B") { Id = 2 };
|
|
templateB.Compositions.Add(new TemplateComposition("c1") { Id = 2, TemplateId = 2, ComposedTemplateId = 3 });
|
|
var templateA = new Template("A") { Id = 1 };
|
|
templateA.Compositions.Add(new TemplateComposition("b1") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });
|
|
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(3, It.IsAny<CancellationToken>())).ReturnsAsync(templateC);
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(templateA);
|
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { templateA, templateB, templateC });
|
|
|
|
var result = await _service.AddCompositionAsync(3, 1, "a1", "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Move template between folders
|
|
// ========================================================================
|
|
|
|
[Fact]
|
|
public async Task MoveTemplate_ToFolder_ReturnsSuccess()
|
|
{
|
|
var t = new Template("X") { Id = 1, FolderId = null };
|
|
var folder = new TemplateFolder("Dev") { Id = 7 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
|
|
_repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
|
|
|
|
var result = await _service.MoveTemplateAsync(1, 7, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(7, result.Value.FolderId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MoveTemplate_ToRoot_ReturnsSuccess()
|
|
{
|
|
var t = new Template("X") { Id = 1, FolderId = 7 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
|
|
|
|
var result = await _service.MoveTemplateAsync(1, null, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Null(result.Value.FolderId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MoveTemplate_TargetFolderMissing_ReturnsFailure()
|
|
{
|
|
var t = new Template("X") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
|
|
_repoMock.Setup(r => r.GetFolderByIdAsync(99, It.IsAny<CancellationToken>())).ReturnsAsync((TemplateFolder?)null);
|
|
|
|
var result = await _service.MoveTemplateAsync(1, 99, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("not found", result.Error);
|
|
}
|
|
}
|