From 6b43a4b0e8495446aadea8990ce5ffbdcedc3d68 Mon Sep 17 00:00:00 2001 From: trifonovt <87468028+TihomirTrifonov@users.noreply.github.com> Date: Tue, 12 May 2026 15:06:08 +0200 Subject: [PATCH] Add vehicle unit tachograph file sessions --- ...eventhub-esper-poc.postman_collection.json | 47 +++ .../service/LegalRequirementsClient.java | 2 +- .../service/TachographFileSessionService.java | 29 +- .../service/TachographXmlParser.java | 6 +- .../VehicleUnitXmlExtractionService.java | 364 ++++++++++++++++++ .../DriverCardXmlExtractionServiceTest.java | 2 +- .../TachographFileSessionServiceTest.java | 72 +++- .../service/TachographXmlParserTest.java | 14 +- .../VehicleUnitXmlExtractionServiceTest.java | 68 ++++ .../service/VehicleUnitXmlSamples.java | 72 ++++ 10 files changed, 650 insertions(+), 26 deletions(-) create mode 100644 src/main/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionService.java create mode 100644 src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionServiceTest.java create mode 100644 src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlSamples.java diff --git a/postman/eventhub-esper-poc.postman_collection.json b/postman/eventhub-esper-poc.postman_collection.json index 45f7c2d..0b6d943 100644 --- a/postman/eventhub-esper-poc.postman_collection.json +++ b/postman/eventhub-esper-poc.postman_collection.json @@ -248,6 +248,49 @@ } } }, + { + "name": "Create tachograph vehicle unit file session", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "file", + "type": "file", + "src": "{{tachographVuDddFile}}" + }, + { + "key": "tenantKey", + "type": "text", + "value": "{{tenantKey}}" + }, + { + "key": "sourceInstanceKey", + "type": "text", + "value": "legalrequirements-vehicleunit" + }, + { + "key": "sessionLabel", + "type": "text", + "value": "vehicle-unit upload sample" + } + ] + }, + "url": { + "raw": "{{baseUrl}}/api/eventhub/tachograph-file-sessions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "eventhub", + "tachograph-file-sessions" + ] + } + } + }, { "name": "Get tachograph file session", "request": { @@ -374,6 +417,10 @@ { "key": "tachographDddFile", "value": "C:\\\\temp\\\\driver-card.ddd" + }, + { + "key": "tachographVuDddFile", + "value": "C:\\\\temp\\\\vehicle-unit.ddd" } ] } diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/LegalRequirementsClient.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/LegalRequirementsClient.java index e049650..ebb59bc 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/LegalRequirementsClient.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/LegalRequirementsClient.java @@ -26,7 +26,7 @@ public class LegalRequirementsClient { this.objectMapper = objectMapper; } - public LegalRequirementsUploadResult uploadDriverCard(byte[] fileBytes, String fileName) { + public LegalRequirementsUploadResult uploadTachographFile(byte[] fileBytes, String fileName) { EventHubProperties.LegalRequirements config = properties.getTachographFileSession().getLegalRequirements(); HttpClient client = HttpClient.newBuilder() .connectTimeout(config.getConnectTimeout()) diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionService.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionService.java index 51633cf..5f89bb7 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionService.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionService.java @@ -28,20 +28,23 @@ public class TachographFileSessionService { private final TachographFileSessionRepository repository; private final LegalRequirementsClient legalRequirementsClient; private final TachographXmlParser tachographXmlParser; - private final DriverCardXmlExtractionService extractionService; + private final DriverCardXmlExtractionService driverCardExtractionService; + private final VehicleUnitXmlExtractionService vehicleUnitExtractionService; public TachographFileSessionService( EventHubProperties properties, TachographFileSessionRepository repository, LegalRequirementsClient legalRequirementsClient, TachographXmlParser tachographXmlParser, - DriverCardXmlExtractionService extractionService + DriverCardXmlExtractionService driverCardExtractionService, + VehicleUnitXmlExtractionService vehicleUnitExtractionService ) { this.properties = properties; this.repository = repository; this.legalRequirementsClient = legalRequirementsClient; this.tachographXmlParser = tachographXmlParser; - this.extractionService = extractionService; + this.driverCardExtractionService = driverCardExtractionService; + this.vehicleUnitExtractionService = vehicleUnitExtractionService; } public CreateTachographFileSessionResponse createSession( @@ -54,23 +57,26 @@ public class TachographFileSessionService { validateFile(file); byte[] fileBytes = file.getBytes(); validateFileBytes(fileBytes); - LegalRequirementsUploadResult uploadResult = legalRequirementsClient.uploadDriverCard(fileBytes, file.getOriginalFilename()); - TachographXmlParser.ParsedTachographXml parsedXml = tachographXmlParser.parseDriverCardXml(uploadResult.xmlContent()); + LegalRequirementsUploadResult uploadResult = legalRequirementsClient.uploadTachographFile(fileBytes, file.getOriginalFilename()); + TachographXmlParser.ParsedTachographXml parsedXml = tachographXmlParser.parse(uploadResult.xmlContent()); Instant createdAt = Instant.now(); Instant expiresAt = createdAt.plus(properties.getTachographFileSession().getTtl()); + boolean driverCardFile = "DriverCard".equals(parsedXml.rootElementName()); TachographFileSessionMetadata metadata = new TachographFileSessionMetadata( tenant(tenantKey), - sourceInstance(sourceInstanceKey), + sourceInstance(sourceInstanceKey, driverCardFile), blankToNull(sessionLabel), file.getOriginalFilename(), sha256(fileBytes), fileBytes.length, uploadResult.dataPackageId(), sha256(uploadResult.xmlContent().getBytes(StandardCharsets.UTF_8)), - true, + driverCardFile, null ); - TachographFileSession session = extractionService.extract(parsedXml, metadata, createdAt, expiresAt); + TachographFileSession session = driverCardFile + ? driverCardExtractionService.extract(parsedXml, metadata, createdAt, expiresAt) + : vehicleUnitExtractionService.extract(parsedXml, metadata, createdAt, expiresAt); TachographFileSession saved = repository.save(session); return new CreateTachographFileSessionResponse(toSummary(saved)); } catch (Exception e) { @@ -171,8 +177,11 @@ public class TachographFileSessionService { return blankToNull(tenantKey) == null ? "default" : tenantKey.trim(); } - private String sourceInstance(String sourceInstanceKey) { - return blankToNull(sourceInstanceKey) == null ? "legalrequirements-drivercard" : sourceInstanceKey.trim(); + private String sourceInstance(String sourceInstanceKey, boolean driverCardFile) { + if (blankToNull(sourceInstanceKey) != null) { + return sourceInstanceKey.trim(); + } + return driverCardFile ? "legalrequirements-drivercard" : "legalrequirements-vehicleunit"; } private String blankToNull(String value) { diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographXmlParser.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographXmlParser.java index b0ffd76..882dbae 100644 --- a/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographXmlParser.java +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/TachographXmlParser.java @@ -28,7 +28,7 @@ public class TachographXmlParser { this.schema = loadSchema(); } - public ParsedTachographXml parseDriverCardXml(String xmlContent) { + public ParsedTachographXml parse(String xmlContent) { try { validate(xmlContent); DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); @@ -44,8 +44,8 @@ public class TachographXmlParser { DocumentBuilder builder = factory.newDocumentBuilder(); Document document = builder.parse(new InputSource(new StringReader(xmlContent))); String rootName = document.getDocumentElement() == null ? null : document.getDocumentElement().getNodeName(); - if (!"DriverCard".equals(rootName)) { - throw new UnsupportedTachographFileTypeException("Only DriverCard XML documents are supported in phase 1."); + if (!"DriverCard".equals(rootName) && !"VehicleUnit".equals(rootName)) { + throw new UnsupportedTachographFileTypeException("Only DriverCard and VehicleUnit XML documents are supported."); } return new ParsedTachographXml(document, rootName); } catch (ParserConfigurationException | IOException | SAXException e) { diff --git a/src/main/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionService.java b/src/main/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionService.java new file mode 100644 index 0000000..3b18e70 --- /dev/null +++ b/src/main/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionService.java @@ -0,0 +1,364 @@ +package at.procon.eventhub.tachographfilesession.service; + +import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession; +import at.procon.eventhub.tachographfilesession.model.ExtractedCardActivityInterval; +import at.procon.eventhub.tachographfilesession.model.ExtractedCardVehicleUsageInterval; +import at.procon.eventhub.tachographfilesession.model.ExtractedDriver; +import at.procon.eventhub.tachographfilesession.model.ExtractedDriverCard; +import at.procon.eventhub.tachographfilesession.model.ExtractedVehicle; +import at.procon.eventhub.tachographfilesession.model.ExtractedVehicleRegistration; +import at.procon.eventhub.tachographfilesession.model.ExtractionStats; +import at.procon.eventhub.tachographfilesession.model.ExtractionWarning; +import at.procon.eventhub.tachographfilesession.model.TachographFileSession; +import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathExpressionException; +import javax.xml.xpath.XPathFactory; +import org.springframework.stereotype.Component; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +@Component +public class VehicleUnitXmlExtractionService { + + private final DriverKeyFactory driverKeyFactory; + private final VehicleKeyFactory vehicleKeyFactory; + + public VehicleUnitXmlExtractionService(DriverKeyFactory driverKeyFactory, VehicleKeyFactory vehicleKeyFactory) { + this.driverKeyFactory = driverKeyFactory; + this.vehicleKeyFactory = vehicleKeyFactory; + } + + public TachographFileSession extract( + TachographXmlParser.ParsedTachographXml parsedXml, + TachographFileSessionMetadata metadata, + Instant createdAt, + Instant expiresAt + ) { + Document document = parsedXml.document(); + List sessionWarnings = new ArrayList<>(); + + VehicleContext vehicleContext = extractVehicleContext(document, sessionWarnings); + Map driversByKey = new LinkedHashMap<>(); + + if (nodes(document, "/VehicleUnit/Activities/vuActivityDailyData").getLength() > 0) { + sessionWarnings.add(new ExtractionWarning( + "VU_ACTIVITY_NOT_EXTRACTED", + "Vehicle-unit daily activity data is present but is not yet mapped into card activity intervals in this phase.", + "/VehicleUnit/Activities/vuActivityDailyData" + )); + } + + NodeList records = nodes(document, "/VehicleUnit/Activities/vuCardIWData/vuCardIWRecords"); + for (int i = 0; i < records.getLength(); i++) { + Element record = (Element) records.item(i); + String path = "/VehicleUnit/Activities/vuCardIWData/vuCardIWRecords[" + (i + 1) + "]"; + String driverIdentification = text(record, "fullCardNumber/cardNumber/driverIdentification"); + if (driverIdentification == null) { + continue; + } + String cardNation = text(record, "fullCardNumber/cardIssuingMemberState"); + String cardNumber = joinCardNumber(record, "fullCardNumber/cardNumber"); + if (cardNumber == null) { + sessionWarnings.add(new ExtractionWarning( + "MISSING_VU_DRIVER_CARD", + "Vehicle-unit insertion/withdrawal record is missing a usable driver card number.", + path + )); + continue; + } + String driverKey = driverKeyFactory.createDriverKey(cardNation, cardNumber); + DriverExtractionBuilder builder = driversByKey.computeIfAbsent( + driverKey, + ignored -> new DriverExtractionBuilder(driverKey, driverKeyFactory.createSourceDriverId(driverKey)) + ); + builder.mergeDriver( + text(record, "cardHolderName/holderSurname/name"), + text(record, "cardHolderName/holderFirstNames/name") + ); + builder.mergeDriverCard( + driverKeyFactory.createSourceDriverCardId(driverKey), + cardNation, + cardNumber, + null, + null, + null, + offsetDateTime(text(record, "cardExpiryDate")) + ); + builder.addVehicleContext(vehicleContext.registration(), vehicleContext.vehicle()); + + OffsetDateTime from = offsetDateTime(text(record, "cardInsertionTime")); + if (from == null) { + sessionWarnings.add(new ExtractionWarning( + "MISSING_VU_CARD_INSERTION", + "Vehicle-unit insertion/withdrawal record is missing cardInsertionTime.", + path + )); + continue; + } + OffsetDateTime to = offsetDateTime(text(record, "cardWithdrawalTime")); + if (to == null) { + to = vehicleContext.defaultOpenIntervalEnd(); + sessionWarnings.add(new ExtractionWarning( + "OPEN_VU_CARD_INTERVAL", + "Vehicle-unit insertion/withdrawal record has no withdrawal time; interval was closed using the VU downloadable-period end.", + path + )); + } + if (to == null || to.isBefore(from)) { + sessionWarnings.add(new ExtractionWarning( + "INVALID_VU_CARD_INTERVAL", + "Vehicle-unit insertion/withdrawal record has an invalid interval range.", + path + )); + continue; + } + + builder.vehicleUsageIntervals.add(new ExtractedCardVehicleUsageInterval( + "VUIW-" + (i + 1), + from, + to, + longValue(text(record, "vehicleOdometerValueAtInsertion")), + longValue(text(record, "vehicleOdometerValueAtWithdrawal")), + vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(), + vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(), + path + )); + } + + if (driversByKey.isEmpty()) { + sessionWarnings.add(new ExtractionWarning( + "NO_DRIVER_CARD_IW_DATA", + "Vehicle-unit XML did not contain driver-card insertion/withdrawal records to build driver sessions.", + "/VehicleUnit/Activities" + )); + } + + List allWarnings = new ArrayList<>(sessionWarnings); + Map driverSessions = new LinkedHashMap<>(); + int activityCount = 0; + int vehicleUsageCount = 0; + for (DriverExtractionBuilder builder : driversByKey.values()) { + builder.vehicleUsageIntervals.sort(Comparator.comparing(ExtractedCardVehicleUsageInterval::from)); + allWarnings.addAll(builder.warnings); + activityCount += builder.cardActivityIntervals.size(); + vehicleUsageCount += builder.vehicleUsageIntervals.size(); + driverSessions.put(builder.driverKey, builder.build()); + } + + int registrationCount = vehicleContext.registration() == null ? 0 : 1; + int vehicleCount = vehicleContext.vehicle() == null ? 0 : 1; + ExtractionStats stats = new ExtractionStats( + driverSessions.size(), + activityCount, + vehicleUsageCount, + registrationCount, + vehicleCount, + allWarnings.size() + ); + + return new TachographFileSession( + UUID.randomUUID(), + metadata, + Map.copyOf(driverSessions), + stats, + List.copyOf(allWarnings), + createdAt, + expiresAt + ); + } + + private VehicleContext extractVehicleContext(Document document, List warnings) { + Element overview = firstElement(document, "/VehicleUnit/Overview[1]"); + if (overview == null) { + warnings.add(new ExtractionWarning( + "MISSING_VU_OVERVIEW", + "Vehicle-unit XML does not contain an Overview block.", + "/VehicleUnit" + )); + return new VehicleContext(null, null, null); + } + + String registrationNation = text(overview, "vehicleRegistrationIdentification/vehicleRegistrationNation"); + String registrationNumber = text(overview, "vehicleRegistrationIdentification/vehicleRegistrationNumber/vehicleRegNumber"); + ExtractedVehicleRegistration registration = null; + String registrationKey = registrationNation == null && registrationNumber == null + ? null + : vehicleKeyFactory.createRegistrationKey(registrationNation, registrationNumber); + if (registrationKey != null) { + registration = new ExtractedVehicleRegistration( + registrationKey, + vehicleKeyFactory.createSourceVehicleRegistrationId(registrationKey), + registrationNation, + registrationNumber + ); + } + + String vin = text(overview, "vehicleIdentificationNumber"); + ExtractedVehicle vehicle = null; + String vehicleKey = vehicleKeyFactory.createVehicleKey(vin); + if (vehicleKey != null) { + vehicle = new ExtractedVehicle( + vehicleKey, + vehicleKeyFactory.createSourceVehicleId(vehicleKey), + vin + ); + } + + OffsetDateTime defaultEnd = offsetDateTime(text(overview, "vuDownloadablePeriod/maxDownloadableTime")); + return new VehicleContext(registration, vehicle, defaultEnd); + } + + private Element firstElement(Object node, String expression) { + NodeList nodes = nodes(node, expression); + if (nodes.getLength() == 0) { + return null; + } + return (Element) nodes.item(0); + } + + private NodeList nodes(Object node, String expression) { + try { + XPath xpath = XPathFactory.newInstance().newXPath(); + return (NodeList) xpath.evaluate(expression, node, XPathConstants.NODESET); + } catch (XPathExpressionException e) { + throw new IllegalStateException("Invalid XPath expression: " + expression, e); + } + } + + private String text(Object node, String expression) { + try { + XPath xpath = XPathFactory.newInstance().newXPath(); + String value = xpath.evaluate(expression, node); + return value == null || value.isBlank() ? null : value.trim(); + } catch (XPathExpressionException e) { + throw new IllegalStateException("Invalid XPath expression: " + expression, e); + } + } + + private OffsetDateTime offsetDateTime(String value) { + if (value == null || value.isBlank()) { + return null; + } + return OffsetDateTime.parse(value.trim()).withOffsetSameInstant(ZoneOffset.UTC); + } + + private Long longValue(String value) { + if (value == null || value.isBlank()) { + return null; + } + return Long.parseLong(value.trim()); + } + + private String joinCardNumber(Element node, String basePath) { + String driverIdentification = text(node, basePath + "/driverIdentification"); + if (driverIdentification == null) { + return null; + } + String replacement = text(node, basePath + "/cardReplacementIndex"); + String renewal = text(node, basePath + "/cardRenewalIndex"); + StringBuilder builder = new StringBuilder(driverIdentification); + if (replacement != null) { + builder.append(replacement); + } + if (renewal != null) { + builder.append(renewal); + } + return builder.toString(); + } + + private static final class DriverExtractionBuilder { + private final String driverKey; + private final String sourceDriverId; + private ExtractedDriver driver; + private ExtractedDriverCard driverCard; + private final Map registrationsByKey = new LinkedHashMap<>(); + private final Map vehiclesByKey = new LinkedHashMap<>(); + private final List vehicleUsageIntervals = new ArrayList<>(); + private final List cardActivityIntervals = new ArrayList<>(); + private final List warnings = new ArrayList<>(); + + private DriverExtractionBuilder(String driverKey, String sourceDriverId) { + this.driverKey = driverKey; + this.sourceDriverId = sourceDriverId; + } + + private void mergeDriver(String surname, String firstNames) { + if (driver == null) { + driver = new ExtractedDriver( + driverKey, + sourceDriverId, + surname, + firstNames, + null, + null, + null, + null, + null + ); + } + } + + private void mergeDriverCard( + String sourceDriverCardId, + String cardNation, + String cardNumber, + String issuingAuthorityName, + OffsetDateTime issueDate, + OffsetDateTime validityBegin, + OffsetDateTime expiryDate + ) { + if (driverCard == null) { + driverCard = new ExtractedDriverCard( + sourceDriverCardId, + cardNation, + cardNumber, + issuingAuthorityName, + issueDate, + validityBegin, + expiryDate + ); + } + } + + private void addVehicleContext(ExtractedVehicleRegistration registration, ExtractedVehicle vehicle) { + if (registration != null) { + registrationsByKey.putIfAbsent(registration.registrationKey(), registration); + } + if (vehicle != null) { + vehiclesByKey.putIfAbsent(vehicle.vehicleKey(), vehicle); + } + } + + private DriverExtractionSession build() { + return new DriverExtractionSession( + driverKey, + driver, + driverCard, + List.copyOf(registrationsByKey.values()), + List.copyOf(vehiclesByKey.values()), + List.copyOf(vehicleUsageIntervals), + List.copyOf(cardActivityIntervals), + List.copyOf(warnings) + ); + } + } + + private record VehicleContext( + ExtractedVehicleRegistration registration, + ExtractedVehicle vehicle, + OffsetDateTime defaultOpenIntervalEnd + ) { + } +} diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionServiceTest.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionServiceTest.java index c874953..1095730 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionServiceTest.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/DriverCardXmlExtractionServiceTest.java @@ -20,7 +20,7 @@ class DriverCardXmlExtractionServiceTest { @Test void extractsDriverCardVehiclesAndActivities() { TachographFileSession session = service.extract( - parser.parseDriverCardXml(DriverCardXmlSamples.validDriverCardXml()), + parser.parse(DriverCardXmlSamples.validDriverCardXml()), new TachographFileSessionMetadata( "default", "legalrequirements-drivercard", diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionServiceTest.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionServiceTest.java index aeff6cb..7cace0d 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionServiceTest.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographFileSessionServiceTest.java @@ -28,13 +28,22 @@ class TachographFileSessionServiceTest { TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties); LegalRequirementsClient client = Mockito.mock(LegalRequirementsClient.class); TachographXmlParser parser = Mockito.mock(TachographXmlParser.class); - DriverCardXmlExtractionService extractor = Mockito.mock(DriverCardXmlExtractionService.class); - TachographFileSessionService service = new TachographFileSessionService(properties, repository, client, parser, extractor); + DriverCardXmlExtractionService driverCardExtractor = Mockito.mock(DriverCardXmlExtractionService.class); + VehicleUnitXmlExtractionService vehicleUnitExtractor = Mockito.mock(VehicleUnitXmlExtractionService.class); + TachographFileSessionService service = new TachographFileSessionService( + properties, + repository, + client, + parser, + driverCardExtractor, + vehicleUnitExtractor + ); MockMultipartFile file = new MockMultipartFile("file", "sample.ddd", "application/octet-stream", "abc".getBytes(StandardCharsets.UTF_8)); - when(client.uploadDriverCard(any(), eq("sample.ddd"))).thenReturn(new LegalRequirementsUploadResult("42", "")); + when(client.uploadTachographFile(any(), eq("sample.ddd"))).thenReturn(new LegalRequirementsUploadResult("42", "")); TachographXmlParser.ParsedTachographXml parsed = Mockito.mock(TachographXmlParser.ParsedTachographXml.class); - when(parser.parseDriverCardXml("")).thenReturn(parsed); + when(parsed.rootElementName()).thenReturn("DriverCard"); + when(parser.parse("")).thenReturn(parsed); TachographFileSession extracted = new TachographFileSession( UUID.randomUUID(), new TachographFileSessionMetadata("default", "legalrequirements-drivercard", "sample", "sample.ddd", "a", 3, "42", "b", true, null), @@ -44,7 +53,7 @@ class TachographFileSessionServiceTest { Instant.now(), Instant.now().plusSeconds(10) ); - when(extractor.extract(eq(parsed), any(), any(), any())).thenReturn(extracted); + when(driverCardExtractor.extract(eq(parsed), any(), any(), any())).thenReturn(extracted); CreateTachographFileSessionResponse response = service.createSession(file, null, null, "sample"); @@ -59,8 +68,16 @@ class TachographFileSessionServiceTest { TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties); LegalRequirementsClient client = Mockito.mock(LegalRequirementsClient.class); TachographXmlParser parser = Mockito.mock(TachographXmlParser.class); - DriverCardXmlExtractionService extractor = Mockito.mock(DriverCardXmlExtractionService.class); - TachographFileSessionService service = new TachographFileSessionService(properties, repository, client, parser, extractor); + DriverCardXmlExtractionService driverCardExtractor = Mockito.mock(DriverCardXmlExtractionService.class); + VehicleUnitXmlExtractionService vehicleUnitExtractor = Mockito.mock(VehicleUnitXmlExtractionService.class); + TachographFileSessionService service = new TachographFileSessionService( + properties, + repository, + client, + parser, + driverCardExtractor, + vehicleUnitExtractor + ); MockMultipartFile file = new MockMultipartFile("file", "sample.ddd", "application/octet-stream", new byte[1025]); @@ -68,6 +85,45 @@ class TachographFileSessionServiceTest { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("size limit"); - verifyNoInteractions(client, parser, extractor); + verifyNoInteractions(client, parser, driverCardExtractor, vehicleUnitExtractor); + } + + @Test + void usesVehicleUnitDefaultsAndExtractorForVehicleUnitXml() { + EventHubProperties properties = new EventHubProperties(); + TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties); + LegalRequirementsClient client = Mockito.mock(LegalRequirementsClient.class); + TachographXmlParser parser = Mockito.mock(TachographXmlParser.class); + DriverCardXmlExtractionService driverCardExtractor = Mockito.mock(DriverCardXmlExtractionService.class); + VehicleUnitXmlExtractionService vehicleUnitExtractor = Mockito.mock(VehicleUnitXmlExtractionService.class); + TachographFileSessionService service = new TachographFileSessionService( + properties, + repository, + client, + parser, + driverCardExtractor, + vehicleUnitExtractor + ); + + MockMultipartFile file = new MockMultipartFile("file", "vu.ddd", "application/octet-stream", "vu".getBytes(StandardCharsets.UTF_8)); + when(client.uploadTachographFile(any(), eq("vu.ddd"))).thenReturn(new LegalRequirementsUploadResult("77", "")); + TachographXmlParser.ParsedTachographXml parsed = Mockito.mock(TachographXmlParser.ParsedTachographXml.class); + when(parsed.rootElementName()).thenReturn("VehicleUnit"); + when(parser.parse("")).thenReturn(parsed); + TachographFileSession extracted = new TachographFileSession( + UUID.randomUUID(), + new TachographFileSessionMetadata("default", "legalrequirements-vehicleunit", "vu", "vu.ddd", "a", 2, "77", "b", false, null), + Map.of(), + new ExtractionStats(0, 0, 0, 0, 0, 0), + java.util.List.of(), + Instant.now(), + Instant.now().plusSeconds(10) + ); + when(vehicleUnitExtractor.extract(eq(parsed), any(), any(), any())).thenReturn(extracted); + + CreateTachographFileSessionResponse response = service.createSession(file, null, null, "vu"); + + assertThat(response.session().driverCardFile()).isFalse(); + assertThat(response.session().sourceInstanceKey()).isEqualTo("legalrequirements-vehicleunit"); } } diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographXmlParserTest.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographXmlParserTest.java index 17c72c9..839197d 100644 --- a/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographXmlParserTest.java +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/TachographXmlParserTest.java @@ -11,17 +11,25 @@ class TachographXmlParserTest { @Test void parsesValidDriverCardXml() { - TachographXmlParser.ParsedTachographXml parsed = parser.parseDriverCardXml(DriverCardXmlSamples.validDriverCardXml()); + TachographXmlParser.ParsedTachographXml parsed = parser.parse(DriverCardXmlSamples.validDriverCardXml()); assertThat(parsed.rootElementName()).isEqualTo("DriverCard"); assertThat(parsed.document().getDocumentElement().getNodeName()).isEqualTo("DriverCard"); } + @Test + void parsesValidVehicleUnitXml() { + TachographXmlParser.ParsedTachographXml parsed = parser.parse(""); + + assertThat(parsed.rootElementName()).isEqualTo("VehicleUnit"); + assertThat(parsed.document().getDocumentElement().getNodeName()).isEqualTo("VehicleUnit"); + } + @Test void rejectsInvalidXmlAgainstSchema() { String invalid = ""; - assertThatThrownBy(() -> parser.parseDriverCardXml(invalid)) + assertThatThrownBy(() -> parser.parse(invalid)) .isInstanceOf(TachographXmlValidationException.class); } @@ -34,7 +42,7 @@ class TachographXmlParserTest { """; - assertThatThrownBy(() -> parser.parseDriverCardXml(xmlWithDoctype)) + assertThatThrownBy(() -> parser.parse(xmlWithDoctype)) .isInstanceOf(TachographXmlValidationException.class); } } diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionServiceTest.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionServiceTest.java new file mode 100644 index 0000000..a2544e4 --- /dev/null +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlExtractionServiceTest.java @@ -0,0 +1,68 @@ +package at.procon.eventhub.tachographfilesession.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession; +import at.procon.eventhub.tachographfilesession.model.TachographFileSession; +import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata; +import java.io.StringReader; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import javax.xml.parsers.DocumentBuilderFactory; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.xml.sax.InputSource; + +class VehicleUnitXmlExtractionServiceTest { + + private final VehicleUnitXmlExtractionService service = new VehicleUnitXmlExtractionService( + new DriverKeyFactory(), + new VehicleKeyFactory() + ); + + @Test + void extractsVehicleUnitDriverSessionsFromCardInsertionWithdrawalData() throws Exception { + TachographFileSession session = service.extract( + new TachographXmlParser.ParsedTachographXml(document(VehicleUnitXmlSamples.vehicleUnitXml()), "VehicleUnit"), + new TachographFileSessionMetadata( + "default", + "legalrequirements-vehicleunit", + "sample-vu", + "sample-vu.ddd", + "abc", + 10, + "42", + "def", + false, + null + ), + Instant.now(), + Instant.now().plus(4, ChronoUnit.HOURS) + ); + + assertThat(session.driversByKey()).hasSize(2); + DriverExtractionSession firstDriver = session.driversByKey().get("12:12345678901200"); + DriverExtractionSession secondDriver = session.driversByKey().get("12:99999999999911"); + assertThat(firstDriver).isNotNull(); + assertThat(secondDriver).isNotNull(); + + assertThat(firstDriver.driver().surname()).isEqualTo("Muster"); + assertThat(firstDriver.vehicleRegistrations()).extracting("registrationKey").containsExactly("12:W-1000V"); + assertThat(firstDriver.vehicles()).extracting("vehicleKey").containsExactly("VINVU123456789012"); + assertThat(firstDriver.cardVehicleUsageIntervals()).hasSize(1); + assertThat(firstDriver.cardVehicleUsageIntervals().get(0).from().toString()).isEqualTo("2026-04-01T08:00Z"); + assertThat(firstDriver.cardVehicleUsageIntervals().get(0).to().toString()).isEqualTo("2026-04-01T11:00Z"); + + assertThat(secondDriver.cardVehicleUsageIntervals()).hasSize(1); + assertThat(secondDriver.cardVehicleUsageIntervals().get(0).to().toString()).isEqualTo("2026-04-02T10:00Z"); + + assertThat(session.warnings()).extracting("code") + .contains("VU_ACTIVITY_NOT_EXTRACTED", "OPEN_VU_CARD_INTERVAL"); + } + + private Document document(String xml) throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(false); + return factory.newDocumentBuilder().parse(new InputSource(new StringReader(xml))); + } +} diff --git a/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlSamples.java b/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlSamples.java new file mode 100644 index 0000000..fd56887 --- /dev/null +++ b/src/test/java/at/procon/eventhub/tachographfilesession/service/VehicleUnitXmlSamples.java @@ -0,0 +1,72 @@ +package at.procon.eventhub.tachographfilesession.service; + +final class VehicleUnitXmlSamples { + + private VehicleUnitXmlSamples() { + } + + static String vehicleUnitXml() { + return """ + + + VINVU123456789012 + + 12 + W-1000V + + + 2026-04-02T10:00:00Z + + + + + + + Muster + Max + + + DRIVER_CARD + 12 + + 123456789012 + 0 + 0 + + 2 + + 2031-04-01T00:00:00Z + 2026-04-01T08:00:00Z + 1000 + DRIVER + 2026-04-01T11:00:00Z + 1100 + NO_ENTRY + + + + Test + Tina + + + DRIVER_CARD + 12 + + 999999999999 + 1 + 1 + + 2 + + 2026-04-02T07:30:00Z + 1200 + DRIVER + MANUAL_ENTRIES + + + + + + """; + } +}