Refacto du code coté Python, java et coté angular afin de mieux séparer les responsabilité et d'avoir moins de répétitivité dans le code.
Some checks failed
E2E Tests / e2e (push) Waiting to run
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 26s
Build & Push Images / tests (push) Failing after 13s
Build & Push Images / build (brain) (push) Has been skipped
Build & Push Images / build (core) (push) Has been skipped
Build & Push Images / build (web) (push) Has been skipped
Build & Push Images / build-switcher (push) Has been skipped
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 1m12s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 1m28s
Some checks failed
E2E Tests / e2e (push) Waiting to run
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 26s
Build & Push Images / tests (push) Failing after 13s
Build & Push Images / build (brain) (push) Has been skipped
Build & Push Images / build (core) (push) Has been skipped
Build & Push Images / build (web) (push) Has been skipped
Build & Push Images / build-switcher (push) Has been skipped
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 1m12s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 1m28s
Mise en place de tests unitaires coté Python et Angular Mise en place de la couverture de test directement dans le workflow : le programme ne build pas si jamais un test échoue Passage en v0.16.2 en conséquence
This commit is contained in:
@@ -14,7 +14,6 @@ import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
|
||||
import com.loremind.infrastructure.web.config.UserLanguageHolder;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.MultipartBodyBuilder;
|
||||
import org.springframework.http.codec.ServerSentEvent;
|
||||
@@ -23,7 +22,6 @@ import org.springframework.web.reactive.function.BodyInserters;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
@@ -43,7 +41,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
new ParameterizedTypeReference<>() {};
|
||||
|
||||
private final WebClient webClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final BrainSseImportSupport sse;
|
||||
private final long importTimeoutSeconds;
|
||||
|
||||
public BrainCampaignImportClient(
|
||||
@@ -52,7 +50,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
@Value("${brain.base-url}") String baseUrl,
|
||||
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) {
|
||||
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
|
||||
this.objectMapper = objectMapper;
|
||||
this.sse = new BrainSseImportSupport(objectMapper);
|
||||
this.importTimeoutSeconds = importTimeoutSeconds;
|
||||
}
|
||||
|
||||
@@ -67,7 +65,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
Consumer<Throwable> onError) {
|
||||
|
||||
MultipartBodyBuilder parts = new MultipartBodyBuilder();
|
||||
parts.part("file", filePart(pdfBytes, filename))
|
||||
parts.part("file", sse.filePart(pdfBytes, filename, "campaign.pdf"))
|
||||
.filename(filename == null || filename.isBlank() ? "campaign.pdf" : filename);
|
||||
|
||||
Flux<ServerSentEvent<String>> flux = webClient.post()
|
||||
@@ -83,31 +81,16 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
int[] ocrPageCount = {0};
|
||||
boolean[] terminated = {false};
|
||||
|
||||
try {
|
||||
flux
|
||||
.timeout(Duration.ofSeconds(importTimeoutSeconds))
|
||||
.doOnNext(sse -> handleEvent(
|
||||
sse, pageCount, ocrPageCount, terminated,
|
||||
onProgress, onHeartbeat, onStatus, onDone, onError))
|
||||
.blockLast();
|
||||
if (!terminated[0]) {
|
||||
onError.accept(new CampaignImportException(
|
||||
"Le flux d'import s'est interrompu avant la fin."));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (!terminated[0]) {
|
||||
// On EXPOSE la cause réelle (type + message) : sans ça, le diagnostic
|
||||
// est impossible (timeout WebClient, connexion coupée, réponse non-2xx…).
|
||||
String cause = e.getClass().getSimpleName()
|
||||
+ (e.getMessage() != null ? " — " + e.getMessage() : "");
|
||||
onError.accept(new CampaignImportException(
|
||||
"Erreur lors du streaming d'import depuis le Brain : " + cause, e));
|
||||
}
|
||||
}
|
||||
sse.runStream(
|
||||
flux, importTimeoutSeconds, terminated,
|
||||
event -> handleEvent(
|
||||
event, pageCount, ocrPageCount, terminated,
|
||||
onProgress, onHeartbeat, onStatus, onDone, onError),
|
||||
onError, CampaignImportException::new);
|
||||
}
|
||||
|
||||
private void handleEvent(
|
||||
ServerSentEvent<String> sse,
|
||||
ServerSentEvent<String> ssEvent,
|
||||
int[] pageCount,
|
||||
int[] ocrPageCount,
|
||||
boolean[] terminated,
|
||||
@@ -117,8 +100,8 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
Consumer<CampaignImportProposal> onDone,
|
||||
Consumer<Throwable> onError) {
|
||||
|
||||
String event = sse.event();
|
||||
String data = sse.data() == null ? "" : sse.data();
|
||||
String event = ssEvent.event();
|
||||
String data = ssEvent.data() == null ? "" : ssEvent.data();
|
||||
|
||||
if ("heartbeat".equals(event)) {
|
||||
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
|
||||
@@ -129,23 +112,17 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
if ("status".equals(event)) {
|
||||
// Message d'attente lisible (retry sur fournisseur saturé, morceau
|
||||
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
|
||||
onStatus.accept(readMessage(data));
|
||||
onStatus.accept(sse.readMessage(data));
|
||||
return;
|
||||
}
|
||||
if ("chunk_failed".equals(event)) {
|
||||
JsonNode node = readJson(data);
|
||||
String msg = node != null && node.hasNonNull("message")
|
||||
? node.get("message").asText() : "";
|
||||
int current = node != null ? node.path("current").asInt() : 0;
|
||||
int total = node != null ? node.path("total").asInt() : 0;
|
||||
onStatus.accept("Morceau " + current + "/" + total + " ignoré"
|
||||
+ (msg.isEmpty() ? "." : " : " + msg));
|
||||
onStatus.accept(sse.chunkFailedStatus(data));
|
||||
return;
|
||||
}
|
||||
if ("error".equals(event)) {
|
||||
terminated[0] = true;
|
||||
onError.accept(new CampaignImportException(
|
||||
"Le Brain a signalé une erreur : " + readMessage(data)));
|
||||
"Le Brain a signalé une erreur : " + sse.readMessage(data)));
|
||||
return;
|
||||
}
|
||||
if ("extracting".equals(event)) {
|
||||
@@ -153,7 +130,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
return;
|
||||
}
|
||||
|
||||
JsonNode node = readJson(data);
|
||||
JsonNode node = sse.readJson(data);
|
||||
if (node == null) return;
|
||||
|
||||
if ("start".equals(event)) {
|
||||
@@ -246,33 +223,8 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
|
||||
// --- Helpers -------------------------------------------------------------
|
||||
|
||||
private ByteArrayResource filePart(byte[] pdfBytes, String filename) {
|
||||
return new ByteArrayResource(pdfBytes) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return (filename == null || filename.isBlank()) ? "campaign.pdf" : filename;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static String text(JsonNode node, String field) {
|
||||
JsonNode v = node.path(field);
|
||||
return v.isMissingNode() || v.isNull() ? "" : v.asText();
|
||||
}
|
||||
|
||||
private JsonNode readJson(String data) {
|
||||
try {
|
||||
return objectMapper.readTree(data);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String readMessage(String data) {
|
||||
JsonNode node = readJson(data);
|
||||
if (node != null && node.hasNonNull("message")) {
|
||||
return node.get("message").asText();
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,9 +108,7 @@ public class BrainChatPayloadBuilder {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", q.name());
|
||||
map.put("arc_name", q.arcName());
|
||||
if (q.description() != null && !q.description().isBlank()) {
|
||||
map.put("description", q.description());
|
||||
}
|
||||
putIfText(map, "description", q.description());
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -121,18 +119,14 @@ public class BrainChatPayloadBuilder {
|
||||
if (e.occurredAt() != null) {
|
||||
map.put("occurred_at", e.occurredAt().toString());
|
||||
}
|
||||
if (e.sourceSessionName() != null && !e.sourceSessionName().isBlank()) {
|
||||
map.put("source_session_name", e.sourceSessionName());
|
||||
}
|
||||
putIfText(map, "source_session_name", e.sourceSessionName());
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("system_name", gs.systemName());
|
||||
if (gs.systemDescription() != null && !gs.systemDescription().isBlank()) {
|
||||
map.put("system_description", gs.systemDescription());
|
||||
}
|
||||
putIfText(map, "system_description", gs.systemDescription());
|
||||
map.put("sections", gs.sections() != null ? gs.sections() : Map.of());
|
||||
return map;
|
||||
}
|
||||
@@ -211,18 +205,14 @@ public class BrainChatPayloadBuilder {
|
||||
private Map<String, Object> characterSummaryToMap(CharacterSummary c) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", c.name());
|
||||
if (c.snippet() != null && !c.snippet().isBlank()) {
|
||||
map.put("snippet", c.snippet());
|
||||
}
|
||||
putIfText(map, "snippet", c.snippet());
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> npcSummaryToMap(NpcSummary n) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", n.name());
|
||||
if (n.snippet() != null && !n.snippet().isBlank()) {
|
||||
map.put("snippet", n.snippet());
|
||||
}
|
||||
putIfText(map, "snippet", n.snippet());
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -300,9 +290,7 @@ public class BrainChatPayloadBuilder {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("label", b.label());
|
||||
map.put("target_scene_name", b.targetSceneName());
|
||||
if (b.condition() != null && !b.condition().isBlank()) {
|
||||
map.put("condition", b.condition());
|
||||
}
|
||||
putIfText(map, "condition", b.condition());
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -310,14 +298,14 @@ public class BrainChatPayloadBuilder {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", r.name());
|
||||
if (r.floor() != null) map.put("floor", r.floor());
|
||||
if (r.description() != null && !r.description().isBlank()) map.put("description", r.description());
|
||||
if (r.enemies() != null && !r.enemies().isBlank()) map.put("enemies", r.enemies());
|
||||
putIfText(map, "description", r.description());
|
||||
putIfText(map, "enemies", r.enemies());
|
||||
if (r.branches() != null && !r.branches().isEmpty()) {
|
||||
map.put("branches", r.branches().stream().map(b -> {
|
||||
Map<String, Object> bm = new LinkedHashMap<>();
|
||||
bm.put("label", b.label());
|
||||
bm.put("target_room_name", b.targetRoomName());
|
||||
if (b.condition() != null && !b.condition().isBlank()) bm.put("condition", b.condition());
|
||||
putIfText(bm, "condition", b.condition());
|
||||
return bm;
|
||||
}).collect(Collectors.toList()));
|
||||
}
|
||||
@@ -331,4 +319,15 @@ public class BrainChatPayloadBuilder {
|
||||
map.put("fields", ne.fields());
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute {@code key → value} uniquement si {@code value} est une chaîne non
|
||||
* nulle et non blanche. Centralise l'omission des champs Optional « texte »
|
||||
* pour s'aligner sur le schéma Pydantic du Brain (champ absent si vide).
|
||||
*/
|
||||
private static void putIfText(Map<String, Object> map, String key, String value) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
map.put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import com.loremind.infrastructure.web.config.UserLanguageHolder;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -26,7 +25,6 @@ import org.springframework.web.reactive.function.BodyInserters;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -56,7 +54,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final WebClient webClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final BrainSseImportSupport sse;
|
||||
private final String baseUrl;
|
||||
private final long importTimeoutSeconds;
|
||||
|
||||
@@ -68,7 +66,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
||||
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) {
|
||||
this.restTemplate = restTemplate;
|
||||
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
|
||||
this.objectMapper = objectMapper;
|
||||
this.sse = new BrainSseImportSupport(objectMapper);
|
||||
this.baseUrl = baseUrl;
|
||||
this.importTimeoutSeconds = importTimeoutSeconds;
|
||||
}
|
||||
@@ -81,7 +79,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("file", filePart(pdfBytes, filename));
|
||||
body.add("file", sse.filePart(pdfBytes, filename, "rules.pdf"));
|
||||
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||
|
||||
try {
|
||||
@@ -121,7 +119,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
||||
Consumer<Throwable> onError) {
|
||||
|
||||
MultipartBodyBuilder parts = new MultipartBodyBuilder();
|
||||
parts.part("file", filePart(pdfBytes, filename))
|
||||
parts.part("file", sse.filePart(pdfBytes, filename, "rules.pdf"))
|
||||
.filename(filename == null || filename.isBlank() ? "rules.pdf" : filename);
|
||||
|
||||
Flux<ServerSentEvent<String>> flux = webClient.post()
|
||||
@@ -139,33 +137,16 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
||||
int[] ocrPageCount = {0};
|
||||
boolean[] terminated = {false};
|
||||
|
||||
try {
|
||||
flux
|
||||
.timeout(Duration.ofSeconds(importTimeoutSeconds))
|
||||
.doOnNext(sse -> handleEvent(
|
||||
sse, pageCount, ocrPageCount, terminated,
|
||||
onProgress, onHeartbeat, onStatus, onDone, onError))
|
||||
.blockLast();
|
||||
// Flux terminé sans event done/error (ex: connexion coupée) → on signale.
|
||||
if (!terminated[0]) {
|
||||
onError.accept(new RulesImportException(
|
||||
"Le flux d'import s'est interrompu avant la fin."));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (!terminated[0]) {
|
||||
// On EXPOSE la cause réelle (type + message) : sans ça, l'UI n'a qu'un
|
||||
// message générique et le diagnostic est impossible (timeout WebClient,
|
||||
// connexion coupée, réponse non-2xx du Brain, etc.).
|
||||
String cause = e.getClass().getSimpleName()
|
||||
+ (e.getMessage() != null ? " — " + e.getMessage() : "");
|
||||
onError.accept(new RulesImportException(
|
||||
"Erreur lors du streaming d'import depuis le Brain : " + cause, e));
|
||||
}
|
||||
}
|
||||
sse.runStream(
|
||||
flux, importTimeoutSeconds, terminated,
|
||||
event -> handleEvent(
|
||||
event, pageCount, ocrPageCount, terminated,
|
||||
onProgress, onHeartbeat, onStatus, onDone, onError),
|
||||
onError, RulesImportException::new);
|
||||
}
|
||||
|
||||
private void handleEvent(
|
||||
ServerSentEvent<String> sse,
|
||||
ServerSentEvent<String> ssEvent,
|
||||
int[] pageCount,
|
||||
int[] ocrPageCount,
|
||||
boolean[] terminated,
|
||||
@@ -175,8 +156,8 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
||||
Consumer<RulesImportResult> onDone,
|
||||
Consumer<Throwable> onError) {
|
||||
|
||||
String event = sse.event();
|
||||
String data = sse.data() == null ? "" : sse.data();
|
||||
String event = ssEvent.event();
|
||||
String data = ssEvent.data() == null ? "" : ssEvent.data();
|
||||
|
||||
if ("heartbeat".equals(event)) {
|
||||
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
|
||||
@@ -188,23 +169,17 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
||||
if ("status".equals(event)) {
|
||||
// Message d'attente lisible (retry sur fournisseur saturé, morceau
|
||||
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
|
||||
onStatus.accept(readMessage(data));
|
||||
onStatus.accept(sse.readMessage(data));
|
||||
return;
|
||||
}
|
||||
if ("chunk_failed".equals(event)) {
|
||||
JsonNode node = readJson(data);
|
||||
String msg = node != null && node.hasNonNull("message")
|
||||
? node.get("message").asText() : "";
|
||||
int current = node != null ? node.path("current").asInt() : 0;
|
||||
int total = node != null ? node.path("total").asInt() : 0;
|
||||
onStatus.accept("Morceau " + current + "/" + total + " ignoré"
|
||||
+ (msg.isEmpty() ? "." : " : " + msg));
|
||||
onStatus.accept(sse.chunkFailedStatus(data));
|
||||
return;
|
||||
}
|
||||
if ("error".equals(event)) {
|
||||
terminated[0] = true;
|
||||
onError.accept(new RulesImportException(
|
||||
"Le Brain a signalé une erreur : " + readMessage(data)));
|
||||
"Le Brain a signalé une erreur : " + sse.readMessage(data)));
|
||||
return;
|
||||
}
|
||||
if ("extracting".equals(event)) {
|
||||
@@ -213,7 +188,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
||||
return;
|
||||
}
|
||||
|
||||
JsonNode node = readJson(data);
|
||||
JsonNode node = sse.readJson(data);
|
||||
if (node == null) return;
|
||||
|
||||
if ("start".equals(event)) {
|
||||
@@ -239,32 +214,6 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
||||
|
||||
// --- Helpers -------------------------------------------------------------
|
||||
|
||||
/** ByteArrayResource avec nom de fichier : sans nom, l'upload n'est pas vu comme un fichier. */
|
||||
private ByteArrayResource filePart(byte[] pdfBytes, String filename) {
|
||||
return new ByteArrayResource(pdfBytes) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return (filename == null || filename.isBlank()) ? "rules.pdf" : filename;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private JsonNode readJson(String data) {
|
||||
try {
|
||||
return objectMapper.readTree(data);
|
||||
} catch (Exception e) {
|
||||
return null; // morceau de flux non-JSON inattendu : on l'ignore.
|
||||
}
|
||||
}
|
||||
|
||||
private String readMessage(String data) {
|
||||
JsonNode node = readJson(data);
|
||||
if (node != null && node.hasNonNull("message")) {
|
||||
return node.get("message").asText();
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private List<String> toStringList(JsonNode array) {
|
||||
List<String> out = new ArrayList<>();
|
||||
if (array != null && array.isArray()) {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.loremind.infrastructure.ai;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.http.codec.ServerSentEvent;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Helper d'infrastructure mutualisé entre les clients d'import SSE du Brain
|
||||
* ({@link BrainRulesImportClient} et {@link BrainCampaignImportClient}), qui
|
||||
* partagent la même mécanique de transport (multipart → flux SSE WebClient) et
|
||||
* les mêmes événements transverses (heartbeat / status / chunk_failed).
|
||||
* <p>
|
||||
* Volontairement instancié en interne par chaque client (et non injecté) pour
|
||||
* préserver leurs signatures de constructeur. Le parsing métier des événements
|
||||
* {@code start} / {@code progress} / {@code done} reste propre à chaque client.
|
||||
*/
|
||||
final class BrainSseImportSupport {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
BrainSseImportSupport(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/** ByteArrayResource avec nom de fichier : sans nom, l'upload n'est pas vu comme un fichier. */
|
||||
ByteArrayResource filePart(byte[] bytes, String filename, String defaultName) {
|
||||
return new ByteArrayResource(bytes) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return (filename == null || filename.isBlank()) ? defaultName : filename;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse le JSON, ou {@code null} si illisible (morceau de flux non-JSON inattendu : ignoré). */
|
||||
JsonNode readJson(String data) {
|
||||
try {
|
||||
return objectMapper.readTree(data);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Champ {@code message} du JSON, ou la {@code data} brute si non-JSON / champ absent. */
|
||||
String readMessage(String data) {
|
||||
JsonNode node = readJson(data);
|
||||
if (node != null && node.hasNonNull("message")) {
|
||||
return node.get("message").asText();
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Statut lisible « Morceau x/y ignoré[ : message] » depuis un payload {@code chunk_failed}. */
|
||||
String chunkFailedStatus(String data) {
|
||||
JsonNode node = readJson(data);
|
||||
String msg = node != null && node.hasNonNull("message")
|
||||
? node.get("message").asText() : "";
|
||||
int current = node != null ? node.path("current").asInt() : 0;
|
||||
int total = node != null ? node.path("total").asInt() : 0;
|
||||
return "Morceau " + current + "/" + total + " ignoré"
|
||||
+ (msg.isEmpty() ? "." : " : " + msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consomme le flux SSE jusqu'au bout ({@code blockLast}) en dispatchant chaque
|
||||
* événement vers {@code handler}, et traduit les fins anormales en {@code onError} :
|
||||
* <ul>
|
||||
* <li>flux clos sans event {@code done}/{@code error} ({@code terminated[0]==false}) ;</li>
|
||||
* <li>exception de transport (timeout WebClient, connexion coupée, réponse non-2xx)
|
||||
* — la cause réelle (type + message) est exposée dans le message.</li>
|
||||
* </ul>
|
||||
* {@code errorFactory} fabrique l'exception de domaine à partir d'un message et
|
||||
* d'une cause (nullable pour l'interruption silencieuse).
|
||||
*/
|
||||
void runStream(
|
||||
Flux<ServerSentEvent<String>> flux,
|
||||
long timeoutSeconds,
|
||||
boolean[] terminated,
|
||||
Consumer<ServerSentEvent<String>> handler,
|
||||
Consumer<Throwable> onError,
|
||||
BiFunction<String, Throwable, ? extends RuntimeException> errorFactory) {
|
||||
try {
|
||||
flux
|
||||
.timeout(Duration.ofSeconds(timeoutSeconds))
|
||||
.doOnNext(handler)
|
||||
.blockLast();
|
||||
// Flux terminé sans event done/error (ex: connexion coupée) → on signale.
|
||||
if (!terminated[0]) {
|
||||
onError.accept(errorFactory.apply(
|
||||
"Le flux d'import s'est interrompu avant la fin.", null));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (!terminated[0]) {
|
||||
String cause = e.getClass().getSimpleName()
|
||||
+ (e.getMessage() != null ? " — " + e.getMessage() : "");
|
||||
onError.accept(errorFactory.apply(
|
||||
"Erreur lors du streaming d'import depuis le Brain : " + cause, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.loremind.infrastructure.transfer;
|
||||
|
||||
import com.loremind.domain.campaigncontext.ArcType;
|
||||
import com.loremind.domain.campaigncontext.Prerequisite;
|
||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Logique de remapping des identifiants pour l'import en mode FUSION
|
||||
* (cf. {@link ImportService}).
|
||||
* <p>
|
||||
* Fonctions PURES (sans état ni I/O) : chaque entité importée reçoit un nouvel id,
|
||||
* et toutes les références — FK {@code Long} comme refs faibles stockées en
|
||||
* {@code String} — sont réécrites {@code oldId → newId} via les maps fournies.
|
||||
* Une référence absente de la map est CONSERVÉE telle quelle (choix : ne jamais
|
||||
* perdre d'info, ne jamais planter sur une référence hors périmètre d'export).
|
||||
*/
|
||||
final class IdRemapper {
|
||||
|
||||
private IdRemapper() {
|
||||
}
|
||||
|
||||
/** Remap d'une FK Long : si absente de la map, on garde l'ancienne valeur ; {@code null → null}. */
|
||||
static Long remapId(Map<Long, Long> map, Long oldId) {
|
||||
if (oldId == null) return null;
|
||||
return map.getOrDefault(oldId, oldId);
|
||||
}
|
||||
|
||||
/** Remap d'un id stocké en String ({@code "oldLong" → "newLong"}) via une map Long. */
|
||||
static String remapStringId(Map<Long, Long> map, String oldId) {
|
||||
if (oldId == null || oldId.isBlank()) return oldId;
|
||||
try {
|
||||
Long newId = map.get(Long.parseLong(oldId.trim()));
|
||||
return newId != null ? String.valueOf(newId) : oldId;
|
||||
} catch (NumberFormatException ex) {
|
||||
return oldId; // pas un Long : on laisse tel quel
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> remapStringList(Map<Long, Long> map, List<String> ids) {
|
||||
if (ids == null) return null;
|
||||
List<String> out = new ArrayList<>(ids.size());
|
||||
for (String id : ids) out.add(remapStringId(map, id));
|
||||
return out;
|
||||
}
|
||||
|
||||
static List<Prerequisite> remapPrerequisites(Map<Long, Long> chapterMap, List<Prerequisite> prereqs) {
|
||||
if (prereqs == null) return null;
|
||||
List<Prerequisite> out = new ArrayList<>(prereqs.size());
|
||||
for (Prerequisite p : prereqs) {
|
||||
if (p instanceof Prerequisite.QuestCompleted qc) {
|
||||
out.add(new Prerequisite.QuestCompleted(remapStringId(chapterMap, qc.questId())));
|
||||
} else {
|
||||
out.add(p); // FlagSet / SessionReached : inchangés
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static List<SceneBranch> remapBranches(Map<Long, Long> sceneMap, List<SceneBranch> branches) {
|
||||
if (branches == null) return null;
|
||||
List<SceneBranch> out = new ArrayList<>(branches.size());
|
||||
for (SceneBranch b : branches) {
|
||||
out.add(new SceneBranch(b.label(), remapStringId(sceneMap, b.targetSceneId()), b.condition()));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Parse un {@link ArcType} tolérant : type inconnu ou {@code null} → {@code LINEAR}. */
|
||||
static ArcType parseArcType(String type) {
|
||||
if (type == null) return ArcType.LINEAR;
|
||||
try {
|
||||
return ArcType.valueOf(type);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return ArcType.LINEAR;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.loremind.infrastructure.transfer;
|
||||
|
||||
import com.loremind.domain.images.ports.ImageStorage;
|
||||
import com.loremind.infrastructure.persistence.entity.ImageJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.ImageJpaRepository;
|
||||
import com.loremind.infrastructure.transfer.dto.ContentExport;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Réécriture des images lors d'un import (cf. {@link ImportService}).
|
||||
* <p>
|
||||
* Les binaires sont stockés sous LEUR CLÉ D'ORIGINE (pas de remapping de clé) :
|
||||
* une image dont la clé existe déjà est RÉUTILISÉE (pas de réupload), pour éviter
|
||||
* les doublons quand on agrège plusieurs exports dans la même base.
|
||||
*/
|
||||
@Component
|
||||
class ImageImporter {
|
||||
|
||||
private final ImageJpaRepository imageRepo;
|
||||
private final ImageStorage imageStorage;
|
||||
|
||||
ImageImporter(ImageJpaRepository imageRepo, ImageStorage imageStorage) {
|
||||
this.imageRepo = imageRepo;
|
||||
this.imageStorage = imageStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Réécrit les binaires d'images (clé préservée) et leurs métadonnées.
|
||||
*
|
||||
* @param export contenu importé (source des métadonnées par clé)
|
||||
* @param imageBinaries {@code storageKey → binaire} lus depuis le zip
|
||||
* @param result compteurs d'images (uploadées / réutilisées) à incrémenter
|
||||
*/
|
||||
void importImages(ContentExport export,
|
||||
Map<String, byte[]> imageBinaries,
|
||||
ImportResult.Builder result) {
|
||||
// Index des métadonnées d'image par clé (depuis le data.json).
|
||||
Map<String, ContentExport.ImageDto> metaByKey = new HashMap<>();
|
||||
for (ContentExport.ImageDto img : nullSafe(export.images())) {
|
||||
if (img.storageKey() != null) metaByKey.put(img.storageKey(), img);
|
||||
}
|
||||
|
||||
for (Map.Entry<String, byte[]> bin : imageBinaries.entrySet()) {
|
||||
String storageKey = bin.getKey();
|
||||
byte[] data = bin.getValue();
|
||||
if (imageRepo.findByStorageKey(storageKey).isPresent()) {
|
||||
// Image déjà présente : on réutilise, pas de réupload (éviter doublon).
|
||||
result.imageReused();
|
||||
continue;
|
||||
}
|
||||
ContentExport.ImageDto meta = metaByKey.get(storageKey);
|
||||
String contentType = meta != null && meta.contentType() != null
|
||||
? meta.contentType() : guessContentType(storageKey);
|
||||
long size = meta != null ? meta.sizeBytes() : data.length;
|
||||
|
||||
imageStorage.store(storageKey, contentType, new ByteArrayInputStream(data), data.length);
|
||||
|
||||
ImageJpaEntity e = new ImageJpaEntity();
|
||||
e.setFilename(meta != null && meta.filename() != null
|
||||
? meta.filename() : fileNameOf(storageKey));
|
||||
e.setContentType(contentType);
|
||||
e.setSizeBytes(size);
|
||||
e.setStorageKey(storageKey);
|
||||
imageRepo.save(e);
|
||||
result.imageUploaded();
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> List<T> nullSafe(List<T> list) {
|
||||
return list != null ? list : List.of();
|
||||
}
|
||||
|
||||
private static String fileNameOf(String storageKey) {
|
||||
int slash = storageKey.lastIndexOf('/');
|
||||
return slash >= 0 ? storageKey.substring(slash + 1) : storageKey;
|
||||
}
|
||||
|
||||
private static String guessContentType(String storageKey) {
|
||||
String lower = storageKey.toLowerCase();
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package com.loremind.infrastructure.transfer;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.loremind.domain.campaigncontext.Prerequisite;
|
||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
||||
import com.loremind.domain.images.ports.ImageStorage;
|
||||
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
|
||||
import com.loremind.infrastructure.persistence.entity.*;
|
||||
import com.loremind.infrastructure.persistence.jpa.*;
|
||||
@@ -11,7 +8,6 @@ import com.loremind.infrastructure.transfer.dto.ContentExport;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -65,8 +61,7 @@ public class ImportService {
|
||||
private final EnemyJpaRepository enemyRepo;
|
||||
private final ItemCatalogJpaRepository itemCatalogRepo;
|
||||
private final RandomTableJpaRepository randomTableRepo;
|
||||
private final ImageJpaRepository imageRepo;
|
||||
private final ImageStorage imageStorage;
|
||||
private final ImageImporter imageImporter;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ImportService(GameSystemJpaRepository gameSystemRepo,
|
||||
@@ -83,8 +78,7 @@ public class ImportService {
|
||||
EnemyJpaRepository enemyRepo,
|
||||
ItemCatalogJpaRepository itemCatalogRepo,
|
||||
RandomTableJpaRepository randomTableRepo,
|
||||
ImageJpaRepository imageRepo,
|
||||
ImageStorage imageStorage,
|
||||
ImageImporter imageImporter,
|
||||
ObjectMapper objectMapper) {
|
||||
this.gameSystemRepo = gameSystemRepo;
|
||||
this.loreRepo = loreRepo;
|
||||
@@ -100,42 +94,20 @@ public class ImportService {
|
||||
this.enemyRepo = enemyRepo;
|
||||
this.itemCatalogRepo = itemCatalogRepo;
|
||||
this.randomTableRepo = randomTableRepo;
|
||||
this.imageRepo = imageRepo;
|
||||
this.imageStorage = imageStorage;
|
||||
this.imageImporter = imageImporter;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ImportResult importZip(InputStream zipStream) {
|
||||
// 1. Parse du zip.
|
||||
ContentExport export = null;
|
||||
Map<String, byte[]> imageBinaries = new LinkedHashMap<>(); // storageKey -> binaire
|
||||
try (ZipInputStream zip = new ZipInputStream(zipStream)) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zip.getNextEntry()) != null) {
|
||||
String name = entry.getName();
|
||||
if ("data.json".equals(name)) {
|
||||
export = objectMapper.readValue(readAll(zip), ContentExport.class);
|
||||
} else if (name.startsWith("images/") && !entry.isDirectory()) {
|
||||
// La cle de stockage est le chemin sans le prefixe "images/" du zip,
|
||||
// c'est-a-dire EXACTEMENT le storageKey d'origine ("images/UUID.ext").
|
||||
String storageKey = name.substring("images/".length());
|
||||
imageBinaries.put(storageKey, readAll(zip));
|
||||
}
|
||||
// manifest.json : ignore a l'import (info seulement).
|
||||
zip.closeEntry();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Echec de lecture du zip d'import", e);
|
||||
}
|
||||
if (export == null) {
|
||||
throw new IllegalArgumentException("Archive invalide : data.json introuvable");
|
||||
}
|
||||
ParsedArchive archive = parseArchive(zipStream);
|
||||
ContentExport export = archive.export();
|
||||
|
||||
ImportResult.Builder result = new ImportResult.Builder();
|
||||
|
||||
// 2. Reecriture des images (cle preservee).
|
||||
importImages(export, imageBinaries, result);
|
||||
imageImporter.importImages(export, archive.imageBinaries(), result);
|
||||
|
||||
// 3. Insertion top-down + maps de remapping.
|
||||
Map<Long, Long> gameSystemMap = new HashMap<>();
|
||||
@@ -184,7 +156,7 @@ public class ImportService {
|
||||
e.setName(d.name());
|
||||
e.setIcon(d.icon());
|
||||
e.setParentId(d.parentId()); // remappe plus bas
|
||||
e.setLoreId(remapRequired(loreMap, d.loreId()));
|
||||
e.setLoreId(IdRemapper.remapId(loreMap, d.loreId()));
|
||||
LoreNodeJpaEntity saved = loreNodeRepo.save(e);
|
||||
loreNodeMap.put(d.id(), saved.getId());
|
||||
if (d.parentId() != null) loreNodesToFix.add(saved);
|
||||
@@ -195,7 +167,7 @@ public class ImportService {
|
||||
List<ContentExport.TemplateDto> templatesWithDefaultNode = new ArrayList<>();
|
||||
for (ContentExport.TemplateDto d : nullSafe(export.templates())) {
|
||||
TemplateJpaEntity e = new TemplateJpaEntity();
|
||||
e.setLoreId(remapRequired(loreMap, d.loreId()));
|
||||
e.setLoreId(IdRemapper.remapId(loreMap, d.loreId()));
|
||||
e.setName(d.name());
|
||||
e.setDescription(d.description());
|
||||
e.setDefaultNodeId(d.defaultNodeId()); // remappe plus bas
|
||||
@@ -208,9 +180,9 @@ public class ImportService {
|
||||
// -- Page (relatedPageIds remappe en 2e passe)
|
||||
for (ContentExport.PageDto d : nullSafe(export.pages())) {
|
||||
PageJpaEntity e = new PageJpaEntity();
|
||||
e.setLoreId(remapRequired(loreMap, d.loreId()));
|
||||
e.setNodeId(remapRequired(loreNodeMap, d.nodeId()));
|
||||
e.setTemplateId(remapNullable(templateMap, d.templateId()));
|
||||
e.setLoreId(IdRemapper.remapId(loreMap, d.loreId()));
|
||||
e.setNodeId(IdRemapper.remapId(loreNodeMap, d.nodeId()));
|
||||
e.setTemplateId(IdRemapper.remapId(templateMap, d.templateId()));
|
||||
e.setTitle(d.title());
|
||||
e.setValues(d.values());
|
||||
e.setImageValues(d.imageValues());
|
||||
@@ -240,9 +212,9 @@ public class ImportService {
|
||||
ArcJpaEntity e = new ArcJpaEntity();
|
||||
e.setName(d.name());
|
||||
e.setDescription(d.description());
|
||||
e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
|
||||
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
|
||||
e.setOrder(d.order());
|
||||
e.setType(parseArcType(d.type()));
|
||||
e.setType(IdRemapper.parseArcType(d.type()));
|
||||
e.setIcon(d.icon());
|
||||
e.setThemes(d.themes());
|
||||
e.setStakes(d.stakes());
|
||||
@@ -263,7 +235,7 @@ public class ImportService {
|
||||
e.setName(d.name());
|
||||
e.setDescription(d.description());
|
||||
e.setIcon(d.icon());
|
||||
e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
|
||||
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
|
||||
e.setOrder(d.order());
|
||||
List<CatalogItemJpaEntity> items = new ArrayList<>();
|
||||
for (ContentExport.CatalogItemDto i : nullSafe(d.items())) {
|
||||
@@ -292,7 +264,7 @@ public class ImportService {
|
||||
e.setDescription(d.description());
|
||||
e.setDiceFormula(d.diceFormula());
|
||||
e.setIcon(d.icon());
|
||||
e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
|
||||
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
|
||||
e.setOrder(d.order());
|
||||
List<RandomTableEntryJpaEntity> entries = new ArrayList<>();
|
||||
for (ContentExport.RandomTableEntryDto en : nullSafe(d.entries())) {
|
||||
@@ -318,7 +290,7 @@ public class ImportService {
|
||||
ChapterJpaEntity e = new ChapterJpaEntity();
|
||||
e.setName(d.name());
|
||||
e.setDescription(d.description());
|
||||
e.setArcId(remapRequired(arcMap, d.arcId()));
|
||||
e.setArcId(IdRemapper.remapId(arcMap, d.arcId()));
|
||||
e.setOrder(d.order());
|
||||
e.setPrerequisites(PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson())); // remappe plus bas
|
||||
e.setIcon(d.icon());
|
||||
@@ -341,7 +313,7 @@ public class ImportService {
|
||||
e.setValues(d.values());
|
||||
e.setImageValues(d.imageValues());
|
||||
e.setKeyValueValues(d.keyValueValues());
|
||||
e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
|
||||
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
|
||||
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
|
||||
e.setFolder(d.folder());
|
||||
e.setOrder(d.order());
|
||||
@@ -360,7 +332,7 @@ public class ImportService {
|
||||
e.setValues(d.values());
|
||||
e.setImageValues(d.imageValues());
|
||||
e.setKeyValueValues(d.keyValueValues());
|
||||
e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
|
||||
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
|
||||
e.setOrder(d.order());
|
||||
enemyMap.put(d.id(), enemyRepo.save(e).getId());
|
||||
}
|
||||
@@ -375,7 +347,7 @@ public class ImportService {
|
||||
e.setValues(d.values());
|
||||
e.setImageValues(d.imageValues());
|
||||
e.setKeyValueValues(d.keyValueValues());
|
||||
e.setCampaignId(remapNullable(campaignMap, d.campaignId()));
|
||||
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
|
||||
e.setPlaythroughId(null); // Playthrough hors perimetre d'export
|
||||
e.setOrder(d.order());
|
||||
characterMap.put(d.id(), characterRepo.save(e).getId());
|
||||
@@ -387,7 +359,7 @@ public class ImportService {
|
||||
SceneJpaEntity e = new SceneJpaEntity();
|
||||
e.setName(d.name());
|
||||
e.setDescription(d.description());
|
||||
e.setChapterId(remapRequired(chapterMap, d.chapterId()));
|
||||
e.setChapterId(IdRemapper.remapId(chapterMap, d.chapterId()));
|
||||
e.setOrder(d.order());
|
||||
e.setIcon(d.icon());
|
||||
e.setLocation(d.location());
|
||||
@@ -434,8 +406,8 @@ public class ImportService {
|
||||
// Campaign.loreId & gameSystemId (refs faibles String -> remap via maps Long).
|
||||
for (Long newCampaignId : campaignMap.values()) {
|
||||
campaignRepo.findById(newCampaignId).ifPresent(c -> {
|
||||
String newLore = remapStringId(loreMap, c.getLoreId());
|
||||
String newGs = remapStringId(gameSystemMap, c.getGameSystemId());
|
||||
String newLore = IdRemapper.remapStringId(loreMap, c.getLoreId());
|
||||
String newGs = IdRemapper.remapStringId(gameSystemMap, c.getGameSystemId());
|
||||
c.setLoreId(newLore);
|
||||
c.setGameSystemId(newGs);
|
||||
campaignRepo.save(c);
|
||||
@@ -445,7 +417,7 @@ public class ImportService {
|
||||
// Page.relatedPageIds
|
||||
for (Long newPageId : pageMap.values()) {
|
||||
pageRepo.findById(newPageId).ifPresent(p -> {
|
||||
p.setRelatedPageIds(remapStringList(pageMap, p.getRelatedPageIds()));
|
||||
p.setRelatedPageIds(IdRemapper.remapStringList(pageMap, p.getRelatedPageIds()));
|
||||
pageRepo.save(p);
|
||||
});
|
||||
}
|
||||
@@ -453,7 +425,7 @@ public class ImportService {
|
||||
// Arc.relatedPageIds
|
||||
for (Long newArcId : arcMap.values()) {
|
||||
arcRepo.findById(newArcId).ifPresent(a -> {
|
||||
a.setRelatedPageIds(remapStringList(pageMap, a.getRelatedPageIds()));
|
||||
a.setRelatedPageIds(IdRemapper.remapStringList(pageMap, a.getRelatedPageIds()));
|
||||
arcRepo.save(a);
|
||||
});
|
||||
}
|
||||
@@ -461,8 +433,8 @@ public class ImportService {
|
||||
// Chapter.relatedPageIds + prerequisites(QuestCompleted -> map Chapter)
|
||||
for (Long newChapterId : chapterMap.values()) {
|
||||
chapterRepo.findById(newChapterId).ifPresent(c -> {
|
||||
c.setRelatedPageIds(remapStringList(pageMap, c.getRelatedPageIds()));
|
||||
c.setPrerequisites(remapPrerequisites(chapterMap, c.getPrerequisites()));
|
||||
c.setRelatedPageIds(IdRemapper.remapStringList(pageMap, c.getRelatedPageIds()));
|
||||
c.setPrerequisites(IdRemapper.remapPrerequisites(chapterMap, c.getPrerequisites()));
|
||||
chapterRepo.save(c);
|
||||
});
|
||||
}
|
||||
@@ -470,7 +442,7 @@ public class ImportService {
|
||||
// Npc.relatedPageIds
|
||||
for (Long newNpcId : npcMap.values()) {
|
||||
npcRepo.findById(newNpcId).ifPresent(n -> {
|
||||
n.setRelatedPageIds(remapStringList(pageMap, n.getRelatedPageIds()));
|
||||
n.setRelatedPageIds(IdRemapper.remapStringList(pageMap, n.getRelatedPageIds()));
|
||||
npcRepo.save(n);
|
||||
});
|
||||
}
|
||||
@@ -478,9 +450,9 @@ public class ImportService {
|
||||
// Scene.relatedPageIds + enemyIds(map Enemy) + branches.targetSceneId(map Scene)
|
||||
for (Long newSceneId : sceneMap.values()) {
|
||||
sceneRepo.findById(newSceneId).ifPresent(s -> {
|
||||
s.setRelatedPageIds(remapStringList(pageMap, s.getRelatedPageIds()));
|
||||
s.setEnemyIds(remapStringList(enemyMap, s.getEnemyIds()));
|
||||
s.setBranches(remapBranches(sceneMap, s.getBranches()));
|
||||
s.setRelatedPageIds(IdRemapper.remapStringList(pageMap, s.getRelatedPageIds()));
|
||||
s.setEnemyIds(IdRemapper.remapStringList(enemyMap, s.getEnemyIds()));
|
||||
s.setBranches(IdRemapper.remapBranches(sceneMap, s.getBranches()));
|
||||
sceneRepo.save(s);
|
||||
});
|
||||
}
|
||||
@@ -488,104 +460,40 @@ public class ImportService {
|
||||
return result.build();
|
||||
}
|
||||
|
||||
// ----- Images -----
|
||||
// ----- Lecture de l'archive -----
|
||||
|
||||
private void importImages(ContentExport export,
|
||||
Map<String, byte[]> imageBinaries,
|
||||
ImportResult.Builder result) {
|
||||
// Index des metadonnees d'image par cle (depuis le data.json).
|
||||
Map<String, ContentExport.ImageDto> metaByKey = new HashMap<>();
|
||||
for (ContentExport.ImageDto img : nullSafe(export.images())) {
|
||||
if (img.storageKey() != null) metaByKey.put(img.storageKey(), img);
|
||||
}
|
||||
/** Contenu déballé d'un zip d'import : le {@code data.json} parsé + les binaires d'images. */
|
||||
private record ParsedArchive(ContentExport export, Map<String, byte[]> imageBinaries) {
|
||||
}
|
||||
|
||||
for (Map.Entry<String, byte[]> bin : imageBinaries.entrySet()) {
|
||||
String storageKey = bin.getKey();
|
||||
byte[] data = bin.getValue();
|
||||
if (imageRepo.findByStorageKey(storageKey).isPresent()) {
|
||||
// Image deja presente : on reutilise, pas de reupload (eviter doublon).
|
||||
result.imageReused();
|
||||
continue;
|
||||
/**
|
||||
* Déballe le zip : {@code data.json → ContentExport} et {@code images/<clé> → binaire}
|
||||
* (le {@code manifest.json} est ignoré, info seulement). Lève si {@code data.json} manque.
|
||||
*/
|
||||
private ParsedArchive parseArchive(InputStream zipStream) {
|
||||
ContentExport export = null;
|
||||
Map<String, byte[]> imageBinaries = new LinkedHashMap<>(); // storageKey -> binaire
|
||||
try (ZipInputStream zip = new ZipInputStream(zipStream)) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zip.getNextEntry()) != null) {
|
||||
String name = entry.getName();
|
||||
if ("data.json".equals(name)) {
|
||||
export = objectMapper.readValue(readAll(zip), ContentExport.class);
|
||||
} else if (name.startsWith("images/") && !entry.isDirectory()) {
|
||||
// La cle de stockage est le chemin sans le prefixe "images/" du zip,
|
||||
// c'est-a-dire EXACTEMENT le storageKey d'origine ("images/UUID.ext").
|
||||
String storageKey = name.substring("images/".length());
|
||||
imageBinaries.put(storageKey, readAll(zip));
|
||||
}
|
||||
zip.closeEntry();
|
||||
}
|
||||
ContentExport.ImageDto meta = metaByKey.get(storageKey);
|
||||
String contentType = meta != null && meta.contentType() != null
|
||||
? meta.contentType() : guessContentType(storageKey);
|
||||
long size = meta != null ? meta.sizeBytes() : data.length;
|
||||
|
||||
imageStorage.store(storageKey, contentType, new ByteArrayInputStream(data), data.length);
|
||||
|
||||
ImageJpaEntity e = new ImageJpaEntity();
|
||||
e.setFilename(meta != null && meta.filename() != null
|
||||
? meta.filename() : fileNameOf(storageKey));
|
||||
e.setContentType(contentType);
|
||||
e.setSizeBytes(size);
|
||||
e.setStorageKey(storageKey);
|
||||
imageRepo.save(e);
|
||||
result.imageUploaded();
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Echec de lecture du zip d'import", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Helpers de remapping -----
|
||||
|
||||
/** Remap obligatoire d'une FK Long : si absente de la map, on garde l'ancienne valeur. */
|
||||
private Long remapRequired(Map<Long, Long> map, Long oldId) {
|
||||
if (oldId == null) return null;
|
||||
return map.getOrDefault(oldId, oldId);
|
||||
}
|
||||
|
||||
/** Remap d'une FK Long nullable : null reste null. */
|
||||
private Long remapNullable(Map<Long, Long> map, Long oldId) {
|
||||
if (oldId == null) return null;
|
||||
return map.getOrDefault(oldId, oldId);
|
||||
}
|
||||
|
||||
/** Remap d'un id stocke en String ("oldLong" -> "newLong") via une map Long. */
|
||||
private String remapStringId(Map<Long, Long> map, String oldId) {
|
||||
if (oldId == null || oldId.isBlank()) return oldId;
|
||||
try {
|
||||
Long newId = map.get(Long.parseLong(oldId.trim()));
|
||||
return newId != null ? String.valueOf(newId) : oldId;
|
||||
} catch (NumberFormatException ex) {
|
||||
return oldId; // pas un Long : on laisse tel quel
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> remapStringList(Map<Long, Long> map, List<String> ids) {
|
||||
if (ids == null) return null;
|
||||
List<String> out = new ArrayList<>(ids.size());
|
||||
for (String id : ids) out.add(remapStringId(map, id));
|
||||
return out;
|
||||
}
|
||||
|
||||
private List<Prerequisite> remapPrerequisites(Map<Long, Long> chapterMap, List<Prerequisite> prereqs) {
|
||||
if (prereqs == null) return null;
|
||||
List<Prerequisite> out = new ArrayList<>(prereqs.size());
|
||||
for (Prerequisite p : prereqs) {
|
||||
if (p instanceof Prerequisite.QuestCompleted qc) {
|
||||
out.add(new Prerequisite.QuestCompleted(remapStringId(chapterMap, qc.questId())));
|
||||
} else {
|
||||
out.add(p); // FlagSet / SessionReached : inchanges
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private List<SceneBranch> remapBranches(Map<Long, Long> sceneMap, List<SceneBranch> branches) {
|
||||
if (branches == null) return null;
|
||||
List<SceneBranch> out = new ArrayList<>(branches.size());
|
||||
for (SceneBranch b : branches) {
|
||||
out.add(new SceneBranch(b.label(), remapStringId(sceneMap, b.targetSceneId()), b.condition()));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private com.loremind.domain.campaigncontext.ArcType parseArcType(String type) {
|
||||
if (type == null) return com.loremind.domain.campaigncontext.ArcType.LINEAR;
|
||||
try {
|
||||
return com.loremind.domain.campaigncontext.ArcType.valueOf(type);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return com.loremind.domain.campaigncontext.ArcType.LINEAR;
|
||||
if (export == null) {
|
||||
throw new IllegalArgumentException("Archive invalide : data.json introuvable");
|
||||
}
|
||||
return new ParsedArchive(export, imageBinaries);
|
||||
}
|
||||
|
||||
// ----- Helpers divers -----
|
||||
@@ -599,18 +507,4 @@ public class ImportService {
|
||||
in.transferTo(buffer);
|
||||
return buffer.toByteArray();
|
||||
}
|
||||
|
||||
private static String fileNameOf(String storageKey) {
|
||||
int slash = storageKey.lastIndexOf('/');
|
||||
return slash >= 0 ? storageKey.substring(slash + 1) : storageKey;
|
||||
}
|
||||
|
||||
private static String guessContentType(String storageKey) {
|
||||
String lower = storageKey.toLowerCase();
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.loremind.infrastructure.updates;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Parsing et comparaison de versions semver (stratégie de {@link UpdateCheckService}).
|
||||
* <p>
|
||||
* Volontairement tolérant : préfixe {@code v}/{@code V} accepté, pré-release
|
||||
* ({@code -beta.1}) et build metadata ({@code +build.42}) strippés avant comparaison.
|
||||
* Un tag non parsable est simplement ignoré (jamais d'exception). Fonctions PURES.
|
||||
*/
|
||||
final class SemverComparator {
|
||||
|
||||
private SemverComparator() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parcourt la liste des tags, garde uniquement ceux qui parsent en semver
|
||||
* (1 à 3 chiffres séparés par des points, préfixe {@code v} optionnel),
|
||||
* retourne le plus élevé, ou {@code null} si aucun n'est valide.
|
||||
*/
|
||||
@Nullable
|
||||
static String findMaxSemver(List<String> tags) {
|
||||
String maxTag = null;
|
||||
int[] maxParts = null;
|
||||
for (String t : tags) {
|
||||
if (t == null || t.isBlank()) continue;
|
||||
int[] parts = parseSemver(t);
|
||||
if (parts == null) continue;
|
||||
if (maxParts == null || compareParts(parts, maxParts) > 0) {
|
||||
maxParts = parts;
|
||||
maxTag = t;
|
||||
}
|
||||
}
|
||||
return maxTag;
|
||||
}
|
||||
|
||||
/** @return {@code [major, minor, patch]} ou {@code null} si non parsable. */
|
||||
@Nullable
|
||||
static int[] parseSemver(String tag) {
|
||||
if (tag == null) return null;
|
||||
String s = tag.trim();
|
||||
if (s.isEmpty()) return null;
|
||||
if (s.startsWith("v") || s.startsWith("V")) s = s.substring(1);
|
||||
int dashIdx = s.indexOf('-');
|
||||
if (dashIdx > 0) s = s.substring(0, dashIdx);
|
||||
int plusIdx = s.indexOf('+');
|
||||
if (plusIdx > 0) s = s.substring(0, plusIdx);
|
||||
String[] parts = s.split("\\.");
|
||||
if (parts.length < 1 || parts.length > 3) return null;
|
||||
int[] result = new int[]{0, 0, 0};
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
try {
|
||||
int v = Integer.parseInt(parts[i]);
|
||||
if (v < 0) return null;
|
||||
result[i] = v;
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Compare deux versions semver brutes (préfixe toléré). Négatif si {@code a < b}. */
|
||||
static int compareSemver(String a, String b) {
|
||||
int[] aParts = parseSemver(a);
|
||||
int[] bParts = parseSemver(b);
|
||||
if (aParts == null || bParts == null) return 0;
|
||||
return compareParts(aParts, bParts);
|
||||
}
|
||||
|
||||
private static int compareParts(int[] a, int[] b) {
|
||||
for (int i = 0; i < 3; i++) {
|
||||
int diff = Integer.compare(a[i], b[i]);
|
||||
if (diff != 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -114,32 +113,9 @@ public class UpdateCheckService {
|
||||
return new UpdateStatus(true, false, true, null, statuses, Instant.now());
|
||||
}
|
||||
|
||||
List<ImageStatus> statuses = new ArrayList<>();
|
||||
boolean anyUpdate = false;
|
||||
boolean anyUnknown = false;
|
||||
for (String image : images) {
|
||||
String latest = null;
|
||||
try {
|
||||
latest = fetchLatestSemverTag(registry, image, null);
|
||||
} catch (Exception e) {
|
||||
log.warn("Tags fetch failed for {}: {}", image, e.getMessage());
|
||||
}
|
||||
ImageStatusKind kind;
|
||||
if (latest == null) {
|
||||
kind = ImageStatusKind.UNKNOWN;
|
||||
anyUnknown = true;
|
||||
} else {
|
||||
int cmp = compareSemver(currentVersion, latest);
|
||||
if (cmp >= 0) {
|
||||
kind = ImageStatusKind.UP_TO_DATE;
|
||||
} else {
|
||||
kind = ImageStatusKind.UPDATE_AVAILABLE;
|
||||
anyUpdate = true;
|
||||
}
|
||||
}
|
||||
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
|
||||
}
|
||||
return new UpdateStatus(true, anyUpdate, anyUnknown, currentVersion, statuses, Instant.now());
|
||||
ImagesEvaluation ev = evaluateImages(images, registry, null);
|
||||
return new UpdateStatus(true, ev.anyUpdate(), ev.anyUnknown(),
|
||||
currentVersion, ev.statuses(), Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,21 +145,37 @@ public class UpdateCheckService {
|
||||
(creds.get().username() + ":" + creds.get().password()).getBytes(StandardCharsets.UTF_8));
|
||||
String betaRegistry = normalizeRegistry(creds.get().registry());
|
||||
|
||||
ImagesEvaluation ev = evaluateImages(betaImages, betaRegistry, basicAuth);
|
||||
return new BetaStatus(true, ev.anyUpdate(), ev.anyUnknown(),
|
||||
ev.statuses(), Instant.now(), null);
|
||||
}
|
||||
|
||||
/** Résultat de l'évaluation d'une liste d'images : statuts + drapeaux agrégés. */
|
||||
private record ImagesEvaluation(List<ImageStatus> statuses, boolean anyUpdate, boolean anyUnknown) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Boucle commune à {@link #check()} et {@link #checkBeta()} : pour chaque image,
|
||||
* récupère le tag semver le plus haut et le compare à la version courante.
|
||||
* Une image dont les tags sont injoignables ou non semver → {@code UNKNOWN}.
|
||||
*/
|
||||
private ImagesEvaluation evaluateImages(List<String> imageList, String registryUrl,
|
||||
@Nullable String authHeader) {
|
||||
List<ImageStatus> statuses = new ArrayList<>();
|
||||
boolean anyUpdate = false;
|
||||
boolean anyUnknown = false;
|
||||
for (String image : betaImages) {
|
||||
for (String image : imageList) {
|
||||
String latest = null;
|
||||
try {
|
||||
latest = fetchLatestSemverTag(betaRegistry, image, basicAuth);
|
||||
latest = fetchLatestSemverTag(registryUrl, image, authHeader);
|
||||
} catch (Exception e) {
|
||||
log.warn("Beta tags fetch failed for {}: {}", image, e.getMessage());
|
||||
log.warn("Tags fetch failed for {}: {}", image, e.getMessage());
|
||||
}
|
||||
ImageStatusKind kind;
|
||||
if (latest == null) {
|
||||
kind = ImageStatusKind.UNKNOWN;
|
||||
anyUnknown = true;
|
||||
} else if (currentVersion != null && compareSemver(currentVersion, latest) >= 0) {
|
||||
} else if (currentVersion != null && SemverComparator.compareSemver(currentVersion, latest) >= 0) {
|
||||
kind = ImageStatusKind.UP_TO_DATE;
|
||||
} else {
|
||||
kind = ImageStatusKind.UPDATE_AVAILABLE;
|
||||
@@ -191,7 +183,7 @@ public class UpdateCheckService {
|
||||
}
|
||||
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
|
||||
}
|
||||
return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null);
|
||||
return new ImagesEvaluation(statuses, anyUpdate, anyUnknown);
|
||||
}
|
||||
|
||||
public void apply() {
|
||||
@@ -209,6 +201,10 @@ public class UpdateCheckService {
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Registry HTTP API v2 - tags listing + auth bearer
|
||||
//
|
||||
// NB : le RestTemplate (champ `http`) reste porté par ce service — les tests
|
||||
// l'injectent par réflexion. Le parsing semver et le parsing du challenge
|
||||
// WWW-Authenticate sont, eux, externalisés (SemverComparator, WwwAuthenticate).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -244,7 +240,7 @@ public class UpdateCheckService {
|
||||
body = tagsCall(url, bearerHeaders);
|
||||
}
|
||||
if (body == null || body.tags == null || body.tags.isEmpty()) return null;
|
||||
return findMaxSemver(body.tags);
|
||||
return SemverComparator.findMaxSemver(body.tags);
|
||||
}
|
||||
|
||||
private TagsListResponse tagsCall(String url, HttpHeaders headers) {
|
||||
@@ -253,69 +249,6 @@ public class UpdateCheckService {
|
||||
return resp.getBody();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parcourt la liste des tags, garde uniquement ceux qui parsent en semver
|
||||
* (1 a 3 chiffres separes par des points, optionnel prefix "v"), retourne le max.
|
||||
* Pre-release / build metadata sont strippes pour la comparaison.
|
||||
*/
|
||||
@Nullable
|
||||
static String findMaxSemver(List<String> tags) {
|
||||
String maxTag = null;
|
||||
int[] maxParts = null;
|
||||
for (String t : tags) {
|
||||
if (t == null || t.isBlank()) continue;
|
||||
int[] parts = parseSemver(t);
|
||||
if (parts == null) continue;
|
||||
if (maxParts == null || compareParts(parts, maxParts) > 0) {
|
||||
maxParts = parts;
|
||||
maxTag = t;
|
||||
}
|
||||
}
|
||||
return maxTag;
|
||||
}
|
||||
|
||||
/** @return [major, minor, patch] ou null si non parsable. */
|
||||
@Nullable
|
||||
static int[] parseSemver(String tag) {
|
||||
if (tag == null) return null;
|
||||
String s = tag.trim();
|
||||
if (s.isEmpty()) return null;
|
||||
if (s.startsWith("v") || s.startsWith("V")) s = s.substring(1);
|
||||
int dashIdx = s.indexOf('-');
|
||||
if (dashIdx > 0) s = s.substring(0, dashIdx);
|
||||
int plusIdx = s.indexOf('+');
|
||||
if (plusIdx > 0) s = s.substring(0, plusIdx);
|
||||
String[] parts = s.split("\\.");
|
||||
if (parts.length < 1 || parts.length > 3) return null;
|
||||
int[] result = new int[]{0, 0, 0};
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
try {
|
||||
int v = Integer.parseInt(parts[i]);
|
||||
if (v < 0) return null;
|
||||
result[i] = v;
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Compare deux versions semver brutes (sans prefix). Negatif si a < b. */
|
||||
static int compareSemver(String a, String b) {
|
||||
int[] aParts = parseSemver(a);
|
||||
int[] bParts = parseSemver(b);
|
||||
if (aParts == null || bParts == null) return 0;
|
||||
return compareParts(aParts, bParts);
|
||||
}
|
||||
|
||||
private static int compareParts(int[] a, int[] b) {
|
||||
for (int i = 0; i < 3; i++) {
|
||||
int diff = Integer.compare(a[i], b[i]);
|
||||
if (diff != 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suit le challenge {@code WWW-Authenticate: Bearer realm="..."} pour obtenir
|
||||
* un token. Si {@code basicAuth} est fourni, l'utilise pour l'echange (cas
|
||||
@@ -326,7 +259,7 @@ public class UpdateCheckService {
|
||||
if (wwwAuth == null) return null;
|
||||
String prefix = "Bearer ";
|
||||
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
|
||||
Map<String, String> params = parseAuthParams(wwwAuth.substring(prefix.length()));
|
||||
Map<String, String> params = WwwAuthenticate.parseParams(wwwAuth.substring(prefix.length()));
|
||||
String realm = params.get("realm");
|
||||
if (realm == null) return null;
|
||||
StringBuilder url = new StringBuilder(realm);
|
||||
@@ -359,34 +292,6 @@ public class UpdateCheckService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Parser minimaliste pour {@code key="value", key2="value2"}. */
|
||||
private static Map<String, String> parseAuthParams(String s) {
|
||||
Map<String, String> out = new HashMap<>();
|
||||
int i = 0;
|
||||
int n = s.length();
|
||||
while (i < n) {
|
||||
while (i < n && (s.charAt(i) == ',' || s.charAt(i) == ' ')) i++;
|
||||
int eq = s.indexOf('=', i);
|
||||
if (eq < 0) break;
|
||||
String key = s.substring(i, eq).trim();
|
||||
int valStart = eq + 1;
|
||||
String val;
|
||||
if (valStart < n && s.charAt(valStart) == '"') {
|
||||
int valEnd = s.indexOf('"', valStart + 1);
|
||||
if (valEnd < 0) break;
|
||||
val = s.substring(valStart + 1, valEnd);
|
||||
i = valEnd + 1;
|
||||
} else {
|
||||
int valEnd = s.indexOf(',', valStart);
|
||||
if (valEnd < 0) valEnd = n;
|
||||
val = s.substring(valStart, valEnd).trim();
|
||||
i = valEnd;
|
||||
}
|
||||
out.put(key, val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String normalizeRegistry(String value) {
|
||||
if (value == null || value.isBlank()) return "";
|
||||
String v = value.trim();
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.loremind.infrastructure.updates;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Parseur minimaliste des paramètres d'un challenge HTTP
|
||||
* {@code WWW-Authenticate: Bearer realm="...", service="...", scope="..."}
|
||||
* (cf. le flux de token du registry Docker dans {@link UpdateCheckService}).
|
||||
* <p>
|
||||
* Accepte les valeurs entre guillemets ({@code key="value"}) comme non quotées
|
||||
* ({@code key=value}), séparées par des virgules et/ou espaces. Fonction PURE.
|
||||
*/
|
||||
final class WwwAuthenticate {
|
||||
|
||||
private WwwAuthenticate() {
|
||||
}
|
||||
|
||||
/** Parse {@code key="value", key2=value2} en map clé→valeur (sans les guillemets). */
|
||||
static Map<String, String> parseParams(String s) {
|
||||
Map<String, String> out = new HashMap<>();
|
||||
int i = 0;
|
||||
int n = s.length();
|
||||
while (i < n) {
|
||||
while (i < n && (s.charAt(i) == ',' || s.charAt(i) == ' ')) i++;
|
||||
int eq = s.indexOf('=', i);
|
||||
if (eq < 0) break;
|
||||
String key = s.substring(i, eq).trim();
|
||||
int valStart = eq + 1;
|
||||
String val;
|
||||
if (valStart < n && s.charAt(valStart) == '"') {
|
||||
int valEnd = s.indexOf('"', valStart + 1);
|
||||
if (valEnd < 0) break;
|
||||
val = s.substring(valStart + 1, valEnd);
|
||||
i = valEnd + 1;
|
||||
} else {
|
||||
int valEnd = s.indexOf(',', valStart);
|
||||
if (valEnd < 0) valEnd = n;
|
||||
val = s.substring(valStart, valEnd).trim();
|
||||
i = valEnd;
|
||||
}
|
||||
out.put(key, val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import com.loremind.infrastructure.web.dto.generationcontext.ChatMessageDTO;
|
||||
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamCampaignRequestDTO;
|
||||
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamRequestDTO;
|
||||
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamSessionRequestDTO;
|
||||
import com.loremind.infrastructure.web.sse.SseJson;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.core.task.TaskExecutor;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -162,7 +163,7 @@ public class AiChatController {
|
||||
private void sendToken(SseEmitter emitter, String token) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event()
|
||||
.data("{\"token\":" + jsonEscape(token) + "}"));
|
||||
.data("{\"token\":" + SseJson.escape(token) + "}"));
|
||||
} catch (IOException e) {
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
@@ -182,7 +183,7 @@ public class AiChatController {
|
||||
String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
|
||||
emitter.send(SseEmitter.event()
|
||||
.name("error")
|
||||
.data("{\"message\":" + jsonEscape(message) + "}"));
|
||||
.data("{\"message\":" + SseJson.escape(message) + "}"));
|
||||
emitter.complete();
|
||||
} catch (IOException ioe) {
|
||||
emitter.completeWithError(ioe);
|
||||
@@ -191,31 +192,6 @@ public class AiChatController {
|
||||
|
||||
// --- Utilitaires --------------------------------------------------------
|
||||
|
||||
/** Encadre une chaîne de guillemets et échappe les caractères JSON dangereux. */
|
||||
private String jsonEscape(String raw) {
|
||||
if (raw == null) return "\"\"";
|
||||
StringBuilder sb = new StringBuilder(raw.length() + 2);
|
||||
sb.append('"');
|
||||
for (int i = 0; i < raw.length(); i++) {
|
||||
char c = raw.charAt(i);
|
||||
switch (c) {
|
||||
case '"': sb.append("\\\""); break;
|
||||
case '\\': sb.append("\\\\"); break;
|
||||
case '\n': sb.append("\\n"); break;
|
||||
case '\r': sb.append("\\r"); break;
|
||||
case '\t': sb.append("\\t"); break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
sb.append(String.format("\\u%04x", (int) c));
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append('"');
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private List<ChatMessage> toDomainMessages(List<ChatMessageDTO> dtos) {
|
||||
if (dtos == null) return List.of();
|
||||
return dtos.stream()
|
||||
|
||||
@@ -5,10 +5,8 @@ import com.loremind.application.gamesystemcontext.GameSystemService;
|
||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||
import com.loremind.domain.gamesystemcontext.RulesImportResult;
|
||||
import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
||||
import com.loremind.infrastructure.web.dto.gamesystemcontext.RulesImportResponseDTO;
|
||||
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
|
||||
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
|
||||
import org.slf4j.Logger;
|
||||
@@ -23,7 +21,6 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
@@ -261,18 +258,11 @@ public class GameSystemController {
|
||||
dto.getName(),
|
||||
dto.getDescription(),
|
||||
dto.getRulesMarkdown(),
|
||||
toDomainFields(dto.getCharacterTemplate()),
|
||||
toDomainFields(dto.getNpcTemplate()),
|
||||
toDomainFields(dto.getEnemyTemplate()),
|
||||
templateFieldMapper.toDomainList(dto.getCharacterTemplate()),
|
||||
templateFieldMapper.toDomainList(dto.getNpcTemplate()),
|
||||
templateFieldMapper.toDomainList(dto.getEnemyTemplate()),
|
||||
dto.getAuthor(),
|
||||
dto.isPublic()
|
||||
);
|
||||
}
|
||||
|
||||
private List<TemplateField> toDomainFields(List<TemplateFieldDTO> dtos) {
|
||||
if (dtos == null) return new ArrayList<>();
|
||||
List<TemplateField> out = new ArrayList<>(dtos.size());
|
||||
for (TemplateFieldDTO d : dtos) out.add(templateFieldMapper.toDomain(d));
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.loremind.domain.campaigncontext.Notebook;
|
||||
import com.loremind.domain.campaigncontext.NotebookSource;
|
||||
import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer;
|
||||
import com.loremind.domain.campaigncontext.ports.NotebookException;
|
||||
import com.loremind.infrastructure.web.sse.SseJson;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.core.task.TaskExecutor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -211,7 +212,7 @@ public class NotebookController {
|
||||
|
||||
private void sendToken(SseEmitter emitter, String token) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().name("token").data("{\"token\":" + jsonEscape(token) + "}"));
|
||||
emitter.send(SseEmitter.event().name("token").data("{\"token\":" + SseJson.escape(token) + "}"));
|
||||
} catch (IOException | IllegalStateException e) {
|
||||
// flux fermé/expiré : on cesse d'écrire
|
||||
}
|
||||
@@ -247,32 +248,13 @@ public class NotebookController {
|
||||
private void fail(SseEmitter emitter, Throwable error) {
|
||||
try {
|
||||
String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
|
||||
emitter.send(SseEmitter.event().name("error").data("{\"message\":" + jsonEscape(message) + "}"));
|
||||
emitter.send(SseEmitter.event().name("error").data("{\"message\":" + SseJson.escape(message) + "}"));
|
||||
emitter.complete();
|
||||
} catch (IOException | IllegalStateException e) {
|
||||
// flux déjà fermé/expiré : rien à envoyer
|
||||
}
|
||||
}
|
||||
|
||||
private String jsonEscape(String raw) {
|
||||
if (raw == null) return "\"\"";
|
||||
StringBuilder sb = new StringBuilder(raw.length() + 2).append('"');
|
||||
for (int i = 0; i < raw.length(); i++) {
|
||||
char c = raw.charAt(i);
|
||||
switch (c) {
|
||||
case '"': sb.append("\\\""); break;
|
||||
case '\\': sb.append("\\\\"); break;
|
||||
case '\n': sb.append("\\n"); break;
|
||||
case '\r': sb.append("\\r"); break;
|
||||
case '\t': sb.append("\\t"); break;
|
||||
default:
|
||||
if (c < 0x20) sb.append(String.format("\\u%04x", (int) c));
|
||||
else sb.append(c);
|
||||
}
|
||||
}
|
||||
return sb.append('"').toString();
|
||||
}
|
||||
|
||||
public record CreateRequest(String campaignId, String name) {}
|
||||
public record RenameRequest(String name) {}
|
||||
/**
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
package com.loremind.infrastructure.web.mapper;
|
||||
|
||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
||||
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class GameSystemMapper {
|
||||
|
||||
@@ -25,9 +20,9 @@ public class GameSystemMapper {
|
||||
dto.setName(g.getName());
|
||||
dto.setDescription(g.getDescription());
|
||||
dto.setRulesMarkdown(g.getRulesMarkdown());
|
||||
dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate()));
|
||||
dto.setNpcTemplate(toDTOList(g.getNpcTemplate()));
|
||||
dto.setEnemyTemplate(toDTOList(g.getEnemyTemplate()));
|
||||
dto.setCharacterTemplate(fieldMapper.toDTOList(g.getCharacterTemplate()));
|
||||
dto.setNpcTemplate(fieldMapper.toDTOList(g.getNpcTemplate()));
|
||||
dto.setEnemyTemplate(fieldMapper.toDTOList(g.getEnemyTemplate()));
|
||||
dto.setAuthor(g.getAuthor());
|
||||
dto.setPublic(g.isPublic());
|
||||
return dto;
|
||||
@@ -40,25 +35,11 @@ public class GameSystemMapper {
|
||||
.name(dto.getName())
|
||||
.description(dto.getDescription())
|
||||
.rulesMarkdown(dto.getRulesMarkdown())
|
||||
.characterTemplate(toDomainList(dto.getCharacterTemplate()))
|
||||
.npcTemplate(toDomainList(dto.getNpcTemplate()))
|
||||
.enemyTemplate(toDomainList(dto.getEnemyTemplate()))
|
||||
.characterTemplate(fieldMapper.toDomainList(dto.getCharacterTemplate()))
|
||||
.npcTemplate(fieldMapper.toDomainList(dto.getNpcTemplate()))
|
||||
.enemyTemplate(fieldMapper.toDomainList(dto.getEnemyTemplate()))
|
||||
.author(dto.getAuthor())
|
||||
.isPublic(dto.isPublic())
|
||||
.build();
|
||||
}
|
||||
|
||||
private List<TemplateFieldDTO> toDTOList(List<TemplateField> fields) {
|
||||
if (fields == null) return new ArrayList<>();
|
||||
List<TemplateFieldDTO> out = new ArrayList<>(fields.size());
|
||||
for (TemplateField f : fields) out.add(fieldMapper.toDTO(f));
|
||||
return out;
|
||||
}
|
||||
|
||||
private List<TemplateField> toDomainList(List<TemplateFieldDTO> dtos) {
|
||||
if (dtos == null) return new ArrayList<>();
|
||||
List<TemplateField> out = new ArrayList<>(dtos.size());
|
||||
for (TemplateFieldDTO d : dtos) out.add(fieldMapper.toDomain(d));
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,4 +60,20 @@ public class TemplateFieldMapper {
|
||||
}
|
||||
return new TemplateField(dto.getName(), type, layout, labels);
|
||||
}
|
||||
|
||||
/** Mappe une liste de champs domaine → DTO ({@code null} → liste vide). */
|
||||
public List<TemplateFieldDTO> toDTOList(List<TemplateField> fields) {
|
||||
if (fields == null) return new ArrayList<>();
|
||||
List<TemplateFieldDTO> out = new ArrayList<>(fields.size());
|
||||
for (TemplateField f : fields) out.add(toDTO(f));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Mappe une liste de champs DTO → domaine ({@code null} → liste vide). */
|
||||
public List<TemplateField> toDomainList(List<TemplateFieldDTO> dtos) {
|
||||
if (dtos == null) return new ArrayList<>();
|
||||
List<TemplateField> out = new ArrayList<>(dtos.size());
|
||||
for (TemplateFieldDTO d : dtos) out.add(toDomain(d));
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.loremind.infrastructure.web.sse;
|
||||
|
||||
/**
|
||||
* Petits utilitaires d'écriture JSON pour les payloads SSE bâtis à la main par
|
||||
* les controllers de streaming (chat IA, notebook…).
|
||||
* <p>
|
||||
* Volontairement minimaliste (pas de Jackson) : les payloads concernés sont des
|
||||
* objets plats à un ou deux champs ({@code {"token":"…"}}, {@code {"message":"…"}})
|
||||
* écrits dans la boucle de streaming, où l'on veut éviter l'allocation d'un
|
||||
* ObjectMapper par token.
|
||||
*/
|
||||
public final class SseJson {
|
||||
|
||||
private SseJson() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Encadre {@code raw} de guillemets et échappe les caractères JSON dangereux
|
||||
* (guillemet, antislash, retours chariot, tabulation, caractères de contrôle).
|
||||
* Renvoie {@code "\"\""} (chaîne JSON vide) si {@code raw} est {@code null}.
|
||||
*/
|
||||
public static String escape(String raw) {
|
||||
if (raw == null) return "\"\"";
|
||||
StringBuilder sb = new StringBuilder(raw.length() + 2);
|
||||
sb.append('"');
|
||||
for (int i = 0; i < raw.length(); i++) {
|
||||
char c = raw.charAt(i);
|
||||
switch (c) {
|
||||
case '"': sb.append("\\\""); break;
|
||||
case '\\': sb.append("\\\\"); break;
|
||||
case '\n': sb.append("\\n"); break;
|
||||
case '\r': sb.append("\\r"); break;
|
||||
case '\t': sb.append("\\t"); break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
sb.append(String.format("\\u%04x", (int) c));
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append('"');
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.loremind.infrastructure.transfer;
|
||||
|
||||
import com.loremind.domain.campaigncontext.ArcType;
|
||||
import com.loremind.domain.campaigncontext.Prerequisite;
|
||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
|
||||
/**
|
||||
* Tests unitaires PURS (sans Spring/DB) de {@link IdRemapper}, la logique de
|
||||
* remapping {@code oldId → newId} de l'import en mode FUSION. Verrouille les
|
||||
* invariants critiques : une référence absente de la map est CONSERVÉE, jamais
|
||||
* perdue ni remplacée par null.
|
||||
*/
|
||||
class IdRemapperTest {
|
||||
|
||||
private static final Map<Long, Long> MAP = Map.of(1L, 100L, 2L, 200L);
|
||||
|
||||
// --- remapId (FK Long) ---------------------------------------------------
|
||||
|
||||
@Test
|
||||
void remapId_null_resteNull() {
|
||||
assertNull(IdRemapper.remapId(MAP, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void remapId_present_estRemappe() {
|
||||
assertEquals(100L, IdRemapper.remapId(MAP, 1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void remapId_absent_conserveLAncienneValeur() {
|
||||
assertEquals(42L, IdRemapper.remapId(MAP, 42L));
|
||||
}
|
||||
|
||||
// --- remapStringId (ref faible String) -----------------------------------
|
||||
|
||||
@Test
|
||||
void remapStringId_null_resteNull() {
|
||||
assertNull(IdRemapper.remapStringId(MAP, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void remapStringId_blanc_resteInchange() {
|
||||
assertEquals(" ", IdRemapper.remapStringId(MAP, " "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void remapStringId_numeriquePresent_estRemappe() {
|
||||
assertEquals("200", IdRemapper.remapStringId(MAP, "2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void remapStringId_numeriqueAvecEspaces_estTrimmeEtRemappe() {
|
||||
assertEquals("100", IdRemapper.remapStringId(MAP, " 1 "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void remapStringId_numeriqueAbsent_conserveLAncien() {
|
||||
assertEquals("999", IdRemapper.remapStringId(MAP, "999"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void remapStringId_nonNumerique_resteInchange() {
|
||||
assertEquals("abc-uuid", IdRemapper.remapStringId(MAP, "abc-uuid"));
|
||||
}
|
||||
|
||||
// --- remapStringList -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
void remapStringList_null_resteNull() {
|
||||
assertNull(IdRemapper.remapStringList(MAP, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void remapStringList_remappeChaqueElement() {
|
||||
assertEquals(List.of("100", "999", "200"),
|
||||
IdRemapper.remapStringList(MAP, List.of("1", "999", "2")));
|
||||
}
|
||||
|
||||
// --- remapPrerequisites --------------------------------------------------
|
||||
|
||||
@Test
|
||||
void remapPrerequisites_null_resteNull() {
|
||||
assertNull(IdRemapper.remapPrerequisites(MAP, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void remapPrerequisites_questCompleted_remappeLeQuestId() {
|
||||
List<Prerequisite> out = IdRemapper.remapPrerequisites(
|
||||
MAP, List.of(new Prerequisite.QuestCompleted("1")));
|
||||
assertEquals("100", ((Prerequisite.QuestCompleted) out.get(0)).questId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void remapPrerequisites_autresTypes_inchanges() {
|
||||
Prerequisite flag = new Prerequisite.FlagSet("porte_ouverte");
|
||||
Prerequisite session = new Prerequisite.SessionReached(3);
|
||||
List<Prerequisite> out = IdRemapper.remapPrerequisites(MAP, List.of(flag, session));
|
||||
// FlagSet / SessionReached ne sont pas réécrits : même instance.
|
||||
assertSame(flag, out.get(0));
|
||||
assertSame(session, out.get(1));
|
||||
}
|
||||
|
||||
// --- remapBranches -------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void remapBranches_null_resteNull() {
|
||||
assertNull(IdRemapper.remapBranches(MAP, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void remapBranches_remappeTargetSceneId_etPreserveLabelEtCondition() {
|
||||
SceneBranch in = new SceneBranch("Si attaque", "2", "notes MJ");
|
||||
SceneBranch out = IdRemapper.remapBranches(MAP, List.of(in)).get(0);
|
||||
assertEquals("Si attaque", out.label());
|
||||
assertEquals("200", out.targetSceneId());
|
||||
assertEquals("notes MJ", out.condition());
|
||||
}
|
||||
|
||||
// --- parseArcType --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void parseArcType_null_donneLinear() {
|
||||
assertEquals(ArcType.LINEAR, IdRemapper.parseArcType(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseArcType_valeurValide_estParsee() {
|
||||
assertEquals(ArcType.HUB, IdRemapper.parseArcType("HUB"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseArcType_valeurInconnue_retombeSurLinear() {
|
||||
assertEquals(ArcType.LINEAR, IdRemapper.parseArcType("INEXISTANT"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.loremind.infrastructure.updates;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Tests des utilitaires semver (extraits de UpdateCheckServiceTest lors de la
|
||||
* séparation de {@link SemverComparator}).
|
||||
*/
|
||||
class SemverComparatorTest {
|
||||
|
||||
@Test
|
||||
void parseSemver_acceptsCommonFormats() {
|
||||
assertArrayEquals(new int[]{0, 8, 0}, SemverComparator.parseSemver("0.8.0"));
|
||||
assertArrayEquals(new int[]{0, 8, 0}, SemverComparator.parseSemver("v0.8.0"));
|
||||
assertArrayEquals(new int[]{1, 0, 0}, SemverComparator.parseSemver("1.0.0"));
|
||||
assertArrayEquals(new int[]{0, 8, 0}, SemverComparator.parseSemver("0.8.0-beta.1"));
|
||||
assertArrayEquals(new int[]{0, 8, 0}, SemverComparator.parseSemver("0.8.0+build.42"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseSemver_rejectsInvalid() {
|
||||
assertNull(SemverComparator.parseSemver(null));
|
||||
assertNull(SemverComparator.parseSemver(""));
|
||||
assertNull(SemverComparator.parseSemver("latest"));
|
||||
assertNull(SemverComparator.parseSemver("stable"));
|
||||
assertNull(SemverComparator.parseSemver("0.8.0.1.2"));
|
||||
assertNull(SemverComparator.parseSemver("0.x.0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compareSemver_basic() {
|
||||
assertTrue(SemverComparator.compareSemver("0.7.2", "0.8.0") < 0);
|
||||
assertTrue(SemverComparator.compareSemver("0.8.0", "0.7.2") > 0);
|
||||
assertEquals(0, SemverComparator.compareSemver("0.8.0", "0.8.0"));
|
||||
assertEquals(0, SemverComparator.compareSemver("v0.8.0", "0.8.0"));
|
||||
assertTrue(SemverComparator.compareSemver("0.8.0", "0.10.0") < 0);
|
||||
assertTrue(SemverComparator.compareSemver("1.0.0", "0.99.99") > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findMaxSemver_picksHighest() {
|
||||
assertEquals("0.8.0", SemverComparator.findMaxSemver(
|
||||
List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest")));
|
||||
assertEquals("0.10.0", SemverComparator.findMaxSemver(
|
||||
List.of("0.8.0", "0.10.0", "0.9.5")));
|
||||
assertEquals("v1.0.0", SemverComparator.findMaxSemver(
|
||||
List.of("v0.8.0", "v1.0.0", "latest")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findMaxSemver_returnsNullWhenNoValidTag() {
|
||||
assertNull(SemverComparator.findMaxSemver(List.of("latest", "stable", "main")));
|
||||
assertNull(SemverComparator.findMaxSemver(List.of()));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.loremind.infrastructure.updates;
|
||||
|
||||
import com.loremind.application.licensing.LicenseService;
|
||||
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus;
|
||||
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind;
|
||||
import com.loremind.infrastructure.updates.UpdateCheckService.TagsListResponse;
|
||||
@@ -194,52 +193,6 @@ public class UpdateCheckServiceTest {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Utilitaires semver
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void parseSemver_acceptsCommonFormats() {
|
||||
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0"));
|
||||
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("v0.8.0"));
|
||||
assertArrayEquals(new int[]{1, 0, 0}, UpdateCheckService.parseSemver("1.0.0"));
|
||||
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0-beta.1"));
|
||||
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0+build.42"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseSemver_rejectsInvalid() {
|
||||
assertNull(UpdateCheckService.parseSemver(null));
|
||||
assertNull(UpdateCheckService.parseSemver(""));
|
||||
assertNull(UpdateCheckService.parseSemver("latest"));
|
||||
assertNull(UpdateCheckService.parseSemver("stable"));
|
||||
assertNull(UpdateCheckService.parseSemver("0.8.0.1.2"));
|
||||
assertNull(UpdateCheckService.parseSemver("0.x.0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compareSemver_basic() {
|
||||
assertTrue(UpdateCheckService.compareSemver("0.7.2", "0.8.0") < 0);
|
||||
assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.7.2") > 0);
|
||||
assertEquals(0, UpdateCheckService.compareSemver("0.8.0", "0.8.0"));
|
||||
assertEquals(0, UpdateCheckService.compareSemver("v0.8.0", "0.8.0"));
|
||||
assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.10.0") < 0);
|
||||
assertTrue(UpdateCheckService.compareSemver("1.0.0", "0.99.99") > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findMaxSemver_picksHighest() {
|
||||
assertEquals("0.8.0", UpdateCheckService.findMaxSemver(
|
||||
List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest")));
|
||||
assertEquals("0.10.0", UpdateCheckService.findMaxSemver(
|
||||
List.of("0.8.0", "0.10.0", "0.9.5")));
|
||||
assertEquals("v1.0.0", UpdateCheckService.findMaxSemver(
|
||||
List.of("v0.8.0", "v1.0.0", "latest")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findMaxSemver_returnsNullWhenNoValidTag() {
|
||||
assertNull(UpdateCheckService.findMaxSemver(List.of("latest", "stable", "main")));
|
||||
assertNull(UpdateCheckService.findMaxSemver(List.of()));
|
||||
}
|
||||
// NB : les utilitaires semver (parseSemver / findMaxSemver / compareSemver) sont
|
||||
// désormais dans SemverComparator et testés par SemverComparatorTest.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.loremind.infrastructure.updates;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Tests du parseur de challenge {@code WWW-Authenticate} ({@link WwwAuthenticate}),
|
||||
* jusque-là non couvert quand il était privé dans UpdateCheckService.
|
||||
*/
|
||||
class WwwAuthenticateTest {
|
||||
|
||||
@Test
|
||||
void parse_challengeGhcrComplet() {
|
||||
// Forme typique renvoyée par ghcr.io / Docker Hub.
|
||||
Map<String, String> p = WwwAuthenticate.parseParams(
|
||||
"realm=\"https://ghcr.io/token\",service=\"ghcr.io\",scope=\"repository:org/img:pull\"");
|
||||
assertEquals("https://ghcr.io/token", p.get("realm"));
|
||||
assertEquals("ghcr.io", p.get("service"));
|
||||
assertEquals("repository:org/img:pull", p.get("scope"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_valeursNonQuotees() {
|
||||
Map<String, String> p = WwwAuthenticate.parseParams("realm=https://r/token, service=reg");
|
||||
assertEquals("https://r/token", p.get("realm"));
|
||||
assertEquals("reg", p.get("service"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_chaineVide_donneMapVide() {
|
||||
assertTrue(WwwAuthenticate.parseParams("").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_guillemetNonFerme_sArreteProprement() {
|
||||
// Valeur ouverte sans guillemet fermant : on s'arrête sans planter.
|
||||
Map<String, String> p = WwwAuthenticate.parseParams("realm=\"https://r/token");
|
||||
assertNull(p.get("realm"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user