Compare commits

..

3 Commits

Author SHA1 Message Date
Marijn 07ce8f79e6 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
2026-07-10 09:27:57 +02:00
Marijn b1f68425cd 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
2026-07-10 09:09:52 +02:00
Marijn 0aa15415de 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
2026-07-10 00:14:00 +02:00
31 changed files with 1121 additions and 745 deletions
+18 -1
View File
@@ -123,6 +123,7 @@ jobs:
checks: write checks: write
pull-requests: write pull-requests: write
contents: read contents: read
code-quality: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
@@ -150,13 +151,29 @@ jobs:
- name: Load fixtures - name: Load fixtures
run: docker compose exec -T php bin/console -e test doctrine:fixtures:load --no-interaction --group=test run: docker compose exec -T php bin/console -e test doctrine:fixtures:load --no-interaction --group=test
- name: Run PHPUnit - name: Run PHPUnit
run: docker compose exec -T -e MAILER_DSN=null://null php vendor/bin/phpunit --log-junit var/phpunit/junit.xml run: docker compose exec -T -e MAILER_DSN=null://null php vendor/bin/phpunit --log-junit var/phpunit/junit.xml --coverage-cobertura var/coverage/cobertura.xml
- name: Copy test reports out of the container
# var/ is a Docker volume (see Dockerfile's VOLUME /app/var/), not bind-mounted to the
# runner, so reports written there by PHPUnit must be copied out explicitly.
if: always()
continue-on-error: true
run: |
mkdir -p var/phpunit var/coverage
docker compose cp php:/app/var/phpunit/junit.xml var/phpunit/junit.xml
docker compose cp php:/app/var/coverage/cobertura.xml var/coverage/cobertura.xml
- name: Publish PHPUnit test results - name: Publish PHPUnit test results
if: always() if: always()
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6 uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6
with: with:
report_paths: var/phpunit/junit.xml report_paths: var/phpunit/junit.xml
check_name: PHPUnit check_name: PHPUnit
- name: Upload code coverage
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: var/coverage/cobertura.xml
language: PHP
label: phpunit
- name: Doctrine Schema Validator - name: Doctrine Schema Validator
run: docker compose exec -T php bin/console -e test doctrine:schema:validate run: docker compose exec -T php bin/console -e test doctrine:schema:validate
+29 -129
View File
@@ -4,11 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
**Tijd voor de test** is a PHP/Symfony 8.1 application for managing quizzes in the style of **Wie is de Mol?** (WIDM) — **Tijd voor de test** is a PHP/Symfony 8.1 application for managing quizzes in the style of **Wie is de Mol?** (WIDM) — a Dutch TV show where contestants try to identify a saboteur ("de Mol") among them. At the end of each episode, participants take a quiz about the Mol's identity and actions; the candidate with the least correct answers is eliminated. This app replicates that quiz format with:
a Dutch TV show where contestants try to identify a saboteur ("de Mol") among them. At the end of each episode,
participants take a quiz about the Mol's identity and actions; the candidate with the least correct answers is
eliminated. This app replicates that quiz format with:
- Test creation with variable question counts - Test creation with variable question counts
- Season management with active test controls - Season management with active test controls
- Candidate answer tracking with automatic timing - Candidate answer tracking with automatic timing
@@ -16,7 +12,6 @@ eliminated. This app replicates that quiz format with:
- Backoffice management for quiz administration and statistics - Backoffice management for quiz administration and statistics
Tech Stack: Tech Stack:
- **Framework**: Symfony 8.1 - **Framework**: Symfony 8.1
- **PHP**: 8.5+ - **PHP**: 8.5+
- **Database**: PostgreSQL 16 - **Database**: PostgreSQL 16
@@ -81,7 +76,7 @@ All code quality checks run in CI/CD (.github/workflows/ci.yml) and should pass
``` ```
src/ src/
Controller/ # HTTP request handlers (attribute-routed) Controller/ # HTTP request handlers (attribute-routed)
Backoffice/ # Admin panel controllers Backoffice/ # Admin panel controllers
Entity/ # Doctrine ORM entities Entity/ # Doctrine ORM entities
Repository/ # Database queries Repository/ # Database queries
@@ -127,151 +122,59 @@ tests/
- **Elimination**: Records red/green screens and forced results with joker adjustments. - **Elimination**: Records red/green screens and forced results with joker adjustments.
- **User**: Administrative accounts for managing the system. - **User**: Administrative accounts for managing the system.
## Domain Context: "De Test" (Wie is de Mol)
**Wie is de Mol?** (WIDM) is a Dutch reality competition: a group of contestants ("kandidaten") travels together while
one of them, "de Mol", secretly sabotages assignments. Each episode ends with the fixed line: *"Tijd voor de test.
Twintig vragen over de identiteit en het doen en laten van de Mol. Degene die het minst weet, ligt uit het spel. Behalve
de Mol. Die hoeft nooit naar huis."* ("Time for the test. Twenty questions about the identity and the actions of the
Mol. Whoever knows the least is out of the game. Except the Mol — they never have to go home.") The contestant with the
worst score is eliminated ("afvallen"); the Mol is immune regardless of score, since they already know the answers. This
app is a generic engine for running that quiz format for private/fan seasons, not just modeling the TV show
incidentally — the entity model below exists specifically to reproduce WIDM's test mechanics.
### What a test's 20 questions actually are
Per the intro line, questions fall into two factual categories — never opinion ("who would you vote off") — plus a
third recurring format used on the show:
1. **Identity of the Mol**: guessing which contestant is the Mol.
2. **The Mol's actions**: what the Mol did or where the Mol was during a specific assignment/moment.
3. **Candidate self-answered questions**: earlier, every contestant privately answered a question about themselves
(an interview-style question); the test then asks other contestants to guess what a *specific* candidate answered
about themselves. This tests how well contestants know each other, not just Mol-tracking.
### Why answers can be bound to candidates
All three categories above can have contestants themselves as the answer options rather than free text: "who is the
Mol" and "who did X" both need contestant names as options, and "what did candidate Y answer" needs Y's own submitted
answer among the options. In the domain model this is `Answer::$candidates` (a `ManyToMany` to `Candidate`, on both
sides): an answer option can *be* another contestant, not just text.
Because the relationship is many-to-many on the answer side too, a single answer option can cover **more than one
candidate at once** — e.g. "Anna en Bram" as one option for "who missed the assignment together", or an option
representing everyone who gave a particular self-answer in category 3 above. So a candidate-bound answer isn't always
one candidate, it can be a group; treat `Answer::$candidates` as "the set of contestants this option represents", not
as a single foreign key.
Combined with `GivenAnswer::$candidate` (who answered), every given answer on a candidate-bound question is a directed
relationship from the answering candidate to *every* candidate covered by the chosen option — a one-to-many edge when
the option is a group, not just candidate A pointed at candidate B. This is the mechanic behind any "who's suspected of
what" or sociogram-style statistic — it only applies to candidate-bound questions, plain trivia questions have no such
relationship. `Quiz::getQuestionErrors()` already relies on this distinction to validate that every active candidate is
covered exactly once per candidate-bound question (a candidate appearing across multiple group-options on the same
question counts as covered more than once).
### Elimination mechanics
- **Red/green screens**: at the end of a test, contestants are shown red or green screens one at a time to build tension
before the elimination is revealed. `Elimination::$data` stores the colour shown per candidate (
`SCREEN_RED/SCREEN_GREEN` via `getScreenColour()`), independent of the actual quiz score.
- **Jokers / corrections**: contestants can hold a "joker" (an advantage, e.g. an extra correct answer) that adjusts
their effective score without changing what they actually answered. This is `QuizCandidate::$corrections` — a float
added to the raw score, kept separate from `GivenAnswer` so the audit trail of what was actually answered stays
untouched.
- **Dropouts**: `Quiz::$dropouts` controls how many contestants can be eliminated in a single test (normally 1, but some
episodes eliminate more).
- **Finalization/locking**: `Quiz::$isFinalized` and `$isLocked` gate when a quiz's questions/answers can still be
edited — a quiz becomes immutable once a candidate has started it or an admin explicitly finalizes it. Treat this as
the natural point where computed results (scores, statistics) can be cached indefinitely, since nothing that feeds
them can change afterward.
### Terminology map (Dutch UI ↔ domain code)
| UI/domain term (Dutch) | Code |
|------------------------------|-------------------------------|
| Test | `Quiz` |
| Vraag | `Question` |
| Antwoord | `Answer` |
| Kandidaat | `Candidate` |
| Ingevuld antwoord | `GivenAnswer` |
| Afvallen / rood-groen scherm | `Elimination` |
| Joker / correctie | `QuizCandidate::$corrections` |
## Architecture Notes ## Architecture Notes
### Routing ### Routing
- Routes are **attribute-based** (PHP 8 attributes in controller methods) - Routes are **attribute-based** (PHP 8 attributes in controller methods)
- Configured in `config/routes/attributes.yaml` for automatic discovery - Configured in `config/routes/attributes.yaml` for automatic discovery
- Main entry point: `config/routes.yaml` - Main entry point: `config/routes.yaml`
### Service Container & Dependency Injection ### Service Container & Dependency Injection
- Services in `src/` are automatically registered via PSR-4 namespace `Tvdt\` - Services in `src/` are automatically registered via PSR-4 namespace `Tvdt\`
- Exclusions: Entity, DependencyInjection, Kernel classes - Exclusions: Entity, DependencyInjection, Kernel classes
- Autowiring and autoconfiguration enabled by default - Autowiring and autoconfiguration enabled by default
- Service definitions in `config/services.yaml` - Service definitions in `config/services.yaml`
### Database & Migrations ### Database & Migrations
- PostgreSQL-based with Doctrine ORM - PostgreSQL-based with Doctrine ORM
- Migrations in `migrations/` at project root, namespace `DoctrineMigrations` (intentionally not autoloaded); generate - Migrations in `migrations/` at project root, namespace `DoctrineMigrations` (intentionally not autoloaded); generate with `bin/console make:migration`
with `bin/console make:migration`
- Test fixtures in `src/DataFixtures/` (loaded with `--group=test`) - Test fixtures in `src/DataFixtures/` (loaded with `--group=test`)
- Test database configured separately via `.env.test` - Test database configured separately via `.env.test`
### Testing Infrastructure ### Testing Infrastructure
- **PHPUnit 13** with DAMA Doctrine Test Bundle for transaction rollback - **PHPUnit 13** with DAMA Doctrine Test Bundle for transaction rollback
- Bootstrap: `tests/bootstrap.php` loads env vars and autoloader; `tests/symfony-container.php` boots the test - Bootstrap: `tests/bootstrap.php` loads env vars and autoloader; `tests/symfony-container.php` boots the test kernel/container (used by Rector)
kernel/container (used by Rector)
- Symfony test utilities (BrowserKit, CSS selectors) available - Symfony test utilities (BrowserKit, CSS selectors) available
- Coverage excluded from: `src/DataFixtures/` - Coverage excluded from: `src/DataFixtures/`
- Test environment: `APP_ENV=test` (set in phpunit.dist.xml) - Test environment: `APP_ENV=test` (set in phpunit.dist.xml)
### Testing Conventions (TDD) ### Testing Conventions (TDD)
- **Write the failing test first.** When fixing any PHP-reachable bug, write a PHPUnit test that reproduces the failure before touching the production code. Fix the code until the test passes.
- **Write the failing test first.** When fixing any PHP-reachable bug, write a PHPUnit test that reproduces the failure
before touching the production code. Fix the code until the test passes.
- Only skip a test if the bug is purely in JavaScript/frontend where PHPUnit cannot reach it. - Only skip a test if the bug is purely in JavaScript/frontend where PHPUnit cannot reach it.
- Don't write tests for trivial presentational markup (e.g. asserting a tooltip/popover attribute or a CSS class exists - Don't write tests for trivial presentational markup (e.g. asserting a tooltip/popover attribute or a CSS class exists in a template). Tests cover behavior: routing, forms, persistence, authorization.
in a template). Tests cover behavior: routing, forms, persistence, authorization. - Follow the pattern in `tests/Controller/Backoffice/` for controller/integration tests: log in, GET for CSRF token, POST form data, assert redirect, clear entity manager, assert DB state.
- Follow the pattern in `tests/Controller/Backoffice/` for controller/integration tests: log in, GET for CSRF token,
POST form data, assert redirect, clear entity manager, assert DB state.
- **Prefer `TestCase` over `WebTestCase`/`KernelTestCase`.** Reach for the full kernel/DB boot only when the test
genuinely needs routing, persistence, or the container — pure logic (services, listeners, helpers) should be tested
with plain PHPUnit `TestCase` and mocked dependencies; it's faster and more isolated.
- **Boy Scout Rule**: when you're already touching a file for an unrelated change, fix small nearby issues in the same
commit (e.g. a test that unnecessarily extends `WebTestCase`, a stale comment) rather than leaving them for later —
but don't let this balloon into an unrelated refactor.
### Code Style & Standards ### Code Style & Standards
- **PHP-CS-Fixer**: Symfony ruleset + risky rules enabled - **PHP-CS-Fixer**: Symfony ruleset + risky rules enabled
- Strict types declaration required - Strict types declaration required
- Trailing commas in multiline structures - Trailing commas in multiline structures
- No else-only blocks - No else-only blocks
- **Rector**: Aggressive modernization with all attribute sets + prepared sets (dead code, code quality, Doctrine, - **Rector**: Aggressive modernization with all attribute sets + prepared sets (dead code, code quality, Doctrine, Symfony, PHPUnit)
Symfony, PHPUnit)
- **PHPStan**: Level 8 with extensions for Doctrine and Symfony - **PHPStan**: Level 8 with extensions for Doctrine and Symfony
- **Twig-CS-Fixer**: Template style enforcement - **Twig-CS-Fixer**: Template style enforcement
- **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure — - **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure — they throw exceptions instead
they throw exceptions instead
### Environment Configuration ### Environment Configuration
- `.env` - Local development defaults (uncommitted in .env.local) - `.env` - Local development defaults (uncommitted in .env.local)
- `.env.dev` - Development overrides - `.env.dev` - Development overrides
- `.env.test` - Test environment configuration - `.env.test` - Test environment configuration
- Production uses `composer dump-env prod` for compiled configuration - Production uses `composer dump-env prod` for compiled configuration
- Key variables: - Key variables:
- `APP_ENV` - Environment (dev/test/prod) - `APP_ENV` - Environment (dev/test/prod)
- `DATABASE_URL` - PostgreSQL connection string - `DATABASE_URL` - PostgreSQL connection string
- `MAILER_SENDER` - From address for emails - `MAILER_SENDER` - From address for emails
### Frontend Build ### Frontend Build
- Asset mapper (no Node.js/Webpack) for JS/CSS bundling; JS modules declared in `importmap.php` - Asset mapper (no Node.js/Webpack) for JS/CSS bundling; JS modules declared in `importmap.php`
- **Stimulus** controllers in `assets/controllers/`, **Turbo** for SPA-like navigation - **Stimulus** controllers in `assets/controllers/`, **Turbo** for SPA-like navigation
- Sass sources in `assets/styles/`, compiled via `bin/console sass:build` - Sass sources in `assets/styles/`, compiled via `bin/console sass:build`
@@ -284,28 +187,27 @@ GitHub Actions workflow (`.github/workflows/ci.yml`):
1. **Linting**: Dockerfile (hadolint), Twig templates 1. **Linting**: Dockerfile (hadolint), Twig templates
2. **Code Quality**: 2. **Code Quality**:
- PHP-CS-Fixer style check - PHP-CS-Fixer style check
- Twig-CS-Fixer style check - Twig-CS-Fixer style check
- PHPStan static analysis - PHPStan static analysis
- Rector dry-run - Rector dry-run
3. **Integration Tests**: 3. **Integration Tests**:
- Docker image build and start services - Docker image build and start services
- Database creation and migration - Database creation and migration
- Fixture loading - Fixture loading
- Full PHPUnit test suite with JUnit XML output - Full PHPUnit test suite with JUnit XML output
- Doctrine schema validation - Doctrine schema validation
4. **Build & Deploy** (on tags or main, disabled currently): 4. **Build & Deploy** (on tags or main, disabled currently):
- Docker image push to GitHub Container Registry - Docker image push to GitHub Container Registry
- Sentry release creation - Sentry release creation
- Portainer webhook trigger for production deployment - Portainer webhook trigger for production deployment
Runs on all pushes to main and pull requests. Concurrency cancels old runs on new commits. Runs on all pushes to main and pull requests. Concurrency cancels old runs on new commits.
## Important Files & Conventions ## Important Files & Conventions
- **Kernel**: `src/Kernel.php` - Symfony kernel class - **Kernel**: `src/Kernel.php` - Symfony kernel class
- **AbstractController**: Base class for all controllers — defines route parameter regexes (`SEASON_CODE_REGEX`, - **AbstractController**: Base class for all controllers — defines route parameter regexes (`SEASON_CODE_REGEX`, `CANDIDATE_HASH_REGEX`) and flash helpers
`CANDIDATE_HASH_REGEX`) and flash helpers
- **Flash Messages**: Use `FlashType` enum instead of string literals - **Flash Messages**: Use `FlashType` enum instead of string literals
- **QuizSpreadsheetService**: Handles importing quizzes from XLSX files - **QuizSpreadsheetService**: Handles importing quizzes from XLSX files
- **Rector container**: `tests/symfony-container.php` — boots a test kernel so Rector can resolve Symfony service types - **Rector container**: `tests/symfony-container.php` — boots a test kernel so Rector can resolve Symfony service types
@@ -324,7 +226,6 @@ Runs on all pushes to main and pull requests. Concurrency cancels old runs on ne
## Composer Scripts ## Composer Scripts
Auto-executed scripts on install/update: Auto-executed scripts on install/update:
- `cache:clear` - Symfony cache clear - `cache:clear` - Symfony cache clear
- `assets:install` - Copy public assets - `assets:install` - Copy public assets
- `importmap:install` - JS import map setup - `importmap:install` - JS import map setup
@@ -343,5 +244,4 @@ When writing Dutch help content in `templates/backoffice/help/nl/`:
- The backoffice elimination logic is in `Controller/Backoffice/PrepareEliminationController.php` - The backoffice elimination logic is in `Controller/Backoffice/PrepareEliminationController.php`
- Quiz timing logic starts on candidate start click and stops on final answer selection - Quiz timing logic starts on candidate start click and stops on final answer selection
- Background music feature noted but not yet implemented (requirements only) - Background music feature noted but not yet implemented (requirements only)
- Statistics module (per-quiz statistics page, candidate accusation matrix, caching) is planned per GitHub issue #199 - Statistics functionality is marked TBD in README
see "Domain Context" above for why candidate-bound answers matter to it
@@ -1,40 +0,0 @@
import {Controller} from '@hotwired/stimulus';
const STORAGE_KEY = 'tvdt-fullscreen';
export default class extends Controller {
connect() {
this.onFullscreenChange = this.onFullscreenChange.bind(this);
document.addEventListener('fullscreenchange', this.onFullscreenChange);
this.syncState();
if (sessionStorage.getItem(STORAGE_KEY) === '1' && !document.fullscreenElement) {
this.request();
}
}
disconnect() {
document.removeEventListener('fullscreenchange', this.onFullscreenChange);
}
toggle() {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
this.request();
}
}
request() {
document.documentElement.requestFullscreen().catch(() => {});
}
onFullscreenChange() {
sessionStorage.setItem(STORAGE_KEY, document.fullscreenElement ? '1' : '0');
this.syncState();
}
syncState() {
document.documentElement.classList.toggle('is-fullscreen', Boolean(document.fullscreenElement));
}
}
-26
View File
@@ -94,32 +94,6 @@ input.btn-check:checked + label.answer-btn {
gap: 0.5rem; gap: 0.5rem;
} }
.fullscreen-btn {
position: fixed;
bottom: 0.75rem;
left: 0.75rem;
z-index: 1040;
width: 2.25rem;
height: 2.25rem;
padding: 0;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.35);
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
&:hover,
&:focus {
color: rgba(255, 255, 255, 0.8);
outline: none;
}
}
html.is-fullscreen .fullscreen-btn {
display: none;
}
.elimination-screen { .elimination-screen {
position: fixed; position: fixed;
top: 0; top: 0;
-48
View File
@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Security;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Event\LogoutEvent;
final readonly class LogoutRedirectListener implements EventSubscriberInterface
{
private const array BLOCKED_TARGET_PREFIXES = ['/backoffice', '/elimination'];
public function __construct(private UrlGeneratorInterface $urlGenerator) {}
public function onLogout(LogoutEvent $event): void
{
$target = $event->getRequest()->query->get('target');
if (\is_string($target) && $this->isAllowedTarget($target)) {
$event->setResponse(new RedirectResponse($target));
return;
}
$event->setResponse(new RedirectResponse($this->urlGenerator->generate('tvdt_quiz_select_season')));
}
public static function getSubscribedEvents(): array
{
// Must run before Symfony's DefaultLogoutListener (priority 64), which only
// sets a response if none is set yet.
return [
LogoutEvent::class => ['onLogout', 128],
];
}
private function isAllowedTarget(string $target): bool
{
if (!str_starts_with($target, '/') || str_starts_with($target, '//')) {
return false;
}
return !array_any(self::BLOCKED_TARGET_PREFIXES, static fn (string $prefix): bool => str_starts_with($target, $prefix));
}
}
-10
View File
@@ -1,13 +1,3 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block importmap %}{{ importmap('quiz') }}{% endblock %} {% block importmap %}{{ importmap('quiz') }}{% endblock %}
{% block nav %}{{ include('quiz/nav.html.twig') }}{% endblock %} {% block nav %}{{ include('quiz/nav.html.twig') }}{% endblock %}
{% block main %}
<div data-controller="fullscreen">
<button type="button"
class="fullscreen-btn"
data-action="fullscreen#toggle"
aria-label="{{ 'Fullscreen'|trans }}"
title="{{ 'Fullscreen'|trans }}">⛶</button>
{{ parent() }}
</div>
{% endblock %}
+1 -1
View File
@@ -4,7 +4,7 @@
<a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary btn-sm"> <a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary btn-sm">
{{ 'Backoffice'|trans }} {{ 'Backoffice'|trans }}
</a> </a>
<a href="{{ path('tvdt_login_logout', {target: app.request.pathInfo}) }}" class="btn btn-outline-secondary btn-sm"> <a href="{{ path('tvdt_login_logout') }}" class="btn btn-outline-secondary btn-sm">
{{ 'Logout'|trans }} {{ 'Logout'|trans }}
</a> </a>
{% else %} {% else %}
+9 -10
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Tvdt\Tests\Command; namespace Tvdt\Tests\Command;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
@@ -48,21 +49,19 @@ final class ClaimSeasonCommandTest extends KernelTestCase
$this->assertCount(3, $season->owners); $this->assertCount(3, $season->owners);
} }
public function testInvalidEmailFails(): void /** @return iterable<string, array{string, string}> */
public static function invalidArgumentsProvider(): iterable
{ {
$this->commandTester->execute([ yield 'unknown email' => ['krtek', 'nonexisting@example.org'];
'season-code' => 'krtek', yield 'unknown season' => ['dhadk', 'test@example.org'];
'email' => 'nonexisting@example.org',
]);
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
} }
public function testInvalidSeasonCodeFails(): void #[DataProvider('invalidArgumentsProvider')]
public function testInvalidArgumentFails(string $seasonCode, string $email): void
{ {
$this->commandTester->execute([ $this->commandTester->execute([
'season-code' => 'dhadk', 'season-code' => $seasonCode,
'email' => 'test@example.org', 'email' => $email,
]); ]);
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode()); $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; namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\BackofficeController; use Tvdt\Controller\Backoffice\BackofficeController;
use Tvdt\Entity\Quiz; use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
use Tvdt\Entity\User;
#[CoversClass(BackofficeController::class)] #[CoversClass(BackofficeController::class)]
final class BackofficeControllerTest extends WebTestCase final class BackofficeControllerTest extends AbstractControllerWebTestCase
{ {
public function testExportQuizFilenameIsSanitized(): void public function testExportQuizFilenameIsSanitized(): void
{ {
$client = self::createClient(); $user = $this->getUserByEmail('user2@example.org');
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
$user = $entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']);
$this->assertInstanceOf(User::class, $user);
$user->isVerified = true; $user->isVerified = true;
$entityManager->flush();
$client->loginUser($user);
$quiz = $entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1']); $this->entityManager->flush();
$this->assertInstanceOf(Quiz::class, $quiz); $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(); 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->assertStringContainsString('filename=Quiz-1.xlsx', $disposition);
$this->assertStringNotContainsString('Quiz 1.xlsx', $disposition); $this->assertStringNotContainsString('Quiz 1.xlsx', $disposition);
} }
public function testExportQuizRequiresVerifiedEmail(): void public function testExportQuizRequiresVerifiedEmail(): void
{ {
$client = self::createClient(); $user = $this->getUserByEmail('user2@example.org');
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
$user = $entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->assertFalse($user->isVerified); $this->assertFalse($user->isVerified);
$client->loginUser($user); $this->client->loginUser($user);
$quiz = $entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1']); $quiz = $this->getQuizByName('Quiz 1');
$this->assertInstanceOf(Quiz::class, $quiz);
$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)); 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; namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuestionBankController; use Tvdt\Controller\Backoffice\QuestionBankController;
use Tvdt\Entity\BankAnswer; use Tvdt\Entity\BankAnswer;
use Tvdt\Entity\BankQuestion; use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\Question; use Tvdt\Entity\Question;
use Tvdt\Entity\QuestionLabel; use Tvdt\Entity\QuestionLabel;
use Tvdt\Entity\Quiz; use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
use Tvdt\Entity\User;
#[CoversClass(QuestionBankController::class)] #[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 private function getBankQuestion(string $question): BankQuestion
{ {
$bankQuestion = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => $question]); $bankQuestion = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => $question]);
@@ -45,26 +24,9 @@ final class QuestionBankControllerTest extends WebTestCase
return $bankQuestion; 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 public function testIndexListsBankQuestions(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$this->assertResponseIsSuccessful(); $this->assertResponseIsSuccessful();
@@ -75,7 +37,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testIndexFiltersByLabel(): void public function testIndexFiltersByLabel(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$label = $this->entityManager->getRepository(QuestionLabel::class)->findOneBy(['name' => 'Locatie']); $label = $this->entityManager->getRepository(QuestionLabel::class)->findOneBy(['name' => 'Locatie']);
$this->assertInstanceOf(QuestionLabel::class, $label); $this->assertInstanceOf(QuestionLabel::class, $label);
@@ -89,9 +51,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testNonOwnerIsDenied(): void public function testNonOwnerIsDenied(): void
{ {
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']); $this->loginAs('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
@@ -100,7 +60,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testCreateBankQuestion(): void 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'); $crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
$this->assertResponseIsSuccessful(); $this->assertResponseIsSuccessful();
@@ -129,7 +89,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testCreateAllowedWithoutCorrectAnswer(): void 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'); $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'); $token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
@@ -145,6 +105,7 @@ final class QuestionBankControllerTest extends WebTestCase
]); ]);
$this->assertResponseRedirects(); $this->assertResponseRedirects();
$this->entityManager->clear();
$saved = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => 'Vraag zonder goed antwoord']); $saved = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => 'Vraag zonder goed antwoord']);
$this->assertInstanceOf(BankQuestion::class, $saved); $this->assertInstanceOf(BankQuestion::class, $saved);
$this->assertFalse($saved->isCompleteForQuiz); $this->assertFalse($saved->isCompleteForQuiz);
@@ -152,7 +113,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testEditBankQuestion(): void public function testEditBankQuestion(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?'); $bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
$url = \sprintf('/backoffice/season/krtek/question-bank/%s/edit', $bankQuestion->id); $url = \sprintf('/backoffice/season/krtek/question-bank/%s/edit', $bankQuestion->id);
@@ -181,12 +142,12 @@ final class QuestionBankControllerTest extends WebTestCase
public function testDeleteUsedBankQuestionLeavesQuizIntact(): void public function testDeleteUsedBankQuestionLeavesQuizIntact(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?'); $bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?');
$quiz2QuestionCount = $this->getQuizByName('Quiz 2')->questions->count(); $quiz2QuestionCount = $this->getQuizByName('Quiz 2')->questions->count();
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $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), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/delete', $bankQuestion->id), [
'_token' => $token, '_token' => $token,
@@ -201,13 +162,13 @@ final class QuestionBankControllerTest extends WebTestCase
public function testAssignCopiesQuestionIntoQuiz(): void public function testAssignCopiesQuestionIntoQuiz(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?'); $bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
$quiz = $this->getQuizByName('Quiz 2'); $quiz = $this->getQuizByName('Quiz 2');
$questionCount = $quiz->questions->count(); $questionCount = $quiz->questions->count();
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $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), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
'_token' => $token, '_token' => $token,
@@ -240,7 +201,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testAssignUsedNonReusableQuestionIsRefused(): void public function testAssignUsedNonReusableQuestionIsRefused(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?'); $bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?');
$quiz = $this->getQuizByName('Quiz 2'); $quiz = $this->getQuizByName('Quiz 2');
$questionCount = $quiz->questions->count(); $questionCount = $quiz->questions->count();
@@ -248,7 +209,7 @@ final class QuestionBankControllerTest extends WebTestCase
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $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 // 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), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
'_token' => $token, '_token' => $token,
'quiz' => (string) $quiz->id, 'quiz' => (string) $quiz->id,
@@ -262,13 +223,13 @@ final class QuestionBankControllerTest extends WebTestCase
public function testAssignSameReusableQuestionTwiceToSameQuizIsRefused(): void public function testAssignSameReusableQuestionTwiceToSameQuizIsRefused(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Wie is de Krtek?'); $bankQuestion = $this->getBankQuestion('Wie is de Krtek?');
$quiz = $this->getQuizByName('Quiz 2'); $quiz = $this->getQuizByName('Quiz 2');
$questionCount = $quiz->questions->count(); $questionCount = $quiz->questions->count();
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $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); $url = \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id);
$this->client->request(Request::METHOD_POST, $url, ['_token' => $token, 'quiz' => (string) $quiz->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 public function testAssignIntoFinalizedQuizIsDenied(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Wie is de Krtek?'); $bankQuestion = $this->getBankQuestion('Wie is de Krtek?');
$finalizedQuiz = $this->getQuizByName('Quiz 1'); $finalizedQuiz = $this->getQuizByName('Quiz 1');
$this->assertTrue($finalizedQuiz->isFinalized); $this->assertTrue($finalizedQuiz->isFinalized);
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $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), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
'_token' => $token, '_token' => $token,
@@ -301,7 +262,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testCreateBankQuestionPreservesAnswerOrdering(): void 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'); $crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
$this->assertResponseIsSuccessful(); $this->assertResponseIsSuccessful();
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value'); $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 public function testEditBankQuestionPreservesAnswerOrdering(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?'); $bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
// Fixture answers in insertion order (all have ordering=0): Brood (correct), Yoghurt, Niks // 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 public function testAddAndDeleteLabel(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $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'); $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->assertInstanceOf(QuestionLabel::class, $label);
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $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), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/labels/%s/delete', $label->slug), [
'_token' => $deleteToken, '_token' => $deleteToken,
@@ -4,11 +4,8 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice; namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Safe\DateTimeImmutable; use Safe\DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuizController; use Tvdt\Controller\Backoffice\QuizController;
use Tvdt\Entity\Answer; use Tvdt\Entity\Answer;
@@ -17,51 +14,21 @@ use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\Question; use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz; use Tvdt\Entity\Quiz;
use Tvdt\Entity\QuizCandidate; use Tvdt\Entity\QuizCandidate;
use Tvdt\Entity\Season; use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
use Tvdt\Entity\User;
#[CoversClass(QuizController::class)] #[CoversClass(QuizController::class)]
final class QuizControllerTest extends WebTestCase final class QuizControllerTest extends AbstractControllerWebTestCase
{ {
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void protected function setUp(): void
{ {
$this->client = self::createClient(); parent::setUp();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']); $this->loginAs('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;
} }
private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string
{ {
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id)); return $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id), $formActionContains);
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 testIndexRedirectsToOverview(): void public function testIndexRedirectsToOverview(): void
@@ -296,9 +263,7 @@ final class QuizControllerTest extends WebTestCase
public function testNonOwnerIsDenied(): void public function testNonOwnerIsDenied(): void
{ {
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']); $this->loginAs('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
$quiz = $this->getQuizByName('Quiz 1'); $quiz = $this->getQuizByName('Quiz 1');
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id)); $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 public function testOverviewLoadsForEmptyQuiz(): void
{ {
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']); $season = $this->getSeasonByCode('krtek');
$this->assertInstanceOf(Season::class, $season);
$emptyQuiz = new Quiz(); $emptyQuiz = new Quiz();
$emptyQuiz->name = 'Empty Quiz'; $emptyQuiz->name = 'Empty Quiz';
@@ -326,8 +290,7 @@ final class QuizControllerTest extends WebTestCase
public function testAnswerMappingRedirectsWithFlashWhenNoQuestions(): void public function testAnswerMappingRedirectsWithFlashWhenNoQuestions(): void
{ {
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']); $season = $this->getSeasonByCode('krtek');
$this->assertInstanceOf(Season::class, $season);
$emptyQuiz = new Quiz(); $emptyQuiz = new Quiz();
$emptyQuiz->name = 'Empty Quiz'; $emptyQuiz->name = 'Empty Quiz';
@@ -4,63 +4,29 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice; namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Safe\DateTimeImmutable; use Safe\DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuizController; use Tvdt\Controller\Backoffice\QuizController;
use Tvdt\Entity\Answer; use Tvdt\Entity\Answer;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\Question; use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz; use Tvdt\Entity\Quiz;
use Tvdt\Entity\QuizCandidate; use Tvdt\Entity\QuizCandidate;
use Tvdt\Entity\Season; use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
use Tvdt\Entity\User;
#[CoversClass(QuizController::class)] #[CoversClass(QuizController::class)]
final class QuizFinalizeTest extends WebTestCase final class QuizFinalizeTest extends AbstractControllerWebTestCase
{ {
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void protected function setUp(): void
{ {
$this->client = self::createClient(); parent::setUp();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']); $this->loginAs('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;
} }
private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string
{ {
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id)); return $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id), $formActionContains);
$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');
} }
public function testFinalizeSetsFinalizedAt(): void public function testFinalizeSetsFinalizedAt(): void
@@ -79,7 +45,7 @@ final class QuizFinalizeTest extends WebTestCase
public function testFinalizeRefusedWhenQuizHasErrors(): void public function testFinalizeRefusedWhenQuizHasErrors(): void
{ {
$season = $this->getKrtekSeason(); $season = $this->getSeasonByCode('krtek');
$invalidQuiz = new Quiz(); $invalidQuiz = new Quiz();
$invalidQuiz->name = 'Invalid Quiz'; $invalidQuiz->name = 'Invalid Quiz';
@@ -116,7 +82,7 @@ final class QuizFinalizeTest extends WebTestCase
$this->assertResponseRedirects(); $this->assertResponseRedirects();
$this->entityManager->clear(); $this->entityManager->clear();
$season = $this->getKrtekSeason(); $season = $this->getSeasonByCode('krtek');
$this->assertInstanceOf(Quiz::class, $season->activeQuiz); $this->assertInstanceOf(Quiz::class, $season->activeQuiz);
$this->assertSame('Quiz 1', $season->activeQuiz->name); $this->assertSame('Quiz 1', $season->activeQuiz->name);
} }
@@ -134,7 +100,7 @@ final class QuizFinalizeTest extends WebTestCase
$this->assertResponseRedirects(); $this->assertResponseRedirects();
$this->entityManager->clear(); $this->entityManager->clear();
$season = $this->getKrtekSeason(); $season = $this->getSeasonByCode('krtek');
$this->assertInstanceOf(Quiz::class, $season->activeQuiz); $this->assertInstanceOf(Quiz::class, $season->activeQuiz);
$this->assertSame('Quiz 2', $season->activeQuiz->name); $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 // Scrape the token before a candidate starts, since the button disappears afterwards
$token = $this->getCsrfTokenFromOverview($quiz, '/unfinalize'); $token = $this->getCsrfTokenFromOverview($quiz, '/unfinalize');
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => 'Tom']); $candidate = $this->getCandidate('Tom');
$this->assertInstanceOf(Candidate::class, $candidate);
$quizCandidate = new QuizCandidate($quiz, $candidate); $quizCandidate = new QuizCandidate($quiz, $candidate);
$quizCandidate->started = new DateTimeImmutable(); $quizCandidate->started = new DateTimeImmutable();
@@ -229,6 +194,6 @@ final class QuizFinalizeTest extends WebTestCase
self::assertResponseRedirects(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz2->id)); self::assertResponseRedirects(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz2->id));
$this->entityManager->clear(); $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; namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuizQuestionController; use Tvdt\Controller\Backoffice\QuizQuestionController;
use Tvdt\Entity\Question; use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz; use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
use Tvdt\Entity\User;
#[CoversClass(QuizQuestionController::class)] #[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 public function testEditPreservesAnswerOrdering(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$quiz = $this->getQuizByName('Quiz 2'); $quiz = $this->getQuizByName('Quiz 2');
$question = null; $question = null;
@@ -111,7 +82,7 @@ final class QuizQuestionControllerTest extends WebTestCase
public function testReorderQuestionsWithinQuiz(): void public function testReorderQuestionsWithinQuiz(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$quiz = $this->getQuizByName('Quiz 2'); $quiz = $this->getQuizByName('Quiz 2');
$originalQuestions = $quiz->questions->toArray(); $originalQuestions = $quiz->questions->toArray();
@@ -144,4 +115,43 @@ final class QuizQuestionControllerTest extends WebTestCase
$this->assertSame($originalLastId, (string) $reorderedQuestions[0]->id); $this->assertSame($originalLastId, (string) $reorderedQuestions[0]->id);
$this->assertSame($originalFirstId, (string) $reorderedQuestions[\count($reorderedQuestions) - 1]->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; namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\SeasonController; use Tvdt\Controller\Backoffice\SeasonController;
use Tvdt\Entity\Candidate; use Tvdt\Entity\Candidate;
use Tvdt\Entity\Season; use Tvdt\Entity\Season;
use Tvdt\Entity\User; use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
#[CoversClass(SeasonController::class)] #[CoversClass(SeasonController::class)]
final class SeasonControllerTest extends WebTestCase final class SeasonControllerTest extends AbstractControllerWebTestCase
{ {
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void protected function setUp(): void
{ {
$this->client = self::createClient(); parent::setUp();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']); $this->loginAs('krtek-admin@example.org');
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
} }
public function testRegenerateSeasonCodeChangesTheCode(): void public function testRegenerateSeasonCodeChangesTheCode(): void
{ {
$oldCode = 'krtek'; $oldCode = 'krtek';
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/%s/settings', $oldCode)); $token = $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/%s/settings', $oldCode), '/regenerate-code');
self::assertResponseIsSuccessful();
$token = (string) $crawler->filter('form[action*="/regenerate-code"] input[name="_token"]')->first()->attr('value');
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/%s/settings/regenerate-code', $oldCode), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/%s/settings/regenerate-code', $oldCode), [
'_token' => $token, '_token' => $token,
@@ -54,13 +41,9 @@ final class SeasonControllerTest extends WebTestCase
public function testRegenerateSeasonCodeIsDeniedForNonOwner(): void public function testRegenerateSeasonCodeIsDeniedForNonOwner(): void
{ {
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/settings'); $token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/settings', '/regenerate-code');
self::assertResponseIsSuccessful();
$token = (string) $crawler->filter('form[action*="/regenerate-code"] input[name="_token"]')->first()->attr('value');
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']); $this->loginAs('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
$this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/settings/regenerate-code', [ $this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/settings/regenerate-code', [
'_token' => $token, '_token' => $token,
@@ -69,29 +52,10 @@ final class SeasonControllerTest extends WebTestCase
self::assertResponseStatusCodeSame(403); 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 public function testRenameCandidate(): void
{ {
$candidate = $this->getCandidate('Tom'); $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), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
'_token' => $token, '_token' => $token,
@@ -109,7 +73,7 @@ final class SeasonControllerTest extends WebTestCase
public function testRenameCandidateToExistingNameShowsError(): void public function testRenameCandidateToExistingNameShowsError(): void
{ {
$candidate = $this->getCandidate('Tom'); $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), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
'_token' => $token, '_token' => $token,
@@ -128,7 +92,7 @@ final class SeasonControllerTest extends WebTestCase
{ {
$candidate = $this->getCandidate('Tom'); $candidate = $this->getCandidate('Tom');
$candidateId = $candidate->id; $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), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/delete', $candidate->id), [
'_token' => $token, '_token' => $token,
@@ -143,11 +107,9 @@ final class SeasonControllerTest extends WebTestCase
public function testRenameCandidateIsDeniedForNonOwner(): void public function testRenameCandidateIsDeniedForNonOwner(): void
{ {
$candidate = $this->getCandidate('Tom'); $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->loginAs('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
'_token' => $token, '_token' => $token,
@@ -4,11 +4,9 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice; namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Safe\DateTimeImmutable; use Safe\DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Tvdt\Controller\Backoffice\SettingsController; use Tvdt\Controller\Backoffice\SettingsController;
@@ -17,43 +15,21 @@ use Tvdt\Entity\Quiz;
use Tvdt\Entity\ResetPasswordRequest; use Tvdt\Entity\ResetPasswordRequest;
use Tvdt\Entity\Season; use Tvdt\Entity\Season;
use Tvdt\Entity\User; use Tvdt\Entity\User;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
#[CoversClass(SettingsController::class)] #[CoversClass(SettingsController::class)]
final class SettingsControllerTest extends WebTestCase final class SettingsControllerTest extends AbstractControllerWebTestCase
{ {
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void protected function setUp(): void
{ {
$this->client = self::createClient(); parent::setUp();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$this->loginAs('test@example.org'); $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 private function getCsrfTokenFromSettings(string $formActionContains): string
{ {
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/settings'); return $this->getCsrfTokenFromPage('/backoffice/settings', $formActionContains);
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 testSettingsPageLoadsAndNavContainsSettingsLink(): void public function testSettingsPageLoadsAndNavContainsSettingsLink(): void
@@ -98,7 +74,6 @@ final class SettingsControllerTest extends WebTestCase
$this->entityManager->clear(); $this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org'); $user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class); $hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($user, 'NewPass123!')); $this->assertTrue($hasher->isPasswordValid($user, 'NewPass123!'));
@@ -107,32 +82,21 @@ final class SettingsControllerTest extends WebTestCase
self::assertResponseIsSuccessful(); 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'); yield 'wrong current password' => ['wrong-password', 'NewPass123!', 'NewPass123!'];
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([ yield 'mismatched repeat' => [TestFixtures::PASSWORD, 'NewPass123!', 'SomethingElse!'];
'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));
} }
public function testChangePasswordWithMismatchedRepeatIsRejected(): void #[DataProvider('invalidPasswordChangeProvider')]
public function testChangePasswordIsRejected(string $currentPassword, string $first, string $second): void
{ {
$this->client->request(Request::METHOD_GET, '/backoffice/settings'); $this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([ $form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([
'change_user_password_form[currentPassword]' => TestFixtures::PASSWORD, 'change_user_password_form[currentPassword]' => $currentPassword,
'change_user_password_form[plainPassword][first]' => 'NewPass123!', 'change_user_password_form[plainPassword][first]' => $first,
'change_user_password_form[plainPassword][second]' => 'SomethingElse!', 'change_user_password_form[plainPassword][second]' => $second,
]); ]);
$this->client->submit($form); $this->client->submit($form);
@@ -140,7 +104,6 @@ final class SettingsControllerTest extends WebTestCase
$this->entityManager->clear(); $this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org'); $user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class); $hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($user, TestFixtures::PASSWORD)); $this->assertTrue($hasher->isPasswordValid($user, TestFixtures::PASSWORD));
} }
@@ -157,9 +120,8 @@ final class SettingsControllerTest extends WebTestCase
self::assertEmailCount(1); self::assertEmailCount(1);
$this->entityManager->clear(); $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'); $user = $this->getUserByEmail('new-address@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertFalse($user->isVerified); $this->assertFalse($user->isVerified);
// User stays logged in // User stays logged in
@@ -179,7 +141,7 @@ final class SettingsControllerTest extends WebTestCase
self::assertEmailCount(0); self::assertEmailCount(0);
$this->entityManager->clear(); $this->entityManager->clear();
$this->assertInstanceOf(User::class, $this->getUserByEmail('test@example.org')); $this->getUserByEmail('test@example.org');
} }
public function testResendConfirmationEmailSendsEmail(): void public function testResendConfirmationEmailSendsEmail(): void
@@ -200,8 +162,8 @@ final class SettingsControllerTest extends WebTestCase
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/resend-confirmation'); $token = $this->getCsrfTokenFromSettings('/backoffice/settings/resend-confirmation');
$user = $this->getUserByEmail('test@example.org'); $user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$user->isVerified = true; $user->isVerified = true;
$this->entityManager->flush(); $this->entityManager->flush();
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/settings'); $crawler = $this->client->request(Request::METHOD_GET, '/backoffice/settings');
@@ -242,7 +204,6 @@ final class SettingsControllerTest extends WebTestCase
public function testChangePasswordInvalidatesResetPasswordRequests(): void public function testChangePasswordInvalidatesResetPasswordRequests(): void
{ {
$user = $this->getUserByEmail('test@example.org'); $user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->createResetPasswordRequest($user); $this->createResetPasswordRequest($user);
$this->client->request(Request::METHOD_GET, '/backoffice/settings'); $this->client->request(Request::METHOD_GET, '/backoffice/settings');
@@ -257,14 +218,12 @@ final class SettingsControllerTest extends WebTestCase
$this->entityManager->clear(); $this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org'); $user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $user])); $this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $user]));
} }
public function testChangeEmailInvalidatesResetPasswordRequests(): void public function testChangeEmailInvalidatesResetPasswordRequests(): void
{ {
$user = $this->getUserByEmail('test@example.org'); $user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->createResetPasswordRequest($user); $this->createResetPasswordRequest($user);
$this->client->request(Request::METHOD_GET, '/backoffice/settings'); $this->client->request(Request::METHOD_GET, '/backoffice/settings');
@@ -277,7 +236,6 @@ final class SettingsControllerTest extends WebTestCase
$this->entityManager->clear(); $this->entityManager->clear();
$user = $this->getUserByEmail('new-address@example.org'); $user = $this->getUserByEmail('new-address@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $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'); self::assertResponseRedirects('/backoffice/settings');
$this->entityManager->clear(); $this->entityManager->clear();
$this->assertInstanceOf(User::class, $this->getUserByEmail('test@example.org')); $this->getUserByEmail('test@example.org');
} }
public function testDeleteAccountRemovesSoleOwnerSeasonsAndKeepsSharedSeasons(): void public function testDeleteAccountRemovesSoleOwnerSeasonsAndKeepsSharedSeasons(): void
@@ -309,7 +267,7 @@ final class SettingsControllerTest extends WebTestCase
self::assertResponseRedirects(); self::assertResponseRedirects();
$this->entityManager->clear(); $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 // Sole-owner season is removed, including its quiz
$this->assertNotInstanceOf(Season::class, $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'doomd'])); $this->assertNotInstanceOf(Season::class, $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'doomd']));
@@ -340,7 +298,7 @@ final class SettingsControllerTest extends WebTestCase
self::assertResponseRedirects(); self::assertResponseRedirects();
$this->entityManager->clear(); $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) { foreach (['krtek', 'bbbbb'] as $seasonCode) {
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => $seasonCode]); $season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => $seasonCode]);
@@ -378,7 +336,6 @@ final class SettingsControllerTest extends WebTestCase
public function testDownloadDataRequiresVerifiedEmail(): void public function testDownloadDataRequiresVerifiedEmail(): void
{ {
$user = $this->getUserByEmail('test@example.org'); $user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertFalse($user->isVerified); $this->assertFalse($user->isVerified);
$this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data'); $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 private function markUserVerified(string $email): void
{ {
$user = $this->getUserByEmail($email); $user = $this->getUserByEmail($email);
$this->assertInstanceOf(User::class, $user);
$user->isVerified = true; $user->isVerified = true;
$this->entityManager->flush(); $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; namespace Tvdt\Tests\Controller;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\KernelBrowser; use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface; use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
@@ -15,18 +13,8 @@ use Tvdt\Controller\ResetPasswordController;
use Tvdt\Entity\User; use Tvdt\Entity\User;
#[CoversClass(ResetPasswordController::class)] #[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 public function testRequestPageLoads(): void
{ {
$this->client->request(Request::METHOD_GET, '/reset-password'); $this->client->request(Request::METHOD_GET, '/reset-password');
@@ -35,22 +23,19 @@ final class ResetPasswordControllerTest extends WebTestCase
$this->assertSelectorExists('form'); $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'); yield 'unknown email' => ['unknown@example.org'];
$form = $this->client->getCrawler()->filter('form')->form([ yield 'known email' => ['test@example.org'];
'reset_password_request_form[email]' => 'unknown@example.org',
]);
$this->client->submit($form);
$this->assertResponseRedirects('/reset-password/check-email');
} }
public function testRequestWithKnownEmailRedirectsToCheckEmail(): void #[DataProvider('emailProvider')]
public function testRequestRedirectsToCheckEmail(string $email): void
{ {
$this->client->request(Request::METHOD_GET, '/reset-password'); $this->client->request(Request::METHOD_GET, '/reset-password');
$form = $this->client->getCrawler()->filter('form')->form([ $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); $this->client->submit($form);
+1 -10
View File
@@ -6,21 +6,12 @@ namespace Tvdt\Tests\Controller;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Safe\DateTimeImmutable; use Safe\DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\WellKnownController; use Tvdt\Controller\WellKnownController;
#[CoversClass(WellKnownController::class)] #[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 public function testChangePasswordRedirectsToSettings(): void
{ {
$this->client->request(Request::METHOD_GET, '/.well-known/change-password'); $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; namespace Tvdt\Tests\Helpers;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Safe\Exceptions\UrlException; use Safe\Exceptions\UrlException;
use Tvdt\Helpers\Base64; use Tvdt\Helpers\Base64;
#[CoversClass(Base64::class)]
final class Base64Test extends TestCase 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')); yield 'Marijn' => ['Marijn', 'TWFyaWpu'];
$this->assertSame('UGhpbGluZQ', Base64::base64UrlEncode('Philine')); yield 'Philine' => ['Philine', 'UGhpbGluZQ'];
yield 'byte 254' => [\chr(254), '_g'];
$this->assertSame('_g', Base64::base64UrlEncode(\chr(254))); yield 'byte 250' => [\chr(250), '-g'];
$this->assertSame('-g', Base64::base64UrlEncode(\chr(250)));
} }
public function testBase64UrlDecode(): void #[DataProvider('pairProvider')]
public function testBase64UrlEncode(string $decoded, string $encoded): void
{ {
$this->assertSame('Marijn', Base64::base64UrlDecode('TWFyaWpu')); $this->assertSame($encoded, Base64::base64UrlEncode($decoded));
$this->assertSame('Philine', Base64::base64UrlDecode('UGhpbGluZQ')); }
$this->assertSame(\chr(254), Base64::base64UrlDecode('_g')); #[DataProvider('pairProvider')]
$this->assertSame(\chr(250), Base64::base64UrlDecode('-g')); public function testBase64UrlDecode(string $decoded, string $encoded): void
{
$this->assertSame($decoded, Base64::base64UrlDecode($encoded));
} }
public function testBase64UrlDecodeCanHandlePadding(): void public function testBase64UrlDecodeCanHandlePadding(): void
+17 -27
View File
@@ -4,41 +4,31 @@ declare(strict_types=1);
namespace Tvdt\Tests\Helpers; namespace Tvdt\Tests\Helpers;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Tvdt\Helpers\FilenameSanitizer; use Tvdt\Helpers\FilenameSanitizer;
#[CoversClass(FilenameSanitizer::class)]
final class FilenameSanitizerTest extends TestCase 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($expected, FilenameSanitizer::sanitize($input));
$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('///'));
} }
} }
@@ -53,4 +53,22 @@ final class CandidateRepositoryTest extends DatabaseTestCase
); );
$this->assertNotInstanceOf(Candidate::class, $result); $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; namespace Tvdt\Tests\Repository;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Tvdt\Entity\Season; use Tvdt\Entity\Season;
use Tvdt\Repository\SeasonRepository; use Tvdt\Repository\SeasonRepository;
#[CoversClass(SeasonRepository::class)] #[CoversClass(SeasonRepository::class)]
final class SeasonRepositoryTest extends DatabaseTestCase 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); $seasons = $this->seasonRepository->getSeasonsForUser($user);
$this->assertCount(1, $seasons); $this->assertCount(1, $seasons);
$this->assertSame('krtek', $seasons[0]->seasonCode); $this->assertSame($expectedSeasonCode, $seasons[0]->seasonCode);
$user = $this->getUserByEmail('user1@example.org');
$seasons = $this->seasonRepository->getSeasonsForUser($user);
$this->assertCount(1, $seasons);
$this->assertSame('bbbbb', $seasons[0]->seasonCode);
} }
public function testUserWithMultipleSeasons(): void public function testUserWithMultipleSeasons(): void
@@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Security;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Event\LogoutEvent;
use Tvdt\Security\LogoutRedirectListener;
#[CoversClass(LogoutRedirectListener::class)]
final class LogoutRedirectListenerTest extends TestCase
{
public function testLogoutRedirectsBackToTheGivenTarget(): void
{
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->never())->method('generate');
$listener = new LogoutRedirectListener($urlGenerator);
$event = new LogoutEvent(Request::create('/logout?target=/krtek'), null);
$listener->onLogout($event);
$this->assertSame('/krtek', $event->getResponse()?->headers->get('Location'));
}
public function testLogoutWithoutTargetRedirectsToSeasonSelect(): void
{
$listener = new LogoutRedirectListener($this->seasonSelectUrlGenerator());
$event = new LogoutEvent(Request::create('/logout'), null);
$listener->onLogout($event);
$this->assertSame('/', $event->getResponse()?->headers->get('Location'));
}
#[DataProvider('blockedTargetProvider')]
public function testLogoutIgnoresBlockedOrUnsafeTargets(string $target): void
{
$listener = new LogoutRedirectListener($this->seasonSelectUrlGenerator());
$event = new LogoutEvent(Request::create('/logout?target='.urlencode($target)), null);
$listener->onLogout($event);
$this->assertSame('/', $event->getResponse()?->headers->get('Location'));
}
/** @return iterable<string, array{string}> */
public static function blockedTargetProvider(): iterable
{
yield 'backoffice page' => ['/backoffice/season/krtek'];
yield 'elimination page' => ['/elimination/00000000-0000-0000-0000-000000000000'];
yield 'protocol-relative url' => ['//evil.example.org'];
yield 'absolute url' => ['https://evil.example.org'];
}
private function seasonSelectUrlGenerator(): UrlGeneratorInterface&MockObject
{
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects($this->once())
->method('generate')
->with('tvdt_quiz_select_season')
->willReturn('/');
return $urlGenerator;
}
}
-4
View File
@@ -369,10 +369,6 @@
<source>Forgot your password?</source> <source>Forgot your password?</source>
<target>Wachtwoord vergeten?</target> <target>Wachtwoord vergeten?</target>
</trans-unit> </trans-unit>
<trans-unit id="3cWWP_q" resname="Fullscreen">
<source>Fullscreen</source>
<target>Volledig scherm</target>
</trans-unit>
<trans-unit id="MebBrmp" resname="Gray"> <trans-unit id="MebBrmp" resname="Gray">
<source>Gray</source> <source>Gray</source>
<target>Grijs</target> <target>Grijs</target>