feat(mgmt): accept + validate ElementDataType on attribute add/update

This commit is contained in:
Joseph Doherty
2026-06-16 16:05:05 -04:00
parent ad6bfc8af9
commit 1525670fe7
3 changed files with 197 additions and 4 deletions
@@ -8,8 +8,8 @@ public record DeleteTemplateCommand(int TemplateId);
public record ValidateTemplateCommand(int TemplateId);
// Template member operations
public record AddTemplateAttributeCommand(int TemplateId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked);
public record UpdateTemplateAttributeCommand(int AttributeId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked);
public record AddTemplateAttributeCommand(int TemplateId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked, string? ElementDataType = null);
public record UpdateTemplateAttributeCommand(int AttributeId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked, string? ElementDataType = null);
public record DeleteTemplateAttributeCommand(int AttributeId);
public record AddTemplateAlarmCommand(int TemplateId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked);
public record UpdateTemplateAlarmCommand(int AlarmId, string Name, string TriggerType, int PriorityLevel, string? Description, string? TriggerConfiguration, bool IsLocked);
@@ -1442,9 +1442,13 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleAddAttribute(IServiceProvider sp, AddTemplateAttributeCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateService>();
var dataType = Enum.Parse<Commons.Types.Enums.DataType>(cmd.DataType, ignoreCase: true);
var elementType = ParseElementDataType(cmd.ElementDataType);
ValidateAttributeTypes(cmd.Name, dataType, elementType, cmd.Value);
var attr = new TemplateAttribute(cmd.Name)
{
DataType = Enum.Parse<ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.DataType>(cmd.DataType, ignoreCase: true),
DataType = dataType,
ElementDataType = elementType,
Value = cmd.Value,
Description = cmd.Description,
DataSourceReference = cmd.DataSourceReference,
@@ -1457,9 +1461,13 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleUpdateAttribute(IServiceProvider sp, UpdateTemplateAttributeCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateService>();
var dataType = Enum.Parse<Commons.Types.Enums.DataType>(cmd.DataType, ignoreCase: true);
var elementType = ParseElementDataType(cmd.ElementDataType);
ValidateAttributeTypes(cmd.Name, dataType, elementType, cmd.Value);
var attr = new TemplateAttribute(cmd.Name)
{
DataType = Enum.Parse<ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.DataType>(cmd.DataType, ignoreCase: true),
DataType = dataType,
ElementDataType = elementType,
Value = cmd.Value,
Description = cmd.Description,
DataSourceReference = cmd.DataSourceReference,
@@ -1469,6 +1477,57 @@ public class ManagementActor : ReceiveActor
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
/// <summary>
/// Parses an optional element data type token. Returns null when the token is
/// empty/whitespace; throws <see cref="ManagementCommandException"/> on an
/// unrecognised type name.
/// </summary>
private static Commons.Types.Enums.DataType? ParseElementDataType(string? elementDataType)
{
if (string.IsNullOrWhiteSpace(elementDataType)) return null;
if (!Enum.TryParse<Commons.Types.Enums.DataType>(elementDataType, ignoreCase: true, out var parsed))
throw new ManagementCommandException($"Unrecognised element type '{elementDataType}'.");
return parsed;
}
/// <summary>
/// Validates the (DataType, ElementDataType, Value) triple shared by the add
/// and update attribute handlers. Throws <see cref="ManagementCommandException"/>
/// on any violation:
/// <list type="bullet">
/// <item>List attributes require a valid scalar element type.</item>
/// <item>Scalar attributes may not carry an element type.</item>
/// <item>A List default value must decode against the declared element type.</item>
/// </list>
/// </summary>
private static void ValidateAttributeTypes(
string name, Commons.Types.Enums.DataType dataType, Commons.Types.Enums.DataType? elementType, string? value)
{
if (dataType == Commons.Types.Enums.DataType.List)
{
if (elementType is null || !Commons.Types.AttributeValueCodec.IsValidElementType(elementType.Value))
throw new ManagementCommandException(
$"List attribute '{name}' requires a valid element type (String, Int32, Float, Double, Boolean, DateTime).");
if (!string.IsNullOrWhiteSpace(value))
{
try
{
Commons.Types.AttributeValueCodec.Decode(value, Commons.Types.Enums.DataType.List, elementType);
}
catch (FormatException ex)
{
throw new ManagementCommandException(
$"List attribute '{name}' has an invalid list value: {ex.Message}");
}
}
}
else if (elementType is not null)
{
throw new ManagementCommandException("Element type is only valid on List attributes.");
}
}
private static async Task<object?> HandleDeleteAttribute(IServiceProvider sp, DeleteTemplateAttributeCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateService>();
@@ -387,6 +387,140 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Contains("Designer", response.Message);
}
// ========================================================================
// MV-10: ElementDataType accept + validate on attribute add/update
// ========================================================================
[Fact]
public void AddListAttribute_WithStringElementType_PersistsBothColumns()
{
// A template exists with no attributes; AddAttributeAsync will save the
// entity built by the handler. Capture it to assert the persisted shape.
var template = new Template("T1") { Id = 1 };
_templateRepo.GetTemplateByIdAsync(1, Arg.Any<CancellationToken>()).Returns(template);
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Template> { template });
TemplateAttribute? saved = null;
_templateRepo
.When(r => r.AddTemplateAttributeAsync(Arg.Any<TemplateAttribute>(), Arg.Any<CancellationToken>()))
.Do(ci => saved = ci.Arg<TemplateAttribute>());
_templateRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Tags", "List", "[\"a\",\"b\"]", null, null, false, "String"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.NotNull(saved);
Assert.Equal(Commons.Types.Enums.DataType.List, saved!.DataType);
Assert.Equal(Commons.Types.Enums.DataType.String, saved.ElementDataType);
Assert.Equal("[\"a\",\"b\"]", saved.Value);
}
[Fact]
public void AddListAttribute_WithNoElementType_ReturnsManagementError()
{
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Tags", "List", null, null, null, false, null),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("requires a valid element type", response.Error);
}
[Fact]
public void AddListAttribute_WithBinaryElementType_ReturnsManagementError()
{
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Tags", "List", null, null, null, false, "Binary"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("requires a valid element type", response.Error);
}
[Fact]
public void AddScalarAttribute_WithElementType_ReturnsManagementError()
{
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Count", "Int32", null, null, null, false, "String"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("only valid on List attributes", response.Error);
}
[Fact]
public void AddListAttribute_WithMalformedDefault_ReturnsManagementError()
{
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Tags", "List", "[\"a\"", null, null, false, "String"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("invalid list value", response.Error);
}
[Fact]
public void AddListAttribute_WithTypeMismatchedDefault_ReturnsManagementError()
{
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateAttributeCommand(1, "Counts", "List", "[\"x\"]", null, null, false, "Int32"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("invalid list value", response.Error);
}
[Fact]
public void UpdateScalarAttribute_WithElementType_ReturnsManagementError()
{
// Update path runs the same pre-service validation: a scalar attribute
// may not carry an element type, regardless of repository state.
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new UpdateTemplateAttributeCommand(5, "Count", "Int32", null, null, null, false, "String"),
"Designer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("only valid on List attributes", response.Error);
}
[Fact]
public void UpdateApiKey_WithDesignRole_ReturnsUnauthorized()
{