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