Compare commits

..

4 Commits

Author SHA1 Message Date
dependabot[bot] c2637481f2 build(deps-dev): bump friendsofphp/php-cs-fixer (#207)
Bumps the dev-dependencies group with 1 update: [friendsofphp/php-cs-fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer).


Updates `friendsofphp/php-cs-fixer` from 3.95.12 to 3.95.13
- [Release notes](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases)
- [Changelog](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/compare/v3.95.12...v3.95.13)

---
updated-dependencies:
- dependency-name: friendsofphp/php-cs-fixer
  dependency-version: 3.95.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-10 23:55:45 +00:00
Marijn 938456087a fix: support more than 25 columns in data export sheets (#205)
range('A', ...) silently truncates its end argument to one byte once a
sheet needs a column past 'Z' (e.g. 26+ questions), triggering a PHP
warning and mis-sizing columns. Replace it with a helper that walks
column indexes via Coordinate::stringFromColumnIndex, which handles
multi-letter columns correctly.

Fixes PHP-SYMFONY-3Z
2026-07-10 19:30:37 +02:00
Marijn 33a0e8a584 Avoid dev port clashes and isolate docker compose per git worktree (#203)
* Avoid dev port clashes and isolate docker compose per git worktree

Default dev ports (80/443/5432) clash with other projects' compose
stacks. Remap them to 8080/8443/5433+ by default, and add `just init`
(auto-run by `just up`) to generate a per-worktree `.env.local` with a
unique COMPOSE_PROJECT_NAME, image tag, and free host ports, so
multiple worktrees can run `just up` concurrently without sharing
containers, volumes, images, or ports.

* Pin CI to port 80 for the HTTP reachability check; use 5430 as default Postgres dev port

CI runs in an isolated single-purpose runner with no port-clash concern,
so pin the HTTP reachability check back to port 80 explicitly rather
than changing the dev default.

Also move the default dev Postgres port range from 5433 to 5430, since
5433 clashes with other commonly used local projects.

* Fix Justfile init: propagate free_port failures and use portable hash

free_port exhaustion was swallowed inside a command substitution used as
an echo argument, silently writing empty ports to .env.local. shasum is
also not guaranteed on stripped-down Linux hosts.
2026-07-10 18:14:39 +02:00
Marijn 2fd15ba8fa test: expand coverage, dedupe data-driven tests, extract shared WebTestCase base (#201)
* test: expand coverage, dedupe data-driven tests, extract shared WebTestCase base

- Add #[CoversClass] to Base64Test and FilenameSanitizerTest
- Merge near-duplicate test methods into #[DataProvider] cases across
  ResetPasswordControllerTest, SettingsControllerTest, ClaimSeasonCommandTest,
  FilenameSanitizerTest, Base64Test, and SeasonRepositoryTest
- Add integration tests for previously untested controllers: public
  QuizController (quiz-taking flow), LoginController, RegistrationController,
  EliminationController, and PrepareEliminationController
- Add unit tests for Elimination and BankQuestion entity logic
- Extract shared WebTestCase setup/helpers (client, entityManager, login,
  entity lookups, CSRF token scraping) into AbstractControllerWebTestCase,
  removing duplicated boilerplate from all 14 WebTestCase files

* test: address PR review feedback

- Scope AbstractControllerWebTestCase::getCandidate/getQuizByName by
  season code (both Candidate and Quiz are only unique per season, not
  system-wide) and add a CandidateRepository regression test guarding
  against same-named candidates in different seasons
- Add missing entityManager->clear() before verifying DB state after a
  POST in PrepareEliminationControllerTest and QuestionBankControllerTest
- Add non-owner denial tests for BackofficeController::exportQuiz and
  QuizQuestionController::edit/reorder, which had IsGranted checks with
  no test coverage

* ci: publish PHPUnit coverage to GitHub code coverage

- Generate a Cobertura report alongside the existing JUnit report and
  upload it with actions/upload-code-coverage so coverage shows up on
  PRs and the default branch via GitHub's code coverage feature
- Add a step to copy both reports out of the php container before
  publishing them, since var/ is a Docker volume (see the Dockerfile's
  VOLUME /app/var/) and isn't bind-mounted to the runner — this also
  fixes the existing JUnit report publishing step, which was silently
  looking at a path that never had contets on the runner

* ci: fix coverage report paths and tolerate Code Quality not yet enabled

- Write PHPUnit's JUnit and Cobertura reports to reports/ instead of
  var/, since var/ is a Docker volume (Dockerfile's VOLUME /app/var/)
  that isn't bind-mounted to the runner - reports written there never
  reached the host, which is also why the prior junit.xml publish step
  had nothing to read. reports/ is a plain path under the project's
  bind mount, so no extra copy-out step is needed
- Set fail-on-error: false on the coverage upload step: it 404s until
  "Code Quality" is turned on for the repo under Settings > Code
  security > Code quality, a one-time manual step
2026-07-10 18:06:48 +02:00
31 changed files with 1257 additions and 435 deletions
+18 -2
View File
@@ -70,6 +70,8 @@ jobs:
set: |
*.cache-from=type=gha,scope=${{github.ref}}-devbuild
- name: Start services
env:
HTTP_PORT: "80"
run: docker compose up php database --wait --no-build
- name: Warm up dev cache
run: docker compose exec -T php bin/console cache:warmup --env=dev
@@ -123,6 +125,7 @@ jobs:
checks: write
pull-requests: write
contents: read
code-quality: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
@@ -150,13 +153,26 @@ jobs:
- name: Load fixtures
run: docker compose exec -T php bin/console -e test doctrine:fixtures:load --no-interaction --group=test
- name: Run PHPUnit
run: docker compose exec -T -e MAILER_DSN=null://null php vendor/bin/phpunit --log-junit var/phpunit/junit.xml
# Reports are written outside var/ since var/ is a Docker volume (see the Dockerfile's
# VOLUME /app/var/) and isn't bind-mounted to the runner, unlike the rest of the project.
run: docker compose exec -T -e MAILER_DSN=null://null php vendor/bin/phpunit --log-junit reports/junit.xml --coverage-cobertura reports/coverage/cobertura.xml
- name: Publish PHPUnit test results
if: always()
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6
with:
report_paths: var/phpunit/junit.xml
report_paths: reports/junit.xml
check_name: PHPUnit
- name: Upload code coverage
# Requires "Code Quality" to be enabled for this repository under
# Settings > Code security > Code quality; fail-on-error is false so CI
# doesn't go red before that one-time, manual repository setting is turned on.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: actions/upload-code-coverage@82c7aee3fb2ad768e00b00a0a8d749c5815085b6 # v1
with:
file: reports/coverage/cobertura.xml
language: PHP
label: phpunit
fail-on-error: false
- name: Doctrine Schema Validator
run: docker compose exec -T php bin/console -e test doctrine:schema:validate
+1
View File
@@ -1,4 +1,5 @@
/frankenphp/data
/reports/
### Generated by gibo (https://github.com/simonwhitaker/gibo)
### https://raw.github.com/github/gitignore/6eeebe6f49678aacd8311ce079842c971b3ebe96/Symfony.gitignore
+18
View File
@@ -40,6 +40,24 @@ just shell # Interactive shell inside the PHP container
just shell-run # Shell in a fresh one-off container
```
### Working in git worktrees
`just up` auto-runs `just init` first, which generates a gitignored `.env.local` per checkout with a unique
`COMPOSE_PROJECT_NAME`, `IMAGES_PREFIX`, and free `HTTP_PORT`/`HTTPS_PORT`/`POSTGRES_PORT`/`MAILPIT_PORT`/
`SPOTLIGHT_PORT`. This means every worktree gets its own containers, network, volumes, and image tag — running
`just up` in two worktrees at the same time does **not** make them share a database, image, or port, even if the
worktree directories have the same basename.
- Run `just ports` to see the ports assigned to the *current* checkout — the app for that worktree is at
`https://localhost:<HTTPS_PORT>`, not a fixed port. Never assume port 8080/8443/5432/etc. when working inside a
worktree; always check `.env.local` or `just ports` first.
- `.env.local` is generated once and reused; it's safe to run `just init`/`just up` repeatedly. Delete `.env.local`
and re-run `just init` to force new ports (e.g. if the assigned ones are now taken by something else).
- Each worktree's Postgres data, uploaded files, and Caddy state live in per-worktree Docker volumes — nothing is
shared with the main checkout or other worktrees. Migrations/fixtures must be (re-)run per worktree.
- `just down`/`just clean` in one worktree only ever affects that worktree's own containers/volumes — safe to run
without impacting other worktrees.
### Database
```bash
+62 -1
View File
@@ -1,9 +1,70 @@
up *args:
# Load per-worktree overrides (project name, image tag, ports) generated by `just init`
set dotenv-load := true
set dotenv-filename := ".env.local"
# Generate a per-worktree COMPOSE_PROJECT_NAME, IMAGES_PREFIX and free host ports in .env.local,
# so multiple worktrees/checkouts of this repo can run `just up` at the same time without their
# containers, volumes, images or ports colliding. Safe to re-run; no-ops if already configured.
init:
#!/usr/bin/env bash
set -euo pipefail
if [ -f .env.local ] && grep -q '^COMPOSE_PROJECT_NAME=' .env.local; then
echo ".env.local already configured for this worktree, skipping (delete it to regenerate)."
exit 0
fi
free_port() {
local port="$1" max="$2"
while [ "$port" -le "$max" ]; do
if ! (exec 3<>/dev/tcp/127.0.0.1/"$port") 2>/dev/null; then
echo "$port"
return
fi
exec 3>&- 2>/dev/null || true
port=$((port + 1))
done
echo "no free port found between $1 and $2" >&2
exit 1
}
if command -v sha1sum >/dev/null 2>&1; then
hash_cmd=(sha1sum)
elif command -v shasum >/dev/null 2>&1; then
hash_cmd=(shasum)
else
hash_cmd=(sha256sum)
fi
hash=$(pwd | "${hash_cmd[@]}" | cut -c1-8)
project="tvdt-${hash}"
http_port=$(free_port 8080 8179)
https_port=$(free_port 8443 8542)
postgres_port=$(free_port 5430 5529)
mailpit_port=$(free_port 8025 8124)
spotlight_port=$(free_port 8969 9068)
{
echo "COMPOSE_PROJECT_NAME=${project}"
echo "IMAGES_PREFIX=${project}-"
echo "HTTP_PORT=${http_port}"
echo "HTTPS_PORT=${https_port}"
echo "POSTGRES_PORT=${postgres_port}"
echo "MAILPIT_PORT=${mailpit_port}"
echo "SPOTLIGHT_PORT=${spotlight_port}"
} >> .env.local
echo "Generated .env.local for this worktree:"
cat .env.local
up *args: init
#!/usr/bin/env bash
set -a
[ -f .env.local ] && source .env.local
set +a
docker compose up -d {{ args }}
down *args:
docker compose down --remove-orphans {{ args }}
# Show the host ports assigned to this worktree (see `just init`)
ports:
@cat .env.local 2>/dev/null || echo "No .env.local yet, run 'just up' or 'just init' first."
stop:
docker compose stop
+7 -2
View File
@@ -25,8 +25,13 @@ just migrate # Run pending database migrations
just fixtures # Load dev fixtures (truncates first)
```
The app is available at **https://localhost** (self-signed cert — run
`just trust-cert` on macOS to trust it).
`just up` first runs `just init`, which generates a `.env.local` (gitignored)
with a unique `COMPOSE_PROJECT_NAME`, image tag and free host ports for this
checkout, so multiple worktrees/clones can run at the same time without their
containers, volumes, images or ports colliding. Run `just ports` to see the
ports assigned to the current checkout — the app is served at
`https://localhost:<HTTPS_PORT>` (self-signed cert — run `just trust-cert` on
macOS to trust it).
### Useful commands
+6 -6
View File
@@ -24,15 +24,15 @@ services:
ports:
# HTTP
- target: 80
published: ${HTTP_PORT:-80}
published: ${HTTP_PORT:-8080}
protocol: tcp
# HTTPS
- target: 443
published: ${HTTPS_PORT:-443}
published: ${HTTPS_PORT:-8443}
protocol: tcp
# HTTP/3
- target: 443
published: ${HTTP3_PORT:-443}
published: ${HTTPS_PORT:-8443}
protocol: udp
sass:
image: ${IMAGES_PREFIX:-}app-php
@@ -56,7 +56,7 @@ services:
###> doctrine/doctrine-bundle ###
database:
ports:
- "5432:5432"
- "${POSTGRES_PORT:-5430}:5432"
###< doctrine/doctrine-bundle ###
###> symfony/mailer ###
@@ -64,7 +64,7 @@ services:
image: axllent/mailpit
ports:
- "1025"
- "8025:8025"
- "${MAILPIT_PORT:-8025}:8025"
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
@@ -73,7 +73,7 @@ services:
spotlight:
image: ghcr.io/getsentry/spotlight:latest
ports:
- "8969:8969"
- "${SPOTLIGHT_PORT:-8969}:8969"
volumes:
sass:
Generated
+6 -6
View File
@@ -9408,16 +9408,16 @@
},
{
"name": "friendsofphp/php-cs-fixer",
"version": "v3.95.12",
"version": "v3.95.13",
"source": {
"type": "git",
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
"reference": "b1b9055997a98dce3c2338e884626e718a25a923"
"reference": "ea941114a002eb5e5876f190223deec1066c482c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/b1b9055997a98dce3c2338e884626e718a25a923",
"reference": "b1b9055997a98dce3c2338e884626e718a25a923",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/ea941114a002eb5e5876f190223deec1066c482c",
"reference": "ea941114a002eb5e5876f190223deec1066c482c",
"shasum": ""
},
"require": {
@@ -9501,7 +9501,7 @@
],
"support": {
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.12"
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.13"
},
"funding": [
{
@@ -9509,7 +9509,7 @@
"type": "github"
}
],
"time": "2026-07-07T13:29:36+00:00"
"time": "2026-07-10T09:23:21+00:00"
},
{
"name": "myclabs/deep-copy",
+19 -3
View File
@@ -263,7 +263,7 @@ class DataExportService
}
$lastColumnIndex = 1 + \count($questions);
foreach (range('A', Coordinate::stringFromColumnIndex($lastColumnIndex)) as $column) {
foreach ($this->columnLetters($lastColumnIndex) as $column) {
$sheet->getColumnDimension($column)->setWidth(30);
$sheet->getStyle($column.':'.$column)->getAlignment()->setWrapText(true);
}
@@ -297,7 +297,7 @@ class DataExportService
++$row;
}
foreach (range('A', Coordinate::stringFromColumnIndex(2 + \count($candidates))) as $column) {
foreach ($this->columnLetters(2 + \count($candidates)) as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true);
}
}
@@ -405,7 +405,7 @@ class DataExportService
}
$lastColumnIndex = $answerStartColumnIndex + max(1, 2 * $maxAnswers);
foreach (range('A', Coordinate::stringFromColumnIndex($lastColumnIndex)) as $column) {
foreach ($this->columnLetters($lastColumnIndex) as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true);
}
}
@@ -426,6 +426,22 @@ class DataExportService
}
}
/**
* Column letters from 'A' up to and including the given 1-based column index.
* Unlike range('A', ...), this works past 'Z' (e.g. index 27 => 'AA').
*
* @return list<string>
*/
private function columnLetters(int $lastColumnIndex): array
{
$letters = [];
for ($columnIndex = 1; $columnIndex <= $lastColumnIndex; ++$columnIndex) {
$letters[] = Coordinate::stringFromColumnIndex($columnIndex);
}
return $letters;
}
/** @throws FilesystemException */
private function writeToTempFile(Spreadsheet $spreadsheet): string
{
+9 -10
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Tvdt\Tests\Command;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Command\Command;
@@ -48,21 +49,19 @@ final class ClaimSeasonCommandTest extends KernelTestCase
$this->assertCount(3, $season->owners);
}
public function testInvalidEmailFails(): void
/** @return iterable<string, array{string, string}> */
public static function invalidArgumentsProvider(): iterable
{
$this->commandTester->execute([
'season-code' => 'krtek',
'email' => 'nonexisting@example.org',
]);
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
yield 'unknown email' => ['krtek', 'nonexisting@example.org'];
yield 'unknown season' => ['dhadk', 'test@example.org'];
}
public function testInvalidSeasonCodeFails(): void
#[DataProvider('invalidArgumentsProvider')]
public function testInvalidArgumentFails(string $seasonCode, string $email): void
{
$this->commandTester->execute([
'season-code' => 'dhadk',
'email' => 'test@example.org',
'season-code' => $seasonCode,
'email' => $email,
]);
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season;
use Tvdt\Entity\User;
abstract class AbstractControllerWebTestCase extends WebTestCase
{
protected KernelBrowser $client;
protected EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
}
protected function getUserByEmail(string $email): User
{
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $email]);
$this->assertInstanceOf(User::class, $user);
return $user;
}
protected function loginAs(string $email): void
{
$this->client->loginUser($this->getUserByEmail($email));
}
/** Quiz names are only unique per season (see Quiz's UniqueConstraint), so this is scoped by season code. */
protected function getQuizByName(string $name, string $seasonCode = 'krtek'): Quiz
{
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name, 'season' => $this->getSeasonByCode($seasonCode)]);
$this->assertInstanceOf(Quiz::class, $quiz);
return $quiz;
}
/** Candidate names are only unique per season (see Candidate's UniqueConstraint), so this is scoped by season code. */
protected function getCandidate(string $name, string $seasonCode = 'krtek'): Candidate
{
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => $name, 'season' => $this->getSeasonByCode($seasonCode)]);
$this->assertInstanceOf(Candidate::class, $candidate);
return $candidate;
}
protected function getSeasonByCode(string $seasonCode): Season
{
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => $seasonCode]);
$this->assertInstanceOf(Season::class, $season);
return $season;
}
/** GETs $url and extracts the CSRF token from a form whose action contains $formActionContains. */
protected function getCsrfTokenFromPage(string $url, string $formActionContains, string $tokenFieldName = '_token'): string
{
$crawler = $this->client->request(Request::METHOD_GET, $url);
self::assertResponseIsSuccessful();
return $this->getCsrfTokenFromCrawler($crawler, $formActionContains, $tokenFieldName);
}
/** Extracts the CSRF token from a form on the page already loaded in the client. */
protected function getCsrfTokenFromCurrentPage(string $formActionContains, string $tokenFieldName = '_token'): string
{
return $this->getCsrfTokenFromCrawler($this->client->getCrawler(), $formActionContains, $tokenFieldName);
}
/** GETs $url and extracts the CSRF token input, regardless of which form it belongs to. */
protected function getTokenFromPage(string $url, string $tokenFieldName = '_token'): string
{
$crawler = $this->client->request(Request::METHOD_GET, $url);
self::assertResponseIsSuccessful();
$input = $crawler->filter(\sprintf('input[name="%s"]', $tokenFieldName));
$this->assertGreaterThan(0, $input->count(), \sprintf('No input named "%s" found on the page', $tokenFieldName));
return (string) $input->first()->attr('value');
}
private function getCsrfTokenFromCrawler(Crawler $crawler, string $formActionContains, string $tokenFieldName): string
{
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="%s"]', $formActionContains, $tokenFieldName));
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
return (string) $input->first()->attr('value');
}
}
@@ -4,54 +4,53 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\BackofficeController;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\User;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
#[CoversClass(BackofficeController::class)]
final class BackofficeControllerTest extends WebTestCase
final class BackofficeControllerTest extends AbstractControllerWebTestCase
{
public function testExportQuizFilenameIsSanitized(): void
{
$client = self::createClient();
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
$user = $entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']);
$this->assertInstanceOf(User::class, $user);
$user = $this->getUserByEmail('user2@example.org');
$user->isVerified = true;
$entityManager->flush();
$client->loginUser($user);
$quiz = $entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1']);
$this->assertInstanceOf(Quiz::class, $quiz);
$this->entityManager->flush();
$this->client->loginUser($user);
$client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
$quiz = $this->getQuizByName('Quiz 1');
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
self::assertResponseIsSuccessful();
$disposition = (string) $client->getResponse()->headers->get('Content-Disposition');
$disposition = (string) $this->client->getResponse()->headers->get('Content-Disposition');
$this->assertStringContainsString('filename=Quiz-1.xlsx', $disposition);
$this->assertStringNotContainsString('Quiz 1.xlsx', $disposition);
}
public function testExportQuizRequiresVerifiedEmail(): void
{
$client = self::createClient();
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
$user = $entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']);
$this->assertInstanceOf(User::class, $user);
$user = $this->getUserByEmail('user2@example.org');
$this->assertFalse($user->isVerified);
$client->loginUser($user);
$this->client->loginUser($user);
$quiz = $entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1']);
$this->assertInstanceOf(Quiz::class, $quiz);
$quiz = $this->getQuizByName('Quiz 1');
$client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
self::assertResponseRedirects(\sprintf('/backoffice/season/%s', $quiz->season->seasonCode));
}
public function testExportQuizIsDeniedForNonOwner(): void
{
$this->loginAs('test@example.org');
$quiz = $this->getQuizByName('Quiz 1');
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
self::assertResponseStatusCodeSame(403);
}
}
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use PHPUnit\Framework\Attributes\CoversClass;
use Safe\DateTimeImmutable;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\PrepareEliminationController;
use Tvdt\Entity\Answer;
use Tvdt\Entity\Elimination;
use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\Question;
use Tvdt\Entity\QuizCandidate;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
#[CoversClass(PrepareEliminationController::class)]
final class PrepareEliminationControllerTest extends AbstractControllerWebTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->loginAs('krtek-admin@example.org');
}
public function testIndexCreatesEliminationAndRedirectsToView(): void
{
$quiz = $this->getQuizByName('Quiz 1');
$candidate = $this->getCandidate('Tom');
$quizCandidate = new QuizCandidate($quiz, $candidate);
$quizCandidate->started = new DateTimeImmutable();
$this->entityManager->persist($quizCandidate);
$firstQuestion = $quiz->questions->first();
$this->assertInstanceOf(Question::class, $firstQuestion);
$answer = $firstQuestion->answers->first();
$this->assertInstanceOf(Answer::class, $answer);
$this->entityManager->persist(new GivenAnswer($candidate, $quiz, $answer));
$this->entityManager->flush();
$token = $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/krtek/quiz/%s/result', $quiz->id), '/elimination/prepare');
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/quiz/%s/elimination/prepare', $quiz->id), [
'_token' => $token,
]);
$response = $this->client->getResponse();
$this->assertTrue($response->isRedirect());
$this->assertStringContainsString('/backoffice/elimination/', (string) $response->headers->get('Location'));
$this->entityManager->clear();
$quiz = $this->getQuizByName('Quiz 1');
$elimination = $this->entityManager->getRepository(Elimination::class)->findOneBy(['quiz' => $quiz]);
$this->assertInstanceOf(Elimination::class, $elimination);
$this->assertArrayHasKey('Tom', $elimination->data);
}
public function testViewEliminationPageLoads(): void
{
$quiz = $this->getQuizByName('Quiz 1');
$elimination = new Elimination($quiz);
$elimination->data = ['Tom' => Elimination::SCREEN_GREEN];
$this->entityManager->persist($elimination);
$this->entityManager->flush();
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/elimination/%s', $elimination->id));
self::assertResponseIsSuccessful();
self::assertSelectorExists('form');
}
public function testViewEliminationSavesUpdatedColours(): void
{
$quiz = $this->getQuizByName('Quiz 1');
$elimination = new Elimination($quiz);
$elimination->data = ['Tom' => Elimination::SCREEN_GREEN];
$this->entityManager->persist($elimination);
$this->entityManager->flush();
$token = $this->getTokenFromPage(\sprintf('/backoffice/elimination/%s', $elimination->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/elimination/%s', $elimination->id), [
'_token' => $token,
'colour-tom' => Elimination::SCREEN_RED,
'start' => '0',
]);
self::assertResponseRedirects(\sprintf('/backoffice/elimination/%s', $elimination->id));
$this->entityManager->clear();
$updated = $this->entityManager->getRepository(Elimination::class)->find($elimination->id);
$this->assertInstanceOf(Elimination::class, $updated);
$this->assertSame(Elimination::SCREEN_RED, $updated->data['Tom']);
}
public function testViewEliminationWithStartRedirectsToPublicElimination(): void
{
$quiz = $this->getQuizByName('Quiz 1');
$elimination = new Elimination($quiz);
$elimination->data = ['Tom' => Elimination::SCREEN_GREEN];
$this->entityManager->persist($elimination);
$this->entityManager->flush();
$token = $this->getTokenFromPage(\sprintf('/backoffice/elimination/%s', $elimination->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/elimination/%s', $elimination->id), [
'_token' => $token,
'start' => '1',
]);
self::assertResponseRedirects(\sprintf('/elimination/%s', $elimination->id));
}
}
@@ -4,39 +4,18 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuestionBankController;
use Tvdt\Entity\BankAnswer;
use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\Question;
use Tvdt\Entity\QuestionLabel;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\User;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
#[CoversClass(QuestionBankController::class)]
final class QuestionBankControllerTest extends WebTestCase
final class QuestionBankControllerTest extends AbstractControllerWebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
}
private function loginAsOwner(): void
{
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
}
private function getBankQuestion(string $question): BankQuestion
{
$bankQuestion = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => $question]);
@@ -45,26 +24,9 @@ final class QuestionBankControllerTest extends WebTestCase
return $bankQuestion;
}
private function getQuizByName(string $name): Quiz
{
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
$this->assertInstanceOf(Quiz::class, $quiz);
return $quiz;
}
private function getCsrfToken(string $formActionContains): string
{
$crawler = $this->client->getCrawler();
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
return (string) $input->first()->attr('value');
}
public function testIndexListsBankQuestions(): void
{
$this->loginAsOwner();
$this->loginAs('krtek-admin@example.org');
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$this->assertResponseIsSuccessful();
@@ -75,7 +37,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testIndexFiltersByLabel(): void
{
$this->loginAsOwner();
$this->loginAs('krtek-admin@example.org');
$label = $this->entityManager->getRepository(QuestionLabel::class)->findOneBy(['name' => 'Locatie']);
$this->assertInstanceOf(QuestionLabel::class, $label);
@@ -89,9 +51,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testNonOwnerIsDenied(): void
{
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
$this->loginAs('test@example.org');
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
@@ -100,7 +60,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testCreateBankQuestion(): void
{
$this->loginAsOwner();
$this->loginAs('krtek-admin@example.org');
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
$this->assertResponseIsSuccessful();
@@ -129,7 +89,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testCreateAllowedWithoutCorrectAnswer(): void
{
$this->loginAsOwner();
$this->loginAs('krtek-admin@example.org');
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
@@ -145,6 +105,7 @@ final class QuestionBankControllerTest extends WebTestCase
]);
$this->assertResponseRedirects();
$this->entityManager->clear();
$saved = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => 'Vraag zonder goed antwoord']);
$this->assertInstanceOf(BankQuestion::class, $saved);
$this->assertFalse($saved->isCompleteForQuiz);
@@ -152,7 +113,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testEditBankQuestion(): void
{
$this->loginAsOwner();
$this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
$url = \sprintf('/backoffice/season/krtek/question-bank/%s/edit', $bankQuestion->id);
@@ -181,12 +142,12 @@ final class QuestionBankControllerTest extends WebTestCase
public function testDeleteUsedBankQuestionLeavesQuizIntact(): void
{
$this->loginAsOwner();
$this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?');
$quiz2QuestionCount = $this->getQuizByName('Quiz 2')->questions->count();
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$token = $this->getCsrfToken(\sprintf('%s/delete', $bankQuestion->id));
$token = $this->getCsrfTokenFromCurrentPage(\sprintf('%s/delete', $bankQuestion->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/delete', $bankQuestion->id), [
'_token' => $token,
@@ -201,13 +162,13 @@ final class QuestionBankControllerTest extends WebTestCase
public function testAssignCopiesQuestionIntoQuiz(): void
{
$this->loginAsOwner();
$this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
$quiz = $this->getQuizByName('Quiz 2');
$questionCount = $quiz->questions->count();
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id));
$token = $this->getCsrfTokenFromCurrentPage(\sprintf('%s/assign', $bankQuestion->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
'_token' => $token,
@@ -240,7 +201,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testAssignUsedNonReusableQuestionIsRefused(): void
{
$this->loginAsOwner();
$this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?');
$quiz = $this->getQuizByName('Quiz 2');
$questionCount = $quiz->questions->count();
@@ -248,7 +209,7 @@ final class QuestionBankControllerTest extends WebTestCase
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
// The assign form is not rendered for used questions, so post with another form's token
$token = $this->getCsrfToken('/assign');
$token = $this->getCsrfTokenFromCurrentPage('/assign');
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
'_token' => $token,
'quiz' => (string) $quiz->id,
@@ -262,13 +223,13 @@ final class QuestionBankControllerTest extends WebTestCase
public function testAssignSameReusableQuestionTwiceToSameQuizIsRefused(): void
{
$this->loginAsOwner();
$this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Wie is de Krtek?');
$quiz = $this->getQuizByName('Quiz 2');
$questionCount = $quiz->questions->count();
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id));
$token = $this->getCsrfTokenFromCurrentPage(\sprintf('%s/assign', $bankQuestion->id));
$url = \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id);
$this->client->request(Request::METHOD_POST, $url, ['_token' => $token, 'quiz' => (string) $quiz->id]);
@@ -283,13 +244,13 @@ final class QuestionBankControllerTest extends WebTestCase
public function testAssignIntoFinalizedQuizIsDenied(): void
{
$this->loginAsOwner();
$this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Wie is de Krtek?');
$finalizedQuiz = $this->getQuizByName('Quiz 1');
$this->assertTrue($finalizedQuiz->isFinalized);
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id));
$token = $this->getCsrfTokenFromCurrentPage(\sprintf('%s/assign', $bankQuestion->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
'_token' => $token,
@@ -301,7 +262,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testCreateBankQuestionPreservesAnswerOrdering(): void
{
$this->loginAsOwner();
$this->loginAs('krtek-admin@example.org');
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
$this->assertResponseIsSuccessful();
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
@@ -334,7 +295,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testEditBankQuestionPreservesAnswerOrdering(): void
{
$this->loginAsOwner();
$this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
// Fixture answers in insertion order (all have ordering=0): Brood (correct), Yoghurt, Niks
@@ -374,7 +335,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testAddAndDeleteLabel(): void
{
$this->loginAsOwner();
$this->loginAs('krtek-admin@example.org');
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$token = (string) $crawler->filter('form[action$="/question-bank/labels"] input[name="_token"]')->attr('value');
@@ -389,7 +350,7 @@ final class QuestionBankControllerTest extends WebTestCase
$this->assertInstanceOf(QuestionLabel::class, $label);
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$deleteToken = $this->getCsrfToken(\sprintf('labels/%s/delete', $label->slug));
$deleteToken = $this->getCsrfTokenFromCurrentPage(\sprintf('labels/%s/delete', $label->slug));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/labels/%s/delete', $label->slug), [
'_token' => $deleteToken,
@@ -4,11 +4,8 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Safe\DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuizController;
use Tvdt\Entity\Answer;
@@ -17,51 +14,21 @@ use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\QuizCandidate;
use Tvdt\Entity\Season;
use Tvdt\Entity\User;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
#[CoversClass(QuizController::class)]
final class QuizControllerTest extends WebTestCase
final class QuizControllerTest extends AbstractControllerWebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
parent::setUp();
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
}
private function getQuizByName(string $name): Quiz
{
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
$this->assertInstanceOf(Quiz::class, $quiz);
return $quiz;
}
private function getCandidate(string $name): Candidate
{
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => $name]);
$this->assertInstanceOf(Candidate::class, $candidate);
return $candidate;
$this->loginAs('krtek-admin@example.org');
}
private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string
{
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
self::assertResponseIsSuccessful();
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
return (string) $input->first()->attr('value');
return $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id), $formActionContains);
}
public function testIndexRedirectsToOverview(): void
@@ -296,9 +263,7 @@ final class QuizControllerTest extends WebTestCase
public function testNonOwnerIsDenied(): void
{
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
$this->loginAs('test@example.org');
$quiz = $this->getQuizByName('Quiz 1');
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
@@ -308,8 +273,7 @@ final class QuizControllerTest extends WebTestCase
public function testOverviewLoadsForEmptyQuiz(): void
{
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
$this->assertInstanceOf(Season::class, $season);
$season = $this->getSeasonByCode('krtek');
$emptyQuiz = new Quiz();
$emptyQuiz->name = 'Empty Quiz';
@@ -326,8 +290,7 @@ final class QuizControllerTest extends WebTestCase
public function testAnswerMappingRedirectsWithFlashWhenNoQuestions(): void
{
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
$this->assertInstanceOf(Season::class, $season);
$season = $this->getSeasonByCode('krtek');
$emptyQuiz = new Quiz();
$emptyQuiz->name = 'Empty Quiz';
@@ -4,63 +4,29 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Safe\DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuizController;
use Tvdt\Entity\Answer;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\QuizCandidate;
use Tvdt\Entity\Season;
use Tvdt\Entity\User;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
#[CoversClass(QuizController::class)]
final class QuizFinalizeTest extends WebTestCase
final class QuizFinalizeTest extends AbstractControllerWebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
parent::setUp();
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
}
private function getQuizByName(string $name): Quiz
{
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
$this->assertInstanceOf(Quiz::class, $quiz);
return $quiz;
}
private function getKrtekSeason(): Season
{
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
$this->assertInstanceOf(Season::class, $season);
return $season;
$this->loginAs('krtek-admin@example.org');
}
private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string
{
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
$this->assertResponseIsSuccessful();
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
return (string) $input->first()->attr('value');
return $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id), $formActionContains);
}
public function testFinalizeSetsFinalizedAt(): void
@@ -79,7 +45,7 @@ final class QuizFinalizeTest extends WebTestCase
public function testFinalizeRefusedWhenQuizHasErrors(): void
{
$season = $this->getKrtekSeason();
$season = $this->getSeasonByCode('krtek');
$invalidQuiz = new Quiz();
$invalidQuiz->name = 'Invalid Quiz';
@@ -116,7 +82,7 @@ final class QuizFinalizeTest extends WebTestCase
$this->assertResponseRedirects();
$this->entityManager->clear();
$season = $this->getKrtekSeason();
$season = $this->getSeasonByCode('krtek');
$this->assertInstanceOf(Quiz::class, $season->activeQuiz);
$this->assertSame('Quiz 1', $season->activeQuiz->name);
}
@@ -134,7 +100,7 @@ final class QuizFinalizeTest extends WebTestCase
$this->assertResponseRedirects();
$this->entityManager->clear();
$season = $this->getKrtekSeason();
$season = $this->getSeasonByCode('krtek');
$this->assertInstanceOf(Quiz::class, $season->activeQuiz);
$this->assertSame('Quiz 2', $season->activeQuiz->name);
}
@@ -183,8 +149,7 @@ final class QuizFinalizeTest extends WebTestCase
// Scrape the token before a candidate starts, since the button disappears afterwards
$token = $this->getCsrfTokenFromOverview($quiz, '/unfinalize');
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => 'Tom']);
$this->assertInstanceOf(Candidate::class, $candidate);
$candidate = $this->getCandidate('Tom');
$quizCandidate = new QuizCandidate($quiz, $candidate);
$quizCandidate->started = new DateTimeImmutable();
@@ -229,6 +194,6 @@ final class QuizFinalizeTest extends WebTestCase
self::assertResponseRedirects(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz2->id));
$this->entityManager->clear();
$this->assertNotInstanceOf(Quiz::class, $this->getKrtekSeason()->activeQuiz);
$this->assertNotInstanceOf(Quiz::class, $this->getSeasonByCode('krtek')->activeQuiz);
}
}
@@ -4,47 +4,18 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuizQuestionController;
use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\User;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
#[CoversClass(QuizQuestionController::class)]
final class QuizQuestionControllerTest extends WebTestCase
final class QuizQuestionControllerTest extends AbstractControllerWebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
}
private function loginAsOwner(): void
{
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
}
private function getQuizByName(string $name): Quiz
{
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
$this->assertInstanceOf(Quiz::class, $quiz);
return $quiz;
}
public function testEditPreservesAnswerOrdering(): void
{
$this->loginAsOwner();
$this->loginAs('krtek-admin@example.org');
$quiz = $this->getQuizByName('Quiz 2');
$question = null;
@@ -111,7 +82,7 @@ final class QuizQuestionControllerTest extends WebTestCase
public function testReorderQuestionsWithinQuiz(): void
{
$this->loginAsOwner();
$this->loginAs('krtek-admin@example.org');
$quiz = $this->getQuizByName('Quiz 2');
$originalQuestions = $quiz->questions->toArray();
@@ -144,4 +115,43 @@ final class QuizQuestionControllerTest extends WebTestCase
$this->assertSame($originalLastId, (string) $reorderedQuestions[0]->id);
$this->assertSame($originalFirstId, (string) $reorderedQuestions[\count($reorderedQuestions) - 1]->id);
}
public function testEditIsDeniedForNonOwner(): void
{
$this->loginAs('test@example.org');
$quiz = $this->getQuizByName('Quiz 2');
$question = $quiz->questions->first();
$this->assertInstanceOf(Question::class, $question);
$this->client->request(Request::METHOD_GET, \sprintf(
'/backoffice/season/krtek/quiz/%s/question/%s/edit',
$quiz->id,
$question->id,
));
self::assertResponseStatusCodeSame(403);
}
public function testReorderIsDeniedForNonOwner(): void
{
$quiz = $this->getQuizByName('Quiz 2');
// Scrape a valid CSRF token as the owner before switching to the non-owner account,
// since the token is bound to the session, not the logged-in user.
$this->loginAs('krtek-admin@example.org');
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
self::assertResponseIsSuccessful();
$csrfToken = $crawler->filter('[data-bo--question-list-csrf-value]')->attr('data-bo--question-list-csrf-value');
$this->assertNotEmpty($csrfToken);
$this->loginAs('test@example.org');
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/quiz/%s/questions/reorder', $quiz->id), [
'_token' => $csrfToken,
'ordering' => array_map(static fn (Question $q): string => (string) $q->id, $quiz->questions->toArray()),
]);
self::assertResponseStatusCodeSame(403);
}
}
@@ -4,40 +4,27 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\SeasonController;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\Season;
use Tvdt\Entity\User;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
#[CoversClass(SeasonController::class)]
final class SeasonControllerTest extends WebTestCase
final class SeasonControllerTest extends AbstractControllerWebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
parent::setUp();
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
$this->loginAs('krtek-admin@example.org');
}
public function testRegenerateSeasonCodeChangesTheCode(): void
{
$oldCode = 'krtek';
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/%s/settings', $oldCode));
self::assertResponseIsSuccessful();
$token = (string) $crawler->filter('form[action*="/regenerate-code"] input[name="_token"]')->first()->attr('value');
$token = $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/%s/settings', $oldCode), '/regenerate-code');
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/%s/settings/regenerate-code', $oldCode), [
'_token' => $token,
@@ -54,13 +41,9 @@ final class SeasonControllerTest extends WebTestCase
public function testRegenerateSeasonCodeIsDeniedForNonOwner(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/settings');
self::assertResponseIsSuccessful();
$token = (string) $crawler->filter('form[action*="/regenerate-code"] input[name="_token"]')->first()->attr('value');
$token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/settings', '/regenerate-code');
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
$this->loginAs('test@example.org');
$this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/settings/regenerate-code', [
'_token' => $token,
@@ -69,29 +52,10 @@ final class SeasonControllerTest extends WebTestCase
self::assertResponseStatusCodeSame(403);
}
private function getCandidate(string $name): Candidate
{
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => $name]);
$this->assertInstanceOf(Candidate::class, $candidate);
return $candidate;
}
private function getCsrfTokenFromCandidatesTab(string $formActionContains): string
{
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/candidates');
self::assertResponseIsSuccessful();
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
return (string) $input->first()->attr('value');
}
public function testRenameCandidate(): void
{
$candidate = $this->getCandidate('Tom');
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/rename', $candidate->id));
$token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/candidates', \sprintf('/candidate/%s/rename', $candidate->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
'_token' => $token,
@@ -109,7 +73,7 @@ final class SeasonControllerTest extends WebTestCase
public function testRenameCandidateToExistingNameShowsError(): void
{
$candidate = $this->getCandidate('Tom');
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/rename', $candidate->id));
$token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/candidates', \sprintf('/candidate/%s/rename', $candidate->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
'_token' => $token,
@@ -128,7 +92,7 @@ final class SeasonControllerTest extends WebTestCase
{
$candidate = $this->getCandidate('Tom');
$candidateId = $candidate->id;
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/delete', $candidate->id));
$token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/candidates', \sprintf('/candidate/%s/delete', $candidate->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/delete', $candidate->id), [
'_token' => $token,
@@ -143,11 +107,9 @@ final class SeasonControllerTest extends WebTestCase
public function testRenameCandidateIsDeniedForNonOwner(): void
{
$candidate = $this->getCandidate('Tom');
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/rename', $candidate->id));
$token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/candidates', \sprintf('/candidate/%s/rename', $candidate->id));
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
$this->loginAs('test@example.org');
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
'_token' => $token,
@@ -4,11 +4,9 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Safe\DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Tvdt\Controller\Backoffice\SettingsController;
@@ -17,43 +15,21 @@ use Tvdt\Entity\Quiz;
use Tvdt\Entity\ResetPasswordRequest;
use Tvdt\Entity\Season;
use Tvdt\Entity\User;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
#[CoversClass(SettingsController::class)]
final class SettingsControllerTest extends WebTestCase
final class SettingsControllerTest extends AbstractControllerWebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
parent::setUp();
$this->loginAs('test@example.org');
}
private function loginAs(string $email): void
{
$user = $this->getUserByEmail($email);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
}
private function getUserByEmail(string $email): ?User
{
return $this->entityManager->getRepository(User::class)->findOneBy(['email' => $email]);
}
private function getCsrfTokenFromSettings(string $formActionContains): string
{
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseIsSuccessful();
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
return (string) $input->first()->attr('value');
return $this->getCsrfTokenFromPage('/backoffice/settings', $formActionContains);
}
public function testSettingsPageLoadsAndNavContainsSettingsLink(): void
@@ -98,7 +74,6 @@ final class SettingsControllerTest extends WebTestCase
$this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($user, 'NewPass123!'));
@@ -107,32 +82,21 @@ final class SettingsControllerTest extends WebTestCase
self::assertResponseIsSuccessful();
}
public function testChangePasswordWithWrongCurrentPasswordIsRejected(): void
/** @return iterable<string, array{string, string, string}> */
public static function invalidPasswordChangeProvider(): iterable
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([
'change_user_password_form[currentPassword]' => 'wrong-password',
'change_user_password_form[plainPassword][first]' => 'NewPass123!',
'change_user_password_form[plainPassword][second]' => 'NewPass123!',
]);
$this->client->submit($form);
self::assertResponseStatusCodeSame(422);
$this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($user, TestFixtures::PASSWORD));
yield 'wrong current password' => ['wrong-password', 'NewPass123!', 'NewPass123!'];
yield 'mismatched repeat' => [TestFixtures::PASSWORD, 'NewPass123!', 'SomethingElse!'];
}
public function testChangePasswordWithMismatchedRepeatIsRejected(): void
#[DataProvider('invalidPasswordChangeProvider')]
public function testChangePasswordIsRejected(string $currentPassword, string $first, string $second): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([
'change_user_password_form[currentPassword]' => TestFixtures::PASSWORD,
'change_user_password_form[plainPassword][first]' => 'NewPass123!',
'change_user_password_form[plainPassword][second]' => 'SomethingElse!',
'change_user_password_form[currentPassword]' => $currentPassword,
'change_user_password_form[plainPassword][first]' => $first,
'change_user_password_form[plainPassword][second]' => $second,
]);
$this->client->submit($form);
@@ -140,7 +104,6 @@ final class SettingsControllerTest extends WebTestCase
$this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($user, TestFixtures::PASSWORD));
}
@@ -157,9 +120,8 @@ final class SettingsControllerTest extends WebTestCase
self::assertEmailCount(1);
$this->entityManager->clear();
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('test@example.org'));
$this->assertNotInstanceOf(User::class, $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']));
$user = $this->getUserByEmail('new-address@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertFalse($user->isVerified);
// User stays logged in
@@ -179,7 +141,7 @@ final class SettingsControllerTest extends WebTestCase
self::assertEmailCount(0);
$this->entityManager->clear();
$this->assertInstanceOf(User::class, $this->getUserByEmail('test@example.org'));
$this->getUserByEmail('test@example.org');
}
public function testResendConfirmationEmailSendsEmail(): void
@@ -200,8 +162,8 @@ final class SettingsControllerTest extends WebTestCase
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/resend-confirmation');
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$user->isVerified = true;
$this->entityManager->flush();
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/settings');
@@ -242,7 +204,6 @@ final class SettingsControllerTest extends WebTestCase
public function testChangePasswordInvalidatesResetPasswordRequests(): void
{
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->createResetPasswordRequest($user);
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
@@ -257,14 +218,12 @@ final class SettingsControllerTest extends WebTestCase
$this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $user]));
}
public function testChangeEmailInvalidatesResetPasswordRequests(): void
{
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->createResetPasswordRequest($user);
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
@@ -277,7 +236,6 @@ final class SettingsControllerTest extends WebTestCase
$this->entityManager->clear();
$user = $this->getUserByEmail('new-address@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $user]));
}
@@ -293,7 +251,7 @@ final class SettingsControllerTest extends WebTestCase
self::assertResponseRedirects('/backoffice/settings');
$this->entityManager->clear();
$this->assertInstanceOf(User::class, $this->getUserByEmail('test@example.org'));
$this->getUserByEmail('test@example.org');
}
public function testDeleteAccountRemovesSoleOwnerSeasonsAndKeepsSharedSeasons(): void
@@ -309,7 +267,7 @@ final class SettingsControllerTest extends WebTestCase
self::assertResponseRedirects();
$this->entityManager->clear();
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('sole-owner@example.org'));
$this->assertNotInstanceOf(User::class, $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'sole-owner@example.org']));
// Sole-owner season is removed, including its quiz
$this->assertNotInstanceOf(Season::class, $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'doomd']));
@@ -340,7 +298,7 @@ final class SettingsControllerTest extends WebTestCase
self::assertResponseRedirects();
$this->entityManager->clear();
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('user2@example.org'));
$this->assertNotInstanceOf(User::class, $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']));
foreach (['krtek', 'bbbbb'] as $seasonCode) {
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => $seasonCode]);
@@ -378,7 +336,6 @@ final class SettingsControllerTest extends WebTestCase
public function testDownloadDataRequiresVerifiedEmail(): void
{
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertFalse($user->isVerified);
$this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data');
@@ -389,8 +346,8 @@ final class SettingsControllerTest extends WebTestCase
private function markUserVerified(string $email): void
{
$user = $this->getUserByEmail($email);
$this->assertInstanceOf(User::class, $user);
$user->isVerified = true;
$this->entityManager->flush();
}
}
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\EliminationController;
use Tvdt\Entity\Elimination;
use Tvdt\Helpers\Base64;
#[CoversClass(EliminationController::class)]
final class EliminationControllerTest extends AbstractControllerWebTestCase
{
private Elimination $elimination;
protected function setUp(): void
{
parent::setUp();
$quiz = $this->getQuizByName('Quiz 1');
$this->elimination = new Elimination($quiz);
$this->elimination->data = ['Tom' => Elimination::SCREEN_GREEN];
$this->entityManager->persist($this->elimination);
$this->entityManager->flush();
$this->loginAs('krtek-admin@example.org');
}
public function testIndexIsDeniedForNonOwner(): void
{
$this->loginAs('test@example.org');
$this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s', $this->elimination->id));
self::assertResponseStatusCodeSame(403);
}
public function testIndexPageLoads(): void
{
$this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s', $this->elimination->id));
self::assertResponseIsSuccessful();
self::assertSelectorExists('form');
}
public function testIndexRedirectsToCandidateScreen(): void
{
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s', $this->elimination->id));
$form = $crawler->filter('form')->form([
'elimination_enter_name[name]' => 'Tom',
]);
$this->client->submit($form);
self::assertResponseRedirects(\sprintf('/elimination/%s/%s', $this->elimination->id, Base64::base64UrlEncode('Tom')));
}
public function testCandidateScreenUnknownCandidateRedirectsWithFlash(): void
{
$this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s/%s', $this->elimination->id, Base64::base64UrlEncode('Nobody')));
self::assertResponseRedirects(\sprintf('/elimination/%s', $this->elimination->id));
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Kon kandidaat met naam Nobody niet vinden');
}
public function testCandidateScreenCandidateNotInEliminationDataRedirectsWithFlash(): void
{
$this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s/%s', $this->elimination->id, Base64::base64UrlEncode('Claudia')));
self::assertResponseRedirects(\sprintf('/elimination/%s', $this->elimination->id));
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Kon geen kandidaat vinden met de naam Claudia in de eliminatie');
}
public function testCandidateScreenRendersColour(): void
{
$this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s/%s', $this->elimination->id, Base64::base64UrlEncode('Tom')));
self::assertResponseIsSuccessful();
self::assertSelectorExists(\sprintf('#%s', Elimination::SCREEN_GREEN));
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\LoginController;
#[CoversClass(LoginController::class)]
final class LoginControllerTest extends AbstractControllerWebTestCase
{
public function testLoginPageLoadsWhenNotAuthenticated(): void
{
$this->client->request(Request::METHOD_GET, '/login');
self::assertResponseIsSuccessful();
self::assertSelectorExists('form');
}
public function testLoginRedirectsToBackofficeWhenAlreadyAuthenticated(): void
{
$this->loginAs('test@example.org');
$this->client->request(Request::METHOD_GET, '/login');
self::assertResponseRedirects('/backoffice/');
}
public function testLoginWithInvalidCredentialsShowsFlash(): void
{
$this->client->request(Request::METHOD_GET, '/login');
$form = $this->client->getCrawler()->filter('form')->form([
'_username' => 'test@example.org',
'_password' => 'wrong-password',
]);
$this->client->submit($form);
self::assertResponseRedirects('/login');
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Ongeldige inloggegevens.');
}
public function testLogoutIsInterceptedByFirewall(): void
{
$this->loginAs('test@example.org');
$this->client->request(Request::METHOD_GET, '/logout');
self::assertResponseRedirects();
}
}
+209
View File
@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\QuizController;
use Tvdt\Entity\Answer;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\Question;
use Tvdt\Entity\QuizCandidate;
use Tvdt\Helpers\Base64;
#[CoversClass(QuizController::class)]
final class QuizControllerTest extends AbstractControllerWebTestCase
{
private function answerQuestion(Question $question): void
{
$tomHash = Base64::base64UrlEncode('Tom');
$url = \sprintf('/krtek/%s', $tomHash);
$crawler = $this->client->request(Request::METHOD_GET, $url);
self::assertResponseIsSuccessful();
$token = (string) $crawler->filter('input[name="token"]')->first()->attr('value');
$answer = $question->answers->first();
$this->assertInstanceOf(Answer::class, $answer);
$this->client->request(Request::METHOD_POST, $url, [
'token' => $token,
'answer' => (string) $answer->id,
]);
self::assertResponseRedirects($url);
}
public function testSelectSeasonPageLoads(): void
{
$this->client->request(Request::METHOD_GET, '/');
self::assertResponseIsSuccessful();
self::assertSelectorExists('form');
}
public function testSelectSeasonWithInvalidCodeRedirectsWithFlash(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/');
$form = $crawler->filter('form')->form([
'select_season[season_code]' => 'aaaaa',
]);
$this->client->submit($form);
self::assertResponseRedirects('/');
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Ongeldige seizoencode');
}
public function testSelectSeasonWithValidCodeRedirectsToEnterName(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/');
$form = $crawler->filter('form')->form([
'select_season[season_code]' => 'krtek',
]);
$this->client->submit($form);
self::assertResponseRedirects('/krtek');
}
public function testEnterNamePageLoads(): void
{
$this->client->request(Request::METHOD_GET, '/krtek');
self::assertResponseIsSuccessful();
self::assertSelectorExists('form');
}
public function testEnterNameRedirectsToQuizPage(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/krtek');
$form = $crawler->filter('form')->form([
'enter_name[name]' => 'Tom',
]);
$this->client->submit($form);
self::assertResponseRedirects(\sprintf('/krtek/%s', Base64::base64UrlEncode('Tom')));
}
public function testQuizPageUnknownCandidateRedirectsWithFlash(): void
{
$this->client->request(Request::METHOD_GET, \sprintf('/krtek/%s', Base64::base64UrlEncode('Nobody')));
self::assertResponseRedirects('/krtek');
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Kandidaat niet gevonden');
}
public function testQuizPageWithoutActiveQuizRedirectsWithFlash(): void
{
$season = $this->getSeasonByCode('bbbbb');
$season->addCandidate(new Candidate('Nienke'));
$this->entityManager->flush();
$this->client->request(Request::METHOD_GET, \sprintf('/bbbbb/%s', Base64::base64UrlEncode('Nienke')));
self::assertResponseRedirects('/bbbbb');
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Er is geen test actief');
}
public function testQuizPageRendersFirstQuestion(): void
{
$this->client->request(Request::METHOD_GET, \sprintf('/krtek/%s', Base64::base64UrlEncode('Tom')));
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('body', 'Is de Krtek een man of een vrouw?');
}
public function testQuizPageAnsweringPersistsGivenAnswerAndRedirects(): void
{
$quiz = $this->getQuizByName('Quiz 1');
$firstQuestion = $quiz->questions->first();
$this->assertInstanceOf(Question::class, $firstQuestion);
$answer = $firstQuestion->answers->first();
$this->assertInstanceOf(Answer::class, $answer);
$this->answerQuestion($firstQuestion);
$this->entityManager->clear();
$candidate = $this->getCandidate('Tom');
$givenAnswer = $this->entityManager->getRepository(GivenAnswer::class)->findOneBy(['candidate' => $candidate]);
$this->assertInstanceOf(GivenAnswer::class, $givenAnswer);
$this->assertTrue($answer->id->equals($givenAnswer->answer->id));
}
public function testQuizPageInvalidAnswerIdShowsFlash(): void
{
$url = \sprintf('/krtek/%s', Base64::base64UrlEncode('Tom'));
$crawler = $this->client->request(Request::METHOD_GET, $url);
$token = (string) $crawler->filter('input[name="token"]')->first()->attr('value');
$this->client->request(Request::METHOD_POST, $url, [
'token' => $token,
'answer' => '00000000-0000-0000-0000-000000000000',
]);
self::assertResponseRedirects($url);
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Selecteer een antwoorden alsjeblieft');
}
public function testQuizPageOutOfOrderAnswerShowsFlash(): void
{
$quiz = $this->getQuizByName('Quiz 1');
$secondQuestion = $quiz->questions->get(1);
$this->assertInstanceOf(Question::class, $secondQuestion);
$answer = $secondQuestion->answers->first();
$this->assertInstanceOf(Answer::class, $answer);
$url = \sprintf('/krtek/%s', Base64::base64UrlEncode('Tom'));
$crawler = $this->client->request(Request::METHOD_GET, $url);
$token = (string) $crawler->filter('input[name="token"]')->first()->attr('value');
$this->client->request(Request::METHOD_POST, $url, [
'token' => $token,
'answer' => (string) $answer->id,
]);
self::assertResponseRedirects($url);
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Je kan deze vraag niet beantwoorden');
}
public function testQuizPageCompletedShowsFlashAndRedirects(): void
{
$quiz = $this->getQuizByName('Quiz 1');
foreach ($quiz->questions as $question) {
$this->answerQuestion($question);
}
$this->client->request(Request::METHOD_GET, \sprintf('/krtek/%s', Base64::base64UrlEncode('Tom')));
self::assertResponseRedirects('/krtek');
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Test voltooid');
}
public function testQuizPageInactiveCandidateIsBlocked(): void
{
$quiz = $this->getQuizByName('Quiz 1');
$candidate = $this->getCandidate('Tom');
$quizCandidate = new QuizCandidate($quiz, $candidate);
$quizCandidate->active = false;
$this->entityManager->persist($quizCandidate);
$this->entityManager->flush();
$this->client->request(Request::METHOD_GET, \sprintf('/krtek/%s', Base64::base64UrlEncode('Tom')));
self::assertResponseRedirects('/krtek');
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Je mag deze test niet beantwoorden');
}
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\HttpFoundation\Request;
use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface;
use Tvdt\Controller\RegistrationController;
#[CoversClass(RegistrationController::class)]
final class RegistrationControllerTest extends AbstractControllerWebTestCase
{
public function testRegisterPageLoadsWhenNotAuthenticated(): void
{
$this->client->request(Request::METHOD_GET, '/register');
self::assertResponseIsSuccessful();
self::assertSelectorExists('form');
}
public function testRegisterRedirectsToBackofficeWhenAlreadyAuthenticated(): void
{
$this->loginAs('test@example.org');
$this->client->request(Request::METHOD_GET, '/register');
self::assertResponseRedirects('/backoffice/');
}
public function testRegisterCreatesUserSendsConfirmationAndLogsIn(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/register');
$form = $crawler->filter('form')->form([
'registration_form[email]' => 'newuser@example.org',
'registration_form[plainPassword][first]' => 'NewPass123!',
'registration_form[plainPassword][second]' => 'NewPass123!',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/');
self::assertEmailCount(1);
$this->entityManager->clear();
$user = $this->getUserByEmail('newuser@example.org');
$this->assertFalse($user->isVerified);
}
public function testVerifyEmailWithoutIdRedirectsToRegister(): void
{
$this->client->request(Request::METHOD_GET, '/verify/email');
self::assertResponseRedirects('/register');
}
public function testVerifyEmailWithUnknownIdRedirectsToRegister(): void
{
$this->client->request(Request::METHOD_GET, '/verify/email', ['id' => '00000000-0000-0000-0000-000000000000']);
self::assertResponseRedirects('/register');
}
public function testVerifyEmailWithValidSignatureMarksUserVerified(): void
{
$user = $this->getUserByEmail('test@example.org');
$this->assertFalse($user->isVerified);
/** @var VerifyEmailHelperInterface $helper */
$helper = self::getContainer()->get(VerifyEmailHelperInterface::class);
$signature = $helper->generateSignature('tvdt_verify_email', $user->id->toRfc4122(), $user->email, ['id' => $user->id]);
$this->client->request(Request::METHOD_GET, $signature->getSignedUrl());
self::assertResponseRedirects('/backoffice/');
$this->entityManager->clear();
$updatedUser = $this->getUserByEmail('test@example.org');
$this->assertTrue($updatedUser->isVerified);
}
public function testVerifyEmailWithInvalidSignatureShowsErrorAndRedirects(): void
{
$user = $this->getUserByEmail('test@example.org');
$this->client->request(Request::METHOD_GET, '/verify/email', ['id' => (string) $user->id, 'expires' => '9999999999', 'signature' => 'invalid']);
self::assertResponseRedirects('/register');
}
}
@@ -4,10 +4,8 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
@@ -15,18 +13,8 @@ use Tvdt\Controller\ResetPasswordController;
use Tvdt\Entity\User;
#[CoversClass(ResetPasswordController::class)]
final class ResetPasswordControllerTest extends WebTestCase
final class ResetPasswordControllerTest extends AbstractControllerWebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
}
public function testRequestPageLoads(): void
{
$this->client->request(Request::METHOD_GET, '/reset-password');
@@ -35,22 +23,19 @@ final class ResetPasswordControllerTest extends WebTestCase
$this->assertSelectorExists('form');
}
public function testRequestWithUnknownEmailRedirectsToCheckEmail(): void
/** @return iterable<string, array{string}> */
public static function emailProvider(): iterable
{
$this->client->request(Request::METHOD_GET, '/reset-password');
$form = $this->client->getCrawler()->filter('form')->form([
'reset_password_request_form[email]' => 'unknown@example.org',
]);
$this->client->submit($form);
$this->assertResponseRedirects('/reset-password/check-email');
yield 'unknown email' => ['unknown@example.org'];
yield 'known email' => ['test@example.org'];
}
public function testRequestWithKnownEmailRedirectsToCheckEmail(): void
#[DataProvider('emailProvider')]
public function testRequestRedirectsToCheckEmail(string $email): void
{
$this->client->request(Request::METHOD_GET, '/reset-password');
$form = $this->client->getCrawler()->filter('form')->form([
'reset_password_request_form[email]' => 'test@example.org',
'reset_password_request_form[email]' => $email,
]);
$this->client->submit($form);
+1 -10
View File
@@ -6,21 +6,12 @@ namespace Tvdt\Tests\Controller;
use PHPUnit\Framework\Attributes\CoversClass;
use Safe\DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\WellKnownController;
#[CoversClass(WellKnownController::class)]
final class WellKnownControllerTest extends WebTestCase
final class WellKnownControllerTest extends AbstractControllerWebTestCase
{
private KernelBrowser $client;
protected function setUp(): void
{
$this->client = self::createClient();
}
public function testChangePasswordRedirectsToSettings(): void
{
$this->client->request(Request::METHOD_GET, '/.well-known/change-password');
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Entity;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Tvdt\Entity\BankAnswer;
use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\BankQuestionUsage;
use Tvdt\Entity\Quiz;
#[CoversClass(BankQuestion::class)]
final class BankQuestionTest extends TestCase
{
public function testIsCompleteForQuizIsFalseWithoutTwoAnswers(): void
{
$bankQuestion = new BankQuestion();
$bankQuestion->addAnswer(new BankAnswer('Only answer', true));
$this->assertFalse($bankQuestion->isCompleteForQuiz);
}
public function testIsCompleteForQuizIsFalseWithoutCorrectAnswer(): void
{
$bankQuestion = new BankQuestion();
$bankQuestion->addAnswer(new BankAnswer('Wrong 1'));
$bankQuestion->addAnswer(new BankAnswer('Wrong 2'));
$this->assertFalse($bankQuestion->isCompleteForQuiz);
}
public function testIsCompleteForQuizIsFalseWithMultipleCorrectAnswers(): void
{
$bankQuestion = new BankQuestion();
$bankQuestion->addAnswer(new BankAnswer('Right 1', true));
$bankQuestion->addAnswer(new BankAnswer('Right 2', true));
$this->assertFalse($bankQuestion->isCompleteForQuiz);
}
public function testIsCompleteForQuizIsTrueWithTwoAnswersAndOneCorrect(): void
{
$bankQuestion = new BankQuestion();
$bankQuestion->addAnswer(new BankAnswer('Right', true));
$bankQuestion->addAnswer(new BankAnswer('Wrong'));
$this->assertTrue($bankQuestion->isCompleteForQuiz);
}
public function testCanBeAssignedIsTrueWhenUnused(): void
{
$bankQuestion = new BankQuestion();
$this->assertTrue($bankQuestion->canBeAssigned);
}
public function testCanBeAssignedIsTrueWhenReusableEvenIfUsed(): void
{
$bankQuestion = new BankQuestion();
$bankQuestion->reusable = true;
$bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, new Quiz()));
$this->assertTrue($bankQuestion->canBeAssigned);
}
public function testCanBeAssignedIsFalseWhenSingleUseAndUsed(): void
{
$bankQuestion = new BankQuestion();
$bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, new Quiz()));
$this->assertFalse($bankQuestion->canBeAssigned);
}
public function testIsUsedInQuizIsTrueForQuizWithUsage(): void
{
$bankQuestion = new BankQuestion();
$quiz = new Quiz();
$bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, $quiz));
$this->assertTrue($bankQuestion->isUsedInQuiz($quiz));
}
public function testIsUsedInQuizIsFalseForDifferentQuiz(): void
{
$bankQuestion = new BankQuestion();
$bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, new Quiz()));
$this->assertFalse($bankQuestion->isUsedInQuiz(new Quiz()));
}
public function testToStringReturnsQuestionText(): void
{
$bankQuestion = new BankQuestion();
$bankQuestion->question = 'Wie is de Krtek?';
$this->assertSame('Wie is de Krtek?', (string) $bankQuestion);
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Entity;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\InputBag;
use Tvdt\Entity\Elimination;
use Tvdt\Entity\Quiz;
#[CoversClass(Elimination::class)]
final class EliminationTest extends TestCase
{
public function testGetScreenColourReturnsNullForNullName(): void
{
$elimination = new Elimination(new Quiz());
$this->assertNull($elimination->getScreenColour(null));
}
public function testGetScreenColourReturnsNullForUnknownName(): void
{
$elimination = new Elimination(new Quiz());
$elimination->data = $this->colours(['Tom' => Elimination::SCREEN_GREEN]);
$this->assertNull($elimination->getScreenColour('Claudia'));
}
public function testGetScreenColourReturnsColourForKnownName(): void
{
$elimination = new Elimination(new Quiz());
$elimination->data = $this->colours(['Tom' => Elimination::SCREEN_GREEN, 'Claudia' => Elimination::SCREEN_RED]);
$this->assertSame(Elimination::SCREEN_RED, $elimination->getScreenColour('Claudia'));
}
public function testUpdateFromInputBagUpdatesKnownColours(): void
{
$elimination = new Elimination(new Quiz());
$elimination->data = $this->colours(['Tom' => Elimination::SCREEN_GREEN, 'Claudia' => Elimination::SCREEN_RED]);
$elimination->updateFromInputBag($this->inputBag(['colour-tom' => Elimination::SCREEN_RED]));
$this->assertSame(Elimination::SCREEN_RED, $elimination->data['Tom']);
$this->assertSame(Elimination::SCREEN_RED, $elimination->data['Claudia']);
}
public function testUpdateFromInputBagIgnoresMissingInput(): void
{
$elimination = new Elimination(new Quiz());
$elimination->data = $this->colours(['Tom' => Elimination::SCREEN_GREEN]);
$elimination->updateFromInputBag($this->inputBag([]));
$this->assertSame(Elimination::SCREEN_GREEN, $elimination->data['Tom']);
}
public function testUpdateFromInputBagReturnsSelf(): void
{
$elimination = new Elimination(new Quiz());
$this->assertSame($elimination, $elimination->updateFromInputBag($this->inputBag([])));
}
/**
* @param array<string, string> $colours
*
* @return array<string, string>
*/
private function colours(array $colours): array
{
return $colours;
}
/**
* @param array<string, string> $parameters
*
* @return InputBag<bool|float|int|string>
*/
private function inputBag(array $parameters): InputBag
{
/** @var InputBag<bool|float|int|string> $inputBag */
$inputBag = new InputBag($parameters);
return $inputBag;
}
}
+17 -11
View File
@@ -4,28 +4,34 @@ declare(strict_types=1);
namespace Tvdt\Tests\Helpers;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Safe\Exceptions\UrlException;
use Tvdt\Helpers\Base64;
#[CoversClass(Base64::class)]
final class Base64Test extends TestCase
{
public function testBase64UrlEncode(): void
/** @return iterable<string, array{string, string}> */
public static function pairProvider(): iterable
{
$this->assertSame('TWFyaWpu', Base64::base64UrlEncode('Marijn'));
$this->assertSame('UGhpbGluZQ', Base64::base64UrlEncode('Philine'));
$this->assertSame('_g', Base64::base64UrlEncode(\chr(254)));
$this->assertSame('-g', Base64::base64UrlEncode(\chr(250)));
yield 'Marijn' => ['Marijn', 'TWFyaWpu'];
yield 'Philine' => ['Philine', 'UGhpbGluZQ'];
yield 'byte 254' => [\chr(254), '_g'];
yield 'byte 250' => [\chr(250), '-g'];
}
public function testBase64UrlDecode(): void
#[DataProvider('pairProvider')]
public function testBase64UrlEncode(string $decoded, string $encoded): void
{
$this->assertSame('Marijn', Base64::base64UrlDecode('TWFyaWpu'));
$this->assertSame('Philine', Base64::base64UrlDecode('UGhpbGluZQ'));
$this->assertSame($encoded, Base64::base64UrlEncode($decoded));
}
$this->assertSame(\chr(254), Base64::base64UrlDecode('_g'));
$this->assertSame(\chr(250), Base64::base64UrlDecode('-g'));
#[DataProvider('pairProvider')]
public function testBase64UrlDecode(string $decoded, string $encoded): void
{
$this->assertSame($decoded, Base64::base64UrlDecode($encoded));
}
public function testBase64UrlDecodeCanHandlePadding(): void
+17 -27
View File
@@ -4,41 +4,31 @@ declare(strict_types=1);
namespace Tvdt\Tests\Helpers;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Tvdt\Helpers\FilenameSanitizer;
#[CoversClass(FilenameSanitizer::class)]
final class FilenameSanitizerTest extends TestCase
{
public function testReplacesSpacesWithDashes(): void
/** @return iterable<string, array{string, string}> */
public static function sanitizeProvider(): iterable
{
$this->assertSame('Krtek-Weekend', FilenameSanitizer::sanitize('Krtek Weekend'));
yield 'replaces spaces with dashes' => ['Krtek Weekend', 'Krtek-Weekend'];
yield 'strips path traversal' => ['../../etc/passwd', 'etc-passwd'];
yield 'strips forward slash' => ['a/b', 'a-b'];
yield 'strips backslash' => ['a\\b', 'a-b'];
yield 'strips control characters and special symbols' => ["Quiz #1 <script>\0", 'Quiz-1-script'];
yield 'transliterates unicode to ascii' => ['Wéird Ñame', 'Weird-Name'];
yield 'transliterates at sign in email' => ['test@example.org', 'test-example-org'];
yield 'returns unnamed for empty input' => ['', 'unnamed'];
yield 'returns unnamed for fully stripped input' => ['///', 'unnamed'];
}
public function testStripsPathSeparatorsAndTraversal(): void
#[DataProvider('sanitizeProvider')]
public function testSanitize(string $input, string $expected): void
{
$this->assertSame('etc-passwd', FilenameSanitizer::sanitize('../../etc/passwd'));
$this->assertSame('a-b', FilenameSanitizer::sanitize('a/b'));
$this->assertSame('a-b', FilenameSanitizer::sanitize('a\\b'));
}
public function testStripsControlCharactersAndSpecialSymbols(): void
{
$this->assertSame('Quiz-1-script', FilenameSanitizer::sanitize("Quiz #1 <script>\0"));
}
public function testTransliteratesUnicodeToAscii(): void
{
$this->assertSame('Weird-Name', FilenameSanitizer::sanitize('Wéird Ñame'));
}
public function testTransliteratesAtSignInEmail(): void
{
$this->assertSame('test-example-org', FilenameSanitizer::sanitize('test@example.org'));
}
public function testReturnsUnnamedForEmptyOrFullyStrippedInput(): void
{
$this->assertSame('unnamed', FilenameSanitizer::sanitize(''));
$this->assertSame('unnamed', FilenameSanitizer::sanitize('///'));
$this->assertSame($expected, FilenameSanitizer::sanitize($input));
}
}
@@ -53,4 +53,22 @@ final class CandidateRepositoryTest extends DatabaseTestCase
);
$this->assertNotInstanceOf(Candidate::class, $result);
}
/** Candidate names are only unique per season, so a same-named candidate in another season must not leak in. */
public function testGetCandidateByHashScopesByCandidateSeasonNotJustName(): void
{
$krtekSeason = $this->getSeasonByCode('krtek');
$anotherSeason = $this->getSeasonByCode('bbbbb');
$duplicateNamedCandidate = new Candidate('Claudia');
$anotherSeason->addCandidate($duplicateNamedCandidate);
$this->entityManager->persist($duplicateNamedCandidate);
$this->entityManager->flush();
$candidate = $this->candidateRepository->getCandidateByHash($krtekSeason, 'Q2xhdWRpYQ');
$this->assertInstanceOf(Candidate::class, $candidate);
$this->assertSame($krtekSeason, $candidate->season);
$this->assertNotSame($duplicateNamedCandidate, $candidate);
}
}
+12 -9
View File
@@ -5,25 +5,28 @@ declare(strict_types=1);
namespace Tvdt\Tests\Repository;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Tvdt\Entity\Season;
use Tvdt\Repository\SeasonRepository;
#[CoversClass(SeasonRepository::class)]
final class SeasonRepositoryTest extends DatabaseTestCase
{
public function testGetSeasonsForUser(): void
/** @return iterable<string, array{string, string}> */
public static function userSeasonsProvider(): iterable
{
$user = $this->getUserByEmail('krtek-admin@example.org');
yield 'krtek admin' => ['krtek-admin@example.org', 'krtek'];
yield 'user1' => ['user1@example.org', 'bbbbb'];
}
#[DataProvider('userSeasonsProvider')]
public function testGetSeasonsForUser(string $email, string $expectedSeasonCode): void
{
$user = $this->getUserByEmail($email);
$seasons = $this->seasonRepository->getSeasonsForUser($user);
$this->assertCount(1, $seasons);
$this->assertSame('krtek', $seasons[0]->seasonCode);
$user = $this->getUserByEmail('user1@example.org');
$seasons = $this->seasonRepository->getSeasonsForUser($user);
$this->assertCount(1, $seasons);
$this->assertSame('bbbbb', $seasons[0]->seasonCode);
$this->assertSame($expectedSeasonCode, $seasons[0]->seasonCode);
}
public function testUserWithMultipleSeasons(): void
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Service;
use Doctrine\ORM\EntityManagerInterface;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz;
use Tvdt\Repository\QuizRepository;
use Tvdt\Service\DataExportService;
use Tvdt\Service\QuizSpreadsheetService;
/**
* Reproduces a Sentry warning (PHP-SYMFONY-3Z): with more than 25 questions, the last raw-answers
* column goes past 'Z' (e.g. 'AA'), and range('A', 'AA') is invalid because range()'s second argument
* must be a single byte.
*/
#[CoversClass(DataExportService::class)]
final class DataExportServiceRawAnswersColumnsTest extends TestCase
{
public function testFillRawAnswersSheetHandlesMoreThanTwentyFiveQuestions(): void
{
$subject = new DataExportService(
$this->createStub(EntityManagerInterface::class),
$this->createStub(QuizSpreadsheetService::class),
$this->createStub(QuizRepository::class),
);
$quiz = new Quiz();
for ($i = 0; $i < 26; ++$i) {
$question = new Question();
$question->question = 'Question '.($i + 1);
$quiz->addQuestion($question);
}
$sheet = new Spreadsheet()->getActiveSheet();
$method = new \ReflectionMethod(DataExportService::class, 'fillRawAnswersSheet');
$method->invoke($subject, $sheet, $quiz);
$this->assertEqualsWithDelta(30.0, $sheet->getColumnDimension('AA')->getWidth(), \PHP_FLOAT_EPSILON);
}
}