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:
Joseph Doherty
2026-07-24 13:59:01 -04:00
parent 3d7e1226d2
commit 6de782e0fd
@@ -5,6 +5,12 @@ using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
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
{
[Fact]
@@ -27,4 +33,167 @@ public class SqlEquipmentTagParserTests
=> SqlEquipmentTagParser.TryParse(
"""{"driver":"Sql","model":"Nonsense","table":"t","keyColumn":"k","keyValue":"v","valueColumn":"c"}""",
"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();
}