test(sql): cover the WideRow + rejection branches of the equipment-tag parser
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -5,6 +5,12 @@ using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
|
|||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins <see cref="SqlEquipmentTagParser.TryParse"/> against its documented contract: what it accepts, what
|
||||||
|
/// it captures verbatim, and — the larger half — everything it must <b>reject</b>. Rejection is the whole
|
||||||
|
/// safety story here: a blob the parser waves through becomes an OPC UA node the driver cannot feed, or (for
|
||||||
|
/// a typo'd enum) a node fed from the <em>wrong</em> model while publishing a confident <c>Good</c> (R2-11).
|
||||||
|
/// </summary>
|
||||||
public class SqlEquipmentTagParserTests
|
public class SqlEquipmentTagParserTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -27,4 +33,167 @@ public class SqlEquipmentTagParserTests
|
|||||||
=> SqlEquipmentTagParser.TryParse(
|
=> SqlEquipmentTagParser.TryParse(
|
||||||
"""{"driver":"Sql","model":"Nonsense","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""",
|
"""{"driver":"Sql","model":"Nonsense","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""",
|
||||||
"raw", out _).ShouldBeFalse();
|
"raw", out _).ShouldBeFalse();
|
||||||
|
|
||||||
|
// ---- WideRow: the where-pair selector ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_WideRow_wherePair_readsColumnAndSelector_andLeavesTopByTimestampUnset()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
{"driver":"Sql","model":"WideRow","table":"dbo.LatestStatus","columnName":"oven_temp",
|
||||||
|
"timestampColumn":"sample_ts","rowSelector":{"whereColumn":"station_id","whereValue":"7"}}
|
||||||
|
""";
|
||||||
|
|
||||||
|
SqlEquipmentTagParser.TryParse(json, "Plant/Sql/dev1/OvenTemp", out var def).ShouldBeTrue();
|
||||||
|
def.Model.ShouldBe(SqlTagModel.WideRow);
|
||||||
|
def.Table.ShouldBe("dbo.LatestStatus");
|
||||||
|
def.ColumnName.ShouldBe("oven_temp");
|
||||||
|
def.TimestampColumn.ShouldBe("sample_ts");
|
||||||
|
def.RowSelectorColumn.ShouldBe("station_id");
|
||||||
|
def.RowSelectorValue.ShouldBe("7");
|
||||||
|
def.RowSelectorTopByTimestamp.ShouldBeNull();
|
||||||
|
def.Name.ShouldBe("Plant/Sql/dev1/OvenTemp");
|
||||||
|
// Wide-row carries none of the key-value fields.
|
||||||
|
def.KeyColumn.ShouldBeNull();
|
||||||
|
def.KeyValue.ShouldBeNull();
|
||||||
|
def.ValueColumn.ShouldBeNull();
|
||||||
|
def.DeclaredType.ShouldBeNull(); // no "type" authored ⇒ infer from column metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("7", "7")] // JSON number → its raw token, so the reader binds one string either way
|
||||||
|
[InlineData("\"7\"", "7")] // JSON string → its contents
|
||||||
|
[InlineData("true", "true")] // JSON bool → its raw token (lower-case, as written)
|
||||||
|
public void TryParse_WideRow_whereValue_isCapturedFromEitherAStringOrABareScalar(
|
||||||
|
string authored, string expected)
|
||||||
|
{
|
||||||
|
var json = """{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{"whereColumn":"station_id","whereValue":"""
|
||||||
|
+ authored + "}}";
|
||||||
|
|
||||||
|
SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue();
|
||||||
|
def.RowSelectorValue.ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_WideRow_keepsAMaliciousWhereValueVerbatim()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
{"driver":"Sql","model":"WideRow","table":"T","columnName":"c",
|
||||||
|
"rowSelector":{"whereColumn":"station_id","whereValue":"7'); DROP TABLE x --"}}
|
||||||
|
""";
|
||||||
|
|
||||||
|
SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue();
|
||||||
|
// Captured as-is: the planner BINDS it as @w, so sanitising here would only corrupt legal values.
|
||||||
|
def.RowSelectorValue.ShouldBe("7'); DROP TABLE x --");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- WideRow: the topByTimestamp selector ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_WideRow_topByTimestamp_readsTheOrderColumn_andLeavesTheWherePairUnset()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
{"driver":"Sql","model":"WideRow","table":"dbo.Readings","columnName":"oven_temp",
|
||||||
|
"rowSelector":{"topByTimestamp":"sample_ts"},"type":"Float32"}
|
||||||
|
""";
|
||||||
|
|
||||||
|
SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue();
|
||||||
|
def.RowSelectorTopByTimestamp.ShouldBe("sample_ts");
|
||||||
|
def.RowSelectorColumn.ShouldBeNull();
|
||||||
|
def.RowSelectorValue.ShouldBeNull();
|
||||||
|
def.DeclaredType.ShouldBe(DriverDataType.Float32);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_WideRow_bothSelectorsAuthored_keepsOnlyTheWherePair()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
{"driver":"Sql","model":"WideRow","table":"T","columnName":"c",
|
||||||
|
"rowSelector":{"whereColumn":"station_id","whereValue":"7","topByTimestamp":"sample_ts"}}
|
||||||
|
""";
|
||||||
|
|
||||||
|
SqlEquipmentTagParser.TryParse(json, "raw", out var def).ShouldBeTrue();
|
||||||
|
// SqlGroupPlanner relies on exactly one selector being populated — it branches on the where-pair
|
||||||
|
// first and would otherwise silently drop the topByTimestamp ordering from a group's key.
|
||||||
|
def.RowSelectorColumn.ShouldBe("station_id");
|
||||||
|
def.RowSelectorValue.ShouldBe("7");
|
||||||
|
def.RowSelectorTopByTimestamp.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- WideRow rejections ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{"driver":"Sql","model":"WideRow","table":"T","rowSelector":{"topByTimestamp":"ts"}}""")]
|
||||||
|
[InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"","rowSelector":{"topByTimestamp":"ts"}}""")]
|
||||||
|
[InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":" ","rowSelector":{"topByTimestamp":"ts"}}""")]
|
||||||
|
[InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":42,"rowSelector":{"topByTimestamp":"ts"}}""")]
|
||||||
|
public void TryParse_WideRow_withoutAUsableColumnName_rejectsTag(string json)
|
||||||
|
=> SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse();
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// No rowSelector object at all.
|
||||||
|
[InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c"}""")]
|
||||||
|
// Present but empty.
|
||||||
|
[InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{}}""")]
|
||||||
|
// Half a where-pair is not a selector: a whereColumn with no value…
|
||||||
|
[InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{"whereColumn":"station_id"}}""")]
|
||||||
|
// …and a whereValue with no column.
|
||||||
|
[InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":{"whereValue":"7"}}""")]
|
||||||
|
// rowSelector authored as the wrong JSON kind is ignored, leaving no selector.
|
||||||
|
[InlineData("""{"driver":"Sql","model":"WideRow","table":"T","columnName":"c","rowSelector":"station_id=7"}""")]
|
||||||
|
public void TryParse_WideRow_withNoRowSelector_rejectsTag(string json)
|
||||||
|
{
|
||||||
|
// Accepting one would author a node whose query cannot say WHICH row it reads.
|
||||||
|
SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- recognition + shared rejections ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""["not","an","object"]""")]
|
||||||
|
[InlineData(""""just a string"""")]
|
||||||
|
[InlineData("42")]
|
||||||
|
[InlineData("null")]
|
||||||
|
[InlineData("not json at all")]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void TryParse_aNonObjectRoot_rejectsTag_withoutThrowing(string reference)
|
||||||
|
=> SqlEquipmentTagParser.TryParse(reference, "raw", out _).ShouldBeFalse();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_aBlobWithNeitherADriverMarkerNorAModelDiscriminator_rejectsTag()
|
||||||
|
{
|
||||||
|
// Otherwise another driver's TagConfig that happens to carry a "table" would be served as a Sql tag.
|
||||||
|
const string json =
|
||||||
|
"""{"table":"dbo.TagValues","keyColumn":"tag_name","keyValue":"v","valueColumn":"num_value"}""";
|
||||||
|
SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_aBlobBelongingToAnotherDriver_rejectsTag()
|
||||||
|
=> SqlEquipmentTagParser.TryParse(
|
||||||
|
"""{"driver":"Modbus","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c"}""",
|
||||||
|
"raw", out _).ShouldBeFalse();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_theDeferredQueryModel_rejectsTag()
|
||||||
|
{
|
||||||
|
// Design §5.4 / P3. Serving it would materialise a node the runtime has no way to read.
|
||||||
|
const string json = """{"driver":"Sql","model":"Query","table":"T","query":"SELECT 1"}""";
|
||||||
|
SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{"driver":"Sql","model":"KeyValue","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")]
|
||||||
|
[InlineData("""{"driver":"Sql","model":"KeyValue","table":"","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")]
|
||||||
|
[InlineData("""{"driver":"Sql","model":"KeyValue","table":" ","keyColumn":"k","keyValue":"v","valueColumn":"c"}""")]
|
||||||
|
[InlineData("""{"driver":"Sql","model":"WideRow","columnName":"c","rowSelector":{"topByTimestamp":"ts"}}""")]
|
||||||
|
public void TryParse_withoutATable_rejectsTag(string json)
|
||||||
|
=> SqlEquipmentTagParser.TryParse(json, "raw", out _).ShouldBeFalse();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryParse_invalidTypeEnum_rejectsTag()
|
||||||
|
=> SqlEquipmentTagParser.TryParse(
|
||||||
|
"""{"driver":"Sql","model":"KeyValue","table":"T","keyColumn":"k","keyValue":"v","valueColumn":"c","type":"Nonsense"}""",
|
||||||
|
"raw", out _).ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user