diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index dc58a7100..71c143955 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -79,6 +79,7 @@ jobs: # Named by path because no job in this matrix runs the tests/ root, only its # subdirectories - a file added there runs nowhere unless it is listed here. - { name: "PresentationMediaUploads", filter: "tests/PresentationMediaUploadsTest.php tests/PresentationMediaUploadsVisibilityTest.php tests/PresentationSerializerCacheKeyTest.php tests/PresentationReopenModelTest.php tests/PresentationReopenApiTest.php tests/PresentationReopenAuthzTest.php tests/PresentationSubmissionReopenedEmailTest.php" } + - { name: "SpeakerSubmitterPublishedFilter", filter: "tests/ProcessSpeakersEmailRequestJobTest.php tests/ProcessSubmittersEmailRequestJobTest.php tests/SpeakerRepositoryTest.php tests/SubmitterRepositoryTest.php tests/SpeakerServiceOriginalFilterTest.php tests/SubmitterServiceOriginalFilterTest.php" } - { name: "Repositories", filter: "tests/Repositories/" } - { name: "Services", filter: "tests/Unit/Services/" } - { name: "Integration", filter: "tests/Integration/" } diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php index d47d7b9a1..96717d694 100644 --- a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php +++ b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php @@ -252,7 +252,7 @@ public function __construct ), new OA\Parameter( name: 'filter', - description: 'Filter by id, not_id, first_name, last_name, email, full_name, member_id, member_user_external_id, has_accepted_presentations, has_alternate_presentations, has_rejected_presentations, presentations_track_id, presentations_track_group_id, presentations_selection_plan_id, presentations_type_id, presentations_title, presentations_abstract, presentations_submitter_full_name, presentations_submitter_email, has_media_upload_with_type, has_not_media_upload_with_type. Operands supported: == (equal), @@ (contains), =@ (starts with).', + description: 'Filter by id, not_id, first_name, last_name, email, full_name, member_id, member_user_external_id, has_accepted_presentations, has_alternate_presentations, has_rejected_presentations, has_published_presentations, presentations_track_id, presentations_track_group_id, presentations_selection_plan_id, presentations_type_id, presentations_title, presentations_abstract, presentations_submitter_full_name, presentations_submitter_email, has_media_upload_with_type, has_not_media_upload_with_type. Operands supported: == (equal), @@ (contains), =@ (starts with).', in: 'query', required: false, schema: new OA\Schema(type: 'string') @@ -368,7 +368,7 @@ function ($page, $per_page, $filter, $order, $applyExtraFilters) use ($summit) { ), new OA\Parameter( name: 'filter', - description: 'Filter query (supports multiple operators). Filterable fields: id, not_id, first_name, last_name, email, full_name, member_id, member_user_external_id, has_accepted_presentations, has_alternate_presentations, has_rejected_presentations, presentations_track_id, presentations_track_group_id, presentations_selection_plan_id, presentations_type_id, presentations_title, presentations_abstract, presentations_submitter_full_name, presentations_submitter_email, has_media_upload_with_type, has_not_media_upload_with_type.', + description: 'Filter query (supports multiple operators). Filterable fields: id, not_id, first_name, last_name, email, full_name, member_id, member_user_external_id, has_accepted_presentations, has_alternate_presentations, has_rejected_presentations, has_published_presentations, presentations_track_id, presentations_track_group_id, presentations_selection_plan_id, presentations_type_id, presentations_title, presentations_abstract, presentations_submitter_full_name, presentations_submitter_email, has_media_upload_with_type, has_not_media_upload_with_type.', in: 'query', required: false, schema: new OA\Schema(type: 'string') @@ -442,7 +442,7 @@ public function getSpeakersActivitiesCount($summit_id) ), new OA\Parameter( name: 'filter', - description: 'Filter by id, not_id, first_name, last_name, email, full_name, member_id, member_user_external_id, has_accepted_presentations, has_alternate_presentations, has_rejected_presentations, presentations_track_id, presentations_track_group_id, presentations_selection_plan_id, presentations_type_id, presentations_title, presentations_abstract, presentations_submitter_full_name, presentations_submitter_email, has_media_upload_with_type, has_not_media_upload_with_type. Operands supported: == (equal), @@ (contains), =@ (starts with).', + description: 'Filter by id, not_id, first_name, last_name, email, full_name, member_id, member_user_external_id, has_accepted_presentations, has_alternate_presentations, has_rejected_presentations, has_published_presentations, presentations_track_id, presentations_track_group_id, presentations_selection_plan_id, presentations_type_id, presentations_title, presentations_abstract, presentations_submitter_full_name, presentations_submitter_email, has_media_upload_with_type, has_not_media_upload_with_type. Operands supported: == (equal), @@ (contains), =@ (starts with).', in: 'query', required: false, schema: new OA\Schema(type: 'string') @@ -3054,7 +3054,7 @@ public function deleteSpeakerBigPhoto($speaker_id) ), new OA\Parameter( name: 'filter', - description: 'Filter speakers by id, not_id, first_name, last_name, email, full_name, member_id, member_user_external_id, has_accepted_presentations, has_alternate_presentations, has_rejected_presentations, presentations_track_id, presentations_track_group_id, presentations_selection_plan_id, presentations_type_id, presentations_title, presentations_abstract, presentations_submitter_full_name, presentations_submitter_email, has_media_upload_with_type, has_not_media_upload_with_type', + description: 'Filter speakers by id, not_id, first_name, last_name, email, full_name, member_id, member_user_external_id, has_accepted_presentations, has_alternate_presentations, has_rejected_presentations, has_published_presentations, presentations_track_id, presentations_track_group_id, presentations_selection_plan_id, presentations_type_id, presentations_title, presentations_abstract, presentations_submitter_full_name, presentations_submitter_email, has_media_upload_with_type, has_not_media_upload_with_type', in: 'query', required: false, schema: new OA\Schema(type: 'string') diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSubmittersApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSubmittersApiController.php index abf6da451..c567283ea 100644 --- a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSubmittersApiController.php +++ b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSubmittersApiController.php @@ -155,6 +155,7 @@ function () { 'has_accepted_presentations' => ['=='], 'has_alternate_presentations' => ['=='], 'has_rejected_presentations' => ['=='], + 'has_published_presentations' => ['=='], 'presentations_track_id' => ['=='], 'presentations_track_group_id' => ['=='], 'presentations_selection_plan_id' => ['=='], @@ -181,6 +182,7 @@ function () { 'has_accepted_presentations' => 'sometimes|string|in:true,false', 'has_alternate_presentations' => 'sometimes|string|in:true,false', 'has_rejected_presentations' => 'sometimes|string|in:true,false', + 'has_published_presentations' => 'sometimes|string|in:true,false', 'presentations_track_id' => 'sometimes|integer', 'presentations_track_group_id' => 'sometimes|integer', 'presentations_selection_plan_id' => 'sometimes|integer', @@ -301,6 +303,7 @@ function () { 'has_accepted_presentations' => ['=='], 'has_alternate_presentations' => ['=='], 'has_rejected_presentations' => ['=='], + 'has_published_presentations' => ['=='], 'presentations_track_id' => ['=='], 'presentations_track_group_id' => ['=='], 'presentations_selection_plan_id' => ['=='], @@ -327,6 +330,7 @@ function () { 'has_accepted_presentations' => 'sometimes|string|in:true,false', 'has_alternate_presentations' => 'sometimes|string|in:true,false', 'has_rejected_presentations' => 'sometimes|string|in:true,false', + 'has_published_presentations' => 'sometimes|string|in:true,false', 'presentations_track_id' => 'sometimes|integer', 'presentations_track_group_id' => 'sometimes|integer', 'presentations_selection_plan_id' => 'sometimes|integer', @@ -454,6 +458,7 @@ public function send($summit_id) 'has_accepted_presentations' => ['=='], 'has_alternate_presentations' => ['=='], 'has_rejected_presentations' => ['=='], + 'has_published_presentations' => ['=='], 'presentations_track_id' => ['=='], 'presentations_track_group_id' => ['=='], 'presentations_selection_plan_id' => ['=='], @@ -483,6 +488,7 @@ public function send($summit_id) 'has_accepted_presentations' => 'sometimes|string|in:true,false', 'has_alternate_presentations' => 'sometimes|string|in:true,false', 'has_rejected_presentations' => 'sometimes|string|in:true,false', + 'has_published_presentations' => 'sometimes|string|in:true,false', 'presentations_track_id' => 'sometimes|integer', 'presentations_track_group_id' => 'sometimes|integer', 'presentations_selection_plan_id' => 'sometimes|integer', @@ -523,7 +529,7 @@ public function send($summit_id) name: "filter", in: "query", required: false, - description: "Filter query (supports multiple operators). Filterable fields: id, not_id, first_name, last_name, email, full_name, member_id, member_user_external_id, has_accepted_presentations, has_alternate_presentations, has_rejected_presentations, presentations_track_id, presentations_selection_plan_id, presentations_type_id, presentations_title, presentations_abstract, presentations_submitter_full_name, presentations_submitter_email, is_speaker, has_media_upload_with_type, has_not_media_upload_with_type.", + description: "Filter query (supports multiple operators). Filterable fields: id, not_id, first_name, last_name, email, full_name, member_id, member_user_external_id, has_accepted_presentations, has_alternate_presentations, has_rejected_presentations, has_published_presentations, presentations_track_id, presentations_selection_plan_id, presentations_type_id, presentations_title, presentations_abstract, presentations_submitter_full_name, presentations_submitter_email, is_speaker, has_media_upload_with_type, has_not_media_upload_with_type.", schema: new OA\Schema(type: "string", example: "has_accepted_presentations==true") ), ], @@ -564,6 +570,7 @@ public function getSubmittersActivitiesCount($summit_id) 'has_accepted_presentations' => ['=='], 'has_alternate_presentations' => ['=='], 'has_rejected_presentations' => ['=='], + 'has_published_presentations' => ['=='], 'presentations_track_id' => ['=='], 'presentations_track_group_id' => ['=='], 'presentations_selection_plan_id' => ['=='], @@ -591,6 +598,7 @@ public function getSubmittersActivitiesCount($summit_id) 'has_accepted_presentations' => 'sometimes|string|in:true,false', 'has_alternate_presentations' => 'sometimes|string|in:true,false', 'has_rejected_presentations' => 'sometimes|string|in:true,false', + 'has_published_presentations' => 'sometimes|string|in:true,false', 'presentations_track_id' => 'sometimes|integer', 'presentations_track_group_id' => 'sometimes|integer', 'presentations_selection_plan_id' => 'sometimes|integer', diff --git a/app/Jobs/Emails/PresentationSubmissions/ProcessSubmittersEmailRequestJob.php b/app/Jobs/Emails/PresentationSubmissions/ProcessSubmittersEmailRequestJob.php index f143b12ad..5d043feea 100644 --- a/app/Jobs/Emails/PresentationSubmissions/ProcessSubmittersEmailRequestJob.php +++ b/app/Jobs/Emails/PresentationSubmissions/ProcessSubmittersEmailRequestJob.php @@ -92,6 +92,7 @@ public function handle 'has_accepted_presentations' => ['=='], 'has_alternate_presentations' => ['=='], 'has_rejected_presentations' => ['=='], + 'has_published_presentations' => ['=='], 'presentations_track_id' => ['=='], 'presentations_selection_plan_id' => ['=='], 'presentations_type_id' => ['=='], diff --git a/app/Repositories/Summit/DoctrineMemberRepository.php b/app/Repositories/Summit/DoctrineMemberRepository.php index 2f3731b38..e3fa54246 100644 --- a/app/Repositories/Summit/DoctrineMemberRepository.php +++ b/app/Repositories/Summit/DoctrineMemberRepository.php @@ -293,6 +293,37 @@ protected function getFilterMappings() ), ] ), + 'has_published_presentations' => + new DoctrineSwitchFilterMapping([ + 'true' => new DoctrineCaseFilterMapping( + 'true', + 'EXISTS ( + SELECT __p15.id FROM models\summit\Presentation __p15 + JOIN __p15.created_by __c15 WITH __c15 = e.id + JOIN __p15.category __cat15 + JOIN __p15.type __t15 + LEFT JOIN __p15.selection_plan __sel_plan15 + LEFT JOIN models\summit\PresentationMediaUpload __pm15 WITH __pm15.presentation = __p15 + LEFT JOIN __pm15.media_upload_type __mut15 + WHERE __p15.summit = :summit AND __p15.published = 1 ' + .(!empty($extraSelectionStatusFilter)? sprintf($extraSelectionStatusFilter, '15'): ''). + ')' + ), + 'false' => new DoctrineCaseFilterMapping( + 'false', + 'NOT EXISTS ( + SELECT __p15.id FROM models\summit\Presentation __p15 + JOIN __p15.created_by __c15 WITH __c15 = e.id + JOIN __p15.category __cat15 + JOIN __p15.type __t15 + LEFT JOIN __p15.selection_plan __sel_plan15 + LEFT JOIN models\summit\PresentationMediaUpload __pm15 WITH __pm15.presentation = __p15 + LEFT JOIN __pm15.media_upload_type __mut15 + WHERE __p15.summit = :summit AND __p15.published = 1 ' + .(!empty($extraSelectionStatusFilter)? sprintf($extraSelectionStatusFilter, '15'): ''). + ')' + ), + ]), 'has_alternate_presentations' => new DoctrineSwitchFilterMapping([ 'true' => new DoctrineCaseFilterMapping( diff --git a/app/Repositories/Summit/DoctrineSpeakerRepository.php b/app/Repositories/Summit/DoctrineSpeakerRepository.php index 9140d2515..670a6f179 100644 --- a/app/Repositories/Summit/DoctrineSpeakerRepository.php +++ b/app/Repositories/Summit/DoctrineSpeakerRepository.php @@ -346,6 +346,55 @@ protected function getFilterMappings() ] ), + 'has_published_presentations' => + new DoctrineSwitchFilterMapping([ + 'true' => new DoctrineCaseFilterMapping( + 'true', + 'EXISTS ( + SELECT __p15.id FROM models\summit\Presentation __p15 + JOIN __p15.speakers __spk15 WITH __spk15.speaker = e.id + JOIN __p15.category __cat15 + JOIN __p15.type __t15 + LEFT JOIN __p15.selection_plan __sel_plan15 + LEFT JOIN models\summit\PresentationMediaUpload __pm15 WITH __pm15.presentation = __p15 + LEFT JOIN __pm15.media_upload_type __mut15 + WHERE __p15.summit = :summit AND __p15.published = 1 ' + .(!empty($extraSelectionStatusFilter)? sprintf($extraSelectionStatusFilter, '15'): ' '). + ') OR EXISTS ( + SELECT __p16.id FROM models\summit\Presentation __p16 + JOIN __p16.moderator __md16 WITH __md16.id = e.id + JOIN __p16.category __cat16 + JOIN __p16.type __t16 + LEFT JOIN __p16.selection_plan __sel_plan16 + LEFT JOIN models\summit\PresentationMediaUpload __pm16 WITH __pm16.presentation = __p16 + LEFT JOIN __pm16.media_upload_type __mut16 + WHERE __p16.summit = :summit AND __p16.published = 1 ' + .(!empty($extraSelectionStatusFilter)? sprintf($extraSelectionStatusFilter, '16'): ' ').')' + ), + 'false' => new DoctrineCaseFilterMapping( + 'false', + 'NOT EXISTS ( + SELECT __p15.id FROM models\summit\Presentation __p15 + JOIN __p15.speakers __spk15 WITH __spk15.speaker = e.id + JOIN __p15.category __cat15 + JOIN __p15.type __t15 + LEFT JOIN __p15.selection_plan __sel_plan15 + LEFT JOIN models\summit\PresentationMediaUpload __pm15 WITH __pm15.presentation = __p15 + LEFT JOIN __pm15.media_upload_type __mut15 + WHERE __p15.summit = :summit AND __p15.published = 1 ' + .(!empty($extraSelectionStatusFilter)? sprintf($extraSelectionStatusFilter, '15'): ' '). + ') AND NOT EXISTS ( + SELECT __p16.id FROM models\summit\Presentation __p16 + JOIN __p16.moderator __md16 WITH __md16.id = e.id + JOIN __p16.category __cat16 + JOIN __p16.type __t16 + LEFT JOIN __p16.selection_plan __sel_plan16 + LEFT JOIN models\summit\PresentationMediaUpload __pm16 WITH __pm16.presentation = __p16 + LEFT JOIN __pm16.media_upload_type __mut16 + WHERE __p16.summit = :summit AND __p16.published = 1 ' + .(!empty($extraSelectionStatusFilter)? sprintf($extraSelectionStatusFilter, '16'): ' ').')' + ), + ]), 'has_alternate_presentations' => new DoctrineSwitchFilterMapping([ 'true' => new DoctrineCaseFilterMapping( diff --git a/app/Services/Model/ISpeakerFilterFields.php b/app/Services/Model/ISpeakerFilterFields.php index ce4d43f52..8fb445bc4 100644 --- a/app/Services/Model/ISpeakerFilterFields.php +++ b/app/Services/Model/ISpeakerFilterFields.php @@ -36,6 +36,7 @@ interface ISpeakerFilterFields 'has_accepted_presentations' => ['=='], 'has_alternate_presentations' => ['=='], 'has_rejected_presentations' => ['=='], + 'has_published_presentations' => ['=='], 'presentations_track_id' => ['=='], 'presentations_track_group_id' => ['=='], 'presentations_selection_plan_id' => ['=='], @@ -60,6 +61,7 @@ interface ISpeakerFilterFields 'has_accepted_presentations' => 'sometimes|string|in:true,false', 'has_alternate_presentations' => 'sometimes|string|in:true,false', 'has_rejected_presentations' => 'sometimes|string|in:true,false', + 'has_published_presentations' => 'sometimes|string|in:true,false', 'presentations_track_id' => 'sometimes|integer', 'presentations_track_group_id' => 'sometimes|integer', 'presentations_selection_plan_id' => 'sometimes|integer', diff --git a/app/Services/Model/Imp/SubmitterService.php b/app/Services/Model/Imp/SubmitterService.php index 4e30925ad..2b4627f87 100644 --- a/app/Services/Model/Imp/SubmitterService.php +++ b/app/Services/Model/Imp/SubmitterService.php @@ -146,6 +146,7 @@ function 'has_accepted_presentations' => ['=='], 'has_alternate_presentations' => ['=='], 'has_rejected_presentations' => ['=='], + 'has_published_presentations' => ['=='], 'presentations_track_id' => ['=='], 'presentations_selection_plan_id' => ['=='], 'presentations_type_id' => ['=='], diff --git a/tests/BrowserKitTestCase.php b/tests/BrowserKitTestCase.php index 24e5e3ed7..dd876624a 100644 --- a/tests/BrowserKitTestCase.php +++ b/tests/BrowserKitTestCase.php @@ -59,7 +59,7 @@ protected function prepareForTests(): void { // clean up DB::setDefaultConnection("model"); Artisan::call("doctrine:migrations:migrate", ["--em" => "config", "--no-interaction" => true]); - Artisan::call("doctrine:migrations:migrate", ["--em" => "model", "--no-interaction" => true]); + Artisan::call("doctrine:migrations:migrate", ["--em" => "model_write", "--no-interaction" => true]); DB::setDefaultConnection("config"); diff --git a/tests/ProcessSpeakersEmailRequestJobTest.php b/tests/ProcessSpeakersEmailRequestJobTest.php new file mode 100644 index 000000000..36e3e0158 --- /dev/null +++ b/tests/ProcessSpeakersEmailRequestJobTest.php @@ -0,0 +1,104 @@ + 'SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ACCEPTED_ONLY']; + } + + private function captureFilter(Mockery\MockInterface $service): \stdClass + { + $captured = new \stdClass(); + $captured->filter = null; + $service->shouldReceive('sendEmails') + ->once() + ->andReturnUsing(function (int $id, array $payload, ?Filter $filter) use ($captured) { + $captured->filter = $filter; + }); + return $captured; + } + + public function testHandleAcceptsHasPublishedPresentationsTrueFilter(): void + { + $service = Mockery::mock(ISpeakerService::class); + $captured = $this->captureFilter($service); + + $job = new ProcessSpeakersEmailRequestJob( + 999, + $this->makePayload(), + ['has_published_presentations==true'] + ); + + $job->handle($service); + + $this->assertInstanceOf(Filter::class, $captured->filter, + 'handle() must pass a parsed Filter to sendEmails, not throw FilterParserException'); + } + + public function testHandleAcceptsHasPublishedPresentationsFalseFilter(): void + { + $service = Mockery::mock(ISpeakerService::class); + $captured = $this->captureFilter($service); + + $job = new ProcessSpeakersEmailRequestJob( + 999, + $this->makePayload(), + ['has_published_presentations==false'] + ); + + $job->handle($service); + + $this->assertInstanceOf(Filter::class, $captured->filter); + } + + public function testHandleAcceptsHasPublishedPresentationsCombinedWithOtherFilters(): void + { + // Verify the field works alongside the sibling filters already in the job allow-list. + $service = Mockery::mock(ISpeakerService::class); + $captured = $this->captureFilter($service); + + $job = new ProcessSpeakersEmailRequestJob( + 999, + $this->makePayload(), + ['has_published_presentations==true', 'has_accepted_presentations==true'] + ); + + $job->handle($service); + + $this->assertInstanceOf(Filter::class, $captured->filter); + } +} diff --git a/tests/ProcessSubmittersEmailRequestJobTest.php b/tests/ProcessSubmittersEmailRequestJobTest.php new file mode 100644 index 000000000..f30c22dcc --- /dev/null +++ b/tests/ProcessSubmittersEmailRequestJobTest.php @@ -0,0 +1,104 @@ + 'SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ACCEPTED_ONLY']; + } + + private function captureFilter(Mockery\MockInterface $service): \stdClass + { + $captured = new \stdClass(); + $captured->filter = null; + $service->shouldReceive('sendEmails') + ->once() + ->andReturnUsing(function (int $id, array $payload, ?Filter $filter) use ($captured) { + $captured->filter = $filter; + }); + return $captured; + } + + public function testHandleAcceptsHasPublishedPresentationsTrueFilter(): void + { + $service = Mockery::mock(ISubmitterService::class); + $captured = $this->captureFilter($service); + + $job = new ProcessSubmittersEmailRequestJob( + 999, + $this->makePayload(), + ['has_published_presentations==true'] + ); + + $job->handle($service); + + $this->assertInstanceOf(Filter::class, $captured->filter, + 'handle() must pass a parsed Filter to sendEmails, not throw FilterParserException'); + } + + public function testHandleAcceptsHasPublishedPresentationsFalseFilter(): void + { + $service = Mockery::mock(ISubmitterService::class); + $captured = $this->captureFilter($service); + + $job = new ProcessSubmittersEmailRequestJob( + 999, + $this->makePayload(), + ['has_published_presentations==false'] + ); + + $job->handle($service); + + $this->assertInstanceOf(Filter::class, $captured->filter); + } + + public function testHandleAcceptsHasPublishedPresentationsCombinedWithOtherFilters(): void + { + // Verify the field works alongside the sibling filters already in the job allow-list. + $service = Mockery::mock(ISubmitterService::class); + $captured = $this->captureFilter($service); + + $job = new ProcessSubmittersEmailRequestJob( + 999, + $this->makePayload(), + ['has_published_presentations==true', 'has_accepted_presentations==true'] + ); + + $job->handle($service); + + $this->assertInstanceOf(Filter::class, $captured->filter); + } +} diff --git a/tests/SpeakerRepositoryTest.php b/tests/SpeakerRepositoryTest.php index e825826f5..1f9cd3133 100644 --- a/tests/SpeakerRepositoryTest.php +++ b/tests/SpeakerRepositoryTest.php @@ -112,6 +112,12 @@ public function testGetAllByPageFilterById(): void public function testGetAllByPageNotIdFilterExcludesSpeaker(): void { + $second = new PresentationSpeaker(); + $second->setFirstName('Second'); + $second->setLastName('Speaker'); + self::$em->persist($second); + self::$em->flush(); + $all = $this->repo()->getAllByPage(new PagingInfo(1, 100)); $this->assertGreaterThan(1, $all->getTotal(), 'Need at least 2 speakers for not_id test'); @@ -366,6 +372,294 @@ public function testGetUniqueActivitiesCountBySummitDoesNotForceMemoryStorageEng } } + // ----------------------------------------------------------------- + // getSpeakersBySummit / getUniqueActivitiesCountBySummit - has_published_presentations + // The filter checks Presentation.published = 1 for both speaker and + // moderator roles. + // ----------------------------------------------------------------- + + public function testGetSpeakersBySummitHasPublishedPresentationsTrueViaModeratorRole(): void + { + // A speaker who is the moderator (not in speakers collection) of a published + // presentation must appear in has_published_presentations==true results. + $moderator = new PresentationSpeaker(); + $moderator->setFirstName('PublishedModerator'); + $moderator->setLastName('TestSpeaker'); + self::$em->persist($moderator); + + $p = new Presentation(); + self::$summit->addEvent($p); + $p->setTitle('Moderator Published Presentation'); + $p->setAbstract('Abstract'); + $p->setCategory(self::$defaultTrack); + $p->setType(self::$defaultPresentationType); + $p->setProgress(Presentation::PHASE_COMPLETE); + $p->setStatus(Presentation::STATUS_RECEIVED); + $p->setStartDate(new \DateTime('now', new \DateTimeZone('UTC'))); + $p->setEndDate((new \DateTime('now', new \DateTimeZone('UTC')))->add(new \DateInterval('PT2H'))); + $p->setModerator($moderator); + $p->publish(); + self::$em->flush(); + + $filter = FilterParser::parse( + ['filter' => 'has_published_presentations==true'], + ['has_published_presentations' => ['==']] + ); + + $page = $this->repo()->getSpeakersBySummit(self::$summit, new PagingInfo(1, 100), $filter); + + $ids = array_map(fn($s) => $s->getId(), $page->getItems()); + $this->assertContains($moderator->getId(), $ids); + } + + public function testGetSpeakersBySummitHasPublishedPresentationsFalse(): void + { + // Create a speaker with an unpublished presentation only. + $unpublishedSpeaker = new PresentationSpeaker(); + $unpublishedSpeaker->setFirstName('UnpublishedOnly'); + $unpublishedSpeaker->setLastName('TestSpeaker'); + self::$em->persist($unpublishedSpeaker); + + $p = new Presentation(); + self::$summit->addEvent($p); + $p->setTitle('Unpublished Presentation'); + $p->setAbstract('Abstract'); + $p->setCategory(self::$defaultTrack); + $p->setType(self::$defaultPresentationType); + $p->setProgress(Presentation::PHASE_COMPLETE); + $p->setStatus(Presentation::STATUS_RECEIVED); + $p->setStartDate(new \DateTime('now', new \DateTimeZone('UTC'))); + $p->setEndDate((new \DateTime('now', new \DateTimeZone('UTC')))->add(new \DateInterval('PT2H'))); + $p->addSpeaker($unpublishedSpeaker); + // Deliberately NOT calling publish() — leaves published = 0. + self::$em->flush(); + + $filter = FilterParser::parse( + ['filter' => 'has_published_presentations==false'], + ['has_published_presentations' => ['==']] + ); + + $page = $this->repo()->getSpeakersBySummit(self::$summit, new PagingInfo(1, 100), $filter); + + $ids = array_map(fn($s) => $s->getId(), $page->getItems()); + $this->assertContains($unpublishedSpeaker->getId(), $ids, + 'Speaker with only unpublished presentations must appear in false results'); + $this->assertNotContains(self::$defaultSpeaker->getId(), $ids, + 'Speaker with published presentations must not appear in false results'); + } + + public function testGetSpeakersBySummitHasPublishedPresentationsTrueExcludesUnpublishedOnlySpeaker(): void + { + // Speaker with no published presentations must be excluded from the true results. + $unpublishedSpeaker = new PresentationSpeaker(); + $unpublishedSpeaker->setFirstName('UnpublishedOnly2'); + $unpublishedSpeaker->setLastName('TestSpeaker'); + self::$em->persist($unpublishedSpeaker); + + $p = new Presentation(); + self::$summit->addEvent($p); + $p->setTitle('Unpublished Presentation 2'); + $p->setAbstract('Abstract'); + $p->setCategory(self::$defaultTrack); + $p->setType(self::$defaultPresentationType); + $p->setProgress(Presentation::PHASE_COMPLETE); + $p->setStatus(Presentation::STATUS_RECEIVED); + $p->setStartDate(new \DateTime('now', new \DateTimeZone('UTC'))); + $p->setEndDate((new \DateTime('now', new \DateTimeZone('UTC')))->add(new \DateInterval('PT2H'))); + $p->addSpeaker($unpublishedSpeaker); + self::$em->flush(); + + $filter = FilterParser::parse( + ['filter' => 'has_published_presentations==true'], + ['has_published_presentations' => ['==']] + ); + + $page = $this->repo()->getSpeakersBySummit(self::$summit, new PagingInfo(1, 100), $filter); + + $ids = array_map(fn($s) => $s->getId(), $page->getItems()); + $this->assertContains(self::$defaultSpeaker->getId(), $ids, + 'Speaker with published presentations must appear in true results'); + $this->assertNotContains($unpublishedSpeaker->getId(), $ids, + 'Speaker with only unpublished presentations must not appear in true results'); + } + + public function testGetUniqueActivitiesCountBySummitHasPublishedPresentationsTrue(): void + { + // All seeded presentations are published; speaker1 satisfies the filter. + $filter = FilterParser::parse( + ['filter' => 'has_published_presentations==true'], + ['has_published_presentations' => ['==']] + ); + $count = $this->repo()->getUniqueActivitiesCountBySummit(self::$summit, $filter); + $this->assertGreaterThan(0, $count); + } + + public function testGetUniqueActivitiesCountBySummitHasPublishedPresentationsFalseIsZeroWhenAllPublished(): void + { + // Every presentation in the fixture is published, so no speaker satisfies + // has_published_presentations==false — the count of their activities must be 0. + $filter = FilterParser::parse( + ['filter' => 'has_published_presentations==false'], + ['has_published_presentations' => ['==']] + ); + $count = $this->repo()->getUniqueActivitiesCountBySummit(self::$summit, $filter); + $this->assertEquals(0, $count); + } + + // ----------------------------------------------------------------- + // has_published_presentations + presentations_track_id combined + // Each condition must be satisfied by the SAME presentation. + // The defect: a speaker with an unpublished presentation in track A + // and a published one in track B satisfies both conditions through + // two different presentations and is incorrectly included. + // ----------------------------------------------------------------- + + public function testHasPublishedPresentationsIsScopedByTrackFilter(): void + { + // Speaker satisfies each condition through a DIFFERENT presentation: + // - unpublished in defaultTrack -> satisfies presentations_track_id + // - published in secondaryTrack -> satisfies has_published_presentations + // Only a mapping that scopes both into one subquery excludes them. + $speaker = new PresentationSpeaker(); + $speaker->setFirstName('CrossPresentationMatch'); + $speaker->setLastName('TestSpeaker'); + self::$em->persist($speaker); + + $this->seedPresentation($speaker, self::$defaultTrack, 'Unpublished In Default', false); + $this->seedPresentation($speaker, self::$secondaryTrack, 'Published In Secondary', true); + + // Positive control: published in the track being filtered. + $control = new PresentationSpeaker(); + $control->setFirstName('PublishedInDefault'); + $control->setLastName('TestSpeaker'); + self::$em->persist($control); + $this->seedPresentation($control, self::$defaultTrack, 'Published In Default', true); + + self::$em->flush(); + + $filter = FilterParser::parse( + [ + 'has_published_presentations==true', + 'presentations_track_id==' . self::$defaultTrack->getId(), + ], + [ + 'has_published_presentations' => ['=='], + 'presentations_track_id' => ['=='], + ] + ); + + $ids = array_map( + fn($s) => $s->getId(), + $this->repo()->getSpeakersBySummit(self::$summit, new PagingInfo(1, 100), $filter)->getItems() + ); + + $this->assertNotContains($speaker->getId(), $ids, + 'no published presentation exists in defaultTrack for this speaker'); + $this->assertContains($control->getId(), $ids, + 'speaker published in defaultTrack must still be returned'); + } + + private function seedPresentation( + PresentationSpeaker $speaker, $track, string $title, bool $publish + ): Presentation { + $p = new Presentation(); + self::$summit->addEvent($p); + $p->setTitle($title); + $p->setAbstract('Abstract'); + $p->setCategory($track); + $p->setType(self::$defaultPresentationType); + $p->setProgress(Presentation::PHASE_COMPLETE); + $p->setStatus(Presentation::STATUS_RECEIVED); + $p->setStartDate(new \DateTime('now', new \DateTimeZone('UTC'))); + $p->setEndDate((new \DateTime('now', new \DateTimeZone('UTC')))->add(new \DateInterval('PT2H'))); + $p->addSpeaker($speaker); + if ($publish) $p->publish(); + return $p; + } + + public function testHasPublishedPresentationsIsScopedByTrackFilterModeratorBranch(): void + { + // Exercises the OR EXISTS moderator subquery specifically: + // - speaker: moderator of an unpublished presentation in defaultTrack + // AND moderator of a published presentation in secondaryTrack + // -> satisfies each condition through different presentations; must be excluded + // - control: moderator of a published presentation in defaultTrack + // -> must be included (validates the moderator path, not the speaker path) + $speaker = new PresentationSpeaker(); + $speaker->setFirstName('CrossPresentationMod'); + $speaker->setLastName('TestSpeaker'); + self::$em->persist($speaker); + + // Unpublished in defaultTrack via moderator role (satisfies presentations_track_id). + $pUnpub = new Presentation(); + self::$summit->addEvent($pUnpub); + $pUnpub->setTitle('Mod Unpublished In Default'); + $pUnpub->setAbstract('Abstract'); + $pUnpub->setCategory(self::$defaultTrack); + $pUnpub->setType(self::$defaultPresentationType); + $pUnpub->setProgress(Presentation::PHASE_COMPLETE); + $pUnpub->setStatus(Presentation::STATUS_RECEIVED); + $pUnpub->setStartDate(new \DateTime('now', new \DateTimeZone('UTC'))); + $pUnpub->setEndDate((new \DateTime('now', new \DateTimeZone('UTC')))->add(new \DateInterval('PT2H'))); + $pUnpub->setModerator($speaker); + + // Published in secondaryTrack via moderator role (satisfies has_published_presentations). + $p = new Presentation(); + self::$summit->addEvent($p); + $p->setTitle('Mod Published In Secondary'); + $p->setAbstract('Abstract'); + $p->setCategory(self::$secondaryTrack); + $p->setType(self::$defaultPresentationType); + $p->setProgress(Presentation::PHASE_COMPLETE); + $p->setStatus(Presentation::STATUS_RECEIVED); + $p->setStartDate(new \DateTime('now', new \DateTimeZone('UTC'))); + $p->setEndDate((new \DateTime('now', new \DateTimeZone('UTC')))->add(new \DateInterval('PT2H'))); + $p->setModerator($speaker); + $p->publish(); + + // Positive control: moderator of a published presentation in defaultTrack. + $control = new PresentationSpeaker(); + $control->setFirstName('ModPublishedInDefault'); + $control->setLastName('TestSpeaker'); + self::$em->persist($control); + + $pControl = new Presentation(); + self::$summit->addEvent($pControl); + $pControl->setTitle('Mod Control Published In Default'); + $pControl->setAbstract('Abstract'); + $pControl->setCategory(self::$defaultTrack); + $pControl->setType(self::$defaultPresentationType); + $pControl->setProgress(Presentation::PHASE_COMPLETE); + $pControl->setStatus(Presentation::STATUS_RECEIVED); + $pControl->setStartDate(new \DateTime('now', new \DateTimeZone('UTC'))); + $pControl->setEndDate((new \DateTime('now', new \DateTimeZone('UTC')))->add(new \DateInterval('PT2H'))); + $pControl->setModerator($control); + $pControl->publish(); + + self::$em->flush(); + + $filter = FilterParser::parse( + [ + 'has_published_presentations==true', + 'presentations_track_id==' . self::$defaultTrack->getId(), + ], + [ + 'has_published_presentations' => ['=='], + 'presentations_track_id' => ['=='], + ] + ); + + $ids = array_map( + fn($s) => $s->getId(), + $this->repo()->getSpeakersBySummit(self::$summit, new PagingInfo(1, 100), $filter)->getItems() + ); + + $this->assertNotContains($speaker->getId(), $ids, + 'moderator with no published presentation in defaultTrack must be excluded'); + $this->assertContains($control->getId(), $ids, + 'moderator of a published presentation in defaultTrack must be returned'); + } + // ----------------------------------------------------------------- // getAllByPage - multi-page pagination // The two-phase approach uses LIMIT/OFFSET for page > 1. diff --git a/tests/SpeakerServiceOriginalFilterTest.php b/tests/SpeakerServiceOriginalFilterTest.php new file mode 100644 index 000000000..62cfd955b --- /dev/null +++ b/tests/SpeakerServiceOriginalFilterTest.php @@ -0,0 +1,130 @@ +setMember(self::$em->find(Member::class, self::$member2->getId())); + self::$em->persist($speaker); + + // Same speaker, one accepted (published) presentation per track. + $this->seedAcceptedPresentation($speaker, self::$defaultTrack, 'Accepted In Default Track'); + $this->seedAcceptedPresentation($speaker, self::$secondaryTrack, 'Accepted In Secondary Track'); + self::$em->flush(); + + // Mirrors summit-admin's "selected rows" send: ids go in `filter`, + // the grid criteria travel in payload.original_filter (speaker-actions.js:1187). + $filter = FilterParser::parse( + ['id==' . $speaker->getId()], + ['id' => ['==']] + ); + + $payload = [ + 'email_flow_event' => PresentationSpeakerSelectionProcessAcceptedOnlyEmail::EVENT_SLUG, + 'should_resend' => true, + 'original_filter' => [ + 'has_published_presentations==true', + 'presentations_track_id==' . self::$defaultTrack->getId(), + ], + ]; + + App::make(ISpeakerService::class)->sendEmails(self::$summit->getId(), $payload, $filter); + + $jobs = Queue::pushed(PresentationSpeakerSelectionProcessAcceptedOnlyEmail::class); + $this->assertCount(1, $jobs, 'exactly one speaker email must be queued'); + + $emailPayload = $this->readPayload($jobs->first()); + $accepted = $emailPayload[IMailTemplatesConstants::accepted_presentations]; + + // With has_published_presentations missing from the service allow-list the + // parse throws, original_filter is dropped whole, the id== filter takes over, + // and the secondaryTrack presentation leaks into the email body. + $this->assertCount(1, $accepted, + 'only the presentation in the filtered track belongs in the email'); + $this->assertSame( + [self::$defaultTrack->getId()], + array_values(array_unique(array_map(fn(array $p) => $p['track']['id'], $accepted))), + 'every listed presentation must belong to the track carried by original_filter' + ); + } + + private function seedAcceptedPresentation( + PresentationSpeaker $speaker, + $track, + string $title + ): Presentation { + $p = new Presentation(); + self::$summit->addEvent($p); + $p->setTitle($title); + $p->setAbstract('Abstract'); + $p->setCategory($track); + $p->setType(self::$defaultPresentationType); + $p->setProgress(Presentation::PHASE_COMPLETE); + $p->setStatus(Presentation::STATUS_RECEIVED); + $p->setStartDate(new \DateTime('now', new \DateTimeZone('UTC'))); + $p->setEndDate((new \DateTime('now', new \DateTimeZone('UTC')))->add(new \DateInterval('PT2H'))); + $p->addSpeaker($speaker); + $p->publish(); // published => "accepted" for getAcceptedPresentations + return $p; + } + + /** AbstractEmailJob::$payload is protected and has no accessor. */ + private function readPayload(object $job): array + { + $prop = new \ReflectionProperty(\App\Jobs\Emails\AbstractEmailJob::class, 'payload'); + $prop->setAccessible(true); + return $prop->getValue($job); + } +} diff --git a/tests/SubmitterRepositoryTest.php b/tests/SubmitterRepositoryTest.php index d831191f9..cc3fbc9b8 100644 --- a/tests/SubmitterRepositoryTest.php +++ b/tests/SubmitterRepositoryTest.php @@ -456,6 +456,241 @@ public function testGetSubmittersHasAcceptedWithTrackIdCombined(): void 'member2 must appear: published presentation in defaultTrack'); } + // ----------------------------------------------------------------- + // has_published_presentations filter + // Submitters are identified by created_by. + // ----------------------------------------------------------------- + + public function testGetSubmittersHasPublishedPresentationsTrue(): void + { + $member = self::$em->find(Member::class, self::$member->getId()); + $member2 = self::$em->find(Member::class, self::$member2->getId()); + + $start = new \DateTime('now', new \DateTimeZone('UTC')); + $end = (clone $start)->add(new \DateInterval('PT2H')); + + // member2: published presentation + $p1 = new Presentation(); + self::$summit->addEvent($p1); + $p1->setTitle('Published Filter - Published'); + $p1->setAbstract('Abstract'); + $p1->setCategory(self::$defaultTrack); + $p1->setType(self::$defaultPresentationType); + $p1->setProgress(Presentation::PHASE_COMPLETE); + $p1->setStatus(Presentation::STATUS_RECEIVED); + $p1->setStartDate($start); + $p1->setEndDate($end); + $p1->setCreatedBy($member2); + $p1->publish(); + + // member: unpublished presentation only + $p2 = new Presentation(); + self::$summit->addEvent($p2); + $p2->setTitle('Published Filter - Unpublished'); + $p2->setAbstract('Abstract'); + $p2->setCategory(self::$defaultTrack); + $p2->setType(self::$defaultPresentationType); + $p2->setProgress(Presentation::PHASE_COMPLETE); + $p2->setStatus(Presentation::STATUS_RECEIVED); + $p2->setStartDate($start); + $p2->setEndDate($end); + $p2->setCreatedBy($member); + // Deliberately NOT calling publish() + + self::$em->flush(); + + $repo = EntityManager::getRepository(Member::class); + $filter = FilterParser::parse( + ['filter' => 'has_published_presentations==true'], + ['has_published_presentations' => ['==']] + ); + $page = $repo->getSubmittersBySummit(self::$summit, new PagingInfo(1, 10), $filter, null); + + $ids = array_map(fn($m) => $m->getId(), $page->getItems()); + self::assertContains($member2->getId(), $ids, + 'member2 (published presentation) must be included'); + self::assertNotContains($member->getId(), $ids, + 'member (unpublished presentation only) must be excluded'); + } + + public function testGetSubmittersHasPublishedPresentationsFalse(): void + { + $member = self::$em->find(Member::class, self::$member->getId()); + $member2 = self::$em->find(Member::class, self::$member2->getId()); + + $start = new \DateTime('now', new \DateTimeZone('UTC')); + $end = (clone $start)->add(new \DateInterval('PT2H')); + + // member: unpublished presentation only + $p1 = new Presentation(); + self::$summit->addEvent($p1); + $p1->setTitle('Published False Filter - Unpublished'); + $p1->setAbstract('Abstract'); + $p1->setCategory(self::$defaultTrack); + $p1->setType(self::$defaultPresentationType); + $p1->setProgress(Presentation::PHASE_COMPLETE); + $p1->setStatus(Presentation::STATUS_RECEIVED); + $p1->setStartDate($start); + $p1->setEndDate($end); + $p1->setCreatedBy($member); + + // member2: published presentation + $p2 = new Presentation(); + self::$summit->addEvent($p2); + $p2->setTitle('Published False Filter - Published'); + $p2->setAbstract('Abstract'); + $p2->setCategory(self::$defaultTrack); + $p2->setType(self::$defaultPresentationType); + $p2->setProgress(Presentation::PHASE_COMPLETE); + $p2->setStatus(Presentation::STATUS_RECEIVED); + $p2->setStartDate($start); + $p2->setEndDate($end); + $p2->setCreatedBy($member2); + $p2->publish(); + + self::$em->flush(); + + $repo = EntityManager::getRepository(Member::class); + $filter = FilterParser::parse( + ['filter' => 'has_published_presentations==false'], + ['has_published_presentations' => ['==']] + ); + $page = $repo->getSubmittersBySummit(self::$summit, new PagingInfo(1, 10), $filter, null); + + $ids = array_map(fn($m) => $m->getId(), $page->getItems()); + self::assertContains($member->getId(), $ids, + 'member (unpublished presentation only) must be included'); + self::assertNotContains($member2->getId(), $ids, + 'member2 (published presentation) must be excluded'); + } + + public function testGetUniqueActivitiesCountBySummitHasPublishedPresentationsTrue(): void + { + $member2 = self::$em->find(Member::class, self::$member2->getId()); + + $start = new \DateTime('now', new \DateTimeZone('UTC')); + $end = (clone $start)->add(new \DateInterval('PT2H')); + + $p = new Presentation(); + self::$summit->addEvent($p); + $p->setTitle('Count Published - Published'); + $p->setAbstract('Abstract'); + $p->setCategory(self::$defaultTrack); + $p->setType(self::$defaultPresentationType); + $p->setProgress(Presentation::PHASE_COMPLETE); + $p->setStatus(Presentation::STATUS_RECEIVED); + $p->setStartDate($start); + $p->setEndDate($end); + $p->setCreatedBy($member2); + $p->publish(); + self::$em->flush(); + + $repo = EntityManager::getRepository(Member::class); + $filter = FilterParser::parse( + ['filter' => 'has_published_presentations==true'], + ['has_published_presentations' => ['==']] + ); + $count = $repo->getUniqueActivitiesCountBySummit(self::$summit, $filter); + $this->assertGreaterThan(0, $count); + } + + public function testGetUniqueActivitiesCountBySummitHasPublishedPresentationsFalseIsZeroWhenAllSubmittersHavePublished(): void + { + $member2 = self::$em->find(Member::class, self::$member2->getId()); + + $start = new \DateTime('now', new \DateTimeZone('UTC')); + $end = (clone $start)->add(new \DateInterval('PT2H')); + + // Only a published presentation exists for member2 in this summit. + $p = new Presentation(); + self::$summit->addEvent($p); + $p->setTitle('Count Published False - Published Only'); + $p->setAbstract('Abstract'); + $p->setCategory(self::$defaultTrack); + $p->setType(self::$defaultPresentationType); + $p->setProgress(Presentation::PHASE_COMPLETE); + $p->setStatus(Presentation::STATUS_RECEIVED); + $p->setStartDate($start); + $p->setEndDate($end); + $p->setCreatedBy($member2); + $p->publish(); + self::$em->flush(); + + $repo = EntityManager::getRepository(Member::class); + $filter = FilterParser::parse( + ['filter' => 'has_published_presentations==false'], + ['has_published_presentations' => ['==']] + ); + // member2 has a published presentation so they don't satisfy false; + // no submitter in this summit satisfies the filter → count must be 0. + $count = $repo->getUniqueActivitiesCountBySummit(self::$summit, $filter); + $this->assertEquals(0, $count); + } + + // ----------------------------------------------------------------- + // has_published_presentations + presentations_track_id combined + // Each condition must be satisfied by the SAME presentation. + // The defect: a submitter with an unpublished presentation in track A + // and a published one in track B satisfies both conditions through + // two different presentations and is incorrectly included. + // ----------------------------------------------------------------- + + public function testHasPublishedPresentationsIsScopedByTrackFilter(): void + { + $member = self::$em->find(Member::class, self::$member->getId()); + $member2 = self::$em->find(Member::class, self::$member2->getId()); + + // member satisfies each condition through a DIFFERENT presentation. + $this->seedPresentation($member, self::$defaultTrack, 'Unpublished In Default', false); + $this->seedPresentation($member, self::$secondaryTrack, 'Published In Secondary', true); + + // Positive control: published in the track being filtered. + $this->seedPresentation($member2, self::$defaultTrack, 'Published In Default', true); + + self::$em->flush(); + + $filter = FilterParser::parse( + [ + 'has_published_presentations==true', + 'presentations_track_id==' . self::$defaultTrack->getId(), + ], + [ + 'has_published_presentations' => ['=='], + 'presentations_track_id' => ['=='], + ] + ); + + $repo = EntityManager::getRepository(Member::class); + $ids = array_map( + fn($m) => $m->getId(), + $repo->getSubmittersBySummit(self::$summit, new PagingInfo(1, 10), $filter, null)->getItems() + ); + + self::assertNotContains($member->getId(), $ids, + 'member has no published presentation in defaultTrack'); + self::assertContains($member2->getId(), $ids, + 'member2 published in defaultTrack must still be returned'); + } + + private function seedPresentation(Member $creator, $track, string $title, bool $publish): Presentation + { + $start = new \DateTime('now', new \DateTimeZone('UTC')); + + $p = new Presentation(); + self::$summit->addEvent($p); + $p->setTitle($title); + $p->setAbstract('Abstract'); + $p->setCategory($track); + $p->setType(self::$defaultPresentationType); + $p->setProgress(Presentation::PHASE_COMPLETE); + $p->setStatus(Presentation::STATUS_RECEIVED); + $p->setStartDate($start); + $p->setEndDate((clone $start)->add(new \DateInterval('PT2H'))); + $p->setCreatedBy($creator); + if ($publish) $p->publish(); + return $p; + } + // ----------------------------------------------------------------- // getUniqueActivitiesCountBySummit - presentations_track_group_id // The submitter repo and speaker repo share the filter name but use diff --git a/tests/SubmitterServiceOriginalFilterTest.php b/tests/SubmitterServiceOriginalFilterTest.php new file mode 100644 index 000000000..86f5f8444 --- /dev/null +++ b/tests/SubmitterServiceOriginalFilterTest.php @@ -0,0 +1,126 @@ +find(Member::class, self::$member2->getId()); + + // Same submitter, one published (= accepted) presentation per track. + $this->seedAcceptedPresentation($submitter, self::$defaultTrack, 'Submitted In Default Track'); + $this->seedAcceptedPresentation($submitter, self::$secondaryTrack, 'Submitted In Secondary Track'); + self::$em->flush(); + + // Mirrors summit-admin's "selected rows" send: ids go in `filter`, + // the grid criteria travel in payload.original_filter (submitter-actions.js:313). + $filter = FilterParser::parse( + ['id==' . $submitter->getId()], + ['id' => ['==']] + ); + + $payload = [ + 'email_flow_event' => PresentationSubmitterSelectionProcessAcceptedOnlyEmail::EVENT_SLUG, + 'should_resend' => true, + 'original_filter' => [ + 'has_published_presentations==true', + 'presentations_track_id==' . self::$defaultTrack->getId(), + ], + ]; + + App::make(ISubmitterService::class)->sendEmails(self::$summit->getId(), $payload, $filter); + + $jobs = Queue::pushed(PresentationSubmitterSelectionProcessAcceptedOnlyEmail::class); + $this->assertCount(1, $jobs, 'exactly one submitter email must be queued'); + + $emailPayload = $this->readPayload($jobs->first()); + $accepted = $emailPayload[IMailTemplatesConstants::accepted_presentations]; + + // With has_published_presentations missing from the service allow-list the + // parse throws, original_filter is dropped whole, the id== filter takes over, + // and the secondaryTrack presentation leaks into the email body. + $this->assertCount(1, $accepted, + 'only the presentation in the filtered track belongs in the email'); + $this->assertSame( + [self::$defaultTrack->getId()], + array_values(array_unique(array_map(fn(array $p) => $p['track']['id'], $accepted))), + 'every listed presentation must belong to the track carried by original_filter' + ); + } + + private function seedAcceptedPresentation( + Member $submitter, + $track, + string $title + ): Presentation { + $p = new Presentation(); + self::$summit->addEvent($p); + $p->setTitle($title); + $p->setAbstract('Abstract'); + $p->setCategory($track); + $p->setType(self::$defaultPresentationType); + $p->setProgress(Presentation::PHASE_COMPLETE); + $p->setStatus(Presentation::STATUS_RECEIVED); + $p->setStartDate(new \DateTime('now', new \DateTimeZone('UTC'))); + $p->setEndDate((new \DateTime('now', new \DateTimeZone('UTC')))->add(new \DateInterval('PT2H'))); + $p->setCreatedBy($submitter); + $p->publish(); // published => "accepted" for Member::getAcceptedPresentations + return $p; + } + + /** AbstractEmailJob::$payload is protected and has no accessor. */ + private function readPayload(object $job): array + { + $prop = new \ReflectionProperty(\App\Jobs\Emails\AbstractEmailJob::class, 'payload'); + $prop->setAccessible(true); + return $prop->getValue($job); + } +} diff --git a/tests/oauth2/OAuth2SummitSpeakersApiTest.php b/tests/oauth2/OAuth2SummitSpeakersApiTest.php index 90f1a130d..05e5b5ff8 100644 --- a/tests/oauth2/OAuth2SummitSpeakersApiTest.php +++ b/tests/oauth2/OAuth2SummitSpeakersApiTest.php @@ -2833,4 +2833,78 @@ public function testCreateMySpeakerEmptyBioFallsBackToMemberBio() self::$em->flush(); } -} \ No newline at end of file + public function testGetCurrentSummitSpeakersWithPublishedPresentations() + { + // Seed a speaker with only an unpublished presentation — must not appear. + $unpublishedOnly = new PresentationSpeaker(); + $unpublishedOnly->setFirstName('UnpublishedOnlyApi'); + $unpublishedOnly->setLastName('TestSpeaker'); + self::$em->persist($unpublishedOnly); + + $p = new Presentation(); + self::$summit->addEvent($p); + $p->setTitle('Unpublished Api Presentation'); + $p->setAbstract('Abstract'); + $p->setCategory(self::$defaultTrack); + $p->setType(self::$defaultPresentationType); + $p->setProgress(Presentation::PHASE_COMPLETE); + $p->setStatus(Presentation::STATUS_RECEIVED); + $p->setStartDate(new \DateTime('now', new \DateTimeZone('UTC'))); + $p->setEndDate((new \DateTime('now', new \DateTimeZone('UTC')))->add(new \DateInterval('PT2H'))); + $p->addSpeaker($unpublishedOnly); + // deliberately not published + self::$em->flush(); + + $params = [ + 'id' => self::$summit->getId(), + 'page' => 1, + 'per_page' => 100, + 'filter' => ['has_published_presentations==true'], + 'order' => '+id', + ]; + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json", + ]; + + $response = $this->action( + "GET", + "OAuth2SummitSpeakersApiController@getSpeakers", + $params, [], [], [], $headers + ); + + $this->assertResponseStatus(200); + $ids = array_map(fn($s) => $s->id, json_decode($response->getContent())->data); + + $this->assertContains(self::$defaultSpeaker->getId(), $ids, + 'speaker with a published presentation must be returned'); + $this->assertNotContains($unpublishedOnly->getId(), $ids, + 'speaker with only unpublished presentations must be filtered out'); + } + + public function testGetCurrentSummitSpeakersActivitiesCountWithPublishedPresentations() + { + // The fixture already seeds published presentations for self::$defaultSpeaker, + // so a single call is enough to confirm the filter returns a non-zero count. + // Inclusion/exclusion correctness is covered by testGetCurrentSummitSpeakersWithPublishedPresentations. + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json", + ]; + + $response = $this->action( + "GET", + "OAuth2SummitSpeakersApiController@getSpeakersActivitiesCount", + ['id' => self::$summit->getId(), 'filter' => ['has_published_presentations==true']], + [], [], [], $headers + ); + + $this->assertResponseStatus(200); + $data = json_decode($response->getContent()); + $this->assertNotNull($data); + $this->assertTrue(isset($data->count)); + $this->assertGreaterThan(0, $data->count); + } + +} diff --git a/tests/oauth2/OAuth2SummitSubmittersApiTest.php b/tests/oauth2/OAuth2SummitSubmittersApiTest.php index e487e9bf5..a7dc6a98e 100644 --- a/tests/oauth2/OAuth2SummitSubmittersApiTest.php +++ b/tests/oauth2/OAuth2SummitSubmittersApiTest.php @@ -1,5 +1,7 @@ assertNotNull($submitters); } + + public function testGetCurrentSummitSubmittersWithPublishedPresentations() + { + $member = self::$em->find(Member::class, self::$member->getId()); + $member2 = self::$em->find(Member::class, self::$member2->getId()); + + $start = new \DateTime('now', new \DateTimeZone('UTC')); + $end = (clone $start)->add(new \DateInterval('PT2H')); + + // member2: published presentation — must appear. + $p1 = new Presentation(); + self::$summit->addEvent($p1); + $p1->setTitle('Submitter Api Published'); + $p1->setAbstract('Abstract'); + $p1->setCategory(self::$defaultTrack); + $p1->setType(self::$defaultPresentationType); + $p1->setProgress(Presentation::PHASE_COMPLETE); + $p1->setStatus(Presentation::STATUS_RECEIVED); + $p1->setStartDate($start); + $p1->setEndDate($end); + $p1->setCreatedBy($member2); + $p1->publish(); + + // member: unpublished presentation only — must NOT appear. + $p2 = new Presentation(); + self::$summit->addEvent($p2); + $p2->setTitle('Submitter Api Unpublished'); + $p2->setAbstract('Abstract'); + $p2->setCategory(self::$defaultTrack); + $p2->setType(self::$defaultPresentationType); + $p2->setProgress(Presentation::PHASE_COMPLETE); + $p2->setStatus(Presentation::STATUS_RECEIVED); + $p2->setStartDate($start); + $p2->setEndDate($end); + $p2->setCreatedBy($member); + // deliberately not published + + self::$em->flush(); + + $params = [ + 'id' => self::$summit->getId(), + 'page' => 1, + 'per_page' => 100, + 'filter' => ['has_published_presentations==true'], + 'order' => '+id', + ]; + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json", + ]; + + $response = $this->action( + "GET", + "OAuth2SummitSubmittersApiController@getAllBySummit", + $params, [], [], [], $headers + ); + + $this->assertResponseStatus(200); + $ids = array_map(fn($s) => $s->id, json_decode($response->getContent())->data); + + $this->assertContains($member2->getId(), $ids, + 'submitter with a published presentation must be returned'); + $this->assertNotContains($member->getId(), $ids, + 'submitter with only unpublished presentations must be filtered out'); + } + + public function testGetCurrentSummitSubmittersActivitiesCountWithPublishedPresentations() + { + $member2 = self::$em->find(Member::class, self::$member2->getId()); + + // The fixture sets no created_by on presentations, so the baseline is 0. + // Seed exactly one published presentation; the count must equal exactly 1. + $start = new \DateTime('now', new \DateTimeZone('UTC')); + $p = new Presentation(); + self::$summit->addEvent($p); + $p->setTitle('Count Submitter Published Api'); + $p->setAbstract('Abstract'); + $p->setCategory(self::$defaultTrack); + $p->setType(self::$defaultPresentationType); + $p->setProgress(Presentation::PHASE_COMPLETE); + $p->setStatus(Presentation::STATUS_RECEIVED); + $p->setStartDate($start); + $p->setEndDate((clone $start)->add(new \DateInterval('PT2H'))); + $p->setCreatedBy($member2); + $p->publish(); + self::$em->flush(); + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json", + ]; + + $response = $this->action( + "GET", + "OAuth2SummitSubmittersApiController@getSubmittersActivitiesCount", + ['id' => self::$summit->getId(), 'filter' => ['has_published_presentations==true']], + [], [], [], $headers + ); + + $this->assertResponseStatus(200); + $data = json_decode($response->getContent()); + $this->assertNotNull($data); + $this->assertTrue(isset($data->count)); + $this->assertEquals(1, $data->count, + 'exactly one published presentation was seeded; count must be 1'); + } } \ No newline at end of file