Compare commits
8 Commits
v2026.2.7-
...
v2026.2.8-
| Author | SHA1 | Date | |
|---|---|---|---|
| b209ad4220 | |||
| df1eefdfe0 | |||
| 7aaaf120b2 | |||
| e48d651eb5 | |||
| d704be5bff | |||
| 6bf908b43e | |||
| 085e010907 | |||
| 8935216525 |
3
.env.test
Normal file
3
.env.test
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# define your env variables for the test env here
|
||||||
|
KERNEL_CLASS='App\Kernel'
|
||||||
|
APP_SECRET='$ecretf0rt3st'
|
||||||
142
.gitea/workflows/ci.yml-bak
Normal file
142
.gitea/workflows/ci.yml-bak
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
name: CI - Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tests:
|
||||||
|
runs-on: splendid-bear
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: mineseeker_test
|
||||||
|
POSTGRES_PASSWORD: test_password
|
||||||
|
POSTGRES_DB: mineseeker_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP 8.3
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
extensions: pdo_pgsql, gd, intl, zip, sodium
|
||||||
|
coverage: none
|
||||||
|
tools: composer:v2
|
||||||
|
|
||||||
|
- name: Validate composer.json
|
||||||
|
run: composer validate --strict
|
||||||
|
|
||||||
|
- name: Cache Composer dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: vendor
|
||||||
|
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-composer-
|
||||||
|
|
||||||
|
- name: Install PHP dependencies
|
||||||
|
run: composer install --prefer-dist --no-progress --no-interaction
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Cache node modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-
|
||||||
|
|
||||||
|
- name: Install Node dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build assets
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Create .env.test file
|
||||||
|
run: |
|
||||||
|
cat > .env.test << 'ENVEOF'
|
||||||
|
APP_ENV=test
|
||||||
|
APP_SECRET=test-secret-key-for-ci-testing-only
|
||||||
|
DATABASE_URL="postgresql://mineseeker_test:test_password@localhost:5432/mineseeker_test?serverVersion=18&charset=utf8"
|
||||||
|
KERNEL_CLASS='App\Kernel'
|
||||||
|
SYMFONY_DEPRECATIONS_HELPER=999999
|
||||||
|
PANTHER_APP_ENV=panther
|
||||||
|
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
|
||||||
|
ENVEOF
|
||||||
|
|
||||||
|
- name: Setup test database
|
||||||
|
run: make test-db-setup
|
||||||
|
|
||||||
|
- name: Run PHPUnit tests
|
||||||
|
run: vendor/bin/phpunit --testdox --colors=always
|
||||||
|
|
||||||
|
- name: Run PHPUnit tests with coverage (optional)
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
run: |
|
||||||
|
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text --coverage-clover=coverage.xml
|
||||||
|
|
||||||
|
- name: Upload coverage reports (optional)
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: codecov/codecov-action@v4
|
||||||
|
with:
|
||||||
|
file: ./coverage.xml
|
||||||
|
flags: unittests
|
||||||
|
name: phpunit-coverage
|
||||||
|
fail_ci_if_error: false
|
||||||
|
|
||||||
|
lint:
|
||||||
|
runs-on: splendid-bear
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP 8.3
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
tools: composer:v2, phpstan
|
||||||
|
|
||||||
|
- name: Install PHP dependencies
|
||||||
|
run: composer install --prefer-dist --no-progress --no-interaction
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install Node dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run ESLint
|
||||||
|
run: npm run lint || true
|
||||||
|
|
||||||
|
- name: Check code style (PHP)
|
||||||
|
run: |
|
||||||
|
if [ -f "vendor/bin/php-cs-fixer" ]; then
|
||||||
|
vendor/bin/php-cs-fixer fix --dry-run --diff
|
||||||
|
else
|
||||||
|
echo "PHP-CS-Fixer not installed, skipping..."
|
||||||
|
fi
|
||||||
126
.gitea/workflows/deploy.yaml
Normal file
126
.gitea/workflows/deploy.yaml
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
name: Deploy to Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: splendid-bear
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: mineseeker_test
|
||||||
|
POSTGRES_PASSWORD: test_password
|
||||||
|
POSTGRES_DB: mineseeker_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP 8.3
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
extensions: pdo_pgsql, gd, intl, zip, sodium
|
||||||
|
coverage: none
|
||||||
|
tools: composer:v2
|
||||||
|
|
||||||
|
- name: Install PHP dependencies
|
||||||
|
run: composer install --prefer-dist --no-progress --no-interaction
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install Node dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build assets
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Create .env.test file
|
||||||
|
run: |
|
||||||
|
cat > .env.test << 'ENVEOF'
|
||||||
|
APP_ENV=test
|
||||||
|
APP_SECRET=test-secret-key-for-ci-testing-only
|
||||||
|
DATABASE_URL="postgresql://mineseeker_test:test_password@localhost:5432/mineseeker_test?serverVersion=18&charset=utf8"
|
||||||
|
KERNEL_CLASS='App\Kernel'
|
||||||
|
SYMFONY_DEPRECATIONS_HELPER=999999
|
||||||
|
ENVEOF
|
||||||
|
|
||||||
|
- name: Setup test database
|
||||||
|
run: make test-db-setup
|
||||||
|
|
||||||
|
- name: Run PHPUnit tests
|
||||||
|
run: vendor/bin/phpunit --testdox --colors=always --stop-on-failure
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: test
|
||||||
|
runs-on: splendid-bear
|
||||||
|
steps:
|
||||||
|
- name: Checkout tag
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ gitea.token }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
export HOME=/tmp
|
||||||
|
git config --global credential.helper '!f() { echo "username=oauth2"; echo "password=$GITEA_TOKEN"; }; f'
|
||||||
|
git config --global --add safe.directory "${{ vars.PROD_APP_DIR }}"
|
||||||
|
cd "${{ vars.PROD_APP_DIR }}"
|
||||||
|
git remote set-url origin "${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
git fetch --tags --force
|
||||||
|
git checkout "${{ gitea.ref_name }}"
|
||||||
|
|
||||||
|
- name: Write .env
|
||||||
|
env:
|
||||||
|
PROD_ENV_FILE: ${{ secrets.PROD_ENV_FILE }}
|
||||||
|
run: |
|
||||||
|
printf '%s' "$PROD_ENV_FILE" > "${{ vars.PROD_APP_DIR }}/.env"
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
run: |
|
||||||
|
cd "${{ vars.PROD_APP_DIR }}"
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
- name: Run database migrations
|
||||||
|
run: |
|
||||||
|
cd "${{ vars.PROD_APP_DIR }}"
|
||||||
|
docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
|
||||||
|
- name: Clear cache
|
||||||
|
run: |
|
||||||
|
cd "${{ vars.PROD_APP_DIR }}"
|
||||||
|
docker compose run --rm app php bin/console cache:clear --env=prod
|
||||||
|
|
||||||
|
- name: Start services
|
||||||
|
run: |
|
||||||
|
cd "${{ vars.PROD_APP_DIR }}"
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
- name: Health check
|
||||||
|
run: |
|
||||||
|
sleep 5
|
||||||
|
curl -f http://localhost:10080/ || exit 1
|
||||||
|
|
||||||
|
- name: Notify deployment success
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
echo "✅ Deployment successful for tag ${{ gitea.ref_name }}"
|
||||||
|
|
||||||
|
- name: Notify deployment failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "❌ Deployment failed for tag ${{ gitea.ref_name }}"
|
||||||
|
exit 1
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -14,3 +14,8 @@ nohup.out
|
|||||||
/var/
|
/var/
|
||||||
/vendor/
|
/vendor/
|
||||||
###< symfony/framework-bundle ###
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
|
###> phpunit/phpunit ###
|
||||||
|
/phpunit.xml
|
||||||
|
/.phpunit.cache/
|
||||||
|
###< phpunit/phpunit ###
|
||||||
|
|||||||
464
AGENTS.md
Normal file
464
AGENTS.md
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
# AI Agent Guidelines for MineSeeker
|
||||||
|
|
||||||
|
This document provides guidelines and context for AI coding agents working on the MineSeeker project.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**MineSeeker** is a real-time multiplayer 1v1 minesweeper game built with Symfony (PHP) and React. Players compete to claim mines on a shared 16×16 grid, with the first to reach 26 mines winning.
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
- **Backend:** Symfony 7.2 (PHP 8.3)
|
||||||
|
- **Frontend:** React 18, Vite 6
|
||||||
|
- **Database:** PostgreSQL 17 with materialized views
|
||||||
|
- **Storage:** MinIO (S3-compatible)
|
||||||
|
- **Real-time:** Mercure (Server-Sent Events)
|
||||||
|
- **Styling:** SCSS, MUI (Material-UI), Emotion
|
||||||
|
- **Fonts:** @fontsource packages (web), Carlito-Bold.ttf (server-side images)
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
|
||||||
|
**Core Gameplay:**
|
||||||
|
- **Multiplayer-focused:** 1v1 competitive gameplay where players race to claim more mines than their opponent
|
||||||
|
- **Win condition:** First player to claim 26 out of 51 mines wins
|
||||||
|
- **Real-time updates:** WebSocket-like gameplay using Mercure (Server-Sent Events)
|
||||||
|
- **Game restoration:** Players can resume unfinished games from where they left off
|
||||||
|
- **Bonus points system:** Rewards skilled play (blind hits, chain combos, edge mines, endgame mines, safe cell reveals)
|
||||||
|
|
||||||
|
**User Features:**
|
||||||
|
- **Authentication:** Password + optional TOTP + optional WebAuthn passkeys
|
||||||
|
- **Anonymous play:** Guest players can play without creating an account
|
||||||
|
- **Profile statistics:** Detailed stats including wins, losses, draws, win rate, average score, total mines hit, and bonus points
|
||||||
|
- **Battle history:** View and replay past games move-by-move
|
||||||
|
|
||||||
|
**Sharing & Social:**
|
||||||
|
- **Battle reports:** Shareable public pages for each completed game (`/battle/{uuid}`)
|
||||||
|
- **OG image generation:** Automatic creation of 1200×630 PNG images for social media sharing (using PHP GD)
|
||||||
|
- **Open Graph tags:** Battle share pages include rich preview cards with player names, avatars, scores, and bonus points
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Backend (Symfony)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── Controller/ # HTTP endpoints (game, profile, battle sharing)
|
||||||
|
├── Entity/ # Doctrine ORM entities
|
||||||
|
├── Repository/ # Database queries (uses QueryBuilder, not raw SQL)
|
||||||
|
├── Service/ # Business logic (BattleCardGenerator, WebAuthn, Email)
|
||||||
|
├── Dto/ # Data Transfer Objects (immutable, readonly)
|
||||||
|
├── Util/ # Game logic (TopicManager for Mercure)
|
||||||
|
└── Migrations/ # Database schema changes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important patterns:**
|
||||||
|
- Use Doctrine ORM QueryBuilder (not raw SQL) in repositories
|
||||||
|
- DTOs are `final readonly` classes with constructor property promotion
|
||||||
|
- Services use dependency injection via `config/services.yaml`
|
||||||
|
- Materialized views for performance (auto-refreshed via triggers)
|
||||||
|
|
||||||
|
### Frontend (React)
|
||||||
|
|
||||||
|
```
|
||||||
|
assets/
|
||||||
|
├── js/
|
||||||
|
│ ├── mine-seeker/ # Main game bundle (self-contained)
|
||||||
|
│ │ ├── MineSeeker.jsx # Root component, wraps GameProvider + QueryClientProvider
|
||||||
|
│ │ ├── components/ # Game-specific components
|
||||||
|
│ │ │ ├── GameBoard.jsx # Main game board grid
|
||||||
|
│ │ │ ├── GameTimer.jsx # Game timer display
|
||||||
|
│ │ │ ├── BonusBox.jsx # Bonus points indicator
|
||||||
|
│ │ │ ├── BonusStatsDialog.jsx # Bonus statistics modal
|
||||||
|
│ │ │ ├── CaptchaOverlay.jsx # Captcha challenge overlay
|
||||||
|
│ │ │ ├── ChallengeCountdown.jsx # Challenge timer
|
||||||
|
│ │ │ ├── OnlinePlayersDialog.jsx # Online players list
|
||||||
|
│ │ │ ├── WaitingOverlayContent.jsx # Waiting for opponent
|
||||||
|
│ │ │ ├── grid/ # Grid-related components (cells, mines)
|
||||||
|
│ │ │ ├── profile/ # In-game profile components (PlayerColumn)
|
||||||
|
│ │ │ ├── timer/ # Timer-related components
|
||||||
|
│ │ │ └── user/ # User-related components
|
||||||
|
│ │ ├── contexts/ # React Context API
|
||||||
|
│ │ │ ├── GameContext.jsx # Game state context
|
||||||
|
│ │ │ └── GameProvider.jsx # Context provider with state logic
|
||||||
|
│ │ ├── hooks/ # Custom React hooks
|
||||||
|
│ │ │ ├── useGameDataProvider.js # React Query data provider
|
||||||
|
│ │ │ ├── useGameRefs.jsx # Refs for DOM elements
|
||||||
|
│ │ │ ├── useGameState.jsx # Game state management
|
||||||
|
│ │ │ ├── useServerCommunication.jsx # Mercure SSE connection
|
||||||
|
│ │ │ └── useStepTimer.jsx # Step-by-step timer
|
||||||
|
│ │ └── utils/ # Game-specific utilities
|
||||||
|
│ │ └── constants.jsx # Game constants, colors, defaults
|
||||||
|
│ ├── components/ # Shared UI components
|
||||||
|
│ ├── utils/ # Shared utilities
|
||||||
|
│ ├── profile.jsx # Profile page entry
|
||||||
|
│ ├── passkey.jsx # Passkey management entry
|
||||||
|
│ └── contact.jsx # Contact form entry
|
||||||
|
├── css/
|
||||||
|
│ └── homepage/ # SCSS partials (imported by style.homepage.scss)
|
||||||
|
└── fonts/
|
||||||
|
└── Carlito-Bold.ttf # TTF font for PHP GD image generation
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important patterns:**
|
||||||
|
- Vite aliases: `@mine-components`, `@mine-contexts`, `@mine-hooks`, `@mine-utils`, `@global-components`, `@global-utils`
|
||||||
|
- React Query only available inside `mine-seeker` bundle
|
||||||
|
- Avoid circular dependencies (e.g., don't import from `@global-components` inside `components/` directory)
|
||||||
|
- PropTypes required on all components
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Adding a New Feature
|
||||||
|
|
||||||
|
1. **Backend:**
|
||||||
|
- Create migration for schema changes
|
||||||
|
- Add/update entities and repositories
|
||||||
|
- Create DTOs for data transfer
|
||||||
|
- Add controller endpoints
|
||||||
|
- Update service configuration if needed
|
||||||
|
|
||||||
|
2. **Frontend:**
|
||||||
|
- Create components in appropriate bundle
|
||||||
|
- Add PropTypes to all components
|
||||||
|
- Use existing hooks and utilities
|
||||||
|
- Follow styled-components pattern for MUI customization
|
||||||
|
|
||||||
|
3. **Documentation:**
|
||||||
|
- Update relevant docs in `docs/` folder
|
||||||
|
- Add examples if introducing new patterns
|
||||||
|
|
||||||
|
### Database Changes
|
||||||
|
|
||||||
|
- Always create migrations: `bin/console make:migration`
|
||||||
|
- Use Doctrine QueryBuilder in repositories (not raw SQL)
|
||||||
|
- For PostgreSQL-specific features (materialized views, triggers), use raw SQL in migrations only
|
||||||
|
- Materialized views should auto-refresh via triggers
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
- **Web fonts:** Use `@fontsource` packages (WOFF/WOFF2)
|
||||||
|
- **Server-side images:** Use TTF fonts in `assets/fonts/` (PHP GD requires TTF)
|
||||||
|
- **CSS:** Create SCSS partials in `assets/css/homepage/`, import in main file
|
||||||
|
- **Components:** Use Emotion styled-components or CSS classes
|
||||||
|
|
||||||
|
### File Headers
|
||||||
|
|
||||||
|
All PHP and JS/JSX files should have this header:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php declare(strict_types=1);
|
||||||
|
/*
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coding Standards
|
||||||
|
|
||||||
|
### PHP
|
||||||
|
|
||||||
|
- **PSR Standards:** Follow PSR-1, PSR-12 coding standards
|
||||||
|
- **Type declarations:** Use strict types (`declare(strict_types=1)`)
|
||||||
|
- **Property promotion:** Use constructor property promotion for DTOs and services
|
||||||
|
- **Readonly:** Use `readonly` for immutable properties
|
||||||
|
- **Final:** Mark DTOs as `final`
|
||||||
|
- **Doctrine:** Use QueryBuilder with `expr()` methods, not string concatenation
|
||||||
|
- **Null safety:** Use null coalescing `??` and null-safe operator `?->`
|
||||||
|
- **Formatting:** 4-space indentation, opening braces on same line for methods/classes
|
||||||
|
|
||||||
|
**Example DTO:**
|
||||||
|
|
||||||
|
```php
|
||||||
|
final readonly class ProfileGameDto implements JsonSerializable
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ?int $id,
|
||||||
|
public string $redName,
|
||||||
|
public string $blueName,
|
||||||
|
public bool $bothRegistered,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript/React
|
||||||
|
|
||||||
|
- **Components:** Functional components with hooks
|
||||||
|
- **PropTypes:** Required on all components
|
||||||
|
- **Imports:** Use aliases (`@global-components`, `@mine-hooks`, etc.)
|
||||||
|
- **State:** Use `useState`, `useEffect`, `useCallback`, `useMemo` appropriately
|
||||||
|
- **Avoid:** Circular dependencies, especially with barrel exports
|
||||||
|
|
||||||
|
**Example component:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { string, number } from 'prop-types';
|
||||||
|
|
||||||
|
export const MyComponent = ({ title, count }) => {
|
||||||
|
const [value, setValue] = useState(0);
|
||||||
|
|
||||||
|
return <div>{title}: {count + value}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
MyComponent.propTypes = {
|
||||||
|
title: string.isRequired,
|
||||||
|
count: number.isRequired,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Important Files & Locations
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- `config/services.yaml` - Service definitions and parameters
|
||||||
|
- `vite.config.js` - Vite build config, aliases
|
||||||
|
- `.env` - Environment variables (not in git)
|
||||||
|
- `composer.json` - PHP dependencies
|
||||||
|
- `package.json` - Node dependencies
|
||||||
|
|
||||||
|
### Key Services
|
||||||
|
|
||||||
|
- `BattleCardGenerator` - Generates 1200×630 PNG OG images using PHP GD
|
||||||
|
- `TopicManager` - Mercure topic management and game logic
|
||||||
|
- `WebAuthnService` - Passkey authentication
|
||||||
|
- `Email services` - Various email senders in `src/Service/Email/`
|
||||||
|
|
||||||
|
### Important Entities
|
||||||
|
|
||||||
|
- `User` - Registered users
|
||||||
|
- `PlayedGame` - Game records with moves, grid, scores
|
||||||
|
- `RecentBattle` - Read-only entity from materialized view
|
||||||
|
- `UserStats` - Read-only entity from materialized view
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- `docs/game-mechanics/BONUS_POINTS_SYSTEM.md` - Bonus points reference
|
||||||
|
- `docs/FONTS.md` - Font usage and management
|
||||||
|
- `AGENTS.md` - This file
|
||||||
|
- `CHANGELOG.md` - Project changelog
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### ❌ Don't Do
|
||||||
|
|
||||||
|
- **Don't use raw SQL in repositories** (use Doctrine QueryBuilder)
|
||||||
|
- **Don't create circular imports** (e.g., importing `@global-components` from within `components/`)
|
||||||
|
- **Don't use system fonts directly** (bundle TTF in `assets/fonts/`)
|
||||||
|
- **Don't skip PropTypes** on React components
|
||||||
|
- **Don't use `var`** in JavaScript (use `const` or `let`)
|
||||||
|
- **Don't define variables after using them** (hoisting issues with `const`)
|
||||||
|
|
||||||
|
### ✅ Do
|
||||||
|
|
||||||
|
- **Use Doctrine QueryBuilder** with `expr()` methods
|
||||||
|
- **Import components directly** to avoid circular dependencies
|
||||||
|
- **Bundle fonts in project** for portability
|
||||||
|
- **Add PropTypes** to all components
|
||||||
|
- **Use `const` for immutable values**, `let` for mutable
|
||||||
|
- **Define variables before using them**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Keeping Components in Sync
|
||||||
|
|
||||||
|
### Battle Report Display
|
||||||
|
|
||||||
|
The battle report/statistics appear in **two places** that must be kept synchronized:
|
||||||
|
|
||||||
|
#### 1. BattleDialog Component (React)
|
||||||
|
**File:** `assets/js/components/BattleDialog.jsx`
|
||||||
|
- React dialog component shown on profile page
|
||||||
|
- Uses Material-UI and styled-components
|
||||||
|
- Displays game stats, bonus points, winner information
|
||||||
|
|
||||||
|
#### 2. Battle Share Page (Twig)
|
||||||
|
**File:** `templates/Game/battle_share.html.twig`
|
||||||
|
- Public battle share page (accessible via `/battle/{uuid}`)
|
||||||
|
- Server-rendered HTML with SCSS styling
|
||||||
|
- Shows same information as BattleDialog
|
||||||
|
|
||||||
|
### Synchronization Rules
|
||||||
|
|
||||||
|
**When updating BattleDialog.jsx, also update battle_share.html.twig:**
|
||||||
|
|
||||||
|
✅ Adding new stats or fields
|
||||||
|
✅ Changing display logic (winner calculation, formatting)
|
||||||
|
✅ Modifying bonus points display
|
||||||
|
✅ Updating labels or text
|
||||||
|
|
||||||
|
**Keep in sync:**
|
||||||
|
- Game outcome logic (win/loss/draw/abandoned)
|
||||||
|
- Bonus points formatting
|
||||||
|
- Player name display
|
||||||
|
- Score display
|
||||||
|
- Stats and metadata shown
|
||||||
|
|
||||||
|
**Example:** If you add a new stat to BattleDialog showing "fastest mine claim time", you must also add it to battle_share.html.twig so both displays show the same information.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing & Building
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run migrations
|
||||||
|
bin/console doctrine:migrations:migrate
|
||||||
|
|
||||||
|
# Clear cache
|
||||||
|
bin/console cache:clear
|
||||||
|
|
||||||
|
# Refresh materialized views
|
||||||
|
bin/console dbal:run-sql "REFRESH MATERIALIZED VIEW CONCURRENTLY recent_battles"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development build with watch
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Production build
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# After changing fonts
|
||||||
|
rm -rf var/og-cache/*
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git Workflow
|
||||||
|
|
||||||
|
### Commit Messages
|
||||||
|
|
||||||
|
Follow conventional commits:
|
||||||
|
|
||||||
|
- `feat: add bonus points to battle cards`
|
||||||
|
- `fix: resolve circular dependency in BattleDialog`
|
||||||
|
- `refactor: use Doctrine QueryBuilder in RecentBattleRepository`
|
||||||
|
- `docs: add AGENTS.md for AI coding agents`
|
||||||
|
- `chore: update dependencies`
|
||||||
|
|
||||||
|
### Creating Pull Requests
|
||||||
|
|
||||||
|
When creating PRs, include:
|
||||||
|
|
||||||
|
1. **Summary** - What was changed and why
|
||||||
|
2. **Changes** - List of modified files/components
|
||||||
|
3. **Testing** - How to verify the changes
|
||||||
|
4. **Screenshots** - For UI changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
- Use materialized views for expensive queries (profile stats, recent battles)
|
||||||
|
- Auto-refresh materialized views via triggers on source table changes
|
||||||
|
- Index frequently queried columns
|
||||||
|
- Use `COALESCE()` for nullable aggregates
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- Lazy load large components
|
||||||
|
- Use React Query for data fetching and caching
|
||||||
|
- Avoid unnecessary re-renders (use `useMemo`, `useCallback`)
|
||||||
|
- Bundle code per entry point (profile, passkey, contact, game)
|
||||||
|
|
||||||
|
### Images
|
||||||
|
|
||||||
|
- Battle card images are cached in `var/og-cache/`
|
||||||
|
- Images regenerated when games change (via deterministic UUID)
|
||||||
|
- Use appropriate image sizes (1200×630 for OG images)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
- Password + TOTP (optional) + WebAuthn passkeys (optional)
|
||||||
|
- Backup codes for TOTP recovery
|
||||||
|
- Session-based authentication with `IS_AUTHENTICATED_REMEMBERED`
|
||||||
|
|
||||||
|
### Data Access
|
||||||
|
|
||||||
|
- Controllers check `denyAccessUnlessGranted()`
|
||||||
|
- Materialized views filter by `user_id`
|
||||||
|
- Guest players use separate `Gamer` entity (no `User` account)
|
||||||
|
|
||||||
|
### Input Validation
|
||||||
|
|
||||||
|
- Symfony forms for user input
|
||||||
|
- File upload validation (size, MIME type)
|
||||||
|
- WebAuthn challenge validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Symfony
|
||||||
|
bin/console debug:container ServiceName # Inspect service configuration
|
||||||
|
bin/console debug:router # List all routes
|
||||||
|
bin/console make:migration # Create new migration
|
||||||
|
bin/console doctrine:migrations:list # List migrations
|
||||||
|
|
||||||
|
# Database
|
||||||
|
bin/console dbal:run-sql "SELECT * FROM ..." # Run SQL query
|
||||||
|
|
||||||
|
# Assets
|
||||||
|
npm run build # Build production assets
|
||||||
|
npm run dev # Build with watch mode
|
||||||
|
|
||||||
|
# Git
|
||||||
|
git log --oneline --graph # View commit history
|
||||||
|
git diff origin/main...HEAD # See changes since main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Need Help?
|
||||||
|
|
||||||
|
- **Bonus Points:** See `docs/game-mechanics/BONUS_POINTS_SYSTEM.md`
|
||||||
|
- **Fonts:** See `docs/FONTS.md`
|
||||||
|
- **Symfony Docs:** https://symfony.com/doc/current/
|
||||||
|
- **React Docs:** https://react.dev/
|
||||||
|
- **Doctrine ORM:** https://www.doctrine-project.org/projects/doctrine-orm/en/current/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
|
||||||
|
- **2026-04-21:** Initial AGENTS.md created
|
||||||
|
- Document common patterns, pitfalls, and project structure
|
||||||
|
- Include coding standards and examples
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Happy coding! 🚀**
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
|
||||||
|
## v2026.2.7-1 (2026-04-21)
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
* Fine-tune the recent battle list #8. [Lang]
|
||||||
|
|
||||||
|
|
||||||
## v2026.2.7-0 (2026-04-21)
|
## v2026.2.7-0 (2026-04-21)
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -22,12 +22,6 @@ RUN install-php-extensions \
|
|||||||
apcu \
|
apcu \
|
||||||
sodium
|
sodium
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
fonts-dejavu-core \
|
|
||||||
fontconfig \
|
|
||||||
&& fc-cache -f -v \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
||||||
RUN printf '[opcache]\nopcache.enable=1\nopcache.memory_consumption=256\nopcache.max_accelerated_files=20000\nopcache.validate_timestamps=0\n' \
|
RUN printf '[opcache]\nopcache.enable=1\nopcache.memory_consumption=256\nopcache.max_accelerated_files=20000\nopcache.validate_timestamps=0\n' \
|
||||||
> "$PHP_INI_DIR/conf.d/opcache.ini"
|
> "$PHP_INI_DIR/conf.d/opcache.ini"
|
||||||
|
|||||||
23
Makefile
23
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help start start-build stop build down ps logs prune-everything db-reset mercure-jwt cache-clear og-cache-clear
|
.PHONY: help start start-build stop build down ps logs prune-everything db-reset mercure-jwt cache-clear og-cache-clear test-db-setup test-db-reset test
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
@@ -14,6 +14,9 @@ help:
|
|||||||
@echo " make ccp - Clear the production cache"
|
@echo " make ccp - Clear the production cache"
|
||||||
@echo " make cache-clear - Clear all caches (Vite, Symfony, node_modules)"
|
@echo " make cache-clear - Clear all caches (Vite, Symfony, node_modules)"
|
||||||
@echo " make og-cache-clear - Clear Open Graph cache only"
|
@echo " make og-cache-clear - Clear Open Graph cache only"
|
||||||
|
@echo " make test-db-setup - One-time setup: Create test database and run migrations"
|
||||||
|
@echo " make test-db-reset - Reset test database (drop, create, migrate)"
|
||||||
|
@echo " make test - Run PHPUnit tests"
|
||||||
|
|
||||||
start:
|
start:
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -80,5 +83,23 @@ og-cache-clear:
|
|||||||
@echo "✓ OG cache cleared!"
|
@echo "✓ OG cache cleared!"
|
||||||
@echo " Battle card images will be regenerated on next access"
|
@echo " Battle card images will be regenerated on next access"
|
||||||
|
|
||||||
|
test-db-setup:
|
||||||
|
@echo "Setting up test database..."
|
||||||
|
@bin/console dbal:run-sql "SELECT 1 FROM pg_database WHERE datname='mineseeker_test'" 2>/dev/null | grep -q 1 || \
|
||||||
|
(bin/console dbal:run-sql "CREATE DATABASE mineseeker_test" && echo "✓ Database 'mineseeker_test' created")
|
||||||
|
@bin/console doctrine:migrations:migrate --env=test --no-interaction --allow-no-migration 2>&1 | grep -v "WARNING" || true
|
||||||
|
@echo "✓ Test database setup complete!"
|
||||||
|
@echo " Database: mineseeker_test"
|
||||||
|
@echo " Run tests with: make test"
|
||||||
|
|
||||||
|
test-db-reset:
|
||||||
|
@echo "Resetting test database..."
|
||||||
|
@bin/console dbal:run-sql "DROP DATABASE IF EXISTS mineseeker_test" --quiet
|
||||||
|
@bin/console dbal:run-sql "CREATE DATABASE mineseeker_test" --quiet
|
||||||
|
@bin/console doctrine:migrations:migrate --env=test --no-interaction --quiet
|
||||||
|
@echo "✓ Test database reset complete!"
|
||||||
|
@echo " Database: mineseeker_test"
|
||||||
|
@echo " Run tests with: make test"
|
||||||
|
|
||||||
|
test:
|
||||||
|
@php -d memory_limit=512M bin/phpunit --testdox --colors=always
|
||||||
|
|||||||
81
README.md
81
README.md
@@ -287,11 +287,15 @@ git push origin v2026.01
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Game Documentation
|
## Documentation
|
||||||
|
|
||||||
For detailed information about game mechanics, bonus systems, and scoring rules, see the [docs](./docs/) directory:
|
For detailed information about game mechanics, bonus systems, fonts, testing, and other technical details, see the [docs](./docs/) directory:
|
||||||
|
|
||||||
|
- **[AI Agent Guidelines](./AGENTS.md)** — Comprehensive guide for AI coding agents working on this project
|
||||||
- **[Bonus Points System](./docs/game-mechanics/BONUS_POINTS_SYSTEM.md)** — Complete reference for all bonus point types, calculation rules, and implementation details
|
- **[Bonus Points System](./docs/game-mechanics/BONUS_POINTS_SYSTEM.md)** — Complete reference for all bonus point types, calculation rules, and implementation details
|
||||||
|
- **[Fonts](./docs/FONTS.md)** — TrueType fonts used for server-side image generation
|
||||||
|
- **[Testing Guide](./docs/testing/TESTING.md)** — Complete testing setup with Foundry factories, database isolation, and best practices
|
||||||
|
- **[Factory Documentation](./docs/testing/FACTORIES.md)** — Detailed API reference for all test data factories
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -300,3 +304,76 @@ For detailed information about game mechanics, bonus systems, and scoring rules,
|
|||||||
LGPL-3.0 — see [LICENSE](LICENSE) for details.
|
LGPL-3.0 — see [LICENSE](LICENSE) for details.
|
||||||
|
|
||||||
© 2026 [SplendidBear](https://www.splendidbear.org)
|
© 2026 [SplendidBear](https://www.splendidbear.org)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing & CI/CD
|
||||||
|
|
||||||
|
MineSeeker has a comprehensive test suite with **71 automated tests** and continuous integration/deployment pipelines.
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Setup test database (first time only)
|
||||||
|
make test-db-setup
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Run with documentation output
|
||||||
|
vendor/bin/phpunit --testdox
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Suite
|
||||||
|
|
||||||
|
- **71 tests** with **227 assertions**
|
||||||
|
- **Controller tests** - HTTP endpoints, authentication, routing
|
||||||
|
- **DTO tests** - Data serialization and calculations
|
||||||
|
- **Entity tests** - Domain logic and defaults
|
||||||
|
- **Service tests** - Business logic and external APIs
|
||||||
|
- **Integration tests** - Foundry factories and database isolation
|
||||||
|
|
||||||
|
**Test execution time:** ~6-8 seconds
|
||||||
|
|
||||||
|
### Continuous Integration
|
||||||
|
|
||||||
|
**Automated testing** runs on every push/pull request:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .gitea/workflows/ci.yml-bak
|
||||||
|
✓ PHP 8.3 setup with all extensions
|
||||||
|
✓ PostgreSQL 18 service container
|
||||||
|
✓ Composer and npm dependency installation
|
||||||
|
✓ Asset building with Vite
|
||||||
|
✓ Database migrations
|
||||||
|
✓ Full test suite execution
|
||||||
|
✓ Code linting (ESLint, PHP-CS-Fixer)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Continuous Deployment
|
||||||
|
|
||||||
|
**Automated deployment** on version tags (e.g., `v1.2.3`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .gitea/workflows/deploy.yml
|
||||||
|
1. Run full test suite (blocks deployment if fails)
|
||||||
|
2. Checkout tagged version
|
||||||
|
3. Build Docker image
|
||||||
|
4. Run database migrations
|
||||||
|
5. Restart services
|
||||||
|
6. Health check verification
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deploy to production:**
|
||||||
|
```bash
|
||||||
|
git tag -a v1.2.3 -m "Release version 1.2.3"
|
||||||
|
git push origin v1.2.3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **[Testing Guide](docs/testing/TESTING.md)** - Comprehensive testing documentation
|
||||||
|
- **[Factory Reference](docs/testing/FACTORIES.md)** - Foundry factory API
|
||||||
|
- **[CI/CD Guide](docs/CI_CD.md)** - Pipeline configuration and workflows
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
BIN
assets/fonts/Carlito-Bold.ttf
Normal file
BIN
assets/fonts/Carlito-Bold.ttf
Normal file
Binary file not shown.
@@ -12,7 +12,9 @@ import { array } from 'prop-types';
|
|||||||
import { formatDuration } from '@global-utils/format';
|
import { formatDuration } from '@global-utils/format';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import { createTheme, styled, ThemeProvider } from '@mui/material/styles';
|
import { createTheme, styled, ThemeProvider } from '@mui/material/styles';
|
||||||
import { Avatar, BonusPoints, StatRow } from '@global-components';
|
import { Avatar } from './battle-dialog/Avatar';
|
||||||
|
import { BonusPoints } from './battle-dialog/BonusPoints';
|
||||||
|
import { StatRow } from './battle-dialog/StatRow';
|
||||||
|
|
||||||
const darkTheme = createTheme({ palette: { mode: 'dark' } });
|
const darkTheme = createTheme({ palette: { mode: 'dark' } });
|
||||||
|
|
||||||
@@ -69,11 +71,11 @@ export const BattleDialog = ({ games }) => {
|
|||||||
const endReason = resign
|
const endReason = resign
|
||||||
? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned`
|
? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned`
|
||||||
: 26 <= maxPoints ? 'Points' : 'Abandoned';
|
: 26 <= maxPoints ? 'Points' : 'Abandoned';
|
||||||
const canShare = !canContinue;
|
|
||||||
const bothRegistered = game.bothRegistered;
|
const bothRegistered = game.bothRegistered;
|
||||||
const canContinue = bothRegistered && !resign && 26 > maxPoints;
|
|
||||||
const playUrl = `${window.location.origin}/play/${game.uuid}`;
|
|
||||||
const shareUrl = `${window.location.origin}/battle/${game.uuid}`;
|
const shareUrl = `${window.location.origin}/battle/${game.uuid}`;
|
||||||
|
const canContinue = bothRegistered && !resign && 26 > maxPoints;
|
||||||
|
const canShare = !canContinue;
|
||||||
|
const playUrl = `${window.location.origin}/play/${game.uuid}`;
|
||||||
|
|
||||||
const duration = formatDuration(game.created, game.date);
|
const duration = formatDuration(game.created, game.date);
|
||||||
const pointDiff = Math.abs((game.redPoints ?? 0) - (game.bluePoints ?? 0));
|
const pointDiff = Math.abs((game.redPoints ?? 0) - (game.bluePoints ?? 0));
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const GameProvider = ({ children }) => {
|
|||||||
mine: new Howl({ src: ['/sound/mine.mp3'] }),
|
mine: new Howl({ src: ['/sound/mine.mp3'] }),
|
||||||
warning: new Howl({ src: ['/sound/warning.mp3'] }),
|
warning: new Howl({ src: ['/sound/warning.mp3'] }),
|
||||||
won: new Howl({ src: ['/sound/won.mp3'] }),
|
won: new Howl({ src: ['/sound/won.mp3'] }),
|
||||||
|
starting: new Howl({ src: ['/sound/starting.mp3'] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Sync helpers (keep ref + state in lockstep) */
|
/** Sync helpers (keep ref + state in lockstep) */
|
||||||
@@ -269,6 +270,8 @@ export const GameProvider = ({ children }) => {
|
|||||||
makeGameEndIfItEnds, resignProcess,
|
makeGameEndIfItEnds, resignProcess,
|
||||||
/** UI action */
|
/** UI action */
|
||||||
onBombToggle,
|
onBombToggle,
|
||||||
|
/** Sounds */
|
||||||
|
sounds,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
|
|||||||
/** Sync helpers */
|
/** Sync helpers */
|
||||||
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
|
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
|
||||||
/** Game logic */
|
/** Game logic */
|
||||||
showOverlay, hideOverlay, applyStep, makeGameEndIfItEnds, resignProcess,
|
showOverlay, hideOverlay, applyStep, makeGameEndIfItEnds, resignProcess, sounds,
|
||||||
/** Current cells snapshot (for active-check in onClick) */
|
/** Current cells snapshot (for active-check in onClick) */
|
||||||
cells,
|
cells,
|
||||||
} = useGame();
|
} = useGame();
|
||||||
@@ -212,6 +212,7 @@ const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev
|
|||||||
*/
|
*/
|
||||||
if (!endRef.current && (!isTrueRestoredRef.current || 0 !== opponentLastSeenRef.current)) {
|
if (!endRef.current && (!isTrueRestoredRef.current || 0 !== opponentLastSeenRef.current)) {
|
||||||
hideOverlay();
|
hideOverlay();
|
||||||
|
sounds.current.starting.play();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
4
bin/phpunit
Executable file
4
bin/phpunit
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
|
||||||
@@ -44,11 +44,16 @@
|
|||||||
"web-auth/webauthn-framework": "^5.2"
|
"web-auth/webauthn-framework": "^5.2"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
"dama/doctrine-test-bundle": "^8.6",
|
||||||
|
"phpunit/phpunit": "^13.1",
|
||||||
"roave/security-advisories": "dev-master",
|
"roave/security-advisories": "dev-master",
|
||||||
|
"symfony/browser-kit": "7.4.*",
|
||||||
|
"symfony/css-selector": "7.4.*",
|
||||||
"symfony/dotenv": "7.4.*",
|
"symfony/dotenv": "7.4.*",
|
||||||
"symfony/maker-bundle": "^1.5",
|
"symfony/maker-bundle": "^1.5",
|
||||||
"symfony/stopwatch": "7.4.*",
|
"symfony/stopwatch": "7.4.*",
|
||||||
"symfony/web-profiler-bundle": "7.4.*"
|
"symfony/web-profiler-bundle": "7.4.*",
|
||||||
|
"zenstruck/foundry": "^2.9"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"preferred-install": {
|
"preferred-install": {
|
||||||
|
|||||||
2473
composer.lock
generated
2473
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -16,4 +16,6 @@ return [
|
|||||||
Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true],
|
Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true],
|
||||||
League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
|
League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
|
||||||
Liip\ImagineBundle\LiipImagineBundle::class => ['all' => true],
|
Liip\ImagineBundle\LiipImagineBundle::class => ['all' => true],
|
||||||
|
Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true],
|
||||||
|
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
framework:
|
framework:
|
||||||
test: true
|
test: true
|
||||||
session:
|
session:
|
||||||
storage_id: session.storage.mock_file
|
storage_factory_id: session.storage.factory.mock_file
|
||||||
|
|||||||
16
config/packages/zenstruck_foundry.yaml
Normal file
16
config/packages/zenstruck_foundry.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
when@dev: &dev
|
||||||
|
# See full configuration: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#full-default-bundle-configuration
|
||||||
|
zenstruck_foundry:
|
||||||
|
persistence:
|
||||||
|
# Flush only once per call of `PersistentObjectFactory::create()`
|
||||||
|
flush_once: true
|
||||||
|
|
||||||
|
# If you use the `make:factory --test` command, you may need to uncomment the following.
|
||||||
|
# See https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#generate
|
||||||
|
#services:
|
||||||
|
# App\Tests\Factory\:
|
||||||
|
# resource: '%kernel.project_dir%/tests/Factory/'
|
||||||
|
# autowire: true
|
||||||
|
# autoconfigure: true
|
||||||
|
|
||||||
|
when@test: *dev
|
||||||
@@ -28,6 +28,7 @@ services:
|
|||||||
App\Service\BattleCardGenerator:
|
App\Service\BattleCardGenerator:
|
||||||
arguments:
|
arguments:
|
||||||
$cacheDir: '%kernel.project_dir%/var/og-cache'
|
$cacheDir: '%kernel.project_dir%/var/og-cache'
|
||||||
|
$fontPath: '%kernel.project_dir%/assets/fonts/Carlito-Bold.ttf'
|
||||||
$minioMediaStorage: '@mineseeker.media.storage'
|
$minioMediaStorage: '@mineseeker.media.storage'
|
||||||
|
|
||||||
Aws\S3\S3Client:
|
Aws\S3\S3Client:
|
||||||
|
|||||||
443
docs/CI_CD.md
Normal file
443
docs/CI_CD.md
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
# CI/CD Integration Guide
|
||||||
|
|
||||||
|
This document explains how automated tests are integrated into the MineSeeker deployment pipeline using Gitea Actions.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
MineSeeker uses **Gitea Actions** (GitHub Actions compatible) for continuous integration and deployment:
|
||||||
|
|
||||||
|
- **CI Pipeline** (`.gitea/workflows/ci.yml`) - Runs on every push/PR to main/develop
|
||||||
|
- **CD Pipeline** (`.gitea/workflows/deploy.yml`) - Runs on version tags, includes tests before deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI Workflow (Continuous Integration)
|
||||||
|
|
||||||
|
**Trigger:** Push or Pull Request to `main` or `develop` branches
|
||||||
|
|
||||||
|
**File:** `.gitea/workflows/ci.yml`
|
||||||
|
|
||||||
|
### Jobs
|
||||||
|
|
||||||
|
#### 1. **Tests Job**
|
||||||
|
|
||||||
|
Runs the full PHPUnit test suite with:
|
||||||
|
|
||||||
|
- PostgreSQL 18 service container
|
||||||
|
- PHP 8.3 with required extensions
|
||||||
|
- Composer dependency installation
|
||||||
|
- Node.js for asset building
|
||||||
|
- Database setup and migrations
|
||||||
|
- 71 PHPUnit tests with testdox output
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Checkout code
|
||||||
|
2. Setup PHP 8.3 with extensions (pdo_pgsql, gd, intl, zip, sodium)
|
||||||
|
3. Validate `composer.json`
|
||||||
|
4. Cache and install Composer dependencies
|
||||||
|
5. Setup Node.js 20 and install dependencies
|
||||||
|
6. Build frontend assets with Vite
|
||||||
|
7. Create `.env.test` configuration
|
||||||
|
8. Setup test database with migrations
|
||||||
|
9. Run PHPUnit tests with `--testdox` output
|
||||||
|
10. (Optional) Generate coverage report on PRs
|
||||||
|
|
||||||
|
#### 2. **Lint Job**
|
||||||
|
|
||||||
|
Code quality checks:
|
||||||
|
|
||||||
|
- ESLint for JavaScript/JSX
|
||||||
|
- PHP-CS-Fixer (if installed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CD Workflow (Continuous Deployment)
|
||||||
|
|
||||||
|
**Trigger:** Push of version tags (e.g., `v1.0.0`, `v1.2.3`)
|
||||||
|
|
||||||
|
**File:** `.gitea/workflows/deploy.yml`
|
||||||
|
|
||||||
|
### Jobs
|
||||||
|
|
||||||
|
#### 1. **Test Job** (Pre-deployment)
|
||||||
|
|
||||||
|
**Critical:** Deployment only proceeds if tests pass.
|
||||||
|
|
||||||
|
- Runs same test suite as CI workflow
|
||||||
|
- Uses `--stop-on-failure` flag for fast feedback
|
||||||
|
- Blocks deployment on any test failure
|
||||||
|
|
||||||
|
#### 2. **Deploy Job** (Production Deployment)
|
||||||
|
|
||||||
|
**Depends on:** `test` job must complete successfully
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Checkout tagged version
|
||||||
|
2. Write production `.env` from secrets
|
||||||
|
3. Build Docker image
|
||||||
|
4. Run database migrations
|
||||||
|
5. Clear production cache
|
||||||
|
6. Start/restart services with `docker compose up -d`
|
||||||
|
7. Health check (curl to verify app is running)
|
||||||
|
8. Notify success/failure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Requirements
|
||||||
|
|
||||||
|
### Gitea Repository Variables
|
||||||
|
|
||||||
|
Set these in Gitea repository settings:
|
||||||
|
|
||||||
|
```
|
||||||
|
PROD_APP_DIR=/path/to/production/app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gitea Repository Secrets
|
||||||
|
|
||||||
|
```
|
||||||
|
PROD_ENV_FILE=<contents of production .env file>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Database Configuration
|
||||||
|
|
||||||
|
The CI/CD pipeline uses a PostgreSQL service container with these credentials:
|
||||||
|
|
||||||
|
```env
|
||||||
|
POSTGRES_USER=mineseeker_test
|
||||||
|
POSTGRES_PASSWORD=test_password
|
||||||
|
POSTGRES_DB=mineseeker_test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running Tests Locally
|
||||||
|
|
||||||
|
Before pushing, run tests locally to catch issues early:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Setup test database (first time only)
|
||||||
|
make test-db-setup
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Run with testdox output
|
||||||
|
vendor/bin/phpunit --testdox
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=var/coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment Workflow
|
||||||
|
|
||||||
|
### Standard Deployment Process
|
||||||
|
|
||||||
|
1. **Develop features** on feature branches
|
||||||
|
2. **Open Pull Request** to `develop` or `main`
|
||||||
|
- CI workflow runs automatically
|
||||||
|
- Tests must pass before merge
|
||||||
|
3. **Merge PR** after review and passing tests
|
||||||
|
4. **Create version tag** when ready to deploy:
|
||||||
|
```bash
|
||||||
|
git tag -a v1.2.3 -m "Release version 1.2.3"
|
||||||
|
git push origin v1.2.3
|
||||||
|
```
|
||||||
|
5. **Deployment runs automatically:**
|
||||||
|
- Tests run first
|
||||||
|
- If tests pass, Docker image builds
|
||||||
|
- Migrations run
|
||||||
|
- Services restart
|
||||||
|
- Health check verifies deployment
|
||||||
|
|
||||||
|
### Rollback Process
|
||||||
|
|
||||||
|
If deployment fails or issues are discovered:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tag and deploy previous stable version
|
||||||
|
git push origin v1.2.2
|
||||||
|
|
||||||
|
# Or SSH to production and manually rollback
|
||||||
|
cd /path/to/production/app
|
||||||
|
git checkout v1.2.2
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Suite Details
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
|
||||||
|
The CI/CD pipeline runs **71 tests** across:
|
||||||
|
|
||||||
|
- **Controller Tests** (29 tests)
|
||||||
|
- GameController, ProfileController, SecurityController
|
||||||
|
- HTTP status codes, authentication, routing
|
||||||
|
|
||||||
|
- **DTO Tests** (9 tests)
|
||||||
|
- ProfileGameDto, ProfileStatsDto, ProfileChartDataDto
|
||||||
|
- Serialization, null handling, calculations
|
||||||
|
|
||||||
|
- **Entity Tests** (8 tests)
|
||||||
|
- UserStats calculations and defaults
|
||||||
|
|
||||||
|
- **Service Tests** (13 tests)
|
||||||
|
- MercureJwtService, RecaptchaService
|
||||||
|
- Token generation, API verification
|
||||||
|
|
||||||
|
- **Integration Tests** (12 tests)
|
||||||
|
- Factory usage examples
|
||||||
|
- Database isolation
|
||||||
|
|
||||||
|
### Test Execution Time
|
||||||
|
|
||||||
|
Typical test suite runtime: **6-8 seconds**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Before Committing
|
||||||
|
|
||||||
|
✅ **Always run tests locally:**
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **Check code style:**
|
||||||
|
```bash
|
||||||
|
vendor/bin/php-cs-fixer fix --dry-run
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **Verify assets build:**
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### When Creating PRs
|
||||||
|
|
||||||
|
✅ **Wait for CI checks** to pass before requesting review
|
||||||
|
|
||||||
|
✅ **Fix failing tests** immediately - don't merge broken code
|
||||||
|
|
||||||
|
✅ **Review test output** in Gitea Actions logs
|
||||||
|
|
||||||
|
### When Deploying
|
||||||
|
|
||||||
|
✅ **Tag semantic versions:** `v1.2.3` (major.minor.patch)
|
||||||
|
|
||||||
|
✅ **Write meaningful tag messages:**
|
||||||
|
```bash
|
||||||
|
git tag -a v1.2.3 -m "Add bonus points to battle cards, fix avatar upload bug"
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **Monitor deployment logs** in Gitea Actions
|
||||||
|
|
||||||
|
✅ **Verify health check** passes after deployment
|
||||||
|
|
||||||
|
✅ **Test critical features** in production after deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Tests Pass Locally but Fail in CI
|
||||||
|
|
||||||
|
**Possible causes:**
|
||||||
|
|
||||||
|
1. **Database state:** CI uses fresh database, local may have leftover data
|
||||||
|
```bash
|
||||||
|
make test-db-reset # Reset local test database
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Environment differences:** Check `.env.test` matches CI configuration
|
||||||
|
|
||||||
|
3. **Cached dependencies:** CI caches may be stale
|
||||||
|
- Clear cache in Gitea Actions settings
|
||||||
|
- Or add `--no-cache` to composer install
|
||||||
|
|
||||||
|
### Deployment Fails After Tests Pass
|
||||||
|
|
||||||
|
**Common issues:**
|
||||||
|
|
||||||
|
1. **Migration conflicts:** Manually run migrations on production
|
||||||
|
```bash
|
||||||
|
docker compose run --rm app php bin/console doctrine:migrations:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Missing environment variables:** Check `PROD_ENV_FILE` secret is up-to-date
|
||||||
|
|
||||||
|
3. **Docker build errors:** Check Dockerfile and build context
|
||||||
|
|
||||||
|
4. **Health check timeout:** Increase sleep time or check application startup
|
||||||
|
|
||||||
|
### Database Migration Issues
|
||||||
|
|
||||||
|
If migrations fail during deployment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH to production server
|
||||||
|
cd /path/to/production/app
|
||||||
|
|
||||||
|
# Check migration status
|
||||||
|
docker compose run --rm app php bin/console doctrine:migrations:status
|
||||||
|
|
||||||
|
# Manually run migrations
|
||||||
|
docker compose run --rm app php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
|
||||||
|
# If migration is stuck, mark as executed
|
||||||
|
docker compose run --rm app php bin/console doctrine:migrations:version YYYYMMDDHHMMSS --add
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoring and Notifications
|
||||||
|
|
||||||
|
### Viewing CI/CD Logs
|
||||||
|
|
||||||
|
1. Go to Gitea repository
|
||||||
|
2. Click **Actions** tab
|
||||||
|
3. Select workflow run
|
||||||
|
4. View detailed logs for each step
|
||||||
|
|
||||||
|
### Setting Up Notifications
|
||||||
|
|
||||||
|
**Gitea webhook notifications:**
|
||||||
|
|
||||||
|
Configure webhooks in repository settings to notify:
|
||||||
|
- Slack/Discord when builds fail
|
||||||
|
- Email on deployment success/failure
|
||||||
|
- Custom endpoints for monitoring systems
|
||||||
|
|
||||||
|
**Example webhook payload:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "workflow_run",
|
||||||
|
"repository": "Mine",
|
||||||
|
"workflow": "deploy.yml",
|
||||||
|
"status": "success",
|
||||||
|
"tag": "v1.2.3"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Advanced Configuration
|
||||||
|
|
||||||
|
### Running Tests with Coverage
|
||||||
|
|
||||||
|
Enable coverage in CI (requires Xdebug):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: |
|
||||||
|
XDEBUG_MODE=coverage vendor/bin/phpunit \
|
||||||
|
--coverage-clover=coverage.xml \
|
||||||
|
--coverage-html=var/coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parallel Test Execution
|
||||||
|
|
||||||
|
For larger test suites, use ParaTest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer require --dev brianium/paratest
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Run tests in parallel
|
||||||
|
run: vendor/bin/paratest --processes=4
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Seeding for E2E Tests
|
||||||
|
|
||||||
|
Add step before tests:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Seed test data
|
||||||
|
run: |
|
||||||
|
php bin/console doctrine:fixtures:load --no-interaction --env=test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Secrets Management
|
||||||
|
|
||||||
|
❗ **Never commit secrets to repository**
|
||||||
|
|
||||||
|
✅ **Use Gitea secrets** for sensitive data:
|
||||||
|
- `PROD_ENV_FILE` - Production environment variables
|
||||||
|
- Database credentials
|
||||||
|
- API keys
|
||||||
|
|
||||||
|
✅ **Rotate secrets regularly**
|
||||||
|
|
||||||
|
✅ **Use environment-specific secrets** (staging, production)
|
||||||
|
|
||||||
|
### Database Security
|
||||||
|
|
||||||
|
✅ **Test database is isolated** - No production data access
|
||||||
|
|
||||||
|
✅ **Credentials are ephemeral** - Service containers use temporary passwords
|
||||||
|
|
||||||
|
✅ **No data persistence** - Test database recreated on each run
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### Caching Strategies
|
||||||
|
|
||||||
|
CI/CD workflows cache:
|
||||||
|
|
||||||
|
1. **Composer dependencies** - `vendor/` directory
|
||||||
|
2. **Node modules** - `node_modules/` directory
|
||||||
|
3. **Docker layers** - Image build cache
|
||||||
|
|
||||||
|
### Reducing Build Time
|
||||||
|
|
||||||
|
✅ **Use `composer install --no-dev`** in production builds
|
||||||
|
|
||||||
|
✅ **Multi-stage Docker builds** - Separate assets from PHP
|
||||||
|
|
||||||
|
✅ **Parallel jobs** - Tests and linting run concurrently
|
||||||
|
|
||||||
|
✅ **Skip unnecessary steps** - Use conditionals (`if:` statements)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Planned Improvements
|
||||||
|
|
||||||
|
- [ ] Automated browser tests with Symfony Panther
|
||||||
|
- [ ] Visual regression testing for UI changes
|
||||||
|
- [ ] Performance benchmarking in CI
|
||||||
|
- [ ] Automated security scanning (Symfony Security Checker)
|
||||||
|
- [ ] Staging environment deployments before production
|
||||||
|
- [ ] Blue-green deployment strategy
|
||||||
|
- [ ] Automated rollback on health check failure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- **Gitea Actions Documentation:** https://docs.gitea.com/usage/actions/overview
|
||||||
|
- **GitHub Actions Reference:** https://docs.github.com/en/actions (compatible syntax)
|
||||||
|
- **PHPUnit Documentation:** https://phpunit.de/documentation.html
|
||||||
|
- **Symfony Testing:** https://symfony.com/doc/current/testing.html
|
||||||
|
- **Docker Compose:** https://docs.docker.com/compose/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** 2026-04-21
|
||||||
|
**MineSeeker Version:** 1.0.0
|
||||||
|
**CI/CD Platform:** Gitea Actions
|
||||||
47
docs/FONTS.md
Normal file
47
docs/FONTS.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Font Files
|
||||||
|
|
||||||
|
This directory contains TrueType Font (TTF) files used for server-side image generation with PHP GD.
|
||||||
|
|
||||||
|
## Carlito-Bold.ttf
|
||||||
|
|
||||||
|
- **Font:** Carlito Bold
|
||||||
|
- **Source:** Google Fonts (Carlito Project)
|
||||||
|
- **License:** SIL Open Font License 1.1
|
||||||
|
- **URL:** https://github.com/googlefonts/carlito
|
||||||
|
- **Usage:** Used by `BattleCardGenerator` service for generating battle card OG images
|
||||||
|
- **Note:** Carlito is a metric-compatible font family to Calibri
|
||||||
|
|
||||||
|
## Why TTF instead of @fontsource?
|
||||||
|
|
||||||
|
The `@fontsource` npm packages provide WOFF/WOFF2 files for web usage, but PHP's GD library (`imagettftext()`) requires TrueType Font (TTF) files for server-side text rendering.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
If you want to use a different font:
|
||||||
|
|
||||||
|
1. **Install system fonts:**
|
||||||
|
```bash
|
||||||
|
# Find available TTF fonts
|
||||||
|
find /usr/share/fonts -name "*.ttf" -type f
|
||||||
|
|
||||||
|
# Copy desired font
|
||||||
|
cp /usr/share/fonts/path/to/Font-Bold.ttf assets/fonts/
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Download from Google Fonts:**
|
||||||
|
```bash
|
||||||
|
# Visit https://fonts.google.com
|
||||||
|
# Download the font family
|
||||||
|
# Extract the TTF file from the zip
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Update service configuration:**
|
||||||
|
Edit `config/services.yaml` and update the `$fontPath` parameter.
|
||||||
|
|
||||||
|
## Cache Clearing
|
||||||
|
|
||||||
|
After changing fonts, clear the OG image cache:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf var/og-cache/*
|
||||||
|
```
|
||||||
160
docs/README.md
160
docs/README.md
@@ -1,6 +1,6 @@
|
|||||||
# Mine-Seeker Game Documentation
|
# Mine-Seeker Game Documentation
|
||||||
|
|
||||||
This directory contains comprehensive documentation about the Mine-Seeker game mechanics and implementation.
|
This directory contains comprehensive documentation about the Mine-Seeker game mechanics, testing, and deployment.
|
||||||
|
|
||||||
## Game Mechanics
|
## Game Mechanics
|
||||||
|
|
||||||
@@ -18,8 +18,94 @@ Complete reference for the bonus points system including:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### [Testing Guide](./testing/TESTING.md)
|
||||||
|
Complete testing setup and best practices including:
|
||||||
|
- Test environment configuration
|
||||||
|
- Database isolation with DAMA Doctrine Test Bundle
|
||||||
|
- Test database setup (`mineseeker_test`)
|
||||||
|
- Modern PHPUnit attributes (`#[Test]`, `#[TestDox]`)
|
||||||
|
- Writing effective tests
|
||||||
|
- Running tests (all, specific, with coverage)
|
||||||
|
- Best practices (AAA pattern, factories, type hints)
|
||||||
|
- CI/CD integration examples
|
||||||
|
- Troubleshooting guide
|
||||||
|
|
||||||
|
**Recommended for**: All developers, understanding test infrastructure, writing new tests, debugging test failures.
|
||||||
|
|
||||||
|
### [Factory Documentation](./testing/FACTORIES.md)
|
||||||
|
Detailed API reference for Zenstruck Foundry factories:
|
||||||
|
- All available factories (User, Gamer, PlayedGame, Step, Grid, etc.)
|
||||||
|
- Factory usage examples
|
||||||
|
- Creating related entities
|
||||||
|
- Repository access
|
||||||
|
- Database isolation explanation
|
||||||
|
- Best practices and patterns
|
||||||
|
|
||||||
|
**Recommended for**: Writing tests with test data, understanding factory patterns, creating complex test scenarios.
|
||||||
|
|
||||||
|
### [Quick Reference](./testing/QUICK_REFERENCE.md)
|
||||||
|
Quick command reference for testing:
|
||||||
|
- Common test commands
|
||||||
|
- Factory usage cheat sheet
|
||||||
|
- Troubleshooting tips
|
||||||
|
|
||||||
|
**Recommended for**: Quick lookups during development.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI/CD & Deployment
|
||||||
|
|
||||||
|
### [CI/CD Integration Guide](./CI_CD.md)
|
||||||
|
Comprehensive guide for continuous integration and deployment:
|
||||||
|
- Gitea Actions workflows (CI and CD pipelines)
|
||||||
|
- Automated testing on every push/PR
|
||||||
|
- Deployment process with pre-deployment testing
|
||||||
|
- Configuration requirements (secrets, variables)
|
||||||
|
- Rollback procedures
|
||||||
|
- Security considerations
|
||||||
|
- Performance optimization
|
||||||
|
- Monitoring and notifications
|
||||||
|
- Troubleshooting deployment issues
|
||||||
|
|
||||||
|
**Recommended for**: Understanding deployment pipeline, setting up CI/CD, debugging deployment failures, production operations.
|
||||||
|
|
||||||
|
### [CI/CD Quick Reference](./testing/CI_CD_QUICK_REFERENCE.md)
|
||||||
|
Quick reference for CI/CD operations:
|
||||||
|
- Common commands (testing, deployment, rollback)
|
||||||
|
- Workflow diagrams (CI and CD pipelines)
|
||||||
|
- Status badges for README
|
||||||
|
- Common issues and solutions
|
||||||
|
- Environment variables
|
||||||
|
- Monitoring links
|
||||||
|
|
||||||
|
**Recommended for**: Quick deployments, troubleshooting, day-to-day operations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Documentation
|
||||||
|
|
||||||
|
### [Fonts](./FONTS.md)
|
||||||
|
TrueType fonts used for server-side image generation:
|
||||||
|
- Font file locations
|
||||||
|
- Usage in battle card generation
|
||||||
|
- Web vs. server-side font formats
|
||||||
|
|
||||||
|
**Recommended for**: Working on OG image generation, understanding font rendering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quick Reference
|
## Quick Reference
|
||||||
|
|
||||||
|
### Test Suite Overview
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Total Tests | 71 |
|
||||||
|
| Total Assertions | 227 |
|
||||||
|
| Execution Time | ~6-8 seconds |
|
||||||
|
| Coverage | Controller, DTO, Entity, Service, Integration |
|
||||||
|
|
||||||
### Bonus Points at a Glance
|
### Bonus Points at a Glance
|
||||||
| Bonus Type | Points | Condition |
|
| Bonus Type | Points | Condition |
|
||||||
|-----------|--------|-----------|
|
|-----------|--------|-----------|
|
||||||
@@ -30,18 +116,80 @@ Complete reference for the bonus points system including:
|
|||||||
| Chain Combo | Tracked | Consecutive mine clicks (no safe clicks) |
|
| Chain Combo | Tracked | Consecutive mine clicks (no safe clicks) |
|
||||||
| Biggest Reveal | Tracked | Largest number of safe cells revealed |
|
| Biggest Reveal | Tracked | Largest number of safe cells revealed |
|
||||||
|
|
||||||
### Key Rules
|
### Available Test Factories
|
||||||
- Safe cell bonus only awarded for ≥2 cells minimum
|
| Factory | Entity | Purpose |
|
||||||
- Chain counter resets on any safe cell click
|
|---------|--------|---------|
|
||||||
- Endgame threshold: 51 - (redPoints + bluePoints) ≤ 10
|
| `UserFactory` | `User` | Registered users |
|
||||||
- Bonus stats are per-player and persist in database
|
| `GamerFactory` | `Gamer` | Anonymous/guest players |
|
||||||
|
| `PlayedGameFactory` | `PlayedGame` | Game records |
|
||||||
|
| `StepFactory` | `Step` | Game moves |
|
||||||
|
| `GridFactory` | `Grid` | Game grids (16×16) |
|
||||||
|
| `GridRowFactory` | `GridRow` | Grid rows |
|
||||||
|
| `WebAuthnCredentialFactory` | `WebAuthnCredential` | Passkey credentials |
|
||||||
|
| `ContactMessageFactory` | `ContactMessage` | Contact messages |
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
```bash
|
||||||
|
# Setup (first time only)
|
||||||
|
make test-db-setup
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Run with documentation output
|
||||||
|
vendor/bin/phpunit --testdox
|
||||||
|
|
||||||
|
# Specific file
|
||||||
|
vendor/bin/phpunit tests/Controller/ProfileControllerTest.php
|
||||||
|
|
||||||
|
# With coverage (requires Xdebug/PCOV)
|
||||||
|
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html var/coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI/CD Commands
|
||||||
|
```bash
|
||||||
|
# Deploy to production
|
||||||
|
git tag -a v1.2.3 -m "Release version 1.2.3"
|
||||||
|
git push origin v1.2.3
|
||||||
|
|
||||||
|
# View CI/CD runs
|
||||||
|
# https://source.splendidbear.org/SplendidBear-Websites/Mine/actions
|
||||||
|
|
||||||
|
# Rollback deployment
|
||||||
|
git push origin v1.2.2 # Deploy previous version
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Files Using This Information
|
## Files Using This Information
|
||||||
|
|
||||||
|
### Game Mechanics
|
||||||
- Backend: `/src/Util/TopicManager.php`
|
- Backend: `/src/Util/TopicManager.php`
|
||||||
- Frontend: `/assets/js/mine-seeker/contexts/GameProvider.jsx`
|
- Frontend: `/assets/js/mine-seeker/contexts/GameProvider.jsx`
|
||||||
- UI: `/assets/js/mine-seeker/components/BonusStatsDialog.jsx`
|
- UI: `/assets/js/mine-seeker/components/BonusStatsDialog.jsx`
|
||||||
- Constants: `/assets/js/mine-seeker/utils/constants.jsx`
|
- Constants: `/assets/js/mine-seeker/utils/constants.jsx`
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Test Base Class: `/tests/WebTestCase.php`
|
||||||
|
- Factories: `/tests/Factory/`
|
||||||
|
- Example Tests: `/tests/Integration/FactoryExampleTest.php`
|
||||||
|
- PHPUnit Config: `/phpunit.dist.xml`
|
||||||
|
- Bundle Config: `/config/bundles.php`
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
- CI Workflow: `/.gitea/workflows/ci.yml`
|
||||||
|
- CD Workflow: `/.gitea/workflows/deploy.yml`
|
||||||
|
- Dockerfile: `/Dockerfile`
|
||||||
|
- Docker Compose: `/compose.yaml`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- **[Main README](../README.md)** - Project overview, installation, deployment
|
||||||
|
- **[AI Agent Guidelines](../AGENTS.md)** - Comprehensive guide for AI coding agents
|
||||||
|
- **[Zenstruck Foundry](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html)** - Official Foundry documentation
|
||||||
|
- **[PHPUnit](https://phpunit.de/)** - PHPUnit documentation
|
||||||
|
- **[Symfony Testing](https://symfony.com/doc/current/testing.html)** - Symfony testing guide
|
||||||
|
- **[Gitea Actions](https://docs.gitea.com/usage/actions/overview)** - Gitea Actions documentation
|
||||||
|
- **[Docker Compose](https://docs.docker.com/compose/)** - Docker Compose reference
|
||||||
|
|||||||
265
docs/testing/FACTORIES.md
Normal file
265
docs/testing/FACTORIES.md
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
# Foundry Factories for MineSeeker
|
||||||
|
|
||||||
|
Factory classes for creating test data. For general Foundry usage, see [Zenstruck Foundry Docs](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Available Factories
|
||||||
|
|
||||||
|
All factories are in `tests/Factory/`:
|
||||||
|
|
||||||
|
| Factory | Entity | Use For |
|
||||||
|
|---------|--------|---------|
|
||||||
|
| `UserFactory` | `User` | Registered users with auth |
|
||||||
|
| `GamerFactory` | `Gamer` | Anonymous/guest players |
|
||||||
|
| `PlayedGameFactory` | `PlayedGame` | Game records |
|
||||||
|
| `StepFactory` | `Step` | Individual game moves |
|
||||||
|
| `GridFactory` | `Grid` | 16×16 game grids |
|
||||||
|
| `GridRowFactory` | `GridRow` | Grid row data |
|
||||||
|
| `WebAuthnCredentialFactory` | `WebAuthnCredential` | Passkey credentials |
|
||||||
|
| `ContactMessageFactory` | `ContactMessage` | Contact form messages |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Examples
|
||||||
|
|
||||||
|
### UserFactory
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Basic user
|
||||||
|
$user = UserFactory::createOne();
|
||||||
|
|
||||||
|
// Unverified user
|
||||||
|
$user = UserFactory::createOne(['isVerified' => false]);
|
||||||
|
|
||||||
|
// Admin user
|
||||||
|
$user = UserFactory::createOne(['roles' => ['ROLE_ADMIN']]);
|
||||||
|
|
||||||
|
// Multiple users
|
||||||
|
UserFactory::createMany(5);
|
||||||
|
```
|
||||||
|
|
||||||
|
### GamerFactory
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Anonymous gamer
|
||||||
|
$gamer = GamerFactory::new()->anonymous()->create();
|
||||||
|
```
|
||||||
|
|
||||||
|
### PlayedGameFactory
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Game with registered players
|
||||||
|
$game = PlayedGameFactory::new()
|
||||||
|
->withRegisteredPlayers()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
// Game with anonymous players
|
||||||
|
$game = PlayedGameFactory::new()
|
||||||
|
->withAnonymousPlayers()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
// Red wins
|
||||||
|
$game = PlayedGameFactory::new()
|
||||||
|
->withRegisteredPlayers()
|
||||||
|
->redWins()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
// Blue wins
|
||||||
|
$game = PlayedGameFactory::new()
|
||||||
|
->withRegisteredPlayers()
|
||||||
|
->blueWins()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
// Resigned game
|
||||||
|
$game = PlayedGameFactory::new()
|
||||||
|
->resigned('red')
|
||||||
|
->create();
|
||||||
|
```
|
||||||
|
|
||||||
|
### StepFactory
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Mine hit
|
||||||
|
$step = StepFactory::new()
|
||||||
|
->forPlayer('red')
|
||||||
|
->mine()
|
||||||
|
->create(['playedGame' => $game]);
|
||||||
|
|
||||||
|
// Safe cell
|
||||||
|
$step = StepFactory::new()
|
||||||
|
->forPlayer('blue')
|
||||||
|
->safe()
|
||||||
|
->create(['playedGame' => $game]);
|
||||||
|
|
||||||
|
// With revealed cells
|
||||||
|
$step = StepFactory::new()
|
||||||
|
->withRevealedCells([
|
||||||
|
['row' => 5, 'col' => 5],
|
||||||
|
['row' => 5, 'col' => 6],
|
||||||
|
])
|
||||||
|
->create(['playedGame' => $game]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### GridFactory
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Full 16×16 grid
|
||||||
|
$grid = GridFactory::createOne(['playedGame' => $game]);
|
||||||
|
|
||||||
|
// Automatically creates 16 rows
|
||||||
|
self::assertCount(16, $grid->gridRow);
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebAuthnCredentialFactory
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Basic credential
|
||||||
|
$credential = WebAuthnCredentialFactory::createOne(['user' => $user]);
|
||||||
|
|
||||||
|
// Named credential, recently used
|
||||||
|
$credential = WebAuthnCredentialFactory::new()
|
||||||
|
->withName('YubiKey 5C')
|
||||||
|
->recentlyUsed()
|
||||||
|
->create(['user' => $user]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### ContactMessageFactory
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Basic message
|
||||||
|
$message = ContactMessageFactory::createOne();
|
||||||
|
|
||||||
|
// Without consent
|
||||||
|
$message = ContactMessageFactory::new()
|
||||||
|
->withoutConsent()
|
||||||
|
->create();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Factory API Reference
|
||||||
|
|
||||||
|
### Common Methods (All Factories)
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Create single entity
|
||||||
|
Factory::createOne([
|
||||||
|
'property' => 'value',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create multiple
|
||||||
|
Factory::createMany(5);
|
||||||
|
|
||||||
|
// Create with factory methods
|
||||||
|
Factory::new()
|
||||||
|
->customMethod()
|
||||||
|
->create(['property' => 'value']);
|
||||||
|
|
||||||
|
// Access repository
|
||||||
|
Factory::repository()->findAll();
|
||||||
|
Factory::repository()->count([]);
|
||||||
|
Factory::repository()->findBy(['field' => 'value']);
|
||||||
|
```
|
||||||
|
|
||||||
|
### PlayedGameFactory Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `withRegisteredPlayers()` | Creates game with 2 registered users |
|
||||||
|
| `withAnonymousPlayers()` | Creates game with 2 anonymous gamers |
|
||||||
|
| `withMixedPlayers()` | One registered, one anonymous |
|
||||||
|
| `redWins()` | Red has 26 points |
|
||||||
|
| `blueWins()` | Blue has 26 points |
|
||||||
|
| `resigned(string $player)` | Set resignation ('red' or 'blue') |
|
||||||
|
|
||||||
|
### StepFactory Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `forPlayer(string $player)` | Set player ('red' or 'blue') |
|
||||||
|
| `mine()` | Step hits a mine |
|
||||||
|
| `safe()` | Step reveals safe cell |
|
||||||
|
| `withRevealedCells(array $cells)` | Set revealed cells array |
|
||||||
|
|
||||||
|
### GamerFactory Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `anonymous()` | Set username as "Guest_12345" |
|
||||||
|
|
||||||
|
### WebAuthnCredentialFactory Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `withName(string $name)` | Set credential name |
|
||||||
|
| `recentlyUsed()` | Set lastUsedAt to now |
|
||||||
|
|
||||||
|
### ContactMessageFactory Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `withoutConsent()` | Set consent to false |
|
||||||
|
| `anonymous()` | Remove IP address |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MineSeeker-Specific Patterns
|
||||||
|
|
||||||
|
### Complete Game Setup
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Create full game with steps and grid
|
||||||
|
$game = PlayedGameFactory::new()
|
||||||
|
->withRegisteredPlayers()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
// Add steps
|
||||||
|
StepFactory::createMany(10, [
|
||||||
|
'playedGame' => $game,
|
||||||
|
'player' => 'red',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add grid
|
||||||
|
$grid = GridFactory::createOne(['playedGame' => $game]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Battle History
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Create multiple finished games for a user
|
||||||
|
$user = UserFactory::createOne();
|
||||||
|
|
||||||
|
PlayedGameFactory::new()
|
||||||
|
->redWins()
|
||||||
|
->create(['red' => $user]);
|
||||||
|
|
||||||
|
PlayedGameFactory::new()
|
||||||
|
->blueWins()
|
||||||
|
->create(['blue' => $user]);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Isolation
|
||||||
|
|
||||||
|
Tests automatically run in isolated transactions:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function testDatabaseIsolation(): void
|
||||||
|
{
|
||||||
|
UserFactory::createMany(5);
|
||||||
|
self::assertCount(5, UserFactory::repository()->findAll());
|
||||||
|
|
||||||
|
// Automatically rolled back after test
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No manual cleanup needed. Each test starts with a clean database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## External Resources
|
||||||
|
|
||||||
|
- **[Zenstruck Foundry Documentation](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html)** - Complete Foundry guide
|
||||||
|
- **[DAMA Doctrine Test Bundle](https://github.com/dmaicher/doctrine-test-bundle)** - Transaction isolation details
|
||||||
317
docs/testing/TESTING.md
Normal file
317
docs/testing/TESTING.md
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
# Testing Guide for MineSeeker
|
||||||
|
|
||||||
|
MineSeeker-specific testing setup and workflows. For general PHPUnit/Symfony testing, see [Symfony Testing Docs](https://symfony.com/doc/current/testing.html).
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# One-time setup
|
||||||
|
make test-db-setup
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Database Configuration
|
||||||
|
|
||||||
|
MineSeeker uses a **separate test database** (`mineseeker_test`) with automatic transaction rollback for isolated tests.
|
||||||
|
|
||||||
|
### Stack
|
||||||
|
|
||||||
|
- **PHPUnit 13** - Testing framework
|
||||||
|
- **[Zenstruck Foundry](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html)** - Factory library for test fixtures
|
||||||
|
- **[DAMA Doctrine Test Bundle](https://github.com/dmaicher/doctrine-test-bundle)** - Transaction isolation (rollback after each test)
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
**`phpunit.dist.xml`**:
|
||||||
|
```xml
|
||||||
|
<env name="DATABASE_URL" value="postgresql://...mineseeker_test..." />
|
||||||
|
<env name="DAMA_DISABLE_STATIC_CONNECTION" value="0" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**`config/bundles.php`**:
|
||||||
|
```php
|
||||||
|
Zenstruck\Foundry\ZenstruckFoundryBundle::class => ['dev' => true, 'test' => true],
|
||||||
|
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### One-time Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test-db-setup
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates `mineseeker_test` database and runs migrations.
|
||||||
|
|
||||||
|
### Reset Test Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test-db-reset
|
||||||
|
```
|
||||||
|
|
||||||
|
Drops and recreates test database (useful after schema changes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All tests
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Specific file
|
||||||
|
vendor/bin/phpunit tests/Controller/ProfileControllerTest.php
|
||||||
|
|
||||||
|
# Specific method
|
||||||
|
vendor/bin/phpunit --filter testCreateUser
|
||||||
|
|
||||||
|
# With test names
|
||||||
|
vendor/bin/phpunit --testdox
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MineSeeker Factories
|
||||||
|
|
||||||
|
See **[FACTORIES.md](./FACTORIES.md)** for complete factory API.
|
||||||
|
|
||||||
|
### Available Factories
|
||||||
|
|
||||||
|
| Factory | Entity | Location |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| `UserFactory` | Registered users | `tests/Factory/UserFactory.php` |
|
||||||
|
| `GamerFactory` | Anonymous players | `tests/Factory/GamerFactory.php` |
|
||||||
|
| `PlayedGameFactory` | Game records | `tests/Factory/PlayedGameFactory.php` |
|
||||||
|
| `StepFactory` | Game moves | `tests/Factory/StepFactory.php` |
|
||||||
|
| `GridFactory` | 16×16 game grids | `tests/Factory/GridFactory.php` |
|
||||||
|
| `GridRowFactory` | Grid rows | `tests/Factory/GridRowFactory.php` |
|
||||||
|
| `WebAuthnCredentialFactory` | Passkey credentials | `tests/Factory/WebAuthnCredentialFactory.php` |
|
||||||
|
| `ContactMessageFactory` | Contact messages | `tests/Factory/ContactMessageFactory.php` |
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Tests\Factory\PlayedGameFactory;
|
||||||
|
use App\Tests\WebTestCase;
|
||||||
|
|
||||||
|
class GameTest extends WebTestCase
|
||||||
|
{
|
||||||
|
public function testGameCreation(): void
|
||||||
|
{
|
||||||
|
$game = PlayedGameFactory::new()
|
||||||
|
->withRegisteredPlayers()
|
||||||
|
->redWins()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
self::assertEquals(26, $game->redPoints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
All tests extend `App\Tests\WebTestCase`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Controller;
|
||||||
|
|
||||||
|
use App\Tests\Factory\UserFactory;
|
||||||
|
use App\Tests\WebTestCase;
|
||||||
|
|
||||||
|
class MyControllerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
public function testExample(): void
|
||||||
|
{
|
||||||
|
$user = UserFactory::createOne();
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
$client->request('GET', '/profile');
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important**: Always extend `App\Tests\WebTestCase`, not `Symfony\Bundle\FrameworkBundle\Test\WebTestCase`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── Controller/ # HTTP endpoint tests
|
||||||
|
├── Dto/ # Data Transfer Object tests
|
||||||
|
├── Entity/ # Entity logic tests
|
||||||
|
├── Service/ # Service layer tests
|
||||||
|
├── Integration/ # Integration tests
|
||||||
|
├── Factory/ # Foundry factories
|
||||||
|
├── WebTestCase.php # Base test class
|
||||||
|
└── bootstrap.php # PHPUnit bootstrap
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MineSeeker-Specific Patterns
|
||||||
|
|
||||||
|
### Testing Game Flow
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function testRedPlayerWinsGame(): void
|
||||||
|
{
|
||||||
|
$game = PlayedGameFactory::new()
|
||||||
|
->withRegisteredPlayers()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
// Create steps to simulate gameplay
|
||||||
|
for ($i = 0; $i < 26; $i++) {
|
||||||
|
StepFactory::new()
|
||||||
|
->forPlayer('red')
|
||||||
|
->mine()
|
||||||
|
->create(['playedGame' => $game]);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertEquals(26, $game->redPoints);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Battle Sharing
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function testBattleSharePageReturnsValidGame(): void
|
||||||
|
{
|
||||||
|
$game = PlayedGameFactory::new()
|
||||||
|
->withRegisteredPlayers()
|
||||||
|
->redWins()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/battle/' . $game->uuid);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorTextContains('h1', 'Battle Report');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Authentication
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function testProfileRequiresAuthentication(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/profile');
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAuthenticatedUserCanAccessProfile(): void
|
||||||
|
{
|
||||||
|
$user = UserFactory::createOne();
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->loginUser($user->_real());
|
||||||
|
|
||||||
|
$client->request('GET', '/profile');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Tests interfering with each other?
|
||||||
|
|
||||||
|
Ensure you extend `App\Tests\WebTestCase`, not the base Symfony class.
|
||||||
|
|
||||||
|
### Schema out of sync?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test-db-reset
|
||||||
|
```
|
||||||
|
|
||||||
|
### Memory limit errors?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php -d memory_limit=1024M vendor/bin/phpunit
|
||||||
|
```
|
||||||
|
|
||||||
|
Or increase in `phpunit.dist.xml`:
|
||||||
|
```xml
|
||||||
|
<ini name="memory_limit" value="1024M"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test database doesn't exist?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test-db-setup
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## External Resources
|
||||||
|
|
||||||
|
- **[Symfony Testing](https://symfony.com/doc/current/testing.html)** - Symfony testing guide
|
||||||
|
- **[Zenstruck Foundry](https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html)** - Factory documentation
|
||||||
|
- **[DAMA Doctrine Test Bundle](https://github.com/dmaicher/doctrine-test-bundle)** - Transaction isolation
|
||||||
|
- **[PHPUnit](https://docs.phpunit.de/)** - PHPUnit documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modern PHPUnit Attributes
|
||||||
|
|
||||||
|
MineSeeker tests use modern PHP 8 attributes instead of method name prefixes:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
|
||||||
|
#[TestDox('Security Controller')]
|
||||||
|
class SecurityControllerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Login page loads successfully with form fields')]
|
||||||
|
public function loginPageLoadsSuccessfully(): void
|
||||||
|
{
|
||||||
|
// Test implementation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- ✅ More readable method names (no `test` prefix required)
|
||||||
|
- ✅ Self-documenting with `TestDox` descriptions
|
||||||
|
- ✅ Better IDE support and refactoring
|
||||||
|
- ✅ Cleaner `--testdox` output
|
||||||
|
|
||||||
|
**Run with documentation:**
|
||||||
|
```bash
|
||||||
|
vendor/bin/phpunit --testdox
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
```
|
||||||
|
Security Controller
|
||||||
|
✔ Login page loads successfully with form fields
|
||||||
|
✔ Login page has links to register and forgot password
|
||||||
|
✔ Register page loads successfully with form
|
||||||
|
```
|
||||||
48
phpunit.dist.xml
Normal file
48
phpunit.dist.xml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
colors="true"
|
||||||
|
failOnDeprecation="true"
|
||||||
|
failOnNotice="true"
|
||||||
|
failOnWarning="true"
|
||||||
|
bootstrap="tests/bootstrap.php"
|
||||||
|
cacheDirectory=".phpunit.cache"
|
||||||
|
>
|
||||||
|
<php>
|
||||||
|
<ini name="display_errors" value="1" />
|
||||||
|
<ini name="error_reporting" value="-1" />
|
||||||
|
<server name="APP_ENV" value="test" force="true" />
|
||||||
|
<server name="SHELL_VERBOSITY" value="-1" />
|
||||||
|
<env name="SYMFONY_DEPRECATIONS_HELPER" value="disabled" />
|
||||||
|
<env name="DATABASE_URL" value="postgresql://system7:bazmeg@127.0.0.1:15432/mineseeker_test?serverVersion=16&charset=utf8" />
|
||||||
|
<env name="DAMA_DISABLE_STATIC_CONNECTION" value="0" />
|
||||||
|
</php>
|
||||||
|
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Project Test Suite">
|
||||||
|
<directory>tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
|
||||||
|
<source ignoreSuppressionOfDeprecations="true"
|
||||||
|
ignoreIndirectDeprecations="true"
|
||||||
|
restrictNotices="true"
|
||||||
|
restrictWarnings="true"
|
||||||
|
>
|
||||||
|
<include>
|
||||||
|
<directory>src</directory>
|
||||||
|
</include>
|
||||||
|
|
||||||
|
<deprecationTrigger>
|
||||||
|
<method>Doctrine\Deprecations\Deprecation::trigger</method>
|
||||||
|
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
|
||||||
|
<function>trigger_deprecation</function>
|
||||||
|
</deprecationTrigger>
|
||||||
|
</source>
|
||||||
|
|
||||||
|
<extensions>
|
||||||
|
<bootstrap class="Zenstruck\Foundry\PHPUnit\FoundryExtension" />
|
||||||
|
</extensions>
|
||||||
|
</phpunit>
|
||||||
BIN
public/sound/starting.mp3
Normal file
BIN
public/sound/starting.mp3
Normal file
Binary file not shown.
@@ -34,11 +34,11 @@ final class BattleCardGenerator
|
|||||||
{
|
{
|
||||||
private const int WIDTH = 1200;
|
private const int WIDTH = 1200;
|
||||||
private const int HEIGHT = 630;
|
private const int HEIGHT = 630;
|
||||||
private const string FONT = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
|
|
||||||
private const int AVATAR_SIZE = 120;
|
private const int AVATAR_SIZE = 120;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly string $cacheDir,
|
private readonly string $cacheDir,
|
||||||
|
private readonly string $fontPath,
|
||||||
private readonly FilesystemOperator $minioMediaStorage,
|
private readonly FilesystemOperator $minioMediaStorage,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
@@ -159,7 +159,7 @@ final class BattleCardGenerator
|
|||||||
$redBonusPoints = $game->redBonusPoints ?? 0;
|
$redBonusPoints = $game->redBonusPoints ?? 0;
|
||||||
$blueBonusPoints = $game->blueBonusPoints ?? 0;
|
$blueBonusPoints = $game->blueBonusPoints ?? 0;
|
||||||
$bonusText = number_format((float)$redBonusPoints, 1, '.', '') . ' * : * ' . number_format((float)$blueBonusPoints, 1, '.', '');
|
$bonusText = number_format((float)$redBonusPoints, 1, '.', '') . ' * : * ' . number_format((float)$blueBonusPoints, 1, '.', '');
|
||||||
$this->centeredText($im, $bonusText, 24, self::WIDTH / 2, 425, $gold);
|
$this->centeredText($im, $bonusText, 24, self::WIDTH / 2, 445, $gold);
|
||||||
|
|
||||||
if ($winner === 'red') {
|
if ($winner === 'red') {
|
||||||
$resultText = $redName . ' wins';
|
$resultText = $redName . ' wins';
|
||||||
@@ -176,11 +176,11 @@ final class BattleCardGenerator
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($resultText !== '') {
|
if ($resultText !== '') {
|
||||||
$this->centeredText($im, $resultText, 30, self::WIDTH / 2, 475, $resultColor);
|
$this->centeredText($im, $resultText, 30, self::WIDTH / 2, 495, $resultColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($resign) {
|
if ($resign) {
|
||||||
$this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::WIDTH / 2, 508, $muted);
|
$this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::WIDTH / 2, 528, $muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->centeredText($im, 'mineseeker.hu', 16, self::WIDTH / 2, self::HEIGHT - 20, $muted);
|
$this->centeredText($im, 'mineseeker.hu', 16, self::WIDTH / 2, self::HEIGHT - 20, $muted);
|
||||||
@@ -273,23 +273,23 @@ final class BattleCardGenerator
|
|||||||
/** Draw initials */
|
/** Draw initials */
|
||||||
$initials = mb_strtoupper(mb_substr($name, 0, 2));
|
$initials = mb_strtoupper(mb_substr($name, 0, 2));
|
||||||
$fontSize = 48;
|
$fontSize = 48;
|
||||||
$bbox = imagettfbbox($fontSize, 0, self::FONT, $initials);
|
$bbox = imagettfbbox($fontSize, 0, $this->fontPath, $initials);
|
||||||
$textW = $bbox[2] - $bbox[0];
|
$textW = $bbox[2] - $bbox[0];
|
||||||
$textH = $bbox[1] - $bbox[7];
|
$textH = $bbox[1] - $bbox[7];
|
||||||
$textX = $cx - $textW / 2;
|
$textX = $cx - $textW / 2;
|
||||||
$textY = $cy + $textH / 2;
|
$textY = $cy + $textH / 2;
|
||||||
|
|
||||||
$white = imagecolorallocate($im, 255, 255, 255);
|
$white = imagecolorallocate($im, 255, 255, 255);
|
||||||
imagettftext($im, $fontSize, 0, (int)$textX, (int)$textY, $white, self::FONT, $initials);
|
imagettftext($im, $fontSize, 0, (int)$textX, (int)$textY, $white, $this->fontPath, $initials);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render text centered on $cx. */
|
/** Render text centered on $cx. */
|
||||||
private function centeredText(GdImage $im, string $text, int $size, int $cx, int $y, int $color): void
|
private function centeredText(GdImage $im, string $text, int $size, int $cx, int $y, int $color): void
|
||||||
{
|
{
|
||||||
$bbox = imagettfbbox($size, 0, self::FONT, $text);
|
$bbox = imagettfbbox($size, 0, $this->fontPath, $text);
|
||||||
$w = $bbox[2] - $bbox[0];
|
$w = $bbox[2] - $bbox[0];
|
||||||
imagettftext($im, $size, 0, (int)($cx - $w / 2), $y, $color, self::FONT, $text);
|
imagettftext($im, $size, 0, (int)($cx - $w / 2), $y, $color, $this->fontPath, $text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render text centered on $cx, shrinking font size to fit $maxWidth. */
|
/** Render text centered on $cx, shrinking font size to fit $maxWidth. */
|
||||||
@@ -302,7 +302,7 @@ final class BattleCardGenerator
|
|||||||
int $color,
|
int $color,
|
||||||
int $maxWidth
|
int $maxWidth
|
||||||
): void {
|
): void {
|
||||||
$bbox = imagettfbbox($size, 0, self::FONT, $text);
|
$bbox = imagettfbbox($size, 0, $this->fontPath, $text);
|
||||||
$w = $bbox[2] - $bbox[0];
|
$w = $bbox[2] - $bbox[0];
|
||||||
if ($w > $maxWidth) {
|
if ($w > $maxWidth) {
|
||||||
$size = (int)($size * $maxWidth / $w);
|
$size = (int)($size * $maxWidth / $w);
|
||||||
|
|||||||
33
src/Story/AppStory.php
Normal file
33
src/Story/AppStory.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Story;
|
||||||
|
|
||||||
|
use Zenstruck\Foundry\Attribute\AsFixture;
|
||||||
|
use Zenstruck\Foundry\Story;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class AppStory
|
||||||
|
*
|
||||||
|
* @package App\Story
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @category Class
|
||||||
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||||
|
* @link www.splendidbear.org
|
||||||
|
* @since 2026. 04. 21.
|
||||||
|
*/
|
||||||
|
#[AsFixture(name: 'main')]
|
||||||
|
final class AppStory extends Story
|
||||||
|
{
|
||||||
|
public function build(): void
|
||||||
|
{
|
||||||
|
// SomeFactory::createOne();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,12 +26,13 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
|||||||
* @link www.splendidbear.org
|
* @link www.splendidbear.org
|
||||||
* @since 2026. 04. 12.
|
* @since 2026. 04. 12.
|
||||||
*/
|
*/
|
||||||
class RecaptchaValidator extends ConstraintValidator
|
final class RecaptchaValidator extends ConstraintValidator
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly RecaptchaService $recaptcha,
|
private readonly RecaptchaService $recaptcha,
|
||||||
private readonly RequestStack $requestStack,
|
private readonly RequestStack $requestStack,
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
public function validate(mixed $value, Constraint $constraint): void
|
public function validate(mixed $value, Constraint $constraint): void
|
||||||
{
|
{
|
||||||
@@ -39,10 +40,10 @@ class RecaptchaValidator extends ConstraintValidator
|
|||||||
throw new UnexpectedTypeException($constraint, Recaptcha::class);
|
throw new UnexpectedTypeException($constraint, Recaptcha::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
$request = $this->requestStack->getCurrentRequest();
|
$request = $this->requestStack->getCurrentRequest();
|
||||||
$remoteIp = $request !== null ? ((string) $request->getClientIp()) : '';
|
$remoteIp = $request !== null ? ((string)$request->getClientIp()) : '';
|
||||||
|
|
||||||
if ($this->recaptcha->verify((string) $value, $remoteIp)) {
|
if ($this->recaptcha->verify((string)$value, $remoteIp)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
37
symfony.lock
37
symfony.lock
@@ -2,6 +2,15 @@
|
|||||||
"cocur/slugify": {
|
"cocur/slugify": {
|
||||||
"version": "v3.1"
|
"version": "v3.1"
|
||||||
},
|
},
|
||||||
|
"dama/doctrine-test-bundle": {
|
||||||
|
"version": "8.6",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes-contrib",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "8.3",
|
||||||
|
"ref": "dfc51177476fb39d014ed89944cde53dc3326d23"
|
||||||
|
}
|
||||||
|
},
|
||||||
"doctrine/collections": {
|
"doctrine/collections": {
|
||||||
"version": "v1.5.0"
|
"version": "v1.5.0"
|
||||||
},
|
},
|
||||||
@@ -120,6 +129,21 @@
|
|||||||
"ref": "3a6673f248f8fc1dd364dadfef4c5b381d1efab6"
|
"ref": "3a6673f248f8fc1dd364dadfef4c5b381d1efab6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"phpunit/phpunit": {
|
||||||
|
"version": "13.1",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "11.1",
|
||||||
|
"ref": "ca0bc067abfb40a8de1b2561b96cbfc2b833c314"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
".env.test",
|
||||||
|
"phpunit.dist.xml",
|
||||||
|
"tests/bootstrap.php",
|
||||||
|
"bin/phpunit"
|
||||||
|
]
|
||||||
|
},
|
||||||
"psr/cache": {
|
"psr/cache": {
|
||||||
"version": "1.0.1"
|
"version": "1.0.1"
|
||||||
},
|
},
|
||||||
@@ -469,5 +493,18 @@
|
|||||||
},
|
},
|
||||||
"zendframework/zend-eventmanager": {
|
"zendframework/zend-eventmanager": {
|
||||||
"version": "3.2.1"
|
"version": "3.2.1"
|
||||||
|
},
|
||||||
|
"zenstruck/foundry": {
|
||||||
|
"version": "2.9",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "2.7",
|
||||||
|
"ref": "9bd824886195ebe64a24438db1b6d17eb833ac56"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/zenstruck_foundry.yaml",
|
||||||
|
"src/Story/AppStory.php"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
145
tests/Controller/GameControllerTest.php
Normal file
145
tests/Controller/GameControllerTest.php
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Controller;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use App\Tests\WebTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class GameControllerTest
|
||||||
|
*
|
||||||
|
* @package App\Tests\Controller
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @category Class
|
||||||
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||||
|
* @link www.splendidbear.org
|
||||||
|
* @since 2026. 04. 21.
|
||||||
|
*/
|
||||||
|
#[TestDox('Game Controller')]
|
||||||
|
class GameControllerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private KernelBrowser $client;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->client = static::createClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Homepage loads successfully with navigation links')]
|
||||||
|
public function homepageLoadsSuccessfully(): void
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request('GET', '/');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||||
|
self::assertSelectorExists('h1');
|
||||||
|
self::assertSelectorExists('a[href="/play"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Play page loads successfully')]
|
||||||
|
public function playPageLoadsSuccessfully(): void
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request('GET', '/play');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||||
|
self::assertResponseHasHeader('Content-Type');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Play page with game association loads successfully')]
|
||||||
|
public function playPageWithGameAssocLoadsSuccessfully(): void
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request('GET', '/play/testgame123');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||||
|
self::assertResponseHasHeader('Content-Type');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Privacy policy page loads successfully')]
|
||||||
|
public function privacyPolicyPageLoadsSuccessfully(): void
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request('GET', '/privacy-policy');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||||
|
self::assertSelectorExists('h1');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Terms of service page loads successfully')]
|
||||||
|
public function termsOfServicePageLoadsSuccessfully(): void
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request('GET', '/terms-of-service');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||||
|
self::assertSelectorExists('h1');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Contact page loads successfully with form')]
|
||||||
|
public function contactPageLoadsSuccessfully(): void
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request('GET', '/contact');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||||
|
self::assertSelectorExists('form');
|
||||||
|
self::assertSelectorExists('button[type="submit"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Landing page loads successfully with play link')]
|
||||||
|
public function landingPageLoadsSuccessfully(): void
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request('GET', '/landing-page');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||||
|
self::assertSelectorExists('h1');
|
||||||
|
self::assertSelectorExists('a[href="/play"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Rules page loads successfully')]
|
||||||
|
public function rulesPageLoadsSuccessfully(): void
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request('GET', '/rules');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||||
|
self::assertSelectorExists('h1');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Homepage contains navigation links to play, login, and register')]
|
||||||
|
public function homepageContainsNavigationLinks(): void
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request('GET', '/');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorExists('a[href="/play"]');
|
||||||
|
self::assertSelectorExists('a[href="/login"]');
|
||||||
|
self::assertSelectorExists('a[href="/register"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Play page has correct meta tags for SEO and social sharing')]
|
||||||
|
public function playPageHasCorrectMetaTags(): void
|
||||||
|
{
|
||||||
|
$this->client->request('GET', '/play');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorExists('meta[name="description"]');
|
||||||
|
self::assertSelectorExists('meta[property="og:title"]');
|
||||||
|
}
|
||||||
|
}
|
||||||
146
tests/Controller/ProfileControllerTest.php
Normal file
146
tests/Controller/ProfileControllerTest.php
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Controller;
|
||||||
|
|
||||||
|
use App\Tests\Factory\PlayedGameFactory;
|
||||||
|
use App\Tests\Factory\UserFactory;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use App\Tests\WebTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ProfileControllerTest
|
||||||
|
*
|
||||||
|
* @package App\Tests\Controller
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @category Class
|
||||||
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||||
|
* @link www.splendidbear.org
|
||||||
|
* @since 2026. 04. 21.
|
||||||
|
*/
|
||||||
|
#[TestDox('Profile Controller')]
|
||||||
|
class ProfileControllerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private KernelBrowser $client;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->client = static::createClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Profile page requires authentication')]
|
||||||
|
public function profilePageRequiresAuthentication(): void
|
||||||
|
{
|
||||||
|
$this->client->request('GET', '/profile');
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Profile security page requires authentication')]
|
||||||
|
public function profileSecurityPageRequiresAuthentication(): void
|
||||||
|
{
|
||||||
|
$this->client->request('GET', '/profile/security');
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Profile avatar upload requires authentication')]
|
||||||
|
public function profileAvatarPageRequiresAuthentication(): void
|
||||||
|
{
|
||||||
|
$this->client->request('POST', '/profile/avatar');
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Battle share page returns 404 for non-existent game')]
|
||||||
|
public function battleSharePageReturns404ForNonExistentGame(): void
|
||||||
|
{
|
||||||
|
$uuid = '00000000-0000-4000-a000-000000000000';
|
||||||
|
$this->client->request('GET', '/battle/' . $uuid);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Battle share page displays valid game details')]
|
||||||
|
public function battleSharePageShowsValidGame(): void
|
||||||
|
{
|
||||||
|
$game = PlayedGameFactory::new()
|
||||||
|
->withRegisteredPlayers()
|
||||||
|
->redWins()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
$crawler = $this->client->request('GET', '/battle/' . $game->uuid);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorExists('h1');
|
||||||
|
self::assertGreaterThan(0, $crawler->filter('body')->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Battle share page has Open Graph meta tags for social sharing')]
|
||||||
|
public function battleSharePageHasOgMetaTags(): void
|
||||||
|
{
|
||||||
|
$game = PlayedGameFactory::new()
|
||||||
|
->withRegisteredPlayers()
|
||||||
|
->redWins()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
$this->client->request('GET', '/battle/' . $game->uuid);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorExists('meta[property="og:title"]');
|
||||||
|
self::assertSelectorExists('meta[property="og:image"]');
|
||||||
|
self::assertSelectorExists('meta[property="og:description"]');
|
||||||
|
self::assertSelectorExists('meta[property="og:type"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('OG battle image returns 404 for non-existent game')]
|
||||||
|
public function ogBattleImageReturns404ForNonExistentGame(): void
|
||||||
|
{
|
||||||
|
$uuid = '00000000-0000-4000-a000-000000000000';
|
||||||
|
$this->client->request('GET', '/og/battle/' . $uuid . '.png');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('OG battle image generates valid PNG for existing game')]
|
||||||
|
public function ogBattleImageReturnsImageForValidGame(): void
|
||||||
|
{
|
||||||
|
$game = PlayedGameFactory::new()
|
||||||
|
->withRegisteredPlayers()
|
||||||
|
->redWins()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
$this->client->request('GET', '/og/battle/' . $game->uuid . '.png');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertResponseHeaderSame('Content-Type', 'image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Battle share page returns 404 for invalid UUID format')]
|
||||||
|
public function battleSharePageWithInvalidUuidFormat(): void
|
||||||
|
{
|
||||||
|
$this->client->request('GET', '/battle/invalid-uuid');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
148
tests/Controller/SecurityControllerTest.php
Normal file
148
tests/Controller/SecurityControllerTest.php
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Controller;
|
||||||
|
|
||||||
|
use App\Tests\Factory\UserFactory;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use App\Tests\WebTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class SecurityControllerTest
|
||||||
|
*
|
||||||
|
* @package App\Tests\Controller
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @category Class
|
||||||
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||||
|
* @link www.splendidbear.org
|
||||||
|
* @since 2026. 04. 21.
|
||||||
|
*/
|
||||||
|
#[TestDox('Security Controller')]
|
||||||
|
class SecurityControllerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private KernelBrowser $client;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->client = static::createClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Login page loads successfully with form fields')]
|
||||||
|
public function loginPageLoadsSuccessfully(): void
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request('GET', '/login');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||||
|
self::assertSelectorExists('form');
|
||||||
|
self::assertSelectorExists('input[name="_username"]');
|
||||||
|
self::assertSelectorExists('input[name="_password"]');
|
||||||
|
self::assertSelectorExists('button[type="submit"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Login page has links to register and forgot password')]
|
||||||
|
public function loginPageHasRegisterLink(): void
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request('GET', '/login');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorExists('a[href="/register"]');
|
||||||
|
self::assertSelectorExists('a[href="/forgot-password"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Register page loads successfully with form')]
|
||||||
|
public function registerPageLoadsSuccessfully(): void
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request('GET', '/register');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||||
|
self::assertSelectorExists('form');
|
||||||
|
self::assertSelectorExists('button[type="submit"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Register page has link to login')]
|
||||||
|
public function registerPageHasLoginLink(): void
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request('GET', '/register');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorExists('a[href="/login"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Forgot password page loads successfully with form')]
|
||||||
|
public function forgotPasswordPageLoadsSuccessfully(): void
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request('GET', '/forgot-password');
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(Response::HTTP_OK);
|
||||||
|
self::assertSelectorExists('form');
|
||||||
|
self::assertSelectorExists('button[type="submit"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Account activation with invalid token redirects to login')]
|
||||||
|
public function activateWithInvalidTokenShowsError(): void
|
||||||
|
{
|
||||||
|
$this->client->request('GET', '/activate/invalid-token');
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Password reset with invalid token redirects to forgot password')]
|
||||||
|
public function resetPasswordWithInvalidTokenShowsError(): void
|
||||||
|
{
|
||||||
|
$this->client->request('GET', '/reset-password/invalid-token');
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/forgot-password');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Authenticated user is redirected from login page to homepage')]
|
||||||
|
public function authenticatedUserRedirectsFromLogin(): void
|
||||||
|
{
|
||||||
|
$user = UserFactory::createOne();
|
||||||
|
$this->client->loginUser($user->_real());
|
||||||
|
|
||||||
|
$this->client->request('GET', '/login');
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Authenticated user is redirected from register page to homepage')]
|
||||||
|
public function authenticatedUserRedirectsFromRegister(): void
|
||||||
|
{
|
||||||
|
$user = UserFactory::createOne();
|
||||||
|
$this->client->loginUser($user->_real());
|
||||||
|
|
||||||
|
$this->client->request('GET', '/register');
|
||||||
|
|
||||||
|
self::assertResponseRedirects('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Login form has remember me checkbox')]
|
||||||
|
public function loginFormHasRememberMeCheckbox(): void
|
||||||
|
{
|
||||||
|
$crawler = $this->client->request('GET', '/login');
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertSelectorExists('input[name="_remember_me"]');
|
||||||
|
}
|
||||||
|
}
|
||||||
89
tests/Dto/ProfileChartDataDtoTest.php
Normal file
89
tests/Dto/ProfileChartDataDtoTest.php
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Dto;
|
||||||
|
|
||||||
|
use App\Dto\ProfileChartDataDto;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ProfileChartDataDtoTest
|
||||||
|
*
|
||||||
|
* @package App\Tests\Dto
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @category Class
|
||||||
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||||
|
* @link www.splendidbear.org
|
||||||
|
* @since 2026. 04. 21.
|
||||||
|
*/
|
||||||
|
#[TestDox('Profile Chart Data Dto')]
|
||||||
|
class ProfileChartDataDtoTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Json serialize returns all properties')]
|
||||||
|
public function jsonSerializeReturnsAllProperties(): void
|
||||||
|
{
|
||||||
|
$months = ['Jan', 'Feb', 'Mar'];
|
||||||
|
$wins = [5, 8, 10];
|
||||||
|
$losses = [2, 3, 4];
|
||||||
|
$draws = [1, 0, 1];
|
||||||
|
$recentGames = [
|
||||||
|
['id' => 1, 'result' => 'win'],
|
||||||
|
['id' => 2, 'result' => 'loss'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$dto = new ProfileChartDataDto(
|
||||||
|
months: $months,
|
||||||
|
wins: $wins,
|
||||||
|
losses: $losses,
|
||||||
|
draws: $draws,
|
||||||
|
pieWins: 23,
|
||||||
|
pieLosses: 9,
|
||||||
|
pieDraws: 2,
|
||||||
|
recentGames: $recentGames,
|
||||||
|
);
|
||||||
|
|
||||||
|
$json = $dto->jsonSerialize();
|
||||||
|
|
||||||
|
$this->assertSame($months, $json['months']);
|
||||||
|
$this->assertSame($wins, $json['wins']);
|
||||||
|
$this->assertSame($losses, $json['losses']);
|
||||||
|
$this->assertSame($draws, $json['draws']);
|
||||||
|
$this->assertSame(23, $json['pieWins']);
|
||||||
|
$this->assertSame(9, $json['pieLosses']);
|
||||||
|
$this->assertSame(2, $json['pieDraws']);
|
||||||
|
$this->assertSame($recentGames, $json['recentGames']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Constructor with empty arrays')]
|
||||||
|
public function constructorWithEmptyArrays(): void
|
||||||
|
{
|
||||||
|
$dto = new ProfileChartDataDto(
|
||||||
|
months: [],
|
||||||
|
wins: [],
|
||||||
|
losses: [],
|
||||||
|
draws: [],
|
||||||
|
pieWins: 0,
|
||||||
|
pieLosses: 0,
|
||||||
|
pieDraws: 0,
|
||||||
|
recentGames: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
$json = $dto->jsonSerialize();
|
||||||
|
|
||||||
|
$this->assertSame([], $json['months']);
|
||||||
|
$this->assertSame([], $json['wins']);
|
||||||
|
$this->assertSame([], $json['recentGames']);
|
||||||
|
$this->assertSame(0, $json['pieWins']);
|
||||||
|
}
|
||||||
|
}
|
||||||
126
tests/Dto/ProfileGameDtoTest.php
Normal file
126
tests/Dto/ProfileGameDtoTest.php
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Dto;
|
||||||
|
|
||||||
|
use App\Dto\ProfileGameDto;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ProfileGameDtoTest
|
||||||
|
*
|
||||||
|
* @package App\Tests\Dto
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @category Class
|
||||||
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||||
|
* @link www.splendidbear.org
|
||||||
|
* @since 2026. 04. 21.
|
||||||
|
*/
|
||||||
|
#[TestDox('Profile Game Dto')]
|
||||||
|
class ProfileGameDtoTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Json serialize returns all properties')]
|
||||||
|
public function jsonSerializeReturnsAllProperties(): void
|
||||||
|
{
|
||||||
|
$dto = new ProfileGameDto(
|
||||||
|
id: 1,
|
||||||
|
uuid: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
redName: 'RedPlayer',
|
||||||
|
blueName: 'BluePlayer',
|
||||||
|
redAvatar: '/uploads/avatars/red.png',
|
||||||
|
blueAvatar: '/uploads/avatars/blue.png',
|
||||||
|
redPoints: 10,
|
||||||
|
bluePoints: 8,
|
||||||
|
redExplodedBomb: false,
|
||||||
|
blueExplodedBomb: true,
|
||||||
|
resign: null,
|
||||||
|
created: '2026-04-20 10:00',
|
||||||
|
date: '2026-04-20 10:30',
|
||||||
|
isRed: true,
|
||||||
|
result: 'win',
|
||||||
|
myPoints: 10,
|
||||||
|
oppPoints: 8,
|
||||||
|
redBonusPoints: 5.5,
|
||||||
|
blueBonusPoints: 2.0,
|
||||||
|
redBonusStats: ['blindHits' => 2, 'chainBest' => 3],
|
||||||
|
blueBonusStats: ['blindHits' => 1, 'chainBest' => 2],
|
||||||
|
bothRegistered: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$json = $dto->jsonSerialize();
|
||||||
|
|
||||||
|
$this->assertSame(1, $json['id']);
|
||||||
|
$this->assertSame('550e8400-e29b-41d4-a716-446655440000', $json['uuid']);
|
||||||
|
$this->assertSame('RedPlayer', $json['redName']);
|
||||||
|
$this->assertSame('BluePlayer', $json['blueName']);
|
||||||
|
$this->assertSame('/uploads/avatars/red.png', $json['redAvatar']);
|
||||||
|
$this->assertSame('/uploads/avatars/blue.png', $json['blueAvatar']);
|
||||||
|
$this->assertSame(10, $json['redPoints']);
|
||||||
|
$this->assertSame(8, $json['bluePoints']);
|
||||||
|
$this->assertFalse($json['redExplodedBomb']);
|
||||||
|
$this->assertTrue($json['blueExplodedBomb']);
|
||||||
|
$this->assertNull($json['resign']);
|
||||||
|
$this->assertSame('2026-04-20 10:00', $json['created']);
|
||||||
|
$this->assertSame('2026-04-20 10:30', $json['date']);
|
||||||
|
$this->assertTrue($json['isRed']);
|
||||||
|
$this->assertSame('win', $json['result']);
|
||||||
|
$this->assertSame(10, $json['myPoints']);
|
||||||
|
$this->assertSame(8, $json['oppPoints']);
|
||||||
|
$this->assertSame(5.5, $json['redBonusPoints']);
|
||||||
|
$this->assertSame(2.0, $json['blueBonusPoints']);
|
||||||
|
$this->assertSame(['blindHits' => 2, 'chainBest' => 3], $json['redBonusStats']);
|
||||||
|
$this->assertSame(['blindHits' => 1, 'chainBest' => 2], $json['blueBonusStats']);
|
||||||
|
$this->assertTrue($json['bothRegistered']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Json serialize with null values')]
|
||||||
|
public function jsonSerializeWithNullValues(): void
|
||||||
|
{
|
||||||
|
$dto = new ProfileGameDto(
|
||||||
|
id: null,
|
||||||
|
uuid: null,
|
||||||
|
redName: 'Guest',
|
||||||
|
blueName: 'Guest',
|
||||||
|
redAvatar: null,
|
||||||
|
blueAvatar: null,
|
||||||
|
redPoints: null,
|
||||||
|
bluePoints: null,
|
||||||
|
redExplodedBomb: null,
|
||||||
|
blueExplodedBomb: null,
|
||||||
|
resign: null,
|
||||||
|
created: null,
|
||||||
|
date: null,
|
||||||
|
isRed: false,
|
||||||
|
result: 'draw',
|
||||||
|
myPoints: null,
|
||||||
|
oppPoints: null,
|
||||||
|
redBonusPoints: 0.0,
|
||||||
|
blueBonusPoints: 0.0,
|
||||||
|
redBonusStats: [],
|
||||||
|
blueBonusStats: [],
|
||||||
|
bothRegistered: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$json = $dto->jsonSerialize();
|
||||||
|
|
||||||
|
$this->assertNull($json['id']);
|
||||||
|
$this->assertNull($json['uuid']);
|
||||||
|
$this->assertNull($json['redAvatar']);
|
||||||
|
$this->assertNull($json['blueAvatar']);
|
||||||
|
$this->assertNull($json['redPoints']);
|
||||||
|
$this->assertNull($json['bluePoints']);
|
||||||
|
$this->assertSame('draw', $json['result']);
|
||||||
|
$this->assertFalse($json['bothRegistered']);
|
||||||
|
}
|
||||||
|
}
|
||||||
134
tests/Dto/ProfileStatsDtoTest.php
Normal file
134
tests/Dto/ProfileStatsDtoTest.php
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Dto;
|
||||||
|
|
||||||
|
use App\Dto\ProfileStatsDto;
|
||||||
|
use App\Entity\UserStats;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ProfileStatsDtoTest
|
||||||
|
*
|
||||||
|
* @package App\Tests\Dto
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @category Class
|
||||||
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||||
|
* @link www.splendidbear.org
|
||||||
|
* @since 2026. 04. 21.
|
||||||
|
*/
|
||||||
|
#[TestDox('Profile Stats Dto')]
|
||||||
|
class ProfileStatsDtoTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('From user stats with valid stats')]
|
||||||
|
public function fromUserStatsWithValidStats(): void
|
||||||
|
{
|
||||||
|
$userStats = new UserStats();
|
||||||
|
$userStats->userId = 1;
|
||||||
|
$userStats->totalGames = 100;
|
||||||
|
$userStats->wins = 60;
|
||||||
|
$userStats->losses = 30;
|
||||||
|
$userStats->draws = 10;
|
||||||
|
$userStats->totalMines = 500;
|
||||||
|
$userStats->totalBonusPoints = '150.5';
|
||||||
|
$userStats->avgBonus = '2.5';
|
||||||
|
$userStats->bestChain = 5;
|
||||||
|
$userStats->blindHits = 20;
|
||||||
|
$userStats->edgeMines = 15;
|
||||||
|
$userStats->gamesWithScores = 90;
|
||||||
|
|
||||||
|
$dto = ProfileStatsDto::fromUserStats($userStats);
|
||||||
|
|
||||||
|
$this->assertSame(100, $dto->total);
|
||||||
|
$this->assertSame(60, $dto->wins);
|
||||||
|
$this->assertSame(30, $dto->losses);
|
||||||
|
$this->assertSame(10, $dto->draws);
|
||||||
|
$this->assertSame(500, $dto->minesHit);
|
||||||
|
$this->assertSame(67, $dto->winRate); // 60/90 * 100 = 67
|
||||||
|
$this->assertSame(6, $dto->avgScore); // 500/90 = 5.56 -> 6
|
||||||
|
$this->assertSame(150.5, $dto->bonusPoints);
|
||||||
|
$this->assertSame(2.5, $dto->avgBonus);
|
||||||
|
$this->assertSame(5, $dto->bestChain);
|
||||||
|
$this->assertSame(20, $dto->blindHits);
|
||||||
|
$this->assertSame(15, $dto->edgeMines);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('From user stats with null returns empty')]
|
||||||
|
public function fromUserStatsWithNullReturnsEmpty(): void
|
||||||
|
{
|
||||||
|
$dto = ProfileStatsDto::fromUserStats(null);
|
||||||
|
|
||||||
|
$this->assertSame(0, $dto->total);
|
||||||
|
$this->assertSame(0, $dto->wins);
|
||||||
|
$this->assertSame(0, $dto->losses);
|
||||||
|
$this->assertSame(0, $dto->draws);
|
||||||
|
$this->assertSame(0, $dto->minesHit);
|
||||||
|
$this->assertSame(0, $dto->winRate);
|
||||||
|
$this->assertSame(0, $dto->avgScore);
|
||||||
|
$this->assertSame(0.0, $dto->bonusPoints);
|
||||||
|
$this->assertSame(0.0, $dto->avgBonus);
|
||||||
|
$this->assertSame(0, $dto->bestChain);
|
||||||
|
$this->assertSame(0, $dto->blindHits);
|
||||||
|
$this->assertSame(0, $dto->edgeMines);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Empty returns default values')]
|
||||||
|
public function emptyReturnsDefaultValues(): void
|
||||||
|
{
|
||||||
|
$dto = ProfileStatsDto::empty();
|
||||||
|
|
||||||
|
$this->assertSame(0, $dto->total);
|
||||||
|
$this->assertSame(0, $dto->wins);
|
||||||
|
$this->assertSame(0, $dto->losses);
|
||||||
|
$this->assertSame(0, $dto->draws);
|
||||||
|
$this->assertSame(0, $dto->minesHit);
|
||||||
|
$this->assertSame(0, $dto->winRate);
|
||||||
|
$this->assertSame(0, $dto->avgScore);
|
||||||
|
$this->assertSame(0.0, $dto->bonusPoints);
|
||||||
|
$this->assertSame(0.0, $dto->avgBonus);
|
||||||
|
$this->assertSame(0, $dto->bestChain);
|
||||||
|
$this->assertSame(0, $dto->blindHits);
|
||||||
|
$this->assertSame(0, $dto->edgeMines);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Win rate calculation with no games with scores')]
|
||||||
|
public function winRateCalculationWithNoGamesWithScores(): void
|
||||||
|
{
|
||||||
|
$userStats = new UserStats();
|
||||||
|
$userStats->totalGames = 10;
|
||||||
|
$userStats->wins = 5;
|
||||||
|
$userStats->losses = 5;
|
||||||
|
$userStats->draws = 0;
|
||||||
|
$userStats->gamesWithScores = 0;
|
||||||
|
|
||||||
|
$dto = ProfileStatsDto::fromUserStats($userStats);
|
||||||
|
|
||||||
|
$this->assertSame(0, $dto->winRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Avg score calculation with no games with scores')]
|
||||||
|
public function avgScoreCalculationWithNoGamesWithScores(): void
|
||||||
|
{
|
||||||
|
$userStats = new UserStats();
|
||||||
|
$userStats->totalMines = 100;
|
||||||
|
$userStats->gamesWithScores = 0;
|
||||||
|
|
||||||
|
$dto = ProfileStatsDto::fromUserStats($userStats);
|
||||||
|
|
||||||
|
$this->assertSame(0, $dto->avgScore);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
tests/Entity/UserStatsTest.php
Normal file
142
tests/Entity/UserStatsTest.php
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Entity;
|
||||||
|
|
||||||
|
use App\Entity\UserStats;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class UserStatsTest
|
||||||
|
*
|
||||||
|
* @package App\Tests\Entity
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @category Class
|
||||||
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||||
|
* @link www.splendidbear.org
|
||||||
|
* @since 2026. 04. 21.
|
||||||
|
*/
|
||||||
|
#[TestDox('User Stats')]
|
||||||
|
class UserStatsTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Get win rate calculates correctly')]
|
||||||
|
public function getWinRateCalculatesCorrectly(): void
|
||||||
|
{
|
||||||
|
$stats = new UserStats();
|
||||||
|
$stats->wins = 60;
|
||||||
|
$stats->gamesWithScores = 100;
|
||||||
|
|
||||||
|
$result = $stats->getWinRate();
|
||||||
|
|
||||||
|
$this->assertSame(60, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Get win rate with zero games returns zero')]
|
||||||
|
public function getWinRateWithZeroGamesReturnsZero(): void
|
||||||
|
{
|
||||||
|
$stats = new UserStats();
|
||||||
|
$stats->wins = 10;
|
||||||
|
$stats->gamesWithScores = 0;
|
||||||
|
|
||||||
|
$result = $stats->getWinRate();
|
||||||
|
|
||||||
|
$this->assertSame(0, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Get win rate rounds correctly')]
|
||||||
|
public function getWinRateRoundsCorrectly(): void
|
||||||
|
{
|
||||||
|
$stats = new UserStats();
|
||||||
|
$stats->wins = 33;
|
||||||
|
$stats->gamesWithScores = 100;
|
||||||
|
|
||||||
|
$result = $stats->getWinRate();
|
||||||
|
|
||||||
|
$this->assertSame(33, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Get avg score calculates correctly')]
|
||||||
|
public function getAvgScoreCalculatesCorrectly(): void
|
||||||
|
{
|
||||||
|
$stats = new UserStats();
|
||||||
|
$stats->totalMines = 550;
|
||||||
|
$stats->gamesWithScores = 100;
|
||||||
|
|
||||||
|
$result = $stats->getAvgScore();
|
||||||
|
|
||||||
|
$this->assertSame(6, $result); // 550/100 = 5.5 -> 6
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Get avg score with zero games returns zero')]
|
||||||
|
public function getAvgScoreWithZeroGamesReturnsZero(): void
|
||||||
|
{
|
||||||
|
$stats = new UserStats();
|
||||||
|
$stats->totalMines = 100;
|
||||||
|
$stats->gamesWithScores = 0;
|
||||||
|
|
||||||
|
$result = $stats->getAvgScore();
|
||||||
|
|
||||||
|
$this->assertSame(0, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Get avg score rounds down')]
|
||||||
|
public function getAvgScoreRoundsDown(): void
|
||||||
|
{
|
||||||
|
$stats = new UserStats();
|
||||||
|
$stats->totalMines = 101;
|
||||||
|
$stats->gamesWithScores = 100;
|
||||||
|
|
||||||
|
$result = $stats->getAvgScore();
|
||||||
|
|
||||||
|
$this->assertSame(1, $result); // 101/100 = 1.01 -> 1
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Get avg score rounds up')]
|
||||||
|
public function getAvgScoreRoundsUp(): void
|
||||||
|
{
|
||||||
|
$stats = new UserStats();
|
||||||
|
$stats->totalMines = 151;
|
||||||
|
$stats->gamesWithScores = 100;
|
||||||
|
|
||||||
|
$result = $stats->getAvgScore();
|
||||||
|
|
||||||
|
$this->assertSame(2, $result); // 151/100 = 1.51 -> 2
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Default values')]
|
||||||
|
public function defaultValues(): void
|
||||||
|
{
|
||||||
|
$stats = new UserStats();
|
||||||
|
|
||||||
|
$this->assertSame(0, $stats->userId);
|
||||||
|
$this->assertSame(0, $stats->totalGames);
|
||||||
|
$this->assertSame(0, $stats->wins);
|
||||||
|
$this->assertSame(0, $stats->losses);
|
||||||
|
$this->assertSame(0, $stats->draws);
|
||||||
|
$this->assertSame(0, $stats->totalMines);
|
||||||
|
$this->assertSame('0.0', $stats->totalBonusPoints);
|
||||||
|
$this->assertSame('0.0', $stats->avgBonus);
|
||||||
|
$this->assertSame(0, $stats->bestChain);
|
||||||
|
$this->assertSame(0, $stats->blindHits);
|
||||||
|
$this->assertSame(0, $stats->edgeMines);
|
||||||
|
$this->assertSame(0, $stats->gamesWithScores);
|
||||||
|
$this->assertNull($stats->lastGameAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
tests/Factory/ContactMessageFactory.php
Normal file
55
tests/Factory/ContactMessageFactory.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Factory;
|
||||||
|
|
||||||
|
use App\Entity\ContactMessage;
|
||||||
|
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ContactMessageFactory
|
||||||
|
*
|
||||||
|
* @package App\Tests\Factory
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @category Class
|
||||||
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||||
|
* @link www.splendidbear.org
|
||||||
|
* @since 2026. 04. 21.
|
||||||
|
*
|
||||||
|
* @extends PersistentProxyObjectFactory<ContactMessage>
|
||||||
|
*/
|
||||||
|
class ContactMessageFactory extends PersistentProxyObjectFactory
|
||||||
|
{
|
||||||
|
protected function defaults(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => self::faker()->name(),
|
||||||
|
'email' => self::faker()->safeEmail(),
|
||||||
|
'content' => self::faker()->paragraph(3),
|
||||||
|
'consent' => true,
|
||||||
|
'ipAddress' => self::faker()->ipv4(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function class(): string
|
||||||
|
{
|
||||||
|
return ContactMessage::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withoutConsent(): self
|
||||||
|
{
|
||||||
|
return $this->with(['consent' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function anonymous(): self
|
||||||
|
{
|
||||||
|
return $this->with(['ipAddress' => null]);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
tests/Factory/GamerFactory.php
Normal file
51
tests/Factory/GamerFactory.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Factory;
|
||||||
|
|
||||||
|
use App\Entity\Gamer;
|
||||||
|
use DateTime;
|
||||||
|
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class GamerFactory
|
||||||
|
*
|
||||||
|
* @package App\Tests\Factory
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @category Class
|
||||||
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||||
|
* @link www.splendidbear.org
|
||||||
|
* @since 2026. 04. 21.
|
||||||
|
*
|
||||||
|
* @extends PersistentProxyObjectFactory<Gamer>
|
||||||
|
*/
|
||||||
|
class GamerFactory extends PersistentProxyObjectFactory
|
||||||
|
{
|
||||||
|
protected function defaults(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'userName' => self::faker()->userName(),
|
||||||
|
'ip' => self::faker()->ipv4(),
|
||||||
|
'country' => self::faker()->countryCode(),
|
||||||
|
'userAgent' => self::faker()->userAgent(),
|
||||||
|
'connTimestamp' => new DateTime(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function class(): string
|
||||||
|
{
|
||||||
|
return Gamer::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function anonymous(): self
|
||||||
|
{
|
||||||
|
return $this->with(['userName' => sprintf('Guest_%d', self::faker()->randomNumber(5))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
tests/Factory/GridFactory.php
Normal file
53
tests/Factory/GridFactory.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Factory;
|
||||||
|
|
||||||
|
use App\Entity\Grid;
|
||||||
|
use App\Entity\GridRow;
|
||||||
|
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class GridFactory
|
||||||
|
*
|
||||||
|
* @package App\Tests\Factory
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @category Class
|
||||||
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||||
|
* @link www.splendidbear.org
|
||||||
|
* @since 2026. 04. 21.
|
||||||
|
*
|
||||||
|
* @extends PersistentProxyObjectFactory<Grid>
|
||||||
|
*/
|
||||||
|
class GridFactory extends PersistentProxyObjectFactory
|
||||||
|
{
|
||||||
|
protected function defaults(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'playedGame' => PlayedGameFactory::new(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function class(): string
|
||||||
|
{
|
||||||
|
return Grid::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function initialize(): static
|
||||||
|
{
|
||||||
|
return $this->afterInstantiate(function (Grid $grid): void {
|
||||||
|
for ($i = 0; $i < 16; $i++) {
|
||||||
|
/** @var GridRow $factory */
|
||||||
|
$factory = GridRowFactory::new()->create()->_real();
|
||||||
|
$grid->addGridRow($factory);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
52
tests/Factory/GridRowFactory.php
Normal file
52
tests/Factory/GridRowFactory.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Factory;
|
||||||
|
|
||||||
|
use App\Entity\GridRow;
|
||||||
|
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class GridRowFactory
|
||||||
|
*
|
||||||
|
* @package App\Tests\Factory
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @category Class
|
||||||
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||||
|
* @link www.splendidbear.org
|
||||||
|
* @since 2026. 04. 21.
|
||||||
|
*
|
||||||
|
* @extends PersistentProxyObjectFactory<GridRow>
|
||||||
|
*/
|
||||||
|
class GridRowFactory extends PersistentProxyObjectFactory
|
||||||
|
{
|
||||||
|
protected function defaults(): array
|
||||||
|
{
|
||||||
|
$columns = [];
|
||||||
|
|
||||||
|
for ($i = 0; $i < 16; $i++) {
|
||||||
|
$columns[] = self::faker()->numberBetween(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'gridCol' => $columns,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function class(): string
|
||||||
|
{
|
||||||
|
return GridRow::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withColumns(array $columns): self
|
||||||
|
{
|
||||||
|
return $this->with(['gridCol' => $columns]);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
tests/Factory/PlayedGameFactory.php
Normal file
108
tests/Factory/PlayedGameFactory.php
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Factory;
|
||||||
|
|
||||||
|
use App\Entity\PlayedGame;
|
||||||
|
use DateTime;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class PlayedGameFactory
|
||||||
|
*
|
||||||
|
* @package App\Tests\Factory
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @category Class
|
||||||
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||||
|
* @link www.splendidbear.org
|
||||||
|
* @since 2026. 04. 21.
|
||||||
|
*
|
||||||
|
* @extends PersistentProxyObjectFactory<PlayedGame>
|
||||||
|
*/
|
||||||
|
class PlayedGameFactory extends PersistentProxyObjectFactory
|
||||||
|
{
|
||||||
|
protected function defaults(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uuid' => Uuid::v4(),
|
||||||
|
'gameAssoc' => self::faker()->uuid(),
|
||||||
|
'redPoints' => self::faker()->numberBetween(0, 51),
|
||||||
|
'bluePoints' => self::faker()->numberBetween(0, 51),
|
||||||
|
'redExplodedBomb' => false,
|
||||||
|
'blueExplodedBomb' => false,
|
||||||
|
'resign' => null,
|
||||||
|
'redBonusPoints' => self::faker()->randomFloat(2, 0, 100),
|
||||||
|
'blueBonusPoints' => self::faker()->randomFloat(2, 0, 100),
|
||||||
|
'redBonusStats' => null,
|
||||||
|
'blueBonusStats' => null,
|
||||||
|
'created' => new DateTime(),
|
||||||
|
'updated' => new DateTime(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function class(): string
|
||||||
|
{
|
||||||
|
return PlayedGame::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withRegisteredPlayers(): self
|
||||||
|
{
|
||||||
|
return $this->with([
|
||||||
|
'red' => UserFactory::new(),
|
||||||
|
'blue' => UserFactory::new(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withAnonymousPlayers(): self
|
||||||
|
{
|
||||||
|
return $this->with([
|
||||||
|
'redAnon' => GamerFactory::new(),
|
||||||
|
'blueAnon' => GamerFactory::new(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withMixedPlayers(): self
|
||||||
|
{
|
||||||
|
return $this->with([
|
||||||
|
'red' => UserFactory::new(),
|
||||||
|
'blueAnon' => GamerFactory::new(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function finished(): self
|
||||||
|
{
|
||||||
|
return $this->with([
|
||||||
|
'redPoints' => 26,
|
||||||
|
'bluePoints' => 25,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function redWins(): self
|
||||||
|
{
|
||||||
|
return $this->with([
|
||||||
|
'redPoints' => 26,
|
||||||
|
'bluePoints' => self::faker()->numberBetween(0, 25),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function blueWins(): self
|
||||||
|
{
|
||||||
|
return $this->with([
|
||||||
|
'redPoints' => self::faker()->numberBetween(0, 25),
|
||||||
|
'bluePoints' => 26,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resigned(string $player): self
|
||||||
|
{
|
||||||
|
return $this->with(['resign' => $player]);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
tests/Factory/StepFactory.php
Normal file
68
tests/Factory/StepFactory.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Factory;
|
||||||
|
|
||||||
|
use App\Entity\Step;
|
||||||
|
use DateTime;
|
||||||
|
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class StepFactory
|
||||||
|
*
|
||||||
|
* @package App\Tests\Factory
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @category Class
|
||||||
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||||
|
* @link www.splendidbear.org
|
||||||
|
* @since 2026. 04. 21.
|
||||||
|
*
|
||||||
|
* @extends PersistentProxyObjectFactory<Step>
|
||||||
|
*/
|
||||||
|
class StepFactory extends PersistentProxyObjectFactory
|
||||||
|
{
|
||||||
|
protected function defaults(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'row' => self::faker()->numberBetween(0, 15),
|
||||||
|
'col' => self::faker()->numberBetween(0, 15),
|
||||||
|
'wBomb' => self::faker()->boolean(),
|
||||||
|
'player' => self::faker()->randomElement(['red', 'blue']),
|
||||||
|
'revealedCells' => null,
|
||||||
|
'playedGame' => PlayedGameFactory::new(),
|
||||||
|
'created' => new DateTime(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function class(): string
|
||||||
|
{
|
||||||
|
return Step::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mine(): self
|
||||||
|
{
|
||||||
|
return $this->with(['wBomb' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function safe(): self
|
||||||
|
{
|
||||||
|
return $this->with(['wBomb' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forPlayer(string $player): self
|
||||||
|
{
|
||||||
|
return $this->with(['player' => $player]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withRevealedCells(array $cells): self
|
||||||
|
{
|
||||||
|
return $this->with(['revealedCells' => $cells]);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
tests/Factory/UserFactory.php
Normal file
60
tests/Factory/UserFactory.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Factory;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class UserFactory
|
||||||
|
*
|
||||||
|
* @package App\Tests\Factory
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @category Class
|
||||||
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||||
|
* @link www.splendidbear.org
|
||||||
|
* @since 2026. 04. 21.
|
||||||
|
*
|
||||||
|
* @extends PersistentProxyObjectFactory<User>
|
||||||
|
*/
|
||||||
|
class UserFactory extends PersistentProxyObjectFactory
|
||||||
|
{
|
||||||
|
protected function defaults(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'username' => self::faker()->unique()->userName(),
|
||||||
|
'email' => self::faker()->unique()->safeEmail(),
|
||||||
|
'password' => 'hashedpassword',
|
||||||
|
'isVerified' => true,
|
||||||
|
'roles' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function class(): string
|
||||||
|
{
|
||||||
|
return User::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withPassword(string $hashedPassword): self
|
||||||
|
{
|
||||||
|
return $this->with(['password' => $hashedPassword]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unverified(): self
|
||||||
|
{
|
||||||
|
return $this->with(['isVerified' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withRoles(array $roles): self
|
||||||
|
{
|
||||||
|
return $this->with(['roles' => $roles]);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
tests/Factory/WebAuthnCredentialFactory.php
Normal file
62
tests/Factory/WebAuthnCredentialFactory.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Factory;
|
||||||
|
|
||||||
|
use App\Entity\WebAuthnCredential;
|
||||||
|
use DateTime;
|
||||||
|
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class WebAuthnCredentialFactory
|
||||||
|
*
|
||||||
|
* @package App\Tests\Factory
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @category Class
|
||||||
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||||
|
* @link www.splendidbear.org
|
||||||
|
* @since 2026. 04. 21.
|
||||||
|
*
|
||||||
|
* @extends PersistentProxyObjectFactory<WebAuthnCredential>
|
||||||
|
*/
|
||||||
|
class WebAuthnCredentialFactory extends PersistentProxyObjectFactory
|
||||||
|
{
|
||||||
|
protected function defaults(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user' => UserFactory::new(),
|
||||||
|
'credentialData' => json_encode([
|
||||||
|
'type' => 'public-key',
|
||||||
|
'id' => base64_encode(self::faker()->uuid()),
|
||||||
|
'transports' => ['usb', 'nfc'],
|
||||||
|
], JSON_THROW_ON_ERROR),
|
||||||
|
'credentialName' => self::faker()->words(3, true),
|
||||||
|
'createdAt' => new DateTime(),
|
||||||
|
'lastUsedAt' => null,
|
||||||
|
'isBackupEligible' => self::faker()->boolean(),
|
||||||
|
'isBackupAuthenticated' => self::faker()->boolean(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function class(): string
|
||||||
|
{
|
||||||
|
return WebAuthnCredential::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withName(string $name): self
|
||||||
|
{
|
||||||
|
return $this->with(['credentialName' => $name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recentlyUsed(): self
|
||||||
|
{
|
||||||
|
return $this->with(['lastUsedAt' => new DateTime()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
211
tests/Integration/FactoryExampleTest.php
Normal file
211
tests/Integration/FactoryExampleTest.php
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Integration;
|
||||||
|
|
||||||
|
use App\Entity\PlayedGame;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Tests\Factory\ContactMessageFactory;
|
||||||
|
use App\Tests\Factory\GamerFactory;
|
||||||
|
use App\Tests\Factory\GridFactory;
|
||||||
|
use App\Tests\Factory\PlayedGameFactory;
|
||||||
|
use App\Tests\Factory\StepFactory;
|
||||||
|
use App\Tests\Factory\UserFactory;
|
||||||
|
use App\Tests\Factory\WebAuthnCredentialFactory;
|
||||||
|
use App\Tests\WebTestCase;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class FactoryExampleTest
|
||||||
|
*
|
||||||
|
* Example test demonstrating Foundry factory usage
|
||||||
|
*
|
||||||
|
* @package App\Tests\Integration
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @category Class
|
||||||
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||||
|
* @link www.splendidbear.org
|
||||||
|
* @since 2026. 04. 21.
|
||||||
|
*/
|
||||||
|
#[TestDox('Factory Examples')]
|
||||||
|
class FactoryExampleTest extends WebTestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Creates a verified user with UserFactory')]
|
||||||
|
public function createUser(): void
|
||||||
|
{
|
||||||
|
$user = UserFactory::createOne();
|
||||||
|
|
||||||
|
self::assertInstanceOf(User::class, $user->_real());
|
||||||
|
self::assertNotNull($user->username);
|
||||||
|
self::assertNotNull($user->email);
|
||||||
|
self::assertTrue($user->isVerified);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Creates an unverified user')]
|
||||||
|
public function createUnverifiedUser(): void
|
||||||
|
{
|
||||||
|
$user = UserFactory::createOne(['isVerified' => false]);
|
||||||
|
|
||||||
|
self::assertFalse($user->isVerified);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Creates multiple users at once')]
|
||||||
|
public function createMultipleUsers(): void
|
||||||
|
{
|
||||||
|
UserFactory::createMany(5);
|
||||||
|
|
||||||
|
self::assertCount(5, UserFactory::repository()->findAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Creates an anonymous gamer with guest username')]
|
||||||
|
public function createAnonymousGamer(): void
|
||||||
|
{
|
||||||
|
$gamer = GamerFactory::new()->anonymous()->create();
|
||||||
|
|
||||||
|
self::assertStringStartsWith('Guest_', $gamer->userName);
|
||||||
|
self::assertNotNull($gamer->ip);
|
||||||
|
self::assertNotNull($gamer->connTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Creates a game with two registered players')]
|
||||||
|
public function createGameWithRegisteredPlayers(): void
|
||||||
|
{
|
||||||
|
$game = PlayedGameFactory::new()
|
||||||
|
->withRegisteredPlayers()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
self::assertInstanceOf(PlayedGame::class, $game->_real());
|
||||||
|
self::assertNotNull($game->red);
|
||||||
|
self::assertNotNull($game->blue);
|
||||||
|
self::assertNull($game->redAnon);
|
||||||
|
self::assertNull($game->blueAnon);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Creates a game with two anonymous players')]
|
||||||
|
public function createGameWithAnonymousPlayers(): void
|
||||||
|
{
|
||||||
|
$game = PlayedGameFactory::new()
|
||||||
|
->withAnonymousPlayers()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
self::assertNull($game->red);
|
||||||
|
self::assertNull($game->blue);
|
||||||
|
self::assertNotNull($game->redAnon);
|
||||||
|
self::assertNotNull($game->blueAnon);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Creates a finished game where red player wins')]
|
||||||
|
public function createFinishedGame(): void
|
||||||
|
{
|
||||||
|
$game = PlayedGameFactory::new()
|
||||||
|
->withRegisteredPlayers()
|
||||||
|
->redWins()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
self::assertEquals(26, $game->redPoints);
|
||||||
|
self::assertLessThan(26, $game->bluePoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Creates a game with multiple steps from both players')]
|
||||||
|
public function createGameWithSteps(): void
|
||||||
|
{
|
||||||
|
$game = PlayedGameFactory::new()
|
||||||
|
->withRegisteredPlayers()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
StepFactory::new()
|
||||||
|
->forPlayer('red')
|
||||||
|
->mine()
|
||||||
|
->create(['playedGame' => $game]);
|
||||||
|
|
||||||
|
StepFactory::new()
|
||||||
|
->forPlayer('red')
|
||||||
|
->mine()
|
||||||
|
->create(['playedGame' => $game]);
|
||||||
|
|
||||||
|
StepFactory::new()
|
||||||
|
->forPlayer('red')
|
||||||
|
->mine()
|
||||||
|
->create(['playedGame' => $game]);
|
||||||
|
|
||||||
|
StepFactory::new()
|
||||||
|
->forPlayer('blue')
|
||||||
|
->safe()
|
||||||
|
->create(['playedGame' => $game]);
|
||||||
|
|
||||||
|
StepFactory::new()
|
||||||
|
->forPlayer('blue')
|
||||||
|
->safe()
|
||||||
|
->create(['playedGame' => $game]);
|
||||||
|
|
||||||
|
self::assertCount(5, StepFactory::repository()->findAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Creates a game with a 16x16 grid')]
|
||||||
|
public function createGameWithGrid(): void
|
||||||
|
{
|
||||||
|
$game = PlayedGameFactory::new()
|
||||||
|
->withRegisteredPlayers()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
$grid = GridFactory::createOne(['playedGame' => $game]);
|
||||||
|
|
||||||
|
self::assertCount(16, $grid->gridRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Creates a WebAuthn credential for a user')]
|
||||||
|
public function createWebAuthnCredential(): void
|
||||||
|
{
|
||||||
|
$user = UserFactory::createOne();
|
||||||
|
|
||||||
|
$credential = WebAuthnCredentialFactory::new()
|
||||||
|
->withName('YubiKey 5C')
|
||||||
|
->recentlyUsed()
|
||||||
|
->create(['user' => $user]);
|
||||||
|
|
||||||
|
self::assertEquals($user->_real(), $credential->user);
|
||||||
|
self::assertEquals('YubiKey 5C', $credential->credentialName);
|
||||||
|
self::assertNotNull($credential->lastUsedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Creates a contact message with consent')]
|
||||||
|
public function createContactMessage(): void
|
||||||
|
{
|
||||||
|
$message = ContactMessageFactory::createOne();
|
||||||
|
|
||||||
|
self::assertNotNull($message->name);
|
||||||
|
self::assertNotNull($message->email);
|
||||||
|
self::assertNotNull($message->content);
|
||||||
|
self::assertTrue($message->consent);
|
||||||
|
self::assertNotNull($message->createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Tests are isolated - database is reset between tests')]
|
||||||
|
public function databaseIsolation(): void
|
||||||
|
{
|
||||||
|
UserFactory::createMany(3);
|
||||||
|
self::assertCount(3, UserFactory::repository()->findAll());
|
||||||
|
|
||||||
|
/** Database will be reset before next test due to ResetDatabase trait */
|
||||||
|
}
|
||||||
|
}
|
||||||
108
tests/README.md
Normal file
108
tests/README.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Tests Directory
|
||||||
|
|
||||||
|
This directory contains all test files for the MineSeeker project.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests (recommended)
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Or with PHPUnit directly:
|
||||||
|
vendor/bin/phpunit
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
vendor/bin/phpunit tests/Controller/ProfileControllerTest.php
|
||||||
|
|
||||||
|
# Run with filter
|
||||||
|
vendor/bin/phpunit --filter testCreateUser
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
For comprehensive testing documentation, see:
|
||||||
|
|
||||||
|
- **[Testing Guide](../docs/testing/TESTING.md)** - Complete testing setup, best practices, troubleshooting
|
||||||
|
- **[Factory Documentation](../docs/testing/FACTORIES.md)** - Detailed factory API reference
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── Controller/ # HTTP endpoint tests
|
||||||
|
├── Dto/ # Data Transfer Object tests
|
||||||
|
├── Entity/ # Entity logic tests
|
||||||
|
├── Service/ # Service layer tests
|
||||||
|
├── Integration/ # Integration tests (example: FactoryExampleTest.php)
|
||||||
|
├── Factory/ # Foundry factory classes
|
||||||
|
│ ├── UserFactory.php
|
||||||
|
│ ├── GamerFactory.php
|
||||||
|
│ ├── PlayedGameFactory.php
|
||||||
|
│ ├── StepFactory.php
|
||||||
|
│ ├── GridFactory.php
|
||||||
|
│ ├── GridRowFactory.php
|
||||||
|
│ ├── WebAuthnCredentialFactory.php
|
||||||
|
│ └── ContactMessageFactory.php
|
||||||
|
├── WebTestCase.php # Base test class (extends this!)
|
||||||
|
├── bootstrap.php # PHPUnit bootstrap
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Factory Examples
|
||||||
|
|
||||||
|
```php
|
||||||
|
use App\Tests\Factory\UserFactory;
|
||||||
|
use App\Tests\Factory\PlayedGameFactory;
|
||||||
|
use App\Tests\WebTestCase;
|
||||||
|
|
||||||
|
class MyTest extends WebTestCase
|
||||||
|
{
|
||||||
|
public function testExample(): void
|
||||||
|
{
|
||||||
|
/** Create user */
|
||||||
|
$user = UserFactory::createOne();
|
||||||
|
|
||||||
|
/** Create game with registered players */
|
||||||
|
$game = PlayedGameFactory::new()
|
||||||
|
->withRegisteredPlayers()
|
||||||
|
->redWins()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
/** Create multiple entities */
|
||||||
|
UserFactory::createMany(5);
|
||||||
|
|
||||||
|
/** Access repository */
|
||||||
|
$users = UserFactory::repository()->findAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- **Always extend `App\Tests\WebTestCase`** - provides database isolation
|
||||||
|
- **Use factories** - don't manually create entities with `new Entity()`
|
||||||
|
- **Test database** - Tests run on `mineseeker_test`, never production
|
||||||
|
- **Automatic rollback** - Each test is wrapped in a transaction
|
||||||
|
|
||||||
|
## Test Database Setup (One-time)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create test database
|
||||||
|
bin/console dbal:run-sql "CREATE DATABASE mineseeker_test"
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
bin/console doctrine:migrations:migrate --env=test --no-interaction
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Tests interfering with each other?
|
||||||
|
→ Make sure your test extends `App\Tests\WebTestCase`
|
||||||
|
|
||||||
|
### Database schema out of sync?
|
||||||
|
→ Run `bin/console doctrine:migrations:migrate --env=test`
|
||||||
|
|
||||||
|
### Memory limit errors?
|
||||||
|
→ Run `php -d memory_limit=512M vendor/bin/phpunit`
|
||||||
|
|
||||||
|
For more troubleshooting, see [Testing Guide](../docs/testing/TESTING.md#troubleshooting).
|
||||||
109
tests/Service/MercureJwtServiceTest.php
Normal file
109
tests/Service/MercureJwtServiceTest.php
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\Service\MercureJwtService;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class MercureJwtServiceTest
|
||||||
|
*
|
||||||
|
* @package App\Tests\Service
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @category Class
|
||||||
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||||
|
* @link www.splendidbear.org
|
||||||
|
* @since 2026. 04. 21.
|
||||||
|
*/
|
||||||
|
#[TestDox('Mercure Jwt Service')]
|
||||||
|
class MercureJwtServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
/** JWT HS256 requires at least 32 characters for the secret key */
|
||||||
|
private const SECRET = 'test-mercure-secret-key-12345678901234567890';
|
||||||
|
|
||||||
|
private MercureJwtService $service;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->service = new MercureJwtService(self::SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Mint subscriber token returns valid jwt')]
|
||||||
|
public function mintSubscriberTokenReturnsValidJwt(): void
|
||||||
|
{
|
||||||
|
$token = $this->service->mintSubscriberToken('game123', 'player1');
|
||||||
|
|
||||||
|
$this->assertIsString($token);
|
||||||
|
$this->assertNotEmpty($token);
|
||||||
|
|
||||||
|
/** Token should have 3 parts (header.payload.signature) */
|
||||||
|
$parts = explode('.', $token);
|
||||||
|
$this->assertCount(3, $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Mint subscriber token with different game assoc')]
|
||||||
|
public function mintSubscriberTokenWithDifferentGameAssoc(): void
|
||||||
|
{
|
||||||
|
$token1 = $this->service->mintSubscriberToken('gameA', 'player1');
|
||||||
|
$token2 = $this->service->mintSubscriberToken('gameB', 'player1');
|
||||||
|
|
||||||
|
/** Different gameAssoc should result in different tokens */
|
||||||
|
$this->assertNotSame($token1, $token2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Mint subscriber token with different user names')]
|
||||||
|
public function mintSubscriberTokenWithDifferentUserNames(): void
|
||||||
|
{
|
||||||
|
$token1 = $this->service->mintSubscriberToken('game1', 'playerA');
|
||||||
|
$token2 = $this->service->mintSubscriberToken('game1', 'playerB');
|
||||||
|
|
||||||
|
/** Different usernames should result in different tokens */
|
||||||
|
$this->assertNotSame($token1, $token2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Mint subscriber token contains proper structure')]
|
||||||
|
public function mintSubscriberTokenContainsProperStructure(): void
|
||||||
|
{
|
||||||
|
$token = $this->service->mintSubscriberToken('game123', 'testplayer');
|
||||||
|
|
||||||
|
/** Decode without verification to check structure */
|
||||||
|
$parts = explode('.', $token);
|
||||||
|
|
||||||
|
/** Decode payload (middle part) */
|
||||||
|
$payload = json_decode(base64_decode($parts[1] . str_repeat('=', (4 - strlen($parts[1]) % 4))), true);
|
||||||
|
|
||||||
|
$this->assertIsArray($payload);
|
||||||
|
$this->assertArrayHasKey('mercure', $payload);
|
||||||
|
$this->assertArrayHasKey('subscribe', $payload['mercure']);
|
||||||
|
$this->assertArrayHasKey('payload', $payload['mercure']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Mint subscriber token payload contains correct data')]
|
||||||
|
public function mintSubscriberTokenPayloadContainsCorrectData(): void
|
||||||
|
{
|
||||||
|
$token = $this->service->mintSubscriberToken('test-game', 'test-user');
|
||||||
|
|
||||||
|
$parts = explode('.', $token);
|
||||||
|
$payload = json_decode(base64_decode($parts[1] . str_repeat('=', (4 - strlen($parts[1]) % 4))), true);
|
||||||
|
|
||||||
|
$this->assertSame('test-user', $payload['mercure']['payload']['username']);
|
||||||
|
$this->assertSame('test-game', $payload['mercure']['payload']['gameAssoc']);
|
||||||
|
$this->assertContains('*', $payload['mercure']['subscribe']);
|
||||||
|
}
|
||||||
|
}
|
||||||
188
tests/Service/RecaptchaServiceTest.php
Normal file
188
tests/Service/RecaptchaServiceTest.php
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\Service\RecaptchaService;
|
||||||
|
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\HttpClient\MockHttpClient;
|
||||||
|
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class RecaptchaServiceTest
|
||||||
|
*
|
||||||
|
* @package App\Tests\Service
|
||||||
|
* @author Lang <https://www.splendidbear.org>
|
||||||
|
* @category Class
|
||||||
|
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
||||||
|
* @link www.splendidbear.org
|
||||||
|
* @since 2026. 04. 21.
|
||||||
|
*/
|
||||||
|
#[AllowMockObjectsWithoutExpectations]
|
||||||
|
#[TestDox('Recaptcha Service')]
|
||||||
|
class RecaptchaServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private const SECRET_KEY = 'test-secret-key';
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Verify returns false for empty token')]
|
||||||
|
public function verifyReturnsFalseForEmptyToken(): void
|
||||||
|
{
|
||||||
|
$httpClient = $this->createMock(HttpClientInterface::class);
|
||||||
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
|
|
||||||
|
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
|
||||||
|
|
||||||
|
$result = $service->verify('');
|
||||||
|
|
||||||
|
$this->assertFalse($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Verify returns false when api returns failure')]
|
||||||
|
public function verifyReturnsFalseWhenApiReturnsFailure(): void
|
||||||
|
{
|
||||||
|
$mockResponse = new MockResponse(json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error-codes' => ['invalid-input-secret'],
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
$httpClient = new MockHttpClient($mockResponse);
|
||||||
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
|
$logger->expects($this->once())->method('info');
|
||||||
|
|
||||||
|
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
|
||||||
|
|
||||||
|
$result = $service->verify('invalid-token');
|
||||||
|
|
||||||
|
$this->assertFalse($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Verify returns true when api returns success and high score')]
|
||||||
|
public function verifyReturnsTrueWhenApiReturnsSuccessAndHighScore(): void
|
||||||
|
{
|
||||||
|
$mockResponse = new MockResponse(json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'score' => 0.8,
|
||||||
|
'hostname' => 'test.com',
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
$httpClient = new MockHttpClient($mockResponse);
|
||||||
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
|
$logger->expects($this->once())->method('info');
|
||||||
|
|
||||||
|
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
|
||||||
|
|
||||||
|
$result = $service->verify('valid-token');
|
||||||
|
|
||||||
|
$this->assertTrue($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Verify returns false when score below threshold')]
|
||||||
|
public function verifyReturnsFalseWhenScoreBelowThreshold(): void
|
||||||
|
{
|
||||||
|
$mockResponse = new MockResponse(json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'score' => 0.3,
|
||||||
|
'hostname' => 'test.com',
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
$httpClient = new MockHttpClient($mockResponse);
|
||||||
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
|
$logger->expects($this->once())->method('info');
|
||||||
|
|
||||||
|
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
|
||||||
|
|
||||||
|
$result = $service->verify('low-score-token');
|
||||||
|
|
||||||
|
$this->assertFalse($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Verify returns false when api throws exception')]
|
||||||
|
public function verifyReturnsFalseWhenApiThrowsException(): void
|
||||||
|
{
|
||||||
|
$mockResponse = new MockResponse('', ['http_code' => 500]);
|
||||||
|
|
||||||
|
$httpClient = new MockHttpClient($mockResponse);
|
||||||
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
|
$logger->expects($this->once())->method('error');
|
||||||
|
|
||||||
|
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
|
||||||
|
|
||||||
|
$result = $service->verify('test-token');
|
||||||
|
|
||||||
|
$this->assertFalse($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Verify includes remote ip when provided')]
|
||||||
|
public function verifyIncludesRemoteIpWhenProvided(): void
|
||||||
|
{
|
||||||
|
$mockResponse = new MockResponse(json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'score' => 0.9,
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
$httpClient = new MockHttpClient($mockResponse);
|
||||||
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
|
$logger->expects($this->once())->method('info');
|
||||||
|
|
||||||
|
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
|
||||||
|
|
||||||
|
$result = $service->verify('test-token', '192.168.1.1');
|
||||||
|
|
||||||
|
$this->assertTrue($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Verify with score at threshold')]
|
||||||
|
public function verifyWithScoreAtThreshold(): void
|
||||||
|
{
|
||||||
|
$mockResponse = new MockResponse(json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'score' => 0.5,
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
$httpClient = new MockHttpClient($mockResponse);
|
||||||
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
|
|
||||||
|
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
|
||||||
|
|
||||||
|
$result = $service->verify('threshold-token');
|
||||||
|
|
||||||
|
$this->assertTrue($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Verify with no score fails')]
|
||||||
|
public function verifyWithNoScoreFails(): void
|
||||||
|
{
|
||||||
|
$mockResponse = new MockResponse(json_encode([
|
||||||
|
'success' => true,
|
||||||
|
], JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
$httpClient = new MockHttpClient($mockResponse);
|
||||||
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
|
|
||||||
|
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
|
||||||
|
|
||||||
|
$result = $service->verify('no-score-token');
|
||||||
|
|
||||||
|
$this->assertFalse($result);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
tests/WebTestCase.php
Normal file
21
tests/WebTestCase.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Tests;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase as BaseWebTestCase;
|
||||||
|
use Zenstruck\Foundry\Test\Factories;
|
||||||
|
use Zenstruck\Foundry\Test\ResetDatabase;
|
||||||
|
|
||||||
|
abstract class WebTestCase extends BaseWebTestCase
|
||||||
|
{
|
||||||
|
use ResetDatabase;
|
||||||
|
use Factories;
|
||||||
|
}
|
||||||
21
tests/bootstrap.php
Normal file
21
tests/bootstrap.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* This file is part of the SplendidBear Websites' projects.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2026 @ www.splendidbear.org
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use Symfony\Component\Dotenv\Dotenv;
|
||||||
|
|
||||||
|
require dirname(__DIR__).'/vendor/autoload.php';
|
||||||
|
|
||||||
|
if (method_exists(Dotenv::class, 'bootEnv')) {
|
||||||
|
new Dotenv()->bootEnv(dirname(__DIR__).'/.env');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['APP_DEBUG']) {
|
||||||
|
umask(0000);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user