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