using System.Net; using MeterVault.Infrastructure.Options; using MeterVault.Infrastructure.Update; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; namespace MeterVault.Integration.Tests; /// /// Version comparison behind the update banner. Pure — no database, no network. /// /// /// The failure mode worth guarding is a banner that never clears: it trains the operator to ignore /// it, and the next real update goes unnoticed. Every case here is one that would produce exactly /// that if the parse or the ordering were naive. /// public sealed class UpdateCheckTests { [Theory] [InlineData("0.1.0", 0, 1, 0)] [InlineData("v0.2.0", 0, 2, 0)] [InlineData(" 1.4.7 ", 1, 4, 7)] // The shape the running assembly actually reports — MSBuild appends the commit hash. [InlineData("0.1.0+8fe5f4411b467943c6717cc994ca74d8883d2839", 0, 1, 0)] // A prerelease compares equal to its release, so an rc tag never nags a released instance. [InlineData("0.2.0-rc.1", 0, 2, 0)] public void Parses_the_forms_tags_and_assemblies_actually_use(string text, int major, int minor, int patch) { Assert.True(ReleaseVersion.TryParse(text, out var version)); Assert.Equal(new ReleaseVersion(major, minor, patch), version); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] [InlineData("latest")] [InlineData("1.0")] // System.Version would accept this and invent a -1 [InlineData("1.0.0.0")] // ...and this, silently changing the comparison [InlineData("v1.x.0")] public void Rejects_anything_it_cannot_order(string? text) { Assert.False(ReleaseVersion.TryParse(text, out _)); } [Fact] public void Orders_numerically_not_lexically() { Assert.True(ReleaseVersion.TryParse("0.10.0", out var ten)); Assert.True(ReleaseVersion.TryParse("0.9.0", out var nine)); // "0.10.0" < "0.9.0" as strings — the classic way an update banner goes permanently quiet. Assert.True(ten > nine); Assert.False(nine > ten); } [Fact] public void Picks_the_highest_tag_regardless_of_listing_order() { // Gitea does not promise semver ordering, so taking the first entry is not safe. string?[] tags = ["v0.9.0", "v0.10.0", "v0.2.0"]; Assert.True(ReleaseVersion.TryPickLatest(tags, out var latest)); Assert.Equal(new ReleaseVersion(0, 10, 0), latest!.Value); } [Fact] public void Ignores_tags_that_are_not_versions() { string?[] tags = ["nightly", null, "v0.3.0", "release-candidate", ""]; Assert.True(ReleaseVersion.TryPickLatest(tags, out var latest)); Assert.Equal(new ReleaseVersion(0, 3, 0), latest!.Value); } [Fact] public void Reports_nothing_when_no_tag_is_a_version() { Assert.False(ReleaseVersion.TryPickLatest(["nightly", "main"], out _)); } [Fact] public void An_update_is_only_available_when_the_published_version_is_strictly_newer() { var running = new ReleaseVersion(0, 1, 0); Assert.True(new UpdateStatus(running, new ReleaseVersion(0, 2, 0), DateTimeOffset.UtcNow).UpdateAvailable); Assert.False(new UpdateStatus(running, running, DateTimeOffset.UtcNow).UpdateAvailable); // A dev build ahead of the newest tag must not be told to "update" backwards. Assert.False(new UpdateStatus(new ReleaseVersion(0, 3, 0), running, DateTimeOffset.UtcNow).UpdateAvailable); } [Fact] public void An_unknown_version_on_either_side_shows_no_banner() { // Never guess. A missing version means "cannot tell", which is silence, not a nag. Assert.False(new UpdateStatus(null, new ReleaseVersion(9, 9, 9), null).UpdateAvailable); Assert.False(new UpdateStatus(new ReleaseVersion(0, 1, 0), null, null).UpdateAvailable); } [Fact] public void The_running_assembly_reports_the_version_from_the_VERSION_file() { // Guards the MSBuild plumbing: without VERSION stamped into the assembly this is null and // the banner can never appear, however well the comparison works. Compared against the file // itself, so reading the host process's version by mistake cannot pass this. var repoRoot = FindRepoRoot(); Assert.True(ReleaseVersion.TryParse(File.ReadAllText(Path.Combine(repoRoot, "VERSION")), out var declared)); Assert.Equal(declared, UpdateCheckService.RunningVersion()); } [Fact] public async Task Reads_the_newest_tag_out_of_a_gitea_tag_listing() { // The payload shape is the real one, captured from // /api/v1/repos/FinalFactory/MeterVault/tags — the seam the unit tests above cannot cover. const string Payload = """ [ {"name":"v0.1.0","message":"Release v0.1.0","id":"4a51306"}, {"name":"v0.3.0","message":"Release v0.3.0","id":"aa11bb2"}, {"name":"v0.2.0","message":"Release v0.2.0","id":"cc33dd4"} ] """; var status = await NewService(HttpStatusCode.OK, Payload).GetAsync(); Assert.Equal(new ReleaseVersion(0, 3, 0), status.Latest); Assert.NotNull(status.CheckedAt); } [Fact] public async Task An_unreachable_repository_reports_no_latest_instead_of_throwing() { // A dashboard on an instance with no outbound access must still render. var status = await NewService(HttpStatusCode.ServiceUnavailable, "nope").GetAsync(); Assert.Null(status.Latest); Assert.False(status.UpdateAvailable); } [Fact] public async Task Garbage_in_the_response_is_treated_as_cannot_tell() { var status = await NewService(HttpStatusCode.OK, "login page").GetAsync(); Assert.Null(status.Latest); Assert.False(status.UpdateAvailable); } [Fact] public async Task Disabling_the_check_makes_no_request_at_all() { var handler = new StubHandler(HttpStatusCode.OK, "[]"); var service = new UpdateCheckService( new StubHttpClientFactory(handler), Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { UpdateCheckEnabled = false }), NullLogger.Instance); await service.GetAsync(); Assert.Equal(0, handler.Calls); } private static UpdateCheckService NewService(HttpStatusCode status, string payload) => new(new StubHttpClientFactory(new StubHandler(status, payload)), Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { UpdateCheckEnabled = true, UpdateCheckUrl = "https://example.invalid/tags", }), NullLogger.Instance); private sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory { public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); } private sealed class StubHandler(HttpStatusCode status, string payload) : HttpMessageHandler { public int Calls { get; private set; } protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { Calls++; return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(payload) }); } } private static string FindRepoRoot() { var directory = new DirectoryInfo(AppContext.BaseDirectory); while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "VERSION"))) { directory = directory.Parent; } Assert.NotNull(directory); return directory!.FullName; } }