using System.IO; using FluentAssertions; using TeamsISO.App.Services; namespace TeamsISO.App.Tests.Services; // UpdateChecker unit tests. // // We don't exercise CheckAsync (the real HTTP call against Forgejo) — // tests must not depend on the network. Coverage instead: // • TryParseSemVer: version-comparison parsing across the inputs the // real release stream produces. // • CheckIfDueAsync throttle: a recent cooldown stamp short-circuits // and returns null *before* CheckAsync runs (which would otherwise // fire an HTTP request). public sealed class UpdateCheckerTests : IDisposable { private readonly string _tempDir; private readonly string? _previousOverride; public UpdateCheckerTests() { _tempDir = Path.Combine(Path.GetTempPath(), $"teamsiso-update-{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDir); _previousOverride = UpdateChecker.StateDirectoryOverride; UpdateChecker.StateDirectoryOverride = _tempDir; } public void Dispose() { UpdateChecker.StateDirectoryOverride = _previousOverride; try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); } catch { /* cleanup best-effort */ } } [Theory] [InlineData("v1.2.3", "1.2.3")] [InlineData("V1.2.3", "1.2.3")] // case-insensitive 'v' strip [InlineData("1.2.3", "1.2.3")] [InlineData("v1.2.3.4", "1.2.3.4")] // 4-segment .NET-style versions [InlineData("v1.2.3-alpha", "1.2.3")] // pre-release suffix stripped [InlineData("v1.2.3-beta.4", "1.2.3")] public void TryParseSemVer_AcceptsExpectedForms(string input, string expected) { UpdateChecker.TryParseSemVer(input).Should().Be(Version.Parse(expected)); } [Theory] [InlineData("not-a-version")] [InlineData("v.invalid")] [InlineData("")] public void TryParseSemVer_ReturnsNullOnGarbage(string input) { UpdateChecker.TryParseSemVer(input).Should().BeNull(); } [Fact] public void TryParseSemVer_OrderingIsSemantic() { // The CheckAsync comparison is "latest > current" — pin the // ordering across the version arc the release process actually // produces. var older = UpdateChecker.TryParseSemVer("v0.1.0")!; var newer = UpdateChecker.TryParseSemVer("v0.2.0")!; var newest = UpdateChecker.TryParseSemVer("v1.0.0")!; (newer > older).Should().BeTrue(); (newest > newer).Should().BeTrue(); (newest > older).Should().BeTrue(); (older > newer).Should().BeFalse(); } [Fact] public async Task CheckIfDueAsync_ReturnsNull_WhenCooldownStampIsRecent() { // Pre-write a "we just checked" stamp. The throttle should // short-circuit and return null without firing the HTTP call, // which means the test passes deterministically offline. File.WriteAllText( Path.Combine(_tempDir, "last-update-check.txt"), DateTimeOffset.UtcNow.ToString("o")); var result = await UpdateChecker.CheckIfDueAsync(TimeSpan.FromHours(24)); result.Should().BeNull("a stamp inside the cooldown window suppresses the check"); } [Fact] public async Task CheckIfDueAsync_ReturnsNull_WhenStampIsOldButCooldownIsLargerThanGap() { // Edge case: stamp 1h old, cooldown 24h → still suppressed. File.WriteAllText( Path.Combine(_tempDir, "last-update-check.txt"), DateTimeOffset.UtcNow.AddHours(-1).ToString("o")); var result = await UpdateChecker.CheckIfDueAsync(TimeSpan.FromHours(24)); result.Should().BeNull(); } [Fact] public void LaunchCheckEnabled_RoundTrips() { // Default (no flag file) → enabled. UpdateChecker.LaunchCheckEnabled.Should().BeTrue(); UpdateChecker.LaunchCheckEnabled = false; UpdateChecker.LaunchCheckEnabled.Should().BeFalse( "writing the opt-out flag should be visible immediately"); File.Exists(Path.Combine(_tempDir, "no-update-check.flag")) .Should().BeTrue(); UpdateChecker.LaunchCheckEnabled = true; UpdateChecker.LaunchCheckEnabled.Should().BeTrue(); File.Exists(Path.Combine(_tempDir, "no-update-check.flag")) .Should().BeFalse("re-enabling should remove the opt-out flag"); } }