diff --git a/src/test/java/org/dostalgia/ExecutableDetectorTest.java b/src/test/java/org/dostalgia/ExecutableDetectorTest.java index 3b2a25d..5b848f7 100644 --- a/src/test/java/org/dostalgia/ExecutableDetectorTest.java +++ b/src/test/java/org/dostalgia/ExecutableDetectorTest.java @@ -136,6 +136,69 @@ class ExecutableDetectorTest { assertFalse(ExecutableDetector.isLikelySelfExtractor(f)); } + @Test + void findMain_prefersDosOverWindows(@TempDir Path dir) throws Exception { + // Windows PE executable: MZ header + PE signature at offset 0x80 + byte[] windowsExe = new byte[0x100]; + windowsExe[0] = 0x4D; windowsExe[1] = 0x5A; // MZ + windowsExe[0x3C] = 0x80; // PE offset at 0x80 + windowsExe[0x80] = 0x50; windowsExe[0x81] = 0x45; // "PE" signature + Files.write(dir.resolve("WIN.EXE"), windowsExe); + + // DOS executable: just MZ header, no PE + byte[] dosExe = new byte[0x40]; + dosExe[0] = 0x4D; dosExe[1] = 0x5A; // MZ + Files.write(dir.resolve("DOS.EXE"), dosExe); + + String main = detector.findMain(dir); + assertNotNull(main); + assertTrue(main.endsWith("DOS.EXE"), + "DOS executable should be preferred over Windows, but got: " + main); + } + + @Test + void findMain_returnsNullForNoExecutables(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("readme.txt"), "text"); + Files.writeString(dir.resolve("notes.md"), "markdown"); + assertNull(detector.findMain(dir)); + } + + @Test + void findMain_skipsSelfExtractorsWithPkSignature(@TempDir Path dir) throws Exception { + // Self-extractor with PK\x03\x04 signature bytes (ZIP local header) + byte[] sfx = new byte[0x80]; + sfx[0] = 0x4D; sfx[1] = 0x5A; // MZ header + sfx[0x60] = 0x50; sfx[0x61] = 0x4B; sfx[0x62] = 0x03; sfx[0x63] = 0x04; // PK\x03\x04 + Files.write(dir.resolve("SFX.EXE"), sfx); + + // Real game executable + Files.write(dir.resolve("GAME.EXE"), createMZ(2000)); + + String main = detector.findMain(dir); + assertNotNull(main); + assertTrue(main.endsWith("GAME.EXE"), + "Self-extractor with PK signature bytes should be skipped, but got: " + main); + } + + @Test + void findMain_handlesMixedContent(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("readme.txt"), "text"); + Files.write(dir.resolve("DOS4GW.EXE"), createMZ(200)); // extender, skipped + Files.write(dir.resolve("INSTALL.EXE"), createMZ(150)); // installer, deprioritized + Files.write(dir.resolve("GAME.EXE"), createMZ(5000)); // real game, should win + Files.write(dir.resolve("helper.com"), createMZ(100)); // small .com + + String main = detector.findMain(dir); + assertNotNull(main); + assertTrue(main.endsWith("GAME.EXE"), + "Main game executable should be selected, but got: " + main); + } + + @Test + void findAll_returnsEmptyForEmptyDir(@TempDir Path dir) throws Exception { + assertTrue(detector.findAll(dir).isEmpty()); + } + private static byte[] createMZ(int size) { byte[] buf = new byte[size]; buf[0] = 0x4D; // M diff --git a/src/test/java/org/dostalgia/FileUtilsTest.java b/src/test/java/org/dostalgia/FileUtilsTest.java new file mode 100644 index 0000000..060ddbf --- /dev/null +++ b/src/test/java/org/dostalgia/FileUtilsTest.java @@ -0,0 +1,94 @@ +package org.dostalgia; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class FileUtilsTest { + + @Test + void deleteDirectory_nonExistentDir_doesNotThrow(@TempDir Path dir) { + Path nonExistent = dir.resolve("doesnotexist"); + assertDoesNotThrow(() -> FileUtils.deleteDirectory(nonExistent)); + } + + @Test + void deleteDirectory_deletesFilesRecursively(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("file1.txt"), "hello"); + Files.writeString(dir.resolve("file2.txt"), "world"); + Path sub = Files.createDirectories(dir.resolve("sub")); + Files.writeString(sub.resolve("nested.txt"), "deep"); + + assertTrue(Files.exists(dir.resolve("file1.txt"))); + FileUtils.deleteDirectory(dir); + assertFalse(Files.exists(dir)); + } + + @Test + void deleteDirectory_deletesSubdirectories(@TempDir Path dir) throws Exception { + Path a = Files.createDirectories(dir.resolve("a/b/c")); + Files.writeString(a.resolve("deep.txt"), "deep"); + Path sub2 = Files.createDirectories(dir.resolve("sub2")); + Files.writeString(sub2.resolve("file.txt"), "data"); + + FileUtils.deleteDirectory(dir); + assertFalse(Files.exists(dir)); + } + + @Test + void findFilesByExtensions_findsExeComBat(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("game.exe"), "data"); + Files.writeString(dir.resolve("helper.com"), "data"); + Files.writeString(dir.resolve("run.bat"), "data"); + Files.writeString(dir.resolve("readme.txt"), "data"); + + List result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat")); + assertEquals(3, result.size()); + assertTrue(result.contains("game.exe")); + assertTrue(result.contains("helper.com")); + assertTrue(result.contains("run.bat")); + } + + @Test + void findFilesByExtensions_caseInsensitive(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("GAME.EXE"), "data"); + Files.writeString(dir.resolve("Helper.Com"), "data"); + Files.writeString(dir.resolve("RUN.BAT"), "data"); + + List result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat")); + assertEquals(3, result.size()); + } + + @Test + void findFilesByExtensions_emptyDir_returnsEmpty(@TempDir Path dir) throws Exception { + List result = FileUtils.findFilesByExtensions(dir, Set.of(".exe")); + assertTrue(result.isEmpty()); + } + + @Test + void findFilesByExtensions_noMatches_returnsEmpty(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("readme.txt"), "data"); + Files.writeString(dir.resolve("notes.md"), "data"); + + List result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat")); + assertTrue(result.isEmpty()); + } + + @Test + void findFilesByExtensions_includesSubdirectories(@TempDir Path dir) throws Exception { + Path sub = Files.createDirectories(dir.resolve("subdir")); + Files.writeString(sub.resolve("game.exe"), "data"); + Files.writeString(dir.resolve("root.exe"), "data"); + + List result = FileUtils.findFilesByExtensions(dir, Set.of(".exe")); + assertEquals(2, result.size()); + assertTrue(result.contains("root.exe")); + assertTrue(result.contains("subdir/game.exe")); + } +} diff --git a/src/test/java/org/dostalgia/GameServiceTest.java b/src/test/java/org/dostalgia/GameServiceTest.java new file mode 100644 index 0000000..35be9f4 --- /dev/null +++ b/src/test/java/org/dostalgia/GameServiceTest.java @@ -0,0 +1,291 @@ +package org.dostalgia; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class GameServiceTest { + + private static final ObjectMapper MAPPER = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(SerializationFeature.INDENT_OUTPUT); + + // -- sanitizeId tests -- + + @Test + void sanitizeId_normalString_kept() { + assertEquals("hello", GameService.sanitizeId("hello")); + } + + @Test + void sanitizeId_uppercaseToLowercase() { + assertEquals("hello", GameService.sanitizeId("HELLO")); + } + + @Test + void sanitizeId_spacesToHyphens() { + assertEquals("hello-world", GameService.sanitizeId("hello world")); + } + + @Test + void sanitizeId_specialCharsStripped() { + assertEquals("helloworld", GameService.sanitizeId("hello!@#$world")); + } + + @Test + void sanitizeId_tripleHyphenStripped() { + assertEquals("test", GameService.sanitizeId("---test---")); + } + + @Test + void sanitizeId_emptyReturnsUnknown() { + assertEquals("unknown", GameService.sanitizeId("")); + } + + @Test + void sanitizeId_onlySpecialCharsReturnsUnknown() { + assertEquals("unknown", GameService.sanitizeId("!@#$%^&*()")); + } + + @Test + void sanitizeId_mixedCaseAndSpaces() { + assertEquals("doom-ii-hell-on-earth", GameService.sanitizeId("DOOM II: Hell on Earth")); + } + + @Test + void sanitizeId_underscoresToHyphens() { + assertEquals("my-game", GameService.sanitizeId("my_game")); + } + + @Test + void sanitizeId_numbersPreserved() { + assertEquals("game2", GameService.sanitizeId("Game2")); + } + + // -- gameDir / gameJsonPath tests -- + + @Test + void gameDir_returnsCorrectPath(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + Path dir = svc.gameDir("test-game"); + assertEquals(tempDir.resolve("games/test-game"), dir); + } + + @Test + void gameJsonPath_returnsCorrectPath(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + Path path = svc.gameJsonPath("test-game"); + assertEquals(tempDir.resolve("games/test-game/game.json"), path); + } + + // -- init tests -- + + @Test + void init_createsDirectories(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + assertTrue(Files.isDirectory(tempDir.resolve("games"))); + assertTrue(Files.isDirectory(tempDir.resolve("artwork"))); + assertTrue(Files.isDirectory(tempDir.resolve("saves"))); + } + + // -- save / load tests -- + + @Test + void saveAndLoad_roundTrip(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + Game game = new Game("test-game", "Test Game"); + game.setYear(1995); + game.setGenre("FPS"); + svc.save(game); + + Game loaded = svc.load("test-game"); + assertEquals("test-game", loaded.getId()); + assertEquals("Test Game", loaded.getTitle()); + assertEquals(1995, loaded.getYear()); + assertEquals("FPS", loaded.getGenre()); + assertNotNull(loaded.getCreatedAt()); + assertNotNull(loaded.getUpdatedAt()); + } + + @Test + void save_updatesUpdatedAt(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + Game game = new Game("game1", "Game One"); + svc.save(game); + Thread.sleep(10); // ensure timestamp advances + + game.setGenre("Adventure"); + svc.save(game); + + Game loaded = svc.load("game1"); + assertNotNull(loaded.getUpdatedAt()); + assertTrue(loaded.getUpdatedAt().isAfter(loaded.getCreatedAt()) + || loaded.getUpdatedAt().equals(loaded.getCreatedAt()), + "updatedAt should be >= createdAt"); + } + + @Test + void load_nonExistent_throwsNoSuchFileException(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + assertThrows(NoSuchFileException.class, () -> svc.load("nonexistent")); + } + + // -- list tests -- + + @Test + void list_emptyDir_returnsEmpty(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + List games = svc.list(); + assertTrue(games.isEmpty()); + } + + @Test + void saveAndList_returnsGame(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + svc.save(new Game("game1", "Game One")); + svc.save(new Game("game2", "Game Two")); + + List games = svc.list(); + assertEquals(2, games.size()); + } + + @Test + void list_sortsByTitle(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + svc.save(new Game("zzz", "Z Game")); + svc.save(new Game("aaa", "A Game")); + + List games = svc.list(); + assertEquals(2, games.size()); + assertEquals("A Game", games.get(0).getTitle()); + assertEquals("Z Game", games.get(1).getTitle()); + } + + // -- delete tests -- + + @Test + void delete_removesGameDir(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + svc.save(new Game("game1", "Game One")); + assertTrue(Files.exists(svc.gameDir("game1"))); + + svc.delete("game1"); + assertFalse(Files.exists(svc.gameDir("game1"))); + } + + @Test + void delete_nonExistent_doesNotThrow(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + assertDoesNotThrow(() -> svc.delete("nonexistent")); + } + + @Test + void delete_removesOldJsdosFiles(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + // Create backward-compat files that delete should clean up + Files.writeString(tempDir.resolve("games/game1.jsdos"), "old"); + Files.writeString(tempDir.resolve("games/game1.setup.jsdos"), "old-setup"); + + svc.delete("game1"); + + assertFalse(Files.exists(tempDir.resolve("games/game1.jsdos"))); + assertFalse(Files.exists(tempDir.resolve("games/game1.setup.jsdos"))); + } + + // -- JSON serialization tests -- + + @Test + void gameSerialization_snakeCase(@TempDir Path tempDir) throws Exception { + Game g = new Game("my-game", "My Game"); + g.setIgdbCoverId("abc123"); + g.setHasCover(true); + g.setHasScreenshots(false); + g.setIgdbId(42); + g.setBundleSize(1024L); + g.setSetupExe("setup.exe"); + + String json = MAPPER.writeValueAsString(g); + assertTrue(json.contains("\"igdb_cover_id\"")); + assertTrue(json.contains("\"has_cover\"")); + assertTrue(json.contains("\"has_screenshots\"")); + assertTrue(json.contains("\"bundle_size\"")); + assertTrue(json.contains("\"setup_exe\"")); + assertFalse(json.contains("igdbCoverId")); + assertFalse(json.contains("hasCover")); + assertFalse(json.contains("hasScreenshots")); + assertFalse(json.contains("bundleSize")); + assertFalse(json.contains("setupExe")); + + Game deserialized = MAPPER.readValue(json, Game.class); + assertEquals("my-game", deserialized.getId()); + assertEquals("My Game", deserialized.getTitle()); + assertEquals("abc123", deserialized.getIgdbCoverId()); + assertTrue(deserialized.isHasCover()); + assertFalse(deserialized.isHasScreenshots()); + assertEquals(Integer.valueOf(42), deserialized.getIgdbId()); + assertEquals(Long.valueOf(1024L), deserialized.getBundleSize()); + assertEquals("setup.exe", deserialized.getSetupExe()); + } + + @Test + void gameSerialization_timestamps(@TempDir Path tempDir) throws Exception { + Game g = new Game("ts-game", "Timestamp Test"); + String json = MAPPER.writeValueAsString(g); + + assertTrue(json.contains("\"created_at\"")); + assertTrue(json.contains("\"updated_at\"")); + + Game deserialized = MAPPER.readValue(json, Game.class); + assertNotNull(deserialized.getCreatedAt()); + assertNotNull(deserialized.getUpdatedAt()); + } +} diff --git a/src/test/java/org/dostalgia/GameTest.java b/src/test/java/org/dostalgia/GameTest.java new file mode 100644 index 0000000..9f84b9e --- /dev/null +++ b/src/test/java/org/dostalgia/GameTest.java @@ -0,0 +1,154 @@ +package org.dostalgia; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class GameTest { + + @Test + void noArgConstructor_setsNothing() { + Game g = new Game(); + assertNull(g.getId()); + assertNull(g.getTitle()); + assertNull(g.getYear()); + assertNull(g.getGenre()); + assertNull(g.getDeveloper()); + assertNull(g.getPublisher()); + assertNull(g.getDescription()); + assertNull(g.getRating()); + assertNull(g.getBundleFile()); + assertNull(g.getSetupExe()); + assertNull(g.getBundleSize()); + assertNull(g.getExecutable()); + assertNull(g.getExecutables()); + assertNull(g.getPlatform()); + assertNull(g.getIgdbId()); + assertNull(g.getIgdbCoverId()); + assertFalse(g.isHasCover()); + assertFalse(g.isHasScreenshots()); + assertNull(g.getScreenshots()); + assertNull(g.getVideos()); + assertFalse(g.isReady()); + assertNull(g.getCreatedAt()); + assertNull(g.getUpdatedAt()); + } + + @Test + void twoArgConstructor_setsIdTitleAndTimestamps() { + Game g = new Game("test-id", "Test Game"); + assertEquals("test-id", g.getId()); + assertEquals("Test Game", g.getTitle()); + assertFalse(g.isReady()); + assertNotNull(g.getCreatedAt()); + assertNotNull(g.getUpdatedAt()); + } + + @Test + void settersAndGetters_roundTrip() { + Game g = new Game(); + g.setId("id1"); + g.setTitle("Title"); + g.setYear(1995); + g.setGenre("Action"); + g.setDeveloper("DevCo"); + g.setPublisher("PubCo"); + g.setDescription("A great game"); + g.setRating(4.5); + g.setBundleFile("game.zip"); + g.setSetupExe("setup.exe"); + g.setBundleSize(12345L); + g.setExecutable("game.exe"); + g.setExecutables(List.of("game.exe", "setup.exe")); + g.setPlatform("dos"); + g.setIgdbId(42); + g.setIgdbCoverId("abc123"); + g.setHasCover(true); + g.setHasScreenshots(true); + g.setScreenshots(List.of("shot1.png")); + g.setVideos(List.of("vid1")); + g.setReady(true); + Instant now = Instant.now(); + g.setCreatedAt(now); + g.setUpdatedAt(now); + + assertEquals("id1", g.getId()); + assertEquals("Title", g.getTitle()); + assertEquals(1995, g.getYear()); + assertEquals("Action", g.getGenre()); + assertEquals("DevCo", g.getDeveloper()); + assertEquals("PubCo", g.getPublisher()); + assertEquals("A great game", g.getDescription()); + assertEquals(4.5, g.getRating()); + assertEquals("game.zip", g.getBundleFile()); + assertEquals("setup.exe", g.getSetupExe()); + assertEquals(12345L, g.getBundleSize()); + assertEquals("game.exe", g.getExecutable()); + assertEquals(List.of("game.exe", "setup.exe"), g.getExecutables()); + assertEquals("dos", g.getPlatform()); + assertEquals(42, g.getIgdbId()); + assertEquals("abc123", g.getIgdbCoverId()); + assertTrue(g.isHasCover()); + assertTrue(g.isHasScreenshots()); + assertEquals(List.of("shot1.png"), g.getScreenshots()); + assertEquals(List.of("vid1"), g.getVideos()); + assertTrue(g.isReady()); + assertEquals(now, g.getCreatedAt()); + assertEquals(now, g.getUpdatedAt()); + } + + @Test + void isHasSetup_nullSetupExe_returnsFalse() { + Game g = new Game(); + g.setSetupExe(null); + assertFalse(g.isHasSetup()); + } + + @Test + void isHasSetup_blankSetupExe_returnsFalse() { + Game g = new Game(); + g.setSetupExe(" "); + assertFalse(g.isHasSetup()); + } + + @Test + void isHasSetup_nonBlankSetupExe_returnsTrue() { + Game g = new Game(); + g.setSetupExe("setup.exe"); + assertTrue(g.isHasSetup()); + } + + @Test + void isPlayable_notReady_returnsFalse() { + Game g = new Game(); + g.setReady(false); + assertFalse(g.isPlayable()); + } + + @Test + void isPlayable_readyNullPlatform_returnsTrue() { + Game g = new Game(); + g.setReady(true); + g.setPlatform(null); + assertTrue(g.isPlayable()); + } + + @Test + void isPlayable_readyDosPlatform_returnsTrue() { + Game g = new Game(); + g.setReady(true); + g.setPlatform("dos"); + assertTrue(g.isPlayable()); + } + + @Test + void isPlayable_readyWindowsPlatform_returnsFalse() { + Game g = new Game(); + g.setReady(true); + g.setPlatform("windows"); + assertFalse(g.isPlayable()); + } +} diff --git a/src/test/java/org/dostalgia/IgdbServiceTest.java b/src/test/java/org/dostalgia/IgdbServiceTest.java new file mode 100644 index 0000000..d32d8c4 --- /dev/null +++ b/src/test/java/org/dostalgia/IgdbServiceTest.java @@ -0,0 +1,265 @@ +package org.dostalgia; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +class IgdbServiceTest { + + private IgdbService service; + private ObjectMapper mapper; + + private Method epochToYearMethod; + private Method extractGenresMethod; + private Method sanitizeMethod; + private Method jsonNodeToMapMethod; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() throws Exception { + service = new IgdbService(); + service.clientId = Optional.of("test-client-id"); + service.clientSecret = Optional.of("test-client-secret"); + mapper = new ObjectMapper(); + + epochToYearMethod = IgdbService.class.getDeclaredMethod("epochToYear", JsonNode.class); + epochToYearMethod.setAccessible(true); + + extractGenresMethod = IgdbService.class.getDeclaredMethod("extractGenres", JsonNode.class); + extractGenresMethod.setAccessible(true); + + sanitizeMethod = IgdbService.class.getDeclaredMethod("sanitize", String.class); + sanitizeMethod.setAccessible(true); + + jsonNodeToMapMethod = IgdbService.class.getDeclaredMethod("jsonNodeToMap", JsonNode.class); + jsonNodeToMapMethod.setAccessible(true); + } + + // -- isConfigured tests -- + + @Test + void isConfigured_returnsTrueWhenBothPresentAndNonBlank() { + assertTrue(service.isConfigured()); + } + + @Test + void isConfigured_returnsFalseWhenClientIdEmpty() { + service.clientId = Optional.empty(); + assertFalse(service.isConfigured()); + } + + @Test + void isConfigured_returnsFalseWhenClientSecretEmpty() { + service.clientSecret = Optional.empty(); + assertFalse(service.isConfigured()); + } + + @Test + void isConfigured_returnsFalseWhenClientIdBlank() { + service.clientId = Optional.of(""); + assertFalse(service.isConfigured()); + } + + @Test + void isConfigured_returnsFalseWhenClientSecretBlank() { + service.clientSecret = Optional.of(""); + assertFalse(service.isConfigured()); + } + + // -- epochToYear tests -- + + @Test + void epochToYear_withReleaseDate_returnsYear() throws Exception { + // 946684800 = 2000-01-01T00:00:00Z + JsonNode node = mapper.readTree("{\"first_release_date\": 946684800}"); + Optional year = (Optional) epochToYearMethod.invoke(null, node); + assertTrue(year.isPresent()); + int y = year.get(); + assertTrue(y >= 1999 && y <= 2001, "Year should be around 2000 but was " + y); + } + + @Test + void epochToYear_withoutReleaseDate_returnsEmpty() throws Exception { + JsonNode node = mapper.readTree("{\"name\": \"Test Game\"}"); + Optional year = (Optional) epochToYearMethod.invoke(null, node); + assertFalse(year.isPresent()); + } + + @Test + void epochToYear_withEpochZero_returnsYear() throws Exception { + // epoch 0 = 1970-01-01T00:00:00Z + JsonNode node = mapper.readTree("{\"first_release_date\": 0}"); + Optional year = (Optional) epochToYearMethod.invoke(null, node); + assertTrue(year.isPresent()); + assertEquals(1970, year.get()); + } + + @Test + void epochToYear_negativeEpoch_returnsYear() throws Exception { + // negative epoch is before 1970 + JsonNode node = mapper.readTree("{\"first_release_date\": -315619200}"); + Optional year = (Optional) epochToYearMethod.invoke(null, node); + assertTrue(year.isPresent()); + // Calendar may handle negative values differently; just verify it returns something + assertNotNull(year.get()); + } + + // -- extractGenres tests -- + + @Test + void extractGenres_withGenresArray_returnsList() throws Exception { + JsonNode node = mapper.readTree("{\"genres\": [{\"name\": \"Action\"}, {\"name\": \"Adventure\"}]}"); + List genres = (List) extractGenresMethod.invoke(null, node); + assertEquals(2, genres.size()); + assertTrue(genres.contains("Action")); + assertTrue(genres.contains("Adventure")); + } + + @Test + void extractGenres_emptyArray_returnsEmptyList() throws Exception { + JsonNode node = mapper.readTree("{\"genres\": []}"); + List genres = (List) extractGenresMethod.invoke(null, node); + assertTrue(genres.isEmpty()); + } + + @Test + void extractGenres_missingField_returnsEmptyList() throws Exception { + JsonNode node = mapper.readTree("{\"name\": \"Test\"}"); + List genres = (List) extractGenresMethod.invoke(null, node); + assertTrue(genres.isEmpty()); + } + + @Test + void extractGenres_genreWithoutName_skipsEntry() throws Exception { + JsonNode node = mapper.readTree("{\"genres\": [{\"name\": \"Action\"}, {\"id\": 5}]}"); + List genres = (List) extractGenresMethod.invoke(null, node); + assertEquals(1, genres.size()); + assertEquals("Action", genres.get(0)); + } + + // -- sanitize tests -- + + @Test + void sanitize_escapesBackslashes() throws Exception { + String result = (String) sanitizeMethod.invoke(service, "path\\to\\file"); + assertEquals("path\\\\to\\\\file", result); + } + + @Test + void sanitize_escapesQuotes() throws Exception { + String result = (String) sanitizeMethod.invoke(service, "say \"hello\""); + assertEquals("say \\\"hello\\\"", result); + } + + @Test + void sanitize_escapesBothBackslashesAndQuotes() throws Exception { + String result = (String) sanitizeMethod.invoke(service, "\\\"mixed\\\""); + assertEquals("\\\\\\\"mixed\\\\\\\"", result); + } + + @Test + void sanitize_normalString_unchanged() throws Exception { + String result = (String) sanitizeMethod.invoke(service, "hello world"); + assertEquals("hello world", result); + } + + @Test + void sanitize_emptyString_unchanged() throws Exception { + String result = (String) sanitizeMethod.invoke(service, ""); + assertEquals("", result); + } + + // -- jsonNodeToMap tests -- + + @Test + void jsonNodeToMap_basicFields() throws Exception { + JsonNode node = mapper.readTree("{\"id\": 123, \"name\": \"Test Game\"}"); + Map map = (Map) jsonNodeToMapMethod.invoke(service, node); + assertEquals(123, map.get("igdb_id")); + assertEquals("Test Game", map.get("name")); + } + + @Test + void jsonNodeToMap_withGenres() throws Exception { + JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"genres\": [{\"name\": \"RPG\"}]}"); + Map map = (Map) jsonNodeToMapMethod.invoke(service, node); + assertTrue(map.containsKey("genres")); + assertEquals(List.of("RPG"), map.get("genres")); + } + + @Test + void jsonNodeToMap_withInvolvedCompanies() throws Exception { + JsonNode node = mapper.readTree(""" + { + "id": 1, + "name": "G", + "involved_companies": [ + { "company": { "name": "DevStudio" }, "developer": true, "publisher": false }, + { "company": { "name": "PubStudio" }, "developer": false, "publisher": true } + ] + } + """); + Map map = (Map) jsonNodeToMapMethod.invoke(service, node); + assertEquals("DevStudio", map.get("developer")); + assertEquals("PubStudio", map.get("publisher")); + } + + @Test + void jsonNodeToMap_withSummary() throws Exception { + JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"summary\": \"A great game\"}"); + Map map = (Map) jsonNodeToMapMethod.invoke(service, node); + assertEquals("A great game", map.get("summary")); + } + + @Test + void jsonNodeToMap_withCoverUrl() throws Exception { + JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"cover\": { \"url\": \"//images.igdb.com/igdb/image/upload/t_thumb/abc.jpg\" }}"); + Map map = (Map) jsonNodeToMapMethod.invoke(service, node); + String coverUrl = (String) map.get("cover_url"); + assertNotNull(coverUrl); + assertTrue(coverUrl.startsWith("https:")); + assertTrue(coverUrl.contains("t_cover_big")); + } + + @Test + void jsonNodeToMap_emptyObject_returnsDefaults() throws Exception { + JsonNode node = mapper.readTree("{}"); + Map map = (Map) jsonNodeToMapMethod.invoke(service, node); + assertEquals(0, map.get("igdb_id")); + assertEquals("", map.get("name")); + } + + @Test + void jsonNodeToMap_noInvolvedCompanies_skipsDevPub() throws Exception { + JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\"}"); + Map map = (Map) jsonNodeToMapMethod.invoke(service, node); + assertFalse(map.containsKey("developer")); + assertFalse(map.containsKey("publisher")); + } + + @Test + void jsonNodeToMap_firstDevAndPubOnly() throws Exception { + // Multiple developers/publishers — only first of each should be recorded + JsonNode node = mapper.readTree(""" + { + "id": 1, "name": "G", + "involved_companies": [ + { "company": { "name": "FirstDev" }, "developer": true, "publisher": false }, + { "company": { "name": "SecondDev" }, "developer": true, "publisher": false }, + { "company": { "name": "FirstPub" }, "developer": false, "publisher": true } + ] + } + """); + Map map = (Map) jsonNodeToMapMethod.invoke(service, node); + assertEquals("FirstDev", map.get("developer")); + assertEquals("FirstPub", map.get("publisher")); + } +} diff --git a/src/test/java/org/dostalgia/StaticResourceTest.java b/src/test/java/org/dostalgia/StaticResourceTest.java new file mode 100644 index 0000000..c967e3b --- /dev/null +++ b/src/test/java/org/dostalgia/StaticResourceTest.java @@ -0,0 +1,198 @@ +package org.dostalgia; + +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class StaticResourceTest { + + @Test + void health_returnsOkStatusAndVersion(@TempDir Path tempDir) { + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.health(); + assertEquals(200, resp.getStatus()); + Object entity = resp.getEntity(); + assertInstanceOf(Map.class, entity); + Map map = (Map) entity; + assertEquals("ok", map.get("status")); + assertEquals("0.1.0", map.get("version")); + } + + @Test + void getGameFile_jsdosContentType(@TempDir Path tempDir) throws Exception { + Path gamesDir = Files.createDirectories(tempDir.resolve("games")); + Files.writeString(gamesDir.resolve("test.jsdos"), "zip content"); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("test.jsdos"); + assertEquals(200, resp.getStatus()); + assertNotNull(resp.getMediaType()); + assertEquals("application/zip", resp.getMediaType().toString()); + } + + @Test + void getGameFile_zipContentType(@TempDir Path tempDir) throws Exception { + Path gamesDir = Files.createDirectories(tempDir.resolve("games")); + Files.writeString(gamesDir.resolve("archive.zip"), "zip content"); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("archive.zip"); + assertEquals(200, resp.getStatus()); + assertNotNull(resp.getMediaType()); + assertEquals("application/zip", resp.getMediaType().toString()); + } + + @Test + void getGameFile_jpgContentType(@TempDir Path tempDir) throws Exception { + Path gamesDir = Files.createDirectories(tempDir.resolve("games")); + Files.writeString(gamesDir.resolve("cover.jpg"), "image data"); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("cover.jpg"); + assertEquals(200, resp.getStatus()); + assertNotNull(resp.getMediaType()); + assertEquals("image/jpeg", resp.getMediaType().toString()); + } + + @Test + void getGameFile_jpegContentType(@TempDir Path tempDir) throws Exception { + Path gamesDir = Files.createDirectories(tempDir.resolve("games")); + Files.writeString(gamesDir.resolve("photo.jpeg"), "image data"); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("photo.jpeg"); + assertEquals(200, resp.getStatus()); + assertNotNull(resp.getMediaType()); + assertEquals("image/jpeg", resp.getMediaType().toString()); + } + + @Test + void getGameFile_pngContentType(@TempDir Path tempDir) throws Exception { + Path gamesDir = Files.createDirectories(tempDir.resolve("games")); + Files.writeString(gamesDir.resolve("screenshot.png"), "png data"); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("screenshot.png"); + assertEquals(200, resp.getStatus()); + assertNotNull(resp.getMediaType()); + assertEquals("image/png", resp.getMediaType().toString()); + } + + @Test + void getGameFile_webpContentType(@TempDir Path tempDir) throws Exception { + Path gamesDir = Files.createDirectories(tempDir.resolve("games")); + Files.writeString(gamesDir.resolve("image.webp"), "webp data"); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("image.webp"); + assertEquals(200, resp.getStatus()); + assertNotNull(resp.getMediaType()); + assertEquals("image/webp", resp.getMediaType().toString()); + } + + @Test + void getGameFile_gifContentType(@TempDir Path tempDir) throws Exception { + Path gamesDir = Files.createDirectories(tempDir.resolve("games")); + Files.writeString(gamesDir.resolve("anim.gif"), "gif data"); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("anim.gif"); + assertEquals(200, resp.getStatus()); + assertNotNull(resp.getMediaType()); + assertEquals("image/gif", resp.getMediaType().toString()); + } + + @Test + void getGameFile_jsonContentType(@TempDir Path tempDir) throws Exception { + Path gamesDir = Files.createDirectories(tempDir.resolve("games")); + Files.writeString(gamesDir.resolve("data.json"), "{}"); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("data.json"); + assertEquals(200, resp.getStatus()); + assertNotNull(resp.getMediaType()); + assertEquals("application/json", resp.getMediaType().toString()); + } + + @Test + void getGameFile_exeContentType(@TempDir Path tempDir) throws Exception { + Path gamesDir = Files.createDirectories(tempDir.resolve("games")); + Files.writeString(gamesDir.resolve("game.exe"), "MZ"); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("game.exe"); + assertEquals(200, resp.getStatus()); + assertNotNull(resp.getMediaType()); + assertEquals("application/octet-stream", resp.getMediaType().toString()); + } + + @Test + void getGameFile_pathTraversal_returns403(@TempDir Path tempDir) { + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("../etc/passwd"); + assertEquals(403, resp.getStatus()); + } + + @Test + void getGameFile_nonExistentFile_returns404(@TempDir Path tempDir) throws Exception { + Files.createDirectories(tempDir.resolve("games")); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("nonexistent.exe"); + assertEquals(404, resp.getStatus()); + } + + @Test + void getArtwork_contentTypeAndCacheHeader(@TempDir Path tempDir) throws Exception { + Path artworkDir = Files.createDirectories(tempDir.resolve("artwork")); + Files.writeString(artworkDir.resolve("cover.jpg"), "image data"); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getArtwork("cover.jpg"); + assertEquals(200, resp.getStatus()); + assertNotNull(resp.getMediaType()); + assertEquals("image/jpeg", resp.getMediaType().toString()); + assertTrue(resp.getHeaders().containsKey("Cache-Control")); + } + + @Test + void getArtwork_pathTraversal_returns403(@TempDir Path tempDir) { + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getArtwork("../../etc/passwd"); + assertEquals(403, resp.getStatus()); + } +} diff --git a/src/test/java/org/dostalgia/ZipServiceTest.java b/src/test/java/org/dostalgia/ZipServiceTest.java new file mode 100644 index 0000000..ab512ad --- /dev/null +++ b/src/test/java/org/dostalgia/ZipServiceTest.java @@ -0,0 +1,230 @@ +package org.dostalgia; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class ZipServiceTest { + + private final ZipService zip = new ZipService(); + + // Wire the config dependency manually (no CDI) + { + zip.config = new ConfigBuilder(); + } + + @Test + void unzip_extractsFiles(@TempDir Path dir) throws Exception { + Path zipFile = dir.resolve("test.zip"); + Path dest = dir.resolve("output"); + + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) { + zos.putNextEntry(new ZipEntry("game.exe")); + zos.write("MZ".getBytes()); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("readme.txt")); + zos.write("Hello".getBytes()); + zos.closeEntry(); + } + + zip.unzip(zipFile, dest); + assertTrue(Files.exists(dest.resolve("game.exe"))); + assertTrue(Files.exists(dest.resolve("readme.txt"))); + assertEquals("MZ", Files.readString(dest.resolve("game.exe"))); + assertEquals("Hello", Files.readString(dest.resolve("readme.txt"))); + } + + @Test + void unzip_handlesSubdirectories(@TempDir Path dir) throws Exception { + Path zipFile = dir.resolve("test.zip"); + Path dest = dir.resolve("output"); + + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) { + zos.putNextEntry(new ZipEntry("subdir/game.exe")); + zos.write("MZ".getBytes()); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("subdir/nested/deep.txt")); + zos.write("deep".getBytes()); + zos.closeEntry(); + } + + zip.unzip(zipFile, dest); + assertTrue(Files.exists(dest.resolve("subdir/game.exe"))); + assertTrue(Files.exists(dest.resolve("subdir/nested/deep.txt"))); + } + + @Test + void unzip_pathTraversal_skipsMaliciousEntries(@TempDir Path dir) throws Exception { + Path zipFile = dir.resolve("test.zip"); + Path dest = dir.resolve("output"); + Files.createDirectories(dest); + + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) { + zos.putNextEntry(new ZipEntry("../evil.txt")); + zos.write("malicious".getBytes()); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("good.txt")); + zos.write("good".getBytes()); + zos.closeEntry(); + } + + zip.unzip(zipFile, dest); + // Malicious entry should be skipped (path traversal) + assertFalse(Files.exists(dir.resolve("evil.txt"))); + // Good entry should be extracted + assertTrue(Files.exists(dest.resolve("good.txt"))); + } + + @Test + void unzip_directoryEntries_createDirs(@TempDir Path dir) throws Exception { + Path zipFile = dir.resolve("test.zip"); + Path dest = dir.resolve("output"); + + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) { + zos.putNextEntry(new ZipEntry("adir/")); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("adir/file.txt")); + zos.write("data".getBytes()); + zos.closeEntry(); + } + + zip.unzip(zipFile, dest); + assertTrue(Files.isDirectory(dest.resolve("adir"))); + assertTrue(Files.exists(dest.resolve("adir/file.txt"))); + } + + @Test + void flattenSingleDir_noOpWhenMultipleDirs(@TempDir Path dir) throws Exception { + Files.createDirectories(dir.resolve("sub1")); + Files.createDirectories(dir.resolve("sub2")); + Files.writeString(dir.resolve("sub1/file.txt"), "data"); + + zip.flattenSingleDir(dir); + assertTrue(Files.exists(dir.resolve("sub1/file.txt"))); + assertTrue(Files.isDirectory(dir.resolve("sub1"))); + assertTrue(Files.isDirectory(dir.resolve("sub2"))); + } + + @Test + void flattenSingleDir_noOpWhenNoSingleDir(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("file.txt"), "data"); + Files.writeString(dir.resolve("other.txt"), "data"); + + zip.flattenSingleDir(dir); + assertTrue(Files.exists(dir.resolve("file.txt"))); + assertTrue(Files.exists(dir.resolve("other.txt"))); + } + + @Test + void flattenSingleDir_flattensOneDir(@TempDir Path dir) throws Exception { + Path single = Files.createDirectories(dir.resolve("single")); + Files.createDirectories(single.resolve("nested")); + Files.writeString(single.resolve("file.txt"), "data"); + Files.writeString(single.resolve("nested/deep.txt"), "deep"); + + zip.flattenSingleDir(dir); + // Files should now be directly under dir + assertTrue(Files.exists(dir.resolve("file.txt"))); + assertTrue(Files.exists(dir.resolve("nested/deep.txt"))); + // The intermediate single dir should be gone + assertFalse(Files.exists(single)); + } + + @Test + void flattenSingleDir_emptyDir_noOp(@TempDir Path dir) throws Exception { + zip.flattenSingleDir(dir); + assertTrue(Files.exists(dir)); + } + + @Test + void createBundle_createsValidJsdosZip(@TempDir Path dir) throws Exception { + Path extractDir = dir.resolve("extract"); + Files.createDirectories(extractDir.resolve("sub")); + Files.writeString(extractDir.resolve("game.exe"), "MZ"); + Files.writeString(extractDir.resolve("sub/data.bin"), "binary"); + Files.writeString(extractDir.resolve("readme.txt"), "info"); + + Path bundlePath = dir.resolve("output.jsdos"); + + zip.createBundle(extractDir, "game.exe", List.of(), bundlePath); + + assertTrue(Files.exists(bundlePath)); + assertTrue(Files.size(bundlePath) > 0); + + try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) { + boolean hasGameExe = false; + boolean hasJsdosDir = false; + boolean hasJsdosConf = false; + boolean hasJsdosJson = false; + boolean hasDataBin = false; + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + String name = entry.getName(); + if (name.equals("game.exe")) hasGameExe = true; + if (name.startsWith(".jsdos/")) hasJsdosDir = true; + if (name.equals(".jsdos/dosbox.conf")) hasJsdosConf = true; + if (name.equals(".jsdos/jsdos.json")) hasJsdosJson = true; + if (name.equals("sub/data.bin")) hasDataBin = true; + zis.closeEntry(); + } + assertTrue(hasGameExe, "Should contain game.exe"); + assertTrue(hasJsdosDir, "Should contain .jsdos/ entries"); + assertTrue(hasJsdosConf, "Should contain .jsdos/dosbox.conf"); + assertTrue(hasJsdosJson, "Should contain .jsdos/jsdos.json"); + assertTrue(hasDataBin, "Should contain sub/data.bin"); + } + } + + @Test + void createBundle_respectsSkipExt(@TempDir Path dir) throws Exception { + Path extractDir = dir.resolve("extract"); + Files.createDirectories(extractDir); + Files.writeString(extractDir.resolve("game.exe"), "MZ"); + Files.writeString(extractDir.resolve("cdimage.nrg"), "image"); + Files.writeString(extractDir.resolve("cdimage.mdf"), "image"); + + Path bundlePath = dir.resolve("output.jsdos"); + + zip.createBundle(extractDir, "game.exe", List.of(), bundlePath); + + try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + String name = entry.getName(); + assertNotEquals("cdimage.nrg", name, "Should not contain .nrg"); + assertNotEquals("cdimage.mdf", name, "Should not contain .mdf"); + zis.closeEntry(); + } + } + } + + @Test + void createBundle_noExe_createsCdOnlyConfig(@TempDir Path dir) throws Exception { + Path extractDir = dir.resolve("extract"); + Files.createDirectories(extractDir); + Files.writeString(extractDir.resolve("somefile.bin"), "data"); + + Path bundlePath = dir.resolve("output.jsdos"); + + // null exePath triggers CD-only config generation + zip.createBundle(extractDir, null, List.of("cd.iso"), bundlePath); + + try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) { + boolean hasJsdosConf = false; + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.getName().equals(".jsdos/dosbox.conf")) hasJsdosConf = true; + zis.closeEntry(); + } + assertTrue(hasJsdosConf, "CD-only bundle should still have dosbox.conf"); + } + } +}