From 767b22f945fcf62106c4ceb58afe8ca3786515a6 Mon Sep 17 00:00:00 2001 From: Zaine Date: Thu, 20 Aug 2026 14:20:13 +0100 Subject: [PATCH] updates --- data/resource-loader/.gitignore | 2 + .../adapter/in/web/CalibreController.java | 0 .../out/sqlite/SqliteCalibreCatalog.java | 0 .../port/in/BrowseCalibreBooksUseCase.java | 0 .../port/out/CalibreCatalogPort.java | 0 .../service/CalibreApplicationService.java | 0 .../zaine/app/calibre/domain/CalibreBook.java | 0 .../application/ApplicationException.java | 0 .../org/zaine/app/config/BuildProperties.java | 0 .../notes/adapter/in/web/NotesController.java | 0 .../out/persistence/NoteJpaEntity.java | 0 .../persistence/NotePersistenceAdapter.java | 0 .../persistence/SpringDataNoteRepository.java | 0 .../application/port/in/NotesUseCase.java | 0 .../port/out/NoteRepositoryPort.java | 0 .../service/NotesApplicationService.java | 0 .../java/org/zaine/app/notes/domain/Note.java | 0 .../ResourceLoaderConflictException.java | 11 ++ .../resource/ResourceLoaderController.java | 65 ++++++ .../app/resource/ResourceLoaderService.java | 185 ++++++++++++++++++ .../CalibreApplicationServiceTest.java | 0 .../RetiredEndpointContractTest.java | 0 .../exception/GlobalExceptionHandlerTest.java | 0 .../service/NotesApplicationServiceTest.java | 0 .../ResourceLoaderControllerTest.java | 76 +++++++ .../resource/ResourceLoaderServiceTest.java | 87 ++++++++ .../app/service/BuildRunStateServiceTest.java | 0 .../app/service/CommentsServiceTest.java | 0 .../app/service/CompetenciesServiceTest.java | 0 .../org.mockito.plugins.MockMaker | 0 30 files changed, 426 insertions(+) create mode 100755 data/resource-loader/.gitignore mode change 100644 => 100755 src/main/java/org/zaine/app/calibre/adapter/in/web/CalibreController.java mode change 100644 => 100755 src/main/java/org/zaine/app/calibre/adapter/out/sqlite/SqliteCalibreCatalog.java mode change 100644 => 100755 src/main/java/org/zaine/app/calibre/application/port/in/BrowseCalibreBooksUseCase.java mode change 100644 => 100755 src/main/java/org/zaine/app/calibre/application/port/out/CalibreCatalogPort.java mode change 100644 => 100755 src/main/java/org/zaine/app/calibre/application/service/CalibreApplicationService.java mode change 100644 => 100755 src/main/java/org/zaine/app/calibre/domain/CalibreBook.java mode change 100644 => 100755 src/main/java/org/zaine/app/common/application/ApplicationException.java mode change 100644 => 100755 src/main/java/org/zaine/app/config/BuildProperties.java mode change 100644 => 100755 src/main/java/org/zaine/app/notes/adapter/in/web/NotesController.java mode change 100644 => 100755 src/main/java/org/zaine/app/notes/adapter/out/persistence/NoteJpaEntity.java mode change 100644 => 100755 src/main/java/org/zaine/app/notes/adapter/out/persistence/NotePersistenceAdapter.java mode change 100644 => 100755 src/main/java/org/zaine/app/notes/adapter/out/persistence/SpringDataNoteRepository.java mode change 100644 => 100755 src/main/java/org/zaine/app/notes/application/port/in/NotesUseCase.java mode change 100644 => 100755 src/main/java/org/zaine/app/notes/application/port/out/NoteRepositoryPort.java mode change 100644 => 100755 src/main/java/org/zaine/app/notes/application/service/NotesApplicationService.java mode change 100644 => 100755 src/main/java/org/zaine/app/notes/domain/Note.java create mode 100755 src/main/java/org/zaine/app/resource/ResourceLoaderConflictException.java create mode 100755 src/main/java/org/zaine/app/resource/ResourceLoaderController.java create mode 100755 src/main/java/org/zaine/app/resource/ResourceLoaderService.java mode change 100644 => 100755 src/test/java/org/zaine/app/calibre/application/service/CalibreApplicationServiceTest.java mode change 100644 => 100755 src/test/java/org/zaine/app/controller/RetiredEndpointContractTest.java mode change 100644 => 100755 src/test/java/org/zaine/app/exception/GlobalExceptionHandlerTest.java mode change 100644 => 100755 src/test/java/org/zaine/app/notes/application/service/NotesApplicationServiceTest.java create mode 100755 src/test/java/org/zaine/app/resource/ResourceLoaderControllerTest.java create mode 100755 src/test/java/org/zaine/app/resource/ResourceLoaderServiceTest.java mode change 100644 => 100755 src/test/java/org/zaine/app/service/BuildRunStateServiceTest.java mode change 100644 => 100755 src/test/java/org/zaine/app/service/CommentsServiceTest.java mode change 100644 => 100755 src/test/java/org/zaine/app/service/CompetenciesServiceTest.java mode change 100644 => 100755 src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/data/resource-loader/.gitignore b/data/resource-loader/.gitignore new file mode 100755 index 0000000..80aeb2a --- /dev/null +++ b/data/resource-loader/.gitignore @@ -0,0 +1,2 @@ +library.json +thumbnails/ diff --git a/src/main/java/org/zaine/app/calibre/adapter/in/web/CalibreController.java b/src/main/java/org/zaine/app/calibre/adapter/in/web/CalibreController.java old mode 100644 new mode 100755 diff --git a/src/main/java/org/zaine/app/calibre/adapter/out/sqlite/SqliteCalibreCatalog.java b/src/main/java/org/zaine/app/calibre/adapter/out/sqlite/SqliteCalibreCatalog.java old mode 100644 new mode 100755 diff --git a/src/main/java/org/zaine/app/calibre/application/port/in/BrowseCalibreBooksUseCase.java b/src/main/java/org/zaine/app/calibre/application/port/in/BrowseCalibreBooksUseCase.java old mode 100644 new mode 100755 diff --git a/src/main/java/org/zaine/app/calibre/application/port/out/CalibreCatalogPort.java b/src/main/java/org/zaine/app/calibre/application/port/out/CalibreCatalogPort.java old mode 100644 new mode 100755 diff --git a/src/main/java/org/zaine/app/calibre/application/service/CalibreApplicationService.java b/src/main/java/org/zaine/app/calibre/application/service/CalibreApplicationService.java old mode 100644 new mode 100755 diff --git a/src/main/java/org/zaine/app/calibre/domain/CalibreBook.java b/src/main/java/org/zaine/app/calibre/domain/CalibreBook.java old mode 100644 new mode 100755 diff --git a/src/main/java/org/zaine/app/common/application/ApplicationException.java b/src/main/java/org/zaine/app/common/application/ApplicationException.java old mode 100644 new mode 100755 diff --git a/src/main/java/org/zaine/app/config/BuildProperties.java b/src/main/java/org/zaine/app/config/BuildProperties.java old mode 100644 new mode 100755 diff --git a/src/main/java/org/zaine/app/notes/adapter/in/web/NotesController.java b/src/main/java/org/zaine/app/notes/adapter/in/web/NotesController.java old mode 100644 new mode 100755 diff --git a/src/main/java/org/zaine/app/notes/adapter/out/persistence/NoteJpaEntity.java b/src/main/java/org/zaine/app/notes/adapter/out/persistence/NoteJpaEntity.java old mode 100644 new mode 100755 diff --git a/src/main/java/org/zaine/app/notes/adapter/out/persistence/NotePersistenceAdapter.java b/src/main/java/org/zaine/app/notes/adapter/out/persistence/NotePersistenceAdapter.java old mode 100644 new mode 100755 diff --git a/src/main/java/org/zaine/app/notes/adapter/out/persistence/SpringDataNoteRepository.java b/src/main/java/org/zaine/app/notes/adapter/out/persistence/SpringDataNoteRepository.java old mode 100644 new mode 100755 diff --git a/src/main/java/org/zaine/app/notes/application/port/in/NotesUseCase.java b/src/main/java/org/zaine/app/notes/application/port/in/NotesUseCase.java old mode 100644 new mode 100755 diff --git a/src/main/java/org/zaine/app/notes/application/port/out/NoteRepositoryPort.java b/src/main/java/org/zaine/app/notes/application/port/out/NoteRepositoryPort.java old mode 100644 new mode 100755 diff --git a/src/main/java/org/zaine/app/notes/application/service/NotesApplicationService.java b/src/main/java/org/zaine/app/notes/application/service/NotesApplicationService.java old mode 100644 new mode 100755 diff --git a/src/main/java/org/zaine/app/notes/domain/Note.java b/src/main/java/org/zaine/app/notes/domain/Note.java old mode 100644 new mode 100755 diff --git a/src/main/java/org/zaine/app/resource/ResourceLoaderConflictException.java b/src/main/java/org/zaine/app/resource/ResourceLoaderConflictException.java new file mode 100755 index 0000000..0309383 --- /dev/null +++ b/src/main/java/org/zaine/app/resource/ResourceLoaderConflictException.java @@ -0,0 +1,11 @@ +package org.zaine.app.resource; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.PRECONDITION_FAILED) +public class ResourceLoaderConflictException extends RuntimeException { + public ResourceLoaderConflictException() { + super("The Resource Loader library changed on another device."); + } +} diff --git a/src/main/java/org/zaine/app/resource/ResourceLoaderController.java b/src/main/java/org/zaine/app/resource/ResourceLoaderController.java new file mode 100755 index 0000000..cf038db --- /dev/null +++ b/src/main/java/org/zaine/app/resource/ResourceLoaderController.java @@ -0,0 +1,65 @@ +package org.zaine.app.resource; + +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.fasterxml.jackson.databind.JsonNode; + +import io.swagger.v3.oas.annotations.tags.Tag; + +@RestController +@RequestMapping("/api/resource-loader") +@Tag(name = "Resource Loader", description = "Cross-device Resource Loader metadata and thumbnails") +public class ResourceLoaderController { + private final ResourceLoaderService service; + + public ResourceLoaderController(ResourceLoaderService service) { + this.service = service; + } + + @GetMapping + public ResponseEntity loadLibrary() { + ResourceLoaderService.LibrarySnapshot snapshot = service.loadLibrary(); + return ResponseEntity.ok() + .eTag(snapshot.etag()) + .cacheControl(CacheControl.noStore()) + .body(snapshot.library()); + } + + @PutMapping + public ResponseEntity saveLibrary( + @RequestBody JsonNode library, + @RequestHeader(value = "If-Match", required = false) String ifMatch) { + ResourceLoaderService.LibrarySnapshot snapshot = service.saveLibrary(library, ifMatch); + return ResponseEntity.ok() + .eTag(snapshot.etag()) + .cacheControl(CacheControl.noStore()) + .body(snapshot.library()); + } + + @GetMapping("/thumbnails/{id}") + public ResponseEntity loadThumbnail(@PathVariable String id) { + ResourceLoaderService.ThumbnailSnapshot thumbnail = service.loadThumbnail(id); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(thumbnail.contentType())) + .cacheControl(CacheControl.noStore()) + .body(thumbnail.bytes()); + } + + @PutMapping("/thumbnails/{id}") + public ResponseEntity saveThumbnail( + @PathVariable String id, + @RequestHeader("Content-Type") String contentType, + @RequestBody byte[] bytes) { + service.saveThumbnail(id, contentType, bytes); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/org/zaine/app/resource/ResourceLoaderService.java b/src/main/java/org/zaine/app/resource/ResourceLoaderService.java new file mode 100755 index 0000000..6fe32da --- /dev/null +++ b/src/main/java/org/zaine/app/resource/ResourceLoaderService.java @@ -0,0 +1,185 @@ +package org.zaine.app.resource; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.zaine.app.common.application.ApplicationException; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +@Service +public class ResourceLoaderService { + private static final String DEFAULT_STORAGE_DIRECTORY = + "/home/zaine/master-folder/org-platform/org_backend/data/resource-loader"; + private static final Set THUMBNAIL_TYPES = Set.of("image/jpeg", "image/png", "image/webp"); + private static final long MAX_LIBRARY_BYTES = 10L * 1024 * 1024; + private static final long MAX_THUMBNAIL_BYTES = 5L * 1024 * 1024; + + private final ObjectMapper objectMapper; + private final Path storageDirectory; + private final Path libraryPath; + private final Path thumbnailDirectory; + + public ResourceLoaderService( + ObjectMapper objectMapper, + @Value("${resource-loader.storage-dir:" + DEFAULT_STORAGE_DIRECTORY + "}") String storageDirectory) { + this.objectMapper = objectMapper; + this.storageDirectory = Path.of(storageDirectory); + this.libraryPath = this.storageDirectory.resolve("library.json"); + this.thumbnailDirectory = this.storageDirectory.resolve("thumbnails"); + } + + public synchronized LibrarySnapshot loadLibrary() { + if (!Files.isRegularFile(libraryPath)) { + throw ApplicationException.notFound("Resource Loader library has not been initialised."); + } + try { + byte[] bytes = Files.readAllBytes(libraryPath); + JsonNode library = objectMapper.readTree(bytes); + validateLibrary(library); + return new LibrarySnapshot(library, etag(bytes)); + } catch (ApplicationException e) { + throw e; + } catch (IOException e) { + throw ApplicationException.failure("Failed to read the Resource Loader library."); + } + } + + public synchronized LibrarySnapshot saveLibrary(JsonNode library, String ifMatch) { + validateLibrary(library); + try { + if (Files.isRegularFile(libraryPath) && ifMatch != null && !ifMatch.isBlank()) { + String currentEtag = etag(Files.readAllBytes(libraryPath)); + if (!currentEtag.equals(ifMatch.trim())) { + throw new ResourceLoaderConflictException(); + } + } + + byte[] bytes = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(library); + if (bytes.length > MAX_LIBRARY_BYTES) { + throw ApplicationException.payloadTooLarge("Resource Loader metadata exceeds 10 MB."); + } + Files.createDirectories(storageDirectory); + atomicWrite(libraryPath, bytes); + return new LibrarySnapshot(objectMapper.readTree(bytes), etag(bytes)); + } catch (ResourceLoaderConflictException | ApplicationException e) { + throw e; + } catch (IOException e) { + throw ApplicationException.failure("Failed to save the Resource Loader library."); + } + } + + public synchronized void saveThumbnail(String id, String contentType, byte[] bytes) { + validateId(id); + String normalisedType = contentType == null ? "" : contentType.split(";", 2)[0].trim().toLowerCase(); + if (!THUMBNAIL_TYPES.contains(normalisedType)) { + throw ApplicationException.badRequest("Thumbnail must be JPEG, PNG, or WebP."); + } + if (bytes == null || bytes.length == 0) { + throw ApplicationException.badRequest("Thumbnail is empty."); + } + if (bytes.length > MAX_THUMBNAIL_BYTES) { + throw ApplicationException.payloadTooLarge("Thumbnail exceeds 5 MB."); + } + try { + Files.createDirectories(thumbnailDirectory); + atomicWrite(thumbnailPath(id), bytes); + atomicWrite(thumbnailTypePath(id), normalisedType.getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw ApplicationException.failure("Failed to save the Resource Loader thumbnail."); + } + } + + public ThumbnailSnapshot loadThumbnail(String id) { + validateId(id); + Path image = thumbnailPath(id); + Path type = thumbnailTypePath(id); + if (!Files.isRegularFile(image) || !Files.isRegularFile(type)) { + throw ApplicationException.notFound("Resource Loader thumbnail was not found."); + } + try { + return new ThumbnailSnapshot(Files.readAllBytes(image), Files.readString(type).trim()); + } catch (IOException e) { + throw ApplicationException.failure("Failed to read the Resource Loader thumbnail."); + } + } + + private void validateLibrary(JsonNode library) { + if (library == null || !library.isObject()) { + throw ApplicationException.badRequest("Resource Loader library must be a JSON object."); + } + if (!library.path("schemaVersion").isInt() || library.path("schemaVersion").intValue() != 1) { + throw ApplicationException.badRequest("Unsupported Resource Loader schemaVersion."); + } + JsonNode vaults = library.path("vaults"); + if (!vaults.isArray() || vaults.isEmpty()) { + throw ApplicationException.badRequest("Resource Loader library must contain at least one vault."); + } + for (JsonNode vault : vaults) { + if (!nonBlank(vault, "id") || !nonBlank(vault, "name") || !vault.path("folders").isArray() + || !vault.path("resources").isArray()) { + throw ApplicationException.badRequest("Every vault requires an id, name, folders, and resources."); + } + for (JsonNode resource : vault.path("resources")) { + if (!nonBlank(resource, "id") || !nonBlank(resource, "title") || !resource.path("sessions").isArray()) { + throw ApplicationException.badRequest("Every resource requires an id, title, and sessions array."); + } + } + } + } + + private static boolean nonBlank(JsonNode node, String field) { + return node.path(field).isTextual() && !node.path(field).textValue().isBlank(); + } + + private static void validateId(String id) { + if (id == null || !id.matches("[A-Za-z0-9_-]{1,128}")) { + throw ApplicationException.badRequest("Invalid Resource Loader thumbnail id."); + } + } + + private Path thumbnailPath(String id) { + return thumbnailDirectory.resolve(id + ".bin"); + } + + private Path thumbnailTypePath(String id) { + return thumbnailDirectory.resolve(id + ".type"); + } + + private static void atomicWrite(Path target, byte[] bytes) throws IOException { + Path temporary = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".tmp"); + try { + Files.write(temporary, bytes); + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temporary); + } + } + + private static String etag(byte[] bytes) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(bytes); + return "\"" + HexFormat.of().formatHex(digest) + "\""; + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + + public record LibrarySnapshot(JsonNode library, String etag) {} + public record ThumbnailSnapshot(byte[] bytes, String contentType) {} +} diff --git a/src/test/java/org/zaine/app/calibre/application/service/CalibreApplicationServiceTest.java b/src/test/java/org/zaine/app/calibre/application/service/CalibreApplicationServiceTest.java old mode 100644 new mode 100755 diff --git a/src/test/java/org/zaine/app/controller/RetiredEndpointContractTest.java b/src/test/java/org/zaine/app/controller/RetiredEndpointContractTest.java old mode 100644 new mode 100755 diff --git a/src/test/java/org/zaine/app/exception/GlobalExceptionHandlerTest.java b/src/test/java/org/zaine/app/exception/GlobalExceptionHandlerTest.java old mode 100644 new mode 100755 diff --git a/src/test/java/org/zaine/app/notes/application/service/NotesApplicationServiceTest.java b/src/test/java/org/zaine/app/notes/application/service/NotesApplicationServiceTest.java old mode 100644 new mode 100755 diff --git a/src/test/java/org/zaine/app/resource/ResourceLoaderControllerTest.java b/src/test/java/org/zaine/app/resource/ResourceLoaderControllerTest.java new file mode 100755 index 0000000..e31167b --- /dev/null +++ b/src/test/java/org/zaine/app/resource/ResourceLoaderControllerTest.java @@ -0,0 +1,76 @@ +package org.zaine.app.resource; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +class ResourceLoaderControllerTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + private ResourceLoaderService service; + private MockMvc mvc; + + @BeforeEach + void setUp() { + service = mock(ResourceLoaderService.class); + mvc = MockMvcBuilders.standaloneSetup(new ResourceLoaderController(service)).build(); + } + + @Test + void returnsTheLibraryAndEtag() throws Exception { + JsonNode library = library(); + when(service.loadLibrary()).thenReturn(new ResourceLoaderService.LibrarySnapshot(library, "\"revision-one\"")); + + mvc.perform(get("/api/resource-loader")) + .andExpect(status().isOk()) + .andExpect(header().string("ETag", "\"revision-one\"")) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.schemaVersion").value(1)); + } + + @Test + void passesIfMatchWhenSaving() throws Exception { + JsonNode library = library(); + when(service.saveLibrary(any(JsonNode.class), eq("\"revision-one\""))) + .thenReturn(new ResourceLoaderService.LibrarySnapshot(library, "\"revision-two\"")); + + mvc.perform(put("/api/resource-loader") + .contentType(MediaType.APPLICATION_JSON) + .header("If-Match", "\"revision-one\"") + .content(library.toString())) + .andExpect(status().isOk()) + .andExpect(header().string("ETag", "\"revision-two\"")); + + verify(service).saveLibrary(any(JsonNode.class), eq("\"revision-one\"")); + } + + @Test + void preventsBrowserCachingOfThumbnails() throws Exception { + when(service.loadThumbnail("cover-one")) + .thenReturn(new ResourceLoaderService.ThumbnailSnapshot( + new byte[] { 1, 2, 3 }, "image/jpeg")); + + mvc.perform(get("/api/resource-loader/thumbnails/cover-one")) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "no-store")); + } + + private JsonNode library() throws Exception { + return objectMapper.readTree("{\"schemaVersion\":1,\"vaults\":[]}"); + } +} diff --git a/src/test/java/org/zaine/app/resource/ResourceLoaderServiceTest.java b/src/test/java/org/zaine/app/resource/ResourceLoaderServiceTest.java new file mode 100755 index 0000000..748a607 --- /dev/null +++ b/src/test/java/org/zaine/app/resource/ResourceLoaderServiceTest.java @@ -0,0 +1,87 @@ +package org.zaine.app.resource; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.zaine.app.common.application.ApplicationException; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +class ResourceLoaderServiceTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @TempDir + Path tempDir; + + @Test + void savesAndLoadsLibraryWithAnEtag() throws Exception { + ResourceLoaderService service = service(); + JsonNode library = library("First title"); + + ResourceLoaderService.LibrarySnapshot saved = service.saveLibrary(library, null); + ResourceLoaderService.LibrarySnapshot loaded = service.loadLibrary(); + + assertEquals("First title", loaded.library().path("vaults").get(0).path("resources").get(0).path("title").asText()); + assertEquals(saved.etag(), loaded.etag()); + } + + @Test + void rejectsAStaleEtag() throws Exception { + ResourceLoaderService service = service(); + ResourceLoaderService.LibrarySnapshot first = service.saveLibrary(library("First"), null); + ResourceLoaderService.LibrarySnapshot second = service.saveLibrary(library("Second"), first.etag()); + + assertNotEquals(first.etag(), second.etag()); + assertThrows(ResourceLoaderConflictException.class, () -> service.saveLibrary(library("Stale"), first.etag())); + } + + @Test + void rejectsInvalidLibraries() throws Exception { + ResourceLoaderService service = service(); + JsonNode invalid = objectMapper.readTree("{\"schemaVersion\":2,\"vaults\":[]}"); + assertThrows(ApplicationException.class, () -> service.saveLibrary(invalid, null)); + } + + @Test + void storesThumbnailBytesAndType() { + ResourceLoaderService service = service(); + byte[] bytes = new byte[] { 1, 2, 3, 4 }; + service.saveThumbnail("thumbnail-id", "image/jpeg", bytes); + + ResourceLoaderService.ThumbnailSnapshot loaded = service.loadThumbnail("thumbnail-id"); + assertArrayEquals(bytes, loaded.bytes()); + assertEquals("image/jpeg", loaded.contentType()); + } + + private ResourceLoaderService service() { + return new ResourceLoaderService(objectMapper, tempDir.toString()); + } + + private JsonNode library(String title) throws Exception { + return objectMapper.readTree(""" + { + "schemaVersion": 1, + "vaults": [{ + "id": "vault-work", + "name": "Work", + "folders": [], + "resources": [{ + "id": "resource-1", + "title": "%s", + "type": "book", + "format": "physical", + "status": "backlog", + "sessions": [] + }] + }] + } + """.formatted(title)); + } +} diff --git a/src/test/java/org/zaine/app/service/BuildRunStateServiceTest.java b/src/test/java/org/zaine/app/service/BuildRunStateServiceTest.java old mode 100644 new mode 100755 diff --git a/src/test/java/org/zaine/app/service/CommentsServiceTest.java b/src/test/java/org/zaine/app/service/CommentsServiceTest.java old mode 100644 new mode 100755 diff --git a/src/test/java/org/zaine/app/service/CompetenciesServiceTest.java b/src/test/java/org/zaine/app/service/CompetenciesServiceTest.java old mode 100644 new mode 100755 diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker old mode 100644 new mode 100755