diff --git a/build.gradle.kts b/build.gradle.kts index dc8d93d..251b365 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,8 +49,11 @@ dependencies { // Загрузчик BSL Language Server implementation("org.slf4j:slf4j-api:2.0.18") - implementation("org.kohsuke:github-api:1.330") - implementation("org.apache.commons:commons-compress:1.28.0") + implementation("com.google.code.gson:gson:2.13.1") { + // Чисто compile-time аннотации (@CanIgnoreReturnValue и пр.) — в рантайме не нужны, + // не тащим их в замкнутость. + exclude(group = "com.google.errorprone", module = "error_prone_annotations") + } implementation("org.semver4j:semver4j:6.0.0") testImplementation("org.assertj:assertj-core:3.27.7") diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java index 82bca80..eaa7843 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java @@ -22,8 +22,6 @@ package com.github._1c_syntax.utils.downloader; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; -import org.apache.commons.compress.archivers.zip.ZipFile; import org.jspecify.annotations.Nullable; import org.semver4j.Semver; @@ -34,20 +32,21 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.PosixFilePermission; import java.time.Duration; import java.util.Comparator; -import java.util.EnumSet; import java.util.Locale; import java.util.Optional; import java.util.Properties; -import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; @@ -88,8 +87,8 @@ public class BslLanguageServerDownloader { * @param installDir каталог установки сервера; в нём создаются подпапки с версиями * и файл {@code SERVER-INFO} * @param releaseClient источник сведений о последнем релизе - * @param httpClient клиент только для скачивания ассета (github-api эту загрузку не умеет); - * должен следовать редиректам — ассеты GitHub отдаются редиректом на CDN + * @param httpClient клиент для скачивания ассета; должен следовать редиректам — + * ассеты GitHub отдаются редиректом на CDN */ public BslLanguageServerDownloader(Path installDir, GitHubReleaseClient releaseClient, HttpClient httpClient) { @@ -224,6 +223,7 @@ private void downloadAndExtract(GitHubReleaseClient.Release release, String vers download(downloadUrl, archive, progressListener); deleteRecursively(versionDir); extract(archive, versionDir); + makeLauncherExecutable(version); } finally { Files.deleteIfExists(archive); } @@ -293,10 +293,10 @@ private static void closeQuietly(InputStream stream) { private static void extract(Path archive, Path targetDir) throws IOException { Files.createDirectories(targetDir); - try (var zip = ZipFile.builder().setPath(archive).get()) { - var entries = zip.getEntries(); + try (var zip = new ZipFile(archive.toFile(), StandardCharsets.UTF_8)) { + var entries = zip.entries(); while (entries.hasMoreElements()) { - ZipArchiveEntry entry = entries.nextElement(); + ZipEntry entry = entries.nextElement(); var target = targetDir.resolve(entry.getName()).normalize(); if (!target.startsWith(targetDir)) { throw new IOException("Illegal archive entry (zip slip): " + entry.getName()); @@ -309,30 +309,29 @@ private static void extract(Path archive, Path targetDir) throws IOException { try (InputStream input = zip.getInputStream(entry)) { Files.copy(input, target, REPLACE_EXISTING); } - applyUnixMode(target, entry.getUnixMode()); } } } - private static void applyUnixMode(Path path, int unixMode) throws IOException { - if (!POSIX || unixMode == 0) { - return; - } - Files.setPosixFilePermissions(path, permissionsFromMode(unixMode)); - } - /** - * Преобразует unix-режим из zip в набор прав, ограниченный владельцем: групповые и «прочие» - * права намеренно не выдаются, чтобы не создавать слишком свободный доступ. Владельцу всегда - * доступны чтение и запись, бит исполнения выставляется, если он был установлен в архиве - * (нужно для launcher'а и бинарей внутри native-image). + * Помечает лаунчер сервера исполняемым. {@link java.util.zip} не переносит unix-права из архива + * (в отличие от прежней распаковки через commons-compress), поэтому на POSIX-системах бит + * исполнения выставляется вручную — на том единственном файле, который и будет запущен. Для + * native-image раскладки это и есть исполняемый файл бандла. На Windows, а также если ожидаемого + * файла в архиве не оказалось, — no-op. */ - private static Set permissionsFromMode(int mode) { - var permissions = EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); - if ((mode & 0111) != 0) { - permissions.add(PosixFilePermission.OWNER_EXECUTE); + private void makeLauncherExecutable(String version) throws IOException { + if (!POSIX) { + return; + } + var binary = binaryPath(version); + if (!Files.exists(binary)) { + return; + } + var permissions = Files.getPosixFilePermissions(binary); + if (permissions.add(PosixFilePermission.OWNER_EXECUTE)) { + Files.setPosixFilePermissions(binary, permissions); } - return permissions; } private void cleanupOtherVersions(String keepVersion) { diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java index 87eeee9..58bce06 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java @@ -21,22 +21,31 @@ */ package com.github._1c_syntax.utils.downloader; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; import org.jspecify.annotations.Nullable; -import org.kohsuke.github.GHAsset; -import org.kohsuke.github.GHRelease; -import org.kohsuke.github.GHRepository; -import org.kohsuke.github.GitHub; -import org.kohsuke.github.GitHubBuilder; -import org.kohsuke.github.extras.HttpClientGitHubConnector; import java.io.IOException; -import java.util.HashMap; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.LinkedHashMap; import java.util.Map; /** * Клиент GitHub-релизов BSL Language Server: находит последний релиз канала в репозитории - * {@value #REPOSITORY}. HTTP-доступ обеспечивает сама github-api (свой {@link HttpClientGitHubConnector} - * со встроенным клиентом) — отдельный HTTP-клиент здесь не нужен. + * {@value #REPOSITORY} через GitHub REST API. Работает на {@link java.net.http.HttpClient} + * и разбирает ответ через gson — без клиентских библиотек GitHub (и без Jackson, который тянула + * прежняя github-api), чтобы рантайм-замкнутость оставалась минимальной и OSGi-совместимой. + * + *

Из ответа нужны лишь тег релиза, флаг {@code draft} и ассеты (имя + URL). Структурный разбор + * gson берёт их строго из нужных полей, поэтому произвольное содержимое поля {@code body} + * (релиз-ноуты) на результат не влияет. * *

Отдельная зависимость загрузчика — чтобы в тестах его можно было замокать и прогнать поток * скачивания без обращения к GitHub. Класс не {@code final} специально: так его мокает Mockito. @@ -44,14 +53,40 @@ public class GitHubReleaseClient { private static final String REPOSITORY = "1c-syntax/bsl-language-server"; + private static final String API_ROOT = "https://api.github.com"; + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10); + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30); + // Релизы отдаются newest-first: не-draft почти всегда на первой странице, поэтому страницы + // небольшие; пагинация ниже дочитает хвост в вырожденном случае «страница целиком из драфтов». + private static final int RELEASES_PER_PAGE = 30; + // Верхняя граница пагинации: у настоящего GitHub цикл завершает пустая страница за последней, + // но зеркало/прокси/кэш может бесконечно отдавать одну и ту же непустую страницу драфтов — + // ограничение защищает от бесконечного опроса до упора в rate limit. + private static final int MAX_RELEASES_PAGES = 10; + // Сколько символов тела ответа включать в текст ошибки для диагностики (GitHub кладёт причину + // в поле message; токен в теле не возвращается, так что утечки секрета нет). + private static final int ERROR_BODY_LIMIT = 500; private final @Nullable String token; + private final HttpClient httpClient; /** * @param token GitHub OAuth-токен для обхода лимитов анонимного API; может быть {@code null} */ public GitHubReleaseClient(@Nullable String token) { + this(token, HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .followRedirects(HttpClient.Redirect.NORMAL) + .build()); + } + + /** + * @param token GitHub OAuth-токен для обхода лимитов анонимного API; может быть {@code null} + * @param httpClient клиент для запросов к GitHub API — например, с настроенным прокси + */ + public GitHubReleaseClient(@Nullable String token, HttpClient httpClient) { this.token = token; + this.httpClient = httpClient; } /** @@ -62,37 +97,138 @@ public GitHubReleaseClient(@Nullable String token) { * @throws IOException если релизы недоступны или подходящего релиза нет */ public Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOException { - var builder = new GitHubBuilder().withConnector(new HttpClientGitHubConnector()); - if (token != null && !token.isBlank()) { - builder.withOAuthToken(token); + var release = channel == BslLanguageServerReleaseChannel.PRERELEASE + ? latestNonDraftRelease() + : latestStableRelease(); + + var result = release == null ? null : toRelease(release); + if (result == null) { + throw new IOException( + "Repository " + REPOSITORY + " has no suitable releases for channel " + channel); + } + return result; + } + + /** + * Последний стабильный релиз: эндпоинт {@code releases/latest} сам исключает draft + * и pre-release, а при полном отсутствии стабильных релизов отвечает 404. + */ + private @Nullable JsonObject latestStableRelease() throws IOException { + var response = send("/repos/" + REPOSITORY + "/releases/latest"); + if (response.statusCode() == 404) { + return null; } - GitHub github = builder.build(); - - GHRepository repository = github.getRepository(REPOSITORY); - - GHRelease release; - if (channel == BslLanguageServerReleaseChannel.PRERELEASE) { - // GitHub отдаёт релизы newest-first — берём первый не-draft, не подгружая все страницы. - release = null; - for (GHRelease candidate : repository.listReleases()) { - if (!candidate.isDraft()) { - release = candidate; - break; + return parse(body(response)) instanceof JsonObject release ? release : null; + } + + /** + * Последний релиз с учётом pre-release: список {@code releases} отдаётся newest-first, + * берём первый не-draft. Драфты видны только пользователям с push-доступом, но при вызове + * с таким токеном их нужно пропустить, дочитывая следующие страницы при необходимости. + */ + private @Nullable JsonObject latestNonDraftRelease() throws IOException { + for (var page = 1; page <= MAX_RELEASES_PAGES; page++) { + var path = "/repos/" + REPOSITORY + "/releases?per_page=" + RELEASES_PER_PAGE + "&page=" + page; + if (!(parse(get(path)) instanceof JsonArray releases) || releases.isEmpty()) { + return null; + } + for (JsonElement candidate : releases) { + if (candidate instanceof JsonObject release && !isDraft(release)) { + return release; } } - } else { - release = repository.getLatestRelease(); } + return null; + } - if (release == null) { - throw new IOException("Repository " + REPOSITORY + " has no suitable releases for channel " + channel); + private static boolean isDraft(JsonObject release) { + return asBoolean(release.get("draft")); + } + + /** + * Извлекает из объекта релиза версию (тег) и карту «имя ассета → URL». Возвращает {@code null}, + * если у релиза нет тега. + */ + private static @Nullable Release toRelease(JsonObject release) { + var version = asString(release.get("tag_name")); + if (version == null) { + return null; + } + var assetUrls = new LinkedHashMap(); + if (release.get("assets") instanceof JsonArray assets) { + for (JsonElement candidate : assets) { + if (candidate instanceof JsonObject asset) { + var name = asString(asset.get("name")); + var url = asString(asset.get("browser_download_url")); + if (name != null && url != null) { + assetUrls.putIfAbsent(name, url); + } + } + } + } + return new Release(version, Map.copyOf(assetUrls)); + } + + private static @Nullable String asString(@Nullable JsonElement element) { + return element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isString() + ? element.getAsString() + : null; + } + + private static boolean asBoolean(@Nullable JsonElement element) { + return element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isBoolean() + && element.getAsBoolean(); + } + + private static JsonElement parse(String body) throws IOException { + try { + return JsonParser.parseString(body); + } catch (JsonSyntaxException e) { + throw new IOException("Malformed GitHub API response", e); + } + } + + private String get(String path) throws IOException { + return body(send(path)); + } + + private HttpResponse send(String path) throws IOException { + var builder = HttpRequest.newBuilder(URI.create(API_ROOT + path)) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("User-Agent", "1c-syntax-utils") + .timeout(REQUEST_TIMEOUT) + .GET(); + if (token != null && !token.isBlank()) { + builder.header("Authorization", "Bearer " + token); } - var assetUrls = new HashMap(); - for (GHAsset asset : release.listAssets().toList()) { - assetUrls.putIfAbsent(asset.getName(), asset.getBrowserDownloadUrl()); + try { + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("GitHub API request " + path + " was interrupted", e); + } + } + + private static String body(HttpResponse response) throws IOException { + if (response.statusCode() != 200) { + var details = errorDetails(response.body()); + throw new IOException("GitHub API request " + response.request().uri() + + " failed: HTTP " + response.statusCode() + details); + } + return response.body(); + } + + private static String errorDetails(@Nullable String body) { + if (body == null || body.isBlank()) { + return ""; + } + var trimmed = body.strip(); + if (trimmed.length() > ERROR_BODY_LIMIT) { + trimmed = trimmed.substring(0, ERROR_BODY_LIMIT) + "…"; } - return new Release(release.getTagName(), Map.copyOf(assetUrls)); + return ": " + trimmed; } /** diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java index 38336ed..0247c8c 100644 --- a/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloaderTest.java @@ -190,6 +190,20 @@ void downloadIfNeededDownloadsExtractsAndRecordsVersion(@TempDir Path installDir assertThat(downloader.installedVersion()).contains("1.2.3"); } + @Test + void downloadIfNeededReturnsExecutableLauncher(@TempDir Path installDir) throws IOException { + var archive = zipWithLaunchers(300 * 1024); + var releaseClient = mock(GitHubReleaseClient.class); + when(releaseClient.latestRelease(any())) + .thenReturn(new GitHubReleaseClient.Release("1.2.3", allOsAssets())); + var downloader = new BslLanguageServerDownloader(installDir, releaseClient, httpClientReturning(archive)); + + var binary = downloader.downloadIfNeeded(BslLanguageServerReleaseChannel.STABLE); + + assertThat(binary).exists(); + assertThat(Files.isExecutable(binary)).isTrue(); + } + @Test void downloadIfNeededReportsProgressForTheAsset(@TempDir Path installDir) throws IOException { var archive = zipWithLaunchers(300 * 1024); diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java new file mode 100644 index 0000000..4e7aadf --- /dev/null +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java @@ -0,0 +1,274 @@ +/* + * This file is a part of 1c-syntax utils. + * + * Copyright (c) 2018-2026 + * Alexey Sosnoviy , Nikita Fedkin and contributors + * + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * 1c-syntax utils is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3.0 of the License, or (at your option) any later version. + * + * 1c-syntax utils is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with 1c-syntax utils. + */ +package com.github._1c_syntax.utils.downloader; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.entry; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class GitHubReleaseClientTest { + + private final List requests = new ArrayList<>(); + + @Test + void stableChannelUsesLatestReleaseEndpoint() throws IOException { + var client = new GitHubReleaseClient("token", httpClient(200, releaseObject("v1.2.3", false, "nix", "win"))); + + var release = client.latestRelease(BslLanguageServerReleaseChannel.STABLE); + + assertThat(release.version()).isEqualTo("v1.2.3"); + assertThat(release.assetDownloadUrls()).containsOnly( + entry("bsl-language-server_nix.zip", downloadUrl("v1.2.3", "nix")), + entry("bsl-language-server_win.zip", downloadUrl("v1.2.3", "win"))); + + assertThat(requests).hasSize(1); + var request = requests.get(0); + assertThat(request.uri().toString()) + .isEqualTo("https://api.github.com/repos/1c-syntax/bsl-language-server/releases/latest"); + assertThat(request.headers().firstValue("Authorization")).contains("Bearer token"); + assertThat(request.headers().firstValue("Accept")).contains("application/vnd.github+json"); + assertThat(request.headers().firstValue("User-Agent")).contains("1c-syntax-utils"); + } + + @Test + void releaseBodyDoesNotAffectStructuralParsing() throws IOException { + // body содержит и старую download-ссылку того же репозитория, и чужую — структурный разбор + // берёт версию из tag_name, а ассеты только из assets[], поэтому ссылки в body игнорируются. + var body = "{\"tag_name\":\"v1.2.3\",\"draft\":false," + + "\"body\":\"со времён " + downloadUrl("v0.20.0", "nix") + " раскладка изменилась; " + + "см. также https://github.com/evil/repo/releases/download/v9.9.9/bsl-language-server_nix.zip\"," + + "\"assets\":[" + asset("nix", "v1.2.3") + "]}"; + var client = new GitHubReleaseClient(null, httpClient(200, body)); + + var release = client.latestRelease(BslLanguageServerReleaseChannel.STABLE); + + assertThat(release.version()).isEqualTo("v1.2.3"); + assertThat(release.assetDownloadUrls()) + .containsOnly(entry("bsl-language-server_nix.zip", downloadUrl("v1.2.3", "nix"))); + } + + @Test + void anonymousClientSendsNoAuthorizationHeader() throws IOException { + var client = new GitHubReleaseClient(null, httpClient(200, releaseObject("v1.2.3", false, "nix"))); + + client.latestRelease(BslLanguageServerReleaseChannel.STABLE); + + assertThat(requests.get(0).headers().firstValue("Authorization")).isEmpty(); + } + + @Test + void blankTokenSendsNoAuthorizationHeader() throws IOException { + var client = new GitHubReleaseClient(" ", httpClient(200, releaseObject("v1.2.3", false, "nix"))); + + client.latestRelease(BslLanguageServerReleaseChannel.STABLE); + + assertThat(requests.get(0).headers().firstValue("Authorization")).isEmpty(); + } + + @Test + void prereleaseChannelPicksFirstNonDraftAndScopesAssetsToIt() throws IOException { + var json = "[" + releaseObject("v9.9.9", true, "nix") + "," + + releaseObject("v1.3.0-rc.1", false, "nix") + "," + + releaseObject("v1.2.3", false, "win") + "]"; + var client = new GitHubReleaseClient(null, httpClient(200, json)); + + var release = client.latestRelease(BslLanguageServerReleaseChannel.PRERELEASE); + + assertThat(release.version()).isEqualTo("v1.3.0-rc.1"); + // Ассеты именно выбранного релиза, а не соседних в списке. + assertThat(release.assetDownloadUrls()) + .containsOnly(entry("bsl-language-server_nix.zip", downloadUrl("v1.3.0-rc.1", "nix"))); + assertThat(requests).hasSize(1); + assertThat(requests.get(0).uri().toString()) + .startsWith("https://api.github.com/repos/1c-syntax/bsl-language-server/releases?"); + } + + @Test + void prereleaseChannelReadsNextPageWhenFirstPageContainsOnlyDrafts() throws IOException { + var client = new GitHubReleaseClient(null, httpClient(request -> { + var firstPage = request.uri().toString().endsWith("page=1"); + return response(request, 200, "[" + releaseObject( + firstPage ? "v9.9.9" : "v1.3.0-rc.1", firstPage, "nix") + "]"); + })); + + var release = client.latestRelease(BslLanguageServerReleaseChannel.PRERELEASE); + + assertThat(release.version()).isEqualTo("v1.3.0-rc.1"); + assertThat(requests).hasSize(2); + assertThat(requests.get(0).uri().toString()).endsWith("page=1"); + assertThat(requests.get(1).uri().toString()).endsWith("page=2"); + } + + @Test + void prereleaseChannelStopsPagingAtBoundWhenEveryPageIsDraft() { + var client = new GitHubReleaseClient(null, httpClient(request -> + response(request, 200, "[" + releaseObject("v9.9.9", true, "nix") + "]"))); + + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.PRERELEASE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("no suitable releases"); + // Ограничение пагинации: без него мок отдавал бы непустую draft-страницу бесконечно. + assertThat(requests).hasSize(10); + } + + @Test + void prereleaseChannelStopsAtEmptyPage() { + var client = new GitHubReleaseClient(null, httpClient(200, "[]")); + + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.PRERELEASE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("no suitable releases"); + assertThat(requests).hasSize(1); + } + + @Test + void stableChannelFailsWhenThereAreNoStableReleases() { + var client = new GitHubReleaseClient(null, httpClient(404, "{\"message\": \"Not Found\"}")); + + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.STABLE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("no suitable releases"); + } + + @Test + void failsWhenReleaseHasNoTagName() { + var client = new GitHubReleaseClient(null, httpClient(200, "{\"draft\": false, \"assets\": []}")); + + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.STABLE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("no suitable releases"); + } + + @Test + void releaseWithoutAssetsYieldsVersionAndEmptyMap() throws IOException { + var client = new GitHubReleaseClient(null, + httpClient(200, "{\"tag_name\": \"v1.0.0\", \"draft\": false, \"assets\": []}")); + + var release = client.latestRelease(BslLanguageServerReleaseChannel.STABLE); + + assertThat(release.version()).isEqualTo("v1.0.0"); + assertThat(release.assetDownloadUrls()).isEmpty(); + } + + @Test + void failsOnHttpError() { + var client = new GitHubReleaseClient(null, httpClient(403, "{\"message\": \"rate limit\"}")); + + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.STABLE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("HTTP 403") + .hasMessageContaining("rate limit"); + } + + @Test + void failsOnMalformedResponse() { + var client = new GitHubReleaseClient(null, httpClient(200, "{ this is not json")); + + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.STABLE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("Malformed"); + } + + @Test + void interruptedRequestRestoresInterruptFlagAndFails() throws Exception { + var httpClient = mock(HttpClient.class); + doAnswer(invocation -> { + throw new InterruptedException("interrupted"); + }).when(httpClient).send(any(), any()); + var client = new GitHubReleaseClient(null, httpClient); + + try { + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.STABLE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("interrupted"); + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + } finally { + Thread.interrupted(); // сбрасываем флаг, чтобы не влиять на другие тесты + } + } + + private static String downloadUrl(String tag, String os) { + return "https://github.com/1c-syntax/bsl-language-server/releases/download/" + + tag + "/bsl-language-server_" + os + ".zip"; + } + + private static String asset(String os, String tag) { + return "{\"name\":\"bsl-language-server_" + os + ".zip\"," + + "\"browser_download_url\":\"" + downloadUrl(tag, os) + "\"}"; + } + + /** + * JSON одного релиза с ассетами под указанные ОС. + */ + private static String releaseObject(String tag, boolean draft, String... oses) { + var assets = Arrays.stream(oses).map(os -> asset(os, tag)).collect(Collectors.joining(",")); + return "{\"tag_name\":\"" + tag + "\",\"draft\":" + draft + + ",\"body\":\"notes\",\"assets\":[" + assets + "]}"; + } + + private HttpClient httpClient(int status, String body) { + return httpClient(request -> response(request, status, body)); + } + + /** + * Мок {@link HttpClient}, отвечающий на каждый {@code send} через {@code responses} + * и записывающий запросы в {@link #requests}. + */ + private HttpClient httpClient(Function> responses) { + var client = mock(HttpClient.class); + try { + when(client.send(any(), any())).thenAnswer(invocation -> { + HttpRequest request = invocation.getArgument(0); + requests.add(request); + return responses.apply(request); + }); + } catch (IOException | InterruptedException e) { + throw new IllegalStateException(e); // не бывает: это настройка мока, а не реальный вызов + } + return client; + } + + @SuppressWarnings("unchecked") + private static HttpResponse response(HttpRequest request, int status, String body) { + HttpResponse response = mock(HttpResponse.class); + when(response.statusCode()).thenReturn(status); + when(response.body()).thenReturn(body); + when(response.request()).thenReturn(request); + return response; + } +}