Restore tachographfilesession changes from 2f4ffc2

This commit is contained in:
trifonovt 2026-06-10 17:40:33 +02:00
parent 91c2da02fc
commit 8a106c02c5
12 changed files with 458 additions and 28 deletions

View File

@ -11,6 +11,8 @@ import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionListDri
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto; import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto;
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionProcessingService; import at.procon.eventhub.tachographfilesession.service.TachographFileSessionProcessingService;
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionService; import at.procon.eventhub.tachographfilesession.service.TachographFileSessionService;
import at.procon.eventhub.tachographfilesession.dto.TachographUploadWorkflowResponse;
import jakarta.servlet.http.HttpSession;
import java.util.UUID; import java.util.UUID;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -44,10 +46,21 @@ public class TachographFileSessionController {
@RequestParam("file") MultipartFile file, @RequestParam("file") MultipartFile file,
@RequestParam(required = false) String tenantKey, @RequestParam(required = false) String tenantKey,
@RequestParam(required = false) String sourceInstanceKey, @RequestParam(required = false) String sourceInstanceKey,
@RequestParam(required = false) String sessionLabel @RequestParam(required = false) String sessionLabel,
HttpSession webSession
) { ) {
return ResponseEntity.status(HttpStatus.CREATED) return ResponseEntity.status(HttpStatus.CREATED)
.body(service.createSession(file, tenantKey, sourceInstanceKey, sessionLabel)); .body(service.createSession(file, tenantKey, sourceInstanceKey, sessionLabel, webSession));
}
@PostMapping("/workflow/reset")
public ResponseEntity<TachographUploadWorkflowResponse> resetUploadWorkflow(HttpSession webSession) {
return ResponseEntity.ok(service.resetUploadWorkflow(webSession));
}
@GetMapping("/workflow")
public ResponseEntity<TachographUploadWorkflowResponse> getUploadWorkflow(HttpSession webSession) {
return ResponseEntity.ok(service.getUploadWorkflow(webSession));
} }
@GetMapping("/{sessionId}") @GetMapping("/{sessionId}")

View File

@ -1,6 +1,12 @@
package at.procon.eventhub.tachographfilesession.dto; package at.procon.eventhub.tachographfilesession.dto;
public record CreateTachographFileSessionResponse( public record CreateTachographFileSessionResponse(
TachographFileSessionSummaryDto session TachographFileSessionSummaryDto session,
TachographUploadWorkflowDto workflow,
TachographCompositeSessionSummaryDto compositeSession
) { ) {
public CreateTachographFileSessionResponse(TachographFileSessionSummaryDto session) {
this(session, null, null);
}
} }

View File

@ -0,0 +1,13 @@
package at.procon.eventhub.tachographfilesession.dto;
import java.util.List;
import java.util.UUID;
public record TachographUploadWorkflowDto(
boolean driverCardUploaded,
UUID driverCardSessionId,
List<UUID> uploadedSessionIds,
List<UUID> vehicleUnitSessionIds,
UUID compositeSessionId
) {
}

View File

@ -0,0 +1,6 @@
package at.procon.eventhub.tachographfilesession.dto;
public record TachographUploadWorkflowResponse(
TachographUploadWorkflowDto workflow
) {
}

View File

