diff --git a/webapp/src/Controller/API/AbstractApiController.php b/webapp/src/Controller/API/AbstractApiController.php index 92eddb8569..06425c05e5 100644 --- a/webapp/src/Controller/API/AbstractApiController.php +++ b/webapp/src/Controller/API/AbstractApiController.php @@ -29,6 +29,16 @@ public function __construct( protected readonly EventLogService $eventLogService ) {} + /** + * Whether to filter out contests that haven't started yet in calls to getQueryBuilder + * without an explicit value set for $filterBeforeContest. + * Override in subclasses to change this behavior. + */ + protected function shouldFilterBeforeContest(): bool + { + return true; + } + /** * Get the query builder used for getting contests. * diff --git a/webapp/src/Controller/API/ContestController.php b/webapp/src/Controller/API/ContestController.php index 24f46b76c0..2dd5e8bc35 100644 --- a/webapp/src/Controller/API/ContestController.php +++ b/webapp/src/Controller/API/ContestController.php @@ -356,24 +356,24 @@ public function setProblemsetAction(Request $request, string $cid, ValidatorInte public function problemsetAction(Request $request, string $cid): Response { /** @var Contest|null $contest */ - $contest = $this->getQueryBuilder($request) + $contest = $this->getQueryBuilder($request, filterBeforeContest: true) ->andWhere(sprintf('%s = :id', $this->getIdField())) ->setParameter('id', $cid) ->getQuery() ->getOneOrNullResult(); + if ($contest === null) { + throw new NotFoundHttpException(sprintf('Object with ID \'%s\' not found', $cid)); + } + $hasAccess = $this->dj->checkrole('jury') || $this->dj->checkrole('api_reader') || - $contest?->getFreezeData()->started(); + $contest->getFreezeData()->started(); if (!$hasAccess) { throw new AccessDeniedHttpException(); } - if ($contest === null) { - throw new NotFoundHttpException(sprintf('Object with ID \'%s\' not found', $cid)); - } - if (!$contest->getContestProblemsetType()) { throw new NotFoundHttpException(sprintf('Contest with ID \'%s\' has no problemset text', $cid)); } @@ -950,10 +950,18 @@ public function samplesDataZipAction(Request $request): Response return $this->dj->getSamplesZipForContest($contest); } - protected function getQueryBuilder(Request $request, bool $filterBeforeContest = true): QueryBuilder + protected function shouldFilterBeforeContest(): bool + { + return false; + } + + protected function getQueryBuilder(Request $request, ?bool $filterBeforeContest = null): QueryBuilder { try { - return $this->getContestQueryBuilder($request->query->getBoolean('onlyActive', true), $filterBeforeContest); + return $this->getContestQueryBuilder( + $request->query->getBoolean('onlyActive', true), + $filterBeforeContest ?? $this->shouldFilterBeforeContest() + ); } catch (TypeError) { throw new BadRequestHttpException('\'onlyActive\' must be a boolean.'); } diff --git a/webapp/tests/Unit/Controller/API/ContestControllerTest.php b/webapp/tests/Unit/Controller/API/ContestControllerTest.php index ae937d858e..b853ce52d0 100644 --- a/webapp/tests/Unit/Controller/API/ContestControllerTest.php +++ b/webapp/tests/Unit/Controller/API/ContestControllerTest.php @@ -2,6 +2,7 @@ namespace App\Tests\Unit\Controller\API; +use App\DataFixtures\Test\DemoPreStartContestFixture; use App\Entity\Contest; class ContestControllerTest extends BaseTestCase @@ -26,4 +27,48 @@ class ContestControllerTest extends BaseTestCase protected array $expectedAbsent = ['4242', 'nonexistent']; protected ?string $objectClassForExternalId = Contest::class; + + /** + * Test that a contest that is activated but not yet started is visible in the list action for a team user. + */ + public function testListShowsActivatedButNotStartedContest(): void + { + $this->loadFixture(DemoPreStartContestFixture::class); + + $url = $this->helperGetEndpointURL($this->apiEndpoint); + // Use 'demo' user which has team role + $objects = $this->verifyApiJsonResponse('GET', $url, 200, 'demo'); + + self::assertIsArray($objects); + self::assertNotEmpty($objects, 'Contest list should not be empty'); + + // Find the demo contest in the response + $foundContest = null; + foreach ($objects as $contest) { + if ($contest['shortname'] === 'demo') { + $foundContest = $contest; + break; + } + } + + self::assertNotNull($foundContest, 'Demo contest should be visible after activation even before start'); + self::assertSame('Demo contest', $foundContest['formal_name']); + } + + /** + * Test that a contest that is activated but not yet started is visible in the single action for a team user. + */ + public function testSingleShowsActivatedButNotStartedContest(): void + { + $this->loadFixture(DemoPreStartContestFixture::class); + + $contestId = $this->resolveEntityId(Contest::class, '1'); + $url = $this->helperGetEndpointURL($this->apiEndpoint, $contestId); + // Use 'demo' user which has team role + $contest = $this->verifyApiJsonResponse('GET', $url, 200, 'demo'); + + self::assertIsArray($contest); + self::assertSame('Demo contest', $contest['formal_name']); + self::assertSame('demo', $contest['shortname']); + } }