Compare commits

..

10 Commits

Author SHA1 Message Date
Marijn 97cec66083 Add Inky-based, WIDM-styled email templates (#212)
Adds twig/inky-extra and twig/cssinliner-extra to build the password
reset and email confirmation emails from a shared responsive layout,
styled after the quiz's green/black look, with Dutch copy in the
show's dramatic tone.
2026-07-12 20:57:16 +00:00
Marijn ee408cd065 Share the self-signed Caddy CA/cert across worktrees (#211)
Each worktree previously bind-mounted its own frankenphp/data, so
Caddy minted a fresh CA and cert per checkout and just trust-cert had
to be re-run every time. Anchor CADDY_DATA_DIR at the shared .git
common dir instead, so all worktrees of the same clone reuse one CA.
2026-07-12 20:12:35 +00:00
Marijn b4a27a7c0d Migrate frontend to TypeScript with Deno-based tooling (#209)
* Migrate frontend to TypeScript with Deno-based tooling (#206)

Compiles assets/*.ts via sensiolabs/typescript-bundle (standalone SWC
binary) and adds Deno for formatting, linting, type-checking, and
tests, keeping the project's no-Node/npm approach intact. Wires all
four into CI, the Justfile, and the pre-commit hook.

* fix: build TypeScript assets before running PHPUnit in CI

The tests job ran bin/console sass:build but never typescript:build,
so var/typescript/ didn't exist and any page rendering the importmap
(e.g. backoffice/base.html.twig) errored during tests.

* test: add regression test for backoffice navbar-toggler dead target

Guards against the navbar-toggler button pointing at a collapse
target (#navbarSupportedContent) that isn't rendered for the current
user — the bug hit on the login page before #210 restructured the
nav to always render at least one item (the Releases link) regardless
of auth state.

* fix: stop hand-enumerating TS files for deno check

deno check assets/*.ts assets/controllers/*.ts assets/controllers/bo/*.ts
was duplicated in CI and the Justfile, and silently misses any new
controller subdirectory (fmt/lint/test already recurse assets/ via
deno.json). Add a top-level exclude for assets/vendor/ (respected by
all deno subcommands, unlike the per-task include/exclude blocks) so
deno check assets/ can recurse safely instead.

* fix: GitHubReleasesService date parsing and falsy release name

- Move the release-mapping array_map inside the try block so a
  malformed published_at (or any other parse failure) degrades to the
  same empty-list fallback as an HTTP failure, instead of throwing
  uncaught out of the cache callback.
- Stop treating a release literally named "0" as unnamed — the old
  `?:` fallback is falsy for that string and silently substituted the
  tag name instead.
- Render release dates in UTC explicitly; Twig's date filter otherwise
  silently converts to the app's default timezone (Europe/Amsterdam),
  which could show the wrong calendar day for releases near midnight.

* docs: add scope-creep-as-a-service rule to CLAUDE.md

Per user instruction: when a review turns up a real bug outside the
current task's scope, fix it in the same MR (with a regression test)
rather than just reporting it, unless it needs a human design call.
2026-07-12 22:11:00 +02:00
Marijn 4e98909f11 Add GitHub Releases modal to backoffice (#210)
* Add GitHub Releases modal to backoffice

Shows release notes (fetched from the GitHub API, cached for an hour,
Markdown rendered via league/commonmark) from a top-right nav button
available whether logged in or out, with the current version shown in
the modal header. Also clears cache.app on container start so the
release cache can't carry stale data across redeploys of the
var/ Docker volume.

* Render release notes markdown via twig/markdown-extra instead of manual CommonMark

twig/extra-bundle already wires the markdown filter to league/commonmark
automatically once twig/markdown-extra is installed, so the service no
longer needs to instantiate and call CommonMarkConverter itself.

* Explicitly disallow unsafe link protocols in rendered markdown

twig/extra-bundle's default (allow_unsafe_links: true) permits
javascript:/vbscript:/file:/data: URLs in rendered links, kept for
backwards compatibility. Disable it explicitly since we now render
release note markdown through this filter.

* Autolink bare URLs in rendered release notes

The Twig CommonMark converter only registers CommonMarkCoreExtension by
default, which doesn't turn bare URLs into clickable links (unlike
GitHub's own renderer). Register CommonMark's AutolinkExtension via the
twig.markdown.league_extension tag so PR/compare URLs in release notes
render as actual hyperlinks instead of plain text.

* Address review feedback on GitHubReleasesService and tests

- Extract the cache callback into a named fetchReleases() method instead
  of an inline closure with all the fetch/parse logic.
- Simplify the User-Agent header to just "TijdVoorDeTest".
- Add a 5s HTTP timeout so a slow GitHub API can't block the request.
- Cache HTTP failures for only 60s instead of the full hour, so a brief
  GitHub outage doesn't suppress releases for as long.
- Replace deprecated word-wrap with overflow-wrap in release-notes CSS.
- Extract duplicated mock setup in ReleasesControllerTest into a helper.
- Reword the "could not load" translation ("Kan" instead of "Kon").

* Address second round of CodeRabbit feedback

- Escape raw HTML embedded in release-note markdown instead of passing
  it through, via twig_extra.commonmark.html_input: escape.
- Don't cache GitHub API failures at all (set $save = false in the
  cache callback) instead of caching them for a short TTL, so the very
  next request retries immediately.
- Sort releases by published_at descending before mapping, so the
  first entry (used for the current-version badge and default-expanded
  accordion item) is guaranteed to be the newest regardless of the
  order GitHub's API happens to return.
2026-07-12 18:59:36 +00:00
Marijn a6f1c3cecd Bold the correct answer in the raw answers export sheet (#208)
Makes it easy to visually spot which answer was correct when scanning
the GDPR export's per-candidate/per-question crosstab.
2026-07-12 18:56:46 +02:00
Marijn 8382900b9e Add reset progress action for a candidate's quiz attempt, and modal-based candidate editing (#204)
* feat: add reset progress action for a candidate's quiz attempt

Admins previously had no way to let a candidate retake a quiz once
started. Adds a backoffice action that clears a candidate's given
answers and start timestamp for a quiz, closes #20.

* fix: wrap candidate progress reset in a transaction

Avoids leaving a candidate half-reset (answers deleted but started
still set) if the second flush fails. Addresses CodeRabbit review
comment on PR #204.

* feat: open candidate edit/add forms in modals with dirty-aware dismissal

Rename-candidate modal always blocked backdrop-click dismissal via a
hardcoded static backdrop; wire it into the existing bo--modal
controller so outside clicks/Escape only get blocked once the name
field has actually been edited. Also move "Add Candidate" from a
full-page navigation into the same turbo-frame modal pattern already
used for editing quiz questions, keeping the full-page form as a
fallback for non-JS/turbo requests.

* feat: show a hint for the candidates textarea and gate translations in CI

Add a help hint on the add-candidates form explaining one candidate per
line, and translate it plus a few strings from the earlier reset-progress
feature that had been missed. Also add a CI step that re-runs
translation:extract and fails the build if it produces uncommitted
changes, so untranslated/stale strings can't slip through review again.

* Just links
2026-07-12 18:31:06 +02:00
dependabot[bot] c2637481f2 build(deps-dev): bump friendsofphp/php-cs-fixer (#207)
Bumps the dev-dependencies group with 1 update: [friendsofphp/php-cs-fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer).


Updates `friendsofphp/php-cs-fixer` from 3.95.12 to 3.95.13
- [Release notes](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases)
- [Changelog](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/compare/v3.95.12...v3.95.13)

---
updated-dependencies:
- dependency-name: friendsofphp/php-cs-fixer
  dependency-version: 3.95.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-10 23:55:45 +00:00
Marijn 938456087a fix: support more than 25 columns in data export sheets (#205)
range('A', ...) silently truncates its end argument to one byte once a
sheet needs a column past 'Z' (e.g. 26+ questions), triggering a PHP
warning and mis-sizing columns. Replace it with a helper that walks
column indexes via Coordinate::stringFromColumnIndex, which handles
multi-letter columns correctly.

Fixes PHP-SYMFONY-3Z
2026-07-10 19:30:37 +02:00
Marijn 33a0e8a584 Avoid dev port clashes and isolate docker compose per git worktree (#203)
* Avoid dev port clashes and isolate docker compose per git worktree

Default dev ports (80/443/5432) clash with other projects' compose
stacks. Remap them to 8080/8443/5433+ by default, and add `just init`
(auto-run by `just up`) to generate a per-worktree `.env.local` with a
unique COMPOSE_PROJECT_NAME, image tag, and free host ports, so
multiple worktrees can run `just up` concurrently without sharing
containers, volumes, images, or ports.

* Pin CI to port 80 for the HTTP reachability check; use 5430 as default Postgres dev port

CI runs in an isolated single-purpose runner with no port-clash concern,
so pin the HTTP reachability check back to port 80 explicitly rather
than changing the dev default.

Also move the default dev Postgres port range from 5433 to 5430, since
5433 clashes with other commonly used local projects.

* Fix Justfile init: propagate free_port failures and use portable hash

free_port exhaustion was swallowed inside a command substitution used as
an echo argument, silently writing empty ports to .env.local. shasum is
also not guaranteed on stripped-down Linux hosts.
2026-07-10 18:14:39 +02:00
Marijn 2fd15ba8fa test: expand coverage, dedupe data-driven tests, extract shared WebTestCase base (#201)
* test: expand coverage, dedupe data-driven tests, extract shared WebTestCase base

- Add #[CoversClass] to Base64Test and FilenameSanitizerTest
- Merge near-duplicate test methods into #[DataProvider] cases across
  ResetPasswordControllerTest, SettingsControllerTest, ClaimSeasonCommandTest,
  FilenameSanitizerTest, Base64Test, and SeasonRepositoryTest
- Add integration tests for previously untested controllers: public
  QuizController (quiz-taking flow), LoginController, RegistrationController,
  EliminationController, and PrepareEliminationController
- Add unit tests for Elimination and BankQuestion entity logic
- Extract shared WebTestCase setup/helpers (client, entityManager, login,
  entity lookups, CSRF token scraping) into AbstractControllerWebTestCase,
  removing duplicated boilerplate from all 14 WebTestCase files

* test: address PR review feedback

- Scope AbstractControllerWebTestCase::getCandidate/getQuizByName by
  season code (both Candidate and Quiz are only unique per season, not
  system-wide) and add a CandidateRepository regression test guarding
  against same-named candidates in different seasons
- Add missing entityManager->clear() before verifying DB state after a
  POST in PrepareEliminationControllerTest and QuestionBankControllerTest
- Add non-owner denial tests for BackofficeController::exportQuiz and
  QuizQuestionController::edit/reorder, which had IsGranted checks with
  no test coverage

* ci: publish PHPUnit coverage to GitHub code coverage

- Generate a Cobertura report alongside the existing JUnit report and
  upload it with actions/upload-code-coverage so coverage shows up on
  PRs and the default branch via GitHub's code coverage feature
- Add a step to copy both reports out of the php container before
  publishing them, since var/ is a Docker volume (see the Dockerfile's
  VOLUME /app/var/) and isn't bind-mounted to the runner — this also
  fixes the existing JUnit report publishing step, which was silently
  looking at a path that never had contets on the runner

* ci: fix coverage report paths and tolerate Code Quality not yet enabled

- Write PHPUnit's JUnit and Cobertura reports to reports/ instead of
  var/, since var/ is a Docker volume (Dockerfile's VOLUME /app/var/)
  that isn't bind-mounted to the runner - reports written there never
  reached the host, which is also why the prior junit.xml publish step
  had nothing to read. reports/ is a plain path under the project's
  bind mount, so no extra copy-out step is needed
- Set fail-on-error: false on the coverage upload step: it 404s until
  "Code Quality" is turned on for the repo under Settings > Code
  security > Code quality, a one-time manual step
2026-07-10 18:06:48 +02:00
92 changed files with 4415 additions and 1044 deletions
+21 -1
View File
@@ -12,7 +12,12 @@ while IFS= read -r file; do
[[ -n "$file" ]] && STAGED_TWIG+=("$file") [[ -n "$file" ]] && STAGED_TWIG+=("$file")
done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.twig$' || true) done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.twig$' || true)
if [[ ${#STAGED_PHP[@]} -eq 0 && ${#STAGED_TWIG[@]} -eq 0 ]]; then STAGED_TS=()
while IFS= read -r file; do
[[ -n "$file" ]] && STAGED_TS+=("$file")
done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.ts$' || true)
if [[ ${#STAGED_PHP[@]} -eq 0 && ${#STAGED_TWIG[@]} -eq 0 && ${#STAGED_TS[@]} -eq 0 ]]; then
exit 0 exit 0
fi fi
@@ -46,3 +51,18 @@ if [[ ${#STAGED_TWIG[@]} -gt 0 ]]; then
"${DOCKER_CMD[@]}" vendor/bin/twig-cs-fixer fix "${STAGED_TWIG[@]}" "${DOCKER_CMD[@]}" vendor/bin/twig-cs-fixer fix "${STAGED_TWIG[@]}"
git add "${STAGED_TWIG[@]}" git add "${STAGED_TWIG[@]}"
fi fi
if [[ ${#STAGED_TS[@]} -gt 0 ]]; then
echo "TypeScript (${#STAGED_TS[@]} file(s)): Deno fmt → lint → check"
echo " → Deno fmt"
"${DOCKER_CMD[@]}" deno fmt "${STAGED_TS[@]}"
git add "${STAGED_TS[@]}"
echo " → Deno lint"
"${DOCKER_CMD[@]}" deno lint --fix "${STAGED_TS[@]}"
git add "${STAGED_TS[@]}"
echo " → Deno check"
"${DOCKER_CMD[@]}" deno check "${STAGED_TS[@]}"
fi
+50 -2
View File
@@ -70,6 +70,8 @@ jobs:
set: | set: |
*.cache-from=type=gha,scope=${{github.ref}}-devbuild *.cache-from=type=gha,scope=${{github.ref}}-devbuild
- name: Start services - name: Start services
env:
HTTP_PORT: "80"
run: docker compose up php database --wait --no-build run: docker compose up php database --wait --no-build
- name: Warm up dev cache - name: Warm up dev cache
run: docker compose exec -T php bin/console cache:warmup --env=dev run: docker compose exec -T php bin/console cache:warmup --env=dev
@@ -93,6 +95,31 @@ jobs:
id: rector id: rector
continue-on-error: true continue-on-error: true
run: docker compose exec -T php vendor/bin/rector process --dry-run --no-progress-bar --output-format=github run: docker compose exec -T php vendor/bin/rector process --dry-run --no-progress-bar --output-format=github
- name: Translations
id: translations
continue-on-error: true
run: |
docker compose exec -T php bin/console translation:extract --force --format=xliff --sort=asc nl
if ! git diff --exit-code -- translations; then
echo "::error::Translations are out of date. Run 'just translations' and commit the changes."
exit 1
fi
- name: TypeScript Formatting
id: ts_fmt
continue-on-error: true
run: docker compose exec -T php deno fmt --check assets/
- name: TypeScript Lint
id: ts_lint
continue-on-error: true
run: docker compose exec -T php deno lint assets/
- name: TypeScript Type Check
id: ts_check
continue-on-error: true
run: docker compose exec -T php deno check assets/
- name: TypeScript Tests
id: ts_test
continue-on-error: true
run: docker compose exec -T php deno test assets/
- name: Check HTTP reachability - name: Check HTTP reachability
run: curl -v --fail-with-body http://localhost run: curl -v --fail-with-body http://localhost
- name: Assert all checks passed - name: Assert all checks passed
@@ -111,6 +138,11 @@ jobs:
check "Twig Coding Style" "${{ steps.twig_cs.outcome }}" check "Twig Coding Style" "${{ steps.twig_cs.outcome }}"
check "PHPStan" "${{ steps.phpstan.outcome }}" check "PHPStan" "${{ steps.phpstan.outcome }}"
check "Rector" "${{ steps.rector.outcome }}" check "Rector" "${{ steps.rector.outcome }}"
check "Translations" "${{ steps.translations.outcome }}"
check "TypeScript Formatting" "${{ steps.ts_fmt.outcome }}"
check "TypeScript Lint" "${{ steps.ts_lint.outcome }}"
check "TypeScript Type Check" "${{ steps.ts_check.outcome }}"
check "TypeScript Tests" "${{ steps.ts_test.outcome }}"
exit $failed exit $failed
tests: tests:
@@ -123,6 +155,7 @@ jobs:
checks: write checks: write
pull-requests: write pull-requests: write
contents: read contents: read
code-quality: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
@@ -143,6 +176,8 @@ jobs:
run: docker compose up php database --wait --no-build run: docker compose up php database --wait --no-build
- name: Build SCSS - name: Build SCSS
run: docker compose exec -T php bin/console sass:build run: docker compose exec -T php bin/console sass:build
- name: Build TypeScript
run: docker compose exec -T php bin/console typescript:build
- name: Create test database - name: Create test database
run: docker compose exec -T php bin/console -e test doctrine:database:create run: docker compose exec -T php bin/console -e test doctrine:database:create
- name: Run migrations - name: Run migrations
@@ -150,13 +185,26 @@ jobs:
- name: Load fixtures - name: Load fixtures
run: docker compose exec -T php bin/console -e test doctrine:fixtures:load --no-interaction --group=test run: docker compose exec -T php bin/console -e test doctrine:fixtures:load --no-interaction --group=test
- name: Run PHPUnit - name: Run PHPUnit
run: docker compose exec -T -e MAILER_DSN=null://null php vendor/bin/phpunit --log-junit var/phpunit/junit.xml # 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 - name: Publish PHPUnit test results
if: always() if: always()
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6 uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6
with: with:
report_paths: var/phpunit/junit.xml report_paths: reports/junit.xml
check_name: PHPUnit check_name: PHPUnit
- name: Upload code coverage
# Requires "Code Quality" to be enabled for this repository under
# Settings > Code security > Code quality; fail-on-error is false so CI
# doesn't go red before that one-time, manual repository setting is turned on.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: actions/upload-code-coverage@82c7aee3fb2ad768e00b00a0a8d749c5815085b6 # v1
with:
file: reports/coverage/cobertura.xml
language: PHP
label: phpunit
fail-on-error: false
- name: Doctrine Schema Validator - name: Doctrine Schema Validator
run: docker compose exec -T php bin/console -e test doctrine:schema:validate run: docker compose exec -T php bin/console -e test doctrine:schema:validate
+1
View File
@@ -1,4 +1,5 @@
/frankenphp/data /frankenphp/data
/reports/
### Generated by gibo (https://github.com/simonwhitaker/gibo) ### Generated by gibo (https://github.com/simonwhitaker/gibo)
### https://raw.github.com/github/gitignore/6eeebe6f49678aacd8311ce079842c971b3ebe96/Symfony.gitignore ### https://raw.github.com/github/gitignore/6eeebe6f49678aacd8311ce079842c971b3ebe96/Symfony.gitignore
+10 -1
View File
@@ -4,7 +4,6 @@
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" packagePrefix="Tvdt\" /> <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" packagePrefix="Tvdt\" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" packagePrefix="Tvdt\Tests\" /> <sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" packagePrefix="Tvdt\Tests\" />
<sourceFolder url="file://$MODULE_DIR$/vendor/maennchen/zipstream-php/test" isTestSource="true" />
<excludeFolder url="file://$MODULE_DIR$/vendor/clue/ndjson-react" /> <excludeFolder url="file://$MODULE_DIR$/vendor/clue/ndjson-react" />
<excludeFolder url="file://$MODULE_DIR$/vendor/composer" /> <excludeFolder url="file://$MODULE_DIR$/vendor/composer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/collections" /> <excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/collections" />
@@ -172,6 +171,16 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/file-filter" /> <excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/file-filter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/object-mapper" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/object-mapper" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfonycasts/reset-password-bundle" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfonycasts/reset-password-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/dflydev/dot-access-data" />
<excludeFolder url="file://$MODULE_DIR$/vendor/league/commonmark" />
<excludeFolder url="file://$MODULE_DIR$/vendor/league/config" />
<excludeFolder url="file://$MODULE_DIR$/vendor/lorenzo/pinky" />
<excludeFolder url="file://$MODULE_DIR$/vendor/nette/schema" />
<excludeFolder url="file://$MODULE_DIR$/vendor/nette/utils" />
<excludeFolder url="file://$MODULE_DIR$/vendor/tijsverkoyen/css-to-inline-styles" />
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/cssinliner-extra" />
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/inky-extra" />
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/markdown-extra" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
Generated
+11
View File
@@ -206,6 +206,16 @@
<path value="$PROJECT_DIR$/vendor/martin-georgiev/postgresql-for-doctrine" /> <path value="$PROJECT_DIR$/vendor/martin-georgiev/postgresql-for-doctrine" />
<path value="$PROJECT_DIR$/vendor/symfony/object-mapper" /> <path value="$PROJECT_DIR$/vendor/symfony/object-mapper" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/reset-password-bundle" /> <path value="$PROJECT_DIR$/vendor/symfonycasts/reset-password-bundle" />
<path value="$PROJECT_DIR$/vendor/lorenzo/pinky" />
<path value="$PROJECT_DIR$/vendor/tijsverkoyen/css-to-inline-styles" />
<path value="$PROJECT_DIR$/vendor/twig/inky-extra" />
<path value="$PROJECT_DIR$/vendor/twig/markdown-extra" />
<path value="$PROJECT_DIR$/vendor/twig/cssinliner-extra" />
<path value="$PROJECT_DIR$/vendor/league/commonmark" />
<path value="$PROJECT_DIR$/vendor/league/config" />
<path value="$PROJECT_DIR$/vendor/nette/utils" />
<path value="$PROJECT_DIR$/vendor/nette/schema" />
<path value="$PROJECT_DIR$/vendor/dflydev/dot-access-data" />
</include_path> </include_path>
</component> </component>
<component name="PhpInterpreters"> <component name="PhpInterpreters">
@@ -403,6 +413,7 @@
<component name="PhpUnit"> <component name="PhpUnit">
<phpunit_settings> <phpunit_settings>
<phpunit_by_interpreter interpreter_id="96512cb2-7b9e-4e1d-bfa2-bf7f3be424c8" bootstrap_file_path="./tests/bootstrap.php" configuration_file_path="./phpunit.dist.xml" custom_loader_path="/app/vendor/autoload.php" phpunit_phar_path="" use_bootstrap_file="true" use_configuration_file="true" /> <phpunit_by_interpreter interpreter_id="96512cb2-7b9e-4e1d-bfa2-bf7f3be424c8" bootstrap_file_path="./tests/bootstrap.php" configuration_file_path="./phpunit.dist.xml" custom_loader_path="/app/vendor/autoload.php" phpunit_phar_path="" use_bootstrap_file="true" use_configuration_file="true" />
<PhpUnitSettings custom_loader_path="$PROJECT_DIR$/vendor/autoload.php" />
</phpunit_settings> </phpunit_settings>
</component> </component>
<component name="Psalm"> <component name="Psalm">
+32 -2
View File
@@ -23,7 +23,7 @@ Tech Stack:
- **ORM**: Doctrine - **ORM**: Doctrine
- **Server**: FrankenPHP with Caddy - **Server**: FrankenPHP with Caddy
- **Container**: Docker Compose - **Container**: Docker Compose
- **Frontend**: Twig templates with SASS (via asset mapper) - **Frontend**: Twig templates with SASS and TypeScript (via asset mapper)
- **Testing**: PHPUnit 13 with DAMA Doctrine test bundle - **Testing**: PHPUnit 13 with DAMA Doctrine test bundle
## Build & Development Commands ## Build & Development Commands
@@ -40,6 +40,24 @@ just shell # Interactive shell inside the PHP container
just shell-run # Shell in a fresh one-off container just shell-run # Shell in a fresh one-off container
``` ```
### Working in git worktrees
`just up` auto-runs `just init` first, which generates a gitignored `.env.local` per checkout with a unique
`COMPOSE_PROJECT_NAME`, `IMAGES_PREFIX`, and free `HTTP_PORT`/`HTTPS_PORT`/`POSTGRES_PORT`/`MAILPIT_PORT`/
`SPOTLIGHT_PORT`. This means every worktree gets its own containers, network, volumes, and image tag — running
`just up` in two worktrees at the same time does **not** make them share a database, image, or port, even if the
worktree directories have the same basename.
- Run `just ports` to see the ports assigned to the *current* checkout — the app for that worktree is at
`https://localhost:<HTTPS_PORT>`, not a fixed port. Never assume port 8080/8443/5432/etc. when working inside a
worktree; always check `.env.local` or `just ports` first.
- `.env.local` is generated once and reused; it's safe to run `just init`/`just up` repeatedly. Delete `.env.local`
and re-run `just init` to force new ports (e.g. if the assigned ones are now taken by something else).
- Each worktree's Postgres data, uploaded files, and Caddy state live in per-worktree Docker volumes — nothing is
shared with the main checkout or other worktrees. Migrations/fixtures must be (re-)run per worktree.
- `just down`/`just clean` in one worktree only ever affects that worktree's own containers/volumes — safe to run
without impacting other worktrees.
### Database ### Database
```bash ```bash
@@ -234,7 +252,8 @@ question counts as covered more than once).
- **Write the failing test first.** When fixing any PHP-reachable bug, write a PHPUnit test that reproduces the failure - **Write the failing test first.** When fixing any PHP-reachable bug, write a PHPUnit test that reproduces the failure
before touching the production code. Fix the code until the test passes. before touching the production code. Fix the code until the test passes.
- Only skip a test if the bug is purely in JavaScript/frontend where PHPUnit cannot reach it. - For bugs in `assets/*.ts` logic, write a `deno test` first instead — same TDD rule, different runner. Only skip a
test if the bug is in markup/DOM wiring that isn't worth a test per the rule below.
- Don't write tests for trivial presentational markup (e.g. asserting a tooltip/popover attribute or a CSS class exists - Don't write tests for trivial presentational markup (e.g. asserting a tooltip/popover attribute or a CSS class exists
in a template). Tests cover behavior: routing, forms, persistence, authorization. in a template). Tests cover behavior: routing, forms, persistence, authorization.
- Follow the pattern in `tests/Controller/Backoffice/` for controller/integration tests: log in, GET for CSRF token, - Follow the pattern in `tests/Controller/Backoffice/` for controller/integration tests: log in, GET for CSRF token,
@@ -245,6 +264,11 @@ question counts as covered more than once).
- **Boy Scout Rule**: when you're already touching a file for an unrelated change, fix small nearby issues in the same - **Boy Scout Rule**: when you're already touching a file for an unrelated change, fix small nearby issues in the same
commit (e.g. a test that unnecessarily extends `WebTestCase`, a stale comment) rather than leaving them for later — commit (e.g. a test that unnecessarily extends `WebTestCase`, a stale comment) rather than leaving them for later —
but don't let this balloon into an unrelated refactor. but don't let this balloon into an unrelated refactor.
- **Scope creep as a service**: when a review (yours or `/code-review`'s) turns up a real bug or gap outside the
original task's scope — even in already-merged code untouched by the current diff — fix it in the same MR rather
than just reporting it and moving on. Write a regression test first per the TDD rule above. Only leave something
unaddressed if fixing it would require a design decision only a human can make (e.g. reverting an intentional
security/access-control choice) — in that case, say so explicitly instead of silently skipping it.
### Code Style & Standards ### Code Style & Standards
@@ -258,6 +282,11 @@ question counts as covered more than once).
- **Twig-CS-Fixer**: Template style enforcement - **Twig-CS-Fixer**: Template style enforcement
- **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure — - **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure —
they throw exceptions instead they throw exceptions instead
- **TypeScript (`assets/`)**: Compiled via `sensiolabs/typescript-bundle` (standalone SWC binary, no Node/npm).
Formatting, linting, type-checking, and tests use **Deno** (`deno fmt` / `deno lint` / `deno check` / `deno test`) —
a single standalone binary, kept dev-only (installed in the `frankenphp_dev` Docker stage, not prod), consistent
with the project's no-Node-anywhere approach. See `deno.json` for config; run via `just fix-ts` / `just check-ts` /
`just test-ts`. Tests live alongside their source as `*_test.ts` files
### Environment Configuration ### Environment Configuration
@@ -288,6 +317,7 @@ GitHub Actions workflow (`.github/workflows/ci.yml`):
- Twig-CS-Fixer style check - Twig-CS-Fixer style check
- PHPStan static analysis - PHPStan static analysis
- Rector dry-run - Rector dry-run
- Translation extraction check (fails if `translation:extract` produces uncommitted changes)
3. **Integration Tests**: 3. **Integration Tests**:
- Docker image build and start services - Docker image build and start services
- Database creation and migration - Database creation and migration
+8
View File
@@ -32,6 +32,7 @@ RUN set -eux; \
opcache \ opcache \
zip \ zip \
gd \ gd \
xsl \
; ;
# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser # https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
@@ -62,11 +63,18 @@ ENV APP_ENV=dev XDEBUG_MODE=off
# hadolint ignore=DL3008 # hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
bash-completion \ bash-completion \
unzip \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --link frankenphp/console-complete.bash /usr/share/bash-completion/completions/console COPY --link frankenphp/console-complete.bash /usr/share/bash-completion/completions/console
COPY --link frankenphp/composer-complete.bash /usr/share/bash-completion/completions/composer COPY --link frankenphp/composer-complete.bash /usr/share/bash-completion/completions/composer
# Deno: standalone binary (no Node/npm) used for TypeScript lint/format/type-check/test,
# dev-only tooling so it's not installed in the prod stage.
ENV DENO_INSTALL=/usr/local
# hadolint ignore=DL4006
RUN curl -fsSL https://deno.land/install.sh | sh -s v2.9.2
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
RUN set -eux; \ RUN set -eux; \
+103 -3
View File
@@ -1,9 +1,94 @@
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. Also points CADDY_DATA_DIR at a location shared
# by all worktrees of the same clone, so they reuse one self-signed CA/cert instead of generating
# their own. 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
if ! grep -q '^CADDY_DATA_DIR=' .env.local; then
git_common_dir=$(git rev-parse --path-format=absolute --git-common-dir)
echo "CADDY_DATA_DIR=${git_common_dir}/tvdt-caddy-data" >> .env.local
echo "Backfilled CADDY_DATA_DIR into existing .env.local."
fi
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)
# .git is shared by all worktrees of the same clone, so anchoring the Caddy data dir
# (self-signed CA + TLS certs) there instead of under the worktree lets every worktree
# reuse the same CA, avoiding a fresh cert (and a fresh `just trust-cert`) per checkout.
git_common_dir=$(git rev-parse --path-format=absolute --git-common-dir)
caddy_data_dir="${git_common_dir}/tvdt-caddy-data"
{
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}"
echo "CADDY_DATA_DIR=${caddy_data_dir}"
} >> .env.local
echo "Generated .env.local for this worktree:"
cat .env.local
up *args: init
#!/usr/bin/env bash
set -a
[ -f .env.local ] && source .env.local
set +a
docker compose up -d {{ args }} docker compose up -d {{ args }}
down *args: down *args:
docker compose down --remove-orphans {{ args }} docker compose down --remove-orphans {{ args }}
# Show the URLs (and DB connection) assigned to this worktree (see `just init`)
ports:
#!/usr/bin/env bash
set -euo pipefail
if [ ! -f .env.local ]; then
echo "No .env.local yet, run 'just up' or 'just init' first."
exit 0
fi
set -a
source .env.local
set +a
echo "Tvdt: https://localhost:${HTTPS_PORT}"
echo "Mailpit: http://localhost:${MAILPIT_PORT}"
echo "Spotlight: http://localhost:${SPOTLIGHT_PORT}"
stop: stop:
docker compose stop docker compose stop
@@ -40,6 +125,16 @@ phpstan *args:
test *args: test *args:
docker compose exec php vendor/bin/phpunit {{ args }} docker compose exec php vendor/bin/phpunit {{ args }}
fix-ts:
docker compose exec php deno fmt assets/
docker compose exec php deno lint --fix assets/
check-ts:
docker compose exec php deno check assets/
test-ts *args:
docker compose exec php deno test assets/ {{ args }}
[confirm] [confirm]
clean: clean:
docker compose down -v --remove-orphans docker compose down -v --remove-orphans
@@ -56,8 +151,13 @@ install-hooks:
chmod +x .githooks/pre-commit chmod +x .githooks/pre-commit
@echo "Pre-commit hook installed." @echo "Pre-commit hook installed."
trust-cert: trust-cert: init
#!/usr/bin/env bash
set -euo pipefail
set -a
source .env.local
set +a
sudo security add-trusted-cer -d \ sudo security add-trusted-cer -d \
-r trustRoot \ -r trustRoot \
-k "$HOME/Library/Keychains/login.keychain" \ -k "$HOME/Library/Keychains/login.keychain" \
./frankenphp/data/caddy/pki/authorities/local/root.crt "${CADDY_DATA_DIR:-./frankenphp/data}/caddy/pki/authorities/local/root.crt"
+7 -2
View File
@@ -25,8 +25,13 @@ just migrate # Run pending database migrations
just fixtures # Load dev fixtures (truncates first) just fixtures # Load dev fixtures (truncates first)
``` ```
The app is available at **https://localhost** (self-signed cert — run `just up` first runs `just init`, which generates a `.env.local` (gitignored)
`just trust-cert` on macOS to trust it). 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 ### Useful commands
@@ -2,12 +2,15 @@ import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.min.css'; import 'bootstrap-icons/font/bootstrap-icons.min.css';
import './styles/backoffice.scss'; import './styles/backoffice.scss';
import '@hotwired/turbo'; import '@hotwired/turbo';
import './stimulus.js'; import './stimulus.ts';
import './bootstrap.js'; import './bootstrap.ts';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
const dsn = document.querySelector('meta[name="sentry-dsn"]')?.content ?? ''; const dsn = document.querySelector<HTMLMetaElement>('meta[name="sentry-dsn"]')
const userEmail = document.querySelector('meta[name="user-email"]')?.content ?? ''; ?.content ?? '';
const userEmail =
document.querySelector<HTMLMetaElement>('meta[name="user-email"]')
?.content ?? '';
// When no real DSN is configured, route to the local Spotlight sidecar so // When no real DSN is configured, route to the local Spotlight sidecar so
// nothing reaches Sentry. A syntactically valid DSN is still required for the // nothing reaches Sentry. A syntactically valid DSN is still required for the
-1
View File
@@ -1 +0,0 @@
import * as bootstrap from 'bootstrap'
+1
View File
@@ -0,0 +1 @@
import 'bootstrap';
@@ -1,136 +0,0 @@
import {Controller} from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['collection'];
static values = {prototype: String};
connect() {
this.index = this.collectionTarget.children.length;
this._syncOrdering();
if (this.index === 0) {
this.addItem();
}
// `submit` fires on the ancestor <form>, which is outside this controller's
// subtree. Stimulus data-action only works within the controller element, so
// addEventListener on the form is the only option here.
this._form = this.element.closest('form');
if (this._form) {
this._submitHandler = () => {
[...this.collectionTarget.children].forEach(item => {
const input = item.querySelector('input[type="text"]');
if (input && input.value.trim() === '') item.remove();
});
};
this._form.addEventListener('submit', this._submitHandler);
}
}
disconnect() {
if (this._form && this._submitHandler) {
this._form.removeEventListener('submit', this._submitHandler);
}
}
addItem() {
const item = document.createElement('div');
item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index);
const el = item.firstElementChild;
this.collectionTarget.appendChild(el);
this.index++;
this._syncOrdering();
}
removeItem(event) {
event.target.closest('[data-collection-item]').remove();
this._notifyChange();
}
sortAlphabetically() {
const items = [...this.collectionTarget.children];
items.sort((a, b) => {
const textA = (a.querySelector('input[type="text"]')?.value ?? '').toLowerCase();
const textB = (b.querySelector('input[type="text"]')?.value ?? '').toLowerCase();
return textA.localeCompare(textB);
});
items.forEach(item => this.collectionTarget.appendChild(item));
this._syncOrdering();
this._notifyChange();
}
randomize() {
const items = [...this.collectionTarget.children];
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[items[i], items[j]] = [items[j], items[i]];
}
items.forEach(item => this.collectionTarget.appendChild(item));
this._syncOrdering();
this._notifyChange();
}
autoExpand(event) {
if (event.target.type !== 'text') return;
const item = event.target.closest('[data-collection-item]');
const last = [...this.collectionTarget.children].at(-1);
if (item && item === last && event.target.value.trim() !== '') {
this.addItem();
}
}
// — drag-and-drop —
dragStart(event) {
this._dragging = event.currentTarget.closest('[data-collection-item]');
this._dragging.classList.add('opacity-50');
event.dataTransfer.effectAllowed = 'move';
}
dragEnd(event) {
event.currentTarget.closest('[data-collection-item]').classList.remove('opacity-50');
this._dragging = null;
this.collectionTarget.querySelectorAll('[data-collection-item]').forEach(i =>
i.classList.remove('border-top', 'border-bottom', 'border-primary'),
);
}
dragOver(event) {
event.preventDefault();
const el = event.currentTarget;
if (!this._dragging || this._dragging === el) return;
event.dataTransfer.dropEffect = 'move';
const rect = el.getBoundingClientRect();
const isBottom = event.clientY > rect.top + rect.height / 2;
el.classList.toggle('border-top', !isBottom);
el.classList.toggle('border-bottom', isBottom);
el.classList.add('border-primary');
}
dragLeave(event) {
event.currentTarget.classList.remove('border-top', 'border-bottom', 'border-primary');
}
drop(event) {
event.preventDefault();
const el = event.currentTarget;
el.classList.remove('border-top', 'border-bottom', 'border-primary');
if (!this._dragging || this._dragging === el) return;
const rect = el.getBoundingClientRect();
const isBottom = event.clientY > rect.top + rect.height / 2;
this.collectionTarget.insertBefore(this._dragging, isBottom ? el.nextSibling : el);
this._syncOrdering();
this._notifyChange();
}
_notifyChange() {
this.element.dispatchEvent(new Event('change', {bubbles: true}));
}
_syncOrdering() {
[...this.collectionTarget.children].forEach((el, i) => {
const input = el.querySelector('input[name*="[ordering]"]');
if (input) input.value = i;
});
}
}
@@ -0,0 +1,172 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['collection'];
static values = { prototype: String };
declare readonly collectionTarget: HTMLElement;
declare readonly prototypeValue: string;
index = 0;
_dragging: Element | null = null;
_form: HTMLFormElement | null = null;
_submitHandler: (() => void) | null = null;
connect(): void {
this.index = this.collectionTarget.children.length;
this._syncOrdering();
if (this.index === 0) {
this.addItem();
}
// `submit` fires on the ancestor <form>, which is outside this controller's
// subtree. Stimulus data-action only works within the controller element, so
// addEventListener on the form is the only option here.
this._form = this.element.closest('form');
if (this._form) {
this._submitHandler = () => {
[...this.collectionTarget.children].forEach((item) => {
const input = item.querySelector<HTMLInputElement>(
'input[type="text"]',
);
if (input && input.value.trim() === '') item.remove();
});
};
this._form.addEventListener('submit', this._submitHandler);
}
}
disconnect(): void {
if (this._form && this._submitHandler) {
this._form.removeEventListener('submit', this._submitHandler);
}
}
addItem(): void {
const item = document.createElement('div');
item.innerHTML = this.prototypeValue.replace(
/__name__/g,
String(this.index),
);
const el = item.firstElementChild;
if (el) this.collectionTarget.appendChild(el);
this.index++;
this._syncOrdering();
}
removeItem(event: Event): void {
(event.target as HTMLElement).closest('[data-collection-item]')
?.remove();
this._notifyChange();
}
sortAlphabetically(): void {
const items = [...this.collectionTarget.children];
items.sort((a, b) => {
const textA =
(a.querySelector<HTMLInputElement>('input[type="text"]')
?.value ?? '').toLowerCase();
const textB =
(b.querySelector<HTMLInputElement>('input[type="text"]')
?.value ?? '').toLowerCase();
return textA.localeCompare(textB);
});
items.forEach((item) => this.collectionTarget.appendChild(item));
this._syncOrdering();
this._notifyChange();
}
randomize(): void {
const items = [...this.collectionTarget.children];
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[items[i], items[j]] = [items[j], items[i]];
}
items.forEach((item) => this.collectionTarget.appendChild(item));
this._syncOrdering();
this._notifyChange();
}
autoExpand(event: Event): void {
const target = event.target as HTMLInputElement;
if (target.type !== 'text') return;
const item = target.closest('[data-collection-item]');
const last = [...this.collectionTarget.children].at(-1);
if (item && item === last && target.value.trim() !== '') {
this.addItem();
}
}
// — drag-and-drop —
dragStart(event: DragEvent): void {
this._dragging = (event.currentTarget as HTMLElement).closest(
'[data-collection-item]',
);
this._dragging?.classList.add('opacity-50');
event.dataTransfer!.effectAllowed = 'move';
}
dragEnd(event: DragEvent): void {
(event.currentTarget as HTMLElement).closest('[data-collection-item]')
?.classList.remove('opacity-50');
this._dragging = null;
this.collectionTarget.querySelectorAll('[data-collection-item]')
.forEach((i) =>
i.classList.remove(
'border-top',
'border-bottom',
'border-primary',
)
);
}
dragOver(event: DragEvent): void {
event.preventDefault();
const el = event.currentTarget as HTMLElement;
if (!this._dragging || this._dragging === el) return;
event.dataTransfer!.dropEffect = 'move';
const rect = el.getBoundingClientRect();
const isBottom = event.clientY > rect.top + rect.height / 2;
el.classList.toggle('border-top', !isBottom);
el.classList.toggle('border-bottom', isBottom);
el.classList.add('border-primary');
}
dragLeave(event: DragEvent): void {
(event.currentTarget as HTMLElement).classList.remove(
'border-top',
'border-bottom',
'border-primary',
);
}
drop(event: DragEvent): void {
event.preventDefault();
const el = event.currentTarget as HTMLElement;
el.classList.remove('border-top', 'border-bottom', 'border-primary');
if (!this._dragging || this._dragging === el) return;
const rect = el.getBoundingClientRect();
const isBottom = event.clientY > rect.top + rect.height / 2;
this.collectionTarget.insertBefore(
this._dragging,
isBottom ? el.nextSibling : el,
);
this._syncOrdering();
this._notifyChange();
}
_notifyChange(): void {
this.element.dispatchEvent(new Event('change', { bubbles: true }));
}
_syncOrdering(): void {
[...this.collectionTarget.children].forEach((el, i) => {
const input = el.querySelector<HTMLInputElement>(
'input[name*="[ordering]"]',
);
if (input) input.value = String(i);
});
}
}
@@ -0,0 +1,114 @@
import { assertEquals } from '@std/assert';
// Stimulus's Controller base class constructor only does `this.context = context`,
// and target getters are normally installed by Application.register — since we
// construct the controller directly (no real Stimulus app), collectionTarget/element
// are assigned here as plain writable properties, backed by a minimal fake DOM
// container that supports the one operation these methods actually rely on:
// appendChild() moving an existing child to the end (real DOM semantics).
class FakeInput {
value: string;
constructor(value = '') {
this.value = value;
}
}
class FakeItem {
textInput: FakeInput;
orderingInput = new FakeInput();
constructor(text: string) {
this.textInput = new FakeInput(text);
}
querySelector<T>(selector: string): T | null {
if (selector.includes('type="text"')) {
return this.textInput as unknown as T;
}
if (selector.includes('ordering')) {
return this.orderingInput as unknown as T;
}
return null;
}
}
class FakeCollection {
children: FakeItem[] = [];
appendChild(item: FakeItem) {
const idx = this.children.indexOf(item);
if (idx !== -1) this.children.splice(idx, 1);
this.children.push(item);
}
}
const { default: FormCollectionController } = await import(
'./form_collection_controller.ts'
);
// deno-lint-ignore no-explicit-any
function makeController(items: FakeItem[]): any {
const collectionTarget = new FakeCollection();
collectionTarget.children = items;
// `element` is a read-only getter on Controller (delegates to `this.scope.element`),
// so the fake element is supplied via the constructor context rather than assigned.
const controller = new FormCollectionController({
scope: { element: { dispatchEvent: () => true } },
} as never);
return Object.assign(controller, { collectionTarget });
}
Deno.test("_syncOrdering writes the current index into each item's ordering input", () => {
const items = [new FakeItem('c'), new FakeItem('a'), new FakeItem('b')];
const controller = makeController(items);
controller._syncOrdering();
assertEquals(items.map((i) => i.orderingInput.value), ['0', '1', '2']);
});
Deno.test('sortAlphabetically reorders items by their text input value, case-insensitively', () => {
const c = new FakeItem('Charlie');
const a = new FakeItem('alice');
const b = new FakeItem('Bob');
const controller = makeController([c, a, b]);
controller.sortAlphabetically();
assertEquals(
controller.collectionTarget.children.map((i: FakeItem) =>
i.textInput.value
),
['alice', 'Bob', 'Charlie'],
);
// _syncOrdering must run after the reorder, against the new order
assertEquals(
controller.collectionTarget.children.map((i: FakeItem) =>
i.orderingInput.value
),
['0', '1', '2'],
);
});
Deno.test('randomize keeps the same set of items and resyncs ordering', () => {
const items = [
new FakeItem('1'),
new FakeItem('2'),
new FakeItem('3'),
new FakeItem('4'),
];
const controller = makeController([...items]);
controller.randomize();
const resultValues = controller.collectionTarget.children.map((
i: FakeItem,
) => i.textInput.value);
assertEquals(resultValues.slice().sort(), ['1', '2', '3', '4']);
assertEquals(
controller.collectionTarget.children.map((i: FakeItem) =>
i.orderingInput.value
),
['0', '1', '2', '3'],
);
});
-49
View File
@@ -1,49 +0,0 @@
import {Controller} from '@hotwired/stimulus';
import {Modal} from 'bootstrap';
import {visit} from '@hotwired/turbo';
export default class extends Controller {
static targets = ['modal', 'frame'];
open(event) {
event.preventDefault();
const {src, modalTitle} = event.currentTarget.dataset;
if (modalTitle) {
const titleEl = this.modalTarget.querySelector('.modal-title');
if (titleEl) titleEl.textContent = modalTitle;
}
this.resetDirty();
this.frameTarget.innerHTML = '<div class="modal-body text-center py-4"><div class="spinner-border" role="status"></div></div>';
this.frameTarget.removeAttribute('src');
this.frameTarget.setAttribute('src', src);
Modal.getOrCreateInstance(this.modalTarget).show();
}
frameSubmitEnd(event) {
if (event.detail.success) {
Modal.getOrCreateInstance(this.modalTarget).hide();
visit(window.location.href);
}
}
markDirty() {
if (this._dirty) return;
this._dirty = true;
// Using _config instead of preventDefault on hide.bs.modal because we need
// to block only user-triggered dismissal (backdrop click, Escape key) while
// keeping programmatic hide() working — frameSubmitEnd() calls hide() after
// a successful save and must not be blocked. _config.backdrop/keyboard is
// the correct primitive for that distinction and has been stable across all
// Bootstrap 5.x releases.
const modal = Modal.getOrCreateInstance(this.modalTarget);
modal._config.backdrop = 'static';
modal._config.keyboard = false;
}
resetDirty() {
this._dirty = false;
const modal = Modal.getOrCreateInstance(this.modalTarget);
modal._config.backdrop = true;
modal._config.keyboard = true;
}
}
+66
View File
@@ -0,0 +1,66 @@
import { Controller } from '@hotwired/stimulus';
import { Modal } from 'bootstrap';
import { visit } from '@hotwired/turbo';
// Bootstrap's public Modal type doesn't expose `_config` (an internal, but stable, field
// across all 5.x releases) — extend it locally rather than casting to `any` everywhere.
interface ModalWithConfig extends Modal {
_config: { backdrop: boolean | 'static'; keyboard: boolean };
}
export default class extends Controller {
static targets = ['modal', 'frame'];
declare readonly modalTarget: HTMLElement;
declare readonly frameTarget: HTMLElement;
_dirty = false;
open(event: MouseEvent): void {
event.preventDefault();
const { src, modalTitle } =
(event.currentTarget as HTMLElement).dataset;
if (modalTitle) {
const titleEl = this.modalTarget.querySelector('.modal-title');
if (titleEl) titleEl.textContent = modalTitle;
}
this.resetDirty();
this.frameTarget.innerHTML =
'<div class="modal-body text-center py-4"><div class="spinner-border" role="status"></div></div>';
this.frameTarget.removeAttribute('src');
if (src) this.frameTarget.setAttribute('src', src);
Modal.getOrCreateInstance(this.modalTarget).show();
}
frameSubmitEnd(event: CustomEvent<{ success: boolean }>): void {
if (event.detail.success) {
Modal.getOrCreateInstance(this.modalTarget).hide();
visit(window.location.href);
}
}
markDirty(): void {
if (this._dirty) return;
this._dirty = true;
// Using _config instead of preventDefault on hide.bs.modal because we need
// to block only user-triggered dismissal (backdrop click, Escape key) while
// keeping programmatic hide() working — frameSubmitEnd() calls hide() after
// a successful save and must not be blocked. _config.backdrop/keyboard is
// the correct primitive for that distinction and has been stable across all
// Bootstrap 5.x releases.
const modal = Modal.getOrCreateInstance(
this.modalTarget,
) as ModalWithConfig;
modal._config.backdrop = 'static';
modal._config.keyboard = false;
}
resetDirty(): void {
this._dirty = false;
const modal = Modal.getOrCreateInstance(
this.modalTarget,
) as ModalWithConfig;
modal._config.backdrop = true;
modal._config.keyboard = true;
}
}
@@ -1,13 +0,0 @@
import {Controller} from '@hotwired/stimulus';
import {Popover} from 'bootstrap';
export default class extends Controller {
connect() {
this.popovers = [...this.element.querySelectorAll('[data-bs-toggle="popover"]')]
.map(popoverTriggerEl => Popover.getOrCreateInstance(popoverTriggerEl));
}
disconnect() {
this.popovers.forEach(popover => popover.dispose());
}
}
@@ -0,0 +1,19 @@
import { Controller } from '@hotwired/stimulus';
import { Popover } from 'bootstrap';
export default class extends Controller {
popovers: Popover[] = [];
connect(): void {
this.popovers = [
...this.element.querySelectorAll('[data-bs-toggle="popover"]'),
]
.map((popoverTriggerEl) =>
Popover.getOrCreateInstance(popoverTriggerEl)
);
}
disconnect(): void {
this.popovers.forEach((popover) => popover.dispose());
}
}
@@ -1,123 +0,0 @@
import {Controller} from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['list', 'item', 'status'];
static values = {
reorderUrl: String,
csrf: String,
savedLabel: String,
errorLabel: String,
errorHint: String,
};
connect() {
this._locked = false;
}
dragStart(event) {
const item = event.currentTarget.closest('[data-bo--question-list-target="item"]');
this._dragging = item;
event.dataTransfer.effectAllowed = 'move';
setTimeout(() => item.classList.add('opacity-50'), 0);
}
dragEnd(event) {
const item = event.currentTarget.closest('[data-bo--question-list-target="item"]');
item.classList.remove('opacity-50');
this._dragging = null;
this._removePlaceholder();
}
dragOver(event) {
event.preventDefault();
if (!this._dragging) return;
event.dataTransfer.dropEffect = 'move';
const target = event.target.closest('[data-bo--question-list-target="item"]');
if (!target || target === this._dragging) return;
const rect = target.getBoundingClientRect();
const insertBefore = event.clientY > rect.top + rect.height / 2 ? target.nextSibling : target;
if (!this._placeholder) {
this._placeholder = document.createElement('div');
this._placeholder.className = 'bg-primary rounded mb-2';
this._placeholder.style.height = '3px';
}
if (this._placeholder.nextSibling !== insertBefore) {
this.listTarget.insertBefore(this._placeholder, insertBefore);
}
}
dragLeave(event) {
if (!event.relatedTarget || !this.listTarget.contains(event.relatedTarget)) {
this._removePlaceholder();
}
}
async drop(event) {
event.preventDefault();
if (!this._dragging || !this._placeholder || this._locked) return;
this.listTarget.insertBefore(this._dragging, this._placeholder);
this._removePlaceholder();
await this._persistOrder();
}
_removePlaceholder() {
if (this._placeholder) {
this._placeholder.remove();
this._placeholder = null;
}
}
_setStatus(state) {
if (!this.hasStatusTarget) return;
const el = this.statusTarget;
el.classList.remove('d-none', 'text-bg-success', 'text-bg-danger', 'text-bg-warning');
if (state === 'saving') {
el.classList.add('text-bg-warning');
el.textContent = '…';
} else if (state === 'saved') {
el.classList.add('text-bg-success');
el.textContent = this.savedLabelValue || 'Saved';
} else if (state === 'error') {
el.classList.add('text-bg-danger');
el.textContent = this.errorLabelValue || 'Error';
}
}
async _persistOrder() {
this._setStatus('saving');
const params = new URLSearchParams();
params.append('_token', this.csrfValue);
this.itemTargets.forEach((el, i) => {
params.append('ordering[]', el.dataset.questionId);
const numberEl = el.querySelector('[data-question-number]');
if (numberEl) numberEl.textContent = String(i + 1);
});
for (let attempt = 0; attempt < 2; attempt++) {
try {
const res = await fetch(this.reorderUrlValue, {method: 'POST', body: params});
if (res.ok) {
this._setStatus('saved');
return;
}
} catch {
// network error — retry on first attempt
}
}
this._locked = true;
this._setStatus('error');
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible mt-3';
alert.setAttribute('role', 'alert');
const hint = this.errorHintValue || 'Refresh the page to try again.';
alert.innerHTML = `${this.errorLabelValue || 'Error saving order'} &mdash; ${hint} <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>`;
this.listTarget.after(alert);
}
}
@@ -0,0 +1,161 @@
import { Controller } from '@hotwired/stimulus';
type Status = 'saving' | 'saved' | 'error';
export default class extends Controller {
static targets = ['list', 'item', 'status'];
static values = {
reorderUrl: String,
csrf: String,
savedLabel: String,
errorLabel: String,
errorHint: String,
};
declare readonly listTarget: HTMLElement;
declare readonly itemTargets: HTMLElement[];
declare readonly statusTarget: HTMLElement;
declare readonly hasStatusTarget: boolean;
declare readonly reorderUrlValue: string;
declare readonly csrfValue: string;
declare readonly savedLabelValue: string;
declare readonly errorLabelValue: string;
declare readonly errorHintValue: string;
_locked = false;
_dragging: HTMLElement | null = null;
_placeholder: HTMLDivElement | null = null;
connect(): void {
this._locked = false;
}
dragStart(event: DragEvent): void {
const item = (event.currentTarget as HTMLElement).closest<HTMLElement>(
'[data-bo--question-list-target="item"]',
);
this._dragging = item;
event.dataTransfer!.effectAllowed = 'move';
setTimeout(() => item?.classList.add('opacity-50'), 0);
}
dragEnd(event: DragEvent): void {
const item = (event.currentTarget as HTMLElement).closest<HTMLElement>(
'[data-bo--question-list-target="item"]',
);
item?.classList.remove('opacity-50');
this._dragging = null;
this._removePlaceholder();
}
dragOver(event: DragEvent): void {
event.preventDefault();
if (!this._dragging) return;
event.dataTransfer!.dropEffect = 'move';
const target = (event.target as HTMLElement).closest<HTMLElement>(
'[data-bo--question-list-target="item"]',
);
if (!target || target === this._dragging) return;
const rect = target.getBoundingClientRect();
const insertBefore = event.clientY > rect.top + rect.height / 2
? target.nextSibling
: target;
if (!this._placeholder) {
this._placeholder = document.createElement('div');
this._placeholder.className = 'bg-primary rounded mb-2';
this._placeholder.style.height = '3px';
}
if (this._placeholder.nextSibling !== insertBefore) {
this.listTarget.insertBefore(this._placeholder, insertBefore);
}
}
dragLeave(event: DragEvent): void {
if (
!event.relatedTarget ||
!this.listTarget.contains(event.relatedTarget as Node)
) {
this._removePlaceholder();
}
}
async drop(event: DragEvent): Promise<void> {
event.preventDefault();
if (!this._dragging || !this._placeholder || this._locked) return;
this.listTarget.insertBefore(this._dragging, this._placeholder);
this._removePlaceholder();
await this._persistOrder();
}
_removePlaceholder(): void {
if (this._placeholder) {
this._placeholder.remove();
this._placeholder = null;
}
}
_setStatus(state: Status): void {
if (!this.hasStatusTarget) return;
const el = this.statusTarget;
el.classList.remove(
'd-none',
'text-bg-success',
'text-bg-danger',
'text-bg-warning',
);
if (state === 'saving') {
el.classList.add('text-bg-warning');
el.textContent = '…';
} else if (state === 'saved') {
el.classList.add('text-bg-success');
el.textContent = this.savedLabelValue || 'Saved';
} else if (state === 'error') {
el.classList.add('text-bg-danger');
el.textContent = this.errorLabelValue || 'Error';
}
}
async _persistOrder(): Promise<void> {
this._setStatus('saving');
const params = new URLSearchParams();
params.append('_token', this.csrfValue);
this.itemTargets.forEach((el, i) => {
params.append('ordering[]', el.dataset.questionId ?? '');
const numberEl = el.querySelector('[data-question-number]');
if (numberEl) numberEl.textContent = String(i + 1);
});
for (let attempt = 0; attempt < 2; attempt++) {
try {
const res = await fetch(this.reorderUrlValue, {
method: 'POST',
body: params,
});
if (res.ok) {
this._setStatus('saved');
return;
}
} catch {
// network error — retry on first attempt
}
}
this._locked = true;
this._setStatus('error');
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible mt-3';
alert.setAttribute('role', 'alert');
const hint = this.errorHintValue || 'Refresh the page to try again.';
alert.innerHTML = `${
this.errorLabelValue || 'Error saving order'
} &mdash; ${hint} <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>`;
this.listTarget.after(alert);
}
}
@@ -0,0 +1,140 @@
import { assertEquals } from '@std/assert';
// Stimulus's Controller base class constructor only does `this.context = context`,
// and target/value getters (listTarget, csrfValue, ...) are normally installed by
// Application.register — since we construct the controller directly (no real
// Stimulus app), they're assigned here as plain writable properties instead.
class FakeClassList {
classes = new Set<string>();
add(...names: string[]) {
names.forEach((n) => this.classes.add(n));
}
remove(...names: string[]) {
names.forEach((n) => this.classes.delete(n));
}
contains(name: string) {
return this.classes.has(name);
}
}
function fakeElement() {
return {
classList: new FakeClassList(),
textContent: '',
after: () => {},
setAttribute: () => {},
};
}
// deno-lint-ignore no-explicit-any
(globalThis as any).document = {
createElement: () => ({
...fakeElement(),
innerHTML: '',
}),
};
const { default: QuestionListController } = await import(
'./question_list_controller.ts'
);
// deno-lint-ignore no-explicit-any
function makeController(overrides: Record<string, unknown> = {}): any {
const controller = new QuestionListController({} as never);
return Object.assign(controller, {
listTarget: fakeElement(),
itemTargets: [],
hasStatusTarget: true,
statusTarget: fakeElement(),
csrfValue: 'csrf-token',
reorderUrlValue: '/reorder',
savedLabelValue: 'Saved',
errorLabelValue: 'Error',
errorHintValue: 'Refresh the page.',
...overrides,
});
}
Deno.test('_setStatus renders the saving state', () => {
const controller = makeController();
controller._setStatus('saving');
assertEquals(
controller.statusTarget.classList.contains('text-bg-warning'),
true,
);
assertEquals(controller.statusTarget.textContent, '…');
});
Deno.test('_setStatus renders the saved state with the configured label', () => {
const controller = makeController();
controller._setStatus('saved');
assertEquals(
controller.statusTarget.classList.contains('text-bg-success'),
true,
);
assertEquals(controller.statusTarget.textContent, 'Saved');
});
Deno.test('_setStatus is a no-op when there is no status target', () => {
const controller = makeController({ hasStatusTarget: false });
controller._setStatus('saving');
assertEquals(controller.statusTarget.textContent, '');
});
Deno.test('_persistOrder posts the item ordering and reports success', async () => {
const numberEl = { textContent: '' };
const item = {
dataset: { questionId: '42' },
querySelector: () => numberEl,
};
const controller = makeController({ itemTargets: [item] });
let requestBody: URLSearchParams | undefined;
globalThis.fetch = ((_url: string, init: RequestInit) => {
requestBody = init.body as URLSearchParams;
return Promise.resolve({ ok: true } as Response);
}) as typeof fetch;
await controller._persistOrder();
assertEquals(controller._locked, false);
assertEquals(
controller.statusTarget.classList.contains('text-bg-success'),
true,
);
assertEquals(numberEl.textContent, '1');
assertEquals(requestBody?.get('_token'), 'csrf-token');
assertEquals(requestBody?.get('ordering[]'), '42');
});
Deno.test('_persistOrder retries once, then locks and shows an error after repeated failure', async () => {
const controller = makeController();
let calls = 0;
globalThis.fetch = (() => {
calls++;
return Promise.resolve({ ok: false } as Response);
}) as typeof fetch;
await controller._persistOrder();
assertEquals(calls, 2);
assertEquals(controller._locked, true);
assertEquals(
controller.statusTarget.classList.contains('text-bg-danger'),
true,
);
});
Deno.test('_persistOrder treats a network error the same as a failed response', async () => {
const controller = makeController();
globalThis.fetch =
(() => Promise.reject(new Error('network down'))) as typeof fetch;
await controller._persistOrder();
assertEquals(controller._locked, true);
assertEquals(
controller.statusTarget.classList.contains('text-bg-danger'),
true,
);
});
-28
View File
@@ -1,28 +0,0 @@
import {Controller} from '@hotwired/stimulus';
import {Tooltip, Modal} from 'bootstrap';
export default class extends Controller {
static targets = ['clearModal', 'deleteModal'];
connect() {
this.tooltips = [];
const tooltipTriggerList = this.element.querySelectorAll('[data-bs-toggle="tooltip"]');
[...tooltipTriggerList].forEach(tooltipTriggerEl => {
this.tooltips.push(Tooltip.getOrCreateInstance(tooltipTriggerEl));
});
}
disconnect() {
this.tooltips.forEach(tooltip => tooltip.dispose());
}
clearQuiz() {
const modal = Modal.getOrCreateInstance(this.clearModalTarget);
modal.show();
}
deleteQuiz() {
const modal = Modal.getOrCreateInstance(this.deleteModalTarget);
modal.show();
}
}
+35
View File
@@ -0,0 +1,35 @@
import { Controller } from '@hotwired/stimulus';
import { Modal, Tooltip } from 'bootstrap';
export default class extends Controller {
static targets = ['clearModal', 'deleteModal'];
declare readonly clearModalTarget: HTMLElement;
declare readonly deleteModalTarget: HTMLElement;
tooltips: Tooltip[] = [];
connect(): void {
this.tooltips = [];
const tooltipTriggerList = this.element.querySelectorAll(
'[data-bs-toggle="tooltip"]',
);
[...tooltipTriggerList].forEach((tooltipTriggerEl) => {
this.tooltips.push(Tooltip.getOrCreateInstance(tooltipTriggerEl));
});
}
disconnect(): void {
this.tooltips.forEach((tooltip) => tooltip.dispose());
}
clearQuiz(): void {
const modal = Modal.getOrCreateInstance(this.clearModalTarget);
modal.show();
}
deleteQuiz(): void {
const modal = Modal.getOrCreateInstance(this.deleteModalTarget);
modal.show();
}
}
@@ -1,81 +0,0 @@
const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/;
// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event
// and thus this event-listener will not be executed.
document.addEventListener('submit', function (event) {
generateCsrfToken(event.target);
}, true);
// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie
// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked
document.addEventListener('turbo:submit-start', function (event) {
const h = generateCsrfHeaders(event.detail.formSubmission.formElement);
Object.keys(h).map(function (k) {
event.detail.formSubmission.fetchRequest.headers[k] = h[k];
});
});
// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted
document.addEventListener('turbo:submit-end', function (event) {
removeCsrfToken(event.detail.formSubmission.formElement);
});
export function generateCsrfToken (formElement) {
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return;
}
let csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
let csrfToken = csrfField.value;
if (!csrfCookie && nameCheck.test(csrfToken)) {
csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken);
csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18))));
}
csrfField.dispatchEvent(new Event('change', { bubbles: true }));
if (csrfCookie && tokenCheck.test(csrfToken)) {
const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict';
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
}
}
export function generateCsrfHeaders (formElement) {
const headers = {};
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return headers;
}
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
headers[csrfCookie] = csrfField.value;
}
return headers;
}
export function removeCsrfToken (formElement) {
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return;
}
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
const cookie = csrfCookie + '_' + csrfField.value + '=0; path=/; samesite=strict; max-age=0';
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
}
}
/* stimulusFetch: 'lazy' */
export default 'csrf-protection-controller';
@@ -0,0 +1,129 @@
const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/;
interface TurboSubmitEventDetail {
formSubmission: {
formElement: HTMLFormElement;
fetchRequest: { headers: Record<string, string> };
};
}
const CSRF_FIELD_SELECTOR =
'input[data-controller="csrf-protection"], input[name="_csrf_token"]';
// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event
// and thus this event-listener will not be executed.
document.addEventListener('submit', function (event) {
generateCsrfToken(event.target as HTMLFormElement);
}, true);
// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie
// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked
document.addEventListener('turbo:submit-start', function (event) {
const detail = (event as CustomEvent<TurboSubmitEventDetail>).detail;
const h = generateCsrfHeaders(detail.formSubmission.formElement);
Object.keys(h).forEach(function (k) {
detail.formSubmission.fetchRequest.headers[k] = h[k];
});
});
// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted
document.addEventListener('turbo:submit-end', function (event) {
const detail = (event as CustomEvent<TurboSubmitEventDetail>).detail;
removeCsrfToken(detail.formSubmission.formElement);
});
export function generateCsrfToken(formElement: HTMLFormElement): void {
const csrfField = formElement.querySelector<HTMLInputElement>(
CSRF_FIELD_SELECTOR,
);
if (!csrfField) {
return;
}
let csrfCookie = csrfField.getAttribute(
'data-csrf-protection-cookie-value',
);
let csrfToken = csrfField.value;
if (!csrfCookie && nameCheck.test(csrfToken)) {
csrfField.setAttribute(
'data-csrf-protection-cookie-value',
csrfCookie = csrfToken,
);
csrfField.defaultValue = csrfToken = btoa(
String.fromCharCode.apply(
null,
Array.from(
(window.crypto ||
(window as unknown as { msCrypto: Crypto }).msCrypto)
.getRandomValues(new Uint8Array(18)),
),
),
);
}
csrfField.dispatchEvent(new Event('change', { bubbles: true }));
if (csrfCookie && tokenCheck.test(csrfToken)) {
const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie +
'; path=/; samesite=strict';
document.cookie = window.location.protocol === 'https:'
? '__Host-' + cookie + '; secure'
: cookie;
}
}
export function generateCsrfHeaders(
formElement: HTMLFormElement,
): Record<string, string> {
const headers: Record<string, string> = {};
const csrfField = formElement.querySelector<HTMLInputElement>(
CSRF_FIELD_SELECTOR,
);
if (!csrfField) {
return headers;
}
const csrfCookie = csrfField.getAttribute(
'data-csrf-protection-cookie-value',
);
if (
tokenCheck.test(csrfField.value) && nameCheck.test(String(csrfCookie))
) {
headers[String(csrfCookie)] = csrfField.value;
}
return headers;
}
export function removeCsrfToken(formElement: HTMLFormElement): void {
const csrfField = formElement.querySelector<HTMLInputElement>(
CSRF_FIELD_SELECTOR,
);
if (!csrfField) {
return;
}
const csrfCookie = csrfField.getAttribute(
'data-csrf-protection-cookie-value',
);
if (
tokenCheck.test(csrfField.value) && nameCheck.test(String(csrfCookie))
) {
const cookie = String(csrfCookie) + '_' + csrfField.value +
'=0; path=/; samesite=strict; max-age=0';
document.cookie = window.location.protocol === 'https:'
? '__Host-' + cookie + '; secure'
: cookie;
}
}
/* stimulusFetch: 'lazy' */
export default 'csrf-protection-controller';
@@ -0,0 +1,119 @@
import { assertEquals, assertMatch } from '@std/assert';
// The module under test registers `document.addEventListener(...)` calls at
// import time and reads `window.location`/`window.crypto`, so a minimal
// `document`/`window` stub must exist in the global scope *before* the module
// is imported. Deno has no browser DOM, and the actual surface these
// functions touch on a form/input is narrow (getAttribute/setAttribute/value/
// dispatchEvent), so hand-built fakes are used instead of a full DOM polyfill.
let cookieJar = '';
// deno-lint-ignore no-explicit-any
(globalThis as any).document = {
addEventListener: () => {},
get cookie() {
return cookieJar;
},
set cookie(value: string) {
cookieJar += (cookieJar ? '; ' : '') + value;
},
};
// deno-lint-ignore no-explicit-any
(globalThis as any).window = {
location: { protocol: 'http:' },
crypto: globalThis.crypto,
};
const { generateCsrfHeaders, generateCsrfToken, removeCsrfToken } =
await import(
'./csrf_protection_controller.ts'
);
function fakeField(attributes: Record<string, string> = {}, value = '') {
const store = new Map(Object.entries(attributes));
return {
getAttribute: (name: string) => store.get(name) ?? null,
setAttribute: (name: string, val: string) => {
store.set(name, val);
},
value,
defaultValue: value,
dispatchEvent: () => true,
};
}
// deno-lint-ignore no-explicit-any
function fakeForm(field: ReturnType<typeof fakeField> | null): any {
return { querySelector: () => field };
}
Deno.test('generateCsrfToken does nothing when the form has no csrf field', () => {
cookieJar = '';
generateCsrfToken(fakeForm(null));
assertEquals(cookieJar, '');
});
Deno.test('generateCsrfToken generates a token and cookie on first use', () => {
cookieJar = '';
const field = fakeField({}, 'my_field_name');
generateCsrfToken(fakeForm(field));
assertEquals(
field.getAttribute('data-csrf-protection-cookie-value'),
'my_field_name',
);
assertMatch(field.defaultValue, /^[-_/+a-zA-Z0-9]{24,}$/);
assertMatch(cookieJar, /my_field_name_[-_/+a-zA-Z0-9]{24,}=my_field_name/);
});
Deno.test('generateCsrfToken keeps an existing valid token/cookie pair unchanged', () => {
cookieJar = '';
const token = 'a'.repeat(24);
const field = fakeField({
'data-csrf-protection-cookie-value': 'existing_name',
}, token);
generateCsrfToken(fakeForm(field));
assertEquals(field.value, token);
assertMatch(cookieJar, new RegExp(`existing_name_${token}=existing_name`));
});
Deno.test('generateCsrfHeaders returns empty headers without a csrf field', () => {
assertEquals(generateCsrfHeaders(fakeForm(null)), {});
});
Deno.test('generateCsrfHeaders returns the cookie-name/token header when both are valid', () => {
const token = 'b'.repeat(24);
const field = fakeField({
'data-csrf-protection-cookie-value': 'field_name',
}, token);
assertEquals(generateCsrfHeaders(fakeForm(field)), { field_name: token });
});
Deno.test('generateCsrfHeaders omits the header when the token is too short', () => {
const field = fakeField({
'data-csrf-protection-cookie-value': 'field_name',
}, 'too-short');
assertEquals(generateCsrfHeaders(fakeForm(field)), {});
});
Deno.test('removeCsrfToken expires the cookie for a valid token/cookie pair', () => {
cookieJar = '';
const token = 'c'.repeat(24);
const field = fakeField({
'data-csrf-protection-cookie-value': 'field_name',
}, token);
removeCsrfToken(fakeForm(field));
assertMatch(cookieJar, new RegExp(`field_name_${token}=0`));
});
Deno.test('removeCsrfToken does nothing without a csrf field', () => {
cookieJar = '';
removeCsrfToken(fakeForm(null));
assertEquals(cookieJar, '');
});
@@ -1,14 +0,0 @@
import {Controller} from '@hotwired/stimulus';
export default class extends Controller {
next() {
const currentUrl = new URL(window.location.href);
const pathParts = currentUrl.pathname.split('/');
// Remove the last segment
pathParts.pop();
// Update the pathname
currentUrl.pathname = pathParts.join('/');
// Navigate
window.location.href = currentUrl.href;
}
}
@@ -0,0 +1,14 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
next(): void {
const currentUrl = new URL(window.location.href);
const pathParts = currentUrl.pathname.split('/');
// Remove the last segment
pathParts.pop();
// Update the pathname
currentUrl.pathname = pathParts.join('/');
// Navigate
window.location.href = currentUrl.href;
}
}
@@ -1,40 +0,0 @@
import {Controller} from '@hotwired/stimulus';
const STORAGE_KEY = 'tvdt-fullscreen';
export default class extends Controller {
connect() {
this.onFullscreenChange = this.onFullscreenChange.bind(this);
document.addEventListener('fullscreenchange', this.onFullscreenChange);
this.syncState();
if (sessionStorage.getItem(STORAGE_KEY) === '1' && !document.fullscreenElement) {
this.request();
}
}
disconnect() {
document.removeEventListener('fullscreenchange', this.onFullscreenChange);
}
toggle() {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
this.request();
}
}
request() {
document.documentElement.requestFullscreen().catch(() => {});
}
onFullscreenChange() {
sessionStorage.setItem(STORAGE_KEY, document.fullscreenElement ? '1' : '0');
this.syncState();
}
syncState() {
document.documentElement.classList.toggle('is-fullscreen', Boolean(document.fullscreenElement));
}
}
@@ -0,0 +1,51 @@
import { Controller } from '@hotwired/stimulus';
const STORAGE_KEY = 'tvdt-fullscreen';
export default class extends Controller {
connect(): void {
document.addEventListener('fullscreenchange', this.onFullscreenChange);
this.syncState();
if (
sessionStorage.getItem(STORAGE_KEY) === '1' &&
!document.fullscreenElement
) {
this.request();
}
}
disconnect(): void {
document.removeEventListener(
'fullscreenchange',
this.onFullscreenChange,
);
}
toggle(): void {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
this.request();
}
}
request(): void {
document.documentElement.requestFullscreen().catch(() => {});
}
onFullscreenChange = (): void => {
sessionStorage.setItem(
STORAGE_KEY,
document.fullscreenElement ? '1' : '0',
);
this.syncState();
};
syncState(): void {
document.documentElement.classList.toggle(
'is-fullscreen',
Boolean(document.fullscreenElement),
);
}
}
@@ -0,0 +1,75 @@
import { assertEquals } from '@std/assert';
// Stimulus's Controller base class constructor only does `this.context = context`
// (see @hotwired/stimulus dist/stimulus.js), and none of this controller's methods
// touch Stimulus-specific getters (element/scope/targets), so a plain `{}` context
// is enough to construct a real instance. `document`/`sessionStorage` are stubbed
// since Deno has no browser DOM.
class FakeClassList {
classes = new Set<string>();
toggle(name: string, force?: boolean) {
const shouldAdd = force ?? !this.classes.has(name);
if (shouldAdd) this.classes.add(name);
else this.classes.delete(name);
}
contains(name: string) {
return this.classes.has(name);
}
}
const classList = new FakeClassList();
let fullscreenElement: unknown = null;
const sessionStore = new Map<string, string>();
// deno-lint-ignore no-explicit-any
(globalThis as any).document = {
documentElement: { classList },
get fullscreenElement() {
return fullscreenElement;
},
addEventListener: () => {},
removeEventListener: () => {},
};
// Deno defines a native `sessionStorage` accessor on globalThis (get/set pair), so a
// plain assignment would just call through to it instead of replacing it — redefine
// the property outright.
Object.defineProperty(globalThis, 'sessionStorage', {
value: {
getItem: (key: string) => sessionStore.get(key) ?? null,
setItem: (key: string, value: string) => sessionStore.set(key, value),
},
configurable: true,
});
const { default: FullscreenController } = await import(
'./fullscreen_controller.ts'
);
// deno-lint-ignore no-explicit-any
function makeController(): any {
return new FullscreenController({} as never);
}
Deno.test('syncState adds is-fullscreen when a fullscreen element is set', () => {
fullscreenElement = { tagName: 'HTML' };
makeController().syncState();
assertEquals(classList.contains('is-fullscreen'), true);
});
Deno.test('syncState removes is-fullscreen when there is no fullscreen element', () => {
fullscreenElement = null;
makeController().syncState();
assertEquals(classList.contains('is-fullscreen'), false);
});
Deno.test('onFullscreenChange persists the fullscreen state and syncs classes', () => {
fullscreenElement = { tagName: 'HTML' };
makeController().onFullscreenChange();
assertEquals(sessionStore.get('tvdt-fullscreen'), '1');
assertEquals(classList.contains('is-fullscreen'), true);
fullscreenElement = null;
makeController().onFullscreenChange();
assertEquals(sessionStore.get('tvdt-fullscreen'), '0');
assertEquals(classList.contains('is-fullscreen'), false);
});
+2 -2
View File
@@ -1,4 +1,4 @@
import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/css/bootstrap.min.css';
import './styles/quiz.scss'; import './styles/quiz.scss';
import './stimulus.js'; import './stimulus.ts';
import './bootstrap.js'; import './bootstrap.ts';
+1 -1
View File
@@ -1,3 +1,3 @@
import { startStimulusApp } from '@symfony/stimulus-bundle'; import { startStimulusApp } from '@symfony/stimulus-bundle';
const app = startStimulusApp(); startStimulusApp();
+6
View File
@@ -3,3 +3,9 @@
.col-result-md { width: 20%; } .col-result-md { width: 20%; }
.modal-content > turbo-frame { display: contents; } .modal-content > turbo-frame { display: contents; }
.release-notes {
overflow-wrap: break-word;
> :last-child { margin-bottom: 0; }
}
+11
View File
@@ -0,0 +1,11 @@
// Ambient declarations for the parts of the AssetMapper toolchain that Deno's
// module resolution doesn't otherwise understand: CSS/SCSS side-effect imports
// (handled by AssetMapper at build time, not a JS module) and the Symfony
// stimulus-bundle loader (a local vendor/ file, not an npm package).
declare module '*.css';
declare module '*.scss';
declare module '@symfony/stimulus-bundle' {
export function startStimulusApp(): unknown;
}
+7 -7
View File
@@ -10,7 +10,7 @@ services:
- ~/.composer/cache:/root/.composer/cache - ~/.composer/cache:/root/.composer/cache
- ./frankenphp/Caddyfile:/etc/caddy/Caddyfile:ro - ./frankenphp/Caddyfile:/etc/caddy/Caddyfile:ro
- ./frankenphp/conf.d/20-app.dev.ini:/usr/local/etc/php/app.conf.d/20-app.dev.ini:ro - ./frankenphp/conf.d/20-app.dev.ini:/usr/local/etc/php/app.conf.d/20-app.dev.ini:ro
- ./frankenphp/data:/data - ${CADDY_DATA_DIR:-./frankenphp/data}:/data
- sass:/app/var/sass - sass:/app/var/sass
environment: environment:
MERCURE_EXTRA_DIRECTIVES: demo MERCURE_EXTRA_DIRECTIVES: demo
@@ -24,15 +24,15 @@ services:
ports: ports:
# HTTP # HTTP
- target: 80 - target: 80
published: ${HTTP_PORT:-80} published: ${HTTP_PORT:-8080}
protocol: tcp protocol: tcp
# HTTPS # HTTPS
- target: 443 - target: 443
published: ${HTTPS_PORT:-443} published: ${HTTPS_PORT:-8443}
protocol: tcp protocol: tcp
# HTTP/3 # HTTP/3
- target: 443 - target: 443
published: ${HTTP3_PORT:-443} published: ${HTTPS_PORT:-8443}
protocol: udp protocol: udp
sass: sass:
image: ${IMAGES_PREFIX:-}app-php image: ${IMAGES_PREFIX:-}app-php
@@ -56,7 +56,7 @@ services:
###> doctrine/doctrine-bundle ### ###> doctrine/doctrine-bundle ###
database: database:
ports: ports:
- "5432:5432" - "${POSTGRES_PORT:-5430}:5432"
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###
###> symfony/mailer ### ###> symfony/mailer ###
@@ -64,7 +64,7 @@ services:
image: axllent/mailpit image: axllent/mailpit
ports: ports:
- "1025" - "1025"
- "8025:8025" - "${MAILPIT_PORT:-8025}:8025"
environment: environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1 MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1 MP_SMTP_AUTH_ALLOW_INSECURE: 1
@@ -73,7 +73,7 @@ services:
spotlight: spotlight:
image: ghcr.io/getsentry/spotlight:latest image: ghcr.io/getsentry/spotlight:latest
ports: ports:
- "8969:8969" - "${SPOTLIGHT_PORT:-8969}:8969"
volumes: volumes:
sass: sass:
+6
View File
@@ -13,10 +13,12 @@
"doctrine/doctrine-bundle": "^3.2.2", "doctrine/doctrine-bundle": "^3.2.2",
"doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6.2", "doctrine/orm": "^3.6.2",
"league/commonmark": "^2.7",
"martin-georgiev/postgresql-for-doctrine": "^4.4", "martin-georgiev/postgresql-for-doctrine": "^4.4",
"phpdocumentor/reflection-docblock": "^6.0.3", "phpdocumentor/reflection-docblock": "^6.0.3",
"phpoffice/phpspreadsheet": "^5.5", "phpoffice/phpspreadsheet": "^5.5",
"phpstan/phpdoc-parser": "^2.3.2", "phpstan/phpdoc-parser": "^2.3.2",
"sensiolabs/typescript-bundle": "^0.2.2",
"sentry/sentry-symfony": "^5.9.0", "sentry/sentry-symfony": "^5.9.0",
"stof/doctrine-extensions-bundle": "^1.15.3", "stof/doctrine-extensions-bundle": "^1.15.3",
"symfony/asset": "8.1.*", "symfony/asset": "8.1.*",
@@ -27,6 +29,7 @@
"symfony/flex": "^2.11.0", "symfony/flex": "^2.11.0",
"symfony/form": "8.1.*", "symfony/form": "8.1.*",
"symfony/framework-bundle": "8.1.*", "symfony/framework-bundle": "8.1.*",
"symfony/http-client": "8.1.*",
"symfony/mailer": "8.1.*", "symfony/mailer": "8.1.*",
"symfony/object-mapper": "8.1.*", "symfony/object-mapper": "8.1.*",
"symfony/property-access": "8.1.*", "symfony/property-access": "8.1.*",
@@ -46,8 +49,11 @@
"symfonycasts/sass-bundle": "^0.10", "symfonycasts/sass-bundle": "^0.10",
"symfonycasts/verify-email-bundle": "^1.18.0", "symfonycasts/verify-email-bundle": "^1.18.0",
"thecodingmachine/safe": "^3.4.0", "thecodingmachine/safe": "^3.4.0",
"twig/cssinliner-extra": "^3.26.0",
"twig/extra-bundle": "^3.24.0", "twig/extra-bundle": "^3.24.0",
"twig/inky-extra": "^3.26.0",
"twig/intl-extra": "^3.24.0", "twig/intl-extra": "^3.24.0",
"twig/markdown-extra": "^3.24.0",
"twig/twig": "^3.27.1" "twig/twig": "^3.27.1"
}, },
"require-dev": { "require-dev": {
Generated
+873 -76
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -6,6 +6,7 @@ use DAMA\DoctrineTestBundle\DAMADoctrineTestBundle;
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle; use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle; use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Sensiolabs\TypeScriptBundle\SensiolabsTypeScriptBundle;
use Sentry\SentryBundle\SentryBundle; use Sentry\SentryBundle\SentryBundle;
use Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle; use Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
@@ -38,4 +39,5 @@ return [
DAMADoctrineTestBundle::class => ['test' => true], DAMADoctrineTestBundle::class => ['test' => true],
StofDoctrineExtensionsBundle::class => ['all' => true], StofDoctrineExtensionsBundle::class => ['all' => true],
SymfonyCastsResetPasswordBundle::class => ['all' => true], SymfonyCastsResetPasswordBundle::class => ['all' => true],
SensiolabsTypeScriptBundle::class => ['all' => true],
]; ];
+4
View File
@@ -6,8 +6,12 @@ framework:
excluded_patterns: excluded_patterns:
- '*/assets/styles/_*.scss' - '*/assets/styles/_*.scss'
- '*/assets/styles/**/_*.scss' - '*/assets/styles/**/_*.scss'
- '*/assets/**/*_test.ts'
- '*/assets/types/*.d.ts'
missing_import_mode: strict missing_import_mode: strict
sensiolabs_typescript:
source_dir: ['%kernel.project_dir%/assets']
when@prod: when@prod:
framework: framework:
+1
View File
@@ -30,6 +30,7 @@ security:
access_control: access_control:
- { path: ^/admin, roles: ROLE_ADMIN } - { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/backoffice/releases$, roles: PUBLIC_ACCESS }
- { path: ^/backoffice, roles: IS_AUTHENTICATED } - { path: ^/backoffice, roles: IS_AUTHENTICATED }
when@test: when@test:
+8
View File
@@ -0,0 +1,8 @@
twig_extra:
commonmark:
allow_unsafe_links: false
html_input: escape
services:
League\CommonMark\Extension\Autolink\AutolinkExtension:
tags: ['twig.markdown.league_extension']
+14 -3
View File
@@ -1271,16 +1271,16 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: false
* }, * },
* markdown?: bool|array{ * markdown?: bool|array{
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: true
* }, * },
* intl?: bool|array{ * intl?: bool|array{
* enabled?: bool|Param, // Default: true * enabled?: bool|Param, // Default: true
* }, * },
* cssinliner?: bool|array{ * cssinliner?: bool|array{
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: true
* }, * },
* inky?: bool|array{ * inky?: bool|array{
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: true
* }, * },
* string?: bool|array{ * string?: bool|array{
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: false
@@ -1496,6 +1496,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* throttle_limit?: int|Param, // Another password reset cannot be made faster than this throttle time in seconds. // Default: 3600 * throttle_limit?: int|Param, // Another password reset cannot be made faster than this throttle time in seconds. // Default: 3600
* enable_garbage_collection?: bool|Param, // Enable/Disable automatic garbage collection. // Default: true * enable_garbage_collection?: bool|Param, // Enable/Disable automatic garbage collection. // Default: true
* } * }
* @psalm-type SensiolabsTypescriptConfig = array{
* source_dir?: list<scalar|Param|null>,
* binary_download_dir?: scalar|Param|null, // The directory where the SWC binary will be downloaded // Default: "%kernel.project_dir%/var"
* swc_binary?: scalar|Param|null, // The SWC binary to use // Default: null
* swc_config_file?: scalar|Param|null, // Path to .swcrc configuration file to use // Default: "%kernel.project_dir%/.swcrc"
* swc_version?: scalar|Param|null, // The SWC version to use // Default: "v1.3.92"
* }
* @psalm-type ConfigType = array{ * @psalm-type ConfigType = array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
@@ -1512,6 +1519,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* turbo?: TurboConfig, * turbo?: TurboConfig,
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig, * symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
* sensiolabs_typescript?: SensiolabsTypescriptConfig,
* "when@dev"?: array{ * "when@dev"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
@@ -1531,6 +1539,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* turbo?: TurboConfig, * turbo?: TurboConfig,
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig, * symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
* sensiolabs_typescript?: SensiolabsTypescriptConfig,
* }, * },
* "when@prod"?: array{ * "when@prod"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
@@ -1549,6 +1558,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* turbo?: TurboConfig, * turbo?: TurboConfig,
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig, * symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
* sensiolabs_typescript?: SensiolabsTypescriptConfig,
* }, * },
* "when@test"?: array{ * "when@test"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
@@ -1568,6 +1578,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* dama_doctrine_test?: DamaDoctrineTestConfig, * dama_doctrine_test?: DamaDoctrineTestConfig,
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig, * symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
* sensiolabs_typescript?: SensiolabsTypescriptConfig,
* }, * },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias * ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig, * imports?: ImportsConfig,
+33
View File
@@ -0,0 +1,33 @@
{
"exclude": ["assets/vendor/"],
"compilerOptions": {
"lib": ["deno.window", "dom", "dom.iterable"],
"strict": true,
"noImplicitOverride": false,
"types": ["./assets/types/global.d.ts"]
},
"imports": {
"@hotwired/stimulus": "npm:@hotwired/stimulus@^3.2.2",
"@hotwired/turbo": "npm:@hotwired/turbo@^8.0.23",
"bootstrap": "npm:bootstrap@^5.3.8",
"@sentry/browser": "npm:@sentry/browser@^10.63.0",
"@std/assert": "jsr:@std/assert@^1.0.19"
},
"fmt": {
"include": ["assets/"],
"exclude": ["assets/vendor/", "assets/styles/", "assets/img/"],
"singleQuote": true,
"indentWidth": 4
},
"lint": {
"include": ["assets/"],
"exclude": ["assets/vendor/"],
"rules": {
"exclude": ["no-window", "no-window-prefix"]
}
},
"test": {
"include": ["assets/"],
"exclude": ["assets/vendor/"]
}
}
Generated
+96
View File
@@ -0,0 +1,96 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@*": "1.0.19",
"jsr:@std/assert@^1.0.19": "1.0.19",
"jsr:@std/internal@^1.0.12": "1.0.14",
"npm:@hotwired/stimulus@^3.2.2": "3.2.2",
"npm:@hotwired/turbo@^8.0.23": "8.0.23",
"npm:@sentry/browser@^10.63.0": "10.65.0",
"npm:bootstrap@^5.3.8": "5.3.8_@popperjs+core@2.11.8"
},
"jsr": {
"@std/assert@1.0.19": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/internal@1.0.14": {
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
}
},
"npm": {
"@hotwired/stimulus@3.2.2": {
"integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A=="
},
"@hotwired/turbo@8.0.23": {
"integrity": "sha512-GZ7cijxEZ6Ig71u7rD6LHaRv/wcE/hNsc+nEfiWOkLNqUgLOwo5MNGWOy5ZV9ZUDSiQx1no7YxjTNnT4O6//cQ=="
},
"@popperjs/core@2.11.8": {
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
},
"@sentry/browser-utils@10.65.0": {
"integrity": "sha512-4J0mkfNJAGUOkpg1ZggizyftFTn9N20b+Jl87UnWsDUkNG0Ic1l/FIzMPTVxXrAnhBGu0ULO0TFWMoQ5s3QtZw==",
"dependencies": [
"@sentry/conventions",
"@sentry/core"
]
},
"@sentry/browser@10.65.0": {
"integrity": "sha512-XUDDsx0qxzeIlcOu1fDEqTcDl0eiOqghsgV+ReuuNP4jYjZ9kUQxE3rXWM5mlT1pBi4VaQ4FHqvQZZrRXy+oDw==",
"dependencies": [
"@sentry/browser-utils",
"@sentry/conventions",
"@sentry/core",
"@sentry/feedback",
"@sentry/replay",
"@sentry/replay-canvas"
]
},
"@sentry/conventions@0.15.1": {
"integrity": "sha512-ZLP8bRdMON3prWE2tJyImuYscCxdcJeIPIhrOs/rgyFm3C1nCh1B6gdvPj3AZ5zW08oSFFCsq7T+tYEW3h8MNA=="
},
"@sentry/core@10.65.0": {
"integrity": "sha512-3aqtmM5NgNGo45BNaaBzi0LPQZAw//NEL4HKS5fXm12pJMa4KEkze8DEKnkTEIrGnWaOJKamecHKlnNg/Mqf/Q==",
"dependencies": [
"@sentry/conventions"
]
},
"@sentry/feedback@10.65.0": {
"integrity": "sha512-ck8h7wgd3F3bYNk0v1OgohmyLBeXcKxqlfBJRtQq4k6KZUq+pXimOG7ckNguVMYjCo3PEfuG+ckKc21yqotKug==",
"dependencies": [
"@sentry/core"
]
},
"@sentry/replay-canvas@10.65.0": {
"integrity": "sha512-A7X3RVk1Gk+knK8Ip/2EjejckNCLgCfRZo6eGlsy6qyz904KBpYmys1a0o7QkzFRjhIndjHAfcVxwt6jSLJlrQ==",
"dependencies": [
"@sentry/core",
"@sentry/replay"
]
},
"@sentry/replay@10.65.0": {
"integrity": "sha512-aW988CcQBNArbOMzOFOziipHz6uQyXSa4i5CPWsu+nhVPTJHafosi5Lv9n6NM/icDX5e23VdnX6mZd8SyJuo8A==",
"dependencies": [
"@sentry/browser-utils",
"@sentry/core"
]
},
"bootstrap@5.3.8_@popperjs+core@2.11.8": {
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
"dependencies": [
"@popperjs/core"
]
}
},
"workspace": {
"dependencies": [
"jsr:@std/assert@^1.0.19",
"npm:@hotwired/stimulus@^3.2.2",
"npm:@hotwired/turbo@^8.0.23",
"npm:@sentry/browser@^10.63.0",
"npm:bootstrap@^5.3.8"
]
}
}
+4
View File
@@ -33,6 +33,10 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
fi fi
fi fi
# var/ is a Docker volume that survives redeploys, so cache.app (e.g. the GitHub releases cache)
# can carry stale entries from the previous image into the new one unless cleared here.
php bin/console cache:pool:clear cache.app
setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var
setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var
+2 -2
View File
@@ -25,8 +25,8 @@ declare(strict_types=1);
* }> * }>
*/ */
return [ return [
'quiz' => ['path' => './assets/quiz.js', 'entrypoint' => true], 'quiz' => ['path' => './assets/quiz.ts', 'entrypoint' => true],
'backoffice' => ['path' => './assets/backoffice.js', 'entrypoint' => true], 'backoffice' => ['path' => './assets/backoffice.ts', 'entrypoint' => true],
'@symfony/stimulus-bundle' => ['path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js'], '@symfony/stimulus-bundle' => ['path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js'],
'bootstrap' => ['version' => '5.3.8'], 'bootstrap' => ['version' => '5.3.8'],
'@popperjs/core' => ['version' => '2.11.8'], '@popperjs/core' => ['version' => '2.11.8'],
@@ -25,6 +25,7 @@ use Tvdt\Entity\QuizCandidate;
use Tvdt\Entity\Season; use Tvdt\Entity\Season;
use Tvdt\Enum\FlashType; use Tvdt\Enum\FlashType;
use Tvdt\Exception\ErrorClearingQuizException; use Tvdt\Exception\ErrorClearingQuizException;
use Tvdt\Repository\GivenAnswerRepository;
use Tvdt\Repository\QuizCandidateRepository; use Tvdt\Repository\QuizCandidateRepository;
use Tvdt\Repository\QuizRepository; use Tvdt\Repository\QuizRepository;
use Tvdt\Security\Voter\SeasonVoter; use Tvdt\Security\Voter\SeasonVoter;
@@ -37,6 +38,7 @@ class QuizController extends AbstractController
private readonly QuizRepository $quizRepository, private readonly QuizRepository $quizRepository,
private readonly TranslatorInterface $translator, private readonly TranslatorInterface $translator,
private readonly QuizCandidateRepository $quizCandidateRepository, private readonly QuizCandidateRepository $quizCandidateRepository,
private readonly GivenAnswerRepository $givenAnswerRepository,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
) {} ) {}
@@ -397,6 +399,26 @@ class QuizController extends AbstractController
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]); return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
} }
#[IsCsrfTokenValid('reset_candidate_progress')]
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
#[Route(
'/backoffice/quiz/{quiz}/candidate/{candidate}/reset',
name: 'tvdt_backoffice_reset_candidate_progress',
requirements: ['quiz' => Requirement::UUID, 'candidate' => Requirement::UUID],
methods: ['POST'],
)]
public function resetCandidateProgress(Quiz $quiz, Candidate $candidate): RedirectResponse
{
$this->em->wrapInTransaction(function () use ($quiz, $candidate): void {
$this->givenAnswerRepository->deleteAllForCandidateInQuiz($quiz, $candidate);
$this->quizCandidateRepository->resetProgressForCandidate($quiz, $candidate);
});
$this->addFlash(FlashType::Success, $this->translator->trans('Candidate progress reset'));
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
}
/** /**
* Pre-computes per-candidate data (quiz participation and given answer counts) to avoid nested loops in templates. * Pre-computes per-candidate data (quiz participation and given answer counts) to avoid nested loops in templates.
* *
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Tvdt\Controller\Backoffice;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Tvdt\Controller\AbstractController;
use Tvdt\Service\GitHubReleasesService;
final class ReleasesController extends AbstractController
{
public function __construct(
private readonly GitHubReleasesService $gitHubReleasesService,
) {}
#[Route('/backoffice/releases', name: 'tvdt_backoffice_releases', methods: ['GET'])]
public function index(): Response
{
return $this->render('backoffice/releases/_frame.html.twig', [
'releases' => $this->gitHubReleasesService->getReleases(),
]);
}
}
+12 -2
View File
@@ -129,6 +129,8 @@ class SeasonController extends AbstractController
)] )]
public function addCandidates(Season $season, Request $request): Response public function addCandidates(Season $season, Request $request): Response
{ {
$isTurboFrame = $request->headers->has('Turbo-Frame');
$form = $this->createForm(AddCandidatesFormType::class); $form = $this->createForm(AddCandidatesFormType::class);
$form->handleRequest($request); $form->handleRequest($request);
@@ -140,10 +142,18 @@ class SeasonController extends AbstractController
$this->em->flush(); $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->render('backoffice/season_add_candidates.html.twig', ['form' => $form, 'season' => $season]); return $this->redirectToRoute('tvdt_backoffice_season_candidates', ['seasonCode' => $season->seasonCode]);
}
$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')] #[IsCsrfTokenValid('rename_candidate')]
+3 -1
View File
@@ -19,7 +19,9 @@ class AddCandidatesFormType extends AbstractType
{ {
$builder $builder
->add('candidates', TextareaType::class, [ ->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,
]) ])
; ;
} }
+13
View File
@@ -6,7 +6,9 @@ namespace Tvdt\Repository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\GivenAnswer; use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\Quiz;
/** @extends ServiceEntityRepository<GivenAnswer> */ /** @extends ServiceEntityRepository<GivenAnswer> */
class GivenAnswerRepository extends ServiceEntityRepository class GivenAnswerRepository extends ServiceEntityRepository
@@ -15,4 +17,15 @@ class GivenAnswerRepository extends ServiceEntityRepository
{ {
parent::__construct($registry, GivenAnswer::class); parent::__construct($registry, GivenAnswer::class);
} }
public function deleteAllForCandidateInQuiz(Quiz $quiz, Candidate $candidate): void
{
$givenAnswers = $this->findBy(['quiz' => $quiz, 'candidate' => $candidate]);
foreach ($givenAnswers as $givenAnswer) {
$this->getEntityManager()->remove($givenAnswer);
}
$this->getEntityManager()->flush();
}
} }
@@ -68,4 +68,15 @@ class QuizCandidateRepository extends ServiceEntityRepository
$quizCandidate->penaltySeconds = $penalty; $quizCandidate->penaltySeconds = $penalty;
$this->getEntityManager()->flush(); $this->getEntityManager()->flush();
} }
public function resetProgressForCandidate(Quiz $quiz, Candidate $candidate): void
{
$quizCandidate = $this->findOneBy(['candidate' => $candidate, 'quiz' => $quiz]);
if (!$quizCandidate instanceof QuizCandidate) {
return;
}
$quizCandidate->started = null;
$this->getEntityManager()->flush();
}
} }
+33 -5
View File
@@ -223,7 +223,7 @@ class DataExportService
} }
} }
/** Raw crosstab: one row per candidate, one column per question, cell = the answer text they gave. */ /** Raw crosstab: one row per candidate, one column per question, cell = the answer text they gave (bold when correct). */
private function fillRawAnswersSheet(Worksheet $sheet, Quiz $quiz): void private function fillRawAnswersSheet(Worksheet $sheet, Quiz $quiz): void
{ {
/** @var list<Question> $questions */ /** @var list<Question> $questions */
@@ -240,11 +240,14 @@ class DataExportService
/** @var array<string, array<string, string>> $answersByCandidateAndQuestion */ /** @var array<string, array<string, string>> $answersByCandidateAndQuestion */
$answersByCandidateAndQuestion = []; $answersByCandidateAndQuestion = [];
/** @var array<string, array<string, bool>> $correctnessByCandidateAndQuestion */
$correctnessByCandidateAndQuestion = [];
foreach ($questions as $question) { foreach ($questions as $question) {
foreach ($question->answers as $answer) { foreach ($question->answers as $answer) {
foreach ($answer->givenAnswers as $givenAnswer) { foreach ($answer->givenAnswers as $givenAnswer) {
$candidateId = $givenAnswer->candidate->id->toString(); $candidateId = $givenAnswer->candidate->id->toString();
$answersByCandidateAndQuestion[$candidateId][$question->id->toString()] = $answer->text; $answersByCandidateAndQuestion[$candidateId][$question->id->toString()] = $answer->text;
$correctnessByCandidateAndQuestion[$candidateId][$question->id->toString()] = $answer->isRightAnswer;
} }
} }
} }
@@ -252,18 +255,27 @@ class DataExportService
$row = 2; $row = 2;
foreach ($quiz->candidateData as $quizCandidate) { foreach ($quiz->candidateData as $quizCandidate) {
$candidate = $quizCandidate->candidate; $candidate = $quizCandidate->candidate;
$candidateId = $candidate->id->toString();
$line = [$candidate->name]; $line = [$candidate->name];
foreach ($questions as $question) { foreach ($questions as $question) {
$line[] = $answersByCandidateAndQuestion[$candidate->id->toString()][$question->id->toString()] ?? ''; $line[] = $answersByCandidateAndQuestion[$candidateId][$question->id->toString()] ?? '';
} }
$sheet->fromArray($line, null, 'A'.$row); $sheet->fromArray($line, null, 'A'.$row);
foreach ($questions as $columnIndex => $question) {
if ($correctnessByCandidateAndQuestion[$candidateId][$question->id->toString()] ?? false) {
$column = Coordinate::stringFromColumnIndex(2 + $columnIndex);
$sheet->getStyle($column.$row)->getFont()->setBold(true);
}
}
++$row; ++$row;
} }
$lastColumnIndex = 1 + \count($questions); $lastColumnIndex = 1 + \count($questions);
foreach (range('A', Coordinate::stringFromColumnIndex($lastColumnIndex)) as $column) { foreach ($this->columnLetters($lastColumnIndex) as $column) {
$sheet->getColumnDimension($column)->setWidth(30); $sheet->getColumnDimension($column)->setWidth(30);
$sheet->getStyle($column.':'.$column)->getAlignment()->setWrapText(true); $sheet->getStyle($column.':'.$column)->getAlignment()->setWrapText(true);
} }
@@ -297,7 +309,7 @@ class DataExportService
++$row; ++$row;
} }
foreach (range('A', Coordinate::stringFromColumnIndex(2 + \count($candidates))) as $column) { foreach ($this->columnLetters(2 + \count($candidates)) as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true); $sheet->getColumnDimension($column)->setAutoSize(true);
} }
} }
@@ -405,7 +417,7 @@ class DataExportService
} }
$lastColumnIndex = $answerStartColumnIndex + max(1, 2 * $maxAnswers); $lastColumnIndex = $answerStartColumnIndex + max(1, 2 * $maxAnswers);
foreach (range('A', Coordinate::stringFromColumnIndex($lastColumnIndex)) as $column) { foreach ($this->columnLetters($lastColumnIndex) as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true); $sheet->getColumnDimension($column)->setAutoSize(true);
} }
} }
@@ -426,6 +438,22 @@ class DataExportService
} }
} }
/**
* Column letters from 'A' up to and including the given 1-based column index.
* Unlike range('A', ...), this works past 'Z' (e.g. index 27 => 'AA').
*
* @return list<string>
*/
private function columnLetters(int $lastColumnIndex): array
{
$letters = [];
for ($columnIndex = 1; $columnIndex <= $lastColumnIndex; ++$columnIndex) {
$letters[] = Coordinate::stringFromColumnIndex($columnIndex);
}
return $letters;
}
/** @throws FilesystemException */ /** @throws FilesystemException */
private function writeToTempFile(Spreadsheet $spreadsheet): string private function writeToTempFile(Spreadsheet $spreadsheet): string
{ {
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Tvdt\Service;
use Psr\Cache\InvalidArgumentException;
use Safe\DateTimeImmutable;
use Safe\Exceptions\SafeExceptionInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class GitHubReleasesService
{
private const string RELEASES_URL = 'https://api.github.com/repos/MarijnDoeve/TijdVoorDeTest/releases';
public function __construct(
private HttpClientInterface $httpClient,
private CacheInterface $cache,
) {}
/**
* @throws InvalidArgumentException
*
* @return list<array{tagName: string, name: string, publishedAt: ?\DateTimeImmutable, body: string, url: string}>
*/
public function getReleases(): array
{
return $this->cache->get('github_releases', $this->fetchReleases(...));
}
/** @return list<array{tagName: string, name: string, publishedAt: ?\DateTimeImmutable, body: string, url: string}> */
private function fetchReleases(ItemInterface $item, bool &$save): array
{
try {
$response = $this->httpClient->request('GET', self::RELEASES_URL, [
'timeout' => 5,
'headers' => [
'Accept' => 'application/vnd.github+json',
'User-Agent' => 'TijdVoorDeTest',
],
]);
/** @var list<array{tag_name: string, name: ?string, published_at: ?string, body: ?string, html_url: string}> $releases */
$releases = $response->toArray();
usort($releases, static fn (array $a, array $b): int => ($b['published_at'] ?? '') <=> ($a['published_at'] ?? ''));
$result = array_map(static function (array $release): array {
$name = $release['name'] ?? '';
return [
'tagName' => $release['tag_name'],
'name' => '' !== $name ? $name : $release['tag_name'],
'publishedAt' => $release['published_at'] ? new DateTimeImmutable($release['published_at']) : null,
'body' => (string) $release['body'],
'url' => $release['html_url'],
];
}, $releases);
} catch (ExceptionInterface|SafeExceptionInterface|\DateMalformedStringException) {
$save = false;
return [];
}
$item->expiresAfter(3600);
return $result;
}
}
+3
View File
@@ -98,6 +98,9 @@
"tests/bootstrap.php" "tests/bootstrap.php"
] ]
}, },
"sensiolabs/typescript-bundle": {
"version": "v0.2.2"
},
"sentry/sentry-symfony": { "sentry/sentry-symfony": {
"version": "5.8", "version": "5.8",
"recipe": { "recipe": {
+21 -2
View File
@@ -10,9 +10,9 @@
aria-label="Toggle navigation"> aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
{% if is_granted('IS_AUTHENTICATED') %}
<div class="collapse navbar-collapse" id="navbarSupportedContent"> <div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> <ul class="navbar-nav me-auto mb-2 mb-lg-0">
{% if is_granted('IS_AUTHENTICATED') %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link{% if 'tvdt_backoffice_index' == app.current_route() %} active{% endif %}" <a class="nav-link{% if 'tvdt_backoffice_index' == app.current_route() %} active{% endif %}"
href="{{ path('tvdt_backoffice_index') }}">{{ 'Seasons'|trans }}</a> href="{{ path('tvdt_backoffice_index') }}">{{ 'Seasons'|trans }}</a>
@@ -21,8 +21,27 @@
<a class="nav-link" <a class="nav-link"
href="{{ path('tvdt_backoffice_template') }}">{{ 'Download Template'|trans }}</a> href="{{ path('tvdt_backoffice_template') }}">{{ 'Download Template'|trans }}</a>
</li> </li>
{% endif %}
</ul> </ul>
<ul class="navbar-nav mb-auto me-2 me-lg-0"> <ul class="navbar-nav mb-auto me-2 me-lg-0">
<li class="nav-item" data-controller="bo--modal"
data-action="turbo:submit-end->bo--modal#frameSubmitEnd">
<button type="button" class="nav-link border-0 bg-transparent"
data-action="click->bo--modal#open"
data-src="{{ path('tvdt_backoffice_releases') }}"
data-modal-title="{{ 'Releases'|trans }}">{{ 'Releases'|trans }}</button>
<div class="modal fade" tabindex="-1" data-bo--modal-target="modal"
data-action="hidden.bs.modal->bo--modal#resetDirty"
aria-labelledby="releasesModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<turbo-frame id="releases-modal-frame" data-bo--modal-target="frame"></turbo-frame>
</div>
</div>
</div>
</li>
{% if is_granted('IS_AUTHENTICATED') %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link{% if 'tvdt_backoffice_settings' == app.current_route() %} active{% endif %}" <a class="nav-link{% if 'tvdt_backoffice_settings' == app.current_route() %} active{% endif %}"
href="{{ path('tvdt_backoffice_settings') }}">{{ 'Settings'|trans }}</a> href="{{ path('tvdt_backoffice_settings') }}">{{ 'Settings'|trans }}</a>
@@ -31,8 +50,8 @@
<a class="nav-link" <a class="nav-link"
href="{{ path('tvdt_login_logout') }}">{{ 'Logout'|trans }}</a> href="{{ path('tvdt_login_logout') }}">{{ 'Logout'|trans }}</a>
</li> </li>
{% endif %}
</ul> </ul>
</div> </div>
{% endif %}
</div> </div>
</nav> </nav>
@@ -37,7 +37,7 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
<form action="{{ path('tvdt_backoffice_toggle_candidate', {quiz: quiz.id, candidate: candidate.id}) }}" method="POST"> <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') }}"> <input type="hidden" name="_token" value="{{ csrf_token('toggle_candidate') }}">
<button type="submit" class="btn btn-sm btn-outline-secondary"> <button type="submit" class="btn btn-sm btn-outline-secondary">
{% if quizCandidate == null or quizCandidate.active %} {% if quizCandidate == null or quizCandidate.active %}
@@ -47,6 +47,12 @@
{% endif %} {% endif %}
</button> </button>
</form> </form>
{% if quizCandidate and quizCandidate.started %}
<form action="{{ path('tvdt_backoffice_reset_candidate_progress', {quiz: quiz.id, candidate: candidate.id}) }}" method="POST" class="d-inline" onsubmit="return confirm('{{ 'Are you sure you want to reset progress for this candidate? Their given answers for this quiz will be deleted.'|trans|e('js') }}');">
<input type="hidden" name="_token" value="{{ csrf_token('reset_candidate_progress') }}">
<button type="submit" class="btn btn-sm btn-outline-danger">{{ 'Reset progress'|trans }}</button>
</form>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -1,11 +1,22 @@
<h1>Hi! Please confirm your email!</h1> {% extends 'emails/layout.html.twig' %}
<p> {% block preheader %}Bevestig je e-mailadres voor Tijd voor de test.{% endblock %}
Please confirm your email address by clicking the following link: <br><br>
<a href="{{ signedUrl|raw }}">Confirm my Email</a>. {% block heading %}Nog één stap voor je mee mag doen{% endblock %}
This link will expire in {{ expiresAtMessageKey|trans(expiresAtMessageData, 'VerifyEmailBundle') }}.
{% block body %}
<p class="text">Beste speler,</p>
<p class="text">
Welkom bij Tijd voor de test! Voor je echt aan de slag kunt, moeten we nog één ding
weten: of jij écht bent wie je zegt dat je bent. Klik daarom op de knop hieronder.
</p> </p>
<p> <center><button href="{{ signedUrl|raw }}">Bevestig e-mailadres</button></center>
Cheers!
<p class="text">
Deze link verloopt over {{ expiresAtMessageKey|trans(expiresAtMessageData, 'VerifyEmailBundle') }}, dus
wacht niet te lang.
</p> </p>
<p class="text">Veel plezier!</p>
{% endblock %}
@@ -0,0 +1,40 @@
<turbo-frame id="releases-modal-frame">
<div class="modal-header">
<h1 class="modal-title fs-5" id="releasesModalLabel">
{{ 'Releases'|trans }}
{% if releases[0] is defined %}
<span class="text-muted fs-6 ms-2">{{ 'Current version'|trans }}: {{ releases[0].tagName }}</span>
{% endif %}
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{% if releases is empty %}
<p class="text-muted mb-0">{{ 'Could not load releases from GitHub.'|trans }}</p>
{% else %}
<div class="accordion" id="releasesAccordion">
{% for release in releases %}
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button{% if not loop.first %} collapsed{% endif %}" type="button"
data-bs-toggle="collapse" data-bs-target="#release-{{ loop.index }}">
{{ release.name }}
{% if release.publishedAt %}
<span class="text-muted ms-2 small">{{ release.publishedAt|date('d-m-Y', 'UTC') }}</span>
{% endif %}
</button>
</h2>
<div id="release-{{ loop.index }}"
class="accordion-collapse collapse{% if loop.first %} show{% endif %}"
data-bs-parent="#releasesAccordion">
<div class="accordion-body">
<div class="release-notes">{{ release.body|markdown_to_html }}</div>
<a href="{{ release.url }}" target="_blank" rel="noopener noreferrer" class="small">{{ 'View on GitHub'|trans }}</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</turbo-frame>
@@ -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="col-md-6 col-12">
<div class="mb-3"> <div class="mb-3">
<a class="btn btn-sm btn-outline-primary" <button type="button" class="btn btn-sm btn-outline-primary"
href="{{ path('tvdt_backoffice_add_candidates', {seasonCode: season.seasonCode}) }}">{{ 'Add Candidate'|trans }}</a> 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> </div>
<ul class="list-group mb-3"> <ul class="list-group mb-3">
{% for candidate in season.candidates %} {% for candidate in season.candidates %}
@@ -17,7 +19,9 @@
title="{{ 'Delete'|trans }}"><i class="bi bi-trash"></i></button> title="{{ 'Delete'|trans }}"><i class="bi bi-trash"></i></button>
</div> </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"> tabindex="-1" aria-labelledby="renameCandidate-{{ candidate.id }}Label" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
@@ -31,7 +35,8 @@
<input type="hidden" name="_token" value="{{ csrf_token('rename_candidate') }}"> <input type="hidden" name="_token" value="{{ csrf_token('rename_candidate') }}">
<label class="form-label" for="renameCandidateName-{{ candidate.id }}">{{ 'Name'|trans }}</label> <label class="form-label" for="renameCandidateName-{{ candidate.id }}">{{ 'Name'|trans }}</label>
<input type="text" class="form-control" id="renameCandidateName-{{ candidate.id }}" <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>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
@@ -69,6 +74,23 @@
{{ 'No candidates'|trans }} {{ 'No candidates'|trans }}
{% endfor %} {% endfor %}
</ul> </ul>
<div class="modal fade" tabindex="-1"
data-bo--modal-target="modal"
data-action="hidden.bs.modal->bo--modal#resetDirty"
aria-labelledby="addCandidatesModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="addCandidatesModalLabel">{{ 'Add Candidate'|trans }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<turbo-frame id="add-candidates-modal-frame"
data-bo--modal-target="frame"
data-action="input->bo--modal#markDirty change->bo--modal#markDirty"></turbo-frame>
</div>
</div>
</div>
</div> </div>
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
{{ include('backoffice/help/season_candidates.html.twig') }} {{ include('backoffice/help/season_candidates.html.twig') }}
+168
View File
@@ -0,0 +1,168 @@
{% apply inline_css %}
{% apply inky_to_html %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Tijd voor de test{% endblock %}</title>
<style>
body, table, td { font-family: Arial, Helvetica, sans-serif; }
body {
margin: 0;
padding: 0;
width: 100% !important;
background-color: #f2f4f1;
color: #22281f;
}
table { border-collapse: collapse; }
img { border: 0; }
a { color: #2d7a1f; }
.body-table { width: 100%; background-color: #f2f4f1; }
.container { width: 100%; max-width: 580px; }
.header > tbody > tr > th { padding: 28px 24px 20px 24px; text-align: center; }
.brand-table { margin: 0 auto; }
.square-cell { width: 18px; }
.square {
width: 18px !important;
height: 18px !important;
max-width: 18px;
min-width: 18px;
max-height: 18px;
min-height: 18px;
font-size: 0;
line-height: 1px;
border-radius: 4px;
background-color: #6abf4b;
}
.square-gap { width: 10px; font-size: 0; line-height: 1px; }
.brand {
font-size: 20px;
font-weight: bold;
letter-spacing: 0.5px;
color: #17321a;
white-space: nowrap;
}
.content-wrapper { background-color: #f2f4f1; }
.content-wrapper > tbody > tr > th { padding: 24px 28px; }
.card {
background-color: #ffffff;
border-radius: 8px;
border: 1px solid #e1e6dd;
}
.card td.card-inner { padding: 32px; }
h1.heading {
margin: 0 0 16px 0;
font-size: 21px;
line-height: 1.3;
color: #17321a;
}
p.text {
margin: 0 0 16px 0;
font-size: 15px;
line-height: 1.6;
color: #3a4239;
}
p.text:last-child { margin-bottom: 0; }
.button { margin: 8px auto 12px auto; width: auto !important; }
.button table { width: auto; margin: 0 auto; }
.button td { border-radius: 5px; background: #2d7a1f; }
.button a {
background: #2d7a1f;
color: #ffffff !important;
border-radius: 5px;
display: inline-block;
padding: 12px 26px;
font-size: 15px;
font-weight: bold;
text-decoration: none;
border: 1px solid #2d7a1f;
}
.h-line th { border-bottom: 1px solid #e1e6dd; height: 1px; font-size: 0; line-height: 0; }
.footer > tbody > tr > th { padding: 24px 28px 32px 28px; text-align: center; }
.footer p {
margin: 0;
font-size: 12px;
line-height: 1.6;
color: #8b9186;
}
.preheader {
display: none !important;
visibility: hidden;
opacity: 0;
color: transparent;
height: 0;
width: 0;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
overflow: hidden;
mso-hide: all;
}
</style>
</head>
<body>
<span class="preheader">{% block preheader %}{% endblock %}</span>
<table class="body-table" role="presentation">
<tr>
<td align="center">
<container>
<row class="header">
<columns small="12" large="12" no-expander="true">
<table class="brand-table" role="presentation" align="center">
<tr>
<td class="square-cell" valign="middle">
<table role="presentation" width="18" height="18" style="width: 18px !important; height: 18px !important; max-width: 18px; min-width: 18px;">
<tr style="height: 18px !important;">
<td class="square" width="18" height="18" style="width: 18px !important; height: 18px !important; max-width: 18px; min-width: 18px; max-height: 18px; min-height: 18px;">&nbsp;</td>
</tr>
</table>
</td>
<td class="square-gap">&nbsp;</td>
<td class="brand" valign="middle">Tijd voor de test</td>
</tr>
</table>
</columns>
</row>
<row class="content-wrapper">
<columns small="12" large="12" class="content" no-expander="true">
<table class="card" role="presentation" width="100%">
<tr>
<td class="card-inner">
<h1 class="heading">{% block heading %}{% endblock %}</h1>
{% block body %}{% endblock %}
</td>
</tr>
</table>
</columns>
</row>
<row class="footer">
<columns small="12" large="12" no-expander="true">
<h-line></h-line>
<spacer size="16"></spacer>
{% block footer_extra %}{% endblock %}
</columns>
</row>
</container>
</td>
</tr>
</table>
</body>
</html>
{% endapply %}
{% endapply %}
+22 -4
View File
@@ -1,9 +1,27 @@
<h1>Hi!</h1> {% extends 'emails/layout.html.twig' %}
<p>To reset your password, please visit the following link</p> {% block preheader %}Je hebt om een nieuw wachtwoord gevraagd voor Tijd voor de test.{% endblock %}
{% block heading %}Tijd voor een nieuw wachtwoord{% endblock %}
{% block body %}
<p class="text">Beste speler,</p>
<p class="text">
Je hebt aangegeven dat je wachtwoord opnieuw moet worden ingesteld. Voordat je verder mag, is er nog één
opdracht: klik op de knop hieronder.
</p>
<center><button href="{{ url('tvdt_reset_password', {token: resetToken.token}) }}">Stel wachtwoord opnieuw in</button></center>
<p class="text">
Werkt de knop niet? Kopieer dan deze link naar je browser:<br>
<a href="{{ url('tvdt_reset_password', {token: resetToken.token}) }}">{{ url('tvdt_reset_password', {token: resetToken.token}) }}</a> <a href="{{ url('tvdt_reset_password', {token: resetToken.token}) }}">{{ url('tvdt_reset_password', {token: resetToken.token}) }}</a>
</p>
<p>This link will expire in {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.</p> <p class="text">
Let op: deze link is, niet eeuwig geldig. Hij verloopt over
{{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.
</p>
<p>Cheers!</p> <p class="text">Veel succes!</p>
{% endblock %}
+9 -10
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Tvdt\Tests\Command; namespace Tvdt\Tests\Command;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
@@ -48,21 +49,19 @@ final class ClaimSeasonCommandTest extends KernelTestCase
$this->assertCount(3, $season->owners); $this->assertCount(3, $season->owners);
} }
public function testInvalidEmailFails(): void /** @return iterable<string, array{string, string}> */
public static function invalidArgumentsProvider(): iterable
{ {
$this->commandTester->execute([ yield 'unknown email' => ['krtek', 'nonexisting@example.org'];
'season-code' => 'krtek', yield 'unknown season' => ['dhadk', 'test@example.org'];
'email' => 'nonexisting@example.org',
]);
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
} }
public function testInvalidSeasonCodeFails(): void #[DataProvider('invalidArgumentsProvider')]
public function testInvalidArgumentFails(string $seasonCode, string $email): void
{ {
$this->commandTester->execute([ $this->commandTester->execute([
'season-code' => 'dhadk', 'season-code' => $seasonCode,
'email' => 'test@example.org', 'email' => $email,
]); ]);
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode()); $this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\Season;
use Tvdt\Entity\User;
abstract class AbstractControllerWebTestCase extends WebTestCase
{
protected KernelBrowser $client;
protected EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
}
protected function getUserByEmail(string $email): User
{
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $email]);
$this->assertInstanceOf(User::class, $user);
return $user;
}
protected function loginAs(string $email): void
{
$this->client->loginUser($this->getUserByEmail($email));
}
/** Quiz names are only unique per season (see Quiz's UniqueConstraint), so this is scoped by season code. */
protected function getQuizByName(string $name, string $seasonCode = 'krtek'): Quiz
{
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name, 'season' => $this->getSeasonByCode($seasonCode)]);
$this->assertInstanceOf(Quiz::class, $quiz);
return $quiz;
}
/** Candidate names are only unique per season (see Candidate's UniqueConstraint), so this is scoped by season code. */
protected function getCandidate(string $name, string $seasonCode = 'krtek'): Candidate
{
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => $name, 'season' => $this->getSeasonByCode($seasonCode)]);
$this->assertInstanceOf(Candidate::class, $candidate);
return $candidate;
}
protected function getSeasonByCode(string $seasonCode): Season
{
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => $seasonCode]);
$this->assertInstanceOf(Season::class, $season);
return $season;
}
/** GETs $url and extracts the CSRF token from a form whose action contains $formActionContains. */
protected function getCsrfTokenFromPage(string $url, string $formActionContains, string $tokenFieldName = '_token'): string
{
$crawler = $this->client->request(Request::METHOD_GET, $url);
self::assertResponseIsSuccessful();
return $this->getCsrfTokenFromCrawler($crawler, $formActionContains, $tokenFieldName);
}
/** Extracts the CSRF token from a form on the page already loaded in the client. */
protected function getCsrfTokenFromCurrentPage(string $formActionContains, string $tokenFieldName = '_token'): string
{
return $this->getCsrfTokenFromCrawler($this->client->getCrawler(), $formActionContains, $tokenFieldName);
}
/** GETs $url and extracts the CSRF token input, regardless of which form it belongs to. */
protected function getTokenFromPage(string $url, string $tokenFieldName = '_token'): string
{
$crawler = $this->client->request(Request::METHOD_GET, $url);
self::assertResponseIsSuccessful();
$input = $crawler->filter(\sprintf('input[name="%s"]', $tokenFieldName));
$this->assertGreaterThan(0, $input->count(), \sprintf('No input named "%s" found on the page', $tokenFieldName));
return (string) $input->first()->attr('value');
}
private function getCsrfTokenFromCrawler(Crawler $crawler, string $formActionContains, string $tokenFieldName): string
{
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="%s"]', $formActionContains, $tokenFieldName));
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
return (string) $input->first()->attr('value');
}
}
@@ -4,54 +4,53 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice; namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\BackofficeController; use Tvdt\Controller\Backoffice\BackofficeController;
use Tvdt\Entity\Quiz; use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
use Tvdt\Entity\User;
#[CoversClass(BackofficeController::class)] #[CoversClass(BackofficeController::class)]
final class BackofficeControllerTest extends WebTestCase final class BackofficeControllerTest extends AbstractControllerWebTestCase
{ {
public function testExportQuizFilenameIsSanitized(): void public function testExportQuizFilenameIsSanitized(): void
{ {
$client = self::createClient(); $user = $this->getUserByEmail('user2@example.org');
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
$user = $entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']);
$this->assertInstanceOf(User::class, $user);
$user->isVerified = true; $user->isVerified = true;
$entityManager->flush();
$client->loginUser($user);
$quiz = $entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1']); $this->entityManager->flush();
$this->assertInstanceOf(Quiz::class, $quiz); $this->client->loginUser($user);
$client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id)); $quiz = $this->getQuizByName('Quiz 1');
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
$disposition = (string) $client->getResponse()->headers->get('Content-Disposition'); $disposition = (string) $this->client->getResponse()->headers->get('Content-Disposition');
$this->assertStringContainsString('filename=Quiz-1.xlsx', $disposition); $this->assertStringContainsString('filename=Quiz-1.xlsx', $disposition);
$this->assertStringNotContainsString('Quiz 1.xlsx', $disposition); $this->assertStringNotContainsString('Quiz 1.xlsx', $disposition);
} }
public function testExportQuizRequiresVerifiedEmail(): void public function testExportQuizRequiresVerifiedEmail(): void
{ {
$client = self::createClient(); $user = $this->getUserByEmail('user2@example.org');
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
$user = $entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->assertFalse($user->isVerified); $this->assertFalse($user->isVerified);
$client->loginUser($user); $this->client->loginUser($user);
$quiz = $entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1']); $quiz = $this->getQuizByName('Quiz 1');
$this->assertInstanceOf(Quiz::class, $quiz);
$client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id)); $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
self::assertResponseRedirects(\sprintf('/backoffice/season/%s', $quiz->season->seasonCode)); self::assertResponseRedirects(\sprintf('/backoffice/season/%s', $quiz->season->seasonCode));
} }
public function testExportQuizIsDeniedForNonOwner(): void
{
$this->loginAs('test@example.org');
$quiz = $this->getQuizByName('Quiz 1');
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/quiz/%s/export', $quiz->id));
self::assertResponseStatusCodeSame(403);
}
} }
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use PHPUnit\Framework\Attributes\CoversClass;
use Safe\DateTimeImmutable;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\PrepareEliminationController;
use Tvdt\Entity\Answer;
use Tvdt\Entity\Elimination;
use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\Question;
use Tvdt\Entity\QuizCandidate;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
#[CoversClass(PrepareEliminationController::class)]
final class PrepareEliminationControllerTest extends AbstractControllerWebTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->loginAs('krtek-admin@example.org');
}
public function testIndexCreatesEliminationAndRedirectsToView(): void
{
$quiz = $this->getQuizByName('Quiz 1');
$candidate = $this->getCandidate('Tom');
$quizCandidate = new QuizCandidate($quiz, $candidate);
$quizCandidate->started = new DateTimeImmutable();
$this->entityManager->persist($quizCandidate);
$firstQuestion = $quiz->questions->first();
$this->assertInstanceOf(Question::class, $firstQuestion);
$answer = $firstQuestion->answers->first();
$this->assertInstanceOf(Answer::class, $answer);
$this->entityManager->persist(new GivenAnswer($candidate, $quiz, $answer));
$this->entityManager->flush();
$token = $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/krtek/quiz/%s/result', $quiz->id), '/elimination/prepare');
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/quiz/%s/elimination/prepare', $quiz->id), [
'_token' => $token,
]);
$response = $this->client->getResponse();
$this->assertTrue($response->isRedirect());
$this->assertStringContainsString('/backoffice/elimination/', (string) $response->headers->get('Location'));
$this->entityManager->clear();
$quiz = $this->getQuizByName('Quiz 1');
$elimination = $this->entityManager->getRepository(Elimination::class)->findOneBy(['quiz' => $quiz]);
$this->assertInstanceOf(Elimination::class, $elimination);
$this->assertArrayHasKey('Tom', $elimination->data);
}
public function testViewEliminationPageLoads(): void
{
$quiz = $this->getQuizByName('Quiz 1');
$elimination = new Elimination($quiz);
$elimination->data = ['Tom' => Elimination::SCREEN_GREEN];
$this->entityManager->persist($elimination);
$this->entityManager->flush();
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/elimination/%s', $elimination->id));
self::assertResponseIsSuccessful();
self::assertSelectorExists('form');
}
public function testViewEliminationSavesUpdatedColours(): void
{
$quiz = $this->getQuizByName('Quiz 1');
$elimination = new Elimination($quiz);
$elimination->data = ['Tom' => Elimination::SCREEN_GREEN];
$this->entityManager->persist($elimination);
$this->entityManager->flush();
$token = $this->getTokenFromPage(\sprintf('/backoffice/elimination/%s', $elimination->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/elimination/%s', $elimination->id), [
'_token' => $token,
'colour-tom' => Elimination::SCREEN_RED,
'start' => '0',
]);
self::assertResponseRedirects(\sprintf('/backoffice/elimination/%s', $elimination->id));
$this->entityManager->clear();
$updated = $this->entityManager->getRepository(Elimination::class)->find($elimination->id);
$this->assertInstanceOf(Elimination::class, $updated);
$this->assertSame(Elimination::SCREEN_RED, $updated->data['Tom']);
}
public function testViewEliminationWithStartRedirectsToPublicElimination(): void
{
$quiz = $this->getQuizByName('Quiz 1');
$elimination = new Elimination($quiz);
$elimination->data = ['Tom' => Elimination::SCREEN_GREEN];
$this->entityManager->persist($elimination);
$this->entityManager->flush();
$token = $this->getTokenFromPage(\sprintf('/backoffice/elimination/%s', $elimination->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/elimination/%s', $elimination->id), [
'_token' => $token,
'start' => '1',
]);
self::assertResponseRedirects(\sprintf('/elimination/%s', $elimination->id));
}
}
@@ -4,39 +4,18 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice; namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuestionBankController; use Tvdt\Controller\Backoffice\QuestionBankController;
use Tvdt\Entity\BankAnswer; use Tvdt\Entity\BankAnswer;
use Tvdt\Entity\BankQuestion; use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\Question; use Tvdt\Entity\Question;
use Tvdt\Entity\QuestionLabel; use Tvdt\Entity\QuestionLabel;
use Tvdt\Entity\Quiz; use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
use Tvdt\Entity\User;
#[CoversClass(QuestionBankController::class)] #[CoversClass(QuestionBankController::class)]
final class QuestionBankControllerTest extends WebTestCase final class QuestionBankControllerTest extends AbstractControllerWebTestCase
{ {
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
}
private function loginAsOwner(): void
{
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
}
private function getBankQuestion(string $question): BankQuestion private function getBankQuestion(string $question): BankQuestion
{ {
$bankQuestion = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => $question]); $bankQuestion = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => $question]);
@@ -45,26 +24,9 @@ final class QuestionBankControllerTest extends WebTestCase
return $bankQuestion; return $bankQuestion;
} }
private function getQuizByName(string $name): Quiz
{
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
$this->assertInstanceOf(Quiz::class, $quiz);
return $quiz;
}
private function getCsrfToken(string $formActionContains): string
{
$crawler = $this->client->getCrawler();
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
return (string) $input->first()->attr('value');
}
public function testIndexListsBankQuestions(): void public function testIndexListsBankQuestions(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$this->assertResponseIsSuccessful(); $this->assertResponseIsSuccessful();
@@ -75,7 +37,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testIndexFiltersByLabel(): void public function testIndexFiltersByLabel(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$label = $this->entityManager->getRepository(QuestionLabel::class)->findOneBy(['name' => 'Locatie']); $label = $this->entityManager->getRepository(QuestionLabel::class)->findOneBy(['name' => 'Locatie']);
$this->assertInstanceOf(QuestionLabel::class, $label); $this->assertInstanceOf(QuestionLabel::class, $label);
@@ -89,9 +51,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testNonOwnerIsDenied(): void public function testNonOwnerIsDenied(): void
{ {
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']); $this->loginAs('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
@@ -100,7 +60,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testCreateBankQuestion(): void public function testCreateBankQuestion(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new'); $crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
$this->assertResponseIsSuccessful(); $this->assertResponseIsSuccessful();
@@ -129,7 +89,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testCreateAllowedWithoutCorrectAnswer(): void public function testCreateAllowedWithoutCorrectAnswer(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new'); $crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value'); $token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
@@ -145,6 +105,7 @@ final class QuestionBankControllerTest extends WebTestCase
]); ]);
$this->assertResponseRedirects(); $this->assertResponseRedirects();
$this->entityManager->clear();
$saved = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => 'Vraag zonder goed antwoord']); $saved = $this->entityManager->getRepository(BankQuestion::class)->findOneBy(['question' => 'Vraag zonder goed antwoord']);
$this->assertInstanceOf(BankQuestion::class, $saved); $this->assertInstanceOf(BankQuestion::class, $saved);
$this->assertFalse($saved->isCompleteForQuiz); $this->assertFalse($saved->isCompleteForQuiz);
@@ -152,7 +113,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testEditBankQuestion(): void public function testEditBankQuestion(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?'); $bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
$url = \sprintf('/backoffice/season/krtek/question-bank/%s/edit', $bankQuestion->id); $url = \sprintf('/backoffice/season/krtek/question-bank/%s/edit', $bankQuestion->id);
@@ -181,12 +142,12 @@ final class QuestionBankControllerTest extends WebTestCase
public function testDeleteUsedBankQuestionLeavesQuizIntact(): void public function testDeleteUsedBankQuestionLeavesQuizIntact(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?'); $bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?');
$quiz2QuestionCount = $this->getQuizByName('Quiz 2')->questions->count(); $quiz2QuestionCount = $this->getQuizByName('Quiz 2')->questions->count();
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$token = $this->getCsrfToken(\sprintf('%s/delete', $bankQuestion->id)); $token = $this->getCsrfTokenFromCurrentPage(\sprintf('%s/delete', $bankQuestion->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/delete', $bankQuestion->id), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/delete', $bankQuestion->id), [
'_token' => $token, '_token' => $token,
@@ -201,13 +162,13 @@ final class QuestionBankControllerTest extends WebTestCase
public function testAssignCopiesQuestionIntoQuiz(): void public function testAssignCopiesQuestionIntoQuiz(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?'); $bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
$quiz = $this->getQuizByName('Quiz 2'); $quiz = $this->getQuizByName('Quiz 2');
$questionCount = $quiz->questions->count(); $questionCount = $quiz->questions->count();
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id)); $token = $this->getCsrfTokenFromCurrentPage(\sprintf('%s/assign', $bankQuestion->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
'_token' => $token, '_token' => $token,
@@ -240,7 +201,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testAssignUsedNonReusableQuestionIsRefused(): void public function testAssignUsedNonReusableQuestionIsRefused(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?'); $bankQuestion = $this->getBankQuestion('Waar sliep de Krtek?');
$quiz = $this->getQuizByName('Quiz 2'); $quiz = $this->getQuizByName('Quiz 2');
$questionCount = $quiz->questions->count(); $questionCount = $quiz->questions->count();
@@ -248,7 +209,7 @@ final class QuestionBankControllerTest extends WebTestCase
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
// The assign form is not rendered for used questions, so post with another form's token // The assign form is not rendered for used questions, so post with another form's token
$token = $this->getCsrfToken('/assign'); $token = $this->getCsrfTokenFromCurrentPage('/assign');
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
'_token' => $token, '_token' => $token,
'quiz' => (string) $quiz->id, 'quiz' => (string) $quiz->id,
@@ -262,13 +223,13 @@ final class QuestionBankControllerTest extends WebTestCase
public function testAssignSameReusableQuestionTwiceToSameQuizIsRefused(): void public function testAssignSameReusableQuestionTwiceToSameQuizIsRefused(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Wie is de Krtek?'); $bankQuestion = $this->getBankQuestion('Wie is de Krtek?');
$quiz = $this->getQuizByName('Quiz 2'); $quiz = $this->getQuizByName('Quiz 2');
$questionCount = $quiz->questions->count(); $questionCount = $quiz->questions->count();
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id)); $token = $this->getCsrfTokenFromCurrentPage(\sprintf('%s/assign', $bankQuestion->id));
$url = \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id); $url = \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id);
$this->client->request(Request::METHOD_POST, $url, ['_token' => $token, 'quiz' => (string) $quiz->id]); $this->client->request(Request::METHOD_POST, $url, ['_token' => $token, 'quiz' => (string) $quiz->id]);
@@ -283,13 +244,13 @@ final class QuestionBankControllerTest extends WebTestCase
public function testAssignIntoFinalizedQuizIsDenied(): void public function testAssignIntoFinalizedQuizIsDenied(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Wie is de Krtek?'); $bankQuestion = $this->getBankQuestion('Wie is de Krtek?');
$finalizedQuiz = $this->getQuizByName('Quiz 1'); $finalizedQuiz = $this->getQuizByName('Quiz 1');
$this->assertTrue($finalizedQuiz->isFinalized); $this->assertTrue($finalizedQuiz->isFinalized);
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$token = $this->getCsrfToken(\sprintf('%s/assign', $bankQuestion->id)); $token = $this->getCsrfTokenFromCurrentPage(\sprintf('%s/assign', $bankQuestion->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/%s/assign', $bankQuestion->id), [
'_token' => $token, '_token' => $token,
@@ -301,7 +262,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testCreateBankQuestionPreservesAnswerOrdering(): void public function testCreateBankQuestionPreservesAnswerOrdering(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new'); $crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
$this->assertResponseIsSuccessful(); $this->assertResponseIsSuccessful();
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value'); $token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
@@ -334,7 +295,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testEditBankQuestionPreservesAnswerOrdering(): void public function testEditBankQuestionPreservesAnswerOrdering(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?'); $bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
// Fixture answers in insertion order (all have ordering=0): Brood (correct), Yoghurt, Niks // Fixture answers in insertion order (all have ordering=0): Brood (correct), Yoghurt, Niks
@@ -374,7 +335,7 @@ final class QuestionBankControllerTest extends WebTestCase
public function testAddAndDeleteLabel(): void public function testAddAndDeleteLabel(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$token = (string) $crawler->filter('form[action$="/question-bank/labels"] input[name="_token"]')->attr('value'); $token = (string) $crawler->filter('form[action$="/question-bank/labels"] input[name="_token"]')->attr('value');
@@ -389,7 +350,7 @@ final class QuestionBankControllerTest extends WebTestCase
$this->assertInstanceOf(QuestionLabel::class, $label); $this->assertInstanceOf(QuestionLabel::class, $label);
$this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank'); $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank');
$deleteToken = $this->getCsrfToken(\sprintf('labels/%s/delete', $label->slug)); $deleteToken = $this->getCsrfTokenFromCurrentPage(\sprintf('labels/%s/delete', $label->slug));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/labels/%s/delete', $label->slug), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/question-bank/labels/%s/delete', $label->slug), [
'_token' => $deleteToken, '_token' => $deleteToken,
@@ -4,11 +4,8 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice; namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Safe\DateTimeImmutable; use Safe\DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuizController; use Tvdt\Controller\Backoffice\QuizController;
use Tvdt\Entity\Answer; use Tvdt\Entity\Answer;
@@ -17,51 +14,21 @@ use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\Question; use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz; use Tvdt\Entity\Quiz;
use Tvdt\Entity\QuizCandidate; use Tvdt\Entity\QuizCandidate;
use Tvdt\Entity\Season; use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
use Tvdt\Entity\User;
#[CoversClass(QuizController::class)] #[CoversClass(QuizController::class)]
final class QuizControllerTest extends WebTestCase final class QuizControllerTest extends AbstractControllerWebTestCase
{ {
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void protected function setUp(): void
{ {
$this->client = self::createClient(); parent::setUp();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']); $this->loginAs('krtek-admin@example.org');
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
}
private function getQuizByName(string $name): Quiz
{
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
$this->assertInstanceOf(Quiz::class, $quiz);
return $quiz;
}
private function getCandidate(string $name): Candidate
{
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => $name]);
$this->assertInstanceOf(Candidate::class, $candidate);
return $candidate;
} }
private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string
{ {
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id)); return $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id), $formActionContains);
self::assertResponseIsSuccessful();
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
return (string) $input->first()->attr('value');
} }
public function testIndexRedirectsToOverview(): void public function testIndexRedirectsToOverview(): void
@@ -206,6 +173,47 @@ final class QuizControllerTest extends WebTestCase
$this->assertTrue($updated->active); $this->assertTrue($updated->active);
} }
public function testResetCandidateProgressDeletesGivenAnswersAndClearsStarted(): void
{
$quiz = $this->getQuizByName('Quiz 1');
$candidate = $this->getCandidate('Tom');
$quizCandidate = new QuizCandidate($quiz, $candidate);
$quizCandidate->started = new DateTimeImmutable();
$this->entityManager->persist($quizCandidate);
$firstQuestion = $quiz->questions->first();
$this->assertInstanceOf(Question::class, $firstQuestion);
$answer = $firstQuestion->answers->first();
$this->assertInstanceOf(Answer::class, $answer);
$this->entityManager->persist(new GivenAnswer($candidate, $quiz, $answer));
$this->entityManager->flush();
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/candidates-list', $quiz->id));
self::assertResponseIsSuccessful();
$token = (string) $crawler->filter(\sprintf('form[action*="/%s/reset"] input[name="_token"]', $candidate->id))->first()->attr('value');
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/quiz/%s/candidate/%s/reset', $quiz->id, $candidate->id), [
'_token' => $token,
]);
self::assertResponseRedirects();
$this->entityManager->clear();
$updated = $this->entityManager->getRepository(QuizCandidate::class)->findOneBy([
'quiz' => $this->getQuizByName('Quiz 1'),
'candidate' => $this->getCandidate('Tom'),
]);
$this->assertInstanceOf(QuizCandidate::class, $updated);
$this->assertNotInstanceOf(\DateTimeImmutable::class, $updated->started);
$remainingAnswers = $this->entityManager->getRepository(GivenAnswer::class)->findBy([
'quiz' => $quiz,
'candidate' => $candidate,
]);
$this->assertCount(0, $remainingAnswers);
}
public function testModifyCorrection(): void public function testModifyCorrection(): void
{ {
$quiz = $this->getQuizByName('Quiz 1'); $quiz = $this->getQuizByName('Quiz 1');
@@ -296,9 +304,7 @@ final class QuizControllerTest extends WebTestCase
public function testNonOwnerIsDenied(): void public function testNonOwnerIsDenied(): void
{ {
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']); $this->loginAs('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
$quiz = $this->getQuizByName('Quiz 1'); $quiz = $this->getQuizByName('Quiz 1');
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id)); $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
@@ -308,8 +314,7 @@ final class QuizControllerTest extends WebTestCase
public function testOverviewLoadsForEmptyQuiz(): void public function testOverviewLoadsForEmptyQuiz(): void
{ {
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']); $season = $this->getSeasonByCode('krtek');
$this->assertInstanceOf(Season::class, $season);
$emptyQuiz = new Quiz(); $emptyQuiz = new Quiz();
$emptyQuiz->name = 'Empty Quiz'; $emptyQuiz->name = 'Empty Quiz';
@@ -326,8 +331,7 @@ final class QuizControllerTest extends WebTestCase
public function testAnswerMappingRedirectsWithFlashWhenNoQuestions(): void public function testAnswerMappingRedirectsWithFlashWhenNoQuestions(): void
{ {
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']); $season = $this->getSeasonByCode('krtek');
$this->assertInstanceOf(Season::class, $season);
$emptyQuiz = new Quiz(); $emptyQuiz = new Quiz();
$emptyQuiz->name = 'Empty Quiz'; $emptyQuiz->name = 'Empty Quiz';
@@ -4,63 +4,29 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice; namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Safe\DateTimeImmutable; use Safe\DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuizController; use Tvdt\Controller\Backoffice\QuizController;
use Tvdt\Entity\Answer; use Tvdt\Entity\Answer;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\Question; use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz; use Tvdt\Entity\Quiz;
use Tvdt\Entity\QuizCandidate; use Tvdt\Entity\QuizCandidate;
use Tvdt\Entity\Season; use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
use Tvdt\Entity\User;
#[CoversClass(QuizController::class)] #[CoversClass(QuizController::class)]
final class QuizFinalizeTest extends WebTestCase final class QuizFinalizeTest extends AbstractControllerWebTestCase
{ {
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void protected function setUp(): void
{ {
$this->client = self::createClient(); parent::setUp();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']); $this->loginAs('krtek-admin@example.org');
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
}
private function getQuizByName(string $name): Quiz
{
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
$this->assertInstanceOf(Quiz::class, $quiz);
return $quiz;
}
private function getKrtekSeason(): Season
{
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'krtek']);
$this->assertInstanceOf(Season::class, $season);
return $season;
} }
private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string private function getCsrfTokenFromOverview(Quiz $quiz, string $formActionContains): string
{ {
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id)); return $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id), $formActionContains);
$this->assertResponseIsSuccessful();
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
return (string) $input->first()->attr('value');
} }
public function testFinalizeSetsFinalizedAt(): void public function testFinalizeSetsFinalizedAt(): void
@@ -79,7 +45,7 @@ final class QuizFinalizeTest extends WebTestCase
public function testFinalizeRefusedWhenQuizHasErrors(): void public function testFinalizeRefusedWhenQuizHasErrors(): void
{ {
$season = $this->getKrtekSeason(); $season = $this->getSeasonByCode('krtek');
$invalidQuiz = new Quiz(); $invalidQuiz = new Quiz();
$invalidQuiz->name = 'Invalid Quiz'; $invalidQuiz->name = 'Invalid Quiz';
@@ -116,7 +82,7 @@ final class QuizFinalizeTest extends WebTestCase
$this->assertResponseRedirects(); $this->assertResponseRedirects();
$this->entityManager->clear(); $this->entityManager->clear();
$season = $this->getKrtekSeason(); $season = $this->getSeasonByCode('krtek');
$this->assertInstanceOf(Quiz::class, $season->activeQuiz); $this->assertInstanceOf(Quiz::class, $season->activeQuiz);
$this->assertSame('Quiz 1', $season->activeQuiz->name); $this->assertSame('Quiz 1', $season->activeQuiz->name);
} }
@@ -134,7 +100,7 @@ final class QuizFinalizeTest extends WebTestCase
$this->assertResponseRedirects(); $this->assertResponseRedirects();
$this->entityManager->clear(); $this->entityManager->clear();
$season = $this->getKrtekSeason(); $season = $this->getSeasonByCode('krtek');
$this->assertInstanceOf(Quiz::class, $season->activeQuiz); $this->assertInstanceOf(Quiz::class, $season->activeQuiz);
$this->assertSame('Quiz 2', $season->activeQuiz->name); $this->assertSame('Quiz 2', $season->activeQuiz->name);
} }
@@ -183,8 +149,7 @@ final class QuizFinalizeTest extends WebTestCase
// Scrape the token before a candidate starts, since the button disappears afterwards // Scrape the token before a candidate starts, since the button disappears afterwards
$token = $this->getCsrfTokenFromOverview($quiz, '/unfinalize'); $token = $this->getCsrfTokenFromOverview($quiz, '/unfinalize');
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => 'Tom']); $candidate = $this->getCandidate('Tom');
$this->assertInstanceOf(Candidate::class, $candidate);
$quizCandidate = new QuizCandidate($quiz, $candidate); $quizCandidate = new QuizCandidate($quiz, $candidate);
$quizCandidate->started = new DateTimeImmutable(); $quizCandidate->started = new DateTimeImmutable();
@@ -229,6 +194,6 @@ final class QuizFinalizeTest extends WebTestCase
self::assertResponseRedirects(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz2->id)); self::assertResponseRedirects(\sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz2->id));
$this->entityManager->clear(); $this->entityManager->clear();
$this->assertNotInstanceOf(Quiz::class, $this->getKrtekSeason()->activeQuiz); $this->assertNotInstanceOf(Quiz::class, $this->getSeasonByCode('krtek')->activeQuiz);
} }
} }
@@ -4,47 +4,18 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice; namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuizQuestionController; use Tvdt\Controller\Backoffice\QuizQuestionController;
use Tvdt\Entity\Question; use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz; use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
use Tvdt\Entity\User;
#[CoversClass(QuizQuestionController::class)] #[CoversClass(QuizQuestionController::class)]
final class QuizQuestionControllerTest extends WebTestCase final class QuizQuestionControllerTest extends AbstractControllerWebTestCase
{ {
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
}
private function loginAsOwner(): void
{
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
}
private function getQuizByName(string $name): Quiz
{
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
$this->assertInstanceOf(Quiz::class, $quiz);
return $quiz;
}
public function testEditPreservesAnswerOrdering(): void public function testEditPreservesAnswerOrdering(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$quiz = $this->getQuizByName('Quiz 2'); $quiz = $this->getQuizByName('Quiz 2');
$question = null; $question = null;
@@ -111,7 +82,7 @@ final class QuizQuestionControllerTest extends WebTestCase
public function testReorderQuestionsWithinQuiz(): void public function testReorderQuestionsWithinQuiz(): void
{ {
$this->loginAsOwner(); $this->loginAs('krtek-admin@example.org');
$quiz = $this->getQuizByName('Quiz 2'); $quiz = $this->getQuizByName('Quiz 2');
$originalQuestions = $quiz->questions->toArray(); $originalQuestions = $quiz->questions->toArray();
@@ -144,4 +115,43 @@ final class QuizQuestionControllerTest extends WebTestCase
$this->assertSame($originalLastId, (string) $reorderedQuestions[0]->id); $this->assertSame($originalLastId, (string) $reorderedQuestions[0]->id);
$this->assertSame($originalFirstId, (string) $reorderedQuestions[\count($reorderedQuestions) - 1]->id); $this->assertSame($originalFirstId, (string) $reorderedQuestions[\count($reorderedQuestions) - 1]->id);
} }
public function testEditIsDeniedForNonOwner(): void
{
$this->loginAs('test@example.org');
$quiz = $this->getQuizByName('Quiz 2');
$question = $quiz->questions->first();
$this->assertInstanceOf(Question::class, $question);
$this->client->request(Request::METHOD_GET, \sprintf(
'/backoffice/season/krtek/quiz/%s/question/%s/edit',
$quiz->id,
$question->id,
));
self::assertResponseStatusCodeSame(403);
}
public function testReorderIsDeniedForNonOwner(): void
{
$quiz = $this->getQuizByName('Quiz 2');
// Scrape a valid CSRF token as the owner before switching to the non-owner account,
// since the token is bound to the session, not the logged-in user.
$this->loginAs('krtek-admin@example.org');
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id));
self::assertResponseIsSuccessful();
$csrfToken = $crawler->filter('[data-bo--question-list-csrf-value]')->attr('data-bo--question-list-csrf-value');
$this->assertNotEmpty($csrfToken);
$this->loginAs('test@example.org');
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/quiz/%s/questions/reorder', $quiz->id), [
'_token' => $csrfToken,
'ordering' => array_map(static fn (Question $q): string => (string) $q->id, $quiz->questions->toArray()),
]);
self::assertResponseStatusCodeSame(403);
}
} }
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\ReleasesController;
use Tvdt\Service\GitHubReleasesService;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
#[CoversClass(ReleasesController::class)]
final class ReleasesControllerTest extends AbstractControllerWebTestCase
{
public function testReleasesFrameRendersReleaseNotes(): void
{
$this->loginAs('user2@example.org');
$this->mockReleasesService();
$this->client->request(Request::METHOD_GET, '/backoffice/releases');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('body', 'v0.8.0');
self::assertSelectorTextContains('body', 'Some release notes');
self::assertSelectorTextContains('#releasesModalLabel', 'Huidige versie: v0.8.0');
}
public function testReleasesFrameIsAccessibleWithoutAuthentication(): void
{
$this->mockReleasesService();
$this->client->request(Request::METHOD_GET, '/backoffice/releases');
self::assertResponseIsSuccessful();
}
private function mockReleasesService(): void
{
$body = json_encode([
[
'tag_name' => 'v0.8.0',
'name' => 'v0.8.0',
'published_at' => '2026-07-12T10:00:00Z',
'body' => 'Some release notes',
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
],
], \JSON_THROW_ON_ERROR);
$httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]);
self::getContainer()->set(GitHubReleasesService::class, new GitHubReleasesService($httpClient, new ArrayAdapter()));
}
}
@@ -4,40 +4,27 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice; namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\SeasonController; use Tvdt\Controller\Backoffice\SeasonController;
use Tvdt\Entity\Candidate; use Tvdt\Entity\Candidate;
use Tvdt\Entity\Season; use Tvdt\Entity\Season;
use Tvdt\Entity\User; use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
#[CoversClass(SeasonController::class)] #[CoversClass(SeasonController::class)]
final class SeasonControllerTest extends WebTestCase final class SeasonControllerTest extends AbstractControllerWebTestCase
{ {
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void protected function setUp(): void
{ {
$this->client = self::createClient(); parent::setUp();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']); $this->loginAs('krtek-admin@example.org');
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
} }
public function testRegenerateSeasonCodeChangesTheCode(): void public function testRegenerateSeasonCodeChangesTheCode(): void
{ {
$oldCode = 'krtek'; $oldCode = 'krtek';
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/backoffice/season/%s/settings', $oldCode)); $token = $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/%s/settings', $oldCode), '/regenerate-code');
self::assertResponseIsSuccessful();
$token = (string) $crawler->filter('form[action*="/regenerate-code"] input[name="_token"]')->first()->attr('value');
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/%s/settings/regenerate-code', $oldCode), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/%s/settings/regenerate-code', $oldCode), [
'_token' => $token, '_token' => $token,
@@ -54,13 +41,9 @@ final class SeasonControllerTest extends WebTestCase
public function testRegenerateSeasonCodeIsDeniedForNonOwner(): void public function testRegenerateSeasonCodeIsDeniedForNonOwner(): void
{ {
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/settings'); $token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/settings', '/regenerate-code');
self::assertResponseIsSuccessful();
$token = (string) $crawler->filter('form[action*="/regenerate-code"] input[name="_token"]')->first()->attr('value');
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']); $this->loginAs('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
$this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/settings/regenerate-code', [ $this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/settings/regenerate-code', [
'_token' => $token, '_token' => $token,
@@ -69,29 +52,10 @@ final class SeasonControllerTest extends WebTestCase
self::assertResponseStatusCodeSame(403); self::assertResponseStatusCodeSame(403);
} }
private function getCandidate(string $name): Candidate
{
$candidate = $this->entityManager->getRepository(Candidate::class)->findOneBy(['name' => $name]);
$this->assertInstanceOf(Candidate::class, $candidate);
return $candidate;
}
private function getCsrfTokenFromCandidatesTab(string $formActionContains): string
{
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/candidates');
self::assertResponseIsSuccessful();
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
return (string) $input->first()->attr('value');
}
public function testRenameCandidate(): void public function testRenameCandidate(): void
{ {
$candidate = $this->getCandidate('Tom'); $candidate = $this->getCandidate('Tom');
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/rename', $candidate->id)); $token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/candidates', \sprintf('/candidate/%s/rename', $candidate->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
'_token' => $token, '_token' => $token,
@@ -109,7 +73,7 @@ final class SeasonControllerTest extends WebTestCase
public function testRenameCandidateToExistingNameShowsError(): void public function testRenameCandidateToExistingNameShowsError(): void
{ {
$candidate = $this->getCandidate('Tom'); $candidate = $this->getCandidate('Tom');
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/rename', $candidate->id)); $token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/candidates', \sprintf('/candidate/%s/rename', $candidate->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
'_token' => $token, '_token' => $token,
@@ -128,7 +92,7 @@ final class SeasonControllerTest extends WebTestCase
{ {
$candidate = $this->getCandidate('Tom'); $candidate = $this->getCandidate('Tom');
$candidateId = $candidate->id; $candidateId = $candidate->id;
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/delete', $candidate->id)); $token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/candidates', \sprintf('/candidate/%s/delete', $candidate->id));
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/delete', $candidate->id), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/delete', $candidate->id), [
'_token' => $token, '_token' => $token,
@@ -140,14 +104,48 @@ final class SeasonControllerTest extends WebTestCase
$this->assertNotInstanceOf(Candidate::class, $this->entityManager->getRepository(Candidate::class)->find($candidateId)); $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 public function testRenameCandidateIsDeniedForNonOwner(): void
{ {
$candidate = $this->getCandidate('Tom'); $candidate = $this->getCandidate('Tom');
$token = $this->getCsrfTokenFromCandidatesTab(\sprintf('/candidate/%s/rename', $candidate->id)); $token = $this->getCsrfTokenFromPage('/backoffice/season/krtek/candidates', \sprintf('/candidate/%s/rename', $candidate->id));
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']); $this->loginAs('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [ $this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/candidate/%s/rename', $candidate->id), [
'_token' => $token, '_token' => $token,
@@ -4,11 +4,9 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice; namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Safe\DateTimeImmutable; use Safe\DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Tvdt\Controller\Backoffice\SettingsController; use Tvdt\Controller\Backoffice\SettingsController;
@@ -17,43 +15,21 @@ use Tvdt\Entity\Quiz;
use Tvdt\Entity\ResetPasswordRequest; use Tvdt\Entity\ResetPasswordRequest;
use Tvdt\Entity\Season; use Tvdt\Entity\Season;
use Tvdt\Entity\User; use Tvdt\Entity\User;
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
#[CoversClass(SettingsController::class)] #[CoversClass(SettingsController::class)]
final class SettingsControllerTest extends WebTestCase final class SettingsControllerTest extends AbstractControllerWebTestCase
{ {
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void protected function setUp(): void
{ {
$this->client = self::createClient(); parent::setUp();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$this->loginAs('test@example.org'); $this->loginAs('test@example.org');
} }
private function loginAs(string $email): void
{
$user = $this->getUserByEmail($email);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
}
private function getUserByEmail(string $email): ?User
{
return $this->entityManager->getRepository(User::class)->findOneBy(['email' => $email]);
}
private function getCsrfTokenFromSettings(string $formActionContains): string private function getCsrfTokenFromSettings(string $formActionContains): string
{ {
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/settings'); return $this->getCsrfTokenFromPage('/backoffice/settings', $formActionContains);
self::assertResponseIsSuccessful();
$input = $crawler->filter(\sprintf('form[action*="%s"] input[name="_token"]', $formActionContains));
$this->assertGreaterThan(0, $input->count(), \sprintf('No form found with action containing "%s"', $formActionContains));
return (string) $input->first()->attr('value');
} }
public function testSettingsPageLoadsAndNavContainsSettingsLink(): void public function testSettingsPageLoadsAndNavContainsSettingsLink(): void
@@ -98,7 +74,6 @@ final class SettingsControllerTest extends WebTestCase
$this->entityManager->clear(); $this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org'); $user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class); $hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($user, 'NewPass123!')); $this->assertTrue($hasher->isPasswordValid($user, 'NewPass123!'));
@@ -107,32 +82,21 @@ final class SettingsControllerTest extends WebTestCase
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
} }
public function testChangePasswordWithWrongCurrentPasswordIsRejected(): void /** @return iterable<string, array{string, string, string}> */
public static function invalidPasswordChangeProvider(): iterable
{ {
$this->client->request(Request::METHOD_GET, '/backoffice/settings'); yield 'wrong current password' => ['wrong-password', 'NewPass123!', 'NewPass123!'];
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([ yield 'mismatched repeat' => [TestFixtures::PASSWORD, 'NewPass123!', 'SomethingElse!'];
'change_user_password_form[currentPassword]' => 'wrong-password',
'change_user_password_form[plainPassword][first]' => 'NewPass123!',
'change_user_password_form[plainPassword][second]' => 'NewPass123!',
]);
$this->client->submit($form);
self::assertResponseStatusCodeSame(422);
$this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($user, TestFixtures::PASSWORD));
} }
public function testChangePasswordWithMismatchedRepeatIsRejected(): void #[DataProvider('invalidPasswordChangeProvider')]
public function testChangePasswordIsRejected(string $currentPassword, string $first, string $second): void
{ {
$this->client->request(Request::METHOD_GET, '/backoffice/settings'); $this->client->request(Request::METHOD_GET, '/backoffice/settings');
$form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([ $form = $this->client->getCrawler()->filter('form[action*="/backoffice/settings/password"]')->form([
'change_user_password_form[currentPassword]' => TestFixtures::PASSWORD, 'change_user_password_form[currentPassword]' => $currentPassword,
'change_user_password_form[plainPassword][first]' => 'NewPass123!', 'change_user_password_form[plainPassword][first]' => $first,
'change_user_password_form[plainPassword][second]' => 'SomethingElse!', 'change_user_password_form[plainPassword][second]' => $second,
]); ]);
$this->client->submit($form); $this->client->submit($form);
@@ -140,7 +104,6 @@ final class SettingsControllerTest extends WebTestCase
$this->entityManager->clear(); $this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org'); $user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class); $hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
$this->assertTrue($hasher->isPasswordValid($user, TestFixtures::PASSWORD)); $this->assertTrue($hasher->isPasswordValid($user, TestFixtures::PASSWORD));
} }
@@ -157,9 +120,8 @@ final class SettingsControllerTest extends WebTestCase
self::assertEmailCount(1); self::assertEmailCount(1);
$this->entityManager->clear(); $this->entityManager->clear();
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('test@example.org')); $this->assertNotInstanceOf(User::class, $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']));
$user = $this->getUserByEmail('new-address@example.org'); $user = $this->getUserByEmail('new-address@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertFalse($user->isVerified); $this->assertFalse($user->isVerified);
// User stays logged in // User stays logged in
@@ -179,7 +141,7 @@ final class SettingsControllerTest extends WebTestCase
self::assertEmailCount(0); self::assertEmailCount(0);
$this->entityManager->clear(); $this->entityManager->clear();
$this->assertInstanceOf(User::class, $this->getUserByEmail('test@example.org')); $this->getUserByEmail('test@example.org');
} }
public function testResendConfirmationEmailSendsEmail(): void public function testResendConfirmationEmailSendsEmail(): void
@@ -200,8 +162,8 @@ final class SettingsControllerTest extends WebTestCase
$token = $this->getCsrfTokenFromSettings('/backoffice/settings/resend-confirmation'); $token = $this->getCsrfTokenFromSettings('/backoffice/settings/resend-confirmation');
$user = $this->getUserByEmail('test@example.org'); $user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$user->isVerified = true; $user->isVerified = true;
$this->entityManager->flush(); $this->entityManager->flush();
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/settings'); $crawler = $this->client->request(Request::METHOD_GET, '/backoffice/settings');
@@ -242,7 +204,6 @@ final class SettingsControllerTest extends WebTestCase
public function testChangePasswordInvalidatesResetPasswordRequests(): void public function testChangePasswordInvalidatesResetPasswordRequests(): void
{ {
$user = $this->getUserByEmail('test@example.org'); $user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->createResetPasswordRequest($user); $this->createResetPasswordRequest($user);
$this->client->request(Request::METHOD_GET, '/backoffice/settings'); $this->client->request(Request::METHOD_GET, '/backoffice/settings');
@@ -257,14 +218,12 @@ final class SettingsControllerTest extends WebTestCase
$this->entityManager->clear(); $this->entityManager->clear();
$user = $this->getUserByEmail('test@example.org'); $user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $user])); $this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $user]));
} }
public function testChangeEmailInvalidatesResetPasswordRequests(): void public function testChangeEmailInvalidatesResetPasswordRequests(): void
{ {
$user = $this->getUserByEmail('test@example.org'); $user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->createResetPasswordRequest($user); $this->createResetPasswordRequest($user);
$this->client->request(Request::METHOD_GET, '/backoffice/settings'); $this->client->request(Request::METHOD_GET, '/backoffice/settings');
@@ -277,7 +236,6 @@ final class SettingsControllerTest extends WebTestCase
$this->entityManager->clear(); $this->entityManager->clear();
$user = $this->getUserByEmail('new-address@example.org'); $user = $this->getUserByEmail('new-address@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $user])); $this->assertSame(0, $this->entityManager->getRepository(ResetPasswordRequest::class)->count(['user' => $user]));
} }
@@ -293,7 +251,7 @@ final class SettingsControllerTest extends WebTestCase
self::assertResponseRedirects('/backoffice/settings'); self::assertResponseRedirects('/backoffice/settings');
$this->entityManager->clear(); $this->entityManager->clear();
$this->assertInstanceOf(User::class, $this->getUserByEmail('test@example.org')); $this->getUserByEmail('test@example.org');
} }
public function testDeleteAccountRemovesSoleOwnerSeasonsAndKeepsSharedSeasons(): void public function testDeleteAccountRemovesSoleOwnerSeasonsAndKeepsSharedSeasons(): void
@@ -309,7 +267,7 @@ final class SettingsControllerTest extends WebTestCase
self::assertResponseRedirects(); self::assertResponseRedirects();
$this->entityManager->clear(); $this->entityManager->clear();
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('sole-owner@example.org')); $this->assertNotInstanceOf(User::class, $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'sole-owner@example.org']));
// Sole-owner season is removed, including its quiz // Sole-owner season is removed, including its quiz
$this->assertNotInstanceOf(Season::class, $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'doomd'])); $this->assertNotInstanceOf(Season::class, $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => 'doomd']));
@@ -340,7 +298,7 @@ final class SettingsControllerTest extends WebTestCase
self::assertResponseRedirects(); self::assertResponseRedirects();
$this->entityManager->clear(); $this->entityManager->clear();
$this->assertNotInstanceOf(User::class, $this->getUserByEmail('user2@example.org')); $this->assertNotInstanceOf(User::class, $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'user2@example.org']));
foreach (['krtek', 'bbbbb'] as $seasonCode) { foreach (['krtek', 'bbbbb'] as $seasonCode) {
$season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => $seasonCode]); $season = $this->entityManager->getRepository(Season::class)->findOneBy(['seasonCode' => $seasonCode]);
@@ -378,7 +336,6 @@ final class SettingsControllerTest extends WebTestCase
public function testDownloadDataRequiresVerifiedEmail(): void public function testDownloadDataRequiresVerifiedEmail(): void
{ {
$user = $this->getUserByEmail('test@example.org'); $user = $this->getUserByEmail('test@example.org');
$this->assertInstanceOf(User::class, $user);
$this->assertFalse($user->isVerified); $this->assertFalse($user->isVerified);
$this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data'); $this->client->request(Request::METHOD_GET, '/backoffice/settings/download-data');
@@ -389,8 +346,8 @@ final class SettingsControllerTest extends WebTestCase
private function markUserVerified(string $email): void private function markUserVerified(string $email): void
{ {
$user = $this->getUserByEmail($email); $user = $this->getUserByEmail($email);
$this->assertInstanceOf(User::class, $user);
$user->isVerified = true; $user->isVerified = true;
$this->entityManager->flush(); $this->entityManager->flush();
} }
} }
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\EliminationController;
use Tvdt\Entity\Elimination;
use Tvdt\Helpers\Base64;
#[CoversClass(EliminationController::class)]
final class EliminationControllerTest extends AbstractControllerWebTestCase
{
private Elimination $elimination;
protected function setUp(): void
{
parent::setUp();
$quiz = $this->getQuizByName('Quiz 1');
$this->elimination = new Elimination($quiz);
$this->elimination->data = ['Tom' => Elimination::SCREEN_GREEN];
$this->entityManager->persist($this->elimination);
$this->entityManager->flush();
$this->loginAs('krtek-admin@example.org');
}
public function testIndexIsDeniedForNonOwner(): void
{
$this->loginAs('test@example.org');
$this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s', $this->elimination->id));
self::assertResponseStatusCodeSame(403);
}
public function testIndexPageLoads(): void
{
$this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s', $this->elimination->id));
self::assertResponseIsSuccessful();
self::assertSelectorExists('form');
}
public function testIndexRedirectsToCandidateScreen(): void
{
$crawler = $this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s', $this->elimination->id));
$form = $crawler->filter('form')->form([
'elimination_enter_name[name]' => 'Tom',
]);
$this->client->submit($form);
self::assertResponseRedirects(\sprintf('/elimination/%s/%s', $this->elimination->id, Base64::base64UrlEncode('Tom')));
}
public function testCandidateScreenUnknownCandidateRedirectsWithFlash(): void
{
$this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s/%s', $this->elimination->id, Base64::base64UrlEncode('Nobody')));
self::assertResponseRedirects(\sprintf('/elimination/%s', $this->elimination->id));
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Kon kandidaat met naam Nobody niet vinden');
}
public function testCandidateScreenCandidateNotInEliminationDataRedirectsWithFlash(): void
{
$this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s/%s', $this->elimination->id, Base64::base64UrlEncode('Claudia')));
self::assertResponseRedirects(\sprintf('/elimination/%s', $this->elimination->id));
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Kon geen kandidaat vinden met de naam Claudia in de eliminatie');
}
public function testCandidateScreenRendersColour(): void
{
$this->client->request(Request::METHOD_GET, \sprintf('/elimination/%s/%s', $this->elimination->id, Base64::base64UrlEncode('Tom')));
self::assertResponseIsSuccessful();
self::assertSelectorExists(\sprintf('#%s', Elimination::SCREEN_GREEN));
}
}
+71
View File
@@ -0,0 +1,71 @@
<?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 testNavbarTogglerHasNoDeadTargetWhenNotAuthenticated(): void
{
$this->client->request(Request::METHOD_GET, '/login');
$crawler = $this->client->getCrawler();
$toggler = $crawler->filter('.navbar-toggler');
if (0 === $toggler->count()) {
self::assertSelectorNotExists('#navbarSupportedContent');
return;
}
$target = $toggler->attr('data-bs-target');
$this->assertNotNull($target);
self::assertSelectorExists($target);
}
public function testLoginRedirectsToBackofficeWhenAlreadyAuthenticated(): void
{
$this->loginAs('test@example.org');
$this->client->request(Request::METHOD_GET, '/login');
self::assertResponseRedirects('/backoffice/');
}
public function testLoginWithInvalidCredentialsShowsFlash(): void
{
$this->client->request(Request::METHOD_GET, '/login');
$form = $this->client->getCrawler()->filter('form')->form([
'_username' => 'test@example.org',
'_password' => 'wrong-password',
]);
$this->client->submit($form);
self::assertResponseRedirects('/login');
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Ongeldige inloggegevens.');
}
public function testLogoutIsInterceptedByFirewall(): void
{
$this->loginAs('test@example.org');
$this->client->request(Request::METHOD_GET, '/logout');
self::assertResponseRedirects();
}
}
+209
View File
@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\QuizController;
use Tvdt\Entity\Answer;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\Question;
use Tvdt\Entity\QuizCandidate;
use Tvdt\Helpers\Base64;
#[CoversClass(QuizController::class)]
final class QuizControllerTest extends AbstractControllerWebTestCase
{
private function answerQuestion(Question $question): void
{
$tomHash = Base64::base64UrlEncode('Tom');
$url = \sprintf('/krtek/%s', $tomHash);
$crawler = $this->client->request(Request::METHOD_GET, $url);
self::assertResponseIsSuccessful();
$token = (string) $crawler->filter('input[name="token"]')->first()->attr('value');
$answer = $question->answers->first();
$this->assertInstanceOf(Answer::class, $answer);
$this->client->request(Request::METHOD_POST, $url, [
'token' => $token,
'answer' => (string) $answer->id,
]);
self::assertResponseRedirects($url);
}
public function testSelectSeasonPageLoads(): void
{
$this->client->request(Request::METHOD_GET, '/');
self::assertResponseIsSuccessful();
self::assertSelectorExists('form');
}
public function testSelectSeasonWithInvalidCodeRedirectsWithFlash(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/');
$form = $crawler->filter('form')->form([
'select_season[season_code]' => 'aaaaa',
]);
$this->client->submit($form);
self::assertResponseRedirects('/');
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Ongeldige seizoencode');
}
public function testSelectSeasonWithValidCodeRedirectsToEnterName(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/');
$form = $crawler->filter('form')->form([
'select_season[season_code]' => 'krtek',
]);
$this->client->submit($form);
self::assertResponseRedirects('/krtek');
}
public function testEnterNamePageLoads(): void
{
$this->client->request(Request::METHOD_GET, '/krtek');
self::assertResponseIsSuccessful();
self::assertSelectorExists('form');
}
public function testEnterNameRedirectsToQuizPage(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/krtek');
$form = $crawler->filter('form')->form([
'enter_name[name]' => 'Tom',
]);
$this->client->submit($form);
self::assertResponseRedirects(\sprintf('/krtek/%s', Base64::base64UrlEncode('Tom')));
}
public function testQuizPageUnknownCandidateRedirectsWithFlash(): void
{
$this->client->request(Request::METHOD_GET, \sprintf('/krtek/%s', Base64::base64UrlEncode('Nobody')));
self::assertResponseRedirects('/krtek');
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Kandidaat niet gevonden');
}
public function testQuizPageWithoutActiveQuizRedirectsWithFlash(): void
{
$season = $this->getSeasonByCode('bbbbb');
$season->addCandidate(new Candidate('Nienke'));
$this->entityManager->flush();
$this->client->request(Request::METHOD_GET, \sprintf('/bbbbb/%s', Base64::base64UrlEncode('Nienke')));
self::assertResponseRedirects('/bbbbb');
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Er is geen test actief');
}
public function testQuizPageRendersFirstQuestion(): void
{
$this->client->request(Request::METHOD_GET, \sprintf('/krtek/%s', Base64::base64UrlEncode('Tom')));
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('body', 'Is de Krtek een man of een vrouw?');
}
public function testQuizPageAnsweringPersistsGivenAnswerAndRedirects(): void
{
$quiz = $this->getQuizByName('Quiz 1');
$firstQuestion = $quiz->questions->first();
$this->assertInstanceOf(Question::class, $firstQuestion);
$answer = $firstQuestion->answers->first();
$this->assertInstanceOf(Answer::class, $answer);
$this->answerQuestion($firstQuestion);
$this->entityManager->clear();
$candidate = $this->getCandidate('Tom');
$givenAnswer = $this->entityManager->getRepository(GivenAnswer::class)->findOneBy(['candidate' => $candidate]);
$this->assertInstanceOf(GivenAnswer::class, $givenAnswer);
$this->assertTrue($answer->id->equals($givenAnswer->answer->id));
}
public function testQuizPageInvalidAnswerIdShowsFlash(): void
{
$url = \sprintf('/krtek/%s', Base64::base64UrlEncode('Tom'));
$crawler = $this->client->request(Request::METHOD_GET, $url);
$token = (string) $crawler->filter('input[name="token"]')->first()->attr('value');
$this->client->request(Request::METHOD_POST, $url, [
'token' => $token,
'answer' => '00000000-0000-0000-0000-000000000000',
]);
self::assertResponseRedirects($url);
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Selecteer een antwoorden alsjeblieft');
}
public function testQuizPageOutOfOrderAnswerShowsFlash(): void
{
$quiz = $this->getQuizByName('Quiz 1');
$secondQuestion = $quiz->questions->get(1);
$this->assertInstanceOf(Question::class, $secondQuestion);
$answer = $secondQuestion->answers->first();
$this->assertInstanceOf(Answer::class, $answer);
$url = \sprintf('/krtek/%s', Base64::base64UrlEncode('Tom'));
$crawler = $this->client->request(Request::METHOD_GET, $url);
$token = (string) $crawler->filter('input[name="token"]')->first()->attr('value');
$this->client->request(Request::METHOD_POST, $url, [
'token' => $token,
'answer' => (string) $answer->id,
]);
self::assertResponseRedirects($url);
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Je kan deze vraag niet beantwoorden');
}
public function testQuizPageCompletedShowsFlashAndRedirects(): void
{
$quiz = $this->getQuizByName('Quiz 1');
foreach ($quiz->questions as $question) {
$this->answerQuestion($question);
}
$this->client->request(Request::METHOD_GET, \sprintf('/krtek/%s', Base64::base64UrlEncode('Tom')));
self::assertResponseRedirects('/krtek');
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Test voltooid');
}
public function testQuizPageInactiveCandidateIsBlocked(): void
{
$quiz = $this->getQuizByName('Quiz 1');
$candidate = $this->getCandidate('Tom');
$quizCandidate = new QuizCandidate($quiz, $candidate);
$quizCandidate->active = false;
$this->entityManager->persist($quizCandidate);
$this->entityManager->flush();
$this->client->request(Request::METHOD_GET, \sprintf('/krtek/%s', Base64::base64UrlEncode('Tom')));
self::assertResponseRedirects('/krtek');
$this->client->followRedirect();
self::assertSelectorTextContains('body', 'Je mag deze test niet beantwoorden');
}
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\HttpFoundation\Request;
use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface;
use Tvdt\Controller\RegistrationController;
#[CoversClass(RegistrationController::class)]
final class RegistrationControllerTest extends AbstractControllerWebTestCase
{
public function testRegisterPageLoadsWhenNotAuthenticated(): void
{
$this->client->request(Request::METHOD_GET, '/register');
self::assertResponseIsSuccessful();
self::assertSelectorExists('form');
}
public function testRegisterRedirectsToBackofficeWhenAlreadyAuthenticated(): void
{
$this->loginAs('test@example.org');
$this->client->request(Request::METHOD_GET, '/register');
self::assertResponseRedirects('/backoffice/');
}
public function testRegisterCreatesUserSendsConfirmationAndLogsIn(): void
{
$crawler = $this->client->request(Request::METHOD_GET, '/register');
$form = $crawler->filter('form')->form([
'registration_form[email]' => 'newuser@example.org',
'registration_form[plainPassword][first]' => 'NewPass123!',
'registration_form[plainPassword][second]' => 'NewPass123!',
]);
$this->client->submit($form);
self::assertResponseRedirects('/backoffice/');
self::assertEmailCount(1);
$this->entityManager->clear();
$user = $this->getUserByEmail('newuser@example.org');
$this->assertFalse($user->isVerified);
}
public function testVerifyEmailWithoutIdRedirectsToRegister(): void
{
$this->client->request(Request::METHOD_GET, '/verify/email');
self::assertResponseRedirects('/register');
}
public function testVerifyEmailWithUnknownIdRedirectsToRegister(): void
{
$this->client->request(Request::METHOD_GET, '/verify/email', ['id' => '00000000-0000-0000-0000-000000000000']);
self::assertResponseRedirects('/register');
}
public function testVerifyEmailWithValidSignatureMarksUserVerified(): void
{
$user = $this->getUserByEmail('test@example.org');
$this->assertFalse($user->isVerified);
/** @var VerifyEmailHelperInterface $helper */
$helper = self::getContainer()->get(VerifyEmailHelperInterface::class);
$signature = $helper->generateSignature('tvdt_verify_email', $user->id->toRfc4122(), $user->email, ['id' => $user->id]);
$this->client->request(Request::METHOD_GET, $signature->getSignedUrl());
self::assertResponseRedirects('/backoffice/');
$this->entityManager->clear();
$updatedUser = $this->getUserByEmail('test@example.org');
$this->assertTrue($updatedUser->isVerified);
}
public function testVerifyEmailWithInvalidSignatureShowsErrorAndRedirects(): void
{
$user = $this->getUserByEmail('test@example.org');
$this->client->request(Request::METHOD_GET, '/verify/email', ['id' => (string) $user->id, 'expires' => '9999999999', 'signature' => 'invalid']);
self::assertResponseRedirects('/register');
}
}
@@ -4,10 +4,8 @@ declare(strict_types=1);
namespace Tvdt\Tests\Controller; namespace Tvdt\Tests\Controller;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\KernelBrowser; use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface; use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
@@ -15,18 +13,8 @@ use Tvdt\Controller\ResetPasswordController;
use Tvdt\Entity\User; use Tvdt\Entity\User;
#[CoversClass(ResetPasswordController::class)] #[CoversClass(ResetPasswordController::class)]
final class ResetPasswordControllerTest extends WebTestCase final class ResetPasswordControllerTest extends AbstractControllerWebTestCase
{ {
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
}
public function testRequestPageLoads(): void public function testRequestPageLoads(): void
{ {
$this->client->request(Request::METHOD_GET, '/reset-password'); $this->client->request(Request::METHOD_GET, '/reset-password');
@@ -35,22 +23,19 @@ final class ResetPasswordControllerTest extends WebTestCase
$this->assertSelectorExists('form'); $this->assertSelectorExists('form');
} }
public function testRequestWithUnknownEmailRedirectsToCheckEmail(): void /** @return iterable<string, array{string}> */
public static function emailProvider(): iterable
{ {
$this->client->request(Request::METHOD_GET, '/reset-password'); yield 'unknown email' => ['unknown@example.org'];
$form = $this->client->getCrawler()->filter('form')->form([ yield 'known email' => ['test@example.org'];
'reset_password_request_form[email]' => 'unknown@example.org',
]);
$this->client->submit($form);
$this->assertResponseRedirects('/reset-password/check-email');
} }
public function testRequestWithKnownEmailRedirectsToCheckEmail(): void #[DataProvider('emailProvider')]
public function testRequestRedirectsToCheckEmail(string $email): void
{ {
$this->client->request(Request::METHOD_GET, '/reset-password'); $this->client->request(Request::METHOD_GET, '/reset-password');
$form = $this->client->getCrawler()->filter('form')->form([ $form = $this->client->getCrawler()->filter('form')->form([
'reset_password_request_form[email]' => 'test@example.org', 'reset_password_request_form[email]' => $email,
]); ]);
$this->client->submit($form); $this->client->submit($form);
+1 -10
View File
@@ -6,21 +6,12 @@ namespace Tvdt\Tests\Controller;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Safe\DateTimeImmutable; use Safe\DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\WellKnownController; use Tvdt\Controller\WellKnownController;
#[CoversClass(WellKnownController::class)] #[CoversClass(WellKnownController::class)]
final class WellKnownControllerTest extends WebTestCase final class WellKnownControllerTest extends AbstractControllerWebTestCase
{ {
private KernelBrowser $client;
protected function setUp(): void
{
$this->client = self::createClient();
}
public function testChangePasswordRedirectsToSettings(): void public function testChangePasswordRedirectsToSettings(): void
{ {
$this->client->request(Request::METHOD_GET, '/.well-known/change-password'); $this->client->request(Request::METHOD_GET, '/.well-known/change-password');
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Entity;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Tvdt\Entity\BankAnswer;
use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\BankQuestionUsage;
use Tvdt\Entity\Quiz;
#[CoversClass(BankQuestion::class)]
final class BankQuestionTest extends TestCase
{
public function testIsCompleteForQuizIsFalseWithoutTwoAnswers(): void
{
$bankQuestion = new BankQuestion();
$bankQuestion->addAnswer(new BankAnswer('Only answer', true));
$this->assertFalse($bankQuestion->isCompleteForQuiz);
}
public function testIsCompleteForQuizIsFalseWithoutCorrectAnswer(): void
{
$bankQuestion = new BankQuestion();
$bankQuestion->addAnswer(new BankAnswer('Wrong 1'));
$bankQuestion->addAnswer(new BankAnswer('Wrong 2'));
$this->assertFalse($bankQuestion->isCompleteForQuiz);
}
public function testIsCompleteForQuizIsFalseWithMultipleCorrectAnswers(): void
{
$bankQuestion = new BankQuestion();
$bankQuestion->addAnswer(new BankAnswer('Right 1', true));
$bankQuestion->addAnswer(new BankAnswer('Right 2', true));
$this->assertFalse($bankQuestion->isCompleteForQuiz);
}
public function testIsCompleteForQuizIsTrueWithTwoAnswersAndOneCorrect(): void
{
$bankQuestion = new BankQuestion();
$bankQuestion->addAnswer(new BankAnswer('Right', true));
$bankQuestion->addAnswer(new BankAnswer('Wrong'));
$this->assertTrue($bankQuestion->isCompleteForQuiz);
}
public function testCanBeAssignedIsTrueWhenUnused(): void
{
$bankQuestion = new BankQuestion();
$this->assertTrue($bankQuestion->canBeAssigned);
}
public function testCanBeAssignedIsTrueWhenReusableEvenIfUsed(): void
{
$bankQuestion = new BankQuestion();
$bankQuestion->reusable = true;
$bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, new Quiz()));
$this->assertTrue($bankQuestion->canBeAssigned);
}
public function testCanBeAssignedIsFalseWhenSingleUseAndUsed(): void
{
$bankQuestion = new BankQuestion();
$bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, new Quiz()));
$this->assertFalse($bankQuestion->canBeAssigned);
}
public function testIsUsedInQuizIsTrueForQuizWithUsage(): void
{
$bankQuestion = new BankQuestion();
$quiz = new Quiz();
$bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, $quiz));
$this->assertTrue($bankQuestion->isUsedInQuiz($quiz));
}
public function testIsUsedInQuizIsFalseForDifferentQuiz(): void
{
$bankQuestion = new BankQuestion();
$bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, new Quiz()));
$this->assertFalse($bankQuestion->isUsedInQuiz(new Quiz()));
}
public function testToStringReturnsQuestionText(): void
{
$bankQuestion = new BankQuestion();
$bankQuestion->question = 'Wie is de Krtek?';
$this->assertSame('Wie is de Krtek?', (string) $bankQuestion);
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Entity;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\InputBag;
use Tvdt\Entity\Elimination;
use Tvdt\Entity\Quiz;
#[CoversClass(Elimination::class)]
final class EliminationTest extends TestCase
{
public function testGetScreenColourReturnsNullForNullName(): void
{
$elimination = new Elimination(new Quiz());
$this->assertNull($elimination->getScreenColour(null));
}
public function testGetScreenColourReturnsNullForUnknownName(): void
{
$elimination = new Elimination(new Quiz());
$elimination->data = $this->colours(['Tom' => Elimination::SCREEN_GREEN]);
$this->assertNull($elimination->getScreenColour('Claudia'));
}
public function testGetScreenColourReturnsColourForKnownName(): void
{
$elimination = new Elimination(new Quiz());
$elimination->data = $this->colours(['Tom' => Elimination::SCREEN_GREEN, 'Claudia' => Elimination::SCREEN_RED]);
$this->assertSame(Elimination::SCREEN_RED, $elimination->getScreenColour('Claudia'));
}
public function testUpdateFromInputBagUpdatesKnownColours(): void
{
$elimination = new Elimination(new Quiz());
$elimination->data = $this->colours(['Tom' => Elimination::SCREEN_GREEN, 'Claudia' => Elimination::SCREEN_RED]);
$elimination->updateFromInputBag($this->inputBag(['colour-tom' => Elimination::SCREEN_RED]));
$this->assertSame(Elimination::SCREEN_RED, $elimination->data['Tom']);
$this->assertSame(Elimination::SCREEN_RED, $elimination->data['Claudia']);
}
public function testUpdateFromInputBagIgnoresMissingInput(): void
{
$elimination = new Elimination(new Quiz());
$elimination->data = $this->colours(['Tom' => Elimination::SCREEN_GREEN]);
$elimination->updateFromInputBag($this->inputBag([]));
$this->assertSame(Elimination::SCREEN_GREEN, $elimination->data['Tom']);
}
public function testUpdateFromInputBagReturnsSelf(): void
{
$elimination = new Elimination(new Quiz());
$this->assertSame($elimination, $elimination->updateFromInputBag($this->inputBag([])));
}
/**
* @param array<string, string> $colours
*
* @return array<string, string>
*/
private function colours(array $colours): array
{
return $colours;
}
/**
* @param array<string, string> $parameters
*
* @return InputBag<bool|float|int|string>
*/
private function inputBag(array $parameters): InputBag
{
/** @var InputBag<bool|float|int|string> $inputBag */
$inputBag = new InputBag($parameters);
return $inputBag;
}
}
+17 -11
View File
@@ -4,28 +4,34 @@ declare(strict_types=1);
namespace Tvdt\Tests\Helpers; namespace Tvdt\Tests\Helpers;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Safe\Exceptions\UrlException; use Safe\Exceptions\UrlException;
use Tvdt\Helpers\Base64; use Tvdt\Helpers\Base64;
#[CoversClass(Base64::class)]
final class Base64Test extends TestCase final class Base64Test extends TestCase
{ {
public function testBase64UrlEncode(): void /** @return iterable<string, array{string, string}> */
public static function pairProvider(): iterable
{ {
$this->assertSame('TWFyaWpu', Base64::base64UrlEncode('Marijn')); yield 'Marijn' => ['Marijn', 'TWFyaWpu'];
$this->assertSame('UGhpbGluZQ', Base64::base64UrlEncode('Philine')); yield 'Philine' => ['Philine', 'UGhpbGluZQ'];
yield 'byte 254' => [\chr(254), '_g'];
$this->assertSame('_g', Base64::base64UrlEncode(\chr(254))); yield 'byte 250' => [\chr(250), '-g'];
$this->assertSame('-g', Base64::base64UrlEncode(\chr(250)));
} }
public function testBase64UrlDecode(): void #[DataProvider('pairProvider')]
public function testBase64UrlEncode(string $decoded, string $encoded): void
{ {
$this->assertSame('Marijn', Base64::base64UrlDecode('TWFyaWpu')); $this->assertSame($encoded, Base64::base64UrlEncode($decoded));
$this->assertSame('Philine', Base64::base64UrlDecode('UGhpbGluZQ')); }
$this->assertSame(\chr(254), Base64::base64UrlDecode('_g')); #[DataProvider('pairProvider')]
$this->assertSame(\chr(250), Base64::base64UrlDecode('-g')); public function testBase64UrlDecode(string $decoded, string $encoded): void
{
$this->assertSame($decoded, Base64::base64UrlDecode($encoded));
} }
public function testBase64UrlDecodeCanHandlePadding(): void public function testBase64UrlDecodeCanHandlePadding(): void
+17 -27
View File
@@ -4,41 +4,31 @@ declare(strict_types=1);
namespace Tvdt\Tests\Helpers; namespace Tvdt\Tests\Helpers;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Tvdt\Helpers\FilenameSanitizer; use Tvdt\Helpers\FilenameSanitizer;
#[CoversClass(FilenameSanitizer::class)]
final class FilenameSanitizerTest extends TestCase final class FilenameSanitizerTest extends TestCase
{ {
public function testReplacesSpacesWithDashes(): void /** @return iterable<string, array{string, string}> */
public static function sanitizeProvider(): iterable
{ {
$this->assertSame('Krtek-Weekend', FilenameSanitizer::sanitize('Krtek Weekend')); yield 'replaces spaces with dashes' => ['Krtek Weekend', 'Krtek-Weekend'];
yield 'strips path traversal' => ['../../etc/passwd', 'etc-passwd'];
yield 'strips forward slash' => ['a/b', 'a-b'];
yield 'strips backslash' => ['a\\b', 'a-b'];
yield 'strips control characters and special symbols' => ["Quiz #1 <script>\0", 'Quiz-1-script'];
yield 'transliterates unicode to ascii' => ['Wéird Ñame', 'Weird-Name'];
yield 'transliterates at sign in email' => ['test@example.org', 'test-example-org'];
yield 'returns unnamed for empty input' => ['', 'unnamed'];
yield 'returns unnamed for fully stripped input' => ['///', 'unnamed'];
} }
public function testStripsPathSeparatorsAndTraversal(): void #[DataProvider('sanitizeProvider')]
public function testSanitize(string $input, string $expected): void
{ {
$this->assertSame('etc-passwd', FilenameSanitizer::sanitize('../../etc/passwd')); $this->assertSame($expected, FilenameSanitizer::sanitize($input));
$this->assertSame('a-b', FilenameSanitizer::sanitize('a/b'));
$this->assertSame('a-b', FilenameSanitizer::sanitize('a\\b'));
}
public function testStripsControlCharactersAndSpecialSymbols(): void
{
$this->assertSame('Quiz-1-script', FilenameSanitizer::sanitize("Quiz #1 <script>\0"));
}
public function testTransliteratesUnicodeToAscii(): void
{
$this->assertSame('Weird-Name', FilenameSanitizer::sanitize('Wéird Ñame'));
}
public function testTransliteratesAtSignInEmail(): void
{
$this->assertSame('test-example-org', FilenameSanitizer::sanitize('test@example.org'));
}
public function testReturnsUnnamedForEmptyOrFullyStrippedInput(): void
{
$this->assertSame('unnamed', FilenameSanitizer::sanitize(''));
$this->assertSame('unnamed', FilenameSanitizer::sanitize('///'));
} }
} }
@@ -53,4 +53,22 @@ final class CandidateRepositoryTest extends DatabaseTestCase
); );
$this->assertNotInstanceOf(Candidate::class, $result); $this->assertNotInstanceOf(Candidate::class, $result);
} }
/** Candidate names are only unique per season, so a same-named candidate in another season must not leak in. */
public function testGetCandidateByHashScopesByCandidateSeasonNotJustName(): void
{
$krtekSeason = $this->getSeasonByCode('krtek');
$anotherSeason = $this->getSeasonByCode('bbbbb');
$duplicateNamedCandidate = new Candidate('Claudia');
$anotherSeason->addCandidate($duplicateNamedCandidate);
$this->entityManager->persist($duplicateNamedCandidate);
$this->entityManager->flush();
$candidate = $this->candidateRepository->getCandidateByHash($krtekSeason, 'Q2xhdWRpYQ');
$this->assertInstanceOf(Candidate::class, $candidate);
$this->assertSame($krtekSeason, $candidate->season);
$this->assertNotSame($duplicateNamedCandidate, $candidate);
}
} }
+12 -9
View File
@@ -5,25 +5,28 @@ declare(strict_types=1);
namespace Tvdt\Tests\Repository; namespace Tvdt\Tests\Repository;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Tvdt\Entity\Season; use Tvdt\Entity\Season;
use Tvdt\Repository\SeasonRepository; use Tvdt\Repository\SeasonRepository;
#[CoversClass(SeasonRepository::class)] #[CoversClass(SeasonRepository::class)]
final class SeasonRepositoryTest extends DatabaseTestCase final class SeasonRepositoryTest extends DatabaseTestCase
{ {
public function testGetSeasonsForUser(): void /** @return iterable<string, array{string, string}> */
public static function userSeasonsProvider(): iterable
{ {
$user = $this->getUserByEmail('krtek-admin@example.org'); yield 'krtek admin' => ['krtek-admin@example.org', 'krtek'];
yield 'user1' => ['user1@example.org', 'bbbbb'];
}
#[DataProvider('userSeasonsProvider')]
public function testGetSeasonsForUser(string $email, string $expectedSeasonCode): void
{
$user = $this->getUserByEmail($email);
$seasons = $this->seasonRepository->getSeasonsForUser($user); $seasons = $this->seasonRepository->getSeasonsForUser($user);
$this->assertCount(1, $seasons); $this->assertCount(1, $seasons);
$this->assertSame('krtek', $seasons[0]->seasonCode); $this->assertSame($expectedSeasonCode, $seasons[0]->seasonCode);
$user = $this->getUserByEmail('user1@example.org');
$seasons = $this->seasonRepository->getSeasonsForUser($user);
$this->assertCount(1, $seasons);
$this->assertSame('bbbbb', $seasons[0]->seasonCode);
} }
public function testUserWithMultipleSeasons(): void public function testUserWithMultipleSeasons(): void
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Service;
use Doctrine\ORM\EntityManagerInterface;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz;
use Tvdt\Repository\QuizRepository;
use Tvdt\Service\DataExportService;
use Tvdt\Service\QuizSpreadsheetService;
/**
* Reproduces a Sentry warning (PHP-SYMFONY-3Z): with more than 25 questions, the last raw-answers
* column goes past 'Z' (e.g. 'AA'), and range('A', 'AA') is invalid because range()'s second argument
* must be a single byte.
*/
#[CoversClass(DataExportService::class)]
final class DataExportServiceRawAnswersColumnsTest extends TestCase
{
public function testFillRawAnswersSheetHandlesMoreThanTwentyFiveQuestions(): void
{
$subject = new DataExportService(
$this->createStub(EntityManagerInterface::class),
$this->createStub(QuizSpreadsheetService::class),
$this->createStub(QuizRepository::class),
);
$quiz = new Quiz();
for ($i = 0; $i < 26; ++$i) {
$question = new Question();
$question->question = 'Question '.($i + 1);
$quiz->addQuestion($question);
}
$sheet = new Spreadsheet()->getActiveSheet();
$method = new \ReflectionMethod(DataExportService::class, 'fillRawAnswersSheet');
$method->invoke($subject, $sheet, $quiz);
$this->assertEqualsWithDelta(30.0, $sheet->getColumnDimension('AA')->getWidth(), \PHP_FLOAT_EPSILON);
}
}
+45
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tvdt\Tests\Service; namespace Tvdt\Tests\Service;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Reader; use PhpOffice\PhpSpreadsheet\Reader;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
@@ -201,6 +202,50 @@ final class DataExportServiceTest extends DatabaseTestCase
$this->assertSame('Man', $claudiaRow[$questionColumnIndex]); $this->assertSame('Man', $claudiaRow[$questionColumnIndex]);
} }
public function testRawAnswersSheetBoldsCorrectAnswersOnly(): void
{
$season = $this->getSeasonByCode('krtek');
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
$this->assertInstanceOf(Quiz::class, $quiz);
/** @var Question $firstQuestion */
$firstQuestion = $quiz->questions->first();
$correctAnswer = $firstQuestion->answers->filter(static fn (Answer $answer): bool => $answer->isRightAnswer)->first();
$wrongAnswer = $firstQuestion->answers->filter(static fn (Answer $answer): bool => !$answer->isRightAnswer)->first();
$this->assertInstanceOf(Answer::class, $correctAnswer);
$this->assertInstanceOf(Answer::class, $wrongAnswer);
$candidateWithCorrectAnswer = $this->getCandidateBySeasonAndName($season, 'Claudia');
$candidateWithWrongAnswer = $this->getCandidateBySeasonAndName($season, 'Eelco');
$this->quizCandidateRepository->createIfNotExist($quiz, $candidateWithCorrectAnswer);
$this->quizCandidateRepository->createIfNotExist($quiz, $candidateWithWrongAnswer);
$this->entityManager->persist(new GivenAnswer($candidateWithCorrectAnswer, $quiz, $correctAnswer));
$this->entityManager->persist(new GivenAnswer($candidateWithWrongAnswer, $quiz, $wrongAnswer));
$this->entityManager->flush();
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
$this->assertIsString($quizContent);
$zip->close();
$sheet = $this->loadSheet($quizContent, 'Raw answers');
$rows = $sheet->toArray();
$header = $rows[0];
$questionColumnIndex = array_search($firstQuestion->question, $header, true);
$this->assertIsInt($questionColumnIndex);
$column = Coordinate::stringFromColumnIndex($questionColumnIndex + 1);
$candidateNames = array_column(\array_slice($rows, 1), 0);
$correctRowNumber = 2 + array_search('Claudia', $candidateNames, true);
$wrongRowNumber = 2 + array_search('Eelco', $candidateNames, true);
$this->assertTrue($sheet->getStyle($column.$correctRowNumber)->getFont()->getBold(), 'Expected the correct answer to be bold');
$this->assertFalse($sheet->getStyle($column.$wrongRowNumber)->getFont()->getBold(), 'Expected the wrong answer to not be bold');
}
public function testQuizInfoSheetShowsDropoutsFinalizationAndDisabledQuestions(): void public function testQuizInfoSheetShowsDropoutsFinalizationAndDisabledQuestions(): void
{ {
$season = $this->getSeasonByCode('krtek'); $season = $this->getSeasonByCode('krtek');
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Service;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Safe\DateTimeImmutable;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Tvdt\Service\GitHubReleasesService;
#[CoversClass(GitHubReleasesService::class)]
final class GitHubReleasesServiceTest extends TestCase
{
public function testGetReleasesMapsGitHubResponse(): void
{
$body = json_encode([
[
'tag_name' => 'v0.8.0',
'name' => 'v0.8.0',
'published_at' => '2026-07-12T10:00:00Z',
'body' => "## Added\n- Something new",
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
],
], \JSON_THROW_ON_ERROR);
$httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]);
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
$releases = $subject->getReleases();
$this->assertEquals([
'tagName' => 'v0.8.0',
'name' => 'v0.8.0',
'publishedAt' => new DateTimeImmutable('2026-07-12T10:00:00Z'),
'body' => "## Added\n- Something new",
'url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
], $releases[0]);
}
public function testGetReleasesReturnsEmptyArrayOnHttpFailure(): void
{
$httpClient = new MockHttpClient(static fn (): MockResponse => new MockResponse('', ['http_code' => 500]));
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
$this->assertSame([], $subject->getReleases());
}
public function testHttpFailureIsNotCached(): void
{
$requestCount = 0;
$httpClient = new MockHttpClient(static function () use (&$requestCount): MockResponse {
++$requestCount;
return new MockResponse('', ['http_code' => 500]);
});
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
$subject->getReleases();
$subject->getReleases();
$this->assertSame(2, $requestCount);
}
public function testGetReleasesReturnsEmptyArrayOnUnparseablePublishedAt(): void
{
$body = json_encode([
[
'tag_name' => 'v0.8.0',
'name' => 'v0.8.0',
'published_at' => 'not-a-valid-date',
'body' => 'Some release notes',
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
],
], \JSON_THROW_ON_ERROR);
$httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]);
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
$this->assertSame([], $subject->getReleases());
}
public function testGetReleasesKeepsALiteralZeroReleaseName(): void
{
$body = json_encode([
[
'tag_name' => 'v0.9.0',
'name' => '0',
'published_at' => '2026-07-12T10:00:00Z',
'body' => 'Some release notes',
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.9.0',
],
], \JSON_THROW_ON_ERROR);
$httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]);
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
$releases = $subject->getReleases();
$this->assertSame('0', $releases[0]['name']);
}
public function testGetReleasesSortsNewestFirst(): void
{
$body = json_encode([
[
'tag_name' => 'v0.7.0',
'name' => 'v0.7.0',
'published_at' => '2026-07-10T10:00:00Z',
'body' => 'Older release',
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.7.0',
],
[
'tag_name' => 'v0.8.0',
'name' => 'v0.8.0',
'published_at' => '2026-07-12T10:00:00Z',
'body' => 'Newer release',
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
],
], \JSON_THROW_ON_ERROR);
$httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]);
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
$releases = $subject->getReleases();
$this->assertSame('v0.8.0', $releases[0]['tagName']);
$this->assertSame('v0.7.0', $releases[1]['tagName']);
}
}
+32
View File
@@ -117,6 +117,10 @@
<source>Are you sure you want to delete this quiz?</source> <source>Are you sure you want to delete this quiz?</source>
<target>Weet je zeker dat je deze test wilt verwijderen?</target> <target>Weet je zeker dat je deze test wilt verwijderen?</target>
</trans-unit> </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"> <trans-unit id="4bcq6sL" resname="Assign">
<source>Assign</source> <source>Assign</source>
<target>Toewijzen</target> <target>Toewijzen</target>
@@ -161,6 +165,10 @@
<source>Candidate not found</source> <source>Candidate not found</source>
<target>Kandidaat niet gevonden</target> <target>Kandidaat niet gevonden</target>
</trans-unit> </trans-unit>
<trans-unit id="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"> <trans-unit id="QH4e_Ho" resname="Candidate renamed">
<source>Candidate renamed</source> <source>Candidate renamed</source>
<target>Kandidaat hernoemd</target> <target>Kandidaat hernoemd</target>
@@ -233,6 +241,10 @@
<source>Could not find candidate with name {name} in elimination.</source> <source>Could not find candidate with name {name} in elimination.</source>
<target>Kon geen kandidaat vinden met de naam {name} in de eliminatie</target> <target>Kon geen kandidaat vinden met de naam {name} in de eliminatie</target>
</trans-unit> </trans-unit>
<trans-unit id="HL5QYZd" resname="Could not load releases from GitHub.">
<source>Could not load releases from GitHub.</source>
<target>Kan releases niet laden van GitHub.</target>
</trans-unit>
<trans-unit id="0DvmToq" resname="Create a season"> <trans-unit id="0DvmToq" resname="Create a season">
<source>Create a season</source> <source>Create a season</source>
<target>Maak een seizoen aan</target> <target>Maak een seizoen aan</target>
@@ -257,6 +269,10 @@
<source>Current password</source> <source>Current password</source>
<target>Huidig wachtwoord</target> <target>Huidig wachtwoord</target>
</trans-unit> </trans-unit>
<trans-unit id="xreXHN5" resname="Current version">
<source>Current version</source>
<target>Huidige versie</target>
</trans-unit>
<trans-unit id="PkrbQOH" resname="Cyan"> <trans-unit id="PkrbQOH" resname="Cyan">
<source>Cyan</source> <source>Cyan</source>
<target>Cyaan</target> <target>Cyaan</target>
@@ -541,6 +557,10 @@
<source>Number of dropouts:</source> <source>Number of dropouts:</source>
<target>Aantal afvallers:</target> <target>Aantal afvallers:</target>
</trans-unit> </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"> <trans-unit id="_SqArFZ" resname="Open">
<source>Open</source> <source>Open</source>
<target>Openen</target> <target>Openen</target>
@@ -733,6 +753,10 @@
<source>Register</source> <source>Register</source>
<target>Registreren</target> <target>Registreren</target>
</trans-unit> </trans-unit>
<trans-unit id="3s37xTt" resname="Releases">
<source>Releases</source>
<target>Releases</target>
</trans-unit>
<trans-unit id="WevL4T_" resname="Remember me"> <trans-unit id="WevL4T_" resname="Remember me">
<source>Remember me</source> <source>Remember me</source>
<target>Onthoud mij</target> <target>Onthoud mij</target>
@@ -761,6 +785,10 @@
<source>Reset password</source> <source>Reset password</source>
<target>Wachtwoord herstellen</target> <target>Wachtwoord herstellen</target>
</trans-unit> </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"> <trans-unit id="eyayNGN" resname="Reset your password">
<source>Reset your password</source> <source>Reset your password</source>
<target>Wachtwoord herstellen</target> <target>Wachtwoord herstellen</target>
@@ -961,6 +989,10 @@
<source>View</source> <source>View</source>
<target>Bekijken</target> <target>Bekijken</target>
</trans-unit> </trans-unit>
<trans-unit id="a1.g0sm" resname="View on GitHub">
<source>View on GitHub</source>
<target>Bekijk op GitHub</target>
</trans-unit>
<trans-unit id="JWRtx_o" resname="White"> <trans-unit id="JWRtx_o" resname="White">
<source>White</source> <source>White</source>
<target>Wit</target> <target>Wit</target>