using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ScadaLink.Commons.Messages.RemoteQuery; namespace ScadaLink.SiteEventLogging.Tests; public class EventLogQueryServiceTests : IDisposable { private readonly SiteEventLogger _eventLogger; private readonly EventLogQueryService _queryService; private readonly string _dbPath; public EventLogQueryServiceTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"test_query_{Guid.NewGuid()}.db"); var options = Options.Create(new SiteEventLogOptions { DatabasePath = _dbPath, QueryPageSize = 500 }); _eventLogger = new SiteEventLogger(options, NullLogger.Instance); _queryService = new EventLogQueryService( _eventLogger, options, NullLogger.Instance); } public void Dispose() { _eventLogger.Dispose(); if (File.Exists(_dbPath)) File.Delete(_dbPath); } private async Task SeedEvents() { await _eventLogger.LogEventAsync("script", "Error", "inst-1", "ScriptActor:Monitor", "Script timeout"); await _eventLogger.LogEventAsync("alarm", "Warning", "inst-1", "AlarmActor:TempHigh", "Alarm triggered"); await _eventLogger.LogEventAsync("deployment", "Info", "inst-2", "DeploymentManager", "Instance deployed"); await _eventLogger.LogEventAsync("connection", "Error", null, "DCL:OPC1", "Connection lost"); await _eventLogger.LogEventAsync("script", "Info", "inst-2", "ScriptActor:Calculate", "Script completed"); } private EventLogQueryRequest MakeRequest( string? eventType = null, string? severity = null, string? instanceId = null, string? keyword = null, long? continuationToken = null, int pageSize = 500, DateTimeOffset? from = null, DateTimeOffset? to = null) => new( CorrelationId: Guid.NewGuid().ToString(), SiteId: "site-1", From: from, To: to, EventType: eventType, Severity: severity, InstanceId: instanceId, KeywordFilter: keyword, ContinuationToken: continuationToken, PageSize: pageSize, Timestamp: DateTimeOffset.UtcNow); [Fact] public async Task Query_ReturnsAllEvents_WhenNoFilters() { await SeedEvents(); var response = _queryService.ExecuteQuery(MakeRequest()); Assert.True(response.Success); Assert.Equal(5, response.Entries.Count); Assert.False(response.HasMore); } [Fact] public async Task Query_FiltersByEventType() { await SeedEvents(); var response = _queryService.ExecuteQuery(MakeRequest(eventType: "script")); Assert.True(response.Success); Assert.Equal(2, response.Entries.Count); Assert.All(response.Entries, e => Assert.Equal("script", e.EventType)); } [Fact] public async Task Query_FiltersBySeverity() { await SeedEvents(); var response = _queryService.ExecuteQuery(MakeRequest(severity: "Error")); Assert.True(response.Success); Assert.Equal(2, response.Entries.Count); Assert.All(response.Entries, e => Assert.Equal("Error", e.Severity)); } [Fact] public async Task Query_FiltersByInstanceId() { await SeedEvents(); var response = _queryService.ExecuteQuery(MakeRequest(instanceId: "inst-1")); Assert.True(response.Success); Assert.Equal(2, response.Entries.Count); Assert.All(response.Entries, e => Assert.Equal("inst-1", e.InstanceId)); } [Fact] public async Task Query_KeywordSearch_MatchesMessage() { await SeedEvents(); var response = _queryService.ExecuteQuery(MakeRequest(keyword: "timeout")); Assert.True(response.Success); Assert.Single(response.Entries); Assert.Contains("timeout", response.Entries[0].Message, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task Query_KeywordSearch_MatchesSource() { await SeedEvents(); var response = _queryService.ExecuteQuery(MakeRequest(keyword: "AlarmActor")); Assert.True(response.Success); Assert.Single(response.Entries); Assert.Contains("AlarmActor", response.Entries[0].Source); } [Fact] public async Task Query_CombinesMultipleFilters() { await SeedEvents(); var response = _queryService.ExecuteQuery(MakeRequest( eventType: "script", severity: "Error", instanceId: "inst-1")); Assert.True(response.Success); Assert.Single(response.Entries); Assert.Equal("Script timeout", response.Entries[0].Message); } [Fact] public async Task Query_Pagination_ReturnsCorrectPageSize() { await SeedEvents(); var response = _queryService.ExecuteQuery(MakeRequest(pageSize: 2)); Assert.True(response.Success); Assert.Equal(2, response.Entries.Count); Assert.True(response.HasMore); Assert.NotNull(response.ContinuationToken); } [Fact] public async Task Query_Pagination_ContinuationTokenWorksCorrectly() { await SeedEvents(); // Get first page var page1 = _queryService.ExecuteQuery(MakeRequest(pageSize: 2)); Assert.Equal(2, page1.Entries.Count); Assert.True(page1.HasMore); // Get second page using continuation token var page2 = _queryService.ExecuteQuery(MakeRequest( pageSize: 2, continuationToken: page1.ContinuationToken)); Assert.Equal(2, page2.Entries.Count); Assert.True(page2.HasMore); // Get third page var page3 = _queryService.ExecuteQuery(MakeRequest( pageSize: 2, continuationToken: page2.ContinuationToken)); Assert.Single(page3.Entries); Assert.False(page3.HasMore); // Verify no overlapping entries var allIds = page1.Entries.Select(e => e.Id) .Concat(page2.Entries.Select(e => e.Id)) .Concat(page3.Entries.Select(e => e.Id)) .ToList(); Assert.Equal(5, allIds.Distinct().Count()); } [Fact] public async Task Query_FiltersByTimeRange() { // Insert events at controlled times var now = DateTimeOffset.UtcNow; // Insert with a direct SQL to control timestamps InsertEventAt(now.AddHours(-2), "script", "Info", null, "S1", "Old event"); InsertEventAt(now.AddMinutes(-30), "script", "Info", null, "S2", "Recent event"); InsertEventAt(now, "script", "Info", null, "S3", "Now event"); var response = _queryService.ExecuteQuery(MakeRequest( from: now.AddHours(-1), to: now.AddMinutes(1))); Assert.True(response.Success); Assert.Equal(2, response.Entries.Count); } [Fact] public async Task Query_EmptyResult_WhenNoMatches() { await SeedEvents(); var response = _queryService.ExecuteQuery(MakeRequest(eventType: "nonexistent")); Assert.True(response.Success); Assert.Empty(response.Entries); Assert.False(response.HasMore); Assert.Null(response.ContinuationToken); } [Fact] public void Query_ReturnsCorrelationId() { var request = MakeRequest(); var response = _queryService.ExecuteQuery(request); Assert.Equal(request.CorrelationId, response.CorrelationId); Assert.Equal("site-1", response.SiteId); } [Fact] public async Task Query_ReturnsAllEventLogEntryFields() { await _eventLogger.LogEventAsync("script", "Error", "inst-1", "ScriptActor:Run", "Failure", "{\"stack\":\"trace\"}"); var response = _queryService.ExecuteQuery(MakeRequest()); Assert.Single(response.Entries); var entry = response.Entries[0]; Assert.True(entry.Id > 0); Assert.Equal("script", entry.EventType); Assert.Equal("Error", entry.Severity); Assert.Equal("inst-1", entry.InstanceId); Assert.Equal("ScriptActor:Run", entry.Source); Assert.Equal("Failure", entry.Message); Assert.Equal("{\"stack\":\"trace\"}", entry.Details); } private void InsertEventAt(DateTimeOffset timestamp, string eventType, string severity, string? instanceId, string source, string message) { using var cmd = _eventLogger.Connection.CreateCommand(); cmd.CommandText = """ INSERT INTO site_events (timestamp, event_type, severity, instance_id, source, message) VALUES ($ts, $et, $sev, $iid, $src, $msg) """; cmd.Parameters.AddWithValue("$ts", timestamp.ToString("o")); cmd.Parameters.AddWithValue("$et", eventType); cmd.Parameters.AddWithValue("$sev", severity); cmd.Parameters.AddWithValue("$iid", (object?)instanceId ?? DBNull.Value); cmd.Parameters.AddWithValue("$src", source); cmd.Parameters.AddWithValue("$msg", message); cmd.ExecuteNonQuery(); } }