933 lines
44 KiB
C#
933 lines
44 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_WithParent_DoesNotRunDeadCollisionQuery()
|
|
{
|
|
// A freshly created child has no members of its own, and the parent's
|
|
// members were already collision-validated when they were added — so
|
|
// create-time collision detection on a child is a guaranteed no-op.
|
|
// The previous code allocated an unused full-table read; the fix
|
|
// removes it. This guards against the dead query being reintroduced.
|
|
var parent = new Template("Base") { Id = 1 };
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(parent);
|
|
|
|
var result = await _service.CreateTemplateAsync("Child", null, 1, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
_repoMock.Verify(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
[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_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()
|
|
{
|
|
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_UnlockedAttribute_DataTypeChangeRejected()
|
|
{
|
|
// An unlocked attribute must still not be able to change its fixed DataType.
|
|
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.Int32, IsLocked = false, Value = "42"
|
|
};
|
|
var result = await _service.UpdateAttributeAsync(1, proposed, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("DataType", result.Error);
|
|
// The fixed field must not have been mutated.
|
|
Assert.Equal(DataType.Float, existing.DataType);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAttribute_UnlockedAttribute_DataSourceReferenceChangeRejected()
|
|
{
|
|
var existing = new TemplateAttribute("Temperature")
|
|
{
|
|
Id = 1, TemplateId = 1, DataType = DataType.Float, IsLocked = false,
|
|
DataSourceReference = "/Motor/Temp"
|
|
};
|
|
_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",
|
|
DataSourceReference = "/Motor/Other"
|
|
};
|
|
var result = await _service.UpdateAttributeAsync(1, proposed, "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("DataSourceReference", result.Error);
|
|
Assert.Equal("/Motor/Temp", existing.DataSourceReference);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|