diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitTicketApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitTicketApiController.php index b77584a41..0c25fe084 100644 --- a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitTicketApiController.php +++ b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitTicketApiController.php @@ -645,11 +645,12 @@ function () use ($summit) { ]; foreach ($summit->getBadgeFeaturesTypes() as $featuresType) { - $allowed_columns[] = $featuresType->getName(); + $allowed_columns[] = sprintf('%s%s', ISummitOrderService::BadgeFeatureColumnPrefix, $featuresType->getName()); } foreach ($summit->getOrderExtraQuestionsByUsage(SummitOrderExtraQuestionTypeConstants::TicketQuestionUsage) as $question) { - $allowed_columns[] = $question->getLabel(); + // must match the serializer-emitted column exactly ( prefixed, sanitized label ) + $allowed_columns[] = sprintf('%s%s', ISummitOrderService::ExtraQuestionColumnPrefix, html_entity_decode(strip_tags($question->getLabel()))); } $columns_param = Request::input("columns", ""); @@ -745,7 +746,7 @@ public function ingestExternalTicketData($summit_id) path: '/api/v1/summits/{id}/tickets/csv/template', operationId: 'getTicketImportTemplate', summary: 'Get ticket import template', - description: 'Returns a CSV template for importing ticket data. Includes one column per summit badge feature name plus one extra_question:{question name} column per summit order extra question (Ticket/Both usage).', + description: 'Returns a CSV template for importing ticket data. Includes one badge_feature:{feature name} column per summit badge feature plus one extra_question:{question name} column per summit order extra question (Ticket/Both usage).', security: [['summit_tickets_oauth2' => [ SummitScopes::WriteSummitData, SummitScopes::WriteRegistrationData, @@ -788,8 +789,8 @@ public function getImportTicketDataTemplate($summit_id) * ticket_promo_code (optional) * badge_type_id (optional) * badge_type_name (optional) - * badge_features (optional, one col per badge feature name) - * extra_question:{question name} (optional, one col per order extra question - Ticket/Both usage) + * badge_feature:{feature name} (optional, one col per badge feature; bare feature name accepted for legacy csvs) + * extra_question:{question name} (optional, one col per order extra question - Ticket/Both usage; label accepted too) */ $summit = SummitFinderStrategyFactory::build($this->summit_repository, $this->getResourceServerContext())->find($summit_id); @@ -814,7 +815,7 @@ public function getImportTicketDataTemplate($summit_id) // badge features for summit foreach ($summit->getBadgeFeaturesTypes() as $featuresType) { - $row[$featuresType->getName()] = ''; + $row[sprintf('%s%s', ISummitOrderService::BadgeFeatureColumnPrefix, $featuresType->getName())] = ''; } // order extra questions for summit ( ticket / attendee scoped ones ) @@ -841,7 +842,7 @@ public function getImportTicketDataTemplate($summit_id) path: '/api/v1/summits/{id}/tickets/csv', operationId: 'importTicketData', summary: 'Import ticket data from CSV', - description: 'Imports ticket data from a CSV file. Supported columns: id, number, attendee_email, attendee_first_name, attendee_last_name, attendee_tags, attendee_company, attendee_company_id, ticket_type_name, ticket_type_id, promo_code_id, promo_code, ticket_promo_code, badge_type_id, badge_type_name, one column per badge feature name (1/0) and one extra_question:{question name} column per order extra question (Ticket/Both usage; for list type questions use the value name/label, "|" separated for multi value).', + description: 'Imports ticket data from a CSV file. Supported columns: id, number, attendee_email, attendee_first_name, attendee_last_name, attendee_tags, attendee_company, attendee_company_id, ticket_type_name, ticket_type_id, promo_code_id, promo_code, ticket_promo_code, badge_type_id, badge_type_name, one badge_feature:{feature name} column per badge feature (1/0; bare feature name accepted for legacy CSVs) and one extra_question:{question name} column per order extra question (Ticket/Both usage; question label accepted too; for list type questions use the value name/label, "|" separated for multi value).', security: [['summit_tickets_oauth2' => [ SummitScopes::WriteSummitData, SummitScopes::WriteRegistrationData, diff --git a/app/ModelSerializers/Summit/Registration/SummitAttendeeTicketCSVSerializer.php b/app/ModelSerializers/Summit/Registration/SummitAttendeeTicketCSVSerializer.php index 90a2fa64b..ceb20573b 100644 --- a/app/ModelSerializers/Summit/Registration/SummitAttendeeTicketCSVSerializer.php +++ b/app/ModelSerializers/Summit/Registration/SummitAttendeeTicketCSVSerializer.php @@ -11,6 +11,8 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ +use App\Models\Foundation\ExtraQuestions\ExtraQuestionType; +use App\Services\Model\ISummitOrderService; use models\summit\SummitAttendeeTicket; use models\summit\SummitBadgeFeatureType; use models\summit\SummitOrderExtraQuestionType; @@ -73,7 +75,9 @@ public function serialize($expand = null, array $fields = [], array $relations = $ticket_features = $ticket->getBadgeFeaturesNames(); foreach ($params['features_types'] as $features_type) { if (!$features_type instanceof SummitBadgeFeatureType) continue; - $values[$features_type->getName()] = in_array($features_type->getName(), $ticket_features) ? '1' : '0'; + // namespaced column, matches the ticket data import convention ( round-trip ) + $feature_column = sprintf('%s%s', ISummitOrderService::BadgeFeatureColumnPrefix, $features_type->getName()); + $values[$feature_column] = in_array($features_type->getName(), $ticket_features) ? '1' : '0'; } } @@ -86,7 +90,9 @@ public function serialize($expand = null, array $fields = [], array $relations = $question_label = self::$questionLabelCache[$question->getId()] ??= html_entity_decode(strip_tags($question->getLabel())); - $values[$question_label] = ''; + // namespaced column, matches the ticket data import convention ( round-trip ) + $question_column = sprintf('%s%s', ISummitOrderService::ExtraQuestionColumnPrefix, $question_label); + $values[$question_column] = ''; if (!is_null($ticket_owner)) { $value = $answersByOwner[$ticket_owner->getId()][$question->getId()] ?? null; @@ -94,9 +100,9 @@ public function serialize($expand = null, array $fields = [], array $relations = $cacheKey = $question->getId() . '|' . $value; $value = self::$niceValueCache[$cacheKey] - ??= $question->getNiceValue($value); + ??= self::formatAnswerValue($question, $value); - $values[$question_label] = $value; + $values[$question_column] = $value; } } } @@ -141,4 +147,26 @@ public function serialize($expand = null, array $fields = [], array $relations = return $values; } + + /** + * Multi-value answers are joined with the ticket data import's value separator instead of + * getNiceValue's comma, so exported cells re-import as-is ( labels may legitimately contain + * commas, which would corrupt a comma-joined cell on re-import ). + * @param SummitOrderExtraQuestionType $question + * @param string $value + * @return string + */ + private static function formatAnswerValue(SummitOrderExtraQuestionType $question, string $value): string + { + if (!$question->allowsValues()) + return strval($question->getNiceValue($value)); + + $labels = []; + foreach (explode(ExtraQuestionType::QuestionChoicesCharSeparator, $value) as $value_id) { + $label = $question->getNiceValue(trim($value_id)); + if (empty($label)) continue; + $labels[] = $label; + } + return implode(ISummitOrderService::ExtraQuestionValueSeparator, $labels); + } } diff --git a/app/Services/Model/ISummitOrderService.php b/app/Services/Model/ISummitOrderService.php index 273acbe70..64d7d48fe 100644 --- a/app/Services/Model/ISummitOrderService.php +++ b/app/Services/Model/ISummitOrderService.php @@ -25,8 +25,12 @@ */ interface ISummitOrderService extends IProcessPaymentService { - // ticket data import csv column naming convention for order extra question answers + // ticket data csv column naming convention: additive per-name column families are namespaced + // so their names can never collide ( import still accepts the bare badge feature name, for legacy csvs ) const ExtraQuestionColumnPrefix = 'extra_question:'; + const BadgeFeatureColumnPrefix = 'badge_feature:'; + // separator for multi-value extra-question csv cells, shared by import and export ( round-trip ) + const ExtraQuestionValueSeparator = '|'; /** * @param Member|null $owner diff --git a/app/Services/Model/Imp/SummitOrderService.php b/app/Services/Model/Imp/SummitOrderService.php index 2a1d27b64..779a44cc7 100644 --- a/app/Services/Model/Imp/SummitOrderService.php +++ b/app/Services/Model/Imp/SummitOrderService.php @@ -1783,6 +1783,17 @@ final class SummitOrderService */ private $refund_request_repository; + /** + * Per-summit lookup index for resolveExtraQuestionColumns, built once per import job + * instead of two DB round-trips ( by-name + by-label ) per extra_question column per row + * ( order_extra_questions is EXTRA_LAZY, so matching() is a real query, not an in-memory + * filter ). Keyed by summit id; each entry maps a comparison key ( name / raw label / + * export-sanitized label ) to a question, so a hand-edited CSV using the raw label still + * resolves alongside a re-imported export using the sanitized one. + * @var array + */ + private $order_extra_questions_index_cache = []; + /** * @param ISummitTicketTypeRepository $ticket_type_repository * @param IMemberRepository $member_repository @@ -4286,8 +4297,8 @@ public function importTicketData(Summit $summit, UploadedFile $csv_file): void * ticket_promo_code (optional) * badge_type_id (optional) * badge_type_name (optional) - * one col per feature - * extra_question:{question name} (optional, one col per order extra question - Ticket/Both usage) + * badge_feature:{feature name} (optional, one col per badge feature; bare feature name accepted for legacy csvs) + * extra_question:{question name} (optional, one col per order extra question - Ticket/Both usage; label accepted too) */ // validate format with col names @@ -4340,6 +4351,12 @@ public function processTicketData(int $summit_id, string $filename) return $summit; }); + // force a fresh extra-questions index for this job — this service is a singleton + // (ModelServicesProvider::class registers ISummitOrderService as such), so a warm queue + // worker could otherwise resolve extra_question columns against a stale in-memory index + // left over from a previous import job on the same summit + $this->getOrderExtraQuestionsIndex($summit, true); + $reader = CSVReader::buildFrom($csv_data); $ticket_data_present = $reader->hasColumn("id") || $reader->hasColumn("number"); @@ -4630,7 +4647,11 @@ public function processTicketData(int $summit_id, string $filename) foreach ($summit->getBadgeFeaturesTypes() as $featuresType) { $feature_name = $featuresType->getName(); Log::debug(sprintf("SummitOrderService::processTicketData processing badge type feature %s for ticket %s", $feature_name, $ticket->getId())); - if (!$reader->hasColumn($feature_name)) { + // canonical namespaced column; bare feature name accepted for legacy csvs + $feature_column = sprintf('%s%s', self::BadgeFeatureColumnPrefix, $feature_name); + if (!$reader->hasColumn($feature_column)) + $feature_column = $feature_name; + if (!$reader->hasColumn($feature_column)) { Log::debug(sprintf("SummitOrderService::processTicketData badge type feature %s does not exists as column", $feature_name)); continue; } @@ -4640,7 +4661,7 @@ public function processTicketData(int $summit_id, string $filename) $clearedFeatures = true; } - $mustAdd = intval($row[$feature_name]) === 1; + $mustAdd = intval($row[$feature_column]) === 1; if (!$mustAdd) { Log::debug(sprintf("SummitOrderService::processTicketData badge type feature %s not set for ticket %s", $feature_name, $ticket->getId())); continue; @@ -4671,10 +4692,10 @@ public function processTicketData(int $summit_id, string $filename) /** * Upserts attendee extra question answers from a ticket data import row * (one column per question, "extra_question:{question name}" naming convention). - * The prefix is deliberate: on this import the bare-name column namespace already belongs to - * badge features, and a question named like a feature would be ambiguous at parse time — - * additive per-name column families should be namespaced ( badge feature columns stay bare - * for backward compatibility with existing CSVs ). + * The prefix is deliberate: additive per-name column families are namespaced so their names + * can never collide ( badge features use "badge_feature:{name}"; the bare feature name is + * still accepted for backward compatibility with existing CSVs ). Questions match by name, + * falling back to label — the ticket csv export emits question labels as column names. * Unknown question names, order-scoped questions, disallowed questions and empty values are * skipped, never fail the row. Mandatory-question completeness is not enforced ( admin bulk load ). * Former answers not present on the row are carried over on a best effort basis: the shared @@ -4748,6 +4769,47 @@ private function upsertAttendeeExtraQuestionAnswers(Summit $summit, SummitAttend } } + /** + * Builds a lookup index of the summit's order extra questions, keyed by every string a CSV + * column could legitimately match against: exact name, exact ( raw, as-stored ) label, and + * the export-sanitized label ( html_entity_decode(strip_tags(...)) — the same transform + * SummitAttendeeTicketCSVSerializer applies when building export headers, so a re-imported + * export round-trips even when the stored label contains HTML/entities, e.g. Eventbrite- + * seeded questions via SummitOrderExtraQuestionTypeService::seedSummitOrderExtraQuestionTypesFromEventBrite ). + * One pass over the ( EXTRA_LAZY ) collection instead of two DB round-trips per column per row. + * + * Cached per summit for the lifetime of this service instance — but `$refresh` MUST be forced + * ( see processTicketData() ) at the start of every import job: `ISummitOrderService` is bound + * as an application singleton, so on a warm queue worker this instance survives across many + * job executions. Without a forced rebuild, a question added/edited between two import jobs + * for the same summit would silently resolve against a stale in-memory index. + * @param Summit $summit + * @param bool $refresh force a fresh rebuild even if a cached index exists for this summit + * @return array{by_key: array} + */ + private function getOrderExtraQuestionsIndex(Summit $summit, bool $refresh = false): array + { + $summit_id = $summit->getId(); + if (!$refresh && isset($this->order_extra_questions_index_cache[$summit_id])) + return $this->order_extra_questions_index_cache[$summit_id]; + + $by_key = []; + $add = function (?string $key, SummitOrderExtraQuestionType $question) use (&$by_key) { + if (empty($key)) return; + $by_key[$key][$question->getId()] = $question; + }; + + foreach ($summit->getOrderExtraQuestions() as $question) { + if (!$question instanceof SummitOrderExtraQuestionType) continue; + $add(trim($question->getName()), $question); + $label = $question->getLabel(); + $add(trim($label), $question); + $add(trim(html_entity_decode(strip_tags($label))), $question); + } + + return $this->order_extra_questions_index_cache[$summit_id] = ['by_key' => $by_key]; + } + /** * Resolves the row's extra_question:* columns to [question_id => value], applying the * empty-value, unknown-question, order-scoped, not-allowed and change-lock skips. @@ -4780,8 +4842,18 @@ private function resolveExtraQuestionColumns(Summit $summit, SummitAttendee $att continue; } - $question = $summit->getOrderExtraQuestionByName($question_name); - if (is_null($question)) { + // match by name, by raw label, and by the export-sanitized label ( the ticket csv + // export emits html_entity_decode(strip_tags($label)) as the column name, so a + // re-imported export must resolve against that sanitized form, not just the raw + // stored label — see getOrderExtraQuestionsIndex() ); each candidate is vetted for + // usage / attendee eligibility before deciding ambiguity, so a question that could + // never take this answer ( order scoped or not allowed for the attendee ) can not + // shadow a legitimate match; only when two eligible questions remain — one + // question's name is another question's label — is the column truly ambiguous and + // skipped, rather than silently filing the answer under the wrong question + $candidates = $this->getOrderExtraQuestionsIndex($summit)['by_key'][$question_name] ?? []; + + if (count($candidates) === 0) { Log::warning ( sprintf @@ -4794,31 +4866,55 @@ private function resolveExtraQuestionColumns(Summit $summit, SummitAttendee $att continue; } - if ($question->getUsage() === SummitOrderExtraQuestionTypeConstants::OrderQuestionUsage) { - Log::warning - ( - sprintf + $eligible = []; + foreach ($candidates as $candidate) { + if ($candidate->getUsage() === SummitOrderExtraQuestionTypeConstants::OrderQuestionUsage) { + Log::warning ( - "SummitOrderService::resolveExtraQuestionColumns question %s is order scoped, can not be answered per attendee, skipping it", - $question_name - ) - ); - continue; + sprintf + ( + "SummitOrderService::resolveExtraQuestionColumns question %s ( column %s ) is order scoped, can not be answered per attendee, skipping it", + $candidate->getId(), + $question_name + ) + ); + continue; + } + if (!$attendee->isAllowedQuestion($candidate)) { + Log::warning + ( + sprintf + ( + "SummitOrderService::resolveExtraQuestionColumns question %s ( column %s ) is not allowed for attendee %s, skipping it", + $candidate->getId(), + $question_name, + $attendee->getEmail() + ) + ); + continue; + } + $eligible[$candidate->getId()] = $candidate; } - if (!$attendee->isAllowedQuestion($question)) { + if (count($eligible) === 0) continue; + + if (count($eligible) > 1) { Log::warning ( sprintf ( - "SummitOrderService::resolveExtraQuestionColumns question %s is not allowed for attendee %s, skipping it", + "SummitOrderService::resolveExtraQuestionColumns column %s is ambiguous on summit %s ( name of question %s, label of question %s ), skipping it", $question_name, - $attendee->getEmail() + $summit->getId(), + array_keys($eligible)[0], + array_keys($eligible)[1] ) ); continue; } + $question = reset($eligible); + if ($question->allowsValues()) { $value = $this->resolveListQuestionValue($question, $value); if (is_null($value)) continue; @@ -4866,7 +4962,7 @@ private function resolveListQuestionValue(SummitOrderExtraQuestionType $question { $value_ids = []; - foreach (explode('|', $value) as $v) { + foreach (explode(self::ExtraQuestionValueSeparator, $value) as $v) { $v = trim($v); if ($v === '') continue; $question_value = $question->getValueByName($v); diff --git a/tests/SummitOrderServiceTest.php b/tests/SummitOrderServiceTest.php index daf42f230..8be5ab4b9 100644 --- a/tests/SummitOrderServiceTest.php +++ b/tests/SummitOrderServiceTest.php @@ -53,6 +53,7 @@ use models\summit\SummitOrderExtraQuestionType; use models\summit\SummitOrderExtraQuestionTypeConstants; use models\summit\SummitTicketType; +use ModelSerializers\SerializerRegistry; /** * Class SummitOrderServiceTest @@ -432,12 +433,13 @@ private function insertOrderExtraQuestion string $name, string $type = ExtraQuestionTypeConstants::TextQuestionType, array $values = [], - string $usage = SummitOrderExtraQuestionTypeConstants::TicketQuestionUsage + string $usage = SummitOrderExtraQuestionTypeConstants::TicketQuestionUsage, + ?string $label = null ): SummitOrderExtraQuestionType { $question = new SummitOrderExtraQuestionType(); $question->setName($name); - $question->setLabel($name); + $question->setLabel($label ?? $name); $question->setType($type); $question->setUsage($usage); @@ -881,6 +883,263 @@ public function testImportTicketDataPreservesLockedAnswerWhenUpdatesDisallowed() $this->assertEquals('Meat', $answer->getValue()); } + public function testImportTicketDataAcceptsPrefixedBadgeFeatureColumns() + { + Queue::fake(); + + $feature1 = new SummitBadgeFeatureType(); + $feature1->setName('FEATURE 1'); + self::$summit->addFeatureType($feature1); + + $feature2 = new SummitBadgeFeatureType(); + $feature2->setName('FEATURE 2'); + self::$summit->addFeatureType($feature2); + + $ticket = $this->getUnassignedTicket(); + $ticket->getBadge()->addFeature($feature1); + self::$em->persist(self::$summit); + self::$em->flush(); + + // canonical namespaced feature columns ( the bare-name form stays covered by + // testImportTicketDataBadgeFeaturesStillClearedAndReSet ) + $csv_content = <<getNumber()},new.attendee@nowhere.com,New,Attendee,BADGE TYPE1,0,1 +CSV; + + $service = $this->buildTicketDataImportService($csv_content); + $service->processTicketData(self::$summit->getId(), 'tickets.csv'); + + $feature_names = []; + foreach ($ticket->getBadge()->getFeatures() as $feature) { + $feature_names[] = $feature->getName(); + } + $this->assertEquals(['FEATURE 2'], $feature_names); + } + + public function testImportTicketDataMatchesExtraQuestionByLabel() + { + Queue::fake(); + + // the ticket csv export emits question labels as column names, so import + // matches by name and by label ( ambiguous name/label collisions are skipped — + // see testImportTicketDataSkipsAmbiguousExtraQuestionColumn ) + $question = $this->insertOrderExtraQuestion + ( + 'DIET_REQ', + ExtraQuestionTypeConstants::TextQuestionType, + [], + SummitOrderExtraQuestionTypeConstants::TicketQuestionUsage, + 'Dietary Requirements' + ); + + $ticket = $this->getUnassignedTicket(); + + $csv_content = <<getNumber()},new.attendee@nowhere.com,New,Attendee,Vegan +CSV; + + $service = $this->buildTicketDataImportService($csv_content); + $service->processTicketData(self::$summit->getId(), 'tickets.csv'); + + $attendee = App::make(ISummitAttendeeRepository::class) + ->getBySummitAndEmail(self::$summit, 'new.attendee@nowhere.com'); + $this->assertNotNull($attendee); + $answer = $attendee->getExtraQuestionAnswerByQuestion($question); + $this->assertNotNull($answer); + $this->assertEquals('Vegan', $answer->getValue()); + } + + public function testImportTicketDataSkipsAmbiguousExtraQuestionColumn() + { + Queue::fake(); + + // question A's NAME equals question B's LABEL — the column is ambiguous and must be + // skipped, never silently filed under either question + $question_a = $this->insertOrderExtraQuestion + ( + 'Shirt', + ExtraQuestionTypeConstants::TextQuestionType, + [], + SummitOrderExtraQuestionTypeConstants::TicketQuestionUsage, + 'Question A Label' + ); + $question_b = $this->insertOrderExtraQuestion + ( + 'B_INTERNAL_NAME', + ExtraQuestionTypeConstants::TextQuestionType, + [], + SummitOrderExtraQuestionTypeConstants::TicketQuestionUsage, + 'Shirt' + ); + + $ticket = $this->getUnassignedTicket(); + + $csv_content = <<getNumber()},new.attendee@nowhere.com,New,Attendee,Large +CSV; + + $service = $this->buildTicketDataImportService($csv_content); + $service->processTicketData(self::$summit->getId(), 'tickets.csv'); + + // row still processes ( attendee created ), the ambiguous column is skipped entirely + $attendee = App::make(ISummitAttendeeRepository::class) + ->getBySummitAndEmail(self::$summit, 'new.attendee@nowhere.com'); + $this->assertNotNull($attendee); + $this->assertNull($attendee->getExtraQuestionAnswerByQuestion($question_a)); + $this->assertNull($attendee->getExtraQuestionAnswerByQuestion($question_b)); + $this->assertCount(0, $attendee->getExtraQuestionAnswers()); + } + + public function testImportTicketDataIneligibleQuestionDoesNotMakeColumnAmbiguous() + { + Queue::fake(); + + // question A's NAME equals question B's LABEL, but B is order scoped so it was + // never a real candidate — the column must resolve to A, not be skipped as + // ambiguous ( ambiguity is decided among eligible questions only ) + $question_a = $this->insertOrderExtraQuestion + ( + 'Shirt', + ExtraQuestionTypeConstants::TextQuestionType, + [], + SummitOrderExtraQuestionTypeConstants::TicketQuestionUsage, + 'Question A Label' + ); + $question_b = $this->insertOrderExtraQuestion + ( + 'B_INTERNAL_NAME', + ExtraQuestionTypeConstants::TextQuestionType, + [], + SummitOrderExtraQuestionTypeConstants::OrderQuestionUsage, + 'Shirt' + ); + + $ticket = $this->getUnassignedTicket(); + + $csv_content = <<getNumber()},new.attendee@nowhere.com,New,Attendee,Large +CSV; + + $service = $this->buildTicketDataImportService($csv_content); + $service->processTicketData(self::$summit->getId(), 'tickets.csv'); + + $attendee = App::make(ISummitAttendeeRepository::class) + ->getBySummitAndEmail(self::$summit, 'new.attendee@nowhere.com'); + $this->assertNotNull($attendee); + $answer = $attendee->getExtraQuestionAnswerByQuestion($question_a); + $this->assertNotNull($answer); + $this->assertEquals('Large', $answer->getValue()); + $this->assertNull($attendee->getExtraQuestionAnswerByQuestion($question_b)); + } + + public function testTicketCSVExportHtmlEntityLabelRoundTripsThroughImport() + { + Queue::fake(); + + // label as stored e.g. by the Eventbrite ingestion path + // ( SummitOrderExtraQuestionTypeService::seedSummitOrderExtraQuestionTypesFromEventBrite ) + // or by an admin entering basic markup through the normal API ( HTMLPurifier permits + // ///... by default ) — the raw stored label contains an HTML entity, but the + // export header is the sanitized ( html_entity_decode(strip_tags(...)) ) form + $question = $this->insertOrderExtraQuestion + ( + 'DIET_REQ', + ExtraQuestionTypeConstants::TextQuestionType, + [], + SummitOrderExtraQuestionTypeConstants::TicketQuestionUsage, + 'Café Preference' + ); + + $attendee = $this->getDefaultAttendee(); + $ticket = $attendee->getTickets()->first(); + + // export: header is the sanitized label, not the raw stored one + $values = SerializerRegistry::getInstance() + ->getSerializer($ticket, SerializerRegistry::SerializerType_CSV) + ->serialize(null, [], [], [ + 'ticket_questions' => [$question], + 'answers_by_owner' => [$attendee->getId() => [$question->getId() => 'Vegan']], + ]); + + $question_column = 'extra_question:Café Preference'; + $this->assertArrayHasKey($question_column, $values); + $this->assertEquals('Vegan', $values[$question_column]); + + // re-import the exact exported header + cell, verbatim, for a new attendee — must resolve + // against the sanitized label, since getOrderExtraQuestionByLabel()'s raw-label match can + // never equal the sanitized exported header + $unassigned = $this->getUnassignedTicket(); + $csv_content = <<getNumber()},new.attendee@nowhere.com,New,Attendee,Vegan +CSV; + + $service = $this->buildTicketDataImportService($csv_content); + $service->processTicketData(self::$summit->getId(), 'tickets.csv'); + + $new_attendee = App::make(ISummitAttendeeRepository::class) + ->getBySummitAndEmail(self::$summit, 'new.attendee@nowhere.com'); + $this->assertNotNull($new_attendee); + $answer = $new_attendee->getExtraQuestionAnswerByQuestion($question); + $this->assertNotNull($answer, 'expected the CSV export/import round trip to preserve the answer'); + $this->assertEquals('Vegan', $answer->getValue()); + } + + public function testTicketCSVExportMultiValueAnswerRoundTripsThroughImport() + { + Queue::fake(); + + $question = $this->insertOrderExtraQuestion + ( + 'T-Shirt Size', + ExtraQuestionTypeConstants::CheckBoxListQuestionType, + ['Small', 'Large'] + ); + + $small_id = $question->getValueByName('Small')->getId(); + $large_id = $question->getValueByName('Large')->getId(); + $stored_value = sprintf('%s,%s', $small_id, $large_id); + + $attendee = $this->getDefaultAttendee(); + $ticket = $attendee->getTickets()->first(); + + // export: headers use the import's column prefix, multi-value cells the import's "|" separator + $values = SerializerRegistry::getInstance() + ->getSerializer($ticket, SerializerRegistry::SerializerType_CSV) + ->serialize(null, [], [], [ + 'ticket_questions' => [$question], + 'answers_by_owner' => [$attendee->getId() => [$question->getId() => $stored_value]], + ]); + + $question_column = 'extra_question:T-Shirt Size'; + $this->assertArrayHasKey($question_column, $values); + $this->assertEquals('Small|Large', $values[$question_column]); + + // ... and the exported cell re-imports as-is for a new attendee + $unassigned = $this->getUnassignedTicket(); + $csv_content = <<getNumber()},new.attendee@nowhere.com,New,Attendee,{$values[$question_column]} +CSV; + + $service = $this->buildTicketDataImportService($csv_content); + $service->processTicketData(self::$summit->getId(), 'tickets.csv'); + + $new_attendee = App::make(ISummitAttendeeRepository::class) + ->getBySummitAndEmail(self::$summit, 'new.attendee@nowhere.com'); + $this->assertNotNull($new_attendee); + $answer = $new_attendee->getExtraQuestionAnswerByQuestion($question); + $this->assertNotNull($answer); + + $expected_value_ids = [$small_id, $large_id]; + sort($expected_value_ids); + $this->assertEquals(implode(',', $expected_value_ids), $answer->getValue()); + } + public function testImportTicketDataCreatesBadgeWhenTicketHasNone() { Queue::fake();