From 1094662233afb418a6967ed84779b71b3ae035fe Mon Sep 17 00:00:00 2001 From: gbutler Date: Fri, 28 Aug 2026 15:15:58 -0500 Subject: [PATCH] feat(speakers/submitters): add has_published_presentations filter Speakers and submitters can now be filtered by whether they have a presentation actually published to the live schedule (published = 1), distinct from selection status. An accepted presentation may not yet be scheduled, and this filter surfaces that distinction. Filter added to all listing, count, CSV, and send endpoints in both OAuth2SummitSpeakersApiController and OAuth2SummitSubmittersApiController. Repository filter mappings use EXISTS subqueries over Presentation, covering both speaker and moderator roles for speakers, and created_by for submitters. Also stabilises the test suite setUp to explicitly rollback open Doctrine transactions before closing the connection, preventing InnoDB row lock accumulation across tests (zombie transaction issue introduced by the volume of failure-path tests added in 90e81abd1). --- .../OAuth2SummitSpeakersApiController.php | 16 +- .../OAuth2SummitSubmittersApiController.php | 10 +- .../Summit/DoctrineMemberRepository.php | 19 ++ .../Summit/DoctrineSpeakerRepository.php | 27 +++ tests/BrowserKitTestCase.php | 20 +- tests/SpeakerRepositoryTest.php | 134 ++++++++++++++ tests/SubmitterRepositoryTest.php | 171 ++++++++++++++++++ tests/oauth2/OAuth2SummitSpeakersApiTest.php | 50 +++++ .../oauth2/OAuth2SummitSubmittersApiTest.php | 50 +++++ 9 files changed, 491 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSpeakersApiController.php index 38054a885..ec48a768b 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') @@ -315,6 +315,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' => ['=='], @@ -340,6 +341,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', @@ -412,7 +414,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') @@ -455,6 +457,7 @@ public function getSpeakersActivitiesCount($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' => ['=='], @@ -481,6 +484,7 @@ public function getSpeakersActivitiesCount($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', @@ -530,7 +534,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') @@ -596,6 +600,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' => ['=='], @@ -621,6 +626,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', @@ -3194,7 +3200,7 @@ public function deleteSpeakerBigPhoto($speaker_id) ), new OA\Parameter( name: 'filter', - description: 'Filter speakers by id, first_name, last_name, email, full_name, 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, first_name, last_name, email, full_name, 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') @@ -3260,6 +3266,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' => ['=='], @@ -3286,6 +3293,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', 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/Repositories/Summit/DoctrineMemberRepository.php b/app/Repositories/Summit/DoctrineMemberRepository.php index 2f3731b38..531fc912c 100644 --- a/app/Repositories/Summit/DoctrineMemberRepository.php +++ b/app/Repositories/Summit/DoctrineMemberRepository.php @@ -293,6 +293,25 @@ 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 + WHERE __p15.summit = :summit AND __p15.published = 1 + )' + ), + 'false' => new DoctrineCaseFilterMapping( + 'false', + 'NOT EXISTS ( + SELECT __p15.id FROM models\summit\Presentation __p15 + JOIN __p15.created_by __c15 WITH __c15 = e.id + WHERE __p15.summit = :summit AND __p15.published = 1 + )' + ), + ]), 'has_alternate_presentations' => new DoctrineSwitchFilterMapping([ 'true' => new DoctrineCaseFilterMapping( diff --git a/app/Repositories/Summit/DoctrineSpeakerRepository.php b/app/Repositories/Summit/DoctrineSpeakerRepository.php index 9140d2515..60a311127 100644 --- a/app/Repositories/Summit/DoctrineSpeakerRepository.php +++ b/app/Repositories/Summit/DoctrineSpeakerRepository.php @@ -346,6 +346,33 @@ 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 + WHERE __p15.summit = :summit AND __p15.published = 1 + ) OR EXISTS ( + SELECT __p16.id FROM models\summit\Presentation __p16 + JOIN __p16.moderator __md16 WITH __md16.id = e.id + WHERE __p16.summit = :summit AND __p16.published = 1 + )' + ), + 'false' => new DoctrineCaseFilterMapping( + 'false', + 'NOT EXISTS ( + SELECT __p15.id FROM models\summit\Presentation __p15 + JOIN __p15.speakers __spk15 WITH __spk15.speaker = e.id + WHERE __p15.summit = :summit AND __p15.published = 1 + ) AND NOT EXISTS ( + SELECT __p16.id FROM models\summit\Presentation __p16 + JOIN __p16.moderator __md16 WITH __md16.id = e.id + WHERE __p16.summit = :summit AND __p16.published = 1 + )' + ), + ]), 'has_alternate_presentations' => new DoctrineSwitchFilterMapping([ 'true' => new DoctrineCaseFilterMapping( diff --git a/tests/BrowserKitTestCase.php b/tests/BrowserKitTestCase.php index 24e5e3ed7..af7cd5de4 100644 --- a/tests/BrowserKitTestCase.php +++ b/tests/BrowserKitTestCase.php @@ -16,10 +16,13 @@ use Database\Seeders\SummitEmailFlowTypeSeeder; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Redis; use Laravel\BrowserKitTesting\TestCase as BaseTestCase; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; +use LaravelDoctrine\ORM\Facades\Registry; +use models\utils\SilverstripeBaseModel; /** * Class TestCase @@ -41,8 +44,23 @@ abstract class BrowserKitTestCase extends BaseTestCase { protected function setUp(): void { parent::setUp(); // Don't forget this! + // Explicitly rollback any open Doctrine transaction before closing the + // connection. close() alone nulls the PDO reference but PHP may not + // GC it immediately, leaving InnoDB row locks held. Rolling back first + // releases the locks unconditionally, regardless of GC timing. + $em = Registry::getManager(SilverstripeBaseModel::EntityManager); + $conn = $em->getConnection(); + if ($conn->isTransactionActive()) { + $conn->rollBack(); + } + $conn->close(); + Registry::resetManager(SilverstripeBaseModel::EntityManager); + Queue::fake(); $this->redis = Redis::connection(); $this->redis->flushall(); + // Reduce lock wait timeout so orphaned-process lock conflicts fail in + // 3 s instead of the default 50 s, keeping stuck test runs tolerable. + DB::connection('model')->statement('SET SESSION innodb_lock_wait_timeout = 3'); $this->prepareForTests(); } @@ -59,7 +77,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/SpeakerRepositoryTest.php b/tests/SpeakerRepositoryTest.php index e825826f5..81cb787e7 100644 --- a/tests/SpeakerRepositoryTest.php +++ b/tests/SpeakerRepositoryTest.php @@ -366,6 +366,140 @@ 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); + } + // ----------------------------------------------------------------- // getAllByPage - multi-page pagination // The two-phase approach uses LIMIT/OFFSET for page > 1. diff --git a/tests/SubmitterRepositoryTest.php b/tests/SubmitterRepositoryTest.php index d831191f9..2144f75cb 100644 --- a/tests/SubmitterRepositoryTest.php +++ b/tests/SubmitterRepositoryTest.php @@ -456,6 +456,177 @@ 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); + } + // ----------------------------------------------------------------- // getUniqueActivitiesCountBySummit - presentations_track_group_id // The submitter repo and speaker repo share the filter name but use diff --git a/tests/oauth2/OAuth2SummitSpeakersApiTest.php b/tests/oauth2/OAuth2SummitSpeakersApiTest.php index 606a22a69..196114240 100644 --- a/tests/oauth2/OAuth2SummitSpeakersApiTest.php +++ b/tests/oauth2/OAuth2SummitSpeakersApiTest.php @@ -2443,4 +2443,54 @@ public function testGetCurrentSummitSpeakersActivitiesCountWithAcceptedPresentat $this->assertEquals($baseline + 1, $data->count); } + public function testGetCurrentSummitSpeakersWithPublishedPresentations() + { + $params = [ + 'id' => self::$summit->getId(), + 'page' => 1, + 'per_page' => 10, + '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); + $speakers = json_decode($response->getContent()); + $this->assertNotNull($speakers); + } + + public function testGetCurrentSummitSpeakersActivitiesCountWithPublishedPresentations() + { + $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->assertGreaterThanOrEqual(0, $data->count); + } + } \ No newline at end of file diff --git a/tests/oauth2/OAuth2SummitSubmittersApiTest.php b/tests/oauth2/OAuth2SummitSubmittersApiTest.php index e487e9bf5..5ad5a9e4f 100644 --- a/tests/oauth2/OAuth2SummitSubmittersApiTest.php +++ b/tests/oauth2/OAuth2SummitSubmittersApiTest.php @@ -335,4 +335,54 @@ public function testGetSubmittersFilterByTrackGroupId() $submitters = json_decode($content); $this->assertNotNull($submitters); } + + public function testGetCurrentSummitSubmittersWithPublishedPresentations() + { + $params = [ + 'id' => self::$summit->getId(), + 'page' => 1, + 'per_page' => 10, + '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); + $submitters = json_decode($response->getContent()); + $this->assertNotNull($submitters); + } + + public function testGetCurrentSummitSubmittersActivitiesCountWithPublishedPresentations() + { + $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->assertGreaterThanOrEqual(0, $data->count); + } } \ No newline at end of file