mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-10 09:30:14 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
07ce8f79e6
|
|||
|
b1f68425cd
|
|||
|
0aa15415de
|
|||
| 0ee15e3cbb | |||
| 352e34a428 | |||
| b1a959fdc6 | |||
| 27d3d64154 | |||
| e7586c2d6b | |||
| a49d32e8ff | |||
| 4547a43199 | |||
| 1d3e99d2b2 | |||
| d7e8d094cf | |||
| ba1e8d8eb6 | |||
| 5d92d91432 | |||
| 04c40412cd | |||
| b915d87d4a |
@@ -1,3 +1,5 @@
|
|||||||
# 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
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ jobs:
|
|||||||
checks: write
|
checks: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
contents: read
|
contents: read
|
||||||
|
code-quality: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
@@ -150,13 +151,29 @@ jobs:
|
|||||||
- name: Load fixtures
|
- name: Load fixtures
|
||||||
run: docker compose exec -T php bin/console -e test doctrine:fixtures:load --no-interaction --group=test
|
run: docker compose exec -T php bin/console -e test doctrine:fixtures:load --no-interaction --group=test
|
||||||
- name: Run PHPUnit
|
- name: Run PHPUnit
|
||||||
run: docker compose exec -T php vendor/bin/phpunit --log-junit var/phpunit/junit.xml
|
run: docker compose exec -T -e MAILER_DSN=null://null php vendor/bin/phpunit --log-junit var/phpunit/junit.xml --coverage-cobertura var/coverage/cobertura.xml
|
||||||
|
- name: Copy test reports out of the container
|
||||||
|
# var/ is a Docker volume (see Dockerfile's VOLUME /app/var/), not bind-mounted to the
|
||||||
|
# runner, so reports written there by PHPUnit must be copied out explicitly.
|
||||||
|
if: always()
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
mkdir -p var/phpunit var/coverage
|
||||||
|
docker compose cp php:/app/var/phpunit/junit.xml var/phpunit/junit.xml
|
||||||
|
docker compose cp php:/app/var/coverage/cobertura.xml var/coverage/cobertura.xml
|
||||||
- name: Publish PHPUnit test results
|
- name: Publish PHPUnit test results
|
||||||
if: always()
|
if: always()
|
||||||
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6
|
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6
|
||||||
with:
|
with:
|
||||||
report_paths: var/phpunit/junit.xml
|
report_paths: var/phpunit/junit.xml
|
||||||
check_name: PHPUnit
|
check_name: PHPUnit
|
||||||
|
- name: Upload code coverage
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
uses: actions/upload-code-coverage@82c7aee3fb2ad768e00b00a0a8d749c5815085b6 # v1
|
||||||
|
with:
|
||||||
|
file: var/coverage/cobertura.xml
|
||||||
|
language: PHP
|
||||||
|
label: phpunit
|
||||||
- name: Doctrine Schema Validator
|
- name: Doctrine Schema Validator
|
||||||
run: docker compose exec -T php bin/console -e test doctrine:schema:validate
|
run: docker compose exec -T php bin/console -e test doctrine:schema:validate
|
||||||
|
|
||||||
@@ -166,7 +183,7 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
actions: write
|
||||||
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:
|
||||||
@@ -174,6 +191,8 @@ 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))
|
||||||
|
|
||||||
@@ -191,12 +210,32 @@ 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 still in progress (attempt $attempt/$max_attempts), waiting 30s..."
|
echo "CI in progress (attempt $attempt/$max_attempts), waiting 30s..."
|
||||||
sleep 30
|
sleep 30
|
||||||
else
|
continue
|
||||||
echo "::error::No prior successful CI run found for ${{ github.sha }}. Only tag commits that have passed CI on main."
|
fi
|
||||||
|
|
||||||
|
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 }}."
|
||||||
@@ -234,6 +273,7 @@ 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}"
|
||||||
@@ -260,9 +300,12 @@ 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
|
||||||
|
|||||||
Generated
+1
@@ -171,6 +171,7 @@
|
|||||||
<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
@@ -205,6 +205,7 @@
|
|||||||
<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">
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ tests/
|
|||||||
### Testing Conventions (TDD)
|
### Testing Conventions (TDD)
|
||||||
- **Write the failing test first.** When fixing any PHP-reachable bug, write a PHPUnit test that reproduces the failure before touching the production code. Fix the code until the test passes.
|
- **Write the failing test first.** When fixing any PHP-reachable bug, write a PHPUnit test that reproduces the failure before touching the production code. Fix the code until the test passes.
|
||||||
- Only skip a test if the bug is purely in JavaScript/frontend where PHPUnit cannot reach it.
|
- Only skip a test if the bug is purely in JavaScript/frontend where PHPUnit cannot reach it.
|
||||||
|
- Don't write tests for trivial presentational markup (e.g. asserting a tooltip/popover attribute or a CSS class exists in a template). Tests cover behavior: routing, forms, persistence, authorization.
|
||||||
- Follow the pattern in `tests/Controller/Backoffice/` for controller/integration tests: log in, GET for CSRF token, POST form data, assert redirect, clear entity manager, assert DB state.
|
- Follow the pattern in `tests/Controller/Backoffice/` for controller/integration tests: log in, GET for CSRF token, POST form data, assert redirect, clear entity manager, assert DB state.
|
||||||
|
|
||||||
### Code Style & Standards
|
### Code Style & Standards
|
||||||
|
|||||||
@@ -109,3 +109,7 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,12 +35,14 @@
|
|||||||
"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
+75
-27
@@ -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": "7171824ca13f4df0801dfa5d7f58d6a0",
|
"content-hash": "ccae654dd9c952e8920d9cb9c0f35ff5",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "composer/pcre",
|
"name": "composer/pcre",
|
||||||
@@ -1475,16 +1475,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "guzzlehttp/psr7",
|
"name": "guzzlehttp/psr7",
|
||||||
"version": "2.12.3",
|
"version": "2.12.4",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/guzzle/psr7.git",
|
"url": "https://github.com/guzzle/psr7.git",
|
||||||
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d"
|
"reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
|
"url": "https://api.github.com/repos/guzzle/psr7/zipball/51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c",
|
||||||
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
|
"reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c",
|
||||||
"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.3"
|
"source": "https://github.com/guzzle/psr7/tree/2.12.4"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1590,7 +1590,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-06-23T15:21:08+00:00"
|
"time": "2026-07-08T15:56:20+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.2",
|
"version": "2.3.3",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/phpstan/phpdoc-parser.git",
|
"url": "https://github.com/phpstan/phpdoc-parser.git",
|
||||||
"reference": "a004701b11273a26cd7955a61d67a7f1e525a45a"
|
"reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a",
|
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3",
|
||||||
"reference": "a004701b11273a26cd7955a61d67a7f1e525a45a",
|
"reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3",
|
||||||
"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.2"
|
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3"
|
||||||
},
|
},
|
||||||
"time": "2026-01-25T14:56:51+00:00"
|
"time": "2026-07-08T07:01:06+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "psr/cache",
|
"name": "psr/cache",
|
||||||
@@ -8281,6 +8281,54 @@
|
|||||||
],
|
],
|
||||||
"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",
|
||||||
@@ -9360,16 +9408,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "friendsofphp/php-cs-fixer",
|
"name": "friendsofphp/php-cs-fixer",
|
||||||
"version": "v3.95.11",
|
"version": "v3.95.12",
|
||||||
"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": "35f98e1293283397824d7f349ce5afb8747c3cd5"
|
"reference": "b1b9055997a98dce3c2338e884626e718a25a923"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/35f98e1293283397824d7f349ce5afb8747c3cd5",
|
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/b1b9055997a98dce3c2338e884626e718a25a923",
|
||||||
"reference": "35f98e1293283397824d7f349ce5afb8747c3cd5",
|
"reference": "b1b9055997a98dce3c2338e884626e718a25a923",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -9409,7 +9457,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.34 || ^10.5.63 || ^11.5.55",
|
"phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56",
|
||||||
"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"
|
||||||
@@ -9453,7 +9501,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.11"
|
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.12"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -9461,7 +9509,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-06-25T14:17:04+00:00"
|
"time": "2026-07-07T13:29:36+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "myclabs/deep-copy",
|
"name": "myclabs/deep-copy",
|
||||||
@@ -10404,16 +10452,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/phpunit",
|
"name": "phpunit/phpunit",
|
||||||
"version": "13.2.3",
|
"version": "13.2.4",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||||
"reference": "d76d0e24225e587d6a5f0c6f6d9fef0d90712b54"
|
"reference": "8f5180f4627fc1978be2f61d8d9979dbe37e0c10"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d76d0e24225e587d6a5f0c6f6d9fef0d90712b54",
|
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/8f5180f4627fc1978be2f61d8d9979dbe37e0c10",
|
||||||
"reference": "d76d0e24225e587d6a5f0c6f6d9fef0d90712b54",
|
"reference": "8f5180f4627fc1978be2f61d8d9979dbe37e0c10",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -10427,7 +10475,7 @@
|
|||||||
"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.2",
|
"phpunit/php-code-coverage": "^14.2.3",
|
||||||
"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",
|
||||||
@@ -10484,7 +10532,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.3"
|
"source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.4"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -10492,7 +10540,7 @@
|
|||||||
"type": "other"
|
"type": "other"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-06T14:55:56+00:00"
|
"time": "2026-07-08T08:36:51+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "react/cache",
|
"name": "react/cache",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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;
|
||||||
@@ -36,4 +37,5 @@ 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],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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: [ 'forwarded' ]
|
trusted_headers: [ 'x-forwarded-proto' ]
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
framework:
|
framework:
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
symfonycasts_reset_password:
|
||||||
|
request_password_repository: Tvdt\Repository\ResetPasswordRequestRepository
|
||||||
Generated
+10
@@ -1490,6 +1490,12 @@ 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,
|
||||||
@@ -1505,6 +1511,7 @@ 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,
|
||||||
@@ -1523,6 +1530,7 @@ 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,
|
||||||
@@ -1540,6 +1548,7 @@ 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,
|
||||||
@@ -1558,6 +1567,7 @@ 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,
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ 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
|
||||||
@@ -13,6 +16,22 @@ 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,11 +14,13 @@ 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\Entity\User;
|
use Tvdt\Enum\FlashType;
|
||||||
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;
|
||||||
@@ -32,17 +34,15 @@ 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($user);
|
: $this->seasonRepository->getSeasonsForUser($this->authenticatedUser);
|
||||||
|
|
||||||
return $this->render('backoffice/index.html.twig', [
|
return $this->render('backoffice/index.html.twig', [
|
||||||
'seasons' => $seasons,
|
'seasons' => $seasons,
|
||||||
@@ -58,10 +58,7 @@ final class BackofficeController extends AbstractController
|
|||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
$user = $this->getUser();
|
$season->addOwner($this->authenticatedUser);
|
||||||
\assert($user instanceof User);
|
|
||||||
|
|
||||||
$season->addOwner($user);
|
|
||||||
$season->generateSeasonCode();
|
$season->generateSeasonCode();
|
||||||
|
|
||||||
$this->em->persist($season);
|
$this->em->persist($season);
|
||||||
@@ -90,11 +87,17 @@ final class BackofficeController extends AbstractController
|
|||||||
requirements: ['quiz' => Requirement::UUID],
|
requirements: ['quiz' => Requirement::UUID],
|
||||||
methods: ['GET'],
|
methods: ['GET'],
|
||||||
)]
|
)]
|
||||||
public function exportQuiz(Quiz $quiz): StreamedResponse
|
public function exportQuiz(Quiz $quiz): Response
|
||||||
{
|
{
|
||||||
|
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, $quiz->name.'.xlsx'));
|
$response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, FilenameSanitizer::sanitize($quiz->name).'.xlsx'));
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,17 +114,11 @@ 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';
|
||||||
|
|
||||||
$response = $this->render($template, [
|
return $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')]
|
||||||
@@ -167,17 +161,11 @@ 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';
|
||||||
|
|
||||||
$response = $this->render($template, [
|
return $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')]
|
||||||
@@ -382,13 +370,6 @@ 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 syncUsagesAfterEdit(BankQuestion $bankQuestion): void
|
private function syncUsagesAfterEdit(BankQuestion $bankQuestion): void
|
||||||
{
|
{
|
||||||
$pendingNames = [];
|
$pendingNames = [];
|
||||||
|
|||||||
@@ -61,25 +61,7 @@ class QuizController extends AbstractController
|
|||||||
{
|
{
|
||||||
$fetchedQuiz = $this->quizRepository->fetchWithQuestionsAndCandidates($quiz->id);
|
$fetchedQuiz = $this->quizRepository->fetchWithQuestionsAndCandidates($quiz->id);
|
||||||
|
|
||||||
// Create indexed lookup for quiz candidates by candidate ID
|
$candidateData = $this->buildCandidateData($season, $quiz, $fetchedQuiz->candidateData);
|
||||||
$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,
|
||||||
@@ -118,25 +100,7 @@ class QuizController extends AbstractController
|
|||||||
)]
|
)]
|
||||||
public function candidatesTab(Season $season, Quiz $quiz): Response
|
public function candidatesTab(Season $season, Quiz $quiz): Response
|
||||||
{
|
{
|
||||||
// Create indexed lookup for quiz candidates by candidate ID
|
$candidateData = $this->buildCandidateData($season, $quiz, $quiz->candidateData);
|
||||||
$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,
|
||||||
@@ -432,4 +396,33 @@ 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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,18 +74,12 @@ 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';
|
||||||
|
|
||||||
$response = $this->render($template, [
|
return $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')]
|
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ 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;
|
||||||
@@ -26,6 +29,7 @@ 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;
|
||||||
|
|
||||||
@@ -37,6 +41,7 @@ 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')]
|
||||||
@@ -97,6 +102,24 @@ 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',
|
||||||
@@ -123,6 +146,56 @@ class SeasonController extends AbstractController
|
|||||||
return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form, 'season' => $season]);
|
return $this->render('backoffice/season_add_candidates.html.twig', ['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')]
|
||||||
#[Route(
|
#[Route(
|
||||||
'/backoffice/season/{seasonCode:season}/add-quiz',
|
'/backoffice/season/{seasonCode:season}/add-quiz',
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<?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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,14 +5,11 @@ 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;
|
||||||
@@ -26,7 +23,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 LoggerInterface $logger, 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 UserRepository $userRepository, private readonly EntityManagerInterface $entityManager) {}
|
||||||
|
|
||||||
#[Route('/register', name: 'tvdt_register')]
|
#[Route('/register', name: 'tvdt_register')]
|
||||||
public function register(
|
public function register(
|
||||||
@@ -49,17 +46,8 @@ final class RegistrationController extends AbstractController
|
|||||||
$this->entityManager->persist($user);
|
$this->entityManager->persist($user);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
try {
|
|
||||||
// generate a signed url and email it to the user
|
// generate a signed url and email it to the user
|
||||||
$this->emailVerifier->sendEmailConfirmation('tvdt_verify_email', $user,
|
$this->emailVerifier->sendDefaultConfirmation($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);
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ 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;
|
||||||
|
|
||||||
@@ -70,6 +74,33 @@ 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ 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')]
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?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([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?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([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?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([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: 8, max: 4096, minMessage: 'Your password should be at least {{ limit }} characters'),
|
new Length(min: User::PASSWORD_MIN_LENGTH, max: User::PASSWORD_MAX_LENGTH, minMessage: 'Your password should be at least {{ limit }} characters'),
|
||||||
],
|
],
|
||||||
'translation_domain' => false,
|
'translation_domain' => false,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?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([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,12 @@ 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 {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,15 @@ 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> */
|
||||||
@@ -28,6 +34,94 @@ 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]);
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ 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;
|
||||||
|
|
||||||
@@ -18,8 +20,29 @@ 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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ 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;
|
||||||
@@ -117,8 +118,13 @@ class QuizSpreadsheetService
|
|||||||
public function quizToXlsx(Quiz $quiz): \Closure
|
public function quizToXlsx(Quiz $quiz): \Closure
|
||||||
{
|
{
|
||||||
$spreadsheet = new Spreadsheet();
|
$spreadsheet = new Spreadsheet();
|
||||||
$sheet = $spreadsheet->getActiveSheet();
|
$this->fillQuestionsSheet($spreadsheet->getActiveSheet(), $quiz);
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -153,11 +159,9 @@ 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function toXlsx(Spreadsheet $spreadsheet): \Closure
|
public function toXlsx(Spreadsheet $spreadsheet): \Closure
|
||||||
{
|
{
|
||||||
$writer = new Writer\Xlsx($spreadsheet);
|
$writer = new Writer\Xlsx($spreadsheet);
|
||||||
|
|
||||||
|
|||||||
@@ -356,6 +356,18 @@
|
|||||||
"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,3 +1,4 @@
|
|||||||
<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,3 +1,4 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<h1 class="py-2 h3 mb-3 font-weight-normal">{{ 'Please sign in'|trans }}</h1>
|
<h3 class="mb-3">{{ 'Please sign in'|trans }}</h3>
|
||||||
<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,5 +31,7 @@
|
|||||||
</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 %}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
</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>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% block title %}{{ 'Register'|trans }}{% endblock %}
|
{% block title %}{{ 'Register'|trans }}{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<h3>{{ 'Register'|trans }}</h3>
|
<h3 class="mb-3">{{ 'Register'|trans }}</h3>
|
||||||
|
|
||||||
{{ form_errors(registrationForm) }}
|
{{ form_errors(registrationForm) }}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,67 @@
|
|||||||
<a class="btn btn-sm btn-outline-primary"
|
<a class="btn btn-sm btn-outline-primary"
|
||||||
href="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }}</a>
|
href="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }}</a>
|
||||||
</div>
|
</div>
|
||||||
<ul class="mb-3">
|
<ul class="list-group mb-3">
|
||||||
{% for candidate in season.candidates %}
|
{% for candidate in season.candidates %}
|
||||||
<li>{{ candidate.name }}</li>
|
<li class="list-group-item d-flex align-items-center justify-content-between gap-2">
|
||||||
|
{{ 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-bs-backdrop="static"
|
||||||
|
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>
|
||||||
|
</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 %}
|
||||||
|
|||||||
@@ -1,8 +1,40 @@
|
|||||||
<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,11 +8,13 @@
|
|||||||
</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{% if season.activeQuiz == quiz %} active{% endif %}"
|
<a class="list-group-item list-group-item-action d-flex align-items-center gap-2{% 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 quiz.isFinalized %}
|
{% if season.activeQuiz == quiz %}
|
||||||
<span class="badge text-bg-success">{{ 'Finalized'|trans }}</span>
|
<span class="badge text-bg-light ms-auto">{{ 'Active'|trans }}</span>
|
||||||
|
{% elseif quiz.isFinalized %}
|
||||||
|
<span class="badge text-bg-success ms-auto">{{ 'Ready'|trans }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace Tvdt\Tests\Command;
|
namespace Tvdt\Tests\Command;
|
||||||
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
@@ -48,21 +49,19 @@ final class ClaimSeasonCommandTest extends KernelTestCase
|
|||||||
$this->assertCount(3, $season->owners);
|
$this->assertCount(3, $season->owners);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testInvalidEmailFails(): void
|
/** @return iterable<string, array{string, string}> */
|
||||||
|
public static function invalidArgumentsProvider(): iterable
|
||||||
{
|
{
|
||||||
$this->commandTester->execute([
|
yield 'unknown email' => ['krtek', 'nonexisting@example.org'];
|
||||||
'season-code' => 'krtek',
|
yield 'unknown season' => ['dhadk', 'test@example.org'];
|
||||||
'email' => 'nonexisting@example.org',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testInvalidSeasonCodeFails(): void
|
#[DataProvider('invalidArgumentsProvider')]
|
||||||
|
public function testInvalidArgumentFails(string $seasonCode, string $email): void
|
||||||
{
|
{
|
||||||
$this->commandTester->execute([
|
$this->commandTester->execute([
|
||||||
'season-code' => 'dhadk',
|
'season-code' => $seasonCode,
|
||||||
'email' => 'test@example.org',
|
'email' => $email,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
|
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Tests\Controller;
|
||||||
|
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Tvdt\Entity\Candidate;
|
||||||
|
use Tvdt\Entity\Quiz;
|
||||||
|
use Tvdt\Entity\Season;
|
||||||
|
use Tvdt\Entity\User;
|
||||||
|
|
||||||
|
abstract class AbstractControllerWebTestCase extends WebTestCase
|
||||||
|
{
|
||||||
|
protected KernelBrowser $client;
|
||||||
|
|
||||||
|
protected EntityManagerInterface $entityManager;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->client = self::createClient();
|
||||||
|
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getUserByEmail(string $email): User
|
||||||
|
{
|
||||||
|
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $email]);
|
||||||
|
$this->assertInstanceOf(User::class, $user);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function loginAs(string $email): void
|
||||||
|
{
|
||||||
|
$this->client->loginUser($this->getUserByEmail($email));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quiz names are only unique per season (see Quiz's UniqueConstraint), so this is scoped by season code. */
|
||||||
|
protected function getQuizByName(string $name, string $seasonCode = 'krtek'): Quiz
|
||||||
|
{
|
||||||
|
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name, 'season' => $this->getSeasonByCode($seasonCode)]);
|
||||||
|
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||||
|
|
||||||
|
return $quiz;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Candidate names are only unique per season (see Candidate's UniqueConstraint), so this is scoped by season code. */
|
||||||
|
protected function getCandidate(string $name, string $seasonCode = 'krtek'): Candidate
|
||||||
|
{
|
||||||
|
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => $name, 'season' => $this->getSeasonByCode($seasonCode)]);
|
||||||
|
$this->assertInstanceOf(Candidate::class, $candidate);
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getSeasonByCode(string $seasonCode): Season
|
||||||
|
{
|
||||||
|
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => $seasonCode]);
|
||||||
|
$this->assertInstanceOf(Season::class, $season);
|
||||||
|
|
||||||
|
return $season;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GETs $url and extracts the CSRF token from a form whose action contains $formActionContains. */
|
||||||
|
protected function getCsrfTokenFromPage(string $url, string $formActionContains, string $tokenFieldName = '_token'): string
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, $url);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
return $this->getCsrfTokenFromCrawler($crawler, $formActionContains, $tokenFieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts the CSRF token from a form on the page already loaded in the client. */
|
||||||
|
protected function getCsrfTokenFromCurrentPage(string $formActionContains, string $tokenFieldName = '_token'): string
|
||||||
|
{
|
||||||
|
return $this->getCsrfTokenFromCrawler($this->client->getCrawler(), $formActionContains, $tokenFieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GETs $url and extracts the CSRF token input, regardless of which form it belongs to. */
|
||||||
|
protected function getTokenFromPage(string $url, string $tokenFieldName = '_token'): string
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, $url);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$input = $crawler->filter(\sprintf('input[name="%s"]', $tokenFieldName));
|
||||||
|
$this->assertGreaterThan(0, $input->count(), \sprintf('No input named "%s" found on the page', $tokenFieldName));
|
||||||
|
|
||||||
|
return (string) $input->first()->attr('value');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCsrfTokenFromCrawler(Crawler $crawler, string $formActionContains, string $tokenFieldName): string
|
||||||
|
{
|
||||||
|
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="%s"]', $formActionContains, $tokenFieldName));
|
||||||
|
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
|
||||||
|
|
||||||
|
return (string) $input->first()->attr('value');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Tests\Controller\Backoffice;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use Safe\DateTimeImmutable;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Tvdt\Controller\Backoffice\PrepareEliminationController;
|
||||||
|
use Tvdt\Entity\Answer;
|
||||||
|
use Tvdt\Entity\Elimination;
|
||||||
|
use Tvdt\Entity\GivenAnswer;
|
||||||
|
use Tvdt\Entity\Question;
|
||||||
|
use Tvdt\Entity\QuizCandidate;
|
||||||
|
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||||
|
|
||||||
|
#[CoversClass(PrepareEliminationController::class)]
|
||||||
|
final class PrepareEliminationControllerTest extends AbstractControllerWebTestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->loginAs('krtek-admin@example.org');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIndexCreatesEliminationAndRedirectsToView(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
$candidate = $this->getCandidate('Tom');
|
||||||
|
|
||||||
|
$quizCandidate = new QuizCandidate($quiz, $candidate);
|
||||||
|
$quizCandidate->started = new DateTimeImmutable();
|
||||||
|
|
||||||
|
$this->entityManager->persist($quizCandidate);
|
||||||
|
|
||||||
|
$firstQuestion = $quiz->questions->first();
|
||||||
|
$this->assertInstanceOf(Question::class, $firstQuestion);
|
||||||
|
$answer = $firstQuestion->answers->first();
|
||||||
|
$this->assertInstanceOf(Answer::class, $answer);
|
||||||
|
$this->entityManager->persist(new GivenAnswer($candidate, $quiz, $answer));
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$token = $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/krtek/quiz/%s/result', $quiz->id), '/elimination/prepare');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/quiz/%s/elimination/prepare', $quiz->id), [
|
||||||
|
'_token' => $token,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->client->getResponse();
|
||||||
|
$this->assertTrue($response->isRedirect());
|
||||||
|
$this->assertStringContainsString('/backoffice/elimination/', (string) $response->headers->get('Location'));
|
||||||
|
|
||||||
|
$this->entityManager->clear();
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
$elimination = $this->entityManager->getRepository(Elimination::class)->findOneBy(['quiz' => $quiz]);
|
||||||
|
$this->assertInstanceOf(Elimination::class, $elimination);
|
||||||
|
$this->assertArrayHasKey('Tom', $elimination->data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testViewEliminationPageLoads(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
$elimination = new Elimination($quiz);
|
||||||
|
$elimination->data = ['Tom' => Elimination::SCREEN_GREEN];
|
||||||
|
|
||||||
|
$this->entityManager->persist($elimination);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/elimination/%s', $elimination->id));
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorExists('form');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testViewEliminationSavesUpdatedColours(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
$elimination = new Elimination($quiz);
|
||||||
|
$elimination->data = ['Tom' => Elimination::SCREEN_GREEN];
|
||||||
|
|
||||||
|
$this->entityManager->persist($elimination);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$token = $this->getTokenFromPage(\sprintf('/backoffice/elimination/%s', $elimination->id));
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/elimination/%s', $elimination->id), [
|
||||||
|
'_token' => $token,
|
||||||
|
'colour-tom' => Elimination::SCREEN_RED,
|
||||||
|
'start' => '0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseRedirects(\sprintf('/backoffice/elimination/%s', $elimination->id));
|
||||||
|
$this->entityManager->clear();
|
||||||
|
|
||||||
|
$updated = $this->entityManager->getRepository(Elimination::class)->find($elimination->id);
|
||||||
|
$this->assertInstanceOf(Elimination::class, $updated);
|
||||||
|
$this->assertSame(Elimination::SCREEN_RED, $updated->data['Tom']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testViewEliminationWithStartRedirectsToPublicElimination(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
$elimination = new Elimination($quiz);
|
||||||
|
$elimination->data = ['Tom' => Elimination::SCREEN_GREEN];
|
||||||
|
|
||||||
|
$this->entityManager->persist($elimination);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$token = $this->getTokenFromPage(\sprintf('/backoffice/elimination/%s', $elimination->id));
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/elimination/%s', $elimination->id), [
|
||||||
|
'_token' => $token,
|
||||||
|
'start' => '1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseRedirects(\sprintf('/elimination/%s', $elimination->id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,39 +4,18 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tvdt\Tests\Controller\Backoffice;
|
namespace Tvdt\Tests\Controller\Backoffice;
|
||||||
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Tvdt\Controller\Backoffice\QuestionBankController;
|
use Tvdt\Controller\Backoffice\QuestionBankController;
|
||||||
use Tvdt\Entity\BankAnswer;
|
use Tvdt\Entity\BankAnswer;
|
||||||
use Tvdt\Entity\BankQuestion;
|
use Tvdt\Entity\BankQuestion;
|
||||||
use Tvdt\Entity\Question;
|
use Tvdt\Entity\Question;
|
||||||
use Tvdt\Entity\QuestionLabel;
|
use Tvdt\Entity\QuestionLabel;
|
||||||
use Tvdt\Entity\Quiz;
|
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||||
use Tvdt\Entity\User;
|
|
||||||
|
|
||||||
#[CoversClass(QuestionBankController::class)]
|
#[CoversClass(QuestionBankController::class)]
|
||||||
final class QuestionBankControllerTest extends WebTestCase
|
final class QuestionBankControllerTest extends AbstractControllerWebTestCase
|
||||||
{
|
{
|
||||||
private KernelBrowser $client;
|
|
||||||
|
|
||||||
private EntityManagerInterface $entityManager;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$this->client = self::createClient();
|
|
||||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function loginAsOwner(): void
|
|
||||||
{
|
|
||||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
|
|
||||||
$this->assertInstanceOf(User::class, $user);
|
|
||||||
$this->client->loginUser($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getBankQuestion(string $question): BankQuestion
|
private function getBankQuestion(string $question): BankQuestion
|
||||||
{
|
{
|
||||||
$bankQuestion = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => $question]);
|
$bankQuestion = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => $question]);
|
||||||
@@ -45,26 +24,9 @@ final class QuestionBankControllerTest extends WebTestCase
|
|||||||
return $bankQuestion;
|
return $bankQuestion;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getQuizByName(string $name): Quiz
|
|
||||||
{
|
|
||||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
|
|
||||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
|
||||||
|
|
||||||
return $quiz;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getCsrfToken(string $formActionContains): string
|
|
||||||
{
|
|
||||||
$crawler = $this->client->getCrawler();
|
|
||||||
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
|
|
||||||
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
|
|
||||||
|
|
||||||
return (string) $input->first()->attr('value');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testIndexListsBankQuestions(): void
|
public function testIndexListsBankQuestions(): void
|
||||||
{
|
{
|
||||||
$this->loginAsOwner();
|
$this->loginAs('krtek-admin@example.org');
|
||||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||||
|
|
||||||
$this->assertResponseIsSuccessful();
|
$this->assertResponseIsSuccessful();
|
||||||
@@ -75,7 +37,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
|||||||
|
|
||||||
public function testIndexFiltersByLabel(): void
|
public function testIndexFiltersByLabel(): void
|
||||||
{
|
{
|
||||||
$this->loginAsOwner();
|
$this->loginAs('krtek-admin@example.org');
|
||||||
$label = $this->entityManager->getRepository(QuestionLabel::class)->findOneBy(['name' => 'Locatie']);
|
$label = $this->entityManager->getRepository(QuestionLabel::class)->findOneBy(['name' => 'Locatie']);
|
||||||
$this->assertInstanceOf(QuestionLabel::class, $label);
|
$this->assertInstanceOf(QuestionLabel::class, $label);
|
||||||
|
|
||||||
@@ -89,9 +51,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
|||||||
|
|
||||||
public function testNonOwnerIsDenied(): void
|
public function testNonOwnerIsDenied(): void
|
||||||
{
|
{
|
||||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
|
$this->loginAs('test@example.org');
|
||||||
$this->assertInstanceOf(User::class, $user);
|
|
||||||
$this->client->loginUser($user);
|
|
||||||
|
|
||||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||||
|
|
||||||
@@ -100,7 +60,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
|||||||
|
|
||||||
public function testCreateBankQuestion(): void
|
public function testCreateBankQuestion(): void
|
||||||
{
|
{
|
||||||
$this->loginAsOwner();
|
$this->loginAs('krtek-admin@example.org');
|
||||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
|
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
|
||||||
$this->assertResponseIsSuccessful();
|
$this->assertResponseIsSuccessful();
|
||||||
|
|
||||||
@@ -129,7 +89,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
|||||||
|
|
||||||
public function testCreateAllowedWithoutCorrectAnswer(): void
|
public function testCreateAllowedWithoutCorrectAnswer(): void
|
||||||
{
|
{
|
||||||
$this->loginAsOwner();
|
$this->loginAs('krtek-admin@example.org');
|
||||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
|
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
|
||||||
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
|
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
|
||||||
|
|
||||||
@@ -145,6 +105,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertResponseRedirects();
|
$this->assertResponseRedirects();
|
||||||
|
$this->entityManager->clear();
|
||||||
$saved = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => 'Vraag zonder goed antwoord']);
|
$saved = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => 'Vraag zonder goed antwoord']);
|
||||||
$this->assertInstanceOf(BankQuestion::class, $saved);
|
$this->assertInstanceOf(BankQuestion::class, $saved);
|
||||||
$this->assertFalse($saved->isCompleteForQuiz);
|
$this->assertFalse($saved->isCompleteForQuiz);
|
||||||
@@ -152,7 +113,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
|||||||
|
|
||||||
public function testEditBankQuestion(): void
|
public function testEditBankQuestion(): void
|
||||||
{
|
{
|
||||||
$this->loginAsOwner();
|
$this->loginAs('krtek-admin@example.org');
|
||||||
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
|
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
|
||||||
|
|
||||||
$url = \sprintf('/backoffice/season/krtek/question-bank/%s/edit', $bankQuestion->id);
|
$url = \sprintf('/backoffice/season/krtek/question-bank/%s/edit', $bankQuestion->id);
|
||||||
@@ -181,12 +142,12 @@ final class QuestionBankControllerTest extends WebTestCase
|
|||||||
|
|
||||||
public function testDeleteUsedBankQuestionLeavesQuizIntact(): void
|
public function testDeleteUsedBankQuestionLeavesQuizIntact(): void
|
||||||
{
|
{
|
||||||
$this->loginAsOwner();
|
$this->loginAs('krtek-admin@example.org');
|
||||||
$bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?');
|
$bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?');
|
||||||
$quiz2QuestionCount = $this->getQuizByName('Quiz 2')->questions->count();
|
$quiz2QuestionCount = $this->getQuizByName('Quiz 2')->questions->count();
|
||||||
|
|
||||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||||
$token = $this->getCsrfToken(\sprintf('%s/delete', $bankQuestion->id));
|
$token = $this->getCsrfTokenFromCurrentPage(\sprintf('%s/delete', $bankQuestion->id));
|
||||||
|
|
||||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/delete', $bankQuestion->id), [
|
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/delete', $bankQuestion->id), [
|
||||||
'_token' => $token,
|
'_token' => $token,
|
||||||
@@ -201,13 +162,13 @@ final class QuestionBankControllerTest extends WebTestCase
|
|||||||
|
|
||||||
public function testAssignCopiesQuestionIntoQuiz(): void
|
public function testAssignCopiesQuestionIntoQuiz(): void
|
||||||
{
|
{
|
||||||
$this->loginAsOwner();
|
$this->loginAs('krtek-admin@example.org');
|
||||||
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
|
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
|
||||||
$quiz = $this->getQuizByName('Quiz 2');
|
$quiz = $this->getQuizByName('Quiz 2');
|
||||||
$questionCount = $quiz->questions->count();
|
$questionCount = $quiz->questions->count();
|
||||||
|
|
||||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||||
$token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id));
|
$token = $this->getCsrfTokenFromCurrentPage(\sprintf('%s/assign', $bankQuestion->id));
|
||||||
|
|
||||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
|
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
|
||||||
'_token' => $token,
|
'_token' => $token,
|
||||||
@@ -240,7 +201,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
|||||||
|
|
||||||
public function testAssignUsedNonReusableQuestionIsRefused(): void
|
public function testAssignUsedNonReusableQuestionIsRefused(): void
|
||||||
{
|
{
|
||||||
$this->loginAsOwner();
|
$this->loginAs('krtek-admin@example.org');
|
||||||
$bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?');
|
$bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?');
|
||||||
$quiz = $this->getQuizByName('Quiz 2');
|
$quiz = $this->getQuizByName('Quiz 2');
|
||||||
$questionCount = $quiz->questions->count();
|
$questionCount = $quiz->questions->count();
|
||||||
@@ -248,7 +209,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
|||||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||||
|
|
||||||
// The assign form is not rendered for used questions, so post with another form's token
|
// The assign form is not rendered for used questions, so post with another form's token
|
||||||
$token = $this->getCsrfToken('/assign');
|
$token = $this->getCsrfTokenFromCurrentPage('/assign');
|
||||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
|
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
|
||||||
'_token' => $token,
|
'_token' => $token,
|
||||||
'quiz' => (string) $quiz->id,
|
'quiz' => (string) $quiz->id,
|
||||||
@@ -262,13 +223,13 @@ final class QuestionBankControllerTest extends WebTestCase
|
|||||||
|
|
||||||
public function testAssignSameReusableQuestionTwiceToSameQuizIsRefused(): void
|
public function testAssignSameReusableQuestionTwiceToSameQuizIsRefused(): void
|
||||||
{
|
{
|
||||||
$this->loginAsOwner();
|
$this->loginAs('krtek-admin@example.org');
|
||||||
$bankQuestion = $this->getBankQuestion('Wie is de Krtek?');
|
$bankQuestion = $this->getBankQuestion('Wie is de Krtek?');
|
||||||
$quiz = $this->getQuizByName('Quiz 2');
|
$quiz = $this->getQuizByName('Quiz 2');
|
||||||
$questionCount = $quiz->questions->count();
|
$questionCount = $quiz->questions->count();
|
||||||
|
|
||||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||||
$token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id));
|
$token = $this->getCsrfTokenFromCurrentPage(\sprintf('%s/assign', $bankQuestion->id));
|
||||||
|
|
||||||
$url = \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id);
|
$url = \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id);
|
||||||
$this->client->request(Request::METHOD_POST, $url, ['_token' => $token, 'quiz' => (string) $quiz->id]);
|
$this->client->request(Request::METHOD_POST, $url, ['_token' => $token, 'quiz' => (string) $quiz->id]);
|
||||||
@@ -283,13 +244,13 @@ final class QuestionBankControllerTest extends WebTestCase
|
|||||||
|
|
||||||
public function testAssignIntoFinalizedQuizIsDenied(): void
|
public function testAssignIntoFinalizedQuizIsDenied(): void
|
||||||
{
|
{
|
||||||
$this->loginAsOwner();
|
$this->loginAs('krtek-admin@example.org');
|
||||||
$bankQuestion = $this->getBankQuestion('Wie is de Krtek?');
|
$bankQuestion = $this->getBankQuestion('Wie is de Krtek?');
|
||||||
$finalizedQuiz = $this->getQuizByName('Quiz 1');
|
$finalizedQuiz = $this->getQuizByName('Quiz 1');
|
||||||
$this->assertTrue($finalizedQuiz->isFinalized);
|
$this->assertTrue($finalizedQuiz->isFinalized);
|
||||||
|
|
||||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||||
$token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id));
|
$token = $this->getCsrfTokenFromCurrentPage(\sprintf('%s/assign', $bankQuestion->id));
|
||||||
|
|
||||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
|
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
|
||||||
'_token' => $token,
|
'_token' => $token,
|
||||||
@@ -301,7 +262,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
|||||||
|
|
||||||
public function testCreateBankQuestionPreservesAnswerOrdering(): void
|
public function testCreateBankQuestionPreservesAnswerOrdering(): void
|
||||||
{
|
{
|
||||||
$this->loginAsOwner();
|
$this->loginAs('krtek-admin@example.org');
|
||||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
|
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
|
||||||
$this->assertResponseIsSuccessful();
|
$this->assertResponseIsSuccessful();
|
||||||
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
|
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
|
||||||
@@ -334,7 +295,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
|||||||
|
|
||||||
public function testEditBankQuestionPreservesAnswerOrdering(): void
|
public function testEditBankQuestionPreservesAnswerOrdering(): void
|
||||||
{
|
{
|
||||||
$this->loginAsOwner();
|
$this->loginAs('krtek-admin@example.org');
|
||||||
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
|
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
|
||||||
// Fixture answers in insertion order (all have ordering=0): Brood (correct), Yoghurt, Niks
|
// Fixture answers in insertion order (all have ordering=0): Brood (correct), Yoghurt, Niks
|
||||||
|
|
||||||
@@ -374,7 +335,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
|||||||
|
|
||||||
public function testAddAndDeleteLabel(): void
|
public function testAddAndDeleteLabel(): void
|
||||||
{
|
{
|
||||||
$this->loginAsOwner();
|
$this->loginAs('krtek-admin@example.org');
|
||||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||||
$token = (string) $crawler->filter('form[action$="/question-bank/labels"] input[name="_token"]')->attr('value');
|
$token = (string) $crawler->filter('form[action$="/question-bank/labels"] input[name="_token"]')->attr('value');
|
||||||
|
|
||||||
@@ -389,7 +350,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
|||||||
$this->assertInstanceOf(QuestionLabel::class, $label);
|
$this->assertInstanceOf(QuestionLabel::class, $label);
|
||||||
|
|
||||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||||
$deleteToken = $this->getCsrfToken(\sprintf('labels/%s/delete', $label->slug));
|
$deleteToken = $this->getCsrfTokenFromCurrentPage(\sprintf('labels/%s/delete', $label->slug));
|
||||||
|
|
||||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/labels/%s/delete', $label->slug), [
|
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/labels/%s/delete', $label->slug), [
|
||||||
'_token' => $deleteToken,
|
'_token' => $deleteToken,
|
||||||
|
|||||||
@@ -4,11 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tvdt\Tests\Controller\Backoffice;
|
namespace Tvdt\Tests\Controller\Backoffice;
|
||||||
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
use Safe\DateTimeImmutable;
|
use Safe\DateTimeImmutable;
|
||||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Tvdt\Controller\Backoffice\QuizController;
|
use Tvdt\Controller\Backoffice\QuizController;
|
||||||
use Tvdt\Entity\Answer;
|
use Tvdt\Entity\Answer;
|
||||||
@@ -17,51 +14,21 @@ use Tvdt\Entity\GivenAnswer;
|
|||||||
use Tvdt\Entity\Question;
|
use Tvdt\Entity\Question;
|
||||||
use Tvdt\Entity\Quiz;
|
use Tvdt\Entity\Quiz;
|
||||||
use Tvdt\Entity\QuizCandidate;
|
use Tvdt\Entity\QuizCandidate;
|
||||||
use Tvdt\Entity\Season;
|
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||||
use Tvdt\Entity\User;
|
|
||||||
|
|
||||||
#[CoversClass(QuizController::class)]
|
#[CoversClass(QuizController::class)]
|
||||||
final class QuizControllerTest extends WebTestCase
|
final class QuizControllerTest extends AbstractControllerWebTestCase
|
||||||
{
|
{
|
||||||
private KernelBrowser $client;
|
|
||||||
|
|
||||||
private EntityManagerInterface $entityManager;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->client = self::createClient();
|
parent::setUp();
|
||||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
|
||||||
|
|
||||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
|
$this->loginAs('krtek-admin@example.org');
|
||||||
$this->assertInstanceOf(User::class, $user);
|
|
||||||
$this->client->loginUser($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getQuizByName(string $name): Quiz
|
|
||||||
{
|
|
||||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
|
|
||||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
|
||||||
|
|
||||||
return $quiz;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getCandidate(string $name): Candidate
|
|
||||||
{
|
|
||||||
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => $name]);
|
|
||||||
$this->assertInstanceOf(Candidate::class, $candidate);
|
|
||||||
|
|
||||||
return $candidate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string
|
private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string
|
||||||
{
|
{
|
||||||
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
|
return $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id), $formActionContains);
|
||||||
self::assertResponseIsSuccessful();
|
|
||||||
|
|
||||||
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
|
|
||||||
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
|
|
||||||
|
|
||||||
return (string) $input->first()->attr('value');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIndexRedirectsToOverview(): void
|
public function testIndexRedirectsToOverview(): void
|
||||||
@@ -296,9 +263,7 @@ final class QuizControllerTest extends WebTestCase
|
|||||||
|
|
||||||
public function testNonOwnerIsDenied(): void
|
public function testNonOwnerIsDenied(): void
|
||||||
{
|
{
|
||||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
|
$this->loginAs('test@example.org');
|
||||||
$this->assertInstanceOf(User::class, $user);
|
|
||||||
$this->client->loginUser($user);
|
|
||||||
|
|
||||||
$quiz = $this->getQuizByName('Quiz 1');
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
|
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
|
||||||
@@ -308,8 +273,7 @@ final class QuizControllerTest extends WebTestCase
|
|||||||
|
|
||||||
public function testOverviewLoadsForEmptyQuiz(): void
|
public function testOverviewLoadsForEmptyQuiz(): void
|
||||||
{
|
{
|
||||||
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
|
$season = $this->getSeasonByCode('krtek');
|
||||||
$this->assertInstanceOf(Season::class, $season);
|
|
||||||
|
|
||||||
$emptyQuiz = new Quiz();
|
$emptyQuiz = new Quiz();
|
||||||
$emptyQuiz->name = 'Empty Quiz';
|
$emptyQuiz->name = 'Empty Quiz';
|
||||||
@@ -326,8 +290,7 @@ final class QuizControllerTest extends WebTestCase
|
|||||||
|
|
||||||
public function testAnswerMappingRedirectsWithFlashWhenNoQuestions(): void
|
public function testAnswerMappingRedirectsWithFlashWhenNoQuestions(): void
|
||||||
{
|
{
|
||||||
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
|
$season = $this->getSeasonByCode('krtek');
|
||||||
$this->assertInstanceOf(Season::class, $season);
|
|
||||||
|
|
||||||
$emptyQuiz = new Quiz();
|
$emptyQuiz = new Quiz();
|
||||||
$emptyQuiz->name = 'Empty Quiz';
|
$emptyQuiz->name = 'Empty Quiz';
|
||||||
|
|||||||
@@ -4,63 +4,29 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tvdt\Tests\Controller\Backoffice;
|
namespace Tvdt\Tests\Controller\Backoffice;
|
||||||
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
use Safe\DateTimeImmutable;
|
use Safe\DateTimeImmutable;
|
||||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Tvdt\Controller\Backoffice\QuizController;
|
use Tvdt\Controller\Backoffice\QuizController;
|
||||||
use Tvdt\Entity\Answer;
|
use Tvdt\Entity\Answer;
|
||||||
use Tvdt\Entity\Candidate;
|
|
||||||
use Tvdt\Entity\Question;
|
use Tvdt\Entity\Question;
|
||||||
use Tvdt\Entity\Quiz;
|
use Tvdt\Entity\Quiz;
|
||||||
use Tvdt\Entity\QuizCandidate;
|
use Tvdt\Entity\QuizCandidate;
|
||||||
use Tvdt\Entity\Season;
|
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||||
use Tvdt\Entity\User;
|
|
||||||
|
|
||||||
#[CoversClass(QuizController::class)]
|
#[CoversClass(QuizController::class)]
|
||||||
final class QuizFinalizeTest extends WebTestCase
|
final class QuizFinalizeTest extends AbstractControllerWebTestCase
|
||||||
{
|
{
|
||||||
private KernelBrowser $client;
|
|
||||||
|
|
||||||
private EntityManagerInterface $entityManager;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->client = self::createClient();
|
parent::setUp();
|
||||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
|
||||||
|
|
||||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
|
$this->loginAs('krtek-admin@example.org');
|
||||||
$this->assertInstanceOf(User::class, $user);
|
|
||||||
$this->client->loginUser($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getQuizByName(string $name): Quiz
|
|
||||||
{
|
|
||||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
|
|
||||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
|
||||||
|
|
||||||
return $quiz;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getKrtekSeason(): Season
|
|
||||||
{
|
|
||||||
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
|
|
||||||
$this->assertInstanceOf(Season::class, $season);
|
|
||||||
|
|
||||||
return $season;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string
|
private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string
|
||||||
{
|
{
|
||||||
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
|
return $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id), $formActionContains);
|
||||||
$this->assertResponseIsSuccessful();
|
|
||||||
|
|
||||||
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
|
|
||||||
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
|
|
||||||
|
|
||||||
return (string) $input->first()->attr('value');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testFinalizeSetsFinalizedAt(): void
|
public function testFinalizeSetsFinalizedAt(): void
|
||||||
@@ -79,7 +45,7 @@ final class QuizFinalizeTest extends WebTestCase
|
|||||||
|
|
||||||
public function testFinalizeRefusedWhenQuizHasErrors(): void
|
public function testFinalizeRefusedWhenQuizHasErrors(): void
|
||||||
{
|
{
|
||||||
$season = $this->getKrtekSeason();
|
$season = $this->getSeasonByCode('krtek');
|
||||||
|
|
||||||
$invalidQuiz = new Quiz();
|
$invalidQuiz = new Quiz();
|
||||||
$invalidQuiz->name = 'Invalid Quiz';
|
$invalidQuiz->name = 'Invalid Quiz';
|
||||||
@@ -116,7 +82,7 @@ final class QuizFinalizeTest extends WebTestCase
|
|||||||
$this->assertResponseRedirects();
|
$this->assertResponseRedirects();
|
||||||
|
|
||||||
$this->entityManager->clear();
|
$this->entityManager->clear();
|
||||||
$season = $this->getKrtekSeason();
|
$season = $this->getSeasonByCode('krtek');
|
||||||
$this->assertInstanceOf(Quiz::class, $season->activeQuiz);
|
$this->assertInstanceOf(Quiz::class, $season->activeQuiz);
|
||||||
$this->assertSame('Quiz 1', $season->activeQuiz->name);
|
$this->assertSame('Quiz 1', $season->activeQuiz->name);
|
||||||
}
|
}
|
||||||
@@ -134,7 +100,7 @@ final class QuizFinalizeTest extends WebTestCase
|
|||||||
$this->assertResponseRedirects();
|
$this->assertResponseRedirects();
|
||||||
|
|
||||||
$this->entityManager->clear();
|
$this->entityManager->clear();
|
||||||
$season = $this->getKrtekSeason();
|
$season = $this->getSeasonByCode('krtek');
|
||||||
$this->assertInstanceOf(Quiz::class, $season->activeQuiz);
|
$this->assertInstanceOf(Quiz::class, $season->activeQuiz);
|
||||||
$this->assertSame('Quiz 2', $season->activeQuiz->name);
|
$this->assertSame('Quiz 2', $season->activeQuiz->name);
|
||||||
}
|
}
|
||||||
@@ -183,8 +149,7 @@ final class QuizFinalizeTest extends WebTestCase
|
|||||||
// Scrape the token before a candidate starts, since the button disappears afterwards
|
// Scrape the token before a candidate starts, since the button disappears afterwards
|
||||||
$token = $this->getCsrfTokenFromOverview($quiz, '/unfinalize');
|
$token = $this->getCsrfTokenFromOverview($quiz, '/unfinalize');
|
||||||
|
|
||||||
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => 'Tom']);
|
$candidate = $this->getCandidate('Tom');
|
||||||
$this->assertInstanceOf(Candidate::class, $candidate);
|
|
||||||
$quizCandidate = new QuizCandidate($quiz, $candidate);
|
$quizCandidate = new QuizCandidate($quiz, $candidate);
|
||||||
$quizCandidate->started = new DateTimeImmutable();
|
$quizCandidate->started = new DateTimeImmutable();
|
||||||
|
|
||||||
@@ -229,6 +194,6 @@ final class QuizFinalizeTest extends WebTestCase
|
|||||||
self::assertResponseRedirects(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz2->id));
|
self::assertResponseRedirects(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz2->id));
|
||||||
|
|
||||||
$this->entityManager->clear();
|
$this->entityManager->clear();
|
||||||
$this->assertNotInstanceOf(Quiz::class, $this->getKrtekSeason()->activeQuiz);
|
$this->assertNotInstanceOf(Quiz::class, $this->getSeasonByCode('krtek')->activeQuiz);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,47 +4,18 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tvdt\Tests\Controller\Backoffice;
|
namespace Tvdt\Tests\Controller\Backoffice;
|
||||||
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Tvdt\Controller\Backoffice\QuizQuestionController;
|
use Tvdt\Controller\Backoffice\QuizQuestionController;
|
||||||
use Tvdt\Entity\Question;
|
use Tvdt\Entity\Question;
|
||||||
use Tvdt\Entity\Quiz;
|
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||||
use Tvdt\Entity\User;
|
|
||||||
|
|
||||||
#[CoversClass(QuizQuestionController::class)]
|
#[CoversClass(QuizQuestionController::class)]
|
||||||
final class QuizQuestionControllerTest extends WebTestCase
|
final class QuizQuestionControllerTest extends AbstractControllerWebTestCase
|
||||||
{
|
{
|
||||||
private KernelBrowser $client;
|
|
||||||
|
|
||||||
private EntityManagerInterface $entityManager;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$this->client = self::createClient();
|
|
||||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function loginAsOwner(): void
|
|
||||||
{
|
|
||||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
|
|
||||||
$this->assertInstanceOf(User::class, $user);
|
|
||||||
$this->client->loginUser($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getQuizByName(string $name): Quiz
|
|
||||||
{
|
|
||||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
|
|
||||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
|
||||||
|
|
||||||
return $quiz;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testEditPreservesAnswerOrdering(): void
|
public function testEditPreservesAnswerOrdering(): void
|
||||||
{
|
{
|
||||||
$this->loginAsOwner();
|
$this->loginAs('krtek-admin@example.org');
|
||||||
|
|
||||||
$quiz = $this->getQuizByName('Quiz 2');
|
$quiz = $this->getQuizByName('Quiz 2');
|
||||||
$question = null;
|
$question = null;
|
||||||
@@ -111,7 +82,7 @@ final class QuizQuestionControllerTest extends WebTestCase
|
|||||||
|
|
||||||
public function testReorderQuestionsWithinQuiz(): void
|
public function testReorderQuestionsWithinQuiz(): void
|
||||||
{
|
{
|
||||||
$this->loginAsOwner();
|
$this->loginAs('krtek-admin@example.org');
|
||||||
|
|
||||||
$quiz = $this->getQuizByName('Quiz 2');
|
$quiz = $this->getQuizByName('Quiz 2');
|
||||||
$originalQuestions = $quiz->questions->toArray();
|
$originalQuestions = $quiz->questions->toArray();
|
||||||
@@ -144,4 +115,43 @@ final class QuizQuestionControllerTest extends WebTestCase
|
|||||||
$this->assertSame($originalLastId, (string) $reorderedQuestions[0]->id);
|
$this->assertSame($originalLastId, (string) $reorderedQuestions[0]->id);
|
||||||
$this->assertSame($originalFirstId, (string) $reorderedQuestions[\count($reorderedQuestions) - 1]->id);
|
$this->assertSame($originalFirstId, (string) $reorderedQuestions[\count($reorderedQuestions) - 1]->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testEditIsDeniedForNonOwner(): void
|
||||||
|
{
|
||||||
|
$this->loginAs('test@example.org');
|
||||||
|
|
||||||
|
$quiz = $this->getQuizByName('Quiz 2');
|
||||||
|
$question = $quiz->questions->first();
|
||||||
|
$this->assertInstanceOf(Question::class, $question);
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf(
|
||||||
|
'/backoffice/season/krtek/quiz/%s/question/%s/edit',
|
||||||
|
$quiz->id,
|
||||||
|
$question->id,
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReorderIsDeniedForNonOwner(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 2');
|
||||||
|
|
||||||
|
// Scrape a valid CSRF token as the owner before switching to the non-owner account,
|
||||||
|
// since the token is bound to the session, not the logged-in user.
|
||||||
|
$this->loginAs('krtek-admin@example.org');
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$csrfToken = $crawler->filter('[data-bo--question-list-csrf-value]')->attr('data-bo--question-list-csrf-value');
|
||||||
|
$this->assertNotEmpty($csrfToken);
|
||||||
|
|
||||||
|
$this->loginAs('test@example.org');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/quiz/%s/questions/reorder', $quiz->id), [
|
||||||
|
'_token' => $csrfToken,
|
||||||
|
'ordering' => array_map(static fn (Question $q): string => (string) $q->id, $quiz->questions->toArray()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<?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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Tests\Controller;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Tvdt\Controller\EliminationController;
|
||||||
|
use Tvdt\Entity\Elimination;
|
||||||
|
use Tvdt\Helpers\Base64;
|
||||||
|
|
||||||
|
#[CoversClass(EliminationController::class)]
|
||||||
|
final class EliminationControllerTest extends AbstractControllerWebTestCase
|
||||||
|
{
|
||||||
|
private Elimination $elimination;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
|
||||||
|
$this->elimination = new Elimination($quiz);
|
||||||
|
$this->elimination->data = ['Tom' => Elimination::SCREEN_GREEN];
|
||||||
|
|
||||||
|
$this->entityManager->persist($this->elimination);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->loginAs('krtek-admin@example.org');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIndexIsDeniedForNonOwner(): void
|
||||||
|
{
|
||||||
|
$this->loginAs('test@example.org');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s', $this->elimination->id));
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIndexPageLoads(): void
|
||||||
|
{
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s', $this->elimination->id));
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorExists('form');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIndexRedirectsToCandidateScreen(): void
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s', $this->elimination->id));
|
||||||
|
$form = $crawler->filter('form')->form([
|
||||||
|
'elimination_enter_name[name]' => 'Tom',
|
||||||
|
]);
|
||||||
|
$this->client->submit($form);
|
||||||
|
|
||||||
|
self::assertResponseRedirects(\sprintf('/elimination/%s/%s', $this->elimination->id, Base64::base64UrlEncode('Tom')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCandidateScreenUnknownCandidateRedirectsWithFlash(): void
|
||||||
|
{
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s/%s', $this->elimination->id, Base64::base64UrlEncode('Nobody')));
|
||||||
|
|
||||||
|
self::assertResponseRedirects(\sprintf('/elimination/%s', $this->elimination->id));
|
||||||
|
$this->client->followRedirect();
|
||||||
|
self::assertSelectorTextContains('body', 'Kon kandidaat met naam Nobody niet vinden');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCandidateScreenCandidateNotInEliminationDataRedirectsWithFlash(): void
|
||||||
|
{
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s/%s', $this->elimination->id, Base64::base64UrlEncode('Claudia')));
|
||||||
|
|
||||||
|
self::assertResponseRedirects(\sprintf('/elimination/%s', $this->elimination->id));
|
||||||
|
$this->client->followRedirect();
|
||||||
|
self::assertSelectorTextContains('body', 'Kon geen kandidaat vinden met de naam Claudia in de eliminatie');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCandidateScreenRendersColour(): void
|
||||||
|
{
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s/%s', $this->elimination->id, Base64::base64UrlEncode('Tom')));
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorExists(\sprintf('#%s', Elimination::SCREEN_GREEN));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Tests\Controller;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Tvdt\Controller\LoginController;
|
||||||
|
|
||||||
|
#[CoversClass(LoginController::class)]
|
||||||
|
final class LoginControllerTest extends AbstractControllerWebTestCase
|
||||||
|
{
|
||||||
|
public function testLoginPageLoadsWhenNotAuthenticated(): void
|
||||||
|
{
|
||||||
|
$this->client->request(Request::METHOD_GET, '/login');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorExists('form');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoginRedirectsToBackofficeWhenAlreadyAuthenticated(): void
|
||||||
|
{
|
||||||
|
$this->loginAs('test@example.org');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, '/login');
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/backoffice/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoginWithInvalidCredentialsShowsFlash(): void
|
||||||
|
{
|
||||||
|
$this->client->request(Request::METHOD_GET, '/login');
|
||||||
|
$form = $this->client->getCrawler()->filter('form')->form([
|
||||||
|
'_username' => 'test@example.org',
|
||||||
|
'_password' => 'wrong-password',
|
||||||
|
]);
|
||||||
|
$this->client->submit($form);
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/login');
|
||||||
|
$this->client->followRedirect();
|
||||||
|
self::assertSelectorTextContains('body', 'Ongeldige inloggegevens.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogoutIsInterceptedByFirewall(): void
|
||||||
|
{
|
||||||
|
$this->loginAs('test@example.org');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, '/logout');
|
||||||
|
|
||||||
|
self::assertResponseRedirects();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Tests\Controller;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Tvdt\Controller\QuizController;
|
||||||
|
use Tvdt\Entity\Answer;
|
||||||
|
use Tvdt\Entity\Candidate;
|
||||||
|
use Tvdt\Entity\GivenAnswer;
|
||||||
|
use Tvdt\Entity\Question;
|
||||||
|
use Tvdt\Entity\QuizCandidate;
|
||||||
|
use Tvdt\Helpers\Base64;
|
||||||
|
|
||||||
|
#[CoversClass(QuizController::class)]
|
||||||
|
final class QuizControllerTest extends AbstractControllerWebTestCase
|
||||||
|
{
|
||||||
|
private function answerQuestion(Question $question): void
|
||||||
|
{
|
||||||
|
$tomHash = Base64::base64UrlEncode('Tom');
|
||||||
|
$url = \sprintf('/krtek/%s', $tomHash);
|
||||||
|
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, $url);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$token = (string) $crawler->filter('input[name="token"]')->first()->attr('value');
|
||||||
|
|
||||||
|
$answer = $question->answers->first();
|
||||||
|
$this->assertInstanceOf(Answer::class, $answer);
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_POST, $url, [
|
||||||
|
'token' => $token,
|
||||||
|
'answer' => (string) $answer->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseRedirects($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSelectSeasonPageLoads(): void
|
||||||
|
{
|
||||||
|
$this->client->request(Request::METHOD_GET, '/');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorExists('form');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSelectSeasonWithInvalidCodeRedirectsWithFlash(): void
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, '/');
|
||||||
|
$form = $crawler->filter('form')->form([
|
||||||
|
'select_season[season_code]' => 'aaaaa',
|
||||||
|
]);
|
||||||
|
$this->client->submit($form);
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/');
|
||||||
|
$this->client->followRedirect();
|
||||||
|
self::assertSelectorTextContains('body', 'Ongeldige seizoencode');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSelectSeasonWithValidCodeRedirectsToEnterName(): void
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, '/');
|
||||||
|
$form = $crawler->filter('form')->form([
|
||||||
|
'select_season[season_code]' => 'krtek',
|
||||||
|
]);
|
||||||
|
$this->client->submit($form);
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/krtek');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEnterNamePageLoads(): void
|
||||||
|
{
|
||||||
|
$this->client->request(Request::METHOD_GET, '/krtek');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorExists('form');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEnterNameRedirectsToQuizPage(): void
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, '/krtek');
|
||||||
|
$form = $crawler->filter('form')->form([
|
||||||
|
'enter_name[name]' => 'Tom',
|
||||||
|
]);
|
||||||
|
$this->client->submit($form);
|
||||||
|
|
||||||
|
self::assertResponseRedirects(\sprintf('/krtek/%s', Base64::base64UrlEncode('Tom')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testQuizPageUnknownCandidateRedirectsWithFlash(): void
|
||||||
|
{
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/krtek/%s', Base64::base64UrlEncode('Nobody')));
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/krtek');
|
||||||
|
$this->client->followRedirect();
|
||||||
|
self::assertSelectorTextContains('body', 'Kandidaat niet gevonden');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testQuizPageWithoutActiveQuizRedirectsWithFlash(): void
|
||||||
|
{
|
||||||
|
$season = $this->getSeasonByCode('bbbbb');
|
||||||
|
$season->addCandidate(new Candidate('Nienke'));
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/bbbbb/%s', Base64::base64UrlEncode('Nienke')));
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/bbbbb');
|
||||||
|
$this->client->followRedirect();
|
||||||
|
self::assertSelectorTextContains('body', 'Er is geen test actief');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testQuizPageRendersFirstQuestion(): void
|
||||||
|
{
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/krtek/%s', Base64::base64UrlEncode('Tom')));
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorTextContains('body', 'Is de Krtek een man of een vrouw?');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testQuizPageAnsweringPersistsGivenAnswerAndRedirects(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
$firstQuestion = $quiz->questions->first();
|
||||||
|
$this->assertInstanceOf(Question::class, $firstQuestion);
|
||||||
|
$answer = $firstQuestion->answers->first();
|
||||||
|
$this->assertInstanceOf(Answer::class, $answer);
|
||||||
|
|
||||||
|
$this->answerQuestion($firstQuestion);
|
||||||
|
$this->entityManager->clear();
|
||||||
|
|
||||||
|
$candidate = $this->getCandidate('Tom');
|
||||||
|
$givenAnswer = $this->entityManager->getRepository(GivenAnswer::class)->findOneBy(['candidate' => $candidate]);
|
||||||
|
$this->assertInstanceOf(GivenAnswer::class, $givenAnswer);
|
||||||
|
$this->assertTrue($answer->id->equals($givenAnswer->answer->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testQuizPageInvalidAnswerIdShowsFlash(): void
|
||||||
|
{
|
||||||
|
$url = \sprintf('/krtek/%s', Base64::base64UrlEncode('Tom'));
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, $url);
|
||||||
|
$token = (string) $crawler->filter('input[name="token"]')->first()->attr('value');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_POST, $url, [
|
||||||
|
'token' => $token,
|
||||||
|
'answer' => '00000000-0000-0000-0000-000000000000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseRedirects($url);
|
||||||
|
$this->client->followRedirect();
|
||||||
|
self::assertSelectorTextContains('body', 'Selecteer een antwoorden alsjeblieft');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testQuizPageOutOfOrderAnswerShowsFlash(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
$secondQuestion = $quiz->questions->get(1);
|
||||||
|
$this->assertInstanceOf(Question::class, $secondQuestion);
|
||||||
|
$answer = $secondQuestion->answers->first();
|
||||||
|
$this->assertInstanceOf(Answer::class, $answer);
|
||||||
|
|
||||||
|
$url = \sprintf('/krtek/%s', Base64::base64UrlEncode('Tom'));
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, $url);
|
||||||
|
$token = (string) $crawler->filter('input[name="token"]')->first()->attr('value');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_POST, $url, [
|
||||||
|
'token' => $token,
|
||||||
|
'answer' => (string) $answer->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseRedirects($url);
|
||||||
|
$this->client->followRedirect();
|
||||||
|
self::assertSelectorTextContains('body', 'Je kan deze vraag niet beantwoorden');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testQuizPageCompletedShowsFlashAndRedirects(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
|
||||||
|
foreach ($quiz->questions as $question) {
|
||||||
|
$this->answerQuestion($question);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/krtek/%s', Base64::base64UrlEncode('Tom')));
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/krtek');
|
||||||
|
$this->client->followRedirect();
|
||||||
|
self::assertSelectorTextContains('body', 'Test voltooid');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testQuizPageInactiveCandidateIsBlocked(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
$candidate = $this->getCandidate('Tom');
|
||||||
|
|
||||||
|
$quizCandidate = new QuizCandidate($quiz, $candidate);
|
||||||
|
$quizCandidate->active = false;
|
||||||
|
|
||||||
|
$this->entityManager->persist($quizCandidate);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/krtek/%s', Base64::base64UrlEncode('Tom')));
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/krtek');
|
||||||
|
$this->client->followRedirect();
|
||||||
|
self::assertSelectorTextContains('body', 'Je mag deze test niet beantwoorden');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Tests\Controller;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface;
|
||||||
|
use Tvdt\Controller\RegistrationController;
|
||||||
|
|
||||||
|
#[CoversClass(RegistrationController::class)]
|
||||||
|
final class RegistrationControllerTest extends AbstractControllerWebTestCase
|
||||||
|
{
|
||||||
|
public function testRegisterPageLoadsWhenNotAuthenticated(): void
|
||||||
|
{
|
||||||
|
$this->client->request(Request::METHOD_GET, '/register');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorExists('form');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRegisterRedirectsToBackofficeWhenAlreadyAuthenticated(): void
|
||||||
|
{
|
||||||
|
$this->loginAs('test@example.org');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, '/register');
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/backoffice/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRegisterCreatesUserSendsConfirmationAndLogsIn(): void
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, '/register');
|
||||||
|
$form = $crawler->filter('form')->form([
|
||||||
|
'registration_form[email]' => 'newuser@example.org',
|
||||||
|
'registration_form[plainPassword][first]' => 'NewPass123!',
|
||||||
|
'registration_form[plainPassword][second]' => 'NewPass123!',
|
||||||
|
]);
|
||||||
|
$this->client->submit($form);
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/backoffice/');
|
||||||
|
self::assertEmailCount(1);
|
||||||
|
|
||||||
|
$this->entityManager->clear();
|
||||||
|
$user = $this->getUserByEmail('newuser@example.org');
|
||||||
|
$this->assertFalse($user->isVerified);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyEmailWithoutIdRedirectsToRegister(): void
|
||||||
|
{
|
||||||
|
$this->client->request(Request::METHOD_GET, '/verify/email');
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/register');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyEmailWithUnknownIdRedirectsToRegister(): void
|
||||||
|
{
|
||||||
|
$this->client->request(Request::METHOD_GET, '/verify/email', ['id' => '00000000-0000-0000-0000-000000000000']);
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/register');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyEmailWithValidSignatureMarksUserVerified(): void
|
||||||
|
{
|
||||||
|
$user = $this->getUserByEmail('test@example.org');
|
||||||
|
$this->assertFalse($user->isVerified);
|
||||||
|
|
||||||
|
/** @var VerifyEmailHelperInterface $helper */
|
||||||
|
$helper = self::getContainer()->get(VerifyEmailHelperInterface::class);
|
||||||
|
$signature = $helper->generateSignature('tvdt_verify_email', $user->id->toRfc4122(), $user->email, ['id' => $user->id]);
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, $signature->getSignedUrl());
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/backoffice/');
|
||||||
|
|
||||||
|
$this->entityManager->clear();
|
||||||
|
$updatedUser = $this->getUserByEmail('test@example.org');
|
||||||
|
$this->assertTrue($updatedUser->isVerified);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testVerifyEmailWithInvalidSignatureShowsErrorAndRedirects(): void
|
||||||
|
{
|
||||||
|
$user = $this->getUserByEmail('test@example.org');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, '/verify/email', ['id' => (string) $user->id, 'expires' => '9999999999', 'signature' => 'invalid']);
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/register');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?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!'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Tests\Entity;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Tvdt\Entity\BankAnswer;
|
||||||
|
use Tvdt\Entity\BankQuestion;
|
||||||
|
use Tvdt\Entity\BankQuestionUsage;
|
||||||
|
use Tvdt\Entity\Quiz;
|
||||||
|
|
||||||
|
#[CoversClass(BankQuestion::class)]
|
||||||
|
final class BankQuestionTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testIsCompleteForQuizIsFalseWithoutTwoAnswers(): void
|
||||||
|
{
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
$bankQuestion->addAnswer(new BankAnswer('Only answer', true));
|
||||||
|
|
||||||
|
$this->assertFalse($bankQuestion->isCompleteForQuiz);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsCompleteForQuizIsFalseWithoutCorrectAnswer(): void
|
||||||
|
{
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
$bankQuestion->addAnswer(new BankAnswer('Wrong 1'));
|
||||||
|
$bankQuestion->addAnswer(new BankAnswer('Wrong 2'));
|
||||||
|
|
||||||
|
$this->assertFalse($bankQuestion->isCompleteForQuiz);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsCompleteForQuizIsFalseWithMultipleCorrectAnswers(): void
|
||||||
|
{
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
$bankQuestion->addAnswer(new BankAnswer('Right 1', true));
|
||||||
|
$bankQuestion->addAnswer(new BankAnswer('Right 2', true));
|
||||||
|
|
||||||
|
$this->assertFalse($bankQuestion->isCompleteForQuiz);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsCompleteForQuizIsTrueWithTwoAnswersAndOneCorrect(): void
|
||||||
|
{
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
$bankQuestion->addAnswer(new BankAnswer('Right', true));
|
||||||
|
$bankQuestion->addAnswer(new BankAnswer('Wrong'));
|
||||||
|
|
||||||
|
$this->assertTrue($bankQuestion->isCompleteForQuiz);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCanBeAssignedIsTrueWhenUnused(): void
|
||||||
|
{
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
|
||||||
|
$this->assertTrue($bankQuestion->canBeAssigned);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCanBeAssignedIsTrueWhenReusableEvenIfUsed(): void
|
||||||
|
{
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
$bankQuestion->reusable = true;
|
||||||
|
$bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, new Quiz()));
|
||||||
|
|
||||||
|
$this->assertTrue($bankQuestion->canBeAssigned);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCanBeAssignedIsFalseWhenSingleUseAndUsed(): void
|
||||||
|
{
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
$bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, new Quiz()));
|
||||||
|
|
||||||
|
$this->assertFalse($bankQuestion->canBeAssigned);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsUsedInQuizIsTrueForQuizWithUsage(): void
|
||||||
|
{
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
$quiz = new Quiz();
|
||||||
|
$bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, $quiz));
|
||||||
|
|
||||||
|
$this->assertTrue($bankQuestion->isUsedInQuiz($quiz));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsUsedInQuizIsFalseForDifferentQuiz(): void
|
||||||
|
{
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
$bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, new Quiz()));
|
||||||
|
|
||||||
|
$this->assertFalse($bankQuestion->isUsedInQuiz(new Quiz()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testToStringReturnsQuestionText(): void
|
||||||
|
{
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
$bankQuestion->question = 'Wie is de Krtek?';
|
||||||
|
|
||||||
|
$this->assertSame('Wie is de Krtek?', (string) $bankQuestion);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Tests\Entity;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\InputBag;
|
||||||
|
use Tvdt\Entity\Elimination;
|
||||||
|
use Tvdt\Entity\Quiz;
|
||||||
|
|
||||||
|
#[CoversClass(Elimination::class)]
|
||||||
|
final class EliminationTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testGetScreenColourReturnsNullForNullName(): void
|
||||||
|
{
|
||||||
|
$elimination = new Elimination(new Quiz());
|
||||||
|
|
||||||
|
$this->assertNull($elimination->getScreenColour(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetScreenColourReturnsNullForUnknownName(): void
|
||||||
|
{
|
||||||
|
$elimination = new Elimination(new Quiz());
|
||||||
|
$elimination->data = $this->colours(['Tom' => Elimination::SCREEN_GREEN]);
|
||||||
|
|
||||||
|
$this->assertNull($elimination->getScreenColour('Claudia'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetScreenColourReturnsColourForKnownName(): void
|
||||||
|
{
|
||||||
|
$elimination = new Elimination(new Quiz());
|
||||||
|
$elimination->data = $this->colours(['Tom' => Elimination::SCREEN_GREEN, 'Claudia' => Elimination::SCREEN_RED]);
|
||||||
|
|
||||||
|
$this->assertSame(Elimination::SCREEN_RED, $elimination->getScreenColour('Claudia'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateFromInputBagUpdatesKnownColours(): void
|
||||||
|
{
|
||||||
|
$elimination = new Elimination(new Quiz());
|
||||||
|
$elimination->data = $this->colours(['Tom' => Elimination::SCREEN_GREEN, 'Claudia' => Elimination::SCREEN_RED]);
|
||||||
|
|
||||||
|
$elimination->updateFromInputBag($this->inputBag(['colour-tom' => Elimination::SCREEN_RED]));
|
||||||
|
|
||||||
|
$this->assertSame(Elimination::SCREEN_RED, $elimination->data['Tom']);
|
||||||
|
$this->assertSame(Elimination::SCREEN_RED, $elimination->data['Claudia']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateFromInputBagIgnoresMissingInput(): void
|
||||||
|
{
|
||||||
|
$elimination = new Elimination(new Quiz());
|
||||||
|
$elimination->data = $this->colours(['Tom' => Elimination::SCREEN_GREEN]);
|
||||||
|
|
||||||
|
$elimination->updateFromInputBag($this->inputBag([]));
|
||||||
|
|
||||||
|
$this->assertSame(Elimination::SCREEN_GREEN, $elimination->data['Tom']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateFromInputBagReturnsSelf(): void
|
||||||
|
{
|
||||||
|
$elimination = new Elimination(new Quiz());
|
||||||
|
|
||||||
|
$this->assertSame($elimination, $elimination->updateFromInputBag($this->inputBag([])));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $colours
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function colours(array $colours): array
|
||||||
|
{
|
||||||
|
return $colours;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $parameters
|
||||||
|
*
|
||||||
|
* @return InputBag<bool|float|int|string>
|
||||||
|
*/
|
||||||
|
private function inputBag(array $parameters): InputBag
|
||||||
|
{
|
||||||
|
/** @var InputBag<bool|float|int|string> $inputBag */
|
||||||
|
$inputBag = new InputBag($parameters);
|
||||||
|
|
||||||
|
return $inputBag;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,28 +4,34 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tvdt\Tests\Helpers;
|
namespace Tvdt\Tests\Helpers;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Safe\Exceptions\UrlException;
|
use Safe\Exceptions\UrlException;
|
||||||
use Tvdt\Helpers\Base64;
|
use Tvdt\Helpers\Base64;
|
||||||
|
|
||||||
|
#[CoversClass(Base64::class)]
|
||||||
final class Base64Test extends TestCase
|
final class Base64Test extends TestCase
|
||||||
{
|
{
|
||||||
public function testBase64UrlEncode(): void
|
/** @return iterable<string, array{string, string}> */
|
||||||
|
public static function pairProvider(): iterable
|
||||||
{
|
{
|
||||||
$this->assertSame('TWFyaWpu', Base64::base64UrlEncode('Marijn'));
|
yield 'Marijn' => ['Marijn', 'TWFyaWpu'];
|
||||||
$this->assertSame('UGhpbGluZQ', Base64::base64UrlEncode('Philine'));
|
yield 'Philine' => ['Philine', 'UGhpbGluZQ'];
|
||||||
|
yield 'byte 254' => [\chr(254), '_g'];
|
||||||
$this->assertSame('_g', Base64::base64UrlEncode(\chr(254)));
|
yield 'byte 250' => [\chr(250), '-g'];
|
||||||
$this->assertSame('-g', Base64::base64UrlEncode(\chr(250)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testBase64UrlDecode(): void
|
#[DataProvider('pairProvider')]
|
||||||
|
public function testBase64UrlEncode(string $decoded, string $encoded): void
|
||||||
{
|
{
|
||||||
$this->assertSame('Marijn', Base64::base64UrlDecode('TWFyaWpu'));
|
$this->assertSame($encoded, Base64::base64UrlEncode($decoded));
|
||||||
$this->assertSame('Philine', Base64::base64UrlDecode('UGhpbGluZQ'));
|
}
|
||||||
|
|
||||||
$this->assertSame(\chr(254), Base64::base64UrlDecode('_g'));
|
#[DataProvider('pairProvider')]
|
||||||
$this->assertSame(\chr(250), Base64::base64UrlDecode('-g'));
|
public function testBase64UrlDecode(string $decoded, string $encoded): void
|
||||||
|
{
|
||||||
|
$this->assertSame($decoded, Base64::base64UrlDecode($encoded));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testBase64UrlDecodeCanHandlePadding(): void
|
public function testBase64UrlDecodeCanHandlePadding(): void
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,4 +53,22 @@ final class CandidateRepositoryTest extends DatabaseTestCase
|
|||||||
);
|
);
|
||||||
$this->assertNotInstanceOf(Candidate::class, $result);
|
$this->assertNotInstanceOf(Candidate::class, $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Candidate names are only unique per season, so a same-named candidate in another season must not leak in. */
|
||||||
|
public function testGetCandidateByHashScopesByCandidateSeasonNotJustName(): void
|
||||||
|
{
|
||||||
|
$krtekSeason = $this->getSeasonByCode('krtek');
|
||||||
|
$anotherSeason = $this->getSeasonByCode('bbbbb');
|
||||||
|
|
||||||
|
$duplicateNamedCandidate = new Candidate('Claudia');
|
||||||
|
$anotherSeason->addCandidate($duplicateNamedCandidate);
|
||||||
|
$this->entityManager->persist($duplicateNamedCandidate);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$candidate = $this->candidateRepository->getCandidateByHash($krtekSeason, 'Q2xhdWRpYQ');
|
||||||
|
|
||||||
|
$this->assertInstanceOf(Candidate::class, $candidate);
|
||||||
|
$this->assertSame($krtekSeason, $candidate->season);
|
||||||
|
$this->assertNotSame($duplicateNamedCandidate, $candidate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,25 +5,28 @@ declare(strict_types=1);
|
|||||||
namespace Tvdt\Tests\Repository;
|
namespace Tvdt\Tests\Repository;
|
||||||
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
use Tvdt\Entity\Season;
|
use Tvdt\Entity\Season;
|
||||||
use Tvdt\Repository\SeasonRepository;
|
use Tvdt\Repository\SeasonRepository;
|
||||||
|
|
||||||
#[CoversClass(SeasonRepository::class)]
|
#[CoversClass(SeasonRepository::class)]
|
||||||
final class SeasonRepositoryTest extends DatabaseTestCase
|
final class SeasonRepositoryTest extends DatabaseTestCase
|
||||||
{
|
{
|
||||||
public function testGetSeasonsForUser(): void
|
/** @return iterable<string, array{string, string}> */
|
||||||
|
public static function userSeasonsProvider(): iterable
|
||||||
{
|
{
|
||||||
$user = $this->getUserByEmail('krtek-admin@example.org');
|
yield 'krtek admin' => ['krtek-admin@example.org', 'krtek'];
|
||||||
|
yield 'user1' => ['user1@example.org', 'bbbbb'];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('userSeasonsProvider')]
|
||||||
|
public function testGetSeasonsForUser(string $email, string $expectedSeasonCode): void
|
||||||
|
{
|
||||||
|
$user = $this->getUserByEmail($email);
|
||||||
|
|
||||||
$seasons = $this->seasonRepository->getSeasonsForUser($user);
|
$seasons = $this->seasonRepository->getSeasonsForUser($user);
|
||||||
$this->assertCount(1, $seasons);
|
$this->assertCount(1, $seasons);
|
||||||
$this->assertSame('krtek', $seasons[0]->seasonCode);
|
$this->assertSame($expectedSeasonCode, $seasons[0]->seasonCode);
|
||||||
|
|
||||||
$user = $this->getUserByEmail('user1@example.org');
|
|
||||||
|
|
||||||
$seasons = $this->seasonRepository->getSeasonsForUser($user);
|
|
||||||
$this->assertCount(1, $seasons);
|
|
||||||
$this->assertSame('bbbbb', $seasons[0]->seasonCode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testUserWithMultipleSeasons(): void
|
public function testUserWithMultipleSeasons(): void
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ namespace Tvdt\Tests\Repository;
|
|||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
use Tvdt\DataFixtures\TestFixtures;
|
use Tvdt\DataFixtures\TestFixtures;
|
||||||
|
use Tvdt\Entity\BankQuestion;
|
||||||
|
use Tvdt\Entity\Elimination;
|
||||||
|
use Tvdt\Entity\GivenAnswer;
|
||||||
|
use Tvdt\Entity\Question;
|
||||||
|
use Tvdt\Entity\Quiz;
|
||||||
|
use Tvdt\Entity\QuizCandidate;
|
||||||
use Tvdt\Repository\UserRepository;
|
use Tvdt\Repository\UserRepository;
|
||||||
|
|
||||||
use function PHPUnit\Framework\assertEmpty;
|
use function PHPUnit\Framework\assertEmpty;
|
||||||
@@ -42,4 +48,89 @@ final class UserRepositoryTest extends DatabaseTestCase
|
|||||||
$this->expectException(\InvalidArgumentException::class);
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
$this->userRepository->makeAdmin('invalid@example.org');
|
$this->userRepository->makeAdmin('invalid@example.org');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GDPR right-to-erasure: deleting the sole owner of a season must physically remove every
|
||||||
|
* row tied to it, not merely soft-delete it. QuizCandidate, GivenAnswer, and Elimination are
|
||||||
|
* all Gedmo\SoftDeleteable, so a naive $em->remove($season) cascade leaves them (or the
|
||||||
|
* transaction itself) behind. Assertions bypass the softdeleteable filter and read raw SQL,
|
||||||
|
* since a soft-deleted row would otherwise still be invisible to a filtered ORM query.
|
||||||
|
*/
|
||||||
|
public function testDeleteUserHardDeletesQuizCandidateGivenAnswerAndElimination(): void
|
||||||
|
{
|
||||||
|
$user = $this->getUserByEmail('sole-owner@example.org');
|
||||||
|
$season = $this->getSeasonByCode('doomd');
|
||||||
|
|
||||||
|
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Doomed Quiz', 'season' => $season]);
|
||||||
|
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||||
|
$candidate = $this->getCandidateBySeasonAndName($season, 'Vera');
|
||||||
|
|
||||||
|
/** @var Question $question */
|
||||||
|
$question = $quiz->questions->first();
|
||||||
|
$rightAnswer = $question->answers->first();
|
||||||
|
$this->assertNotFalse($rightAnswer);
|
||||||
|
|
||||||
|
$this->quizCandidateRepository->createIfNotExist($quiz, $candidate);
|
||||||
|
$quizCandidate = $this->quizCandidateRepository->findOneBy(['quiz' => $quiz, 'candidate' => $candidate]);
|
||||||
|
$this->assertInstanceOf(QuizCandidate::class, $quizCandidate);
|
||||||
|
|
||||||
|
$givenAnswer = new GivenAnswer($candidate, $quiz, $rightAnswer);
|
||||||
|
$this->entityManager->persist($givenAnswer);
|
||||||
|
|
||||||
|
$elimination = new Elimination($quiz);
|
||||||
|
$elimination->data = ['Vera' => Elimination::SCREEN_GREEN];
|
||||||
|
|
||||||
|
$this->entityManager->persist($elimination);
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$quizCandidateId = $quizCandidate->id->toString();
|
||||||
|
$givenAnswerId = $givenAnswer->id->toString();
|
||||||
|
$eliminationId = $elimination->id->toString();
|
||||||
|
|
||||||
|
$this->userRepository->deleteUser($user);
|
||||||
|
$this->entityManager->clear();
|
||||||
|
|
||||||
|
$connection = $this->entityManager->getConnection();
|
||||||
|
$this->assertSame(0, (int) $connection->fetchOne('select count(*) from quiz_candidate where id = ?', [$quizCandidateId]));
|
||||||
|
$this->assertSame(0, (int) $connection->fetchOne('select count(*) from given_answer where id = ?', [$givenAnswerId]));
|
||||||
|
$this->assertSame(0, (int) $connection->fetchOne('select count(*) from elimination where id = ?', [$eliminationId]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gedmo\Loggable writes an audit row (including the editor's username/email) to
|
||||||
|
* ext_log_entries for every change to a Versioned field. Those rows aren't linked via a
|
||||||
|
* foreign key (object_id is a plain string), so deleting the season/BankQuestion never
|
||||||
|
* cleans them up on its own — the deleted account's email would otherwise live on forever.
|
||||||
|
*/
|
||||||
|
public function testDeleteUserPurgesBankQuestionAuditLogEntries(): void
|
||||||
|
{
|
||||||
|
$user = $this->getUserByEmail('sole-owner@example.org');
|
||||||
|
$season = $this->getSeasonByCode('doomd');
|
||||||
|
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
$bankQuestion->question = 'Wie is de Krtek eigenlijk?';
|
||||||
|
$bankQuestion->season = $season;
|
||||||
|
|
||||||
|
$this->entityManager->persist($bankQuestion);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$bankQuestionId = $bankQuestion->id->toString();
|
||||||
|
$connection = $this->entityManager->getConnection();
|
||||||
|
|
||||||
|
$logCountBefore = (int) $connection->fetchOne(
|
||||||
|
'select count(*) from ext_log_entries where object_class = ? and object_id = ?',
|
||||||
|
[BankQuestion::class, $bankQuestionId],
|
||||||
|
);
|
||||||
|
$this->assertGreaterThan(0, $logCountBefore);
|
||||||
|
|
||||||
|
$this->userRepository->deleteUser($user);
|
||||||
|
$this->entityManager->clear();
|
||||||
|
|
||||||
|
$logCountAfter = (int) $connection->fetchOne(
|
||||||
|
'select count(*) from ext_log_entries where object_class = ? and object_id = ?',
|
||||||
|
[BankQuestion::class, $bankQuestionId],
|
||||||
|
);
|
||||||
|
$this->assertSame(0, $logCountAfter);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Tests\Service;
|
||||||
|
|
||||||
|
use PhpOffice\PhpSpreadsheet\Reader;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use Tvdt\Entity\Answer;
|
||||||
|
use Tvdt\Entity\GivenAnswer;
|
||||||
|
use Tvdt\Entity\Question;
|
||||||
|
use Tvdt\Entity\Quiz;
|
||||||
|
use Tvdt\Entity\QuizCandidate;
|
||||||
|
use Tvdt\Entity\User;
|
||||||
|
use Tvdt\Service\DataExportService;
|
||||||
|
use Tvdt\Tests\Repository\DatabaseTestCase;
|
||||||
|
|
||||||
|
use function Safe\file_put_contents;
|
||||||
|
use function Safe\tempnam;
|
||||||
|
use function Safe\unlink;
|
||||||
|
|
||||||
|
#[CoversClass(DataExportService::class)]
|
||||||
|
final class DataExportServiceTest extends DatabaseTestCase
|
||||||
|
{
|
||||||
|
private DataExportService $subject;
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $tempFiles = [];
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->subject = self::getContainer()->get(DataExportService::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
foreach ($this->tempFiles as $path) {
|
||||||
|
if (file_exists($path)) {
|
||||||
|
unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExportForUserWithNoSeasonsContainsOnlyProfile(): void
|
||||||
|
{
|
||||||
|
$zip = $this->openZip($this->getUserByEmail('test@example.org'));
|
||||||
|
|
||||||
|
$this->assertSame(1, $zip->numFiles);
|
||||||
|
$this->assertNotFalse($zip->locateName('profile.xlsx'));
|
||||||
|
$zip->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExportForUserIncludesOwnedSeasonsQuizzesAndCandidates(): void
|
||||||
|
{
|
||||||
|
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
|
||||||
|
|
||||||
|
$names = $this->entryNames($zip);
|
||||||
|
|
||||||
|
$this->assertContains('profile.xlsx', $names);
|
||||||
|
$this->assertContains('krtek-Krtek-Weekend/Quiz-1.xlsx', $names);
|
||||||
|
$this->assertContains('krtek-Krtek-Weekend/Quiz-2.xlsx', $names);
|
||||||
|
$this->assertContains('krtek-Krtek-Weekend/candidates.xlsx', $names);
|
||||||
|
$this->assertContains('krtek-Krtek-Weekend/question-bank.xlsx', $names);
|
||||||
|
$this->assertContains('bbbbb-Another-Season/candidates.xlsx', $names);
|
||||||
|
$this->assertContains('bbbbb-Another-Season/question-bank.xlsx', $names);
|
||||||
|
|
||||||
|
// Another Season has no quizzes, so no quiz xlsx should be present for it.
|
||||||
|
foreach ($names as $name) {
|
||||||
|
$this->assertStringStartsNotWith('bbbbb-Another-Season/Quiz', $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
|
||||||
|
$this->assertIsString($quizContent);
|
||||||
|
$this->assertSame(['Quiz info', 'Questions', 'Raw answers', 'Results', 'Eliminations'], $this->sheetNames($quizContent));
|
||||||
|
|
||||||
|
$candidatesContent = $zip->getFromName('krtek-Krtek-Weekend/candidates.xlsx');
|
||||||
|
$this->assertIsString($candidatesContent);
|
||||||
|
$this->assertSame(['Candidates', 'Season info'], $this->sheetNames($candidatesContent));
|
||||||
|
|
||||||
|
$questionBankContent = $zip->getFromName('krtek-Krtek-Weekend/question-bank.xlsx');
|
||||||
|
$this->assertIsString($questionBankContent);
|
||||||
|
$this->assertSame(['Questions', 'Labels'], $this->sheetNames($questionBankContent));
|
||||||
|
|
||||||
|
$zip->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testQuestionBankSheetIncludesBankQuestionsAndUsage(): void
|
||||||
|
{
|
||||||
|
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
|
||||||
|
|
||||||
|
$questionBankContent = $zip->getFromName('krtek-Krtek-Weekend/question-bank.xlsx');
|
||||||
|
$this->assertIsString($questionBankContent);
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
$rows = $this->loadSheet($questionBankContent, 'Questions')->toArray();
|
||||||
|
$header = $rows[0];
|
||||||
|
$dataRows = \array_slice($rows, 1);
|
||||||
|
|
||||||
|
$questionIndex = array_search('Question', $header, true);
|
||||||
|
$reusableIndex = array_search('Reusable', $header, true);
|
||||||
|
$labelsIndex = array_search('Labels', $header, true);
|
||||||
|
$usedInQuizzesIndex = array_search('Used in quizzes', $header, true);
|
||||||
|
|
||||||
|
$reusableRow = current(array_filter($dataRows, static fn (array $row): bool => 'Wie is de Krtek?' === $row[$questionIndex]));
|
||||||
|
$this->assertIsArray($reusableRow);
|
||||||
|
$this->assertSame('Yes', $reusableRow[$reusableIndex]);
|
||||||
|
$this->assertSame('Finale', $reusableRow[$labelsIndex]);
|
||||||
|
|
||||||
|
$usedRow = current(array_filter($dataRows, static fn (array $row): bool => 'Waar sliep de Krtek?' === $row[$questionIndex]));
|
||||||
|
$this->assertIsArray($usedRow);
|
||||||
|
$this->assertSame('Quiz 2', $usedRow[$usedInQuizzesIndex]);
|
||||||
|
|
||||||
|
$labelRows = $this->loadSheet($questionBankContent, 'Labels')->toArray();
|
||||||
|
$labelNames = array_column(\array_slice($labelRows, 1), 0);
|
||||||
|
$this->assertContains('Locatie', $labelNames);
|
||||||
|
$this->assertContains('Finale', $labelNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testProfileSheetDoesNotContainPasswordHash(): void
|
||||||
|
{
|
||||||
|
$user = $this->getUserByEmail('user2@example.org');
|
||||||
|
$zip = $this->openZip($user);
|
||||||
|
|
||||||
|
$profileContent = $zip->getFromName('profile.xlsx');
|
||||||
|
$this->assertIsString($profileContent);
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
$rows = $this->loadSheet($profileContent, 'Account')->toArray();
|
||||||
|
$flattened = implode(' ', array_merge(...$rows));
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString($user->password, $flattened);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResultsSheetIncludesSoftDeletedQuizCandidates(): void
|
||||||
|
{
|
||||||
|
$season = $this->getSeasonByCode('krtek');
|
||||||
|
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
|
||||||
|
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||||
|
$candidate = $this->getCandidateBySeasonAndName($season, 'Claudia');
|
||||||
|
|
||||||
|
$quizCandidate = new QuizCandidate($quiz, $candidate);
|
||||||
|
$this->entityManager->persist($quizCandidate);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->entityManager->remove($quizCandidate);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
$this->entityManager->clear();
|
||||||
|
|
||||||
|
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
|
||||||
|
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
|
||||||
|
$this->assertIsString($quizContent);
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
$rows = $this->loadSheet($quizContent, 'Results')->toArray();
|
||||||
|
$deletedColumnIndex = array_search('Deleted', $rows[0], true);
|
||||||
|
$this->assertIsInt($deletedColumnIndex);
|
||||||
|
$hasDeletedRow = array_any(\array_slice($rows, 1), static fn (array $row): bool => null !== $row[$deletedColumnIndex] && '' !== $row[$deletedColumnIndex]);
|
||||||
|
|
||||||
|
$this->assertTrue($hasDeletedRow, 'Expected the soft-deleted QuizCandidate to still appear with a Deleted timestamp');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRawAnswersSheetShowsCandidatesByQuestionsGrid(): void
|
||||||
|
{
|
||||||
|
$season = $this->getSeasonByCode('krtek');
|
||||||
|
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
|
||||||
|
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||||
|
$candidate = $this->getCandidateBySeasonAndName($season, 'Claudia');
|
||||||
|
|
||||||
|
/** @var Question $firstQuestion */
|
||||||
|
$firstQuestion = $quiz->questions->first();
|
||||||
|
$chosenAnswer = $firstQuestion->answers->filter(static fn (Answer $answer): bool => 'Man' === $answer->text)->first();
|
||||||
|
$this->assertInstanceOf(Answer::class, $chosenAnswer);
|
||||||
|
|
||||||
|
$this->quizCandidateRepository->createIfNotExist($quiz, $candidate);
|
||||||
|
|
||||||
|
$givenAnswer = new GivenAnswer($candidate, $quiz, $chosenAnswer);
|
||||||
|
$this->entityManager->persist($givenAnswer);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
|
||||||
|
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
|
||||||
|
$this->assertIsString($quizContent);
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
$rows = $this->loadSheet($quizContent, 'Raw answers')->toArray();
|
||||||
|
$header = $rows[0];
|
||||||
|
$this->assertSame('Candidate', $header[0]);
|
||||||
|
|
||||||
|
$questionColumnIndex = array_search($firstQuestion->question, $header, true);
|
||||||
|
$this->assertIsInt($questionColumnIndex);
|
||||||
|
|
||||||
|
$claudiaRow = current(array_filter(
|
||||||
|
\array_slice($rows, 1),
|
||||||
|
static fn (array $row): bool => 'Claudia' === $row[0],
|
||||||
|
));
|
||||||
|
$this->assertIsArray($claudiaRow);
|
||||||
|
$this->assertSame('Man', $claudiaRow[$questionColumnIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testQuizInfoSheetShowsDropoutsFinalizationAndDisabledQuestions(): void
|
||||||
|
{
|
||||||
|
$season = $this->getSeasonByCode('krtek');
|
||||||
|
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
|
||||||
|
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||||
|
$this->assertTrue($quiz->isFinalized);
|
||||||
|
|
||||||
|
/** @var Question $disabledQuestion */
|
||||||
|
$disabledQuestion = $quiz->questions->first();
|
||||||
|
$disabledQuestion->enabled = false;
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
|
||||||
|
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
|
||||||
|
$this->assertIsString($quizContent);
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
$rows = $this->loadSheet($quizContent, 'Quiz info')->toArray();
|
||||||
|
$values = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$values[$row[0]] = $row[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame('Quiz 1', $values['Quiz name']);
|
||||||
|
$this->assertSame($quiz->dropouts, (int) $values['Number of dropouts']);
|
||||||
|
$this->assertSame('Yes', $values['Finalized']);
|
||||||
|
$this->assertNotEmpty($values['Finalized at']);
|
||||||
|
$this->assertStringContainsString($disabledQuestion->question, (string) $values['Disabled questions']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function openZip(User $user): \ZipArchive
|
||||||
|
{
|
||||||
|
$zipPath = $this->subject->exportForUser($user);
|
||||||
|
$this->tempFiles[] = $zipPath;
|
||||||
|
|
||||||
|
$zip = new \ZipArchive();
|
||||||
|
$this->assertTrue($zip->open($zipPath));
|
||||||
|
|
||||||
|
return $zip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
private function entryNames(\ZipArchive $zip): array
|
||||||
|
{
|
||||||
|
$names = [];
|
||||||
|
for ($i = 0; $i < $zip->numFiles; ++$i) {
|
||||||
|
$name = $zip->getNameIndex($i);
|
||||||
|
$this->assertIsString($name);
|
||||||
|
$names[] = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
private function sheetNames(string $xlsxContent): array
|
||||||
|
{
|
||||||
|
$path = $this->createTempPath();
|
||||||
|
file_put_contents($path, $xlsxContent);
|
||||||
|
|
||||||
|
return array_values(new Reader\Xlsx()->load($path)->getSheetNames());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadSheet(string $xlsxContent, string $sheetName): Worksheet
|
||||||
|
{
|
||||||
|
$path = $this->createTempPath();
|
||||||
|
file_put_contents($path, $xlsxContent);
|
||||||
|
|
||||||
|
$sheet = new Reader\Xlsx()->load($path)->getSheetByName($sheetName);
|
||||||
|
$this->assertInstanceOf(Worksheet::class, $sheet);
|
||||||
|
|
||||||
|
return $sheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTempPath(): string
|
||||||
|
{
|
||||||
|
$path = tempnam(sys_get_temp_dir(), 'tvdt_export_test_');
|
||||||
|
$this->tempFiles[] = $path;
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
|
||||||
|
<file source-language="nl" target-language="nl" datatype="plaintext" original="file.ext">
|
||||||
|
<header>
|
||||||
|
<tool tool-id="symfony" tool-name="Symfony"/>
|
||||||
|
</header>
|
||||||
|
<body>
|
||||||
|
<trans-unit id="E69bPZ6" resname="%count% day|%count% days">
|
||||||
|
<source>%count% day|%count% days</source>
|
||||||
|
<target>%count% dag|%count% dagen</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="H3jnVKg" resname="%count% hour|%count% hours">
|
||||||
|
<source>%count% hour|%count% hours</source>
|
||||||
|
<target>%count% uur|%count% uren</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="30tji6_" resname="%count% minute|%count% minutes">
|
||||||
|
<source>%count% minute|%count% minutes</source>
|
||||||
|
<target>%count% minuut|%count% minuten</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="NCe6niW" resname="%count% month|%count% months">
|
||||||
|
<source>%count% month|%count% months</source>
|
||||||
|
<target>%count% maand|%count% maanden</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="U_NoHhq" resname="%count% year|%count% years">
|
||||||
|
<source>%count% year|%count% years</source>
|
||||||
|
<target>%count% jaar|%count% jaar</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="NUQGnNx" resname="Please update the request_password_repository configuration in config/packages/reset_password.yaml to point to your "request password repository" service.">
|
||||||
|
<source>Please update the request_password_repository configuration in config/packages/reset_password.yaml to point to your "request password repository" service.</source>
|
||||||
|
<target>Update de request_password_repository waarde in config/packages/reset_password.yaml met een verwijzing naar je "request password repository" service.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="XyawDv9" resname="The link in your email is expired. Please try to reset your password again.">
|
||||||
|
<source>The link in your email is expired. Please try to reset your password again.</source>
|
||||||
|
<target>De link in je e-mail is verlopen. Probeer opnieuw je wachtwoord te herstellen.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="RPp.BKQ" resname="The reset password link is invalid. Please try to reset your password again.">
|
||||||
|
<source>The reset password link is invalid. Please try to reset your password again.</source>
|
||||||
|
<target>De link om je wachtwoord te herstellen is niet geldig. Probeer opnieuw je wachtwoord te herstellen.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id=".BeFwl." resname="There was a problem handling your password reset request">
|
||||||
|
<source>There was a problem handling your password reset request</source>
|
||||||
|
<target>Er is een probleem opgetreden bij het afhandelen van het verzoek om je wachtwoord te herstellen</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="ZeI3pSX" resname="There was a problem validating your password reset request">
|
||||||
|
<source>There was a problem validating your password reset request</source>
|
||||||
|
<target>Er is een probleem opgetreden bij het valideren van het verzoek om je wachtwoord te herstellen</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="IZsa0iw" resname="You have already requested a reset password email. Please check your email or try again soon.">
|
||||||
|
<source>You have already requested a reset password email. Please check your email or try again soon.</source>
|
||||||
|
<target>Je hebt al een verzoek ingediend voor een e-mail om je wachtwoord te herstellen. Controleer of je een e-mail hebt ontvangen of probeer het later nog eens.</target>
|
||||||
|
</trans-unit>
|
||||||
|
</body>
|
||||||
|
</file>
|
||||||
|
</xliff>
|
||||||
@@ -5,10 +5,18 @@
|
|||||||
<tool tool-id="symfony" tool-name="Symfony"/>
|
<tool tool-id="symfony" tool-name="Symfony"/>
|
||||||
</header>
|
</header>
|
||||||
<body>
|
<body>
|
||||||
|
<trans-unit id="QcFeGZy" resname="A candidate with this name already exists in this season">
|
||||||
|
<source>A candidate with this name already exists in this season</source>
|
||||||
|
<target>Er bestaat al een kandidaat met deze naam in dit seizoen</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="VNxXghX" resname="A label with a similar name already exists">
|
<trans-unit id="VNxXghX" resname="A label with a similar name already exists">
|
||||||
<source>A label with a similar name already exists</source>
|
<source>A label with a similar name already exists</source>
|
||||||
<target>Er bestaat al een label met deze naam</target>
|
<target>Er bestaat al een label met deze naam</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Yu2_QSh" resname="A new confirmation email has been sent. Please check your inbox.">
|
||||||
|
<source>A new confirmation email has been sent. Please check your inbox.</source>
|
||||||
|
<target>Er is een nieuwe bevestigingsmail verstuurd. Check je inbox.</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="spyU5K3" resname="A quiz with this name already exists in this season">
|
<trans-unit id="spyU5K3" resname="A quiz with this name already exists in this season">
|
||||||
<source>A quiz with this name already exists in this season</source>
|
<source>A quiz with this name already exists in this season</source>
|
||||||
<target>Er bestaat al een test met deze naam in dit seizoen</target>
|
<target>Er bestaat al een test met deze naam in dit seizoen</target>
|
||||||
@@ -73,6 +81,10 @@
|
|||||||
<source>Add question</source>
|
<source>Add question</source>
|
||||||
<target>Vraag toevoegen</target>
|
<target>Vraag toevoegen</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="nLlOrcy" resname="After changing your email address you will receive a new confirmation email.">
|
||||||
|
<source>After changing your email address you will receive a new confirmation email.</source>
|
||||||
|
<target>Na het wijzigen van je e-mailadres ontvang je een nieuwe bevestigingsmail.</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="tHdA52O" resname="All">
|
<trans-unit id="tHdA52O" resname="All">
|
||||||
<source>All</source>
|
<source>All</source>
|
||||||
<target>Alle</target>
|
<target>Alle</target>
|
||||||
@@ -93,6 +105,10 @@
|
|||||||
<source>Are you sure you want to clear all the results? This will also delete all the eliminations.</source>
|
<source>Are you sure you want to clear all the results? This will also delete all the eliminations.</source>
|
||||||
<target>Weet je zeker dat je de resultaten wilt leegmaken? Dit gooit ook alle eliminaties weg.</target>
|
<target>Weet je zeker dat je de resultaten wilt leegmaken? Dit gooit ook alle eliminaties weg.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="zisWo9d" resname="Are you sure you want to delete this candidate? All their answers will be lost.">
|
||||||
|
<source>Are you sure you want to delete this candidate? All their answers will be lost.</source>
|
||||||
|
<target>Weet je zeker dat je deze kandidaat wilt verwijderen? Alle gegeven antwoorden gaan dan verloren.</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="8HZ5s3T" resname="Are you sure you want to delete this question from the question bank?">
|
<trans-unit id="8HZ5s3T" resname="Are you sure you want to delete this question from the question bank?">
|
||||||
<source>Are you sure you want to delete this question from the question bank?</source>
|
<source>Are you sure you want to delete this question from the question bank?</source>
|
||||||
<target>Weet je zeker dat je deze vraag uit de vragenbank wilt verwijderen?</target>
|
<target>Weet je zeker dat je deze vraag uit de vragenbank wilt verwijderen?</target>
|
||||||
@@ -109,6 +125,10 @@
|
|||||||
<source>Back</source>
|
<source>Back</source>
|
||||||
<target>Terug</target>
|
<target>Terug</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="mR7n673" resname="Back to login">
|
||||||
|
<source>Back to login</source>
|
||||||
|
<target>Terug naar inloggen</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="JUdglpF" resname="Backoffice">
|
<trans-unit id="JUdglpF" resname="Backoffice">
|
||||||
<source>Backoffice</source>
|
<source>Backoffice</source>
|
||||||
<target>Backoffice</target>
|
<target>Backoffice</target>
|
||||||
@@ -133,10 +153,18 @@
|
|||||||
<source>Candidate answers saved</source>
|
<source>Candidate answers saved</source>
|
||||||
<target>Kandidaatantwoorden opgeslagen</target>
|
<target>Kandidaatantwoorden opgeslagen</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="tggdgJl" resname="Candidate deleted">
|
||||||
|
<source>Candidate deleted</source>
|
||||||
|
<target>Kandidaat verwijderd</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="TiTLBGW" resname="Candidate not found">
|
<trans-unit id="TiTLBGW" resname="Candidate not found">
|
||||||
<source>Candidate not found</source>
|
<source>Candidate not found</source>
|
||||||
<target>Kandidaat niet gevonden</target>
|
<target>Kandidaat niet gevonden</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="QH4e_Ho" resname="Candidate renamed">
|
||||||
|
<source>Candidate renamed</source>
|
||||||
|
<target>Kandidaat hernoemd</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="6QiGbuz" resname="Candidate status updated">
|
<trans-unit id="6QiGbuz" resname="Candidate status updated">
|
||||||
<source>Candidate status updated</source>
|
<source>Candidate status updated</source>
|
||||||
<target>Kandidaatstatus bijgewerkt</target>
|
<target>Kandidaatstatus bijgewerkt</target>
|
||||||
@@ -145,6 +173,18 @@
|
|||||||
<source>Candidates</source>
|
<source>Candidates</source>
|
||||||
<target>Kandidaten</target>
|
<target>Kandidaten</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="BqCoDf2" resname="Change email">
|
||||||
|
<source>Change email</source>
|
||||||
|
<target>E-mailadres wijzigen</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="EbzUhXX" resname="Change password">
|
||||||
|
<source>Change password</source>
|
||||||
|
<target>Wachtwoord wijzigen</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="o6WwCao" resname="Check your email">
|
||||||
|
<source>Check your email</source>
|
||||||
|
<target>Controleer je e-mail</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="J1c2y63" resname="Clear Quiz...">
|
<trans-unit id="J1c2y63" resname="Clear Quiz...">
|
||||||
<source>Clear Quiz...</source>
|
<source>Clear Quiz...</source>
|
||||||
<target>Test leegmaken...</target>
|
<target>Test leegmaken...</target>
|
||||||
@@ -165,6 +205,18 @@
|
|||||||
<source>Confirm Answers</source>
|
<source>Confirm Answers</source>
|
||||||
<target>Bevestig antwoorden</target>
|
<target>Bevestig antwoorden</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="T1Z6nI1" resname="Confirm your email address to enable this feature.">
|
||||||
|
<source>Confirm your email address to enable this feature.</source>
|
||||||
|
<target>Bevestig je e-mailadres om deze functie te kunnen gebruiken.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="QZfvKMx" resname="Confirm your email address to enable this.">
|
||||||
|
<source>Confirm your email address to enable this.</source>
|
||||||
|
<target>Bevestig je e-mailadres om dit te gebruiken.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="PiAVEe9" resname="Confirmed">
|
||||||
|
<source>Confirmed</source>
|
||||||
|
<target>Bevestigd</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="sFpB4C2" resname="Correct Answers">
|
<trans-unit id="sFpB4C2" resname="Correct Answers">
|
||||||
<source>Correct Answers</source>
|
<source>Correct Answers</source>
|
||||||
<target>Goede antwoorden</target>
|
<target>Goede antwoorden</target>
|
||||||
@@ -193,10 +245,26 @@
|
|||||||
<source>Create an empty quiz and add questions from the question bank.</source>
|
<source>Create an empty quiz and add questions from the question bank.</source>
|
||||||
<target>Maak een lege quiz aan en voeg vragen toe vanuit de vragenbank.</target>
|
<target>Maak een lege quiz aan en voeg vragen toe vanuit de vragenbank.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="jrXhJOR" resname="Current code:">
|
||||||
|
<source>Current code:</source>
|
||||||
|
<target>Huidige code:</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="3leUyoA" resname="Current email address:">
|
||||||
|
<source>Current email address:</source>
|
||||||
|
<target>Huidig e-mailadres:</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="ukaFrcB" resname="Current password">
|
||||||
|
<source>Current password</source>
|
||||||
|
<target>Huidig wachtwoord</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="PkrbQOH" resname="Cyan">
|
<trans-unit id="PkrbQOH" resname="Cyan">
|
||||||
<source>Cyan</source>
|
<source>Cyan</source>
|
||||||
<target>Cyaan</target>
|
<target>Cyaan</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="dG6EuYH" resname="Danger zone">
|
||||||
|
<source>Danger zone</source>
|
||||||
|
<target>Gevarenzone</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="S5P7nQd" resname="Deactivate">
|
<trans-unit id="S5P7nQd" resname="Deactivate">
|
||||||
<source>Deactivate</source>
|
<source>Deactivate</source>
|
||||||
<target>Deactiveren</target>
|
<target>Deactiveren</target>
|
||||||
@@ -217,10 +285,30 @@
|
|||||||
<source>Delete Quiz...</source>
|
<source>Delete Quiz...</source>
|
||||||
<target>Test verwijderen...</target>
|
<target>Test verwijderen...</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="rZiKnxa" resname="Delete account">
|
||||||
|
<source>Delete account</source>
|
||||||
|
<target>Account verwijderen</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="S8jZ6w1" resname="Delete account...">
|
||||||
|
<source>Delete account...</source>
|
||||||
|
<target>Account verwijderen...</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="bw.C4wH" resname="Deleting your account also deletes every season you are the only owner of. This cannot be undone.">
|
||||||
|
<source>Deleting your account also deletes every season you are the only owner of. This cannot be undone.</source>
|
||||||
|
<target>Als je je account verwijdert, worden ook alle seizoenen verwijderd waarvan jij de enige eigenaar bent. Dit kan niet ongedaan worden gemaakt.</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="R9yHzHv" resname="Download Template">
|
<trans-unit id="R9yHzHv" resname="Download Template">
|
||||||
<source>Download Template</source>
|
<source>Download Template</source>
|
||||||
<target>Download sjabloon</target>
|
<target>Download sjabloon</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="43g0Dc8" resname="Download an archive of everything stored under your account: your profile, the seasons you own, their quizzes, results and candidates.">
|
||||||
|
<source>Download an archive of everything stored under your account: your profile, the seasons you own, their quizzes, results and candidates.</source>
|
||||||
|
<target>Download een archief met alles wat onder je account is opgeslagen: je profiel, de seizoenen die je bezit, met de bijbehorende testen, resultaten en kandidaten.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="58e2QWG" resname="Download data">
|
||||||
|
<source>Download data</source>
|
||||||
|
<target>Gegevens downloaden</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="dwUtS3b" resname="Draft">
|
<trans-unit id="dwUtS3b" resname="Draft">
|
||||||
<source>Draft</source>
|
<source>Draft</source>
|
||||||
<target>Concept</target>
|
<target>Concept</target>
|
||||||
@@ -249,6 +337,10 @@
|
|||||||
<source>Enter name</source>
|
<source>Enter name</source>
|
||||||
<target>Voer een naam in</target>
|
<target>Voer een naam in</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="ifotYH6" resname="Enter your email address, and we will send you a link to reset your password.">
|
||||||
|
<source>Enter your email address, and we will send you a link to reset your password.</source>
|
||||||
|
<target>Voer je e-mailadres in en we sturen je een link om je wachtwoord te herstellen.</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="RnI7jJT" resname="Enter your name">
|
<trans-unit id="RnI7jJT" resname="Enter your name">
|
||||||
<source>Enter your name</source>
|
<source>Enter your name</source>
|
||||||
<target>Voer je naam in</target>
|
<target>Voer je naam in</target>
|
||||||
@@ -273,6 +365,10 @@
|
|||||||
<source>Finalized</source>
|
<source>Finalized</source>
|
||||||
<target>Afgerond</target>
|
<target>Afgerond</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Fztxgjr" resname="Forgot your password?">
|
||||||
|
<source>Forgot your password?</source>
|
||||||
|
<target>Wachtwoord vergeten?</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="MebBrmp" resname="Gray">
|
<trans-unit id="MebBrmp" resname="Gray">
|
||||||
<source>Gray</source>
|
<source>Gray</source>
|
||||||
<target>Grijs</target>
|
<target>Grijs</target>
|
||||||
@@ -301,6 +397,14 @@
|
|||||||
<source>Home</source>
|
<source>Home</source>
|
||||||
<target>Home</target>
|
<target>Home</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="NJn0f4_" resname="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.">
|
||||||
|
<source>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.</source>
|
||||||
|
<target>Als er een account met dit e-mailadres bestaat, is er zojuist een e-mail verstuurd met een link om je wachtwoord te herstellen.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="bDPZm0a" resname="If you don't receive an email please check your spam folder or">
|
||||||
|
<source>If you don't receive an email please check your spam folder or</source>
|
||||||
|
<target>Als je geen e-mail ontvangt, controleer dan je spammap of</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="oPzjCWg" resname="Import">
|
<trans-unit id="oPzjCWg" resname="Import">
|
||||||
<source>Import</source>
|
<source>Import</source>
|
||||||
<target>Importeren</target>
|
<target>Importeren</target>
|
||||||
@@ -337,6 +441,14 @@
|
|||||||
<source>Labels</source>
|
<source>Labels</source>
|
||||||
<target>Labels</target>
|
<target>Labels</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="5q6OTdQ" resname="Language">
|
||||||
|
<source>Language</source>
|
||||||
|
<target>Taal</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="dzQVFhY" resname="Language saved">
|
||||||
|
<source>Language saved</source>
|
||||||
|
<target>Taal opgeslagen</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="q0FeoCr" resname="Load Prepared Elimination">
|
<trans-unit id="q0FeoCr" resname="Load Prepared Elimination">
|
||||||
<source>Load Prepared Elimination</source>
|
<source>Load Prepared Elimination</source>
|
||||||
<target>Laad voorbereide eliminatie</target>
|
<target>Laad voorbereide eliminatie</target>
|
||||||
@@ -369,10 +481,18 @@
|
|||||||
<source>Name</source>
|
<source>Name</source>
|
||||||
<target>Naam</target>
|
<target>Naam</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="uWfLt3x" resname="New email address">
|
||||||
|
<source>New email address</source>
|
||||||
|
<target>Nieuw e-mailadres</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="lqTjJ4a" resname="New label">
|
<trans-unit id="lqTjJ4a" resname="New label">
|
||||||
<source>New label</source>
|
<source>New label</source>
|
||||||
<target>Nieuw label</target>
|
<target>Nieuw label</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Zm.jZiK" resname="New password">
|
||||||
|
<source>New password</source>
|
||||||
|
<target>Nieuw wachtwoord</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="gefhnBC" resname="Next">
|
<trans-unit id="gefhnBC" resname="Next">
|
||||||
<source>Next</source>
|
<source>Next</source>
|
||||||
<target>Volgende</target>
|
<target>Volgende</target>
|
||||||
@@ -409,6 +529,10 @@
|
|||||||
<source>Not Started</source>
|
<source>Not Started</source>
|
||||||
<target>Niet gestart</target>
|
<target>Niet gestart</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="zGRLjYz" resname="Not confirmed">
|
||||||
|
<source>Not confirmed</source>
|
||||||
|
<target>Niet bevestigd</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="k7Eqnjt" resname="Number of dropouts:">
|
<trans-unit id="k7Eqnjt" resname="Number of dropouts:">
|
||||||
<source>Number of dropouts:</source>
|
<source>Number of dropouts:</source>
|
||||||
<target>Aantal afvallers:</target>
|
<target>Aantal afvallers:</target>
|
||||||
@@ -445,6 +569,18 @@
|
|||||||
<source>Please Confirm your Email</source>
|
<source>Please Confirm your Email</source>
|
||||||
<target>Bevestig je e-mailadres alsjeblieft</target>
|
<target>Bevestig je e-mailadres alsjeblieft</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="7osID7L" resname="Please confirm your email address before downloading your data.">
|
||||||
|
<source>Please confirm your email address before downloading your data.</source>
|
||||||
|
<target>Bevestig eerst je e-mailadres voordat je je gegevens downloadt.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="XS4opCD" resname="Please confirm your email address before exporting a quiz.">
|
||||||
|
<source>Please confirm your email address before exporting a quiz.</source>
|
||||||
|
<target>Bevestig je e-mailadres voordat je een quiz exporteert.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="y4TgBCP" resname="Please confirm your email address before exporting data.">
|
||||||
|
<source>Please confirm your email address before exporting data.</source>
|
||||||
|
<target>Bevestig eerst je e-mailadres voordat je gegevens exporteert.</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="mq1QYAv" resname="Please select an answer">
|
<trans-unit id="mq1QYAv" resname="Please select an answer">
|
||||||
<source>Please select an answer</source>
|
<source>Please select an answer</source>
|
||||||
<target>Selecteer een antwoorden alsjeblieft</target>
|
<target>Selecteer een antwoorden alsjeblieft</target>
|
||||||
@@ -569,6 +705,10 @@
|
|||||||
<source>Re-opens the quiz for editing. Candidates will no longer be able to take the quiz until it is finalized again.</source>
|
<source>Re-opens the quiz for editing. Candidates will no longer be able to take the quiz until it is finalized again.</source>
|
||||||
<target>Heropent de test voor bewerking. Deelnemers kunnen de test niet meer afnemen totdat deze opnieuw is afgerond.</target>
|
<target>Heropent de test voor bewerking. Deelnemers kunnen de test niet meer afnemen totdat deze opnieuw is afgerond.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="MuwD4xO" resname="Ready">
|
||||||
|
<source>Ready</source>
|
||||||
|
<target>Voorbereid</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="P1HcfAu" resname="Red">
|
<trans-unit id="P1HcfAu" resname="Red">
|
||||||
<source>Red</source>
|
<source>Red</source>
|
||||||
<target>Rood</target>
|
<target>Rood</target>
|
||||||
@@ -577,6 +717,14 @@
|
|||||||
<source>Refresh the page to try again.</source>
|
<source>Refresh the page to try again.</source>
|
||||||
<target>Ververs de pagina om het opnieuw te proberen.</target>
|
<target>Ververs de pagina om het opnieuw te proberen.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="EGW_4W_" resname="Regenerate season code">
|
||||||
|
<source>Regenerate season code</source>
|
||||||
|
<target>Seizoenscode opnieuw genereren</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="EWuNNNu" resname="Regenerate season code...">
|
||||||
|
<source>Regenerate season code...</source>
|
||||||
|
<target>Seizoenscode opnieuw genereren...</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="fGfBzt6" resname="Register">
|
<trans-unit id="fGfBzt6" resname="Register">
|
||||||
<source>Register</source>
|
<source>Register</source>
|
||||||
<target>Registreren</target>
|
<target>Registreren</target>
|
||||||
@@ -589,10 +737,30 @@
|
|||||||
<source>Remove label</source>
|
<source>Remove label</source>
|
||||||
<target>Label verwijderen</target>
|
<target>Label verwijderen</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="WHywg0z" resname="Rename">
|
||||||
|
<source>Rename</source>
|
||||||
|
<target>Hernoemen</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="K2RP6H8" resname="Rename candidate">
|
||||||
|
<source>Rename candidate</source>
|
||||||
|
<target>Kandidaat hernoemen</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="Z9CSKpk" resname="Repeat Password">
|
<trans-unit id="Z9CSKpk" resname="Repeat Password">
|
||||||
<source>Repeat Password</source>
|
<source>Repeat Password</source>
|
||||||
<target>Herhaal wachtwoord</target>
|
<target>Herhaal wachtwoord</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="UJ54wLL" resname="Resend confirmation email">
|
||||||
|
<source>Resend confirmation email</source>
|
||||||
|
<target>Bevestigingsmail opnieuw versturen</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="9JCvQkt" resname="Reset password">
|
||||||
|
<source>Reset password</source>
|
||||||
|
<target>Wachtwoord herstellen</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="eyayNGN" resname="Reset your password">
|
||||||
|
<source>Reset your password</source>
|
||||||
|
<target>Wachtwoord herstellen</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="7UvBPrb" resname="Results & Elimination">
|
<trans-unit id="7UvBPrb" resname="Results & Elimination">
|
||||||
<source>Results & Elimination</source>
|
<source>Results & Elimination</source>
|
||||||
<target><![CDATA[Resultaat & Eliminatie]]></target>
|
<target><![CDATA[Resultaat & Eliminatie]]></target>
|
||||||
@@ -625,10 +793,22 @@
|
|||||||
<source>Season Name</source>
|
<source>Season Name</source>
|
||||||
<target>Seizoennaam</target>
|
<target>Seizoennaam</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="IxKDF4a" resname="Season code">
|
||||||
|
<source>Season code</source>
|
||||||
|
<target>Seizoenscode</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="iigT2eM" resname="Season code regenerated">
|
||||||
|
<source>Season code regenerated</source>
|
||||||
|
<target>Seizoenscode opnieuw gegenereerd</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="kc_J96C" resname="Seasons">
|
<trans-unit id="kc_J96C" resname="Seasons">
|
||||||
<source>Seasons</source>
|
<source>Seasons</source>
|
||||||
<target>Seizoenen</target>
|
<target>Seizoenen</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="5XiCCZa" resname="Send password reset email">
|
||||||
|
<source>Send password reset email</source>
|
||||||
|
<target>Stuur herstel-e-mail</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="VXFwlwn" resname="Settings">
|
<trans-unit id="VXFwlwn" resname="Settings">
|
||||||
<source>Settings</source>
|
<source>Settings</source>
|
||||||
<target>Instellingen</target>
|
<target>Instellingen</target>
|
||||||
@@ -641,6 +821,10 @@
|
|||||||
<source>Sign in</source>
|
<source>Sign in</source>
|
||||||
<target>Log in</target>
|
<target>Log in</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id=".9GO03z" resname="Soon™">
|
||||||
|
<source>Soon™</source>
|
||||||
|
<target>Soon™</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="A0aGG7W" resname="Sort A–Z">
|
<trans-unit id="A0aGG7W" resname="Sort A–Z">
|
||||||
<source>Sort A–Z</source>
|
<source>Sort A–Z</source>
|
||||||
<target>Sorteer A-Z</target>
|
<target>Sorteer A-Z</target>
|
||||||
@@ -657,6 +841,18 @@
|
|||||||
<source>Sync latest changes to this quiz</source>
|
<source>Sync latest changes to this quiz</source>
|
||||||
<target>Laatste wijzigingen synchroniseren naar deze quiz</target>
|
<target>Laatste wijzigingen synchroniseren naar deze quiz</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="cug5d45" resname="The candidate name must be between 1 and 16 characters">
|
||||||
|
<source>The candidate name must be between 1 and 16 characters</source>
|
||||||
|
<target>De naam van de kandidaat moet tussen de 1 en 16 tekens zijn</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="XwQ1Fav" resname="The confirmation email could not be sent. Please try again later.">
|
||||||
|
<source>The confirmation email could not be sent. Please try again later.</source>
|
||||||
|
<target>De bevestigingsmail kon niet worden verzonden. Probeer het later opnieuw.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="mMDwcxj" resname="The confirmation email could not be sent. Please use the resend button to try again.">
|
||||||
|
<source>The confirmation email could not be sent. Please use the resend button to try again.</source>
|
||||||
|
<target>De bevestigingsmail kon niet worden verzonden. Gebruik de knop om het opnieuw te proberen.</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="_z4el3Z" resname="The password fields must match.">
|
<trans-unit id="_z4el3Z" resname="The password fields must match.">
|
||||||
<source>The password fields must match.</source>
|
<source>The password fields must match.</source>
|
||||||
<target>De wachtwoorden moeten overeen komen.</target>
|
<target>De wachtwoorden moeten overeen komen.</target>
|
||||||
@@ -681,14 +877,34 @@
|
|||||||
<source>The quiz must be finalized before it can be activated</source>
|
<source>The quiz must be finalized before it can be activated</source>
|
||||||
<target>De test moet afgerond zijn voordat deze geactiveerd kan worden</target>
|
<target>De test moet afgerond zijn voordat deze geactiveerd kan worden</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="IKPt_Pf" resname="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.">
|
||||||
|
<source>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.</source>
|
||||||
|
<target>De seizoenscode wordt door kandidaten gebruikt om dit seizoen te vinden. Als je een nieuwe code genereert, werkt de oude niet meer, dus deel de nieuwe code opnieuw.</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="HuzRgeN" resname="There are no answers for this question">
|
<trans-unit id="HuzRgeN" resname="There are no answers for this question">
|
||||||
<source>There are no answers for this question</source>
|
<source>There are no answers for this question</source>
|
||||||
<target>Er zijn geen antwoorden voor deze vraag</target>
|
<target>Er zijn geen antwoorden voor deze vraag</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="vsM4tSv" resname="There is already an account with this email">
|
||||||
|
<source>There is already an account with this email</source>
|
||||||
|
<target>Er is al een account met dit e-mailadres</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id=".LrcTyU" resname="There is no active quiz">
|
<trans-unit id=".LrcTyU" resname="There is no active quiz">
|
||||||
<source>There is no active quiz</source>
|
<source>There is no active quiz</source>
|
||||||
<target>Er is geen test actief</target>
|
<target>Er is geen test actief</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="nv0a7LG" resname="This deletes your account and every season you are the only owner of. Enter your password to confirm.">
|
||||||
|
<source>This deletes your account and every season you are the only owner of. Enter your password to confirm.</source>
|
||||||
|
<target>Dit verwijdert je account en alle seizoenen waarvan jij de enige eigenaar bent. Vul je wachtwoord in om te bevestigen.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="EBWUWJu" resname="This invalidates the current season code. Anyone using the old code will no longer be able to join this season.">
|
||||||
|
<source>This invalidates the current season code. Anyone using the old code will no longer be able to join this season.</source>
|
||||||
|
<target>Hiermee wordt de huidige seizoenscode ongeldig. Iedereen die de oude code gebruikt, kan dit seizoen niet meer vinden.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="YueaA2f" resname="This link will expire in %count%.">
|
||||||
|
<source>This link will expire in %count%.</source>
|
||||||
|
<target>Deze link verloopt over %count%.</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="o.FTilS" resname="This question cannot be deleted because it is used in a locked or active quiz">
|
<trans-unit id="o.FTilS" resname="This question cannot be deleted because it is used in a locked or active quiz">
|
||||||
<source>This question cannot be deleted because it is used in a locked or active quiz</source>
|
<source>This question cannot be deleted because it is used in a locked or active quiz</source>
|
||||||
<target>Deze vraag kan niet verwijderd worden omdat die gebruik wordt in een vergrendelde of actieve test</target>
|
<target>Deze vraag kan niet verwijderd worden omdat die gebruik wordt in een vergrendelde of actieve test</target>
|
||||||
@@ -745,6 +961,10 @@
|
|||||||
<source>White</source>
|
<source>White</source>
|
||||||
<target>Wit</target>
|
<target>Wit</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="AVLy021" resname="Wrong password, your account has not been deleted.">
|
||||||
|
<source>Wrong password, your account has not been deleted.</source>
|
||||||
|
<target>Verkeerd wachtwoord, je account is niet verwijderd.</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="RV6M450" resname="Yellow">
|
<trans-unit id="RV6M450" resname="Yellow">
|
||||||
<source>Yellow</source>
|
<source>Yellow</source>
|
||||||
<target>Geel</target>
|
<target>Geel</target>
|
||||||
@@ -769,10 +989,38 @@
|
|||||||
<source>Your Seasons</source>
|
<source>Your Seasons</source>
|
||||||
<target>Jouw seizoenen</target>
|
<target>Jouw seizoenen</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Eh0pcpd" resname="Your data">
|
||||||
|
<source>Your data</source>
|
||||||
|
<target>Je gegevens</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sAb9l8I" resname="Your email address has been changed.">
|
||||||
|
<source>Your email address has been changed.</source>
|
||||||
|
<target>Je e-mailadres is gewijzigd.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="OqDtnJw" resname="Your email address has been changed. Please check your inbox to confirm it.">
|
||||||
|
<source>Your email address has been changed. Please check your inbox to confirm it.</source>
|
||||||
|
<target>Je e-mailadres is gewijzigd. Check je inbox om het te bevestigen.</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="m80cBv0" resname="Your email address has been verified.">
|
<trans-unit id="m80cBv0" resname="Your email address has been verified.">
|
||||||
<source>Your email address has been verified.</source>
|
<source>Your email address has been verified.</source>
|
||||||
<target>Je e-mailadres is geverifieerd.</target>
|
<target>Je e-mailadres is geverifieerd.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="mPitWNe" resname="Your email address is already confirmed.">
|
||||||
|
<source>Your email address is already confirmed.</source>
|
||||||
|
<target>Je e-mailadres is al bevestigd.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="yAT.oxx" resname="Your password has been changed.">
|
||||||
|
<source>Your password has been changed.</source>
|
||||||
|
<target>Je wachtwoord is gewijzigd.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="YDIkAA1" resname="Your password reset request">
|
||||||
|
<source>Your password reset request</source>
|
||||||
|
<target>Verzoek tot wachtwoordherstel</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="zqHSO6v" resname="try again">
|
||||||
|
<source>try again</source>
|
||||||
|
<target>probeer opnieuw</target>
|
||||||
|
</trans-unit>
|
||||||
</body>
|
</body>
|
||||||
</file>
|
</file>
|
||||||
</xliff>
|
</xliff>
|
||||||
|
|||||||
@@ -109,10 +109,22 @@
|
|||||||
<source>Please enter a valid week.</source>
|
<source>Please enter a valid week.</source>
|
||||||
<target>Vul een geldige week in.</target>
|
<target>Vul een geldige week in.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="PI63Rjp" resname="Please enter an email address">
|
||||||
|
<source>Please enter an email address</source>
|
||||||
|
<target>Vul een e-mailadres in</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="o.y86J1" resname="Please enter an integer.">
|
<trans-unit id="o.y86J1" resname="Please enter an integer.">
|
||||||
<source>Please enter an integer.</source>
|
<source>Please enter an integer.</source>
|
||||||
<target>Vul een geldig getal in.</target>
|
<target>Vul een geldig getal in.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Vl4HgaN" resname="Please enter your current password">
|
||||||
|
<source>Please enter your current password</source>
|
||||||
|
<target>Vul je huidige wachtwoord in</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="7VWHcE0" resname="Please enter your email">
|
||||||
|
<source>Please enter your email</source>
|
||||||
|
<target>Voer je e-mailadres in</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="9RtYCIm" resname="Please provide a valid phone number.">
|
<trans-unit id="9RtYCIm" resname="Please provide a valid phone number.">
|
||||||
<source>Please provide a valid phone number.</source>
|
<source>Please provide a valid phone number.</source>
|
||||||
<target>Vul een geldig telefoonnummer in.</target>
|
<target>Vul een geldig telefoonnummer in.</target>
|
||||||
@@ -433,6 +445,10 @@
|
|||||||
<source>This is not a valid UUID.</source>
|
<source>This is not a valid UUID.</source>
|
||||||
<target>Deze waarde is geen geldige UUID.</target>
|
<target>Deze waarde is geen geldige UUID.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="E7D7p81" resname="This is not your current password.">
|
||||||
|
<source>This is not your current password.</source>
|
||||||
|
<target>Dit is niet je huidige wachtwoord.</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="o0vqbDQ" resname="This password has been leaked in a data breach, it must not be used. Please use another password.">
|
<trans-unit id="o0vqbDQ" resname="This password has been leaked in a data breach, it must not be used. Please use another password.">
|
||||||
<source>This password has been leaked in a data breach, it must not be used. Please use another password.</source>
|
<source>This password has been leaked in a data breach, it must not be used. Please use another password.</source>
|
||||||
<target>Dit wachtwoord is gelekt bij een datalek en mag niet worden gebruikt. Kies een ander wachtwoord.</target>
|
<target>Dit wachtwoord is gelekt bij een datalek en mag niet worden gebruikt. Kies een ander wachtwoord.</target>
|
||||||
|
|||||||
Reference in New Issue
Block a user