This commit is contained in:
2
data/resource-loader/.gitignore
vendored
Executable file
2
data/resource-loader/.gitignore
vendored
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
library.json
|
||||||
|
thumbnails/
|
||||||
0
src/main/java/org/zaine/app/calibre/adapter/in/web/CalibreController.java
Normal file → Executable file
0
src/main/java/org/zaine/app/calibre/adapter/in/web/CalibreController.java
Normal file → Executable file
0
src/main/java/org/zaine/app/calibre/adapter/out/sqlite/SqliteCalibreCatalog.java
Normal file → Executable file
0
src/main/java/org/zaine/app/calibre/adapter/out/sqlite/SqliteCalibreCatalog.java
Normal file → Executable file
0
src/main/java/org/zaine/app/calibre/application/port/in/BrowseCalibreBooksUseCase.java
Normal file → Executable file
0
src/main/java/org/zaine/app/calibre/application/port/in/BrowseCalibreBooksUseCase.java
Normal file → Executable file
0
src/main/java/org/zaine/app/calibre/application/port/out/CalibreCatalogPort.java
Normal file → Executable file
0
src/main/java/org/zaine/app/calibre/application/port/out/CalibreCatalogPort.java
Normal file → Executable file
0
src/main/java/org/zaine/app/calibre/application/service/CalibreApplicationService.java
Normal file → Executable file
0
src/main/java/org/zaine/app/calibre/application/service/CalibreApplicationService.java
Normal file → Executable file
0
src/main/java/org/zaine/app/calibre/domain/CalibreBook.java
Normal file → Executable file
0
src/main/java/org/zaine/app/calibre/domain/CalibreBook.java
Normal file → Executable file
0
src/main/java/org/zaine/app/common/application/ApplicationException.java
Normal file → Executable file
0
src/main/java/org/zaine/app/common/application/ApplicationException.java
Normal file → Executable file
0
src/main/java/org/zaine/app/config/BuildProperties.java
Normal file → Executable file
0
src/main/java/org/zaine/app/config/BuildProperties.java
Normal file → Executable file
0
src/main/java/org/zaine/app/notes/adapter/in/web/NotesController.java
Normal file → Executable file
0
src/main/java/org/zaine/app/notes/adapter/in/web/NotesController.java
Normal file → Executable file
0
src/main/java/org/zaine/app/notes/adapter/out/persistence/NoteJpaEntity.java
Normal file → Executable file
0
src/main/java/org/zaine/app/notes/adapter/out/persistence/NoteJpaEntity.java
Normal file → Executable file
0
src/main/java/org/zaine/app/notes/adapter/out/persistence/NotePersistenceAdapter.java
Normal file → Executable file
0
src/main/java/org/zaine/app/notes/adapter/out/persistence/NotePersistenceAdapter.java
Normal file → Executable file
0
src/main/java/org/zaine/app/notes/adapter/out/persistence/SpringDataNoteRepository.java
Normal file → Executable file
0
src/main/java/org/zaine/app/notes/adapter/out/persistence/SpringDataNoteRepository.java
Normal file → Executable file
0
src/main/java/org/zaine/app/notes/application/port/in/NotesUseCase.java
Normal file → Executable file
0
src/main/java/org/zaine/app/notes/application/port/in/NotesUseCase.java
Normal file → Executable file
0
src/main/java/org/zaine/app/notes/application/port/out/NoteRepositoryPort.java
Normal file → Executable file
0
src/main/java/org/zaine/app/notes/application/port/out/NoteRepositoryPort.java
Normal file → Executable file
0
src/main/java/org/zaine/app/notes/application/service/NotesApplicationService.java
Normal file → Executable file
0
src/main/java/org/zaine/app/notes/application/service/NotesApplicationService.java
Normal file → Executable file
0
src/main/java/org/zaine/app/notes/domain/Note.java
Normal file → Executable file
0
src/main/java/org/zaine/app/notes/domain/Note.java
Normal file → Executable file
11
src/main/java/org/zaine/app/resource/ResourceLoaderConflictException.java
Executable file
11
src/main/java/org/zaine/app/resource/ResourceLoaderConflictException.java
Executable file
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/main/java/org/zaine/app/resource/ResourceLoaderController.java
Executable file
65
src/main/java/org/zaine/app/resource/ResourceLoaderController.java
Executable file
@@ -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<JsonNode> loadLibrary() {
|
||||||
|
ResourceLoaderService.LibrarySnapshot snapshot = service.loadLibrary();
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.eTag(snapshot.etag())
|
||||||
|
.cacheControl(CacheControl.noStore())
|
||||||
|
.body(snapshot.library());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping
|
||||||
|
public ResponseEntity<JsonNode> 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<byte[]> 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<Void> saveThumbnail(
|
||||||
|
@PathVariable String id,
|
||||||
|
@RequestHeader("Content-Type") String contentType,
|
||||||
|
@RequestBody byte[] bytes) {
|
||||||
|
service.saveThumbnail(id, contentType, bytes);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
185
src/main/java/org/zaine/app/resource/ResourceLoaderService.java
Executable file
185
src/main/java/org/zaine/app/resource/ResourceLoaderService.java
Executable file
@@ -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<String> 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) {}
|
||||||
|
}
|
||||||
0
src/test/java/org/zaine/app/calibre/application/service/CalibreApplicationServiceTest.java
Normal file → Executable file
0
src/test/java/org/zaine/app/calibre/application/service/CalibreApplicationServiceTest.java
Normal file → Executable file
0
src/test/java/org/zaine/app/controller/RetiredEndpointContractTest.java
Normal file → Executable file
0
src/test/java/org/zaine/app/controller/RetiredEndpointContractTest.java
Normal file → Executable file
0
src/test/java/org/zaine/app/exception/GlobalExceptionHandlerTest.java
Normal file → Executable file
0
src/test/java/org/zaine/app/exception/GlobalExceptionHandlerTest.java
Normal file → Executable file
0
src/test/java/org/zaine/app/notes/application/service/NotesApplicationServiceTest.java
Normal file → Executable file
0
src/test/java/org/zaine/app/notes/application/service/NotesApplicationServiceTest.java
Normal file → Executable file
76
src/test/java/org/zaine/app/resource/ResourceLoaderControllerTest.java
Executable file
76
src/test/java/org/zaine/app/resource/ResourceLoaderControllerTest.java
Executable file
@@ -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\":[]}");
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/test/java/org/zaine/app/resource/ResourceLoaderServiceTest.java
Executable file
87
src/test/java/org/zaine/app/resource/ResourceLoaderServiceTest.java
Executable file
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
0
src/test/java/org/zaine/app/service/BuildRunStateServiceTest.java
Normal file → Executable file
0
src/test/java/org/zaine/app/service/BuildRunStateServiceTest.java
Normal file → Executable file
0
src/test/java/org/zaine/app/service/CommentsServiceTest.java
Normal file → Executable file
0
src/test/java/org/zaine/app/service/CommentsServiceTest.java
Normal file → Executable file
0
src/test/java/org/zaine/app/service/CompetenciesServiceTest.java
Normal file → Executable file
0
src/test/java/org/zaine/app/service/CompetenciesServiceTest.java
Normal file → Executable file
0
src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
Normal file → Executable file
0
src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
Normal file → Executable file
Reference in New Issue
Block a user