mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-11 20:38:21 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
ce415217bf
|
|||
|
5cfeeb57f0
|
|||
|
36238f45bb
|
|||
|
b5997adef6
|
|||
| 33a0e8a584 | |||
| 2fd15ba8fa |
@@ -70,6 +70,8 @@ jobs:
|
||||
set: |
|
||||
*.cache-from=type=gha,scope=${{github.ref}}-devbuild
|
||||
- name: Start services
|
||||
env:
|
||||
HTTP_PORT: "80"
|
||||
run: docker compose up php database --wait --no-build
|
||||
- name: Warm up dev cache
|
||||
run: docker compose exec -T php bin/console cache:warmup --env=dev
|
||||
@@ -93,6 +95,15 @@ jobs:
|
||||
id: rector
|
||||
continue-on-error: true
|
||||
run: docker compose exec -T php vendor/bin/rector process --dry-run --no-progress-bar --output-format=github
|
||||
- name: Translations
|
||||
id: translations
|
||||
continue-on-error: true
|
||||
run: |
|
||||
docker compose exec -T php bin/console translation:extract --force --format=xliff --sort=asc nl
|
||||
if ! git diff --exit-code -- translations; then
|
||||
echo "::error::Translations are out of date. Run 'just translations' and commit the changes."
|
||||
exit 1
|
||||
fi
|
||||
- name: Check HTTP reachability
|
||||
run: curl -v --fail-with-body http://localhost
|
||||
- name: Assert all checks passed
|
||||
@@ -111,6 +122,7 @@ jobs:
|
||||
check "Twig Coding Style" "${{ steps.twig_cs.outcome }}"
|
||||
check "PHPStan" "${{ steps.phpstan.outcome }}"
|
||||
check "Rector" "${{ steps.rector.outcome }}"
|
||||
check "Translations" "${{ steps.translations.outcome }}"
|
||||
exit $failed
|
||||
|
||||
tests:
|
||||
@@ -123,6 +135,7 @@ jobs:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
code-quality: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
@@ -150,13 +163,26 @@ jobs:
|
||||
- name: Load fixtures
|
||||
run: docker compose exec -T php bin/console -e test doctrine:fixtures:load --no-interaction --group=test
|
||||
- name: Run PHPUnit
|
||||
run: docker compose exec -T -e MAILER_DSN=null://null php vendor/bin/phpunit --log-junit var/phpunit/junit.xml
|
||||
# Reports are written outside var/ since var/ is a Docker volume (see the Dockerfile's
|
||||
# VOLUME /app/var/) and isn't bind-mounted to the runner, unlike the rest of the project.
|
||||
run: docker compose exec -T -e MAILER_DSN=null://null php vendor/bin/phpunit --log-junit reports/junit.xml --coverage-cobertura reports/coverage/cobertura.xml
|
||||
- name: Publish PHPUnit test results
|
||||
if: always()
|
||||
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6
|
||||
with:
|
||||
report_paths: var/phpunit/junit.xml
|
||||
report_paths: reports/junit.xml
|
||||
check_name: PHPUnit
|
||||
- name: Upload code coverage
|
||||
# Requires "Code Quality" to be enabled for this repository under
|
||||
# Settings > Code security > Code quality; fail-on-error is false so CI
|
||||
# doesn't go red before that one-time, manual repository setting is turned on.
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: actions/upload-code-coverage@82c7aee3fb2ad768e00b00a0a8d749c5815085b6 # v1
|
||||
with:
|
||||
file: reports/coverage/cobertura.xml
|
||||
language: PHP
|
||||
label: phpunit
|
||||
fail-on-error: false
|
||||
- name: Doctrine Schema Validator
|
||||
run: docker compose exec -T php bin/console -e test doctrine:schema:validate
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/frankenphp/data
|
||||
/reports/
|
||||
|
||||
### Generated by gibo (https://github.com/simonwhitaker/gibo)
|
||||
### https://raw.github.com/github/gitignore/6eeebe6f49678aacd8311ce079842c971b3ebe96/Symfony.gitignore
|
||||
|
||||
@@ -40,6 +40,24 @@ just shell # Interactive shell inside the PHP container
|
||||
just shell-run # Shell in a fresh one-off container
|
||||
```
|
||||
|
||||
### Working in git worktrees
|
||||
|
||||
`just up` auto-runs `just init` first, which generates a gitignored `.env.local` per checkout with a unique
|
||||
`COMPOSE_PROJECT_NAME`, `IMAGES_PREFIX`, and free `HTTP_PORT`/`HTTPS_PORT`/`POSTGRES_PORT`/`MAILPIT_PORT`/
|
||||
`SPOTLIGHT_PORT`. This means every worktree gets its own containers, network, volumes, and image tag — running
|
||||
`just up` in two worktrees at the same time does **not** make them share a database, image, or port, even if the
|
||||
worktree directories have the same basename.
|
||||
|
||||
- Run `just ports` to see the ports assigned to the *current* checkout — the app for that worktree is at
|
||||
`https://localhost:<HTTPS_PORT>`, not a fixed port. Never assume port 8080/8443/5432/etc. when working inside a
|
||||
worktree; always check `.env.local` or `just ports` first.
|
||||
- `.env.local` is generated once and reused; it's safe to run `just init`/`just up` repeatedly. Delete `.env.local`
|
||||
and re-run `just init` to force new ports (e.g. if the assigned ones are now taken by something else).
|
||||
- Each worktree's Postgres data, uploaded files, and Caddy state live in per-worktree Docker volumes — nothing is
|
||||
shared with the main checkout or other worktrees. Migrations/fixtures must be (re-)run per worktree.
|
||||
- `just down`/`just clean` in one worktree only ever affects that worktree's own containers/volumes — safe to run
|
||||
without impacting other worktrees.
|
||||
|
||||
### Database
|
||||
|
||||
```bash
|
||||
@@ -288,6 +306,7 @@ GitHub Actions workflow (`.github/workflows/ci.yml`):
|
||||
- Twig-CS-Fixer style check
|
||||
- PHPStan static analysis
|
||||
- Rector dry-run
|
||||
- Translation extraction check (fails if `translation:extract` produces uncommitted changes)
|
||||
3. **Integration Tests**:
|
||||
- Docker image build and start services
|
||||
- Database creation and migration
|
||||
|
||||
@@ -1,9 +1,70 @@
|
||||
up *args:
|
||||
# Load per-worktree overrides (project name, image tag, ports) generated by `just init`
|
||||
set dotenv-load := true
|
||||
set dotenv-filename := ".env.local"
|
||||
|
||||
# Generate a per-worktree COMPOSE_PROJECT_NAME, IMAGES_PREFIX and free host ports in .env.local,
|
||||
# so multiple worktrees/checkouts of this repo can run `just up` at the same time without their
|
||||
# containers, volumes, images or ports colliding. Safe to re-run; no-ops if already configured.
|
||||
init:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [ -f .env.local ] && grep -q '^COMPOSE_PROJECT_NAME=' .env.local; then
|
||||
echo ".env.local already configured for this worktree, skipping (delete it to regenerate)."
|
||||
exit 0
|
||||
fi
|
||||
free_port() {
|
||||
local port="$1" max="$2"
|
||||
while [ "$port" -le "$max" ]; do
|
||||
if ! (exec 3<>/dev/tcp/127.0.0.1/"$port") 2>/dev/null; then
|
||||
echo "$port"
|
||||
return
|
||||
fi
|
||||
exec 3>&- 2>/dev/null || true
|
||||
port=$((port + 1))
|
||||
done
|
||||
echo "no free port found between $1 and $2" >&2
|
||||
exit 1
|
||||
}
|
||||
if command -v sha1sum >/dev/null 2>&1; then
|
||||
hash_cmd=(sha1sum)
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
hash_cmd=(shasum)
|
||||
else
|
||||
hash_cmd=(sha256sum)
|
||||
fi
|
||||
hash=$(pwd | "${hash_cmd[@]}" | cut -c1-8)
|
||||
project="tvdt-${hash}"
|
||||
http_port=$(free_port 8080 8179)
|
||||
https_port=$(free_port 8443 8542)
|
||||
postgres_port=$(free_port 5430 5529)
|
||||
mailpit_port=$(free_port 8025 8124)
|
||||
spotlight_port=$(free_port 8969 9068)
|
||||
{
|
||||
echo "COMPOSE_PROJECT_NAME=${project}"
|
||||
echo "IMAGES_PREFIX=${project}-"
|
||||
echo "HTTP_PORT=${http_port}"
|
||||
echo "HTTPS_PORT=${https_port}"
|
||||
echo "POSTGRES_PORT=${postgres_port}"
|
||||
echo "MAILPIT_PORT=${mailpit_port}"
|
||||
echo "SPOTLIGHT_PORT=${spotlight_port}"
|
||||
} >> .env.local
|
||||
echo "Generated .env.local for this worktree:"
|
||||
cat .env.local
|
||||
|
||||
up *args: init
|
||||
#!/usr/bin/env bash
|
||||
set -a
|
||||
[ -f .env.local ] && source .env.local
|
||||
set +a
|
||||
docker compose up -d {{ args }}
|
||||
|
||||
down *args:
|
||||
docker compose down --remove-orphans {{ args }}
|
||||
|
||||
# Show the host ports assigned to this worktree (see `just init`)
|
||||
ports:
|
||||
@cat .env.local 2>/dev/null || echo "No .env.local yet, run 'just up' or 'just init' first."
|
||||
|
||||
stop:
|
||||
docker compose stop
|
||||
|
||||
|
||||
@@ -25,8 +25,13 @@ just migrate # Run pending database migrations
|
||||
just fixtures # Load dev fixtures (truncates first)
|
||||
```
|
||||
|
||||
The app is available at **https://localhost** (self-signed cert — run
|
||||
`just trust-cert` on macOS to trust it).
|
||||
`just up` first runs `just init`, which generates a `.env.local` (gitignored)
|
||||
with a unique `COMPOSE_PROJECT_NAME`, image tag and free host ports for this
|
||||
checkout, so multiple worktrees/clones can run at the same time without their
|
||||
containers, volumes, images or ports colliding. Run `just ports` to see the
|
||||
ports assigned to the current checkout — the app is served at
|
||||
`https://localhost:<HTTPS_PORT>` (self-signed cert — run `just trust-cert` on
|
||||
macOS to trust it).
|
||||
|
||||
### Useful commands
|
||||
|
||||
|
||||
@@ -24,15 +24,15 @@ services:
|
||||
ports:
|
||||
# HTTP
|
||||
- target: 80
|
||||
published: ${HTTP_PORT:-80}
|
||||
published: ${HTTP_PORT:-8080}
|
||||
protocol: tcp
|
||||
# HTTPS
|
||||
- target: 443
|
||||
published: ${HTTPS_PORT:-443}
|
||||
published: ${HTTPS_PORT:-8443}
|
||||
protocol: tcp
|
||||
# HTTP/3
|
||||
- target: 443
|
||||
published: ${HTTP3_PORT:-443}
|
||||
published: ${HTTPS_PORT:-8443}
|
||||
protocol: udp
|
||||
sass:
|
||||
image: ${IMAGES_PREFIX:-}app-php
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
###> doctrine/doctrine-bundle ###
|
||||
database:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "${POSTGRES_PORT:-5430}:5432"
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
||||
###> symfony/mailer ###
|
||||
@@ -64,7 +64,7 @@ services:
|
||||
image: axllent/mailpit
|
||||
ports:
|
||||
- "1025"
|
||||
- "8025:8025"
|
||||
- "${MAILPIT_PORT:-8025}:8025"
|
||||
environment:
|
||||
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
||||
@@ -73,7 +73,7 @@ services:
|
||||
spotlight:
|
||||
image: ghcr.io/getsentry/spotlight:latest
|
||||
ports:
|
||||
- "8969:8969"
|
||||
- "${SPOTLIGHT_PORT:-8969}:8969"
|
||||
|
||||
volumes:
|
||||
sass:
|
||||
|
||||
@@ -25,6 +25,7 @@ use Tvdt\Entity\QuizCandidate;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Enum\FlashType;
|
||||
use Tvdt\Exception\ErrorClearingQuizException;
|
||||
use Tvdt\Repository\GivenAnswerRepository;
|
||||
use Tvdt\Repository\QuizCandidateRepository;
|
||||
use Tvdt\Repository\QuizRepository;
|
||||
use Tvdt\Security\Voter\SeasonVoter;
|
||||
@@ -37,6 +38,7 @@ class QuizController extends AbstractController
|
||||
private readonly QuizRepository $quizRepository,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly QuizCandidateRepository $quizCandidateRepository,
|
||||
private readonly GivenAnswerRepository $givenAnswerRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
@@ -397,6 +399,26 @@ class QuizController extends AbstractController
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('reset_candidate_progress')]
|
||||
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
|
||||
#[Route(
|
||||
'/backoffice/quiz/{quiz}/candidate/{candidate}/reset',
|
||||
name: 'tvdt_backoffice_reset_candidate_progress',
|
||||
requirements: ['quiz' => Requirement::UUID, 'candidate' => Requirement::UUID],
|
||||
methods: ['POST'],
|
||||
)]
|
||||
public function resetCandidateProgress(Quiz $quiz, Candidate $candidate): RedirectResponse
|
||||
{
|
||||
$this->em->wrapInTransaction(function () use ($quiz, $candidate): void {
|
||||
$this->givenAnswerRepository->deleteAllForCandidateInQuiz($quiz, $candidate);
|
||||
$this->quizCandidateRepository->resetProgressForCandidate($quiz, $candidate);
|
||||
});
|
||||
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Candidate progress reset'));
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-computes per-candidate data (quiz participation and given answer counts) to avoid nested loops in templates.
|
||||
*
|
||||
|
||||
@@ -129,6 +129,8 @@ class SeasonController extends AbstractController
|
||||
)]
|
||||
public function addCandidates(Season $season, Request $request): Response
|
||||
{
|
||||
$isTurboFrame = $request->headers->has('Turbo-Frame');
|
||||
|
||||
$form = $this->createForm(AddCandidatesFormType::class);
|
||||
$form->handleRequest($request);
|
||||
|
||||
@@ -140,10 +142,18 @@ class SeasonController extends AbstractController
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $season->seasonCode]);
|
||||
if ($isTurboFrame) {
|
||||
return new Response('<turbo-frame id="add-candidates-modal-frame"></turbo-frame>');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('tvdt_backoffice_season_candidates', ['seasonCode' => $season->seasonCode]);
|
||||
}
|
||||
|
||||
return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form, 'season' => $season]);
|
||||
$template = $isTurboFrame
|
||||
? 'backoffice/season/_add_candidates_frame.html.twig'
|
||||
: 'backoffice/season_add_candidates.html.twig';
|
||||
|
||||
return $this->render($template, ['form' => $form, 'season' => $season]);
|
||||
}
|
||||
|
||||
#[IsCsrfTokenValid('rename_candidate')]
|
||||
|
||||
@@ -19,7 +19,9 @@ class AddCandidatesFormType extends AbstractType
|
||||
{
|
||||
$builder
|
||||
->add('candidates', TextareaType::class, [
|
||||
'label' => $this->translator->trans('Candidates'), 'translation_domain' => false,
|
||||
'label' => $this->translator->trans('Candidates'),
|
||||
'help' => $this->translator->trans('One candidate per line'),
|
||||
'translation_domain' => false,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ namespace Tvdt\Repository;
|
||||
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Tvdt\Entity\Candidate;
|
||||
use Tvdt\Entity\GivenAnswer;
|
||||
use Tvdt\Entity\Quiz;
|
||||
|
||||
/** @extends ServiceEntityRepository<GivenAnswer> */
|
||||
class GivenAnswerRepository extends ServiceEntityRepository
|
||||
@@ -15,4 +17,15 @@ class GivenAnswerRepository extends ServiceEntityRepository
|
||||
{
|
||||
parent::__construct($registry, GivenAnswer::class);
|
||||
}
|
||||
|
||||
public function deleteAllForCandidateInQuiz(Quiz $quiz, Candidate $candidate): void
|
||||
{
|
||||
$givenAnswers = $this->findBy(['quiz' => $quiz, 'candidate' => $candidate]);
|
||||
|
||||
foreach ($givenAnswers as $givenAnswer) {
|
||||
$this->getEntityManager()->remove($givenAnswer);
|
||||
}
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,4 +68,15 @@ class QuizCandidateRepository extends ServiceEntityRepository
|
||||
$quizCandidate->penaltySeconds = $penalty;
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function resetProgressForCandidate(Quiz $quiz, Candidate $candidate): void
|
||||
{
|
||||
$quizCandidate = $this->findOneBy(['candidate' => $candidate, 'quiz' => $quiz]);
|
||||
if (!$quizCandidate instanceof QuizCandidate) {
|
||||
return;
|
||||
}
|
||||
|
||||
$quizCandidate->started = null;
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form action="{{ path('tvdt_backoffice_toggle_candidate', {quiz: quiz.id, candidate: candidate.id}) }}" method="POST">
|
||||
<form action="{{ path('tvdt_backoffice_toggle_candidate', {quiz: quiz.id, candidate: candidate.id}) }}" method="POST" class="d-inline">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('toggle_candidate') }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||
{% if quizCandidate == null or quizCandidate.active %}
|
||||
@@ -47,6 +47,12 @@
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% if quizCandidate and quizCandidate.started %}
|
||||
<form action="{{ path('tvdt_backoffice_reset_candidate_progress', {quiz: quiz.id, candidate: candidate.id}) }}" method="POST" class="d-inline" onsubmit="return confirm('{{ 'Are you sure you want to reset progress for this candidate? Their given answers for this quiz will be deleted.'|trans|e('js') }}');">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('reset_candidate_progress') }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">{{ 'Reset progress'|trans }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<turbo-frame id="add-candidates-modal-frame">
|
||||
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
|
||||
<div class="modal-body">
|
||||
{{ form_row(form.candidates) }}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
|
||||
<button type="submit" class="btn btn-primary">{{ 'Submit'|trans }}</button>
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
</turbo-frame>
|
||||
@@ -1,8 +1,10 @@
|
||||
<div class="row">
|
||||
<div class="row" data-controller="bo--modal" data-action="turbo:submit-end->bo--modal#frameSubmitEnd">
|
||||
<div class="col-md-6 col-12">
|
||||
<div class="mb-3">
|
||||
<a class="btn btn-sm btn-outline-primary"
|
||||
href="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }}</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
data-action="click->bo--modal#open"
|
||||
data-src="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}"
|
||||
data-modal-title="{{ 'Add Candidate'|trans }}">{{ 'Add Candidate'|trans }}</button>
|
||||
</div>
|
||||
<ul class="list-group mb-3">
|
||||
{% for candidate in season.candidates %}
|
||||
@@ -17,7 +19,9 @@
|
||||
title="{{ 'Delete'|trans }}"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="renameCandidate-{{ candidate.id }}" data-bs-backdrop="static"
|
||||
<div class="modal fade" id="renameCandidate-{{ candidate.id }}"
|
||||
data-controller="bo--modal" data-bo--modal-target="modal"
|
||||
data-action="hidden.bs.modal->bo--modal#resetDirty"
|
||||
tabindex="-1" aria-labelledby="renameCandidate-{{ candidate.id }}Label" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
@@ -31,7 +35,8 @@
|
||||
<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>
|
||||
name="name" value="{{ candidate.name }}" maxlength="16" required autofocus
|
||||
data-action="input->bo--modal#markDirty change->bo--modal#markDirty">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
|
||||
@@ -69,6 +74,23 @@
|
||||
{{ 'No candidates'|trans }}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="modal fade" tabindex="-1"
|
||||
data-bo--modal-target="modal"
|
||||
data-action="hidden.bs.modal->bo--modal#resetDirty"
|
||||
aria-labelledby="addCandidatesModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="addCandidatesModalLabel">{{ 'Add Candidate'|trans }}</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<turbo-frame id="add-candidates-modal-frame"
|
||||
data-bo--modal-target="frame"
|
||||
data-action="input->bo--modal#markDirty change->bo--modal#markDirty"></turbo-frame>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-12">
|
||||
{{ include('backoffice/help/season_candidates.html.twig') }}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Tvdt\Tests\Command;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
@@ -48,21 +49,19 @@ final class ClaimSeasonCommandTest extends KernelTestCase
|
||||
$this->assertCount(3, $season->owners);
|
||||
}
|
||||
|
||||
public function testInvalidEmailFails(): void
|
||||
/** @return iterable<string, array{string, string}> */
|
||||
public static function invalidArgumentsProvider(): iterable
|
||||
{
|
||||
$this->commandTester->execute([
|
||||
'season-code' => 'krtek',
|
||||
'email' => 'nonexisting@example.org',
|
||||
]);
|
||||
|
||||
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
|
||||
yield 'unknown email' => ['krtek', 'nonexisting@example.org'];
|
||||
yield 'unknown season' => ['dhadk', 'test@example.org'];
|
||||
}
|
||||
|
||||
public function testInvalidSeasonCodeFails(): void
|
||||
#[DataProvider('invalidArgumentsProvider')]
|
||||
public function testInvalidArgumentFails(string $seasonCode, string $email): void
|
||||
{
|
||||
$this->commandTester->execute([
|
||||
'season-code' => 'dhadk',
|
||||
'email' => 'test@example.org',
|
||||
'season-code' => $seasonCode,
|
||||
'email' => $email,
|
||||
]);
|
||||
|
||||
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Entity\Candidate;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
abstract class AbstractControllerWebTestCase extends WebTestCase
|
||||
{
|
||||
protected KernelBrowser $client;
|
||||
|
||||
protected EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = self::createClient();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
}
|
||||
|
||||
protected function getUserByEmail(string $email): User
|
||||
{
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $email]);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
protected function loginAs(string $email): void
|
||||
{
|
||||
$this->client->loginUser($this->getUserByEmail($email));
|
||||
}
|
||||
|
||||
/** Quiz names are only unique per season (see Quiz's UniqueConstraint), so this is scoped by season code. */
|
||||
protected function getQuizByName(string $name, string $seasonCode = 'krtek'): Quiz
|
||||
{
|
||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name, 'season' => $this->getSeasonByCode($seasonCode)]);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
|
||||
return $quiz;
|
||||
}
|
||||
|
||||
/** Candidate names are only unique per season (see Candidate's UniqueConstraint), so this is scoped by season code. */
|
||||
protected function getCandidate(string $name, string $seasonCode = 'krtek'): Candidate
|
||||
{
|
||||
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => $name, 'season' => $this->getSeasonByCode($seasonCode)]);
|
||||
$this->assertInstanceOf(Candidate::class, $candidate);
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
protected function getSeasonByCode(string $seasonCode): Season
|
||||
{
|
||||
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => $seasonCode]);
|
||||
$this->assertInstanceOf(Season::class, $season);
|
||||
|
||||
return $season;
|
||||
}
|
||||
|
||||
/** GETs $url and extracts the CSRF token from a form whose action contains $formActionContains. */
|
||||
protected function getCsrfTokenFromPage(string $url, string $formActionContains, string $tokenFieldName = '_token'): string
|
||||
{
|
||||
$crawler = $this->client->request(Request::METHOD_GET, $url);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
return $this->getCsrfTokenFromCrawler($crawler, $formActionContains, $tokenFieldName);
|
||||
}
|
||||
|
||||
/** Extracts the CSRF token from a form on the page already loaded in the client. */
|
||||
protected function getCsrfTokenFromCurrentPage(string $formActionContains, string $tokenFieldName = '_token'): string
|
||||
{
|
||||
return $this->getCsrfTokenFromCrawler($this->client->getCrawler(), $formActionContains, $tokenFieldName);
|
||||
}
|
||||
|
||||
/** GETs $url and extracts the CSRF token input, regardless of which form it belongs to. */
|
||||
protected function getTokenFromPage(string $url, string $tokenFieldName = '_token'): string
|
||||
{
|
||||
$crawler = $this->client->request(Request::METHOD_GET, $url);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$input = $crawler->filter(\sprintf('input[name="%s"]', $tokenFieldName));
|
||||
$this->assertGreaterThan(0, $input->count(), \sprintf('No input named "%s" found on the page', $tokenFieldName));
|
||||
|
||||
return (string) $input->first()->attr('value');
|
||||
}
|
||||
|
||||
private function getCsrfTokenFromCrawler(Crawler $crawler, string $formActionContains, string $tokenFieldName): string
|
||||
{
|
||||
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="%s"]', $formActionContains, $tokenFieldName));
|
||||
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
|
||||
|
||||
return (string) $input->first()->attr('value');
|
||||
}
|
||||
}
|
||||
@@ -4,54 +4,53 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Controller\Backoffice\BackofficeController;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\User;
|
||||
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||
|
||||
#[CoversClass(BackofficeController::class)]
|
||||
final class BackofficeControllerTest extends WebTestCase
|
||||
final class BackofficeControllerTest extends AbstractControllerWebTestCase
|
||||
{
|
||||
public function testExportQuizFilenameIsSanitized(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$user = $entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$user = $this->getUserByEmail('user2@example.org');
|
||||
$user->isVerified = true;
|
||||
$entityManager->flush();
|
||||
$client->loginUser($user);
|
||||
|
||||
$quiz = $entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1']);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
$this->entityManager->flush();
|
||||
$this->client->loginUser($user);
|
||||
|
||||
$client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
|
||||
$quiz = $this->getQuizByName('Quiz 1');
|
||||
|
||||
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$disposition = (string) $client->getResponse()->headers->get('Content-Disposition');
|
||||
$disposition = (string) $this->client->getResponse()->headers->get('Content-Disposition');
|
||||
$this->assertStringContainsString('filename=Quiz-1.xlsx', $disposition);
|
||||
$this->assertStringNotContainsString('Quiz 1.xlsx', $disposition);
|
||||
}
|
||||
|
||||
public function testExportQuizRequiresVerifiedEmail(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$user = $entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$user = $this->getUserByEmail('user2@example.org');
|
||||
$this->assertFalse($user->isVerified);
|
||||
$client->loginUser($user);
|
||||
$this->client->loginUser($user);
|
||||
|
||||
$quiz = $entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1']);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
$quiz = $this->getQuizByName('Quiz 1');
|
||||
|
||||
$client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
|
||||
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
|
||||
|
||||
self::assertResponseRedirects(\sprintf('/backoffice/season/%s', $quiz->season->seasonCode));
|
||||
}
|
||||
|
||||
public function testExportQuizIsDeniedForNonOwner(): void
|
||||
{
|
||||
$this->loginAs('test@example.org');
|
||||
|
||||
$quiz = $this->getQuizByName('Quiz 1');
|
||||
|
||||
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller\Backoffice;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Controller\Backoffice\PrepareEliminationController;
|
||||
use Tvdt\Entity\Answer;
|
||||
use Tvdt\Entity\Elimination;
|
||||
use Tvdt\Entity\GivenAnswer;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\QuizCandidate;
|
||||
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||
|
||||
#[CoversClass(PrepareEliminationController::class)]
|
||||
final class PrepareEliminationControllerTest extends AbstractControllerWebTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
}
|
||||
|
||||
public function testIndexCreatesEliminationAndRedirectsToView(): void
|
||||
{
|
||||
$quiz = $this->getQuizByName('Quiz 1');
|
||||
$candidate = $this->getCandidate('Tom');
|
||||
|
||||
$quizCandidate = new QuizCandidate($quiz, $candidate);
|
||||
$quizCandidate->started = new DateTimeImmutable();
|
||||
|
||||
$this->entityManager->persist($quizCandidate);
|
||||
|
||||
$firstQuestion = $quiz->questions->first();
|
||||
$this->assertInstanceOf(Question::class, $firstQuestion);
|
||||
$answer = $firstQuestion->answers->first();
|
||||
$this->assertInstanceOf(Answer::class, $answer);
|
||||
$this->entityManager->persist(new GivenAnswer($candidate, $quiz, $answer));
|
||||
$this->entityManager->flush();
|
||||
|
||||
$token = $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/krtek/quiz/%s/result', $quiz->id), '/elimination/prepare');
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/quiz/%s/elimination/prepare', $quiz->id), [
|
||||
'_token' => $token,
|
||||
]);
|
||||
|
||||
$response = $this->client->getResponse();
|
||||
$this->assertTrue($response->isRedirect());
|
||||
$this->assertStringContainsString('/backoffice/elimination/', (string) $response->headers->get('Location'));
|
||||
|
||||
$this->entityManager->clear();
|
||||
$quiz = $this->getQuizByName('Quiz 1');
|
||||
$elimination = $this->entityManager->getRepository(Elimination::class)->findOneBy(['quiz' => $quiz]);
|
||||
$this->assertInstanceOf(Elimination::class, $elimination);
|
||||
$this->assertArrayHasKey('Tom', $elimination->data);
|
||||
}
|
||||
|
||||
public function testViewEliminationPageLoads(): void
|
||||
{
|
||||
$quiz = $this->getQuizByName('Quiz 1');
|
||||
$elimination = new Elimination($quiz);
|
||||
$elimination->data = ['Tom' => Elimination::SCREEN_GREEN];
|
||||
|
||||
$this->entityManager->persist($elimination);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/elimination/%s', $elimination->id));
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSelectorExists('form');
|
||||
}
|
||||
|
||||
public function testViewEliminationSavesUpdatedColours(): void
|
||||
{
|
||||
$quiz = $this->getQuizByName('Quiz 1');
|
||||
$elimination = new Elimination($quiz);
|
||||
$elimination->data = ['Tom' => Elimination::SCREEN_GREEN];
|
||||
|
||||
$this->entityManager->persist($elimination);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$token = $this->getTokenFromPage(\sprintf('/backoffice/elimination/%s', $elimination->id));
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/elimination/%s', $elimination->id), [
|
||||
'_token' => $token,
|
||||
'colour-tom' => Elimination::SCREEN_RED,
|
||||
'start' => '0',
|
||||
]);
|
||||
|
||||
self::assertResponseRedirects(\sprintf('/backoffice/elimination/%s', $elimination->id));
|
||||
$this->entityManager->clear();
|
||||
|
||||
$updated = $this->entityManager->getRepository(Elimination::class)->find($elimination->id);
|
||||
$this->assertInstanceOf(Elimination::class, $updated);
|
||||
$this->assertSame(Elimination::SCREEN_RED, $updated->data['Tom']);
|
||||
}
|
||||
|
||||
public function testViewEliminationWithStartRedirectsToPublicElimination(): void
|
||||
{
|
||||
$quiz = $this->getQuizByName('Quiz 1');
|
||||
$elimination = new Elimination($quiz);
|
||||
$elimination->data = ['Tom' => Elimination::SCREEN_GREEN];
|
||||
|
||||
$this->entityManager->persist($elimination);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$token = $this->getTokenFromPage(\sprintf('/backoffice/elimination/%s', $elimination->id));
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/elimination/%s', $elimination->id), [
|
||||
'_token' => $token,
|
||||
'start' => '1',
|
||||
]);
|
||||
|
||||
self::assertResponseRedirects(\sprintf('/elimination/%s', $elimination->id));
|
||||
}
|
||||
}
|
||||
@@ -4,39 +4,18 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Controller\Backoffice\QuestionBankController;
|
||||
use Tvdt\Entity\BankAnswer;
|
||||
use Tvdt\Entity\BankQuestion;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\QuestionLabel;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\User;
|
||||
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||
|
||||
#[CoversClass(QuestionBankController::class)]
|
||||
final class QuestionBankControllerTest extends WebTestCase
|
||||
final class QuestionBankControllerTest extends AbstractControllerWebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = self::createClient();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
}
|
||||
|
||||
private function loginAsOwner(): void
|
||||
{
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
}
|
||||
|
||||
private function getBankQuestion(string $question): BankQuestion
|
||||
{
|
||||
$bankQuestion = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => $question]);
|
||||
@@ -45,26 +24,9 @@ final class QuestionBankControllerTest extends WebTestCase
|
||||
return $bankQuestion;
|
||||
}
|
||||
|
||||
private function getQuizByName(string $name): Quiz
|
||||
{
|
||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
|
||||
return $quiz;
|
||||
}
|
||||
|
||||
private function getCsrfToken(string $formActionContains): string
|
||||
{
|
||||
$crawler = $this->client->getCrawler();
|
||||
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
|
||||
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
|
||||
|
||||
return (string) $input->first()->attr('value');
|
||||
}
|
||||
|
||||
public function testIndexListsBankQuestions(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
@@ -75,7 +37,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
||||
|
||||
public function testIndexFiltersByLabel(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$label = $this->entityManager->getRepository(QuestionLabel::class)->findOneBy(['name' => 'Locatie']);
|
||||
$this->assertInstanceOf(QuestionLabel::class, $label);
|
||||
|
||||
@@ -89,9 +51,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
||||
|
||||
public function testNonOwnerIsDenied(): void
|
||||
{
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
$this->loginAs('test@example.org');
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
|
||||
@@ -100,7 +60,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
||||
|
||||
public function testCreateBankQuestion(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
@@ -129,7 +89,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
||||
|
||||
public function testCreateAllowedWithoutCorrectAnswer(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
|
||||
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
|
||||
|
||||
@@ -145,6 +105,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
||||
]);
|
||||
|
||||
$this->assertResponseRedirects();
|
||||
$this->entityManager->clear();
|
||||
$saved = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => 'Vraag zonder goed antwoord']);
|
||||
$this->assertInstanceOf(BankQuestion::class, $saved);
|
||||
$this->assertFalse($saved->isCompleteForQuiz);
|
||||
@@ -152,7 +113,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
||||
|
||||
public function testEditBankQuestion(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
|
||||
|
||||
$url = \sprintf('/backoffice/season/krtek/question-bank/%s/edit', $bankQuestion->id);
|
||||
@@ -181,12 +142,12 @@ final class QuestionBankControllerTest extends WebTestCase
|
||||
|
||||
public function testDeleteUsedBankQuestionLeavesQuizIntact(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?');
|
||||
$quiz2QuestionCount = $this->getQuizByName('Quiz 2')->questions->count();
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
$token = $this->getCsrfToken(\sprintf('%s/delete', $bankQuestion->id));
|
||||
$token = $this->getCsrfTokenFromCurrentPage(\sprintf('%s/delete', $bankQuestion->id));
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/delete', $bankQuestion->id), [
|
||||
'_token' => $token,
|
||||
@@ -201,13 +162,13 @@ final class QuestionBankControllerTest extends WebTestCase
|
||||
|
||||
public function testAssignCopiesQuestionIntoQuiz(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$questionCount = $quiz->questions->count();
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
$token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id));
|
||||
$token = $this->getCsrfTokenFromCurrentPage(\sprintf('%s/assign', $bankQuestion->id));
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
|
||||
'_token' => $token,
|
||||
@@ -240,7 +201,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
||||
|
||||
public function testAssignUsedNonReusableQuestionIsRefused(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?');
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$questionCount = $quiz->questions->count();
|
||||
@@ -248,7 +209,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
|
||||
// The assign form is not rendered for used questions, so post with another form's token
|
||||
$token = $this->getCsrfToken('/assign');
|
||||
$token = $this->getCsrfTokenFromCurrentPage('/assign');
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
|
||||
'_token' => $token,
|
||||
'quiz' => (string) $quiz->id,
|
||||
@@ -262,13 +223,13 @@ final class QuestionBankControllerTest extends WebTestCase
|
||||
|
||||
public function testAssignSameReusableQuestionTwiceToSameQuizIsRefused(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$bankQuestion = $this->getBankQuestion('Wie is de Krtek?');
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$questionCount = $quiz->questions->count();
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
$token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id));
|
||||
$token = $this->getCsrfTokenFromCurrentPage(\sprintf('%s/assign', $bankQuestion->id));
|
||||
|
||||
$url = \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id);
|
||||
$this->client->request(Request::METHOD_POST, $url, ['_token' => $token, 'quiz' => (string) $quiz->id]);
|
||||
@@ -283,13 +244,13 @@ final class QuestionBankControllerTest extends WebTestCase
|
||||
|
||||
public function testAssignIntoFinalizedQuizIsDenied(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$bankQuestion = $this->getBankQuestion('Wie is de Krtek?');
|
||||
$finalizedQuiz = $this->getQuizByName('Quiz 1');
|
||||
$this->assertTrue($finalizedQuiz->isFinalized);
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
$token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id));
|
||||
$token = $this->getCsrfTokenFromCurrentPage(\sprintf('%s/assign', $bankQuestion->id));
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
|
||||
'_token' => $token,
|
||||
@@ -301,7 +262,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
||||
|
||||
public function testCreateBankQuestionPreservesAnswerOrdering(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
|
||||
$this->assertResponseIsSuccessful();
|
||||
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
|
||||
@@ -334,7 +295,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
||||
|
||||
public function testEditBankQuestionPreservesAnswerOrdering(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
|
||||
// Fixture answers in insertion order (all have ordering=0): Brood (correct), Yoghurt, Niks
|
||||
|
||||
@@ -374,7 +335,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
||||
|
||||
public function testAddAndDeleteLabel(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
$token = (string) $crawler->filter('form[action$="/question-bank/labels"] input[name="_token"]')->attr('value');
|
||||
|
||||
@@ -389,7 +350,7 @@ final class QuestionBankControllerTest extends WebTestCase
|
||||
$this->assertInstanceOf(QuestionLabel::class, $label);
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
|
||||
$deleteToken = $this->getCsrfToken(\sprintf('labels/%s/delete', $label->slug));
|
||||
$deleteToken = $this->getCsrfTokenFromCurrentPage(\sprintf('labels/%s/delete', $label->slug));
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/labels/%s/delete', $label->slug), [
|
||||
'_token' => $deleteToken,
|
||||
|
||||
@@ -4,11 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Controller\Backoffice\QuizController;
|
||||
use Tvdt\Entity\Answer;
|
||||
@@ -17,51 +14,21 @@ use Tvdt\Entity\GivenAnswer;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\QuizCandidate;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Entity\User;
|
||||
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||
|
||||
#[CoversClass(QuizController::class)]
|
||||
final class QuizControllerTest extends WebTestCase
|
||||
final class QuizControllerTest extends AbstractControllerWebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = self::createClient();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
parent::setUp();
|
||||
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
}
|
||||
|
||||
private function getQuizByName(string $name): Quiz
|
||||
{
|
||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
|
||||
return $quiz;
|
||||
}
|
||||
|
||||
private function getCandidate(string $name): Candidate
|
||||
{
|
||||
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => $name]);
|
||||
$this->assertInstanceOf(Candidate::class, $candidate);
|
||||
|
||||
return $candidate;
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
}
|
||||
|
||||
private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string
|
||||
{
|
||||
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
|
||||
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
|
||||
|
||||
return (string) $input->first()->attr('value');
|
||||
return $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id), $formActionContains);
|
||||
}
|
||||
|
||||
public function testIndexRedirectsToOverview(): void
|
||||
@@ -206,6 +173,47 @@ final class QuizControllerTest extends WebTestCase
|
||||
$this->assertTrue($updated->active);
|
||||
}
|
||||
|
||||
public function testResetCandidateProgressDeletesGivenAnswersAndClearsStarted(): void
|
||||
{
|
||||
$quiz = $this->getQuizByName('Quiz 1');
|
||||
$candidate = $this->getCandidate('Tom');
|
||||
|
||||
$quizCandidate = new QuizCandidate($quiz, $candidate);
|
||||
$quizCandidate->started = new DateTimeImmutable();
|
||||
|
||||
$this->entityManager->persist($quizCandidate);
|
||||
$firstQuestion = $quiz->questions->first();
|
||||
$this->assertInstanceOf(Question::class, $firstQuestion);
|
||||
$answer = $firstQuestion->answers->first();
|
||||
$this->assertInstanceOf(Answer::class, $answer);
|
||||
$this->entityManager->persist(new GivenAnswer($candidate, $quiz, $answer));
|
||||
$this->entityManager->flush();
|
||||
|
||||
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/candidates-list', $quiz->id));
|
||||
self::assertResponseIsSuccessful();
|
||||
$token = (string) $crawler->filter(\sprintf('form[action*="/%s/reset"] input[name="_token"]', $candidate->id))->first()->attr('value');
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/candidate/%s/reset', $quiz->id, $candidate->id), [
|
||||
'_token' => $token,
|
||||
]);
|
||||
|
||||
self::assertResponseRedirects();
|
||||
$this->entityManager->clear();
|
||||
|
||||
$updated = $this->entityManager->getRepository(QuizCandidate::class)->findOneBy([
|
||||
'quiz' => $this->getQuizByName('Quiz 1'),
|
||||
'candidate' => $this->getCandidate('Tom'),
|
||||
]);
|
||||
$this->assertInstanceOf(QuizCandidate::class, $updated);
|
||||
$this->assertNotInstanceOf(\DateTimeImmutable::class, $updated->started);
|
||||
|
||||
$remainingAnswers = $this->entityManager->getRepository(GivenAnswer::class)->findBy([
|
||||
'quiz' => $quiz,
|
||||
'candidate' => $candidate,
|
||||
]);
|
||||
$this->assertCount(0, $remainingAnswers);
|
||||
}
|
||||
|
||||
public function testModifyCorrection(): void
|
||||
{
|
||||
$quiz = $this->getQuizByName('Quiz 1');
|
||||
@@ -296,9 +304,7 @@ final class QuizControllerTest extends WebTestCase
|
||||
|
||||
public function testNonOwnerIsDenied(): void
|
||||
{
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
$this->loginAs('test@example.org');
|
||||
|
||||
$quiz = $this->getQuizByName('Quiz 1');
|
||||
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
|
||||
@@ -308,8 +314,7 @@ final class QuizControllerTest extends WebTestCase
|
||||
|
||||
public function testOverviewLoadsForEmptyQuiz(): void
|
||||
{
|
||||
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
|
||||
$this->assertInstanceOf(Season::class, $season);
|
||||
$season = $this->getSeasonByCode('krtek');
|
||||
|
||||
$emptyQuiz = new Quiz();
|
||||
$emptyQuiz->name = 'Empty Quiz';
|
||||
@@ -326,8 +331,7 @@ final class QuizControllerTest extends WebTestCase
|
||||
|
||||
public function testAnswerMappingRedirectsWithFlashWhenNoQuestions(): void
|
||||
{
|
||||
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
|
||||
$this->assertInstanceOf(Season::class, $season);
|
||||
$season = $this->getSeasonByCode('krtek');
|
||||
|
||||
$emptyQuiz = new Quiz();
|
||||
$emptyQuiz->name = 'Empty Quiz';
|
||||
|
||||
@@ -4,63 +4,29 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Controller\Backoffice\QuizController;
|
||||
use Tvdt\Entity\Answer;
|
||||
use Tvdt\Entity\Candidate;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\QuizCandidate;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Entity\User;
|
||||
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||
|
||||
#[CoversClass(QuizController::class)]
|
||||
final class QuizFinalizeTest extends WebTestCase
|
||||
final class QuizFinalizeTest extends AbstractControllerWebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = self::createClient();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
parent::setUp();
|
||||
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
}
|
||||
|
||||
private function getQuizByName(string $name): Quiz
|
||||
{
|
||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
|
||||
return $quiz;
|
||||
}
|
||||
|
||||
private function getKrtekSeason(): Season
|
||||
{
|
||||
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
|
||||
$this->assertInstanceOf(Season::class, $season);
|
||||
|
||||
return $season;
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
}
|
||||
|
||||
private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string
|
||||
{
|
||||
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
|
||||
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
|
||||
|
||||
return (string) $input->first()->attr('value');
|
||||
return $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id), $formActionContains);
|
||||
}
|
||||
|
||||
public function testFinalizeSetsFinalizedAt(): void
|
||||
@@ -79,7 +45,7 @@ final class QuizFinalizeTest extends WebTestCase
|
||||
|
||||
public function testFinalizeRefusedWhenQuizHasErrors(): void
|
||||
{
|
||||
$season = $this->getKrtekSeason();
|
||||
$season = $this->getSeasonByCode('krtek');
|
||||
|
||||
$invalidQuiz = new Quiz();
|
||||
$invalidQuiz->name = 'Invalid Quiz';
|
||||
@@ -116,7 +82,7 @@ final class QuizFinalizeTest extends WebTestCase
|
||||
$this->assertResponseRedirects();
|
||||
|
||||
$this->entityManager->clear();
|
||||
$season = $this->getKrtekSeason();
|
||||
$season = $this->getSeasonByCode('krtek');
|
||||
$this->assertInstanceOf(Quiz::class, $season->activeQuiz);
|
||||
$this->assertSame('Quiz 1', $season->activeQuiz->name);
|
||||
}
|
||||
@@ -134,7 +100,7 @@ final class QuizFinalizeTest extends WebTestCase
|
||||
$this->assertResponseRedirects();
|
||||
|
||||
$this->entityManager->clear();
|
||||
$season = $this->getKrtekSeason();
|
||||
$season = $this->getSeasonByCode('krtek');
|
||||
$this->assertInstanceOf(Quiz::class, $season->activeQuiz);
|
||||
$this->assertSame('Quiz 2', $season->activeQuiz->name);
|
||||
}
|
||||
@@ -183,8 +149,7 @@ final class QuizFinalizeTest extends WebTestCase
|
||||
// Scrape the token before a candidate starts, since the button disappears afterwards
|
||||
$token = $this->getCsrfTokenFromOverview($quiz, '/unfinalize');
|
||||
|
||||
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => 'Tom']);
|
||||
$this->assertInstanceOf(Candidate::class, $candidate);
|
||||
$candidate = $this->getCandidate('Tom');
|
||||
$quizCandidate = new QuizCandidate($quiz, $candidate);
|
||||
$quizCandidate->started = new DateTimeImmutable();
|
||||
|
||||
@@ -229,6 +194,6 @@ final class QuizFinalizeTest extends WebTestCase
|
||||
self::assertResponseRedirects(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz2->id));
|
||||
|
||||
$this->entityManager->clear();
|
||||
$this->assertNotInstanceOf(Quiz::class, $this->getKrtekSeason()->activeQuiz);
|
||||
$this->assertNotInstanceOf(Quiz::class, $this->getSeasonByCode('krtek')->activeQuiz);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,47 +4,18 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Controller\Backoffice\QuizQuestionController;
|
||||
use Tvdt\Entity\Question;
|
||||
use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\User;
|
||||
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||
|
||||
#[CoversClass(QuizQuestionController::class)]
|
||||
final class QuizQuestionControllerTest extends WebTestCase
|
||||
final class QuizQuestionControllerTest extends AbstractControllerWebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = self::createClient();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
}
|
||||
|
||||
private function loginAsOwner(): void
|
||||
{
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
}
|
||||
|
||||
private function getQuizByName(string $name): Quiz
|
||||
{
|
||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
|
||||
return $quiz;
|
||||
}
|
||||
|
||||
public function testEditPreservesAnswerOrdering(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$question = null;
|
||||
@@ -111,7 +82,7 @@ final class QuizQuestionControllerTest extends WebTestCase
|
||||
|
||||
public function testReorderQuestionsWithinQuiz(): void
|
||||
{
|
||||
$this->loginAsOwner();
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$originalQuestions = $quiz->questions->toArray();
|
||||
@@ -144,4 +115,43 @@ final class QuizQuestionControllerTest extends WebTestCase
|
||||
$this->assertSame($originalLastId, (string) $reorderedQuestions[0]->id);
|
||||
$this->assertSame($originalFirstId, (string) $reorderedQuestions[\count($reorderedQuestions) - 1]->id);
|
||||
}
|
||||
|
||||
public function testEditIsDeniedForNonOwner(): void
|
||||
{
|
||||
$this->loginAs('test@example.org');
|
||||
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
$question = $quiz->questions->first();
|
||||
$this->assertInstanceOf(Question::class, $question);
|
||||
|
||||
$this->client->request(Request::METHOD_GET, \sprintf(
|
||||
'/backoffice/season/krtek/quiz/%s/question/%s/edit',
|
||||
$quiz->id,
|
||||
$question->id,
|
||||
));
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testReorderIsDeniedForNonOwner(): void
|
||||
{
|
||||
$quiz = $this->getQuizByName('Quiz 2');
|
||||
|
||||
// Scrape a valid CSRF token as the owner before switching to the non-owner account,
|
||||
// since the token is bound to the session, not the logged-in user.
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
|
||||
self::assertResponseIsSuccessful();
|
||||
$csrfToken = $crawler->filter('[data-bo--question-list-csrf-value]')->attr('data-bo--question-list-csrf-value');
|
||||
$this->assertNotEmpty($csrfToken);
|
||||
|
||||
$this->loginAs('test@example.org');
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/quiz/%s/questions/reorder', $quiz->id), [
|
||||
'_token' => $csrfToken,
|
||||
'ordering' => array_map(static fn (Question $q): string => (string) $q->id, $quiz->questions->toArray()),
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,40 +4,27 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Controller\Backoffice\SeasonController;
|
||||
use Tvdt\Entity\Candidate;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Entity\User;
|
||||
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||
|
||||
#[CoversClass(SeasonController::class)]
|
||||
final class SeasonControllerTest extends WebTestCase
|
||||
final class SeasonControllerTest extends AbstractControllerWebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = self::createClient();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
parent::setUp();
|
||||
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
}
|
||||
|
||||
public function testRegenerateSeasonCodeChangesTheCode(): void
|
||||
{
|
||||
$oldCode = 'krtek';
|
||||
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/%s/settings', $oldCode));
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$token = (string) $crawler->filter('form[action*="/regenerate-code"] input[name="_token"]')->first()->attr('value');
|
||||
$token = $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/%s/settings', $oldCode), '/regenerate-code');
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/%s/settings/regenerate-code', $oldCode), [
|
||||
'_token' => $token,
|
||||
@@ -54,13 +41,9 @@ final class SeasonControllerTest extends WebTestCase
|
||||
|
||||
public function testRegenerateSeasonCodeIsDeniedForNonOwner(): void
|
||||
{
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/settings');
|
||||
self::assertResponseIsSuccessful();
|
||||
$token = (string) $crawler->filter('form[action*="/regenerate-code"] input[name="_token"]')->first()->attr('value');
|
||||
$token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/settings', '/regenerate-code');
|
||||
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
$this->loginAs('test@example.org');
|
||||
|
||||
$this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/settings/regenerate-code', [
|
||||
'_token' => $token,
|
||||
@@ -69,29 +52,10 @@ final class SeasonControllerTest extends WebTestCase
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
private function getCandidate(string $name): Candidate
|
||||
{
|
||||
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => $name]);
|
||||
$this->assertInstanceOf(Candidate::class, $candidate);
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private function getCsrfTokenFromCandidatesTab(string $formActionContains): string
|
||||
{
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/candidates');
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
|
||||
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
|
||||
|
||||
return (string) $input->first()->attr('value');
|
||||
}
|
||||
|
||||
public function testRenameCandidate(): void
|
||||
{
|
||||
$candidate = $this->getCandidate('Tom');
|
||||
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/rename', $candidate->id));
|
||||
$token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/candidates', \sprintf('/candidate/%s/rename', $candidate->id));
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
|
||||
'_token' => $token,
|
||||
@@ -109,7 +73,7 @@ final class SeasonControllerTest extends WebTestCase
|
||||
public function testRenameCandidateToExistingNameShowsError(): void
|
||||
{
|
||||
$candidate = $this->getCandidate('Tom');
|
||||
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/rename', $candidate->id));
|
||||
$token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/candidates', \sprintf('/candidate/%s/rename', $candidate->id));
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
|
||||
'_token' => $token,
|
||||
@@ -128,7 +92,7 @@ final class SeasonControllerTest extends WebTestCase
|
||||
{
|
||||
$candidate = $this->getCandidate('Tom');
|
||||
$candidateId = $candidate->id;
|
||||
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/delete', $candidate->id));
|
||||
$token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/candidates', \sprintf('/candidate/%s/delete', $candidate->id));
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/delete', $candidate->id), [
|
||||
'_token' => $token,
|
||||
@@ -140,14 +104,48 @@ final class SeasonControllerTest extends WebTestCase
|
||||
$this->assertNotInstanceOf(Candidate::class, $this->entityManager->getRepository(Candidate::class)->find($candidateId));
|
||||
}
|
||||
|
||||
public function testAddCandidates(): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/add-candidate');
|
||||
$form = $this->client->getCrawler()->filter('form')->form([
|
||||
'add_candidates_form[candidates]' => "Nora\nPiet",
|
||||
]);
|
||||
$this->client->submit($form);
|
||||
|
||||
self::assertResponseRedirects('/backoffice/season/krtek/candidates');
|
||||
$this->entityManager->clear();
|
||||
|
||||
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
|
||||
$this->assertInstanceOf(Season::class, $season);
|
||||
$names = array_map(static fn (Candidate $candidate): string => $candidate->name, $season->candidates->toArray());
|
||||
$this->assertContains('Nora', $names);
|
||||
$this->assertContains('Piet', $names);
|
||||
}
|
||||
|
||||
public function testAddCandidatesViaTurboFrameReturnsEmptyFrame(): void
|
||||
{
|
||||
$this->client->xmlHttpRequest(Request::METHOD_GET, '/backoffice/season/krtek/add-candidate', server: ['HTTP_TURBO-FRAME' => 'add-candidates-modal-frame']);
|
||||
$form = $this->client->getCrawler()->filter('form')->form([
|
||||
'add_candidates_form[candidates]' => 'Sanne',
|
||||
]);
|
||||
$this->client->submit($form, [], ['HTTP_TURBO-FRAME' => 'add-candidates-modal-frame']);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$this->assertStringContainsString('<turbo-frame id="add-candidates-modal-frame"></turbo-frame>', (string) $this->client->getResponse()->getContent());
|
||||
$this->entityManager->clear();
|
||||
|
||||
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
|
||||
$this->assertInstanceOf(Season::class, $season);
|
||||
$names = array_map(static fn (Candidate $candidate): string => $candidate->name, $season->candidates->toArray());
|
||||
$this->assertContains('Sanne', $names);
|
||||
}
|
||||
|
||||
public function testRenameCandidateIsDeniedForNonOwner(): void
|
||||
{
|
||||
$candidate = $this->getCandidate('Tom');
|
||||
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/rename', $candidate->id));
|
||||
$token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/candidates', \sprintf('/candidate/%s/rename', $candidate->id));
|
||||
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
$this->loginAs('test@example.org');
|
||||
|
||||
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
|
||||
'_token' => $token,
|
||||
|
||||
@@ -4,11 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller\Backoffice;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Tvdt\Controller\Backoffice\SettingsController;
|
||||
@@ -17,43 +15,21 @@ use Tvdt\Entity\Quiz;
|
||||
use Tvdt\Entity\ResetPasswordRequest;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Entity\User;
|
||||
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||
|
||||
#[CoversClass(SettingsController::class)]
|
||||
final class SettingsControllerTest extends WebTestCase
|
||||
final class SettingsControllerTest extends AbstractControllerWebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = self::createClient();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
parent::setUp();
|
||||
|
||||
$this->loginAs('test@example.org');
|
||||
}
|
||||
|
||||
private function loginAs(string $email): void
|
||||
{
|
||||
$user = $this->getUserByEmail($email);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->client->loginUser($user);
|
||||
}
|
||||
|
||||
private function getUserByEmail(string $email): ?User
|
||||
{
|
||||
return $this->entityManager->getRepository(User::class)->findOneBy(['email' => $email]);
|
||||
}
|
||||
|
||||
private function getCsrfTokenFromSettings(string $formActionContains): string
|
||||
{
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/settings');
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
|
||||
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
|
||||
|
||||
return (string) $input->first()->attr('value');
|
||||
return $this->getCsrfTokenFromPage('/backoffice/settings', $formActionContains);
|
||||
}
|
||||
|
||||
public function testSettingsPageLoadsAndNavContainsSettingsLink(): void
|
||||
@@ -98,7 +74,6 @@ final class SettingsControllerTest extends WebTestCase
|
||||
$this->entityManager->clear();
|
||||
|
||||
$user = $this->getUserByEmail('test@example.org');
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
$this->assertTrue($hasher->isPasswordValid($user, 'NewPass123!'));
|
||||
|
||||
@@ -107,32 +82,21 @@ final class SettingsControllerTest extends WebTestCase
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testChangePasswordWithWrongCurrentPasswordIsRejected(): void
|
||||
/** @return iterable<string, array{string, string, string}> */
|
||||
public static function invalidPasswordChangeProvider(): iterable
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
|
||||
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([
|
||||
'change_user_password_form[currentPassword]' => 'wrong-password',
|
||||
'change_user_password_form[plainPassword][first]' => 'NewPass123!',
|
||||
'change_user_password_form[plainPassword][second]' => 'NewPass123!',
|
||||
]);
|
||||
$this->client->submit($form);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
$this->entityManager->clear();
|
||||
|
||||
$user = $this->getUserByEmail('test@example.org');
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
$this->assertTrue($hasher->isPasswordValid($user, TestFixtures::PASSWORD));
|
||||
yield 'wrong current password' => ['wrong-password', 'NewPass123!', 'NewPass123!'];
|
||||
yield 'mismatched repeat' => [TestFixtures::PASSWORD, 'NewPass123!', 'SomethingElse!'];
|
||||
}
|
||||
|
||||
public function testChangePasswordWithMismatchedRepeatIsRejected(): void
|
||||
#[DataProvider('invalidPasswordChangeProvider')]
|
||||
public function testChangePasswordIsRejected(string $currentPassword, string $first, string $second): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
|
||||
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([
|
||||
'change_user_password_form[currentPassword]' => TestFixtures::PASSWORD,
|
||||
'change_user_password_form[plainPassword][first]' => 'NewPass123!',
|
||||
'change_user_password_form[plainPassword][second]' => 'SomethingElse!',
|
||||
'change_user_password_form[currentPassword]' => $currentPassword,
|
||||
'change_user_password_form[plainPassword][first]' => $first,
|
||||
'change_user_password_form[plainPassword][second]' => $second,
|
||||
]);
|
||||
$this->client->submit($form);
|
||||
|
||||
@@ -140,7 +104,6 @@ final class SettingsControllerTest extends WebTestCase
|
||||
$this->entityManager->clear();
|
||||
|
||||
$user = $this->getUserByEmail('test@example.org');
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
$this->assertTrue($hasher->isPasswordValid($user, TestFixtures::PASSWORD));
|
||||
}
|
||||
@@ -157,9 +120,8 @@ final class SettingsControllerTest extends WebTestCase
|
||||
self::assertEmailCount(1);
|
||||
$this->entityManager->clear();
|
||||
|
||||
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('test@example.org'));
|
||||
$this->assertNotInstanceOf(User::class, $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']));
|
||||
$user = $this->getUserByEmail('new-address@example.org');
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->assertFalse($user->isVerified);
|
||||
|
||||
// User stays logged in
|
||||
@@ -179,7 +141,7 @@ final class SettingsControllerTest extends WebTestCase
|
||||
self::assertEmailCount(0);
|
||||
$this->entityManager->clear();
|
||||
|
||||
$this->assertInstanceOf(User::class, $this->getUserByEmail('test@example.org'));
|
||||
$this->getUserByEmail('test@example.org');
|
||||
}
|
||||
|
||||
public function testResendConfirmationEmailSendsEmail(): void
|
||||
@@ -200,8 +162,8 @@ final class SettingsControllerTest extends WebTestCase
|
||||
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/resend-confirmation');
|
||||
|
||||
$user = $this->getUserByEmail('test@example.org');
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$user->isVerified = true;
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/settings');
|
||||
@@ -242,7 +204,6 @@ final class SettingsControllerTest extends WebTestCase
|
||||
public function testChangePasswordInvalidatesResetPasswordRequests(): void
|
||||
{
|
||||
$user = $this->getUserByEmail('test@example.org');
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->createResetPasswordRequest($user);
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
|
||||
@@ -257,14 +218,12 @@ final class SettingsControllerTest extends WebTestCase
|
||||
$this->entityManager->clear();
|
||||
|
||||
$user = $this->getUserByEmail('test@example.org');
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $user]));
|
||||
}
|
||||
|
||||
public function testChangeEmailInvalidatesResetPasswordRequests(): void
|
||||
{
|
||||
$user = $this->getUserByEmail('test@example.org');
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->createResetPasswordRequest($user);
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/settings');
|
||||
@@ -277,7 +236,6 @@ final class SettingsControllerTest extends WebTestCase
|
||||
$this->entityManager->clear();
|
||||
|
||||
$user = $this->getUserByEmail('new-address@example.org');
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $user]));
|
||||
}
|
||||
|
||||
@@ -293,7 +251,7 @@ final class SettingsControllerTest extends WebTestCase
|
||||
self::assertResponseRedirects('/backoffice/settings');
|
||||
$this->entityManager->clear();
|
||||
|
||||
$this->assertInstanceOf(User::class, $this->getUserByEmail('test@example.org'));
|
||||
$this->getUserByEmail('test@example.org');
|
||||
}
|
||||
|
||||
public function testDeleteAccountRemovesSoleOwnerSeasonsAndKeepsSharedSeasons(): void
|
||||
@@ -309,7 +267,7 @@ final class SettingsControllerTest extends WebTestCase
|
||||
self::assertResponseRedirects();
|
||||
$this->entityManager->clear();
|
||||
|
||||
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('sole-owner@example.org'));
|
||||
$this->assertNotInstanceOf(User::class, $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'sole-owner@example.org']));
|
||||
|
||||
// Sole-owner season is removed, including its quiz
|
||||
$this->assertNotInstanceOf(Season::class, $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'doomd']));
|
||||
@@ -340,7 +298,7 @@ final class SettingsControllerTest extends WebTestCase
|
||||
self::assertResponseRedirects();
|
||||
$this->entityManager->clear();
|
||||
|
||||
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('user2@example.org'));
|
||||
$this->assertNotInstanceOf(User::class, $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']));
|
||||
|
||||
foreach (['krtek', 'bbbbb'] as $seasonCode) {
|
||||
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => $seasonCode]);
|
||||
@@ -378,7 +336,6 @@ final class SettingsControllerTest extends WebTestCase
|
||||
public function testDownloadDataRequiresVerifiedEmail(): void
|
||||
{
|
||||
$user = $this->getUserByEmail('test@example.org');
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$this->assertFalse($user->isVerified);
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data');
|
||||
@@ -389,8 +346,8 @@ final class SettingsControllerTest extends WebTestCase
|
||||
private function markUserVerified(string $email): void
|
||||
{
|
||||
$user = $this->getUserByEmail($email);
|
||||
$this->assertInstanceOf(User::class, $user);
|
||||
$user->isVerified = true;
|
||||
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Controller\EliminationController;
|
||||
use Tvdt\Entity\Elimination;
|
||||
use Tvdt\Helpers\Base64;
|
||||
|
||||
#[CoversClass(EliminationController::class)]
|
||||
final class EliminationControllerTest extends AbstractControllerWebTestCase
|
||||
{
|
||||
private Elimination $elimination;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$quiz = $this->getQuizByName('Quiz 1');
|
||||
|
||||
$this->elimination = new Elimination($quiz);
|
||||
$this->elimination->data = ['Tom' => Elimination::SCREEN_GREEN];
|
||||
|
||||
$this->entityManager->persist($this->elimination);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->loginAs('krtek-admin@example.org');
|
||||
}
|
||||
|
||||
public function testIndexIsDeniedForNonOwner(): void
|
||||
{
|
||||
$this->loginAs('test@example.org');
|
||||
|
||||
$this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s', $this->elimination->id));
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testIndexPageLoads(): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s', $this->elimination->id));
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSelectorExists('form');
|
||||
}
|
||||
|
||||
public function testIndexRedirectsToCandidateScreen(): void
|
||||
{
|
||||
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s', $this->elimination->id));
|
||||
$form = $crawler->filter('form')->form([
|
||||
'elimination_enter_name[name]' => 'Tom',
|
||||
]);
|
||||
$this->client->submit($form);
|
||||
|
||||
self::assertResponseRedirects(\sprintf('/elimination/%s/%s', $this->elimination->id, Base64::base64UrlEncode('Tom')));
|
||||
}
|
||||
|
||||
public function testCandidateScreenUnknownCandidateRedirectsWithFlash(): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s/%s', $this->elimination->id, Base64::base64UrlEncode('Nobody')));
|
||||
|
||||
self::assertResponseRedirects(\sprintf('/elimination/%s', $this->elimination->id));
|
||||
$this->client->followRedirect();
|
||||
self::assertSelectorTextContains('body', 'Kon kandidaat met naam Nobody niet vinden');
|
||||
}
|
||||
|
||||
public function testCandidateScreenCandidateNotInEliminationDataRedirectsWithFlash(): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s/%s', $this->elimination->id, Base64::base64UrlEncode('Claudia')));
|
||||
|
||||
self::assertResponseRedirects(\sprintf('/elimination/%s', $this->elimination->id));
|
||||
$this->client->followRedirect();
|
||||
self::assertSelectorTextContains('body', 'Kon geen kandidaat vinden met de naam Claudia in de eliminatie');
|
||||
}
|
||||
|
||||
public function testCandidateScreenRendersColour(): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s/%s', $this->elimination->id, Base64::base64UrlEncode('Tom')));
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSelectorExists(\sprintf('#%s', Elimination::SCREEN_GREEN));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
|
||||
@@ -15,18 +13,8 @@ use Tvdt\Controller\ResetPasswordController;
|
||||
use Tvdt\Entity\User;
|
||||
|
||||
#[CoversClass(ResetPasswordController::class)]
|
||||
final class ResetPasswordControllerTest extends WebTestCase
|
||||
final class ResetPasswordControllerTest extends AbstractControllerWebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = self::createClient();
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
}
|
||||
|
||||
public function testRequestPageLoads(): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/reset-password');
|
||||
@@ -35,22 +23,19 @@ final class ResetPasswordControllerTest extends WebTestCase
|
||||
$this->assertSelectorExists('form');
|
||||
}
|
||||
|
||||
public function testRequestWithUnknownEmailRedirectsToCheckEmail(): void
|
||||
/** @return iterable<string, array{string}> */
|
||||
public static function emailProvider(): iterable
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/reset-password');
|
||||
$form = $this->client->getCrawler()->filter('form')->form([
|
||||
'reset_password_request_form[email]' => 'unknown@example.org',
|
||||
]);
|
||||
$this->client->submit($form);
|
||||
|
||||
$this->assertResponseRedirects('/reset-password/check-email');
|
||||
yield 'unknown email' => ['unknown@example.org'];
|
||||
yield 'known email' => ['test@example.org'];
|
||||
}
|
||||
|
||||
public function testRequestWithKnownEmailRedirectsToCheckEmail(): void
|
||||
#[DataProvider('emailProvider')]
|
||||
public function testRequestRedirectsToCheckEmail(string $email): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/reset-password');
|
||||
$form = $this->client->getCrawler()->filter('form')->form([
|
||||
'reset_password_request_form[email]' => 'test@example.org',
|
||||
'reset_password_request_form[email]' => $email,
|
||||
]);
|
||||
$this->client->submit($form);
|
||||
|
||||
|
||||
@@ -6,21 +6,12 @@ namespace Tvdt\Tests\Controller;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Controller\WellKnownController;
|
||||
|
||||
#[CoversClass(WellKnownController::class)]
|
||||
final class WellKnownControllerTest extends WebTestCase
|
||||
final class WellKnownControllerTest extends AbstractControllerWebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = self::createClient();
|
||||
}
|
||||
|
||||
public function testChangePasswordRedirectsToSettings(): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/.well-known/change-password');
|
||||
|
||||
@@ -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;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Safe\Exceptions\UrlException;
|
||||
use Tvdt\Helpers\Base64;
|
||||
|
||||
#[CoversClass(Base64::class)]
|
||||
final class Base64Test extends TestCase
|
||||
{
|
||||
public function testBase64UrlEncode(): void
|
||||
/** @return iterable<string, array{string, string}> */
|
||||
public static function pairProvider(): iterable
|
||||
{
|
||||
$this->assertSame('TWFyaWpu', Base64::base64UrlEncode('Marijn'));
|
||||
$this->assertSame('UGhpbGluZQ', Base64::base64UrlEncode('Philine'));
|
||||
|
||||
$this->assertSame('_g', Base64::base64UrlEncode(\chr(254)));
|
||||
$this->assertSame('-g', Base64::base64UrlEncode(\chr(250)));
|
||||
yield 'Marijn' => ['Marijn', 'TWFyaWpu'];
|
||||
yield 'Philine' => ['Philine', 'UGhpbGluZQ'];
|
||||
yield 'byte 254' => [\chr(254), '_g'];
|
||||
yield 'byte 250' => [\chr(250), '-g'];
|
||||
}
|
||||
|
||||
public function testBase64UrlDecode(): void
|
||||
#[DataProvider('pairProvider')]
|
||||
public function testBase64UrlEncode(string $decoded, string $encoded): void
|
||||
{
|
||||
$this->assertSame('Marijn', Base64::base64UrlDecode('TWFyaWpu'));
|
||||
$this->assertSame('Philine', Base64::base64UrlDecode('UGhpbGluZQ'));
|
||||
$this->assertSame($encoded, Base64::base64UrlEncode($decoded));
|
||||
}
|
||||
|
||||
$this->assertSame(\chr(254), Base64::base64UrlDecode('_g'));
|
||||
$this->assertSame(\chr(250), Base64::base64UrlDecode('-g'));
|
||||
#[DataProvider('pairProvider')]
|
||||
public function testBase64UrlDecode(string $decoded, string $encoded): void
|
||||
{
|
||||
$this->assertSame($decoded, Base64::base64UrlDecode($encoded));
|
||||
}
|
||||
|
||||
public function testBase64UrlDecodeCanHandlePadding(): void
|
||||
|
||||
@@ -4,41 +4,31 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Helpers;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Tvdt\Helpers\FilenameSanitizer;
|
||||
|
||||
#[CoversClass(FilenameSanitizer::class)]
|
||||
final class FilenameSanitizerTest extends TestCase
|
||||
{
|
||||
public function testReplacesSpacesWithDashes(): void
|
||||
/** @return iterable<string, array{string, string}> */
|
||||
public static function sanitizeProvider(): iterable
|
||||
{
|
||||
$this->assertSame('Krtek-Weekend', FilenameSanitizer::sanitize('Krtek Weekend'));
|
||||
yield 'replaces spaces with dashes' => ['Krtek Weekend', 'Krtek-Weekend'];
|
||||
yield 'strips path traversal' => ['../../etc/passwd', 'etc-passwd'];
|
||||
yield 'strips forward slash' => ['a/b', 'a-b'];
|
||||
yield 'strips backslash' => ['a\\b', 'a-b'];
|
||||
yield 'strips control characters and special symbols' => ["Quiz #1 <script>\0", 'Quiz-1-script'];
|
||||
yield 'transliterates unicode to ascii' => ['Wéird Ñame', 'Weird-Name'];
|
||||
yield 'transliterates at sign in email' => ['test@example.org', 'test-example-org'];
|
||||
yield 'returns unnamed for empty input' => ['', 'unnamed'];
|
||||
yield 'returns unnamed for fully stripped input' => ['///', 'unnamed'];
|
||||
}
|
||||
|
||||
public function testStripsPathSeparatorsAndTraversal(): void
|
||||
#[DataProvider('sanitizeProvider')]
|
||||
public function testSanitize(string $input, string $expected): void
|
||||
{
|
||||
$this->assertSame('etc-passwd', FilenameSanitizer::sanitize('../../etc/passwd'));
|
||||
$this->assertSame('a-b', FilenameSanitizer::sanitize('a/b'));
|
||||
$this->assertSame('a-b', FilenameSanitizer::sanitize('a\\b'));
|
||||
}
|
||||
|
||||
public function testStripsControlCharactersAndSpecialSymbols(): void
|
||||
{
|
||||
$this->assertSame('Quiz-1-script', FilenameSanitizer::sanitize("Quiz #1 <script>\0"));
|
||||
}
|
||||
|
||||
public function testTransliteratesUnicodeToAscii(): void
|
||||
{
|
||||
$this->assertSame('Weird-Name', FilenameSanitizer::sanitize('Wéird Ñame'));
|
||||
}
|
||||
|
||||
public function testTransliteratesAtSignInEmail(): void
|
||||
{
|
||||
$this->assertSame('test-example-org', FilenameSanitizer::sanitize('test@example.org'));
|
||||
}
|
||||
|
||||
public function testReturnsUnnamedForEmptyOrFullyStrippedInput(): void
|
||||
{
|
||||
$this->assertSame('unnamed', FilenameSanitizer::sanitize(''));
|
||||
$this->assertSame('unnamed', FilenameSanitizer::sanitize('///'));
|
||||
$this->assertSame($expected, FilenameSanitizer::sanitize($input));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,4 +53,22 @@ final class CandidateRepositoryTest extends DatabaseTestCase
|
||||
);
|
||||
$this->assertNotInstanceOf(Candidate::class, $result);
|
||||
}
|
||||
|
||||
/** Candidate names are only unique per season, so a same-named candidate in another season must not leak in. */
|
||||
public function testGetCandidateByHashScopesByCandidateSeasonNotJustName(): void
|
||||
{
|
||||
$krtekSeason = $this->getSeasonByCode('krtek');
|
||||
$anotherSeason = $this->getSeasonByCode('bbbbb');
|
||||
|
||||
$duplicateNamedCandidate = new Candidate('Claudia');
|
||||
$anotherSeason->addCandidate($duplicateNamedCandidate);
|
||||
$this->entityManager->persist($duplicateNamedCandidate);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$candidate = $this->candidateRepository->getCandidateByHash($krtekSeason, 'Q2xhdWRpYQ');
|
||||
|
||||
$this->assertInstanceOf(Candidate::class, $candidate);
|
||||
$this->assertSame($krtekSeason, $candidate->season);
|
||||
$this->assertNotSame($duplicateNamedCandidate, $candidate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,25 +5,28 @@ declare(strict_types=1);
|
||||
namespace Tvdt\Tests\Repository;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tvdt\Entity\Season;
|
||||
use Tvdt\Repository\SeasonRepository;
|
||||
|
||||
#[CoversClass(SeasonRepository::class)]
|
||||
final class SeasonRepositoryTest extends DatabaseTestCase
|
||||
{
|
||||
public function testGetSeasonsForUser(): void
|
||||
/** @return iterable<string, array{string, string}> */
|
||||
public static function userSeasonsProvider(): iterable
|
||||
{
|
||||
$user = $this->getUserByEmail('krtek-admin@example.org');
|
||||
yield 'krtek admin' => ['krtek-admin@example.org', 'krtek'];
|
||||
yield 'user1' => ['user1@example.org', 'bbbbb'];
|
||||
}
|
||||
|
||||
#[DataProvider('userSeasonsProvider')]
|
||||
public function testGetSeasonsForUser(string $email, string $expectedSeasonCode): void
|
||||
{
|
||||
$user = $this->getUserByEmail($email);
|
||||
|
||||
$seasons = $this->seasonRepository->getSeasonsForUser($user);
|
||||
$this->assertCount(1, $seasons);
|
||||
$this->assertSame('krtek', $seasons[0]->seasonCode);
|
||||
|
||||
$user = $this->getUserByEmail('user1@example.org');
|
||||
|
||||
$seasons = $this->seasonRepository->getSeasonsForUser($user);
|
||||
$this->assertCount(1, $seasons);
|
||||
$this->assertSame('bbbbb', $seasons[0]->seasonCode);
|
||||
$this->assertSame($expectedSeasonCode, $seasons[0]->seasonCode);
|
||||
}
|
||||
|
||||
public function testUserWithMultipleSeasons(): void
|
||||
|
||||
@@ -117,6 +117,10 @@
|
||||
<source>Are you sure you want to delete this quiz?</source>
|
||||
<target>Weet je zeker dat je deze test wilt verwijderen?</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="6XTrab." resname="Are you sure you want to reset progress for this candidate? Their given answers for this quiz will be deleted.">
|
||||
<source>Are you sure you want to reset progress for this candidate? Their given answers for this quiz will be deleted.</source>
|
||||
<target>Weet je zeker dat je de voortgang van deze kandidaat wilt resetten? De ingevulde antwoorden voor deze test worden verwijderd.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="4bcq6sL" resname="Assign">
|
||||
<source>Assign</source>
|
||||
<target>Toewijzen</target>
|
||||
@@ -161,6 +165,10 @@
|
||||
<source>Candidate not found</source>
|
||||
<target>Kandidaat niet gevonden</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="fl.hjdA" resname="Candidate progress reset">
|
||||
<source>Candidate progress reset</source>
|
||||
<target>Voortgang kandidaat gereset</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="QH4e_Ho" resname="Candidate renamed">
|
||||
<source>Candidate renamed</source>
|
||||
<target>Kandidaat hernoemd</target>
|
||||
@@ -541,6 +549,10 @@
|
||||
<source>Number of dropouts:</source>
|
||||
<target>Aantal afvallers:</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="vxaREnf" resname="One candidate per line">
|
||||
<source>One candidate per line</source>
|
||||
<target>Eén kandidaat per regel</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="_SqArFZ" resname="Open">
|
||||
<source>Open</source>
|
||||
<target>Openen</target>
|
||||
@@ -761,6 +773,10 @@
|
||||
<source>Reset password</source>
|
||||
<target>Wachtwoord herstellen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="SSMxy68" resname="Reset progress">
|
||||
<source>Reset progress</source>
|
||||
<target>Voortgang resetten</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="eyayNGN" resname="Reset your password">
|
||||
<source>Reset your password</source>
|
||||
<target>Wachtwoord herstellen</target>
|
||||
|
||||
Reference in New Issue
Block a user