From 1ed14c0a199115dcb5c33cf5a429ae524f87d140 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 28 Aug 2026 10:02:18 +0200 Subject: [PATCH] test(core): add metrics history store and log parsing tests --- .../History/SqliteMetricsStoreTests.cs | 99 +++++++++++++++++++ tests/Vantage.Core.Tests/Logs/LogTests.cs | 87 ++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 tests/Vantage.Core.Tests/History/SqliteMetricsStoreTests.cs create mode 100644 tests/Vantage.Core.Tests/Logs/LogTests.cs diff --git a/tests/Vantage.Core.Tests/History/SqliteMetricsStoreTests.cs b/tests/Vantage.Core.Tests/History/SqliteMetricsStoreTests.cs new file mode 100644 index 0000000..c1e63ed --- /dev/null +++ b/tests/Vantage.Core.Tests/History/SqliteMetricsStoreTests.cs @@ -0,0 +1,99 @@ +using Vantage.Core.History; +using Vantage.Core.Models; +using Xunit; + +namespace Vantage.Core.Tests.History; + +public class SqliteMetricsStoreTests +{ + private static readonly DateTimeOffset Origin = new(2026, 8, 26, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task RecordAndQuery_ReturnsPointsInTimeOrder() + { + await using var store = SqliteMetricsStore.InMemory(); + + await store.RecordAsync(new MetricSample("h1", MetricNames.Cpu, Origin, 10)); + await store.RecordAsync(new MetricSample("h1", MetricNames.Cpu, Origin.AddSeconds(30), 30)); + await store.RecordAsync(new MetricSample("h1", MetricNames.Cpu, Origin.AddSeconds(15), 20)); + + var points = await store.QueryAsync("h1", MetricNames.Cpu, Origin, Origin.AddMinutes(1)); + + Assert.Equal(new[] { 10.0, 20.0, 30.0 }, points.Select(p => p.Value)); + Assert.True(points[0].Timestamp < points[1].Timestamp); + Assert.True(points[1].Timestamp < points[2].Timestamp); + } + + [Fact] + public async Task Query_IsScopedToHostAndMetricAndWindow() + { + await using var store = SqliteMetricsStore.InMemory(); + + await store.RecordAsync(new MetricSample("h1", MetricNames.Cpu, Origin, 55)); + await store.RecordAsync(new MetricSample("h2", MetricNames.Cpu, Origin, 99)); + await store.RecordAsync(new MetricSample("h1", MetricNames.Memory, Origin, 42)); + await store.RecordAsync(new MetricSample("h1", MetricNames.Cpu, Origin.AddHours(2), 77)); + + var points = await store.QueryAsync("h1", MetricNames.Cpu, Origin.AddMinutes(-1), Origin.AddMinutes(1)); + + Assert.Single(points); + Assert.Equal(55, points[0].Value); + } + + [Fact] + public async Task RecordHostMetrics_ExpandsStandardSeries() + { + await using var store = SqliteMetricsStore.InMemory(); + var metrics = new HostMetrics + { + CpuUsagePercent = 40, + MemoryUsedBytes = 3_000_000_000, + MemoryTotalBytes = 4_000_000_000, + DiskUsedBytes = 50, + DiskTotalBytes = 100, + Load1 = 1.2, + Load5 = 1.1, + Load15 = 0.9, + CollectedAt = Origin + }; + + await store.RecordHostMetricsAsync("h1", metrics); + + var cpu = await store.QueryAsync("h1", MetricNames.Cpu, Origin.AddMinutes(-1), Origin.AddMinutes(1)); + var disk = await store.QueryAsync("h1", MetricNames.Disk, Origin.AddMinutes(-1), Origin.AddMinutes(1)); + + Assert.Equal(40, cpu.Single().Value); + Assert.Equal(50, disk.Single().Value); + Assert.Equal(6, await store.CountAsync()); + } + + [Fact] + public async Task PruneOlderThan_DropsExpiredRowsOnly() + { + await using var store = SqliteMetricsStore.InMemory(); + + await store.RecordAsync(new MetricSample("h1", MetricNames.Cpu, Origin.AddHours(-10), 1)); + await store.RecordAsync(new MetricSample("h1", MetricNames.Cpu, Origin.AddHours(-1), 2)); + await store.RecordAsync(new MetricSample("h1", MetricNames.Cpu, Origin, 3)); + + var removed = await store.PruneOlderThanAsync(Origin.AddHours(-5)); + + Assert.Equal(1, removed); + Assert.Equal(2, await store.CountAsync()); + + var survivors = await store.QueryAsync("h1", MetricNames.Cpu, Origin.AddDays(-1), Origin.AddDays(1)); + Assert.Equal(new[] { 2.0, 3.0 }, survivors.Select(p => p.Value)); + } + + [Fact] + public async Task PruneOlderThan_WithCutoffBeforeAllData_RemovesNothing() + { + await using var store = SqliteMetricsStore.InMemory(); + await store.RecordAsync(new MetricSample("h1", MetricNames.Cpu, Origin, 3)); + + var removed = await store.PruneOlderThanAsync(Origin.AddDays(-1)); + + Assert.Equal(0, removed); + Assert.Equal(1, await store.CountAsync()); + } +} diff --git a/tests/Vantage.Core.Tests/Logs/LogTests.cs b/tests/Vantage.Core.Tests/Logs/LogTests.cs new file mode 100644 index 0000000..b7a2f6c --- /dev/null +++ b/tests/Vantage.Core.Tests/Logs/LogTests.cs @@ -0,0 +1,87 @@ +using Vantage.Core.Logs; +using Vantage.Core.Models; +using Vantage.Core.Tests.Fakes; +using Vantage.Core.Tests.Fixtures; +using Xunit; + +namespace Vantage.Core.Tests.Logs; + +public class LogTests +{ + [Fact] + public void Command_ForJournalUnit_UsesJournalctl() + { + var command = LogCommand.Build(LogSource.Journal("nginx"), new LogQuery { Lines = 50 }); + + Assert.Contains("journalctl -u 'nginx' -n 50", command); + Assert.Contains("-o short-iso", command); + } + + [Fact] + public void Command_ForFile_UsesTail() + { + var command = LogCommand.Build(LogSource.File("/var/log/syslog"), new LogQuery { Lines = 120 }); + Assert.Contains("tail -n 120 -- '/var/log/syslog'", command); + } + + [Fact] + public void Command_WithFilter_AppendsGrep() + { + var command = LogCommand.Build(LogSource.Journal("sshd"), new LogQuery { Lines = 10, Filter = "Accepted" }); + Assert.Contains("| grep -F -- 'Accepted'", command); + } + + [Fact] + public void Command_QuotesFilterToPreventInjection() + { + var command = LogCommand.Build(LogSource.Journal("sshd"), new LogQuery { Filter = "a'; rm -rf /" }); + Assert.Contains("grep -F -- 'a'\\''; rm -rf /'", command); + } + + [Fact] + public void Parser_ExtractsTimestampUnitAndMessage() + { + var lines = LogLineParser.Parse(CollectorFixtures.JournalOutput); + + Assert.Equal(3, lines.Count); + Assert.Equal("nginx", lines[0].Unit); + Assert.Equal("GET /api/status 200 12ms", lines[0].Message); + Assert.NotNull(lines[0].Timestamp); + Assert.Equal(2026, lines[0].Timestamp!.Value.Year); + } + + [Fact] + public void Parser_PlainLineWithoutTimestamp_KeepsRawAsMessage() + { + var lines = LogLineParser.Parse("just a plain log line without iso stamp\n"); + var line = Assert.Single(lines); + Assert.Null(line.Timestamp); + Assert.Equal("just a plain log line without iso stamp", line.Message); + } + + [Fact] + public async Task SshLogProvider_RunsBuiltCommand() + { + var runner = new FakeSshCommandRunner(CollectorFixtures.JournalOutput); + var provider = new SshLogProvider(runner); + + var lines = await provider.ReadAsync( + new HostConfig(), LogSource.Journal("nginx"), new LogQuery { Lines = 3 }, CancellationToken.None); + + Assert.Contains("journalctl", runner.Commands.Single()); + Assert.Equal(3, lines.Count); + } + + [Fact] + public async Task MockLogProvider_HonorsFilter() + { + var provider = new MockLogProvider(() => new DateTimeOffset(2026, 8, 26, 12, 0, 0, TimeSpan.Zero)); + var host = new HostConfig { Name = "vps01" }; + + var filtered = await provider.ReadAsync( + host, LogSource.Journal("sshd"), new LogQuery { Lines = 40, Filter = "sshd" }, CancellationToken.None); + + Assert.NotEmpty(filtered); + Assert.All(filtered, line => Assert.Contains("sshd", line.Raw)); + } +}