Compare commits

..

8 Commits

Author SHA1 Message Date
coderabbitai[bot] a75bc14638 📝 CodeRabbit Chat: Add retry and unsaved notice for question list ordering 2026-07-07 06:46:09 +00:00
Marijn e2558c70f5 fix: use correct Turbo v8 session export to disable Drive 2026-07-07 08:33:34 +02:00
Marijn 12d40b2602 fix: exclude auto-generated reference.php from pre-commit CS-fixer 2026-07-07 08:01:57 +02:00
Marijn cb577d60d3 feat: replace fetch-based modal forms with Turbo Frames (#181)
Enable @hotwired/turbo with Drive explicitly disabled, then migrate the
bo--modal-form Stimulus controller (custom fetch + X-Modal-Request pattern)
to a thin bo--modal controller that lets Turbo handle HTTP and DOM swap.
Adds frame templates for quiz questions and bank questions; controllers now
detect Turbo-Frame header instead of X-Modal-Request.
2026-07-06 23:13:55 +02:00
Marijn 3b6ff680e5 fix: modal save button + missing translations
- Replace form="id" cross-element approach with requestSubmit() for
  reliable save button wiring in modal footer
- Re-call _bindDirty after validation-error re-render so dirty guard
  is preserved across save attempts
- Translate missing Dutch strings: Order saved, Error saving order,
  Question details, View
2026-07-06 22:41:34 +02:00
Marijn 4a87ac574d feat: add bank question via modal + dirty modal guard
- Add question in question bank now opens a modal instead of navigating
  to a full-page form, consistent with the edit modal pattern
- Modal closes are blocked by static backdrop once the user has made any
  change (input, checkbox, drag-reorder, sort, randomize, remove answer)
- Dirty state resets when the modal is fully hidden
2026-07-06 22:32:44 +02:00
Marijn 394b8c7d33 feat: answer field UX improvements
- Auto-add one empty answer field when opening a blank question form
- Auto-append new empty field when typing in the last answer field
- Strip empty answer rows before submit (novalidate + JS cleanup)
- Tab key skips correct/delete buttons, jumping straight to next answer
2026-07-06 22:28:57 +02:00
Marijn 64a09453e6 feat: quiz page question rework (#181)
- Replace Bootstrap accordion with flat card list for questions
- Add HTML5 drag-and-drop reordering with placeholder-between-cards UX
  and amber/green/red save status indicator next to the heading
- Add edit button per question opening a Bootstrap modal (bo--modal-form
  Stimulus controller with X-Modal-Request header pattern)
- Show read-only view button instead of edit for locked/finalized quizzes
- Add BankQuestion edit modal in question bank tab using same infrastructure
- Move modal action buttons into modal-footer via <template data-modal-footer>
- Fix IS_AUTHENTICATED_FULLY 403: replace ROLE_USER with IS_AUTHENTICATED
  on all backoffice controllers and in security.yaml access_control
2026-07-06 22:19:51 +02:00
108 changed files with 641 additions and 5420 deletions
-2
View File
@@ -1,5 +1,3 @@
# define your env variables for the test env here # define your env variables for the test env here
KERNEL_CLASS='Tvdt\Kernel' KERNEL_CLASS='Tvdt\Kernel'
APP_SECRET='$ecretf0rt3st' APP_SECRET='$ecretf0rt3st'
MAILER_DSN=null://null
MAILER_SENDER=test@example.org
+6 -58
View File
@@ -70,8 +70,6 @@ jobs:
set: | set: |
*.cache-from=type=gha,scope=${{github.ref}}-devbuild *.cache-from=type=gha,scope=${{github.ref}}-devbuild
- name: Start services - name: Start services
env:
HTTP_PORT: "80"
run: docker compose up php database --wait --no-build run: docker compose up php database --wait --no-build
- name: Warm up dev cache - name: Warm up dev cache
run: docker compose exec -T php bin/console cache:warmup --env=dev run: docker compose exec -T php bin/console cache:warmup --env=dev
@@ -95,15 +93,6 @@ jobs:
id: rector id: rector
continue-on-error: true continue-on-error: true
run: docker compose exec -T php vendor/bin/rector process --dry-run --no-progress-bar --output-format=github run: docker compose exec -T php vendor/bin/rector process --dry-run --no-progress-bar --output-format=github
- name: Translations
id: translations
continue-on-error: true
run: |
docker compose exec -T php bin/console translation:extract --force --format=xliff --sort=asc nl
if ! git diff --exit-code -- translations; then
echo "::error::Translations are out of date. Run 'just translations' and commit the changes."
exit 1
fi
- name: Check HTTP reachability - name: Check HTTP reachability
run: curl -v --fail-with-body http://localhost run: curl -v --fail-with-body http://localhost
- name: Assert all checks passed - name: Assert all checks passed
@@ -122,7 +111,6 @@ jobs:
check "Twig Coding Style" "${{ steps.twig_cs.outcome }}" check "Twig Coding Style" "${{ steps.twig_cs.outcome }}"
check "PHPStan" "${{ steps.phpstan.outcome }}" check "PHPStan" "${{ steps.phpstan.outcome }}"
check "Rector" "${{ steps.rector.outcome }}" check "Rector" "${{ steps.rector.outcome }}"
check "Translations" "${{ steps.translations.outcome }}"
exit $failed exit $failed
tests: tests:
@@ -135,7 +123,6 @@ jobs:
checks: write checks: write
pull-requests: write pull-requests: write
contents: read contents: read
code-quality: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
@@ -163,26 +150,13 @@ jobs:
- name: Load fixtures - name: Load fixtures
run: docker compose exec -T php bin/console -e test doctrine:fixtures:load --no-interaction --group=test run: docker compose exec -T php bin/console -e test doctrine:fixtures:load --no-interaction --group=test
- name: Run PHPUnit - name: Run PHPUnit
# Reports are written outside var/ since var/ is a Docker volume (see the Dockerfile's run: docker compose exec -T php vendor/bin/phpunit --log-junit var/phpunit/junit.xml
# VOLUME /app/var/) and isn't bind-mounted to the runner, unlike the rest of the project.
run: docker compose exec -T -e MAILER_DSN=null://null php vendor/bin/phpunit --log-junit reports/junit.xml --coverage-cobertura reports/coverage/cobertura.xml
- name: Publish PHPUnit test results - name: Publish PHPUnit test results
if: always() if: always()
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6 uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6
with: with:
report_paths: reports/junit.xml report_paths: var/phpunit/junit.xml
check_name: PHPUnit check_name: PHPUnit
- name: Upload code coverage
# Requires "Code Quality" to be enabled for this repository under
# Settings > Code security > Code quality; fail-on-error is false so CI
# doesn't go red before that one-time, manual repository setting is turned on.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: actions/upload-code-coverage@82c7aee3fb2ad768e00b00a0a8d749c5815085b6 # v1
with:
file: reports/coverage/cobertura.xml
language: PHP
label: phpunit
fail-on-error: false
- name: Doctrine Schema Validator - name: Doctrine Schema Validator
run: docker compose exec -T php bin/console -e test doctrine:schema:validate run: docker compose exec -T php bin/console -e test doctrine:schema:validate
@@ -192,7 +166,7 @@ jobs:
timeout-minutes: 20 timeout-minutes: 20
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
permissions: permissions:
actions: write actions: read
steps: steps:
- name: Wait for and verify successful CI run on this commit - name: Wait for and verify successful CI run on this commit
env: env:
@@ -200,8 +174,6 @@ jobs:
run: | run: |
max_attempts=30 max_attempts=30
attempt=0 attempt=0
triggered=false
while [[ $attempt -lt $max_attempts ]]; do while [[ $attempt -lt $max_attempts ]]; do
attempt=$((attempt + 1)) attempt=$((attempt + 1))
@@ -219,32 +191,12 @@ jobs:
--jq "[.workflow_runs[] | select(.id != ${{ github.run_id }}) | select(.status == \"in_progress\" or .status == \"queued\" or .status == \"waiting\" or .status == \"requested\" or .status == \"pending\")] | length") --jq "[.workflow_runs[] | select(.id != ${{ github.run_id }}) | select(.status == \"in_progress\" or .status == \"queued\" or .status == \"waiting\" or .status == \"requested\" or .status == \"pending\")] | length")
if [[ "$in_progress_count" -gt 0 ]]; then if [[ "$in_progress_count" -gt 0 ]]; then
echo "CI in progress (attempt $attempt/$max_attempts), waiting 30s..." echo "CI still in progress (attempt $attempt/$max_attempts), waiting 30s..."
sleep 30 sleep 30
continue else
fi echo "::error::No prior successful CI run found for ${{ github.sha }}. Only tag commits that have passed CI on main."
if [[ "$triggered" == "false" ]]; then
echo "No prior CI run found for ${{ github.sha }}. Triggering CI on ${{ github.ref }}..."
gh workflow run ci.yml --repo "${{ github.repository }}" --ref "${{ github.ref }}"
triggered=true
echo "Triggered. Waiting 20s for run to register..."
sleep 20
continue
fi
failed_conclusion=$(gh api \
"repos/${{ github.repository }}/actions/workflows/ci.yml/runs?head_sha=${{ github.sha }}&per_page=10" \
--jq "[.workflow_runs[] | select(.id != ${{ github.run_id }}) | select(.status == \"completed\") | select(.conclusion != \"success\")] | first | .conclusion // empty" \
--raw-output)
if [[ -n "$failed_conclusion" ]]; then
echo "::error::Triggered CI run on main failed with conclusion: $failed_conclusion. Fix the issue before re-tagging."
exit 1 exit 1
fi fi
echo "Waiting for triggered run to register (attempt $attempt/$max_attempts)..."
sleep 20
done done
echo "::error::Timed out waiting for CI run to complete for ${{ github.sha }}." echo "::error::Timed out waiting for CI run to complete for ${{ github.sha }}."
@@ -282,7 +234,6 @@ jobs:
id: meta id: meta
run: | run: |
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
if [[ "${{ github.ref }}" == refs/tags/* ]]; then if [[ "${{ github.ref }}" == refs/tags/* ]]; then
TAG="${GITHUB_REF#refs/tags/}" TAG="${GITHUB_REF#refs/tags/}"
SENTRY_VERSION="${TAG#v}" SENTRY_VERSION="${TAG#v}"
@@ -309,12 +260,9 @@ jobs:
compose.yaml compose.yaml
compose.build.yaml compose.build.yaml
set: | set: |
*.cache-from=type=gha,scope=${{github.ref}}-devbuild
*.cache-from=type=gha,scope=refs/heads/main-devbuild
*.cache-from=type=gha,scope=${{github.ref}} *.cache-from=type=gha,scope=${{github.ref}}
*.cache-from=type=gha,scope=refs/heads/main *.cache-from=type=gha,scope=refs/heads/main
*.cache-to=type=gha,scope=${{github.ref}},mode=max *.cache-to=type=gha,scope=${{github.ref}},mode=max
*.args.BUILD_TIME=${{ steps.meta.outputs.build_time }}
*.tags=${{ steps.meta.outputs.full_name }} *.tags=${{ steps.meta.outputs.full_name }}
- name: Create Sentry release - name: Create Sentry release
-1
View File
@@ -1,5 +1,4 @@
/frankenphp/data /frankenphp/data
/reports/
### Generated by gibo (https://github.com/simonwhitaker/gibo) ### Generated by gibo (https://github.com/simonwhitaker/gibo)
### https://raw.github.com/github/gitignore/6eeebe6f49678aacd8311ce079842c971b3ebe96/Symfony.gitignore ### https://raw.github.com/github/gitignore/6eeebe6f49678aacd8311ce079842c971b3ebe96/Symfony.gitignore
-1
View File
@@ -171,7 +171,6 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-deepclone" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-deepclone" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/file-filter" /> <excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/file-filter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/object-mapper" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/object-mapper" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfonycasts/reset-password-bundle" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
Generated
-1
View File
@@ -205,7 +205,6 @@
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" /> <path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
<path value="$PROJECT_DIR$/vendor/martin-georgiev/postgresql-for-doctrine" /> <path value="$PROJECT_DIR$/vendor/martin-georgiev/postgresql-for-doctrine" />
<path value="$PROJECT_DIR$/vendor/symfony/object-mapper" /> <path value="$PROJECT_DIR$/vendor/symfony/object-mapper" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/reset-password-bundle" />
</include_path> </include_path>
</component> </component>
<component name="PhpInterpreters"> <component name="PhpInterpreters">
+26 -151
View File
@@ -4,11 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
**Tijd voor de test** is a PHP/Symfony 8.1 application for managing quizzes in the style of **Wie is de Mol?** (WIDM) — **Tijd voor de test** is a PHP/Symfony 8.1 application for managing quizzes in the style of **Wie is de Mol?** (WIDM) — a Dutch TV show where contestants try to identify a saboteur ("de Mol") among them. At the end of each episode, participants take a quiz about the Mol's identity and actions; the candidate with the least correct answers is eliminated. This app replicates that quiz format with:
a Dutch TV show where contestants try to identify a saboteur ("de Mol") among them. At the end of each episode,
participants take a quiz about the Mol's identity and actions; the candidate with the least correct answers is
eliminated. This app replicates that quiz format with:
- Test creation with variable question counts - Test creation with variable question counts
- Season management with active test controls - Season management with active test controls
- Candidate answer tracking with automatic timing - Candidate answer tracking with automatic timing
@@ -16,7 +12,6 @@ eliminated. This app replicates that quiz format with:
- Backoffice management for quiz administration and statistics - Backoffice management for quiz administration and statistics
Tech Stack: Tech Stack:
- **Framework**: Symfony 8.1 - **Framework**: Symfony 8.1
- **PHP**: 8.5+ - **PHP**: 8.5+
- **Database**: PostgreSQL 16 - **Database**: PostgreSQL 16
@@ -40,24 +35,6 @@ just shell # Interactive shell inside the PHP container
just shell-run # Shell in a fresh one-off container just shell-run # Shell in a fresh one-off container
``` ```
### Working in git worktrees
`just up` auto-runs `just init` first, which generates a gitignored `.env.local` per checkout with a unique
`COMPOSE_PROJECT_NAME`, `IMAGES_PREFIX`, and free `HTTP_PORT`/`HTTPS_PORT`/`POSTGRES_PORT`/`MAILPIT_PORT`/
`SPOTLIGHT_PORT`. This means every worktree gets its own containers, network, volumes, and image tag — running
`just up` in two worktrees at the same time does **not** make them share a database, image, or port, even if the
worktree directories have the same basename.
- Run `just ports` to see the ports assigned to the *current* checkout — the app for that worktree is at
`https://localhost:<HTTPS_PORT>`, not a fixed port. Never assume port 8080/8443/5432/etc. when working inside a
worktree; always check `.env.local` or `just ports` first.
- `.env.local` is generated once and reused; it's safe to run `just init`/`just up` repeatedly. Delete `.env.local`
and re-run `just init` to force new ports (e.g. if the assigned ones are now taken by something else).
- Each worktree's Postgres data, uploaded files, and Caddy state live in per-worktree Docker volumes — nothing is
shared with the main checkout or other worktrees. Migrations/fixtures must be (re-)run per worktree.
- `just down`/`just clean` in one worktree only ever affects that worktree's own containers/volumes — safe to run
without impacting other worktrees.
### Database ### Database
```bash ```bash
@@ -99,7 +76,7 @@ All code quality checks run in CI/CD (.github/workflows/ci.yml) and should pass
``` ```
src/ src/
Controller/ # HTTP request handlers (attribute-routed) Controller/ # HTTP request handlers (attribute-routed)
Backoffice/ # Admin panel controllers Backoffice/ # Admin panel controllers
Entity/ # Doctrine ORM entities Entity/ # Doctrine ORM entities
Repository/ # Database queries Repository/ # Database queries
@@ -145,151 +122,53 @@ tests/
- **Elimination**: Records red/green screens and forced results with joker adjustments. - **Elimination**: Records red/green screens and forced results with joker adjustments.
- **User**: Administrative accounts for managing the system. - **User**: Administrative accounts for managing the system.
## Domain Context: "De Test" (Wie is de Mol)
**Wie is de Mol?** (WIDM) is a Dutch reality competition: a group of contestants ("kandidaten") travels together while
one of them, "de Mol", secretly sabotages assignments. Each episode ends with the fixed line: *"Tijd voor de test.
Twintig vragen over de identiteit en het doen en laten van de Mol. Degene die het minst weet, ligt uit het spel. Behalve
de Mol. Die hoeft nooit naar huis."* ("Time for the test. Twenty questions about the identity and the actions of the
Mol. Whoever knows the least is out of the game. Except the Mol — they never have to go home.") The contestant with the
worst score is eliminated ("afvallen"); the Mol is immune regardless of score, since they already know the answers. This
app is a generic engine for running that quiz format for private/fan seasons, not just modeling the TV show
incidentally — the entity model below exists specifically to reproduce WIDM's test mechanics.
### What a test's 20 questions actually are
Per the intro line, questions fall into two factual categories — never opinion ("who would you vote off") — plus a
third recurring format used on the show:
1. **Identity of the Mol**: guessing which contestant is the Mol.
2. **The Mol's actions**: what the Mol did or where the Mol was during a specific assignment/moment.
3. **Candidate self-answered questions**: earlier, every contestant privately answered a question about themselves
(an interview-style question); the test then asks other contestants to guess what a *specific* candidate answered
about themselves. This tests how well contestants know each other, not just Mol-tracking.
### Why answers can be bound to candidates
All three categories above can have contestants themselves as the answer options rather than free text: "who is the
Mol" and "who did X" both need contestant names as options, and "what did candidate Y answer" needs Y's own submitted
answer among the options. In the domain model this is `Answer::$candidates` (a `ManyToMany` to `Candidate`, on both
sides): an answer option can *be* another contestant, not just text.
Because the relationship is many-to-many on the answer side too, a single answer option can cover **more than one
candidate at once** — e.g. "Anna en Bram" as one option for "who missed the assignment together", or an option
representing everyone who gave a particular self-answer in category 3 above. So a candidate-bound answer isn't always
one candidate, it can be a group; treat `Answer::$candidates` as "the set of contestants this option represents", not
as a single foreign key.
Combined with `GivenAnswer::$candidate` (who answered), every given answer on a candidate-bound question is a directed
relationship from the answering candidate to *every* candidate covered by the chosen option — a one-to-many edge when
the option is a group, not just candidate A pointed at candidate B. This is the mechanic behind any "who's suspected of
what" or sociogram-style statistic — it only applies to candidate-bound questions, plain trivia questions have no such
relationship. `Quiz::getQuestionErrors()` already relies on this distinction to validate that every active candidate is
covered exactly once per candidate-bound question (a candidate appearing across multiple group-options on the same
question counts as covered more than once).
### Elimination mechanics
- **Red/green screens**: at the end of a test, contestants are shown red or green screens one at a time to build tension
before the elimination is revealed. `Elimination::$data` stores the colour shown per candidate (
`SCREEN_RED/SCREEN_GREEN` via `getScreenColour()`), independent of the actual quiz score.
- **Jokers / corrections**: contestants can hold a "joker" (an advantage, e.g. an extra correct answer) that adjusts
their effective score without changing what they actually answered. This is `QuizCandidate::$corrections` — a float
added to the raw score, kept separate from `GivenAnswer` so the audit trail of what was actually answered stays
untouched.
- **Dropouts**: `Quiz::$dropouts` controls how many contestants can be eliminated in a single test (normally 1, but some
episodes eliminate more).
- **Finalization/locking**: `Quiz::$isFinalized` and `$isLocked` gate when a quiz's questions/answers can still be
edited — a quiz becomes immutable once a candidate has started it or an admin explicitly finalizes it. Treat this as
the natural point where computed results (scores, statistics) can be cached indefinitely, since nothing that feeds
them can change afterward.
### Terminology map (Dutch UI ↔ domain code)
| UI/domain term (Dutch) | Code |
|------------------------------|-------------------------------|
| Test | `Quiz` |
| Vraag | `Question` |
| Antwoord | `Answer` |
| Kandidaat | `Candidate` |
| Ingevuld antwoord | `GivenAnswer` |
| Afvallen / rood-groen scherm | `Elimination` |
| Joker / correctie | `QuizCandidate::$corrections` |
## Architecture Notes ## Architecture Notes
### Routing ### Routing
- Routes are **attribute-based** (PHP 8 attributes in controller methods) - Routes are **attribute-based** (PHP 8 attributes in controller methods)
- Configured in `config/routes/attributes.yaml` for automatic discovery - Configured in `config/routes/attributes.yaml` for automatic discovery
- Main entry point: `config/routes.yaml` - Main entry point: `config/routes.yaml`
### Service Container & Dependency Injection ### Service Container & Dependency Injection
- Services in `src/` are automatically registered via PSR-4 namespace `Tvdt\` - Services in `src/` are automatically registered via PSR-4 namespace `Tvdt\`
- Exclusions: Entity, DependencyInjection, Kernel classes - Exclusions: Entity, DependencyInjection, Kernel classes
- Autowiring and autoconfiguration enabled by default - Autowiring and autoconfiguration enabled by default
- Service definitions in `config/services.yaml` - Service definitions in `config/services.yaml`
### Database & Migrations ### Database & Migrations
- PostgreSQL-based with Doctrine ORM - PostgreSQL-based with Doctrine ORM
- Migrations in `migrations/` at project root, namespace `DoctrineMigrations` (intentionally not autoloaded); generate - Migrations in `migrations/` at project root, namespace `DoctrineMigrations` (intentionally not autoloaded); generate with `bin/console make:migration`
with `bin/console make:migration`
- Test fixtures in `src/DataFixtures/` (loaded with `--group=test`) - Test fixtures in `src/DataFixtures/` (loaded with `--group=test`)
- Test database configured separately via `.env.test` - Test database configured separately via `.env.test`
### Testing Infrastructure ### Testing Infrastructure
- **PHPUnit 13** with DAMA Doctrine Test Bundle for transaction rollback - **PHPUnit 13** with DAMA Doctrine Test Bundle for transaction rollback
- Bootstrap: `tests/bootstrap.php` loads env vars and autoloader; `tests/symfony-container.php` boots the test - Bootstrap: `tests/bootstrap.php` loads env vars and autoloader; `tests/symfony-container.php` boots the test kernel/container (used by Rector)
kernel/container (used by Rector)
- Symfony test utilities (BrowserKit, CSS selectors) available - Symfony test utilities (BrowserKit, CSS selectors) available
- Coverage excluded from: `src/DataFixtures/` - Coverage excluded from: `src/DataFixtures/`
- Test environment: `APP_ENV=test` (set in phpunit.dist.xml) - Test environment: `APP_ENV=test` (set in phpunit.dist.xml)
### Testing Conventions (TDD)
- **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.
- **Prefer `TestCase` over `WebTestCase`/`KernelTestCase`.** Reach for the full kernel/DB boot only when the test
genuinely needs routing, persistence, or the container — pure logic (services, listeners, helpers) should be tested
with plain PHPUnit `TestCase` and mocked dependencies; it's faster and more isolated.
- **Boy Scout Rule**: when you're already touching a file for an unrelated change, fix small nearby issues in the same
commit (e.g. a test that unnecessarily extends `WebTestCase`, a stale comment) rather than leaving them for later —
but don't let this balloon into an unrelated refactor.
### Code Style & Standards ### Code Style & Standards
- **PHP-CS-Fixer**: Symfony ruleset + risky rules enabled - **PHP-CS-Fixer**: Symfony ruleset + risky rules enabled
- Strict types declaration required - Strict types declaration required
- Trailing commas in multiline structures - Trailing commas in multiline structures
- No else-only blocks - No else-only blocks
- **Rector**: Aggressive modernization with all attribute sets + prepared sets (dead code, code quality, Doctrine, - **Rector**: Aggressive modernization with all attribute sets + prepared sets (dead code, code quality, Doctrine, Symfony, PHPUnit)
Symfony, PHPUnit)
- **PHPStan**: Level 8 with extensions for Doctrine and Symfony - **PHPStan**: Level 8 with extensions for Doctrine and Symfony
- **Twig-CS-Fixer**: Template style enforcement - **Twig-CS-Fixer**: Template style enforcement
- **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure — - **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure — they throw exceptions instead
they throw exceptions instead
### Environment Configuration ### Environment Configuration
- `.env` - Local development defaults (uncommitted in .env.local) - `.env` - Local development defaults (uncommitted in .env.local)
- `.env.dev` - Development overrides - `.env.dev` - Development overrides
- `.env.test` - Test environment configuration - `.env.test` - Test environment configuration
- Production uses `composer dump-env prod` for compiled configuration - Production uses `composer dump-env prod` for compiled configuration
- Key variables: - Key variables:
- `APP_ENV` - Environment (dev/test/prod) - `APP_ENV` - Environment (dev/test/prod)
- `DATABASE_URL` - PostgreSQL connection string - `DATABASE_URL` - PostgreSQL connection string
- `MAILER_SENDER` - From address for emails - `MAILER_SENDER` - From address for emails
### Frontend Build ### Frontend Build
- Asset mapper (no Node.js/Webpack) for JS/CSS bundling; JS modules declared in `importmap.php` - Asset mapper (no Node.js/Webpack) for JS/CSS bundling; JS modules declared in `importmap.php`
- **Stimulus** controllers in `assets/controllers/`, **Turbo** for SPA-like navigation - **Stimulus** controllers in `assets/controllers/`, **Turbo** for SPA-like navigation
- Sass sources in `assets/styles/`, compiled via `bin/console sass:build` - Sass sources in `assets/styles/`, compiled via `bin/console sass:build`
@@ -302,29 +181,27 @@ GitHub Actions workflow (`.github/workflows/ci.yml`):
1. **Linting**: Dockerfile (hadolint), Twig templates 1. **Linting**: Dockerfile (hadolint), Twig templates
2. **Code Quality**: 2. **Code Quality**:
- PHP-CS-Fixer style check - PHP-CS-Fixer style check
- Twig-CS-Fixer style check - Twig-CS-Fixer style check
- PHPStan static analysis - PHPStan static analysis
- Rector dry-run - Rector dry-run
- Translation extraction check (fails if `translation:extract` produces uncommitted changes)
3. **Integration Tests**: 3. **Integration Tests**:
- Docker image build and start services - Docker image build and start services
- Database creation and migration - Database creation and migration
- Fixture loading - Fixture loading
- Full PHPUnit test suite with JUnit XML output - Full PHPUnit test suite with JUnit XML output
- Doctrine schema validation - Doctrine schema validation
4. **Build & Deploy** (on tags or main, disabled currently): 4. **Build & Deploy** (on tags or main, disabled currently):
- Docker image push to GitHub Container Registry - Docker image push to GitHub Container Registry
- Sentry release creation - Sentry release creation
- Portainer webhook trigger for production deployment - Portainer webhook trigger for production deployment
Runs on all pushes to main and pull requests. Concurrency cancels old runs on new commits. Runs on all pushes to main and pull requests. Concurrency cancels old runs on new commits.
## Important Files & Conventions ## Important Files & Conventions
- **Kernel**: `src/Kernel.php` - Symfony kernel class - **Kernel**: `src/Kernel.php` - Symfony kernel class
- **AbstractController**: Base class for all controllers — defines route parameter regexes (`SEASON_CODE_REGEX`, - **AbstractController**: Base class for all controllers — defines route parameter regexes (`SEASON_CODE_REGEX`, `CANDIDATE_HASH_REGEX`) and flash helpers
`CANDIDATE_HASH_REGEX`) and flash helpers
- **Flash Messages**: Use `FlashType` enum instead of string literals - **Flash Messages**: Use `FlashType` enum instead of string literals
- **QuizSpreadsheetService**: Handles importing quizzes from XLSX files - **QuizSpreadsheetService**: Handles importing quizzes from XLSX files
- **Rector container**: `tests/symfony-container.php` — boots a test kernel so Rector can resolve Symfony service types - **Rector container**: `tests/symfony-container.php` — boots a test kernel so Rector can resolve Symfony service types
@@ -343,7 +220,6 @@ Runs on all pushes to main and pull requests. Concurrency cancels old runs on ne
## Composer Scripts ## Composer Scripts
Auto-executed scripts on install/update: Auto-executed scripts on install/update:
- `cache:clear` - Symfony cache clear - `cache:clear` - Symfony cache clear
- `assets:install` - Copy public assets - `assets:install` - Copy public assets
- `importmap:install` - JS import map setup - `importmap:install` - JS import map setup
@@ -362,5 +238,4 @@ When writing Dutch help content in `templates/backoffice/help/nl/`:
- The backoffice elimination logic is in `Controller/Backoffice/PrepareEliminationController.php` - The backoffice elimination logic is in `Controller/Backoffice/PrepareEliminationController.php`
- Quiz timing logic starts on candidate start click and stops on final answer selection - Quiz timing logic starts on candidate start click and stops on final answer selection
- Background music feature noted but not yet implemented (requirements only) - Background music feature noted but not yet implemented (requirements only)
- Statistics module (per-quiz statistics page, candidate accusation matrix, caching) is planned per GitHub issue #199 - Statistics functionality is marked TBD in README
see "Domain Context" above for why candidate-bound answers matter to it
-4
View File
@@ -109,7 +109,3 @@ RUN set -eux; \
bin/console sass:build; \ bin/console sass:build; \
bin/console asset-map:compile --no-debug --quiet --no-ansi; \ bin/console asset-map:compile --no-debug --quiet --no-ansi; \
sync; sync;
# Build timestamp for /.well-known/security.txt Expires; must be injected last to avoid cache busting.
ARG BUILD_TIME=""
ENV BUILD_TIME=$BUILD_TIME
+1 -62
View File
@@ -1,70 +1,9 @@
# Load per-worktree overrides (project name, image tag, ports) generated by `just init` up *args:
set dotenv-load := true
set dotenv-filename := ".env.local"
# Generate a per-worktree COMPOSE_PROJECT_NAME, IMAGES_PREFIX and free host ports in .env.local,
# so multiple worktrees/checkouts of this repo can run `just up` at the same time without their
# containers, volumes, images or ports colliding. Safe to re-run; no-ops if already configured.
init:
#!/usr/bin/env bash
set -euo pipefail
if [ -f .env.local ] && grep -q '^COMPOSE_PROJECT_NAME=' .env.local; then
echo ".env.local already configured for this worktree, skipping (delete it to regenerate)."
exit 0
fi
free_port() {
local port="$1" max="$2"
while [ "$port" -le "$max" ]; do
if ! (exec 3<>/dev/tcp/127.0.0.1/"$port") 2>/dev/null; then
echo "$port"
return
fi
exec 3>&- 2>/dev/null || true
port=$((port + 1))
done
echo "no free port found between $1 and $2" >&2
exit 1
}
if command -v sha1sum >/dev/null 2>&1; then
hash_cmd=(sha1sum)
elif command -v shasum >/dev/null 2>&1; then
hash_cmd=(shasum)
else
hash_cmd=(sha256sum)
fi
hash=$(pwd | "${hash_cmd[@]}" | cut -c1-8)
project="tvdt-${hash}"
http_port=$(free_port 8080 8179)
https_port=$(free_port 8443 8542)
postgres_port=$(free_port 5430 5529)
mailpit_port=$(free_port 8025 8124)
spotlight_port=$(free_port 8969 9068)
{
echo "COMPOSE_PROJECT_NAME=${project}"
echo "IMAGES_PREFIX=${project}-"
echo "HTTP_PORT=${http_port}"
echo "HTTPS_PORT=${https_port}"
echo "POSTGRES_PORT=${postgres_port}"
echo "MAILPIT_PORT=${mailpit_port}"
echo "SPOTLIGHT_PORT=${spotlight_port}"
} >> .env.local
echo "Generated .env.local for this worktree:"
cat .env.local
up *args: init
#!/usr/bin/env bash
set -a
[ -f .env.local ] && source .env.local
set +a
docker compose up -d {{ args }} docker compose up -d {{ args }}
down *args: down *args:
docker compose down --remove-orphans {{ args }} docker compose down --remove-orphans {{ args }}
# Show the host ports assigned to this worktree (see `just init`)
ports:
@cat .env.local 2>/dev/null || echo "No .env.local yet, run 'just up' or 'just init' first."
stop: stop:
docker compose stop docker compose stop
+2 -7
View File
@@ -25,13 +25,8 @@ just migrate # Run pending database migrations
just fixtures # Load dev fixtures (truncates first) just fixtures # Load dev fixtures (truncates first)
``` ```
`just up` first runs `just init`, which generates a `.env.local` (gitignored) The app is available at **https://localhost** (self-signed cert — run
with a unique `COMPOSE_PROJECT_NAME`, image tag and free host ports for this `just trust-cert` on macOS to trust it).
checkout, so multiple worktrees/clones can run at the same time without their
containers, volumes, images or ports colliding. Run `just ports` to see the
ports assigned to the current checkout — the app is served at
`https://localhost:<HTTPS_PORT>` (self-signed cert — run `just trust-cert` on
macOS to trust it).
### Useful commands ### Useful commands
+2 -1
View File
@@ -1,7 +1,8 @@
import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.min.css'; import 'bootstrap-icons/font/bootstrap-icons.min.css';
import './styles/backoffice.scss'; import './styles/backoffice.scss';
import '@hotwired/turbo'; import {session as turboSession} from '@hotwired/turbo';
turboSession.drive = false;
import './stimulus.js'; import './stimulus.js';
import './bootstrap.js'; import './bootstrap.js';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
@@ -6,30 +6,30 @@ export default class extends Controller {
connect() { connect() {
this.index = this.collectionTarget.children.length; this.index = this.collectionTarget.children.length;
this._setupDrag();
this._syncOrdering(); this._syncOrdering();
if (this.index === 0) { if (this.index === 0) {
this.addItem(); this.addItem();
} }
// `submit` fires on the ancestor <form>, which is outside this controller's this.collectionTarget.addEventListener('input', (e) => {
// subtree. Stimulus data-action only works within the controller element, so if (e.target.type !== 'text') return;
// addEventListener on the form is the only option here. const item = e.target.closest('[data-collection-item]');
this._form = this.element.closest('form'); const last = [...this.collectionTarget.children].at(-1);
if (this._form) { if (item && item === last && e.target.value.trim() !== '') {
this._submitHandler = () => { this.addItem();
}
});
const form = this.element.closest('form');
if (form) {
form.addEventListener('submit', () => {
[...this.collectionTarget.children].forEach(item => { [...this.collectionTarget.children].forEach(item => {
const input = item.querySelector('input[type="text"]'); const input = item.querySelector('input[type="text"]');
if (input && input.value.trim() === '') item.remove(); if (input && input.value.trim() === '') item.remove();
}); });
}; });
this._form.addEventListener('submit', this._submitHandler);
}
}
disconnect() {
if (this._form && this._submitHandler) {
this._form.removeEventListener('submit', this._submitHandler);
} }
} }
@@ -38,6 +38,7 @@ export default class extends Controller {
item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index); item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index);
const el = item.firstElementChild; const el = item.firstElementChild;
this.collectionTarget.appendChild(el); this.collectionTarget.appendChild(el);
this._makeDraggable(el);
this.index++; this.index++;
this._syncOrdering(); this._syncOrdering();
} }
@@ -70,61 +71,59 @@ export default class extends Controller {
this._notifyChange(); this._notifyChange();
} }
autoExpand(event) { _notifyChange() {
if (event.target.type !== 'text') return; this.element.dispatchEvent(new Event('change', {bubbles: true}));
const item = event.target.closest('[data-collection-item]');
const last = [...this.collectionTarget.children].at(-1);
if (item && item === last && event.target.value.trim() !== '') {
this.addItem();
}
} }
// — drag-and-drop — // — drag-and-drop —
dragStart(event) { _setupDrag() {
this._dragging = event.currentTarget.closest('[data-collection-item]'); [...this.collectionTarget.children].forEach(el => this._makeDraggable(el));
this._dragging.classList.add('opacity-50');
event.dataTransfer.effectAllowed = 'move';
} }
dragEnd(event) { _makeDraggable(el) {
event.currentTarget.closest('[data-collection-item]').classList.remove('opacity-50'); const handle = el.querySelector('[data-drag-handle]');
this._dragging = null; if (!handle) return;
this.collectionTarget.querySelectorAll('[data-collection-item]').forEach(i =>
i.classList.remove('border-top', 'border-bottom', 'border-primary'),
);
}
dragOver(event) { handle.setAttribute('draggable', 'true');
event.preventDefault();
const el = event.currentTarget;
if (!this._dragging || this._dragging === el) return;
event.dataTransfer.dropEffect = 'move';
const rect = el.getBoundingClientRect();
const isBottom = event.clientY > rect.top + rect.height / 2;
el.classList.toggle('border-top', !isBottom);
el.classList.toggle('border-bottom', isBottom);
el.classList.add('border-primary');
}
dragLeave(event) { handle.addEventListener('dragstart', (e) => {
event.currentTarget.classList.remove('border-top', 'border-bottom', 'border-primary'); this._dragging = el;
} el.classList.add('opacity-50');
e.dataTransfer.effectAllowed = 'move';
});
drop(event) { handle.addEventListener('dragend', () => {
event.preventDefault(); this._dragging = null;
const el = event.currentTarget; el.classList.remove('opacity-50');
el.classList.remove('border-top', 'border-bottom', 'border-primary'); this.collectionTarget.querySelectorAll('[data-collection-item]').forEach(i => i.classList.remove('border-top', 'border-bottom', 'border-primary'));
if (!this._dragging || this._dragging === el) return; });
const rect = el.getBoundingClientRect();
const isBottom = event.clientY > rect.top + rect.height / 2;
this.collectionTarget.insertBefore(this._dragging, isBottom ? el.nextSibling : el);
this._syncOrdering();
this._notifyChange();
}
_notifyChange() { el.addEventListener('dragover', (e) => {
this.element.dispatchEvent(new Event('change', {bubbles: true})); e.preventDefault();
if (!this._dragging || this._dragging === el) return;
e.dataTransfer.dropEffect = 'move';
const rect = el.getBoundingClientRect();
const isBottom = e.clientY > rect.top + rect.height / 2;
el.classList.toggle('border-top', !isBottom);
el.classList.toggle('border-bottom', isBottom);
el.classList.add('border-primary');
});
el.addEventListener('dragleave', () => {
el.classList.remove('border-top', 'border-bottom', 'border-primary');
});
el.addEventListener('drop', (e) => {
e.preventDefault();
el.classList.remove('border-top', 'border-bottom', 'border-primary');
if (!this._dragging || this._dragging === el) return;
const rect = el.getBoundingClientRect();
const isBottom = e.clientY > rect.top + rect.height / 2;
this.collectionTarget.insertBefore(this._dragging, isBottom ? el.nextSibling : el);
this._syncOrdering();
this._notifyChange();
});
} }
_syncOrdering() { _syncOrdering() {
+15 -16
View File
@@ -1,6 +1,5 @@
import {Controller} from '@hotwired/stimulus'; import {Controller} from '@hotwired/stimulus';
import {Modal} from 'bootstrap'; import {Modal} from 'bootstrap';
import {visit} from '@hotwired/turbo';
export default class extends Controller { export default class extends Controller {
static targets = ['modal', 'frame']; static targets = ['modal', 'frame'];
@@ -12,36 +11,36 @@ export default class extends Controller {
const titleEl = this.modalTarget.querySelector('.modal-title'); const titleEl = this.modalTarget.querySelector('.modal-title');
if (titleEl) titleEl.textContent = modalTitle; if (titleEl) titleEl.textContent = modalTitle;
} }
this.resetDirty(); this._resetDirty();
this.frameTarget.innerHTML = '<div class="modal-body text-center py-4"><div class="spinner-border" role="status"></div></div>'; this.frameTarget.innerHTML = '<div class="modal-body text-center py-4"><div class="spinner-border" role="status"></div></div>';
this.frameTarget.removeAttribute('src'); this.frameTarget.removeAttribute('src');
this.frameTarget.setAttribute('src', src); this.frameTarget.setAttribute('src', src);
Modal.getOrCreateInstance(this.modalTarget).show(); Modal.getOrCreateInstance(this.modalTarget).show();
} }
frameLoad() {
this._bindDirty();
}
frameSubmitEnd(event) { frameSubmitEnd(event) {
if (event.detail.success) { if (event.detail.success) {
Modal.getOrCreateInstance(this.modalTarget).hide(); Modal.getOrCreateInstance(this.modalTarget).hide();
visit(window.location.href); window.location.reload();
} }
} }
markDirty() { _bindDirty() {
if (this._dirty) return;
this._dirty = true;
// Using _config instead of preventDefault on hide.bs.modal because we need
// to block only user-triggered dismissal (backdrop click, Escape key) while
// keeping programmatic hide() working — frameSubmitEnd() calls hide() after
// a successful save and must not be blocked. _config.backdrop/keyboard is
// the correct primitive for that distinction and has been stable across all
// Bootstrap 5.x releases.
const modal = Modal.getOrCreateInstance(this.modalTarget); const modal = Modal.getOrCreateInstance(this.modalTarget);
modal._config.backdrop = 'static'; const markDirty = () => {
modal._config.keyboard = false; modal._config.backdrop = 'static';
modal._config.keyboard = false;
};
this.frameTarget.addEventListener('input', markDirty, {once: true});
this.frameTarget.addEventListener('change', markDirty, {once: true});
this.modalTarget.addEventListener('hidden.bs.modal', () => this._resetDirty(), {once: true});
} }
resetDirty() { _resetDirty() {
this._dirty = false;
const modal = Modal.getOrCreateInstance(this.modalTarget); const modal = Modal.getOrCreateInstance(this.modalTarget);
modal._config.backdrop = true; modal._config.backdrop = true;
modal._config.keyboard = true; modal._config.keyboard = true;
@@ -1,13 +0,0 @@
import {Controller} from '@hotwired/stimulus';
import {Popover} from 'bootstrap';
export default class extends Controller {
connect() {
this.popovers = [...this.element.querySelectorAll('[data-bs-toggle="popover"]')]
.map(popoverTriggerEl => Popover.getOrCreateInstance(popoverTriggerEl));
}
disconnect() {
this.popovers.forEach(popover => popover.dispose());
}
}
@@ -1,67 +1,83 @@
import {Controller} from '@hotwired/stimulus'; import {Controller} from '@hotwired/stimulus';
import {Modal} from 'bootstrap';
const RETRY_DELAY_MS = 1000;
export default class extends Controller { export default class extends Controller {
static targets = ['list', 'item', 'status']; static targets = ['list', 'item', 'status', 'noticeModal'];
static values = { static values = {
reorderUrl: String, reorderUrl: String,
csrf: String, csrf: String,
canModify: Boolean,
savedLabel: String, savedLabel: String,
errorLabel: String, errorLabel: String,
errorHint: String,
}; };
connect() { connect() {
this._locked = false; if (this.canModifyValue) {
} this._setupDrag();
dragStart(event) {
const item = event.currentTarget.closest('[data-bo--question-list-target="item"]');
this._dragging = item;
event.dataTransfer.effectAllowed = 'move';
setTimeout(() => item.classList.add('opacity-50'), 0);
}
dragEnd(event) {
const item = event.currentTarget.closest('[data-bo--question-list-target="item"]');
item.classList.remove('opacity-50');
this._dragging = null;
this._removePlaceholder();
}
dragOver(event) {
event.preventDefault();
if (!this._dragging) return;
event.dataTransfer.dropEffect = 'move';
const target = event.target.closest('[data-bo--question-list-target="item"]');
if (!target || target === this._dragging) return;
const rect = target.getBoundingClientRect();
const insertBefore = event.clientY > rect.top + rect.height / 2 ? target.nextSibling : target;
if (!this._placeholder) {
this._placeholder = document.createElement('div');
this._placeholder.className = 'bg-primary rounded mb-2';
this._placeholder.style.height = '3px';
}
if (this._placeholder.nextSibling !== insertBefore) {
this.listTarget.insertBefore(this._placeholder, insertBefore);
} }
} }
dragLeave(event) { _setupDrag() {
if (!event.relatedTarget || !this.listTarget.contains(event.relatedTarget)) { this.itemTargets.forEach(el => {
const handle = el.querySelector('[data-drag-handle]');
if (!handle) return;
handle.setAttribute('draggable', 'true');
handle.addEventListener('dragstart', (e) => {
if (this._locked) {
e.preventDefault();
return;
}
this._dragging = el;
e.dataTransfer.effectAllowed = 'move';
setTimeout(() => el.classList.add('opacity-50'), 0);
});
handle.addEventListener('dragend', () => {
el.classList.remove('opacity-50');
this._dragging = null;
this._removePlaceholder();
});
});
this.listTarget.addEventListener('dragover', (e) => {
e.preventDefault();
if (!this._dragging) return;
e.dataTransfer.dropEffect = 'move';
const target = e.target.closest('[data-bo--question-list-target="item"]');
if (!target || target === this._dragging) return;
const rect = target.getBoundingClientRect();
const insertBefore = e.clientY > rect.top + rect.height / 2 ? target.nextSibling : target;
if (!this._placeholder) {
this._placeholder = document.createElement('div');
this._placeholder.className = 'bg-primary rounded mb-2';
this._placeholder.style.height = '3px';
}
if (this._placeholder.nextSibling !== insertBefore) {
this.listTarget.insertBefore(this._placeholder, insertBefore);
}
});
this.listTarget.addEventListener('dragleave', (e) => {
if (!e.relatedTarget || !this.listTarget.contains(e.relatedTarget)) {
this._removePlaceholder();
}
});
this.listTarget.addEventListener('drop', async (e) => {
e.preventDefault();
if (!this._dragging || !this._placeholder) return;
this.listTarget.insertBefore(this._dragging, this._placeholder);
this._removePlaceholder(); this._removePlaceholder();
} await this._persistOrder();
} });
async drop(event) {
event.preventDefault();
if (!this._dragging || !this._placeholder || this._locked) return;
this.listTarget.insertBefore(this._dragging, this._placeholder);
this._removePlaceholder();
await this._persistOrder();
} }
_removePlaceholder() { _removePlaceholder() {
@@ -98,26 +114,40 @@ export default class extends Controller {
if (numberEl) numberEl.textContent = String(i + 1); if (numberEl) numberEl.textContent = String(i + 1);
}); });
for (let attempt = 0; attempt < 2; attempt++) { const attempt = async () => {
const res = await fetch(this.reorderUrlValue, {method: 'POST', body: params});
if (!res.ok) {
throw new Error(`Unexpected response status: ${res.status}`);
}
};
try {
await attempt();
} catch {
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
try { try {
const res = await fetch(this.reorderUrlValue, {method: 'POST', body: params}); await attempt();
if (res.ok) {
this._setStatus('saved');
return;
}
} catch { } catch {
// network error — retry on first attempt this._setStatus('error');
this._lockReordering();
return;
} }
} }
this._locked = true; this._setStatus('saved');
this._setStatus('error'); }
const alert = document.createElement('div'); _lockReordering() {
alert.className = 'alert alert-danger alert-dismissible mt-3'; this._locked = true;
alert.setAttribute('role', 'alert'); this.itemTargets.forEach(el => {
const hint = this.errorHintValue || 'Refresh the page to try again.'; const handle = el.querySelector('[data-drag-handle]');
alert.innerHTML = `${this.errorLabelValue || 'Error saving order'} &mdash; ${hint} <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>`; if (handle) {
this.listTarget.after(alert); handle.removeAttribute('draggable');
handle.classList.add('opacity-25', 'pe-none');
}
});
if (this.hasNoticeModalTarget) {
Modal.getOrCreateInstance(this.noticeModalTarget).show();
}
} }
} }
@@ -1,40 +0,0 @@
import {Controller} from '@hotwired/stimulus';
const STORAGE_KEY = 'tvdt-fullscreen';
export default class extends Controller {
connect() {
this.onFullscreenChange = this.onFullscreenChange.bind(this);
document.addEventListener('fullscreenchange', this.onFullscreenChange);
this.syncState();
if (sessionStorage.getItem(STORAGE_KEY) === '1' && !document.fullscreenElement) {
this.request();
}
}
disconnect() {
document.removeEventListener('fullscreenchange', this.onFullscreenChange);
}
toggle() {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
this.request();
}
}
request() {
document.documentElement.requestFullscreen().catch(() => {});
}
onFullscreenChange() {
sessionStorage.setItem(STORAGE_KEY, document.fullscreenElement ? '1' : '0');
this.syncState();
}
syncState() {
document.documentElement.classList.toggle('is-fullscreen', Boolean(document.fullscreenElement));
}
}
-26
View File
@@ -94,32 +94,6 @@ input.btn-check:checked + label.answer-btn {
gap: 0.5rem; gap: 0.5rem;
} }
.fullscreen-btn {
position: fixed;
bottom: 0.75rem;
left: 0.75rem;
z-index: 1040;
width: 2.25rem;
height: 2.25rem;
padding: 0;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.35);
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
&:hover,
&:focus {
color: rgba(255, 255, 255, 0.8);
outline: none;
}
}
html.is-fullscreen .fullscreen-btn {
display: none;
}
.elimination-screen { .elimination-screen {
position: fixed; position: fixed;
top: 0; top: 0;
+6 -6
View File
@@ -24,15 +24,15 @@ services:
ports: ports:
# HTTP # HTTP
- target: 80 - target: 80
published: ${HTTP_PORT:-8080} published: ${HTTP_PORT:-80}
protocol: tcp protocol: tcp
# HTTPS # HTTPS
- target: 443 - target: 443
published: ${HTTPS_PORT:-8443} published: ${HTTPS_PORT:-443}
protocol: tcp protocol: tcp
# HTTP/3 # HTTP/3
- target: 443 - target: 443
published: ${HTTPS_PORT:-8443} published: ${HTTP3_PORT:-443}
protocol: udp protocol: udp
sass: sass:
image: ${IMAGES_PREFIX:-}app-php image: ${IMAGES_PREFIX:-}app-php
@@ -56,7 +56,7 @@ services:
###> doctrine/doctrine-bundle ### ###> doctrine/doctrine-bundle ###
database: database:
ports: ports:
- "${POSTGRES_PORT:-5430}:5432" - "5432:5432"
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###
###> symfony/mailer ### ###> symfony/mailer ###
@@ -64,7 +64,7 @@ services:
image: axllent/mailpit image: axllent/mailpit
ports: ports:
- "1025" - "1025"
- "${MAILPIT_PORT:-8025}:8025" - "8025:8025"
environment: environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1 MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1 MP_SMTP_AUTH_ALLOW_INSECURE: 1
@@ -73,7 +73,7 @@ services:
spotlight: spotlight:
image: ghcr.io/getsentry/spotlight:latest image: ghcr.io/getsentry/spotlight:latest
ports: ports:
- "${SPOTLIGHT_PORT:-8969}:8969" - "8969:8969"
volumes: volumes:
sass: sass:
-2
View File
@@ -35,14 +35,12 @@
"symfony/security-bundle": "8.1.*", "symfony/security-bundle": "8.1.*",
"symfony/security-csrf": "8.1.*", "symfony/security-csrf": "8.1.*",
"symfony/serializer": "8.1.*", "symfony/serializer": "8.1.*",
"symfony/string": "8.1.*",
"symfony/translation": "8.1.*", "symfony/translation": "8.1.*",
"symfony/twig-bundle": "8.1.*", "symfony/twig-bundle": "8.1.*",
"symfony/uid": "8.1.*", "symfony/uid": "8.1.*",
"symfony/ux-turbo": "^3.1", "symfony/ux-turbo": "^3.1",
"symfony/validator": "8.1.*", "symfony/validator": "8.1.*",
"symfony/yaml": "8.1.*", "symfony/yaml": "8.1.*",
"symfonycasts/reset-password-bundle": "^1.25",
"symfonycasts/sass-bundle": "^0.10", "symfonycasts/sass-bundle": "^0.10",
"symfonycasts/verify-email-bundle": "^1.18.0", "symfonycasts/verify-email-bundle": "^1.18.0",
"thecodingmachine/safe": "^3.4.0", "thecodingmachine/safe": "^3.4.0",
Generated
+67 -114
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "ccae654dd9c952e8920d9cb9c0f35ff5", "content-hash": "7171824ca13f4df0801dfa5d7f58d6a0",
"packages": [ "packages": [
{ {
"name": "composer/pcre", "name": "composer/pcre",
@@ -1475,16 +1475,16 @@
}, },
{ {
"name": "guzzlehttp/psr7", "name": "guzzlehttp/psr7",
"version": "2.12.4", "version": "2.12.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/guzzle/psr7.git", "url": "https://github.com/guzzle/psr7.git",
"reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c" "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c", "url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
"reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c", "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1574,7 +1574,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/guzzle/psr7/issues", "issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/2.12.4" "source": "https://github.com/guzzle/psr7/tree/2.12.3"
}, },
"funding": [ "funding": [
{ {
@@ -1590,7 +1590,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-07-08T15:56:20+00:00" "time": "2026-06-23T15:21:08+00:00"
}, },
{ {
"name": "jean85/pretty-package-versions", "name": "jean85/pretty-package-versions",
@@ -2253,16 +2253,16 @@
}, },
{ {
"name": "phpstan/phpdoc-parser", "name": "phpstan/phpdoc-parser",
"version": "2.3.3", "version": "2.3.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git", "url": "https://github.com/phpstan/phpdoc-parser.git",
"reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a",
"reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2294,9 +2294,9 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types", "description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": { "support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues", "issues": "https://github.com/phpstan/phpdoc-parser/issues",
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2"
}, },
"time": "2026-07-08T07:01:06+00:00" "time": "2026-01-25T14:56:51+00:00"
}, },
{ {
"name": "psr/cache", "name": "psr/cache",
@@ -8281,54 +8281,6 @@
], ],
"time": "2026-06-09T11:06:24+00:00" "time": "2026-06-09T11:06:24+00:00"
}, },
{
"name": "symfonycasts/reset-password-bundle",
"version": "v1.25.0",
"source": {
"type": "git",
"url": "https://github.com/SymfonyCasts/reset-password-bundle.git",
"reference": "084aac1cc40ef75b26134c7967d1e423eeff72f4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SymfonyCasts/reset-password-bundle/zipball/084aac1cc40ef75b26134c7967d1e423eeff72f4",
"reference": "084aac1cc40ef75b26134c7967d1e423eeff72f4",
"shasum": ""
},
"require": {
"php": ">=8.1.10",
"symfony/clock": "^6.3 | ^7.0 | ^8.0",
"symfony/config": "^5.4 | ^6.0 | ^7.0 | ^8.0",
"symfony/dependency-injection": "^5.4 | ^6.0 | ^7.0 | ^8.0",
"symfony/deprecation-contracts": "^2.2 | ^3.0",
"symfony/http-kernel": "^5.4 | ^6.0 | ^7.0 | ^8.0"
},
"require-dev": {
"doctrine/annotations": "^1.0 | ^2.0",
"doctrine/doctrine-bundle": "^2.13 | ^3.0",
"doctrine/orm": "^2.20 | ^3.0",
"symfony/framework-bundle": "^5.4 | ^6.0 | ^7.0 | ^8.0",
"symfony/phpunit-bridge": "^5.4 | ^6.0 | ^7.0 | ^8.0",
"symfony/process": "^6.4 | ^7.0 | ^8.0",
"symfonycasts/internal-test-helpers": "dev-main"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"SymfonyCasts\\Bundle\\ResetPassword\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Symfony bundle that adds password reset functionality.",
"support": {
"issues": "https://github.com/SymfonyCasts/reset-password-bundle/issues",
"source": "https://github.com/SymfonyCasts/reset-password-bundle/tree/v1.25.0"
},
"time": "2026-03-26T10:16:40+00:00"
},
{ {
"name": "symfonycasts/sass-bundle", "name": "symfonycasts/sass-bundle",
"version": "v0.10.0", "version": "v0.10.0",
@@ -9408,16 +9360,16 @@
}, },
{ {
"name": "friendsofphp/php-cs-fixer", "name": "friendsofphp/php-cs-fixer",
"version": "v3.95.12", "version": "v3.95.11",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
"reference": "b1b9055997a98dce3c2338e884626e718a25a923" "reference": "35f98e1293283397824d7f349ce5afb8747c3cd5"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/b1b9055997a98dce3c2338e884626e718a25a923", "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/35f98e1293283397824d7f349ce5afb8747c3cd5",
"reference": "b1b9055997a98dce3c2338e884626e718a25a923", "reference": "35f98e1293283397824d7f349ce5afb8747c3cd5",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -9457,7 +9409,7 @@
"php-coveralls/php-coveralls": "^2.9.1", "php-coveralls/php-coveralls": "^2.9.1",
"php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8",
"php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8",
"phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56", "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55",
"symfony/polyfill-php85": "^1.38", "symfony/polyfill-php85": "^1.38",
"symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.0", "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.0",
"symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.0" "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.0"
@@ -9501,7 +9453,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.12" "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.11"
}, },
"funding": [ "funding": [
{ {
@@ -9509,7 +9461,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-07-07T13:29:36+00:00" "time": "2026-06-25T14:17:04+00:00"
}, },
{ {
"name": "myclabs/deep-copy", "name": "myclabs/deep-copy",
@@ -9573,19 +9525,20 @@
}, },
{ {
"name": "nikic/php-parser", "name": "nikic/php-parser",
"version": "v5.8.0", "version": "v5.7.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/nikic/PHP-Parser.git", "url": "https://github.com/nikic/PHP-Parser.git",
"reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
"reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-ctype": "*",
"ext-json": "*", "ext-json": "*",
"ext-tokenizer": "*", "ext-tokenizer": "*",
"php": ">=7.4" "php": ">=7.4"
@@ -9624,9 +9577,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/nikic/PHP-Parser/issues", "issues": "https://github.com/nikic/PHP-Parser/issues",
"source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
}, },
"time": "2026-07-04T14:30:18+00:00" "time": "2025-12-06T11:56:16+00:00"
}, },
{ {
"name": "phar-io/manifest", "name": "phar-io/manifest",
@@ -9796,11 +9749,11 @@
}, },
{ {
"name": "phpstan/phpstan", "name": "phpstan/phpstan",
"version": "2.2.5", "version": "2.2.4",
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", "url": "https://api.github.com/repos/phpstan/phpstan/zipball/f0fe3fb03bb53ce68cc2416785b260e62226ec27",
"reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", "reference": "f0fe3fb03bb53ce68cc2416785b260e62226ec27",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -9856,7 +9809,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-07-05T06:31:06+00:00" "time": "2026-07-03T07:00:23+00:00"
}, },
{ {
"name": "phpstan/phpstan-doctrine", "name": "phpstan/phpstan-doctrine",
@@ -9937,16 +9890,16 @@
}, },
{ {
"name": "phpstan/phpstan-phpunit", "name": "phpstan/phpstan-phpunit",
"version": "2.0.18", "version": "2.0.17",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpstan/phpstan-phpunit.git", "url": "https://github.com/phpstan/phpstan-phpunit.git",
"reference": "f5dc20ff8082d02339b60cab68ec3eb0d859fb30" "reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/f5dc20ff8082d02339b60cab68ec3eb0d859fb30", "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/c2f977551f0736d60467b3d754b2e0cf4e337b3f",
"reference": "f5dc20ff8082d02339b60cab68ec3eb0d859fb30", "reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -9989,9 +9942,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/phpstan/phpstan-phpunit/issues", "issues": "https://github.com/phpstan/phpstan-phpunit/issues",
"source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.18" "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.17"
}, },
"time": "2026-07-04T12:16:09+00:00" "time": "2026-06-29T05:32:23+00:00"
}, },
{ {
"name": "phpstan/phpstan-symfony", "name": "phpstan/phpstan-symfony",
@@ -10069,16 +10022,16 @@
}, },
{ {
"name": "phpunit/php-code-coverage", "name": "phpunit/php-code-coverage",
"version": "14.2.3", "version": "14.2.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
"reference": "82f6e49ff224e2cde923d74425e583a883910783" "reference": "10d7da3628a99289cdf4c662dd7f0d73f1baec83"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/82f6e49ff224e2cde923d74425e583a883910783", "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/10d7da3628a99289cdf4c662dd7f0d73f1baec83",
"reference": "82f6e49ff224e2cde923d74425e583a883910783", "reference": "10d7da3628a99289cdf4c662dd7f0d73f1baec83",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -10086,7 +10039,7 @@
"ext-libxml": "*", "ext-libxml": "*",
"ext-mbstring": "*", "ext-mbstring": "*",
"ext-xmlwriter": "*", "ext-xmlwriter": "*",
"nikic/php-parser": "^5.8.0", "nikic/php-parser": "^5.7.0",
"php": ">=8.4", "php": ">=8.4",
"phpunit/php-text-template": "^6.0", "phpunit/php-text-template": "^6.0",
"sebastian/complexity": "^6.0", "sebastian/complexity": "^6.0",
@@ -10097,7 +10050,7 @@
"theseer/tokenizer": "^2.0.1" "theseer/tokenizer": "^2.0.1"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^13.2.2" "phpunit/phpunit": "^13.2.0"
}, },
"suggest": { "suggest": {
"ext-pcov": "PHP extension that provides line coverage", "ext-pcov": "PHP extension that provides line coverage",
@@ -10135,7 +10088,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.3" "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.2"
}, },
"funding": [ "funding": [
{ {
@@ -10155,7 +10108,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-07-06T15:04:02+00:00" "time": "2026-06-08T11:50:38+00:00"
}, },
{ {
"name": "phpunit/php-file-iterator", "name": "phpunit/php-file-iterator",
@@ -10452,30 +10405,30 @@
}, },
{ {
"name": "phpunit/phpunit", "name": "phpunit/phpunit",
"version": "13.2.4", "version": "13.2.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git", "url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "8f5180f4627fc1978be2f61d8d9979dbe37e0c10" "reference": "492c067e618de7b3c76105082c90f9d2833401b7"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/8f5180f4627fc1978be2f61d8d9979dbe37e0c10", "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/492c067e618de7b3c76105082c90f9d2833401b7",
"reference": "8f5180f4627fc1978be2f61d8d9979dbe37e0c10", "reference": "492c067e618de7b3c76105082c90f9d2833401b7",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-dom": "*", "ext-dom": "*",
"ext-filter": "*",
"ext-json": "*", "ext-json": "*",
"ext-libxml": "*", "ext-libxml": "*",
"ext-mbstring": "*", "ext-mbstring": "*",
"ext-xml": "*",
"ext-xmlwriter": "*", "ext-xmlwriter": "*",
"myclabs/deep-copy": "^1.13.4", "myclabs/deep-copy": "^1.13.4",
"phar-io/manifest": "^2.0.4", "phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1", "phar-io/version": "^3.2.1",
"php": ">=8.4.1", "php": ">=8.4.1",
"phpunit/php-code-coverage": "^14.2.3", "phpunit/php-code-coverage": "^14.2.2",
"phpunit/php-file-iterator": "^7.0.0", "phpunit/php-file-iterator": "^7.0.0",
"phpunit/php-invoker": "^7.0.0", "phpunit/php-invoker": "^7.0.0",
"phpunit/php-text-template": "^6.0.0", "phpunit/php-text-template": "^6.0.0",
@@ -10532,7 +10485,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues", "issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy", "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.4" "source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.2"
}, },
"funding": [ "funding": [
{ {
@@ -10540,7 +10493,7 @@
"type": "other" "type": "other"
} }
], ],
"time": "2026-07-08T08:36:51+00:00" "time": "2026-06-29T13:36:29+00:00"
}, },
{ {
"name": "react/cache", "name": "react/cache",
@@ -11070,16 +11023,16 @@
}, },
{ {
"name": "rector/rector", "name": "rector/rector",
"version": "2.5.5", "version": "2.5.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/rectorphp/rector.git", "url": "https://github.com/rectorphp/rector.git",
"reference": "9718a72e7f1aacacbdcb6eeed07a47147bce802e" "reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/rectorphp/rector/zipball/9718a72e7f1aacacbdcb6eeed07a47147bce802e", "url": "https://api.github.com/repos/rectorphp/rector/zipball/49ff6339174bdbdf50b0b35ecbcff14a05ac9e24",
"reference": "9718a72e7f1aacacbdcb6eeed07a47147bce802e", "reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11118,7 +11071,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/rectorphp/rector/issues", "issues": "https://github.com/rectorphp/rector/issues",
"source": "https://github.com/rectorphp/rector/tree/2.5.5" "source": "https://github.com/rectorphp/rector/tree/2.5.2"
}, },
"funding": [ "funding": [
{ {
@@ -11126,7 +11079,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-07-09T09:48:44+00:00" "time": "2026-06-22T11:39:33+00:00"
}, },
{ {
"name": "sebastian/cli-parser", "name": "sebastian/cli-parser",
@@ -11818,24 +11771,24 @@
}, },
{ {
"name": "sebastian/lines-of-code", "name": "sebastian/lines-of-code",
"version": "5.0.2", "version": "5.0.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/lines-of-code.git", "url": "https://github.com/sebastianbergmann/lines-of-code.git",
"reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8" "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d2cff273a90c79b0eb590baa682d4b5c318bdbb7",
"reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"nikic/php-parser": "^5.8.0", "nikic/php-parser": "^5.7.0",
"php": ">=8.4" "php": ">=8.4"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^13.2.4" "phpunit/phpunit": "^13.1.10"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@@ -11864,7 +11817,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
"security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
"source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.2" "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.1"
}, },
"funding": [ "funding": [
{ {
@@ -11884,7 +11837,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-07-09T08:42:34+00:00" "time": "2026-05-19T16:23:37+00:00"
}, },
{ {
"name": "sebastian/object-enumerator", "name": "sebastian/object-enumerator",
-2
View File
@@ -15,7 +15,6 @@ use Symfony\Bundle\TwigBundle\TwigBundle;
use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle; use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle;
use Symfony\UX\StimulusBundle\StimulusBundle; use Symfony\UX\StimulusBundle\StimulusBundle;
use Symfony\UX\Turbo\TurboBundle; use Symfony\UX\Turbo\TurboBundle;
use SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle;
use SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle; use SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle;
use Symfonycasts\SassBundle\SymfonycastsSassBundle; use Symfonycasts\SassBundle\SymfonycastsSassBundle;
use Twig\Extra\TwigExtraBundle\TwigExtraBundle; use Twig\Extra\TwigExtraBundle\TwigExtraBundle;
@@ -37,5 +36,4 @@ return [
TurboBundle::class => ['all' => true], TurboBundle::class => ['all' => true],
DAMADoctrineTestBundle::class => ['test' => true], DAMADoctrineTestBundle::class => ['test' => true],
StofDoctrineExtensionsBundle::class => ['all' => true], StofDoctrineExtensionsBundle::class => ['all' => true],
SymfonyCastsResetPasswordBundle::class => ['all' => true],
]; ];
+1 -1
View File
@@ -14,7 +14,7 @@ when@prod:
# shortcut for private IP address ranges of your proxy # shortcut for private IP address ranges of your proxy
trusted_proxies: 'private_ranges' trusted_proxies: 'private_ranges'
# or, if your proxy instead uses the "Forwarded" header # or, if your proxy instead uses the "Forwarded" header
trusted_headers: [ 'x-forwarded-proto' ] trusted_headers: [ 'forwarded' ]
when@test: when@test:
framework: framework:
-2
View File
@@ -1,2 +0,0 @@
symfonycasts_reset_password:
request_password_repository: Tvdt\Repository\ResetPasswordRequestRepository
-10
View File
@@ -1490,12 +1490,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* skip_translation_on_load?: bool|Param, // Default: false * skip_translation_on_load?: bool|Param, // Default: false
* metadata_cache_pool?: scalar|Param|null, // Default: null * metadata_cache_pool?: scalar|Param|null, // Default: null
* } * }
* @psalm-type SymfonycastsResetPasswordConfig = array{
* request_password_repository?: scalar|Param|null, // A class that implements ResetPasswordRequestRepositoryInterface - usually your ResetPasswordRequestRepository.
* lifetime?: int|Param, // The length of time in seconds that a password reset request is valid for after it is created. // Default: 3600
* throttle_limit?: int|Param, // Another password reset cannot be made faster than this throttle time in seconds. // Default: 3600
* enable_garbage_collection?: bool|Param, // Enable/Disable automatic garbage collection. // Default: true
* }
* @psalm-type ConfigType = array{ * @psalm-type ConfigType = array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
@@ -1511,7 +1505,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* stimulus?: StimulusConfig, * stimulus?: StimulusConfig,
* turbo?: TurboConfig, * turbo?: TurboConfig,
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
* "when@dev"?: array{ * "when@dev"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
@@ -1530,7 +1523,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* stimulus?: StimulusConfig, * stimulus?: StimulusConfig,
* turbo?: TurboConfig, * turbo?: TurboConfig,
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
* }, * },
* "when@prod"?: array{ * "when@prod"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
@@ -1548,7 +1540,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* stimulus?: StimulusConfig, * stimulus?: StimulusConfig,
* turbo?: TurboConfig, * turbo?: TurboConfig,
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
* }, * },
* "when@test"?: array{ * "when@test"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
@@ -1567,7 +1558,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* turbo?: TurboConfig, * turbo?: TurboConfig,
* dama_doctrine_test?: DamaDoctrineTestConfig, * dama_doctrine_test?: DamaDoctrineTestConfig,
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
* }, * },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias * ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig, * imports?: ImportsConfig,
-34
View File
@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/** Auto-generated Migration: Please modify to your needs! */
final class Version20260707205155 extends AbstractMigration
{
#[\Override]
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE reset_password_request (id UUID NOT NULL, selector VARCHAR(20) NOT NULL, hashed_token VARCHAR(100) NOT NULL, requested_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, expires_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_id UUID NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_7CE748AA76ED395 ON reset_password_request (user_id)');
$this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE');
}
#[\Override]
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE reset_password_request DROP CONSTRAINT FK_7CE748AA76ED395');
$this->addSql('DROP TABLE reset_password_request');
}
}
-19
View File
@@ -5,9 +5,6 @@ declare(strict_types=1);
namespace Tvdt\Controller; namespace Tvdt\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as AbstractBaseController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as AbstractBaseController;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Tvdt\Entity\Season;
use Tvdt\Entity\User;
use Tvdt\Enum\FlashType; use Tvdt\Enum\FlashType;
abstract class AbstractController extends AbstractBaseController abstract class AbstractController extends AbstractBaseController
@@ -16,22 +13,6 @@ abstract class AbstractController extends AbstractBaseController
protected const string CANDIDATE_HASH_REGEX = '[\w\-=]+'; protected const string CANDIDATE_HASH_REGEX = '[\w\-=]+';
protected User $authenticatedUser {
get {
$user = $this->getUser();
\assert($user instanceof User);
return $user;
}
}
protected function assertSameSeason(Season $season, Season $subjectSeason): void
{
if ($season !== $subjectSeason) {
throw new NotFoundHttpException();
}
}
#[\Override] #[\Override]
protected function addFlash(FlashType|string $type, mixed $message): void protected function addFlash(FlashType|string $type, mixed $message): void
{ {
@@ -14,13 +14,11 @@ use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement; use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface;
use Tvdt\Controller\AbstractController; use Tvdt\Controller\AbstractController;
use Tvdt\Entity\Quiz; use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season; use Tvdt\Entity\Season;
use Tvdt\Enum\FlashType; use Tvdt\Entity\User;
use Tvdt\Form\CreateSeasonFormType; use Tvdt\Form\CreateSeasonFormType;
use Tvdt\Helpers\FilenameSanitizer;
use Tvdt\Repository\SeasonRepository; use Tvdt\Repository\SeasonRepository;
use Tvdt\Security\Voter\SeasonVoter; use Tvdt\Security\Voter\SeasonVoter;
use Tvdt\Service\QuizSpreadsheetService; use Tvdt\Service\QuizSpreadsheetService;
@@ -34,15 +32,17 @@ final class BackofficeController extends AbstractController
private readonly Security $security, private readonly Security $security,
private readonly QuizSpreadsheetService $excel, private readonly QuizSpreadsheetService $excel,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly TranslatorInterface $translator,
) {} ) {}
#[Route('/backoffice/', name: 'tvdt_backoffice_index')] #[Route('/backoffice/', name: 'tvdt_backoffice_index')]
public function index(): Response public function index(): Response
{ {
$user = $this->getUser();
\assert($user instanceof User);
$seasons = $this->security->isGranted('ROLE_ADMIN') $seasons = $this->security->isGranted('ROLE_ADMIN')
? $this->seasonRepository->findAll() ? $this->seasonRepository->findAll()
: $this->seasonRepository->getSeasonsForUser($this->authenticatedUser); : $this->seasonRepository->getSeasonsForUser($user);
return $this->render('backoffice/index.html.twig', [ return $this->render('backoffice/index.html.twig', [
'seasons' => $seasons, 'seasons' => $seasons,
@@ -58,7 +58,10 @@ final class BackofficeController extends AbstractController
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$season->addOwner($this->authenticatedUser); $user = $this->getUser();
\assert($user instanceof User);
$season->addOwner($user);
$season->generateSeasonCode(); $season->generateSeasonCode();
$this->em->persist($season); $this->em->persist($season);
@@ -87,17 +90,11 @@ final class BackofficeController extends AbstractController
requirements: ['quiz' => Requirement::UUID], requirements: ['quiz' => Requirement::UUID],
methods: ['GET'], methods: ['GET'],
)] )]
public function exportQuiz(Quiz $quiz): Response public function exportQuiz(Quiz $quiz): StreamedResponse
{ {
if (!$this->authenticatedUser->isVerified) {
$this->addFlash(FlashType::Warning, $this->translator->trans('Please confirm your email address before exporting a quiz.'));
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $quiz->season->seasonCode]);
}
$response = new StreamedResponse($this->excel->quizToXlsx($quiz)); $response = new StreamedResponse($this->excel->quizToXlsx($quiz));
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); $response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, FilenameSanitizer::sanitize($quiz->name).'.xlsx')); $response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $quiz->name.'.xlsx'));
return $response; return $response;
} }
@@ -90,13 +90,11 @@ class QuestionBankController extends AbstractController
$isTurboFrame = $request->headers->has('Turbo-Frame'); $isTurboFrame = $request->headers->has('Turbo-Frame');
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, [ $form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]);
'season' => $season,
'action' => $this->generateUrl('tvdt_backoffice_question_bank_new', ['seasonCode' => $season->seasonCode]),
]);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$this->applyAnswerOrdering($bankQuestion);
$season->addBankQuestion($bankQuestion); $season->addBankQuestion($bankQuestion);
$this->em->persist($bankQuestion); $this->em->persist($bankQuestion);
$this->em->flush(); $this->em->flush();
@@ -114,11 +112,17 @@ class QuestionBankController extends AbstractController
? 'backoffice/question_bank/_frame.html.twig' ? 'backoffice/question_bank/_frame.html.twig'
: 'backoffice/question_bank/form.html.twig'; : 'backoffice/question_bank/form.html.twig';
return $this->render($template, [ $response = $this->render($template, [
'season' => $season, 'season' => $season,
'form' => $form, 'form' => $form,
'bankQuestion' => null, 'bankQuestion' => null,
]); ]);
if ($form->isSubmitted()) {
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $response;
} }
#[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[IsGranted(SeasonVoter::EDIT, subject: 'season')]
@@ -134,13 +138,7 @@ class QuestionBankController extends AbstractController
$isTurboFrame = $request->headers->has('Turbo-Frame'); $isTurboFrame = $request->headers->has('Turbo-Frame');
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, [ $form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]);
'season' => $season,
'action' => $this->generateUrl('tvdt_backoffice_question_bank_edit', [
'seasonCode' => $season->seasonCode,
'bankQuestion' => $bankQuestion->id,
]),
]);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
@@ -161,11 +159,17 @@ class QuestionBankController extends AbstractController
? 'backoffice/question_bank/_frame.html.twig' ? 'backoffice/question_bank/_frame.html.twig'
: 'backoffice/question_bank/form.html.twig'; : 'backoffice/question_bank/form.html.twig';
return $this->render($template, [ $response = $this->render($template, [
'season' => $season, 'season' => $season,
'form' => $form, 'form' => $form,
'bankQuestion' => $bankQuestion, 'bankQuestion' => $bankQuestion,
]); ]);
if ($form->isSubmitted()) {
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $response;
} }
#[IsCsrfTokenValid('delete_bank_question')] #[IsCsrfTokenValid('delete_bank_question')]
@@ -370,6 +374,21 @@ class QuestionBankController extends AbstractController
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
} }
private function assertSameSeason(Season $season, Season $subjectSeason): void
{
if ($season !== $subjectSeason) {
throw new NotFoundHttpException();
}
}
private function applyAnswerOrdering(BankQuestion $bankQuestion): void
{
$ordering = 1;
foreach ($bankQuestion->answers as $answer) {
$answer->ordering = $ordering++;
}
}
private function syncUsagesAfterEdit(BankQuestion $bankQuestion): void private function syncUsagesAfterEdit(BankQuestion $bankQuestion): void
{ {
$pendingNames = []; $pendingNames = [];
+38 -53
View File
@@ -25,7 +25,6 @@ use Tvdt\Entity\QuizCandidate;
use Tvdt\Entity\Season; use Tvdt\Entity\Season;
use Tvdt\Enum\FlashType; use Tvdt\Enum\FlashType;
use Tvdt\Exception\ErrorClearingQuizException; use Tvdt\Exception\ErrorClearingQuizException;
use Tvdt\Repository\GivenAnswerRepository;
use Tvdt\Repository\QuizCandidateRepository; use Tvdt\Repository\QuizCandidateRepository;
use Tvdt\Repository\QuizRepository; use Tvdt\Repository\QuizRepository;
use Tvdt\Security\Voter\SeasonVoter; use Tvdt\Security\Voter\SeasonVoter;
@@ -38,7 +37,6 @@ class QuizController extends AbstractController
private readonly QuizRepository $quizRepository, private readonly QuizRepository $quizRepository,
private readonly TranslatorInterface $translator, private readonly TranslatorInterface $translator,
private readonly QuizCandidateRepository $quizCandidateRepository, private readonly QuizCandidateRepository $quizCandidateRepository,
private readonly GivenAnswerRepository $givenAnswerRepository,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
) {} ) {}
@@ -63,7 +61,25 @@ class QuizController extends AbstractController
{ {
$fetchedQuiz = $this->quizRepository->fetchWithQuestionsAndCandidates($quiz->id); $fetchedQuiz = $this->quizRepository->fetchWithQuestionsAndCandidates($quiz->id);
$candidateData = $this->buildCandidateData($season, $quiz, $fetchedQuiz->candidateData); // Create indexed lookup for quiz candidates by candidate ID
$quizCandidatesByCandidateId = [];
foreach ($fetchedQuiz->candidateData as $qc) {
$quizCandidatesByCandidateId[$qc->candidate->id->toString()] = $qc;
}
// Get given answers counts efficiently via database query
$givenAnswersCountByCandidateId = $this->quizRepository->getGivenAnswersCountPerCandidate($quiz);
// Pre-compute candidate data to avoid nested loops in template
$candidateData = [];
foreach ($season->candidates as $candidate) {
$candidateIdString = $candidate->id->toString();
$candidateData[] = [
'candidate' => $candidate,
'quizCandidate' => $quizCandidatesByCandidateId[$candidateIdString] ?? null,
'givenAnswersCount' => $givenAnswersCountByCandidateId[$candidateIdString] ?? 0,
];
}
return $this->render('backoffice/quiz.html.twig', [ return $this->render('backoffice/quiz.html.twig', [
'season' => $season, 'season' => $season,
@@ -102,7 +118,25 @@ class QuizController extends AbstractController
)] )]
public function candidatesTab(Season $season, Quiz $quiz): Response public function candidatesTab(Season $season, Quiz $quiz): Response
{ {
$candidateData = $this->buildCandidateData($season, $quiz, $quiz->candidateData); // Create indexed lookup for quiz candidates by candidate ID
$quizCandidatesByCandidateId = [];
foreach ($quiz->candidateData as $qc) {
$quizCandidatesByCandidateId[$qc->candidate->id->toString()] = $qc;
}
// Get given answers counts efficiently via database query
$givenAnswersCountByCandidateId = $this->quizRepository->getGivenAnswersCountPerCandidate($quiz);
// Pre-compute candidate data to avoid nested loops in template
$candidateData = [];
foreach ($season->candidates as $candidate) {
$candidateIdString = $candidate->id->toString();
$candidateData[] = [
'candidate' => $candidate,
'quizCandidate' => $quizCandidatesByCandidateId[$candidateIdString] ?? null,
'givenAnswersCount' => $givenAnswersCountByCandidateId[$candidateIdString] ?? 0,
];
}
return $this->render('backoffice/quiz.html.twig', [ return $this->render('backoffice/quiz.html.twig', [
'season' => $season, 'season' => $season,
@@ -398,53 +432,4 @@ class QuizController extends AbstractController
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]); return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
} }
#[IsCsrfTokenValid('reset_candidate_progress')]
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
#[Route(
'/backoffice/quiz/{quiz}/candidate/{candidate}/reset',
name: 'tvdt_backoffice_reset_candidate_progress',
requirements: ['quiz' => Requirement::UUID, 'candidate' => Requirement::UUID],
methods: ['POST'],
)]
public function resetCandidateProgress(Quiz $quiz, Candidate $candidate): RedirectResponse
{
$this->em->wrapInTransaction(function () use ($quiz, $candidate): void {
$this->givenAnswerRepository->deleteAllForCandidateInQuiz($quiz, $candidate);
$this->quizCandidateRepository->resetProgressForCandidate($quiz, $candidate);
});
$this->addFlash(FlashType::Success, $this->translator->trans('Candidate progress reset'));
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
}
/**
* Pre-computes per-candidate data (quiz participation and given answer counts) to avoid nested loops in templates.
*
* @param iterable<QuizCandidate> $quizCandidates
*
* @return list<array{candidate: Candidate, quizCandidate: QuizCandidate|null, givenAnswersCount: int}>
*/
private function buildCandidateData(Season $season, Quiz $quiz, iterable $quizCandidates): array
{
$quizCandidatesByCandidateId = [];
foreach ($quizCandidates as $qc) {
$quizCandidatesByCandidateId[$qc->candidate->id->toString()] = $qc;
}
$givenAnswersCountByCandidateId = $this->quizRepository->getGivenAnswersCountPerCandidate($quiz);
$candidateData = [];
foreach ($season->candidates as $candidate) {
$candidateIdString = $candidate->id->toString();
$candidateData[] = [
'candidate' => $candidate,
'quizCandidate' => $quizCandidatesByCandidateId[$candidateIdString] ?? null,
'givenAnswersCount' => $givenAnswersCountByCandidateId[$candidateIdString] ?? 0,
];
}
return $candidateData;
}
} }
@@ -46,16 +46,11 @@ class QuizQuestionController extends AbstractController
$isTurboFrame = $request->headers->has('Turbo-Frame'); $isTurboFrame = $request->headers->has('Turbo-Frame');
$form = $this->createForm(QuestionFormType::class, $question, [ $form = $this->createForm(QuestionFormType::class, $question);
'action' => $this->generateUrl('tvdt_backoffice_quiz_question_edit', [
'seasonCode' => $season->seasonCode,
'quiz' => $quiz->id,
'question' => $question->id,
]),
]);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$this->applyAnswerOrdering($question);
$this->em->flush(); $this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated')); $this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
@@ -74,15 +69,20 @@ class QuizQuestionController extends AbstractController
? 'backoffice/quiz/_question_frame.html.twig' ? 'backoffice/quiz/_question_frame.html.twig'
: 'backoffice/quiz/question_form.html.twig'; : 'backoffice/quiz/question_form.html.twig';
return $this->render($template, [ $response = $this->render($template, [
'season' => $season, 'season' => $season,
'quiz' => $quiz, 'quiz' => $quiz,
'question' => $question, 'question' => $question,
'form' => $form, 'form' => $form,
]); ]);
if ($form->isSubmitted()) {
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $response;
} }
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route( #[Route(
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/question/{question}/view', '/backoffice/season/{seasonCode:season}/quiz/{quiz}/question/{question}/view',
name: 'tvdt_backoffice_quiz_question_view', name: 'tvdt_backoffice_quiz_question_view',
@@ -127,10 +127,6 @@ class QuizQuestionController extends AbstractController
} }
} }
if (\count(array_unique($ordering)) !== \count($questionsById)) {
throw new BadRequestHttpException('Ordering must include every question exactly once');
}
$position = 1; $position = 1;
foreach ($ordering as $questionId) { foreach ($ordering as $questionId) {
$questionsById[$questionId]->ordering = $position++; $questionsById[$questionId]->ordering = $position++;
@@ -140,4 +136,12 @@ class QuizQuestionController extends AbstractController
return new Response('', Response::HTTP_NO_CONTENT); return new Response('', Response::HTTP_NO_CONTENT);
} }
private function applyAnswerOrdering(Question $question): void
{
$ordering = 1;
foreach ($question->answers as $answer) {
$answer->ordering = $ordering++;
}
}
} }
+2 -85
View File
@@ -10,13 +10,10 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\NotBlank;
@@ -29,7 +26,6 @@ use Tvdt\Enum\FlashType;
use Tvdt\Form\AddCandidatesFormType; use Tvdt\Form\AddCandidatesFormType;
use Tvdt\Form\SettingsForm; use Tvdt\Form\SettingsForm;
use Tvdt\Form\UploadQuizFormType; use Tvdt\Form\UploadQuizFormType;
use Tvdt\Repository\CandidateRepository;
use Tvdt\Security\Voter\SeasonVoter; use Tvdt\Security\Voter\SeasonVoter;
use Tvdt\Service\QuizSpreadsheetService; use Tvdt\Service\QuizSpreadsheetService;
@@ -41,7 +37,6 @@ class SeasonController extends AbstractController
private readonly TranslatorInterface $translator, private readonly TranslatorInterface $translator,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly QuizSpreadsheetService $quizSpreadsheet, private readonly QuizSpreadsheetService $quizSpreadsheet,
private readonly CandidateRepository $candidateRepository,
) {} ) {}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[IsGranted(SeasonVoter::EDIT, subject: 'season')]
@@ -102,24 +97,6 @@ class SeasonController extends AbstractController
]); ]);
} }
#[IsCsrfTokenValid('regenerate_season_code')]
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/settings/regenerate-code',
name: 'tvdt_backoffice_season_regenerate_code',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX],
methods: ['POST'],
)]
public function regenerateSeasonCode(Season $season): RedirectResponse
{
$season->generateSeasonCode();
$this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Season code regenerated'));
return $this->redirectToRoute('tvdt_backoffice_season_settings', ['seasonCode' => $season->seasonCode]);
}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route( #[Route(
'/backoffice/season/{seasonCode:season}/add-candidate', '/backoffice/season/{seasonCode:season}/add-candidate',
@@ -129,8 +106,6 @@ class SeasonController extends AbstractController
)] )]
public function addCandidates(Season $season, Request $request): Response public function addCandidates(Season $season, Request $request): Response
{ {
$isTurboFrame = $request->headers->has('Turbo-Frame');
$form = $this->createForm(AddCandidatesFormType::class); $form = $this->createForm(AddCandidatesFormType::class);
$form->handleRequest($request); $form->handleRequest($request);
@@ -142,68 +117,10 @@ class SeasonController extends AbstractController
$this->em->flush(); $this->em->flush();
if ($isTurboFrame) { return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $season->seasonCode]);
return new Response('<turbo-frame id="add-candidates-modal-frame"></turbo-frame>');
}
return $this->redirectToRoute('tvdt_backoffice_season_candidates', ['seasonCode' => $season->seasonCode]);
} }
$template = $isTurboFrame return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form, 'season' => $season]);
? 'backoffice/season/_add_candidates_frame.html.twig'
: 'backoffice/season_add_candidates.html.twig';
return $this->render($template, ['form' => $form, 'season' => $season]);
}
#[IsCsrfTokenValid('rename_candidate')]
#[IsGranted(SeasonVoter::EDIT, subject: 'candidate')]
#[Route(
'/backoffice/season/{seasonCode:season}/candidate/{candidate}/rename',
name: 'tvdt_backoffice_candidate_rename',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'candidate' => Requirement::UUID],
methods: ['POST'],
)]
public function renameCandidate(Season $season, Candidate $candidate, Request $request): RedirectResponse
{
$name = mb_trim($request->request->getString('name'));
if ('' === $name || mb_strlen($name) > 16) {
$this->addFlash(FlashType::Danger, $this->translator->trans('The candidate name must be between 1 and 16 characters'));
return $this->redirectToRoute('tvdt_backoffice_season_candidates', ['seasonCode' => $season->seasonCode]);
}
$candidate->name = $name;
try {
$this->em->flush();
} catch (UniqueConstraintViolationException) {
$this->addFlash(FlashType::Danger, $this->translator->trans('A candidate with this name already exists in this season'));
return $this->redirectToRoute('tvdt_backoffice_season_candidates', ['seasonCode' => $season->seasonCode]);
}
$this->addFlash(FlashType::Success, $this->translator->trans('Candidate renamed'));
return $this->redirectToRoute('tvdt_backoffice_season_candidates', ['seasonCode' => $season->seasonCode]);
}
#[IsCsrfTokenValid('delete_candidate')]
#[IsGranted(SeasonVoter::DELETE, subject: 'candidate')]
#[Route(
'/backoffice/season/{seasonCode:season}/candidate/{candidate}/delete',
name: 'tvdt_backoffice_candidate_delete',
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'candidate' => Requirement::UUID],
methods: ['POST'],
)]
public function deleteCandidate(Season $season, Candidate $candidate): RedirectResponse
{
$this->candidateRepository->deleteCandidate($candidate);
$this->addFlash(FlashType::Success, $this->translator->trans('Candidate deleted'));
return $this->redirectToRoute('tvdt_backoffice_season_candidates', ['seasonCode' => $season->seasonCode]);
} }
#[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[IsGranted(SeasonVoter::EDIT, subject: 'season')]
@@ -1,214 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Controller\Backoffice;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Safe\DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
use Symfony\Contracts\Translation\TranslatorInterface;
use Tvdt\Controller\AbstractController;
use Tvdt\Entity\User;
use Tvdt\Enum\FlashType;
use Tvdt\Form\ChangeEmailFormType;
use Tvdt\Form\ChangeUserPasswordFormType;
use Tvdt\Helpers\FilenameSanitizer;
use Tvdt\Repository\UserRepository;
use Tvdt\Security\EmailVerifier;
use Tvdt\Service\DataExportService;
final class SettingsController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly UserRepository $userRepository,
private readonly EmailVerifier $emailVerifier,
private readonly Security $security,
private readonly TranslatorInterface $translator,
private readonly DataExportService $dataExportService,
) {}
#[Route('/backoffice/settings', name: 'tvdt_backoffice_settings', methods: ['GET'])]
public function index(): Response
{
return $this->renderSettings();
}
#[IsCsrfTokenValid('settings_language')]
#[Route('/backoffice/settings/language', name: 'tvdt_backoffice_settings_language', methods: ['POST'])]
public function saveLanguage(): RedirectResponse
{
// Only Dutch is available for now, so saving is a noop.
$this->addFlash(FlashType::Success, $this->translator->trans('Language saved'));
return $this->redirectToRoute('tvdt_backoffice_settings');
}
#[Route('/backoffice/settings/password', name: 'tvdt_backoffice_settings_password', methods: ['POST'])]
public function changePassword(Request $request): Response
{
$user = $this->authenticatedUser;
$form = $this->createForm(ChangeUserPasswordFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var string $plainPassword */
$plainPassword = $form->get('plainPassword')->getData();
$user->password = $this->passwordHasher->hashPassword($user, $plainPassword);
$this->entityManager->flush();
$this->userRepository->invalidateResetPasswordRequests($user);
$this->security->login($user, 'form_login', 'main');
$this->addFlash(FlashType::Success, $this->translator->trans('Your password has been changed.'));
return $this->redirectToRoute('tvdt_backoffice_settings');
}
return $this->renderSettings(passwordForm: $form);
}
#[Route('/backoffice/settings/email', name: 'tvdt_backoffice_settings_email', methods: ['POST'])]
public function changeEmail(Request $request): Response
{
$user = $this->authenticatedUser;
$form = $this->createForm(ChangeEmailFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var string $email */
$email = $form->get('email')->getData();
$found = $this->userRepository->findOneBy(['email' => $email]);
if ($found instanceof User && $found !== $user) {
$form->get('email')->addError(new FormError($this->translator->trans('There is already an account with this email')));
return $this->renderSettings(emailForm: $form);
}
$originalEmail = $user->email;
$originalIsVerified = $user->isVerified;
$user->email = $email;
$user->isVerified = false;
try {
$this->entityManager->flush();
} catch (UniqueConstraintViolationException) {
// A concurrent request can claim the email between the uniqueness check above and the flush
$user->email = $originalEmail;
$user->isVerified = $originalIsVerified;
$form->get('email')->addError(new FormError($this->translator->trans('There is already an account with this email')));
return $this->renderSettings(emailForm: $form);
}
$this->userRepository->invalidateResetPasswordRequests($user);
if ($this->emailVerifier->sendDefaultConfirmation($user)) {
$this->addFlash(FlashType::Success, $this->translator->trans('Your email address has been changed. Please check your inbox to confirm it.'));
} else {
$this->addFlash(FlashType::Success, $this->translator->trans('Your email address has been changed.'));
$this->addFlash(FlashType::Warning, $this->translator->trans('The confirmation email could not be sent. Please use the resend button to try again.'));
}
$this->security->login($user, 'form_login', 'main');
return $this->redirectToRoute('tvdt_backoffice_settings');
}
return $this->renderSettings(emailForm: $form);
}
#[IsCsrfTokenValid('resend_confirmation')]
#[Route('/backoffice/settings/resend-confirmation', name: 'tvdt_backoffice_settings_resend_confirmation', methods: ['POST'])]
public function resendConfirmationEmail(): RedirectResponse
{
$user = $this->authenticatedUser;
if ($user->isVerified) {
$this->addFlash(FlashType::Info, $this->translator->trans('Your email address is already confirmed.'));
return $this->redirectToRoute('tvdt_backoffice_settings');
}
if ($this->emailVerifier->sendDefaultConfirmation($user)) {
$this->addFlash(FlashType::Success, $this->translator->trans('A new confirmation email has been sent. Please check your inbox.'));
} else {
$this->addFlash(FlashType::Warning, $this->translator->trans('The confirmation email could not be sent. Please try again later.'));
}
return $this->redirectToRoute('tvdt_backoffice_settings');
}
#[Route('/backoffice/settings/download-data', name: 'tvdt_backoffice_settings_download_data', methods: ['GET'])]
public function downloadData(): Response
{
if (!$this->authenticatedUser->isVerified) {
$this->addFlash(FlashType::Warning, $this->translator->trans('Please confirm your email address before downloading your data.'));
return $this->redirectToRoute('tvdt_backoffice_settings');
}
$zipPath = $this->dataExportService->exportForUser($this->authenticatedUser);
$filename = \sprintf(
'tijd-voor-de-test-data-%s-%s.zip',
FilenameSanitizer::sanitize($this->authenticatedUser->email),
new DateTimeImmutable()->format('Y-m-d_H-i-s'),
);
$response = new BinaryFileResponse($zipPath);
$response->deleteFileAfterSend(true);
$response->headers->set('Content-Type', 'application/zip');
$response->headers->set(
'Content-Disposition',
HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename),
);
return $response;
}
#[IsCsrfTokenValid('delete_account')]
#[Route('/backoffice/settings/delete', name: 'tvdt_backoffice_settings_delete', methods: ['POST'])]
public function deleteAccount(Request $request): Response
{
$user = $this->authenticatedUser;
$password = (string) $request->request->get('password', '');
if (!$this->passwordHasher->isPasswordValid($user, $password)) {
$this->addFlash(FlashType::Danger, $this->translator->trans('Wrong password, your account has not been deleted.'));
return $this->redirectToRoute('tvdt_backoffice_settings');
}
$this->userRepository->deleteUser($user);
return $this->security->logout(false) ?? $this->redirectToRoute('tvdt_login_login');
}
/**
* @param FormInterface<array{currentPassword: string, plainPassword: string}|null>|null $passwordForm
* @param FormInterface<array{email: string}|null>|null $emailForm
*/
private function renderSettings(?FormInterface $passwordForm = null, ?FormInterface $emailForm = null): Response
{
return $this->render('backoffice/settings/index.html.twig', [
'passwordForm' => $passwordForm ?? $this->createForm(ChangeUserPasswordFormType::class),
'emailForm' => $emailForm ?? $this->createForm(ChangeEmailFormType::class),
]);
}
}
+15 -3
View File
@@ -5,11 +5,14 @@ declare(strict_types=1);
namespace Tvdt\Controller; namespace Tvdt\Controller;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
@@ -23,7 +26,7 @@ use Tvdt\Security\EmailVerifier;
final class RegistrationController extends AbstractController final class RegistrationController extends AbstractController
{ {
public function __construct(private readonly EmailVerifier $emailVerifier, private readonly TranslatorInterface $translator, private readonly UserPasswordHasherInterface $userPasswordHasher, private readonly Security $security, private readonly UserRepository $userRepository, private readonly EntityManagerInterface $entityManager) {} public function __construct(private readonly EmailVerifier $emailVerifier, private readonly TranslatorInterface $translator, private readonly UserPasswordHasherInterface $userPasswordHasher, private readonly Security $security, private readonly LoggerInterface $logger, private readonly UserRepository $userRepository, private readonly EntityManagerInterface $entityManager) {}
#[Route('/register', name: 'tvdt_register')] #[Route('/register', name: 'tvdt_register')]
public function register( public function register(
@@ -46,8 +49,17 @@ final class RegistrationController extends AbstractController
$this->entityManager->persist($user); $this->entityManager->persist($user);
$this->entityManager->flush(); $this->entityManager->flush();
// generate a signed url and email it to the user try {
$this->emailVerifier->sendDefaultConfirmation($user); // generate a signed url and email it to the user
$this->emailVerifier->sendEmailConfirmation('tvdt_verify_email', $user,
new TemplatedEmail()
->to($user->email)
->subject($this->translator->trans('Please Confirm your Email'))
->htmlTemplate('backoffice/registration/confirmation_email.html.twig'),
);
} catch (TransportExceptionInterface $e) {
$this->logger->error($e->getMessage());
}
$response = $this->security->login($user, 'form_login', 'main'); $response = $this->security->login($user, 'form_login', 'main');
\assert($response instanceof Response); \assert($response instanceof Response);
-147
View File
@@ -1,147 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Controller;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait;
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordToken;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
use Tvdt\Entity\User;
use Tvdt\Enum\FlashType;
use Tvdt\Form\ChangePasswordFormType;
use Tvdt\Form\ResetPasswordRequestFormType;
final class ResetPasswordController extends AbstractController
{
use ResetPasswordControllerTrait;
public function __construct(
private readonly ResetPasswordHelperInterface $resetPasswordHelper,
private readonly EntityManagerInterface $entityManager,
private readonly MailerInterface $mailer,
private readonly TranslatorInterface $translator,
private readonly UserPasswordHasherInterface $passwordHasher,
) {}
#[Route('/reset-password', name: 'tvdt_forgot_password_request')]
public function request(Request $request): Response
{
$form = $this->createForm(ResetPasswordRequestFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var string $email */
$email = $form->get('email')->getData();
return $this->processSendingPasswordResetEmail($email, $this->mailer, $this->translator);
}
return $this->render('reset_password/request.html.twig', [
'requestForm' => $form,
]);
}
#[Route('/reset-password/check-email', name: 'tvdt_check_email')]
public function checkEmail(): Response
{
if (!($resetToken = $this->getTokenObjectFromSession()) instanceof ResetPasswordToken) {
$resetToken = $this->resetPasswordHelper->generateFakeResetToken();
}
return $this->render('reset_password/check_email.html.twig', [
'resetToken' => $resetToken,
]);
}
#[Route('/reset-password/reset/{token}', name: 'tvdt_reset_password')]
public function reset(Request $request, ?string $token = null): Response
{
if ($token) {
$this->storeTokenInSession($token);
return $this->redirectToRoute('tvdt_reset_password');
}
$token = $this->getTokenFromSession();
if (null === $token) {
throw $this->createNotFoundException('No reset password token found in the URL or in the session.');
}
try {
/** @var User $user */
$user = $this->resetPasswordHelper->validateTokenAndFetchUser($token);
} catch (ResetPasswordExceptionInterface $resetPasswordException) {
$this->addFlash(FlashType::Danger->value, \sprintf(
'%s - %s',
$this->translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE, [], 'ResetPasswordBundle'),
$this->translator->trans($resetPasswordException->getReason(), [], 'ResetPasswordBundle'),
));
return $this->redirectToRoute('tvdt_forgot_password_request');
}
$form = $this->createForm(ChangePasswordFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->resetPasswordHelper->removeResetRequest($token);
/** @var string $plainPassword */
$plainPassword = $form->get('plainPassword')->getData();
$user->password = $this->passwordHasher->hashPassword($user, $plainPassword);
$this->entityManager->flush();
$this->cleanSessionAfterReset();
return $this->redirectToRoute('tvdt_backoffice_index');
}
return $this->render('reset_password/reset.html.twig', [
'resetForm' => $form,
]);
}
private function processSendingPasswordResetEmail(string $emailFormData, MailerInterface $mailer, TranslatorInterface $translator): RedirectResponse
{
$user = $this->entityManager->getRepository(User::class)->findOneBy([
'email' => $emailFormData,
]);
if (!$user instanceof User) {
return $this->redirectToRoute('tvdt_check_email');
}
try {
$resetToken = $this->resetPasswordHelper->generateResetToken($user);
} catch (ResetPasswordExceptionInterface) {
return $this->redirectToRoute('tvdt_check_email');
}
$email = new TemplatedEmail()
->to($user->getUserIdentifier())
->subject($translator->trans('Your password reset request'))
->htmlTemplate('reset_password/email.html.twig')
->context([
'resetToken' => $resetToken,
]);
$mailer->send($email);
$this->setTokenObjectInSession($resetToken);
return $this->redirectToRoute('tvdt_check_email');
}
}
-59
View File
@@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Controller;
use Safe\DateTimeImmutable;
use Safe\Exceptions\DatetimeException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/** Serves well-known URIs (https://www.rfc-editor.org/rfc/rfc8615). */
final class WellKnownController extends AbstractController
{
public function __construct(
#[Autowire(env: 'default::BUILD_TIME')]
private readonly ?string $buildTime,
#[Autowire(env: 'APP_ENV')]
private readonly string $appEnv,
) {}
/** @see https://w3c.github.io/webappsec-change-password-url/ */
#[Route('/.well-known/change-password', name: 'tvdt_well_known_change_password', methods: ['GET'])]
public function changePassword(): RedirectResponse
{
return $this->redirectToRoute('tvdt_backoffice_settings');
}
/**
* @see https://www.rfc-editor.org/rfc/rfc9116
*
* @throws DatetimeException
* @throws \Exception
*/
#[Route('/.well-known/security.txt', name: 'tvdt_well_known_security_txt', methods: ['GET'])]
public function securityTxt(): Response
{
// One year after the container build, so the file goes stale when deployments stop.
// In prod the build arg must be set; falling back to 'now' would renew Expires on every request,
// defeating the go-stale purpose. In dev/test 'now' is fine — no build bake happens there.
if ((null === $this->buildTime || '' === $this->buildTime) && 'prod' === $this->appEnv) {
throw new \LogicException('BUILD_TIME env var must be set in production (baked in during Docker build).');
}
$buildTime = (null !== $this->buildTime && '' !== $this->buildTime) ? $this->buildTime : 'now';
$expires = new DateTimeImmutable($buildTime)->modify('+1 year')->format(\DATE_RFC3339);
$content = <<<TXT
Contact: https://github.com/MarijnDoeve/TijdVoorDeTest/security/advisories/new
Expires: {$expires}
Preferred-Languages: nl, en
TXT;
return new Response($content, headers: ['Content-Type' => 'text/plain; charset=UTF-8']);
}
}
-31
View File
@@ -9,10 +9,6 @@ use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Tvdt\Entity\Answer;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season; use Tvdt\Entity\Season;
use Tvdt\Entity\User; use Tvdt\Entity\User;
@@ -74,33 +70,6 @@ final class TestFixtures extends Fixture implements FixtureGroupInterface, Depen
$krtek->addOwner($user); $krtek->addOwner($user);
$anotherSeason->addOwner($user); $anotherSeason->addOwner($user);
$soleOwner = new User();
$soleOwner->email = 'sole-owner@example.org';
$soleOwner->password = $this->passwordHasher->hashPassword($soleOwner, self::PASSWORD);
$manager->persist($soleOwner);
$doomedSeason = new Season();
$doomedSeason->name = 'Doomed Season';
$doomedSeason->seasonCode = 'doomd';
$doomedSeason->addCandidate(new Candidate('Vera'));
$quiz = new Quiz();
$quiz->name = 'Doomed Quiz';
$question = new Question();
$question->question = 'Wie is de Krtek?';
$question->ordering = 1;
$question->addAnswer(new Answer('Vera', true));
$quiz->addQuestion($question);
$doomedSeason->addQuiz($quiz);
$manager->persist($doomedSeason);
$doomedSeason->addOwner($soleOwner);
$anotherSeason->addOwner($soleOwner);
$manager->flush(); $manager->flush();
} }
} }
-7
View File
@@ -54,13 +54,6 @@ class Question implements \Stringable
return $this; return $this;
} }
public function removeAnswer(Answer $answer): static
{
$this->answers->removeElement($answer);
return $this;
}
public function __toString(): string public function __toString(): string
{ {
return $this->question ?? ''; return $this->question ?? '';
-36
View File
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestTrait;
use Tvdt\Repository\ResetPasswordRequestRepository;
#[ORM\Entity(repositoryClass: ResetPasswordRequestRepository::class)]
class ResetPasswordRequest implements ResetPasswordRequestInterface
{
use ResetPasswordRequestTrait;
#[ORM\Column(type: UuidType::NAME, unique: true)]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\Id]
public private(set) Uuid $id;
public function __construct(#[ORM\JoinColumn(nullable: false)]
#[ORM\ManyToOne]
private User $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken)
{
$this->initialize($expiresAt, $selector, $hashedToken);
}
public function getUser(): User
{
return $this->user;
}
}
-4
View File
@@ -21,10 +21,6 @@ use Tvdt\Repository\UserRepository;
#[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')] #[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')]
class User implements UserInterface, PasswordAuthenticatedUserInterface class User implements UserInterface, PasswordAuthenticatedUserInterface
{ {
public const int PASSWORD_MIN_LENGTH = 8;
public const int PASSWORD_MAX_LENGTH = 4096;
#[ORM\Column(type: UuidType::NAME, unique: true)] #[ORM\Column(type: UuidType::NAME, unique: true)]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
#[ORM\GeneratedValue(strategy: 'CUSTOM')] #[ORM\GeneratedValue(strategy: 'CUSTOM')]
+1 -3
View File
@@ -19,9 +19,7 @@ class AddCandidatesFormType extends AbstractType
{ {
$builder $builder
->add('candidates', TextareaType::class, [ ->add('candidates', TextareaType::class, [
'label' => $this->translator->trans('Candidates'), 'label' => $this->translator->trans('Candidates'), 'translation_domain' => false,
'help' => $this->translator->trans('One candidate per line'),
'translation_domain' => false,
]) ])
; ;
} }
-1
View File
@@ -43,7 +43,6 @@ class BankQuestionFormType extends AbstractType
'multiple' => true, 'multiple' => true,
'expanded' => true, 'expanded' => true,
'required' => false, 'required' => false,
'choice_attr' => static fn (QuestionLabel $label): array => ['data-colour' => $label->colour->value],
'query_builder' => static fn (QuestionLabelRepository $repository): QueryBuilder => $repository 'query_builder' => static fn (QuestionLabelRepository $repository): QueryBuilder => $repository
->createQueryBuilder('l') ->createQueryBuilder('l')
->where('l.season = :season') ->where('l.season = :season')
-44
View File
@@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Contracts\Translation\TranslatorInterface;
/** @extends AbstractType<array{email: string}> */
final class ChangeEmailFormType extends AbstractType
{
public function __construct(private readonly TranslatorInterface $translator) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, [
'label' => $this->translator->trans('New email address'),
'attr' => ['autocomplete' => 'email'],
'mapped' => false,
'constraints' => [
new NotBlank(message: 'Please enter an email address'),
new Email(),
],
'translation_domain' => false,
])
->add('save', SubmitType::class, [
'label' => $this->translator->trans('Change email'),
'translation_domain' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}
-54
View File
@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Contracts\Translation\TranslatorInterface;
use Tvdt\Entity\User;
/** @extends AbstractType<array{plainPassword: string}> */
final class ChangePasswordFormType extends AbstractType
{
public function __construct(private readonly TranslatorInterface $translator) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'options' => [
'attr' => ['autocomplete' => 'new-password'],
],
'first_options' => [
'label' => $this->translator->trans('New password'),
'constraints' => [
new NotBlank(message: 'Please enter a password'),
new Length(
min: User::PASSWORD_MIN_LENGTH,
max: User::PASSWORD_MAX_LENGTH,
minMessage: 'Your password should be at least {{ limit }} characters',
),
],
],
'second_options' => [
'label' => $this->translator->trans('Repeat Password'),
],
'invalid_message' => $this->translator->trans('The password fields must match.'),
'mapped' => false,
'translation_domain' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}
-70
View File
@@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Security\Core\Validator\Constraints\UserPassword;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Contracts\Translation\TranslatorInterface;
use Tvdt\Entity\User;
/** @extends AbstractType<array{currentPassword: string, plainPassword: string}> */
final class ChangeUserPasswordFormType extends AbstractType
{
public function __construct(private readonly TranslatorInterface $translator) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('currentPassword', PasswordType::class, [
'label' => $this->translator->trans('Current password'),
'attr' => ['autocomplete' => 'current-password'],
'mapped' => false,
'constraints' => [
new NotBlank(message: 'Please enter your current password'),
new UserPassword(message: 'This is not your current password.'),
],
'translation_domain' => false,
])
->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'options' => [
'attr' => ['autocomplete' => 'new-password'],
],
'first_options' => [
'label' => $this->translator->trans('New password'),
'constraints' => [
new NotBlank(message: 'Please enter a password'),
new Length(
min: User::PASSWORD_MIN_LENGTH,
max: User::PASSWORD_MAX_LENGTH,
minMessage: 'Your password should be at least {{ limit }} characters',
),
],
],
'second_options' => [
'label' => $this->translator->trans('Repeat Password'),
],
'invalid_message' => $this->translator->trans('The password fields must match.'),
'mapped' => false,
'translation_domain' => false,
])
->add('save', SubmitType::class, [
'label' => $this->translator->trans('Change password'),
'translation_domain' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ class RegistrationFormType extends AbstractType
'mapped' => false, 'mapped' => false,
'constraints' => [ 'constraints' => [
new NotBlank(message: 'Please enter a password'), new NotBlank(message: 'Please enter a password'),
new Length(min: User::PASSWORD_MIN_LENGTH, max: User::PASSWORD_MAX_LENGTH, minMessage: 'Your password should be at least {{ limit }} characters'), new Length(min: 8, max: 4096, minMessage: 'Your password should be at least {{ limit }} characters'),
], ],
'translation_domain' => false, 'translation_domain' => false,
]) ])
-36
View File
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Contracts\Translation\TranslatorInterface;
/** @extends AbstractType<array{email: string}> */
final class ResetPasswordRequestFormType extends AbstractType
{
public function __construct(private readonly TranslatorInterface $translator) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, [
'label' => $this->translator->trans('Email'),
'attr' => ['autocomplete' => 'email'],
'translation_domain' => false,
'constraints' => [
new NotBlank(message: 'Please enter your email'),
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}
-18
View File
@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Helpers;
use Symfony\Component\String\Slugger\AsciiSlugger;
class FilenameSanitizer
{
/** Slugs user-supplied text (e.g. a season/quiz name) into a string safe to use as a zip entry path segment or a downloaded filename. */
public static function sanitize(string $value): string
{
$slug = new AsciiSlugger()->slug($value)->toString();
return '' === $slug ? 'unnamed' : $slug;
}
}
-6
View File
@@ -19,12 +19,6 @@ class CandidateRepository extends ServiceEntityRepository
parent::__construct($registry, Candidate::class); parent::__construct($registry, Candidate::class);
} }
public function deleteCandidate(Candidate $candidate): void
{
$this->getEntityManager()->remove($candidate);
$this->getEntityManager()->flush();
}
public function getCandidateByHash(Season $season, string $hash): ?Candidate public function getCandidateByHash(Season $season, string $hash): ?Candidate
{ {
try { try {
-13
View File
@@ -6,9 +6,7 @@ namespace Tvdt\Repository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\GivenAnswer; use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\Quiz;
/** @extends ServiceEntityRepository<GivenAnswer> */ /** @extends ServiceEntityRepository<GivenAnswer> */
class GivenAnswerRepository extends ServiceEntityRepository class GivenAnswerRepository extends ServiceEntityRepository
@@ -17,15 +15,4 @@ class GivenAnswerRepository extends ServiceEntityRepository
{ {
parent::__construct($registry, GivenAnswer::class); parent::__construct($registry, GivenAnswer::class);
} }
public function deleteAllForCandidateInQuiz(Quiz $quiz, Candidate $candidate): void
{
$givenAnswers = $this->findBy(['quiz' => $quiz, 'candidate' => $candidate]);
foreach ($givenAnswers as $givenAnswer) {
$this->getEntityManager()->remove($givenAnswer);
}
$this->getEntityManager()->flush();
}
} }
@@ -68,15 +68,4 @@ class QuizCandidateRepository extends ServiceEntityRepository
$quizCandidate->penaltySeconds = $penalty; $quizCandidate->penaltySeconds = $penalty;
$this->getEntityManager()->flush(); $this->getEntityManager()->flush();
} }
public function resetProgressForCandidate(Quiz $quiz, Candidate $candidate): void
{
$quizCandidate = $this->findOneBy(['candidate' => $candidate, 'quiz' => $quiz]);
if (!$quizCandidate instanceof QuizCandidate) {
return;
}
$quizCandidate->started = null;
$this->getEntityManager()->flush();
}
} }
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Repository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
use SymfonyCasts\Bundle\ResetPassword\Persistence\Repository\ResetPasswordRequestRepositoryTrait;
use SymfonyCasts\Bundle\ResetPassword\Persistence\ResetPasswordRequestRepositoryInterface;
use Tvdt\Entity\ResetPasswordRequest;
use Tvdt\Entity\User;
/** @extends ServiceEntityRepository<ResetPasswordRequest> */
final class ResetPasswordRequestRepository extends ServiceEntityRepository implements ResetPasswordRequestRepositoryInterface
{
use ResetPasswordRequestRepositoryTrait;
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ResetPasswordRequest::class);
}
public function createResetPasswordRequest(object $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken): ResetPasswordRequestInterface
{
\assert($user instanceof User);
return new ResetPasswordRequest($user, $expiresAt, $selector, $hashedToken);
}
}
-94
View File
@@ -5,15 +5,9 @@ declare(strict_types=1);
namespace Tvdt\Repository; namespace Tvdt\Repository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\Elimination;
use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\QuizCandidate;
use Tvdt\Entity\Season;
use Tvdt\Entity\User; use Tvdt\Entity\User;
/** @extends ServiceEntityRepository<User> */ /** @extends ServiceEntityRepository<User> */
@@ -34,94 +28,6 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
$this->getEntityManager()->flush(); $this->getEntityManager()->flush();
} }
/** Deletes all outstanding reset-password tokens for the user (e.g. after a password or email change). */
public function invalidateResetPasswordRequests(User $user): void
{
$this->getEntityManager()
->createQuery('delete from Tvdt\Entity\ResetPasswordRequest r where r.user = :user')
->setParameter('user', $user)
->execute();
}
/** Deletes the user, all seasons the user is the sole owner of, and the user's ownership of shared seasons. */
public function deleteUser(User $user): void
{
$em = $this->getEntityManager();
$em->wrapInTransaction(function () use ($em, $user): void {
$this->invalidateResetPasswordRequests($user);
$bankQuestionIds = [];
foreach ($user->seasons->toArray() as $season) {
if (1 === $season->owners->count()) {
$this->purgeSoftDeletableData($em, $season);
array_push($bankQuestionIds, ...$this->bankQuestionIds($season));
$em->remove($season);
continue;
}
$season->removeOwner($user);
}
$em->remove($user);
$em->flush();
// Gedmo\Loggable writes its own "removed" log entry as part of the flush above, so the
// audit-log purge must happen after — purging first would just leave that final row behind.
$this->purgeBankQuestionAuditLog($em, $bankQuestionIds);
});
}
/**
* QuizCandidate, GivenAnswer, and Elimination are Gedmo\SoftDeleteable, so cascading their
* removal through the season/quiz/candidate relations only sets deletedAt it never removes
* the row. That leaves personal data behind indefinitely and, since Candidate/Answer are hard
* deleted via orphanRemoval, it also breaks their foreign keys and rolls back the whole
* deletion. Bulk DQL deletes bypass the Gedmo listener and physically remove these rows first.
*/
private function purgeSoftDeletableData(EntityManagerInterface $em, Season $season): void
{
foreach ([QuizCandidate::class, GivenAnswer::class, Elimination::class] as $class) {
$em->createQuery(<<<DQL
delete from {$class} e
where e.quiz in (select q from Tvdt\Entity\Quiz q where q.season = :season)
DQL)
->setParameter('season', $season)
->execute();
}
}
/** @return list<string> */
private function bankQuestionIds(Season $season): array
{
return array_values(array_map(
static fn (BankQuestion $bankQuestion): string => $bankQuestion->id->toString(),
$season->bankQuestions->toArray(),
));
}
/**
* Gedmo\Loggable audit rows (ext_log_entries) aren't foreign-keyed to the entity they log
* object_id is a plain string so removing a BankQuestion never cleans up its history, and
* the editor's username/email would otherwise remain in those rows forever.
*
* @param list<string> $bankQuestionIds
*/
private function purgeBankQuestionAuditLog(EntityManagerInterface $em, array $bankQuestionIds): void
{
if ([] === $bankQuestionIds) {
return;
}
$em->createQuery(<<<'DQL'
delete from Tvdt\Entity\LogEntry l
where l.objectClass = :class and l.objectId in (:ids)
DQL)
->setParameter('class', BankQuestion::class)
->setParameter('ids', $bankQuestionIds)
->execute();
}
public function makeAdmin(string $email): void public function makeAdmin(string $email): void
{ {
$user = $this->findOneBy(['email' => $email]); $user = $this->findOneBy(['email' => $email]);
-23
View File
@@ -5,12 +5,10 @@ declare(strict_types=1);
namespace Tvdt\Security; namespace Tvdt\Security;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface; use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface;
use Tvdt\Entity\User; use Tvdt\Entity\User;
@@ -20,29 +18,8 @@ readonly class EmailVerifier
private VerifyEmailHelperInterface $verifyEmailHelper, private VerifyEmailHelperInterface $verifyEmailHelper,
private MailerInterface $mailer, private MailerInterface $mailer,
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private TranslatorInterface $translator,
private LoggerInterface $logger,
) {} ) {}
/** Sends the standard confirmation email to the user. Returns false (and logs) on transport errors. */
public function sendDefaultConfirmation(User $user): bool
{
try {
$this->sendEmailConfirmation('tvdt_verify_email', $user,
new TemplatedEmail()
->to($user->email)
->subject($this->translator->trans('Please Confirm your Email'))
->htmlTemplate('backoffice/registration/confirmation_email.html.twig'),
);
return true;
} catch (TransportExceptionInterface $transportException) {
$this->logger->error($transportException->getMessage());
return false;
}
}
/** @throws TransportExceptionInterface */ /** @throws TransportExceptionInterface */
public function sendEmailConfirmation(string $verifyEmailRouteName, User $user, TemplatedEmail $email): void public function sendEmailConfirmation(string $verifyEmailRouteName, User $user, TemplatedEmail $email): void
{ {
-48
View File
@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Security;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Event\LogoutEvent;
final readonly class LogoutRedirectListener implements EventSubscriberInterface
{
private const array BLOCKED_TARGET_PREFIXES = ['/backoffice', '/elimination'];
public function __construct(private UrlGeneratorInterface $urlGenerator) {}
public function onLogout(LogoutEvent $event): void
{
$target = $event->getRequest()->query->get('target');
if (\is_string($target) && $this->isAllowedTarget($target)) {
$event->setResponse(new RedirectResponse($target));
return;
}
$event->setResponse(new RedirectResponse($this->urlGenerator->generate('tvdt_quiz_select_season')));
}
public static function getSubscribedEvents(): array
{
// Must run before Symfony's DefaultLogoutListener (priority 64), which only
// sets a response if none is set yet.
return [
LogoutEvent::class => ['onLogout', 128],
];
}
private function isAllowedTarget(string $target): bool
{
if (!str_starts_with($target, '/') || str_starts_with($target, '//')) {
return false;
}
return !array_any(self::BLOCKED_TARGET_PREFIXES, static fn (string $prefix): bool => str_starts_with($target, $prefix));
}
}
-444
View File
@@ -1,444 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Service;
use Doctrine\ORM\EntityManagerInterface;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer;
use Safe\Exceptions\FilesystemException;
use Tvdt\Dto\Result;
use Tvdt\Entity\BankQuestionUsage;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\Question;
use Tvdt\Entity\QuestionLabel;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season;
use Tvdt\Entity\User;
use Tvdt\Helpers\FilenameSanitizer;
use Tvdt\Repository\QuizRepository;
use function Safe\tempnam;
use function Safe\unlink;
/** Builds a GDPR data-portability export (a zip of xlsx files) for everything owned by a single user. */
class DataExportService
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly QuizSpreadsheetService $quizSpreadsheetService,
private readonly QuizRepository $quizRepository,
) {}
/** @throws FilesystemException @return string path to a temp zip file; caller is responsible for removing it */
public function exportForUser(User $user): string
{
$filter = $this->entityManager->getFilters();
$filter->disable('softdeleteable');
try {
return $this->buildZip($user);
} finally {
$filter->enable('softdeleteable');
}
}
private function buildZip(User $user): string
{
$zipPath = tempnam(sys_get_temp_dir(), 'tvdt_export_');
$tempXlsxFiles = [];
$zip = new \ZipArchive();
if (true !== $zip->open($zipPath, \ZipArchive::OVERWRITE)) {
unlink($zipPath);
throw new \RuntimeException('Could not create the export zip archive.');
}
try {
$profilePath = $this->writeToTempFile($this->buildProfileWorkbook($user));
$tempXlsxFiles[] = $profilePath;
$zip->addFile($profilePath, 'profile.xlsx');
foreach ($user->seasons as $season) {
$folder = FilenameSanitizer::sanitize($season->seasonCode.'-'.$season->name).'/';
foreach ($season->quizzes as $quiz) {
$quizPath = $this->writeToTempFile($this->buildQuizWorkbook($quiz));
$tempXlsxFiles[] = $quizPath;
$zip->addFile($quizPath, $folder.FilenameSanitizer::sanitize($quiz->name).'.xlsx');
}
$candidatesPath = $this->writeToTempFile($this->buildCandidatesWorkbook($season));
$tempXlsxFiles[] = $candidatesPath;
$zip->addFile($candidatesPath, $folder.'candidates.xlsx');
$questionBankPath = $this->writeToTempFile($this->buildQuestionBankWorkbook($season));
$tempXlsxFiles[] = $questionBankPath;
$zip->addFile($questionBankPath, $folder.'question-bank.xlsx');
}
if (!$zip->close()) {
throw new \RuntimeException('Could not finalize the export zip archive.');
}
} catch (\Throwable $throwable) {
unlink($zipPath);
throw $throwable;
} finally {
foreach ($tempXlsxFiles as $tempXlsxFile) {
unlink($tempXlsxFile);
}
}
return $zipPath;
}
private function buildProfileWorkbook(User $user): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$account = $spreadsheet->getActiveSheet();
$account->setTitle('Account');
$account->getStyle('A:A')->getFont()->setBold(true);
$account->fromArray([
['Email', $user->email],
['Roles', implode(', ', $user->getRoles())],
['Email verified', $user->isVerified ? 'Yes' : 'No'],
['Account ID', $user->id->toString()],
], null, 'A1');
$account->getColumnDimension('A')->setAutoSize(true);
$account->getColumnDimension('B')->setAutoSize(true);
$seasons = $spreadsheet->createSheet();
$seasons->setTitle('Seasons');
$seasons->fromArray(['Season', 'Season code', 'Quizzes', 'Candidates', 'Shared with other owners'], null, 'A1');
$seasons->getStyle('1:1')->getFont()->setBold(true);
$row = 2;
foreach ($user->seasons as $season) {
$seasons->fromArray([
$season->name,
$season->seasonCode,
$season->quizzes->count(),
$season->candidates->count(),
$season->owners->count() > 1 ? 'Yes' : 'No',
], null, 'A'.$row);
++$row;
}
foreach (['A', 'B', 'C', 'D', 'E'] as $column) {
$seasons->getColumnDimension($column)->setAutoSize(true);
}
$spreadsheet->setActiveSheetIndex(0);
return $spreadsheet;
}
private function buildQuizWorkbook(Quiz $quiz): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$info = $spreadsheet->getActiveSheet();
$info->setTitle('Quiz info');
$this->fillQuizInfoSheet($info, $quiz);
$questions = $spreadsheet->createSheet();
$questions->setTitle('Questions');
$this->quizSpreadsheetService->fillQuestionsSheet($questions, $quiz);
$rawAnswers = $spreadsheet->createSheet();
$rawAnswers->setTitle('Raw answers');
$this->fillRawAnswersSheet($rawAnswers, $quiz);
$results = $spreadsheet->createSheet();
$results->setTitle('Results');
$this->fillResultsSheet($results, $quiz);
$eliminations = $spreadsheet->createSheet();
$eliminations->setTitle('Eliminations');
$this->fillEliminationsSheet($eliminations, $quiz);
$spreadsheet->setActiveSheetIndex(0);
return $spreadsheet;
}
private function fillQuizInfoSheet(Worksheet $sheet, Quiz $quiz): void
{
$disabledQuestions = $quiz->questions
->filter(static fn (Question $question): bool => !$question->enabled)
->map(static fn (Question $question): string => $question->question)
->toArray();
$sheet->getStyle('A:A')->getFont()->setBold(true);
$sheet->fromArray([
['Quiz name', $quiz->name],
['Number of dropouts', $quiz->dropouts],
['Finalized', $quiz->isFinalized ? 'Yes' : 'No'],
['Finalized at', $quiz->finalizedAt?->format(\DateTimeInterface::ATOM) ?? ''],
['Disabled questions', implode(', ', $disabledQuestions)],
], null, 'A1');
$sheet->getColumnDimension('A')->setAutoSize(true);
$sheet->getColumnDimension('B')->setAutoSize(true);
}
private function fillResultsSheet(Worksheet $sheet, Quiz $quiz): void
{
$sheet->fromArray(['Candidate', 'Correct answers', 'Corrections', 'Penalty (s)', 'Score', 'Time', 'Started', 'Active', 'Deleted'], null, 'A1');
$sheet->getStyle('1:1')->getFont()->setBold(true);
/** @var array<string, Result> $scoresByCandidateId */
$scoresByCandidateId = [];
foreach ($this->quizRepository->getScores($quiz) as $result) {
$scoresByCandidateId[$result->id->toString()] = $result;
}
$row = 2;
foreach ($quiz->candidateData as $quizCandidate) {
$candidate = $quizCandidate->candidate;
$result = $scoresByCandidateId[$candidate->id->toString()] ?? null;
$sheet->fromArray([
$candidate->name,
$result?->correct,
$result?->corrections,
$result?->penaltySeconds,
$result?->score,
$result instanceof Result ? $result->time->format('%i:%S') : null,
$quizCandidate->started?->format(\DateTimeInterface::ATOM),
$quizCandidate->active ? 'Yes' : 'No',
$quizCandidate->getDeletedAt()?->format(\DateTimeInterface::ATOM) ?? '',
], null, 'A'.$row);
++$row;
}
foreach (range('A', 'I') as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true);
}
}
/** Raw crosstab: one row per candidate, one column per question, cell = the answer text they gave. */
private function fillRawAnswersSheet(Worksheet $sheet, Quiz $quiz): void
{
/** @var list<Question> $questions */
$questions = $quiz->questions->toArray();
$header = ['Candidate'];
foreach ($questions as $question) {
$header[] = $question->question;
}
$sheet->fromArray($header, null, 'A1');
$sheet->getStyle('1:1')->getFont()->setBold(true);
$sheet->getStyle('1:1')->getAlignment()->setWrapText(true);
/** @var array<string, array<string, string>> $answersByCandidateAndQuestion */
$answersByCandidateAndQuestion = [];
foreach ($questions as $question) {
foreach ($question->answers as $answer) {
foreach ($answer->givenAnswers as $givenAnswer) {
$candidateId = $givenAnswer->candidate->id->toString();
$answersByCandidateAndQuestion[$candidateId][$question->id->toString()] = $answer->text;
}
}
}
$row = 2;
foreach ($quiz->candidateData as $quizCandidate) {
$candidate = $quizCandidate->candidate;
$line = [$candidate->name];
foreach ($questions as $question) {
$line[] = $answersByCandidateAndQuestion[$candidate->id->toString()][$question->id->toString()] ?? '';
}
$sheet->fromArray($line, null, 'A'.$row);
++$row;
}
$lastColumnIndex = 1 + \count($questions);
foreach (range('A', Coordinate::stringFromColumnIndex($lastColumnIndex)) as $column) {
$sheet->getColumnDimension($column)->setWidth(30);
$sheet->getStyle($column.':'.$column)->getAlignment()->setWrapText(true);
}
}
private function fillEliminationsSheet(Worksheet $sheet, Quiz $quiz): void
{
/** @var list<Candidate> $candidates */
$candidates = $quiz->season->candidates->toArray();
$header = ['Prepared at', 'Deleted'];
foreach ($candidates as $candidate) {
$header[] = $candidate->name;
}
$sheet->fromArray($header, null, 'A1');
$sheet->getStyle('1:1')->getFont()->setBold(true);
$row = 2;
foreach ($quiz->eliminations as $elimination) {
$line = [
$elimination->getCreatedAt()?->format(\DateTimeInterface::ATOM) ?? '',
$elimination->getDeletedAt()?->format(\DateTimeInterface::ATOM) ?? '',
];
foreach ($candidates as $candidate) {
$line[] = $elimination->getScreenColour($candidate->name) ?? '';
}
$sheet->fromArray($line, null, 'A'.$row);
++$row;
}
foreach (range('A', Coordinate::stringFromColumnIndex(2 + \count($candidates))) as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true);
}
}
private function buildCandidatesWorkbook(Season $season): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$candidatesSheet = $spreadsheet->getActiveSheet();
$candidatesSheet->setTitle('Candidates');
$candidatesSheet->fromArray(['Name'], null, 'A1');
$candidatesSheet->getStyle('1:1')->getFont()->setBold(true);
$row = 2;
foreach ($season->candidates as $candidate) {
$candidatesSheet->fromArray([$candidate->name], null, 'A'.$row);
++$row;
}
$candidatesSheet->getColumnDimension('A')->setAutoSize(true);
$infoSheet = $spreadsheet->createSheet();
$infoSheet->setTitle('Season info');
$infoSheet->getStyle('A:A')->getFont()->setBold(true);
$infoSheet->fromArray([
['Season name', $season->name],
['Season code', $season->seasonCode],
['Number of quizzes', $season->quizzes->count()],
['Number of candidates', $season->candidates->count()],
['Active quiz', $season->activeQuiz instanceof Quiz ? $season->activeQuiz->name : ''],
['Show numbers', $season->settings?->showNumbers ? 'Yes' : 'No'],
['Confirm answers', $season->settings?->confirmAnswers ? 'Yes' : 'No'],
['Shared with other owners', $season->owners->count() > 1 ? 'Yes' : 'No'],
], null, 'A1');
$infoSheet->getColumnDimension('A')->setAutoSize(true);
$infoSheet->getColumnDimension('B')->setAutoSize(true);
$spreadsheet->setActiveSheetIndex(0);
return $spreadsheet;
}
private function buildQuestionBankWorkbook(Season $season): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$questions = $spreadsheet->getActiveSheet();
$questions->setTitle('Questions');
$this->fillBankQuestionsSheet($questions, $season);
$labels = $spreadsheet->createSheet();
$labels->setTitle('Labels');
$this->fillQuestionLabelsSheet($labels, $season);
$spreadsheet->setActiveSheetIndex(0);
return $spreadsheet;
}
private function fillBankQuestionsSheet(Worksheet $sheet, Season $season): void
{
$metaColumns = ['Question', 'Reusable', 'Complete for quiz', 'Labels', 'Used in quizzes'];
$sheet->fromArray($metaColumns, null, 'A1');
$sheet->getStyle('1:1')->getFont()->setBold(true);
$answerStartColumnIndex = \count($metaColumns);
$maxAnswers = 0;
$row = 2;
foreach ($season->bankQuestions as $bankQuestion) {
$labels = implode(', ', array_map(
static fn (QuestionLabel $label): string => $label->name,
$bankQuestion->labels->toArray(),
));
$usedInQuizzes = implode(', ', array_map(
static fn (BankQuestionUsage $usage): string => $usage->quiz->name,
$bankQuestion->usages->toArray(),
));
$sheet->fromArray([
$bankQuestion->question,
$bankQuestion->reusable ? 'Yes' : 'No',
$bankQuestion->isCompleteForQuiz ? 'Yes' : 'No',
$labels,
$usedInQuizzes,
], null, 'A'.$row);
$col = 0;
foreach ($bankQuestion->answers as $answer) {
$sheet->setCellValue(Coordinate::stringFromColumnIndex($answerStartColumnIndex + 1 + 2 * $col).$row, $answer->text);
$sheet->setCellValue(Coordinate::stringFromColumnIndex($answerStartColumnIndex + 2 + 2 * $col).$row, $answer->isRightAnswer);
++$col;
}
$maxAnswers = max($maxAnswers, $col);
++$row;
}
for ($i = 0; $i < $maxAnswers; ++$i) {
$answerCol = Coordinate::stringFromColumnIndex($answerStartColumnIndex + 1 + 2 * $i);
$correctCol = Coordinate::stringFromColumnIndex($answerStartColumnIndex + 2 + 2 * $i);
$sheet->setCellValue($answerCol.'1', 'Answer '.($i + 1));
$sheet->setCellValue($correctCol.'1', 'Correct');
}
$lastColumnIndex = $answerStartColumnIndex + max(1, 2 * $maxAnswers);
foreach (range('A', Coordinate::stringFromColumnIndex($lastColumnIndex)) as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true);
}
}
private function fillQuestionLabelsSheet(Worksheet $sheet, Season $season): void
{
$sheet->fromArray(['Name', 'Colour', 'Slug'], null, 'A1');
$sheet->getStyle('1:1')->getFont()->setBold(true);
$row = 2;
foreach ($season->questionLabels as $label) {
$sheet->fromArray([$label->name, $label->colour->name, $label->slug], null, 'A'.$row);
++$row;
}
foreach (['A', 'B', 'C'] as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true);
}
}
/** @throws FilesystemException */
private function writeToTempFile(Spreadsheet $spreadsheet): string
{
$path = tempnam(sys_get_temp_dir(), 'tvdt_export_sheet_');
try {
new Writer\Xlsx($spreadsheet)->save($path);
} catch (\Throwable $throwable) {
unlink($path);
throw $throwable;
}
return $path;
}
}
+4 -8
View File
@@ -7,7 +7,6 @@ namespace Tvdt\Service;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Reader; use PhpOffice\PhpSpreadsheet\Reader;
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer; use PhpOffice\PhpSpreadsheet\Writer;
use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\HttpFoundation\File\File;
use Tvdt\Entity\Answer; use Tvdt\Entity\Answer;
@@ -118,13 +117,8 @@ class QuizSpreadsheetService
public function quizToXlsx(Quiz $quiz): \Closure public function quizToXlsx(Quiz $quiz): \Closure
{ {
$spreadsheet = new Spreadsheet(); $spreadsheet = new Spreadsheet();
$this->fillQuestionsSheet($spreadsheet->getActiveSheet(), $quiz); $sheet = $spreadsheet->getActiveSheet();
return $this->toXlsx($spreadsheet);
}
public function fillQuestionsSheet(Worksheet $sheet, Quiz $quiz): void
{
// Write data rows first so we know the maximum answer count. // Write data rows first so we know the maximum answer count.
$maxAnswers = 0; $maxAnswers = 0;
$row = 2; $row = 2;
@@ -159,9 +153,11 @@ class QuizSpreadsheetService
$sheet->setCellValue($correctCol.'1', 'Correct'); $sheet->setCellValue($correctCol.'1', 'Correct');
$sheet->getColumnDimension($correctCol)->setAutoSize(true); $sheet->getColumnDimension($correctCol)->setAutoSize(true);
} }
return $this->toXlsx($spreadsheet);
} }
public function toXlsx(Spreadsheet $spreadsheet): \Closure private function toXlsx(Spreadsheet $spreadsheet): \Closure
{ {
$writer = new Writer\Xlsx($spreadsheet); $writer = new Writer\Xlsx($spreadsheet);
-12
View File
@@ -356,18 +356,6 @@
"config/routes/web_profiler.yaml" "config/routes/web_profiler.yaml"
] ]
}, },
"symfonycasts/reset-password-bundle": {
"version": "1.25",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "97c1627c0384534997ae1047b93be517ca16de43"
},
"files": [
"config/packages/reset_password.yaml"
]
},
"symfonycasts/sass-bundle": { "symfonycasts/sass-bundle": {
"version": "v0.8.2" "version": "v0.8.2"
}, },
@@ -1,4 +1,3 @@
<h6>Kandidaten</h6> <h6>Kandidaten</h6>
<p>Dit zijn de spelers van dit seizoen. Voeg alle deelnemers toe voordat je de eerste test start, kandidaten worden automatisch aan nieuwe testen gekoppeld.</p> <p>Dit zijn de spelers van dit seizoen. Voeg alle deelnemers toe voordat je de eerste test start, kandidaten worden automatisch aan nieuwe testen gekoppeld.</p>
<p>Namen zijn vrij in te voeren, gebruik dezelfde schrijfwijze die je in het spel gebruikt.</p> <p>Namen zijn vrij in te voeren, gebruik dezelfde schrijfwijze die je in het spel gebruikt.</p>
<p>Gebruik het potlood-icoon om een kandidaat te hernoemen en het prullenbak-icoon om er een te verwijderen. Verwijderen gooit ook alle gegeven antwoorden van die kandidaat weg.</p>
@@ -1,4 +1,3 @@
<h6>Seizoensinstellingen</h6> <h6>Seizoensinstellingen</h6>
<p>Pas hier de weergave-instellingen van dit seizoen aan.</p> <p>Pas hier de weergave-instellingen van dit seizoen aan.</p>
<p><strong>Nummers tonen:</strong> toont vraagnummers tijdens de test. <strong>Antwoord bevestigen:</strong> vraagt kandidaten om hun antwoord te bevestigen voordat ze doorgaan.</p> <p><strong>Nummers tonen:</strong> toont vraagnummers tijdens de test. <strong>Antwoord bevestigen:</strong> vraagt kandidaten om hun antwoord te bevestigen voordat ze doorgaan.</p>
<p><strong>Seizoenscode:</strong> de code waarmee kandidaten dit seizoen kunnen vinden. Genereer een nieuwe code als de huidige per ongeluk gedeeld is, de oude code werkt daarna niet meer.</p>
+1 -3
View File
@@ -4,7 +4,7 @@
{% block body %} {% block body %}
<form method="post"> <form method="post">
<h3 class="mb-3">{{ 'Please sign in'|trans }}</h3> <h1 class="py-2 h3 mb-3 font-weight-normal">{{ 'Please sign in'|trans }}</h1>
<div class="mb-3"> <div class="mb-3">
<label for="username" class="form-label">{{ 'Email'|trans }}</label> <label for="username" class="form-label">{{ 'Email'|trans }}</label>
<input type="email" value="{{ last_username }}" name="_username" id="username" class="form-control" <input type="email" value="{{ last_username }}" name="_username" id="username" class="form-control"
@@ -31,7 +31,5 @@
</button> </button>
<a href="{{ path('tvdt_register') }}" <a href="{{ path('tvdt_register') }}"
class="btn btn-link">{{ 'Create an account'|trans }}</a> class="btn btn-link">{{ 'Create an account'|trans }}</a>
<a href="{{ path('tvdt_forgot_password_request') }}"
class="btn btn-link">{{ 'Forgot your password?'|trans }}</a>
</form> </form>
{% endblock %} {% endblock %}
-4
View File
@@ -23,10 +23,6 @@
</li> </li>
</ul> </ul>
<ul class="navbar-nav mb-auto me-2 me-lg-0"> <ul class="navbar-nav mb-auto me-2 me-lg-0">
<li class="nav-item">
<a class="nav-link{% if 'tvdt_backoffice_settings' == app.current_route() %} active{% endif %}"
href="{{ path('tvdt_backoffice_settings') }}">{{ 'Settings'|trans }}</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" <a class="nav-link"
href="{{ path('tvdt_login_logout') }}">{{ 'Logout'|trans }}</a> href="{{ path('tvdt_login_logout') }}">{{ 'Logout'|trans }}</a>
@@ -1,13 +1,10 @@
{% macro answer_row(answerForm) %} {% macro answer_row(answerForm) %}
<div class="d-flex align-items-center gap-2 mb-2" data-collection-item <div class="d-flex align-items-center gap-2 mb-2" data-collection-item>
data-action="dragover->bo--form-collection#dragOver dragleave->bo--form-collection#dragLeave drop->bo--form-collection#drop">
{{ form_widget(answerForm.ordering) }} {{ form_widget(answerForm.ordering) }}
<span class="text-muted" data-drag-handle style="cursor: grab" title="{{ 'Drag to reorder'|trans }}" <span class="text-muted" data-drag-handle style="cursor: grab" title="{{ 'Drag to reorder'|trans }}"><i class="bi bi-grip-vertical"></i></span>
draggable="true"
data-action="dragstart->bo--form-collection#dragStart dragend->bo--form-collection#dragEnd"><i class="bi bi-grip-vertical"></i></span>
<div class="flex-grow-1">{{ form_widget(answerForm.text) }}</div> <div class="flex-grow-1">{{ form_widget(answerForm.text) }}</div>
<div class="d-none">{{ form_widget(answerForm.isRightAnswer) }}</div> <div class="d-none">{{ form_widget(answerForm.isRightAnswer) }}</div>
<button type="button" <button type="button" tabindex="-1"
class="btn btn-sm {{ answerForm.isRightAnswer.vars.checked ? 'btn-success' : 'btn-danger' }}" class="btn btn-sm {{ answerForm.isRightAnswer.vars.checked ? 'btn-success' : 'btn-danger' }}"
title="{{ 'Toggle correct answer'|trans }}" title="{{ 'Toggle correct answer'|trans }}"
onclick="var cb=this.closest('[data-collection-item]').querySelector('input[type=checkbox]');cb.checked=!cb.checked;this.classList.toggle('btn-success',cb.checked);this.classList.toggle('btn-danger',!cb.checked);this.querySelector('i').className=cb.checked?'bi bi-check-lg':'bi bi-x-lg'"> onclick="var cb=this.closest('[data-collection-item]').querySelector('input[type=checkbox]');cb.checked=!cb.checked;this.classList.toggle('btn-success',cb.checked);this.classList.toggle('btn-danger',!cb.checked);this.querySelector('i').className=cb.checked?'bi bi-check-lg':'bi bi-x-lg'">
@@ -3,30 +3,13 @@
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }} {{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
{{ form_row(form.question) }} {{ form_row(form.question) }}
{{ form_row(form.reusable) }} {{ form_row(form.reusable) }}
<div class="mb-3"> {{ form_row(form.labels) }}
{{ form_label(form.labels) }}
{{ form_errors(form.labels) }}
{% for labelChoice in form.labels %}
<div class="form-check">
<input type="checkbox" class="form-check-input"
id="{{ labelChoice.vars.id }}"
name="{{ labelChoice.vars.full_name }}"
value="{{ labelChoice.vars.value }}"
{% if labelChoice.vars.checked %}checked="checked"{% endif %}>
<label class="form-check-label" for="{{ labelChoice.vars.id }}">
<span class="badge rounded-pill text-bg-{{ labelChoice.vars.attr['data-colour'] }}">{{ labelChoice.vars.label }}</span>
</label>
</div>
{% endfor %}
{% do form.labels.setRendered %}
</div>
<div data-controller="bo--form-collection" <div data-controller="bo--form-collection"
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}"> data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
{{ form_label(form.answers) }} {{ form_label(form.answers) }}
{{ form_errors(form.answers) }} {{ form_errors(form.answers) }}
<div data-bo--form-collection-target="collection" <div data-bo--form-collection-target="collection">
data-action="input->bo--form-collection#autoExpand">
{% for answerForm in form.answers %} {% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }} {{ macros.answer_row(answerForm) }}
{% endfor %} {% endfor %}
@@ -5,29 +5,12 @@
<div class="modal-body"> <div class="modal-body">
{{ form_row(form.question) }} {{ form_row(form.question) }}
{{ form_row(form.reusable) }} {{ form_row(form.reusable) }}
<div class="mb-3"> {{ form_row(form.labels) }}
{{ form_label(form.labels) }}
{{ form_errors(form.labels) }}
{% for labelChoice in form.labels %}
<div class="form-check">
<input type="checkbox" class="form-check-input"
id="{{ labelChoice.vars.id }}"
name="{{ labelChoice.vars.full_name }}"
value="{{ labelChoice.vars.value }}"
{% if labelChoice.vars.checked %}checked="checked"{% endif %}>
<label class="form-check-label" for="{{ labelChoice.vars.id }}">
<span class="badge rounded-pill text-bg-{{ labelChoice.vars.attr['data-colour'] }}">{{ labelChoice.vars.label }}</span>
</label>
</div>
{% endfor %}
{% do form.labels.setRendered %}
</div>
<div data-controller="bo--form-collection" <div data-controller="bo--form-collection"
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}"> data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
{{ form_label(form.answers) }} {{ form_label(form.answers) }}
{{ form_errors(form.answers) }} {{ form_errors(form.answers) }}
<div data-bo--form-collection-target="collection" <div data-bo--form-collection-target="collection">
data-action="input->bo--form-collection#autoExpand">
{% for answerForm in form.answers %} {% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }} {{ macros.answer_row(answerForm) }}
{% endfor %} {% endfor %}
@@ -7,8 +7,7 @@
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}"> data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
{{ form_label(form.answers) }} {{ form_label(form.answers) }}
{{ form_errors(form.answers) }} {{ form_errors(form.answers) }}
<div data-bo--form-collection-target="collection" <div data-bo--form-collection-target="collection">
data-action="input->bo--form-collection#autoExpand">
{% for answerForm in form.answers %} {% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }} {{ macros.answer_row(answerForm) }}
{% endfor %} {% endfor %}
@@ -8,8 +8,7 @@
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}"> data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
{{ form_label(form.answers) }} {{ form_label(form.answers) }}
{{ form_errors(form.answers) }} {{ form_errors(form.answers) }}
<div data-bo--form-collection-target="collection" <div data-bo--form-collection-target="collection">
data-action="input->bo--form-collection#autoExpand">
{% for answerForm in form.answers %} {% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }} {{ macros.answer_row(answerForm) }}
{% endfor %} {% endfor %}
@@ -37,7 +37,7 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
<form action="{{ path('tvdt_backoffice_toggle_candidate', {quiz: quiz.id, candidate: candidate.id}) }}" method="POST" class="d-inline"> <form action="{{ path('tvdt_backoffice_toggle_candidate', {quiz: quiz.id, candidate: candidate.id}) }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('toggle_candidate') }}"> <input type="hidden" name="_token" value="{{ csrf_token('toggle_candidate') }}">
<button type="submit" class="btn btn-sm btn-outline-secondary"> <button type="submit" class="btn btn-sm btn-outline-secondary">
{% if quizCandidate == null or quizCandidate.active %} {% if quizCandidate == null or quizCandidate.active %}
@@ -47,12 +47,6 @@
{% endif %} {% endif %}
</button> </button>
</form> </form>
{% if quizCandidate and quizCandidate.started %}
<form action="{{ path('tvdt_backoffice_reset_candidate_progress', {quiz: quiz.id, candidate: candidate.id}) }}" method="POST" class="d-inline" onsubmit="return confirm('{{ 'Are you sure you want to reset progress for this candidate? Their given answers for this quiz will be deleted.'|trans|e('js') }}');">
<input type="hidden" name="_token" value="{{ csrf_token('reset_candidate_progress') }}">
<button type="submit" class="btn btn-sm btn-outline-danger">{{ 'Reset progress'|trans }}</button>
</form>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -86,20 +86,19 @@
</div> </div>
<div data-controller="bo--question-list bo--modal" <div data-controller="bo--question-list bo--modal"
data-action="turbo:submit-end->bo--modal#frameSubmitEnd" data-action="turbo:frame-load->bo--modal#frameLoad turbo:submit-end->bo--modal#frameSubmitEnd"
data-bo--question-list-reorder-url-value="{{ path('tvdt_backoffice_quiz_questions_reorder', {seasonCode: season.seasonCode, quiz: quiz.id}) }}" data-bo--question-list-reorder-url-value="{{ path('tvdt_backoffice_quiz_questions_reorder', {seasonCode: season.seasonCode, quiz: quiz.id}) }}"
data-bo--question-list-csrf-value="{{ csrf_token('question_reorder') }}" data-bo--question-list-csrf-value="{{ csrf_token('question_reorder') }}"
data-bo--question-list-can-modify-value="{{ is_granted('QUIZ_MODIFY_CONTENT', quiz) ? 'true' : 'false' }}"
data-bo--question-list-saved-label-value="{{ 'Order saved'|trans }}" data-bo--question-list-saved-label-value="{{ 'Order saved'|trans }}"
data-bo--question-list-error-label-value="{{ 'Error saving order'|trans }}" data-bo--question-list-error-label-value="{{ 'Error saving order'|trans }}">
data-bo--question-list-error-hint-value="{{ 'Refresh the page to try again.'|trans }}">
<h4 class="mb-3 d-flex align-items-center gap-2"> <h4 class="mb-3 d-flex align-items-center gap-2">
{{ 'Questions'|trans }} {{ 'Questions'|trans }}
<span class="badge d-none fw-normal" style="font-size:.7rem;vertical-align:baseline" data-bo--question-list-target="status"></span> <span class="badge d-none fw-normal" style="font-size:.7rem;vertical-align:baseline" data-bo--question-list-target="status"></span>
</h4> </h4>
<div data-bo--question-list-target="list" <div data-bo--question-list-target="list">
{% if is_granted('QUIZ_MODIFY_CONTENT', quiz) %}data-action="dragover->bo--question-list#dragOver dragleave->bo--question-list#dragLeave drop->bo--question-list#drop"{% endif %}>
{%~ for question in quiz.questions ~%} {%~ for question in quiz.questions ~%}
<div class="card mb-2" <div class="card mb-2"
data-bo--question-list-target="item" data-bo--question-list-target="item"
@@ -107,9 +106,7 @@
<div class="card-body py-2"> <div class="card-body py-2">
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
{% if is_granted('QUIZ_MODIFY_CONTENT', question) %} {% if is_granted('QUIZ_MODIFY_CONTENT', question) %}
<span class="text-muted" style="cursor:grab" <span class="text-muted" style="cursor:grab" data-drag-handle>
draggable="true"
data-action="dragstart->bo--question-list#dragStart dragend->bo--question-list#dragEnd">
<i class="bi bi-grip-vertical"></i> <i class="bi bi-grip-vertical"></i>
</span> </span>
{% endif %} {% endif %}
@@ -138,13 +135,12 @@
</div> </div>
</div> </div>
{% else %} {% else %}
{{ 'No questions have been added to this quiz yet.'|trans }} {{ 'EMPTY'|trans }}
{% endfor %} {% endfor %}
</div> </div>
<div class="modal fade" tabindex="-1" <div class="modal fade" tabindex="-1"
data-bo--modal-target="modal" data-bo--modal-target="modal"
data-action="hidden.bs.modal->bo--modal#resetDirty"
aria-labelledby="questionEditModalLabel" aria-hidden="true"> aria-labelledby="questionEditModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
@@ -152,9 +148,26 @@
<h5 class="modal-title" id="questionEditModalLabel">{{ 'Edit question'|trans }}</h5> <h5 class="modal-title" id="questionEditModalLabel">{{ 'Edit question'|trans }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<turbo-frame id="question-modal-frame" <turbo-frame id="question-modal-frame" data-bo--modal-target="frame"></turbo-frame>
data-bo--modal-target="frame" </div>
data-action="input->bo--modal#markDirty change->bo--modal#markDirty"></turbo-frame> </div>
</div>
<div class="modal fade" tabindex="-1"
data-bo--question-list-target="noticeModal"
aria-labelledby="questionReorderErrorModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="questionReorderErrorModalLabel">{{ 'Could not save order'|trans }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{ 'The new question order could not be saved. Reordering has been disabled until you refresh the page.'|trans }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Close'|trans }}</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -2,7 +2,7 @@
{% block title %}{{ 'Register'|trans }}{% endblock %} {% block title %}{{ 'Register'|trans }}{% endblock %}
{% block body %} {% block body %}
<h3 class="mb-3">{{ 'Register'|trans }}</h3> <h3>{{ 'Register'|trans }}</h3>
{{ form_errors(registrationForm) }} {{ form_errors(registrationForm) }}
@@ -1,11 +0,0 @@
<turbo-frame id="add-candidates-modal-frame">
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
<div class="modal-body">
{{ form_row(form.candidates) }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
<button type="submit" class="btn btn-primary">{{ 'Submit'|trans }}</button>
</div>
{{ form_end(form) }}
</turbo-frame>
@@ -1,96 +1,16 @@
<div class="row" data-controller="bo--modal" data-action="turbo:submit-end->bo--modal#frameSubmitEnd"> <div class="row">
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
<div class="mb-3"> <div class="mb-3">
<button type="button" class="btn btn-sm btn-outline-primary" <a class="btn btn-sm btn-outline-primary"
data-action="click->bo--modal#open" href="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }}</a>
data-src="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}"
data-modal-title="{{ 'Add Candidate'|trans }}">{{ 'Add Candidate'|trans }}</button>
</div> </div>
<ul class="list-group mb-3"> <ul class="mb-3">
{% for candidate in season.candidates %} {% for candidate in season.candidates %}
<li class="list-group-item d-flex align-items-center justify-content-between gap-2"> <li>{{ candidate.name }}</li>
{{ candidate.name }}
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal"
data-bs-target="#renameCandidate-{{ candidate.id }}"
title="{{ 'Rename'|trans }}"><i class="bi bi-pencil"></i></button>
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal"
data-bs-target="#deleteCandidate-{{ candidate.id }}"
title="{{ 'Delete'|trans }}"><i class="bi bi-trash"></i></button>
</div>
<div class="modal fade" id="renameCandidate-{{ candidate.id }}"
data-controller="bo--modal" data-bo--modal-target="modal"
data-action="hidden.bs.modal->bo--modal#resetDirty"
tabindex="-1" aria-labelledby="renameCandidate-{{ candidate.id }}Label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ path('tvdt_backoffice_candidate_rename', {seasonCode: season.seasonCode, candidate: candidate.id}) }}"
method="POST">
<div class="modal-header">
<h1 class="modal-title fs-5" id="renameCandidate-{{ candidate.id }}Label">{{ 'Rename candidate'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start">
<input type="hidden" name="_token" value="{{ csrf_token('rename_candidate') }}">
<label class="form-label" for="renameCandidateName-{{ candidate.id }}">{{ 'Name'|trans }}</label>
<input type="text" class="form-control" id="renameCandidateName-{{ candidate.id }}"
name="name" value="{{ candidate.name }}" maxlength="16" required autofocus
data-action="input->bo--modal#markDirty change->bo--modal#markDirty">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
<button type="submit" class="btn btn-primary">{{ 'Rename'|trans }}</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal fade" id="deleteCandidate-{{ candidate.id }}" data-bs-backdrop="static"
tabindex="-1" aria-labelledby="deleteCandidate-{{ candidate.id }}Label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="deleteCandidate-{{ candidate.id }}Label">{{ 'Please Confirm'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start">
{{ 'Are you sure you want to delete this candidate? All their answers will be lost.'|trans }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'No'|trans }}</button>
<form action="{{ path('tvdt_backoffice_candidate_delete', {seasonCode: season.seasonCode, candidate: candidate.id}) }}"
method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('delete_candidate') }}">
<button type="submit" class="btn btn-danger">{{ 'Yes'|trans }}</button>
</form>
</div>
</div>
</div>
</div>
</li>
{% else %} {% else %}
{{ 'No candidates'|trans }} {{ 'No candidates'|trans }}
{% endfor %} {% endfor %}
</ul> </ul>
<div class="modal fade" tabindex="-1"
data-bo--modal-target="modal"
data-action="hidden.bs.modal->bo--modal#resetDirty"
aria-labelledby="addCandidatesModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="addCandidatesModalLabel">{{ 'Add Candidate'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<turbo-frame id="add-candidates-modal-frame"
data-bo--modal-target="frame"
data-action="input->bo--modal#markDirty change->bo--modal#markDirty"></turbo-frame>
</div>
</div>
</div>
</div> </div>
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
{{ include('backoffice/help/season_candidates.html.twig') }} {{ include('backoffice/help/season_candidates.html.twig') }}
@@ -1,6 +1,6 @@
<div class="row"> <div class="row">
<div class="col-md-8 col-12" data-controller="bo--modal" <div class="col-md-8 col-12" data-controller="bo--modal"
data-action="turbo:submit-end->bo--modal#frameSubmitEnd"> data-action="turbo:frame-load->bo--modal#frameLoad turbo:submit-end->bo--modal#frameSubmitEnd">
<div class="mb-3"> <div class="mb-3">
<button class="btn btn-sm btn-outline-primary" <button class="btn btn-sm btn-outline-primary"
data-action="click->bo--modal#open" data-action="click->bo--modal#open"
@@ -129,7 +129,6 @@
<button type="button" class="btn btn-outline-secondary" <button type="button" class="btn btn-outline-secondary"
data-action="click->bo--modal#open" data-action="click->bo--modal#open"
data-src="{{ path('tvdt_backoffice_question_bank_edit', {seasonCode: season.seasonCode, bankQuestion: bankQuestion.id}) }}" data-src="{{ path('tvdt_backoffice_question_bank_edit', {seasonCode: season.seasonCode, bankQuestion: bankQuestion.id}) }}"
data-modal-title="{{ 'Edit question'|trans }}"
title="{{ 'Edit'|trans }}"><i class="bi bi-pencil"></i></button> title="{{ 'Edit'|trans }}"><i class="bi bi-pencil"></i></button>
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" <button type="button" class="btn btn-outline-danger" data-bs-toggle="modal"
data-bs-target="#deleteBankQuestion-{{ bankQuestion.id }}" data-bs-target="#deleteBankQuestion-{{ bankQuestion.id }}"
@@ -173,7 +172,6 @@
</table> </table>
<div class="modal fade" tabindex="-1" <div class="modal fade" tabindex="-1"
data-bo--modal-target="modal" data-bo--modal-target="modal"
data-action="hidden.bs.modal->bo--modal#resetDirty"
aria-labelledby="bankQuestionEditModalLabel" aria-hidden="true"> aria-labelledby="bankQuestionEditModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
@@ -181,9 +179,7 @@
<h5 class="modal-title" id="bankQuestionEditModalLabel">{{ 'Edit question'|trans }}</h5> <h5 class="modal-title" id="bankQuestionEditModalLabel">{{ 'Edit question'|trans }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<turbo-frame id="bank-question-modal-frame" <turbo-frame id="bank-question-modal-frame" data-bo--modal-target="frame"></turbo-frame>
data-bo--modal-target="frame"
data-action="input->bo--modal#markDirty change->bo--modal#markDirty"></turbo-frame>
</div> </div>
</div> </div>
</div> </div>
@@ -1,40 +1,8 @@
<div class="row"> <div class="row">
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
{{ form(form) }} {{ form(form) }}
<hr>
<h4 class="text-danger">{{ 'Season code'|trans }}</h4>
<p>{{ 'The season code is used by candidates to join this season. Regenerating it invalidates the current code, so make sure to share the new one.'|trans }}</p>
<p><strong>{{ 'Current code:'|trans }}</strong> {{ season.seasonCode }}</p>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#regenerateSeasonCodeModal">
{{ 'Regenerate season code...'|trans }}
</button>
</div> </div>
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
{{ include('backoffice/help/season_settings.html.twig') }} {{ include('backoffice/help/season_settings.html.twig') }}
</div> </div>
</div> </div>
<div class="modal fade" id="regenerateSeasonCodeModal" data-bs-backdrop="static"
tabindex="-1"
aria-labelledby="regenerateSeasonCodeModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ path('tvdt_backoffice_season_regenerate_code', {seasonCode: season.seasonCode}) }}" method="POST">
<div class="modal-header">
<h1 class="modal-title fs-5" id="regenerateSeasonCodeModalLabel">{{ 'Regenerate season code'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>{{ 'This invalidates the current season code. Anyone using the old code will no longer be able to join this season.'|trans }}</p>
<input type="hidden" name="_token" value="{{ csrf_token('regenerate_season_code') }}">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
<button type="submit" class="btn btn-danger">{{ 'Regenerate season code'|trans }}</button>
</div>
</form>
</div>
</div>
</div>
@@ -8,13 +8,11 @@
</div> </div>
<div class="list-group mb-3"> <div class="list-group mb-3">
{% for quiz in season.quizzes %} {% for quiz in season.quizzes %}
<a class="list-group-item list-group-item-action d-flex align-items-center gap-2{% if season.activeQuiz == quiz %} active{% endif %}" <a class="list-group-item list-group-item-action{% if season.activeQuiz == quiz %} active{% endif %}"
href="{{ path('tvdt_backoffice_quiz', {seasonCode: season.seasonCode, quiz: quiz.id}) }}"> href="{{ path('tvdt_backoffice_quiz', {seasonCode: season.seasonCode, quiz: quiz.id}) }}">
{{ quiz.name }} {{ quiz.name }}
{% if season.activeQuiz == quiz %} {% if quiz.isFinalized %}
<span class="badge text-bg-light ms-auto">{{ 'Active'|trans }}</span> <span class="badge text-bg-success">{{ 'Finalized'|trans }}</span>
{% elseif quiz.isFinalized %}
<span class="badge text-bg-success ms-auto">{{ 'Ready'|trans }}</span>
{% endif %} {% endif %}
</a> </a>
{% else %} {% else %}
@@ -1,102 +0,0 @@
{% extends 'backoffice/base.html.twig' %}
{% block title %}{{ parent() }}{{ 'Settings'|trans }}{% endblock %}
{% block breadcrumbs %}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ path('tvdt_backoffice_index') }}">{{ 'Home'|trans }}</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ 'Settings'|trans }}</li>
</ol>
</nav>
{% endblock %}
{% block body %}
<div class="row">
<div class="col-lg-6 col-12">
<h2 class="mb-4">{{ 'Settings'|trans }}</h2>
<section class="mb-5">
<h4>{{ 'Language'|trans }}</h4>
<form action="{{ path('tvdt_backoffice_settings_language') }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('settings_language') }}">
<div class="mb-3">
<label class="form-label" for="settings-language">{{ 'Language'|trans }}</label>
<select class="form-select" id="settings-language" name="language">
<option value="nl" selected>Nederlands</option>
</select>
</div>
<button type="submit" class="btn btn-primary">{{ 'Save'|trans }}</button>
</form>
</section>
<section class="mb-5">
<h4>{{ 'Change password'|trans }}</h4>
{{ form(passwordForm, {action: path('tvdt_backoffice_settings_password')}) }}
</section>
<section class="mb-5">
<h4>{{ 'Change email'|trans }}</h4>
<p class="mb-1">
<strong>{{ 'Current email address:'|trans }}</strong> {{ app.user.userIdentifier }}
{% if app.user.isVerified %}
<span class="badge text-bg-success">{{ 'Confirmed'|trans }}</span>
{% else %}
<span class="badge text-bg-warning">{{ 'Not confirmed'|trans }}</span>
<form class="d-inline" action="{{ path('tvdt_backoffice_settings_resend_confirmation') }}" method="POST">
<input type="hidden" name="_token" value="{{ csrf_token('resend_confirmation') }}">
<button type="submit" class="btn btn-link btn-sm p-0 align-baseline">
{{ 'Resend confirmation email'|trans }}
</button>
</form>
{% endif %}
</p>
<p>{{ 'After changing your email address you will receive a new confirmation email.'|trans }}</p>
{{ form(emailForm, {action: path('tvdt_backoffice_settings_email')}) }}
</section>
<section class="mb-5">
<h4>{{ 'Your data'|trans }}</h4>
<p>{{ 'Download an archive of everything stored under your account: your profile, the seasons you own, their quizzes, results and candidates.'|trans }}</p>
{% if not app.user.isVerified %}
<p class="text-warning">{{ 'Confirm your email address to enable this feature.'|trans }}</p>
{% endif %}
<a class="btn btn-primary" href="{{ path('tvdt_backoffice_settings_download_data') }}">{{ 'Download data'|trans }}</a>
</section>
<section class="mb-5">
<h4 class="text-danger">{{ 'Danger zone'|trans }}</h4>
<p>{{ 'Deleting your account also deletes every season you are the only owner of. This cannot be undone.'|trans }}</p>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#deleteAccountModal">
{{ 'Delete account...'|trans }}
</button>
</section>
</div>
</div>
<div class="modal fade" id="deleteAccountModal" data-bs-backdrop="static"
tabindex="-1"
aria-labelledby="deleteAccountModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ path('tvdt_backoffice_settings_delete') }}" method="POST">
<div class="modal-header">
<h1 class="modal-title fs-5" id="deleteAccountModalLabel">{{ 'Delete account'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>{{ 'This deletes your account and every season you are the only owner of. Enter your password to confirm.'|trans }}</p>
<input type="hidden" name="_token" value="{{ csrf_token('delete_account') }}">
<label class="form-label" for="delete-account-password">{{ 'Current password'|trans }}</label>
<input type="password" class="form-control" id="delete-account-password"
name="password" required autocomplete="current-password">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
<button type="submit" class="btn btn-danger">{{ 'Delete account'|trans }}</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
-10
View File
@@ -1,13 +1,3 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block importmap %}{{ importmap('quiz') }}{% endblock %} {% block importmap %}{{ importmap('quiz') }}{% endblock %}
{% block nav %}{{ include('quiz/nav.html.twig') }}{% endblock %} {% block nav %}{{ include('quiz/nav.html.twig') }}{% endblock %}
{% block main %}
<div data-controller="fullscreen">
<button type="button"
class="fullscreen-btn"
data-action="fullscreen#toggle"
aria-label="{{ 'Fullscreen'|trans }}"
title="{{ 'Fullscreen'|trans }}">⛶</button>
{{ parent() }}
</div>
{% endblock %}
+1 -1
View File
@@ -4,7 +4,7 @@
<a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary btn-sm"> <a href="{{ path('tvdt_backoffice_index') }}" class="btn btn-outline-secondary btn-sm">
{{ 'Backoffice'|trans }} {{ 'Backoffice'|trans }}
</a> </a>
<a href="{{ path('tvdt_login_logout', {target: app.request.pathInfo}) }}" class="btn btn-outline-secondary btn-sm"> <a href="{{ path('tvdt_login_logout') }}" class="btn btn-outline-secondary btn-sm">
{{ 'Logout'|trans }} {{ 'Logout'|trans }}
</a> </a>
{% else %} {% else %}
@@ -1,16 +0,0 @@
{% extends 'backoffice/base.html.twig' %}
{% block title %}E-mail verstuurd{% endblock %}
{% block body %}
<h3 class="mb-3">{{ 'Check your email'|trans }}</h3>
<p>
{{ 'If an account matching your email exists, then an email was just sent that contains a link that you can use to reset your password.'|trans }}
{{ 'This link will expire in %count%.'|trans({'%count%': resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle')}) }}
</p>
<p>
{{ 'If you don\'t receive an email please check your spam folder or'|trans }}
<a href="{{ path('tvdt_forgot_password_request') }}">{{ 'try again'|trans }}</a>.
</p>
{% endblock %}
-9
View File
@@ -1,9 +0,0 @@
<h1>Hi!</h1>
<p>To reset your password, please visit the following link</p>
<a href="{{ url('tvdt_reset_password', {token: resetToken.token}) }}">{{ url('tvdt_reset_password', {token: resetToken.token}) }}</a>
<p>This link will expire in {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.</p>
<p>Cheers!</p>
@@ -1,18 +0,0 @@
{% extends 'backoffice/base.html.twig' %}
{% block title %}Wachtwoord vergeten{% endblock %}
{% block body %}
<h3 class="mb-3">{{ 'Reset your password'|trans }}</h3>
{{ form_start(requestForm) }}
{{ form_row(requestForm.email) }}
<small class="d-block mb-3">
{{ 'Enter your email address, and we will send you a link to reset your password.'|trans }}
</small>
<button class="btn btn-primary" type="submit">{{ 'Send password reset email'|trans }}</button>
<a href="{{ path('tvdt_login_login') }}" class="btn btn-link">{{ 'Back to login'|trans }}</a>
{{ form_end(requestForm) }}
{% endblock %}
-12
View File
@@ -1,12 +0,0 @@
{% extends 'backoffice/base.html.twig' %}
{% block title %}Nieuw wachtwoord instellen{% endblock %}
{% block body %}
<h3 class="mb-3">{{ 'Reset your password'|trans }}</h3>
{{ form_start(resetForm) }}
{{ form_row(resetForm.plainPassword) }}
<button class="btn btn-primary" type="submit">{{ 'Reset password'|trans }}</button>
{{ form_end(resetForm) }}
{% endblock %}
+13 -12
View File
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Tvdt\Tests\Command; namespace Tvdt\Tests\Command;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
@@ -49,19 +48,21 @@ final class ClaimSeasonCommandTest extends KernelTestCase
$this->assertCount(3, $season->owners); $this->assertCount(3, $season->owners);
} }
/** @return iterable<string, array{string, string}> */ public function testInvalidEmailFails(): void
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
{ {
$this->commandTester->execute([ $this->commandTester->execute([
'season-code' => $seasonCode, 'season-code' => 'krtek',
'email' => $email, '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()); $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');
}
}
@@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\BackofficeController;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
#[CoversClass(BackofficeController::class)]
final class BackofficeControllerTest extends AbstractControllerWebTestCase
{
public function testExportQuizFilenameIsSanitized(): void
{
$user = $this->getUserByEmail('user2@example.org');
$user->isVerified = true;
$this->entityManager->flush();
$this->client->loginUser($user);
$quiz = $this->getQuizByName('Quiz 1');
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
self::assertResponseIsSuccessful();
$disposition = (string) $this->client->getResponse()->headers->get('Content-Disposition');
$this->assertStringContainsString('filename=Quiz-1.xlsx', $disposition);
$this->assertStringNotContainsString('Quiz 1.xlsx', $disposition);
}
public function testExportQuizRequiresVerifiedEmail(): void
{
$user = $this->getUserByEmail('user2@example.org');
$this->assertFalse($user->isVerified);
$this->client->loginUser($user);
$quiz = $this->getQuizByName('Quiz 1');
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
self::assertResponseRedirects(\sprintf('/backoffice/season/%s', $quiz->season->seasonCode));
}
public function testExportQuizIsDeniedForNonOwner(): void
{
$this->loginAs('test@example.org');
$quiz = $this->getQuizByName('Quiz 1');
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
self::assertResponseStatusCodeSame(403);
}
}
@@ -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,38 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice; namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuestionBankController; use Tvdt\Controller\Backoffice\QuestionBankController;
use Tvdt\Entity\BankAnswer;
use Tvdt\Entity\BankQuestion; use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\Question; use Tvdt\Entity\Question;
use Tvdt\Entity\QuestionLabel; use Tvdt\Entity\QuestionLabel;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase; use Tvdt\Entity\Quiz;
use Tvdt\Entity\User;
#[CoversClass(QuestionBankController::class)] #[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 private function getBankQuestion(string $question): BankQuestion
{ {
$bankQuestion = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => $question]); $bankQuestion = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => $question]);
@@ -24,9 +44,26 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
return $bankQuestion; return $bankQuestion;
} }
private function getQuizByName(string $name): Quiz
{
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
$this->assertInstanceOf(Quiz::class, $quiz);
return $quiz;
}
private function getCsrfToken(string $formActionContains): string
{
$crawler = $this->client->getCrawler();
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
return (string) $input->first()->attr('value');
}
public function testIndexListsBankQuestions(): void public function testIndexListsBankQuestions(): void
{ {
$this->loginAs('krtek-admin@example.org'); $this->loginAsOwner();
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$this->assertResponseIsSuccessful(); $this->assertResponseIsSuccessful();
@@ -37,7 +74,7 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
public function testIndexFiltersByLabel(): void public function testIndexFiltersByLabel(): void
{ {
$this->loginAs('krtek-admin@example.org'); $this->loginAsOwner();
$label = $this->entityManager->getRepository(QuestionLabel::class)->findOneBy(['name' => 'Locatie']); $label = $this->entityManager->getRepository(QuestionLabel::class)->findOneBy(['name' => 'Locatie']);
$this->assertInstanceOf(QuestionLabel::class, $label); $this->assertInstanceOf(QuestionLabel::class, $label);
@@ -51,7 +88,9 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
public function testNonOwnerIsDenied(): void 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'); $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
@@ -60,7 +99,7 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
public function testCreateBankQuestion(): void 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'); $crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
$this->assertResponseIsSuccessful(); $this->assertResponseIsSuccessful();
@@ -89,7 +128,7 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
public function testCreateAllowedWithoutCorrectAnswer(): void 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'); $crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value'); $token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
@@ -105,7 +144,6 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
]); ]);
$this->assertResponseRedirects(); $this->assertResponseRedirects();
$this->entityManager->clear();
$saved = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => 'Vraag zonder goed antwoord']); $saved = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => 'Vraag zonder goed antwoord']);
$this->assertInstanceOf(BankQuestion::class, $saved); $this->assertInstanceOf(BankQuestion::class, $saved);
$this->assertFalse($saved->isCompleteForQuiz); $this->assertFalse($saved->isCompleteForQuiz);
@@ -113,7 +151,7 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
public function testEditBankQuestion(): void public function testEditBankQuestion(): void
{ {
$this->loginAs('krtek-admin@example.org'); $this->loginAsOwner();
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?'); $bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
$url = \sprintf('/backoffice/season/krtek/question-bank/%s/edit', $bankQuestion->id); $url = \sprintf('/backoffice/season/krtek/question-bank/%s/edit', $bankQuestion->id);
@@ -142,12 +180,12 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
public function testDeleteUsedBankQuestionLeavesQuizIntact(): void public function testDeleteUsedBankQuestionLeavesQuizIntact(): void
{ {
$this->loginAs('krtek-admin@example.org'); $this->loginAsOwner();
$bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?'); $bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?');
$quiz2QuestionCount = $this->getQuizByName('Quiz 2')->questions->count(); $quiz2QuestionCount = $this->getQuizByName('Quiz 2')->questions->count();
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$token = $this->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), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/delete', $bankQuestion->id), [
'_token' => $token, '_token' => $token,
@@ -162,13 +200,13 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
public function testAssignCopiesQuestionIntoQuiz(): void public function testAssignCopiesQuestionIntoQuiz(): void
{ {
$this->loginAs('krtek-admin@example.org'); $this->loginAsOwner();
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?'); $bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
$quiz = $this->getQuizByName('Quiz 2'); $quiz = $this->getQuizByName('Quiz 2');
$questionCount = $quiz->questions->count(); $questionCount = $quiz->questions->count();
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$token = $this->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), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
'_token' => $token, '_token' => $token,
@@ -201,7 +239,7 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
public function testAssignUsedNonReusableQuestionIsRefused(): void public function testAssignUsedNonReusableQuestionIsRefused(): void
{ {
$this->loginAs('krtek-admin@example.org'); $this->loginAsOwner();
$bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?'); $bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?');
$quiz = $this->getQuizByName('Quiz 2'); $quiz = $this->getQuizByName('Quiz 2');
$questionCount = $quiz->questions->count(); $questionCount = $quiz->questions->count();
@@ -209,7 +247,7 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
// The assign form is not rendered for used questions, so post with another form's token // The assign form is not rendered for used questions, so post with another form's token
$token = $this->getCsrfTokenFromCurrentPage('/assign'); $token = $this->getCsrfToken('/assign');
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
'_token' => $token, '_token' => $token,
'quiz' => (string) $quiz->id, 'quiz' => (string) $quiz->id,
@@ -223,13 +261,13 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
public function testAssignSameReusableQuestionTwiceToSameQuizIsRefused(): void public function testAssignSameReusableQuestionTwiceToSameQuizIsRefused(): void
{ {
$this->loginAs('krtek-admin@example.org'); $this->loginAsOwner();
$bankQuestion = $this->getBankQuestion('Wie is de Krtek?'); $bankQuestion = $this->getBankQuestion('Wie is de Krtek?');
$quiz = $this->getQuizByName('Quiz 2'); $quiz = $this->getQuizByName('Quiz 2');
$questionCount = $quiz->questions->count(); $questionCount = $quiz->questions->count();
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$token = $this->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); $url = \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id);
$this->client->request(Request::METHOD_POST, $url, ['_token' => $token, 'quiz' => (string) $quiz->id]); $this->client->request(Request::METHOD_POST, $url, ['_token' => $token, 'quiz' => (string) $quiz->id]);
@@ -244,13 +282,13 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
public function testAssignIntoFinalizedQuizIsDenied(): void public function testAssignIntoFinalizedQuizIsDenied(): void
{ {
$this->loginAs('krtek-admin@example.org'); $this->loginAsOwner();
$bankQuestion = $this->getBankQuestion('Wie is de Krtek?'); $bankQuestion = $this->getBankQuestion('Wie is de Krtek?');
$finalizedQuiz = $this->getQuizByName('Quiz 1'); $finalizedQuiz = $this->getQuizByName('Quiz 1');
$this->assertTrue($finalizedQuiz->isFinalized); $this->assertTrue($finalizedQuiz->isFinalized);
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$token = $this->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), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
'_token' => $token, '_token' => $token,
@@ -260,82 +298,9 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
$this->assertResponseStatusCodeSame(403); $this->assertResponseStatusCodeSame(403);
} }
public function testCreateBankQuestionPreservesAnswerOrdering(): void
{
$this->loginAs('krtek-admin@example.org');
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
$this->assertResponseIsSuccessful();
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
// Submit 3 answers with non-sequential ordering values.
// The stored ordering field (not the submission index) must dictate retrieval order.
$this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/question-bank/new', [
'bank_question_form' => [
'question' => 'Volgorderingstest nieuwe vraag',
'answers' => [
0 => ['text' => 'Antwoord C', 'isRightAnswer' => '1', 'ordering' => '5'],
1 => ['text' => 'Antwoord A', 'ordering' => '1'],
2 => ['text' => 'Antwoord B', 'ordering' => '3'],
],
'_token' => $token,
],
]);
$this->assertResponseRedirects('/backoffice/season/krtek/question-bank');
$this->entityManager->clear();
$bankQuestion = $this->getBankQuestion('Volgorderingstest nieuwe vraag');
$answers = $bankQuestion->answers->toArray();
$this->assertCount(3, $answers);
// @OrderBy(['ordering' => 'ASC']): ordering 1 → 3 → 5
$this->assertSame('Antwoord A', $answers[0]->text);
$this->assertSame('Antwoord B', $answers[1]->text);
$this->assertSame('Antwoord C', $answers[2]->text);
}
public function testEditBankQuestionPreservesAnswerOrdering(): void
{
$this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
// Fixture answers in insertion order (all have ordering=0): Brood (correct), Yoghurt, Niks
$url = \sprintf('/backoffice/season/krtek/question-bank/%s/edit', $bankQuestion->id);
$crawler = $this->client->request(Request::METHOD_GET, $url);
$this->assertResponseIsSuccessful();
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
$answers = $bankQuestion->answers->toArray();
$this->assertCount(3, $answers);
$texts = array_map(static fn (BankAnswer $a): string => $a->text, $answers);
// Assign ordering values: first answer gets 4, second gets 0, third gets 2.
// Expected retrieval order after @OrderBy ASC: index 1 (0) → index 2 (2) → index 0 (4).
$this->client->request(Request::METHOD_POST, $url, [
'bank_question_form' => [
'question' => $bankQuestion->question,
'answers' => [
0 => ['text' => $texts[0], 'isRightAnswer' => '1', 'ordering' => '4'],
1 => ['text' => $texts[1], 'ordering' => '0'],
2 => ['text' => $texts[2], 'ordering' => '2'],
],
'_token' => $token,
],
]);
$this->assertResponseRedirects('/backoffice/season/krtek/question-bank');
$this->entityManager->clear();
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
$reloadedAnswers = $bankQuestion->answers->toArray();
$this->assertCount(3, $reloadedAnswers);
$this->assertSame($texts[1], $reloadedAnswers[0]->text); // ordering=0 → first
$this->assertSame($texts[2], $reloadedAnswers[1]->text); // ordering=2 → second
$this->assertSame($texts[0], $reloadedAnswers[2]->text); // ordering=4 → third
}
public function testAddAndDeleteLabel(): void public function testAddAndDeleteLabel(): void
{ {
$this->loginAs('krtek-admin@example.org'); $this->loginAsOwner();
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$token = (string) $crawler->filter('form[action$="/question-bank/labels"] input[name="_token"]')->attr('value'); $token = (string) $crawler->filter('form[action$="/question-bank/labels"] input[name="_token"]')->attr('value');
@@ -350,7 +315,7 @@ final class QuestionBankControllerTest extends AbstractControllerWebTestCase
$this->assertInstanceOf(QuestionLabel::class, $label); $this->assertInstanceOf(QuestionLabel::class, $label);
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$deleteToken = $this->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), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/labels/%s/delete', $label->slug), [
'_token' => $deleteToken, '_token' => $deleteToken,
@@ -4,8 +4,11 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice; namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Safe\DateTimeImmutable; use Safe\DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuizController; use Tvdt\Controller\Backoffice\QuizController;
use Tvdt\Entity\Answer; use Tvdt\Entity\Answer;
@@ -14,21 +17,51 @@ use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\Question; use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz; use Tvdt\Entity\Quiz;
use Tvdt\Entity\QuizCandidate; use Tvdt\Entity\QuizCandidate;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase; use Tvdt\Entity\Season;
use Tvdt\Entity\User;
#[CoversClass(QuizController::class)] #[CoversClass(QuizController::class)]
final class QuizControllerTest extends AbstractControllerWebTestCase final class QuizControllerTest extends WebTestCase
{ {
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void 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 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 public function testIndexRedirectsToOverview(): void
@@ -173,47 +206,6 @@ final class QuizControllerTest extends AbstractControllerWebTestCase
$this->assertTrue($updated->active); $this->assertTrue($updated->active);
} }
public function testResetCandidateProgressDeletesGivenAnswersAndClearsStarted(): 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();
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/candidates-list', $quiz->id));
self::assertResponseIsSuccessful();
$token = (string) $crawler->filter(\sprintf('form[action*="/%s/reset"] input[name="_token"]', $candidate->id))->first()->attr('value');
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/candidate/%s/reset', $quiz->id, $candidate->id), [
'_token' => $token,
]);
self::assertResponseRedirects();
$this->entityManager->clear();
$updated = $this->entityManager->getRepository(QuizCandidate::class)->findOneBy([
'quiz' => $this->getQuizByName('Quiz 1'),
'candidate' => $this->getCandidate('Tom'),
]);
$this->assertInstanceOf(QuizCandidate::class, $updated);
$this->assertNotInstanceOf(\DateTimeImmutable::class, $updated->started);
$remainingAnswers = $this->entityManager->getRepository(GivenAnswer::class)->findBy([
'quiz' => $quiz,
'candidate' => $candidate,
]);
$this->assertCount(0, $remainingAnswers);
}
public function testModifyCorrection(): void public function testModifyCorrection(): void
{ {
$quiz = $this->getQuizByName('Quiz 1'); $quiz = $this->getQuizByName('Quiz 1');
@@ -304,7 +296,9 @@ final class QuizControllerTest extends AbstractControllerWebTestCase
public function testNonOwnerIsDenied(): void 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'); $quiz = $this->getQuizByName('Quiz 1');
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id)); $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
@@ -314,7 +308,8 @@ final class QuizControllerTest extends AbstractControllerWebTestCase
public function testOverviewLoadsForEmptyQuiz(): void 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 = new Quiz();
$emptyQuiz->name = 'Empty Quiz'; $emptyQuiz->name = 'Empty Quiz';
@@ -331,7 +326,8 @@ final class QuizControllerTest extends AbstractControllerWebTestCase
public function testAnswerMappingRedirectsWithFlashWhenNoQuestions(): void 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 = new Quiz();
$emptyQuiz->name = 'Empty Quiz'; $emptyQuiz->name = 'Empty Quiz';
@@ -4,29 +4,63 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice; namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Safe\DateTimeImmutable; use Safe\DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuizController; use Tvdt\Controller\Backoffice\QuizController;
use Tvdt\Entity\Answer; use Tvdt\Entity\Answer;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\Question; use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz; use Tvdt\Entity\Quiz;
use Tvdt\Entity\QuizCandidate; use Tvdt\Entity\QuizCandidate;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase; use Tvdt\Entity\Season;
use Tvdt\Entity\User;
#[CoversClass(QuizController::class)] #[CoversClass(QuizController::class)]
final class QuizFinalizeTest extends AbstractControllerWebTestCase final class QuizFinalizeTest extends WebTestCase
{ {
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void 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 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 public function testFinalizeSetsFinalizedAt(): void
@@ -45,7 +79,7 @@ final class QuizFinalizeTest extends AbstractControllerWebTestCase
public function testFinalizeRefusedWhenQuizHasErrors(): void public function testFinalizeRefusedWhenQuizHasErrors(): void
{ {
$season = $this->getSeasonByCode('krtek'); $season = $this->getKrtekSeason();
$invalidQuiz = new Quiz(); $invalidQuiz = new Quiz();
$invalidQuiz->name = 'Invalid Quiz'; $invalidQuiz->name = 'Invalid Quiz';
@@ -82,7 +116,7 @@ final class QuizFinalizeTest extends AbstractControllerWebTestCase
$this->assertResponseRedirects(); $this->assertResponseRedirects();
$this->entityManager->clear(); $this->entityManager->clear();
$season = $this->getSeasonByCode('krtek'); $season = $this->getKrtekSeason();
$this->assertInstanceOf(Quiz::class, $season->activeQuiz); $this->assertInstanceOf(Quiz::class, $season->activeQuiz);
$this->assertSame('Quiz 1', $season->activeQuiz->name); $this->assertSame('Quiz 1', $season->activeQuiz->name);
} }
@@ -100,7 +134,7 @@ final class QuizFinalizeTest extends AbstractControllerWebTestCase
$this->assertResponseRedirects(); $this->assertResponseRedirects();
$this->entityManager->clear(); $this->entityManager->clear();
$season = $this->getSeasonByCode('krtek'); $season = $this->getKrtekSeason();
$this->assertInstanceOf(Quiz::class, $season->activeQuiz); $this->assertInstanceOf(Quiz::class, $season->activeQuiz);
$this->assertSame('Quiz 2', $season->activeQuiz->name); $this->assertSame('Quiz 2', $season->activeQuiz->name);
} }
@@ -149,7 +183,8 @@ final class QuizFinalizeTest extends AbstractControllerWebTestCase
// Scrape the token before a candidate starts, since the button disappears afterwards // Scrape the token before a candidate starts, since the button disappears afterwards
$token = $this->getCsrfTokenFromOverview($quiz, '/unfinalize'); $token = $this->getCsrfTokenFromOverview($quiz, '/unfinalize');
$candidate = $this->getCandidate('Tom'); $candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => 'Tom']);
$this->assertInstanceOf(Candidate::class, $candidate);
$quizCandidate = new QuizCandidate($quiz, $candidate); $quizCandidate = new QuizCandidate($quiz, $candidate);
$quizCandidate->started = new DateTimeImmutable(); $quizCandidate->started = new DateTimeImmutable();
@@ -194,6 +229,6 @@ final class QuizFinalizeTest extends AbstractControllerWebTestCase
self::assertResponseRedirects(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz2->id)); self::assertResponseRedirects(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz2->id));
$this->entityManager->clear(); $this->entityManager->clear();
$this->assertNotInstanceOf(Quiz::class, $this->getSeasonByCode('krtek')->activeQuiz); $this->assertNotInstanceOf(Quiz::class, $this->getKrtekSeason()->activeQuiz);
} }
} }
@@ -1,157 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuizQuestionController;
use Tvdt\Entity\Question;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
#[CoversClass(QuizQuestionController::class)]
final class QuizQuestionControllerTest extends AbstractControllerWebTestCase
{
public function testEditPreservesAnswerOrdering(): void
{
$this->loginAs('krtek-admin@example.org');
$quiz = $this->getQuizByName('Quiz 2');
$question = null;
foreach ($quiz->questions as $q) {
if ('Is de Krtek een man of een vrouw?' === $q->question) {
$question = $q;
break;
}
}
$this->assertInstanceOf(Question::class, $question);
$answers = $question->answers->toArray();
$this->assertCount(2, $answers);
$firstText = $answers[0]->text;
$secondText = $answers[1]->text;
$url = \sprintf(
'/backoffice/season/krtek/quiz/%s/question/%s/edit',
$quiz->id,
$question->id,
);
$crawler = $this->client->request(Request::METHOD_GET, $url);
$this->assertResponseIsSuccessful();
$token = (string) $crawler->filter('input[name="question_form[_token]"]')->attr('value');
// Submit with ordering values that invert which answer appears first on reload.
// The answer currently at index 0 ($firstText) gets ordering=7,
// the one at index 1 ($secondText) gets ordering=3.
// @OrderBy(['ordering' => 'ASC']) on Question::$answers will return
// $secondText (3) before $firstText (7) after flush+clear.
$this->client->request(Request::METHOD_POST, $url, [
'question_form' => [
'question' => $question->question,
'answers' => [
0 => ['text' => $firstText, 'ordering' => '7'],
1 => ['text' => $secondText, 'ordering' => '3'],
],
'_token' => $token,
],
]);
$this->assertResponseRedirects();
$this->entityManager->clear();
$quiz = $this->getQuizByName('Quiz 2');
$reloadedQuestion = null;
foreach ($quiz->questions as $q) {
if ('Is de Krtek een man of een vrouw?' === $q->question) {
$reloadedQuestion = $q;
break;
}
}
$this->assertInstanceOf(Question::class, $reloadedQuestion);
$reloadedAnswers = $reloadedQuestion->answers->toArray();
$this->assertSame(3, $reloadedAnswers[0]->ordering);
$this->assertSame($secondText, $reloadedAnswers[0]->text);
$this->assertSame(7, $reloadedAnswers[1]->ordering);
$this->assertSame($firstText, $reloadedAnswers[1]->text);
}
public function testReorderQuestionsWithinQuiz(): void
{
$this->loginAs('krtek-admin@example.org');
$quiz = $this->getQuizByName('Quiz 2');
$originalQuestions = $quiz->questions->toArray();
$this->assertGreaterThanOrEqual(3, \count($originalQuestions));
$originalFirstId = (string) $originalQuestions[0]->id;
$originalLastId = (string) $originalQuestions[\count($originalQuestions) - 1]->id;
$overviewUrl = \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id);
$crawler = $this->client->request(Request::METHOD_GET, $overviewUrl);
$this->assertResponseIsSuccessful();
$csrfToken = $crawler->filter('[data-bo--question-list-csrf-value]')->attr('data-bo--question-list-csrf-value');
$this->assertNotEmpty($csrfToken);
$reversedIds = array_reverse(array_map(static fn (Question $q): string => (string) $q->id, $originalQuestions));
$reorderUrl = \sprintf('/backoffice/season/krtek/quiz/%s/questions/reorder', $quiz->id);
$this->client->request(Request::METHOD_POST, $reorderUrl, [
'_token' => $csrfToken,
'ordering' => $reversedIds,
]);
$this->assertResponseStatusCodeSame(204);
$this->entityManager->clear();
$quiz = $this->getQuizByName('Quiz 2');
$reorderedQuestions = $quiz->questions->toArray();
$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);
}
}
@@ -1,157 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\SeasonController;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\Season;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
#[CoversClass(SeasonController::class)]
final class SeasonControllerTest extends AbstractControllerWebTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->loginAs('krtek-admin@example.org');
}
public function testRegenerateSeasonCodeChangesTheCode(): void
{
$oldCode = 'krtek';
$token = $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/%s/settings', $oldCode), '/regenerate-code');
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/%s/settings/regenerate-code', $oldCode), [
'_token' => $token,
]);
self::assertResponseRedirects();
$this->entityManager->clear();
$this->assertNotInstanceOf(Season::class, $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => $oldCode]));
$location = (string) $this->client->getResponse()->headers->get('Location');
$this->assertMatchesRegularExpression('#^/backoffice/season/[a-z]{5}/settings$#', $location);
}
public function testRegenerateSeasonCodeIsDeniedForNonOwner(): void
{
$token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/settings', '/regenerate-code');
$this->loginAs('test@example.org');
$this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/settings/regenerate-code', [
'_token' => $token,
]);
self::assertResponseStatusCodeSame(403);
}
public function testRenameCandidate(): void
{
$candidate = $this->getCandidate('Tom');
$token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/candidates', \sprintf('/candidate/%s/rename', $candidate->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
'_token' => $token,
'name' => 'Tommy',
]);
self::assertResponseRedirects('/backoffice/season/krtek/candidates');
$this->entityManager->clear();
$renamed = $this->entityManager->getRepository(Candidate::class)->find($candidate->id);
$this->assertInstanceOf(Candidate::class, $renamed);
$this->assertSame('Tommy', $renamed->name);
}
public function testRenameCandidateToExistingNameShowsError(): void
{
$candidate = $this->getCandidate('Tom');
$token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/candidates', \sprintf('/candidate/%s/rename', $candidate->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
'_token' => $token,
'name' => 'Claudia',
]);
self::assertResponseRedirects('/backoffice/season/krtek/candidates');
$this->entityManager->clear();
$unchanged = $this->entityManager->getRepository(Candidate::class)->find($candidate->id);
$this->assertInstanceOf(Candidate::class, $unchanged);
$this->assertSame('Tom', $unchanged->name);
}
public function testDeleteCandidate(): void
{
$candidate = $this->getCandidate('Tom');
$candidateId = $candidate->id;
$token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/candidates', \sprintf('/candidate/%s/delete', $candidate->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/delete', $candidate->id), [
'_token' => $token,
]);
self::assertResponseRedirects('/backoffice/season/krtek/candidates');
$this->entityManager->clear();
$this->assertNotInstanceOf(Candidate::class, $this->entityManager->getRepository(Candidate::class)->find($candidateId));
}
public function testAddCandidates(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/add-candidate');
$form = $this->client->getCrawler()->filter('form')->form([
'add_candidates_form[candidates]' => "Nora\nPiet",
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/season/krtek/candidates');
$this->entityManager->clear();
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
$this->assertInstanceOf(Season::class, $season);
$names = array_map(static fn (Candidate $candidate): string => $candidate->name, $season->candidates->toArray());
$this->assertContains('Nora', $names);
$this->assertContains('Piet', $names);
}
public function testAddCandidatesViaTurboFrameReturnsEmptyFrame(): void
{
$this->client->xmlHttpRequest(Request::METHOD_GET, '/backoffice/season/krtek/add-candidate', server: ['HTTP_TURBO-FRAME' => 'add-candidates-modal-frame']);
$form = $this->client->getCrawler()->filter('form')->form([
'add_candidates_form[candidates]' => 'Sanne',
]);
$this->client->submit($form, [], ['HTTP_TURBO-FRAME' => 'add-candidates-modal-frame']);
self::assertResponseIsSuccessful();
$this->assertStringContainsString('<turbo-frame id="add-candidates-modal-frame"></turbo-frame>', (string) $this->client->getResponse()->getContent());
$this->entityManager->clear();
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
$this->assertInstanceOf(Season::class, $season);
$names = array_map(static fn (Candidate $candidate): string => $candidate->name, $season->candidates->toArray());
$this->assertContains('Sanne', $names);
}
public function testRenameCandidateIsDeniedForNonOwner(): void
{
$candidate = $this->getCandidate('Tom');
$token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/candidates', \sprintf('/candidate/%s/rename', $candidate->id));
$this->loginAs('test@example.org');
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
'_token' => $token,
'name' => 'Tommy',
]);
self::assertResponseStatusCodeSame(403);
}
}
@@ -1,353 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Safe\DateTimeImmutable;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Tvdt\Controller\Backoffice\SettingsController;
use Tvdt\DataFixtures\TestFixtures;
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
{
protected function setUp(): void
{
parent::setUp();
$this->loginAs('test@example.org');
}
private function getCsrfTokenFromSettings(string $formActionContains): string
{
return $this->getCsrfTokenFromPage('/backoffice/settings', $formActionContains);
}
public function testSettingsPageLoadsAndNavContainsSettingsLink(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseIsSuccessful();
self::assertSelectorExists('nav a[href="/backoffice/settings"]');
}
public function testSettingsPageRequiresAuthentication(): void
{
$this->client->restart();
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseRedirects();
}
public function testLanguageSaveRedirectsBackToSettings(): void
{
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/language');
$this->client->request(Request::METHOD_POST, '/backoffice/settings/language', [
'_token' => $token,
'language' => 'nl',
]);
self::assertResponseRedirects('/backoffice/settings');
}
public function testChangePassword(): 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]' => 'NewPass123!',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/settings');
$this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org');
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($user, 'NewPass123!'));
// User stays logged in
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
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
{
$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,
]);
$this->client->submit($form);
self::assertResponseStatusCodeSame(422);
$this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org');
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($user, TestFixtures::PASSWORD));
}
public function testChangeEmailSendsConfirmationAndKeepsUserLoggedIn(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/email"]')->form([
'change_email_form[email]' => 'new-address@example.org',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/settings');
self::assertEmailCount(1);
$this->entityManager->clear();
$this->assertNotInstanceOf(User::class, $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']));
$user = $this->getUserByEmail('new-address@example.org');
$this->assertFalse($user->isVerified);
// User stays logged in
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseIsSuccessful();
}
public function testChangeEmailToTakenAddressIsRejected(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/email"]')->form([
'change_email_form[email]' => 'user1@example.org',
]);
$this->client->submit($form);
self::assertResponseStatusCodeSame(422);
self::assertEmailCount(0);
$this->entityManager->clear();
$this->getUserByEmail('test@example.org');
}
public function testResendConfirmationEmailSendsEmail(): void
{
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/resend-confirmation');
$this->client->request(Request::METHOD_POST, '/backoffice/settings/resend-confirmation', [
'_token' => $token,
]);
self::assertResponseRedirects('/backoffice/settings');
self::assertEmailCount(1);
}
public function testResendConfirmationEmailForVerifiedUserSendsNothing(): void
{
// Get a valid CSRF token while still unverified, then mark the user as verified
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/resend-confirmation');
$user = $this->getUserByEmail('test@example.org');
$user->isVerified = true;
$this->entityManager->flush();
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseIsSuccessful();
$this->assertCount(0, $crawler->filter('form[action*="/backoffice/settings/resend-confirmation"]'));
$this->client->request(Request::METHOD_POST, '/backoffice/settings/resend-confirmation', [
'_token' => $token,
]);
self::assertResponseRedirects('/backoffice/settings');
self::assertEmailCount(0);
}
public function testChangeEmailToSameAddressIsAccepted(): void
{
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/email"]')->form([
'change_email_form[email]' => 'test@example.org',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/settings');
}
private function createResetPasswordRequest(User $user): void
{
$request = new ResetPasswordRequest(
$user,
new DateTimeImmutable('+1 hour'),
str_repeat('a', 20),
str_repeat('b', 100),
);
$this->entityManager->persist($request);
$this->entityManager->flush();
}
public function testChangePasswordInvalidatesResetPasswordRequests(): void
{
$user = $this->getUserByEmail('test@example.org');
$this->createResetPasswordRequest($user);
$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]' => 'NewPass123!',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/settings');
$this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org');
$this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $user]));
}
public function testChangeEmailInvalidatesResetPasswordRequests(): void
{
$user = $this->getUserByEmail('test@example.org');
$this->createResetPasswordRequest($user);
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/email"]')->form([
'change_email_form[email]' => 'new-address@example.org',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/settings');
$this->entityManager->clear();
$user = $this->getUserByEmail('new-address@example.org');
$this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $user]));
}
public function testDeleteAccountWithWrongPasswordIsRejected(): void
{
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/delete');
$this->client->request(Request::METHOD_POST, '/backoffice/settings/delete', [
'_token' => $token,
'password' => 'wrong-password',
]);
self::assertResponseRedirects('/backoffice/settings');
$this->entityManager->clear();
$this->getUserByEmail('test@example.org');
}
public function testDeleteAccountRemovesSoleOwnerSeasonsAndKeepsSharedSeasons(): void
{
$this->loginAs('sole-owner@example.org');
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/delete');
$this->client->request(Request::METHOD_POST, '/backoffice/settings/delete', [
'_token' => $token,
'password' => TestFixtures::PASSWORD,
]);
self::assertResponseRedirects();
$this->entityManager->clear();
$this->assertNotInstanceOf(User::class, $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'sole-owner@example.org']));
// Sole-owner season is removed, including its quiz
$this->assertNotInstanceOf(Season::class, $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'doomd']));
$this->assertNotInstanceOf(Quiz::class, $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Doomed Quiz']));
// Shared season survives, without the deleted owner
$anotherSeason = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'bbbbb']);
$this->assertInstanceOf(Season::class, $anotherSeason);
$ownerEmails = $anotherSeason->owners->map(static fn (User $owner): string => $owner->email)->toArray();
$this->assertNotContains('sole-owner@example.org', $ownerEmails);
$this->assertContains('user1@example.org', $ownerEmails);
// User is logged out
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
self::assertResponseRedirects();
}
public function testDeleteAccountKeepsMultiOwnerSeasons(): void
{
$this->loginAs('user2@example.org');
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/delete');
$this->client->request(Request::METHOD_POST, '/backoffice/settings/delete', [
'_token' => $token,
'password' => TestFixtures::PASSWORD,
]);
self::assertResponseRedirects();
$this->entityManager->clear();
$this->assertNotInstanceOf(User::class, $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']));
foreach (['krtek', 'bbbbb'] as $seasonCode) {
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => $seasonCode]);
$this->assertInstanceOf(Season::class, $season);
$ownerEmails = $season->owners->map(static fn (User $owner): string => $owner->email)->toArray();
$this->assertNotContains('user2@example.org', $ownerEmails);
$this->assertNotEmpty($ownerEmails);
}
}
public function testDownloadDataRequiresAuthentication(): void
{
$this->client->restart();
$this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data');
self::assertResponseRedirects();
}
public function testDownloadDataReturnsAZipWithATimestampedAccountFilename(): void
{
$this->markUserVerified('test@example.org');
$this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data');
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('Content-Type', 'application/zip');
$disposition = (string) $this->client->getResponse()->headers->get('Content-Disposition');
$this->assertMatchesRegularExpression(
'/filename=tijd-voor-de-test-data-test-example-org-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.zip/',
$disposition,
);
}
public function testDownloadDataRequiresVerifiedEmail(): void
{
$user = $this->getUserByEmail('test@example.org');
$this->assertFalse($user->isVerified);
$this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data');
self::assertResponseRedirects('/backoffice/settings');
}
private function markUserVerified(string $email): void
{
$user = $this->getUserByEmail($email);
$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));
}
}
-53
View File
@@ -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();
}
}
-209
View File
@@ -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');
}
}
@@ -1,89 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
use Tvdt\Controller\ResetPasswordController;
use Tvdt\Entity\User;
#[CoversClass(ResetPasswordController::class)]
final class ResetPasswordControllerTest extends AbstractControllerWebTestCase
{
public function testRequestPageLoads(): void
{
$this->client->request(Request::METHOD_GET, '/reset-password');
$this->assertResponseIsSuccessful();
$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
{
$this->client->request(Request::METHOD_GET, '/reset-password');
$form = $this->client->getCrawler()->filter('form')->form([
'reset_password_request_form[email]' => $email,
]);
$this->client->submit($form);
$this->assertResponseRedirects('/reset-password/check-email');
}
public function testCheckEmailPageLoads(): void
{
$this->client->request(Request::METHOD_GET, '/reset-password/check-email');
$this->assertResponseIsSuccessful();
}
public function testResetWithInvalidTokenRedirectsToRequest(): void
{
$this->client->request(Request::METHOD_GET, '/reset-password/reset/invalidtoken');
$this->client->followRedirect();
$this->assertResponseRedirects('/reset-password');
}
public function testFullResetFlow(): void
{
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
$this->assertInstanceOf(User::class, $user);
/** @var ResetPasswordHelperInterface $helper */
$helper = self::getContainer()->get(ResetPasswordHelperInterface::class);
$resetToken = $helper->generateResetToken($user);
$this->client->request(Request::METHOD_GET, '/reset-password/reset/'.$resetToken->getToken());
$this->assertResponseRedirects('/reset-password/reset');
$this->client->followRedirect();
$this->assertResponseIsSuccessful();
$form = $this->client->getCrawler()->filter('form')->form([
'change_password_form[plainPassword][first]' => 'NewPass123!',
'change_password_form[plainPassword][second]' => 'NewPass123!',
]);
$this->client->submit($form);
$this->assertResponseRedirects('/backoffice/');
$this->entityManager->clear();
$updatedUser = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
$this->assertInstanceOf(User::class, $updatedUser);
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($updatedUser, 'NewPass123!'));
}
}
@@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller;
use PHPUnit\Framework\Attributes\CoversClass;
use Safe\DateTimeImmutable;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\WellKnownController;
#[CoversClass(WellKnownController::class)]
final class WellKnownControllerTest extends AbstractControllerWebTestCase
{
public function testChangePasswordRedirectsToSettings(): void
{
$this->client->request(Request::METHOD_GET, '/.well-known/change-password');
self::assertResponseRedirects('/backoffice/settings');
}
/** @throws \Exception */
public function testSecurityTxt(): void
{
$this->client->request(Request::METHOD_GET, '/.well-known/security.txt');
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('Content-Type', 'text/plain; charset=UTF-8');
$content = (string) $this->client->getResponse()->getContent();
$this->assertStringContainsString('Contact:', $content);
$this->assertMatchesRegularExpression('/^Expires: (.+)$/m', $content);
\Safe\preg_match('/^Expires: (.+)$/m', $content, $matches);
$this->assertArrayHasKey(1, $matches);
$expires = new DateTimeImmutable($matches[1]);
$this->assertGreaterThan(new DateTimeImmutable('now'), $expires);
}
}
-100
View File
@@ -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);
}
}
-89
View File
@@ -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;
}
}
+11 -17
View File
@@ -4,34 +4,28 @@ declare(strict_types=1);
namespace Tvdt\Tests\Helpers; namespace Tvdt\Tests\Helpers;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Safe\Exceptions\UrlException; use Safe\Exceptions\UrlException;
use Tvdt\Helpers\Base64; use Tvdt\Helpers\Base64;
#[CoversClass(Base64::class)]
final class Base64Test extends TestCase final class Base64Test extends TestCase
{ {
/** @return iterable<string, array{string, string}> */ public function testBase64UrlEncode(): void
public static function pairProvider(): iterable
{ {
yield 'Marijn' => ['Marijn', 'TWFyaWpu']; $this->assertSame('TWFyaWpu', Base64::base64UrlEncode('Marijn'));
yield 'Philine' => ['Philine', 'UGhpbGluZQ']; $this->assertSame('UGhpbGluZQ', Base64::base64UrlEncode('Philine'));
yield 'byte 254' => [\chr(254), '_g'];
yield 'byte 250' => [\chr(250), '-g']; $this->assertSame('_g', Base64::base64UrlEncode(\chr(254)));
$this->assertSame('-g', Base64::base64UrlEncode(\chr(250)));
} }
#[DataProvider('pairProvider')] public function testBase64UrlDecode(): void
public function testBase64UrlEncode(string $decoded, string $encoded): void
{ {
$this->assertSame($encoded, Base64::base64UrlEncode($decoded)); $this->assertSame('Marijn', Base64::base64UrlDecode('TWFyaWpu'));
} $this->assertSame('Philine', Base64::base64UrlDecode('UGhpbGluZQ'));
#[DataProvider('pairProvider')] $this->assertSame(\chr(254), Base64::base64UrlDecode('_g'));
public function testBase64UrlDecode(string $decoded, string $encoded): void $this->assertSame(\chr(250), Base64::base64UrlDecode('-g'));
{
$this->assertSame($decoded, Base64::base64UrlDecode($encoded));
} }
public function testBase64UrlDecodeCanHandlePadding(): void public function testBase64UrlDecodeCanHandlePadding(): void
-34
View File
@@ -1,34 +0,0 @@
<?php
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
{
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'];
}
#[DataProvider('sanitizeProvider')]
public function testSanitize(string $input, string $expected): void
{
$this->assertSame($expected, FilenameSanitizer::sanitize($input));
}
}

Some files were not shown because too many files have changed in this diff Show More