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;
}
}