fix(templates): hoist (DataType,ElementDataType,Value) attribute validation into TemplateService (#92)
This commit is contained in:
@@ -234,6 +234,177 @@ public class TemplateServiceTests
|
||||
Assert.Contains("already exists", result.Error);
|
||||
}
|
||||
|
||||
// ── #92: (DataType, ElementDataType, Value) validation hoisted into the
|
||||
// service so the Central UI's direct write path shares the same
|
||||
// server-side guard as ManagementActor.ValidateAttributeTypes. ──
|
||||
|
||||
[Fact]
|
||||
public async Task AddAttribute_InvalidDataType_Fails()
|
||||
{
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
|
||||
// An out-of-range enum value is the in-service equivalent of an
|
||||
// unrecognised data-type token (the CLI/API path rejects the token before
|
||||
// the enum is parsed).
|
||||
var attr = new TemplateAttribute("Temperature") { DataType = (DataType)999, Value = "0" };
|
||||
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("data type", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
// The attribute must never reach the repository.
|
||||
_repoMock.Verify(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAttribute_ListMissingElementType_Fails()
|
||||
{
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
|
||||
var attr = new TemplateAttribute("SetPoints") { DataType = DataType.List, ElementDataType = null };
|
||||
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("element type", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
_repoMock.Verify(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAttribute_ListInvalidElementType_Fails()
|
||||
{
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
|
||||
// Binary is a real DataType but not a valid List element scalar.
|
||||
var attr = new TemplateAttribute("SetPoints") { DataType = DataType.List, ElementDataType = DataType.Binary };
|
||||
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("element type", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
_repoMock.Verify(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAttribute_ScalarWithElementType_Fails()
|
||||
{
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
|
||||
var attr = new TemplateAttribute("Temperature") { DataType = DataType.Float, ElementDataType = DataType.Int32, Value = "0" };
|
||||
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("ElementDataType", result.Error);
|
||||
_repoMock.Verify(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAttribute_ListValueDoesNotDecode_Fails()
|
||||
{
|
||||
var template = new Template("Pump") { Id = 1 };
|
||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||
|
||||
// A valid typed List, but the value carries an element that is not an Int32.
|
||||
var attr = new TemplateAttribute("SetPoints")
|
||||
{
|
||||
DataType = DataType.List, ElementDataType = DataType.Int32, Value = """["abc"]"""
|
||||
};
|
||||
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("invalid list value", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
_repoMock.Verify(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAttribute_ScalarValueDoesNotParse_Fails()
|
||||
{
|
||||
// Mirrors what AttributeValueCodec.Decode would accept for the declared
|
||||
// (DataType, ElementDataType). Scalars decode as-is in the codec, so a
|
||||
// free-form scalar value is accepted by both the CLI/API guard and the
|
||||
// service guard — there is no scalar-string parse here. Documenting the
|
||||
// accepted shape: a non-numeric scalar Value is allowed (validated at deploy).
|
||||
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.Int32, Value = "not-a-number" };
|
||||
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
||||
|
||||
// Behaviour-preserving: the codec leaves scalars untouched, so this is
|
||||
// accepted (the deploy-time SemanticValidator remains the scalar backstop).
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAttribute_ValidTypedList_Succeeds()
|
||||
{
|
||||
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("SetPoints")
|
||||
{
|
||||
DataType = DataType.List, ElementDataType = DataType.Int32, Value = """[10, 20, 30]"""
|
||||
};
|
||||
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("SetPoints", result.Value.Name);
|
||||
Assert.Equal(DataType.List, result.Value.DataType);
|
||||
Assert.Equal(DataType.Int32, result.Value.ElementDataType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAttribute_InvalidListValue_Fails()
|
||||
{
|
||||
// DataType/ElementDataType are fixed on the existing row; the proposed
|
||||
// value must still decode against that fixed element type.
|
||||
var existing = new TemplateAttribute("SetPoints")
|
||||
{
|
||||
Id = 1, TemplateId = 1, DataType = DataType.List, ElementDataType = DataType.Int32, Value = """[1, 2]"""
|
||||
};
|
||||
_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("SetPoints")
|
||||
{
|
||||
DataType = DataType.List, ElementDataType = DataType.Int32, Value = """["abc"]"""
|
||||
};
|
||||
var result = await _service.UpdateAttributeAsync(1, proposed, "admin");
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Contains("invalid list value", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
// The fixed value must not have been mutated.
|
||||
Assert.Equal("""[1, 2]""", existing.Value);
|
||||
_repoMock.Verify(r => r.UpdateTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAttribute_ValidTypedListValue_Succeeds()
|
||||
{
|
||||
var existing = new TemplateAttribute("SetPoints")
|
||||
{
|
||||
Id = 1, TemplateId = 1, DataType = DataType.List, ElementDataType = DataType.Int32, Value = """[1, 2]"""
|
||||
};
|
||||
_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("SetPoints")
|
||||
{
|
||||
DataType = DataType.List, ElementDataType = DataType.Int32, Value = """[10, 20, 30]"""
|
||||
};
|
||||
var result = await _service.UpdateAttributeAsync(1, proposed, "admin");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("""[10, 20, 30]""", result.Value.Value);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// WP-3: Alarm Definitions
|
||||
// ========================================================================
|
||||
|
||||
Reference in New Issue
Block a user