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;
///
/// 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.
///
public class SearchPrefixQueryTests : IDisposable
{
private readonly ScadaBridgeDbContext _context = SqliteTestHelper.CreateInMemoryContext();
private readonly DeploymentManagerRepository _deployments;
private readonly CentralUiRepository _audit;
/// Initializes a new instance of the SearchPrefixQueryTests class.
public SearchPrefixQueryTests()
{
_deployments = new DeploymentManagerRepository(_context);
_audit = new CentralUiRepository(_context);
}
/// Releases the resources held by the test fixture.
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
GC.SuppressFinalize(this);
}
private static ScadaBridgeDbContext SqlServerContext() =>
new(new DbContextOptionsBuilder()
.UseSqlServer("Server=unused;Database=unused;")
.Options);
// ── Query shape (SQL Server SQL) ─────────────────────────────────────────
/// Default deployment search renders a trailing-wildcard-only LIKE and no time window.
[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);
}
/// The Contains opt-in keeps the substring scan but is always time-bounded.
[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);
}
/// Default audit entity-name filter is a prefix match with no window.
[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);
}
/// The audit Contains opt-in is bounded by the default window.
[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);
}
/// The effective window applies only to Contains searches or an explicit bound.
[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();
}
/// Prefix search does not match mid-string, is case-insensitive, finds the prefix.
[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);
}
/// Contains matches mid-string but only inside the window; counts agree.
[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);
}
/// A literal percent is not a wildcard in prefix mode.
[Fact]
public async Task Deployments_Prefix_TreatsPercentAsLiteral()
{
await SeedDeploymentsAsync();
var page = await _deployments.QueryDeploymentListPageAsync(new DeploymentListFilter(Search: "%"), 1, 25);
Assert.Equal(0, page.TotalCount);
}
/// Audit entity-name: prefix by default, bounded Contains on opt-in.
[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);
}
}