Private
Public Access
1
0

new: pkg: add test cases to back-end w/ real database connection in it #10

This commit is contained in:
2026-04-21 17:56:04 +02:00
parent 6bf908b43e
commit d704be5bff
38 changed files with 5429 additions and 17 deletions

3
.env.test Normal file
View File

@@ -0,0 +1,3 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'

5
.gitignore vendored
View File

@@ -14,3 +14,8 @@ nohup.out
/var/ /var/
/vendor/ /vendor/
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###
###> phpunit/phpunit ###
/phpunit.xml
/.phpunit.cache/
###< phpunit/phpunit ###

View File

@@ -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

View File

@@ -289,11 +289,13 @@ git push origin v2026.01
## Documentation ## Documentation
For detailed information about game mechanics, bonus systems, fonts, and other technical details, 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 - **[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 - **[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
--- ---

4
bin/phpunit Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env php
<?php
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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],
]; ];

View File

@@ -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

View 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

View File

@@ -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 implementation.
## Game Mechanics ## Game Mechanics
@@ -18,6 +18,46 @@ 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`)
- 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.
---
## 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
### Bonus Points at a Glance ### Bonus Points at a Glance
@@ -30,18 +70,63 @@ 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
# Recommended: Use Makefile
make test-db-setup
# Or manually:
# All tests
vendor/bin/phpunit
# Specific file
vendor/bin/phpunit tests/Controller/ProfileControllerTest.php
# Specific method
vendor/bin/phpunit --filter testProfilePageRequiresAuthentication
# With coverage (requires Xdebug/PCOV)
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html coverage/
# With increased memory
php -d memory_limit=512M vendor/bin/phpunit
```
--- ---
## 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`
---
## 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

265
docs/testing/FACTORIES.md Normal file
View 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
View 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
View 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&amp;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>

33
src/Story/AppStory.php Normal file
View 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();
}
}

View File

@@ -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
{ {

View File

@@ -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"
]
} }
} }

View 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"]');
}
}

View 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);
}
}

View 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"]');
}
}

View 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']);
}
}

View 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']);
}
}

View 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);
}
}

View 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);
}
}

View 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]);
}
}

View 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))]);
}
}

View 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);
}
});
}
}

View 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]);
}
}

View 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]);
}
}

View 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]);
}
}

View 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]);
}
}

View 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()]);
}
}

View 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
View 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).

View 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']);
}
}

View 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
View 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
View 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);
}