test: add 93 backend tests, raise coverage from 28% to ~80%
Build & Deploy / build-and-deploy (push) Failing after 45s

New test files (6):
- GameTest.java (12 tests) — all getters/setters, constructors,
  isHasSetup (null/blank/valid), isPlayable (not-ready/null/dos/windows)
- FileUtilsTest.java (8 tests) — deleteDirectory (non-existent,
  recursive, subdirs), findFilesByExtensions (extensions, case,
  empty, subdirs)
- StaticResourceTest.java (15 tests) — all 9 content types, health
  endpoint, path traversal 403, non-existent 404, cache headers
- IgdbServiceTest.java (22 tests) — isConfigured (5 cases),
  epochToYear (4), extractGenres (4), sanitize (5), jsonNodeToMap (8)
- GameServiceTest.java (20 tests) — sanitizeId (10), init dirs,
  save/load round-trip, load non-existent throws, list/sort/delete,
  JSON snake_case + timestamp serialization
- ZipServiceTest.java (11 tests) — unzip (files, subdirs, path
  traversal, dir entries), flattenSingleDir (multiple/none/single/
  empty), createBundle (valid zip, skip ext, cd-only)

Expanded tests (1):
- ExecutableDetectorTest.java (+5 tests) — DOS-over-Windows
  preference, no-executables null, PK-signature skip, mixed content,
  empty-dir findAll
This commit is contained in:
Hermes Agent
2026-06-10 12:05:51 +02:00
parent 7e6629441e
commit 54ddef2bff
7 changed files with 1295 additions and 0 deletions
@@ -136,6 +136,69 @@ class ExecutableDetectorTest {
assertFalse(ExecutableDetector.isLikelySelfExtractor(f)); 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) { private static byte[] createMZ(int size) {
byte[] buf = new byte[size]; byte[] buf = new byte[size];
buf[0] = 0x4D; // M buf[0] = 0x4D; // M
@@ -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<String> 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<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat"));
assertEquals(3, result.size());
}
@Test
void findFilesByExtensions_emptyDir_returnsEmpty(@TempDir Path dir) throws Exception {
List<String> 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<String> 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<String> result = FileUtils.findFilesByExtensions(dir, Set.of(".exe"));
assertEquals(2, result.size());
assertTrue(result.contains("root.exe"));
assertTrue(result.contains("subdir/game.exe"));
}
}
@@ -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<Game> 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<Game> 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<Game> 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());
}
}
+154
View File
@@ -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());
}
}
@@ -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<Integer> year = (Optional<Integer>) 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<Integer> year = (Optional<Integer>) 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<Integer> year = (Optional<Integer>) 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<Integer> year = (Optional<Integer>) 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<String> genres = (List<String>) 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<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
assertTrue(genres.isEmpty());
}
@Test
void extractGenres_missingField_returnsEmptyList() throws Exception {
JsonNode node = mapper.readTree("{\"name\": \"Test\"}");
List<String> genres = (List<String>) extractGenresMethod.invoke(null, node);
assertTrue(genres.isEmpty());
}
@Test
void extractGenres_genreWithoutName_skipsEntry() throws Exception {
JsonNode node = mapper.readTree("{\"genres\": [{\"name\": \"Action\"}, {\"id\": 5}]}");
List<String> genres = (List<String>) 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<String, Object> map = (Map<String, Object>) 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<String, Object> map = (Map<String, Object>) 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<String, Object> map = (Map<String, Object>) 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<String, Object> map = (Map<String, Object>) 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<String, Object> map = (Map<String, Object>) 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<String, Object> map = (Map<String, Object>) 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<String, Object> map = (Map<String, Object>) 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<String, Object> map = (Map<String, Object>) jsonNodeToMapMethod.invoke(service, node);
assertEquals("FirstDev", map.get("developer"));
assertEquals("FirstPub", map.get("publisher"));
}
}
@@ -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());
}
}
@@ -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");
}
}
}