mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-10 09:30:14 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c721b148d0 | |||
| 5e7028c972 |
@@ -123,7 +123,6 @@ jobs:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
code-quality: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
@@ -151,29 +150,13 @@ jobs:
|
||||
- name: Load fixtures
|
||||
run: docker compose exec -T php bin/console -e test doctrine:fixtures:load --no-interaction --group=test
|
||||
- name: Run PHPUnit
|
||||
run: docker compose exec -T -e MAILER_DSN=null://null php vendor/bin/phpunit --log-junit var/phpunit/junit.xml --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
|
||||
run: docker compose exec -T -e MAILER_DSN=null://null php vendor/bin/phpunit --log-junit var/phpunit/junit.xml
|
||||
- name: Publish PHPUnit test results
|
||||
if: always()
|
||||
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6
|
||||
with:
|
||||
report_paths: var/phpunit/junit.xml
|
||||
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
|
||||
run: docker compose exec -T php bin/console -e test doctrine:schema:validate
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## 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) — 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:
|
||||
**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:
|
||||
|
||||
- Test creation with variable question counts
|
||||
- Season management with active test controls
|
||||
- Candidate answer tracking with automatic timing
|
||||
@@ -12,6 +16,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- Backoffice management for quiz administration and statistics
|
||||
|
||||
Tech Stack:
|
||||
|
||||
- **Framework**: Symfony 8.1
|
||||
- **PHP**: 8.5+
|
||||
- **Database**: PostgreSQL 16
|
||||
@@ -76,7 +81,7 @@ All code quality checks run in CI/CD (.github/workflows/ci.yml) and should pass
|
||||
|
||||
```
|
||||
src/
|
||||
Controller/ # HTTP request handlers (attribute-routed)
|
||||
Controller/ # HTTP request handlers (attribute-routed)
|
||||
Backoffice/ # Admin panel controllers
|
||||
Entity/ # Doctrine ORM entities
|
||||
Repository/ # Database queries
|
||||
@@ -122,59 +127,151 @@ tests/
|
||||
- **Elimination**: Records red/green screens and forced results with joker adjustments.
|
||||
- **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
|
||||
|
||||
### Routing
|
||||
|
||||
- Routes are **attribute-based** (PHP 8 attributes in controller methods)
|
||||
- Configured in `config/routes/attributes.yaml` for automatic discovery
|
||||
- Main entry point: `config/routes.yaml`
|
||||
|
||||
### Service Container & Dependency Injection
|
||||
|
||||
- Services in `src/` are automatically registered via PSR-4 namespace `Tvdt\`
|
||||
- Exclusions: Entity, DependencyInjection, Kernel classes
|
||||
- Autowiring and autoconfiguration enabled by default
|
||||
- Service definitions in `config/services.yaml`
|
||||
|
||||
### Database & Migrations
|
||||
|
||||
- PostgreSQL-based with Doctrine ORM
|
||||
- Migrations in `migrations/` at project root, namespace `DoctrineMigrations` (intentionally not autoloaded); generate with `bin/console make:migration`
|
||||
- Migrations in `migrations/` at project root, namespace `DoctrineMigrations` (intentionally not autoloaded); generate
|
||||
with `bin/console make:migration`
|
||||
- Test fixtures in `src/DataFixtures/` (loaded with `--group=test`)
|
||||
- Test database configured separately via `.env.test`
|
||||
|
||||
### Testing Infrastructure
|
||||
|
||||
- **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 kernel/container (used by Rector)
|
||||
- Bootstrap: `tests/bootstrap.php` loads env vars and autoloader; `tests/symfony-container.php` boots the test
|
||||
kernel/container (used by Rector)
|
||||
- Symfony test utilities (BrowserKit, CSS selectors) available
|
||||
- Coverage excluded from: `src/DataFixtures/`
|
||||
- Test environment: `APP_ENV=test` (set in phpunit.dist.xml)
|
||||
|
||||
### 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
- **PHP-CS-Fixer**: Symfony ruleset + risky rules enabled
|
||||
- Strict types declaration required
|
||||
- Trailing commas in multiline structures
|
||||
- No else-only blocks
|
||||
- **Rector**: Aggressive modernization with all attribute sets + prepared sets (dead code, code quality, Doctrine, Symfony, PHPUnit)
|
||||
- Strict types declaration required
|
||||
- Trailing commas in multiline structures
|
||||
- No else-only blocks
|
||||
- **Rector**: Aggressive modernization with all attribute sets + prepared sets (dead code, code quality, Doctrine,
|
||||
Symfony, PHPUnit)
|
||||
- **PHPStan**: Level 8 with extensions for Doctrine and Symfony
|
||||
- **Twig-CS-Fixer**: Template style enforcement
|
||||
- **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure — they throw exceptions instead
|
||||
- **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure —
|
||||
they throw exceptions instead
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
- `.env` - Local development defaults (uncommitted in .env.local)
|
||||
- `.env.dev` - Development overrides
|
||||
- `.env.test` - Test environment configuration
|
||||
- Production uses `composer dump-env prod` for compiled configuration
|
||||
- Key variables:
|
||||
- `APP_ENV` - Environment (dev/test/prod)
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `MAILER_SENDER` - From address for emails
|
||||
- `APP_ENV` - Environment (dev/test/prod)
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `MAILER_SENDER` - From address for emails
|
||||
|
||||
### Frontend Build
|
||||
|
||||
- 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
|
||||
- Sass sources in `assets/styles/`, compiled via `bin/console sass:build`
|
||||
@@ -187,27 +284,28 @@ GitHub Actions workflow (`.github/workflows/ci.yml`):
|
||||
|
||||
1. **Linting**: Dockerfile (hadolint), Twig templates
|
||||
2. **Code Quality**:
|
||||
- PHP-CS-Fixer style check
|
||||
- Twig-CS-Fixer style check
|
||||
- PHPStan static analysis
|
||||
- Rector dry-run
|
||||
- PHP-CS-Fixer style check
|
||||
- Twig-CS-Fixer style check
|
||||
- PHPStan static analysis
|
||||
- Rector dry-run
|
||||
3. **Integration Tests**:
|
||||
- Docker image build and start services
|
||||
- Database creation and migration
|
||||
- Fixture loading
|
||||
- Full PHPUnit test suite with JUnit XML output
|
||||
- Doctrine schema validation
|
||||
- Docker image build and start services
|
||||
- Database creation and migration
|
||||
- Fixture loading
|
||||
- Full PHPUnit test suite with JUnit XML output
|
||||
- Doctrine schema validation
|
||||
4. **Build & Deploy** (on tags or main, disabled currently):
|
||||
- Docker image push to GitHub Container Registry
|
||||
- Sentry release creation
|
||||
- Portainer webhook trigger for production deployment
|
||||
- Docker image push to GitHub Container Registry
|
||||
- Sentry release creation
|
||||
- Portainer webhook trigger for production deployment
|
||||
|
||||
Runs on all pushes to main and pull requests. Concurrency cancels old runs on new commits.
|
||||
|
||||
## Important Files & Conventions
|
||||
|
||||
- **Kernel**: `src/Kernel.php` - Symfony kernel class
|
||||
- **AbstractController**: Base class for all controllers — defines route parameter regexes (`SEASON_CODE_REGEX`, `CANDIDATE_HASH_REGEX`) and flash helpers
|
||||
- **AbstractController**: Base class for all controllers — defines route parameter regexes (`SEASON_CODE_REGEX`,
|
||||
`CANDIDATE_HASH_REGEX`) and flash helpers
|
||||
- **Flash Messages**: Use `FlashType` enum instead of string literals
|
||||
- **QuizSpreadsheetService**: Handles importing quizzes from XLSX files
|
||||
- **Rector container**: `tests/symfony-container.php` — boots a test kernel so Rector can resolve Symfony service types
|
||||
@@ -226,6 +324,7 @@ Runs on all pushes to main and pull requests. Concurrency cancels old runs on ne
|
||||
## Composer Scripts
|
||||
|
||||
Auto-executed scripts on install/update:
|
||||
|
||||
- `cache:clear` - Symfony cache clear
|
||||
- `assets:install` - Copy public assets
|
||||
- `importmap:install` - JS import map setup
|
||||
@@ -244,4 +343,5 @@ When writing Dutch help content in `templates/backoffice/help/nl/`:
|
||||
- The backoffice elimination logic is in `Controller/Backoffice/PrepareEliminationController.php`
|
||||
- Quiz timing logic starts on candidate start click and stops on final answer selection
|
||||
- Background music feature noted but not yet implemented (requirements only)
|
||||
- Statistics functionality is marked TBD in README
|
||||
- Statistics module (per-quiz statistics page, candidate accusation matrix, caching) is planned per GitHub issue #199 —
|
||||
see "Domain Context" above for why candidate-bound answers matter to it
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,32 @@ input.btn-check:checked + label.answer-btn {
|
||||
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 {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
||||
Generated
+14
-14
@@ -11070,16 +11070,16 @@
|
||||
},
|
||||
{
|
||||
"name": "rector/rector",
|
||||
"version": "2.5.4",
|
||||
"version": "2.5.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/rectorphp/rector.git",
|
||||
"reference": "adaa18d7cd6b3c960967cfbc98c03efb3116ac0e"
|
||||
"reference": "9718a72e7f1aacacbdcb6eeed07a47147bce802e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/adaa18d7cd6b3c960967cfbc98c03efb3116ac0e",
|
||||
"reference": "adaa18d7cd6b3c960967cfbc98c03efb3116ac0e",
|
||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/9718a72e7f1aacacbdcb6eeed07a47147bce802e",
|
||||
"reference": "9718a72e7f1aacacbdcb6eeed07a47147bce802e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -11118,7 +11118,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/rectorphp/rector/issues",
|
||||
"source": "https://github.com/rectorphp/rector/tree/2.5.4"
|
||||
"source": "https://github.com/rectorphp/rector/tree/2.5.5"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -11126,7 +11126,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-07-06T12:41:46+00:00"
|
||||
"time": "2026-07-09T09:48:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/cli-parser",
|
||||
@@ -11818,24 +11818,24 @@
|
||||
},
|
||||
{
|
||||
"name": "sebastian/lines-of-code",
|
||||
"version": "5.0.1",
|
||||
"version": "5.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/lines-of-code.git",
|
||||
"reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7"
|
||||
"reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d2cff273a90c79b0eb590baa682d4b5c318bdbb7",
|
||||
"reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d1b6f8fce682505dbd048977f1abedf1b8ad3ff8",
|
||||
"reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"nikic/php-parser": "^5.7.0",
|
||||
"nikic/php-parser": "^5.8.0",
|
||||
"php": ">=8.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^13.1.10"
|
||||
"phpunit/phpunit": "^13.2.4"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
@@ -11864,7 +11864,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
|
||||
"security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.1"
|
||||
"source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -11884,7 +11884,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-19T16:23:37+00:00"
|
||||
"time": "2026-07-09T08:42:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/object-enumerator",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,13 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block importmap %}{{ importmap('quiz') }}{% 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 %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary btn-sm">
|
||||
{{ 'Backoffice'|trans }}
|
||||
</a>
|
||||
<a href="{{ path('tvdt_login_logout') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<a href="{{ path('tvdt_login_logout', {target: app.request.pathInfo}) }}" class="btn btn-outline-secondary btn-sm">
|
||||
{{ 'Logout'|trans }}
|
||||
</a>
|
||||
{% else %}
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace Tvdt\Tests\Command;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
@@ -49,19 +48,21 @@ final class ClaimSeasonCommandTest extends KernelTestCase
|
||||
$this->assertCount(3, $season->owners);
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, string}> */
|
||||
public static function invalidArgumentsProvider(): iterable
|
||||
{
|
||||
yield 'unknown email' => ['krtek', 'nonexisting@example.org'];
|
||||
yield 'unknown season' => ['dhadk', 'test@example.org'];
|
||||
}
|
||||
|
||||
#[DataProvider('invalidArgumentsProvider')]
|
||||
public function testInvalidArgumentFails(string $seasonCode, string $email): void
|
||||
public function testInvalidEmailFails(): void
|
||||
{
|
||||
$this->commandTester->execute([
|
||||
'season-code' => $seasonCode,
|
||||
'email' => $email,
|
||||
'season-code' => 'krtek',
|
||||
'email' => 'nonexisting@example.org',
|
||||
]);
|
||||
|
||||
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testInvalidSeasonCodeFails(): void
|
||||
{
|
||||
$this->commandTester->execute([
|
||||
'season-code' => 'dhadk',
|
||||
'email' => 'test@example.org',
|
||||
]);
|
||||
|
||||
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
<?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,53 +4,54 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Controller\Backoffice\BackofficeController;
|
||||
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
#[CoversClass(BackofficeController::class)]
|
||||
final class BackofficeControllerTest extends AbstractControllerWebTestCase
|
||||
final class BackofficeControllerTest extends WebTestCase
|
||||
{
|
||||
public function testExportQuizFilenameIsSanitized(): void
|
||||
{
|
||||
$user = $this->getUserByEmail('user2@example.org');
|
||||
$client = self::createClient();
|
||||
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$user = $entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$user->isVerified = true;
|
||||
$entityManager->flush();
|
||||
$client->loginUser($user);
|
||||
|
||||
$this->entityManager->flush();
|
||||
$this->client->loginUser($user);
|
||||
$quiz = $entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1']);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
|
||||
$quiz = $this->getQuizByName('Quiz 1');
|
||||
|
||||
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
|
||||
$client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$disposition = (string) $this->client->getResponse()->headers->get('Content-Disposition');
|
||||
$disposition = (string) $client->getResponse()->headers->get('Content-Disposition');
|
||||
$this->assertStringContainsString('filename=Quiz-1.xlsx', $disposition);
|
||||
$this->assertStringNotContainsString('Quiz 1.xlsx', $disposition);
|
||||
}
|
||||
|
||||
public function testExportQuizRequiresVerifiedEmail(): void
|
||||
{
|
||||
$user = $this->getUserByEmail('user2@example.org');
|
||||
$client = self::createClient();
|
||||
$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->client->loginUser($user);
|
||||
$client->loginUser($user);
|
||||
|
||||
$quiz = $this->getQuizByName('Quiz 1');
|
||||
$quiz = $entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1']);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
|
||||
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
|
||||
$client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
|
||||
|
||||
self::assertResponseRedirects(\sprintf('/backoffice/season/%s', $quiz->season->seasonCode));
|
||||
}
|
||||
|
||||
public function testExportQuizIsDeniedForNonOwner(): void
|
||||
{
|
||||
$this->loginAs('test@example.org');
|
||||
|
||||
$quiz = $this->getQuizByName('Quiz 1');
|
||||
|
||||
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
<?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,18 +4,39 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Controller\Backoffice\QuestionBankController;
|
||||
use Tvdt\Entity\BankAnswer;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
#[CoversClass(QuestionBankController::class)]
|
||||
final class QuestionBankControllerTest extends AbstractControllerWebTestCase
|
||||
final class QuestionBankControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = self::createClient();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
}
|
||||
|
||||
private function loginAsOwner(): void
|
||||
{
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
}
|
||||
|
||||
private function getBankQuestion(string $question): BankQuestion
|
||||
{
|
||||
$bankQuestion = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => $question]);
|
||||
@@ -24,9 +45,26 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
|
||||
return $bankQuestion;
|
||||
}
|
||||
|
||||
private function getQuizByName(string $name): Quiz
|
||||
{
|
||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
|
||||
return $quiz;
|
||||
}
|
||||
|
||||
private function getCsrfToken(string $formActionContains): string
|
||||
{
|
||||
$crawler = $this->client->getCrawler();
|
||||
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
|
||||
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
|
||||
|
||||
return (string) $input->first()->attr('value');
|
||||
}
|
||||
|
||||
public function testIndexListsBankQuestions(): void
|
||||
{
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$this->loginAsOwner();
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
@@ -37,7 +75,7 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
|
||||
|
||||
public function testIndexFiltersByLabel(): void
|
||||
{
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$this->loginAsOwner();
|
||||
$label = $this->entityManager->getRepository(QuestionLabel::class)->findOneBy(['name' => 'Locatie']);
|
||||
$this->assertInstanceOf(QuestionLabel::class, $label);
|
||||
|
||||
@@ -51,7 +89,9 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
|
||||
|
||||
public function testNonOwnerIsDenied(): void
|
||||
{
|
||||
$this->loginAs('test@example.org');
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
|
||||
@@ -60,7 +100,7 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
|
||||
|
||||
public function testCreateBankQuestion(): void
|
||||
{
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$this->loginAsOwner();
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
@@ -89,7 +129,7 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
|
||||
|
||||
public function testCreateAllowedWithoutCorrectAnswer(): void
|
||||
{
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$this->loginAsOwner();
|
||||
$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');
|
||||
|
||||
@@ -105,7 +145,6 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
|
||||
]);
|
||||
|
||||
$this->assertResponseRedirects();
|
||||
$this->entityManager->clear();
|
||||
$saved = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => 'Vraag zonder goed antwoord']);
|
||||
$this->assertInstanceOf(BankQuestion::class, $saved);
|
||||
$this->assertFalse($saved->isCompleteForQuiz);
|
||||
@@ -113,7 +152,7 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
|
||||
|
||||
public function testEditBankQuestion(): void
|
||||
{
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$this->loginAsOwner();
|
||||
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
|
||||
|
||||
$url = \sprintf('/backoffice/season/krtek/question-bank/%s/edit', $bankQuestion->id);
|
||||
@@ -142,12 +181,12 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
|
||||
|
||||
public function testDeleteUsedBankQuestionLeavesQuizIntact(): void
|
||||
{
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$this->loginAsOwner();
|
||||
$bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?');
|
||||
$quiz2QuestionCount = $this->getQuizByName('Quiz 2')->questions->count();
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
$token = $this->getCsrfTokenFromCurrentPage(\sprintf('%s/delete', $bankQuestion->id));
|
||||
$token = $this->getCsrfToken(\sprintf('%s/delete', $bankQuestion->id));
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/delete', $bankQuestion->id), [
|
||||
'_token' => $token,
|
||||
@@ -162,13 +201,13 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
|
||||
|
||||
public function testAssignCopiesQuestionIntoQuiz(): void
|
||||
{
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$this->loginAsOwner();
|
||||
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$questionCount = $quiz->questions->count();
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
$token = $this->getCsrfTokenFromCurrentPage(\sprintf('%s/assign', $bankQuestion->id));
|
||||
$token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id));
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
|
||||
'_token' => $token,
|
||||
@@ -201,7 +240,7 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
|
||||
|
||||
public function testAssignUsedNonReusableQuestionIsRefused(): void
|
||||
{
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$this->loginAsOwner();
|
||||
$bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?');
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$questionCount = $quiz->questions->count();
|
||||
@@ -209,7 +248,7 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
|
||||
// The assign form is not rendered for used questions, so post with another form's token
|
||||
$token = $this->getCsrfTokenFromCurrentPage('/assign');
|
||||
$token = $this->getCsrfToken('/assign');
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
|
||||
'_token' => $token,
|
||||
'quiz' => (string) $quiz->id,
|
||||
@@ -223,13 +262,13 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
|
||||
|
||||
public function testAssignSameReusableQuestionTwiceToSameQuizIsRefused(): void
|
||||
{
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$this->loginAsOwner();
|
||||
$bankQuestion = $this->getBankQuestion('Wie is de Krtek?');
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$questionCount = $quiz->questions->count();
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
$token = $this->getCsrfTokenFromCurrentPage(\sprintf('%s/assign', $bankQuestion->id));
|
||||
$token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id));
|
||||
|
||||
$url = \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id);
|
||||
$this->client->request(Request::METHOD_POST, $url, ['_token' => $token, 'quiz' => (string) $quiz->id]);
|
||||
@@ -244,13 +283,13 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
|
||||
|
||||
public function testAssignIntoFinalizedQuizIsDenied(): void
|
||||
{
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$this->loginAsOwner();
|
||||
$bankQuestion = $this->getBankQuestion('Wie is de Krtek?');
|
||||
$finalizedQuiz = $this->getQuizByName('Quiz 1');
|
||||
$this->assertTrue($finalizedQuiz->isFinalized);
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
$token = $this->getCsrfTokenFromCurrentPage(\sprintf('%s/assign', $bankQuestion->id));
|
||||
$token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id));
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
|
||||
'_token' => $token,
|
||||
@@ -262,7 +301,7 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
|
||||
|
||||
public function testCreateBankQuestionPreservesAnswerOrdering(): void
|
||||
{
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$this->loginAsOwner();
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
|
||||
$this->assertResponseIsSuccessful();
|
||||
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
|
||||
@@ -295,7 +334,7 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
|
||||
|
||||
public function testEditBankQuestionPreservesAnswerOrdering(): void
|
||||
{
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$this->loginAsOwner();
|
||||
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
|
||||
// Fixture answers in insertion order (all have ordering=0): Brood (correct), Yoghurt, Niks
|
||||
|
||||
@@ -335,7 +374,7 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
|
||||
|
||||
public function testAddAndDeleteLabel(): void
|
||||
{
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$this->loginAsOwner();
|
||||
$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');
|
||||
|
||||
@@ -350,7 +389,7 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
|
||||
$this->assertInstanceOf(QuestionLabel::class, $label);
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
$deleteToken = $this->getCsrfTokenFromCurrentPage(\sprintf('labels/%s/delete', $label->slug));
|
||||
$deleteToken = $this->getCsrfToken(\sprintf('labels/%s/delete', $label->slug));
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/labels/%s/delete', $label->slug), [
|
||||
'_token' => $deleteToken,
|
||||
|
||||
@@ -4,8 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Controller\Backoffice\QuizController;
|
||||
use Tvdt\Entity\Answer;
|
||||
@@ -14,21 +17,51 @@ use Tvdt\Entity\GivenAnswer;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\QuizCandidate;
|
||||
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
#[CoversClass(QuizController::class)]
|
||||
final class QuizControllerTest extends AbstractControllerWebTestCase
|
||||
final class QuizControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->client = self::createClient();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
}
|
||||
|
||||
private function getQuizByName(string $name): Quiz
|
||||
{
|
||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
|
||||
return $quiz;
|
||||
}
|
||||
|
||||
private function getCandidate(string $name): Candidate
|
||||
{
|
||||
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => $name]);
|
||||
$this->assertInstanceOf(Candidate::class, $candidate);
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string
|
||||
{
|
||||
return $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id), $formActionContains);
|
||||
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
|
||||
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
|
||||
|
||||
return (string) $input->first()->attr('value');
|
||||
}
|
||||
|
||||
public function testIndexRedirectsToOverview(): void
|
||||
@@ -263,7 +296,9 @@ final class QuizControllerTest extends AbstractControllerWebTestCase
|
||||
|
||||
public function testNonOwnerIsDenied(): void
|
||||
{
|
||||
$this->loginAs('test@example.org');
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
|
||||
$quiz = $this->getQuizByName('Quiz 1');
|
||||
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
|
||||
@@ -273,7 +308,8 @@ final class QuizControllerTest extends AbstractControllerWebTestCase
|
||||
|
||||
public function testOverviewLoadsForEmptyQuiz(): void
|
||||
{
|
||||
$season = $this->getSeasonByCode('krtek');
|
||||
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
|
||||
$this->assertInstanceOf(Season::class, $season);
|
||||
|
||||
$emptyQuiz = new Quiz();
|
||||
$emptyQuiz->name = 'Empty Quiz';
|
||||
@@ -290,7 +326,8 @@ final class QuizControllerTest extends AbstractControllerWebTestCase
|
||||
|
||||
public function testAnswerMappingRedirectsWithFlashWhenNoQuestions(): void
|
||||
{
|
||||
$season = $this->getSeasonByCode('krtek');
|
||||
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
|
||||
$this->assertInstanceOf(Season::class, $season);
|
||||
|
||||
$emptyQuiz = new Quiz();
|
||||
$emptyQuiz->name = 'Empty Quiz';
|
||||
|
||||
@@ -4,29 +4,63 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Controller\Backoffice\QuizController;
|
||||
use Tvdt\Entity\Answer;
|
||||
use Tvdt\Entity\Candidate;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\QuizCandidate;
|
||||
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
#[CoversClass(QuizController::class)]
|
||||
final class QuizFinalizeTest extends AbstractControllerWebTestCase
|
||||
final class QuizFinalizeTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->client = self::createClient();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
}
|
||||
|
||||
private function getQuizByName(string $name): Quiz
|
||||
{
|
||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
|
||||
return $quiz;
|
||||
}
|
||||
|
||||
private function getKrtekSeason(): Season
|
||||
{
|
||||
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
|
||||
$this->assertInstanceOf(Season::class, $season);
|
||||
|
||||
return $season;
|
||||
}
|
||||
|
||||
private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string
|
||||
{
|
||||
return $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id), $formActionContains);
|
||||
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
|
||||
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
|
||||
|
||||
return (string) $input->first()->attr('value');
|
||||
}
|
||||
|
||||
public function testFinalizeSetsFinalizedAt(): void
|
||||
@@ -45,7 +79,7 @@ final class QuizFinalizeTest extends AbstractControllerWebTestCase
|
||||
|
||||
public function testFinalizeRefusedWhenQuizHasErrors(): void
|
||||
{
|
||||
$season = $this->getSeasonByCode('krtek');
|
||||
$season = $this->getKrtekSeason();
|
||||
|
||||
$invalidQuiz = new Quiz();
|
||||
$invalidQuiz->name = 'Invalid Quiz';
|
||||
@@ -82,7 +116,7 @@ final class QuizFinalizeTest extends AbstractControllerWebTestCase
|
||||
$this->assertResponseRedirects();
|
||||
|
||||
$this->entityManager->clear();
|
||||
$season = $this->getSeasonByCode('krtek');
|
||||
$season = $this->getKrtekSeason();
|
||||
$this->assertInstanceOf(Quiz::class, $season->activeQuiz);
|
||||
$this->assertSame('Quiz 1', $season->activeQuiz->name);
|
||||
}
|
||||
@@ -100,7 +134,7 @@ final class QuizFinalizeTest extends AbstractControllerWebTestCase
|
||||
$this->assertResponseRedirects();
|
||||
|
||||
$this->entityManager->clear();
|
||||
$season = $this->getSeasonByCode('krtek');
|
||||
$season = $this->getKrtekSeason();
|
||||
$this->assertInstanceOf(Quiz::class, $season->activeQuiz);
|
||||
$this->assertSame('Quiz 2', $season->activeQuiz->name);
|
||||
}
|
||||
@@ -149,7 +183,8 @@ final class QuizFinalizeTest extends AbstractControllerWebTestCase
|
||||
// Scrape the token before a candidate starts, since the button disappears afterwards
|
||||
$token = $this->getCsrfTokenFromOverview($quiz, '/unfinalize');
|
||||
|
||||
$candidate = $this->getCandidate('Tom');
|
||||
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => 'Tom']);
|
||||
$this->assertInstanceOf(Candidate::class, $candidate);
|
||||
$quizCandidate = new QuizCandidate($quiz, $candidate);
|
||||
$quizCandidate->started = new DateTimeImmutable();
|
||||
|
||||
@@ -194,6 +229,6 @@ final class QuizFinalizeTest extends AbstractControllerWebTestCase
|
||||
self::assertResponseRedirects(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz2->id));
|
||||
|
||||
$this->entityManager->clear();
|
||||
$this->assertNotInstanceOf(Quiz::class, $this->getSeasonByCode('krtek')->activeQuiz);
|
||||
$this->assertNotInstanceOf(Quiz::class, $this->getKrtekSeason()->activeQuiz);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,18 +4,47 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Controller\Backoffice\QuizQuestionController;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
#[CoversClass(QuizQuestionController::class)]
|
||||
final class QuizQuestionControllerTest extends AbstractControllerWebTestCase
|
||||
final class QuizQuestionControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = self::createClient();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
}
|
||||
|
||||
private function loginAsOwner(): void
|
||||
{
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
}
|
||||
|
||||
private function getQuizByName(string $name): Quiz
|
||||
{
|
||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
|
||||
return $quiz;
|
||||
}
|
||||
|
||||
public function testEditPreservesAnswerOrdering(): void
|
||||
{
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$this->loginAsOwner();
|
||||
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$question = null;
|
||||
@@ -82,7 +111,7 @@ final class QuizQuestionControllerTest extends AbstractControllerWebTestCase
|
||||
|
||||
public function testReorderQuestionsWithinQuiz(): void
|
||||
{
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$this->loginAsOwner();
|
||||
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$originalQuestions = $quiz->questions->toArray();
|
||||
@@ -115,43 +144,4 @@ final class QuizQuestionControllerTest extends AbstractControllerWebTestCase
|
||||
$this->assertSame($originalLastId, (string) $reorderedQuestions[0]->id);
|
||||
$this->assertSame($originalFirstId, (string) $reorderedQuestions[\count($reorderedQuestions) - 1]->id);
|
||||
}
|
||||
|
||||
public function testEditIsDeniedForNonOwner(): void
|
||||
{
|
||||
$this->loginAs('test@example.org');
|
||||
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$question = $quiz->questions->first();
|
||||
$this->assertInstanceOf(Question::class, $question);
|
||||
|
||||
$this->client->request(Request::METHOD_GET, \sprintf(
|
||||
'/backoffice/season/krtek/quiz/%s/question/%s/edit',
|
||||
$quiz->id,
|
||||
$question->id,
|
||||
));
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testReorderIsDeniedForNonOwner(): void
|
||||
{
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
|
||||
// Scrape a valid CSRF token as the owner before switching to the non-owner account,
|
||||
// since the token is bound to the session, not the logged-in user.
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
|
||||
self::assertResponseIsSuccessful();
|
||||
$csrfToken = $crawler->filter('[data-bo--question-list-csrf-value]')->attr('data-bo--question-list-csrf-value');
|
||||
$this->assertNotEmpty($csrfToken);
|
||||
|
||||
$this->loginAs('test@example.org');
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/quiz/%s/questions/reorder', $quiz->id), [
|
||||
'_token' => $csrfToken,
|
||||
'ordering' => array_map(static fn (Question $q): string => (string) $q->id, $quiz->questions->toArray()),
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,27 +4,40 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Controller\Backoffice\SeasonController;
|
||||
use Tvdt\Entity\Candidate;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
#[CoversClass(SeasonController::class)]
|
||||
final class SeasonControllerTest extends AbstractControllerWebTestCase
|
||||
final class SeasonControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->client = self::createClient();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
}
|
||||
|
||||
public function testRegenerateSeasonCodeChangesTheCode(): void
|
||||
{
|
||||
$oldCode = 'krtek';
|
||||
$token = $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/%s/settings', $oldCode), '/regenerate-code');
|
||||
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/%s/settings', $oldCode));
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$token = (string) $crawler->filter('form[action*="/regenerate-code"] input[name="_token"]')->first()->attr('value');
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/%s/settings/regenerate-code', $oldCode), [
|
||||
'_token' => $token,
|
||||
@@ -41,9 +54,13 @@ final class SeasonControllerTest extends AbstractControllerWebTestCase
|
||||
|
||||
public function testRegenerateSeasonCodeIsDeniedForNonOwner(): void
|
||||
{
|
||||
$token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/settings', '/regenerate-code');
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/settings');
|
||||
self::assertResponseIsSuccessful();
|
||||
$token = (string) $crawler->filter('form[action*="/regenerate-code"] input[name="_token"]')->first()->attr('value');
|
||||
|
||||
$this->loginAs('test@example.org');
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
|
||||
$this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/settings/regenerate-code', [
|
||||
'_token' => $token,
|
||||
@@ -52,10 +69,29 @@ final class SeasonControllerTest extends AbstractControllerWebTestCase
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
private function getCandidate(string $name): Candidate
|
||||
{
|
||||
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => $name]);
|
||||
$this->assertInstanceOf(Candidate::class, $candidate);
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private function getCsrfTokenFromCandidatesTab(string $formActionContains): string
|
||||
{
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/candidates');
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
|
||||
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
|
||||
|
||||
return (string) $input->first()->attr('value');
|
||||
}
|
||||
|
||||
public function testRenameCandidate(): void
|
||||
{
|
||||
$candidate = $this->getCandidate('Tom');
|
||||
$token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/candidates', \sprintf('/candidate/%s/rename', $candidate->id));
|
||||
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/rename', $candidate->id));
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
|
||||
'_token' => $token,
|
||||
@@ -73,7 +109,7 @@ final class SeasonControllerTest extends AbstractControllerWebTestCase
|
||||
public function testRenameCandidateToExistingNameShowsError(): void
|
||||
{
|
||||
$candidate = $this->getCandidate('Tom');
|
||||
$token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/candidates', \sprintf('/candidate/%s/rename', $candidate->id));
|
||||
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/rename', $candidate->id));
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
|
||||
'_token' => $token,
|
||||
@@ -92,7 +128,7 @@ final class SeasonControllerTest extends AbstractControllerWebTestCase
|
||||
{
|
||||
$candidate = $this->getCandidate('Tom');
|
||||
$candidateId = $candidate->id;
|
||||
$token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/candidates', \sprintf('/candidate/%s/delete', $candidate->id));
|
||||
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/delete', $candidate->id));
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/delete', $candidate->id), [
|
||||
'_token' => $token,
|
||||
@@ -107,9 +143,11 @@ final class SeasonControllerTest extends AbstractControllerWebTestCase
|
||||
public function testRenameCandidateIsDeniedForNonOwner(): void
|
||||
{
|
||||
$candidate = $this->getCandidate('Tom');
|
||||
$token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/candidates', \sprintf('/candidate/%s/rename', $candidate->id));
|
||||
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/rename', $candidate->id));
|
||||
|
||||
$this->loginAs('test@example.org');
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => '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), [
|
||||
'_token' => $token,
|
||||
|
||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Tvdt\Controller\Backoffice\SettingsController;
|
||||
@@ -15,21 +17,43 @@ use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\ResetPasswordRequest;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Entity\User;
|
||||
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||
|
||||
#[CoversClass(SettingsController::class)]
|
||||
final class SettingsControllerTest extends AbstractControllerWebTestCase
|
||||
final class SettingsControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->client = self::createClient();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$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
|
||||
{
|
||||
return $this->getCsrfTokenFromPage('/backoffice/settings', $formActionContains);
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/settings');
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
|
||||
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
|
||||
|
||||
return (string) $input->first()->attr('value');
|
||||
}
|
||||
|
||||
public function testSettingsPageLoadsAndNavContainsSettingsLink(): void
|
||||
@@ -74,6 +98,7 @@ final class SettingsControllerTest extends AbstractControllerWebTestCase
|
||||
$this->entityManager->clear();
|
||||
|
||||
$user = $this->getUserByEmail('test@example.org');
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
$this->assertTrue($hasher->isPasswordValid($user, 'NewPass123!'));
|
||||
|
||||
@@ -82,21 +107,13 @@ final class SettingsControllerTest extends AbstractControllerWebTestCase
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, string, string}> */
|
||||
public static function invalidPasswordChangeProvider(): iterable
|
||||
{
|
||||
yield 'wrong current password' => ['wrong-password', 'NewPass123!', 'NewPass123!'];
|
||||
yield 'mismatched repeat' => [TestFixtures::PASSWORD, 'NewPass123!', 'SomethingElse!'];
|
||||
}
|
||||
|
||||
#[DataProvider('invalidPasswordChangeProvider')]
|
||||
public function testChangePasswordIsRejected(string $currentPassword, string $first, string $second): void
|
||||
public function testChangePasswordWithWrongCurrentPasswordIsRejected(): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
|
||||
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([
|
||||
'change_user_password_form[currentPassword]' => $currentPassword,
|
||||
'change_user_password_form[plainPassword][first]' => $first,
|
||||
'change_user_password_form[plainPassword][second]' => $second,
|
||||
'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);
|
||||
|
||||
@@ -104,6 +121,26 @@ final class SettingsControllerTest extends AbstractControllerWebTestCase
|
||||
$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
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
|
||||
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([
|
||||
'change_user_password_form[currentPassword]' => TestFixtures::PASSWORD,
|
||||
'change_user_password_form[plainPassword][first]' => 'NewPass123!',
|
||||
'change_user_password_form[plainPassword][second]' => 'SomethingElse!',
|
||||
]);
|
||||
$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));
|
||||
}
|
||||
@@ -120,8 +157,9 @@ final class SettingsControllerTest extends AbstractControllerWebTestCase
|
||||
self::assertEmailCount(1);
|
||||
$this->entityManager->clear();
|
||||
|
||||
$this->assertNotInstanceOf(User::class, $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']));
|
||||
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('test@example.org'));
|
||||
$user = $this->getUserByEmail('new-address@example.org');
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->assertFalse($user->isVerified);
|
||||
|
||||
// User stays logged in
|
||||
@@ -141,7 +179,7 @@ final class SettingsControllerTest extends AbstractControllerWebTestCase
|
||||
self::assertEmailCount(0);
|
||||
$this->entityManager->clear();
|
||||
|
||||
$this->getUserByEmail('test@example.org');
|
||||
$this->assertInstanceOf(User::class, $this->getUserByEmail('test@example.org'));
|
||||
}
|
||||
|
||||
public function testResendConfirmationEmailSendsEmail(): void
|
||||
@@ -162,8 +200,8 @@ final class SettingsControllerTest extends AbstractControllerWebTestCase
|
||||
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/resend-confirmation');
|
||||
|
||||
$user = $this->getUserByEmail('test@example.org');
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$user->isVerified = true;
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/settings');
|
||||
@@ -204,6 +242,7 @@ final class SettingsControllerTest extends AbstractControllerWebTestCase
|
||||
public function testChangePasswordInvalidatesResetPasswordRequests(): void
|
||||
{
|
||||
$user = $this->getUserByEmail('test@example.org');
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->createResetPasswordRequest($user);
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
|
||||
@@ -218,12 +257,14 @@ final class SettingsControllerTest extends AbstractControllerWebTestCase
|
||||
$this->entityManager->clear();
|
||||
|
||||
$user = $this->getUserByEmail('test@example.org');
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $user]));
|
||||
}
|
||||
|
||||
public function testChangeEmailInvalidatesResetPasswordRequests(): void
|
||||
{
|
||||
$user = $this->getUserByEmail('test@example.org');
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->createResetPasswordRequest($user);
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
|
||||
@@ -236,6 +277,7 @@ final class SettingsControllerTest extends AbstractControllerWebTestCase
|
||||
$this->entityManager->clear();
|
||||
|
||||
$user = $this->getUserByEmail('new-address@example.org');
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $user]));
|
||||
}
|
||||
|
||||
@@ -251,7 +293,7 @@ final class SettingsControllerTest extends AbstractControllerWebTestCase
|
||||
self::assertResponseRedirects('/backoffice/settings');
|
||||
$this->entityManager->clear();
|
||||
|
||||
$this->getUserByEmail('test@example.org');
|
||||
$this->assertInstanceOf(User::class, $this->getUserByEmail('test@example.org'));
|
||||
}
|
||||
|
||||
public function testDeleteAccountRemovesSoleOwnerSeasonsAndKeepsSharedSeasons(): void
|
||||
@@ -267,7 +309,7 @@ final class SettingsControllerTest extends AbstractControllerWebTestCase
|
||||
self::assertResponseRedirects();
|
||||
$this->entityManager->clear();
|
||||
|
||||
$this->assertNotInstanceOf(User::class, $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'sole-owner@example.org']));
|
||||
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('sole-owner@example.org'));
|
||||
|
||||
// Sole-owner season is removed, including its quiz
|
||||
$this->assertNotInstanceOf(Season::class, $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'doomd']));
|
||||
@@ -298,7 +340,7 @@ final class SettingsControllerTest extends AbstractControllerWebTestCase
|
||||
self::assertResponseRedirects();
|
||||
$this->entityManager->clear();
|
||||
|
||||
$this->assertNotInstanceOf(User::class, $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']));
|
||||
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('user2@example.org'));
|
||||
|
||||
foreach (['krtek', 'bbbbb'] as $seasonCode) {
|
||||
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => $seasonCode]);
|
||||
@@ -336,6 +378,7 @@ final class SettingsControllerTest extends AbstractControllerWebTestCase
|
||||
public function testDownloadDataRequiresVerifiedEmail(): void
|
||||
{
|
||||
$user = $this->getUserByEmail('test@example.org');
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->assertFalse($user->isVerified);
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data');
|
||||
@@ -346,8 +389,8 @@ final class SettingsControllerTest extends AbstractControllerWebTestCase
|
||||
private function markUserVerified(string $email): void
|
||||
{
|
||||
$user = $this->getUserByEmail($email);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$user->isVerified = true;
|
||||
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
<?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,8 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
|
||||
@@ -13,8 +15,18 @@ use Tvdt\Controller\ResetPasswordController;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
#[CoversClass(ResetPasswordController::class)]
|
||||
final class ResetPasswordControllerTest extends AbstractControllerWebTestCase
|
||||
final class ResetPasswordControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = self::createClient();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
}
|
||||
|
||||
public function testRequestPageLoads(): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/reset-password');
|
||||
@@ -23,19 +35,22 @@ final class ResetPasswordControllerTest extends AbstractControllerWebTestCase
|
||||
$this->assertSelectorExists('form');
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string}> */
|
||||
public static function emailProvider(): iterable
|
||||
{
|
||||
yield 'unknown email' => ['unknown@example.org'];
|
||||
yield 'known email' => ['test@example.org'];
|
||||
}
|
||||
|
||||
#[DataProvider('emailProvider')]
|
||||
public function testRequestRedirectsToCheckEmail(string $email): void
|
||||
public function testRequestWithUnknownEmailRedirectsToCheckEmail(): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/reset-password');
|
||||
$form = $this->client->getCrawler()->filter('form')->form([
|
||||
'reset_password_request_form[email]' => $email,
|
||||
'reset_password_request_form[email]' => 'unknown@example.org',
|
||||
]);
|
||||
$this->client->submit($form);
|
||||
|
||||
$this->assertResponseRedirects('/reset-password/check-email');
|
||||
}
|
||||
|
||||
public function testRequestWithKnownEmailRedirectsToCheckEmail(): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/reset-password');
|
||||
$form = $this->client->getCrawler()->filter('form')->form([
|
||||
'reset_password_request_form[email]' => 'test@example.org',
|
||||
]);
|
||||
$this->client->submit($form);
|
||||
|
||||
|
||||
@@ -6,12 +6,21 @@ namespace Tvdt\Tests\Controller;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Controller\WellKnownController;
|
||||
|
||||
#[CoversClass(WellKnownController::class)]
|
||||
final class WellKnownControllerTest extends AbstractControllerWebTestCase
|
||||
final class WellKnownControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = self::createClient();
|
||||
}
|
||||
|
||||
public function testChangePasswordRedirectsToSettings(): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/.well-known/change-password');
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -4,34 +4,28 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Helpers;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Safe\Exceptions\UrlException;
|
||||
use Tvdt\Helpers\Base64;
|
||||
|
||||
#[CoversClass(Base64::class)]
|
||||
final class Base64Test extends TestCase
|
||||
{
|
||||
/** @return iterable<string, array{string, string}> */
|
||||
public static function pairProvider(): iterable
|
||||
public function testBase64UrlEncode(): void
|
||||
{
|
||||
yield 'Marijn' => ['Marijn', 'TWFyaWpu'];
|
||||
yield 'Philine' => ['Philine', 'UGhpbGluZQ'];
|
||||
yield 'byte 254' => [\chr(254), '_g'];
|
||||
yield 'byte 250' => [\chr(250), '-g'];
|
||||
$this->assertSame('TWFyaWpu', Base64::base64UrlEncode('Marijn'));
|
||||
$this->assertSame('UGhpbGluZQ', Base64::base64UrlEncode('Philine'));
|
||||
|
||||
$this->assertSame('_g', Base64::base64UrlEncode(\chr(254)));
|
||||
$this->assertSame('-g', Base64::base64UrlEncode(\chr(250)));
|
||||
}
|
||||
|
||||
#[DataProvider('pairProvider')]
|
||||
public function testBase64UrlEncode(string $decoded, string $encoded): void
|
||||
public function testBase64UrlDecode(): void
|
||||
{
|
||||
$this->assertSame($encoded, Base64::base64UrlEncode($decoded));
|
||||
}
|
||||
$this->assertSame('Marijn', Base64::base64UrlDecode('TWFyaWpu'));
|
||||
$this->assertSame('Philine', Base64::base64UrlDecode('UGhpbGluZQ'));
|
||||
|
||||
#[DataProvider('pairProvider')]
|
||||
public function testBase64UrlDecode(string $decoded, string $encoded): void
|
||||
{
|
||||
$this->assertSame($decoded, Base64::base64UrlDecode($encoded));
|
||||
$this->assertSame(\chr(254), Base64::base64UrlDecode('_g'));
|
||||
$this->assertSame(\chr(250), Base64::base64UrlDecode('-g'));
|
||||
}
|
||||
|
||||
public function testBase64UrlDecodeCanHandlePadding(): void
|
||||
|
||||
@@ -4,31 +4,41 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Helpers;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tvdt\Helpers\FilenameSanitizer;
|
||||
|
||||
#[CoversClass(FilenameSanitizer::class)]
|
||||
final class FilenameSanitizerTest extends TestCase
|
||||
{
|
||||
/** @return iterable<string, array{string, string}> */
|
||||
public static function sanitizeProvider(): iterable
|
||||
public function testReplacesSpacesWithDashes(): void
|
||||
{
|
||||
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'];
|
||||
$this->assertSame('Krtek-Weekend', FilenameSanitizer::sanitize('Krtek Weekend'));
|
||||
}
|
||||
|
||||
#[DataProvider('sanitizeProvider')]
|
||||
public function testSanitize(string $input, string $expected): void
|
||||
public function testStripsPathSeparatorsAndTraversal(): void
|
||||
{
|
||||
$this->assertSame($expected, FilenameSanitizer::sanitize($input));
|
||||
$this->assertSame('etc-passwd', FilenameSanitizer::sanitize('../../etc/passwd'));
|
||||
$this->assertSame('a-b', FilenameSanitizer::sanitize('a/b'));
|
||||
$this->assertSame('a-b', FilenameSanitizer::sanitize('a\\b'));
|
||||
}
|
||||
|
||||
public function testStripsControlCharactersAndSpecialSymbols(): void
|
||||
{
|
||||
$this->assertSame('Quiz-1-script', FilenameSanitizer::sanitize("Quiz #1 <script>\0"));
|
||||
}
|
||||
|
||||
public function testTransliteratesUnicodeToAscii(): void
|
||||
{
|
||||
$this->assertSame('Weird-Name', FilenameSanitizer::sanitize('Wéird Ñame'));
|
||||
}
|
||||
|
||||
public function testTransliteratesAtSignInEmail(): void
|
||||
{
|
||||
$this->assertSame('test-example-org', FilenameSanitizer::sanitize('test@example.org'));
|
||||
}
|
||||
|
||||
public function testReturnsUnnamedForEmptyOrFullyStrippedInput(): void
|
||||
{
|
||||
$this->assertSame('unnamed', FilenameSanitizer::sanitize(''));
|
||||
$this->assertSame('unnamed', FilenameSanitizer::sanitize('///'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,22 +53,4 @@ final class CandidateRepositoryTest extends DatabaseTestCase
|
||||
);
|
||||
$this->assertNotInstanceOf(Candidate::class, $result);
|
||||
}
|
||||
|
||||
/** Candidate names are only unique per season, so a same-named candidate in another season must not leak in. */
|
||||
public function testGetCandidateByHashScopesByCandidateSeasonNotJustName(): void
|
||||
{
|
||||
$krtekSeason = $this->getSeasonByCode('krtek');
|
||||
$anotherSeason = $this->getSeasonByCode('bbbbb');
|
||||
|
||||
$duplicateNamedCandidate = new Candidate('Claudia');
|
||||
$anotherSeason->addCandidate($duplicateNamedCandidate);
|
||||
$this->entityManager->persist($duplicateNamedCandidate);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$candidate = $this->candidateRepository->getCandidateByHash($krtekSeason, 'Q2xhdWRpYQ');
|
||||
|
||||
$this->assertInstanceOf(Candidate::class, $candidate);
|
||||
$this->assertSame($krtekSeason, $candidate->season);
|
||||
$this->assertNotSame($duplicateNamedCandidate, $candidate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,28 +5,25 @@ declare(strict_types=1);
|
||||
namespace Tvdt\Tests\Repository;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Repository\SeasonRepository;
|
||||
|
||||
#[CoversClass(SeasonRepository::class)]
|
||||
final class SeasonRepositoryTest extends DatabaseTestCase
|
||||
{
|
||||
/** @return iterable<string, array{string, string}> */
|
||||
public static function userSeasonsProvider(): iterable
|
||||
public function testGetSeasonsForUser(): void
|
||||
{
|
||||
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);
|
||||
$user = $this->getUserByEmail('krtek-admin@example.org');
|
||||
|
||||
$seasons = $this->seasonRepository->getSeasonsForUser($user);
|
||||
$this->assertCount(1, $seasons);
|
||||
$this->assertSame($expectedSeasonCode, $seasons[0]->seasonCode);
|
||||
$this->assertSame('krtek', $seasons[0]->seasonCode);
|
||||
|
||||
$user = $this->getUserByEmail('user1@example.org');
|
||||
|
||||
$seasons = $this->seasonRepository->getSeasonsForUser($user);
|
||||
$this->assertCount(1, $seasons);
|
||||
$this->assertSame('bbbbb', $seasons[0]->seasonCode);
|
||||
}
|
||||
|
||||
public function testUserWithMultipleSeasons(): void
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -369,6 +369,10 @@
|
||||
<source>Forgot your password?</source>
|
||||
<target>Wachtwoord vergeten?</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3cWWP_q" resname="Fullscreen">
|
||||
<source>Fullscreen</source>
|
||||
<target>Volledig scherm</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="MebBrmp" resname="Gray">
|
||||
<source>Gray</source>
|
||||
<target>Grijs</target>
|
||||
|
||||
Reference in New Issue
Block a user