@ -28,10 +28,8 @@ public class LegalRequirementsClient {
public LegalRequirementsUploadResult uploadTachographFile(byte[] fileBytes, String fileName) { public LegalRequirementsUploadResult uploadTachographFile(byte[] fileBytes, String fileName) {
EventHubProperties.LegalRequirements config = properties.getTachographFileSession().getLegalRequirements(); EventHubProperties.LegalRequirements config = properties.getTachographFileSession().getLegalRequirements();
HttpClient client = HttpClient.newBuilder() CookieManager cookieManager = newCookieManager();
.connectTimeout(config.getConnectTimeout()) HttpClient client = httpClient(config, cookieManager);
.cookieHandler(new CookieManager(null, CookiePolicy.ACCEPT_ALL))
.build();
try { try {
String dataPackageId = uploadDataPackage(client, config, fileBytes, fileName); String dataPackageId = uploadDataPackage(client, config, fileBytes, fileName);
String xml = downloadXml(client, config, dataPackageId); String xml = downloadXml(client, config, dataPackageId);
@ -43,6 +41,23 @@ public class LegalRequirementsClient {
} }
} }
public LegalRequirementsUploadResult uploadTachographFile(CookieManager cookieManager, byte[] fileBytes, String fileName) {
EventHubProperties.LegalRequirements config = properties.getTachographFileSession().getLegalRequirements();
HttpClient client = httpClient(config, cookieManager);
String dataPackageId = uploadDataPackage(client, config, fileBytes, fileName);
String xml = downloadXml(client, config, dataPackageId);
return new LegalRequirementsUploadResult(dataPackageId, xml);
}
public void resetSession(CookieManager cookieManager) {
EventHubProperties.LegalRequirements config = properties.getTachographFileSession().getLegalRequirements();
resetSessionQuietly(httpClient(config, cookieManager), config);
}
public CookieManager newCookieManager() {
return new CookieManager(null, CookiePolicy.ACCEPT_ALL);
}
private String uploadDataPackage(HttpClient client, EventHubProperties.LegalRequirements config, byte[] fileBytes, String fileName) { private String uploadDataPackage(HttpClient client, EventHubProperties.LegalRequirements config, byte[] fileBytes, String fileName) {
try { try {
String payload = objectMapper.createObjectNode() String payload = objectMapper.createObjectNode()
@ -115,6 +130,13 @@ public class LegalRequirementsClient {
return HttpRequest.newBuilder(URI.create(url)).timeout(timeout); return HttpRequest.newBuilder(URI.create(url)).timeout(timeout);
} }
private HttpClient httpClient(EventHubProperties.LegalRequirements config, CookieManager cookieManager) {
return HttpClient.newBuilder()
.connectTimeout(config.getConnectTimeout())
.cookieHandler(cookieManager == null ? newCookieManager() : cookieManager)
.build();
}
private String basicAuth(EventHubProperties.LegalRequirements config) { private String basicAuth(EventHubProperties.LegalRequirements config) {
String user = config.getUsername() == null ? "" : config.getUsername(); String user = config.getUsername() == null ? "" : config.getUsername();
String password = config.getPassword() == null ? "" : config.getPassword(); String password = config.getPassword() == null ? "" : config.getPassword();

View File

@ -56,10 +56,20 @@ public class TachographCompositeSessionService {
} }
public CreateTachographCompositeSessionResponse createCompositeSession(CreateTachographCompositeSessionRequest request) { public CreateTachographCompositeSessionResponse createCompositeSession(CreateTachographCompositeSessionRequest request) {
if (request == null || request.sessionIds() == null || request.sessionIds().isEmpty()) { return new CreateTachographCompositeSessionResponse(
upsertCompositeSession(null, request == null ? null : request.sessionIds(), request == null ? null : request.label())
);
}
public TachographCompositeSessionSummaryDto upsertCompositeSession(
UUID compositeSessionId,
List<UUID> requestedSessionIds,
String label
) {
if (requestedSessionIds == null || requestedSessionIds.isEmpty()) {
throw new IllegalArgumentException("sessionIds must not be empty."); throw new IllegalArgumentException("sessionIds must not be empty.");
} }
List<UUID> memberSessionIds = request.sessionIds().stream() List<UUID> memberSessionIds = requestedSessionIds.stream()
.filter(Objects::nonNull) .filter(Objects::nonNull)
.distinct() .distinct()
.toList(); .toList();
@ -72,14 +82,20 @@ public class TachographCompositeSessionService {
.filter(value -> value != null && !value.isBlank()) .filter(value -> value != null && !value.isBlank())
.findFirst() .findFirst()
.orElse("default"); .orElse("default");
UUID id = compositeSessionId == null ? UUID.randomUUID() : compositeSessionId;
Instant createdAt = compositeSessionId == null
? Instant.now()
: compositeRepository.find(compositeSessionId)
.map(TachographCompositeSession::createdAt)
.orElseGet(Instant::now);
TachographCompositeSession compositeSession = compositeRepository.save(new TachographCompositeSession( TachographCompositeSession compositeSession = compositeRepository.save(new TachographCompositeSession(
UUID.randomUUID(), id,
tenantKey, tenantKey,
blankToNull(request.label()), blankToNull(label),
memberSessionIds, memberSessionIds,
Instant.now() createdAt
)); ));
return new CreateTachographCompositeSessionResponse(toSummary(compositeSession, sessions)); return toSummary(compositeSession, sessions);
} }
public TachographCompositeSessionSummaryDto getCompositeSession(UUID compositeSessionId) { public TachographCompositeSessionSummaryDto getCompositeSession(UUID compositeSessionId) {

View File

@ -2,14 +2,18 @@ package at.procon.eventhub.tachographfilesession.service;
import at.procon.eventhub.config.EventHubProperties; import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.tachographfilesession.dto.CreateTachographFileSessionResponse; import at.procon.eventhub.tachographfilesession.dto.CreateTachographFileSessionResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographCompositeSessionSummaryDto;
import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverDetailDto; import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverDetailDto;
import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverSummaryDto; import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverSummaryDto;
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionDeleteResponse; import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionDeleteResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionListDriversResponse; import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionListDriversResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto; import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto;
import at.procon.eventhub.tachographfilesession.dto.TachographUploadWorkflowResponse;
import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession; import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession; import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata; import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
import jakarta.servlet.http.HttpSession;
import java.net.CookieManager;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
@ -27,9 +31,14 @@ import org.springframework.web.multipart.MultipartFile;
public class TachographFileSessionService { public class TachographFileSessionService {
private static final Logger log = LoggerFactory.getLogger(TachographFileSessionService.class); private static final Logger log = LoggerFactory.getLogger(TachographFileSessionService.class);
private static final String LEGAL_REQUIREMENTS_COOKIE_MANAGER_ATTRIBUTE =
TachographFileSessionService.class.getName() + ".legalRequirementsCookieManager";
private static final String WORKFLOW_STATE_ATTRIBUTE =
TachographFileSessionService.class.getName() + ".uploadWorkflowState";
private final EventHubProperties properties; private final EventHubProperties properties;
private final TachographFileSessionRepository repository; private final TachographFileSessionRepository repository;
private final TachographCompositeSessionService compositeSessionService;
private final LegalRequirementsClient legalRequirementsClient; private final LegalRequirementsClient legalRequirementsClient;
private final TachographXmlParser tachographXmlParser; private final TachographXmlParser tachographXmlParser;
private final DriverCardXmlExtractionService driverCardExtractionService; private final DriverCardXmlExtractionService driverCardExtractionService;
@ -38,6 +47,7 @@ public class TachographFileSessionService {
public TachographFileSessionService( public TachographFileSessionService(
EventHubProperties properties, EventHubProperties properties,
TachographFileSessionRepository repository, TachographFileSessionRepository repository,
TachographCompositeSessionService compositeSessionService,
LegalRequirementsClient legalRequirementsClient, LegalRequirementsClient legalRequirementsClient,
TachographXmlParser tachographXmlParser, TachographXmlParser tachographXmlParser,
DriverCardXmlExtractionService driverCardExtractionService, DriverCardXmlExtractionService driverCardExtractionService,
@ -45,6 +55,7 @@ public class TachographFileSessionService {
) { ) {
this.properties = properties; this.properties = properties;
this.repository = repository; this.repository = repository;
this.compositeSessionService = compositeSessionService;
this.legalRequirementsClient = legalRequirementsClient; this.legalRequirementsClient = legalRequirementsClient;
this.tachographXmlParser = tachographXmlParser; this.tachographXmlParser = tachographXmlParser;
this.driverCardExtractionService = driverCardExtractionService; this.driverCardExtractionService = driverCardExtractionService;
@ -55,14 +66,21 @@ public class TachographFileSessionService {
MultipartFile file, MultipartFile file,
String tenantKey, String tenantKey,
String sourceInstanceKey, String sourceInstanceKey,
String sessionLabel String sessionLabel,
HttpSession webSession
) { ) {
try { try {
if (webSession == null) {
throw new IllegalArgumentException("HTTP session is required for tachograph upload workflow.");
}
validateFile(file); validateFile(file);
byte[] fileBytes = file.getBytes(); byte[] fileBytes = file.getBytes();
validateFileBytes(fileBytes); validateFileBytes(fileBytes);
CookieManager cookieManager = cookieManager(webSession);
TachographUploadWorkflowState workflowState = workflowState(webSession);
long uploadStartedAt = System.nanoTime(); long uploadStartedAt = System.nanoTime();
LegalRequirementsUploadResult uploadResult = legalRequirementsClient.uploadTachographFile(fileBytes, file.getOriginalFilename()); LegalRequirementsUploadResult uploadResult =
legalRequirementsClient.uploadTachographFile(cookieManager, fileBytes, file.getOriginalFilename());
long uploadDurationMs = elapsedMillis(uploadStartedAt); long uploadDurationMs = elapsedMillis(uploadStartedAt);
long parseStartedAt = System.nanoTime(); long parseStartedAt = System.nanoTime();
@ -72,6 +90,7 @@ public class TachographFileSessionService {
Instant createdAt = Instant.now(); Instant createdAt = Instant.now();
Instant expiresAt = createdAt.plus(properties.getTachographFileSession().getTtl()); Instant expiresAt = createdAt.plus(properties.getTachographFileSession().getTtl());
boolean driverCardFile = "DriverCard".equals(parsedXml.rootElementName()); boolean driverCardFile = "DriverCard".equals(parsedXml.rootElementName());
validateWorkflowSequence(webSession, workflowState, cookieManager, driverCardFile);
TachographFileSessionMetadata metadata = new TachographFileSessionMetadata( TachographFileSessionMetadata metadata = new TachographFileSessionMetadata(
tenant(tenantKey), tenant(tenantKey),
sourceInstance(sourceInstanceKey, driverCardFile), sourceInstance(sourceInstanceKey, driverCardFile),
@ -100,7 +119,10 @@ public class TachographFileSessionService {
fileBytes.length fileBytes.length
); );
TachographFileSession saved = repository.save(session); TachographFileSession saved = repository.save(session);
return new CreateTachographFileSessionResponse(toSummary(saved)); workflowState.recordUploadedSession(saved.sessionId(), driverCardFile);
TachographCompositeSessionSummaryDto compositeSession = updateCompositeSession(workflowState, sessionLabel);
webSession.setAttribute(WORKFLOW_STATE_ATTRIBUTE, workflowState);
return new CreateTachographFileSessionResponse(toSummary(saved), workflowState.toDto(), compositeSession);
} catch (Exception e) { } catch (Exception e) {
if (e instanceof RuntimeException runtimeException) { if (e instanceof RuntimeException runtimeException) {
throw runtimeException; throw runtimeException;
@ -109,6 +131,27 @@ public class TachographFileSessionService {
} }
} }
public TachographUploadWorkflowResponse resetUploadWorkflow(HttpSession webSession) {
if (webSession == null) {
throw new IllegalArgumentException("HTTP session is required for tachograph upload workflow.");
}
CookieManager existingCookieManager = existingCookieManager(webSession);
if (existingCookieManager != null) {
legalRequirementsClient.resetSession(existingCookieManager);
}
webSession.setAttribute(LEGAL_REQUIREMENTS_COOKIE_MANAGER_ATTRIBUTE, legalRequirementsClient.newCookieManager());
TachographUploadWorkflowState state = new TachographUploadWorkflowState();
webSession.setAttribute(WORKFLOW_STATE_ATTRIBUTE, state);
return new TachographUploadWorkflowResponse(state.toDto());
}
public TachographUploadWorkflowResponse getUploadWorkflow(HttpSession webSession) {
if (webSession == null) {
throw new IllegalArgumentException("HTTP session is required for tachograph upload workflow.");
}
return new TachographUploadWorkflowResponse(workflowState(webSession).toDto());
}
public TachographFileSessionSummaryDto getSession(UUID sessionId) { public TachographFileSessionSummaryDto getSession(UUID sessionId) {
return toSummary(requireSession(sessionId)); return toSummary(requireSession(sessionId));
} }
@ -146,6 +189,27 @@ public class TachographFileSessionService {
return repository.find(sessionId).orElseThrow(() -> new TachographFileSessionNotFoundException(sessionId)); return repository.find(sessionId).orElseThrow(() -> new TachographFileSessionNotFoundException(sessionId));
} }
private TachographCompositeSessionSummaryDto updateCompositeSession(
TachographUploadWorkflowState workflowState,
String sessionLabel
) {
if (workflowState.uploadedSessionIds().size() < 2) {
return null;
}
TachographCompositeSessionSummaryDto compositeSession = compositeSessionService.upsertCompositeSession(
workflowState.compositeSessionId(),
workflowState.uploadedSessionIds(),
compositeSessionLabel(sessionLabel)
);
workflowState.compositeSessionId(compositeSession.compositeSessionId());
return compositeSession;
}
private String compositeSessionLabel(String sessionLabel) {
String normalizedLabel = blankToNull(sessionLabel);
return normalizedLabel == null ? "tachograph-upload-workflow" : normalizedLabel;
}
private TachographFileSessionSummaryDto toSummary(TachographFileSession session) { private TachographFileSessionSummaryDto toSummary(TachographFileSession session) {
return new TachographFileSessionSummaryDto( return new TachographFileSessionSummaryDto(
session.sessionId(), session.sessionId(),
@ -207,6 +271,63 @@ public class TachographFileSessionService {
return driverCardFile ? "legalrequirements-drivercard" : "legalrequirements-vehicleunit"; return driverCardFile ? "legalrequirements-drivercard" : "legalrequirements-vehicleunit";
} }
private void validateWorkflowSequence(
HttpSession webSession,
TachographUploadWorkflowState workflowState,
CookieManager cookieManager,
boolean driverCardFile
) {
try {
validateWorkflowSequenceInternal(workflowState, driverCardFile);
} catch (IllegalArgumentException exception) {
resetWorkflowState(webSession, cookieManager);
throw exception;
}
}
private void validateWorkflowSequenceInternal(TachographUploadWorkflowState workflowState, boolean driverCardFile) {
if (!workflowState.driverCardUploaded() && !workflowState.uploadedSessionIds().isEmpty() && driverCardFile) {
throw new IllegalArgumentException("Workflow already contains uploaded files. Reset the workflow before starting a new driver card import.");
}
if (!workflowState.driverCardUploaded() && !driverCardFile) {
throw new IllegalArgumentException("Driver card file must be uploaded first after workflow reset.");
}
if (workflowState.driverCardUploaded() && driverCardFile) {
throw new IllegalArgumentException("Workflow already contains a driver card file. Upload vehicle-unit files next or reset the workflow.");
}
}
private CookieManager cookieManager(HttpSession webSession) {
CookieManager existing = existingCookieManager(webSession);
if (existing != null) {
return existing;
}
CookieManager created = legalRequirementsClient.newCookieManager();
webSession.setAttribute(LEGAL_REQUIREMENTS_COOKIE_MANAGER_ATTRIBUTE, created);
return created;
}
private CookieManager existingCookieManager(HttpSession webSession) {
Object value = webSession.getAttribute(LEGAL_REQUIREMENTS_COOKIE_MANAGER_ATTRIBUTE);
return value instanceof CookieManager cookieManager ? cookieManager : null;
}
private TachographUploadWorkflowState workflowState(HttpSession webSession) {
Object value = webSession.getAttribute(WORKFLOW_STATE_ATTRIBUTE);
if (value instanceof TachographUploadWorkflowState state) {
return state;
}
TachographUploadWorkflowState state = new TachographUploadWorkflowState();
webSession.setAttribute(WORKFLOW_STATE_ATTRIBUTE, state);
return state;
}
private void resetWorkflowState(HttpSession webSession, CookieManager cookieManager) {
legalRequirementsClient.resetSession(cookieManager);
webSession.setAttribute(LEGAL_REQUIREMENTS_COOKIE_MANAGER_ATTRIBUTE, legalRequirementsClient.newCookieManager());
webSession.setAttribute(WORKFLOW_STATE_ATTRIBUTE, new TachographUploadWorkflowState());
}
private String blankToNull(String value) { private String blankToNull(String value) {
return value == null || value.isBlank() ? null : value.trim(); return value == null || value.isBlank() ? null : value.trim();
} }

View File

@ -0,0 +1,57 @@
package at.procon.eventhub.tachographfilesession.service;
import at.procon.eventhub.tachographfilesession.dto.TachographUploadWorkflowDto;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
final class TachographUploadWorkflowState {
private UUID driverCardSessionId;
private final List<UUID> uploadedSessionIds = new ArrayList<>();
private final List<UUID> vehicleUnitSessionIds = new ArrayList<>();
private UUID compositeSessionId;
boolean driverCardUploaded() {
return driverCardSessionId != null;
}
UUID driverCardSessionId() {
return driverCardSessionId;
}
List<UUID> uploadedSessionIds() {
return List.copyOf(uploadedSessionIds);
}
List<UUID> vehicleUnitSessionIds() {
return List.copyOf(vehicleUnitSessionIds);
}
UUID compositeSessionId() {
return compositeSessionId;
}
void recordUploadedSession(UUID sessionId, boolean driverCardFile) {
uploadedSessionIds.add(sessionId);
if (driverCardFile) {
driverCardSessionId = sessionId;
return;
}
vehicleUnitSessionIds.add(sessionId);
}
void compositeSessionId(UUID compositeSessionId) {
this.compositeSessionId = compositeSessionId;
}
TachographUploadWorkflowDto toDto() {
return new TachographUploadWorkflowDto(
driverCardUploaded(),
driverCardSessionId,
uploadedSessionIds(),
vehicleUnitSessionIds(),
compositeSessionId
);
}
}

View File

@ -4,6 +4,10 @@ import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.StringReader; import java.io.StringReader;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HexFormat;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.xml.XMLConstants; import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.DocumentBuilderFactory;
@ -21,6 +25,9 @@ import org.xml.sax.SAXException;
public class TachographXmlParser { public class TachographXmlParser {
private static final String JAXP_MAX_OCCUR_LIMIT = "http://www.oracle.com/xml/jaxp/properties/maxOccurLimit"; private static final String JAXP_MAX_OCCUR_LIMIT = "http://www.oracle.com/xml/jaxp/properties/maxOccurLimit";
private static final Pattern MANUFACTURER_SPECIFIC_ERROR_CODE_PATTERN = Pattern.compile(
"(<manufacturerSpecificErrorCode>)([^<\\s]+)(</manufacturerSpecificErrorCode>)"
);
private final Schema schema; private final Schema schema;
@ -62,11 +69,45 @@ public class TachographXmlParser {
} }
} }
private String normalizeXmlContent(String xmlContent) { String normalizeXmlContent(String xmlContent) {
if (xmlContent == null || xmlContent.isEmpty()) { if (xmlContent == null || xmlContent.isEmpty()) {
return xmlContent; return xmlContent;
} }
return xmlContent.charAt(0) == '\uFEFF' ? xmlContent.substring(1) : xmlContent; String normalized = xmlContent.charAt(0) == '\uFEFF' ? xmlContent.substring(1) : xmlContent;
return normalizeManufacturerSpecificErrorCodes(normalized);
}
private String normalizeManufacturerSpecificErrorCodes(String xmlContent) {
Matcher matcher = MANUFACTURER_SPECIFIC_ERROR_CODE_PATTERN.matcher(xmlContent);
StringBuffer normalized = new StringBuffer(xmlContent.length());
while (matcher.find()) {
String rawValue = matcher.group(2);
String replacementValue = normalizeThreeByteOctetString(rawValue);
matcher.appendReplacement(
normalized,
Matcher.quoteReplacement(matcher.group(1) + replacementValue + matcher.group(3))
);
}
matcher.appendTail(normalized);
return normalized.toString();
}
private String normalizeThreeByteOctetString(String rawValue) {
if (rawValue == null || rawValue.isBlank()) {
return rawValue;
}
if (rawValue.matches("(?i)[0-9a-f]{6}")) {
return rawValue.toUpperCase();
}
try {
byte[] decoded = Base64.getDecoder().decode(rawValue);
if (decoded.length == 3) {
return HexFormat.of().formatHex(decoded).toUpperCase();
}
return rawValue;
} catch (IllegalArgumentException ignored) {
return rawValue;
}
} }
private Schema loadSchema() { private Schema loadSchema() {

View File

@ -18,6 +18,8 @@ import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverSummaryD
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionDeleteResponse; import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionDeleteResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionListDriversResponse; import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionListDriversResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto; import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummaryDto;
import at.procon.eventhub.tachographfilesession.dto.TachographUploadWorkflowDto;
import at.procon.eventhub.tachographfilesession.dto.TachographUploadWorkflowResponse;
import at.procon.eventhub.tachographfilesession.model.ExtractionStats; import at.procon.eventhub.tachographfilesession.model.ExtractionStats;
import at.procon.eventhub.tachographfilesession.model.TachographEsperActivityIntervalEvent; import at.procon.eventhub.tachographfilesession.model.TachographEsperActivityIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent; import at.procon.eventhub.tachographfilesession.model.TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent;
@ -34,6 +36,7 @@ import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders;
@ -49,6 +52,7 @@ class TachographFileSessionControllerTest {
.setControllerAdvice(new TachographFileSessionExceptionHandler()) .setControllerAdvice(new TachographFileSessionExceptionHandler())
.build(); .build();
UUID sessionId = UUID.randomUUID(); UUID sessionId = UUID.randomUUID();
MockHttpSession webSession = new MockHttpSession();
TachographFileDriverSummaryDto driver = new TachographFileDriverSummaryDto("12:123", "Muster", "Max", "12", "123", 3, 2); TachographFileDriverSummaryDto driver = new TachographFileDriverSummaryDto("12:123", "Muster", "Max", "12", "123", 3, 2);
TachographFileSessionSummaryDto summary = new TachographFileSessionSummaryDto( TachographFileSessionSummaryDto summary = new TachographFileSessionSummaryDto(
sessionId, sessionId,
@ -64,8 +68,26 @@ class TachographFileSessionControllerTest {
Instant.parse("2026-05-12T10:00:00Z"), Instant.parse("2026-05-12T10:00:00Z"),
Instant.parse("2026-05-12T14:00:00Z") Instant.parse("2026-05-12T14:00:00Z")
); );
when(service.createSession(org.mockito.ArgumentMatchers.any(), eq("default"), eq("src"), eq("sample"))) when(service.createSession(
.thenReturn(new CreateTachographFileSessionResponse(summary)); org.mockito.ArgumentMatchers.any(),
eq("default"),
eq("src"),
eq("sample"),
org.mockito.ArgumentMatchers.any(jakarta.servlet.http.HttpSession.class)
))
.thenReturn(new CreateTachographFileSessionResponse(
summary,
new TachographUploadWorkflowDto(true, sessionId, List.of(sessionId), List.of(), null),
null
));
when(service.resetUploadWorkflow(org.mockito.ArgumentMatchers.any(jakarta.servlet.http.HttpSession.class)))
.thenReturn(new TachographUploadWorkflowResponse(
new TachographUploadWorkflowDto(false, null, List.of(), List.of(), null)
));
when(service.getUploadWorkflow(org.mockito.ArgumentMatchers.any(jakarta.servlet.http.HttpSession.class)))
.thenReturn(new TachographUploadWorkflowResponse(
new TachographUploadWorkflowDto(true, sessionId, List.of(sessionId), List.of(), null)
));
when(service.getSession(sessionId)).thenReturn(summary); when(service.getSession(sessionId)).thenReturn(summary);
when(service.listDrivers(sessionId)).thenReturn(new TachographFileSessionListDriversResponse(sessionId, List.of(driver))); when(service.listDrivers(sessionId)).thenReturn(new TachographFileSessionListDriversResponse(sessionId, List.of(driver)));
when(service.getDriver(sessionId, "12:123")).thenReturn(new TachographFileDriverDetailDto(sessionId, "12:123", null, null, List.of(), List.of(), List.of(), List.of(), List.of(), List.of())); when(service.getDriver(sessionId, "12:123")).thenReturn(new TachographFileDriverDetailDto(sessionId, "12:123", null, null, List.of(), List.of(), List.of(), List.of(), List.of(), List.of()));
@ -319,13 +341,23 @@ class TachographFileSessionControllerTest {
)); ));
when(service.deleteSession(sessionId)).thenReturn(new TachographFileSessionDeleteResponse(sessionId, true)); when(service.deleteSession(sessionId)).thenReturn(new TachographFileSessionDeleteResponse(sessionId, true));
mockMvc.perform(post("/api/eventhub/tachograph-file-sessions/workflow/reset").session(webSession))
.andExpect(status().isOk())
.andExpect(jsonPath("$.workflow.driverCardUploaded").value(false));
mockMvc.perform(multipart("/api/eventhub/tachograph-file-sessions") mockMvc.perform(multipart("/api/eventhub/tachograph-file-sessions")
.file(new MockMultipartFile("file", "sample.ddd", "application/octet-stream", "abc".getBytes())) .file(new MockMultipartFile("file", "sample.ddd", "application/octet-stream", "abc".getBytes()))
.param("tenantKey", "default") .param("tenantKey", "default")
.param("sourceInstanceKey", "src") .param("sourceInstanceKey", "src")
.param("sessionLabel", "sample")) .param("sessionLabel", "sample")
.session(webSession))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
.andExpect(jsonPath("$.session.sessionId").value(sessionId.toString())); .andExpect(jsonPath("$.session.sessionId").value(sessionId.toString()))
.andExpect(jsonPath("$.workflow.driverCardUploaded").value(true));
mockMvc.perform(get("/api/eventhub/tachograph-file-sessions/workflow").session(webSession))
.andExpect(status().isOk())
.andExpect(jsonPath("$.workflow.driverCardSessionId").value(sessionId.toString()));
mockMvc.perform(get("/api/eventhub/tachograph-file-sessions/{sessionId}", sessionId)) mockMvc.perform(get("/api/eventhub/tachograph-file-sessions/{sessionId}", sessionId))
.andExpect(status().isOk()) .andExpect(status().isOk())

View File

@ -3,22 +3,27 @@ package at.procon.eventhub.tachographfilesession.service;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import at.procon.eventhub.config.EventHubProperties; import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.tachographfilesession.dto.CreateTachographFileSessionResponse; import at.procon.eventhub.tachographfilesession.dto.CreateTachographFileSessionResponse;
import at.procon.eventhub.tachographfilesession.dto.TachographCompositeSessionSummaryDto;
import at.procon.eventhub.tachographfilesession.dto.TachographFileDriverSummaryDto;
import at.procon.eventhub.tachographfilesession.model.ExtractionStats; import at.procon.eventhub.tachographfilesession.model.ExtractionStats;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession; import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata; import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Instant; import java.time.Instant;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
import org.springframework.mock.web.MockHttpSession;
class TachographFileSessionServiceTest { class TachographFileSessionServiceTest {
@ -26,6 +31,7 @@ class TachographFileSessionServiceTest {
void createsAndLoadsSession() { void createsAndLoadsSession() {
EventHubProperties properties = new EventHubProperties(); EventHubProperties properties = new EventHubProperties();
TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties); TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties);
TachographCompositeSessionService compositeSessionService = Mockito.mock(TachographCompositeSessionService.class);
LegalRequirementsClient client = Mockito.mock(LegalRequirementsClient.class); LegalRequirementsClient client = Mockito.mock(LegalRequirementsClient.class);
TachographXmlParser parser = Mockito.mock(TachographXmlParser.class); TachographXmlParser parser = Mockito.mock(TachographXmlParser.class);
DriverCardXmlExtractionService driverCardExtractor = Mockito.mock(DriverCardXmlExtractionService.class); DriverCardXmlExtractionService driverCardExtractor = Mockito.mock(DriverCardXmlExtractionService.class);
@ -33,14 +39,17 @@ class TachographFileSessionServiceTest {
TachographFileSessionService service = new TachographFileSessionService( TachographFileSessionService service = new TachographFileSessionService(
properties, properties,
repository, repository,
compositeSessionService,
client, client,
parser, parser,
driverCardExtractor, driverCardExtractor,
vehicleUnitExtractor vehicleUnitExtractor
); );
when(client.newCookieManager()).thenReturn(new java.net.CookieManager());
MockMultipartFile file = new MockMultipartFile("file", "sample.ddd", "application/octet-stream", "abc".getBytes(StandardCharsets.UTF_8)); MockMultipartFile file = new MockMultipartFile("file", "sample.ddd", "application/octet-stream", "abc".getBytes(StandardCharsets.UTF_8));
when(client.uploadTachographFile(any(), eq("sample.ddd"))).thenReturn(new LegalRequirementsUploadResult("42", "<DriverCard/>")); when(client.uploadTachographFile(any(java.net.CookieManager.class), any(), eq("sample.ddd")))
.thenReturn(new LegalRequirementsUploadResult("42", "<DriverCard/>"));
TachographXmlParser.ParsedTachographXml parsed = Mockito.mock(TachographXmlParser.ParsedTachographXml.class); TachographXmlParser.ParsedTachographXml parsed = Mockito.mock(TachographXmlParser.ParsedTachographXml.class);
when(parsed.rootElementName()).thenReturn("DriverCard"); when(parsed.rootElementName()).thenReturn("DriverCard");
when(parser.parse("<DriverCard/>")).thenReturn(parsed); when(parser.parse("<DriverCard/>")).thenReturn(parsed);
@ -55,9 +64,12 @@ class TachographFileSessionServiceTest {
); );
when(driverCardExtractor.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"); CreateTachographFileSessionResponse response = service.createSession(file, null, null, "sample", new MockHttpSession());
assertThat(response.session().sessionId()).isEqualTo(extracted.sessionId()); assertThat(response.session().sessionId()).isEqualTo(extracted.sessionId());
assertThat(response.workflow()).isNotNull();
assertThat(response.workflow().driverCardUploaded()).isTrue();
assertThat(response.compositeSession()).isNull();
assertThat(service.getSession(extracted.sessionId()).sessionId()).isEqualTo(extracted.sessionId()); assertThat(service.getSession(extracted.sessionId()).sessionId()).isEqualTo(extracted.sessionId());
} }
@ -66,6 +78,7 @@ class TachographFileSessionServiceTest {
EventHubProperties properties = new EventHubProperties(); EventHubProperties properties = new EventHubProperties();
properties.getTachographFileSession().setMaxFileSizeBytes(1024); properties.getTachographFileSession().setMaxFileSizeBytes(1024);
TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties); TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties);
TachographCompositeSessionService compositeSessionService = Mockito.mock(TachographCompositeSessionService.class);
LegalRequirementsClient client = Mockito.mock(LegalRequirementsClient.class); LegalRequirementsClient client = Mockito.mock(LegalRequirementsClient.class);
TachographXmlParser parser = Mockito.mock(TachographXmlParser.class); TachographXmlParser parser = Mockito.mock(TachographXmlParser.class);
DriverCardXmlExtractionService driverCardExtractor = Mockito.mock(DriverCardXmlExtractionService.class); DriverCardXmlExtractionService driverCardExtractor = Mockito.mock(DriverCardXmlExtractionService.class);
@ -73,6 +86,7 @@ class TachographFileSessionServiceTest {
TachographFileSessionService service = new TachographFileSessionService( TachographFileSessionService service = new TachographFileSessionService(
properties, properties,
repository, repository,
compositeSessionService,
client, client,
parser, parser,
driverCardExtractor, driverCardExtractor,
@ -81,17 +95,18 @@ class TachographFileSessionServiceTest {
MockMultipartFile file = new MockMultipartFile("file", "sample.ddd", "application/octet-stream", new byte[1025]); MockMultipartFile file = new MockMultipartFile("file", "sample.ddd", "application/octet-stream", new byte[1025]);
assertThatThrownBy(() -> service.createSession(file, null, null, "sample")) assertThatThrownBy(() -> service.createSession(file, null, null, "sample", new MockHttpSession()))
.isInstanceOf(IllegalArgumentException.class) .isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("size limit"); .hasMessageContaining("size limit");
verifyNoInteractions(client, parser, driverCardExtractor, vehicleUnitExtractor); verifyNoInteractions(compositeSessionService, client, parser, driverCardExtractor, vehicleUnitExtractor);
} }
@Test @Test
void usesVehicleUnitDefaultsAndExtractorForVehicleUnitXml() { void usesVehicleUnitDefaultsAndExtractorForVehicleUnitXml() {
EventHubProperties properties = new EventHubProperties(); EventHubProperties properties = new EventHubProperties();
TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties); TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties);
TachographCompositeSessionService compositeSessionService = Mockito.mock(TachographCompositeSessionService.class);
LegalRequirementsClient client = Mockito.mock(LegalRequirementsClient.class); LegalRequirementsClient client = Mockito.mock(LegalRequirementsClient.class);
TachographXmlParser parser = Mockito.mock(TachographXmlParser.class); TachographXmlParser parser = Mockito.mock(TachographXmlParser.class);
DriverCardXmlExtractionService driverCardExtractor = Mockito.mock(DriverCardXmlExtractionService.class); DriverCardXmlExtractionService driverCardExtractor = Mockito.mock(DriverCardXmlExtractionService.class);
@ -99,14 +114,17 @@ class TachographFileSessionServiceTest {
TachographFileSessionService service = new TachographFileSessionService( TachographFileSessionService service = new TachographFileSessionService(
properties, properties,
repository, repository,
compositeSessionService,
client, client,
parser, parser,
driverCardExtractor, driverCardExtractor,
vehicleUnitExtractor vehicleUnitExtractor
); );
when(client.newCookieManager()).thenReturn(new java.net.CookieManager());
MockMultipartFile file = new MockMultipartFile("file", "vu.ddd", "application/octet-stream", "vu".getBytes(StandardCharsets.UTF_8)); 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", "<VehicleUnit/>")); when(client.uploadTachographFile(any(java.net.CookieManager.class), any(), eq("vu.ddd")))
.thenReturn(new LegalRequirementsUploadResult("77", "<VehicleUnit/>"));
TachographXmlParser.ParsedTachographXml parsed = Mockito.mock(TachographXmlParser.ParsedTachographXml.class); TachographXmlParser.ParsedTachographXml parsed = Mockito.mock(TachographXmlParser.ParsedTachographXml.class);
when(parsed.rootElementName()).thenReturn("VehicleUnit"); when(parsed.rootElementName()).thenReturn("VehicleUnit");
when(parser.parse("<VehicleUnit/>")).thenReturn(parsed); when(parser.parse("<VehicleUnit/>")).thenReturn(parsed);
@ -121,9 +139,74 @@ class TachographFileSessionServiceTest {
); );
when(vehicleUnitExtractor.extract(eq(parsed), any(), any(), any())).thenReturn(extracted); when(vehicleUnitExtractor.extract(eq(parsed), any(), any(), any())).thenReturn(extracted);
CreateTachographFileSessionResponse response = service.createSession(file, null, null, "vu"); MockHttpSession webSession = new MockHttpSession();
service.resetUploadWorkflow(webSession);
MockMultipartFile driverCardFile = new MockMultipartFile("file", "driver.ddd", "application/octet-stream", "dc".getBytes(StandardCharsets.UTF_8));
when(client.uploadTachographFile(any(java.net.CookieManager.class), any(), eq("driver.ddd")))
.thenReturn(new LegalRequirementsUploadResult("42", "<DriverCard/>"));
TachographXmlParser.ParsedTachographXml driverParsed = Mockito.mock(TachographXmlParser.ParsedTachographXml.class);
when(driverParsed.rootElementName()).thenReturn("DriverCard");
when(parser.parse("<DriverCard/>")).thenReturn(driverParsed);
TachographFileSession driverExtracted = new TachographFileSession(
UUID.randomUUID(),
new TachographFileSessionMetadata("default", "legalrequirements-drivercard", "sample", "driver.ddd", "a", 2, "42", "b", true, null),
Map.of(),
new ExtractionStats(0, 0, 0, 0, 0, 0),
java.util.List.of(),
Instant.now(),
Instant.now().plusSeconds(10)
);
when(driverCardExtractor.extract(eq(driverParsed), any(), any(), any())).thenReturn(driverExtracted);
when(compositeSessionService.upsertCompositeSession(any(), anyList(), any()))
.thenReturn(new TachographCompositeSessionSummaryDto(
UUID.randomUUID(),
"default",
"vu",
List.of(driverExtracted.sessionId(), extracted.sessionId()),
List.<TachographFileDriverSummaryDto>of(),
Instant.now()
));
service.createSession(driverCardFile, null, null, "sample", webSession);
CreateTachographFileSessionResponse response = service.createSession(file, null, null, "vu", webSession);
assertThat(response.session().driverCardFile()).isFalse(); assertThat(response.session().driverCardFile()).isFalse();
assertThat(response.session().sourceInstanceKey()).isEqualTo("legalrequirements-vehicleunit"); assertThat(response.session().sourceInstanceKey()).isEqualTo("legalrequirements-vehicleunit");
assertThat(response.compositeSession()).isNotNull();
assertThat(response.workflow().uploadedSessionIds()).hasSize(2);
}
@Test
void rejectsVehicleUnitBeforeDriverCardInWorkflow() {
EventHubProperties properties = new EventHubProperties();
TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties);
TachographCompositeSessionService compositeSessionService = Mockito.mock(TachographCompositeSessionService.class);
LegalRequirementsClient client = Mockito.mock(LegalRequirementsClient.class);
TachographXmlParser parser = Mockito.mock(TachographXmlParser.class);
TachographFileSessionService service = new TachographFileSessionService(
properties,
repository,
compositeSessionService,
client,
parser,
Mockito.mock(DriverCardXmlExtractionService.class),
Mockito.mock(VehicleUnitXmlExtractionService.class)
);
when(client.newCookieManager()).thenReturn(new java.net.CookieManager());
when(client.uploadTachographFile(any(java.net.CookieManager.class), any(), eq("vu.ddd")))
.thenReturn(new LegalRequirementsUploadResult("77", "<VehicleUnit/>"));
TachographXmlParser.ParsedTachographXml parsed = Mockito.mock(TachographXmlParser.ParsedTachographXml.class);
when(parsed.rootElementName()).thenReturn("VehicleUnit");
when(parser.parse("<VehicleUnit/>")).thenReturn(parsed);
assertThatThrownBy(() -> service.createSession(
new MockMultipartFile("file", "vu.ddd", "application/octet-stream", "vu".getBytes(StandardCharsets.UTF_8)),
null,
null,
"vu",
new MockHttpSession()
))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Driver card file must be uploaded first");
} }
} }

View File

@ -43,6 +43,26 @@ class TachographXmlParserTest {
.isInstanceOf(TachographXmlValidationException.class); .isInstanceOf(TachographXmlValidationException.class);
} }
@Test
void normalizesManufacturerSpecificErrorCodeFromBase64ToHex() {
String normalized = parser.normalizeXmlContent("""
<VehicleUnit>
<EventsFaults>
<vuFaultData>
<vuFaultRecords>
<manufacturerSpecificEventFaultData>
<manufacturerCode>0</manufacturerCode>
<manufacturerSpecificErrorCode>////</manufacturerSpecificErrorCode>
</manufacturerSpecificEventFaultData>
</vuFaultRecords>
</vuFaultData>
</EventsFaults>
</VehicleUnit>
""");
assertThat(normalized).contains("<manufacturerSpecificErrorCode>FFFFFF</manufacturerSpecificErrorCode>");
}
@Test @Test
void rejectsXmlWithDoctype() { void rejectsXmlWithDoctype() {
String xmlWithDoctype = """ String xmlWithDoctype = """