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); } }