diff --git a/src/BusProNet/DataProcessor/BookingPayloadBuilder.php b/src/BusProNet/DataProcessor/BookingPayloadBuilder.php index d64eb83..ee664c0 100644 --- a/src/BusProNet/DataProcessor/BookingPayloadBuilder.php +++ b/src/BusProNet/DataProcessor/BookingPayloadBuilder.php @@ -96,7 +96,7 @@ class BookingPayloadBuilder foreach ($bookingData->participants as $index => $participant) { $participantPayload = [ '@id' => $index + 1, - 'status' => $bookingData->participantsStatus[$index], + 'status' => $bookingData->participantsStatus[$index] ?? null, ...$participant->toPayload(), ]; diff --git a/src/BusProNet/XmlParser/BookingParser.php b/src/BusProNet/XmlParser/BookingParser.php index 39aabca..6303ff5 100644 --- a/src/BusProNet/XmlParser/BookingParser.php +++ b/src/BusProNet/XmlParser/BookingParser.php @@ -49,11 +49,13 @@ class BookingParser extends AbstractParser $booking->applicant = $this->parsePersonalData($node->filterXPath('//anmelder')); - $participantsStatus = $this->getArrayValue($node->filterXPath('//status_teilnehmer'), '/'); - $booking->participantsStatus = array_combine(range(0, count($participantsStatus) - 1), $participantsStatus); - $booking->participants = $this->parseParticipants($node->filterXPath('//teilnehmerliste/teilnehmer')); + $booking->participantsStatus = $this->mapParticipantsStatus( + array_keys($booking->participants), + $this->getArrayValue($node->filterXPath('//status_teilnehmer'), '/'), + ); + $paymentData = $node->filterXPath('//zahlung'); $booking->paymentId = $this->getAttrOrNullValue($paymentData, 'idzahlungsart'); $booking->paymentLabel = $this->getAttrOrNullValue($paymentData, 'bezeichnung'); @@ -129,6 +131,38 @@ class BookingParser extends AbstractParser return $participants; } + /** + * Maps the positional status_teilnehmer list onto the participant keys. + * + * BusPro sends the statuses as a slash-separated list in teilnehmerliste order and + * without ids, while participants are keyed by "BusPro participant id - 1" (see + * parseParticipants()). Zipping the two in document order keeps both arrays on the same + * keys even when the ids are not contiguous from 1, which BookingPayloadBuilder relies + * on when it reads participantsStatus[$index] while iterating participants, and which + * gates editing in ParticipantController::isParticipantCanceled(). + * + * Surplus entries on either side are dropped rather than shifting the remaining ones + * onto the wrong participant. + * + * @param list $participantKeys Participant array keys, in document order + * @param list $statusValues Status codes, in document order + * + * @return array + */ + private function mapParticipantsStatus(array $participantKeys, array $statusValues): array + { + $count = min(count($participantKeys), count($statusValues)); + + if (0 === $count) { + return []; + } + + return array_combine( + array_slice($participantKeys, 0, $count), + array_slice($statusValues, 0, $count), + ); + } + private function parsePersonalData(Crawler $node): PersonalData { $personalData = new PersonalData(); diff --git a/tests/BusProNet/XmlParser/BookingParserParticipantsStatusTest.php b/tests/BusProNet/XmlParser/BookingParserParticipantsStatusTest.php new file mode 100644 index 0000000..6b4d0ac --- /dev/null +++ b/tests/BusProNet/XmlParser/BookingParserParticipantsStatusTest.php @@ -0,0 +1,108 @@ +parser = new BookingParser(); + } + + public function testStatusesAreKeyedLikeParticipantsForContiguousIds(): void + { + $booking = $this->parse('F/S/F', [1, 2, 3]); + + $this->assertSame([0, 1, 2], array_keys($booking->participants)); + $this->assertSame([0 => 'F', 1 => 'S', 2 => 'F'], $booking->participantsStatus); + } + + public function testStatusesFollowParticipantIdsWhenIdsAreNotContiguous(): void + { + $booking = $this->parse('F/S/F', [1, 4, 7]); + + $this->assertSame([0, 3, 6], array_keys($booking->participants)); + $this->assertSame([0 => 'F', 3 => 'S', 6 => 'F'], $booking->participantsStatus); + + // Every participant the payload builder iterates must find its own status. + foreach ($booking->participants as $index => $participant) { + $this->assertArrayHasKey($index, $booking->participantsStatus); + } + } + + public function testSurplusStatusEntriesAreDroppedInsteadOfShifting(): void + { + $booking = $this->parse('F/S/F/F', [1, 2, 3]); + + $this->assertSame([0 => 'F', 1 => 'S', 2 => 'F'], $booking->participantsStatus); + } + + public function testMissingStatusEntriesLeaveTheRemainingParticipantsAligned(): void + { + $booking = $this->parse('F/S', [1, 2, 3]); + + $this->assertSame([0 => 'F', 1 => 'S'], $booking->participantsStatus); + $this->assertArrayNotHasKey(2, $booking->participantsStatus); + } + + public function testStatusListSurroundedByWhitespaceIsParsed(): void + { + // BusPro pretty-prints the element, so the text node carries newlines and indentation. + $booking = $this->parse("\n F/S/F\n ", [1, 2, 3]); + + $this->assertSame([0 => 'F', 1 => 'S', 2 => 'F'], $booking->participantsStatus); + } + + public function testEmptyStatusListDoesNotFail(): void + { + $booking = $this->parse('', [1, 2]); + + $this->assertSame([], $booking->participantsStatus); + $this->assertCount(2, $booking->participants); + } + + /** @param list $participantIds */ + private function parse(string $statusList, array $participantIds): \App\BusProNet\Model\Booking + { + $participants = ''; + foreach ($participantIds as $id) { + $participants .= sprintf( + 'Teilnehmer %1$dTest', + $id, + ); + } + + $xml = sprintf( + ' + + + Vorgang_Details + 98787 + %s + + %s +', + $statusList, + $participants, + ); + + return $this->parser->parse(XmlCrawlerFactory::create($xml)->filterXPath('//ergebnis')); + } +}