using System.Net; using System.Net.Http; using System.Runtime.InteropServices; using CursorLang.Models; using CursorLang.Services; using CursorLang.Tests.Infrastructure; namespace CursorLang.Tests.Services; /// /// Reading the release list of Gitea. The answer of the server is not ours to /// shape, so what matters is what the app makes of it. /// public sealed class GiteaReleaseFeedTests { private const string Releases = """ [ { "tag_name": "v1.2.0", "draft": false, "prerelease": false, "html_url": "https://git.alrakis.kz/alrakis/cursor-lang/releases/tag/v1.2.0", "assets": [ { "name": "CursorLang-1.2.0.0.msixbundle", "browser_download_url": "https://git.alrakis.kz/attachments/CursorLang-1.2.0.0.msixbundle", "size": 4096 } ] } ] """; [Fact] public async Task A_release_is_read_whole() { ReleaseInfo? release = await Read(Releases); Assert.NotNull(release); Assert.Equal(new Version(1, 2, 0, 0), release.Version); Assert.Equal("v1.2.0", release.Tag); Assert.Equal("https://git.alrakis.kz/alrakis/cursor-lang/releases/tag/v1.2.0", release.PageUrl?.ToString()); Assert.Equal("CursorLang-1.2.0.0.msixbundle", release.Package.FileName); Assert.Equal( "https://git.alrakis.kz/attachments/CursorLang-1.2.0.0.msixbundle", release.Package.Url.ToString()); Assert.Equal(4096, release.Package.Size); } // The API of Gitea lives on the server itself, next to the pages of the // repository [Fact] public async Task The_request_goes_to_the_releases_of_the_project() { var handler = FakeHttpHandler.Json(Releases); var feed = new GiteaReleaseFeed(handler.CreateClient(), Options()); await feed.GetLatestAsync(TestContext.Current.CancellationToken); Uri asked = Assert.Single(handler.Requests).RequestUri!; Assert.Equal("git.alrakis.kz", asked.Host); Assert.StartsWith( "/api/v1/repos/alrakis/cursor-lang/releases", asked.AbsolutePath, StringComparison.Ordinal); } // A server sitting under a path of its own keeps that path: dropping it // would send the request to a place that answers nothing [Fact] public async Task A_server_behind_a_path_keeps_it() { var handler = FakeHttpHandler.Json(Releases); var feed = new GiteaReleaseFeed( handler.CreateClient(), new UpdateOptions { ServiceUri = new Uri("https://host.example.com/gitea"), Project = "team/app" }); await feed.GetLatestAsync(TestContext.Current.CancellationToken); Uri asked = Assert.Single(handler.Requests).RequestUri!; Assert.StartsWith("/gitea/api/v1/repos/team/app/releases", asked.AbsolutePath, StringComparison.Ordinal); } // «token» is the scheme of Gitea for keys of access [Fact] public void A_closed_repository_gets_the_token_it_asks_for() { var feed = new GiteaReleaseFeed(new HttpClient(), Options("secret")); using var request = new HttpRequestMessage(); feed.Authorize(request); Assert.Equal("token", request.Headers.Authorization?.Scheme); Assert.Equal("secret", request.Headers.Authorization?.Parameter); } [Fact] public void An_open_repository_is_asked_without_a_token() { var feed = new GiteaReleaseFeed(new HttpClient(), Options()); using var request = new HttpRequestMessage(); feed.Authorize(request); Assert.Null(request.Headers.Authorization); } [Theory] [InlineData("1.2.3", "1.2.3.0")] [InlineData("v1.2.3", "1.2.3.0")] [InlineData("V1.2", "1.2.0.0")] [InlineData("1.2.3.4", "1.2.3.4")] public async Task A_version_is_read_out_of_the_tag(string tag, string expected) { ReleaseInfo? release = await Read(WithTag(tag)); Assert.NotNull(release); Assert.Equal(Version.Parse(expected), release.Version); } // A pre-release version is not something the app offers by itself: // such a version is asked for on purpose [Theory] [InlineData("v1.2.3-beta")] [InlineData("nightly")] [InlineData("release-1")] public async Task A_tag_that_is_not_a_version_is_passed_over(string tag) { Assert.Null(await Read(WithTag(tag))); } [Fact] public async Task A_draft_and_a_pre_release_are_passed_over() { const string releases = """ [ { "tag_name": "v3.0.0", "draft": true, "assets": [ { "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] }, { "tag_name": "v2.0.0", "prerelease": true, "assets": [ { "name": "b.msixbundle", "browser_download_url": "https://host/b.msixbundle" } ] }, { "tag_name": "v1.0.0", "assets": [ { "name": "c.msixbundle", "browser_download_url": "https://host/c.msixbundle" } ] } ] """; ReleaseInfo? release = await Read(releases); Assert.NotNull(release); Assert.Equal(new Version(1, 0, 0, 0), release.Version); } // The order of the releases belongs to the server, the highest number to // the app: a fix to an older branch can be the freshest release [Fact] public async Task The_highest_version_wins_over_the_order_of_the_answer() { const string releases = """ [ { "tag_name": "v1.0.5", "assets": [ { "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] }, { "tag_name": "v2.0.0", "assets": [ { "name": "b.msixbundle", "browser_download_url": "https://host/b.msixbundle" } ] } ] """; ReleaseInfo? release = await Read(releases); Assert.NotNull(release); Assert.Equal(new Version(2, 0, 0, 0), release.Version); } [Fact] public async Task A_release_without_a_package_is_passed_over() { const string releases = """ [ { "tag_name": "v2.0.0", "assets": [ { "name": "notes.txt", "browser_download_url": "https://host/notes.txt" } ] }, { "tag_name": "v1.0.0", "assets": [ { "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] } ] """; ReleaseInfo? release = await Read(releases); Assert.NotNull(release); Assert.Equal(new Version(1, 0, 0, 0), release.Version); } // The signature is what Windows checks, but a package offered over an open // connection is not worth downloading in the first place [Fact] public async Task A_package_offered_over_an_open_connection_is_passed_over() { const string releases = """ [ { "tag_name": "v2.0.0", "assets": [ { "name": "a.msixbundle", "browser_download_url": "http://host/a.msixbundle" } ] } ] """; Assert.Null(await Read(releases)); } [Fact] public async Task A_bundle_wins_over_the_packages_of_single_architectures() { const string releases = """ [ { "tag_name": "v2.0.0", "assets": [ { "name": "CursorLang-2.0.0.0-x64.msix", "browser_download_url": "https://host/x64.msix" }, { "name": "CursorLang-2.0.0.0.msixbundle", "browser_download_url": "https://host/all.msixbundle" } ] } ] """; ReleaseInfo? release = await Read(releases); Assert.NotNull(release); Assert.Equal("https://host/all.msixbundle", release.Package.Url.ToString()); } [Fact] public async Task Out_of_several_packages_the_one_for_this_machine_is_taken() { const string releases = """ [ { "tag_name": "v2.0.0", "assets": [ { "name": "CursorLang-2.0.0.0-arm64.msix", "browser_download_url": "https://host/arm64.msix" }, { "name": "CursorLang-2.0.0.0-x64.msix", "browser_download_url": "https://host/x64.msix" } ] } ] """; string expected = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "https://host/arm64.msix" : "https://host/x64.msix"; ReleaseInfo? release = await Read(releases); Assert.NotNull(release); Assert.Equal(expected, release.Package.Url.ToString()); } // Without the architecture in the name there is no telling which package is // for this machine — unless it is the only one there [Fact] public async Task A_package_without_an_architecture_is_taken_only_when_alone() { const string alone = """ [ { "tag_name": "v2.0.0", "assets": [ { "name": "CursorLang.msix", "browser_download_url": "https://host/one.msix" } ] } ] """; const string ambiguous = """ [ { "tag_name": "v2.0.0", "assets": [ { "name": "CursorLang.msix", "browser_download_url": "https://host/one.msix" }, { "name": "CursorLang-other.msix", "browser_download_url": "https://host/other.msix" } ] } ] """; Assert.NotNull(await Read(alone)); Assert.Null(await Read(ambiguous)); } [Fact] public async Task An_empty_list_of_releases_means_nothing_to_offer() { Assert.Null(await Read("[]")); } // A server answering with something else is no reason to fail [Fact] public async Task An_answer_that_is_not_a_list_leaves_the_app_with_nothing() { Assert.Null(await Read("""{ "message": "Not Found" }""")); } [Fact] public async Task A_refusal_of_the_server_is_raised() { var handler = FakeHttpHandler.Status(HttpStatusCode.Unauthorized); var feed = new GiteaReleaseFeed(handler.CreateClient(), Options()); await Assert.ThrowsAsync( () => feed.GetLatestAsync(TestContext.Current.CancellationToken)); } private static string WithTag(string tag) => $$""" [ { "tag_name": "{{tag}}", "assets": [ { "name": "CursorLang.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] } ] """; private static UpdateOptions Options(string? token = null) => new() { ServiceUri = new Uri("https://git.alrakis.kz/"), Project = "alrakis/cursor-lang", AccessToken = token, }; private static Task Read(string json) => new GiteaReleaseFeed(FakeHttpHandler.Json(json).CreateClient(), Options()) .GetLatestAsync(TestContext.Current.CancellationToken); }