Files
OpenNest/OpenNest.WinForms.Tests/ServerFirstUpdateSourceTests.cs
T
ajandClaude Opus 5.5 277ca36f42 feat(server): mirror the desktop update feed for Database-mode clients
Every installed copy checked GitHub Releases on its own, so a shop's
workstations shared GitHub's 60-requests-per-hour unauthenticated limit
per public address and each downloaded the ~90 MB package over the
internet. Shops large enough to hit that already run the nest server.

The server now mirrors the Velopack feed at /updates/{stable|dev}: one
GitHub API request per 30-minute refresh, each package downloaded once
and checked against the feed's size and SHA-256 before it is served. A
channel is mirrored only while clients ask for it, so stable-only shops
never download development builds. Database-mode clients read the
server first and fall back to GitHub when it has no feed (old server,
mirror off, not mirrored yet), downloading each package from whichever
source listed it. A hand-set feed URL still takes precedence.
OPENNEST_UPDATES=off turns the mirror off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:05:20 -04:00

108 lines
4.4 KiB
C#

using Velopack;
using Velopack.Logging;
using Velopack.Sources;
namespace OpenNest.WinForms.Tests;
public class ServerFirstUpdateSourceTests
{
[Fact]
public void FeedUrlWinsOverTheNestServer()
{
var source = Assert.IsType<SimpleWebSource>(
UpdaterService.CreateSource("http://feed.example:8080", includeDevBuilds: true, serverUrl: "http://nests.example:8090"));
Assert.Equal("http://feed.example:8080/", source.BaseUri.ToString());
}
[Fact]
public void WithoutANestServerUpdatesComeFromGitHub()
{
Assert.IsType<GithubSource>(UpdaterService.CreateSource(null, includeDevBuilds: false, serverUrl: null));
Assert.IsType<GithubSource>(UpdaterService.CreateSource("", includeDevBuilds: false, serverUrl: " "));
}
[Theory]
[InlineData(false, "http://nests.example:8090/updates/stable")]
[InlineData(true, "http://nests.example:8090/updates/dev")]
public void DatabaseModeReadsTheServersChannelFirst(bool includeDevBuilds, string expected)
{
Assert.IsType<ServerFirstUpdateSource>(
UpdaterService.CreateSource(null, includeDevBuilds, "http://nests.example:8090/"));
Assert.Equal(expected, ServerChannelUrl(includeDevBuilds, "http://nests.example:8090/"));
}
[Fact]
public async Task AServerFeedIsUsedAndItsPackagesDownloadFromTheServer()
{
var server = new FakeSource("0.6.1");
var github = new FakeSource("0.6.1");
var source = new ServerFirstUpdateSource(server, github);
var feed = await source.GetReleaseFeed(NullVelopackLogger.Instance, "OpenNest", "win");
await source.DownloadReleaseEntry(NullVelopackLogger.Instance, feed.Assets[0], "package", _ => { });
Assert.Same(server, source.LastFeedSource);
Assert.Equal((1, 1), (server.FeedRequests, server.Downloads));
Assert.Equal((0, 0), (github.FeedRequests, github.Downloads));
}
[Fact]
public async Task WhenTheServerHasNoFeedGitHubAnswersAndServesItsOwnPackage()
{
var server = new FakeSource(null);
var github = new FakeSource("0.6.1");
var source = new ServerFirstUpdateSource(server, github);
var feed = await source.GetReleaseFeed(NullVelopackLogger.Instance, "OpenNest", "win");
await source.DownloadReleaseEntry(NullVelopackLogger.Instance, feed.Assets[0], "package", _ => { });
Assert.Same(github, source.LastFeedSource);
Assert.Equal((1, 0), (server.FeedRequests, server.Downloads));
Assert.Equal((1, 1), (github.FeedRequests, github.Downloads));
}
private static string ServerChannelUrl(bool includeDevBuilds, string serverUrl)
{
var source = (ServerFirstUpdateSource)UpdaterService.CreateSource(null, includeDevBuilds, serverUrl);
var field = typeof(ServerFirstUpdateSource).GetField("_server", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
return ((SimpleWebSource)field.GetValue(source)!).BaseUri.ToString().TrimEnd('/');
}
/// <summary>A feed with one full package, or (version null) a source that fails like an unreachable server.</summary>
private sealed class FakeSource : IUpdateSource
{
private readonly string? _version;
public FakeSource(string? version) => _version = version;
public int FeedRequests { get; private set; }
public int Downloads { get; private set; }
public Task<VelopackAssetFeed> GetReleaseFeed(IVelopackLogger logger, string appId, string channel,
Guid? stagingId = null, VelopackAsset? latestLocalRelease = null)
{
FeedRequests++;
if (_version is null)
throw new HttpRequestException("Response status code does not indicate success: 503 (Service Unavailable).");
return Task.FromResult(new VelopackAssetFeed
{
Assets = new[]
{
new VelopackAsset { PackageId = appId, Version = SemanticVersion.Parse(_version), Type = VelopackAssetType.Full, FileName = $"OpenNest-{_version}-full.nupkg" },
},
});
}
public Task DownloadReleaseEntry(IVelopackLogger logger, VelopackAsset releaseEntry, string localFile,
Action<int> progress, CancellationToken cancelToken = default)
{
Downloads++;
return Task.CompletedTask;
}
}
}