perf(search): prefix-by-default Deployments and config-audit search; bounded Contains opt-in (ScadaBridge#50)

Deployments free-text search and the config-audit EntityName filter no longer
emit LIKE '%term%' by default. Prefix (LIKE 'term%') is the default; substring
is an explicit opt-in (DeploymentSearchMode.Contains / entityNameContains)
always bounded by a DeployedAt/Timestamp window (explicit, else 30 days).
Migration PrefixSearchIndexes adds DeployedBy, RevisionHash and EntityName
indexes. Deployments page gains a 'Match anywhere' checkbox.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 15:42:56 -05:00
parent 0ce6633270
commit 47f3a703bf
15 changed files with 2620 additions and 44 deletions
@@ -0,0 +1,201 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
/// <summary>
/// ScadaBridge#50: the Deployments and config-audit free-text searches must not use a
/// leading-wildcard LIKE by default. Query-shape tests render the SQL Server provider's SQL
/// (no connection needed); result tests run on SQLite.
/// </summary>
public class SearchPrefixQueryTests : IDisposable
{
private readonly ScadaBridgeDbContext _context = SqliteTestHelper.CreateInMemoryContext();
private readonly DeploymentManagerRepository _deployments;
private readonly CentralUiRepository _audit;
/// <summary>Initializes a new instance of the <c>SearchPrefixQueryTests</c> class.</summary>
public SearchPrefixQueryTests()
{
_deployments = new DeploymentManagerRepository(_context);
_audit = new CentralUiRepository(_context);
}
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
GC.SuppressFinalize(this);
}
private static ScadaBridgeDbContext SqlServerContext() =>
new(new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer("Server=unused;Database=unused;")
.Options);
// ── Query shape (SQL Server SQL) ─────────────────────────────────────────
/// <summary>Default deployment search renders a trailing-wildcard-only LIKE and no time window.</summary>
[Fact]
public void Deployments_DefaultSearch_HasNoLeadingWildcard()
{
using var ctx = SqlServerContext();
var sql = new DeploymentManagerRepository(ctx)
.BuildListQuery(new DeploymentListFilter(Search: "Line3"), applyStatus: true)
.ToQueryString();
// EF parameterises the LIKE pattern: assert on the declared pattern values.
Assert.Contains("LIKE @search_startswith ESCAPE", sql);
Assert.Contains("= N'Line3%';", sql);
Assert.DoesNotContain("= N'%", sql);
Assert.DoesNotContain("_contains", sql);
Assert.DoesNotContain("DeployedAt] >=", sql);
}
/// <summary>The Contains opt-in keeps the substring scan but is always time-bounded.</summary>
[Fact]
public void Deployments_ContainsSearch_HasLeadingWildcard_AndIsTimeBounded()
{
using var ctx = SqlServerContext();
var sql = new DeploymentManagerRepository(ctx)
.BuildListQuery(new DeploymentListFilter(Search: "Filler", SearchMode: DeploymentSearchMode.Contains), applyStatus: true)
.ToQueryString();
Assert.Contains("= N'%Filler%';", sql);
Assert.Contains("_contains", sql);
Assert.Contains("DeployedAt] >=", sql);
}
/// <summary>Default audit entity-name filter is a prefix match with no window.</summary>
[Fact]
public void AuditLog_DefaultEntityName_HasNoLeadingWildcard_NoWindow()
{
using var ctx = SqlServerContext();
var sql = new CentralUiRepository(ctx)
.BuildAuditLogQuery(null, null, null, null, null, null, "Pump", null, false, DateTimeOffset.UtcNow)
.ToQueryString();
Assert.Contains("LIKE @entityName_startswith ESCAPE", sql);
Assert.Contains("= N'Pump%';", sql);
Assert.DoesNotContain("= N'%", sql);
Assert.DoesNotContain("[Timestamp] >=", sql);
}
/// <summary>The audit Contains opt-in is bounded by the default window.</summary>
[Fact]
public void AuditLog_ContainsEntityName_IsBoundedByDefaultWindow()
{
using var ctx = SqlServerContext();
var sql = new CentralUiRepository(ctx)
.BuildAuditLogQuery(null, null, null, null, null, null, "Pump", null, true, DateTimeOffset.UtcNow)
.ToQueryString();
Assert.Contains("= N'%Pump%';", sql);
Assert.Contains("[Timestamp] >=", sql);
}
/// <summary>The effective window applies only to Contains searches or an explicit bound.</summary>
[Fact]
public void EffectiveDeployedFrom_OnlyForContains_OrExplicit()
{
var now = new DateTimeOffset(2026, 11, 9, 0, 0, 0, TimeSpan.Zero);
Assert.Null(new DeploymentListFilter(Search: "x").EffectiveDeployedFrom(now));
Assert.Null(new DeploymentListFilter(SearchMode: DeploymentSearchMode.Contains).EffectiveDeployedFrom(now));
Assert.Equal(now.AddDays(-30),
new DeploymentListFilter(Search: "x", SearchMode: DeploymentSearchMode.Contains).EffectiveDeployedFrom(now));
var explicitFrom = now.AddDays(-2);
Assert.Equal(explicitFrom,
new DeploymentListFilter(Search: "x", SearchMode: DeploymentSearchMode.Contains, DeployedFrom: explicitFrom).EffectiveDeployedFrom(now));
}
// ── Results (SQLite) ─────────────────────────────────────────────────────
private async Task SeedDeploymentsAsync()
{
var site = new Site("S1", "S1");
_context.Sites.Add(site);
var template = new Template("T");
_context.Templates.Add(template);
await _context.SaveChangesAsync();
var a = new Instance("Line3.Filler") { SiteId = site.Id, TemplateId = template.Id };
var b = new Instance("Line4.Filler") { SiteId = site.Id, TemplateId = template.Id };
_context.Instances.AddRange(a, b);
await _context.SaveChangesAsync();
_context.DeploymentRecords.AddRange(
new DeploymentRecord("dep-old", "alice") { InstanceId = a.Id, Status = DeploymentStatus.Success, DeployedAt = DateTimeOffset.UtcNow.AddDays(-90) },
new DeploymentRecord("dep-new", "alice") { InstanceId = b.Id, Status = DeploymentStatus.Success, DeployedAt = DateTimeOffset.UtcNow.AddDays(-1) });
await _context.SaveChangesAsync();
}
/// <summary>Prefix search does not match mid-string, is case-insensitive, finds the prefix.</summary>
[Fact]
public async Task Deployments_Prefix_DoesNotMatchInTheMiddle()
{
await SeedDeploymentsAsync();
var mid = await _deployments.QueryDeploymentListPageAsync(new DeploymentListFilter(Search: "Filler"), 1, 25);
Assert.Equal(0, mid.TotalCount);
var prefix = await _deployments.QueryDeploymentListPageAsync(new DeploymentListFilter(Search: "line3"), 1, 25);
Assert.Equal("dep-old", Assert.Single(prefix.Rows).DeploymentId);
}
/// <summary>Contains matches mid-string but only inside the window; counts agree.</summary>
[Fact]
public async Task Deployments_Contains_MatchesInTheMiddle_ButOnlyWithinTheWindow()
{
await SeedDeploymentsAsync();
var page = await _deployments.QueryDeploymentListPageAsync(
new DeploymentListFilter(Search: "Filler", SearchMode: DeploymentSearchMode.Contains), 1, 25);
Assert.Equal("dep-new", Assert.Single(page.Rows).DeploymentId);
var wide = await _deployments.QueryDeploymentListPageAsync(
new DeploymentListFilter(Search: "Filler", SearchMode: DeploymentSearchMode.Contains,
DeployedFrom: DateTimeOffset.UtcNow.AddDays(-365)), 1, 25);
Assert.Equal(2, wide.TotalCount);
var counts = await _deployments.GetDeploymentStatusCountsAsync(
new DeploymentListFilter(Search: "Filler", SearchMode: DeploymentSearchMode.Contains));
Assert.Equal(1, counts.Total);
}
/// <summary>A literal percent is not a wildcard in prefix mode.</summary>
[Fact]
public async Task Deployments_Prefix_TreatsPercentAsLiteral()
{
await SeedDeploymentsAsync();
var page = await _deployments.QueryDeploymentListPageAsync(new DeploymentListFilter(Search: "%"), 1, 25);
Assert.Equal(0, page.TotalCount);
}
/// <summary>Audit entity-name: prefix by default, bounded Contains on opt-in.</summary>
[Fact]
public async Task AuditLog_EntityName_PrefixByDefault_ContainsOptInWithinWindow()
{
_context.AuditLogEntries.AddRange(
new AuditLogEntry("a", "Create", "Template", "1", "PumpStation") { Timestamp = DateTimeOffset.UtcNow },
new AuditLogEntry("a", "Create", "Template", "2", "BigPump") { Timestamp = DateTimeOffset.UtcNow },
new AuditLogEntry("a", "Create", "Template", "3", "OldPump") { Timestamp = DateTimeOffset.UtcNow.AddDays(-90) });
await _context.SaveChangesAsync();
var (prefix, _) = await _audit.GetAuditLogEntriesAsync(entityName: "pump");
Assert.Equal("PumpStation", Assert.Single(prefix).EntityName);
var (contains, _) = await _audit.GetAuditLogEntriesAsync(entityName: "Pump", entityNameContains: true);
Assert.Equal(new[] { "BigPump", "PumpStation" }, contains.Select(e => e.EntityName).OrderBy(n => n));
var (wide, _) = await _audit.GetAuditLogEntriesAsync(
entityName: "Pump", entityNameContains: true, from: DateTimeOffset.UtcNow.AddDays(-365));
Assert.Equal(3, wide.Count);
}
}