using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Tests;
///
/// Finding a newer build, fetching it, and not installing it until somebody says so.
///
///
///
/// The view model is driven a pass at a time through CheckOnceAsync rather than through its timer,
/// which is the same split VaultViewModel makes between its sync pass and its sync loop and for the
/// same reason: a suite that waited on a PeriodicTimer would be testing the clock.
///
///
/// The load-bearing test here is . Everything else is
/// about how the feature behaves; that one is about the promise it makes.
///
///
public sealed class UpdateFlowTests : IDisposable
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
private readonly string directory =
Path.Combine(Path.GetTempPath(), $"dodossh-updates-{Guid.CreateVersion7():N}");
private readonly FakeUpdateChannel channel = new();
private int liveSessions;
private int restarts;
private ClientPaths Paths => new(directory);
///
/// The real settings store over a real temporary directory, not a stand-in. Persistence is one of the
/// things being asserted, and the file is the thing that persists.
///
private UpdateViewModel Build() =>
new(
channel,
new ClientSettingsStore(Paths),
TimeProvider.System,
() => liveSessions,
restart: update =>
{
restarts++;
channel.ApplyAndRestart(update);
return Task.CompletedTask;
});
///
public void Dispose()
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void ABuildThatCannotReplaceItself_OffersNothing()
{
channel.IsSupported = false;
var updates = Build();
updates.State.ShouldBe(UpdateState.Unsupported);
updates.IsUnsupported.ShouldBeTrue();
updates.CanCheckNow.ShouldBeFalse();
updates.IsBannerShowing.ShouldBeFalse();
}
[Fact]
public void ABuildThatCannotReplaceItself_StillKnowsItsOwnVersion()
{
channel.IsSupported = false;
channel.CurrentVersion = "0.4.2";
Build().CurrentVersion.ShouldBe("0.4.2");
}
[Fact]
public async Task ABackgroundPassThatFindsNothing_SaysNothing()
{
var updates = Build();
await updates.CheckOnceAsync(Token);
channel.Checks.ShouldBe(1);
updates.State.ShouldBe(UpdateState.Idle);
updates.Status.ShouldBeEmpty();
}
[Fact]
public async Task APressedCheckThatFindsNothing_SaysSo()
{
channel.CurrentVersion = "1.2.0";
var updates = Build();
await updates.CheckNowCommand.ExecuteAsync(null);
updates.State.ShouldBe(UpdateState.Idle);
updates.Status.ShouldContain("1.2.0");
}
[Fact]
public async Task AnUpdateThatIsFound_IsFetchedWithoutBeingAskedAbout()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
channel.Downloads.ShouldBe(1);
updates.State.ShouldBe(UpdateState.Ready);
updates.ReadyVersion.ShouldBe("1.3.0");
updates.DownloadPercent.ShouldBe(100);
updates.IsBannerShowing.ShouldBeTrue();
}
///
/// The whole policy in one assertion. A fetched update sits until a person presses the button, or until
/// the application is next started for their own reasons — because a restart ends every shell, and this
/// application has spent a lot of design effort on shells surviving a lock.
///
[Fact]
public async Task AReadyUpdate_IsNeverAppliedOnItsOwn()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
updates.State.ShouldBe(UpdateState.Ready);
channel.Restarts.ShouldBe(0);
restarts.ShouldBe(0);
}
[Fact]
public async Task RestartingApplies_TheUpdateThatWasFound()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
await updates.RestartNowCommand.ExecuteAsync(null);
channel.Restarts.ShouldBe(1);
channel.RestartedWith!.Version.ShouldBe("1.3.0");
}
///
/// ◆ The regression for an install that closed the application instead of installing anything. On the
/// phone applying is a request that returns, so a failure in it throws on the spot rather than being
/// cut short by the process ending — and with nothing catching it, it left a command handler, passed
/// the dispatcher and took the process with it. Pressing INSTALL closed the application, said nothing,
/// and installed nothing, which reads as anything but a bug in the updater.
///
[Fact]
public async Task AnInstallThatFails_IsReportedRatherThanThrown()
{
channel.Available = new AvailableUpdate("1.3.0");
channel.ApplyingEndsTheProcess = false;
channel.RestartFailure = new InvalidOperationException("The installer refused the session.");
var updates = Build();
await updates.CheckOnceAsync(Token);
// Not Should.ThrowAsync: the assertion is that this does not throw at all.
await updates.RestartNowCommand.ExecuteAsync(null);
updates.State.ShouldBe(UpdateState.Failed);
updates.Status.ShouldBe("The installer refused the session.");
}
[Fact]
public async Task RestartingWithNothingReady_DoesNothing()
{
var updates = Build();
await updates.RestartNowCommand.ExecuteAsync(null);
channel.Restarts.ShouldBe(0);
}
[Fact]
public async Task DismissingTheBanner_KeepsTheOfferOnPreferences()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
updates.DismissBannerCommand.Execute(null);
updates.IsBannerShowing.ShouldBeFalse();
updates.IsReady.ShouldBeTrue();
}
///
/// A forge that cannot be reached is a laptop on a train. Nothing asked, so nothing is said, and the
/// state goes back to Idle rather than to Failed — Failed is reserved for something a person is waiting
/// on an answer to.
///
[Fact]
public async Task ABackgroundCheckThatFails_IsNotAnnounced()
{
channel.CheckFailure = new HttpRequestException("no such host");
var updates = Build();
await updates.CheckOnceAsync(Token);
updates.State.ShouldBe(UpdateState.Idle);
updates.Status.ShouldBeEmpty();
}
[Fact]
public async Task APressedCheckThatFails_SaysWhy()
{
channel.CheckFailure = new HttpRequestException("no such host");
var updates = Build();
await updates.CheckNowCommand.ExecuteAsync(null);
updates.State.ShouldBe(UpdateState.Failed);
updates.Status.ShouldBe("no such host");
}
[Fact]
public async Task TurningOffAutomaticChecks_StopsThePassDoingAnything()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
updates.IsAutomatic = false;
await updates.CheckOnceAsync(Token);
channel.Checks.ShouldBe(0);
}
///
/// A second view model over the same , which is how this repository tests that
/// a preference reached the disk — the same shape as the terminal font size's own persistence test.
///
[Fact]
public void TurningOffAutomaticChecks_IsStillOffOnTheNextLaunch()
{
var first = Build();
first.IsAutomatic.ShouldBeTrue();
first.IsAutomatic = false;
Build().IsAutomatic.ShouldBeFalse();
}
/// Storing one preference does not discard the others.
///
///
/// Pins the read-modify-write in OnIsAutomaticChanged, which nothing else covered: writing this
/// setting reads the file first, so a font size chosen earlier is still there afterwards. Writing the
/// view model's own state instead would silently reset every preference it does not hold.
///
///
/// Note the limit of what read-modify-write buys here, because the comment it guards is easy to read
/// as promising more. It preserves settings this build knows about. A key written by a newer
/// build is dropped, because the store deserialises with
/// JsonUnmappedMemberHandling.Skip — unmapped members are skipped rather than carried, so they
/// do not survive a round trip. That is asserted below rather than left as an assumption, so that
/// anybody who needs forward-compatibility discovers the cost here instead of in the field.
///
///
[Fact]
public void StoringOnePreference_KeepsTheOthersThisBuildKnows()
{
Directory.CreateDirectory(directory);
File.WriteAllText(
Paths.SettingsFile,
"""{"terminalFontSize":19,"somethingOnlyANewerBuildKnows":"keep me"}""");
Build().IsAutomatic = false;
var reread = new ClientSettingsStore(Paths).Read();
reread.TerminalFontSize.ShouldBe(19);
reread.AutomaticUpdateChecks.ShouldBeFalse();
// The honest limit, stated as an assertion: an unmapped key does not survive.
File.ReadAllText(Paths.SettingsFile).ShouldNotContain("somethingOnlyANewerBuildKnows");
}
///
/// A setting missing from the file reads back as the default the record declares.
///
///
///
/// This is the regression test for a defect that was already in the tree and could not bite until a
/// second preference existed. The JSON source generator builds a record through a synthesised
/// parameterised constructor and assigns every property from its argument array, so a member
/// absent from the file arrived as the CLR default and overwrote whatever a property initializer had
/// set. A settings file of {} read back a font size of 0 — clamped to the 8px floor rather than
/// the 13px the renderer draws at — and, once it existed, update checks off.
///
///
/// It is asserted on both settings, not just the new one, because the mechanism has nothing to do with
/// either: it is a property of how this record is deserialised, and the next preference somebody adds
/// inherits it. The fix is that the defaults live on the constructor parameters, which is the only
/// place the generator reads them from.
///
///
/// A file that exists and lacks the key is the case that matters, and it is not the same as no file at
/// all — Read short-circuits to a fresh record when the file is missing, which is why the bug
/// hid. Every machine that has ever run this application has a settings.json without the newer key.
///
///
[Fact]
public void ASettingAbsentFromTheFile_ComesBackAsItsDeclaredDefault()
{
Directory.CreateDirectory(directory);
var store = new ClientSettingsStore(Paths);
File.WriteAllText(Paths.SettingsFile, "{}");
var fromEmptyObject = store.Read();
fromEmptyObject.AutomaticUpdateChecks.ShouldBeTrue();
fromEmptyObject.TerminalFontSize.ShouldBe(ClientSettings.DefaultTerminalFontSize);
// The realistic shape: a file written by the build before this feature existed.
File.WriteAllText(Paths.SettingsFile, """{"terminalFontSize":19}""");
var fromOlderBuild = store.Read();
fromOlderBuild.TerminalFontSize.ShouldBe(19);
fromOlderBuild.AutomaticUpdateChecks.ShouldBeTrue();
}
[Fact]
public async Task TheRestartWarning_CountsTheShellsItWouldClose()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
liveSessions = 0;
updates.RestartWarning.ShouldContain("Nothing is connected");
liveSessions = 1;
updates.RestartWarning.ShouldContain("the shell you have open");
liveSessions = 3;
updates.RestartWarning.ShouldContain("the 3 shells you have open");
}
[Fact]
public async Task ADownloadInFlight_IsAStateTheScreenCanShow()
{
channel.Available = new AvailableUpdate("1.3.0");
channel.HoldDownload = new TaskCompletionSource();
var updates = Build();
var pass = updates.CheckOnceAsync(Token);
// The gate is what makes this observable at all; without it the download would be over before
// anything could look.
while (updates.State is not UpdateState.Downloading)
{
await Task.Yield();
}
updates.IsDownloading.ShouldBeTrue();
updates.CanCheckNow.ShouldBeFalse();
channel.HoldDownload.SetResult();
await pass;
updates.State.ShouldBe(UpdateState.Ready);
}
[Fact]
public async Task APassWhileOneIsAlreadyReady_DoesNotAskAgain()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await updates.CheckOnceAsync(Token);
await updates.CheckOnceAsync(Token);
channel.Checks.ShouldBe(1);
}
[Fact]
public async Task DisposingStopsTheLoop()
{
var updates = Build();
updates.Start();
await updates.DisposeAsync();
await updates.DisposeAsync();
}
}