using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests; /// /// Regression tests: the schema must index the columns the /// query service filters on so common queries do not full-scan a 1 GB database. /// public class SchemaIndexTests : IDisposable { private readonly SiteEventLogger _logger; private readonly SqliteConnection _verifyConnection; private readonly string _dbPath; private readonly TestLocalDb _localDb; /// Initializes a new instance of the SchemaIndexTests class. public SchemaIndexTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"test_index_{Guid.NewGuid()}.db"); var options = Options.Create(new SiteEventLogOptions { DatabasePath = _dbPath }); _localDb = TestLocalDb.Create(_dbPath); _logger = new SiteEventLogger(options, NullLogger.Instance, _localDb.Db); _verifyConnection = new SqliteConnection($"Data Source={_dbPath}"); _verifyConnection.Open(); } /// Releases the resources used by this instance. public void Dispose() { _verifyConnection.Dispose(); _logger.Dispose(); _localDb.Dispose(); TestLocalDb.DeleteFiles(_dbPath); } /// Verifies that schema: has index on severity. [Fact] public void Schema_HasIndexOnSeverity() { using var cmd = _verifyConnection.CreateCommand(); cmd.CommandText = "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'site_events'"; var indexes = new List(); using var reader = cmd.ExecuteReader(); while (reader.Read()) indexes.Add(reader.GetString(0)); Assert.Contains("idx_events_severity_timestamp", indexes); } /// Verifies that severity filtered query, when uses index, not full scan. /// A task that represents the asynchronous operation. [Fact] public async Task SeverityFilteredQuery_UsesIndex_NotFullScan() { await _logger.LogEventAsync("script", "Error", null, "S", "boom"); await _logger.LogEventAsync("script", "Info", null, "S", "ok"); using var cmd = _verifyConnection.CreateCommand(); cmd.CommandText = "EXPLAIN QUERY PLAN SELECT id FROM site_events WHERE severity = 'Error'"; var plan = new System.Text.StringBuilder(); using var reader = cmd.ExecuteReader(); while (reader.Read()) { // The detail column holds the human-readable plan step. plan.Append(reader.GetString(reader.GetOrdinal("detail"))).Append('\n'); } var planText = plan.ToString(); Assert.Contains("idx_events_severity_timestamp", planText); Assert.DoesNotContain("SCAN site_events", planText); } /// /// Verifies that the query service's own page shape — an equality filter, ordered by /// (timestamp, id), limited to a page — is answered in index order rather than by /// sorting every matching row (#123). /// /// The equality-filtered column. /// The composite index expected to answer it. [Theory] [InlineData("severity", "idx_events_severity_timestamp")] [InlineData("event_type", "idx_events_type_timestamp")] public void EqualityFilteredPage_ReadsInTimestampOrder_WithoutSortingEveryMatch(string column, string expectedIndex) { foreach (var window in new[] { "", "AND timestamp >= '2026-01-01' " }) { using var cmd = _verifyConnection.CreateCommand(); cmd.CommandText = $"EXPLAIN QUERY PLAN SELECT id FROM site_events WHERE {column} = 'x' {window}" + "ORDER BY timestamp ASC, id ASC LIMIT 501"; var plan = new System.Text.StringBuilder(); using var reader = cmd.ExecuteReader(); while (reader.Read()) plan.Append(reader.GetString(reader.GetOrdinal("detail"))).Append('\n'); var planText = plan.ToString(); Assert.Contains(expectedIndex, planText); // "USE TEMP B-TREE FOR LAST TERM OF ORDER BY" (ties on timestamp, broken by id) // is fine; a whole-ORDER-BY temp b-tree means every match is read and sorted. Assert.DoesNotContain("USE TEMP B-TREE FOR ORDER BY", planText); } } /// Verifies that a pre-#123 file loses its single-column type/severity indexes on the next schema apply. [Fact] public void Apply_OnAPre123File_ReplacesTheSingleColumnIndexes() { using var connection = new SqliteConnection("Data Source=:memory:"); connection.Open(); using (var cmd = connection.CreateCommand()) { cmd.CommandText = """ CREATE TABLE site_events (id TEXT NOT NULL PRIMARY KEY, timestamp TEXT NOT NULL, event_type TEXT NOT NULL, severity TEXT NOT NULL, instance_id TEXT, source TEXT NOT NULL, message TEXT NOT NULL, details TEXT); CREATE INDEX idx_events_timestamp ON site_events(timestamp); CREATE INDEX idx_events_type ON site_events(event_type); CREATE INDEX idx_events_instance ON site_events(instance_id); CREATE INDEX idx_events_severity ON site_events(severity); INSERT INTO site_events VALUES ('a', '2026-01-01T00:00:00.0000000+00:00', 'alarm', 'Info', NULL, 's', 'm', NULL); """; cmd.ExecuteNonQuery(); } SiteEventLogSchema.Apply(connection); using var list = connection.CreateCommand(); list.CommandText = "SELECT name FROM pragma_index_list('site_events') WHERE origin = 'c' ORDER BY name"; var indexes = new List(); using (var reader = list.ExecuteReader()) while (reader.Read()) indexes.Add(reader.GetString(0)); Assert.Equal( ["idx_events_instance", "idx_events_severity_timestamp", "idx_events_timestamp", "idx_events_type_timestamp"], indexes); using var count = connection.CreateCommand(); count.CommandText = "SELECT COUNT(*) FROM site_events INDEXED BY idx_events_severity_timestamp WHERE severity = 'Info'"; Assert.Equal(1L, (long)count.ExecuteScalar()!); } }