diff --git a/pom.xml b/pom.xml index 618127e4..5a7451cf 100644 --- a/pom.xml +++ b/pom.xml @@ -27,7 +27,9 @@ java UTF-8 - src/main/java/fr/insee/genesis/configuration/**/*.java + **/*MapperImpl.class, + src/main/java/fr/insee/genesis/configuration/**/*.java, + src/main/java/fr/insee/genesis/exceptions/*.java true @@ -109,7 +111,7 @@ com.networknt json-schema-validator - 1.5.9 + 2.0.0 @@ -180,6 +182,8 @@ src/main/java/fr/insee/genesis/configuration/**/* + **/*MapperImpl.class + src/main/java/fr/insee/genesis/exceptions/* diff --git a/src/main/java/fr/insee/genesis/controller/rest/UtilsController.java b/src/main/java/fr/insee/genesis/controller/rest/UtilsController.java index df5d220c..653295c7 100644 --- a/src/main/java/fr/insee/genesis/controller/rest/UtilsController.java +++ b/src/main/java/fr/insee/genesis/controller/rest/UtilsController.java @@ -4,6 +4,7 @@ import fr.insee.genesis.domain.ports.api.SurveyUnitApiPort; import fr.insee.genesis.domain.service.volumetry.VolumetryLogService; import fr.insee.genesis.domain.utils.XMLSplitter; +import fr.insee.genesis.exceptions.GenesisException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; @@ -44,7 +45,7 @@ public ResponseEntity saveResponsesFromXmlFile(@RequestParam("inputFolde @RequestParam("outputFolder") String outputFolder, @RequestParam("filename") String filename, @RequestParam("nbResponsesByFile") int nbSU) - throws XMLStreamException, IOException { + throws XMLStreamException, IOException, GenesisException { XMLSplitter.split(inputFolder, filename, outputFolder, "SurveyUnit", nbSU); return ResponseEntity.ok("File split"); } diff --git a/src/main/java/fr/insee/genesis/controller/rest/responses/RawResponseController.java b/src/main/java/fr/insee/genesis/controller/rest/responses/RawResponseController.java index 76434392..ed41b205 100644 --- a/src/main/java/fr/insee/genesis/controller/rest/responses/RawResponseController.java +++ b/src/main/java/fr/insee/genesis/controller/rest/responses/RawResponseController.java @@ -2,10 +2,10 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.networknt.schema.JsonSchema; -import com.networknt.schema.JsonSchemaFactory; -import com.networknt.schema.SpecVersion; -import com.networknt.schema.ValidationMessage; +import com.networknt.schema.Error; +import com.networknt.schema.Schema; +import com.networknt.schema.SchemaRegistry; +import com.networknt.schema.dialect.Dialects; import fr.insee.genesis.controller.dto.rawdata.LunaticJsonRawDataUnprocessedDto; import fr.insee.genesis.domain.model.surveyunit.Mode; import fr.insee.genesis.domain.model.surveyunit.rawdata.DataProcessResult; @@ -38,7 +38,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.stream.Collectors; @Slf4j @@ -94,14 +93,15 @@ public ResponseEntity saveRawResponsesFromJsonBody( public ResponseEntity saveRawResponsesFromJsonBodyWithValidation( @RequestBody Map body ) { - JsonSchema jsonSchema = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7).getSchema( - RawResponseController.class.getResourceAsStream("/jsonSchemas/RawResponse.json") + SchemaRegistry schemaRegistry = SchemaRegistry.withDialect(Dialects.getDraft202012(), SchemaRegistry.Builder::build); + Schema jsonSchema = schemaRegistry + .getSchema(RawResponseController.class.getResourceAsStream("/jsonSchemas/RawResponse.json") ); try { if (jsonSchema == null) { throw new GenesisException(500, "No RawResponse json schema has been found"); } - Set errors = jsonSchema.validate( + List errors = jsonSchema.validate( new ObjectMapper().readTree( new ObjectMapper().writeValueAsString(body) ) @@ -209,10 +209,10 @@ public ResponseEntity> getRawResponsesFromJs return ResponseEntity.status(HttpStatus.OK).body(new PagedModel<>(rawResponses)); } - private void validate(Set errors) throws GenesisException { + private void validate(List errors) throws GenesisException { if (!errors.isEmpty()) { String errorMessage = errors.stream() - .map(ValidationMessage::getMessage) + .map(Error::getMessage) .collect(Collectors.joining(System.lineSeparator() + " - ")); throw new GenesisException( diff --git a/src/main/java/fr/insee/genesis/domain/service/surveyunit/SurveyUnitService.java b/src/main/java/fr/insee/genesis/domain/service/surveyunit/SurveyUnitService.java index dceced3f..91b35e0c 100644 --- a/src/main/java/fr/insee/genesis/domain/service/surveyunit/SurveyUnitService.java +++ b/src/main/java/fr/insee/genesis/domain/service/surveyunit/SurveyUnitService.java @@ -156,7 +156,7 @@ public List> findLatestByIdAndByQuestionnaireIdAndModeOrde List> listLatestUpdatesbyVariables = new ArrayList<>(); //1) QUERY - // => conversion of "List" -> "List" for query using lamda + // => conversion of "List" -> "List" for query using lambda List queryInParam = interrogationIds.stream().map(InterrogationId::getInterrogationId).toList(); //Get !!!all versions!!! of a set of "interrogationIds" diff --git a/src/main/java/fr/insee/genesis/domain/utils/XMLSplitter.java b/src/main/java/fr/insee/genesis/domain/utils/XMLSplitter.java index 9fea760d..137dd7e0 100644 --- a/src/main/java/fr/insee/genesis/domain/utils/XMLSplitter.java +++ b/src/main/java/fr/insee/genesis/domain/utils/XMLSplitter.java @@ -1,14 +1,8 @@ package fr.insee.genesis.domain.utils; +import fr.insee.genesis.exceptions.GenesisException; import lombok.experimental.UtilityClass; -import java.io.FileNotFoundException; -import java.io.FileReader; -import java.io.FileWriter; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - import javax.xml.XMLConstants; import javax.xml.stream.XMLEventFactory; import javax.xml.stream.XMLEventReader; @@ -20,12 +14,17 @@ import javax.xml.stream.events.StartDocument; import javax.xml.stream.events.StartElement; import javax.xml.stream.events.XMLEvent; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; @UtilityClass public class XMLSplitter { // We use StAX in this class to deal with memory issues on huge XML files - public static void split(String inputfolder, String xmlfile, String outputFolder, String condition, int nbElementsByFile) throws XMLStreamException, IOException { + public static void split(String inputfolder, String xmlfile, String outputFolder, String condition, int nbElementsByFile) throws XMLStreamException, IOException, GenesisException { String xmlResource = inputfolder + xmlfile; List header = getHeader(xmlResource, condition); @@ -34,42 +33,45 @@ public static void split(String inputfolder, String xmlfile, String outputFolder XMLInputFactory xif = XMLInputFactory.newInstance(); xif.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, ""); xif.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); - XMLEventReader xer = xif.createXMLEventReader(new FileReader(xmlResource)); - StartElement rootStartElement = xer.nextTag().asStartElement(); - StartDocument startDocument = xef.createStartDocument(); - EndDocument endDocument = xef.createEndDocument(); - - XMLOutputFactory xof = XMLOutputFactory.newFactory(); - int fileCount = 1; - while(xer.hasNext() && !xer.peek().isEndDocument()) { - XMLEvent xmlEvent = xer.nextEvent(); - - if (isStartElementWithName(condition, xmlEvent)) { - // Create a file for the fragment, the name is derived from the value of the id attribute - FileWriter fileWriter = new FileWriter(outputFolder + "split" + fileCount + ".xml"); - - // A StAX XMLEventWriter will be used to write the XML fragment - XMLEventWriter xew = xof.createXMLEventWriter(fileWriter); - xew.add(startDocument); - - // Add the elements which are common to all split files - addHeadersToNewFile(header, xew); - - // Write the XMLEvents that are part of SurveyUnit element - xew.add(xmlEvent); - xmlEvent = xer.nextEvent(); - int nbResponses = 1; - // We loop until we reach the end tag Survey units indicating the near end of the document - iterateOnSurveyUnits(condition, nbElementsByFile, xer, xmlEvent, xew, nbResponses); - - // Write the file, close everything we opened and update the file's counter - xew.add(xef.createEndElement(rootStartElement.getName(), null)); - xew.add(endDocument); - fileWriter.close(); - - fileCount++; - + try (FileReader fr = new FileReader(xmlResource)){ + XMLEventReader xer = xif.createXMLEventReader(fr); + + StartElement rootStartElement = xer.nextTag().asStartElement(); + StartDocument startDocument = xef.createStartDocument(); + EndDocument endDocument = xef.createEndDocument(); + + XMLOutputFactory xof = XMLOutputFactory.newFactory(); + int fileCount = 1; + while(xer.hasNext() && !xer.peek().isEndDocument()) { + XMLEvent xmlEvent = xer.nextEvent(); + + if (isStartElementWithName(condition, xmlEvent)) { + // Create a file for the fragment, the name is derived from the value of the id attribute + FileWriter fileWriter = new FileWriter(outputFolder + "split" + fileCount + ".xml"); + + // A StAX XMLEventWriter will be used to write the XML fragment + XMLEventWriter xew = xof.createXMLEventWriter(fileWriter); + xew.add(startDocument); + + // Add the elements which are common to all split files + addHeadersToNewFile(header, xew); + + // Write the XMLEvents that are part of SurveyUnit element + xew.add(xmlEvent); + xmlEvent = xer.nextEvent(); + int nbResponses = 1; + // We loop until we reach the end tag Survey units indicating the near end of the document + iterateOnSurveyUnits(condition, nbElementsByFile, xer, xmlEvent, xew, nbResponses); + + // Write the file, close everything we opened and update the file's counter + xew.add(xef.createEndElement(rootStartElement.getName(), null)); + xew.add(endDocument); + fileWriter.close(); + + fileCount++; + } } + xer.close(); } } @@ -103,33 +105,37 @@ private static boolean isEndElementWithName(XMLEvent xmlEvent, String condition) return xmlEvent.isEndElement() && xmlEvent.asEndElement().getName().getLocalPart().equals(condition); } - private static List getHeader(String xmlResource, String condition) throws FileNotFoundException, XMLStreamException { + private static List getHeader(String xmlResource, String condition) throws XMLStreamException, GenesisException { XMLInputFactory xif = XMLInputFactory.newInstance(); xif.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, ""); xif.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); - XMLEventReader xer = xif.createXMLEventReader(new FileReader(xmlResource)); - - List cachedXMLEvents = new ArrayList<>(); - while(xer.hasNext() && !xer.peek().isEndDocument()) { - XMLEvent xmlEvent = xer.nextTag(); - if (!xmlEvent.isStartElement()) { - break; - } - StartElement breakStartElement = xmlEvent.asStartElement(); - - cachedXMLEvents.add(breakStartElement); - xmlEvent = xer.nextEvent(); - while (!(xmlEvent.isEndElement() && xmlEvent.asEndElement().getName().equals(breakStartElement.getName()))) { - if (isStartElementWithName(condition, xmlEvent)) { - xer.close(); - return cachedXMLEvents; - } - cachedXMLEvents.add(xmlEvent); - xmlEvent = xer.nextEvent(); - } - } - xer.close(); - return List.of(); + try (FileReader fr = new FileReader(xmlResource)) { + XMLEventReader xer = xif.createXMLEventReader(fr); + + List cachedXMLEvents = new ArrayList<>(); + while (xer.hasNext() && !xer.peek().isEndDocument()) { + XMLEvent xmlEvent = xer.nextTag(); + if (!xmlEvent.isStartElement()) { + break; + } + StartElement breakStartElement = xmlEvent.asStartElement(); + + cachedXMLEvents.add(breakStartElement); + xmlEvent = xer.nextEvent(); + while (!(xmlEvent.isEndElement() && xmlEvent.asEndElement().getName().equals(breakStartElement.getName()))) { + if (isStartElementWithName(condition, xmlEvent)) { + xer.close(); + return cachedXMLEvents; + } + cachedXMLEvents.add(xmlEvent); + xmlEvent = xer.nextEvent(); + } + } + xer.close(); + } catch (IOException e) { + throw new GenesisException(500,e.getMessage()); + } + return List.of(); } } diff --git a/src/test/java/fr/insee/genesis/controller/mappers/DataProcessingContextMapperDtoTest.java b/src/test/java/fr/insee/genesis/controller/mappers/DataProcessingContextMapperDtoTest.java new file mode 100644 index 00000000..025981fc --- /dev/null +++ b/src/test/java/fr/insee/genesis/controller/mappers/DataProcessingContextMapperDtoTest.java @@ -0,0 +1,49 @@ +package fr.insee.genesis.controller.mappers; + +import fr.insee.genesis.controller.dto.ScheduleDto; +import fr.insee.genesis.domain.model.context.DataProcessingContextModel; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static java.time.LocalDateTime.now; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class DataProcessingContextMapperDtoTest { + @Test + void dataProcessingContextToScheduleDto() { + DataProcessingContextMapperDto d = new DataProcessingContextMapperDto(); + DataProcessingContextModel dataProcessingContext = new DataProcessingContextModel(); + + ScheduleDto actual = d.dataProcessingContextToScheduleDto(dataProcessingContext); + assertNotNull(actual); + assertNull( actual.lastExecution()); + assertNull( actual.surveyName()); + assertNull( actual.kraftwerkExecutionScheduleList()); + + DataProcessingContextMapperDto d2 = new DataProcessingContextMapperDto(); + DataProcessingContextModel dataProcessingContext2 = new DataProcessingContextModel(); + dataProcessingContext2.setId(new ObjectId()); + dataProcessingContext2.setWithReview(true); + dataProcessingContext2.setLastExecution(now()); + + ScheduleDto actual2 = d2.dataProcessingContextToScheduleDto(dataProcessingContext2); + assertNotNull(actual2); + assertNotNull( actual2.lastExecution()); + assertNull( actual2.surveyName()); + } + + @Test + void dataProcessingContextListToScheduleDtoList() { + DataProcessingContextMapperDto d = new DataProcessingContextMapperDto(); + List contexts = new ArrayList<>(); + List expected = new ArrayList<>(); + List actual = d.dataProcessingContextListToScheduleDtoList(contexts); + + assertEquals(expected, actual); + } +} diff --git a/src/test/java/fr/insee/genesis/controller/sources/json/LunaticJsonCollectedVariablesTest.java b/src/test/java/fr/insee/genesis/controller/sources/json/LunaticJsonCollectedVariablesTest.java new file mode 100644 index 00000000..90598a41 --- /dev/null +++ b/src/test/java/fr/insee/genesis/controller/sources/json/LunaticJsonCollectedVariablesTest.java @@ -0,0 +1,13 @@ +package fr.insee.genesis.controller.sources.json; + +import org.junit.jupiter.api.Test; + +class LunaticJsonCollectedVariablesTest { + @Test + void setVariables() { + LunaticJsonCollectedVariables l = new LunaticJsonCollectedVariables(); + String variable = "abc"; + LunaticJsonVariableData value = new LunaticJsonVariableData(); + l.setVariables(variable, value); + } +} diff --git a/src/test/java/fr/insee/genesis/controller/sources/json/LunaticJsonExternalVariablesTest.java b/src/test/java/fr/insee/genesis/controller/sources/json/LunaticJsonExternalVariablesTest.java new file mode 100644 index 00000000..ad7263b8 --- /dev/null +++ b/src/test/java/fr/insee/genesis/controller/sources/json/LunaticJsonExternalVariablesTest.java @@ -0,0 +1,13 @@ +package fr.insee.genesis.controller.sources.json; + +import org.junit.jupiter.api.Test; + +class LunaticJsonExternalVariablesTest { + @Test + void setVariables() { + LunaticJsonExternalVariables l = new LunaticJsonExternalVariables(); + String variable = "abc"; + String value = "abc"; + l.setVariables(variable, value); + } +} diff --git a/src/test/java/fr/insee/genesis/domain/utils/XMLSplitterTest.java b/src/test/java/fr/insee/genesis/domain/utils/XMLSplitterTest.java new file mode 100644 index 00000000..c4a20738 --- /dev/null +++ b/src/test/java/fr/insee/genesis/domain/utils/XMLSplitterTest.java @@ -0,0 +1,208 @@ +package fr.insee.genesis.domain.utils; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.stream.XMLEventReader; +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.events.XMLEvent; +import java.io.File; +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class XMLSplitterTest { + + @TempDir + Path tempDir; + + @Test + void split_splits_in_chunks_and_preserves_headers() throws Exception { + // Given + Path inDir = tempDir.resolve("in"); + Path outDir = tempDir.resolve("out"); + Files.createDirectories(inDir); + Files.createDirectories(outDir); + + Path input = inDir.resolve("input.xml"); + writeFile(input, sampleSurveyXML(5)); // 5 SurveyUnit + + // When : 2 elements by file, we expected 3 files (2+2+1) + XMLSplitter.split(inDir + File.separator, "input.xml", + outDir + File.separator, "SurveyUnit", 2); + + // Then + Path f1 = outDir.resolve("split1.xml"); + Path f2 = outDir.resolve("split2.xml"); + Path f3 = outDir.resolve("split3.xml"); + + assertTrue(Files.exists(f1), "split1.xml should exists"); + assertTrue(Files.exists(f2), "split2.xml should exists"); + assertTrue(Files.exists(f3), "split3.xml should exists"); + + // Each file is well-formed + assertParsableXML(f1); + assertParsableXML(f2); + assertParsableXML(f3); + + // Comptes d'éléments par fichier + assertEquals(2, countStartElements(f1, "SurveyUnit")); + assertEquals(2, countStartElements(f2, "SurveyUnit")); + assertEquals(1, countStartElements(f3, "SurveyUnit")); + + // Les headers (Root/Header/SurveyUnits) sont présents dans chaque fichier + assertEquals(1, countStartElements(f1, "Root")); + assertEquals(1, countStartElements(f1, "Header")); + assertEquals(1, countStartElements(f1, "SurveyUnits")); + + assertEquals(1, countStartElements(f2, "Root")); + assertEquals(1, countStartElements(f2, "Header")); + assertEquals(1, countStartElements(f2, "SurveyUnits")); + + assertEquals(1, countStartElements(f3, "Root")); + assertEquals(1, countStartElements(f3, "Header")); + assertEquals(1, countStartElements(f3, "SurveyUnits")); + } + + @Test + void split_with_large_chunk_makes_single_file_with_all_items() throws Exception { + // Given + Path inDir = tempDir.resolve("in2"); + Path outDir = tempDir.resolve("out2"); + Files.createDirectories(inDir); + Files.createDirectories(outDir); + + Path input = inDir.resolve("input.xml"); + writeFile(input, sampleSurveyXML(3)); // 3 SurveyUnit + + // When: 10 par fichier -> un seul split + XMLSplitter.split(inDir + File.separator, "input.xml", + outDir + File.separator, "SurveyUnit", 10); + + // Then + Path f1 = outDir.resolve("split1.xml"); + assertTrue(Files.exists(f1), "Expected one file"); + assertParsableXML(f1); + assertEquals(3, countStartElements(f1, "SurveyUnit")); + assertFalse(Files.exists(outDir.resolve("split2.xml")), "No second file expected"); + } + + @Test + void split_when_no_condition_element_creates_no_file() throws Exception { + // Given: XML without SurveyUnit + String xml = """ + + +
No Units
+ +
+ """; + Path inDir = tempDir.resolve("in3"); + Path outDir = tempDir.resolve("out3"); + Files.createDirectories(inDir); + Files.createDirectories(outDir); + Path input = inDir.resolve("input.xml"); + writeFile(input, xml); + + // When + XMLSplitter.split(inDir + File.separator, "input.xml", + outDir + File.separator, "SurveyUnit", 2); + + // Then + try (var stream = Files.list(outDir)) { + long count = stream + .filter(p -> p.getFileName().startsWith("split")) + .count(); + assertEquals(0L, count, "No file named split* expected"); + } + } + + @Test + void split_respects_exact_boundary_between_files() throws Exception { + // Given: 4 unités, chunk de 2 -> exactly 2 files + Path inDir = tempDir.resolve("in4"); + Path outDir = tempDir.resolve("out4"); + Files.createDirectories(inDir); + Files.createDirectories(outDir); + writeFile(inDir.resolve("input.xml"), sampleSurveyXML(4)); + + // When + XMLSplitter.split(inDir + File.separator, "input.xml", + outDir+ File.separator, "SurveyUnit", 2); + + // Then + Path f1 = outDir.resolve("split1.xml"); + Path f2 = outDir.resolve("split2.xml"); + assertTrue(Files.exists(f1)); + assertTrue(Files.exists(f2)); + assertFalse(Files.exists(outDir.resolve("split3.xml")), "No file split3 expected"); + + assertParsableXML(f1); + assertParsableXML(f2); + assertEquals(2, countStartElements(f1, "SurveyUnit")); + assertEquals(2, countStartElements(f2, "SurveyUnit")); + } + + // --------- Utilities methods --------- + private void writeFile(Path path, String content) throws IOException { + Files.createDirectories(path.getParent()); + Files.writeString(path, content, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } + + private int countStartElements(Path xml, String localName) throws Exception { + XMLInputFactory xif = XMLInputFactory.newInstance(); + xif.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + xif.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + try (Reader r = Files.newBufferedReader(xml, StandardCharsets.UTF_8)) { + XMLEventReader xer = xif.createXMLEventReader(r); + int count = 0; + while (xer.hasNext()) { + XMLEvent ev = xer.nextEvent(); + if (ev.isStartElement() && ev.asStartElement().getName().getLocalPart().equals(localName)) { + count++; + } + } + xer.close(); + return count; + } + } + + private void assertParsableXML(Path xml) throws Exception { + var dbf = DocumentBuilderFactory.newInstance(); + dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + dbf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + dbf.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + dbf.newDocumentBuilder().parse(xml.toFile()); + } + + private String sampleSurveyXML(int n) { + StringBuilder sb = new StringBuilder(); + sb.append(""" + + +
+ Test +
+ + """); + for (int i = 1; i <= n; i++) { + sb.append(""" + U%d + """.formatted(i, i)); + } + sb.append(""" + +
+ """); + return sb.toString(); + } +} diff --git a/src/test/java/fr/insee/genesis/infrastructure/utils/http/HttpUtilsTest.java b/src/test/java/fr/insee/genesis/infrastructure/utils/http/HttpUtilsTest.java new file mode 100644 index 00000000..e31373a6 --- /dev/null +++ b/src/test/java/fr/insee/genesis/infrastructure/utils/http/HttpUtilsTest.java @@ -0,0 +1,137 @@ +package fr.insee.genesis.infrastructure.utils.http; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests « boite noire » de HttpUtils sans lib externe : + * - serveur HTTP embarqué JDK comme double de l'API + * - vérification des en-têtes, du body et de la désérialisation JSON + */ +class HttpUtilsTest { + + private HttpServer server; + private int port; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress(0), 0); // port auto + port = server.getAddress().getPort(); + server.setExecutor(Executors.newSingleThreadExecutor()); + server.start(); + } + + @AfterEach + void stopServer() { + if (server != null){ server.stop(0);} + } + + // --- DTO simples pour sérialiser/désérialiser via Jackson déjà présent avec Spring WebFlux + public static class RequestDto { + public String name; + public int age; + public RequestDto() {} + public RequestDto(String name, int age) { this.name = name; this.age = age; } + } + public static class ResponseDto { + public String status; + public String echoedName; + public ResponseDto() {} + public ResponseDto(String status, String echoedName) { this.status = status; this.echoedName = echoedName; } + } + + + @Test + void makeApiCall_ok_post_json_and_auth_header() throws Exception { + // Arrange: route /test qui vérifie méthode, headers, body et renvoie un JSON + server.createContext("/test", new HttpHandler() { + @Override public void handle(HttpExchange ex) throws IOException { + try { + assertEquals("POST", ex.getRequestMethod(), "Méthode HTTP inattendue"); + + // Vérifie header Authorization et Content-Type + var auth = ex.getRequestHeaders().getFirst("Authorization"); + assertEquals("Bearer TEST_TOKEN", auth, "Header Authorization manquant/incorrect"); + var ct = ex.getRequestHeaders().getFirst("Content-Type"); + assertTrue(ct != null && ct.contains("application/json"), "Content-Type JSON attendu"); + + // Lis le body + var reqBody = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(reqBody.contains("\"name\":\"Alice\""), "Body JSON ne contient pas name=Alice"); + assertTrue(reqBody.contains("\"age\":30"), "Body JSON ne contient pas age=30"); + + // Réponse JSON + var resp = """ + {"status":"ok","echoedName":"Alice"} + """; + ex.getResponseHeaders().add("Content-Type", "application/json"); + byte[] bytes = resp.getBytes(StandardCharsets.UTF_8); + ex.sendResponseHeaders(200, bytes.length); + try (OutputStream os = ex.getResponseBody()) { os.write(bytes); } + } finally { + ex.close(); + } + } + }); + + var baseUrl = "http://localhost:" + port; + var oidc = new OidcServiceStub("TEST_TOKEN"); + var request = new RequestDto("Alice", 30); + + // Act + ResponseEntity response = HttpUtils.makeApiCall( + baseUrl, + "/test", + HttpMethod.POST, + request, + ResponseDto.class, + oidc + ); + + // Assert + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("ok", response.getBody().status); + assertEquals("Alice", response.getBody().echoedName); + } + + @Test + void makeApiCall_propagates_5xx_error() { + server.createContext("/boom", ex -> { + ex.sendResponseHeaders(500, -1); // pas de corps + ex.close(); + }); + + var baseUrl = "http://localhost:" + port; + var oidc = new OidcServiceStub("TEST_TOKEN"); + + final RequestDto req = new RequestDto("Bob", 42); + + Executable call = () -> HttpUtils.makeApiCall( + baseUrl, "/boom", HttpMethod.POST, req, ResponseDto.class, oidc + ); + + // Check the classpath + RuntimeException exception = assertThrows(RuntimeException.class, call); + assertTrue(exception.getClass().getName().contains("WebClientResponseException")); + } +} diff --git a/src/test/java/fr/insee/genesis/infrastructure/utils/http/OidcServiceStub.java b/src/test/java/fr/insee/genesis/infrastructure/utils/http/OidcServiceStub.java new file mode 100644 index 00000000..7baa6d3e --- /dev/null +++ b/src/test/java/fr/insee/genesis/infrastructure/utils/http/OidcServiceStub.java @@ -0,0 +1,59 @@ +package fr.insee.genesis.infrastructure.utils.http; + +import fr.insee.genesis.configuration.Config; + +import java.io.IOException; + +public class OidcServiceStub extends OidcService{ + + private String token; + + private String refreshedToken; + private boolean throwOnGet; + private boolean throwOnRefresh; + private int nbCalls =0; + private int nbRefresh =0; + + + public OidcServiceStub(String token) { + super(new Config(token)); + this.token = token; + } + + public OidcServiceStub(String initialToken, String refreshedToken) { + super(new Config(initialToken)); + this.token = initialToken; + this.refreshedToken = refreshedToken; + this.throwOnGet=false; + this.throwOnRefresh=false; + } + + public OidcServiceStub(String initialToken, String refreshedToken, boolean throwOnGet, boolean throwOnRefresh) { + super(new Config(initialToken)); + this.token = initialToken; + this.refreshedToken = refreshedToken; + this.throwOnGet=throwOnGet; + this.throwOnRefresh=throwOnRefresh; + } + + @Override public String getServiceAccountToken() { + nbCalls++; + return token; } + + + + @Override + public void retrieveServiceAccountToken() throws IOException { + nbRefresh++; + if (throwOnRefresh) throw new IOException("refresh failed"); + this.token = refreshedToken; + } + + public int getCalls(){ + return nbCalls; + } + + public int refreshCalls(){ + return nbRefresh; + } +} diff --git a/src/test/java/fr/insee/genesis/infrastructure/utils/http/PerretAuthWebClientFilterTest.java b/src/test/java/fr/insee/genesis/infrastructure/utils/http/PerretAuthWebClientFilterTest.java new file mode 100644 index 00000000..3b915319 --- /dev/null +++ b/src/test/java/fr/insee/genesis/infrastructure/utils/http/PerretAuthWebClientFilterTest.java @@ -0,0 +1,146 @@ +package fr.insee.genesis.infrastructure.utils.http; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.reactive.function.client.WebClient; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +class PerretAuthWebClientFilterTest { + + private HttpServer server; + private int port; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress(0), 0); + port = server.getAddress().getPort(); + server.setExecutor(Executors.newSingleThreadExecutor()); + server.start(); + } + + @AfterEach + void stopServer() { + if (server != null) server.stop(0); + } + + + private WebClient clientWithFilter(OidcService oidc) { + return WebClient.builder() + .baseUrl("http://localhost:" + port) + .filter(PerretAuthWebClientFilter.perretAuthFilter(oidc)) + .build(); + } + + @Test + void addsBearerAndNoRetryOn200() { + AtomicInteger hitCount = new AtomicInteger(); + server.createContext("/ok", ex -> { + hitCount.incrementAndGet(); + String auth = ex.getRequestHeaders().getFirst("Authorization"); + assertEquals("Bearer INIT", auth, "Le header Authorization doit contenir le token initial"); + byte[] body = "{\"msg\":\"ok\"}".getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().add("Content-Type", "application/json"); + ex.sendResponseHeaders(200, body.length); + try (OutputStream os = ex.getResponseBody()) { os.write(body); } + ex.close(); + }); + + var oidc = new OidcServiceStub("INIT", "REFRESHED"); + ResponseEntity resp = clientWithFilter(oidc) + .get().uri("/ok") + .retrieve().toEntity(String.class) + .block(); + + assertNotNull(resp); + assertEquals(200, resp.getStatusCode().value()); + assertEquals(1, hitCount.get(), "Pas de retry attendu sur 200"); + assertEquals(1, oidc.getCalls(), "Une seule lecture du token"); + assertEquals(0, oidc.refreshCalls(), "Aucun refresh attendu"); + } + + @Test + void retriesOnceOn401WithRefreshedToken_thenSuccess() { + AtomicInteger hitCount = new AtomicInteger(); + server.createContext("/needs-refresh", (HttpExchange ex) -> { + int n = hitCount.incrementAndGet(); + String auth = ex.getRequestHeaders().getFirst("Authorization"); + if (n == 1) { + // 1er appel : doit être fait avec le token initial return 401 + assertEquals("Bearer INIT", auth, "1er appel doit utiliser le token initial"); + ex.sendResponseHeaders(401, -1); + ex.close(); + } else { + // 2e appel (retry) : doit utiliser le token rafraîchi -> 200 + assertEquals("Bearer REFRESHED", auth, "Retry doit utiliser le token rafraîchi"); + byte[] body = "{\"msg\":\"after-refresh\"}".getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().add("Content-Type", "application/json"); + ex.sendResponseHeaders(200, body.length); + try (OutputStream os = ex.getResponseBody()) { os.write(body); } + ex.close(); + } + }); + + var oidc = new OidcServiceStub("INIT", "REFRESHED"); + ResponseEntity resp = clientWithFilter(oidc) + .get().uri("/needs-refresh") + .retrieve().toEntity(String.class) + .block(); + + assertNotNull(resp); + assertEquals(200, resp.getStatusCode().value()); + assertEquals(2, hitCount.get(), "Un seul retry attendu (total 2 appels)"); + assertTrue(oidc.getCalls() >= 2, "Le token est lu avant et après refresh"); + assertEquals(1, oidc.refreshCalls(), "Un seul refresh attendu"); + } + + @Test + void failsWhenInitialTokenFetchThrows() { + // Pas besoin de serveur : l'erreur survient dans le request processor + var oidc = new OidcServiceStub("INIT", "REFRESHED", /*throwOnGet*/ true, /*throwOnRefresh*/ false); + WebClient client = clientWithFilter(oidc); + + Executable call = () -> client.get().uri("/whatever") + .retrieve().toEntity(String.class).block(); + + assertThrows(RuntimeException.class, call); + assertEquals(1, oidc.getCalls()); + assertEquals(0, oidc.refreshCalls()); + } + + @Test + void failsWhenRefreshThrows_after401() { + server.createContext("/boom", ex -> { + ex.sendResponseHeaders(401, -1); // force chemin de retry + ex.close(); + }); + + var oidc = new OidcServiceStub("INIT", "REFRESHED", /*throwOnGet*/ false, /*throwOnRefresh*/ true); + WebClient client = clientWithFilter(oidc); + + Executable call = () -> client.get().uri("/boom") + .retrieve().toEntity(String.class).block(); + + RuntimeException ex = assertThrows(RuntimeException.class, call); + assertTrue(ex.getMessage().contains("Failed to refresh token")); + // getServiceAccountToken est appelé au 1er passage (avant 401) + assertTrue(oidc.getCalls() >= 1); + assertEquals(1, oidc.refreshCalls(), "Un refresh a été tenté et a échoué"); + } +}