From bdd9a733b14e1b1ca8a4342e00ba067c71721089 Mon Sep 17 00:00:00 2001 From: romanetar Date: Mon, 22 Jun 2026 15:09:12 +0200 Subject: [PATCH 1/5] feat(speakers): add has_pending_presentations filter for speakers and submitters Signed-off-by: romanetar --- .../OAuth2SummitSpeakersApiController.php | 8 +- .../OAuth2SummitSubmittersApiController.php | 10 +- .../ProcessSubmittersEmailRequestJob.php | 1 + .../Summit/DoctrineMemberRepository.php | 55 +++++++++ .../Summit/DoctrineSpeakerRepository.php | 101 ++++++++++++++++ app/Services/Model/ISpeakerFilterFields.php | 2 + app/Services/Model/Imp/SubmitterService.php | 3 +- tests/oauth2/OAuth2SummitSpeakersApiTest.php | 110 +++++++++++++++++- .../oauth2/OAuth2SummitSubmittersApiTest.php | 53 ++++++++- 9 files changed, 335 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php index 96717d694..c27c94fc3 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, 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).', + description: 'Filter by id, not_id, first_name, last_name, email, full_name, member_id, member_user_external_id, has_pending_presentations, 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, 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.', + 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_pending_presentations, 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, 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).', + description: 'Filter by id, not_id, first_name, last_name, email, full_name, member_id, member_user_external_id, has_pending_presentations, 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, 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', + description: 'Filter speakers by id, not_id, first_name, last_name, email, full_name, member_id, member_user_external_id, has_pending_presentations, 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 c567283ea..f1ccb04f9 100644 --- a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSubmittersApiController.php +++ b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSubmittersApiController.php @@ -152,6 +152,7 @@ function () { 'full_name' => ['=@', '@@', '=='], 'member_id' => ['=='], 'member_user_external_id' => ['=='], + 'has_pending_presentations' => ['=='], 'has_accepted_presentations' => ['=='], 'has_alternate_presentations' => ['=='], 'has_rejected_presentations' => ['=='], @@ -179,6 +180,7 @@ function () { 'full_name' => 'sometimes|string', 'member_id' => 'sometimes|integer', 'member_user_external_id' => 'sometimes|integer', + 'has_pending_presentations' => 'sometimes|string|in:true,false', 'has_accepted_presentations' => 'sometimes|string|in:true,false', 'has_alternate_presentations' => 'sometimes|string|in:true,false', 'has_rejected_presentations' => 'sometimes|string|in:true,false', @@ -300,6 +302,7 @@ function () { 'full_name' => ['=@', '@@', '=='], 'member_id' => ['=='], 'member_user_external_id' => ['=='], + 'has_pending_presentations' => ['=='], 'has_accepted_presentations' => ['=='], 'has_alternate_presentations' => ['=='], 'has_rejected_presentations' => ['=='], @@ -327,6 +330,7 @@ function () { 'full_name' => 'sometimes|string', 'member_id' => 'sometimes|integer', 'member_user_external_id' => 'sometimes|integer', + 'has_pending_presentations' => 'sometimes|string|in:true,false', 'has_accepted_presentations' => 'sometimes|string|in:true,false', 'has_alternate_presentations' => 'sometimes|string|in:true,false', 'has_rejected_presentations' => 'sometimes|string|in:true,false', @@ -455,6 +459,7 @@ public function send($summit_id) 'full_name' => ['=@', '@@', '=='], 'member_id' => ['=='], 'member_user_external_id' => ['=='], + 'has_pending_presentations' => ['=='], 'has_accepted_presentations' => ['=='], 'has_alternate_presentations' => ['=='], 'has_rejected_presentations' => ['=='], @@ -485,6 +490,7 @@ public function send($summit_id) 'full_name' => 'sometimes|string', 'member_id' => 'sometimes|integer', 'member_user_external_id' => 'sometimes|integer', + 'has_pending_presentations' => 'sometimes|string|in:true,false', 'has_accepted_presentations' => 'sometimes|string|in:true,false', 'has_alternate_presentations' => 'sometimes|string|in:true,false', 'has_rejected_presentations' => 'sometimes|string|in:true,false', @@ -529,7 +535,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, 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.", + 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_pending_presentations, 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") ), ], @@ -567,6 +573,7 @@ public function getSubmittersActivitiesCount($summit_id) 'full_name' => ['=@', '@@', '=='], 'member_id' => ['=='], 'member_user_external_id' => ['=='], + 'has_pending_presentations' => ['=='], 'has_accepted_presentations' => ['=='], 'has_alternate_presentations' => ['=='], 'has_rejected_presentations' => ['=='], @@ -595,6 +602,7 @@ public function getSubmittersActivitiesCount($summit_id) 'full_name' => 'sometimes|string', 'member_id' => 'sometimes|integer', 'member_user_external_id' => 'sometimes|integer', + 'has_pending_presentations' => 'sometimes|string|in:true,false', 'has_accepted_presentations' => 'sometimes|string|in:true,false', 'has_alternate_presentations' => 'sometimes|string|in:true,false', 'has_rejected_presentations' => 'sometimes|string|in:true,false', diff --git a/app/Jobs/Emails/PresentationSubmissions/ProcessSubmittersEmailRequestJob.php b/app/Jobs/Emails/PresentationSubmissions/ProcessSubmittersEmailRequestJob.php index 5d043feea..703bdad05 100644 --- a/app/Jobs/Emails/PresentationSubmissions/ProcessSubmittersEmailRequestJob.php +++ b/app/Jobs/Emails/PresentationSubmissions/ProcessSubmittersEmailRequestJob.php @@ -89,6 +89,7 @@ public function handle 'full_name' => ['=@', '@@', '=='], 'member_id' => ['=='], 'member_user_external_id' => ['=='], + 'has_pending_presentations' => ['=='], 'has_accepted_presentations' => ['=='], 'has_alternate_presentations' => ['=='], 'has_rejected_presentations' => ['=='], diff --git a/app/Repositories/Summit/DoctrineMemberRepository.php b/app/Repositories/Summit/DoctrineMemberRepository.php index e3fa54246..11728e6bc 100644 --- a/app/Repositories/Summit/DoctrineMemberRepository.php +++ b/app/Repositories/Summit/DoctrineMemberRepository.php @@ -240,6 +240,61 @@ protected function getFilterMappings() __p10_1.summit = :summit AND LOWER(__c10.email) :operator LOWER(:value) )" ), + 'has_pending_presentations' => + new DoctrineSwitchFilterMapping([ + 'true' => new DoctrineCaseFilterMapping( + 'true', + sprintf('EXISTS ( + SELECT __p41.id FROM models\summit\Presentation __p41 + JOIN __p41.created_by __c41 WITH __c41 = e.id + JOIN __p41.category __cat41 + JOIN __p41.type __t41 + LEFT JOIN __p41.selection_plan __sel_plan41 + LEFT JOIN models\summit\PresentationMediaUpload __pm41 WITH __pm41.presentation = __p41 + LEFT JOIN __pm41.media_upload_type __mut41 + WHERE + __p41.summit = :summit + AND __p41.published = 0 '. + (!empty($extraSelectionStatusFilter)? sprintf($extraSelectionStatusFilter, '41'): ' '). + ' AND NOT EXISTS ( + SELECT ___sp41.id + FROM models\summit\SummitSelectedPresentation ___sp41 + JOIN ___sp41.presentation ___p41 + JOIN ___sp41.list ___spl41 WITH ___spl41.list_type = \'%1$s\' AND ___spl41.list_class = \'%2$s\' + WHERE ___p41.id = __p41.id + ))', + SummitSelectedPresentationList::Group, + SummitSelectedPresentationList::Session + ) + ), + 'false' => new DoctrineCaseFilterMapping( + 'false', + sprintf(' + NOT EXISTS ( + SELECT __p41.id FROM models\summit\Presentation __p41 + JOIN __p41.created_by __c41 WITH __c41 = e.id + JOIN __p41.category __cat41 + JOIN __p41.type __t41 + LEFT JOIN __p41.selection_plan __sel_plan41 + LEFT JOIN models\summit\PresentationMediaUpload __pm41 WITH __pm41.presentation = __p41 + LEFT JOIN __pm41.media_upload_type __mut41 + WHERE + __p41.summit = :summit + AND __p41.published = 0 '. + (!empty($extraSelectionStatusFilter)? sprintf($extraSelectionStatusFilter, '41'): ' '). + ' AND NOT EXISTS ( + SELECT ___sp41.id + FROM models\summit\SummitSelectedPresentation ___sp41 + JOIN ___sp41.presentation ___p41 + JOIN ___sp41.list ___spl41 WITH ___spl41.list_type = \'%1$s\' AND ___spl41.list_class = \'%2$s\' + WHERE ___p41.id = __p41.id + ))', + SummitSelectedPresentationList::Group, + SummitSelectedPresentationList::Session + ) + ), + ] + ), 'has_accepted_presentations' => new DoctrineSwitchFilterMapping([ 'true' => new DoctrineCaseFilterMapping( diff --git a/app/Repositories/Summit/DoctrineSpeakerRepository.php b/app/Repositories/Summit/DoctrineSpeakerRepository.php index 670a6f179..6ada22339 100644 --- a/app/Repositories/Summit/DoctrineSpeakerRepository.php +++ b/app/Repositories/Summit/DoctrineSpeakerRepository.php @@ -252,6 +252,107 @@ protected function getFilterMappings() WHERE __p10_2.summit = :summit AND LOWER(__cb10_2.email) :operator LOWER(:value) )"), + 'has_pending_presentations' => + new DoctrineSwitchFilterMapping([ + 'true' => new DoctrineCaseFilterMapping( + 'true', + sprintf('EXISTS ( + SELECT __p41.id FROM models\summit\Presentation __p41 + JOIN __p41.speakers __spk41 WITH __spk41.speaker = e.id + JOIN __p41.category __cat41 + JOIN __p41.type __t41 + LEFT JOIN __p41.selection_plan __sel_plan41 + LEFT JOIN models\summit\PresentationMediaUpload __pm41 WITH __pm41.presentation = __p41 + LEFT JOIN __pm41.media_upload_type __mut41 + WHERE + __p41.summit = :summit + AND __p41.published = 0'. + (!empty($extraSelectionStatusFilter)? sprintf($extraSelectionStatusFilter, '41'): ' '). + 'AND NOT EXISTS ( + SELECT ___sp41.id + FROM models\summit\SummitSelectedPresentation ___sp41 + JOIN ___sp41.presentation ___p41 + JOIN ___sp41.list ___spl41 WITH ___spl41.list_type = \'%1$s\' AND ___spl41.list_class = \'%2$s\' + WHERE ___p41.id = __p41.id + ))', + SummitSelectedPresentationList::Group, + SummitSelectedPresentationList::Session + ). + ' OR '. + sprintf('EXISTS ( + SELECT __p42.id FROM models\summit\Presentation __p42 + JOIN __p42.moderator __md42 WITH __md42.id = e.id + JOIN __p42.category __cat42 + JOIN __p42.type __t42 + LEFT JOIN __p42.selection_plan __sel_plan42 + LEFT JOIN models\summit\PresentationMediaUpload __pm42 WITH __pm42.presentation = __p42 + LEFT JOIN __pm42.media_upload_type __mut42 + WHERE + __p42.summit = :summit + AND __p42.published = 0'. + (!empty($extraSelectionStatusFilter)? sprintf($extraSelectionStatusFilter, '42'): ' '). + 'AND NOT EXISTS ( + SELECT ___sp42.id + FROM models\summit\SummitSelectedPresentation ___sp42 + JOIN ___sp42.presentation ___p42 + JOIN ___sp42.list ___spl42 WITH ___spl42.list_type = \'%1$s\' AND ___spl42.list_class = \'%2$s\' + WHERE ___p42.id = __p42.id + ))', + SummitSelectedPresentationList::Group, + SummitSelectedPresentationList::Session + ) + ), + 'false' => new DoctrineCaseFilterMapping( + 'false', + sprintf(' + NOT EXISTS ( + SELECT __p41.id FROM models\summit\Presentation __p41 + JOIN __p41.speakers __spk41 WITH __spk41.speaker = e.id + JOIN __p41.category __cat41 + JOIN __p41.type __t41 + LEFT JOIN __p41.selection_plan __sel_plan41 + LEFT JOIN models\summit\PresentationMediaUpload __pm41 WITH __pm41.presentation = __p41 + LEFT JOIN __pm41.media_upload_type __mut41 + WHERE + __p41.summit = :summit + AND __p41.published = 0'. + (!empty($extraSelectionStatusFilter)? sprintf($extraSelectionStatusFilter, '41'): ' '). + 'AND NOT EXISTS ( + SELECT ___sp41.id + FROM models\summit\SummitSelectedPresentation ___sp41 + JOIN ___sp41.presentation ___p41 + JOIN ___sp41.list ___spl41 WITH ___spl41.list_type = \'%1$s\' AND ___spl41.list_class = \'%2$s\' + WHERE ___p41.id = __p41.id + ))', + SummitSelectedPresentationList::Group, + SummitSelectedPresentationList::Session + ). + ' AND '. + sprintf('NOT EXISTS ( + SELECT __p42.id FROM models\summit\Presentation __p42 + JOIN __p42.moderator __md42 WITH __md42.id = e.id + JOIN __p42.category __cat42 + JOIN __p42.type __t42 + LEFT JOIN __p42.selection_plan __sel_plan42 + LEFT JOIN models\summit\PresentationMediaUpload __pm42 WITH __pm42.presentation = __p42 + LEFT JOIN __pm42.media_upload_type __mut42 + WHERE + __p42.summit = :summit + AND __p42.published = 0'. + (!empty($extraSelectionStatusFilter)? sprintf($extraSelectionStatusFilter, '42'): ' '). + 'AND NOT EXISTS ( + SELECT ___sp42.id + FROM models\summit\SummitSelectedPresentation ___sp42 + JOIN ___sp42.presentation ___p42 + JOIN ___sp42.list ___spl42 WITH ___spl42.list_type = \'%1$s\' AND ___spl42.list_class = \'%2$s\' + WHERE ___p42.id = __p42.id + ))', + SummitSelectedPresentationList::Group, + SummitSelectedPresentationList::Session + ) + ), + ] + ), 'has_accepted_presentations' => new DoctrineSwitchFilterMapping([ 'true' => new DoctrineCaseFilterMapping( diff --git a/app/Services/Model/ISpeakerFilterFields.php b/app/Services/Model/ISpeakerFilterFields.php index 8fb445bc4..0d5c54c3f 100644 --- a/app/Services/Model/ISpeakerFilterFields.php +++ b/app/Services/Model/ISpeakerFilterFields.php @@ -33,6 +33,7 @@ interface ISpeakerFilterFields 'full_name' => ['=@', '@@', '=='], 'member_id' => ['=='], 'member_user_external_id' => ['=='], + 'has_pending_presentations' => ['=='], 'has_accepted_presentations' => ['=='], 'has_alternate_presentations' => ['=='], 'has_rejected_presentations' => ['=='], @@ -58,6 +59,7 @@ interface ISpeakerFilterFields 'full_name' => 'sometimes|string', 'member_id' => 'sometimes|integer', 'member_user_external_id' => 'sometimes|integer', + 'has_pending_presentations' => 'sometimes|string|in:true,false', 'has_accepted_presentations' => 'sometimes|string|in:true,false', 'has_alternate_presentations' => 'sometimes|string|in:true,false', 'has_rejected_presentations' => 'sometimes|string|in:true,false', diff --git a/app/Services/Model/Imp/SubmitterService.php b/app/Services/Model/Imp/SubmitterService.php index 2b4627f87..0debb8e18 100644 --- a/app/Services/Model/Imp/SubmitterService.php +++ b/app/Services/Model/Imp/SubmitterService.php @@ -143,6 +143,7 @@ function 'full_name' => ['=@', '@@', '=='], 'member_id' => ['=='], 'member_user_external_id' => ['=='], + 'has_pending_presentations' => ['=='], 'has_accepted_presentations' => ['=='], 'has_alternate_presentations' => ['=='], 'has_rejected_presentations' => ['=='], @@ -184,4 +185,4 @@ function ($summit, $outcome_email_recipient, $report) { }, $filter); } -} \ No newline at end of file +} diff --git a/tests/oauth2/OAuth2SummitSpeakersApiTest.php b/tests/oauth2/OAuth2SummitSpeakersApiTest.php index 05e5b5ff8..909a38671 100644 --- a/tests/oauth2/OAuth2SummitSpeakersApiTest.php +++ b/tests/oauth2/OAuth2SummitSpeakersApiTest.php @@ -733,6 +733,62 @@ public function testGetCurrentSummitSpeakersWithRejectedPresentations() $this->assertTrue(!is_null($speakers)); } + public function testGetCurrentSummitSpeakersWithPendingPresentations() + { + $speaker = new PresentationSpeaker(); + $speaker->setFirstName("Pending"); + $speaker->setLastName("Speaker"); + self::$em->persist($speaker); + + $start = new \DateTime('now', new \DateTimeZone('UTC')); + $end = (clone $start)->add(new \DateInterval('PT2H')); + + $pres = new Presentation(); + self::$summit->addEvent($pres); + $pres->setTitle("Pending Test Presentation"); + $pres->setAbstract("Abstract"); + $pres->setCategory(self::$defaultTrack); + $pres->setType(self::$defaultPresentationType); + $pres->setProgress(Presentation::PHASE_COMPLETE); + $pres->setStatus(Presentation::STATUS_RECEIVED); + $pres->setStartDate($start); + $pres->setEndDate($end); + $pres->addSpeaker($speaker); + // Deliberately NOT published and NOT added to any SummitSelectedPresentation group list + self::$em->flush(); + + $params = [ + 'id' => self::$summit->getId(), + 'page' => 1, + 'per_page' => 10, + 'filter' => [ + 'has_pending_presentations==true', + ], + 'order' => '+id' + ]; + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json" + ]; + + $response = $this->action( + "GET", + "OAuth2SummitSpeakersApiController@getSpeakers", + $params, + [], + [], + [], + $headers + ); + + $content = $response->getContent(); + $this->assertResponseStatus(200); + $speakers = json_decode($content); + $this->assertTrue(!is_null($speakers)); + $this->assertTrue(count($speakers->data) > 0); + } + public function testGetCurrentSummitSpeakersFilteredByMemberExternalUserID() { $params = [ @@ -2575,7 +2631,7 @@ public function testGetCurrentSummitSpeakersActivitiesCountWithAcceptedPresentat { // Get the filtered baseline before seeding so the assertion is exact. // A broken filter that returns all results would produce a count far - // greater than baseline + 1, causing the assertEquals to fail. + // greater than baseline + 1, causing the assertEquals to fail. $baseline = EntityManager::getRepository(PresentationSpeaker::class) ->getUniqueActivitiesCountBySummit( self::$summit, @@ -2629,6 +2685,58 @@ public function testGetCurrentSummitSpeakersActivitiesCountWithAcceptedPresentat $this->assertEquals($baseline + 1, $data->count); } + public function testGetCurrentSummitSpeakersActivitiesCountWithPendingPresentations() + { + $baseline = EntityManager::getRepository(PresentationSpeaker::class) + ->getUniqueActivitiesCountBySummit( + self::$summit, + FilterParser::parse( + ['filter' => 'has_pending_presentations==true'], + ['has_pending_presentations' => ['==']] + ) + ); + + $speaker = new PresentationSpeaker(); + $speaker->setFirstName("Pending"); + $speaker->setLastName("Test"); + self::$em->persist($speaker); + + $start = new \DateTime('now', new \DateTimeZone('UTC')); + $end = (clone $start)->add(new \DateInterval('PT2H')); + + $pres = new Presentation(); + self::$summit->addEvent($pres); + $pres->setTitle("Pending Test Presentation"); + $pres->setAbstract("Abstract"); + $pres->setCategory(self::$defaultTrack); + $pres->setType(self::$defaultPresentationType); + $pres->setProgress(Presentation::PHASE_COMPLETE); + $pres->setStatus(Presentation::STATUS_RECEIVED); + $pres->setStartDate($start); + $pres->setEndDate($end); + $pres->addSpeaker($speaker); + // Deliberately NOT published and NOT added to any SummitSelectedPresentation group list + self::$em->flush(); + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json" + ]; + + $response = $this->action( + "GET", + "OAuth2SummitSpeakersApiController@getSpeakersActivitiesCount", + ['id' => self::$summit->getId(), 'filter' => ['has_pending_presentations==true']], + [], [], [], $headers + ); + + $this->assertResponseStatus(200); + $data = json_decode($response->getContent()); + $this->assertNotNull($data); + $this->assertTrue(isset($data->count)); + $this->assertEquals($baseline + 1, $data->count); + } + private function resetEmIfNeeded(): void { if (!self::$em->isOpen()) { diff --git a/tests/oauth2/OAuth2SummitSubmittersApiTest.php b/tests/oauth2/OAuth2SummitSubmittersApiTest.php index a7dc6a98e..0820a5e1b 100644 --- a/tests/oauth2/OAuth2SummitSubmittersApiTest.php +++ b/tests/oauth2/OAuth2SummitSubmittersApiTest.php @@ -134,6 +134,57 @@ public function testGetCurrentSummitSubmittersWithAcceptedPresentations() $this->assertTrue(!is_null($submitters)); } + public function testGetCurrentSummitSubmittersWithPendingPresentations() + { + $start = new \DateTime('now', new \DateTimeZone('UTC')); + $end = (clone $start)->add(new \DateInterval('PT2H')); + + $pres = new Presentation(); + self::$summit->addEvent($pres); + $pres->setTitle("Pending Test Presentation"); + $pres->setAbstract("Abstract"); + $pres->setCategory(self::$defaultTrack); + $pres->setType(self::$defaultPresentationType); + $pres->setProgress(Presentation::PHASE_COMPLETE); + $pres->setStatus(Presentation::STATUS_RECEIVED); + $pres->setStartDate($start); + $pres->setEndDate($end); + $pres->setCreatedBy(self::$defaultMember); + // Deliberately NOT published and NOT added to any SummitSelectedPresentation group list + self::$em->flush(); + + $params = [ + 'id' => self::$summit->getId(), + 'page' => 1, + 'per_page' => 10, + 'filter' => [ + 'has_pending_presentations==true', + ], + 'order' => '+id' + ]; + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json" + ]; + + $response = $this->action( + "GET", + "OAuth2SummitSubmittersApiController@getAllBySummit", + $params, + [], + [], + [], + $headers + ); + + $content = $response->getContent(); + $this->assertResponseStatus(200); + $submitters = json_decode($content); + $this->assertTrue(!is_null($submitters)); + $this->assertTrue(count($submitters->data) > 0); + } + public function testExportCurrentSummitSubmittersWhoAreSpeakers() { $params = [ @@ -444,4 +495,4 @@ public function testGetCurrentSummitSubmittersActivitiesCountWithPublishedPresen $this->assertEquals(1, $data->count, 'exactly one published presentation was seeded; count must be 1'); } -} \ No newline at end of file +} From de860aedff06ad778827b66124ceff85c1d51ad7 Mon Sep 17 00:00:00 2001 From: romanetar Date: Mon, 7 Sep 2026 16:26:09 +0200 Subject: [PATCH 2/5] fix(speakers): require an incomplete submission for has_pending_presentations The filter only checked published=0 and the absence of a selection-list entry, so a speaker/submitter whose presentation was already complete/received (PHASE_COMPLETE/STATUS_RECEIVED) still matched pending==true whenever it hadn't been selected yet by a track chair. During an open CFP this matched nearly every submission, which would have sent "finish your submission" reminders to people who already had. Adds a check on Presentation.progress/status so the filter now also requires the submission itself to be unfinished, in both DoctrineSpeakerRepository and the equivalent mapping in DoctrineMemberRepository. --- .../Summit/DoctrineMemberRepository.php | 7 +- .../Summit/DoctrineSpeakerRepository.php | 13 ++-- tests/oauth2/OAuth2SummitSpeakersApiTest.php | 72 +++++++++++++++++-- .../oauth2/OAuth2SummitSubmittersApiTest.php | 64 ++++++++++++++++- 4 files changed, 141 insertions(+), 15 deletions(-) diff --git a/app/Repositories/Summit/DoctrineMemberRepository.php b/app/Repositories/Summit/DoctrineMemberRepository.php index 11728e6bc..b5846ec8c 100644 --- a/app/Repositories/Summit/DoctrineMemberRepository.php +++ b/app/Repositories/Summit/DoctrineMemberRepository.php @@ -23,6 +23,7 @@ use models\main\IMemberRepository; use models\main\Member; use App\Repositories\SilverStripeDoctrineRepository; +use models\summit\Presentation; use models\summit\Summit; use models\summit\SummitSelectedPresentation; use models\summit\SummitSelectedPresentationList; @@ -254,7 +255,8 @@ protected function getFilterMappings() LEFT JOIN __pm41.media_upload_type __mut41 WHERE __p41.summit = :summit - AND __p41.published = 0 '. + AND __p41.published = 0 + AND (__p41.progress != '.Presentation::PHASE_COMPLETE.' OR __p41.status IS NULL OR __p41.status != \''.Presentation::STATUS_RECEIVED.'\') '. (!empty($extraSelectionStatusFilter)? sprintf($extraSelectionStatusFilter, '41'): ' '). ' AND NOT EXISTS ( SELECT ___sp41.id @@ -280,7 +282,8 @@ protected function getFilterMappings() LEFT JOIN __pm41.media_upload_type __mut41 WHERE __p41.summit = :summit - AND __p41.published = 0 '. + AND __p41.published = 0 + AND (__p41.progress != '.Presentation::PHASE_COMPLETE.' OR __p41.status IS NULL OR __p41.status != \''.Presentation::STATUS_RECEIVED.'\') '. (!empty($extraSelectionStatusFilter)? sprintf($extraSelectionStatusFilter, '41'): ' '). ' AND NOT EXISTS ( SELECT ___sp41.id diff --git a/app/Repositories/Summit/DoctrineSpeakerRepository.php b/app/Repositories/Summit/DoctrineSpeakerRepository.php index 6ada22339..a533cf229 100644 --- a/app/Repositories/Summit/DoctrineSpeakerRepository.php +++ b/app/Repositories/Summit/DoctrineSpeakerRepository.php @@ -21,6 +21,7 @@ use Libs\Utils\Doctrine\DoctrineStatementValueBinder; use models\main\Member; use models\summit\ISpeakerRepository; +use models\summit\Presentation; use models\summit\PresentationSpeaker; use models\summit\Summit; use models\summit\SummitSelectedPresentation; @@ -266,7 +267,8 @@ protected function getFilterMappings() LEFT JOIN __pm41.media_upload_type __mut41 WHERE __p41.summit = :summit - AND __p41.published = 0'. + AND __p41.published = 0 + AND (__p41.progress != '.Presentation::PHASE_COMPLETE.' OR __p41.status IS NULL OR __p41.status != \''.Presentation::STATUS_RECEIVED.'\')'. (!empty($extraSelectionStatusFilter)? sprintf($extraSelectionStatusFilter, '41'): ' '). 'AND NOT EXISTS ( SELECT ___sp41.id @@ -289,7 +291,8 @@ protected function getFilterMappings() LEFT JOIN __pm42.media_upload_type __mut42 WHERE __p42.summit = :summit - AND __p42.published = 0'. + AND __p42.published = 0 + AND (__p42.progress != '.Presentation::PHASE_COMPLETE.' OR __p42.status IS NULL OR __p42.status != \''.Presentation::STATUS_RECEIVED.'\')'. (!empty($extraSelectionStatusFilter)? sprintf($extraSelectionStatusFilter, '42'): ' '). 'AND NOT EXISTS ( SELECT ___sp42.id @@ -315,7 +318,8 @@ protected function getFilterMappings() LEFT JOIN __pm41.media_upload_type __mut41 WHERE __p41.summit = :summit - AND __p41.published = 0'. + AND __p41.published = 0 + AND (__p41.progress != '.Presentation::PHASE_COMPLETE.' OR __p41.status IS NULL OR __p41.status != \''.Presentation::STATUS_RECEIVED.'\')'. (!empty($extraSelectionStatusFilter)? sprintf($extraSelectionStatusFilter, '41'): ' '). 'AND NOT EXISTS ( SELECT ___sp41.id @@ -338,7 +342,8 @@ protected function getFilterMappings() LEFT JOIN __pm42.media_upload_type __mut42 WHERE __p42.summit = :summit - AND __p42.published = 0'. + AND __p42.published = 0 + AND (__p42.progress != '.Presentation::PHASE_COMPLETE.' OR __p42.status IS NULL OR __p42.status != \''.Presentation::STATUS_RECEIVED.'\')'. (!empty($extraSelectionStatusFilter)? sprintf($extraSelectionStatusFilter, '42'): ' '). 'AND NOT EXISTS ( SELECT ___sp42.id diff --git a/tests/oauth2/OAuth2SummitSpeakersApiTest.php b/tests/oauth2/OAuth2SummitSpeakersApiTest.php index 909a38671..ccf2f2249 100644 --- a/tests/oauth2/OAuth2SummitSpeakersApiTest.php +++ b/tests/oauth2/OAuth2SummitSpeakersApiTest.php @@ -749,12 +749,11 @@ public function testGetCurrentSummitSpeakersWithPendingPresentations() $pres->setAbstract("Abstract"); $pres->setCategory(self::$defaultTrack); $pres->setType(self::$defaultPresentationType); - $pres->setProgress(Presentation::PHASE_COMPLETE); - $pres->setStatus(Presentation::STATUS_RECEIVED); $pres->setStartDate($start); $pres->setEndDate($end); $pres->addSpeaker($speaker); - // Deliberately NOT published and NOT added to any SummitSelectedPresentation group list + // Deliberately unfinished (default progress/status), NOT published and NOT + // added to any SummitSelectedPresentation group list self::$em->flush(); $params = [ @@ -789,6 +788,68 @@ public function testGetCurrentSummitSpeakersWithPendingPresentations() $this->assertTrue(count($speakers->data) > 0); } + /** + * Regression test: has_pending_presentations must reflect an unfinished + * submission, not merely "not yet selected by a track chair". A speaker + * whose presentation is already complete/received must NOT be returned, + * even if it is still unpublished and has no selection-list entry. + */ + public function testGetCurrentSummitSpeakersWithPendingPresentationsExcludesCompletedSubmissions() + { + $speaker = new PresentationSpeaker(); + $speaker->setFirstName("Completed"); + $speaker->setLastName("Speaker"); + self::$em->persist($speaker); + + $start = new \DateTime('now', new \DateTimeZone('UTC')); + $end = (clone $start)->add(new \DateInterval('PT2H')); + + $pres = new Presentation(); + self::$summit->addEvent($pres); + $pres->setTitle("Completed Submission Presentation"); + $pres->setAbstract("Abstract"); + $pres->setCategory(self::$defaultTrack); + $pres->setType(self::$defaultPresentationType); + $pres->setProgress(Presentation::PHASE_COMPLETE); + $pres->setStatus(Presentation::STATUS_RECEIVED); + $pres->setStartDate($start); + $pres->setEndDate($end); + $pres->addSpeaker($speaker); + // Submission is complete/received, but deliberately NOT published and NOT + // added to any SummitSelectedPresentation group list + self::$em->flush(); + + $params = [ + 'id' => self::$summit->getId(), + 'page' => 1, + 'per_page' => 10, + 'filter' => [ + 'has_pending_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->assertNotContains($speaker->getId(), $ids, + 'speaker with a completed/received submission must not be treated as pending'); + } + public function testGetCurrentSummitSpeakersFilteredByMemberExternalUserID() { $params = [ @@ -2710,12 +2771,11 @@ public function testGetCurrentSummitSpeakersActivitiesCountWithPendingPresentati $pres->setAbstract("Abstract"); $pres->setCategory(self::$defaultTrack); $pres->setType(self::$defaultPresentationType); - $pres->setProgress(Presentation::PHASE_COMPLETE); - $pres->setStatus(Presentation::STATUS_RECEIVED); $pres->setStartDate($start); $pres->setEndDate($end); $pres->addSpeaker($speaker); - // Deliberately NOT published and NOT added to any SummitSelectedPresentation group list + // Deliberately unfinished (default progress/status), NOT published and NOT + // added to any SummitSelectedPresentation group list self::$em->flush(); $headers = [ diff --git a/tests/oauth2/OAuth2SummitSubmittersApiTest.php b/tests/oauth2/OAuth2SummitSubmittersApiTest.php index 0820a5e1b..02a19833e 100644 --- a/tests/oauth2/OAuth2SummitSubmittersApiTest.php +++ b/tests/oauth2/OAuth2SummitSubmittersApiTest.php @@ -145,12 +145,11 @@ public function testGetCurrentSummitSubmittersWithPendingPresentations() $pres->setAbstract("Abstract"); $pres->setCategory(self::$defaultTrack); $pres->setType(self::$defaultPresentationType); - $pres->setProgress(Presentation::PHASE_COMPLETE); - $pres->setStatus(Presentation::STATUS_RECEIVED); $pres->setStartDate($start); $pres->setEndDate($end); $pres->setCreatedBy(self::$defaultMember); - // Deliberately NOT published and NOT added to any SummitSelectedPresentation group list + // Deliberately unfinished (default progress/status), NOT published and NOT + // added to any SummitSelectedPresentation group list self::$em->flush(); $params = [ @@ -185,6 +184,65 @@ public function testGetCurrentSummitSubmittersWithPendingPresentations() $this->assertTrue(count($submitters->data) > 0); } + /** + * Regression test: has_pending_presentations must reflect an unfinished + * submission, not merely "not yet selected by a track chair". A submitter + * whose presentation is already complete/received must NOT be returned, + * even if it is still unpublished and has no selection-list entry. + */ + public function testGetCurrentSummitSubmittersWithPendingPresentationsExcludesCompletedSubmissions() + { + $member = self::$em->find(Member::class, self::$defaultMember2->getId()); + + $start = new \DateTime('now', new \DateTimeZone('UTC')); + $end = (clone $start)->add(new \DateInterval('PT2H')); + + $pres = new Presentation(); + self::$summit->addEvent($pres); + $pres->setTitle("Completed Submission Presentation"); + $pres->setAbstract("Abstract"); + $pres->setCategory(self::$defaultTrack); + $pres->setType(self::$defaultPresentationType); + $pres->setProgress(Presentation::PHASE_COMPLETE); + $pres->setStatus(Presentation::STATUS_RECEIVED); + $pres->setStartDate($start); + $pres->setEndDate($end); + $pres->setCreatedBy($member); + // Submission is complete/received, but deliberately NOT published and NOT + // added to any SummitSelectedPresentation group list + self::$em->flush(); + + $params = [ + 'id' => self::$summit->getId(), + 'page' => 1, + 'per_page' => 10, + 'filter' => [ + 'has_pending_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->assertNotContains($member->getId(), $ids, + 'submitter with a completed/received submission must not be treated as pending'); + } + public function testExportCurrentSummitSubmittersWhoAreSpeakers() { $params = [ From b0df665e6ab4f22413eaa3a9f5fcba9e6df1cadf Mon Sep 17 00:00:00 2001 From: romanetar Date: Mon, 7 Sep 2026 16:29:18 +0200 Subject: [PATCH 3/5] test(speakers): assert exclusion in has_pending_presentations tests The list/count tests for has_pending_presentations only asserted count > 0 or baseline + 1, which held true even if the filter were a complete no-op: the base speaker/submitter query already returns everyone with summit activity, and getUniqueActivitiesCountBySummit would just count "all activities" if the filter were ignored, still landing on baseline + 1 after adding one presentation. Add a published-presentation negative control to each test and assert on the exact set of returned/counted IDs, so the tests actually fail if the pending filter stops filtering. --- tests/oauth2/OAuth2SummitSpeakersApiTest.php | 59 +++++++++++++++++-- .../oauth2/OAuth2SummitSubmittersApiTest.php | 35 +++++++++-- 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/tests/oauth2/OAuth2SummitSpeakersApiTest.php b/tests/oauth2/OAuth2SummitSpeakersApiTest.php index ccf2f2249..01ae86586 100644 --- a/tests/oauth2/OAuth2SummitSpeakersApiTest.php +++ b/tests/oauth2/OAuth2SummitSpeakersApiTest.php @@ -756,10 +756,33 @@ public function testGetCurrentSummitSpeakersWithPendingPresentations() // added to any SummitSelectedPresentation group list self::$em->flush(); + // Negative control: a speaker with a published presentation must NOT be + // returned. Without this control, the assertion below would pass even if + // the filter were a complete no-op, since the base query already returns + // every speaker with summit activity. + $notPendingSpeaker = new PresentationSpeaker(); + $notPendingSpeaker->setFirstName("NotPending"); + $notPendingSpeaker->setLastName("Speaker"); + self::$em->persist($notPendingSpeaker); + + $publishedPres = new Presentation(); + self::$summit->addEvent($publishedPres); + $publishedPres->setTitle("Published Control Presentation"); + $publishedPres->setAbstract("Abstract"); + $publishedPres->setCategory(self::$defaultTrack); + $publishedPres->setType(self::$defaultPresentationType); + $publishedPres->setProgress(Presentation::PHASE_COMPLETE); + $publishedPres->setStatus(Presentation::STATUS_RECEIVED); + $publishedPres->setStartDate($start); + $publishedPres->setEndDate($end); + $publishedPres->addSpeaker($notPendingSpeaker); + $publishedPres->publish(); + self::$em->flush(); + $params = [ 'id' => self::$summit->getId(), 'page' => 1, - 'per_page' => 10, + 'per_page' => 100, 'filter' => [ 'has_pending_presentations==true', ], @@ -781,11 +804,13 @@ public function testGetCurrentSummitSpeakersWithPendingPresentations() $headers ); - $content = $response->getContent(); $this->assertResponseStatus(200); - $speakers = json_decode($content); - $this->assertTrue(!is_null($speakers)); - $this->assertTrue(count($speakers->data) > 0); + $ids = array_map(fn($s) => $s->id, json_decode($response->getContent())->data); + + $this->assertContains($speaker->getId(), $ids, + 'speaker with an unfinished, unpublished, unselected presentation must be returned'); + $this->assertNotContains($notPendingSpeaker->getId(), $ids, + 'speaker with a published presentation must not be treated as pending'); } /** @@ -2778,6 +2803,30 @@ public function testGetCurrentSummitSpeakersActivitiesCountWithPendingPresentati // added to any SummitSelectedPresentation group list self::$em->flush(); + // Negative control: a published presentation must NOT count as pending. + // Without this control, baseline + 1 would hold even if the filter were a + // complete no-op — getUniqueActivitiesCountBySummit would then just count + // "all activities", and one extra presentation always adds exactly 1 + // regardless of whether the filter actually excludes it. + $notPendingSpeaker = new PresentationSpeaker(); + $notPendingSpeaker->setFirstName("NotPending"); + $notPendingSpeaker->setLastName("Test"); + self::$em->persist($notPendingSpeaker); + + $publishedPres = new Presentation(); + self::$summit->addEvent($publishedPres); + $publishedPres->setTitle("Published Control Presentation"); + $publishedPres->setAbstract("Abstract"); + $publishedPres->setCategory(self::$defaultTrack); + $publishedPres->setType(self::$defaultPresentationType); + $publishedPres->setProgress(Presentation::PHASE_COMPLETE); + $publishedPres->setStatus(Presentation::STATUS_RECEIVED); + $publishedPres->setStartDate($start); + $publishedPres->setEndDate($end); + $publishedPres->addSpeaker($notPendingSpeaker); + $publishedPres->publish(); + self::$em->flush(); + $headers = [ "HTTP_Authorization" => " Bearer " . $this->access_token, "CONTENT_TYPE" => "application/json" diff --git a/tests/oauth2/OAuth2SummitSubmittersApiTest.php b/tests/oauth2/OAuth2SummitSubmittersApiTest.php index 02a19833e..0d74ea581 100644 --- a/tests/oauth2/OAuth2SummitSubmittersApiTest.php +++ b/tests/oauth2/OAuth2SummitSubmittersApiTest.php @@ -136,6 +136,9 @@ public function testGetCurrentSummitSubmittersWithAcceptedPresentations() public function testGetCurrentSummitSubmittersWithPendingPresentations() { + $member = self::$em->find(Member::class, self::$defaultMember->getId()); + $member2 = self::$em->find(Member::class, self::$defaultMember2->getId()); + $start = new \DateTime('now', new \DateTimeZone('UTC')); $end = (clone $start)->add(new \DateInterval('PT2H')); @@ -147,15 +150,33 @@ public function testGetCurrentSummitSubmittersWithPendingPresentations() $pres->setType(self::$defaultPresentationType); $pres->setStartDate($start); $pres->setEndDate($end); - $pres->setCreatedBy(self::$defaultMember); + $pres->setCreatedBy($member); // Deliberately unfinished (default progress/status), NOT published and NOT // added to any SummitSelectedPresentation group list + + // Negative control: a submitter with a published presentation must NOT be + // returned. Without this control, the assertion below would pass even if + // the filter were a complete no-op, since the base query already returns + // every submitter with summit activity. + $publishedPres = new Presentation(); + self::$summit->addEvent($publishedPres); + $publishedPres->setTitle("Published Control Presentation"); + $publishedPres->setAbstract("Abstract"); + $publishedPres->setCategory(self::$defaultTrack); + $publishedPres->setType(self::$defaultPresentationType); + $publishedPres->setProgress(Presentation::PHASE_COMPLETE); + $publishedPres->setStatus(Presentation::STATUS_RECEIVED); + $publishedPres->setStartDate($start); + $publishedPres->setEndDate($end); + $publishedPres->setCreatedBy($member2); + $publishedPres->publish(); + self::$em->flush(); $params = [ 'id' => self::$summit->getId(), 'page' => 1, - 'per_page' => 10, + 'per_page' => 100, 'filter' => [ 'has_pending_presentations==true', ], @@ -177,11 +198,13 @@ public function testGetCurrentSummitSubmittersWithPendingPresentations() $headers ); - $content = $response->getContent(); $this->assertResponseStatus(200); - $submitters = json_decode($content); - $this->assertTrue(!is_null($submitters)); - $this->assertTrue(count($submitters->data) > 0); + $ids = array_map(fn($s) => $s->id, json_decode($response->getContent())->data); + + $this->assertContains($member->getId(), $ids, + 'submitter with an unfinished, unpublished, unselected presentation must be returned'); + $this->assertNotContains($member2->getId(), $ids, + 'submitter with a published presentation must not be treated as pending'); } /** From 2324cb92f3b279f65ca4b902b26761b2e57f968d Mon Sep 17 00:00:00 2001 From: romanetar Date: Mon, 7 Sep 2026 16:44:37 +0200 Subject: [PATCH 4/5] docs(speakers): sync getSpeakers OpenAPI filter description with its siblings The protected getSpeakers filter parameter still had the stale, truncated description ("...has_accepted_presentations, etc.") while the public getSpeakersPublic and the submitters endpoints were already updated with the full enumerated filter list, including has_pending_presentations and has_published_presentations. Align it so the generated docs advertise the same filters on both routes. --- .../Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php index c27c94fc3..2a22f56ea 100644 --- a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php +++ b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php @@ -179,7 +179,7 @@ public function __construct ), new OA\Parameter( name: 'filter', - description: 'Filter by id, first_name, last_name, email, full_name, member_id, has_accepted_presentations, etc.', + description: 'Filter by id, not_id, first_name, last_name, email, full_name, member_id, member_user_external_id, has_pending_presentations, 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') From 58f9ac6ba9692fc76b0e938df548adcd4b765c84 Mon Sep 17 00:00:00 2001 From: romanetar Date: Tue, 15 Sep 2026 16:40:18 +0200 Subject: [PATCH 5/5] test(speakers): cover has_pending_presentations false/moderator/send/count gaps Adds the coverage flagged in PR #562 review: the false branch and the moderator-only branch of has_pending_presentations in DoctrineSpeakerRepository/DoctrineMemberRepository, the narrowed chunk handed to ProcessSpeakersEmailRequestJob on PUT speakers/all/send, the filter forwarded to ProcessSubmittersEmailRequestJob on PUT submitters/all/send, and the submitters activities count endpoint. --- tests/oauth2/OAuth2SummitSpeakersApiTest.php | 257 ++++++++++++++++++ .../oauth2/OAuth2SummitSubmittersApiTest.php | 218 +++++++++++++++ 2 files changed, 475 insertions(+) diff --git a/tests/oauth2/OAuth2SummitSpeakersApiTest.php b/tests/oauth2/OAuth2SummitSpeakersApiTest.php index 01ae86586..6dffa16b0 100644 --- a/tests/oauth2/OAuth2SummitSpeakersApiTest.php +++ b/tests/oauth2/OAuth2SummitSpeakersApiTest.php @@ -875,6 +875,164 @@ public function testGetCurrentSummitSpeakersWithPendingPresentationsExcludesComp 'speaker with a completed/received submission must not be treated as pending'); } + /** + * Regression test: has_pending_presentations==false is a hand-assembled + * NOT EXISTS (...) AND NOT EXISTS (...) DQL string with no prior coverage. + * Assert the complement of the ==true behaviour: a speaker whose only + * submission is complete/received must be returned, while a speaker with + * a genuinely unfinished submission must be excluded. + */ + public function testGetCurrentSummitSpeakersWithPendingPresentationsFalse() + { + $completeSpeaker = new PresentationSpeaker(); + $completeSpeaker->setFirstName("Complete"); + $completeSpeaker->setLastName("Speaker"); + self::$em->persist($completeSpeaker); + + $pendingSpeaker = new PresentationSpeaker(); + $pendingSpeaker->setFirstName("Pending"); + $pendingSpeaker->setLastName("Speaker"); + self::$em->persist($pendingSpeaker); + + $start = new \DateTime('now', new \DateTimeZone('UTC')); + $end = (clone $start)->add(new \DateInterval('PT2H')); + + $completePres = new Presentation(); + self::$summit->addEvent($completePres); + $completePres->setTitle("Complete Submission Presentation"); + $completePres->setAbstract("Abstract"); + $completePres->setCategory(self::$defaultTrack); + $completePres->setType(self::$defaultPresentationType); + $completePres->setProgress(Presentation::PHASE_COMPLETE); + $completePres->setStatus(Presentation::STATUS_RECEIVED); + $completePres->setStartDate($start); + $completePres->setEndDate($end); + $completePres->addSpeaker($completeSpeaker); + // Complete/received, but deliberately NOT published and NOT added to any + // SummitSelectedPresentation group list + self::$em->flush(); + + $pendingPres = new Presentation(); + self::$summit->addEvent($pendingPres); + $pendingPres->setTitle("Pending Submission Presentation"); + $pendingPres->setAbstract("Abstract"); + $pendingPres->setCategory(self::$defaultTrack); + $pendingPres->setType(self::$defaultPresentationType); + $pendingPres->setStartDate($start); + $pendingPres->setEndDate($end); + $pendingPres->addSpeaker($pendingSpeaker); + // Deliberately unfinished (default progress/status), NOT published and NOT + // added to any SummitSelectedPresentation group list + self::$em->flush(); + + $params = [ + 'id' => self::$summit->getId(), + 'page' => 1, + 'per_page' => 100, + 'filter' => [ + 'has_pending_presentations==false', + ], + '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($completeSpeaker->getId(), $ids, + 'speaker whose only submission is complete/received must be returned for ==false'); + $this->assertNotContains($pendingSpeaker->getId(), $ids, + 'speaker with a genuinely unfinished submission must not be returned for ==false'); + } + + /** + * Regression test: the moderator EXISTS subquery (__p42 in + * DoctrineSpeakerRepository) is a separate OR/AND branch from the + * speaker-role subquery (__p41) and has no dedicated coverage. Seed a + * presentation whose only link to the speaker is setModerator(), so a + * broken moderator branch (e.g. dropped or mis-joined) cannot be masked + * by the speaker-role branch. + */ + public function testGetCurrentSummitSpeakersWithPendingPresentationsModeratorOnly() + { + $moderator = new PresentationSpeaker(); + $moderator->setFirstName("Moderator"); + $moderator->setLastName("Only"); + self::$em->persist($moderator); + + $start = new \DateTime('now', new \DateTimeZone('UTC')); + $end = (clone $start)->add(new \DateInterval('PT2H')); + + $pres = new Presentation(); + self::$summit->addEvent($pres); + $pres->setTitle("Moderator Only Pending Presentation"); + $pres->setAbstract("Abstract"); + $pres->setCategory(self::$defaultTrack); + $pres->setType(self::$defaultPresentationType); + $pres->setStartDate($start); + $pres->setEndDate($end); + $pres->setModerator($moderator); + // Deliberately unfinished (default progress/status), NOT published and NOT + // added to any SummitSelectedPresentation group list. $moderator is NOT + // added as a speaker, only as the moderator. + self::$em->flush(); + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json" + ]; + + $trueResponse = $this->action( + "GET", + "OAuth2SummitSpeakersApiController@getSpeakers", + [ + 'id' => self::$summit->getId(), + 'page' => 1, + 'per_page' => 100, + 'filter' => ['has_pending_presentations==true'], + 'order' => '+id' + ], + [], [], [], $headers + ); + + $this->assertResponseStatus(200); + $trueIds = array_map(fn($s) => $s->id, json_decode($trueResponse->getContent())->data); + $this->assertContains($moderator->getId(), $trueIds, + 'speaker whose only link to an unfinished presentation is being its moderator must be returned for ==true'); + + $falseResponse = $this->action( + "GET", + "OAuth2SummitSpeakersApiController@getSpeakers", + [ + 'id' => self::$summit->getId(), + 'page' => 1, + 'per_page' => 100, + 'filter' => ['has_pending_presentations==false'], + 'order' => '+id' + ], + [], [], [], $headers + ); + + $this->assertResponseStatus(200); + $falseIds = array_map(fn($s) => $s->id, json_decode($falseResponse->getContent())->data); + $this->assertNotContains($moderator->getId(), $falseIds, + 'speaker whose only link to an unfinished presentation is being its moderator must not be returned for ==false'); + } + public function testGetCurrentSummitSpeakersFilteredByMemberExternalUserID() { $params = [ @@ -1125,6 +1283,105 @@ public function testSendSpeakersBulkEmailFilteredByMemberUserExternalId() { }); } + /** + * Regression test: the ticket's stated goal is the reminder email through + * PUT .../speakers/all/send, yet nothing previously asserted the chunk + * handed to ProcessSpeakersEmailRequestJob is actually narrowed by + * has_pending_presentations. All fixture presentations for + * self::$defaultSpeaker/self::$speaker are published, so they can never + * satisfy has_pending_presentations==true; only $pending_speaker below can. + */ + public function testSendSpeakersBulkEmailFilteredByHasPendingPresentations() { + Queue::fake(); + + $start = new \DateTime('now', new \DateTimeZone('UTC')); + $end = (clone $start)->add(new \DateInterval('PT2H')); + + $pending_speaker = new PresentationSpeaker(); + $pending_speaker->setFirstName("Pending"); + $pending_speaker->setLastName("Send Speaker"); + self::$em->persist($pending_speaker); + + $pending_presentation = new Presentation(); + self::$summit->addEvent($pending_presentation); + $pending_presentation->setTitle("Pending Send Presentation"); + $pending_presentation->setAbstract("Abstract"); + $pending_presentation->setCategory(self::$defaultTrack); + $pending_presentation->setType(self::$defaultPresentationType); + $pending_presentation->setStartDate($start); + $pending_presentation->setEndDate($end); + $pending_presentation->addSpeaker($pending_speaker); + // Deliberately unfinished (default progress/status), NOT published and NOT + // added to any SummitSelectedPresentation group list + + // Negative control: a speaker whose submission is already complete/received + // must NOT be in the dispatched chunk, even though it is also unpublished. + $complete_speaker = new PresentationSpeaker(); + $complete_speaker->setFirstName("Complete"); + $complete_speaker->setLastName("Send Speaker"); + self::$em->persist($complete_speaker); + + $complete_presentation = new Presentation(); + self::$summit->addEvent($complete_presentation); + $complete_presentation->setTitle("Complete Send Presentation"); + $complete_presentation->setAbstract("Abstract"); + $complete_presentation->setCategory(self::$defaultTrack); + $complete_presentation->setType(self::$defaultPresentationType); + $complete_presentation->setProgress(Presentation::PHASE_COMPLETE); + $complete_presentation->setStatus(Presentation::STATUS_RECEIVED); + $complete_presentation->setStartDate($start); + $complete_presentation->setEndDate($end); + $complete_presentation->addSpeaker($complete_speaker); + + self::$em->flush(); + + $params = [ + 'id' => self::$summit->getId(), + 'filter' => [ + 'has_pending_presentations==true', + ], + ]; + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json" + ]; + + $data = [ + 'email_flow_event' => 'SUMMIT_SUBMISSIONS_PRESENTATION_SPEAKER_ACCEPTED_ALTERNATE', + ]; + + $response = $this->action( + "PUT", + "OAuth2SummitSpeakersApiController@send", + $params, + [], + [], + [], + $headers, + json_encode($data) + ); + + $this->assertResponseStatus(200); + + Queue::assertPushed(\App\Jobs\Emails\ProcessSpeakersEmailRequestJob::class, 1); + Queue::assertPushed(\App\Jobs\Emails\ProcessSpeakersEmailRequestJob::class, function ($job) use ($pending_speaker, $complete_speaker) { + $ref = new \ReflectionObject($job); + $prop = $ref->getProperty('payload'); + $prop->setAccessible(true); + $payload = $prop->getValue($job); + $this->assertEquals( + [$pending_speaker->getId()], + $payload['speaker_ids'] ?? [], + sprintf( + 'the chunk must contain exactly the speaker with a pending submission, not the complete/received control speaker %s', + $complete_speaker->getId() + ) + ); + return true; + }); + } + public function testSendSpeakersBulkEmail() { $params = [ 'id' => self::$summit->getId(), diff --git a/tests/oauth2/OAuth2SummitSubmittersApiTest.php b/tests/oauth2/OAuth2SummitSubmittersApiTest.php index 0d74ea581..b06dc2381 100644 --- a/tests/oauth2/OAuth2SummitSubmittersApiTest.php +++ b/tests/oauth2/OAuth2SummitSubmittersApiTest.php @@ -1,7 +1,10 @@ find(Member::class, self::$defaultMember->getId()); + $member2 = self::$em->find(Member::class, self::$defaultMember2->getId()); + + $start = new \DateTime('now', new \DateTimeZone('UTC')); + $end = (clone $start)->add(new \DateInterval('PT2H')); + + $completePres = new Presentation(); + self::$summit->addEvent($completePres); + $completePres->setTitle("Complete Submission Presentation"); + $completePres->setAbstract("Abstract"); + $completePres->setCategory(self::$defaultTrack); + $completePres->setType(self::$defaultPresentationType); + $completePres->setProgress(Presentation::PHASE_COMPLETE); + $completePres->setStatus(Presentation::STATUS_RECEIVED); + $completePres->setStartDate($start); + $completePres->setEndDate($end); + $completePres->setCreatedBy($member2); + // Complete/received, but deliberately NOT published and NOT added to any + // SummitSelectedPresentation group list + + $pendingPres = new Presentation(); + self::$summit->addEvent($pendingPres); + $pendingPres->setTitle("Pending Submission Presentation"); + $pendingPres->setAbstract("Abstract"); + $pendingPres->setCategory(self::$defaultTrack); + $pendingPres->setType(self::$defaultPresentationType); + $pendingPres->setStartDate($start); + $pendingPres->setEndDate($end); + $pendingPres->setCreatedBy($member); + // Deliberately unfinished (default progress/status), NOT published and NOT + // added to any SummitSelectedPresentation group list + + self::$em->flush(); + + $params = [ + 'id' => self::$summit->getId(), + 'page' => 1, + 'per_page' => 100, + 'filter' => [ + 'has_pending_presentations==false', + ], + '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 whose only submission is complete/received must be returned for ==false'); + $this->assertNotContains($member->getId(), $ids, + 'submitter with a genuinely unfinished submission must not be returned for ==false'); + } + public function testExportCurrentSummitSubmittersWhoAreSpeakers() { $params = [ @@ -333,6 +413,69 @@ public function testSendSpeakersBulkEmail() { $this->assertResponseStatus(200); } + /** + * Regression test: the ticket's stated goal is the reminder email through + * PUT .../submitters/all/send, yet nothing previously asserted + * has_pending_presentations survives validation and reaches + * ProcessSubmittersEmailRequestJob. Unlike SpeakerService::triggerSendEmails, + * SubmitterService::triggerSendEmails does not resolve the filter to ids + * before dispatching - it forwards the raw filter to the job, which resolves + * ids itself when it runs. So the end-to-end assertion here is that the exact + * filter requested is the one handed to the job, not a pre-narrowed id chunk. + */ + public function testSendSubmittersBulkEmailFilteredByHasPendingPresentations() { + Queue::fake(); + + $params = [ + 'id' => self::$summit->getId(), + 'filter' => [ + 'has_pending_presentations==true', + ], + ]; + + $headers = [ + "HTTP_Authorization" => " Bearer " . $this->access_token, + "CONTENT_TYPE" => "application/json" + ]; + + $data = [ + 'email_flow_event' => 'SUMMIT_SUBMISSIONS_PRESENTATION_SUBMITTER_ACCEPTED_ALTERNATE', + 'test_email_recipient' => 'test_recip@nomail.com', + 'outcome_email_recipient' => 'outcome_recip@nomail.com', + ]; + + $response = $this->action + ( + "PUT", + "OAuth2SummitSubmittersApiController@send", + $params, + [], + [], + [], + $headers, + json_encode($data) + ); + + $this->assertResponseStatus(200); + + Queue::assertPushed(\App\Jobs\Emails\ProcessSubmittersEmailRequestJob::class, 1); + Queue::assertPushed(\App\Jobs\Emails\ProcessSubmittersEmailRequestJob::class, function ($job) { + $ref = new \ReflectionObject($job); + $summit_prop = $ref->getProperty('summit_id'); + $summit_prop->setAccessible(true); + $filter_prop = $ref->getProperty('filter'); + $filter_prop->setAccessible(true); + + $this->assertEquals(self::$summit->getId(), $summit_prop->getValue($job)); + $this->assertEquals( + ['has_pending_presentations==true'], + $filter_prop->getValue($job), + 'the has_pending_presentations filter must reach the job unmodified so it can narrow the ids it resolves at run time' + ); + return true; + }); + } + public function testGetSubmittersWithSubmittedMediaUploadsWithType() { $media_upload_ids = array_map(function($v){ @@ -435,6 +578,81 @@ public function testGetCurrentSummitSubmittersActivitiesCountWithAcceptedPresent $this->assertGreaterThanOrEqual(0, $data->count); } + /** + * Regression test: mirrors + * testGetCurrentSummitSpeakersActivitiesCountWithPendingPresentations but + * against OAuth2SummitSubmittersApiController::getSubmittersActivitiesCount, + * which had no coverage of has_pending_presentations at all. The baseline is + * taken via a direct repository call (like the speakers test does) rather + * than through an HTTP round-trip, since $this->action() closes the shared + * EntityManager the fixtures below rely on. The assertion is exact: a + * broken filter that returns every submitter would produce a count far + * greater than baseline + 1. + */ + public function testGetCurrentSummitSubmittersActivitiesCountWithPendingPresentations() + { + $baseline = EntityManager::getRepository(Member::class) + ->getUniqueActivitiesCountBySummit( + self::$summit, + FilterParser::parse( + ['filter' => 'has_pending_presentations==true'], + ['has_pending_presentations' => ['==']] + ) + ); + + $member = self::$em->find(Member::class, self::$defaultMember->getId()); + $member2 = self::$em->find(Member::class, self::$defaultMember2->getId()); + + $start = new \DateTime('now', new \DateTimeZone('UTC')); + $end = (clone $start)->add(new \DateInterval('PT2H')); + + $pres = new Presentation(); + self::$summit->addEvent($pres); + $pres->setTitle("Pending Count Presentation"); + $pres->setAbstract("Abstract"); + $pres->setCategory(self::$defaultTrack); + $pres->setType(self::$defaultPresentationType); + $pres->setStartDate($start); + $pres->setEndDate($end); + $pres->setCreatedBy($member); + // Deliberately unfinished (default progress/status), NOT published and NOT + // added to any SummitSelectedPresentation group list + + // Negative control: a published presentation must NOT count as pending. + $publishedPres = new Presentation(); + self::$summit->addEvent($publishedPres); + $publishedPres->setTitle("Published Count Control Presentation"); + $publishedPres->setAbstract("Abstract"); + $publishedPres->setCategory(self::$defaultTrack); + $publishedPres->setType(self::$defaultPresentationType); + $publishedPres->setProgress(Presentation::PHASE_COMPLETE); + $publishedPres->setStatus(Presentation::STATUS_RECEIVED); + $publishedPres->setStartDate($start); + $publishedPres->setEndDate($end); + $publishedPres->setCreatedBy($member2); + $publishedPres->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_pending_presentations==true']], + [], [], [], $headers + ); + + $this->assertResponseStatus(200); + $data = json_decode($response->getContent()); + $this->assertNotNull($data); + $this->assertTrue(isset($data->count)); + $this->assertEquals($baseline + 1, $data->count); + } + public function testGetSubmittersFilterByTrackGroupId() { // Smoke test: filter[]=presentations_track_group_id==N must return HTTP 200, not 422.