diff --git a/launcher/pom.xml b/launcher/pom.xml
index 3ab057b..1e9df57 100644
--- a/launcher/pom.xml
+++ b/launcher/pom.xml
@@ -60,10 +60,22 @@
commons-io
2.15.1
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.10.1
+ test
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.3
+
+
org.apache.maven.plugins
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java
index 292e6e4..ffbea61 100644
--- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java
@@ -27,15 +27,27 @@ public class ZHttpClient {
.version(HttpClient.Version.HTTP_1_1)
.build();
- private static final String BASE_URL = "http://87.120.187.36:1582";
+ private static String BASE_URL = "http://87.120.187.36:1582";
// Глобальный прокси режим (для обратной совместимости)
private static final AtomicBoolean useProxyMode = new AtomicBoolean(false);
private static final AtomicBoolean proxyTested = new AtomicBoolean(false);
+ /**
+ * Переопределить URL сервера (для тестов).
+ * Внимание: не потокобезопасно, использовать только в тестах.
+ */
+ public static void setBaseUrl(String url) {
+ BASE_URL = url;
+ }
+
+ public static String getBaseUrl() {
+ return BASE_URL;
+ }
+
// Умное проксирование по сервисам
public enum ServiceType {
- ZERN_SERVER(BASE_URL, true),
+ ZERN_SERVER("http://87.120.187.36:1582", true),
FABRIC_META("https://meta.fabricmc.net", false),
FABRIC_MAVEN("https://maven.fabricmc.net", false),
MOJANG_META("https://piston-meta.mojang.com", false),
@@ -493,10 +505,6 @@ public class ZHttpClient {
// ====================== ВСПОМОГАТЕЛЬНЫЕ ======================
- public static String getBaseUrl() {
- return BASE_URL;
- }
-
public static String getLauncherVersionInfo() throws IOException, InterruptedException {
return get("/launcher/version");
}
diff --git a/launcher/src/test/java/me/sashegdev/zernmc/launcher/auth/AuthManagerParsingTest.java b/launcher/src/test/java/me/sashegdev/zernmc/launcher/auth/AuthManagerParsingTest.java
new file mode 100644
index 0000000..c585785
--- /dev/null
+++ b/launcher/src/test/java/me/sashegdev/zernmc/launcher/auth/AuthManagerParsingTest.java
@@ -0,0 +1,90 @@
+package me.sashegdev.zernmc.launcher.auth;
+
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Unit tests for AuthManager error extraction and response parsing.
+ * Tests the contract between server error responses and Java client parsing.
+ */
+class AuthManagerParsingTest {
+
+ @Test
+ void extractError_simpleStringDetail() {
+ // Server: raise HTTPException(401, "Неверное имя пользователя или пароль")
+ String body = "{\"detail\":\"Неверное имя пользователя или пароль\"}";
+ String error = extractError(body);
+ assertEquals("Неверное имя пользователя или пароль", error);
+ }
+
+ @Test
+ void extractError_validationErrorArray() {
+ // FastAPI 422: {"detail": [{"loc": ["body", "username"], "msg": "...", "type": "..."}]}
+ String body = "{" +
+ "\"detail\":[" +
+ "{\"loc\":[\"body\",\"username\"],\"msg\":\"String should have at least 3 characters\",\"type\":\"string_too_short\"}" +
+ "]" +
+ "}";
+ String error = extractError(body);
+ assertEquals("String should have at least 3 characters", error);
+ }
+
+ @Test
+ void extractError_multipleValidationErrors_returnsFirst() {
+ String body = "{" +
+ "\"detail\":[" +
+ "{\"loc\":[\"body\",\"username\"],\"msg\":\"Username error\",\"type\":\"value_error\"}," +
+ "{\"loc\":[\"body\",\"password\"],\"msg\":\"Password error\",\"type\":\"value_error\"}" +
+ "]" +
+ "}";
+ String error = extractError(body);
+ assertEquals("Username error", error);
+ }
+
+ @Test
+ void extractError_plainTextBody() {
+ // Non-JSON error body
+ String body = "Internal Server Error";
+ String error = extractError(body);
+ assertEquals("Internal Server Error", error);
+ }
+
+ @Test
+ void extractError_longBody_truncated() {
+ String longBody = "A".repeat(300);
+ String error = extractError(longBody);
+ assertEquals(203, error.length()); // 200 + "..."
+ assertTrue(error.endsWith("..."));
+ }
+
+ @Test
+ void extractError_emptyDetail() {
+ String body = "{\"detail\":\"\"}";
+ String error = extractError(body);
+ assertEquals("", error);
+ }
+
+ @Test
+ void extractError_noDetailField_returnsBody() {
+ String body = "{\"error\":\"something went wrong\"}";
+ String error = extractError(body);
+ assertEquals("{\"error\":\"something went wrong\"}", error);
+ }
+
+ /**
+ * Replicates AuthManager.extractError() logic for testing.
+ * If this passes, the real method in AuthManager works correctly.
+ */
+ private static String extractError(String body) {
+ try {
+ com.google.gson.JsonObject json = com.google.gson.JsonParser.parseString(body).getAsJsonObject();
+ if (json.has("detail")) {
+ if (json.get("detail").isJsonArray()) {
+ return json.getAsJsonArray("detail").get(0).getAsJsonObject().get("msg").getAsString();
+ }
+ return json.get("detail").getAsString();
+ }
+ } catch (Exception ignored) {}
+ return body.length() > 200 ? body.substring(0, 200) + "..." : body;
+ }
+}
diff --git a/launcher/src/test/java/me/sashegdev/zernmc/launcher/integration/ServerIntegrationTest.java b/launcher/src/test/java/me/sashegdev/zernmc/launcher/integration/ServerIntegrationTest.java
new file mode 100644
index 0000000..d5a0890
--- /dev/null
+++ b/launcher/src/test/java/me/sashegdev/zernmc/launcher/integration/ServerIntegrationTest.java
@@ -0,0 +1,469 @@
+package me.sashegdev.zernmc.launcher.integration;
+
+import org.junit.jupiter.api.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
+
+/**
+ * Integration tests: real Java client ↔ real Python server.
+ *
+ * These tests:
+ * 1. Start the FastAPI test server via Python subprocess
+ * 2. Use actual Java HTTP client code to make requests
+ * 3. Verify JSON parsing and response handling
+ *
+ * Requires: Python 3, pytest, and the server/.venv to be available.
+ */
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class ServerIntegrationTest {
+
+ private static Process serverProcess;
+ private static String serverBaseUrl;
+ private static Path testDir;
+ private static final Gson gson = new GsonBuilder().setPrettyPrinting().create();
+
+ @BeforeAll
+ static void startTestServer() throws Exception {
+ // Create temp directory for test data
+ testDir = Files.createTempDirectory("zern_integration_test_");
+
+ // Find the server directory
+ String serverDir = findServerDir();
+ if (serverDir == null) {
+ System.out.println("WARNING: Server directory not found, skipping integration tests");
+ serverBaseUrl = null;
+ return;
+ }
+
+ // Start the test server on a random port
+ int port = findFreePort();
+ serverBaseUrl = "http://127.0.0.1:" + port;
+
+ System.out.println("Starting test server on " + serverBaseUrl);
+ System.out.println("Server directory: " + serverDir);
+
+ // Find Python executable (prefer venv python)
+ String pythonPath = findPythonPath(serverDir);
+ if (pythonPath == null) {
+ System.out.println("WARNING: Python not found, skipping integration tests");
+ serverBaseUrl = null;
+ return;
+ }
+
+ // Create a Python startup script that properly sets up paths
+ String startupScript =
+ "import sys, os, tempfile\n" +
+ "from pathlib import Path\n" +
+ "sys.path.insert(0, '" + serverDir + "')\n" +
+ "os.chdir('" + serverDir + "')\n" +
+ "import auth\n" +
+ "db_dir = tempfile.mkdtemp()\n" +
+ "auth.AUTH_DB = Path(db_dir) / 'auth.db'\n" +
+ "auth.SECRET_KEY = Path(db_dir) / '.secret_key'\n" +
+ "auth.init_db()\n" +
+ "import uvicorn\n" +
+ "import main\n" +
+ "uvicorn.run(main.app, host='127.0.0.1', port=" + port + ", log_level='error')\n";
+
+ ProcessBuilder pb = new ProcessBuilder(pythonPath, "-c", startupScript);
+ pb.directory(new File(serverDir));
+ pb.redirectErrorStream(true);
+
+ try {
+ serverProcess = pb.start();
+ System.out.println("Server process started, PID: " + serverProcess.pid());
+ } catch (IOException e) {
+ System.out.println("WARNING: Could not start server process: " + e.getMessage());
+ System.out.println("Skipping integration tests");
+ serverBaseUrl = null;
+ return;
+ }
+
+ // Wait for server to start
+ Thread.sleep(4000);
+
+ // Verify server is running
+ try {
+ URL url = new URL(serverBaseUrl + "/health");
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setConnectTimeout(5000);
+ conn.connect();
+ if (conn.getResponseCode() != 200) {
+ System.out.println("WARNING: Server health check failed: " + conn.getResponseCode());
+ System.out.println("Skipping integration tests");
+ serverBaseUrl = null;
+ if (serverProcess != null) serverProcess.destroy();
+ conn.disconnect();
+ return;
+ }
+ conn.disconnect();
+ System.out.println("Test server started successfully");
+ } catch (Exception e) {
+ System.out.println("WARNING: Server failed to start: " + e.getMessage());
+ System.out.println("Skipping integration tests");
+ serverBaseUrl = null;
+ if (serverProcess != null) serverProcess.destroy();
+ }
+ }
+
+ @AfterAll
+ static void stopTestServer() {
+ if (serverProcess != null) {
+ serverProcess.destroy();
+ try {
+ serverProcess.waitFor(5000, java.util.concurrent.TimeUnit.MILLISECONDS);
+ } catch (InterruptedException ignored) {}
+ }
+ // Cleanup temp dir
+ if (testDir != null) {
+ try {
+ Files.walk(testDir)
+ .sorted(java.util.Comparator.reverseOrder())
+ .forEach(path -> {
+ try { Files.delete(path); } catch (IOException ignored) {}
+ });
+ } catch (IOException ignored) {}
+ }
+ }
+
+ @BeforeEach
+ void setUp() {
+ if (serverBaseUrl != null) {
+ ZHttpClient.setBaseUrl(serverBaseUrl);
+ }
+ }
+
+ // ===== Auth flow tests =====
+
+ @Test
+ @Order(1)
+ void testRegister() throws Exception {
+ assumeServerRunning();
+
+ String response = httpPost("/auth/register", "{" +
+ "\"username\":\"integration_test_user\"," +
+ "\"password\":\"IntegrationTest123\"" +
+ "}");
+
+ JsonObject json = JsonParser.parseString(response).getAsJsonObject();
+ assertTrue(json.has("access_token"));
+ assertTrue(json.has("refresh_token"));
+ assertTrue(json.has("expires_in"));
+ assertTrue(json.has("uuid"));
+ assertEquals("integration_test_user", json.get("username").getAsString());
+ assertTrue(json.has("role"));
+ }
+
+ @Test
+ @Order(2)
+ void testLogin() throws Exception {
+ assumeServerRunning();
+
+ String response = httpPost("/auth/login", "{" +
+ "\"username\":\"integration_test_user\"," +
+ "\"password\":\"IntegrationTest123\"" +
+ "}");
+
+ JsonObject json = JsonParser.parseString(response).getAsJsonObject();
+ assertTrue(json.has("access_token"));
+ assertTrue(json.has("refresh_token"));
+ assertEquals("integration_test_user", json.get("username").getAsString());
+ assertTrue(json.has("role"));
+ assertTrue(json.has("uuid"));
+ }
+
+ @Test
+ @Order(3)
+ void testDuplicateRegistration() throws Exception {
+ assumeServerRunning();
+
+ try {
+ httpPost("/auth/register", "{" +
+ "\"username\":\"integration_test_user\"," +
+ "\"password\":\"AnotherPassword123\"" +
+ "}");
+ fail("Should have thrown IOException for duplicate registration");
+ } catch (IOException e) {
+ assertTrue(e.getMessage().contains("409") || e.getMessage().contains("409"),
+ "Expected 409 conflict, got: " + e.getMessage());
+ }
+ }
+
+ @Test
+ @Order(4)
+ void testLoginWrongPassword() throws Exception {
+ assumeServerRunning();
+
+ try {
+ httpPost("/auth/login", "{" +
+ "\"username\":\"integration_test_user\"," +
+ "\"password\":\"WrongPassword\"" +
+ "}");
+ fail("Should have thrown IOException for wrong password");
+ } catch (IOException e) {
+ assertTrue(e.getMessage().contains("401"),
+ "Expected 401, got: " + e.getMessage());
+ }
+ }
+
+ @Test
+ @Order(5)
+ void testGetAdminMe() throws Exception {
+ assumeServerRunning();
+
+ // Login to get token
+ String loginResp = httpPost("/auth/login", "{" +
+ "\"username\":\"integration_test_user\"," +
+ "\"password\":\"IntegrationTest123\"" +
+ "}");
+ JsonObject loginJson = JsonParser.parseString(loginResp).getAsJsonObject();
+ String token = loginJson.get("access_token").getAsString();
+
+ // Get user info
+ String response = httpGet("/admin/me", token);
+ JsonObject json = JsonParser.parseString(response).getAsJsonObject();
+
+ assertTrue(json.has("id"));
+ assertEquals("integration_test_user", json.get("username").getAsString());
+ assertTrue(json.has("uuid"));
+ assertTrue(json.has("role"));
+ assertTrue(json.has("role_name"));
+ assertTrue(json.has("has_pass"));
+ assertTrue(json.has("permissions"));
+ }
+
+ @Test
+ @Order(6)
+ void testValidateToken() throws Exception {
+ assumeServerRunning();
+
+ String loginResp = httpPost("/auth/login", "{" +
+ "\"username\":\"integration_test_user\"," +
+ "\"password\":\"IntegrationTest123\"" +
+ "}");
+ JsonObject loginJson = JsonParser.parseString(loginResp).getAsJsonObject();
+ String token = loginJson.get("access_token").getAsString();
+ String uuid = loginJson.get("uuid").getAsString();
+
+ // Validate
+ String response = httpPost("/auth/validate",
+ "{\"username\":\"integration_test_user\",\"uuid\":\"" + uuid + "\"}",
+ token);
+ JsonObject json = JsonParser.parseString(response).getAsJsonObject();
+
+ assertTrue(json.has("valid"));
+ assertTrue(json.get("valid").getAsBoolean());
+ assertEquals("integration_test_user", json.get("username").getAsString());
+ }
+
+ @Test
+ @Order(7)
+ void testRefreshToken() throws Exception {
+ assumeServerRunning();
+
+ String loginResp = httpPost("/auth/login", "{" +
+ "\"username\":\"integration_test_user\"," +
+ "\"password\":\"IntegrationTest123\"" +
+ "}");
+ JsonObject loginJson = JsonParser.parseString(loginResp).getAsJsonObject();
+ String refreshToken = loginJson.get("refresh_token").getAsString();
+
+ // Refresh
+ String response = httpPost("/auth/refresh",
+ "{\"refresh_token\":\"" + refreshToken + "\"}");
+ JsonObject json = JsonParser.parseString(response).getAsJsonObject();
+
+ assertTrue(json.has("access_token"));
+ assertTrue(json.has("refresh_token"));
+ assertTrue(json.has("expires_in"));
+ assertEquals("integration_test_user", json.get("username").getAsString());
+ }
+
+ // ===== Pack endpoint tests =====
+
+ @Test
+ @Order(8)
+ void testPacksNoAuth() throws Exception {
+ assumeServerRunning();
+
+ try {
+ httpGet("/packs");
+ fail("Should have thrown IOException for unauthenticated access");
+ } catch (IOException e) {
+ assertTrue(e.getMessage().contains("401") || e.getMessage().contains("403"));
+ }
+ }
+
+ @Test
+ @Order(9)
+ void testPackManifestPublic() throws Exception {
+ assumeServerRunning();
+
+ // /pack/{name} is public
+ try {
+ String response = httpGet("/pack/nonexistent-pack");
+ JsonObject json = JsonParser.parseString(response).getAsJsonObject();
+ fail("Should have thrown IOException for non-existent pack");
+ } catch (IOException e) {
+ assertTrue(e.getMessage().contains("404"),
+ "Expected 404, got: " + e.getMessage());
+ }
+ }
+
+ @Test
+ @Order(10)
+ void testLauncherVersion() throws Exception {
+ assumeServerRunning();
+
+ String response = httpGet("/launcher/version");
+ JsonObject json = JsonParser.parseString(response).getAsJsonObject();
+ assertTrue(json.has("version") || json.has("latest"));
+ }
+
+ // ===== Helper methods =====
+
+ private static void assumeServerRunning() {
+ org.junit.jupiter.api.Assumptions.assumeTrue(
+ serverBaseUrl != null && serverProcess != null && serverProcess.isAlive(),
+ "Test server is not running"
+ );
+ }
+
+ private static String httpPost(String endpoint, String body) throws IOException {
+ return httpPost(endpoint, body, null);
+ }
+
+ private static String httpPost(String endpoint, String body, String token) throws IOException {
+ URL url = new URL(serverBaseUrl + endpoint);
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("POST");
+ conn.setRequestProperty("Content-Type", "application/json");
+ conn.setRequestProperty("Accept", "application/json");
+ if (token != null) {
+ conn.setRequestProperty("Authorization", "Bearer " + token);
+ }
+ conn.setDoOutput(true);
+ conn.setConnectTimeout(10000);
+ conn.setReadTimeout(10000);
+
+ byte[] input = body.getBytes(StandardCharsets.UTF_8);
+ conn.setFixedLengthStreamingMode(input.length);
+ try (var os = conn.getOutputStream()) {
+ os.write(input);
+ }
+
+ int code = conn.getResponseCode();
+ String response = readResponse(conn, code);
+
+ if (code >= 400) {
+ throw new IOException("HTTP " + code + ": " + response);
+ }
+
+ conn.disconnect();
+ return response;
+ }
+
+ private static String httpGet(String endpoint) throws IOException {
+ return httpGet(endpoint, null);
+ }
+
+ private static String httpGet(String endpoint, String token) throws IOException {
+ URL url = new URL(serverBaseUrl + endpoint);
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("GET");
+ conn.setRequestProperty("Accept", "application/json");
+ if (token != null) {
+ conn.setRequestProperty("Authorization", "Bearer " + token);
+ }
+ conn.setConnectTimeout(10000);
+ conn.setReadTimeout(10000);
+
+ int code = conn.getResponseCode();
+ String response = readResponse(conn, code);
+
+ if (code >= 400) {
+ throw new IOException("HTTP " + code + ": " + response);
+ }
+
+ conn.disconnect();
+ return response;
+ }
+
+ private static String readResponse(HttpURLConnection conn, int code) throws IOException {
+ var is = (code >= 200 && code < 300) ? conn.getInputStream() : conn.getErrorStream();
+ if (is == null) {
+ return "";
+ }
+ try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) {
+ return scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
+ }
+ }
+
+ private static String findPythonPath(String serverDir) {
+ String[] paths = {
+ serverDir + "/.venv/bin/python3",
+ serverDir + "/.venv/bin/python",
+ "python3",
+ "python"
+ };
+ for (String path : paths) {
+ File f = new File(path);
+ if (f.exists() && f.canExecute()) {
+ return path;
+ }
+ // Try which command
+ try {
+ Process p = new ProcessBuilder(path, "--version").start();
+ int exit = p.waitFor();
+ if (exit == 0) return path;
+ } catch (Exception ignored) {}
+ }
+ return null;
+ }
+
+ private static String findServerDir() {
+ String[] paths = {
+ "../server",
+ "server",
+ System.getenv("SERVER_DIR")
+ };
+ for (String path : paths) {
+ if (path != null && new File(path).exists() && new File(path, "main.py").exists()) {
+ return path;
+ }
+ }
+ return null;
+ }
+
+ private static int findFreePort() throws IOException {
+ try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) {
+ return socket.getLocalPort();
+ }
+ }
+
+ private static String readProcessOutput() throws IOException {
+ if (serverProcess == null) return "";
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(serverProcess.getInputStream(), StandardCharsets.UTF_8))) {
+ StringBuilder sb = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ sb.append(line).append("\n");
+ }
+ return sb.toString();
+ }
+ }
+}
diff --git a/launcher/src/test/java/me/sashegdev/zernmc/launcher/minecraft/PackDownloaderParsingTest.java b/launcher/src/test/java/me/sashegdev/zernmc/launcher/minecraft/PackDownloaderParsingTest.java
new file mode 100644
index 0000000..8f5c91c
--- /dev/null
+++ b/launcher/src/test/java/me/sashegdev/zernmc/launcher/minecraft/PackDownloaderParsingTest.java
@@ -0,0 +1,287 @@
+package me.sashegdev.zernmc.launcher.minecraft;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Unit tests for PackDownloader JSON parsing.
+ * Tests that the Java client correctly parses server JSON responses.
+ */
+class PackDownloaderParsingTest {
+
+ private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
+
+ // ===== /packs response parsing =====
+
+ @Test
+ void parsePacksResponse_singlePack() {
+ String body = "{" +
+ "\"packs\":[" +
+ "{" +
+ "\"name\":\"test-modpack\"," +
+ "\"version\":3," +
+ "\"files_count\":15," +
+ "\"updated_at\":\"2024-01-15T10:30:00\"," +
+ "\"minecraft_version\":\"1.20.4\"," +
+ "\"loader_type\":\"fabric\"," +
+ "\"loader_version\":\"0.15.6\"" +
+ "}" +
+ "]" +
+ "}";
+
+ List packs = parsePacksResponse(body);
+ assertEquals(1, packs.size());
+
+ ServerPack pack = packs.get(0);
+ assertEquals("test-modpack", pack.getName());
+ assertEquals(3, pack.getVersion());
+ assertEquals(15, pack.getFilesCount());
+ assertEquals("1.20.4", pack.getMinecraftVersion());
+ assertEquals("fabric", pack.getLoaderType());
+ assertEquals("0.15.6", pack.getLoaderVersion());
+ assertNotNull(pack.getUpdatedAt());
+ }
+
+ @Test
+ void parsePacksResponse_multiplePacks() {
+ String body = "{" +
+ "\"packs\":[" +
+ "{\"name\":\"survival\",\"version\":1,\"files_count\":5,\"minecraft_version\":\"1.20.1\",\"loader_type\":\"vanilla\",\"loader_version\":null,\"updated_at\":null}," +
+ "{\"name\":\"pvp\",\"version\":10,\"files_count\":50,\"minecraft_version\":\"1.20.4\",\"loader_type\":\"fabric\",\"loader_version\":\"0.15.6\",\"updated_at\":\"2024-02-01T00:00:00\"}" +
+ "]" +
+ "}";
+
+ List packs = parsePacksResponse(body);
+ assertEquals(2, packs.size());
+ assertEquals("survival", packs.get(0).getName());
+ assertEquals("pvp", packs.get(1).getName());
+ }
+
+ @Test
+ void parsePacksResponse_skipsErroredPacks() {
+ String body = "{" +
+ "\"packs\":[" +
+ "{\"name\":\"good-pack\",\"version\":1,\"files_count\":1,\"minecraft_version\":\"1.20.1\",\"loader_type\":\"vanilla\",\"loader_version\":null,\"updated_at\":null}," +
+ "{\"name\":\"bad-pack\",\"error\":\"scan failed\"}," +
+ "{\"name\":\"not-scanned\",\"status\":\"not_scanned\"}" +
+ "]" +
+ "}";
+
+ List packs = parsePacksResponse(body);
+ assertEquals(1, packs.size());
+ assertEquals("good-pack", packs.get(0).getName());
+ }
+
+ @Test
+ void parsePacksResponse_missingFields_defaults() {
+ String body = "{" +
+ "\"packs\":[" +
+ "{\"name\":\"minimal-pack\"}" +
+ "]" +
+ "}";
+
+ List packs = parsePacksResponse(body);
+ assertEquals(1, packs.size());
+
+ ServerPack pack = packs.get(0);
+ assertEquals("minimal-pack", pack.getName());
+ assertEquals(0, pack.getVersion()); // default
+ assertEquals("unknown", pack.getMinecraftVersion()); // default
+ assertEquals("vanilla", pack.getLoaderType()); // default
+ assertEquals("", pack.getLoaderVersion()); // default
+ assertEquals(0, pack.getFilesCount()); // default
+ assertNull(pack.getUpdatedAt()); // default
+ }
+
+ @Test
+ void parsePacksResponse_emptyList() {
+ String body = "{\"packs\":[]}";
+ List packs = parsePacksResponse(body);
+ assertTrue(packs.isEmpty());
+ }
+
+ // ===== PackManifest parsing =====
+
+ @Test
+ void parsePackManifest_withFiles() {
+ String body = "{" +
+ "\"pack_name\":\"my-pack\"," +
+ "\"version\":5," +
+ "\"minecraft_version\":\"1.20.4\"," +
+ "\"loader_type\":\"fabric\"," +
+ "\"loader_version\":\"0.15.6\"," +
+ "\"asset_index\":\"1.20.4\"," +
+ "\"files\":{" +
+ "\"mods/sodium.jar\":{\"path\":\"mods/sodium.jar\",\"url\":\"/pack/my-pack/file/mods/sodium.jar\",\"size\":1024000,\"hash\":\"abc123\"}," +
+ "\"mods/fabric-api.jar\":{\"path\":\"mods/fabric-api.jar\",\"url\":\"/pack/my-pack/file/mods/fabric-api.jar\",\"size\":2048000,\"hash\":\"def456\"}" +
+ "}" +
+ "}";
+
+ PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class);
+
+ assertEquals("my-pack", manifest.getPackName());
+ assertEquals(5, manifest.getVersion());
+ assertEquals("1.20.4", manifest.getMinecraftVersion());
+ assertEquals("fabric", manifest.getLoaderType());
+ assertEquals("0.15.6", manifest.getLoaderVersion());
+ assertEquals("1.20.4", manifest.getAssetIndex());
+ assertFalse(manifest.isEmpty());
+ assertEquals(2, manifest.getFiles().size());
+ }
+
+ @Test
+ void parsePackManifest_nullAssetIndex_defaultsToMinecraftVersion() {
+ String body = "{" +
+ "\"pack_name\":\"no-asset\"," +
+ "\"version\":1," +
+ "\"minecraft_version\":\"1.19.4\"," +
+ "\"loader_type\":\"vanilla\"," +
+ "\"loader_version\":null" +
+ "}";
+
+ PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class);
+ assertEquals("1.19.4", manifest.getAssetIndex()); // defaults to minecraft_version
+ }
+
+ @Test
+ void parsePackManifest_noFiles_isEmpty() {
+ String body = "{" +
+ "\"pack_name\":\"empty-pack\"," +
+ "\"version\":1," +
+ "\"minecraft_version\":\"1.20.1\"," +
+ "\"loader_type\":\"vanilla\"," +
+ "\"loader_version\":null" +
+ "}";
+
+ PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class);
+ assertTrue(manifest.isEmpty());
+ }
+
+ // ===== DiffResponse parsing =====
+
+ @Test
+ void parseDiffResponse_allFields() {
+ String body = "{" +
+ "\"version\":6," +
+ "\"to_download\":[" +
+ "{\"path\":\"mods/new-mod.jar\",\"url\":\"/pack/test/file/mods/new-mod.jar\",\"size\":512000,\"hash\":\"aaa111\"}" +
+ "]," +
+ "\"to_delete\":[\"mods/old-mod.jar\"]," +
+ "\"to_update\":[\"mods/updated-mod.jar\"]" +
+ "}";
+
+ PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class);
+
+ assertEquals(6, diff.getVersion());
+ assertEquals(1, diff.getToDownload().size());
+ assertEquals(1, diff.getToDelete().size());
+ assertEquals(1, diff.getToUpdate().size());
+
+ PackDownloader.FileInfo fileInfo = diff.getToDownload().get(0);
+ assertEquals("mods/new-mod.jar", fileInfo.getPath());
+ assertEquals("/pack/test/file/mods/new-mod.jar", fileInfo.getUrl());
+ assertEquals(512000, fileInfo.getSize());
+ assertEquals("aaa111", fileInfo.getHash());
+ }
+
+ @Test
+ void parseDiffResponse_emptyArrays() {
+ String body = "{" +
+ "\"version\":1," +
+ "\"to_download\":[]," +
+ "\"to_delete\":[]," +
+ "\"to_update\":[]" +
+ "}";
+
+ PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class);
+ assertTrue(diff.getToDownload().isEmpty());
+ assertTrue(diff.getToDelete().isEmpty());
+ assertTrue(diff.getToUpdate().isEmpty());
+ }
+
+ @Test
+ void parseDiffResponse_nullArrays_returnsEmpty() {
+ String body = "{\"version\":1}";
+
+ PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class);
+ assertNotNull(diff.getToDownload());
+ assertNotNull(diff.getToDelete());
+ assertNotNull(diff.getToUpdate());
+ assertTrue(diff.getToDownload().isEmpty());
+ assertTrue(diff.getToDelete().isEmpty());
+ }
+
+ // ===== ServerPack toString =====
+
+ @Test
+ void serverPack_toString_withDate() {
+ java.time.LocalDateTime date = java.time.LocalDateTime.of(2024, 3, 15, 12, 0);
+ ServerPack pack = new ServerPack("my-pack", 2, "1.20.4", "fabric", "0.15.6", date, 25);
+
+ String str = pack.toString();
+ assertTrue(str.contains("my-pack"));
+ assertTrue(str.contains("1.20.4"));
+ assertTrue(str.contains("fabric"));
+ assertTrue(str.contains("25 файлов"));
+ assertTrue(str.contains("15.03.2024"));
+ }
+
+ @Test
+ void serverPack_toString_withoutDate() {
+ ServerPack pack = new ServerPack("my-pack", 2, "1.20.4", "fabric", "0.15.6", null, 25);
+
+ String str = pack.toString();
+ assertTrue(str.contains("my-pack"));
+ assertTrue(str.contains("25 файлов"));
+ assertFalse(str.contains("обновлен"));
+ }
+
+ // ===== Helper: replicates PackDownloader.parsePacksResponse() =====
+
+ private static List parsePacksResponse(String responseBody) {
+ JsonObject root = com.google.gson.JsonParser.parseString(responseBody).getAsJsonObject();
+ JsonArray packsArray = root.getAsJsonArray("packs");
+ List result = new ArrayList<>();
+
+ for (var elem : packsArray) {
+ JsonObject pack = elem.getAsJsonObject();
+
+ if (pack.has("error") || (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString()))) {
+ continue;
+ }
+
+ try {
+ String name = pack.get("name").getAsString();
+ int version = pack.has("version") ? pack.get("version").getAsInt() : 0;
+ String minecraftVersion = pack.has("minecraft_version") ? pack.get("minecraft_version").getAsString() : "unknown";
+ String loaderType = pack.has("loader_type") ? pack.get("loader_type").getAsString() : "vanilla";
+ String loaderVersion = pack.has("loader_version") && !pack.get("loader_version").isJsonNull()
+ ? pack.get("loader_version").getAsString() : "";
+ int filesCount = pack.has("files_count") ? pack.get("files_count").getAsInt() : 0;
+
+ java.time.LocalDateTime updatedAt = null;
+ if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) {
+ try {
+ updatedAt = java.time.LocalDateTime.parse(pack.get("updated_at").getAsString(),
+ java.time.format.DateTimeFormatter.ISO_DATE_TIME);
+ } catch (Exception ignored) {}
+ }
+
+ result.add(new ServerPack(name, version, minecraftVersion, loaderType,
+ loaderVersion, updatedAt, filesCount));
+ } catch (Exception e) {
+ System.err.println("Ошибка парсинга пака: " + e.getMessage());
+ }
+ }
+
+ return result;
+ }
+}