From bb5b83bf1105a9c177a0f92a04f11c004d640b2e Mon Sep 17 00:00:00 2001 From: TomaszB Date: Wed, 19 Nov 2025 23:13:12 +0100 Subject: [PATCH 01/16] WIP: Remove unused and outdated tests to clean up the codebase. Migrate test to codeception --- .gitignore | 5 - AGENTS.md | 7 +- CLAUDE.md | 159 +++++- .../application-mode-toggle_controller.js | 76 +++ .../character-gallery-card_controller.js | 55 ++ .../controllers/survey-builder_controller.js | 158 ++++++ assets/styles/app.scss | 1 + .../components/_backoffice_buttons.scss | 295 ++++------- .../styles/components/_backoffice_menu.scss | 133 ++--- assets/styles/components/_colors.scss | 189 +++++++ assets/styles/components/_dark_mode.scss | 222 ++++---- assets/styles/components/_sidebar_menu.scss | 18 +- composer.json | 2 +- phpunit.xml.dist | 48 -- public/index_test.php | 9 + .../Backoffice/LocationController.php | 8 +- .../Backoffice/ParticipantController.php | 23 + .../Controller/Public/LocationController.php | 42 +- .../Core/Form/Filter/LarpPublicFilterType.php | 14 +- .../Form/Filter/LocationPublicFilterType.php | 77 ++- .../Form/Filter/ParticipantFilterType.php | 3 +- src/Domain/Core/Form/LarpPropertiesType.php | 3 +- src/Domain/Core/Form/LocationType.php | 9 +- .../Repository/LarpParticipantRepository.php | 18 + .../Security/Voter/LarpParticipantsVoter.php | 54 +- .../Form/PlanningResourceType.php | 4 +- .../Gallery/Form/Filter/GalleryFilterType.php | 4 +- .../Mailing/Form/MailTemplateFilterType.php | 2 + .../Repository/MailTemplateRepository.php | 29 +- .../Controller/CharacterGalleryController.php | 94 ++++ .../Filter/CharacterGalleryFilterType.php | 89 ++++ .../Backoffice/SurveyController.php | 87 ++++ .../Backoffice/SurveyResponseController.php | 136 +++++ .../Public/SurveySubmissionController.php | 218 ++++++++ .../Survey/Form/SurveyQuestionOptionType.php | 39 ++ src/Domain/Survey/Form/SurveyQuestionType.php | 78 +++ src/Domain/Survey/Form/SurveyType.php | 67 +++ .../Service/CharacterMatchingService.php | 241 +++++++++ .../Survey/Service/SurveyTemplateService.php | 182 +++++++ src/Story/AppStory.php | 15 - .../larp/application/list.html.twig | 2 +- .../backoffice/larp/mailing/edit.html.twig | 2 +- .../backoffice/larp/mailing/list.html.twig | 4 +- .../story/comment/_comment_thread.html.twig | 6 +- templates/backoffice/location/list.html.twig | 26 +- .../backoffice/location/modify.html.twig | 4 +- .../backoffice/location/reject.html.twig | 2 +- templates/backoffice/survey/edit.html.twig | 190 +++++++ templates/backoffice/survey/preview.html.twig | 127 +++++ .../survey/responses/list.html.twig | 144 ++++++ .../survey/responses/show.html.twig | 196 ++++++++ templates/components/CookieConsent.html.twig | 2 +- templates/domain/gallery/view.html.twig | 4 +- templates/includes/delete_modal.html.twig | 2 +- templates/macros/ui_components.html.twig | 10 +- templates/public/character/gallery.html.twig | 142 ++++++ templates/public/character/show.html.twig | 146 ++++++ templates/public/larp/details.html.twig | 48 +- templates/public/larp/list.html.twig | 4 +- templates/public/larp/my_larps.html.twig | 2 +- templates/public/location/details.html.twig | 2 +- templates/public/location/list.html.twig | 2 +- templates/public/survey/submit.html.twig | 99 ++++ .../Acceptance/Core/LocationCreationTest.php | 8 + tests/Acceptance/Core/RegistrationTest.php | 8 + .../SuperAdmin/LocationManagementTest.php | 8 + .../BackofficeAccessHappyPathTest.php | 167 ------ .../CharacterApplicationsControllerTest.php | 124 ----- .../Integration/Form/FileMappingTypeTest.php | 59 --- .../MailTemplateDefinitionProviderTest.php | 21 - .../Controller/PublicCommonControllerTest.php | 18 - .../Controller/RecruitmentControllerTest.php | 15 - .../Account/BackofficeAccessHappyPathCest.php | 115 +++++ .../CharacterApplicationsControllerCest.php | 87 ++++ .../UserSignupAndApprovalCest.php | 278 ++++++++++ .../UserSignupAndApprovalTest.php | 262 ---------- .../AcceptedUserPermissionsCest.php | 338 +++++++++++++ .../AcceptedUserPermissionsTest.php | 314 ------------ .../LarpParticipantDeletionCest.php | 474 ++++++++++++++++++ .../UnacceptedUserRestrictionsCest.php | 311 ++++++++++++ .../UnacceptedUserRestrictionsTest.php | 296 ----------- .../Incidents/LarpIncidentsTemplateCest.php} | 48 +- .../Integration/FileMappingTypeCest.php | 72 +++ .../Service/MailTemplateManagerTest.php | 2 +- .../Public/PublicCommonControllerCest.php | 19 + .../Security/BackofficeAccessCest.php | 239 +++++++++ .../Security/BackofficeAccessTest.php | 294 ----------- .../Security/LarpVisibilitySecurityCest.php | 324 ++++++++++++ .../Security/LarpVisibilitySecurityTest.php | 334 ------------ .../Security/LocationApprovalSecurityCest.php | 392 +++++++++++++++ .../Security/LocationApprovalSecurityTest.php | 367 -------------- .../Functional/Security/PublicAccessCest.php | 198 ++++++++ .../Functional/Security/PublicAccessTest.php | 214 -------- .../RecruitmentControllerCest.php | 24 + .../Service/StoryGraphFactionFilterTest.php | 2 +- .../Service/StoryObjectVersionServiceTest.php | 2 +- .../SubmissionStatsServiceCest.php | 64 +++ .../Mailing/MailTemplateManagerCest.php | 50 ++ .../Repository/LarpRepositoryCest.php | 312 ++++++++++++ .../Repository/LarpRepositoryTest.php | 300 ----------- .../Integration/Service/LarpWorkflowCest.php | 434 ++++++++++++++++ .../Integration/Service/LarpWorkflowTest.php | 394 --------------- .../Service/LocationApprovalServiceCest.php | 371 ++++++++++++++ .../Service/LocationApprovalServiceTest.php | 345 ------------- .../RecruitmentProposalRepositoryCest.php | 39 ++ .../StoryRecruitmentRepositoryCest.php | 39 ++ .../StoryGraphFactionFilterCest.php | 60 +++ .../StoryObjectVersionServiceCest.php | 46 ++ tests/Support/AcceptanceTester.php | 4 +- tests/Support/Factory/Account/UserFactory.php | 28 +- .../LarpApplicationChoiceFactory.php | 111 ++++ .../Application/LarpApplicationFactory.php | 162 ++++++ tests/Support/Factory/Core/LarpFactory.php | 50 +- .../Factory/Core/LarpParticipantFactory.php | 155 +++++- .../Support/Factory/Core/LocationFactory.php | 21 + .../Factory/Incidents/LarpIncidentFactory.php | 160 ++++++ .../Factory/Mailing/MailTemplateFactory.php | 205 ++++++++ .../Factory/StoryObject/CharacterFactory.php | 191 +++++++ .../Factory/StoryObject/FactionFactory.php | 87 ++++ .../Factory/StoryObject/ThreadFactory.php | 115 +++++ .../Factory/Survey/SurveyAnswerFactory.php | 109 ++++ .../Support/Factory/Survey/SurveyFactory.php | 76 +++ .../Factory/Survey/SurveyQuestionFactory.php | 134 +++++ .../Survey/SurveyQuestionOptionFactory.php | 43 ++ .../Factory/Survey/SurveyResponseFactory.php | 140 ++++++ tests/Support/FunctionalTester.php | 4 +- tests/Support/Helper/Authentication.php | 34 +- tests/Support/UnitTester.php | 4 +- tests/Traits/AuthenticationTestTrait.php | 2 +- .../Service/SubmissionStatsServiceTest.php | 6 +- .../Service/ParticipantCodeValidatorTest.php | 25 +- .../Repository/BaseRepositoryTest.php | 19 +- .../MailTemplateDefinitionProviderTest.php | 27 + .../RecruitmentProposalRepositoryTest.php | 2 +- .../StoryRecruitmentRepositoryTest.php | 2 +- tests/js/factionGroupLayout.test.js | 94 ---- translations/forms.en.yaml | 8 + translations/forms.pl.yaml | 6 + translations/messages.en.yaml | 81 +++ translations/messages.pl.yaml | 2 + 140 files changed, 9769 insertions(+), 4305 deletions(-) create mode 100644 assets/controllers/application-mode-toggle_controller.js create mode 100644 assets/controllers/character-gallery-card_controller.js create mode 100644 assets/controllers/survey-builder_controller.js create mode 100644 assets/styles/components/_colors.scss delete mode 100755 phpunit.xml.dist create mode 100644 public/index_test.php create mode 100644 src/Domain/Public/Controller/CharacterGalleryController.php create mode 100644 src/Domain/Public/Form/Filter/CharacterGalleryFilterType.php create mode 100644 src/Domain/Survey/Controller/Backoffice/SurveyController.php create mode 100644 src/Domain/Survey/Controller/Backoffice/SurveyResponseController.php create mode 100644 src/Domain/Survey/Controller/Public/SurveySubmissionController.php create mode 100644 src/Domain/Survey/Form/SurveyQuestionOptionType.php create mode 100644 src/Domain/Survey/Form/SurveyQuestionType.php create mode 100644 src/Domain/Survey/Form/SurveyType.php create mode 100644 src/Domain/Survey/Service/CharacterMatchingService.php create mode 100644 src/Domain/Survey/Service/SurveyTemplateService.php delete mode 100644 src/Story/AppStory.php create mode 100644 templates/backoffice/survey/edit.html.twig create mode 100644 templates/backoffice/survey/preview.html.twig create mode 100644 templates/backoffice/survey/responses/list.html.twig create mode 100644 templates/backoffice/survey/responses/show.html.twig create mode 100644 templates/public/character/gallery.html.twig create mode 100644 templates/public/character/show.html.twig create mode 100644 templates/public/survey/submit.html.twig create mode 100644 tests/Acceptance/Core/LocationCreationTest.php create mode 100644 tests/Acceptance/Core/RegistrationTest.php create mode 100644 tests/Acceptance/SuperAdmin/LocationManagementTest.php delete mode 100755 tests/Domain/Account/Controller/BackofficeAccessHappyPathTest.php delete mode 100755 tests/Domain/Application/Controller/CharacterApplicationsControllerTest.php delete mode 100755 tests/Domain/Integration/Form/FileMappingTypeTest.php delete mode 100644 tests/Domain/Mailing/Service/MailTemplateDefinitionProviderTest.php delete mode 100755 tests/Domain/Public/Controller/PublicCommonControllerTest.php delete mode 100755 tests/Domain/StoryMarketplace/Controller/RecruitmentControllerTest.php create mode 100644 tests/Functional/Account/BackofficeAccessHappyPathCest.php create mode 100644 tests/Functional/Application/Controller/CharacterApplicationsControllerCest.php create mode 100644 tests/Functional/Authentication/UserSignupAndApprovalCest.php delete mode 100644 tests/Functional/Authentication/UserSignupAndApprovalTest.php create mode 100644 tests/Functional/Authorization/AcceptedUserPermissionsCest.php delete mode 100644 tests/Functional/Authorization/AcceptedUserPermissionsTest.php create mode 100644 tests/Functional/Authorization/LarpParticipantDeletionCest.php create mode 100644 tests/Functional/Authorization/UnacceptedUserRestrictionsCest.php delete mode 100644 tests/Functional/Authorization/UnacceptedUserRestrictionsTest.php rename tests/{Domain/Incidents/Controller/LarpIncidentsTemplateTest.php => Functional/Incidents/LarpIncidentsTemplateCest.php} (57%) mode change 100755 => 100644 create mode 100644 tests/Functional/Integration/FileMappingTypeCest.php rename tests/{Domain => Functional}/Mailing/Service/MailTemplateManagerTest.php (97%) create mode 100644 tests/Functional/Public/PublicCommonControllerCest.php create mode 100644 tests/Functional/Security/BackofficeAccessCest.php delete mode 100644 tests/Functional/Security/BackofficeAccessTest.php create mode 100644 tests/Functional/Security/LarpVisibilitySecurityCest.php delete mode 100644 tests/Functional/Security/LarpVisibilitySecurityTest.php create mode 100644 tests/Functional/Security/LocationApprovalSecurityCest.php delete mode 100644 tests/Functional/Security/LocationApprovalSecurityTest.php create mode 100644 tests/Functional/Security/PublicAccessCest.php delete mode 100644 tests/Functional/Security/PublicAccessTest.php create mode 100644 tests/Functional/StoryMarketplace/RecruitmentControllerCest.php rename tests/{Domain => Functional}/StoryObject/Service/StoryGraphFactionFilterTest.php (97%) mode change 100755 => 100644 rename tests/{Domain => Functional}/StoryObject/Service/StoryObjectVersionServiceTest.php (96%) mode change 100755 => 100644 create mode 100644 tests/Integration/Application/SubmissionStatsServiceCest.php create mode 100644 tests/Integration/Mailing/MailTemplateManagerCest.php create mode 100644 tests/Integration/Repository/LarpRepositoryCest.php delete mode 100644 tests/Integration/Repository/LarpRepositoryTest.php create mode 100644 tests/Integration/Service/LarpWorkflowCest.php delete mode 100644 tests/Integration/Service/LarpWorkflowTest.php create mode 100644 tests/Integration/Service/LocationApprovalServiceCest.php delete mode 100644 tests/Integration/Service/LocationApprovalServiceTest.php create mode 100644 tests/Integration/StoryMarketplace/RecruitmentProposalRepositoryCest.php create mode 100644 tests/Integration/StoryMarketplace/StoryRecruitmentRepositoryCest.php create mode 100644 tests/Integration/StoryObject/StoryGraphFactionFilterCest.php create mode 100644 tests/Integration/StoryObject/StoryObjectVersionServiceCest.php create mode 100644 tests/Support/Factory/Application/LarpApplicationChoiceFactory.php create mode 100644 tests/Support/Factory/Application/LarpApplicationFactory.php create mode 100644 tests/Support/Factory/Incidents/LarpIncidentFactory.php create mode 100644 tests/Support/Factory/Mailing/MailTemplateFactory.php create mode 100644 tests/Support/Factory/StoryObject/CharacterFactory.php create mode 100644 tests/Support/Factory/StoryObject/FactionFactory.php create mode 100644 tests/Support/Factory/StoryObject/ThreadFactory.php create mode 100644 tests/Support/Factory/Survey/SurveyAnswerFactory.php create mode 100644 tests/Support/Factory/Survey/SurveyFactory.php create mode 100644 tests/Support/Factory/Survey/SurveyQuestionFactory.php create mode 100644 tests/Support/Factory/Survey/SurveyQuestionOptionFactory.php create mode 100644 tests/Support/Factory/Survey/SurveyResponseFactory.php rename tests/{Domain => Unit}/Application/Service/SubmissionStatsServiceTest.php (93%) mode change 100755 => 100644 rename tests/{Domain => Unit}/Core/Service/ParticipantCodeValidatorTest.php (50%) mode change 100755 => 100644 rename tests/{Domain => Unit}/Infrastructure/Repository/BaseRepositoryTest.php (74%) mode change 100755 => 100644 create mode 100644 tests/Unit/Mailing/Service/MailTemplateDefinitionProviderTest.php rename tests/{Domain => Unit}/StoryMarketplace/Repository/RecruitmentProposalRepositoryTest.php (95%) mode change 100755 => 100644 rename tests/{Domain => Unit}/StoryMarketplace/Repository/StoryRecruitmentRepositoryTest.php (95%) mode change 100755 => 100644 delete mode 100755 tests/js/factionGroupLayout.test.js diff --git a/.gitignore b/.gitignore index df19e7c..88f7c25 100755 --- a/.gitignore +++ b/.gitignore @@ -9,11 +9,6 @@ /vendor/ ###< symfony/framework-bundle ### -###> phpunit/phpunit ### -/phpunit.xml -.phpunit.result.cache -###< phpunit/phpunit ### - ###> symfony/asset-mapper ### /public/assets/ /public/uploads/maps/* diff --git a/AGENTS.md b/AGENTS.md index 371858f..607d589 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,8 +13,9 @@ - JavaScript is written as ES6 modules and organized under `assets/controllers/` using Stimulus. Use 4 space indentation and define `targets`, `values`, and life‑cycle methods (e.g., `connect`). Webpack Encore bundles assets and the entry point is `assets/app.js`. ## Tests and checks -- Run unit tests with `vendor/bin/phpunit -c phpunit.xml.dist`. -- Run code style checks with `vendor/bin/ecs check`. -- Run static analysis with `vendor/bin/phpstan analyse -c phpstan.neon`. +- Prepare tests with `make test-build`. +- Run unit tests with `make test`. +- Run code style checks with `make ecs-fix`. +- Run static analysis with `make stan`. Make sure these checks pass before committing changes. diff --git a/CLAUDE.md b/CLAUDE.md index 3fa5152..781da08 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,9 +40,21 @@ make ecs-fix make stan # OR: docker compose exec -T php bash -lc "XDEBUG_MODE=off php -d memory_limit=-1 vendor/bin/phpstan analyse -c phpstan.neon" -# Run tests +# Run all tests (Codeception) make test -# OR: docker compose exec -T php bash -lc "APP_ENV=test php vendor/bin/phpunit -c phpunit.xml.dist --colors=always" +# OR: docker compose exec -T php vendor/bin/codecept run --colors + +# Run specific test suites +make test-unit # Unit tests only (fast, no database) +make test-functional # Functional tests (with database, no browser) +make test-acceptance # Acceptance tests (browser-based) + +# Run a specific test file +make test-filter FILTER=Functional/Authentication/UserSignupAndApprovalCest + +# Rebuild Codeception actors (after suite config changes) +make test-build +# OR: docker compose exec -T php vendor/bin/codecept build # Automated refactoring (PHP 8.2) make rector-fix @@ -377,7 +389,7 @@ $pagination = $this->getPagination($qb, $request); For sort controls in filter forms, use twig since they're UI controls, not filters: ```php -{% include 'includes/sort_th.html.twig' with { field: 'name', label: 'common.name'|trans } %} +{% include 'includes/sort_th.html.twig' with { field: 'name', label: 'name'|trans } %} ``` **Examples**: See `FactionController::list()` for complete implementations. @@ -396,7 +408,7 @@ Backoffice list pages follow a consistent template pattern for displaying filter
- {{ 'common.create'|trans }} + {{ 'create'|trans }}
@@ -414,10 +426,10 @@ Backoffice list pages follow a consistent template pattern for displaying filter {# Sortable column header #} {% include 'includes/sort_th.html.twig' with { field: 'name', - label: 'common.name'|trans + label: 'name'|trans } %} - {{ 'common.description'|trans }} - {{ 'common.actions'|trans }} + {{ 'description'|trans }} + {{ 'actions'|trans }} @@ -442,7 +454,7 @@ Backoffice list pages follow a consistent template pattern for displaying filter data-delete-url="{{ path('backoffice_larp_story_tag_delete', { larp: larp.id, tag: tag.id }) }}"> - {{ 'common.delete'|trans }} + {{ 'delete'|trans }} @@ -451,7 +463,7 @@ Backoffice list pages follow a consistent template pattern for displaying filter {% else %} -

{{ 'common.empty_list'|trans }}

+

{{ 'empty_list'|trans }}

{% endif %} @@ -470,7 +482,7 @@ Backoffice list pages follow a consistent template pattern for displaying filter 3. **Conditional Data Display**: - Check `{% if items is not empty %}` before rendering table - - Show `{{ 'common.empty_list'|trans }}` message when no data + - Show `{{ 'empty_list'|trans }}` message when no data 4. **Table Styling**: - `table-responsive` wrapper for mobile scrolling @@ -488,7 +500,7 @@ Backoffice list pages follow a consistent template pattern for displaying filter - Pass item data via `data-*` attributes for JavaScript handling 7. **Empty State**: - - Simple text message: `

{{ 'common.empty_list'|trans }}

` + - Simple text message: `

{{ 'empty_list'|trans }}

` **Sorting Implementation**: @@ -528,13 +540,134 @@ Copy `.env` to `.env.local` and configure: ## Testing -Run tests with PHPUnit: +LARPilot uses **Codeception** for all testing (unit, functional, and acceptance tests). + +### Running Tests + ```bash -vendor/bin/phpunit -c phpunit.xml.dist +# Run all tests +make test + +# Run specific test suites +make test-unit # Unit tests only (fast, no database) +make test-functional # Functional tests (with database, no browser) +make test-acceptance # Acceptance tests (browser-based) + +# Run a specific test file or path +make test-filter FILTER=Functional/Authentication/UserSignupAndApprovalCest +make test-filter FILTER=Unit/Domain/Infrastructure + +# Rebuild actors after suite configuration changes +make test-build ``` +### Test Structure + +``` +tests/ +├── Unit/ # Unit tests (no dependencies) +├── Functional/ # Functional tests (with database) +├── Acceptance/ # Browser-based tests +└── Support/ + ├── Helper/ + │ └── Authentication.php # Custom Codeception helper + └── Factory/ # Foundry factories (organized by domain) + ├── Account/ + ├── Core/ + └── Survey/ +``` + +### Writing Tests + +**Functional Test Example** (Cest format): +```php +wantTo('verify PENDING users cannot access backoffice'); + + $pendingUser = $I->createPendingUser(); + $I->amLoggedInAs($pendingUser); + + $I->amOnRoute('backoffice_larp_create'); + $I->seeResponseCodeIs(302); + } +} +``` + +### Authentication Helper Methods + +The `Authentication` helper (`tests/Support/Helper/Authentication.php`) provides factory-based methods for test data: + +**User Creation:** +- `$I->createPendingUser()` - Create PENDING user +- `$I->createApprovedUser()` - Create APPROVED user +- `$I->createSuspendedUser()` - Create SUSPENDED user +- `$I->createBannedUser()` - Create BANNED user +- `$I->createSuperAdmin()` - Create SUPER_ADMIN user + +**Authentication:** +- `$I->amLoggedInAs($user)` - Log in as specific user + +**LARP Creation:** +- `$I->createLarp($organizer)` - Create LARP (default: DRAFT) +- `$I->createDraftLarp($organizer)` - Create DRAFT LARP +- `$I->createPublishedLarp($organizer)` - Create PUBLISHED LARP +- `$I->addParticipantToLarp($larp, $user, 'player')` - Add participant + +**Location Creation:** +- `$I->createPendingLocation($creator)` - Create PENDING location +- `$I->createApprovedLocation($creator)` - Create APPROVED location +- `$I->createRejectedLocation($creator, $reason)` - Create REJECTED location + +**Utilities:** +- `$I->getEntityManager()` - Get Doctrine EntityManager +- `$I->getUrl('route_name', $params)` - Generate URL from route + +### Foundry Factories + +Tests use **Zenstruck Foundry** factories for test data creation. Factories are organized by domain under `tests/Support/Factory/`: + +**Example Usage:** +```php +use Tests\Support\Factory\Account\UserFactory; +use Tests\Support\Factory\Core\LarpFactory; +use Tests\Support\Factory\Survey\SurveyFactory; + +// Create user with factory +$user = UserFactory::new()->approved()->create(); + +// Create LARP with factory +$larp = LarpFactory::new() + ->forOrganizer($user) + ->withStatus('published') + ->create(); + +// Create survey with questions +$survey = SurveyFactory::new() + ->forLarp($larp) + ->withQuestions(5) + ->create(); +``` + +See factory classes for available methods and options. + +### Test Database + Test database uses suffix `_test` (configured in `config/packages/doctrine.yaml`). +**Prepare test database:** +```bash +make prepare-test-db +``` + ## Code Quality Standards - **PHP Version**: 8.2+ diff --git a/assets/controllers/application-mode-toggle_controller.js b/assets/controllers/application-mode-toggle_controller.js new file mode 100644 index 0000000..04445ad --- /dev/null +++ b/assets/controllers/application-mode-toggle_controller.js @@ -0,0 +1,76 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Application Mode Toggle Controller + * + * Manages the visibility of the "Publish Characters Publicly" checkbox + * based on the selected application mode in LARP settings. + * + * - CHARACTER_SELECTION mode: Shows the checkbox + * - SURVEY mode: Hides the checkbox and unchecks it + */ +export default class extends Controller { + static targets = ['publishCheckbox']; + + connect() { + this.updateVisibility(); + } + + /** + * Called when application mode radio buttons change + */ + change(event) { + this.updateVisibility(); + } + + updateVisibility() { + const selectedMode = this.getSelectedMode(); + const publishContainer = this.findPublishCheckboxContainer(); + + if (!publishContainer) { + return; + } + + if (selectedMode === 'character_selection') { + // Show the checkbox for character selection mode + publishContainer.style.display = 'block'; + } else if (selectedMode === 'survey') { + // Hide the checkbox for survey mode and uncheck it + publishContainer.style.display = 'none'; + + // Uncheck the checkbox + const checkbox = publishContainer.querySelector('input[type="checkbox"]'); + if (checkbox) { + checkbox.checked = false; + } + } + } + + getSelectedMode() { + // Find the checked radio button + const checkedRadio = this.element.querySelector('input[type="radio"]:checked'); + return checkedRadio ? checkedRadio.value : null; + } + + findPublishCheckboxContainer() { + // The checkbox is in a sibling form group + // Look for the parent form and then find the publish checkbox container + const form = this.element.closest('form'); + if (!form) { + return null; + } + + // Find by data attribute if available + if (this.hasPublishCheckboxTarget) { + return this.publishCheckboxTarget.closest('.mb-3, .form-group'); + } + + // Fallback: Find by field name pattern + const publishField = form.querySelector('input[name*="publishCharactersPublicly"]'); + if (publishField) { + return publishField.closest('.mb-3, .form-group'); + } + + return null; + } +} diff --git a/assets/controllers/character-gallery-card_controller.js b/assets/controllers/character-gallery-card_controller.js new file mode 100644 index 0000000..e92b0f2 --- /dev/null +++ b/assets/controllers/character-gallery-card_controller.js @@ -0,0 +1,55 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Character Gallery Card Controller + * + * Handles interactions for character cards in the public gallery. + * Currently provides hover effects and could be extended for lazy loading, + * favoriting, or other interactive features. + */ +export default class extends Controller { + static targets = ['image', 'card']; + + connect() { + this.addHoverEffects(); + } + + addHoverEffects() { + if (this.hasCardTarget) { + this.cardTarget.addEventListener('mouseenter', () => { + this.cardTarget.classList.add('shadow-lg'); + }); + + this.cardTarget.addEventListener('mouseleave', () => { + this.cardTarget.classList.remove('shadow-lg'); + }); + } + } + + /** + * Navigate to character detail page + * Can be used for card click handling if needed + */ + viewDetails(event) { + // Prevent navigation if clicking on a link inside the card + if (event.target.tagName === 'A' || event.target.closest('a')) { + return; + } + + const detailUrl = this.element.dataset.detailUrl; + if (detailUrl) { + window.location.href = detailUrl; + } + } + + /** + * Lazy load image when card enters viewport + * Usage: data-action="intersection@window->character-gallery-card#lazyLoadImage" + */ + lazyLoadImage() { + if (this.hasImageTarget && this.imageTarget.dataset.src) { + this.imageTarget.src = this.imageTarget.dataset.src; + delete this.imageTarget.dataset.src; + } + } +} diff --git a/assets/controllers/survey-builder_controller.js b/assets/controllers/survey-builder_controller.js new file mode 100644 index 0000000..1577d82 --- /dev/null +++ b/assets/controllers/survey-builder_controller.js @@ -0,0 +1,158 @@ +import { Controller } from '@hotwired/stimulus'; +import $ from 'jquery'; +import Sortable from 'sortablejs'; + +export default class extends Controller { + static targets = ['questionsContainer', 'addQuestionButton']; + + connect() { + this.initializeSortable(); + this.initializeQuestionTypeHandlers(); + } + + disconnect() { + if (this.sortable) { + this.sortable.destroy(); + } + } + + initializeSortable() { + const container = document.getElementById('survey-questions-container'); + if (container) { + this.sortable = Sortable.create(container, { + animation: 150, + handle: '.drag-handle', + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + dragClass: 'sortable-drag', + fallbackOnBody: false, + swapThreshold: 0.65, + invertSwap: false, + removeCloneOnHide: false, + onStart: () => { + this.disableStimulus(); + }, + onEnd: (evt) => { + this.enableStimulus(); + this.updateOrderPositions(); + } + }); + } + } + + initializeQuestionTypeHandlers() { + // Listen for question type changes to show/hide options + $(document).on('change', 'select[id$="_questionType"]', (e) => { + const select = $(e.target); + const questionCard = select.closest('.question-item'); + const optionsContainer = questionCard.find('.question-options-container'); + const questionType = select.val(); + + // Show options container only for single_choice and multiple_choice + if (questionType === 'single_choice' || questionType === 'multiple_choice') { + optionsContainer.show(); + } else { + optionsContainer.hide(); + } + }); + } + + disableStimulus() { + if (this.application && this.application.router) { + this.application.router.stop(); + } + } + + enableStimulus() { + if (this.application && this.application.router) { + setTimeout(() => { + this.application.router.start(); + }, 100); + } + } + + updateOrderPositions() { + $('#survey-questions-container .question-item').each(function(index) { + const position = index; + + // Update visual position badge + $(this).find('.position-badge').text('Question ' + (position + 1)); + + // Update hidden orderPosition field + $(this).find('input[name$="[orderPosition]"]').val(position); + + // Update data attribute + $(this).attr('data-position', position); + }); + + // Also update option positions within each question + $('.question-options-container').each(function() { + $(this).find('.option-item').each(function(index) { + $(this).find('input[name$="[orderPosition]"]').val(index); + }); + }); + } + + addQuestion(event) { + event.preventDefault(); + + const container = document.getElementById('survey-questions-container'); + const prototype = container.dataset.prototype; + const index = container.dataset.index || 0; + + // Replace __name__ placeholder with index + const newForm = prototype.replace(/__name__/g, index); + + // Increment index + container.dataset.index = parseInt(index) + 1; + + // Add new question + $(container).append(newForm); + + // Update order positions + this.updateOrderPositions(); + } + + removeQuestion(event) { + event.preventDefault(); + + const questionCard = $(event.target).closest('.question-item'); + + // Confirm deletion + if (confirm('Are you sure you want to remove this question?')) { + questionCard.remove(); + this.updateOrderPositions(); + } + } + + addOption(event) { + event.preventDefault(); + + const button = $(event.target); + const questionCard = button.closest('.question-item'); + const optionsContainer = questionCard.find('.question-options-list'); + const prototype = optionsContainer.data('prototype'); + const index = optionsContainer.data('index') || 0; + + // Replace __name__ placeholder + const newForm = prototype.replace(/__name__/g, index); + + // Increment index + optionsContainer.data('index', parseInt(index) + 1); + + // Add new option + optionsContainer.append(newForm); + + // Update order positions + this.updateOrderPositions(); + } + + removeOption(event) { + event.preventDefault(); + + const optionItem = $(event.target).closest('.option-item'); + optionItem.remove(); + + this.updateOrderPositions(); + } +} diff --git a/assets/styles/app.scss b/assets/styles/app.scss index a78e4a9..177ae21 100755 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -1,4 +1,5 @@ //assets/styles/app.scss +@import "./components/colors"; @import "./components/dark_mode"; @import "./components/sidebar_menu"; @import "./components/backoffice_menu"; diff --git a/assets/styles/components/_backoffice_buttons.scss b/assets/styles/components/_backoffice_buttons.scss index af13aa1..ce066ae 100644 --- a/assets/styles/components/_backoffice_buttons.scss +++ b/assets/styles/components/_backoffice_buttons.scss @@ -1,86 +1,13 @@ -// ============================================ -// BACKOFFICE COLOR VARIABLES -// ============================================ - -// Base Colors -$bo-white: #ffffff; -$bo-black: #000000; -$bo-gray-50: #f8f9fa; -$bo-gray-100: #e9ecef; -$bo-gray-200: #dee2e6; -$bo-gray-300: #ced4da; -$bo-gray-400: #adb5bd; -$bo-gray-500: #6c757d; -$bo-gray-600: #495057; -$bo-gray-700: #343a40; -$bo-gray-800: #212529; - -// Primary Colors (Bootstrap Blue) -$bo-primary: #0d6efd; -$bo-primary-hover: #0b5ed7; -$bo-primary-active: #0a58ca; -$bo-primary-light: #e7f1ff; -$bo-primary-dark: #084298; - -// Success Colors (Green) -$bo-success: #198754; -$bo-success-hover: #157347; -$bo-success-active: #146c43; -$bo-success-light: #d1e7dd; -$bo-success-dark: #0f5132; - -// Danger Colors (Red) -$bo-danger: #dc3545; -$bo-danger-hover: #bb2d3b; -$bo-danger-active: #b02a37; -$bo-danger-light: #f8d7da; -$bo-danger-dark: #842029; - -// Warning Colors (Yellow) -$bo-warning: #ffc107; -$bo-warning-hover: #ffca2c; -$bo-warning-active: #ffcd39; -$bo-warning-light: #fff3cd; -$bo-warning-dark: #997404; - -// Info Colors (Cyan) -$bo-info: #0dcaf0; -$bo-info-hover: #31d2f2; -$bo-info-active: #3dd5f3; -$bo-info-light: #cff4fc; -$bo-info-dark: #055160; - -// Border Radius -$bo-border-radius: 8px; -$bo-border-radius-sm: 6px; -$bo-border-radius-lg: 10px; -$bo-border-radius-xl: 12px; - -// Shadows -$bo-shadow-sm: 0 2px 4px rgba($bo-black, 0.08); -$bo-shadow: 0 2px 8px rgba($bo-black, 0.08); -$bo-shadow-lg: 0 4px 12px rgba($bo-black, 0.12); -$bo-shadow-xl: 0 8px 24px rgba($bo-black, 0.15); - -$bo-shadow-success: 0 4px 12px rgba($bo-success, 0.3); -$bo-shadow-success-hover: 0 6px 16px rgba($bo-success, 0.4); -$bo-shadow-danger: 0 4px 12px rgba($bo-danger, 0.3); - -// Transitions -$bo-transition-default: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); -$bo-transition-smooth: all 0.2s ease; -$bo-transition-fast: all 0.15s ease; - // Modern SUCCESS button styling (GREEN for create/save) .btn-success-modern { - background: $bo-success; + background: var(--bo-success); border: none; - color: $bo-white; - border-radius: $bo-border-radius-lg; + color: var(--bo-white); + border-radius: var(--bo-border-radius-lg); padding: 0.625rem 1.25rem; font-weight: 500; - box-shadow: $bo-shadow-success; - transition: $bo-transition-default; + box-shadow: var(--bo-shadow-success); + transition: var(--bo-transition-default); display: inline-flex; align-items: center; gap: 0.5rem; @@ -90,10 +17,10 @@ $bo-transition-fast: all 0.15s ease; } &:hover { - background: $bo-success-hover; + background: var(--bo-success-hover); transform: translateY(-2px); - box-shadow: $bo-shadow-success-hover; - color: $bo-white; + box-shadow: var(--bo-shadow-success-hover); + color: var(--bo-white); i { transform: scale(1.1); @@ -102,41 +29,41 @@ $bo-transition-fast: all 0.15s ease; &:active { transform: translateY(0); - box-shadow: $bo-shadow-success; + box-shadow: var(--bo-shadow-success); } &:focus { - box-shadow: 0 0 0 0.25rem rgba($bo-success, 0.25); - color: $bo-white; + box-shadow: 0 0 0 0.25rem rgba(var(--bo-success), 0.25); + color: var(--bo-white); } } // Small button variant .btn-sm.btn-primary-modern { padding: 0.375rem 0.875rem; - border-radius: $bo-border-radius; + border-radius: var(--bo-border-radius); font-size: 0.875rem; } // Secondary button with outline (GRAY for cancel/decline) .btn-secondary-modern { - background: $bo-white; - border: 2px solid $bo-gray-500; - color: $bo-gray-500; - border-radius: $bo-border-radius-lg; + background: var(--bo-white); + border: 2px solid var(--bo-gray-500); + color: var(--bo-gray-500); + border-radius: var(--bo-border-radius-lg); padding: 0.625rem 1.25rem; font-weight: 500; - transition: $bo-transition-default; + transition: var(--bo-transition-default); display: inline-flex; align-items: center; gap: 0.5rem; &:hover { - background: $bo-gray-500; - border-color: $bo-gray-500; - color: $bo-white; + background: var(--bo-gray-500); + border-color: var(--bo-gray-500); + color: var(--bo-white); transform: translateY(-2px); - box-shadow: 0 4px 12px rgba($bo-gray-500, 0.3); + box-shadow: 0 4px 12px rgba(var(--bo-gray-500), 0.3); } &:active { @@ -144,18 +71,18 @@ $bo-transition-fast: all 0.15s ease; } &:focus { - box-shadow: 0 0 0 0.25rem rgba($bo-gray-500, 0.25); + box-shadow: 0 0 0 0.25rem rgba(var(--bo-gray-500), 0.25); } } // Danger button with transition .btn-danger { - border-radius: $bo-border-radius; - transition: $bo-transition-default; + border-radius: var(--bo-border-radius); + transition: var(--bo-transition-default); &:hover { transform: translateY(-2px); - box-shadow: $bo-shadow-danger; + box-shadow: var(--bo-shadow-danger); } &:active { @@ -166,24 +93,24 @@ $bo-transition-fast: all 0.15s ease; // Button group with clean styling (NO GRADIENT) .btn-group { position: relative; - box-shadow: $bo-shadow; - border-radius: $bo-border-radius; + box-shadow: var(--bo-shadow); + border-radius: var(--bo-border-radius); overflow: hidden; - border: 1px solid $bo-gray-200; - transition: $bo-transition-default; + border: 1px solid var(--bo-gray-200); + transition: var(--bo-transition-default); &:hover { - box-shadow: $bo-shadow-lg; + box-shadow: var(--bo-shadow-lg); } .btn { border: none; - border-right: 1px solid $bo-gray-200; - background: $bo-white; - color: $bo-gray-600; + border-right: 1px solid var(--bo-gray-200); + background: var(--bo-white); + color: var(--bo-gray-600); margin: 0; border-radius: 0; - transition: $bo-transition-default; + transition: var(--bo-transition-default); font-weight: 500; &:last-child { @@ -191,35 +118,35 @@ $bo-transition-fast: all 0.15s ease; } &:hover { - background: $bo-gray-50; - color: $bo-primary; + background: var(--bo-gray-50); + color: var(--bo-primary); z-index: 1; } &.active { - background: $bo-primary; - color: $bo-white; + background: var(--bo-primary); + color: var(--bo-white); font-weight: 600; } &.btn-outline-success { - background: $bo-white; - color: $bo-gray-600; + background: var(--bo-white); + color: var(--bo-gray-600); border: none; - border-right: 1px solid $bo-gray-200; + border-right: 1px solid var(--bo-gray-200); &:last-child { border-right: none; } &:hover { - background: $bo-gray-50; - color: $bo-primary; + background: var(--bo-gray-50); + color: var(--bo-primary); } &.active { - background: $bo-primary; - color: $bo-white; + background: var(--bo-primary); + color: var(--bo-white); } } } @@ -228,72 +155,72 @@ $bo-transition-fast: all 0.15s ease; // Dropdown toggle with clean styling (NO GRADIENT - except for top menu which has its own styles) .dropdown:not(.user-menu) { .btn { - background: $bo-white; - border: 1px solid $bo-gray-200; - color: $bo-gray-600; - border-radius: $bo-border-radius; - box-shadow: $bo-shadow; - transition: $bo-transition-default; + background: var(--bo-white); + border: 1px solid var(--bo-gray-200); + color: var(--bo-gray-600); + border-radius: var(--bo-border-radius); + box-shadow: var(--bo-shadow); + transition: var(--bo-transition-default); font-weight: 500; &:hover { - background: $bo-gray-50; - color: $bo-primary; - border-color: $bo-primary; + background: var(--bo-gray-50); + color: var(--bo-primary); + border-color: var(--bo-primary); transform: translateY(-2px); - box-shadow: $bo-shadow-lg; + box-shadow: var(--bo-shadow-lg); } &:focus { - box-shadow: 0 0 0 0.25rem rgba($bo-primary, 0.15); + box-shadow: 0 0 0 0.25rem rgba(var(--bo-primary), 0.15); } &.active { - background: $bo-primary; - color: $bo-white; - border-color: $bo-primary; + background: var(--bo-primary); + color: var(--bo-white); + border-color: var(--bo-primary); } } .dropdown-menu { - border-radius: $bo-border-radius-lg; - box-shadow: $bo-shadow-xl; - border: 1px solid rgba($bo-black, 0.08); + border-radius: var(--bo-border-radius-lg); + box-shadow: var(--bo-shadow-xl); + border: 1px solid rgba(var(--bo-black), 0.08); padding: 0.5rem; margin-top: 0.5rem; animation: dropdownSlide 0.2s ease; .dropdown-item { - border-radius: $bo-border-radius; + border-radius: var(--bo-border-radius); padding: 0.625rem 1rem; - transition: $bo-transition-smooth; + transition: var(--bo-transition-smooth); display: flex; align-items: center; gap: 0.75rem; i { - color: $bo-primary; + color: var(--bo-primary); width: 1.25rem; } &:hover { - background: $bo-gray-50; - color: $bo-primary; + background: var(--bo-gray-50); + color: var(--bo-primary); transform: translateX(4px); } &:active { - background: $bo-primary-light; + background: var(--bo-primary-light); } } .dropdown-divider { margin: 0.5rem 0; - border-color: rgba($bo-black, 0.08); + border-color: rgba(var(--bo-black), 0.08); } .dropdown-header { - color: $bo-gray-500; + color: var(--bo-gray-500); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; @@ -316,40 +243,40 @@ $bo-transition-fast: all 0.15s ease; // Card with modern styling .card { - border-radius: $bo-border-radius-xl; - border: 1px solid rgba($bo-black, 0.08); - box-shadow: $bo-shadow-sm; - transition: $bo-transition-default; + border-radius: var(--bo-border-radius-xl); + border: 1px solid rgba(var(--bo-black), 0.08); + box-shadow: var(--bo-shadow-sm); + transition: var(--bo-transition-default); &:hover { - box-shadow: $bo-shadow; + box-shadow: var(--bo-shadow); } .card-header { - border-radius: $bo-border-radius-xl $bo-border-radius-xl 0 0; - border-bottom: 1px solid rgba($bo-black, 0.08); - background: linear-gradient(180deg, $bo-white 0%, $bo-gray-50 100%); + border-radius: var(--bo-border-radius-xl) var(--bo-border-radius-xl) 0 0; + border-bottom: 1px solid rgba(var(--bo-black), 0.08); + background: linear-gradient(180deg, var(--bo-white) 0%, var(--bo-gray-50) 100%); } } // Modern alert styling (NO GRADIENT) .alert { - border-radius: $bo-border-radius-lg; + border-radius: var(--bo-border-radius-lg); border: none; - box-shadow: $bo-shadow; + box-shadow: var(--bo-shadow); &.alert-info { - background: $bo-primary-light; - color: $bo-primary-dark; - border-left: 4px solid $bo-primary; + background: var(--bo-primary-light); + color: var(--bo-primary-dark); + border-left: 4px solid var(--bo-primary); } } // Table action buttons .table { .btn-sm { - border-radius: $bo-border-radius; - transition: $bo-transition-default; + border-radius: var(--bo-border-radius); + transition: var(--bo-transition-default); &:hover { transform: translateY(-2px); @@ -363,7 +290,7 @@ $bo-transition-fast: all 0.15s ease; // Badge modern styling .badge { - border-radius: $bo-border-radius-sm; + border-radius: var(--bo-border-radius-sm); padding: 0.375rem 0.75rem; font-weight: 500; } @@ -371,19 +298,19 @@ $bo-transition-fast: all 0.15s ease; // Empty state with modern styling .empty-state { i { - color: $bo-primary; + color: var(--bo-primary); } } // Form controls modern styling .form-control, .form-select { - border-radius: $bo-border-radius; - border: 1px solid $bo-gray-200; - transition: $bo-transition-smooth; + border-radius: var(--bo-border-radius); + border: 1px solid var(--bo-gray-200); + transition: var(--bo-transition-smooth); &:focus { - border-color: $bo-success; - box-shadow: 0 0 0 0.25rem rgba($bo-success, 0.25); + border-color: var(--bo-success); + box-shadow: 0 0 0 0.25rem rgba(var(--bo-success), 0.25); } } @@ -399,52 +326,52 @@ $bo-transition-fast: all 0.15s ease; // Modern outline success button (green outline) .btn-outline-success:not(.btn-group .btn) { - background: $bo-white; - border: 2px solid $bo-success; - color: $bo-success; - border-radius: $bo-border-radius-lg; + background: var(--bo-white); + border: 2px solid var(--bo-success); + color: var(--bo-success); + border-radius: var(--bo-border-radius-lg); padding: 0.625rem 1.25rem; font-weight: 500; - transition: $bo-transition-default; + transition: var(--bo-transition-default); display: inline-flex; align-items: center; gap: 0.5rem; &:hover { - background: rgba($bo-success, 0.08); - border-color: $bo-success-hover; - color: $bo-success-hover; + background: rgba(var(--bo-success), 0.08); + border-color: var(--bo-success-hover); + color: var(--bo-success-hover); transform: translateY(-2px); - box-shadow: $bo-shadow-success; + box-shadow: var(--bo-shadow-success); } &:active { transform: translateY(0); - background: rgba($bo-success, 0.15); + background: rgba(var(--bo-success), 0.15); } &:focus { - box-shadow: 0 0 0 0.25rem rgba($bo-success, 0.25); - color: $bo-success; + box-shadow: 0 0 0 0.25rem rgba(var(--bo-success), 0.25); + color: var(--bo-success); } } // Custom text colors (clean Bootstrap primary) .text-primary-violet { - color: $bo-primary !important; + color: var(--bo-primary !important); } // Button group enhancements .btn-group { .btn-success, .btn-outline-success { &:first-child { - border-top-left-radius: $bo-border-radius-lg; - border-bottom-left-radius: $bo-border-radius-lg; + border-top-left-radius: var(--bo-border-radius-lg); + border-bottom-left-radius: var(--bo-border-radius-lg); } &:last-child { - border-top-right-radius: $bo-border-radius-lg; - border-bottom-right-radius: $bo-border-radius-lg; + border-top-right-radius: var(--bo-border-radius-lg); + border-bottom-right-radius: var(--bo-border-radius-lg); } &:not(:first-child):not(:last-child) { @@ -493,10 +420,10 @@ $bo-transition-fast: all 0.15s ease; // Table row hover effect (NO GRADIENT) .table-hover tbody tr { - transition: $bo-transition-smooth; + transition: var(--bo-transition-smooth); &:hover { - background-color: $bo-gray-50; + background-color: var(--bo-gray-50); //transform: scale(1.001); } } diff --git a/assets/styles/components/_backoffice_menu.scss b/assets/styles/components/_backoffice_menu.scss index 1ff6322..7afd11b 100644 --- a/assets/styles/components/_backoffice_menu.scss +++ b/assets/styles/components/_backoffice_menu.scss @@ -1,76 +1,11 @@ // Clean Backoffice Menu Styling // Modern Bootstrap navbar with solid colors - NO GRADIENTS -// ============================================ -// BACKOFFICE COLOR VARIABLES -// ============================================ - -// Base Colors -$bo-white: #ffffff; -$bo-black: #000000; -$bo-gray-50: #f8f9fa; -$bo-gray-100: #e9ecef; -$bo-gray-200: #dee2e6; -$bo-gray-300: #ced4da; -$bo-gray-400: #adb5bd; -$bo-gray-500: #6c757d; -$bo-gray-600: #495057; -$bo-gray-700: #343a40; -$bo-gray-800: #212529; - -// Primary Colors (Bootstrap Blue) -$bo-primary: #0d6efd; -$bo-primary-hover: #0b5ed7; -$bo-primary-active: #0a58ca; -$bo-primary-light: #e7f1ff; -$bo-primary-dark: #084298; - -// Success Colors (Green) -$bo-success: #198754; -$bo-success-hover: #157347; -$bo-success-active: #146c43; -$bo-success-light: #d1e7dd; -$bo-success-dark: #0f5132; - -// Danger Colors (Red) -$bo-danger: #dc3545; -$bo-danger-hover: #bb2d3b; -$bo-danger-active: #b02a37; -$bo-danger-light: #f8d7da; -$bo-danger-dark: #842029; - -// Header Colors -$bo-header-bg: $bo-white; -$bo-header-border: $bo-gray-200; -$bo-header-shadow: 0 2px 4px rgba($bo-black, 0.08); - -// Navigation Colors -$bo-nav-text: $bo-gray-600; -$bo-nav-text-hover: $bo-primary; -$bo-nav-text-active: $bo-primary; -$bo-nav-bg-hover: $bo-gray-50; -$bo-nav-bg-active: $bo-primary-light; -$bo-nav-border-active: $bo-primary; - -// LARP Title Colors -$bo-title-color: $bo-gray-800; -$bo-title-icon-color: $bo-primary; - -// Border Radius -$bo-border-radius: 6px; -$bo-border-radius-sm: 4px; -$bo-border-radius-lg: 8px; -$bo-border-radius-xl: 10px; - -// Transitions -$bo-transition-smooth: all 0.2s ease; -$bo-transition-fast: all 0.15s ease; - // Main header container .larp-backoffice-header { - background: $bo-header-bg; - border-bottom: 2px solid $bo-header-border; - box-shadow: $bo-header-shadow; + background: var(--bo-header-bg); + border-bottom: 2px solid var(--bo-header-border); + box-shadow: var(--bo-header-shadow); margin-bottom: 1.5rem; padding: 1rem 0; @@ -81,23 +16,23 @@ $bo-transition-fast: all 0.15s ease; gap: 1rem; padding-bottom: 1rem; margin-bottom: 1rem; - border-bottom: 1px solid $bo-header-border; + border-bottom: 1px solid var(--bo-header-border); // Hamburger toggle button .navbar-toggler { padding: 0.5rem; - border: 1px solid $bo-header-border; - border-radius: $bo-border-radius; + border: 1px solid var(--bo-header-border); + border-radius: var(--bo-border-radius); background: transparent; - transition: $bo-transition-smooth; + transition: var(--bo-transition-smooth); &:hover { - background: $bo-nav-bg-hover; - border-color: $bo-nav-text-hover; + background: var(--bo-nav-bg-hover); + border-color: var(--bo-nav-text-hover); } &:focus { - box-shadow: 0 0 0 0.25rem rgba($bo-primary, 0.15); + box-shadow: 0 0 0 0.25rem rgba(var(--bo-primary), 0.15); } // Hide on desktop @@ -107,7 +42,7 @@ $bo-transition-fast: all 0.15s ease; } .larp-title { - color: $bo-title-color; + color: var(--bo-title-color); font-size: 1.75rem; font-weight: 600; margin: 0; @@ -116,7 +51,7 @@ $bo-transition-fast: all 0.15s ease; gap: 0.75rem; i { - color: $bo-title-icon-color; + color: var(--bo-title-icon-color); font-size: 2rem; } @@ -148,12 +83,12 @@ $bo-transition-fast: all 0.15s ease; .nav-item { .nav-link { - color: $bo-nav-text; + color: var(--bo-nav-text); padding: 0.625rem 1rem; - border-radius: $bo-border-radius; + border-radius: var(--bo-border-radius); font-weight: 500; font-size: 0.9375rem; - transition: $bo-transition-smooth; + transition: var(--bo-transition-smooth); display: flex; align-items: center; gap: 0.5rem; @@ -161,13 +96,13 @@ $bo-transition-fast: all 0.15s ease; i { font-size: 1.125rem; - transition: $bo-transition-smooth; + transition: var(--bo-transition-smooth); } // Hover state &:hover { - color: $bo-nav-text-hover; - background: $bo-nav-bg-hover; + color: var(--bo-nav-text-hover); + background: var(--bo-nav-bg-hover); i { transform: scale(1.1); @@ -176,10 +111,10 @@ $bo-transition-fast: all 0.15s ease; // Active state &.active { - color: $bo-nav-text-active; - background: $bo-nav-bg-active; + color: var(--bo-nav-text-active); + background: var(--bo-nav-bg-active); font-weight: 600; - border-bottom: 2px solid $bo-nav-border-active; + border-bottom: 2px solid var(--bo-nav-border-active); } } @@ -197,48 +132,48 @@ $bo-transition-fast: all 0.15s ease; } .dropdown-menu { - border: 1px solid $bo-header-border; - border-radius: $bo-border-radius-lg; - box-shadow: 0 4px 12px rgba($bo-black, 0.1); + border: 1px solid var(--bo-header-border); + border-radius: var(--bo-border-radius-lg); + box-shadow: 0 4px 12px rgba(var(--bo-black), 0.1); padding: 0.5rem; margin-top: 0.25rem; min-width: 240px; .dropdown-item { - border-radius: $bo-border-radius; + border-radius: var(--bo-border-radius); padding: 0.625rem 1rem; - transition: $bo-transition-smooth; + transition: var(--bo-transition-smooth); display: flex; align-items: center; gap: 0.75rem; - color: $bo-nav-text; + color: var(--bo-nav-text); font-size: 0.9375rem; i { - color: $bo-nav-text-hover; + color: var(--bo-nav-text-hover); width: 1.25rem; font-size: 1rem; text-align: center; } &:hover { - background: $bo-nav-bg-hover; - color: $bo-nav-text-hover; + background: var(--bo-nav-bg-hover); + color: var(--bo-nav-text-hover); transform: translateX(4px); } &:active { - background: $bo-nav-bg-active; + background: var(--bo-nav-bg-active); } } .dropdown-divider { margin: 0.5rem 0; - border-color: $bo-header-border; + border-color: var(--bo-header-border); } .dropdown-header { - color: $bo-gray-500; + color: var(--bo-gray-500); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; @@ -274,7 +209,7 @@ $bo-transition-fast: all 0.15s ease; .navbar-collapse { margin-top: 1rem; padding-top: 1rem; - border-top: 1px solid $bo-header-border; + border-top: 1px solid var(--bo-header-border); } .navbar-nav { diff --git a/assets/styles/components/_colors.scss b/assets/styles/components/_colors.scss new file mode 100644 index 0000000..168c507 --- /dev/null +++ b/assets/styles/components/_colors.scss @@ -0,0 +1,189 @@ +:root { + + // ============================================ + // BACKOFFICE COLOR VARIABLES + // ============================================ + + // Base Colors + --bo-white: #ffffff; + --bo-black: #000000; + --bo-gray-50: #f8f9fa; + --bo-gray-100: #e9ecef; + --bo-gray-200: #dee2e6; + --bo-gray-300: #ced4da; + --bo-gray-400: #adb5bd; + --bo-gray-500: #6c757d; + --bo-gray-600: #495057; + --bo-gray-700: #343a40; + --bo-gray-800: #212529; + + // Primary Colors (Bootstrap Blue) + --bo-primary: #0d6efd; + --bo-primary-hover: #0b5ed7; + --bo-primary-active: #0a58ca; + --bo-primary-light: #e7f1ff; + --bo-primary-dark: #084298; + + // Success Colors (Green) + --bo-success: #198754; + --bo-success-hover: #157347; + --bo-success-active: #146c43; + --bo-success-light: #d1e7dd; + --bo-success-dark: #0f5132; + + // Danger Colors (Red) + --bo-danger: #dc3545; + --bo-danger-hover: #bb2d3b; + --bo-danger-active: #b02a37; + --bo-danger-light: #f8d7da; + --bo-danger-dark: #842029; + + // Warning Colors (Yellow) + --bo-warning: #ffc107; + --bo-warning-hover: #ffca2c; + --bo-warning-active: #ffcd39; + --bo-warning-light: #fff3cd; + --bo-warning-dark: #997404; + + // Info Colors (Cyan) + --bo-info: #0dcaf0; + --bo-info-hover: #31d2f2; + --bo-info-active: #3dd5f3; + --bo-info-light: #cff4fc; + --bo-info-dark: #055160; + + // Header Colors + --bo-header-bg: var(--bo-white); + --bo-header-border: var(--bo-gray-200); + --bo-header-shadow: 0 2px 4px rgba(--bo-black, 0.08); + + // Navigation Colors + --bo-nav-text: var(--bo-gray-600); + --bo-nav-text-hover: var(--bo-primary); + --bo-nav-text-active: var(--bo-primary); + --bo-nav-bg-hover: var(--bo-gray-50); + --bo-nav-bg-active: var(--bo-primary-light); + --bo-nav-border-active: var(--bo-primary); + + // LARP Title Colors + --bo-title-color: var(--bo-gray-800); + --bo-title-icon-color: var(--bo-primary); + + // Border Radius + --bo-border-radius: 6px; + --bo-border-radius-sm: 4px; + --bo-border-radius-lg: 8px; + --bo-border-radius-xl: 10px; + + //--bo-border-radius: 8px; + //--bo-border-radius-sm: 6px; + //--bo-border-radius-lg: 10px; + //--bo-border-radius-xl: 12px; + + // Transitions + --bo-transition-smooth: all 0.2s ease; + --bo-transition-fast: all 0.15s ease; + --bo-transition-default: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + // Shadows + --bo-shadow-sm: 0 2px 4px rgba(var(--bo-black), 0.08); + --bo-shadow: 0 2px 8px rgba(var(--bo-black), 0.08); + --bo-shadow-lg: 0 4px 12px rgba(var(--bo-black), 0.12); + --bo-shadow-xl: 0 8px 24px rgba(var(--bo-black), 0.15); + + --bo-shadow-success: 0 4px 12px rgba(var(--bo-success), 0.3); + --bo-shadow-success-hover: 0 6px 16px rgba(var(--bo-success), 0.4); + --bo-shadow-danger: 0 4px 12px rgba(var(--bo-danger), 0.3); + + //////////////////////// + // Background colors + --bg-primary: #2d2d2d; + --bg-secondary: #f8f9fa; + --bg-tertiary: #f0f0f0; + --bg-card: #ffffff; + --bg-sidebar: linear-gradient(180deg, #2d2d2d 0%, #f8f9fa 100%); + --bg-sidebar-header: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + + // Text colors + --text-primary: #212529; + --text-secondary: #6c757d; + --text-muted: #8e8e93; + --text-inverse: #ffffff; + + // Border colors + --border-color: #dee2e6; + --border-color-light: #e0e0e0; + + // Component colors + --card-bg: #ffffff; + --modal-bg: #ffffff; + --table-bg: #ffffff; + --table-hover-bg: rgba(0, 0, 0, 0.05); + --table-striped-bg: rgba(0, 0, 0, 0.025); + + // Input colors + --input-bg: #ffffff; + --input-border: #ced4da; + --input-text: #212529; + --input-placeholder: #6c757d; + + // Shadow colors + --shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + --shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + --shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175); + + // Sidebar specific + --sidebar-text: #2d3748; + --sidebar-link-hover-bg: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%); + --sidebar-link-active-bg: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%); + --sidebar-scrollbar-track: rgba(0, 0, 0, 0.05); + --sidebar-scrollbar-thumb: rgba(102, 126, 234, 0.3); + --sidebar-scrollbar-thumb-hover: rgba(102, 126, 234, 0.5); +} + +// Dark mode variables +html.dark-mode { + // Background colors + --bg-primary: #1a1a1a; + --bg-secondary: #2f2f2f; + --bg-tertiary: #4a4a4a; + --bg-card: #2d2d2d; + --bg-sidebar: linear-gradient(180deg, #2d2d2d 0%, #1a1a1a 100%); + --bg-sidebar-header: linear-gradient(135deg, #5568d3 0%, #6a4c93 100%); + + // Text colors + --text-primary: #e8e8e8; + --text-secondary: #b8b8b8; + --text-muted: #8e8e93; + --text-inverse: #1a1a1a; + + // Border colors + --border-color: #404040; + --border-color-light: #4a4a4a; + + // Component colors + --card-bg: #2d2d2d; + --modal-bg: #2d2d2d; + --table-bg: #2d2d2d; + --table-hover-bg: rgba(255, 255, 255, 0.05); + --table-striped-bg: rgba(255, 255, 255, 0.025); + + // Input colors + --input-bg: #3a3a3a; + --input-border: #4a4a4a; + --input-text: #e8e8e8; + --input-placeholder: #8e8e93; + + // Shadow colors + --shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.3); + --shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.4); + --shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.5); + + // Sidebar specific + --sidebar-text: #e8e8e8; + --sidebar-link-hover-bg: linear-gradient(135deg, rgba(85, 104, 211, 0.15) 0%, rgba(106, 76, 147, 0.15) 100%); + --sidebar-link-active-bg: linear-gradient(135deg, rgba(85, 104, 211, 0.25) 0%, rgba(106, 76, 147, 0.25) 100%); + --sidebar-scrollbar-track: rgba(255, 255, 255, 0.05); + --sidebar-scrollbar-thumb: rgba(85, 104, 211, 0.4); + --sidebar-scrollbar-thumb-hover: rgba(85, 104, 211, 0.6); +} \ No newline at end of file diff --git a/assets/styles/components/_dark_mode.scss b/assets/styles/components/_dark_mode.scss index 942411e..221cce2 100644 --- a/assets/styles/components/_dark_mode.scss +++ b/assets/styles/components/_dark_mode.scss @@ -1,101 +1,125 @@ -// Dark Mode Variables and Styles - -// Light mode variables (default) -:root { - // Background colors - --bg-primary: #ffffff; - --bg-secondary: #f8f9fa; - --bg-tertiary: #f0f0f0; - --bg-card: #ffffff; - --bg-sidebar: linear-gradient(180deg, #ffffff 0%, #f8f9fa 100%); - --bg-sidebar-header: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +// Apply dark mode styles to various components +html.dark-mode { + .larp-backoffice-header{ + background: var(--bg-tertiary); + border-bottom: 2px solid #0d6efd; + } - // Text colors - --text-primary: #212529; - --text-secondary: #6c757d; - --text-muted: #8e8e93; - --text-inverse: #ffffff; - - // Border colors - --border-color: #dee2e6; - --border-color-light: #e0e0e0; - - // Component colors - --card-bg: #ffffff; - --modal-bg: #ffffff; - --table-bg: #ffffff; - --table-hover-bg: rgba(0, 0, 0, 0.05); - --table-striped-bg: rgba(0, 0, 0, 0.025); - - // Input colors - --input-bg: #ffffff; - --input-border: #ced4da; - --input-text: #212529; - --input-placeholder: #6c757d; - - // Shadow colors - --shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); - --shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); - --shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175); - - // Sidebar specific - --sidebar-text: #2d3748; - --sidebar-link-hover-bg: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%); - --sidebar-link-active-bg: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%); - --sidebar-scrollbar-track: rgba(0, 0, 0, 0.05); - --sidebar-scrollbar-thumb: rgba(102, 126, 234, 0.3); - --sidebar-scrollbar-thumb-hover: rgba(102, 126, 234, 0.5); -} + .larp-header-title { + border-bottom: 1px solid var(--bo-header-border); -// Dark mode variables -html.dark-mode { - // Background colors - --bg-primary: #1a1a1a; - --bg-secondary: #2d2d2d; - --bg-tertiary: #3a3a3a; - --bg-card: #2d2d2d; - --bg-sidebar: linear-gradient(180deg, #2d2d2d 0%, #1a1a1a 100%); - --bg-sidebar-header: linear-gradient(135deg, #5568d3 0%, #6a4c93 100%); + // Hamburger toggle button + .navbar-toggler { + border: 1px solid var(--bo-header-border); + border-radius: var(--bo-border-radius); + background: transparent; + transition: var(--bo-transition-smooth); - // Text colors - --text-primary: #e8e8e8; - --text-secondary: #b8b8b8; - --text-muted: #8e8e93; - --text-inverse: #1a1a1a; - - // Border colors - --border-color: #404040; - --border-color-light: #4a4a4a; - - // Component colors - --card-bg: #2d2d2d; - --modal-bg: #2d2d2d; - --table-bg: #2d2d2d; - --table-hover-bg: rgba(255, 255, 255, 0.05); - --table-striped-bg: rgba(255, 255, 255, 0.025); - - // Input colors - --input-bg: #3a3a3a; - --input-border: #4a4a4a; - --input-text: #e8e8e8; - --input-placeholder: #8e8e93; - - // Shadow colors - --shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.3); - --shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.4); - --shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.5); - - // Sidebar specific - --sidebar-text: #e8e8e8; - --sidebar-link-hover-bg: linear-gradient(135deg, rgba(85, 104, 211, 0.15) 0%, rgba(106, 76, 147, 0.15) 100%); - --sidebar-link-active-bg: linear-gradient(135deg, rgba(85, 104, 211, 0.25) 0%, rgba(106, 76, 147, 0.25) 100%); - --sidebar-scrollbar-track: rgba(255, 255, 255, 0.05); - --sidebar-scrollbar-thumb: rgba(85, 104, 211, 0.4); - --sidebar-scrollbar-thumb-hover: rgba(85, 104, 211, 0.6); -} + &:hover { + background: var(--bo-nav-bg-hover); + border-color: var(--bo-nav-text-hover); + } + + &:focus { + box-shadow: 0 0 0 0.25rem rgba(var(--bo-primary), 0.15); + } + } + + .larp-title { + color: var(--bo-title-color); + font-size: 1.75rem; + font-weight: 600; + margin: 0; + display: flex; + align-items: center; + gap: 0.75rem; + + i { + color: var(--bo-title-icon-color); + font-size: 2rem; + } + + span { + line-height: 1.2; + } + } + + // Preview button + .btn-outline-primary { + white-space: nowrap; + display: inline-flex; + align-items: center; + gap: 0.5rem; + } + } + + // Navigation Bar + .larp-navbar { + + .navbar-nav { + + .nav-item { + .nav-link { + color: var(--bo-nav-text); + border-radius: var(--bo-border-radius); + transition: var(--bo-transition-smooth); + i { + transition: var(--bo-transition-smooth); + } + // Hover state + &:hover { + color: var(--bo-nav-text-hover); + background: var(--bo-nav-bg-hover); + } + + // Active state + &.active { + color: var(--bo-nav-text-active); + background: var(--bo-nav-bg-active); + border-bottom: 2px solid var(--bo-nav-border-active); + } + } + + // Dropdown styling + &.dropdown { + + .dropdown-menu { + border: 1px solid var(--bo-header-border); + border-radius: var(--bo-border-radius-lg); + box-shadow: 0 4px 12px rgba(var(--bo-black), 0.1); + + .dropdown-item { + border-radius: var(--bo-border-radius); + transition: var(--bo-transition-smooth); + color: var(--bo-nav-text); + + i { + color: var(--bo-nav-text-hover); + } + + &:hover { + background: var(--bo-nav-bg-hover); + color: var(--bo-nav-text-hover); + } + + &:active { + background: var(--bo-nav-bg-active); + } + } + + .dropdown-divider { + border-color: var(--bo-header-border); + } + + .dropdown-header { + color: var(--bo-gray-500); + } + } + } + } + } + } -// Apply dark mode styles to various components -html.dark-mode { body { background-color: var(--bg-primary); color: var(--text-primary); @@ -117,6 +141,7 @@ html.dark-mode { background-color: var(--bg-secondary); border-color: var(--border-color); color: var(--text-primary); + background: var(--bs-primary-rgb); } .card-body { @@ -306,3 +331,12 @@ html.dark-mode { outline-offset: 2px; } } + +.card { + + + .card-header { + border-bottom: 1px solid rgba(var(--bo-black), 0.08); + background: linear-gradient(180deg, #2d2d2d 0%, var(--bo-gray-50) 100%); + } +} \ No newline at end of file diff --git a/assets/styles/components/_sidebar_menu.scss b/assets/styles/components/_sidebar_menu.scss index 76cc3c2..bd27bf1 100755 --- a/assets/styles/components/_sidebar_menu.scss +++ b/assets/styles/components/_sidebar_menu.scss @@ -75,8 +75,8 @@ $desktop-min: 769px; right: 0; width: $sidebar-width-mobile; height: 100%; - background: var(--bg-sidebar, $sidebar-bg); - border-left: 1px solid var(--border-color-light, $sidebar-border-color); + background: $sidebar-bg; + border-left: 1px solid $sidebar-border-color; overflow-y: auto; overflow-x: hidden; z-index: 1050; @@ -90,15 +90,15 @@ $desktop-min: 769px; } &::-webkit-scrollbar-track { - background: var(--sidebar-scrollbar-track, rgba(0, 0, 0, 0.05)); + background: rgba(0, 0, 0, 0.05); } &::-webkit-scrollbar-thumb { - background: var(--sidebar-scrollbar-thumb, rgba(102, 126, 234, 0.3)); + background: rgba(102, 126, 234, 0.3); border-radius: 4px; &:hover { - background: var(--sidebar-scrollbar-thumb-hover, rgba(102, 126, 234, 0.5)); + background: rgba(102, 126, 234, 0.5); } } @@ -111,7 +111,7 @@ $desktop-min: 769px; // Sidebar header .sidebar-header { padding: 1.5rem 1.25rem; - background: var(--bg-sidebar-header, linear-gradient(135deg, #667eea 0%, #764ba2 100%)); + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; justify-content: space-between; align-items: center; @@ -220,7 +220,7 @@ $desktop-min: 769px; // Enhanced menu items styling .sidebar-body { .nav-link { - color: var(--sidebar-text, #2d3748); + color: #2d3748; padding: 0.875rem 1rem; margin-bottom: 0.375rem; border-radius: 10px; @@ -247,7 +247,7 @@ $desktop-min: 769px; } &:hover { - background: var(--sidebar-link-hover-bg, linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%)); + background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%); color: #667eea; transform: translateX(4px); padding-left: 1.125rem; @@ -258,7 +258,7 @@ $desktop-min: 769px; } &.active { - background: var(--sidebar-link-active-bg, linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%)); + background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%); color: #667eea; font-weight: 600; box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15); diff --git a/composer.json b/composer.json index 64c875d..7ca36c5 100755 --- a/composer.json +++ b/composer.json @@ -85,7 +85,7 @@ }, "autoload-dev": { "psr-4": { - "App\\Tests\\": "tests/" + "Tests\\": "tests/" } }, "replace": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist deleted file mode 100755 index caf62a6..0000000 --- a/phpunit.xml.dist +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - tests - - - tests/Functional - - - tests/Integration - - - tests/Domain - - - - - - src - - - - - - - - - - - diff --git a/public/index_test.php b/public/index_test.php new file mode 100644 index 0000000..15f67ff --- /dev/null +++ b/public/index_test.php @@ -0,0 +1,9 @@ +isCsrfTokenValid('approve' . $location->getId(), $request->request->get('_token'))) { $this->addFlash('error', 'Invalid CSRF token.'); - return $this->redirectToRoute('backoffice_location_pending_list'); + return $this->redirectToRoute('backoffice_location_list'); } /** @var User $user */ @@ -184,7 +184,7 @@ public function approve(Request $request, Location $location): Response $this->approvalService->approve($location, $user); $this->addFlash('success', sprintf('Location "%s" has been approved.', $location->getTitle())); - return $this->redirectToRoute('backoffice_location_pending_list'); + return $this->redirectToRoute('backoffice_location_list'); } #[Route('/{id}/reject', name: 'reject', methods: ['GET', 'POST'])] @@ -202,7 +202,7 @@ public function reject(Request $request, Location $location): Response $this->approvalService->reject($location, $user, $reason); $this->addFlash('success', sprintf('Location "%s" has been rejected.', $location->getTitle())); - return $this->redirectToRoute('backoffice_location_pending_list'); + return $this->redirectToRoute('backoffice_location_list'); } return $this->render('backoffice/location/reject.html.twig', [ diff --git a/src/Domain/Core/Controller/Backoffice/ParticipantController.php b/src/Domain/Core/Controller/Backoffice/ParticipantController.php index 04a87d8..90b760a 100755 --- a/src/Domain/Core/Controller/Backoffice/ParticipantController.php +++ b/src/Domain/Core/Controller/Backoffice/ParticipantController.php @@ -72,6 +72,29 @@ public function delete( LarpParticipantRepository $participantRepository, LarpParticipant $participant, ): Response { + // Check authorization - only organizers can delete participants + $this->denyAccessUnlessGranted('DELETE_LARP_PARTICIPANT', $participant); + + $currentUser = $this->getUser(); + + // Check if the user is trying to delete themselves + if ($currentUser && $participant->getUser()->getId() === $currentUser->getId()) { + // Check if this participant is an organizer + if ($participant->isAdmin()) { + // Count total organizers for this LARP + $organizerCount = $participantRepository->countMainOrganizersForLarp($larp); + + // Prevent deletion if this is the last organizer + if ($organizerCount <= 1) { + $this->addFlash('danger', $this->translator->trans('larp.participant.error.cannot_delete_last_organizer')); + + return $this->redirectToRoute('backoffice_larp_participant_list', [ + 'larp' => $larp->getId(), + ]); + } + } + } + $participantRepository->remove($participant); $this->addFlash('success', $this->translator->trans('success_delete')); diff --git a/src/Domain/Core/Controller/Public/LocationController.php b/src/Domain/Core/Controller/Public/LocationController.php index b59939d..5899cf7 100755 --- a/src/Domain/Core/Controller/Public/LocationController.php +++ b/src/Domain/Core/Controller/Public/LocationController.php @@ -16,48 +16,8 @@ class LocationController extends BaseController #[Route('/locations', name: 'list', methods: ['GET'])] public function list(Request $request, LocationRepository $locationRepository): Response { - // Get distinct cities and countries for filter dropdowns (only approved locations) - $cities = $locationRepository->createQueryBuilder('l') - ->select('DISTINCT l.city') - ->where('l.isPublic = :isPublic') - ->andWhere('l.isActive = :isActive') - ->andWhere('l.approvalStatus = :approved') - ->andWhere('l.city IS NOT NULL') - ->setParameter('isPublic', true) - ->setParameter('isActive', true) - ->setParameter('approved', LocationApprovalStatus::APPROVED) - ->orderBy('l.city', 'ASC') - ->getQuery() - ->getResult(); - - $countries = $locationRepository->createQueryBuilder('l') - ->select('DISTINCT l.country') - ->where('l.isPublic = :isPublic') - ->andWhere('l.isActive = :isActive') - ->andWhere('l.approvalStatus = :approved') - ->andWhere('l.country IS NOT NULL') - ->setParameter('isPublic', true) - ->setParameter('isActive', true) - ->setParameter('approved', LocationApprovalStatus::APPROVED) - ->orderBy('l.country', 'ASC') - ->getQuery() - ->getResult(); - - // Transform to choice array format - $cityChoices = array_combine( - array_column($cities, 'city'), - array_column($cities, 'city') - ); - $countryChoices = array_combine( - array_column($countries, 'country'), - array_column($countries, 'country') - ); - // Create and handle filter form - $filterForm = $this->createForm(LocationPublicFilterType::class, null, [ - 'cities' => $cityChoices, - 'countries' => $countryChoices, - ]); + $filterForm = $this->createForm(LocationPublicFilterType::class); $filterForm->handleRequest($request); // Build query (only show approved locations) diff --git a/src/Domain/Core/Form/Filter/LarpPublicFilterType.php b/src/Domain/Core/Form/Filter/LarpPublicFilterType.php index 2d3a0b4..70b77aa 100755 --- a/src/Domain/Core/Form/Filter/LarpPublicFilterType.php +++ b/src/Domain/Core/Form/Filter/LarpPublicFilterType.php @@ -6,7 +6,10 @@ use App\Domain\Core\Entity\Enum\LarpSetting; use App\Domain\Core\Entity\Enum\LarpStageStatus; use App\Domain\Core\Entity\Enum\LarpType; +use App\Domain\Core\Entity\Enum\LocationApprovalStatus; use App\Domain\Core\Entity\Location; +use App\Domain\Core\Repository\LocationRepository; +use Doctrine\ORM\QueryBuilder; use Spiriit\Bundle\FormFilterBundle\Filter\Form\Type as Filters; use Spiriit\Bundle\FormFilterBundle\Filter\Query\QueryInterface; use Symfony\Bridge\Doctrine\Form\Type\EntityType; @@ -65,7 +68,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'placeholder' => 'choose', 'required' => false, 'autocomplete' => true, - 'attr' => ['class' => 'form-select'] + 'attr' => ['class' => 'form-select'], + 'query_builder' => fn (LocationRepository $repo): QueryBuilder => $repo->createQueryBuilder('l') + ->where('l.isActive = true') + ->andWhere('l.isPublic = true') + ->andWhere('l.approvalStatus = :status') + ->setParameter('status', LocationApprovalStatus::APPROVED->value) ]) ->add('startDate', Filters\DateFilterType::class, [ 'required' => false, @@ -82,8 +90,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'attr' => [ 'class' => 'form-control', 'min' => 1, + 'max' => 30, 'placeholder' => 'Min days' ], + 'html5' => true, 'apply_filter' => function (QueryInterface $filterQuery, $field, $values) { if (empty($values['value'])) { return null; @@ -100,8 +110,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'attr' => [ 'class' => 'form-control', 'min' => 1, + 'max' => 31, 'placeholder' => 'Max days' ], + 'html5' => true, 'apply_filter' => function (QueryInterface $filterQuery, $field, $values) { if (empty($values['value'])) { return null; diff --git a/src/Domain/Core/Form/Filter/LocationPublicFilterType.php b/src/Domain/Core/Form/Filter/LocationPublicFilterType.php index f297570..e6c5e47 100644 --- a/src/Domain/Core/Form/Filter/LocationPublicFilterType.php +++ b/src/Domain/Core/Form/Filter/LocationPublicFilterType.php @@ -2,7 +2,10 @@ namespace App\Domain\Core\Form\Filter; +use App\Domain\Core\Entity\Enum\LocationApprovalStatus; +use App\Domain\Core\Repository\LocationRepository; use Spiriit\Bundle\FormFilterBundle\Filter\Form\Type as Filters; +use Spiriit\Bundle\FormFilterBundle\Filter\Query\QueryInterface; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormBuilderInterface; @@ -10,29 +13,87 @@ class LocationPublicFilterType extends AbstractType { + public function __construct(private LocationRepository $locationRepository) + { + } + public function buildForm(FormBuilderInterface $builder, array $options): void { + // Get distinct cities and countries for filter dropdowns (only approved locations) + $cities = $this->locationRepository->createQueryBuilder('l') + ->select('DISTINCT l.city') + ->where('l.isPublic = :isPublic') + ->andWhere('l.isActive = :isActive') + ->andWhere('l.approvalStatus = :approved') + ->andWhere('l.city IS NOT NULL') + ->setParameter('isPublic', true) + ->setParameter('isActive', true) + ->setParameter('approved', LocationApprovalStatus::APPROVED) + ->orderBy('l.city', 'ASC') + ->getQuery() + ->getResult(); + + $countries = $this->locationRepository->createQueryBuilder('l') + ->select('DISTINCT l.country') + ->where('l.isPublic = :isPublic') + ->andWhere('l.isActive = :isActive') + ->andWhere('l.approvalStatus = :approved') + ->andWhere('l.country IS NOT NULL') + ->setParameter('isPublic', true) + ->setParameter('isActive', true) + ->setParameter('approved', LocationApprovalStatus::APPROVED) + ->orderBy('l.country', 'ASC') + ->getQuery() + ->getResult(); + + // Transform to choice array format + $cityChoices = array_combine( + array_column($cities, 'city'), + array_column($cities, 'city') + ); + $countryChoices = array_combine( + array_column($countries, 'country'), + array_column($countries, 'country') + ); + + $builder - ->add('search', Filters\TextFilterType::class, [ + ->add('title', Filters\TextFilterType::class, [ 'required' => false, 'label' => 'Search', 'attr' => [ 'class' => 'form-control', - 'placeholder' => 'Search by name...', + 'placeholder' => 'Search by name, city or country...', ], -// 'condition_pattern' => Filters\TextFilterType::PATTERN_CONTAINS, + 'apply_filter' => function (QueryInterface $filterQuery, $field, $values) { + if (empty($values['value'])) { + return null; + } + $qb = $filterQuery->getQueryBuilder(); + $searchTerm = strtolower($values['value']); + + $qb->andWhere( + $qb->expr()->orX( + $qb->expr()->like('LOWER(l.title)', ':searchTerm'), + $qb->expr()->like('LOWER(l.city)', ':searchTerm'), + $qb->expr()->like('LOWER(l.country)', ':searchTerm') + ) + )->setParameter('searchTerm', '%' . $searchTerm . '%'); + + return null; + }, ]) ->add('city', ChoiceType::class, [ 'required' => false, 'placeholder' => 'All Cities', 'attr' => ['class' => 'form-select'], - 'choices' => $options['cities'] ?? [], + 'choices' => $cityChoices, ]) ->add('country', ChoiceType::class, [ 'required' => false, 'placeholder' => 'All Countries', 'attr' => ['class' => 'form-select'], - 'choices' => $options['countries'] ?? [], + 'choices' => $countryChoices, ]) ->add('minCapacity', Filters\NumberFilterType::class, [ 'required' => false, @@ -40,8 +101,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'attr' => [ 'class' => 'form-control', 'min' => 1, + 'max' => 9000, 'placeholder' => 'Min', ], + 'html5' => true ]) ->add('maxCapacity', Filters\NumberFilterType::class, [ 'required' => false, @@ -49,8 +112,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'attr' => [ 'class' => 'form-control', 'min' => 1, + 'max' => 9000, 'placeholder' => 'Max', ], + 'html5' => true ]); } @@ -61,8 +126,6 @@ public function configureOptions(OptionsResolver $resolver): void 'validation_groups' => false, 'method' => 'GET', 'translation_domain' => 'forms', - 'cities' => [], - 'countries' => [], ]); } } diff --git a/src/Domain/Core/Form/Filter/ParticipantFilterType.php b/src/Domain/Core/Form/Filter/ParticipantFilterType.php index 5407b2f..5d1564d 100755 --- a/src/Domain/Core/Form/Filter/ParticipantFilterType.php +++ b/src/Domain/Core/Form/Filter/ParticipantFilterType.php @@ -30,6 +30,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'choice_value' => fn (?ParticipantRole $role) => $role?->value, 'autocomplete' => true, 'multiple' => true, + 'required' => false, 'apply_filter' => static function (QueryInterface $filterQuery, $field, $values) { $roles = $values['value'] ?? []; if (!is_array($roles)) { @@ -37,7 +38,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void } $parameters = []; $expression = $filterQuery->getExpr()->andX(); - /** @var \App\Domain\Account\Entity\Enum\\App\Domain\Core\Entity\Enum\ParticipantRole $role */ + /** @var ParticipantRole $role */ foreach ($roles as $i => $role) { $expression->add("JSONB_EXISTS($field, :role_$i) = true"); $parameters["role_$i"] = $role->value; diff --git a/src/Domain/Core/Form/LarpPropertiesType.php b/src/Domain/Core/Form/LarpPropertiesType.php index bae3913..4e9b800 100755 --- a/src/Domain/Core/Form/LarpPropertiesType.php +++ b/src/Domain/Core/Form/LarpPropertiesType.php @@ -68,7 +68,8 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'help' => 'How players apply for this LARP', 'attr' => [ 'class' => 'form-check', - 'data-controller' => 'application-mode-toggle' + 'data-controller' => 'application-mode-toggle', + 'data-action' => 'change->application-mode-toggle#change' ] ]) ->add('publishCharactersPublicly', CheckboxType::class, [ diff --git a/src/Domain/Core/Form/LocationType.php b/src/Domain/Core/Form/LocationType.php index 2c6bb17..09f96dd 100755 --- a/src/Domain/Core/Form/LocationType.php +++ b/src/Domain/Core/Form/LocationType.php @@ -51,11 +51,13 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ->add('latitude', NumberType::class, [ 'required' => false, 'scale' => 8, + 'html5' => true, 'attr' => ['class' => 'form-control', 'step' => 0.00000001] ]) ->add('longitude', NumberType::class, [ 'required' => false, 'scale' => 8, + 'html5' => true, 'attr' => ['class' => 'form-control', 'step' => 0.00000001] ]) ->add('website', UrlType::class, [ @@ -100,7 +102,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]) ->add('capacity', IntegerType::class, [ 'required' => false, - 'attr' => ['class' => 'form-control'] + 'attr' => [ + 'class' => 'form-control', + 'min' => 1, + 'max' => 9000, + ], +// 'html5' => true, ]) ->add('isPublic', CheckboxType::class, [ 'required' => false, diff --git a/src/Domain/Core/Repository/LarpParticipantRepository.php b/src/Domain/Core/Repository/LarpParticipantRepository.php index 74db618..97ef34c 100755 --- a/src/Domain/Core/Repository/LarpParticipantRepository.php +++ b/src/Domain/Core/Repository/LarpParticipantRepository.php @@ -3,6 +3,8 @@ namespace App\Domain\Core\Repository; use App\Domain\Account\Entity\User; +use App\Domain\Core\Entity\Enum\ParticipantRole; +use App\Domain\Core\Entity\Larp; use App\Domain\Core\Entity\LarpParticipant; use Doctrine\Persistence\ManagerRegistry; @@ -38,4 +40,20 @@ public function findForUserWithCharacters(User $user): array ->getQuery() ->getResult(); } + + /** + * Count the number of organizers for a given LARP + */ + public function countMainOrganizersForLarp(Larp $larp): int + { + $qb = $this->createQueryBuilder('p') + ->select('COUNT(DISTINCT p.id)') + ->where('p.larp = :larp') + ->setParameter('larp', $larp); + + $qb->andWhere("JSONB_EXISTS(p.roles, :role) = true"); + $qb->setParameter("role", ParticipantRole::ORGANIZER->value); + + return (int) $qb->getQuery()->getSingleScalarResult(); + } } diff --git a/src/Domain/Core/Security/Voter/LarpParticipantsVoter.php b/src/Domain/Core/Security/Voter/LarpParticipantsVoter.php index 4bf179e..26ea823 100644 --- a/src/Domain/Core/Security/Voter/LarpParticipantsVoter.php +++ b/src/Domain/Core/Security/Voter/LarpParticipantsVoter.php @@ -11,14 +11,23 @@ class LarpParticipantsVoter extends Voter { public const VIEW = 'VIEW_BO_LARP_PARTICIPANTS'; + public const DELETE = 'DELETE_LARP_PARTICIPANT'; protected function supports(string $attribute, $subject): bool { - return $attribute === self::VIEW && $subject instanceof Larp; + if ($attribute === self::VIEW) { + return $subject instanceof Larp; + } + + if ($attribute === self::DELETE) { + return $subject instanceof LarpParticipant; + } + + return false; } /** - * @param Larp $subject + * @param Larp|LarpParticipant $subject */ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool { @@ -27,9 +36,42 @@ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $ return false; } - $participants = $subject->getParticipants(); - /** @var LarpParticipant|null $userOrganizer */ - $userOrganizer = $participants->filter(fn (LarpParticipant $participant): bool => $participant->getUser()->getId() === $user->getId() && $participant->isAdmin())->first(); - return $userOrganizer !== null; + return match ($attribute) { + self::VIEW => $this->canView($subject, $user), + self::DELETE => $this->canDelete($subject, $user), + default => false, + }; + } + + private function canView(Larp $larp, User $user): bool + { + $participants = $larp->getParticipants(); + /** @var LarpParticipant|null $userParticipant */ + $userParticipant = $participants->filter( + fn (LarpParticipant $participant): bool => + $participant->getUser()->getId() === $user->getId() && $participant->isOrganizer() + )->first(); + + return $userParticipant !== null; + } + + private function canDelete(LarpParticipant $participant, User $user): bool + { + $larp = $participant->getLarp(); + $participants = $larp->getParticipants(); + + // Find the current user's participant record + /** @var LarpParticipant|null $userParticipant */ + $userParticipant = $participants->filter( + fn (LarpParticipant $p): bool => $p->getUser()->getId() === $user->getId() + )->first(); + + // User must be a participant + if ($userParticipant === null) { + return false; + } + + // Only organizers can delete participants + return $userParticipant->isAdmin(); } } diff --git a/src/Domain/EventPlanning/Form/PlanningResourceType.php b/src/Domain/EventPlanning/Form/PlanningResourceType.php index 434c152..41f906b 100755 --- a/src/Domain/EventPlanning/Form/PlanningResourceType.php +++ b/src/Domain/EventPlanning/Form/PlanningResourceType.php @@ -12,7 +12,7 @@ use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\EnumType; -use Symfony\Component\Form\Extension\Core\Type\IntegerType; +use Symfony\Component\Form\Extension\Core\Type\NumberType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; @@ -40,7 +40,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'attr' => ['rows' => 3], ]) - ->add('quantity', IntegerType::class, [ + ->add('quantity', NumberType::class, [ 'label' => 'planning_resource.quantity', 'attr' => ['min' => 1, 'max' => 99999], ]) diff --git a/src/Domain/Gallery/Form/Filter/GalleryFilterType.php b/src/Domain/Gallery/Form/Filter/GalleryFilterType.php index 5b562bb..7b065a5 100644 --- a/src/Domain/Gallery/Form/Filter/GalleryFilterType.php +++ b/src/Domain/Gallery/Form/Filter/GalleryFilterType.php @@ -31,7 +31,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'choice_label' => fn (LarpParticipant $participant): string => $participant->getUser()->getUsername(), 'required' => false, - 'placeholder' => 'common.all', + 'placeholder' => 'all', 'autocomplete' => true, 'query_builder' => function (LarpParticipantRepository $repo) use ($larp) { $qb = $repo->createQueryBuilder('p') @@ -49,7 +49,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'class' => GalleryVisibility::class, 'required' => false, 'multiple' => false, - 'placeholder' => 'common.all', + 'placeholder' => 'all', ]); } diff --git a/src/Domain/Mailing/Form/MailTemplateFilterType.php b/src/Domain/Mailing/Form/MailTemplateFilterType.php index 4d1e616..5cdcc8a 100644 --- a/src/Domain/Mailing/Form/MailTemplateFilterType.php +++ b/src/Domain/Mailing/Form/MailTemplateFilterType.php @@ -17,6 +17,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ->add('name', Filters\TextFilterType::class, [ 'condition_pattern' => FilterOperands::STRING_CONTAINS, 'label' => 'name', + 'required' => false, ]) ->add('type', Filters\EnumFilterType::class, [ 'class' => MailTemplateType::class, @@ -27,6 +28,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]) ->add('enabled', Filters\BooleanFilterType::class, [ 'label' => 'mail_template.enabled', + 'translation_domain' => 'forms', 'required' => false, ]); } diff --git a/src/Domain/Mailing/Repository/MailTemplateRepository.php b/src/Domain/Mailing/Repository/MailTemplateRepository.php index 7254c4c..5d5dd2d 100644 --- a/src/Domain/Mailing/Repository/MailTemplateRepository.php +++ b/src/Domain/Mailing/Repository/MailTemplateRepository.php @@ -3,44 +3,21 @@ namespace App\Domain\Mailing\Repository; use App\Domain\Core\Entity\Larp; +use App\Domain\Core\Repository\BaseRepository; use App\Domain\Mailing\Entity\Enum\MailTemplateType; use App\Domain\Mailing\Entity\MailTemplate; -use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; /** - * @extends ServiceEntityRepository + * @extends BaseRepository */ -class MailTemplateRepository extends ServiceEntityRepository +class MailTemplateRepository extends BaseRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, MailTemplate::class); } - public function save(MailTemplate $template, bool $flush = false): void - { - $this->_em->persist($template); - - if ($flush) { - $this->_em->flush(); - } - } - - public function remove(MailTemplate $template, bool $flush = false): void - { - $this->_em->remove($template); - - if ($flush) { - $this->_em->flush(); - } - } - - public function flush(): void - { - $this->_em->flush(); - } - public function findOneByLarpAndType(Larp $larp, MailTemplateType $type): ?MailTemplate { return $this->findOneBy([ diff --git a/src/Domain/Public/Controller/CharacterGalleryController.php b/src/Domain/Public/Controller/CharacterGalleryController.php new file mode 100644 index 0000000..639af1e --- /dev/null +++ b/src/Domain/Public/Controller/CharacterGalleryController.php @@ -0,0 +1,94 @@ +getPublishCharactersPublicly()) { + throw $this->createAccessDeniedException('Characters are not publicly available for this LARP.'); + } + + // Check LARP is visible + if (!$larp->getStatus()?->isVisibleForEveryone()) { + throw $this->createAccessDeniedException('This LARP is not publicly visible.'); + } + + // Build base query - only show Player characters or those available for recruitment + $qb = $characterRepository->createQueryBuilder('c') + ->where('c.larp = :larp') + ->andWhere('(c.characterType = :playerType OR c.availableForRecruitment = true)') + ->setParameter('larp', $larp) + ->setParameter('playerType', CharacterType::Player); + + // Apply filters + $filterForm = $this->createForm(CharacterGalleryFilterType::class, null, ['larp' => $larp]); + $filterForm->handleRequest($request); + $this->filterBuilderUpdater->addFilterConditions($filterForm, $qb); + + // Get characters + $characters = $qb->getQuery()->getResult(); + + return $this->render('public/character/gallery.html.twig', [ + 'larp' => $larp, + 'characters' => $characters, + 'filterForm' => $filterForm->createView(), + ]); + } + + #[Route('/{id}', name: 'show', methods: ['GET'])] + public function show( + Larp $larp, + Character $character + ): Response { + // Check if characters are published publicly + if (!$larp->getPublishCharactersPublicly()) { + throw $this->createAccessDeniedException('Characters are not publicly available for this LARP.'); + } + + // Check LARP is visible + if (!$larp->getStatus()?->isVisibleForEveryone()) { + throw $this->createAccessDeniedException('This LARP is not publicly visible.'); + } + + // Verify character belongs to this LARP + if ($character->getLarp() !== $larp) { + throw $this->createNotFoundException('Character not found for this LARP.'); + } + + // Verify character is eligible for public viewing + if ($character->getCharacterType() !== CharacterType::Player && !$character->getAvailableForRecruitment()) { + throw $this->createAccessDeniedException('This character is not publicly available.'); + } + + return $this->render('public/character/show.html.twig', [ + 'larp' => $larp, + 'character' => $character, + ]); + } +} diff --git a/src/Domain/Public/Form/Filter/CharacterGalleryFilterType.php b/src/Domain/Public/Form/Filter/CharacterGalleryFilterType.php new file mode 100644 index 0000000..c712b41 --- /dev/null +++ b/src/Domain/Public/Form/Filter/CharacterGalleryFilterType.php @@ -0,0 +1,89 @@ +add('search', TextType::class, [ + 'label' => 'Search', + 'required' => false, + 'attr' => [ + 'placeholder' => 'Search by name or description...', + 'class' => 'form-control', + ], + 'apply_filter' => function (QueryInterface $filterQuery, $field, $values) { + if (empty($values['value'])) { + return null; + } + + $qb = $filterQuery->getQueryBuilder(); + $qb->andWhere('(c.title LIKE :search OR c.description LIKE :search)') + ->setParameter('search', '%' . $values['value'] . '%'); + + return null; + }, + ]) + ->add('tags', EntityType::class, [ + 'class' => Tag::class, + 'label' => 'Tags', + 'required' => false, + 'multiple' => true, + 'autocomplete' => true, + 'query_builder' => fn ($repo) => $repo->createQueryBuilder('t') + ->where('t.larp = :larp') + ->setParameter('larp', $larp) + ->orderBy('t.title', 'ASC'), + 'attr' => ['class' => 'form-select'], + 'apply_filter' => function (QueryInterface $filterQuery, $field, $values) { + if (empty($values['value'])) { + return null; + } + + $qb = $filterQuery->getQueryBuilder(); + $qb->join('c.tags', 't') + ->andWhere('t IN (:tags)') + ->setParameter('tags', $values['value']); + + return null; + }, + ]) + ->add('gender', EnumType::class, [ + 'class' => Gender::class, + 'label' => 'Gender', + 'required' => false, + 'placeholder' => 'Any gender', + 'choice_label' => fn (Gender $gender): string => $gender->getLabel(), + 'attr' => ['class' => 'form-select'], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'csrf_protection' => false, + 'validation_groups' => ['filtering'], + 'method' => 'GET', + 'translation_domain' => 'forms', + 'larp' => null, + ]); + $resolver->setRequired('larp'); + } +} diff --git a/src/Domain/Survey/Controller/Backoffice/SurveyController.php b/src/Domain/Survey/Controller/Backoffice/SurveyController.php new file mode 100644 index 0000000..1b31289 --- /dev/null +++ b/src/Domain/Survey/Controller/Backoffice/SurveyController.php @@ -0,0 +1,87 @@ +getSurvey(); + + if (!$survey instanceof Survey) { + // Create from template if doesn't exist + $survey = $this->templateService->createSurveyFromTemplate($larp); + } + + $form = $this->createForm(SurveyType::class, $survey); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var Survey $survey */ + $survey = $form->getData(); + + // Update question order positions + $position = 1; + foreach ($survey->getQuestions() as $question) { + $question->setOrderPosition($position++); + + // Update option order positions + $optionPosition = 1; + foreach ($question->getOptions() as $option) { + $option->setOrderPosition($optionPosition++); + } + } + + $surveyRepository->save($survey); + $this->addFlash('success', $this->translator->trans('survey.saved')); + + return $this->redirectToRoute('backoffice_larp_survey_edit', ['larp' => $larp->getId()]); + } + + return $this->render('backoffice/survey/edit.html.twig', [ + 'form' => $form->createView(), + 'larp' => $larp, + 'survey' => $survey, + ]); + } + + #[Route('/preview', name: 'preview', methods: ['GET'])] + public function preview(Larp $larp): Response + { + $survey = $larp->getSurvey(); + + if (!$survey instanceof Survey) { + $this->addFlash('error', $this->translator->trans('survey.not_found')); + + return $this->redirectToRoute('backoffice_larp_survey_edit', ['larp' => $larp->getId()]); + } + + return $this->render('backoffice/survey/preview.html.twig', [ + 'larp' => $larp, + 'survey' => $survey, + ]); + } +} diff --git a/src/Domain/Survey/Controller/Backoffice/SurveyResponseController.php b/src/Domain/Survey/Controller/Backoffice/SurveyResponseController.php new file mode 100644 index 0000000..a95a7f0 --- /dev/null +++ b/src/Domain/Survey/Controller/Backoffice/SurveyResponseController.php @@ -0,0 +1,136 @@ +findByLarpWithRelations($larp); + + return $this->render('backoffice/survey/responses/list.html.twig', [ + 'larp' => $larp, + 'responses' => $responses, + ]); + } + + #[Route('/{response}', name: 'show', methods: ['GET'])] + public function show( + Larp $larp, + SurveyResponse $response + ): Response { + // Verify response belongs to this LARP + if ($response->getLarp() !== $larp) { + throw $this->createAccessDeniedException(); + } + + // Get or generate match suggestions + $matchSuggestions = $response->getMatchSuggestions(); + if ($matchSuggestions === null || $matchSuggestions === []) { + $matchSuggestions = $this->matchingService->generateMatchSuggestions($response); + $response->setMatchSuggestions($matchSuggestions); + } + + return $this->render('backoffice/survey/responses/show.html.twig', [ + 'larp' => $larp, + 'response' => $response, + 'matchSuggestions' => $matchSuggestions, + ]); + } + + #[Route('/{response}/assign/{character}', name: 'assign_character', methods: ['POST'])] + public function assignCharacter( + Larp $larp, + SurveyResponse $response, + Character $character, + SurveyResponseRepository $responseRepository + ): Response { + // Verify response belongs to this LARP + if ($response->getLarp() !== $larp || $character->getLarp() !== $larp) { + throw $this->createAccessDeniedException(); + } + + $response->setAssignedCharacter($character); + $response->setStatus(SubmissionStatus::OFFERED); + $responseRepository->save($response); + + $this->addFlash('success', $this->translator->trans('survey.response.character_assigned', [ + '%character%' => $character->getTitle(), + '%user%' => $response->getUser()->getContactEmail(), + ])); + + return $this->redirectToRoute('backoffice_larp_survey_responses_show', [ + 'larp' => $larp->getId(), + 'response' => $response->getId(), + ]); + } + + #[Route('/{response}/regenerate-matches', name: 'regenerate_matches', methods: ['POST'])] + public function regenerateMatches( + Larp $larp, + SurveyResponse $response, + SurveyResponseRepository $responseRepository + ): Response { + // Verify response belongs to this LARP + if ($response->getLarp() !== $larp) { + throw $this->createAccessDeniedException(); + } + + $matchSuggestions = $this->matchingService->generateMatchSuggestions($response); + $response->setMatchSuggestions($matchSuggestions); + $responseRepository->save($response); + + $this->addFlash('success', $this->translator->trans('survey.response.matches_regenerated')); + + return $this->redirectToRoute('backoffice_larp_survey_responses_show', [ + 'larp' => $larp->getId(), + 'response' => $response->getId(), + ]); + } + + #[Route('/regenerate-all', name: 'regenerate_all', methods: ['POST'])] + public function regenerateAllMatches( + Larp $larp, + SurveyResponseRepository $responseRepository + ): Response { + $responses = $responseRepository->findBy(['larp' => $larp]); + $count = 0; + + foreach ($responses as $response) { + $matchSuggestions = $this->matchingService->generateMatchSuggestions($response); + $response->setMatchSuggestions($matchSuggestions); + $count++; + } + + $responseRepository->flush(); + + $this->addFlash('success', $this->translator->trans('survey.response.all_matches_regenerated', [ + '%count%' => $count, + ])); + + return $this->redirectToRoute('backoffice_larp_survey_responses_list', [ + 'larp' => $larp->getId(), + ]); + } +} diff --git a/src/Domain/Survey/Controller/Public/SurveySubmissionController.php b/src/Domain/Survey/Controller/Public/SurveySubmissionController.php new file mode 100644 index 0000000..a1eeffc --- /dev/null +++ b/src/Domain/Survey/Controller/Public/SurveySubmissionController.php @@ -0,0 +1,218 @@ +getStatus()?->value !== 'INQUIRIES') { + $this->addFlash('error', $this->translator->trans('survey.not_accepting_responses')); + + return $this->redirectToRoute('public_larp_details', ['slug' => $larp->getSlug()]); + } + + // Check application mode is SURVEY + if ($larp->getApplicationMode()->value !== 'survey') { + $this->addFlash('error', $this->translator->trans('survey.not_in_survey_mode')); + + return $this->redirectToRoute('public_larp_details', ['slug' => $larp->getSlug()]); + } + + // Get survey + $survey = $larp->getSurvey(); + if (!$survey instanceof Survey || !$survey->isActive()) { + $this->addFlash('error', $this->translator->trans('survey.not_available')); + + return $this->redirectToRoute('public_larp_details', ['slug' => $larp->getSlug()]); + } + + // Check for existing response + $existingResponse = $responseRepository->findOneBy([ + 'larp' => $larp, + 'user' => $this->getUser(), + ]); + + if ($existingResponse instanceof SurveyResponse) { + $this->addFlash('info', $this->translator->trans('survey.already_submitted')); + + return $this->redirectToRoute('public_larp_details', ['slug' => $larp->getSlug()]); + } + + // Build dynamic form from survey + $form = $this->buildSurveyForm($formFactory, $survey); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $formData = $form->getData(); + + // Create survey response + $response = new SurveyResponse(); + $response->setSurvey($survey); + $response->setLarp($larp); + $response->setUser($this->getUser()); + $response->setStatus(SubmissionStatus::NEW); + + // Process answers + foreach ($survey->getQuestions() as $question) { + $fieldName = 'question_' . $question->getId()->toRfc4122(); + $answerValue = $formData[$fieldName] ?? null; + + if ($answerValue !== null && $answerValue !== '' && $answerValue !== []) { + $answer = new SurveyAnswer(); + $answer->setQuestion($question); + $answer->setResponse($response); + + // Handle different question types + if ($question->getQuestionType() === SurveyQuestionType::TAG_SELECTION) { + // $answerValue is array of Tag entities + foreach ($answerValue as $tag) { + $answer->addSelectedTag($tag); + } + } elseif ($question->getQuestionType() === SurveyQuestionType::SINGLE_CHOICE) { + // $answerValue is SurveyQuestionOption entity + $answer->addSelectedOption($answerValue); + } elseif ($question->getQuestionType() === SurveyQuestionType::MULTIPLE_CHOICE) { + // $answerValue is array of SurveyQuestionOption entities + foreach ($answerValue as $option) { + $answer->addSelectedOption($option); + } + } else { + // TEXT, TEXTAREA, RATING + $answer->setAnswerText((string) $answerValue); + } + + $response->addAnswer($answer); + } + } + + // Generate initial match suggestions + $matchSuggestions = $this->matchingService->generateMatchSuggestions($response); + $response->setMatchSuggestions($matchSuggestions); + + // Save response + $responseRepository->save($response); + + $this->addFlash('success', $this->translator->trans('survey.submitted_successfully')); + + return $this->redirectToRoute('public_larp_details', ['slug' => $larp->getSlug()]); + } + + return $this->render('public/survey/submit.html.twig', [ + 'larp' => $larp, + 'survey' => $survey, + 'form' => $form->createView(), + ]); + } + + private function buildSurveyForm(FormFactoryInterface $formFactory, Survey $survey): \Symfony\Component\Form\FormInterface + { + $builder = $formFactory->createBuilder(); + + foreach ($survey->getQuestions() as $question) { + $fieldName = 'question_' . $question->getId()->toRfc4122(); + $options = [ + 'label' => $question->getQuestionText(), + 'required' => $question->isRequired(), + 'help' => $question->getHelpText(), + 'attr' => ['class' => 'form-control'], + ]; + + switch ($question->getQuestionType()) { + case SurveyQuestionType::TEXT: + $builder->add($fieldName, TextType::class, $options); + break; + + case SurveyQuestionType::TEXTAREA: + $options['attr']['rows'] = 4; + $builder->add($fieldName, TextareaType::class, $options); + break; + + case SurveyQuestionType::SINGLE_CHOICE: + $options['choices'] = []; + foreach ($question->getOptions() as $option) { + $options['choices'][$option->getOptionText()] = $option; + } + $options['expanded'] = true; + $options['attr'] = ['class' => 'form-check']; + $builder->add($fieldName, ChoiceType::class, $options); + break; + + case SurveyQuestionType::MULTIPLE_CHOICE: + $options['choices'] = []; + foreach ($question->getOptions() as $option) { + $options['choices'][$option->getOptionText()] = $option; + } + $options['multiple'] = true; + $options['expanded'] = true; + $options['attr'] = ['class' => 'form-check']; + $builder->add($fieldName, ChoiceType::class, $options); + break; + + case SurveyQuestionType::RATING: + $options['attr']['min'] = 1; + $options['attr']['max'] = 5; + $builder->add($fieldName, IntegerType::class, $options); + break; + + case SurveyQuestionType::TAG_SELECTION: + // Use EntityType for tags + $options['class'] = \App\Domain\Core\Entity\Tag::class; + $options['query_builder'] = fn ($repo) => $repo->createQueryBuilder('t') + ->where('t.larp = :larp') + ->setParameter('larp', $survey->getLarp()) + ->orderBy('t.title', 'ASC'); + $options['choice_label'] = 'title'; + $options['multiple'] = true; + $options['autocomplete'] = true; + $options['attr'] = ['class' => 'form-select']; + $builder->add($fieldName, \Symfony\Bridge\Doctrine\Form\Type\EntityType::class, $options); + break; + } + } + + $builder->add('submit', SubmitType::class, [ + 'label' => 'survey.submit', + 'attr' => ['class' => 'btn btn-success btn-lg'], + ]); + + return $builder->getForm(); + } +} diff --git a/src/Domain/Survey/Form/SurveyQuestionOptionType.php b/src/Domain/Survey/Form/SurveyQuestionOptionType.php new file mode 100644 index 0000000..9ed414a --- /dev/null +++ b/src/Domain/Survey/Form/SurveyQuestionOptionType.php @@ -0,0 +1,39 @@ +add('optionText', TextType::class, [ + 'label' => 'Option', + 'attr' => [ + 'class' => 'form-control', + 'placeholder' => 'Enter option text...', + ], + ]) + ->add('orderPosition', HiddenType::class, [ + 'attr' => ['class' => 'option-order-position'], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => SurveyQuestionOption::class, + 'translation_domain' => 'forms', + ]); + } +} diff --git a/src/Domain/Survey/Form/SurveyQuestionType.php b/src/Domain/Survey/Form/SurveyQuestionType.php new file mode 100644 index 0000000..1f56bed --- /dev/null +++ b/src/Domain/Survey/Form/SurveyQuestionType.php @@ -0,0 +1,78 @@ +add('questionText', TextareaType::class, [ + 'label' => 'Question', + 'attr' => [ + 'class' => 'form-control', + 'rows' => 2, + 'placeholder' => 'Enter your question here...', + ], + ]) + ->add('helpText', TextType::class, [ + 'label' => 'Help Text', + 'required' => false, + 'attr' => [ + 'class' => 'form-control', + 'placeholder' => 'Optional guidance for the applicant', + ], + ]) + ->add('questionType', EnumType::class, [ + 'class' => SurveyQuestionTypeEnum::class, + 'choice_label' => fn (SurveyQuestionTypeEnum $type): string => $type->getLabel(), + 'label' => 'Question Type', + 'attr' => [ + 'class' => 'form-select', + 'data-action' => 'change->survey-builder#onQuestionTypeChange', + ], + ]) + ->add('isRequired', CheckboxType::class, [ + 'label' => 'Required', + 'required' => false, + 'attr' => ['class' => 'form-check-input'], + ]) + ->add('orderPosition', HiddenType::class, [ + 'attr' => ['class' => 'question-order-position'], + ]) + ->add('options', CollectionType::class, [ + 'entry_type' => SurveyQuestionOptionType::class, + 'entry_options' => ['label' => false], + 'allow_add' => true, + 'allow_delete' => true, + 'by_reference' => false, + 'label' => 'Options', + 'attr' => [ + 'class' => 'question-options-container', + ], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => SurveyQuestion::class, + 'translation_domain' => 'forms', + ]); + } +} diff --git a/src/Domain/Survey/Form/SurveyType.php b/src/Domain/Survey/Form/SurveyType.php new file mode 100644 index 0000000..d315ca6 --- /dev/null +++ b/src/Domain/Survey/Form/SurveyType.php @@ -0,0 +1,67 @@ +add('title', TextType::class, [ + 'label' => 'Survey Title', + 'attr' => ['class' => 'form-control'], + ]) + ->add('description', TextareaType::class, [ + 'label' => 'Description', + 'required' => false, + 'attr' => [ + 'class' => 'form-control', + 'rows' => 3, + ], + 'help' => 'Introduce the survey to applicants', + ]) + ->add('isActive', CheckboxType::class, [ + 'label' => 'Active', + 'required' => false, + 'help' => 'Only active surveys can receive responses', + 'attr' => ['class' => 'form-check-input'], + ]) + ->add('questions', CollectionType::class, [ + 'entry_type' => SurveyQuestionType::class, + 'entry_options' => ['label' => false], + 'allow_add' => true, + 'allow_delete' => true, + 'by_reference' => false, + 'attr' => [ + 'class' => 'survey-questions-container', + 'data-controller' => 'sortable-questions', + ], + 'label' => false, + ]) + ->add('submit', SubmitType::class, [ + 'label' => 'Save Survey', + 'attr' => ['class' => 'btn btn-primary'], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Survey::class, + 'translation_domain' => 'forms', + ]); + } +} diff --git a/src/Domain/Survey/Service/CharacterMatchingService.php b/src/Domain/Survey/Service/CharacterMatchingService.php new file mode 100644 index 0000000..bcf9c82 --- /dev/null +++ b/src/Domain/Survey/Service/CharacterMatchingService.php @@ -0,0 +1,241 @@ + Array of match suggestions with character IDs and scores + */ + public function generateMatchSuggestions(SurveyResponse $response): array + { + $larp = $response->getLarp(); + + // Extract tags from survey answers + $preferredTags = $this->extractPreferredTags($response); + $unwantedTags = $this->extractUnwantedTags($response); + + // Load eligible characters (Player type or available for recruitment) + $characters = $this->characterRepository->createQueryBuilder('c') + ->where('c.larp = :larp') + ->andWhere('(c.characterType = :playerType OR c.availableForRecruitment = true)') + ->setParameter('larp', $larp) + ->setParameter('playerType', CharacterType::Player) + ->getQuery() + ->getResult(); + + // Calculate match score for each character + $matches = []; + foreach ($characters as $character) { + $score = $this->calculateMatchScore($character, $preferredTags, $unwantedTags, $response); + + // Exclude characters with unwanted tags (blocking condition) + if ($score === null) { + continue; + } + + $matches[] = [ + 'characterId' => $character->getId()->toRfc4122(), + 'characterTitle' => $character->getTitle(), + 'score' => $score, + 'matchReasons' => $this->getMatchReasons($character, $preferredTags), + ]; + } + + // Sort by score descending + usort($matches, fn ($a, $b) => $b['score'] <=> $a['score']); + + // Return top 5 suggestions + return array_slice($matches, 0, 5); + } + + /** + * Calculate match score for a character. + * + * @param Tag[] $preferredTags + * @param Tag[] $unwantedTags + * + * @return int|null Match score, or null if character has unwanted tags (blocking condition) + */ + private function calculateMatchScore( + Character $character, + array $preferredTags, + array $unwantedTags, + SurveyResponse $response + ): ?int { + $score = 0; + $characterTags = $character->getTags()->toArray(); + $characterTagIds = array_map(fn (Tag $tag) => $tag->getId()->toRfc4122(), $characterTags); + + // BLOCKING: Exclude if character has any unwanted tags + foreach ($unwantedTags as $unwantedTag) { + if (in_array($unwantedTag->getId()->toRfc4122(), $characterTagIds, true)) { + return null; // Block this character + } + } + + // +10 points per matching preferred tag + foreach ($preferredTags as $preferredTag) { + if (in_array($preferredTag->getId()->toRfc4122(), $characterTagIds, true)) { + $score += 10; + } + } + + // +5 points for gender preference match + $genderPreference = $this->extractGenderPreference($response); + if ($genderPreference !== null && $character->getGender() === $genderPreference) { + $score += 5; + } + + // +3 points for complexity match (if rating question answered) + $complexityPreference = $this->extractComplexityPreference($response); + if ($complexityPreference !== null) { + $characterComplexity = $this->estimateCharacterComplexity($character); + $complexityDiff = abs($complexityPreference - $characterComplexity); + if ($complexityDiff <= 1) { + $score += 3; + } + } + + return $score; + } + + /** + * Extract preferred tags from survey answers. + * + * @return Tag[] + */ + private function extractPreferredTags(SurveyResponse $response): array + { + $tags = []; + + foreach ($response->getAnswers() as $answer) { + $question = $answer->getQuestion(); + if ($question->getQuestionText() === 'What themes or elements are you most interested in?') { + $tags = array_merge($tags, $answer->getSelectedTags()->toArray()); + } + } + + return $tags; + } + + /** + * Extract unwanted tags from survey answers. + * + * @return Tag[] + */ + private function extractUnwantedTags(SurveyResponse $response): array + { + $tags = []; + + foreach ($response->getAnswers() as $answer) { + $question = $answer->getQuestion(); + if ($question->getQuestionText() === 'Are there any themes or elements you want to avoid?') { + $tags = array_merge($tags, $answer->getSelectedTags()->toArray()); + } + } + + return $tags; + } + + /** + * Extract gender preference from survey answers. + */ + private function extractGenderPreference(SurveyResponse $response): ?string + { + foreach ($response->getAnswers() as $answer) { + $question = $answer->getQuestion(); + if ($question->getQuestionText() === 'Do you have a preferred character gender?') { + $selectedOptions = $answer->getSelectedOptions(); + if ($selectedOptions->count() > 0) { + $optionText = $selectedOptions->first()->getOptionText(); + // Map option text to Gender enum values + return match ($optionText) { + 'Male character' => 'MALE', + 'Female character' => 'FEMALE', + 'Non-binary character' => 'NON_BINARY', + default => null, + }; + } + } + } + + return null; + } + + /** + * Extract complexity preference (1-5 rating). + */ + private function extractComplexityPreference(SurveyResponse $response): ?int + { + foreach ($response->getAnswers() as $answer) { + $question = $answer->getQuestion(); + if ($question->getQuestionText() === 'How complex do you want your character to be?') { + $answerText = $answer->getAnswerText(); + if ($answerText !== null && is_numeric($answerText)) { + return (int) $answerText; + } + } + } + + return null; + } + + /** + * Estimate character complexity based on relationships and threads. + */ + private function estimateCharacterComplexity(Character $character): int + { + $threadCount = $character->getThreads()->count(); + $questCount = $character->getQuests()->count(); + $factionCount = $character->getFactions()->count(); + + $totalConnections = $threadCount + $questCount + $factionCount; + + // Map connection count to 1-5 scale + return match (true) { + $totalConnections === 0 => 1, + $totalConnections <= 2 => 2, + $totalConnections <= 5 => 3, + $totalConnections <= 10 => 4, + default => 5, + }; + } + + /** + * Get human-readable match reasons. + * + * @param Tag[] $preferredTags + * + * @return string[] + */ + private function getMatchReasons(Character $character, array $preferredTags): array + { + $reasons = []; + $characterTags = $character->getTags()->toArray(); + $characterTagIds = array_map(fn (Tag $tag) => $tag->getId()->toRfc4122(), $characterTags); + + foreach ($preferredTags as $preferredTag) { + if (in_array($preferredTag->getId()->toRfc4122(), $characterTagIds, true)) { + $reasons[] = sprintf('Matches your interest in: %s', $preferredTag->getTitle()); + } + } + + return $reasons; + } +} diff --git a/src/Domain/Survey/Service/SurveyTemplateService.php b/src/Domain/Survey/Service/SurveyTemplateService.php new file mode 100644 index 0000000..78c205a --- /dev/null +++ b/src/Domain/Survey/Service/SurveyTemplateService.php @@ -0,0 +1,182 @@ +setLarp($larp); + $survey->setTitle('LARP Application Survey'); + $survey->setDescription('This survey helps us match you with the perfect character for this LARP.'); + $survey->setIsActive(false); + + $this->addTemplateQuestions($survey); + + return $survey; + } + + /** + * Add all template questions to a survey. + */ + private function addTemplateQuestions(Survey $survey): void + { + $position = 1; + + // Question 1: Favourite playstyle + $question = new SurveyQuestion(); + $question->setQuestionText('What is your favourite playstyle?'); + $question->setHelpText('Describe the type of roleplay you enjoy most (e.g., political intrigue, combat, investigation, social interaction, etc.)'); + $question->setQuestionType(SurveyQuestionType::TEXTAREA); + $question->setIsRequired(false); + $question->setOrderPosition($position++); + $survey->addQuestion($question); + + // Question 2: LARP experience level + $question = new SurveyQuestion(); + $question->setQuestionText('What is your LARP experience level?'); + $question->setQuestionType(SurveyQuestionType::SINGLE_CHOICE); + $question->setIsRequired(true); + $question->setOrderPosition($position++); + $this->addExperienceLevelOptions($question); + $survey->addQuestion($question); + + // Question 3: Preferred tags + $question = new SurveyQuestion(); + $question->setQuestionText('What themes or elements are you most interested in?'); + $question->setHelpText('Select the tags that interest you most'); + $question->setQuestionType(SurveyQuestionType::TAG_SELECTION); + $question->setIsRequired(false); + $question->setOrderPosition($position++); + $survey->addQuestion($question); + + // Question 4: Unwanted tags + $question = new SurveyQuestion(); + $question->setQuestionText('Are there any themes or elements you want to avoid?'); + $question->setHelpText('Select tags for content you prefer not to encounter'); + $question->setQuestionType(SurveyQuestionType::TAG_SELECTION); + $question->setIsRequired(false); + $question->setOrderPosition($position++); + $survey->addQuestion($question); + + // Question 5: Triggers/safety concerns + $question = new SurveyQuestion(); + $question->setQuestionText('Do you have any triggers or safety concerns?'); + $question->setHelpText('Please list any content or situations you need to avoid for safety or comfort reasons'); + $question->setQuestionType(SurveyQuestionType::TEXTAREA); + $question->setIsRequired(false); + $question->setOrderPosition($position++); + $survey->addQuestion($question); + + // Question 6: Preferred character gender + $question = new SurveyQuestion(); + $question->setQuestionText('Do you have a preferred character gender?'); + $question->setQuestionType(SurveyQuestionType::SINGLE_CHOICE); + $question->setIsRequired(false); + $question->setOrderPosition($position++); + $this->addGenderPreferenceOptions($question); + $survey->addQuestion($question); + + // Question 7: Character complexity preference + $question = new SurveyQuestion(); + $question->setQuestionText('How complex do you want your character to be?'); + $question->setHelpText('1 = Simple and straightforward, 5 = Very complex with many connections'); + $question->setQuestionType(SurveyQuestionType::RATING); + $question->setIsRequired(false); + $question->setOrderPosition($position++); + $survey->addQuestion($question); + + // Question 8: Costume and prop skills + $question = new SurveyQuestion(); + $question->setQuestionText('What are your costume and prop creation skills?'); + $question->setQuestionType(SurveyQuestionType::MULTIPLE_CHOICE); + $question->setIsRequired(false); + $question->setOrderPosition($position++); + $this->addCostumeSkillOptions($question); + $survey->addQuestion($question); + + // Question 9: Dietary restrictions + $question = new SurveyQuestion(); + $question->setQuestionText('Do you have any dietary restrictions?'); + $question->setHelpText('Please list any allergies, dietary requirements, or food preferences'); + $question->setQuestionType(SurveyQuestionType::TEXT); + $question->setIsRequired(false); + $question->setOrderPosition($position++); + $survey->addQuestion($question); + + // Question 10: Accessibility needs + $question = new SurveyQuestion(); + $question->setQuestionText('Do you have any accessibility needs?'); + $question->setHelpText('Please describe any accommodations you may need (mobility, sensory, medical, etc.)'); + $question->setQuestionType(SurveyQuestionType::TEXTAREA); + $question->setIsRequired(false); + $question->setOrderPosition($position++); + $survey->addQuestion($question); + } + + private function addExperienceLevelOptions(SurveyQuestion $question): void + { + $options = [ + 'This is my first LARP', + 'I have attended 1-3 LARPs', + 'I have attended 4-10 LARPs', + 'I have attended more than 10 LARPs', + 'I am an experienced LARPer (20+ games)', + ]; + + foreach ($options as $index => $optionText) { + $option = new SurveyQuestionOption(); + $option->setOptionText($optionText); + $option->setOrderPosition($index + 1); + $question->addOption($option); + } + } + + private function addGenderPreferenceOptions(SurveyQuestion $question): void + { + $options = [ + 'Male character', + 'Female character', + 'Non-binary character', + 'No preference', + ]; + + foreach ($options as $index => $optionText) { + $option = new SurveyQuestionOption(); + $option->setOptionText($optionText); + $option->setOrderPosition($index + 1); + $question->addOption($option); + } + } + + private function addCostumeSkillOptions(SurveyQuestion $question): void + { + $options = [ + 'I can create my own costume', + 'I can modify existing clothing', + 'I can create simple props', + 'I can create complex props', + 'I can do makeup/prosthetics', + 'I need help with costume', + ]; + + foreach ($options as $index => $optionText) { + $option = new SurveyQuestionOption(); + $option->setOptionText($optionText); + $option->setOrderPosition($index + 1); + $question->addOption($option); + } + } +} diff --git a/src/Story/AppStory.php b/src/Story/AppStory.php deleted file mode 100644 index 5cc5657..0000000 --- a/src/Story/AppStory.php +++ /dev/null @@ -1,15 +0,0 @@ - {{ choice.character.title }} - #{{ choice.priority }} + #{{ choice.priority }} {% endfor %} diff --git a/templates/backoffice/larp/mailing/edit.html.twig b/templates/backoffice/larp/mailing/edit.html.twig index 06585e8..4bd9361 100644 --- a/templates/backoffice/larp/mailing/edit.html.twig +++ b/templates/backoffice/larp/mailing/edit.html.twig @@ -25,7 +25,7 @@
{{ 'mail_template.available_placeholders'|trans }}
{% for placeholder in definition.placeholders %} - {{ '{{ ' ~ placeholder ~ ' }}' }} + {{ '{{ ' ~ placeholder ~ ' }}' }} {% endfor %}
diff --git a/templates/backoffice/larp/mailing/list.html.twig b/templates/backoffice/larp/mailing/list.html.twig index c5b2925..fd68875 100644 --- a/templates/backoffice/larp/mailing/list.html.twig +++ b/templates/backoffice/larp/mailing/list.html.twig @@ -43,11 +43,11 @@ {{ 'mail_template.optional'|trans }} {% endif %} {% if template.type.belongsToFinanceContext %} - {{ 'mail_template.finance'|trans }} + {{ 'mail_template.finance'|trans }} {% endif %} - {{ template.type.label }} + {{ template.type.label }} {{ definition ? definition.description : '-' }} diff --git a/templates/backoffice/larp/story/comment/_comment_thread.html.twig b/templates/backoffice/larp/story/comment/_comment_thread.html.twig index ebc80c6..67fb279 100644 --- a/templates/backoffice/larp/story/comment/_comment_thread.html.twig +++ b/templates/backoffice/larp/story/comment/_comment_thread.html.twig @@ -18,11 +18,11 @@
- {{ comment.author.username }} + {{ comment.author.username }} {{ comment.createdAt|date('M d, Y H:i') }} {% if comment.createdAt != comment.updatedAt %} - + {% endif %} @@ -138,7 +138,7 @@
- {{ reply.author.username }} + {{ reply.author.username }} {{ reply.createdAt|date('M d, Y H:i') }} diff --git a/templates/backoffice/location/list.html.twig b/templates/backoffice/location/list.html.twig index f0c716b..ff0d44a 100755 --- a/templates/backoffice/location/list.html.twig +++ b/templates/backoffice/location/list.html.twig @@ -8,7 +8,7 @@
{% set actions %} {% if is_granted('ROLE_SUPER_ADMIN') %} -{# #} +{# #} {# {{ 'location.pending_locations'|trans }}#} {# #} {% endif %} @@ -22,17 +22,17 @@ {% else %}
- - - - - - - - - - - + + + + + + + + + + + {% for location in locations %} @@ -63,7 +63,6 @@ {{ ui.delete_button(location.id, location.title, path('backoffice_location_delete', {'id': location.id})) }} {% endif %} {% if is_granted('ROLE_SUPER_ADMIN') and location.isApproved == false %} -
{{ 'location.reject'|trans }} -
{% endif %} diff --git a/templates/backoffice/location/modify.html.twig b/templates/backoffice/location/modify.html.twig index 8b01639..7d6eb93 100755 --- a/templates/backoffice/location/modify.html.twig +++ b/templates/backoffice/location/modify.html.twig @@ -43,10 +43,10 @@
{{ form_row(form.title) }}
-
+
{{ form_row(form.isActive) }}
-
+
{{ form_row(form.isPublic) }}
diff --git a/templates/backoffice/location/reject.html.twig b/templates/backoffice/location/reject.html.twig index 0b402ea..7f64a6f 100644 --- a/templates/backoffice/location/reject.html.twig +++ b/templates/backoffice/location/reject.html.twig @@ -31,7 +31,7 @@ {{ form_row(form.reason) }}
- + {{ 'cancel'|trans }} +
+ +
+ + {% for question in form.questions %} +
+
+
+
+ + {{ loop.index }} + {{ 'survey.question'|trans }} {{ loop.index }} +
+ +
+
+ +
+
+
+ {{ form_label(question.questionText) }} + {{ form_widget(question.questionText) }} + {{ form_errors(question.questionText) }} +
+ +
+ {{ form_label(question.questionType) }} + {{ form_widget(question.questionType) }} + {{ form_errors(question.questionType) }} +
+
+ +
+
+ {{ form_label(question.helpText) }} + {{ form_widget(question.helpText) }} + {{ form_errors(question.helpText) }} +
+ +
+
+ {{ form_widget(question.isRequired) }} + {{ form_label(question.isRequired) }} + {{ form_errors(question.isRequired) }} +
+
+
+ + {# Options section for choice-based questions #} +
+ + {% if question.questionType.vars.value in ['single_choice', 'multiple_choice'] %} +
+
+ + +
+ +
+ {% for option in question.options %} +
+ {{ loop.index }} + {{ form_widget(option.optionText) }} + {{ form_widget(option.orderPosition, {'attr': {'class': 'd-none'}}) }} + +
+ {{ form_errors(option.optionText) }} + {% endfor %} +
+
+ {% endif %} +
+ + {{ form_widget(question.orderPosition, {'attr': {'class': 'd-none'}}) }} +
+
+ {% endfor %} +
+ + {{ form_errors(form.questions) }} +
+ + {# Form actions #} +
+ + {{ 'common.back'|trans }} + + +
+ + {{ form_end(form) }} + + + + {# Help card #} +
+
+
{{ 'survey.help.title'|trans }}
+
    +
  • {{ 'survey.help.drag_reorder'|trans }}
  • +
  • {{ 'survey.help.question_types'|trans }}
  • +
  • {{ 'survey.help.tag_selection'|trans }}
  • +
  • {{ 'survey.help.preview'|trans }}
  • +
+
+
+{% endblock %} diff --git a/templates/backoffice/survey/preview.html.twig b/templates/backoffice/survey/preview.html.twig new file mode 100644 index 0000000..639f59b --- /dev/null +++ b/templates/backoffice/survey/preview.html.twig @@ -0,0 +1,127 @@ +{% extends 'backoffice/larp/base.html.twig' %} + +{% block larp_title %}{{ 'survey.preview'|trans }} - {{ larp.title }}{% endblock %} + +{% block larp_content %} +
+
+
+

{{ 'survey.preview'|trans }}

+ + {{ 'survey.edit'|trans }} + +
+
+ +
+ {# Survey header #} +
+

{{ survey.title }}

+ {% if survey.description %} +

{{ survey.description }}

+ {% endif %} +
+ {% if survey.isActive %} + {{ 'survey.active'|trans }} + {% else %} + {{ 'survey.inactive'|trans }} + {% endif %} +
+
+ +
+ + {# Preview questions #} + {% if survey.questions|length > 0 %} +
+ {% for question in survey.questions %} +
+
+ {# Question header #} +
+
+ {{ loop.index }} + {{ question.questionText }} + {% if question.isRequired %} + * + {% endif %} +
+ {% if question.helpText %} + {{ question.helpText }} + {% endif %} +
+ + {# Question type badge #} +
+ + {{ question.questionType.label }} + +
+ + {# Preview based on question type #} +
+ {% if question.questionType.value == 'text' %} + + + {% elseif question.questionType.value == 'textarea' %} + + + {% elseif question.questionType.value == 'single_choice' %} +
+ {% for option in question.options %} +
+ + +
+ {% endfor %} +
+ + {% elseif question.questionType.value == 'multiple_choice' %} +
+ {% for option in question.options %} +
+ + +
+ {% endfor %} +
+ + {% elseif question.questionType.value == 'rating' %} +
+ {% for i in 1..5 %} + + {% endfor %} +
+ + {% elseif question.questionType.value == 'tag_selection' %} +
+ {{ 'survey.preview.tag_selection_info'|trans }} +
+ {% endif %} +
+
+
+ {% endfor %} +
+ {% else %} +
+ {{ 'survey.no_questions'|trans }} +
+ {% endif %} + + {# Preview submit button #} +
+ + {{ 'common.back'|trans }} + + +
+
+
+{% endblock %} diff --git a/templates/backoffice/survey/responses/list.html.twig b/templates/backoffice/survey/responses/list.html.twig new file mode 100644 index 0000000..61aa3ac --- /dev/null +++ b/templates/backoffice/survey/responses/list.html.twig @@ -0,0 +1,144 @@ +{% extends 'backoffice/larp/base.html.twig' %} + +{% block larp_title %}{{ 'survey.responses.list'|trans }} - {{ larp.title }}{% endblock %} + +{% block larp_content %} +
+
+
+

{{ 'survey.responses.list'|trans }}

+
+ + + +
+
+
+ +
+ {% if responses is not empty %} +
+
TitleAddressCityCapacityStatusApprovalActions
{{ 'location.title'|trans }}{{ 'location.address'|trans }}{{ 'location.city'|trans }}{{ 'location.capacity'|trans }}{{ 'location.status'|trans }}{{ 'location.approval'|trans }}{{ 'actions'|trans }}
+ + + + + + + + + + + + + {% for response in responses %} + + + + + + + + + + {% endfor %} + +
{{ 'common.user'|trans }}{{ 'common.email'|trans }}{{ 'common.status'|trans }}{{ 'survey.responses.assigned_character'|trans }}{{ 'survey.responses.match_score'|trans }}{{ 'common.submitted_at'|trans }}{{ 'common.actions'|trans }}
+ + {{ response.user.displayName }} + + {{ response.user.contactEmail }} + {% set statusClass = { + 'NEW': 'secondary', + 'CONSIDER': 'warning', + 'OFFERED': 'info', + 'CONFIRMED': 'success', + 'REJECTED': 'danger', + 'DECLINED': 'danger' + } %} + + {{ response.status.value }} + + + {% if response.assignedCharacter %} + {{ response.assignedCharacter.title }} + {% else %} + - + {% endif %} + + {% if response.matchSuggestions and response.matchSuggestions|length > 0 %} + + {{ response.matchSuggestions[0].score }} pts + + {% else %} + - + {% endif %} + + + {{ response.createdAt|date('Y-m-d H:i') }} + + + + {{ 'common.view'|trans }} + +
+
+ {% else %} +
+ +

{{ 'survey.responses.empty'|trans }}

+
+ {% endif %} +
+
+ + {# Summary cards #} +
+
+
+
+
{{ 'survey.responses.total'|trans }}
+

{{ responses|length }}

+
+
+
+
+
+
+
{{ 'survey.responses.pending'|trans }}
+

+ {{ responses|filter(r => r.status.value == 'NEW')|length }} +

+
+
+
+
+
+
+
{{ 'survey.responses.assigned'|trans }}
+

+ {{ responses|filter(r => r.assignedCharacter is not null)|length }} +

+
+
+
+
+
+
+
{{ 'survey.responses.confirmed'|trans }}
+

+ {{ responses|filter(r => r.status.value == 'CONFIRMED')|length }} +

+
+
+
+
+{% endblock %} diff --git a/templates/backoffice/survey/responses/show.html.twig b/templates/backoffice/survey/responses/show.html.twig new file mode 100644 index 0000000..2cadf71 --- /dev/null +++ b/templates/backoffice/survey/responses/show.html.twig @@ -0,0 +1,196 @@ +{% extends 'backoffice/larp/base.html.twig' %} + +{% block larp_title %}{{ 'survey.response.details'|trans }} - {{ larp.title }}{% endblock %} + +{% block larp_content %} + {# Response header #} +
+
+
+

{{ 'survey.response.details'|trans }}

+
+
+ +
+ + {{ 'common.back'|trans }} + +
+
+
+ +
+
+
+
{{ 'common.applicant'|trans }}
+

{{ response.user.displayName }}

+

{{ response.user.contactEmail }}

+
+
+
{{ 'common.status'|trans }}
+ {% set statusClass = { + 'NEW': 'secondary', + 'CONSIDER': 'warning', + 'OFFERED': 'info', + 'CONFIRMED': 'success', + 'REJECTED': 'danger', + 'DECLINED': 'danger' + } %} + + {{ response.status.value }} + +
+
+
{{ 'common.submitted_at'|trans }}
+

{{ response.createdAt|date('Y-m-d H:i') }}

+
+
+ + {% if response.assignedCharacter %} +
+
+
{{ 'survey.response.assigned_character'|trans }}
+

+ {{ response.assignedCharacter.title }} + {% if response.assignedCharacter.description %} +
{{ response.assignedCharacter.description|sanitize_html|striptags|slice(0, 200) }}... + {% endif %} +

+
+ {% endif %} +
+
+ + {# Match suggestions #} + {% if matchSuggestions and matchSuggestions|length > 0 %} +
+
+

{{ 'survey.response.match_suggestions'|trans }}

+
+
+
+ {% for suggestion in matchSuggestions %} +
+
+
+
+ #{{ loop.index }} + {{ suggestion.characterTitle }} +
+ {% if suggestion.matchReasons %} +
    + {% for reason in suggestion.matchReasons %} +
  • {{ reason }}
  • + {% endfor %} +
+ {% endif %} +
+
+
+
{{ suggestion.score }}
+ {{ 'survey.response.points'|trans }} +
+
+
+
+ +
+
+
+
+ {% endfor %} +
+
+
+ {% else %} +
+ {{ 'survey.response.no_matches'|trans }} +
+ {% endif %} + + {# Survey answers #} +
+
+

{{ 'survey.response.answers'|trans }}

+
+
+ {% if response.answers|length > 0 %} + {% for answer in response.answers %} +
+
+ Q{{ loop.index }} + {{ answer.question.questionText }} +
+ + {% if answer.question.questionType.value == 'text' or answer.question.questionType.value == 'textarea' %} +

{{ answer.answerText|default('-') }}

+ + {% elseif answer.question.questionType.value in ['single_choice', 'multiple_choice'] %} + {% if answer.selectedOptions|length > 0 %} +
    + {% for option in answer.selectedOptions %} +
  • {{ option.optionText }}
  • + {% endfor %} +
+ {% else %} +

{{ 'common.not_answered'|trans }}

+ {% endif %} + + {% elseif answer.question.questionType.value == 'tag_selection' %} + {% if answer.selectedTags|length > 0 %} +
+ {% for tag in answer.selectedTags %} + {{ tag.title }} + {% endfor %} +
+ {% else %} +

{{ 'common.not_answered'|trans }}

+ {% endif %} + + {% elseif answer.question.questionType.value == 'rating' %} +

+ {% if answer.answerText %} + {{ answer.answerText }} / 5 + {% for i in 1..(answer.answerText|number_format) %} + + {% endfor %} + {% else %} + {{ 'common.not_answered'|trans }} + {% endif %} +

+ {% endif %} +
+ {% endfor %} + {% else %} +

{{ 'survey.response.no_answers'|trans }}

+ {% endif %} +
+
+ + {# Organizer notes #} +
+
+
{{ 'survey.response.organizer_notes'|trans }}
+
+
+ {% if response.organizerNotes %} +

{{ response.organizerNotes }}

+ {% else %} +

{{ 'survey.response.no_notes'|trans }}

+ {% endif %} +
+
+{% endblock %} diff --git a/templates/components/CookieConsent.html.twig b/templates/components/CookieConsent.html.twig index 719eaf7..f6c1ed3 100644 --- a/templates/components/CookieConsent.html.twig +++ b/templates/components/CookieConsent.html.twig @@ -40,7 +40,7 @@ - +
+
+ + {# Character Grid #} + {% if characters is not empty %} +
+ {% for character in characters %} +
+
+ {% if character.imageUrl %} + {{ character.title }} + {% else %} +
+ +
+ {% endif %} + +
+
+ + {{ character.title }} + +
+ + {% if character.inGameName %} +

+ {{ character.inGameName }} +

+ {% endif %} + +

+ {{ character.description|sanitize_html|striptags|slice(0, 150) }}... +

+ + {# Tags #} + {% if character.tags|length > 0 %} +
+ {% for tag in character.tags|slice(0, 3) %} + {{ tag.title }} + {% endfor %} + {% if character.tags|length > 3 %} + +{{ character.tags|length - 3 }} + {% endif %} +
+ {% endif %} + + {# Character metadata #} +
+ {% if character.gender %} + {{ character.gender.label }} + {% endif %} + {% if character.factions|length > 0 %} + {{ character.factions|length }} {{ 'common.faction'|trans }} + {% endif %} +
+
+ + +
+
+ {% endfor %} +
+ + {# Back to LARP button #} + + {% else %} +
+
+ +

{{ 'public.characters.no_characters'|trans }}

+ + {{ 'common.back_to_larp'|trans }} + +
+
+ {% endif %} +
+
+
+{% endblock %} diff --git a/templates/public/character/show.html.twig b/templates/public/character/show.html.twig new file mode 100644 index 0000000..3956fdb --- /dev/null +++ b/templates/public/character/show.html.twig @@ -0,0 +1,146 @@ +{% extends 'public/base.html.twig' %} + +{% block body %} +
+
+
+ {# LARP Info Header #} + {% embed 'public/larp/_info_header.html.twig' with { + 'larp': larp, + 'showManageButton': false + } %} + {% endembed %} + + {# Character Header #} +
+
+
+

{{ character.title }}

+ {% if character.availableForRecruitment %} + + {{ 'public.character.available'|trans }} + + {% endif %} +
+ {% if character.inGameName %} +

+ {{ character.inGameName }} +

+ {% endif %} +
+ + {% if character.imageUrl %} + {{ character.title }} + {% endif %} + +
+ {# Character metadata #} +
+ {% if character.gender %} +
+
{{ 'common.gender'|trans }}
+

{{ character.gender.label }}

+
+ {% endif %} + + {% if character.characterType %} +
+
{{ 'character.type'|trans }}
+

{{ character.characterType.label }}

+
+ {% endif %} + + {% if character.factions|length > 0 %} +
+
{{ 'character.factions'|trans }}
+

+ + {% for faction in character.factions %} + {{ faction.title }}{% if not loop.last %}, {% endif %} + {% endfor %} +

+
+ {% endif %} +
+ +
+ + {# Description #} +
+

{{ 'character.description'|trans }}

+
+ {{ character.description|sanitize_html|raw }} +
+
+ + {# Tags #} + {% if character.tags|length > 0 %} +
+
{{ 'character.themes'|trans }}
+
+ {% for tag in character.tags %} + {{ tag.title }} + {% endfor %} +
+
+ {% endif %} + + {# Threads #} + {% if character.threads|length > 0 %} +
+
{{ 'character.threads'|trans }}
+
+ {% for thread in character.threads %} +
+
{{ thread.title }}
+ {% if thread.description %} +

+ {{ thread.description|sanitize_html|striptags|slice(0, 150) }}... +

+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + {# Skills (if visible) #} + {% if character.skills|length > 0 %} +
+
{{ 'character.skills'|trans }}
+
+ {% for skill in character.skills %} +
+ {{ skill.skill.name }} + Level {{ skill.level }} +
+ {% endfor %} +
+
+ {% endif %} +
+ + +
+
+
+
+{% endblock %} diff --git a/templates/public/larp/details.html.twig b/templates/public/larp/details.html.twig index 07d66b2..16d3d9d 100755 --- a/templates/public/larp/details.html.twig +++ b/templates/public/larp/details.html.twig @@ -149,16 +149,46 @@ {% else %} -
-
- -
{{ 'larp.application_open'|trans }}
-

{{ 'larp.application_open_description'|trans }}

+ {# Application mode: Character Selection #} + {% if larp.applicationMode.value == 'character_selection' %} + {% if larp.publishCharactersPublicly %} + {# Show character gallery link #} +
+
+ +
{{ 'larp.browse_characters'|trans }}
+

{{ 'larp.browse_characters_description'|trans }}

+
+ + {{ 'larp.view_character_gallery'|trans }} + +
+ {% else %} + {# Traditional application form #} +
+
+ +
{{ 'larp.application_open'|trans }}
+

{{ 'larp.application_open_description'|trans }}

+
+ + {{ 'larp.apply_for_character'|trans }} + +
+ {% endif %} + {% elseif larp.applicationMode.value == 'survey' %} + {# Application mode: Survey #} +
+
+ +
{{ 'larp.survey_application'|trans }}
+

{{ 'larp.survey_application_description'|trans }}

+
+ + {{ 'larp.fill_out_survey'|trans }} +
- - {{ 'larp.apply_for_character'|trans }} - -
+ {% endif %} {% endif %}
diff --git a/templates/public/larp/list.html.twig b/templates/public/larp/list.html.twig index c9fda69..b2e46ee 100755 --- a/templates/public/larp/list.html.twig +++ b/templates/public/larp/list.html.twig @@ -22,7 +22,7 @@
- + {{ larp.title }} @@ -53,7 +53,7 @@ {% if larp.characterSystem %}
Characters: - {{ larp.characterSystem.label }} + {{ larp.characterSystem.label }}
{% endif %} diff --git a/templates/public/larp/my_larps.html.twig b/templates/public/larp/my_larps.html.twig index b5369d8..716fd02 100644 --- a/templates/public/larp/my_larps.html.twig +++ b/templates/public/larp/my_larps.html.twig @@ -21,7 +21,7 @@ {% else %} {% for participant in participants %}
-
+

-
{{ location.title }}
+
{{ location.title }}

{{ location.fullAddress }}

diff --git a/templates/public/location/list.html.twig b/templates/public/location/list.html.twig index bec7374..a681920 100644 --- a/templates/public/location/list.html.twig +++ b/templates/public/location/list.html.twig @@ -35,7 +35,7 @@ {% endif %}
- + {{ location.title }} diff --git a/templates/public/survey/submit.html.twig b/templates/public/survey/submit.html.twig new file mode 100644 index 0000000..b4717b3 --- /dev/null +++ b/templates/public/survey/submit.html.twig @@ -0,0 +1,99 @@ +{% extends 'public/base.html.twig' %} + +{% block body %} +
+
+
+ {# LARP Info Header #} + {% embed 'public/larp/_info_header.html.twig' with { + 'larp': larp, + 'showManageButton': false + } %} + {% endembed %} + + {# Survey Card #} +
+
+

+ {{ survey.title }} +

+
+ + {% if survey.description %} +
+
+ + {{ survey.description|nl2br }} +
+
+ {% endif %} + +
+ {{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }} + + {# Render each question #} + {% for key, field in form %} + {% if key starts with 'question_' %} +
+ {# Question label with required indicator #} +
+ {{ form_label(field, null, {'label_attr': {'class': 'fw-bold fs-5'}}) }} + {% if field.vars.required %} + * + {% endif %} +
+ + {# Help text #} + {% if field.vars.help %} +
+ {{ field.vars.help }} +
+ {% endif %} + + {# Widget rendering #} +
+ {{ form_widget(field) }} +
+ + {# Errors #} + {{ form_errors(field) }} +
+ {% endif %} + {% endfor %} + + {# Required fields notice #} +
+ + + {{ 'survey.required_fields_notice'|trans }} + +
+ + {# Form actions #} +
+ + {{ 'common.back'|trans }} + + + {{ form_widget(form.submit, {'attr': {'class': 'btn btn-success btn-lg'}}) }} +
+ + {{ form_end(form) }} +
+
+ + {# Additional info card #} +
+
+
+ {{ 'survey.what_happens_next'|trans }} +
+

+ {{ 'survey.submission_process_explanation'|trans }} +

+
+
+
+
+
+{% endblock %} diff --git a/tests/Acceptance/Core/LocationCreationTest.php b/tests/Acceptance/Core/LocationCreationTest.php new file mode 100644 index 0000000..5a6e11a --- /dev/null +++ b/tests/Acceptance/Core/LocationCreationTest.php @@ -0,0 +1,8 @@ +client = static::createClient(); - $this->em = static::getContainer()->get(EntityManagerInterface::class); - - // Ensure we have a LARP and a super-admin for it - $larp = $this->provideLarp(); - $user = $this->provideSuperAdmin($larp); - - // Symfony 6+: loginUser available via BrowserKit - $this->client->loginUser($user); - } - - public function test_super_admin_can_access_all_backoffice_pages(): void - { - /** @var RouterInterface $router */ - $router = static::getContainer()->get(RouterInterface::class); - - $routes = $router->getRouteCollection(); - $backofficePaths = $this->collectBackofficePaths($routes); - - self::assertNotEmpty($backofficePaths, 'No backoffice routes discovered. Adjust the filters in collectBackofficePaths().'); - - foreach ($backofficePaths as $path) { - // GET only, ignore routes with unresolved placeholders - if ($this->pathHasPlaceholders($path)) { - continue; - } - - $this->client->request('GET', $path); - $status = $this->client->getResponse()->getStatusCode(); - - // Accept 200 OK. If a route intentionally redirects to a default child, also accept 302. - $isOk = $status === Response::HTTP_OK || $status === Response::HTTP_FOUND; - $contentType = $this->client->getResponse()->headers->get('content-type'); - - self::assertTrue( - $isOk, - sprintf('Expected 200/302 for "%s", got %d. Content-Type: %s', $path, $status, (string) $contentType) - ); - } - } - - /** - * Change filters here if your backoffice is under a different prefix or namespace. - */ - private function collectBackofficePaths(RouteCollection $routes): array - { - $paths = []; - - /** @var Route $route */ - foreach ($routes as $name => $route) { - // Filter 1: path prefix - $path = $route->getPath(); - if (!is_string($path)) { - continue; - } - - $isBackofficePath = str_starts_with($path, '/backoffice') - || str_starts_with($path, '/admin'); // add/adjust as needed - - // Filter 2: controller namespace (safer if paths vary) - $defaults = $route->getDefaults(); - $controller = $defaults['_controller'] ?? null; - $isBackofficeController = is_string($controller) && str_contains($controller, 'Controller\\Backoffice\\'); - - if (!$isBackofficePath && !$isBackofficeController) { - continue; - } - - // Only GET - $methods = $route->getMethods(); - if (!empty($methods) && !in_array('GET', $methods, true)) { - continue; - } - - $paths[] = $path; - } - - // Unique and stable order - $paths = array_values(array_unique($paths)); - sort($paths); - - return $paths; - } - - private function pathHasPlaceholders(string $path): bool - { - // Basic check for unresolved {param}, skip those to keep test happy-path and env-agnostic - return (bool) preg_match('/\{[^}]+\}/', $path); - } - - private function provideLarp(): Larp - { - // Try to reuse an existing test LARP to speed up runs - $repo = $this->em->getRepository(Larp::class); - $larp = $repo->findOneBy(['slug' => 'test-larp']); - - if (!$larp instanceof Larp) { - $larp = new Larp(); - // Set minimum viable fields; adjust to your entity - $larp->setTitle('Test LARP'); - $larp->setDescription('Test LARP Description'); - $larp->setStartDate(new \DateTime('2025-01-01')); - $larp->setEndDate(new \DateTime('2025-01-03')); - $larp->setStatus(LarpStageStatus::DRAFT); - // Set createdBy to the user we're creating - $user = $this->provideSuperAdmin($larp); - $larp->setCreatedBy($user); - $this->em->persist($larp); - $this->em->flush(); - } - - return $larp; - } - - private function provideSuperAdmin(Larp $larp): User - { - $repo = $this->em->getRepository(User::class); - $user = $repo->findOneBy(['contactEmail' => 'superadmin+test@example.com']); - - if ($user instanceof User) { - return $user; - } - - $user = new User(); - // Set fields as per your User entity - $user->setUsername('superadmin_test'); - $user->setContactEmail('superadmin+test@example.com'); - - $roles = $user->getRoles(); - $roles[] = 'ROLE_SUPER_ADMIN'; - $user->setRoles(array_values(array_unique($roles))); - // add LarpParticipant to user with provided larp - - $this->em->persist($user); - $this->em->flush(); - - return $user; - } -} diff --git a/tests/Domain/Application/Controller/CharacterApplicationsControllerTest.php b/tests/Domain/Application/Controller/CharacterApplicationsControllerTest.php deleted file mode 100755 index 3ed10b7..0000000 --- a/tests/Domain/Application/Controller/CharacterApplicationsControllerTest.php +++ /dev/null @@ -1,124 +0,0 @@ -client = static::createClient(); - $this->em = static::getContainer()->get(EntityManagerInterface::class); - } - - public function testMatchPageLoads(): void - { - // Create test data with unique identifiers - $uniqueId = uniqid('match_', true); - $user = $this->createUser('user_' . $uniqueId, $uniqueId . '@example.com'); - $larp = $this->createLarp('Test LARP ' . $uniqueId, $user); - $character = $this->createCharacter($larp, 'Test Character', $user); - $application = $this->createApplication($larp, $user); - $this->createApplicationChoice($application, $character); - - $this->em->flush(); - - $this->client->loginUser($user); - - $this->client->request('GET', sprintf('/backoffice/larp/%s/applications/match', $larp->getId())); - - $this->assertResponseStatusCodeSame(403); - //TODO But add test for larp organizer - } - - public function testApplicantCantSeeBackofficePage(): void - { - // Create test data with unique identifiers - $uniqueId = uniqid('list_', true); - $user = $this->createUser('user_' . $uniqueId, $uniqueId . '@example.com'); - $larp = $this->createLarp('Test LARP ' . $uniqueId, $user); - $character = $this->createCharacter($larp, 'Character Name', $user); - $application = $this->createApplication($larp, $user); - $this->createApplicationChoice($application, $character); - - $this->em->flush(); - - // Login as user - $this->client->loginUser($user); - - // Make request to list page - $this->client->request('GET', sprintf('/backoffice/larp/%s/applications', $larp->getId())); - - // Assert response is successful - $this->assertResponseStatusCodeSame(403); - //TODO But add test for larp organizer - } - - private function createUser(string $username, string $email): User - { - $user = new User(); - $user->setUsername($username); - $user->setContactEmail($email); - $user->setStatus(UserStatus::APPROVED); - $user->setRoles(['ROLE_USER']); - $this->em->persist($user); - return $user; - } - - private function createLarp(string $title, User $createdBy): Larp - { - $larp = new Larp(); - $larp->setTitle($title); - $larp->setDescription('Test Description'); - $larp->setStartDate(new \DateTime('2025-01-01')); - $larp->setEndDate(new \DateTime('2025-01-03')); - $larp->setStatus(LarpStageStatus::DRAFT); - $larp->setCreatedBy($createdBy); - $this->em->persist($larp); - return $larp; - } - - private function createCharacter(Larp $larp, string $title, User $createdBy): Character - { - $character = new Character(); - $character->setLarp($larp); - $character->setTitle($title); - $character->setCreatedBy($createdBy); - $this->em->persist($character); - return $character; - } - - private function createApplication(Larp $larp, User $user): LarpApplication - { - $application = new LarpApplication(); - $application->setLarp($larp); - $application->setUser($user); - $application->setCreatedBy($user); - $this->em->persist($application); - return $application; - } - - private function createApplicationChoice(LarpApplication $application, Character $character): LarpApplicationChoice - { - $choice = new LarpApplicationChoice(); - $choice->setApplication($application); - $choice->setCharacter($character); - $choice->setPriority(1); - $this->em->persist($choice); - return $choice; - } -} diff --git a/tests/Domain/Integration/Form/FileMappingTypeTest.php b/tests/Domain/Integration/Form/FileMappingTypeTest.php deleted file mode 100755 index b35b623..0000000 --- a/tests/Domain/Integration/Form/FileMappingTypeTest.php +++ /dev/null @@ -1,59 +0,0 @@ -factory->create(FileMappingType::class, $model, [ - 'mimeType' => 'application/vnd.google-apps.document', - ]); - - $this->assertTrue($form->get('mappings')->has('title')); - $this->assertTrue($form->get('mappings')->has('description')); - } - - public function testEventDocSubFormAppears(): void - { - $model = new ExternalResourceMappingModel(ResourceType::EVENT_DOC); - $form = $this->factory->create(FileMappingType::class, $model, [ - 'mimeType' => 'application/vnd.google-apps.document', - ]); - - $this->assertTrue($form->get('mappings')->has('eventName')); - $this->assertTrue($form->get('mappings')->has('description')); - } - - public function testAllowedTypesForDocument(): void - { - $type = new FileMappingType(); - $allowed = $type->getAllowedResourceTypes('application/vnd.google-apps.document'); - $this->assertContains(ResourceType::CHARACTER_DOC, $allowed); - $this->assertContains(ResourceType::EVENT_DOC, $allowed); - } - - public function testSubFormMappingTypes(): void - { - $this->assertSame( - \App\Domain\Integrations\Form\Integrations\CharacterDocMappingType::class, - ResourceType::CHARACTER_DOC->getSubForm() - ); - $this->assertSame( - \App\Domain\Integrations\Form\Integrations\EventDocMappingType::class, - ResourceType::EVENT_DOC->getSubForm() - ); - } -} diff --git a/tests/Domain/Mailing/Service/MailTemplateDefinitionProviderTest.php b/tests/Domain/Mailing/Service/MailTemplateDefinitionProviderTest.php deleted file mode 100644 index 8f93764..0000000 --- a/tests/Domain/Mailing/Service/MailTemplateDefinitionProviderTest.php +++ /dev/null @@ -1,21 +0,0 @@ -getDefinitions(); - - self::assertCount(count(MailTemplateType::cases()), $definitions); - self::assertArrayHasKey(MailTemplateType::CHARACTER_ASSIGNMENT_PUBLISHED->value, $definitions); - $assignmentDefinition = $definitions[MailTemplateType::CHARACTER_ASSIGNMENT_PUBLISHED->value]; - self::assertContains('character_public_url', $assignmentDefinition->placeholders); - } -} diff --git a/tests/Domain/Public/Controller/PublicCommonControllerTest.php b/tests/Domain/Public/Controller/PublicCommonControllerTest.php deleted file mode 100755 index d067886..0000000 --- a/tests/Domain/Public/Controller/PublicCommonControllerTest.php +++ /dev/null @@ -1,18 +0,0 @@ -request('GET', '/switch-locale/de', [], [], ['HTTP_REFERER' => '/']); - - $this->assertResponseRedirects('/'); - $session = $client->getRequest()->getSession(); - $this->assertSame('de', $session->get('_locale')); - } -} diff --git a/tests/Domain/StoryMarketplace/Controller/RecruitmentControllerTest.php b/tests/Domain/StoryMarketplace/Controller/RecruitmentControllerTest.php deleted file mode 100755 index cabdc83..0000000 --- a/tests/Domain/StoryMarketplace/Controller/RecruitmentControllerTest.php +++ /dev/null @@ -1,15 +0,0 @@ -request('GET', '/backoffice/larp/00000000-0000-0000-0000-000000000000/story/thread/123/recruitment'); - $this->assertTrue($client->getResponse()->isRedirection() || $client->getResponse()->isClientError()); - } -} diff --git a/tests/Functional/Account/BackofficeAccessHappyPathCest.php b/tests/Functional/Account/BackofficeAccessHappyPathCest.php new file mode 100644 index 0000000..061ab19 --- /dev/null +++ b/tests/Functional/Account/BackofficeAccessHappyPathCest.php @@ -0,0 +1,115 @@ +createSuperAdmin(); + + $larp = LarpFactory::new() + ->withStatus(LarpStageStatus::DRAFT) + ->withCreator($user) + ->create(); + + LarpParticipantFactory::new() + ->organizer() + ->forLarp($larp) + ->forUser($user) + ->create(); + + $I->amLoggedInAs($user); + } + + public function superAdminCanAccessAllBackofficePages(FunctionalTester $I): void + { + $I->wantTo('verify that super admin can access all backoffice pages'); + + $router = $I->grabService(RouterInterface::class); + $routes = $router->getRouteCollection(); + $backofficePaths = $this->collectBackofficePaths($routes); + + $I->assertNotEmpty( + $backofficePaths, + 'No backoffice routes discovered. Adjust the filters in collectBackofficePaths().' + ); + + foreach ($backofficePaths as $path) { + // Skip routes with unresolved placeholders + if ($this->pathHasPlaceholders($path)) { + continue; + } + + $I->amOnPage($path); +// $I->g + $I->seeResponseCodeIsBetween(200,302); + } + } + + /** + * Collect all backoffice paths from route collection + * + * @return array + */ + private function collectBackofficePaths(RouteCollection $routes): array + { + $paths = []; + + /** @var Route $route */ + foreach ($routes as $name => $route) { + // Filter 1: path prefix + $path = $route->getPath(); + if (!is_string($path)) { + continue; + } + + $isBackofficePath = str_starts_with($path, '/backoffice') + || str_starts_with($path, '/admin'); + + // Filter 2: controller namespace (safer if paths vary) + $defaults = $route->getDefaults(); + $controller = $defaults['_controller'] ?? null; + $isBackofficeController = is_string($controller) && str_contains($controller, 'Controller\\Backoffice\\'); + + if (!$isBackofficePath && !$isBackofficeController) { + continue; + } + + // Only GET + $methods = $route->getMethods(); + if (!empty($methods) && !in_array('GET', $methods, true)) { + continue; + } + + $paths[] = $path; + } + + // Unique and stable order + $paths = array_values(array_unique($paths)); + sort($paths); + + return $paths; + } + + /** + * Check if path has unresolved placeholders + */ + private function pathHasPlaceholders(string $path): bool + { + return (bool) preg_match('/\{[^}]+\}/', $path); + } +} diff --git a/tests/Functional/Application/Controller/CharacterApplicationsControllerCest.php b/tests/Functional/Application/Controller/CharacterApplicationsControllerCest.php new file mode 100644 index 0000000..cad47ab --- /dev/null +++ b/tests/Functional/Application/Controller/CharacterApplicationsControllerCest.php @@ -0,0 +1,87 @@ +wantTo('verify that match page is not accessible to non-organizers'); + + // Create test data using factories + $creator = UserFactory::createApprovedUser(); + $user = UserFactory::createApprovedUser(); + $larp = LarpFactory::createDraftLarp($creator); + $character = CharacterFactory::new() + ->forLarp($larp) + ->create() + ->_real(); + + $application = LarpApplicationFactory::new() + ->forLarp($larp) + ->forUser($user) + ->create() + ->_real(); + + LarpApplicationChoiceFactory::new() + ->forApplication($application) + ->forCharacter($character) + ->topPriority() + ->create(); + + $I->amLoggedInAs($user); + + // Request match page + $I->amOnRoute('backoffice_larp_applications_match', ['larp' => $larp->getId()]); + + // Assert access is denied + $I->seeResponseCodeIs(403); + //TODO: Add test for larp organizer with proper permissions + } + + public function applicantCantSeeBackofficeApplicationsPage(FunctionalTester $I): void + { + $I->wantTo('verify that applicants cannot see backoffice applications page'); + + // Create test data using factories + $user = UserFactory::createApprovedUser(); + $creator = UserFactory::createApprovedUser(); + $larp = LarpFactory::createDraftLarp($creator); + $character = CharacterFactory::new() + ->forLarp($larp) + ->create() + ->_real(); + + $application = LarpApplicationFactory::new() + ->forLarp($larp) + ->forUser($user) + ->create() + ->_real(); + + LarpApplicationChoiceFactory::new() + ->forApplication($application) + ->forCharacter($character) + ->topPriority() + ->create(); + + // Login as user + $I->amLoggedInAs($user); + $I->amOnRoute('backoffice_larp_applications_list', ['larp' => $larp->getId()]); + $I->seeResponseCodeIs(403); + + $I->amLoggedInAs($creator); + $I->amOnRoute('backoffice_larp_applications_list', ['larp' => $larp->getId()]); + $I->seeResponseCodeIs(200); + } +} diff --git a/tests/Functional/Authentication/UserSignupAndApprovalCest.php b/tests/Functional/Authentication/UserSignupAndApprovalCest.php new file mode 100644 index 0000000..cba51eb --- /dev/null +++ b/tests/Functional/Authentication/UserSignupAndApprovalCest.php @@ -0,0 +1,278 @@ +stopFollowingRedirects(); + } + + public function newUserHasPendingStatus(FunctionalTester $I): void + { + $I->wantTo('verify that new users are created with PENDING status'); + + $user = UserFactory::createPendingUser(); + + $I->assertTrue($user->isPending(), 'New user should have PENDING status'); + $I->assertFalse($user->isApproved(), 'New user should not be approved'); + $I->assertEquals(UserStatus::PENDING, $user->getStatus()); + } + + public function pendingUserCanAccessPublicPages(FunctionalTester $I): void + { + $I->wantTo('verify PENDING users can access public pages'); + + $pendingUser = UserFactory::createPendingUser(); + $I->amLoggedInAs($pendingUser); + + // Test accessing homepage + $I->amOnRoute('public_larp_list'); + $I->seeResponseCodeIsSuccessful(); + + // Test accessing public LARP list + $I->amOnRoute('public_larp_list'); + $I->seeResponseCodeIsSuccessful(); + } + + public function pendingUserCannotAccessBackoffice(FunctionalTester $I): void + { + $I->wantTo('verify PENDING users cannot access backoffice'); + + $pendingUser = UserFactory::createPendingUser(); + $I->amLoggedInAs($pendingUser); + + // Try to access backoffice + $I->amOnRoute('backoffice_larp_create'); + + // Should be redirected to pending approval page + $I->seeResponseCodeIs(302); + } + + public function pendingUserCannotAccessLarpCreation(FunctionalTester $I): void + { + $I->wantTo('verify PENDING users cannot create LARPs'); + + $pendingUser = UserFactory::createPendingUser(); + $I->amLoggedInAs($pendingUser); + + $I->amOnRoute('backoffice_larp_create'); + + // Should be redirected to pending approval page + $I->seeResponseCodeIs(302); + } + + public function approvedUserCanAccessBackoffice(FunctionalTester $I): void + { + $I->wantTo('verify APPROVED users can access backoffice'); + + $approvedUser = UserFactory::createApprovedUser(); + $I->amLoggedInAs($approvedUser); + + // Try to access backoffice + $I->amOnRoute('backoffice_dashboard'); + + $I->seeResponseCodeIsSuccessful(); + } + + public function userCanBeProgrammaticallyApproved(FunctionalTester $I): void + { + $I->wantTo('verify users can be approved programmatically'); + + $user = UserFactory::createPendingUser(); + + $I->assertTrue($user->isPending(), 'User should start as PENDING'); + + // Approve user + $user->setStatus(UserStatus::APPROVED); + $I->getEntityManager()->flush(); + + $I->assertTrue($user->isApproved(), 'User should be APPROVED after approval'); + $I->assertFalse($user->isPending(), 'User should no longer be PENDING'); + $I->assertEquals(UserStatus::APPROVED, $user->getStatus()); + } + + public function approvedUserCanBeSuspended(FunctionalTester $I): void + { + $I->wantTo('verify approved users can be suspended'); + + $user = UserFactory::createApprovedUser(); + + $I->assertTrue($user->isApproved(), 'User should start as APPROVED'); + + // Suspend user + $user->setStatus(UserStatus::SUSPENDED); + $I->getEntityManager()->flush(); + + $I->assertTrue($user->isSuspended(), 'User should be SUSPENDED after suspension'); + $I->assertFalse($user->isApproved(), 'User should no longer be APPROVED'); + $I->assertEquals(UserStatus::SUSPENDED, $user->getStatus()); + } + + public function userCanBeBanned(FunctionalTester $I): void + { + $I->wantTo('verify users can be banned'); + + $user = UserFactory::createApprovedUser(); + + $I->assertTrue($user->isApproved(), 'User should start as APPROVED'); + + // Ban user + $user->setStatus(UserStatus::BANNED); + $I->getEntityManager()->flush(); + + $I->assertTrue($user->isBanned(), 'User should be BANNED after banning'); + $I->assertFalse($user->isApproved(), 'User should no longer be APPROVED'); + $I->assertEquals(UserStatus::BANNED, $user->getStatus()); + } + + public function suspendedUserCannotAccessBackoffice(FunctionalTester $I): void + { + $I->wantTo('verify SUSPENDED users cannot access backoffice'); + + $suspendedUser = UserFactory::createSuspendedUser(); + $I->amLoggedInAs($suspendedUser); + + $I->amOnRoute('backoffice_dashboard'); + + $I->seeResponseCodeIs(302); + $I->assertResponseRedirects($I->getUrl('backoffice_account_pending_approval')); + } + + public function bannedUserCannotAccessBackoffice(FunctionalTester $I): void + { + $I->wantTo('verify BANNED users cannot access backoffice'); + + $bannedUser = UserFactory::createBannedUser(); + $I->amLoggedInAs($bannedUser); + + $I->amOnRoute('backoffice_dashboard'); + + $I->seeResponseCodeIs(302); + $I->assertResponseRedirects($I->getUrl('backoffice_account_pending_approval')); + } + + public function suspendedUserCannotCreateLarp(FunctionalTester $I): void + { + $I->wantTo('verify SUSPENDED users cannot create LARPs'); + + $suspendedUser = UserFactory::createSuspendedUser(); + $I->amLoggedInAs($suspendedUser); + + $I->amOnRoute('backoffice_larp_create'); + + $I->seeResponseCodeIs(302); + $I->assertResponseRedirects($I->getUrl('backoffice_account_pending_approval')); + } + + public function bannedUserCannotCreateLarp(FunctionalTester $I): void + { + $I->wantTo('verify BANNED users cannot create LARPs'); + + $bannedUser = UserFactory::createBannedUser(); + $I->amLoggedInAs($bannedUser); + + $I->amOnRoute('backoffice_larp_create'); + + $I->seeResponseCodeIs(302); + $I->assertResponseRedirects($I->getUrl('backoffice_account_pending_approval')); + } + + public function superAdminCanAccessSuperAdminRoutes(FunctionalTester $I): void + { + $I->wantTo('verify SUPER_ADMIN can access super-admin routes'); + + $superAdmin = UserFactory::createSuperAdmin(); + $I->amLoggedInAs($superAdmin); + + // Try to access super admin area + $I->amOnRoute('super_admin_users_list'); + + // Should be successful + $I->seeResponseCodeIs(200); + } + + public function regularUserCannotAccessSuperAdminRoutes(FunctionalTester $I): void + { + $I->wantTo('verify regular users cannot access super-admin routes'); + + $regularUser = UserFactory::createApprovedUser(); + $I->amLoggedInAs($regularUser); + + $I->amOnRoute('super_admin_users_list'); + + $I->seeResponseCodeIs(403); + } + + public function unauthenticatedUserRedirectedToLogin(FunctionalTester $I): void + { + $I->wantTo('verify unauthenticated users are redirected to login'); + $I->logoutProgrammatically(); + + // Stop following redirects to test the redirect response itself + + + $I->amOnRoute('backoffice_dashboard'); + + // Should receive a redirect response + $I->seeResponseCodeIsRedirection(); + } + + public function pendingUserStatusPersistsAfterFlush(FunctionalTester $I): void + { + $I->wantTo('verify PENDING status persists after entity manager flush'); + + $user = UserFactory::createPendingUser(); + $userId = $user->getId(); + + // Clear entity manager to force reload from database + $I->getEntityManager()->clear(); + + // Reload user from database + $reloadedUser = $I->getEntityManager()->find(User::class, $userId); + + $I->assertNotNull($reloadedUser, 'User should be persisted in database'); + $I->assertEquals(UserStatus::PENDING, $reloadedUser->getStatus(), 'Status should persist'); + $I->assertTrue($reloadedUser->isPending(), 'User should still be PENDING after reload'); + } + + public function approvedStatusPersistsAfterFlush(FunctionalTester $I): void + { + $I->wantTo('verify APPROVED status persists after entity manager flush'); + + $user = UserFactory::createPendingUser(); + $userId = $user->getId(); + + // Approve user + $user->setStatus(UserStatus::APPROVED); + $I->getEntityManager()->flush(); + + // Clear entity manager to force reload from database + $I->getEntityManager()->clear(); + + // Reload user from database + $reloadedUser = $I->getEntityManager()->find(User::class, $userId); + + $I->assertNotNull($reloadedUser, 'User should be persisted in database'); + $I->assertEquals(UserStatus::APPROVED, $reloadedUser->getStatus(), 'Approved status should persist'); + $I->assertTrue($reloadedUser->isApproved(), 'User should still be APPROVED after reload'); + } +} diff --git a/tests/Functional/Authentication/UserSignupAndApprovalTest.php b/tests/Functional/Authentication/UserSignupAndApprovalTest.php deleted file mode 100644 index 5acce70..0000000 --- a/tests/Functional/Authentication/UserSignupAndApprovalTest.php +++ /dev/null @@ -1,262 +0,0 @@ -createPendingUser(); - - $this->assertTrue($user->isPending(), 'New user should have PENDING status'); - $this->assertFalse($user->isApproved(), 'New user should not be approved'); - $this->assertEquals(UserStatus::PENDING, $user->getStatus()); - } - - public function test_pending_user_can_access_public_pages(): void - { - $client = static::createClient(); - $pendingUser = $this->createPendingUser(); - - $client->loginUser($pendingUser); - - // Test accessing homepage - $client->request('GET', $this->generateUrl('public_larp_list')); - $this->assertResponseIsSuccessful('PENDING user should be able to access homepage'); - - // Test accessing public LARP list - $client->request('GET', $this->generateUrl('public_larp_list')); - $this->assertResponseIsSuccessful('PENDING user should be able to access public LARP list'); - } - - public function test_pending_user_cannot_access_backoffice(): void - { - $client = static::createClient(); - $pendingUser = $this->createPendingUser(); - - $client->loginUser($pendingUser); - - // Try to access backoffice - $client->request('GET', $this->generateUrl('backoffice_larp_create')); - - // Should be redirected to pending approval page - $this->assertResponseRedirects(null, null, 'PENDING user should be redirected from backoffice'); - } - - public function test_pending_user_cannot_access_larp_creation(): void - { - $client = static::createClient(); - $pendingUser = $this->createPendingUser(); - - $client->loginUser($pendingUser); - - $client->request('GET', $this->generateUrl('backoffice_larp_create')); - - // Should be redirected to pending approval page - $this->assertResponseRedirects(null, null, 'PENDING user should be redirected from LARP creation'); - } - - public function test_approved_user_can_access_backoffice(): void - { - $client = static::createClient(); - $approvedUser = $this->createApprovedUser(); - - $client->loginUser($approvedUser); - - // Try to access backoffice - $client->request('GET', $this->generateUrl('backoffice_dashboard')); - - $this->assertResponseIsSuccessful('APPROVED user should be able to access backoffice'); - } - - public function test_user_can_be_programmatically_approved(): void - { - $user = $this->createPendingUser(); - - $this->assertTrue($user->isPending(), 'User should start as PENDING'); - - $this->approveUser($user); - - $this->assertTrue($user->isApproved(), 'User should be APPROVED after approval'); - $this->assertFalse($user->isPending(), 'User should no longer be PENDING'); - $this->assertEquals(UserStatus::APPROVED, $user->getStatus()); - } - - public function test_approved_user_can_be_suspended(): void - { - $user = $this->createApprovedUser(); - - $this->assertTrue($user->isApproved(), 'User should start as APPROVED'); - - $this->suspendUser($user); - - $this->assertTrue($user->isSuspended(), 'User should be SUSPENDED after suspension'); - $this->assertFalse($user->isApproved(), 'User should no longer be APPROVED'); - $this->assertEquals(UserStatus::SUSPENDED, $user->getStatus()); - } - - public function test_user_can_be_banned(): void - { - $user = $this->createApprovedUser(); - - $this->assertTrue($user->isApproved(), 'User should start as APPROVED'); - - $this->banUser($user); - - $this->assertTrue($user->isBanned(), 'User should be BANNED after banning'); - $this->assertFalse($user->isApproved(), 'User should no longer be APPROVED'); - $this->assertEquals(UserStatus::BANNED, $user->getStatus()); - } - - public function test_suspended_user_cannot_access_backoffice(): void - { - $client = static::createClient(); - $suspendedUser = $this->createSuspendedUser(); - - $client->loginUser($suspendedUser); - - $client->request('GET', $this->generateUrl('backoffice_dashboard')); - - $this->assertResponseStatusCodeSame(302); - $this->assertResponseRedirects($this->generateUrl('backoffice_account_pending_approval')); - } - - public function test_banned_user_cannot_access_backoffice(): void - { - $client = static::createClient(); - $bannedUser = $this->createBannedUser(); - - $client->loginUser($bannedUser); - - $client->request('GET', $this->generateUrl('backoffice_dashboard')); - - $this->assertResponseStatusCodeSame(302); - $this->assertResponseRedirects($this->generateUrl('backoffice_account_pending_approval')); - } - - public function test_suspended_user_cannot_create_larp(): void - { - $client = static::createClient(); - $suspendedUser = $this->createSuspendedUser(); - - $client->loginUser($suspendedUser); - - $client->request('GET', $this->generateUrl('backoffice_larp_create')); - - $this->assertResponseStatusCodeSame(302); - $this->assertResponseRedirects($this->generateUrl('backoffice_account_pending_approval')); - } - - public function test_banned_user_cannot_create_larp(): void - { - $client = static::createClient(); - $bannedUser = $this->createBannedUser(); - - $client->loginUser($bannedUser); - - $client->request('GET', $this->generateUrl('backoffice_larp_create')); - - $this->assertResponseStatusCodeSame(302); - $this->assertResponseRedirects($this->generateUrl('backoffice_account_pending_approval')); - } - - public function test_super_admin_can_access_super_admin_routes(): void - { - $client = static::createClient(); - $superAdmin = $this->createSuperAdmin(); - - $client->loginUser($superAdmin); - - // Try to access super admin area - $client->request('GET', $this->generateUrl('super_admin_users_list')); - - // Should be successful or redirect to a valid super-admin page - $this->assertResponseStatusCodeSame( - 200, - 'SUPER_ADMIN should be able to access super-admin routes' - ); - } - - public function test_regular_user_cannot_access_super_admin_routes(): void - { - $client = static::createClient(); - $regularUser = $this->createApprovedUser(); - - $client->loginUser($regularUser); - - $client->request('GET', $this->generateUrl('super_admin_users_list')); - - $this->assertResponseStatusCodeSame(403, 'Regular user should not access super-admin routes'); - } - - public function test_unauthenticated_user_redirected_to_login(): void - { - $client = static::createClient(); - - $client->request('GET', $this->generateUrl('backoffice_dashboard')); - - $this->assertResponseRedirects(null, null, 'Unauthenticated user should be redirected to login'); - } - - public function test_pending_user_status_persists_after_flush(): void - { - $user = $this->createPendingUser(); - $userId = $user->getId(); - - // Clear entity manager to force reload from database - $this->getEntityManager()->clear(); - - // Reload user from database - $reloadedUser = $this->getEntityManager()->find(\App\Domain\Account\Entity\User::class, $userId); - - $this->assertNotNull($reloadedUser, 'User should be persisted in database'); - $this->assertEquals(UserStatus::PENDING, $reloadedUser->getStatus(), 'Status should persist'); - $this->assertTrue($reloadedUser->isPending(), 'User should still be PENDING after reload'); - } - - public function test_approved_status_persists_after_flush(): void - { - $user = $this->createPendingUser(); - $userId = $user->getId(); - - $this->approveUser($user); - - // Clear entity manager to force reload from database - $this->getEntityManager()->clear(); - - // Reload user from database - $reloadedUser = $this->getEntityManager()->find(\App\Domain\Account\Entity\User::class, $userId); - - $this->assertNotNull($reloadedUser, 'User should be persisted in database'); - $this->assertEquals(UserStatus::APPROVED, $reloadedUser->getStatus(), 'Approved status should persist'); - $this->assertTrue($reloadedUser->isApproved(), 'User should still be APPROVED after reload'); - } -} diff --git a/tests/Functional/Authorization/AcceptedUserPermissionsCest.php b/tests/Functional/Authorization/AcceptedUserPermissionsCest.php new file mode 100644 index 0000000..2b6ffb9 --- /dev/null +++ b/tests/Functional/Authorization/AcceptedUserPermissionsCest.php @@ -0,0 +1,338 @@ +stopFollowingRedirects(); + } + + public function approvedUserCanAccessLarpCreationForm(FunctionalTester $I): void + { + $I->wantTo('verify APPROVED user can access LARP creation form'); + + $approvedUser = UserFactory::createApprovedUser(); + + $I->amLoggedInAs($approvedUser); + + $I->amOnRoute('backoffice_larp_create'); + + $I->seeResponseCodeIsSuccessful(); + } + + public function approvedUserCanCreateFirstLarp(FunctionalTester $I): void + { + $I->wantTo('verify APPROVED user can create their first LARP'); + + $approvedUser = UserFactory::createApprovedUser(); + + // User should be able to create their first LARP (free tier default) + $I->amLoggedInAs($approvedUser); + + $authChecker = $I->grabService('security.authorization_checker'); + $canCreate = $authChecker->isGranted('CREATE_LARP'); + + $I->assertTrue($canCreate, 'APPROVED user should be able to create their first LARP'); + } + + public function newLarpDefaultsToDraftStatus(FunctionalTester $I): void + { + $I->wantTo('verify new LARP defaults to DRAFT status'); + + $approvedUser = UserFactory::createApprovedUser(); + + $larp = $I->createLarp($approvedUser); + + $I->assertEquals( + LarpStageStatus::DRAFT, + $larp->getStatus(), + 'New LARP should default to DRAFT status' + ); + } + + public function freeTierUserCannotCreateSecondLarp(FunctionalTester $I): void + { + $I->wantTo('verify free tier user cannot create second LARP'); + + $approvedUser = UserFactory::createApprovedUser(); + + // Create first LARP (should succeed) + $firstLarp = LarpFactory::createDraftLarp($approvedUser, 'First LARP'); + + $I->assertNotNull($firstLarp, 'First LARP should be created successfully'); + + // Try to create second LARP (should fail for free tier) + $I->amLoggedInAs($approvedUser); + + $authChecker = $I->grabService('security.authorization_checker'); + $canCreateSecond = $authChecker->isGranted('CREATE_LARP'); + + $I->assertFalse( + $canCreateSecond, + 'Free tier user should not be able to create second LARP' + ); + } + + public function freeTierUserWithPlanCanCreateOneLarp(FunctionalTester $I): void + { + $I->wantTo('verify free plan user can create 1 LARP'); + + $freePlan = $I->createFreePlan(); + $approvedUser = $I->createApprovedUser(null, $freePlan); + + $I->amLoggedInAs($approvedUser); + + $authChecker = $I->grabService('security.authorization_checker'); + $canCreate = $authChecker->isGranted('CREATE_LARP'); + + $I->assertTrue($canCreate, 'Free plan user should be able to create 1 LARP'); + + // Create the LARP + $larp = LarpFactory::createDraftLarp($approvedUser); + $I->assertNotNull($larp); + + // Verify cannot create second + $I->getEntityManager()->clear(); // Refresh to get updated counts + $I->amLoggedInAs($approvedUser); + $canCreateSecond = $authChecker->isGranted('CREATE_LARP'); + + $I->assertFalse($canCreateSecond, 'Free plan user should not create second LARP'); + } + + public function premiumPlanUserRespectsMaxLarpsLimit(FunctionalTester $I): void + { + $I->wantTo('verify premium plan user respects maxLarps limit'); + + $premiumPlan = $I->createPremiumPlan(3); // Max 3 LARPs + $approvedUser = $I->createApprovedUser(null, $premiumPlan); + + // Create 3 LARPs + $larp1 = LarpFactory::createDraftLarp($approvedUser, 'LARP 1'); + $larp2 = LarpFactory::createDraftLarp($approvedUser, 'LARP 2'); + $larp3 = LarpFactory::createDraftLarp($approvedUser, 'LARP 3'); + + $I->assertNotNull($larp1); + $I->assertNotNull($larp2); + $I->assertNotNull($larp3); + + // Verify cannot create 4th LARP + $I->amLoggedInAs($approvedUser); + + $I->getEntityManager()->clear(); + $I->amLoggedInAs($approvedUser); + + $authChecker = $I->grabService('security.authorization_checker'); + $canCreateFourth = $authChecker->isGranted('CREATE_LARP'); + + $I->assertFalse($canCreateFourth, 'Premium plan user should not exceed maxLarps limit'); + } + + public function unlimitedPlanUserCanCreateMultipleLarps(FunctionalTester $I): void + { + $I->wantTo('verify unlimited plan user can create multiple LARPs'); + + $unlimitedPlan = $I->createUnlimitedPlan(); + $approvedUser = $I->createApprovedUser(null, $unlimitedPlan); + + // Create multiple LARPs + $larp1 = LarpFactory::createDraftLarp($approvedUser, 'LARP 1'); + $larp2 = LarpFactory::createDraftLarp($approvedUser, 'LARP 2'); + $larp3 = LarpFactory::createDraftLarp($approvedUser, 'LARP 3'); + $larp4 = LarpFactory::createDraftLarp($approvedUser, 'LARP 4'); + $larp5 = LarpFactory::createDraftLarp($approvedUser, 'LARP 5'); + + $I->assertNotNull($larp1); + $I->assertNotNull($larp2); + $I->assertNotNull($larp3); + $I->assertNotNull($larp4); + $I->assertNotNull($larp5); + + // Verify can still create more + $I->amLoggedInAs($approvedUser); + + $authChecker = $I->grabService('security.authorization_checker'); + $canCreateMore = $authChecker->isGranted('CREATE_LARP'); + + $I->assertTrue($canCreateMore, 'Unlimited plan user should be able to create more LARPs'); + } + + public function superAdminBypassesPlanLimits(FunctionalTester $I): void + { + $I->wantTo('verify SUPER_ADMIN bypasses plan limits'); + + $superAdmin = $I->createSuperAdmin(); + + // Create multiple LARPs without a plan + $larp1 = LarpFactory::createDraftLarp($superAdmin, 'Admin LARP 1'); + $larp2 = LarpFactory::createDraftLarp($superAdmin, 'Admin LARP 2'); + $larp3 = LarpFactory::createDraftLarp($superAdmin, 'Admin LARP 3'); + + $I->assertNotNull($larp1); + $I->assertNotNull($larp2); + $I->assertNotNull($larp3); + + // Verify can still create more + $I->amLoggedInAs($superAdmin); + + $authChecker = $I->grabService('security.authorization_checker'); + $canCreateMore = $authChecker->isGranted('CREATE_LARP'); + + $I->assertTrue($canCreateMore, 'SUPER_ADMIN should bypass all plan limits'); + } + + public function approvedUserCanAccessLocationCreationForm(FunctionalTester $I): void + { + $I->wantTo('verify APPROVED user can access Location creation form'); + + $approvedUser = UserFactory::createApprovedUser(); + + $I->amLoggedInAs($approvedUser); + + $I->amOnRoute('backoffice_location_modify_global', ['location' => 'new']); + + $I->seeResponseCodeIsSuccessful(); + } + + public function approvedUserCanCreateLocation(FunctionalTester $I): void + { + $I->wantTo('verify APPROVED user can create Location'); + + $approvedUser = UserFactory::createApprovedUser(); + + $location = $I->createLocation($approvedUser); + + $I->assertNotNull($location, 'APPROVED user should be able to create Location'); + $I->assertEquals($approvedUser, $location->getCreatedBy()); + } + + public function createdLocationDefaultsToPendingStatus(FunctionalTester $I): void + { + $I->wantTo('verify created Location defaults to PENDING status'); + + $approvedUser = UserFactory::createApprovedUser(); + + $location = $I->createLocation($approvedUser); + + $I->assertEquals( + LocationApprovalStatus::PENDING, + $location->getApprovalStatus(), + 'Created Location should default to PENDING status for regular users' + ); + } + + public function superAdminLocationIsAutoApproved(FunctionalTester $I): void + { + $I->wantTo('verify SUPER_ADMIN Location is auto-approved'); + + $superAdmin = $I->createSuperAdmin(); + + // Use LocationApprovalService to create location (simulating real flow) + $locationApprovalService = $I->grabService( + \App\Domain\Core\Service\LocationApprovalService::class + ); + + $location = $I->createLocation($superAdmin); + + // Auto-approve it + $locationApprovalService->autoApprove($location, $superAdmin); + + $I->assertEquals( + LocationApprovalStatus::APPROVED, + $location->getApprovalStatus(), + 'SUPER_ADMIN Location should be auto-approved' + ); + $I->assertEquals($superAdmin, $location->getApprovedBy()); + $I->assertNotNull($location->getApprovedAt()); + } + + public function approvedUserCanCreateMultipleLocations(FunctionalTester $I): void + { + $I->wantTo('verify APPROVED user can create multiple Locations'); + + $approvedUser = UserFactory::createApprovedUser(); + + $location1 = $I->createLocation($approvedUser, LocationApprovalStatus::PENDING, 'Location 1'); + $location2 = $I->createLocation($approvedUser, LocationApprovalStatus::PENDING, 'Location 2'); + $location3 = $I->createLocation($approvedUser, LocationApprovalStatus::PENDING, 'Location 3'); + + $I->assertNotNull($location1); + $I->assertNotNull($location2); + $I->assertNotNull($location3); + + // Verify all are PENDING + $I->assertEquals(LocationApprovalStatus::PENDING, $location1->getApprovalStatus()); + $I->assertEquals(LocationApprovalStatus::PENDING, $location2->getApprovalStatus()); + $I->assertEquals(LocationApprovalStatus::PENDING, $location3->getApprovalStatus()); + } + + public function larpCreationSetsOrganizerAsParticipant(FunctionalTester $I): void + { + $I->wantTo('verify LARP creation sets organizer as participant'); + + $approvedUser = UserFactory::createApprovedUser(); + + $larp = LarpFactory::createDraftLarp($approvedUser); + + // Verify user is added as participant with ORGANIZER role + $participants = $larp->getLarpParticipants(); + $I->assertCount(1, $participants, 'LARP should have 1 participant (organizer)'); + + $participant = $participants[0]; + $I->assertEquals($approvedUser, $participant->getUser()); + $I->assertTrue( + $participant->isOrganizer(), + 'Creator should have ORGANIZER role' + ); + } + + public function userLarpCountReflectsOrganizerRole(FunctionalTester $I): void + { + $I->wantTo('verify user LARP count reflects organizer role'); + + $approvedUser = UserFactory::createApprovedUser(); + + $initialCount = $approvedUser->getOrganizerLarpCount(); + + $larp1 = LarpFactory::createDraftLarp($approvedUser); + + // Clear and reload to get fresh count + $I->getEntityManager()->clear(); + $reloadedUser = $I->getEntityManager()->find( + \App\Domain\Account\Entity\User::class, + $approvedUser->getId() + ); + + $newCount = $reloadedUser->getOrganizerLarpCount(); + + $I->assertEquals( + $initialCount + 1, + $newCount, + 'Organizer LARP count should increase after creating LARP' + ); + } +} diff --git a/tests/Functional/Authorization/AcceptedUserPermissionsTest.php b/tests/Functional/Authorization/AcceptedUserPermissionsTest.php deleted file mode 100644 index 450ab30..0000000 --- a/tests/Functional/Authorization/AcceptedUserPermissionsTest.php +++ /dev/null @@ -1,314 +0,0 @@ -client = static::createClient(); - } - - - public function test_approved_user_can_access_larp_creation_form(): void - { - $approvedUser = $this->createApprovedUser(); - - $this->client->loginUser($approvedUser); - - $this->client->request('GET', $this->generateUrl('backoffice_larp_create')); - - $this->assertResponseIsSuccessful('APPROVED user should access LARP creation form'); - } - - public function test_approved_user_can_create_first_larp(): void - { - $approvedUser = $this->createApprovedUser(); - - // User should be able to create their first LARP (free tier default) - $this->client->loginUser($approvedUser); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canCreate = $authChecker->isGranted('CREATE_LARP'); - - $this->assertTrue($canCreate, 'APPROVED user should be able to create their first LARP'); - } - - public function test_new_larp_defaults_to_draft_status(): void - { - $approvedUser = $this->createApprovedUser(); - - $larp = $this->createLarp($approvedUser); - - $this->assertEquals( - LarpStageStatus::DRAFT, - $larp->getStatus(), - 'New LARP should default to DRAFT status' - ); - } - - public function test_free_tier_user_cannot_create_second_larp(): void - { - $approvedUser = $this->createApprovedUser(); - - // Create first LARP (should succeed) - $firstLarp = $this->createDraftLarp($approvedUser, 'First LARP'); - - $this->assertNotNull($firstLarp, 'First LARP should be created successfully'); - - // Try to create second LARP (should fail for free tier) - $this->client->loginUser($approvedUser); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canCreateSecond = $authChecker->isGranted('CREATE_LARP'); - - $this->assertFalse( - $canCreateSecond, - 'Free tier user should not be able to create second LARP' - ); - } - - public function test_free_tier_user_with_plan_can_create_one_larp(): void - { - $freePlan = $this->createFreePlan(); - $approvedUser = $this->createApprovedUser(null, $freePlan); - - $this->client->loginUser($approvedUser); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canCreate = $authChecker->isGranted('CREATE_LARP'); - - $this->assertTrue($canCreate, 'Free plan user should be able to create 1 LARP'); - - // Create the LARP - $larp = $this->createDraftLarp($approvedUser); - $this->assertNotNull($larp); - - // Verify cannot create second - $this->getEntityManager()->clear(); // Refresh to get updated counts - $this->client->loginUser($approvedUser); - $canCreateSecond = $authChecker->isGranted('CREATE_LARP'); - - $this->assertFalse($canCreateSecond, 'Free plan user should not create second LARP'); - } - - public function test_premium_plan_user_respects_max_larps_limit(): void - { - $premiumPlan = $this->createPremiumPlan(3); // Max 3 LARPs - $approvedUser = $this->createApprovedUser(null, $premiumPlan); - - // Create 3 LARPs - $larp1 = $this->createDraftLarp($approvedUser, 'LARP 1'); - $larp2 = $this->createDraftLarp($approvedUser, 'LARP 2'); - $larp3 = $this->createDraftLarp($approvedUser, 'LARP 3'); - - $this->assertNotNull($larp1); - $this->assertNotNull($larp2); - $this->assertNotNull($larp3); - - // Verify cannot create 4th LARP - $this->client->loginUser($approvedUser); - - $this->getEntityManager()->clear(); - $this->client->loginUser($approvedUser); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canCreateFourth = $authChecker->isGranted('CREATE_LARP'); - - $this->assertFalse($canCreateFourth, 'Premium plan user should not exceed maxLarps limit'); - } - - public function test_unlimited_plan_user_can_create_multiple_larps(): void - { - $unlimitedPlan = $this->createUnlimitedPlan(); - $approvedUser = $this->createApprovedUser(null, $unlimitedPlan); - - // Create multiple LARPs - $larp1 = $this->createDraftLarp($approvedUser, 'LARP 1'); - $larp2 = $this->createDraftLarp($approvedUser, 'LARP 2'); - $larp3 = $this->createDraftLarp($approvedUser, 'LARP 3'); - $larp4 = $this->createDraftLarp($approvedUser, 'LARP 4'); - $larp5 = $this->createDraftLarp($approvedUser, 'LARP 5'); - - $this->assertNotNull($larp1); - $this->assertNotNull($larp2); - $this->assertNotNull($larp3); - $this->assertNotNull($larp4); - $this->assertNotNull($larp5); - - // Verify can still create more - $this->client->loginUser($approvedUser); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canCreateMore = $authChecker->isGranted('CREATE_LARP'); - - $this->assertTrue($canCreateMore, 'Unlimited plan user should be able to create more LARPs'); - } - - public function test_super_admin_bypasses_plan_limits(): void - { - $superAdmin = $this->createSuperAdmin(); - - // Create multiple LARPs without a plan - $larp1 = $this->createDraftLarp($superAdmin, 'Admin LARP 1'); - $larp2 = $this->createDraftLarp($superAdmin, 'Admin LARP 2'); - $larp3 = $this->createDraftLarp($superAdmin, 'Admin LARP 3'); - - $this->assertNotNull($larp1); - $this->assertNotNull($larp2); - $this->assertNotNull($larp3); - - // Verify can still create more - $this->client->loginUser($superAdmin); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canCreateMore = $authChecker->isGranted('CREATE_LARP'); - - $this->assertTrue($canCreateMore, 'SUPER_ADMIN should bypass all plan limits'); - } - - public function test_approved_user_can_access_location_creation_form(): void - { - $approvedUser = $this->createApprovedUser(); - - $this->client->loginUser($approvedUser); - - $this->client->request('GET', $this->generateUrl('backoffice_location_modify_global', ['location' => 'new'])); - - $this->assertResponseIsSuccessful('APPROVED user should access Location creation form'); - } - - public function test_approved_user_can_create_location(): void - { - $approvedUser = $this->createApprovedUser(); - - $location = $this->createLocation($approvedUser); - - $this->assertNotNull($location, 'APPROVED user should be able to create Location'); - $this->assertEquals($approvedUser, $location->getCreatedBy()); - } - - public function test_created_location_defaults_to_pending_status(): void - { - $approvedUser = $this->createApprovedUser(); - - $location = $this->createLocation($approvedUser); - - $this->assertEquals( - LocationApprovalStatus::PENDING, - $location->getApprovalStatus(), - 'Created Location should default to PENDING status for regular users' - ); - } - - public function test_super_admin_location_is_auto_approved(): void - { - $superAdmin = $this->createSuperAdmin(); - - // Use LocationApprovalService to create location (simulating real flow) - $locationApprovalService = static::getContainer()->get( - \App\Domain\Core\Service\LocationApprovalService::class - ); - - $location = $this->createLocation($superAdmin); - - // Auto-approve it - $locationApprovalService->autoApprove($location, $superAdmin); - - $this->assertEquals( - LocationApprovalStatus::APPROVED, - $location->getApprovalStatus(), - 'SUPER_ADMIN Location should be auto-approved' - ); - $this->assertEquals($superAdmin, $location->getApprovedBy()); - $this->assertNotNull($location->getApprovedAt()); - } - - public function test_approved_user_can_create_multiple_locations(): void - { - $approvedUser = $this->createApprovedUser(); - - $location1 = $this->createLocation($approvedUser, LocationApprovalStatus::PENDING, 'Location 1'); - $location2 = $this->createLocation($approvedUser, LocationApprovalStatus::PENDING, 'Location 2'); - $location3 = $this->createLocation($approvedUser, LocationApprovalStatus::PENDING, 'Location 3'); - - $this->assertNotNull($location1); - $this->assertNotNull($location2); - $this->assertNotNull($location3); - - // Verify all are PENDING - $this->assertEquals(LocationApprovalStatus::PENDING, $location1->getApprovalStatus()); - $this->assertEquals(LocationApprovalStatus::PENDING, $location2->getApprovalStatus()); - $this->assertEquals(LocationApprovalStatus::PENDING, $location3->getApprovalStatus()); - } - - public function test_larp_creation_sets_organizer_as_participant(): void - { - $approvedUser = $this->createApprovedUser(); - - $larp = $this->createDraftLarp($approvedUser); - - // Verify user is added as participant with ORGANIZER role - $participants = $larp->getLarpParticipants(); - $this->assertCount(1, $participants, 'LARP should have 1 participant (organizer)'); - - $participant = $participants[0]; - $this->assertEquals($approvedUser, $participant->getUser()); - $this->assertTrue( - $participant->isOrganizer(), - 'Creator should have ORGANIZER role' - ); - } - - public function test_user_larp_count_reflects_organizer_role(): void - { - $approvedUser = $this->createApprovedUser(); - - $initialCount = $approvedUser->getOrganizerLarpCount(); - - $larp1 = $this->createDraftLarp($approvedUser); - - // Clear and reload to get fresh count - $this->getEntityManager()->clear(); - $reloadedUser = $this->getEntityManager()->find( - \App\Domain\Account\Entity\User::class, - $approvedUser->getId() - ); - - $newCount = $reloadedUser->getOrganizerLarpCount(); - - $this->assertEquals( - $initialCount + 1, - $newCount, - 'Organizer LARP count should increase after creating LARP' - ); - } -} diff --git a/tests/Functional/Authorization/LarpParticipantDeletionCest.php b/tests/Functional/Authorization/LarpParticipantDeletionCest.php new file mode 100644 index 0000000..55e9df9 --- /dev/null +++ b/tests/Functional/Authorization/LarpParticipantDeletionCest.php @@ -0,0 +1,474 @@ +stopFollowingRedirects(); + } + + public function nonParticipantCannotDeleteParticipant(FunctionalTester $I): void + { + $I->wantTo('verify non-participant cannot delete participant'); + + // Arrange: Create LARP with organizer and a player + $organizer = UserFactory::new()->approved()->create(); + $player = UserFactory::new()->approved()->create(); + $nonParticipant = UserFactory::new()->approved()->create(); + + $larp = LarpFactory::createDraftLarp($organizer); + + $playerParticipant = LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($player) + ->player() + ->create(); + + // Act: Non-participant tries to delete player + $I->amLoggedInAs($nonParticipant->_real()); + $I->amOnRoute('backoffice_larp_participant_delete', [ + 'larp' => $larp->getId(), + 'participant' => $playerParticipant->getId(), + ]); + + // Assert: Access denied (403) - voter blocks non-participants + $I->seeResponseCodeIs(403); + } + + public function playerCannotDeleteAnyParticipant(FunctionalTester $I): void + { + $I->wantTo('verify player cannot delete any participant'); + + // Arrange: Create LARP with organizer and two players + $organizer = UserFactory::new()->approved()->create(); + $player1 = UserFactory::new()->approved()->create(); + $player2 = UserFactory::new()->approved()->create(); + + $larp = LarpFactory::createDraftLarp($organizer); + + $player1Participant = LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($player1) + ->player() + ->create(); + + $player2Participant = LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($player2) + ->player() + ->create(); + + // Act: Player1 tries to delete Player2 + $I->amLoggedInAs($player1->_real()); + $I->amOnRoute('backoffice_larp_participant_delete', [ + 'larp' => $larp->getId(), + 'participant' => $player2Participant->getId(), + ]); + + // Assert: Access denied - voter blocks players from deleting + $I->seeResponseCodeIs(403); + } + + public function npcCannotDeleteOrganizer(FunctionalTester $I): void + { + $I->wantTo('verify NPC cannot delete organizer'); + + // Arrange: Create LARP with organizer and NPC + $organizer = UserFactory::new()->approved()->create(); + $npc = UserFactory::new()->approved()->create(); + + $larp = LarpFactory::createDraftLarp($organizer); + + $npcParticipant = LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($npc) + ->npcShort() + ->create(); + + // Get organizer's participant record + $organizerParticipant = LarpParticipantFactory::repository() + ->findOneBy([ + 'larp' => $larp->_real(), + 'user' => $organizer->_real() + ]); + + // Act: NPC tries to delete organizer + $I->amLoggedInAs($npc->_real()); + $I->amOnRoute('backoffice_larp_participant_delete', [ + 'larp' => $larp->getId(), + 'participant' => $organizerParticipant->getId(), + ]); + + // Assert: Access denied - voter blocks NPCs + $I->seeResponseCodeIs(403); + } + + public function storyWriterCannotDeleteParticipants(FunctionalTester $I): void + { + $I->wantTo('verify story writer cannot delete participants'); + + // Arrange: Create LARP with organizer and story writer + $organizer = UserFactory::new()->approved()->create(); + $storyWriter = UserFactory::new()->approved()->create(); + $player = UserFactory::new()->approved()->create(); + + $larp = LarpFactory::createDraftLarp($organizer); + + $storyWriterParticipant = LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($storyWriter) + ->storyWriter() + ->create(); + + $playerParticipant = LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($player) + ->player() + ->create(); + + // Act: Story writer tries to delete player + $I->amLoggedInAs($storyWriter->_real()); + $I->amOnRoute('backoffice_larp_participant_delete', [ + 'larp' => $larp->getId(), + 'participant' => $playerParticipant->getId(), + ]); + + // Assert: Access denied - only organizers can delete + $I->seeResponseCodeIs(403); + } + + public function lastOrganizerCannotDeleteThemselves(FunctionalTester $I): void + { + $I->wantTo('verify last organizer cannot delete themselves'); + + // Arrange: Create LARP with single organizer + $organizer = UserFactory::new()->approved()->create(); + $larp = LarpFactory::createDraftLarp($organizer); + + // Get organizer's participant record + $organizerParticipant = LarpParticipantFactory::repository() + ->findOneBy([ + 'larp' => $larp->_real(), + 'user' => $organizer->_real() + ]); + + // Act: Last organizer tries to delete themselves + $I->amLoggedInAs($organizer->_real()); + $I->amOnRoute('backoffice_larp_participant_delete', [ + 'larp' => $larp->getId(), + 'participant' => $organizerParticipant->getId(), + ]); + + // Assert: Redirect with error message (business logic blocks this) + $I->seeResponseCodeIsRedirection(); + $I->assertResponseRedirects($I->getUrl('backoffice_larp_participant_list', ['larp' => $larp->getId()])); + + // Follow redirect and check flash message + $I->followRedirect(); + $I->see('cannot remove yourself', '.alert-danger'); + + // Verify organizer still exists + LarpParticipantFactory::assert()->exists($organizerParticipant); + } + + public function organizerCanDeleteThemselvesWhenMultipleOrganizersExist(FunctionalTester $I): void + { + $I->wantTo('verify organizer can delete themselves when multiple organizers exist'); + + // Arrange: Create LARP with two organizers + $organizer1 = UserFactory::new()->approved()->create(); + $organizer2 = UserFactory::new()->approved()->create(); + + $larp = LarpFactory::createDraftLarp($organizer1); + + $organizer2Participant = LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($organizer2) + ->organizer() + ->create(); + + // Get organizer1's participant record + $organizer1Participant = LarpParticipantFactory::repository() + ->findOneBy([ + 'larp' => $larp->_real(), + 'user' => $organizer1->_real() + ]); + + // Act: First organizer deletes themselves (second organizer still exists) + $I->amLoggedInAs($organizer1->_real()); + $I->amOnRoute('backoffice_larp_participant_delete', [ + 'larp' => $larp->getId(), + 'participant' => $organizer1Participant->getId(), + ]); + + // Assert: Successful deletion with redirect + $I->seeResponseCodeIsRedirection(); + $I->assertResponseRedirects($I->getUrl('backoffice_larp_participant_list', ['larp' => $larp->getId()])); + + // Follow redirect and check success message + $I->followRedirect(); + $I->seeElement('.alert-success'); + + // Verify organizer1 was deleted + LarpParticipantFactory::assert()->notExists(['id' => $organizer1Participant->getId()]); + + // Verify organizer2 still exists + LarpParticipantFactory::assert()->exists($organizer2Participant); + } + + public function organizerCanDeletePlayer(FunctionalTester $I): void + { + $I->wantTo('verify organizer can delete player'); + + // Arrange: Create LARP with organizer and player + $organizer = UserFactory::new()->approved()->create(); + $player = UserFactory::new()->approved()->create(); + + $larp = LarpFactory::createDraftLarp($organizer); + + $playerParticipant = LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($player) + ->player() + ->create(); + + // Act: Organizer deletes player + $I->amLoggedInAs($organizer->_real()); + $I->amOnRoute('backoffice_larp_participant_delete', [ + 'larp' => $larp->getId(), + 'participant' => $playerParticipant->getId(), + ]); + + // Assert: Successful deletion + $I->seeResponseCodeIsRedirection(); + $I->assertResponseRedirects($I->getUrl('backoffice_larp_participant_list', ['larp' => $larp->getId()])); + + $I->followRedirect(); + $I->seeElement('.alert-success'); + + // Verify player was deleted + LarpParticipantFactory::assert()->notExists(['id' => $playerParticipant->getId()]); + } + + public function organizerCanDeleteNpc(FunctionalTester $I): void + { + $I->wantTo('verify organizer can delete NPC'); + + // Arrange: Create LARP with organizer and NPC + $organizer = UserFactory::new()->approved()->create(); + $npc = UserFactory::new()->approved()->create(); + + $larp = LarpFactory::createDraftLarp($organizer); + + $npcParticipant = LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($npc) + ->npcLong() + ->create(); + + // Act: Organizer deletes NPC + $I->amLoggedInAs($organizer->_real()); + $I->amOnRoute('backoffice_larp_participant_delete', [ + 'larp' => $larp->getId(), + 'participant' => $npcParticipant->getId(), + ]); + + // Assert: Successful deletion + $I->seeResponseCodeIsRedirection(); + $I->followRedirect(); + $I->seeElement('.alert-success'); + + // Verify NPC was deleted + LarpParticipantFactory::assert()->notExists(['id' => $npcParticipant->getId()]); + } + + public function organizerCanDeleteStoryWriter(FunctionalTester $I): void + { + $I->wantTo('verify organizer can delete story writer'); + + // Arrange: Create LARP with organizer and story writer + $organizer = UserFactory::new()->approved()->create(); + $writer = UserFactory::new()->approved()->create(); + + $larp = LarpFactory::createDraftLarp($organizer); + + $writerParticipant = LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($writer) + ->storyWriter() + ->create(); + + // Act: Organizer deletes story writer + $I->amLoggedInAs($organizer->_real()); + $I->amOnRoute('backoffice_larp_participant_delete', [ + 'larp' => $larp->getId(), + 'participant' => $writerParticipant->getId(), + ]); + + // Assert: Successful deletion + $I->seeResponseCodeIsRedirection(); + $I->followRedirect(); + $I->seeElement('.alert-success'); + + // Verify writer was deleted + LarpParticipantFactory::assert()->notExists(['id' => $writerParticipant->getId()]); + } + + public function organizerCanDeleteAnotherOrganizerWhenMultipleExist(FunctionalTester $I): void + { + $I->wantTo('verify organizer can delete another organizer when multiple exist'); + + // Arrange: Create LARP with three organizers + $organizer1 = UserFactory::new()->approved()->create(); + $organizer2 = UserFactory::new()->approved()->create(); + $organizer3 = UserFactory::new()->approved()->create(); + + $larp = LarpFactory::createDraftLarp($organizer1); + + $organizer2Participant = LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($organizer2) + ->organizer() + ->create(); + + $organizer3Participant = LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($organizer3) + ->organizer() + ->create(); + + // Act: Organizer1 deletes Organizer2 (Organizer3 still exists) + $I->amLoggedInAs($organizer1->_real()); + $I->amOnRoute('backoffice_larp_participant_delete', [ + 'larp' => $larp->getId(), + 'participant' => $organizer2Participant->getId(), + ]); + + // Assert: Successful deletion + $I->seeResponseCodeIsRedirection(); + $I->followRedirect(); + $I->seeElement('.alert-success'); + + // Verify organizer2 was deleted + LarpParticipantFactory::assert()->notExists(['id' => $organizer2Participant->getId()]); + + // Verify organizer3 still exists + LarpParticipantFactory::assert()->exists($organizer3Participant); + } + + public function staffCannotDeleteParticipants(FunctionalTester $I): void + { + $I->wantTo('verify STAFF cannot delete participants'); + + // Arrange: STAFF is NOT ROLE_ORGANIZER, so they cannot delete + $organizer = UserFactory::new()->approved()->create(); + $staff = UserFactory::new()->approved()->create(); + $player = UserFactory::new()->approved()->create(); + + $larp = LarpFactory::createDraftLarp($organizer); + + // Add staff as STAFF role (not ROLE_ORGANIZER) + LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($staff) + ->staff() + ->create(); + + $playerParticipant = LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($player) + ->player() + ->create(); + + // Act: Staff tries to delete player + $I->amLoggedInAs($staff->_real()); + $I->amOnRoute('backoffice_larp_participant_delete', [ + 'larp' => $larp->getId(), + 'participant' => $playerParticipant->getId(), + ]); + + // Assert: Access denied (STAFF is not ROLE_ORGANIZER) + $I->seeResponseCodeIs(403); + } + + public function gameMasterCannotDeleteParticipants(FunctionalTester $I): void + { + $I->wantTo('verify GAME_MASTER cannot delete participants'); + + // Arrange: GAME_MASTER is NOT ROLE_ORGANIZER, so they cannot delete + $organizer = UserFactory::new()->approved()->create(); + $gameMaster = UserFactory::new()->approved()->create(); + $player = UserFactory::new()->approved()->create(); + + $larp = LarpFactory::createDraftLarp($organizer); + + // Add GM as GAME_MASTER role (not ROLE_ORGANIZER) + LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($gameMaster) + ->gameMaster() + ->create(); + + $playerParticipant = LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($player) + ->player() + ->create(); + + // Act: Game Master tries to delete player + $I->amLoggedInAs($gameMaster->_real()); + $I->amOnRoute('backoffice_larp_participant_delete', [ + 'larp' => $larp->getId(), + 'participant' => $playerParticipant->getId(), + ]); + + // Assert: Access denied (GAME_MASTER is not ROLE_ORGANIZER) + $I->seeResponseCodeIs(403); + } + + public function unauthenticatedUserCannotDeleteParticipant(FunctionalTester $I): void + { + $I->wantTo('verify unauthenticated user cannot delete participant'); + + // Arrange: Create LARP with participant + $organizer = UserFactory::new()->approved()->create(); + $player = UserFactory::new()->approved()->create(); + + $larp = LarpFactory::createDraftLarp($organizer); + + $playerParticipant = LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($player) + ->player() + ->create(); + + // Act: Unauthenticated request to delete participant + $I->amOnRoute('backoffice_larp_participant_delete', [ + 'larp' => $larp->getId(), + 'participant' => $playerParticipant->getId(), + ]); + + // Assert: Redirect to login + $I->seeResponseCodeIsRedirection(); + } +} diff --git a/tests/Functional/Authorization/UnacceptedUserRestrictionsCest.php b/tests/Functional/Authorization/UnacceptedUserRestrictionsCest.php new file mode 100644 index 0000000..d07f95f --- /dev/null +++ b/tests/Functional/Authorization/UnacceptedUserRestrictionsCest.php @@ -0,0 +1,311 @@ +stopFollowingRedirects(); + } + + public function pendingUserCannotAccessLarpCreationForm(FunctionalTester $I): void + { + $I->wantTo('verify PENDING user cannot access LARP creation form'); + + $pendingUser = UserFactory::createPendingUser(); + + $I->amLoggedInAs($pendingUser); + + $I->amOnRoute('backoffice_larp_create'); + + $I->seeResponseCodeIsRedirection(); + $I->assertResponseRedirects($I->getUrl('backoffice_account_pending_approval')); + } + + public function pendingUserCannotSubmitLarpCreation(FunctionalTester $I): void + { + $I->wantTo('verify PENDING user cannot submit LARP creation'); + + $pendingUser = UserFactory::createPendingUser(); + + $I->amLoggedInAs($pendingUser); + + // Try to POST directly to LARP creation endpoint + $I->sendPOST($I->getUrl('backoffice_larp_create'), [ + 'larp' => [ + 'title' => 'Unauthorized LARP', + 'description' => 'This should not be created', + ], + ]); + + $I->seeResponseCodeIsRedirection(); + $I->assertResponseRedirects($I->getUrl('backoffice_account_pending_approval')); + } + + public function pendingUserCannotAccessLocationCreationForm(FunctionalTester $I): void + { + $I->wantTo('verify PENDING user cannot access Location creation form'); + + $pendingUser = UserFactory::createPendingUser(); + + $I->amLoggedInAs($pendingUser); + + $I->amOnRoute('backoffice_location_modify_global', ['location' => 'new']); + + $I->seeResponseCodeIsRedirection(); + $I->assertResponseRedirects($I->getUrl('backoffice_account_pending_approval')); + } + + public function pendingUserCannotSubmitLocationCreation(FunctionalTester $I): void + { + $I->wantTo('verify PENDING user cannot submit Location creation'); + + $pendingUser = UserFactory::createPendingUser(); + + $I->amLoggedInAs($pendingUser); + + // Try to POST directly to Location creation endpoint + $I->sendPOST($I->getUrl('backoffice_location_modify_global', ['location' => 'new']), [ + 'location' => [ + 'name' => 'Unauthorized Location', + 'address' => '123 Test St', + 'city' => 'Test City', + 'country' => 'Test Country', + 'postalCode' => '12345', + ], + ]); + + $I->seeResponseCodeIsRedirection(); + $I->assertResponseRedirects($I->getUrl('backoffice_account_pending_approval')); + } + + public function suspendedUserCannotAccessLarpCreationForm(FunctionalTester $I): void + { + $I->wantTo('verify SUSPENDED user cannot access LARP creation form'); + + $suspendedUser = $I->createSuspendedUser(); + + $I->amLoggedInAs($suspendedUser); + + $I->amOnRoute('backoffice_larp_create'); + + $I->seeResponseCodeIsRedirection(); + $I->assertResponseRedirects($I->getUrl('backoffice_account_pending_approval')); + } + + public function suspendedUserCannotSubmitLarpCreation(FunctionalTester $I): void + { + $I->wantTo('verify SUSPENDED user cannot submit LARP creation'); + + $suspendedUser = $I->createSuspendedUser(); + + $I->amLoggedInAs($suspendedUser); + + $I->sendPOST($I->getUrl('backoffice_larp_create'), [ + 'larp' => [ + 'title' => 'Unauthorized LARP', + 'description' => 'This should not be created', + ], + ]); + + $I->seeResponseCodeIsRedirection(); + $I->assertResponseRedirects($I->getUrl('backoffice_account_pending_approval')); + } + + public function suspendedUserCannotAccessLocationCreationForm(FunctionalTester $I): void + { + $I->wantTo('verify SUSPENDED user cannot access Location creation form'); + + $suspendedUser = $I->createSuspendedUser(); + + $I->amLoggedInAs($suspendedUser); + + $I->amOnRoute('backoffice_location_modify_global', ['location' => 'new']); + + $I->seeResponseCodeIsRedirection(); + $I->assertResponseRedirects($I->getUrl('backoffice_account_pending_approval')); + } + + public function suspendedUserCannotSubmitLocationCreation(FunctionalTester $I): void + { + $I->wantTo('verify SUSPENDED user cannot submit Location creation'); + + $suspendedUser = $I->createSuspendedUser(); + + $I->amLoggedInAs($suspendedUser); + + $I->sendPOST($I->getUrl('backoffice_location_modify_global', ['location' => 'new']), [ + 'location' => [ + 'name' => 'Unauthorized Location', + 'address' => '123 Test St', + ], + ]); + + $I->seeResponseCodeIsRedirection(); + $I->assertResponseRedirects($I->getUrl('backoffice_account_pending_approval')); + } + + public function bannedUserCannotAccessLarpCreationForm(FunctionalTester $I): void + { + $I->wantTo('verify BANNED user cannot access LARP creation form'); + + $bannedUser = $I->createBannedUser(); + + $I->amLoggedInAs($bannedUser); + + $I->amOnRoute('backoffice_larp_create'); + + $I->seeResponseCodeIsRedirection(); + $I->assertResponseRedirects($I->getUrl('backoffice_account_pending_approval')); + } + + public function bannedUserCannotSubmitLarpCreation(FunctionalTester $I): void + { + $I->wantTo('verify BANNED user cannot submit LARP creation'); + + $bannedUser = $I->createBannedUser(); + + $I->amLoggedInAs($bannedUser); + + $I->sendPOST($I->getUrl('backoffice_larp_create'), [ + 'larp' => [ + 'title' => 'Unauthorized LARP', + 'description' => 'This should not be created', + ], + ]); + + $I->seeResponseCodeIsRedirection(); + $I->assertResponseRedirects($I->getUrl('backoffice_account_pending_approval')); + } + + public function bannedUserCannotAccessLocationCreationForm(FunctionalTester $I): void + { + $I->wantTo('verify BANNED user cannot access Location creation form'); + + $bannedUser = $I->createBannedUser(); + + $I->amLoggedInAs($bannedUser); + + $I->amOnRoute('backoffice_location_modify_global', ['location' => 'new']); + + $I->seeResponseCodeIsRedirection(); + $I->assertResponseRedirects($I->getUrl('backoffice_account_pending_approval')); + } + + public function bannedUserCannotSubmitLocationCreation(FunctionalTester $I): void + { + $I->wantTo('verify BANNED user cannot submit Location creation'); + + $bannedUser = $I->createBannedUser(); + + $I->amLoggedInAs($bannedUser); + + $I->sendPOST($I->getUrl('backoffice_location_modify_global', ['location' => 'new']), [ + 'location' => [ + 'name' => 'Unauthorized Location', + 'address' => '123 Test St', + ], + ]); + + $I->seeResponseCodeIsRedirection(); + $I->assertResponseRedirects($I->getUrl('backoffice_account_pending_approval')); + } + + public function pendingUserCannotCreateLarpEntityDirectly(FunctionalTester $I): void + { + $I->wantTo('verify PENDING user cannot create LARP entity directly'); + + $pendingUser = UserFactory::createPendingUser(); + + $I->amLoggedInAs($pendingUser); + + // Verify voter denies access at service layer + $authChecker = $I->grabService('security.authorization_checker'); + + $canCreate = $authChecker->isGranted('CREATE_LARP'); + + $I->assertFalse( + $canCreate, + 'Voter should deny CREATE_LARP permission for PENDING user' + ); + } + + public function pendingUserCannotCreateLocationEntityDirectly(FunctionalTester $I): void + { + $I->wantTo('verify PENDING user cannot create Location entity directly'); + + $pendingUser = UserFactory::createPendingUser(); + + $I->amLoggedInAs($pendingUser); + + // Verify voter denies access at service layer + $authChecker = $I->grabService('security.authorization_checker'); + + $canCreate = $authChecker->isGranted('CREATE_LOCATION'); + + $I->assertFalse( + $canCreate, + 'Voter should deny CREATE_LOCATION permission for PENDING user' + ); + } + + public function pendingUserSeesAppropriateErrorMessageForLarpCreation(FunctionalTester $I): void + { + $I->wantTo('verify PENDING user sees appropriate error message for LARP creation'); + + $pendingUser = UserFactory::createPendingUser(); + + $I->amLoggedInAs($pendingUser); + + $I->amOnRoute('backoffice_larp_create'); + + // If it's a redirect with flash message, follow and check + $I->seeResponseCodeIsBetween(300,400); + $I->followRedirect(); + $I->see('approved'); + } + + public function approvedUserCanAccessLarpCreationForm(FunctionalTester $I): void + { + $I->wantTo('verify APPROVED user can access LARP creation form'); + + $approvedUser = UserFactory::createApprovedUser(); + + $I->amLoggedInAs($approvedUser); + + $I->amOnRoute('backoffice_larp_create'); + + $I->seeResponseCodeIsSuccessful(); + } + + public function approvedUserCanAccessLocationCreationForm(FunctionalTester $I): void + { + $I->wantTo('verify APPROVED user can access Location creation form'); + + $approvedUser = UserFactory::createApprovedUser(); + + $I->amLoggedInAs($approvedUser); + + $I->amOnRoute('backoffice_location_modify_global', ['location' => 'new']); + + $I->seeResponseCodeIsSuccessful(); + } +} diff --git a/tests/Functional/Authorization/UnacceptedUserRestrictionsTest.php b/tests/Functional/Authorization/UnacceptedUserRestrictionsTest.php deleted file mode 100644 index 3ed8d88..0000000 --- a/tests/Functional/Authorization/UnacceptedUserRestrictionsTest.php +++ /dev/null @@ -1,296 +0,0 @@ -client = static::createClient(); - } - - - - public function test_pending_user_cannot_access_larp_creation_form(): void - { - $pendingUser = $this->createPendingUser(); - - $this->client->loginUser($pendingUser); - - $this->client->request('GET', $this->generateUrl('backoffice_larp_create')); - - $this->assertResponseStatusCodeSame(302); - $this->assertResponseRedirects($this->generateUrl('backoffice_account_pending_approval')); - } - - public function test_pending_user_cannot_submit_larp_creation(): void - { - $pendingUser = $this->createPendingUser(); - - $this->client->loginUser($pendingUser); - - // Try to POST directly to LARP creation endpoint - $this->client->request('POST', $this->generateUrl('backoffice_larp_create'), [ - 'larp' => [ - 'title' => 'Unauthorized LARP', - 'description' => 'This should not be created', - ], - ]); - - $this->assertResponseStatusCodeSame(302); - $this->assertResponseRedirects($this->generateUrl('backoffice_account_pending_approval')); - } - - public function test_pending_user_cannot_access_location_creation_form(): void - { - $pendingUser = $this->createPendingUser(); - - $this->client->loginUser($pendingUser); - - $this->client->request('GET', $this->generateUrl('backoffice_location_modify_global', ['location' => 'new'])); - - $this->assertResponseStatusCodeSame(302); - $this->assertResponseRedirects($this->generateUrl('backoffice_account_pending_approval')); - } - - public function test_pending_user_cannot_submit_location_creation(): void - { - $pendingUser = $this->createPendingUser(); - - $this->client->loginUser($pendingUser); - - // Try to POST directly to Location creation endpoint - $this->client->request('POST', $this->generateUrl('backoffice_location_modify_global', ['location' => 'new']), [ - 'location' => [ - 'name' => 'Unauthorized Location', - 'address' => '123 Test St', - 'city' => 'Test City', - 'country' => 'Test Country', - 'postalCode' => '12345', - ], - ]); - - $this->assertResponseStatusCodeSame(302); - $this->assertResponseRedirects($this->generateUrl('backoffice_account_pending_approval')); - } - - public function test_suspended_user_cannot_access_larp_creation_form(): void - { - $suspendedUser = $this->createSuspendedUser(); - - $this->client->loginUser($suspendedUser); - - $this->client->request('GET', $this->generateUrl('backoffice_larp_create')); - - $this->assertResponseStatusCodeSame(302); - $this->assertResponseRedirects($this->generateUrl('backoffice_account_pending_approval')); - } - - public function test_suspended_user_cannot_submit_larp_creation(): void - { - $suspendedUser = $this->createSuspendedUser(); - - $this->client->loginUser($suspendedUser); - - $this->client->request('POST', $this->generateUrl('backoffice_larp_create'), [ - 'larp' => [ - 'title' => 'Unauthorized LARP', - 'description' => 'This should not be created', - ], - ]); - - $this->assertResponseStatusCodeSame(302); - $this->assertResponseRedirects($this->generateUrl('backoffice_account_pending_approval')); - } - - public function test_suspended_user_cannot_access_location_creation_form(): void - { - $suspendedUser = $this->createSuspendedUser(); - - $this->client->loginUser($suspendedUser); - - $this->client->request('GET', $this->generateUrl('backoffice_location_modify_global', ['location' => 'new'])); - - $this->assertResponseStatusCodeSame(302); - $this->assertResponseRedirects($this->generateUrl('backoffice_account_pending_approval')); - } - - public function test_suspended_user_cannot_submit_location_creation(): void - { - $suspendedUser = $this->createSuspendedUser(); - - $this->client->loginUser($suspendedUser); - - $this->client->request('POST', $this->generateUrl('backoffice_location_modify_global', ['location' => 'new']), [ - 'location' => [ - 'name' => 'Unauthorized Location', - 'address' => '123 Test St', - ], - ]); - - $this->assertResponseStatusCodeSame(302); - $this->assertResponseRedirects($this->generateUrl('backoffice_account_pending_approval')); - } - - public function test_banned_user_cannot_access_larp_creation_form(): void - { - $bannedUser = $this->createBannedUser(); - - $this->client->loginUser($bannedUser); - - $this->client->request('GET', $this->generateUrl('backoffice_larp_create')); - - $this->assertResponseStatusCodeSame(302); - $this->assertResponseRedirects($this->generateUrl('backoffice_account_pending_approval')); - } - - public function test_banned_user_cannot_submit_larp_creation(): void - { - $bannedUser = $this->createBannedUser(); - - $this->client->loginUser($bannedUser); - - $this->client->request('POST', $this->generateUrl('backoffice_larp_create'), [ - 'larp' => [ - 'title' => 'Unauthorized LARP', - 'description' => 'This should not be created', - ], - ]); - - $this->assertResponseStatusCodeSame(302); - $this->assertResponseRedirects($this->generateUrl('backoffice_account_pending_approval')); - } - - public function test_banned_user_cannot_access_location_creation_form(): void - { - $bannedUser = $this->createBannedUser(); - - $this->client->loginUser($bannedUser); - - $this->client->request('GET', $this->generateUrl('backoffice_location_modify_global', ['location' => 'new'])); - - $this->assertResponseStatusCodeSame(302); - $this->assertResponseRedirects($this->generateUrl('backoffice_account_pending_approval')); - } - - public function test_banned_user_cannot_submit_location_creation(): void - { - $bannedUser = $this->createBannedUser(); - - $this->client->loginUser($bannedUser); - - $this->client->request('POST', $this->generateUrl('backoffice_location_modify_global', ['location' => 'new']), [ - 'location' => [ - 'name' => 'Unauthorized Location', - 'address' => '123 Test St', - ], - ]); - - $this->assertResponseStatusCodeSame(302); - $this->assertResponseRedirects($this->generateUrl('backoffice_account_pending_approval')); - } - - public function test_pending_user_cannot_create_larp_entity_directly(): void - { - $pendingUser = $this->createPendingUser(); - - $this->client->loginUser($pendingUser); - - // Verify voter denies access at service layer - $authChecker = static::getContainer()->get('security.authorization_checker'); - - $canCreate = $authChecker->isGranted('CREATE_LARP'); - - $this->assertFalse( - $canCreate, - 'Voter should deny CREATE_LARP permission for PENDING user' - ); - } - - public function test_pending_user_cannot_create_location_entity_directly(): void - { - $pendingUser = $this->createPendingUser(); - - $this->client->loginUser($pendingUser); - - // Verify voter denies access at service layer - $authChecker = static::getContainer()->get('security.authorization_checker'); - - $canCreate = $authChecker->isGranted('CREATE_LOCATION'); - - $this->assertFalse( - $canCreate, - 'Voter should deny CREATE_LOCATION permission for PENDING user' - ); - } - - public function test_pending_user_sees_appropriate_error_message_for_larp_creation(): void - { - $pendingUser = $this->createPendingUser(); - - $this->client->loginUser($pendingUser); - - $this->client->request('GET', $this->generateUrl('backoffice_larp_create')); - - // If it's a redirect with flash message, follow and check - if ($this->client->getResponse()->isRedirect()) { - $crawler = $this->client->followRedirect(); - $content = $crawler->html(); - - $this->assertStringContainsStringIgnoringCase( - 'approved', - $content, - 'Error message should mention account approval requirement' - ); - } - } - - public function test_approved_user_can_access_larp_creation_form(): void - { - $approvedUser = $this->createApprovedUser(); - - $this->client->loginUser($approvedUser); - - $this->client->request('GET', $this->generateUrl('backoffice_larp_create')); - - $this->assertResponseIsSuccessful( - 'APPROVED user should be able to access LARP creation form' - ); - } - - public function test_approved_user_can_access_location_creation_form(): void - { - $approvedUser = $this->createApprovedUser(); - - $this->client->loginUser($approvedUser); - - $this->client->request('GET', $this->generateUrl('backoffice_location_modify_global', ['location' => 'new'])); - - $this->assertResponseIsSuccessful( - 'APPROVED user should be able to access Location creation form' - ); - } -} diff --git a/tests/Domain/Incidents/Controller/LarpIncidentsTemplateTest.php b/tests/Functional/Incidents/LarpIncidentsTemplateCest.php old mode 100755 new mode 100644 similarity index 57% rename from tests/Domain/Incidents/Controller/LarpIncidentsTemplateTest.php rename to tests/Functional/Incidents/LarpIncidentsTemplateCest.php index 23184ca..49f2e3d --- a/tests/Domain/Incidents/Controller/LarpIncidentsTemplateTest.php +++ b/tests/Functional/Incidents/LarpIncidentsTemplateCest.php @@ -1,23 +1,38 @@ twig = $I->grabService('twig'); + $this->formFactory = $I->grabService('form.factory'); + + // Ensure request stack has a request + $requestStack = $I->grabService('request_stack'); + if (!$requestStack->getCurrentRequest()) { + $requestStack->push(new \Symfony\Component\HttpFoundation\Request()); + } + } + + public function templateRendersIncidentList(FunctionalTester $I): void { - self::bootKernel(); - $container = self::getContainer(); - $twig = $container->get('twig'); - $formFactory = $container->get('form.factory'); - $container->get('request_stack')->push(new \Symfony\Component\HttpFoundation\Request()); + $I->wantTo('verify that incident list template renders correctly'); $larp = new Larp(); $larp->setTitle('Test'); @@ -32,20 +47,19 @@ public function testTemplateRenders(): void $incident->setCaseId('123'); $incident->setStatus(LarpIncidentStatus::NEW); - $html = $twig->render('backoffice/larp/incident/list.html.twig', [ + $html = $this->twig->render('backoffice/larp/incident/list.html.twig', [ 'larp' => $larp, 'incidents' => [$incident], ]); - $this->assertStringContainsString('123', $html); + $I->assertStringContainsString('123', $html); } - public function testFilteringWorks(): void + public function filteringWorksByStatus(FunctionalTester $I): void { - self::bootKernel(); - $formFactory = self::getContainer()->get('form.factory'); + $I->wantTo('verify that filtering incidents by status works correctly'); - $form = $formFactory->create(LarpIncidentFilterType::class); + $form = $this->formFactory->create(LarpIncidentFilterType::class); $form->submit([ 'status' => [LarpIncidentStatus::CLOSED->value], ]); @@ -77,7 +91,7 @@ public function testFilteringWorks(): void return true; })); - $this->assertCount(1, $filtered); - $this->assertSame('B', $filtered[0]->getCaseId()); + $I->assertCount(1, $filtered); + $I->assertSame('B', $filtered[0]->getCaseId()); } } diff --git a/tests/Functional/Integration/FileMappingTypeCest.php b/tests/Functional/Integration/FileMappingTypeCest.php new file mode 100644 index 0000000..2828804 --- /dev/null +++ b/tests/Functional/Integration/FileMappingTypeCest.php @@ -0,0 +1,72 @@ +formFactory = $I->grabService('form.factory'); + } + + public function characterDocSubFormAppears(FunctionalTester $I): void + { + $I->wantTo('verify that character doc sub-form appears with correct fields'); + + $model = new ExternalResourceMappingModel(ResourceType::CHARACTER_DOC); + $form = $this->formFactory->create(FileMappingType::class, $model, [ + 'mimeType' => 'application/vnd.google-apps.document', + ]); + + $I->assertTrue($form->get('mappings')->has('title')); + $I->assertTrue($form->get('mappings')->has('description')); + } + + public function eventDocSubFormAppears(FunctionalTester $I): void + { + $I->wantTo('verify that event doc sub-form appears with correct fields'); + + $model = new ExternalResourceMappingModel(ResourceType::EVENT_DOC); + $form = $this->formFactory->create(FileMappingType::class, $model, [ + 'mimeType' => 'application/vnd.google-apps.document', + ]); + + $I->assertTrue($form->get('mappings')->has('eventName')); + $I->assertTrue($form->get('mappings')->has('description')); + } + + public function allowedTypesForDocumentAreCorrect(FunctionalTester $I): void + { + $I->wantTo('verify that allowed resource types are correct for documents'); + + $type = new FileMappingType(); + $allowed = $type->getAllowedResourceTypes('application/vnd.google-apps.document'); + + $I->assertContains(ResourceType::CHARACTER_DOC, $allowed); + $I->assertContains(ResourceType::EVENT_DOC, $allowed); + } + + public function subFormMappingTypesAreCorrect(FunctionalTester $I): void + { + $I->wantTo('verify that sub-form mapping types are correctly configured'); + + $I->assertSame( + \App\Domain\Integrations\Form\Integrations\CharacterDocMappingType::class, + ResourceType::CHARACTER_DOC->getSubForm() + ); + $I->assertSame( + \App\Domain\Integrations\Form\Integrations\EventDocMappingType::class, + ResourceType::EVENT_DOC->getSubForm() + ); + } +} diff --git a/tests/Domain/Mailing/Service/MailTemplateManagerTest.php b/tests/Functional/Mailing/Service/MailTemplateManagerTest.php similarity index 97% rename from tests/Domain/Mailing/Service/MailTemplateManagerTest.php rename to tests/Functional/Mailing/Service/MailTemplateManagerTest.php index 3414613..8d764d4 100644 --- a/tests/Domain/Mailing/Service/MailTemplateManagerTest.php +++ b/tests/Functional/Mailing/Service/MailTemplateManagerTest.php @@ -1,6 +1,6 @@ wantTo('verify that locale can be switched via switch-locale route'); + + $I->amOnPage('/switch-locale/de'); + $I->seeResponseCodeIs(302); + $I->seeInSession('_locale', 'de'); + } +} diff --git a/tests/Functional/Security/BackofficeAccessCest.php b/tests/Functional/Security/BackofficeAccessCest.php new file mode 100644 index 0000000..ba91dba --- /dev/null +++ b/tests/Functional/Security/BackofficeAccessCest.php @@ -0,0 +1,239 @@ +wantTo('verify unauthenticated users are redirected from backoffice'); + + $I->amOnRoute('backoffice_dashboard'); + $I->seeResponseCodeIs(302); + } + + public function pendingUserCannotAccessBackoffice(FunctionalTester $I): void + { + $I->wantTo('verify PENDING users cannot access backoffice'); + + $pendingUser = UserFactory::createPendingUser(); + $I->amLoggedInAs($pendingUser); + + $I->amOnRoute('backoffice_dashboard'); + $I->seeResponseCodeIs(302); + } + + public function approvedUserCanAccessBackoffice(FunctionalTester $I): void + { + $I->wantTo('verify APPROVED users can access backoffice'); + + $approvedUser = UserFactory::createApprovedUser(); + $I->amLoggedInAs($approvedUser); + + $I->amOnRoute('backoffice_dashboard'); + $I->seeResponseCodeIsSuccessful(); + } + + public function suspendedUserCannotAccessBackoffice(FunctionalTester $I): void + { + $I->wantTo('verify SUSPENDED users cannot access backoffice'); + + $suspendedUser = $I->createSuspendedUser(); + $I->amLoggedInAs($suspendedUser); + + $I->amOnRoute('backoffice_dashboard'); + $I->seeResponseCodeIs(302); + } + + public function bannedUserCannotAccessBackoffice(FunctionalTester $I): void + { + $I->wantTo('verify BANNED users cannot access backoffice'); + + $bannedUser = $I->createBannedUser(); + $I->amLoggedInAs($bannedUser); + + $I->amOnRoute('backoffice_dashboard'); + $I->seeResponseCodeIs(302); + } + + public function superAdminCanAccessSuperAdminRoutes(FunctionalTester $I): void + { + $I->wantTo('verify SUPER_ADMIN can access super-admin routes'); + + $superAdmin = $I->createSuperAdmin(); + $I->amLoggedInAs($superAdmin); + + $I->amOnRoute('super_admin_users_list'); + $I->seeResponseCodeIsSuccessful(); + } + + public function regularApprovedUserCannotAccessSuperAdminRoutes(FunctionalTester $I): void + { + $I->wantTo('verify regular APPROVED users cannot access super-admin routes'); + + $regularUser = UserFactory::createApprovedUser(); + $I->amLoggedInAs($regularUser); + + $I->amOnRoute('super_admin_users_list'); + $I->seeResponseCodeIs(403); + } + + public function pendingUserCannotAccessSuperAdminRoutes(FunctionalTester $I): void + { + $I->wantTo('verify PENDING users cannot access super-admin routes'); + + $pendingUser = UserFactory::createPendingUser(); + $I->amLoggedInAs($pendingUser); + + $I->amOnRoute('super_admin_users_list'); + $I->seeResponseCodeIs(403); + } + + public function unauthenticatedUserRedirectedFromSuperAdminRoutes(FunctionalTester $I): void + { + $I->wantTo('verify unauthenticated users are redirected from super-admin routes'); + + $I->amOnRoute('super_admin_users_list'); + $I->seeResponseCodeIs(302); + } + + public function multipleAccessControlLayersWorkTogether(FunctionalTester $I): void + { + $I->wantTo('verify that user status AND route access control work together'); + + $pendingUser = UserFactory::createPendingUser(); + $I->amLoggedInAs($pendingUser); + + // Try backoffice (should redirect due to PENDING status) + $I->amOnRoute('backoffice_dashboard'); + $I->seeResponseCodeIs(302); + + // Try LARP creation (should redirect due to voter) + $I->amOnRoute('backoffice_larp_create'); + $I->seeResponseCodeIs(302); + + // Try location creation (should redirect due to voter) + $I->amOnPage($I->getUrl('backoffice_location_modify_global', ['location' => 'new'])); + $I->seeResponseCodeIs(302); + } + + public function statusChangeAffectsAccessImmediately(FunctionalTester $I): void + { + $I->wantTo('verify status changes affect access immediately'); + + $user = UserFactory::createPendingUser(); + $I->amLoggedInAs($user); + + // Initially cannot access backoffice + $I->amOnRoute('backoffice_dashboard'); + $I->seeResponseCodeIs(302); + + // Approve user + $user->setStatus(\App\Domain\Account\Entity\Enum\UserStatus::APPROVED); + $I->getEntityManager()->flush(); + + // Clear entity manager and re-authenticate + $I->getEntityManager()->clear(); + $I->amLoggedInAs($user); + + // Now should be able to access backoffice + $I->amOnRoute('backoffice_dashboard'); + $I->seeResponseCodeIsSuccessful(); + } + + public function superAdminCanAccessAllRoutes(FunctionalTester $I): void + { + $I->wantTo('verify SUPER_ADMIN can access all routes'); + + $superAdmin = $I->createSuperAdmin(); + $I->amLoggedInAs($superAdmin); + + // Test multiple routes + $routes = [ + ['route' => 'backoffice_dashboard'], + ['route' => 'super_admin_users_list'], + ['route' => 'backoffice_larp_create'], + ['route' => 'backoffice_location_modify_global', 'params' => ['location' => 'new']], + ]; + + foreach ($routes as $routeData) { + $route = $routeData['route']; + $params = $routeData['params'] ?? []; + + if (empty($params)) { + $I->amOnRoute($route); + } else { + $I->amOnPage($I->getUrl($route, $params)); + } + + $statusCode = $I->grabResponse()->getStatusCode(); + $I->assertNotEquals(403, $statusCode, "SUPER_ADMIN should access {$route}"); + } + } + + public function roleHierarchyIsRespected(FunctionalTester $I): void + { + $I->wantTo('verify role hierarchy is respected'); + + $superAdmin = $I->createSuperAdmin(); + $I->amLoggedInAs($superAdmin); + + // SUPER_ADMIN should have ROLE_SUPER_ADMIN + $I->assertContains('ROLE_SUPER_ADMIN', $superAdmin->getRoles()); + + // Verify role hierarchy works + $I->amOnRoute('super_admin_users_list'); + $I->seeResponseCodeIsSuccessful(); + } + + public function publicRoutesAccessibleToAll(FunctionalTester $I): void + { + $I->wantTo('verify public routes are accessible without authentication'); + + $publicRoutes = ['public_larp_list', 'public_larp_list']; + + foreach ($publicRoutes as $route) { + $I->amOnRoute($route); + $I->seeResponseCodeIsSuccessful(); + } + } + + public function unauthenticatedUserRedirectedFromBackofficeRoute(FunctionalTester $I): void + { + $I->wantTo('verify unauthenticated users are redirected from backoffice LARP list'); + + $I->amOnRoute('backoffice_larp_list'); + $I->seeResponseCodeIs(302); + } + + public function pendingUserCanAccessPublicRoutes(FunctionalTester $I): void + { + $I->wantTo('verify PENDING users can access public routes'); + + $pendingUser = UserFactory::createPendingUser(); + $I->amLoggedInAs($pendingUser); + + $publicRoutes = ['public_larp_list', 'public_larp_list']; + + foreach ($publicRoutes as $route) { + $I->amOnRoute($route); + $I->seeResponseCodeIsSuccessful(); + } + } +} diff --git a/tests/Functional/Security/BackofficeAccessTest.php b/tests/Functional/Security/BackofficeAccessTest.php deleted file mode 100644 index dd0e462..0000000 --- a/tests/Functional/Security/BackofficeAccessTest.php +++ /dev/null @@ -1,294 +0,0 @@ -client = static::createClient(); - } - - - public function test_unauthenticated_user_redirected_from_backoffice(): void - { - $this->client->request('GET', $this->generateUrl('backoffice_dashboard')); - - $this->assertResponseRedirects( - null, - null, - 'Unauthenticated user should be redirected from backoffice' - ); - } - - public function test_pending_user_cannot_access_backoffice(): void - { - $pendingUser = $this->createPendingUser(); - - $this->client->loginUser($pendingUser); - - $this->client->request('GET', $this->generateUrl('backoffice_dashboard')); - - $this->assertResponseRedirects( - null, - null, - 'PENDING user should be redirected from backoffice' - ); - } - - public function test_approved_user_can_access_backoffice(): void - { - $approvedUser = $this->createApprovedUser(); - - $this->client->loginUser($approvedUser); - - $this->client->request('GET', $this->generateUrl('backoffice_dashboard')); - - $this->assertResponseIsSuccessful( - 'APPROVED user should be able to access backoffice' - ); - } - - public function test_suspended_user_cannot_access_backoffice(): void - { - $suspendedUser = $this->createSuspendedUser(); - - $this->client->loginUser($suspendedUser); - - $this->client->request('GET', $this->generateUrl('backoffice_dashboard')); - - $this->assertResponseRedirects( - null, - null, - 'SUSPENDED user should be redirected from backoffice' - ); - } - - public function test_banned_user_cannot_access_backoffice(): void - { - $bannedUser = $this->createBannedUser(); - - $this->client->loginUser($bannedUser); - - $this->client->request('GET', $this->generateUrl('backoffice_dashboard')); - - $this->assertResponseRedirects( - null, - null, - 'BANNED user should be redirected from backoffice' - ); - } - - public function test_super_admin_can_access_super_admin_routes(): void - { - $superAdmin = $this->createSuperAdmin(); - - $this->client->loginUser($superAdmin); - - $this->client->request('GET', $this->generateUrl('super_admin_users_list')); - - $this->assertResponseIsSuccessful( - 'SUPER_ADMIN should be able to access super-admin routes' - ); - } - - public function test_regular_approved_user_cannot_access_super_admin_routes(): void - { - $regularUser = $this->createApprovedUser(); - - $this->client->loginUser($regularUser); - - $this->client->request('GET', $this->generateUrl('super_admin_users_list')); - - $this->assertResponseStatusCodeSame( - 403, - 'Regular APPROVED user should not be able to access super-admin routes' - ); - } - - public function test_pending_user_cannot_access_super_admin_routes(): void - { - $pendingUser = $this->createPendingUser(); - - $this->client->loginUser($pendingUser); - - $this->client->request('GET', $this->generateUrl('super_admin_users_list')); - - $this->assertResponseStatusCodeSame( - 403, - 'PENDING user should not be able to access super-admin routes' - ); - } - - public function test_unauthenticated_user_redirected_from_super_admin_routes(): void - { - $this->client->request('GET', $this->generateUrl('super_admin_users_list')); - - $this->assertResponseRedirects( - null, - null, - 'Unauthenticated user should be redirected from super-admin routes' - ); - } - - public function test_multiple_access_control_layers_work_together(): void - { - // Test that both user status AND route access control work together - - $pendingUser = $this->createPendingUser(); - - $this->client->loginUser($pendingUser); - - // Try backoffice (should redirect due to PENDING status) - $this->client->request('GET', $this->generateUrl('backoffice_dashboard')); - $this->assertResponseRedirects(null, null, 'PENDING user redirected from backoffice'); - - // Try LARP creation (should redirect due to voter) - $this->client->request('GET', $this->generateUrl('backoffice_larp_create')); - $this->assertResponseRedirects(null, null, 'PENDING user redirected from LARP creation'); - - // Try location creation (should redirect due to voter) - $this->client->request('GET', $this->generateUrl('backoffice_location_modify_global', ['location' => 'new'])); - $this->assertResponseRedirects(null, null, 'PENDING user redirected from location creation'); - } - - public function test_status_change_affects_access_immediately(): void - { - $user = $this->createPendingUser(); - - $this->client->loginUser($user); - - // Initially cannot access backoffice (redirected due to PENDING status) - $this->client->request('GET', $this->generateUrl('backoffice_dashboard')); - $this->assertResponseRedirects(null, null, 'PENDING user redirected from backoffice'); - - // Approve user - $this->approveUser($user); - - // Clear the security token to force re-authentication - $this->getEntityManager()->clear(); - $this->client->loginUser($user); - - // Now should be able to access backoffice - $this->client->request('GET', $this->generateUrl('backoffice_dashboard')); - $this->assertResponseIsSuccessful('APPROVED user can now access backoffice'); - } - - public function test_super_admin_can_access_all_routes(): void - { - $superAdmin = $this->createSuperAdmin(); - - $this->client->loginUser($superAdmin); - - // Test multiple routes - $routes = [ - $this->generateUrl('backoffice_dashboard'), - $this->generateUrl('super_admin_users_list'), - $this->generateUrl('backoffice_larp_create'), - $this->generateUrl('backoffice_location_modify_global', ['location' => 'new']), - ]; - - foreach ($routes as $route) { - $this->client->request('GET', $route); - - $this->assertNotEquals( - 403, - $this->client->getResponse()->getStatusCode(), - "SUPER_ADMIN should access {$route}" - ); - } - } - - public function test_role_hierarchy_is_respected(): void - { - $superAdmin = $this->createSuperAdmin(); - - $this->client->loginUser($superAdmin); - - // SUPER_ADMIN should have all lower roles due to role hierarchy - $this->assertContains( - 'ROLE_SUPER_ADMIN', - $superAdmin->getRoles(), - 'SUPER_ADMIN should have ROLE_SUPER_ADMIN' - ); - - // Verify role hierarchy works by checking access to admin routes - $this->client->request('GET', $this->generateUrl('super_admin_users_list')); - $this->assertResponseIsSuccessful( - 'SUPER_ADMIN should access admin routes due to role hierarchy' - ); - } - - public function test_public_routes_accessible_to_all(): void - { - // Public routes should be accessible without authentication - $publicRoutes = [ - $this->generateUrl('public_larp_list'), - $this->generateUrl('public_larp_list'), - ]; - - foreach ($publicRoutes as $route) { - $this->client->request('GET', $route); - - $this->assertResponseIsSuccessful( - "Public route {$route} should be accessible to unauthenticated users" - ); - } - } - - public function test_unauthenticated_user_redirected_from_backoffice_route(): void - { - $this->client->request('GET', $this->generateUrl('backoffice_larp_list')); - - $this->assertResponseRedirects( - null, - null, - 'Unauthenticated user should be redirected from API routes' - ); - } - - public function test_pending_user_can_access_public_routes(): void - { - $pendingUser = $this->createPendingUser(); - - $this->client->loginUser($pendingUser); - - // Public routes should be accessible even with PENDING status - $publicRoutes = [ - $this->generateUrl('public_larp_list'), - $this->generateUrl('public_larp_list'), - ]; - - foreach ($publicRoutes as $route) { - $this->client->request('GET', $route); - - $this->assertResponseIsSuccessful( - "PENDING user should be able to access public route {$route}" - ); - } - } -} diff --git a/tests/Functional/Security/LarpVisibilitySecurityCest.php b/tests/Functional/Security/LarpVisibilitySecurityCest.php new file mode 100644 index 0000000..f683ad8 --- /dev/null +++ b/tests/Functional/Security/LarpVisibilitySecurityCest.php @@ -0,0 +1,324 @@ +wantTo('verify that a PUBLISHED LARP is publicly visible'); + + $organizer = UserFactory::createApprovedUser(); + $larp = $I->createPublishedLarp($organizer); + + $I->assertTrue( + $larp->getStatus()->isVisibleForEveryone(), + 'PUBLISHED LARP should be publicly visible' + ); + } + + public function draftLarpIsNotPubliclyVisible(FunctionalTester $I): void + { + $I->wantTo('verify that a DRAFT LARP is not publicly visible'); + + $organizer = UserFactory::createApprovedUser(); + $larp = LarpFactory::createDraftLarp($organizer); + + $I->assertFalse( + $larp->getStatus()->isVisibleForEveryone(), + 'DRAFT LARP should not be publicly visible' + ); + } + + public function wipLarpIsNotPubliclyVisible(FunctionalTester $I): void + { + $I->wantTo('verify that a WIP LARP is not publicly visible'); + + $organizer = UserFactory::createApprovedUser(); + $larp = $I->createWipLarp($organizer); + + $I->assertFalse( + $larp->getStatus()->isVisibleForEveryone(), + 'WIP LARP should not be publicly visible' + ); + } + + public function inquiriesLarpIsPubliclyVisible(FunctionalTester $I): void + { + $I->wantTo('verify that an INQUIRIES LARP is publicly visible'); + + $organizer = UserFactory::createApprovedUser(); + $larp = $I->createLarp($organizer, LarpStageStatus::INQUIRIES); + + $I->assertTrue( + $larp->getStatus()->isVisibleForEveryone(), + 'INQUIRIES LARP should be publicly visible' + ); + } + + public function confirmedLarpIsPubliclyVisible(FunctionalTester $I): void + { + $I->wantTo('verify that a CONFIRMED LARP is publicly visible'); + + $organizer = UserFactory::createApprovedUser(); + $larp = $I->createLarp($organizer, LarpStageStatus::CONFIRMED); + + $I->assertTrue( + $larp->getStatus()->isVisibleForEveryone(), + 'CONFIRMED LARP should be publicly visible' + ); + } + + public function completedLarpIsPubliclyVisible(FunctionalTester $I): void + { + $I->wantTo('verify that a COMPLETED LARP is publicly visible'); + + $organizer = UserFactory::createApprovedUser(); + $larp = $I->createLarp($organizer, LarpStageStatus::COMPLETED); + + $I->assertTrue( + $larp->getStatus()->isVisibleForEveryone(), + 'COMPLETED LARP should be publicly visible' + ); + } + + public function unauthenticatedUserCanSeePublicLarpInList(FunctionalTester $I): void + { + $I->wantTo('verify that unauthenticated users can see public LARPs in the list'); + + $organizer = UserFactory::createApprovedUser(); + $publicLarp = $I->createPublishedLarp($organizer, 'Public LARP'); + + // Ensure the entity manager is flushed and cleared before making the request + $em = $I->getEntityManager(); + $em->flush(); + $em->clear(); + + $I->amOnRoute('public_larp_list'); + $I->seeResponseCodeIsSuccessful(); + + // Check if LARP appears in the list (basic check) + $I->see('Public LARP', 'body'); + } + + public function unauthenticatedUserCannotSeeDraftLarpInList(FunctionalTester $I): void + { + $I->wantTo('verify that unauthenticated users cannot see draft LARPs in the list'); + + $organizer = UserFactory::createApprovedUser(); + $draftLarp = LarpFactory::createDraftLarp($organizer, 'Draft LARP'); + + $I->amOnRoute('public_larp_list'); + + $I->dontSee('Draft LARP', 'body'); + } + + public function unauthenticatedUserCannotAccessLarpBackoffice(FunctionalTester $I): void + { + $I->wantTo('verify that unauthenticated users cannot access LARP backoffice'); + + $organizer = UserFactory::createApprovedUser(); + $larp = $I->createPublishedLarp($organizer); + + $I->amOnRoute('backoffice_larp_dashboard', ['larp' => $larp->getId()]); + + $I->seeResponseCodeIs(302); + } + + public function pendingUserCannotAccessLarpBackoffice(FunctionalTester $I): void + { + $I->wantTo('verify that pending users cannot access LARP backoffice'); + + $organizer = UserFactory::createApprovedUser(); + $larp = $I->createPublishedLarp($organizer); + + $pendingUser = UserFactory::createPendingUser(); + + $I->amLoggedInAs($pendingUser); + $I->amOnRoute('backoffice_larp_dashboard', ['larp' => $larp->getId()]); + + $I->seeResponseCodeIs(302); + $I->seeCurrentRouteIs('backoffice_account_pending_approval'); + } + + public function participantCanAccessTheirLarpBackoffice(FunctionalTester $I): void + { + $I->wantTo('verify that organizers can access their LARP backoffice'); + + $organizer = UserFactory::createApprovedUser(); + $larp = LarpFactory::createDraftLarp($organizer); + + $I->amLoggedInAs($organizer); + $I->amOnRoute('backoffice_larp_dashboard', ['larp' => $larp->getId()]); + + $I->seeResponseCodeIsSuccessful(); + } + + public function nonParticipantCannotAccessOtherLarpBackoffice(FunctionalTester $I): void + { + $I->wantTo('verify that non-participants cannot access other LARP backoffices'); + + $organizer = UserFactory::createApprovedUser(); + $larp = LarpFactory::createDraftLarp($organizer); + + $otherUser = UserFactory::createApprovedUser(); + + $I->amLoggedInAs($otherUser); + $I->amOnRoute('backoffice_larp_dashboard', ['larp' => $larp->getId()]); + + $I->seeResponseCodeIs(403); + } + + public function playerParticipantCannotAccessLarpBackoffice(FunctionalTester $I): void + { + $I->wantTo('verify that player participants cannot access LARP backoffice'); + + $organizer = UserFactory::createApprovedUser(); + $larp = LarpFactory::createDraftLarp($organizer); + + $player = UserFactory::createApprovedUser(); + $I->addParticipantToLarp($larp, $player); + + $I->amLoggedInAs($player); + $I->amOnRoute('backoffice_larp_dashboard', ['larp' => $larp->getId()]); + + $I->seeResponseCodeIs(403); + } + + public function organizerHasFullLarpAccess(FunctionalTester $I): void + { + $I->wantTo('verify that organizers have full LARP access'); + + $organizer = UserFactory::createApprovedUser(); + $larp = LarpFactory::createDraftLarp($organizer); + + $participant = $larp->getLarpParticipants()[0]; + + $I->assertTrue( + $participant->isOrganizer(), + 'Organizer should have ORGANIZER role' + ); + $I->assertTrue( + $participant->isAdmin(), + 'Organizer should be admin of their LARP' + ); + } + + public function staffParticipantCanAccessLarpBackoffice(FunctionalTester $I): void + { + $I->wantTo('verify that staff participants can access LARP backoffice'); + + $organizer = UserFactory::createApprovedUser(); + $larp = LarpFactory::createDraftLarp($organizer); + + $staff = UserFactory::createApprovedUser(); + $I->addParticipantToLarp($larp, $staff, [ParticipantRole::STAFF]); + + $I->amLoggedInAs($staff); + $I->amOnRoute('backoffice_larp_dashboard', ['larp' => $larp->getId()]); + + $I->seeResponseCodeIsSuccessful(); + } + + public function superAdminCannotAccessAnyLarpBackoffice(FunctionalTester $I): void + { + $I->wantTo('verify that super admins cannot access LARP backoffices'); + + $organizer = UserFactory::createApprovedUser(); + $larp = LarpFactory::createDraftLarp($organizer); + + $superAdmin = $I->createSuperAdmin(); + + $I->amLoggedInAs($superAdmin); + $I->amOnRoute('backoffice_larp_dashboard', ['larp' => $larp->getId()]); + + $I->seeResponseCodeIs(403); + } + + public function superAdminCanSeeAllHisLarps(FunctionalTester $I): void + { + $I->wantTo('verify that super admins can access admin LARP list'); + + $organizer = UserFactory::createApprovedUser(); + $draftLarp = LarpFactory::createDraftLarp($organizer, 'Secret Draft'); + + $superAdmin = $I->createSuperAdmin(); + + $I->amLoggedInAs($superAdmin); + $I->amOnRoute('backoffice_dashboard'); + + $I->seeResponseCodeIsSuccessful(); + } + + public function approvedUserOnlySeesTheirParticipatingLarps(FunctionalTester $I): void + { + $I->wantTo('verify that users only see their participating LARPs'); + + $organizer1 = UserFactory::createApprovedUser(); + $organizer2 = UserFactory::createApprovedUser(); + + $larp1 = LarpFactory::createDraftLarp($organizer1, 'LARP 1'); + $larp2 = LarpFactory::createDraftLarp($organizer2, 'LARP 2'); + + $I->amLoggedInAs($organizer1); + + // Organizer1 should be able to access LARP 1 + $I->amOnRoute('backoffice_larp_dashboard', ['larp' => $larp1->getId()]); + $I->seeResponseCodeIsSuccessful(); + + // But not LARP 2 + $I->amOnRoute('backoffice_larp_dashboard', ['larp' => $larp2->getId()]); + $I->seeResponseCodeIs(403); + } + + public function cancelledLarpIsNotPubliclyVisible(FunctionalTester $I): void + { + $I->wantTo('verify that cancelled LARPs are not publicly visible'); + + $organizer = UserFactory::createApprovedUser(); + $larp = $I->createLarp($organizer, LarpStageStatus::CANCELLED); + + $I->assertFalse( + $larp->getStatus()->isVisibleForEveryone(), + 'CANCELLED LARP should not be publicly visible' + ); + } + + public function multipleRolesParticipantHasProperAccess(FunctionalTester $I): void + { + $I->wantTo('verify that multi-role participants have proper access'); + + $organizer = UserFactory::createApprovedUser(); + $larp = LarpFactory::createDraftLarp($organizer); + + $multiRoleUser = UserFactory::createApprovedUser(); + $I->addParticipantToLarp($larp, $multiRoleUser, [ + ParticipantRole::STAFF, + ParticipantRole::STORY_WRITER, + ]); + + $I->amLoggedInAs($multiRoleUser); + $I->amOnRoute('backoffice_larp_dashboard', ['larp' => $larp->getId()]); + + $I->seeResponseCodeIsSuccessful(); + } +} diff --git a/tests/Functional/Security/LarpVisibilitySecurityTest.php b/tests/Functional/Security/LarpVisibilitySecurityTest.php deleted file mode 100644 index 6d5dc1a..0000000 --- a/tests/Functional/Security/LarpVisibilitySecurityTest.php +++ /dev/null @@ -1,334 +0,0 @@ -client = static::createClient(); - - // Clear test data before each test to ensure isolation - $this->clearTestData(); - } - - public function test_published_larp_is_publicly_visible(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createPublishedLarp($organizer); - - $this->assertTrue( - $larp->getStatus()->isVisibleForEveryone(), - 'PUBLISHED LARP should be publicly visible' - ); - } - - public function test_draft_larp_is_not_publicly_visible(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createDraftLarp($organizer); - - $this->assertFalse( - $larp->getStatus()->isVisibleForEveryone(), - 'DRAFT LARP should not be publicly visible' - ); - } - - public function test_wip_larp_is_not_publicly_visible(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createWipLarp($organizer); - - $this->assertFalse( - $larp->getStatus()->isVisibleForEveryone(), - 'WIP LARP should not be publicly visible' - ); - } - - public function test_inquiries_larp_is_publicly_visible(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createLarp($organizer, LarpStageStatus::INQUIRIES); - - $this->assertTrue( - $larp->getStatus()->isVisibleForEveryone(), - 'INQUIRIES LARP should be publicly visible' - ); - } - - public function test_confirmed_larp_is_publicly_visible(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createLarp($organizer, LarpStageStatus::CONFIRMED); - - $this->assertTrue( - $larp->getStatus()->isVisibleForEveryone(), - 'CONFIRMED LARP should be publicly visible' - ); - } - - public function test_completed_larp_is_publicly_visible(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createLarp($organizer, LarpStageStatus::COMPLETED); - - $this->assertTrue( - $larp->getStatus()->isVisibleForEveryone(), - 'COMPLETED LARP should be publicly visible' - ); - } - - public function test_unauthenticated_user_can_see_public_larp_in_list(): void - { - $organizer = $this->createApprovedUser(); - $publicLarp = $this->createPublishedLarp($organizer, 'Public LARP'); - - // Ensure the entity manager is flushed and cleared before making the request - $em = $this->getEntityManager(); - $em->flush(); - $em->clear(); - - $crawler = $this->client->request('GET', $this->generateUrl('public_larp_list')); - - $this->assertResponseIsSuccessful('Public should be able to view LARP list'); - - // Check if LARP appears in the list (basic check) - $content = $crawler->html(); - $this->assertStringContainsString( - 'Public LARP', - $content, - 'Public LARP should appear in public list' - ); - } - - public function test_unauthenticated_user_cannot_see_draft_larp_in_list(): void - { - $organizer = $this->createApprovedUser(); - $draftLarp = $this->createDraftLarp($organizer, 'Draft LARP'); - - $crawler = $this->client->request('GET', $this->generateUrl('public_larp_list')); - - $content = $crawler->html(); - $this->assertStringNotContainsString( - 'Draft LARP', - $content, - 'Draft LARP should not appear in public list' - ); - } - - public function test_unauthenticated_user_cannot_access_larp_backoffice(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createPublishedLarp($organizer); - - $this->client->request('GET', $this->generateUrl("backoffice_larp_dashboard", ["larp" => $larp->getId()])); - - $this->assertResponseRedirects( - null, - null, - 'Unauthenticated user should be redirected from LARP backoffice' - ); - } - - public function test_pending_user_cannot_access_larp_backoffice(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createPublishedLarp($organizer); - - $pendingUser = $this->createPendingUser(); - - $this->client->loginUser($pendingUser); - - $this->client->request('GET', $this->generateUrl("backoffice_larp_dashboard", ["larp" => $larp->getId()])); - - $this->assertResponseStatusCodeSame(302); - $this->assertResponseRedirects($this->generateUrl('backoffice_account_pending_approval')); - } - - public function test_participant_can_access_their_larp_backoffice(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createDraftLarp($organizer); - - $this->client->loginUser($organizer); - - $this->client->request('GET', $this->generateUrl("backoffice_larp_dashboard", ["larp" => $larp->getId()])); - - $this->assertResponseIsSuccessful( - 'Organizer should be able to access their LARP backoffice' - ); - } - - public function test_non_participant_cannot_access_other_larp_backoffice(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createDraftLarp($organizer); - - $otherUser = $this->createApprovedUser(); - - $this->client->loginUser($otherUser); - - $this->client->request('GET', $this->generateUrl("backoffice_larp_dashboard", ["larp" => $larp->getId()])); - - $this->assertResponseStatusCodeSame( - 403, - 'Non-participant should not access other LARP backoffice' - ); - } - - public function test_player_participant_can_not_access_larp_backoffice(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createDraftLarp($organizer); - - $player = $this->createApprovedUser(); - $this->addParticipantToLarp($larp, $player); - - $this->client->loginUser($player); - - $this->client->request('GET', $this->generateUrl("backoffice_larp_dashboard", ["larp" => $larp->getId()])); - - $this->assertResponseStatusCodeSame(403); - } - - public function test_organizer_has_full_larp_access(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createDraftLarp($organizer); - - $participant = $larp->getLarpParticipants()[0]; - - $this->assertTrue( - $participant->isOrganizer(), - 'Organizer should have ORGANIZER role' - ); - $this->assertTrue( - $participant->isAdmin(), - 'Organizer should be admin of their LARP' - ); - } - - public function test_staff_participant_can_access_larp_backoffice(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createDraftLarp($organizer); - - $staff = $this->createApprovedUser(); - $this->addParticipantToLarp($larp, $staff, [ParticipantRole::STAFF]); - - $this->client->loginUser($staff); - - $this->client->request('GET', $this->generateUrl("backoffice_larp_dashboard", ["larp" => $larp->getId()])); - - $this->assertResponseIsSuccessful( - 'Staff participant should be able to access LARP backoffice' - ); - } - - public function test_super_admin_can_not_access_any_larp_backoffice(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createDraftLarp($organizer); - - $superAdmin = $this->createSuperAdmin(); - - $this->client->loginUser($superAdmin); - - $this->client->request('GET', $this->generateUrl("backoffice_larp_dashboard", ["larp" => $larp->getId()])); - - $this->assertResponseStatusCodeSame(403); - } - - public function test_super_admin_can_see_all_his_larps(): void - { - $organizer = $this->createApprovedUser(); - $draftLarp = $this->createDraftLarp($organizer, 'Secret Draft'); - - $superAdmin = $this->createSuperAdmin(); - - $this->client->loginUser($superAdmin); - - // Access admin LARP list (should show all LARPs) - $crawler = $this->client->request('GET', $this->generateUrl('backoffice_dashboard')); - - $this->assertResponseIsSuccessful(); - } - - public function test_approved_user_only_sees_their_participating_larps(): void - { - $organizer1 = $this->createApprovedUser(); - $organizer2 = $this->createApprovedUser(); - - $larp1 = $this->createDraftLarp($organizer1, 'LARP 1'); - $larp2 = $this->createDraftLarp($organizer2, 'LARP 2'); - - - $this->client->loginUser($organizer1); - - // Organizer1 should be able to access LARP 1 - $this->client->request('GET', $this->generateUrl("backoffice_larp_dashboard", ["larp" => $larp1->getId()])); - $this->assertResponseIsSuccessful('Organizer1 should access their LARP'); - - // But not LARP 2 - $this->client->request('GET', $this->generateUrl("backoffice_larp_dashboard", ["larp" => $larp2->getId()])); - $this->assertResponseStatusCodeSame(403, 'Organizer1 should not access other organizer\'s LARP'); - } - - public function test_cancelled_larp_is_not_publicly_visible(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createLarp($organizer, LarpStageStatus::CANCELLED); - - $this->assertFalse( - $larp->getStatus()->isVisibleForEveryone(), - 'CANCELLED LARP should not be publicly visible' - ); - } - - public function test_multiple_roles_participant_has_proper_access(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createDraftLarp($organizer); - - $multiRoleUser = $this->createApprovedUser(); - $this->addParticipantToLarp($larp, $multiRoleUser, [ - ParticipantRole::STAFF, - ParticipantRole::STORY_WRITER, - ]); - - $this->client->loginUser($multiRoleUser); - - $this->client->request('GET', $this->generateUrl("backoffice_larp_dashboard", ["larp" => $larp->getId()])); - - $this->assertResponseIsSuccessful( - 'Multi-role participant should be able to access LARP backoffice' - ); - } -} diff --git a/tests/Functional/Security/LocationApprovalSecurityCest.php b/tests/Functional/Security/LocationApprovalSecurityCest.php new file mode 100644 index 0000000..c649be8 --- /dev/null +++ b/tests/Functional/Security/LocationApprovalSecurityCest.php @@ -0,0 +1,392 @@ +wantTo('verify that users can edit their PENDING locations'); + + $user = UserFactory::createApprovedUser(); + $location = $I->createPendingLocation($user); + + $I->amLoggedInAs($user); + + $authChecker = $I->getContainer()->get('security.authorization_checker'); + $canEdit = $authChecker->isGranted('EDIT_LOCATION', $location); + + $I->assertTrue($canEdit, 'User should be able to edit their PENDING location'); + } + + public function userCanEditTheirRejectedLocation(FunctionalTester $I): void + { + $I->wantTo('verify that users can edit their REJECTED locations'); + + $user = UserFactory::createApprovedUser(); + $location = $I->createRejectedLocation($user); + + $I->amLoggedInAs($user); + + $authChecker = $I->getContainer()->get('security.authorization_checker'); + $canEdit = $authChecker->isGranted('EDIT_LOCATION', $location); + + $I->assertTrue($canEdit, 'User should be able to edit their REJECTED location'); + } + + public function userCannotEditTheirApprovedLocation(FunctionalTester $I): void + { + $I->wantTo('verify that users cannot edit their APPROVED locations'); + + $user = UserFactory::createApprovedUser(); + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createPendingLocation($user); + + // Approve it + $locationApprovalService = $I->getContainer()->get( + \App\Domain\Core\Service\LocationApprovalService::class + ); + $locationApprovalService->approve($location, $superAdmin); + + $I->amLoggedInAs($user); + + $authChecker = $I->getContainer()->get('security.authorization_checker'); + $canEdit = $authChecker->isGranted('EDIT_LOCATION', $location); + + $I->assertFalse($canEdit, 'User should not be able to edit their APPROVED location'); + } + + public function userCannotEditOtherUsersLocation(FunctionalTester $I): void + { + $I->wantTo('verify that users cannot edit other users locations'); + + $user1 = UserFactory::createApprovedUser(); + $user2 = UserFactory::createApprovedUser(); + + $location = $I->createPendingLocation($user1); + + $I->amLoggedInAs($user2); + + $authChecker = $I->getContainer()->get('security.authorization_checker'); + $canEdit = $authChecker->isGranted('EDIT_LOCATION', $location); + + $I->assertFalse($canEdit, 'User should not be able to edit other user\'s location'); + } + + public function regularUserCannotApproveLocation(FunctionalTester $I): void + { + $I->wantTo('verify that regular users cannot approve locations'); + + $user = UserFactory::createApprovedUser(); + $location = $I->createPendingLocation($user); + + $I->amLoggedInAs($user); + + $authChecker = $I->getContainer()->get('security.authorization_checker'); + $canApprove = $authChecker->isGranted('APPROVE_LOCATION', $location); + + $I->assertFalse($canApprove, 'Regular user should not be able to approve locations'); + } + + public function superAdminCanApproveLocation(FunctionalTester $I): void + { + $I->wantTo('verify that super admins can approve locations'); + + $user = UserFactory::createApprovedUser(); + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createPendingLocation($user); + + $I->amLoggedInAs($superAdmin); + + $authChecker = $I->getContainer()->get('security.authorization_checker'); + $canApprove = $authChecker->isGranted('APPROVE_LOCATION', $location); + + $I->assertTrue($canApprove, 'SUPER_ADMIN should be able to approve locations'); + } + + public function locationApprovalUpdatesStatus(FunctionalTester $I): void + { + $I->wantTo('verify that location approval updates the status'); + + $user = UserFactory::createApprovedUser(); + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createPendingLocation($user); + + $I->assertEquals(LocationApprovalStatus::PENDING, $location->getApprovalStatus()); + + $locationApprovalService = $I->getContainer()->get( + \App\Domain\Core\Service\LocationApprovalService::class + ); + $locationApprovalService->approve($location, $superAdmin); + + $I->assertEquals(LocationApprovalStatus::APPROVED, $location->getApprovalStatus()); + $I->assertEquals($superAdmin, $location->getApprovedBy()); + $I->assertNotNull($location->getApprovedAt()); + } + + public function regularUserCannotRejectLocation(FunctionalTester $I): void + { + $I->wantTo('verify that regular users cannot reject locations'); + + $user = UserFactory::createApprovedUser(); + $location = $I->createPendingLocation($user); + + $I->amLoggedInAs($user); + + $authChecker = $I->getContainer()->get('security.authorization_checker'); + $canReject = $authChecker->isGranted('REJECT_LOCATION', $location); + + $I->assertFalse($canReject, 'Regular user should not be able to reject locations'); + } + + public function superAdminCanRejectLocation(FunctionalTester $I): void + { + $I->wantTo('verify that super admins can reject locations'); + + $user = UserFactory::createApprovedUser(); + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createPendingLocation($user); + + $I->amLoggedInAs($superAdmin); + + $authChecker = $I->getContainer()->get('security.authorization_checker'); + $canReject = $authChecker->isGranted('REJECT_LOCATION', $location); + + $I->assertTrue($canReject, 'SUPER_ADMIN should be able to reject locations'); + } + + public function locationRejectionStoresReason(FunctionalTester $I): void + { + $I->wantTo('verify that location rejection stores the reason'); + + $user = UserFactory::createApprovedUser(); + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createPendingLocation($user); + + $rejectionReason = 'Invalid address provided'; + + $locationApprovalService = $I->getContainer()->get( + \App\Domain\Core\Service\LocationApprovalService::class + ); + $locationApprovalService->reject($location, $superAdmin, $rejectionReason); + + $I->assertEquals(LocationApprovalStatus::REJECTED, $location->getApprovalStatus()); + $I->assertEquals($rejectionReason, $location->getRejectionReason()); + } + + public function userCanDeleteTheirPendingLocation(FunctionalTester $I): void + { + $I->wantTo('verify that users can delete their PENDING locations'); + + $user = UserFactory::createApprovedUser(); + $location = $I->createPendingLocation($user); + + $I->amLoggedInAs($user); + + $authChecker = $I->getContainer()->get('security.authorization_checker'); + $canDelete = $authChecker->isGranted('DELETE_LOCATION', $location); + + $I->assertTrue($canDelete, 'User should be able to delete their PENDING location'); + } + + public function userCanDeleteTheirRejectedLocation(FunctionalTester $I): void + { + $I->wantTo('verify that users can delete their REJECTED locations'); + + $user = UserFactory::createApprovedUser(); + $location = $I->createRejectedLocation($user); + + $I->amLoggedInAs($user); + + $authChecker = $I->getContainer()->get('security.authorization_checker'); + $canDelete = $authChecker->isGranted('DELETE_LOCATION', $location); + + $I->assertTrue($canDelete, 'User should be able to delete their REJECTED location'); + } + + public function userCannotDeleteTheirApprovedLocation(FunctionalTester $I): void + { + $I->wantTo('verify that users cannot delete their APPROVED locations'); + + $user = UserFactory::createApprovedUser(); + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createPendingLocation($user); + + // Approve it + $locationApprovalService = $I->getContainer()->get( + \App\Domain\Core\Service\LocationApprovalService::class + ); + $locationApprovalService->approve($location, $superAdmin); + + $I->amLoggedInAs($user); + + $authChecker = $I->getContainer()->get('security.authorization_checker'); + $canDelete = $authChecker->isGranted('DELETE_LOCATION', $location); + + $I->assertFalse($canDelete, 'User should not be able to delete their APPROVED location'); + } + + public function superAdminCanDeleteAnyLocation(FunctionalTester $I): void + { + $I->wantTo('verify that super admins can delete any location'); + + $user = UserFactory::createApprovedUser(); + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createApprovedLocation($user); + + $I->amLoggedInAs($superAdmin); + + $authChecker = $I->getContainer()->get('security.authorization_checker'); + $canDelete = $authChecker->isGranted('DELETE_LOCATION', $location); + + $I->assertTrue($canDelete, 'SUPER_ADMIN should be able to delete any location'); + } + + public function superAdminCanEditAnyLocation(FunctionalTester $I): void + { + $I->wantTo('verify that super admins can edit any location'); + + $user = UserFactory::createApprovedUser(); + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createApprovedLocation($user); + + $I->amLoggedInAs($superAdmin); + + $authChecker = $I->getContainer()->get('security.authorization_checker'); + $canEdit = $authChecker->isGranted('EDIT_LOCATION', $location); + + $I->assertTrue($canEdit, 'SUPER_ADMIN should be able to edit any location'); + } + + public function superAdminCanAccessApproveLocationRoute(FunctionalTester $I): void + { + $I->wantTo('verify that super admins can access the location approve route'); + + $user = UserFactory::createApprovedUser(); + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createPendingLocation($user); + + $I->amLoggedInAs($superAdmin); + + $I->sendPOST($I->getUrl('backoffice_location_approve', ['id' => $location->getId()])); + + // Should be successful or redirect (not 403) + $I->assertNotEquals( + 403, + $I->grabHttpResponseCode(), + 'SUPER_ADMIN should be able to access approve route' + ); + } + + public function regularUserCannotAccessApproveLocationRoute(FunctionalTester $I): void + { + $I->wantTo('verify that regular users cannot access the location approve route'); + + $user = UserFactory::createApprovedUser(); + + $location = $I->createPendingLocation($user); + + $I->amLoggedInAs($user); + + $I->sendPOST($I->getUrl('backoffice_location_approve', ['id' => $location->getId()])); + + $I->seeResponseCodeIs(403); + } + + public function superAdminCanAccessRejectLocationRoute(FunctionalTester $I): void + { + $I->wantTo('verify that super admins can access the location reject route'); + + $user = UserFactory::createApprovedUser(); + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createPendingLocation($user); + + $I->amLoggedInAs($superAdmin); + + $I->sendPOST( + $I->getUrl('backoffice_location_reject', ['id' => $location->getId()]), + ['reason' => 'Test rejection'] + ); + + // Should be successful or redirect (not 403) + $I->assertNotEquals( + 403, + $I->grabHttpResponseCode(), + 'SUPER_ADMIN should be able to access reject route' + ); + } + + public function regularUserCannotAccessRejectLocationRoute(FunctionalTester $I): void + { + $I->wantTo('verify that regular users cannot access the location reject route'); + + $user = UserFactory::createApprovedUser(); + + $location = $I->createPendingLocation($user); + + $I->amLoggedInAs($user); + + $I->sendPOST( + $I->getUrl('backoffice_location_reject', ['id' => $location->getId()]), + ['reason' => 'Test rejection'] + ); + + $I->seeResponseCodeIs(403); + } + + public function pendingUserCannotCreateLocation(FunctionalTester $I): void + { + $I->wantTo('verify that pending users cannot create locations'); + + $pendingUser = UserFactory::createPendingUser(); + + $I->amLoggedInAs($pendingUser); + + $authChecker = $I->getContainer()->get('security.authorization_checker'); + $canCreate = $authChecker->isGranted('CREATE_LOCATION'); + + $I->assertFalse($canCreate, 'PENDING user should not be able to create locations'); + } + + public function approvedUserCanCreateLocation(FunctionalTester $I): void + { + $I->wantTo('verify that approved users can create locations'); + + $approvedUser = UserFactory::createApprovedUser(); + + $I->amLoggedInAs($approvedUser); + + $authChecker = $I->getContainer()->get('security.authorization_checker'); + $canCreate = $authChecker->isGranted('CREATE_LOCATION'); + + $I->assertTrue($canCreate, 'APPROVED user should be able to create locations'); + } +} diff --git a/tests/Functional/Security/LocationApprovalSecurityTest.php b/tests/Functional/Security/LocationApprovalSecurityTest.php deleted file mode 100644 index 0c69add..0000000 --- a/tests/Functional/Security/LocationApprovalSecurityTest.php +++ /dev/null @@ -1,367 +0,0 @@ -client = static::createClient(); - } - - - public function test_user_can_edit_their_pending_location(): void - { - $user = $this->createApprovedUser(); - $location = $this->createPendingLocation($user); - - $this->client->loginUser($user); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canEdit = $authChecker->isGranted('EDIT_LOCATION', $location); - - $this->assertTrue($canEdit, 'User should be able to edit their PENDING location'); - } - - public function test_user_can_edit_their_rejected_location(): void - { - $user = $this->createApprovedUser(); - $location = $this->createRejectedLocation($user); - - $this->client->loginUser($user); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canEdit = $authChecker->isGranted('EDIT_LOCATION', $location); - - $this->assertTrue($canEdit, 'User should be able to edit their REJECTED location'); - } - - public function test_user_cannot_edit_their_approved_location(): void - { - $user = $this->createApprovedUser(); - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createPendingLocation($user); - - // Approve it - $locationApprovalService = static::getContainer()->get( - \App\Domain\Core\Service\LocationApprovalService::class - ); - $locationApprovalService->approve($location, $superAdmin); - - $this->client->loginUser($user); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canEdit = $authChecker->isGranted('EDIT_LOCATION', $location); - - $this->assertFalse($canEdit, 'User should not be able to edit their APPROVED location'); - } - - public function test_user_cannot_edit_other_users_location(): void - { - $user1 = $this->createApprovedUser(); - $user2 = $this->createApprovedUser(); - - $location = $this->createPendingLocation($user1); - - $this->client->loginUser($user2); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canEdit = $authChecker->isGranted('EDIT_LOCATION', $location); - - $this->assertFalse($canEdit, 'User should not be able to edit other user\'s location'); - } - - public function test_regular_user_cannot_approve_location(): void - { - $user = $this->createApprovedUser(); - $location = $this->createPendingLocation($user); - - $this->client->loginUser($user); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canApprove = $authChecker->isGranted('APPROVE_LOCATION', $location); - - $this->assertFalse($canApprove, 'Regular user should not be able to approve locations'); - } - - public function test_super_admin_can_approve_location(): void - { - $user = $this->createApprovedUser(); - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createPendingLocation($user); - - $this->client->loginUser($superAdmin); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canApprove = $authChecker->isGranted('APPROVE_LOCATION', $location); - - $this->assertTrue($canApprove, 'SUPER_ADMIN should be able to approve locations'); - } - - public function test_location_approval_updates_status(): void - { - $user = $this->createApprovedUser(); - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createPendingLocation($user); - - $this->assertEquals(LocationApprovalStatus::PENDING, $location->getApprovalStatus()); - - $locationApprovalService = static::getContainer()->get( - \App\Domain\Core\Service\LocationApprovalService::class - ); - $locationApprovalService->approve($location, $superAdmin); - - $this->assertEquals(LocationApprovalStatus::APPROVED, $location->getApprovalStatus()); - $this->assertEquals($superAdmin, $location->getApprovedBy()); - $this->assertNotNull($location->getApprovedAt()); - } - - public function test_regular_user_cannot_reject_location(): void - { - $user = $this->createApprovedUser(); - $location = $this->createPendingLocation($user); - - $this->client->loginUser($user); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canReject = $authChecker->isGranted('REJECT_LOCATION', $location); - - $this->assertFalse($canReject, 'Regular user should not be able to reject locations'); - } - - public function test_super_admin_can_reject_location(): void - { - $user = $this->createApprovedUser(); - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createPendingLocation($user); - - $this->client->loginUser($superAdmin); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canReject = $authChecker->isGranted('REJECT_LOCATION', $location); - - $this->assertTrue($canReject, 'SUPER_ADMIN should be able to reject locations'); - } - - public function test_location_rejection_stores_reason(): void - { - $user = $this->createApprovedUser(); - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createPendingLocation($user); - - $rejectionReason = 'Invalid address provided'; - - $locationApprovalService = static::getContainer()->get( - \App\Domain\Core\Service\LocationApprovalService::class - ); - $locationApprovalService->reject($location, $superAdmin, $rejectionReason); - - $this->assertEquals(LocationApprovalStatus::REJECTED, $location->getApprovalStatus()); - $this->assertEquals($rejectionReason, $location->getRejectionReason()); - } - - public function test_user_can_delete_their_pending_location(): void - { - $user = $this->createApprovedUser(); - $location = $this->createPendingLocation($user); - - $this->client->loginUser($user); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canDelete = $authChecker->isGranted('DELETE_LOCATION', $location); - - $this->assertTrue($canDelete, 'User should be able to delete their PENDING location'); - } - - public function test_user_can_delete_their_rejected_location(): void - { - $user = $this->createApprovedUser(); - $location = $this->createRejectedLocation($user); - - $this->client->loginUser($user); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canDelete = $authChecker->isGranted('DELETE_LOCATION', $location); - - $this->assertTrue($canDelete, 'User should be able to delete their REJECTED location'); - } - - public function test_user_cannot_delete_their_approved_location(): void - { - $user = $this->createApprovedUser(); - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createPendingLocation($user); - - // Approve it - $locationApprovalService = static::getContainer()->get( - \App\Domain\Core\Service\LocationApprovalService::class - ); - $locationApprovalService->approve($location, $superAdmin); - - $this->client->loginUser($user); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canDelete = $authChecker->isGranted('DELETE_LOCATION', $location); - - $this->assertFalse($canDelete, 'User should not be able to delete their APPROVED location'); - } - - public function test_super_admin_can_delete_any_location(): void - { - $user = $this->createApprovedUser(); - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createApprovedLocation($user); - - $this->client->loginUser($superAdmin); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canDelete = $authChecker->isGranted('DELETE_LOCATION', $location); - - $this->assertTrue($canDelete, 'SUPER_ADMIN should be able to delete any location'); - } - - public function test_super_admin_can_edit_any_location(): void - { - $user = $this->createApprovedUser(); - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createApprovedLocation($user); - - $this->client->loginUser($superAdmin); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canEdit = $authChecker->isGranted('EDIT_LOCATION', $location); - - $this->assertTrue($canEdit, 'SUPER_ADMIN should be able to edit any location'); - } - - public function test_super_admin_can_access_approve_location_route(): void - { - $user = $this->createApprovedUser(); - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createPendingLocation($user); - - $this->client->loginUser($superAdmin); - - $this->client->request('POST', $this->generateUrl("backoffice_location_approve", ["id" => $location->getId()])); - - // Should be successful or redirect (not 403) - $this->assertNotEquals( - 403, - $this->client->getResponse()->getStatusCode(), - 'SUPER_ADMIN should be able to access approve route' - ); - } - - public function test_regular_user_cannot_access_approve_location_route(): void - { - $user = $this->createApprovedUser(); - - $location = $this->createPendingLocation($user); - - $this->client->loginUser($user); - - $this->client->request('POST', $this->generateUrl("backoffice_location_approve", ["id" => $location->getId()])); - - $this->assertResponseStatusCodeSame( - 403, - 'Regular user should not be able to access approve route' - ); - } - - public function test_super_admin_can_access_reject_location_route(): void - { - $user = $this->createApprovedUser(); - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createPendingLocation($user); - - $this->client->loginUser($superAdmin); - - $this->client->request('POST', $this->generateUrl("backoffice_location_reject", ["id" => $location->getId()]), [ - 'reason' => 'Test rejection', - ]); - - // Should be successful or redirect (not 403) - $this->assertNotEquals( - 403, - $this->client->getResponse()->getStatusCode(), - 'SUPER_ADMIN should be able to access reject route' - ); - } - - public function test_regular_user_cannot_access_reject_location_route(): void - { - $user = $this->createApprovedUser(); - - $location = $this->createPendingLocation($user); - - $this->client->loginUser($user); - - $this->client->request('POST', $this->generateUrl("backoffice_location_reject", ["id" => $location->getId()]), [ - 'reason' => 'Test rejection', - ]); - - $this->assertResponseStatusCodeSame( - 403, - 'Regular user should not be able to access reject route' - ); - } - - public function test_pending_user_cannot_create_location(): void - { - $pendingUser = $this->createPendingUser(); - - $this->client->loginUser($pendingUser); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canCreate = $authChecker->isGranted('CREATE_LOCATION'); - - $this->assertFalse($canCreate, 'PENDING user should not be able to create locations'); - } - - public function test_approved_user_can_create_location(): void - { - $approvedUser = $this->createApprovedUser(); - - $this->client->loginUser($approvedUser); - - $authChecker = static::getContainer()->get('security.authorization_checker'); - $canCreate = $authChecker->isGranted('CREATE_LOCATION'); - - $this->assertTrue($canCreate, 'APPROVED user should be able to create locations'); - } -} diff --git a/tests/Functional/Security/PublicAccessCest.php b/tests/Functional/Security/PublicAccessCest.php new file mode 100644 index 0000000..e9759f5 --- /dev/null +++ b/tests/Functional/Security/PublicAccessCest.php @@ -0,0 +1,198 @@ +wantTo('verify APPROVED users can access player routes'); + + $approvedUser = UserFactory::createApprovedUser(); + $I->amLoggedInAs($approvedUser); + + $I->amOnRoute('public_larp_my_larps'); + $I->seeResponseCodeIsSuccessful(); + } + + public function pendingUserCanAccessPlayerRoutes(FunctionalTester $I): void + { + $I->wantTo('verify PENDING users can access player routes'); + + $pendingUser = UserFactory::createPendingUser(); + $I->amLoggedInAs($pendingUser); + + $I->amOnRoute('public_larp_my_larps'); + $I->seeResponseCodeIsSuccessful(); + } + + public function approvedUserCanAccessAccountRoutes(FunctionalTester $I): void + { + $I->wantTo('verify APPROVED users can access account routes'); + + $approvedUser = UserFactory::createApprovedUser(); + $I->amLoggedInAs($approvedUser); + + $I->amOnRoute('account_settings'); + $I->seeResponseCodeIsSuccessful(); + } + + public function pendingUserCanAccessAccountRoutes(FunctionalTester $I): void + { + $I->wantTo('verify PENDING users can access account routes'); + + $pendingUser = UserFactory::createPendingUser(); + $I->amLoggedInAs($pendingUser); + + $I->amOnRoute('account_settings'); + $I->seeResponseCodeIsSuccessful(); + } + + public function multipleAccessControlLayersWorkTogether(FunctionalTester $I): void + { + $I->wantTo('verify that user status AND route access control work together'); + + $pendingUser = UserFactory::createPendingUser(); + $I->amLoggedInAs($pendingUser); + + // Try backoffice (should redirect due to PENDING status) + $I->amOnRoute('backoffice_dashboard'); + $I->seeResponseCodeIs(302); + + // Try LARP creation (should redirect due to voter) + $I->amOnRoute('backoffice_larp_create'); + $I->seeResponseCodeIs(302); + + // Try location creation (should redirect due to voter) + $I->amOnPage($I->getUrl('backoffice_location_modify_global', ['location' => 'new'])); + $I->seeResponseCodeIs(302); + } + + public function statusChangeAffectsAccessImmediately(FunctionalTester $I): void + { + $I->wantTo('verify status changes affect access immediately'); + + $user = UserFactory::createPendingUser(); + $I->amLoggedInAs($user); + + // Initially cannot access backoffice (redirected due to PENDING status) + $I->amOnRoute('backoffice_dashboard'); + $I->seeResponseCodeIs(302); + + // Approve user + $user->setStatus(\App\Domain\Account\Entity\Enum\UserStatus::APPROVED); + $I->getEntityManager()->flush(); + + // Clear entity manager and re-authenticate + $I->getEntityManager()->clear(); + $I->amLoggedInAs($user); + + // Now should be able to access backoffice + $I->amOnRoute('backoffice_dashboard'); + $I->seeResponseCodeIsSuccessful(); + } + + public function superAdminCanAccessAllRoutes(FunctionalTester $I): void + { + $I->wantTo('verify SUPER_ADMIN can access all routes'); + + $superAdmin = $I->createSuperAdmin(); + $I->amLoggedInAs($superAdmin); + + // Test multiple routes + $routes = [ + ['route' => 'backoffice_dashboard'], + ['route' => 'super_admin_users_list'], + ['route' => 'backoffice_larp_create'], + ['route' => 'backoffice_location_modify_global', 'params' => ['location' => 'new']], + ]; + + foreach ($routes as $routeData) { + $route = $routeData['route']; + $params = $routeData['params'] ?? []; + + if (empty($params)) { + $I->amOnRoute($route); + } else { + $I->amOnPage($I->getUrl($route, $params)); + } + + $statusCode = $I->grabResponse()->getStatusCode(); + $I->assertNotEquals( + 403, + $statusCode, + "SUPER_ADMIN should access {$route}" + ); + } + } + + public function roleHierarchyIsRespected(FunctionalTester $I): void + { + $I->wantTo('verify role hierarchy is respected'); + + $superAdmin = $I->createSuperAdmin(); + $I->amLoggedInAs($superAdmin); + + // SUPER_ADMIN should have ROLE_SUPER_ADMIN + $I->assertContains( + 'ROLE_SUPER_ADMIN', + $superAdmin->getRoles(), + 'SUPER_ADMIN should have ROLE_SUPER_ADMIN' + ); + + // Verify role hierarchy works by checking access to admin routes + $I->amOnRoute('super_admin_users_list'); + $I->seeResponseCodeIsSuccessful(); + } + + public function publicRoutesAccessibleToAll(FunctionalTester $I): void + { + $I->wantTo('verify public routes are accessible without authentication'); + + // Public routes should be accessible without authentication + $publicRoutes = [ + 'public_larp_list', + 'public_larp_list', // Intentional duplicate from original test + ]; + + foreach ($publicRoutes as $route) { + $I->amOnRoute($route); + $I->seeResponseCodeIsSuccessful(); + } + } + + public function pendingUserCanAccessPublicRoutes(FunctionalTester $I): void + { + $I->wantTo('verify PENDING users can access public routes'); + + $pendingUser = UserFactory::createPendingUser(); + $I->amLoggedInAs($pendingUser); + + // Public routes should be accessible even with PENDING status + $publicRoutes = [ + 'public_larp_list', + 'public_larp_list', // Intentional duplicate from original test + ]; + + foreach ($publicRoutes as $route) { + $I->amOnRoute($route); + $I->seeResponseCodeIsSuccessful(); + } + } +} diff --git a/tests/Functional/Security/PublicAccessTest.php b/tests/Functional/Security/PublicAccessTest.php deleted file mode 100644 index 0b02695..0000000 --- a/tests/Functional/Security/PublicAccessTest.php +++ /dev/null @@ -1,214 +0,0 @@ -client = static::createClient(); - } - - public function test_approved_user_can_access_player_routes(): void - { - $approvedUser = $this->createApprovedUser(); - - $this->client->loginUser($approvedUser); - - $this->client->request('GET', $this->generateUrl('public_larp_my_larps')); - - $this->assertResponseIsSuccessful( - 'APPROVED user should be able to access player routes' - ); - } - - public function test_pending_user_can_access_player_routes(): void - { - $pendingUser = $this->createPendingUser(); - - $this->client->loginUser($pendingUser); - - $this->client->request('GET', $this->generateUrl('public_larp_my_larps')); - - $this->assertResponseIsSuccessful( - 'PENDING user should be able to access account routes' - ); - } - - public function test_approved_user_can_access_account_routes(): void - { - $approvedUser = $this->createApprovedUser(); - - $this->client->loginUser($approvedUser); - - $this->client->request('GET', $this->generateUrl('account_settings')); - - // Should be successful or redirect to a valid account page (not 403) - $this->assertResponseIsSuccessful( - 'APPROVED user should be able to access account routes' - ); - } - - public function test_pending_user_can_access_account_routes(): void - { - $pendingUser = $this->createPendingUser(); - - $this->client->loginUser($pendingUser); - - $this->client->request('GET', $this->generateUrl('account_settings')); - - $this->assertResponseIsSuccessful( - 'PENDING user should be able to access account routes' - ); - } - - public function test_multiple_access_control_layers_work_together(): void - { - // Test that both user status AND route access control work together - - $pendingUser = $this->createPendingUser(); - - $this->client->loginUser($pendingUser); - - // Try backoffice (should redirect due to PENDING status) - $this->client->request('GET', $this->generateUrl('backoffice_dashboard')); - $this->assertResponseRedirects(null, null, 'PENDING user redirected from backoffice'); - - // Try LARP creation (should redirect due to voter) - $this->client->request('GET', $this->generateUrl('backoffice_larp_create')); - $this->assertResponseRedirects(null, null, 'PENDING user redirected from LARP creation'); - - // Try location creation (should redirect due to voter) - $this->client->request('GET', $this->generateUrl('backoffice_location_modify_global', ['location' => 'new'])); - $this->assertResponseRedirects(null, null, 'PENDING user redirected from location creation'); - } - - public function test_status_change_affects_access_immediately(): void - { - $user = $this->createPendingUser(); - - $this->client->loginUser($user); - - // Initially cannot access backoffice (redirected due to PENDING status) - $this->client->request('GET', $this->generateUrl('backoffice_dashboard')); - $this->assertResponseRedirects(null, null, 'PENDING user redirected from backoffice'); - - // Approve user - $this->approveUser($user); - - // Clear the security token to force re-authentication - $this->getEntityManager()->clear(); - $this->client->loginUser($user); - - // Now should be able to access backoffice - $this->client->request('GET', $this->generateUrl('backoffice_dashboard')); - $this->assertResponseIsSuccessful('APPROVED user can now access backoffice'); - } - - public function test_super_admin_can_access_all_routes(): void - { - $superAdmin = $this->createSuperAdmin(); - - $this->client->loginUser($superAdmin); - - // Test multiple routes - $routes = [ - $this->generateUrl('backoffice_dashboard'), - $this->generateUrl('super_admin_users_list'), - $this->generateUrl('backoffice_larp_create'), - $this->generateUrl('backoffice_location_modify_global', ['location' => 'new']), - ]; - - foreach ($routes as $route) { - $this->client->request('GET', $route); - - $this->assertNotEquals( - 403, - $this->client->getResponse()->getStatusCode(), - "SUPER_ADMIN should access {$route}" - ); - } - } - - public function test_role_hierarchy_is_respected(): void - { - $superAdmin = $this->createSuperAdmin(); - - $this->client->loginUser($superAdmin); - - // SUPER_ADMIN should have all lower roles due to role hierarchy - $this->assertContains( - 'ROLE_SUPER_ADMIN', - $superAdmin->getRoles(), - 'SUPER_ADMIN should have ROLE_SUPER_ADMIN' - ); - - // Verify role hierarchy works by checking access to admin routes - $this->client->request('GET', $this->generateUrl('super_admin_users_list')); - $this->assertResponseIsSuccessful( - 'SUPER_ADMIN should access admin routes due to role hierarchy' - ); - } - - public function test_public_routes_accessible_to_all(): void - { - // Public routes should be accessible without authentication - $publicRoutes = [ - $this->generateUrl('public_larp_list'), - $this->generateUrl('public_larp_list'), - ]; - - foreach ($publicRoutes as $route) { - $this->client->request('GET', $route); - - $this->assertResponseIsSuccessful( - "Public route {$route} should be accessible to unauthenticated users" - ); - } - } - - public function test_pending_user_can_access_public_routes(): void - { - $pendingUser = $this->createPendingUser(); - - $this->client->loginUser($pendingUser); - - // Public routes should be accessible even with PENDING status - $publicRoutes = [ - $this->generateUrl('public_larp_list'), - $this->generateUrl('public_larp_list'), - ]; - - foreach ($publicRoutes as $route) { - $this->client->request('GET', $route); - - $this->assertResponseIsSuccessful( - "PENDING user should be able to access public route {$route}" - ); - } - } -} diff --git a/tests/Functional/StoryMarketplace/RecruitmentControllerCest.php b/tests/Functional/StoryMarketplace/RecruitmentControllerCest.php new file mode 100644 index 0000000..5124dc6 --- /dev/null +++ b/tests/Functional/StoryMarketplace/RecruitmentControllerCest.php @@ -0,0 +1,24 @@ +wantTo('verify that recruitment route requires authentication'); + + $I->amOnPage('/backoffice/larp/00000000-0000-0000-0000-000000000000/story/thread/123/recruitment'); + + // Unauthenticated users should be redirected or receive a client error + $responseCode = $I->grabResponseCode(); + $I->assertTrue( + $responseCode >= 300 && $responseCode < 400 || $responseCode >= 400 && $responseCode < 500, + 'Response should be a redirect (3xx) or client error (4xx)' + ); + } +} diff --git a/tests/Domain/StoryObject/Service/StoryGraphFactionFilterTest.php b/tests/Functional/StoryObject/Service/StoryGraphFactionFilterTest.php old mode 100755 new mode 100644 similarity index 97% rename from tests/Domain/StoryObject/Service/StoryGraphFactionFilterTest.php rename to tests/Functional/StoryObject/Service/StoryGraphFactionFilterTest.php index 4123684..7cc667b --- a/tests/Domain/StoryObject/Service/StoryGraphFactionFilterTest.php +++ b/tests/Functional/StoryObject/Service/StoryGraphFactionFilterTest.php @@ -1,6 +1,6 @@ wantTo('verify that submission stats are calculated correctly for a LARP'); + + $larp = new Larp(); + + $faction = new Faction(); + $larp->addFaction($faction); + + $character = new Character(); + $larp->addCharacter($character); + $faction->addMember($character); + + $application = new LarpApplication(); + $application->setLarp($larp); + $choice = new LarpApplicationChoice(); + $choice->setCharacter($character); + $application->addChoice($choice); + + $repo = $this->createMock(LarpApplicationRepository::class); + $repo->expects($this->once()) + ->method('findBy') + ->with(['larp' => $larp]) + ->willReturn([$application]); + + $factionsArray = $larp->getFactions()->toArray(); + + $preloader = $this->createMock(EntityPreloader::class); + $preloader->expects($this->exactly(2)) + ->method('preload') + ->willReturnCallback(function ($entities, $property) use ($application, $factionsArray) { + // First call: preload choices for applications + // Second call: preload members for factions + return null; + }); + + $service = new SubmissionStatsService($repo, $preloader); + $stats = $service->getStatsForLarp($larp); + + $I->assertSame([$application], $stats['applications']); + $I->assertCount(1, $stats['factionStats']); + $I->assertSame($faction, $stats['factionStats'][0]['faction']); + $I->assertEquals(100.0, $stats['factionStats'][0]['percentage']); + } +} diff --git a/tests/Integration/Mailing/MailTemplateManagerCest.php b/tests/Integration/Mailing/MailTemplateManagerCest.php new file mode 100644 index 0000000..e47261e --- /dev/null +++ b/tests/Integration/Mailing/MailTemplateManagerCest.php @@ -0,0 +1,50 @@ +wantTo('verify that ensureTemplatesForLarp creates missing templates'); + + $larp = new Larp(); + $larp->setTitle('Test'); + $larp->setDescription('desc'); + + $definition = new MailTemplateDefinition( + MailTemplateType::ENQUIRY_OPEN, + 'test', + 'desc', + 'subject', + 'body', + ['placeholder'], + true, + ); + + $provider = $this->createMock(MailTemplateDefinitionProvider::class); + $provider->method('getDefinitions')->willReturn([ + MailTemplateType::ENQUIRY_OPEN->value => $definition, + ]); + $provider->method('getDefinition')->willReturn($definition); + + $repository = $this->createMock(MailTemplateRepository::class); + $repository->expects($this->once())->method('findBy')->with(['larp' => $larp])->willReturn([]); + $repository->expects($this->once())->method('save'); + $repository->expects($this->once())->method('flush'); + + $manager = new MailTemplateManager($repository, $provider); + $manager->ensureTemplatesForLarp($larp); + } +} diff --git a/tests/Integration/Repository/LarpRepositoryCest.php b/tests/Integration/Repository/LarpRepositoryCest.php new file mode 100644 index 0000000..c64094a --- /dev/null +++ b/tests/Integration/Repository/LarpRepositoryCest.php @@ -0,0 +1,312 @@ +larpRepository = $I->grabService(LarpRepository::class); + } + + public function findAllReturnsAllLarps(FunctionalTester $I): void + { + $I->wantTo('verify that findAll returns all LARPs'); + + $organizer1 = $I->createApprovedUser('organizer1@example.com'); + $organizer2 = $I->createApprovedUser('organizer2@example.com'); + + $larp1 = LarpFactory::createDraftLarp($organizer1, 'LARP 1'); + $larp2 = $I->createPublishedLarp($organizer2, 'LARP 2'); + + $allLarps = $this->larpRepository->findAll(); + + $I->assertGreaterThanOrEqual(2, count($allLarps), 'Should find at least 2 LARPs'); + } + + public function findByUserReturnsOnlyParticipatingLarps(FunctionalTester $I): void + { + $I->wantTo('verify that findByUser returns only LARPs where user participates'); + + $organizer1 = $I->createApprovedUser('organizer1@example.com'); + $organizer2 = $I->createApprovedUser('organizer2@example.com'); + + $larp1 = LarpFactory::createDraftLarp($organizer1, 'LARP 1'); + $larp2 = LarpFactory::createDraftLarp($organizer2, 'LARP 2'); + + // Find LARPs for organizer1 + $userLarps = $this->larpRepository->createQueryBuilder('l') + ->join('l.larpParticipants', 'lp') + ->where('lp.user = :user') + ->setParameter('user', $organizer1) + ->getQuery() + ->getResult(); + + $I->assertCount(1, $userLarps, 'User should only see their participating LARPs'); + $I->assertEquals($larp1->getId(), $userLarps[0]->getId()); + } + + public function findPubliclyVisibleLarps(FunctionalTester $I): void + { + $I->wantTo('verify that query filters public LARPs correctly by status'); + + $organizer = UserFactory::createApprovedUser(); + + $draftLarp = LarpFactory::createDraftLarp($organizer, 'Draft'); + $wipLarp = $I->createWipLarp($organizer, 'WIP'); + $publishedLarp = $I->createPublishedLarp($organizer, 'Published'); + $inquiriesLarp = $I->createLarp($organizer, LarpStageStatus::INQUIRIES, 'Inquiries'); + + // Query for public LARPs + $publicStatuses = [ + LarpStageStatus::PUBLISHED->value, + LarpStageStatus::INQUIRIES->value, + LarpStageStatus::CONFIRMED->value, + LarpStageStatus::COMPLETED->value, + ]; + + $publicLarps = $this->larpRepository->createQueryBuilder('l') + ->where('l.status IN (:publicStatuses)') + ->setParameter('publicStatuses', $publicStatuses) + ->getQuery() + ->getResult(); + + $publicIds = array_map(fn ($larp) => $larp->getId(), $publicLarps); + + $I->assertContains($publishedLarp->getId(), $publicIds, 'Published LARP should be in public list'); + $I->assertContains($inquiriesLarp->getId(), $publicIds, 'Inquiries LARP should be in public list'); + $I->assertNotContains($draftLarp->getId(), $publicIds, 'Draft LARP should not be in public list'); + $I->assertNotContains($wipLarp->getId(), $publicIds, 'WIP LARP should not be in public list'); + } + + public function countOrganizerLarpsForUser(FunctionalTester $I): void + { + $I->wantTo('verify that counting organizer LARPs works correctly'); + + $organizer = UserFactory::createApprovedUser(); + + LarpFactory::createDraftLarp($organizer, 'LARP 1'); + LarpFactory::createDraftLarp($organizer, 'LARP 2'); + + // Count organizer LARPs + $count = $this->larpRepository->createQueryBuilder('l') + ->select('COUNT(l.id)') + ->join('l.larpParticipants', 'lp') + ->where('lp.user = :user') + ->andWhere('JSON_CONTAINS(lp.roles, :organizerRole) = 1') + ->setParameter('user', $organizer) + ->setParameter('organizerRole', json_encode(ParticipantRole::ORGANIZER->value)) + ->getQuery() + ->getSingleScalarResult(); + + $I->assertEquals(2, $count, 'User should have 2 LARPs as organizer'); + } + + public function findLarpsByStatus(FunctionalTester $I): void + { + $I->wantTo('verify that finding LARPs by status works correctly'); + + $organizer = UserFactory::createApprovedUser(); + + LarpFactory::createDraftLarp($organizer, 'Draft 1'); + LarpFactory::createDraftLarp($organizer, 'Draft 2'); + $I->createPublishedLarp($organizer, 'Published 1'); + + $draftLarps = $this->larpRepository->createQueryBuilder('l') + ->where('l.status = :status') + ->setParameter('status', LarpStageStatus::DRAFT->value) + ->getQuery() + ->getResult(); + + $I->assertGreaterThanOrEqual(2, count($draftLarps), 'Should find at least 2 draft LARPs'); + } + + public function findLarpsWhereUserIsPlayer(FunctionalTester $I): void + { + $I->wantTo('verify that finding LARPs where user is player works correctly'); + + $organizer = $I->createApprovedUser('organizer@example.com'); + $player = $I->createApprovedUser('player@example.com'); + + $larp1 = LarpFactory::createDraftLarp($organizer, 'LARP 1'); + $larp2 = LarpFactory::createDraftLarp($organizer, 'LARP 2'); + + // Add player to larp1 + $I->addParticipantToLarp($larp1, $player, [ParticipantRole::PLAYER]); + + $playerLarps = $this->larpRepository->createQueryBuilder('l') + ->join('l.larpParticipants', 'lp') + ->where('lp.user = :user') + ->andWhere('JSON_CONTAINS(lp.roles, :playerRole) = 1') + ->setParameter('user', $player) + ->setParameter('playerRole', json_encode(ParticipantRole::PLAYER->value)) + ->getQuery() + ->getResult(); + + $I->assertCount(1, $playerLarps, 'Player should participate in 1 LARP'); + $I->assertEquals($larp1->getId(), $playerLarps[0]->getId()); + } + + public function findLarpsWhereUserIsOrganizer(FunctionalTester $I): void + { + $I->wantTo('verify that finding LARPs where user is organizer works correctly'); + + $organizer = $I->createApprovedUser('organizer@example.com'); + $otherUser = $I->createApprovedUser('other@example.com'); + + $larp1 = LarpFactory::createDraftLarp($organizer, 'LARP 1'); + $larp2 = LarpFactory::createDraftLarp($otherUser, 'LARP 2'); + + $organizerLarps = $this->larpRepository->createQueryBuilder('l') + ->join('l.larpParticipants', 'lp') + ->where('lp.user = :user') + ->andWhere('JSON_CONTAINS(lp.roles, :organizerRole) = 1') + ->setParameter('user', $organizer) + ->setParameter('organizerRole', json_encode(ParticipantRole::ORGANIZER->value)) + ->getQuery() + ->getResult(); + + $I->assertCount(1, $organizerLarps, 'User should organize 1 LARP'); + $I->assertEquals($larp1->getId(), $organizerLarps[0]->getId()); + } + + public function findLarpsWithParticipantsCount(FunctionalTester $I): void + { + $I->wantTo('verify that querying LARPs with participant count works correctly'); + + $organizer = $I->createApprovedUser('organizer@example.com'); + $player1 = $I->createApprovedUser('player1@example.com'); + $player2 = $I->createApprovedUser('player2@example.com'); + + $larp = LarpFactory::createDraftLarp($organizer, 'LARP with Participants'); + + // Add players + $I->addParticipantToLarp($larp, $player1, [ParticipantRole::PLAYER]); + $I->addParticipantToLarp($larp, $player2, [ParticipantRole::PLAYER]); + + // Query with participant count + $result = $this->larpRepository->createQueryBuilder('l') + ->select('l', 'COUNT(lp.id) as participantCount') + ->leftJoin('l.larpParticipants', 'lp') + ->where('l.id = :larpId') + ->setParameter('larpId', $larp->getId()) + ->groupBy('l.id') + ->getQuery() + ->getSingleResult(); + + $I->assertEquals(3, $result['participantCount'], 'LARP should have 3 participants (organizer + 2 players)'); + } + + public function findFutureLarps(FunctionalTester $I): void + { + $I->wantTo('verify that finding future LARPs works correctly'); + + $organizer = UserFactory::createApprovedUser(); + + // Create future LARP + $futureLarp = $I->createPublishedLarp($organizer, 'Future LARP'); + + $futureLarps = $this->larpRepository->createQueryBuilder('l') + ->where('l.startDate > :now') + ->setParameter('now', new \DateTime()) + ->getQuery() + ->getResult(); + + $futureIds = array_map(fn ($larp) => $larp->getId(), $futureLarps); + + $I->assertContains( + $futureLarp->getId(), + $futureIds, + 'Future LARP should be in future LARPs list' + ); + } + + public function findLarpsByDateRange(FunctionalTester $I): void + { + $I->wantTo('verify that finding LARPs by date range works correctly'); + + $organizer = UserFactory::createApprovedUser(); + + $larp = $I->createPublishedLarp($organizer, 'LARP in Range'); + + $startDate = new \DateTime('-1 month'); + $endDate = new \DateTime('+2 months'); + + $larpsInRange = $this->larpRepository->createQueryBuilder('l') + ->where('l.startDate >= :startDate') + ->andWhere('l.startDate <= :endDate') + ->setParameter('startDate', $startDate) + ->setParameter('endDate', $endDate) + ->getQuery() + ->getResult(); + + $rangeIds = array_map(fn ($larp) => $larp->getId(), $larpsInRange); + + $I->assertContains( + $larp->getId(), + $rangeIds, + 'LARP should be in date range' + ); + } + + public function repositoryRespectsEntityManagerClear(FunctionalTester $I): void + { + $I->wantTo('verify that repository respects entity manager clear'); + + $organizer = UserFactory::createApprovedUser(); + $larp = LarpFactory::createDraftLarp($organizer); + + $larpId = $larp->getId(); + + // Clear entity manager + $I->getEntityManager()->clear(); + + // Find again + $reloadedLarp = $this->larpRepository->find($larpId); + + $I->assertNotNull($reloadedLarp); + $I->assertEquals($larpId, $reloadedLarp->getId()); + $I->assertEquals(LarpStageStatus::DRAFT, $reloadedLarp->getStatus()); + } + + public function userOrganizerLarpCountMethod(FunctionalTester $I): void + { + $I->wantTo('verify that user organizer LARP count method works correctly'); + + $organizer = UserFactory::createApprovedUser(); + + $initialCount = $organizer->getOrganizerLarpCount(); + + LarpFactory::createDraftLarp($organizer, 'LARP 1'); + LarpFactory::createDraftLarp($organizer, 'LARP 2'); + + // Clear and reload user to get fresh count + $I->getEntityManager()->clear(); + $reloadedUser = $I->getEntityManager()->find( + \App\Domain\Account\Entity\User::class, + $organizer->getId() + ); + + $newCount = $reloadedUser->getOrganizerLarpCount(); + + $I->assertEquals( + $initialCount + 2, + $newCount, + 'Organizer LARP count should increase by 2' + ); + } +} diff --git a/tests/Integration/Repository/LarpRepositoryTest.php b/tests/Integration/Repository/LarpRepositoryTest.php deleted file mode 100644 index f9d14f6..0000000 --- a/tests/Integration/Repository/LarpRepositoryTest.php +++ /dev/null @@ -1,300 +0,0 @@ -clearTestData(); - - $this->larpRepository = static::getContainer()->get(LarpRepository::class); - } - - protected function tearDown(): void - { - $this->clearTestData(); - parent::tearDown(); - } - - public function test_find_all_returns_all_larps(): void - { - $organizer1 = $this->createApprovedUser('organizer1@example.com'); - $organizer2 = $this->createApprovedUser('organizer2@example.com'); - - $larp1 = $this->createDraftLarp($organizer1, 'LARP 1'); - $larp2 = $this->createPublishedLarp($organizer2, 'LARP 2'); - - $allLarps = $this->larpRepository->findAll(); - - $this->assertGreaterThanOrEqual(2, count($allLarps), 'Should find at least 2 LARPs'); - } - - public function test_find_by_user_returns_only_participating_larps(): void - { - $organizer1 = $this->createApprovedUser('organizer1@example.com'); - $organizer2 = $this->createApprovedUser('organizer2@example.com'); - - $larp1 = $this->createDraftLarp($organizer1, 'LARP 1'); - $larp2 = $this->createDraftLarp($organizer2, 'LARP 2'); - - // Find LARPs for organizer1 - $userLarps = $this->larpRepository->createQueryBuilder('l') - ->join('l.larpParticipants', 'lp') - ->where('lp.user = :user') - ->setParameter('user', $organizer1) - ->getQuery() - ->getResult(); - - $this->assertCount(1, $userLarps, 'User should only see their participating LARPs'); - $this->assertEquals($larp1->getId(), $userLarps[0]->getId()); - } - - public function test_find_publicly_visible_larps(): void - { - $organizer = $this->createApprovedUser(); - - $draftLarp = $this->createDraftLarp($organizer, 'Draft'); - $wipLarp = $this->createWipLarp($organizer, 'WIP'); - $publishedLarp = $this->createPublishedLarp($organizer, 'Published'); - $inquiriesLarp = $this->createLarp($organizer, LarpStageStatus::INQUIRIES, 'Inquiries'); - - // Query for public LARPs - $publicStatuses = [ - LarpStageStatus::PUBLISHED->value, - LarpStageStatus::INQUIRIES->value, - LarpStageStatus::CONFIRMED->value, - LarpStageStatus::COMPLETED->value, - ]; - - $publicLarps = $this->larpRepository->createQueryBuilder('l') - ->where('l.status IN (:publicStatuses)') - ->setParameter('publicStatuses', $publicStatuses) - ->getQuery() - ->getResult(); - - $publicIds = array_map(fn ($larp) => $larp->getId(), $publicLarps); - - $this->assertContains($publishedLarp->getId(), $publicIds, 'Published LARP should be in public list'); - $this->assertContains($inquiriesLarp->getId(), $publicIds, 'Inquiries LARP should be in public list'); - $this->assertNotContains($draftLarp->getId(), $publicIds, 'Draft LARP should not be in public list'); - $this->assertNotContains($wipLarp->getId(), $publicIds, 'WIP LARP should not be in public list'); - } - - public function test_count_organizer_larps_for_user(): void - { - $organizer = $this->createApprovedUser(); - - $this->createDraftLarp($organizer, 'LARP 1'); - $this->createDraftLarp($organizer, 'LARP 2'); - - // Count organizer LARPs - $count = $this->larpRepository->createQueryBuilder('l') - ->select('COUNT(l.id)') - ->join('l.larpParticipants', 'lp') - ->where('lp.user = :user') - ->andWhere('JSON_CONTAINS(lp.roles, :organizerRole) = 1') - ->setParameter('user', $organizer) - ->setParameter('organizerRole', json_encode(ParticipantRole::ORGANIZER->value)) - ->getQuery() - ->getSingleScalarResult(); - - $this->assertEquals(2, $count, 'User should have 2 LARPs as organizer'); - } - - public function test_find_larps_by_status(): void - { - $organizer = $this->createApprovedUser(); - - $this->createDraftLarp($organizer, 'Draft 1'); - $this->createDraftLarp($organizer, 'Draft 2'); - $this->createPublishedLarp($organizer, 'Published 1'); - - $draftLarps = $this->larpRepository->createQueryBuilder('l') - ->where('l.status = :status') - ->setParameter('status', LarpStageStatus::DRAFT->value) - ->getQuery() - ->getResult(); - - $this->assertGreaterThanOrEqual(2, count($draftLarps), 'Should find at least 2 draft LARPs'); - } - - public function test_find_larps_where_user_is_player(): void - { - $organizer = $this->createApprovedUser('organizer@example.com'); - $player = $this->createApprovedUser('player@example.com'); - - $larp1 = $this->createDraftLarp($organizer, 'LARP 1'); - $larp2 = $this->createDraftLarp($organizer, 'LARP 2'); - - // Add player to larp1 - $this->addParticipantToLarp($larp1, $player, [ParticipantRole::PLAYER]); - - $playerLarps = $this->larpRepository->createQueryBuilder('l') - ->join('l.larpParticipants', 'lp') - ->where('lp.user = :user') - ->andWhere('JSON_CONTAINS(lp.roles, :playerRole) = 1') - ->setParameter('user', $player) - ->setParameter('playerRole', json_encode(ParticipantRole::PLAYER->value)) - ->getQuery() - ->getResult(); - - $this->assertCount(1, $playerLarps, 'Player should participate in 1 LARP'); - $this->assertEquals($larp1->getId(), $playerLarps[0]->getId()); - } - - public function test_find_larps_where_user_is_organizer(): void - { - $organizer = $this->createApprovedUser('organizer@example.com'); - $otherUser = $this->createApprovedUser('other@example.com'); - - $larp1 = $this->createDraftLarp($organizer, 'LARP 1'); - $larp2 = $this->createDraftLarp($otherUser, 'LARP 2'); - - $organizerLarps = $this->larpRepository->createQueryBuilder('l') - ->join('l.larpParticipants', 'lp') - ->where('lp.user = :user') - ->andWhere('JSON_CONTAINS(lp.roles, :organizerRole) = 1') - ->setParameter('user', $organizer) - ->setParameter('organizerRole', json_encode(ParticipantRole::ORGANIZER->value)) - ->getQuery() - ->getResult(); - - $this->assertCount(1, $organizerLarps, 'User should organize 1 LARP'); - $this->assertEquals($larp1->getId(), $organizerLarps[0]->getId()); - } - - public function test_find_larps_with_participants_count(): void - { - $organizer = $this->createApprovedUser('organizer@example.com'); - $player1 = $this->createApprovedUser('player1@example.com'); - $player2 = $this->createApprovedUser('player2@example.com'); - - $larp = $this->createDraftLarp($organizer, 'LARP with Participants'); - - // Add players - $this->addParticipantToLarp($larp, $player1, [ParticipantRole::PLAYER]); - $this->addParticipantToLarp($larp, $player2, [ParticipantRole::PLAYER]); - - // Query with participant count - $result = $this->larpRepository->createQueryBuilder('l') - ->select('l', 'COUNT(lp.id) as participantCount') - ->leftJoin('l.larpParticipants', 'lp') - ->where('l.id = :larpId') - ->setParameter('larpId', $larp->getId()) - ->groupBy('l.id') - ->getQuery() - ->getSingleResult(); - - $this->assertEquals(3, $result['participantCount'], 'LARP should have 3 participants (organizer + 2 players)'); - } - - public function test_find_future_larps(): void - { - $organizer = $this->createApprovedUser(); - - // Create future LARP - $futureLarp = $this->createPublishedLarp($organizer, 'Future LARP'); - - $futureLarps = $this->larpRepository->createQueryBuilder('l') - ->where('l.startDate > :now') - ->setParameter('now', new \DateTime()) - ->getQuery() - ->getResult(); - - $futureIds = array_map(fn ($larp) => $larp->getId(), $futureLarps); - - $this->assertContains( - $futureLarp->getId(), - $futureIds, - 'Future LARP should be in future LARPs list' - ); - } - - public function test_find_larps_by_date_range(): void - { - $organizer = $this->createApprovedUser(); - - $larp = $this->createPublishedLarp($organizer, 'LARP in Range'); - - $startDate = new \DateTime('-1 month'); - $endDate = new \DateTime('+2 months'); - - $larpsInRange = $this->larpRepository->createQueryBuilder('l') - ->where('l.startDate >= :startDate') - ->andWhere('l.startDate <= :endDate') - ->setParameter('startDate', $startDate) - ->setParameter('endDate', $endDate) - ->getQuery() - ->getResult(); - - $rangeIds = array_map(fn ($larp) => $larp->getId(), $larpsInRange); - - $this->assertContains( - $larp->getId(), - $rangeIds, - 'LARP should be in date range' - ); - } - - public function test_repository_respects_entity_manager_clear(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createDraftLarp($organizer); - - $larpId = $larp->getId(); - - // Clear entity manager - $this->getEntityManager()->clear(); - - // Find again - $reloadedLarp = $this->larpRepository->find($larpId); - - $this->assertNotNull($reloadedLarp); - $this->assertEquals($larpId, $reloadedLarp->getId()); - $this->assertEquals(LarpStageStatus::DRAFT, $reloadedLarp->getStatus()); - } - - public function test_user_organizer_larp_count_method(): void - { - $organizer = $this->createApprovedUser(); - - $initialCount = $organizer->getOrganizerLarpCount(); - - $this->createDraftLarp($organizer, 'LARP 1'); - $this->createDraftLarp($organizer, 'LARP 2'); - - // Clear and reload user to get fresh count - $this->getEntityManager()->clear(); - $reloadedUser = $this->getEntityManager()->find( - \App\Domain\Account\Entity\User::class, - $organizer->getId() - ); - - $newCount = $reloadedUser->getOrganizerLarpCount(); - - $this->assertEquals( - $initialCount + 2, - $newCount, - 'Organizer LARP count should increase by 2' - ); - } -} diff --git a/tests/Integration/Service/LarpWorkflowCest.php b/tests/Integration/Service/LarpWorkflowCest.php new file mode 100644 index 0000000..703c970 --- /dev/null +++ b/tests/Integration/Service/LarpWorkflowCest.php @@ -0,0 +1,434 @@ +larpWorkflow = $I->grabService('workflow.larp_stage_status'); + } + + public function newLarpStartsInDraftStatus(FunctionalTester $I): void + { + $I->wantTo('verify that new LARP starts in DRAFT status'); + + $organizer = UserFactory::createApprovedUser(); + $larp = $I->createLarp($organizer); + + $I->assertEquals( + LarpStageStatus::DRAFT, + $larp->getStatus(), + 'New LARP should start in DRAFT status' + ); + } + + public function draftIsNotPubliclyVisible(FunctionalTester $I): void + { + $I->wantTo('verify that DRAFT status is not publicly visible'); + + $status = LarpStageStatus::DRAFT; + + $I->assertFalse( + $status->isVisibleForEveryone(), + 'DRAFT status should not be publicly visible' + ); + } + + public function wipIsNotPubliclyVisible(FunctionalTester $I): void + { + $I->wantTo('verify that WIP status is not publicly visible'); + + $status = LarpStageStatus::WIP; + + $I->assertFalse( + $status->isVisibleForEveryone(), + 'WIP status should not be publicly visible' + ); + } + + public function publishedIsPubliclyVisible(FunctionalTester $I): void + { + $I->wantTo('verify that PUBLISHED status is publicly visible'); + + $status = LarpStageStatus::PUBLISHED; + + $I->assertTrue( + $status->isVisibleForEveryone(), + 'PUBLISHED status should be publicly visible' + ); + } + + public function inquiriesIsPubliclyVisible(FunctionalTester $I): void + { + $I->wantTo('verify that INQUIRIES status is publicly visible'); + + $status = LarpStageStatus::INQUIRIES; + + $I->assertTrue( + $status->isVisibleForEveryone(), + 'INQUIRIES status should be publicly visible' + ); + } + + public function confirmedIsPubliclyVisible(FunctionalTester $I): void + { + $I->wantTo('verify that CONFIRMED status is publicly visible'); + + $status = LarpStageStatus::CONFIRMED; + + $I->assertTrue( + $status->isVisibleForEveryone(), + 'CONFIRMED status should be publicly visible' + ); + } + + public function completedIsPubliclyVisible(FunctionalTester $I): void + { + $I->wantTo('verify that COMPLETED status is publicly visible'); + + $status = LarpStageStatus::COMPLETED; + + $I->assertTrue( + $status->isVisibleForEveryone(), + 'COMPLETED status should be publicly visible' + ); + } + + public function cancelledIsNotPubliclyVisible(FunctionalTester $I): void + { + $I->wantTo('verify that CANCELLED status is not publicly visible'); + + $status = LarpStageStatus::CANCELLED; + + $I->assertFalse( + $status->isVisibleForEveryone(), + 'CANCELLED status should not be publicly visible' + ); + } + + public function workflowCanTransitionFromDraftToWip(FunctionalTester $I): void + { + $I->wantTo('verify that workflow allows transition from DRAFT to WIP'); + + $organizer = UserFactory::createApprovedUser(); + $larp = LarpFactory::createDraftLarp($organizer); + + $canTransition = $this->larpWorkflow->can($larp, 'to_wip'); + + $I->assertTrue( + $canTransition, + 'Workflow should allow transition from DRAFT to WIP' + ); + } + + public function workflowCanTransitionFromDraftToPublished(FunctionalTester $I): void + { + $I->wantTo('verify that workflow allows transition from DRAFT to PUBLISHED'); + + $organizer = UserFactory::createApprovedUser(); + $larp = LarpFactory::createDraftLarp($organizer); + + $canTransition = $this->larpWorkflow->can($larp, 'to_published'); + + $I->assertTrue( + $canTransition, + 'Workflow should allow transition from DRAFT to PUBLISHED' + ); + } + + public function workflowCanTransitionFromWipToPublished(FunctionalTester $I): void + { + $I->wantTo('verify that workflow allows transition from WIP to PUBLISHED'); + + $organizer = UserFactory::createApprovedUser(); + $larp = $I->createWipLarp($organizer); + + $canTransition = $this->larpWorkflow->can($larp, 'to_published'); + + $I->assertTrue( + $canTransition, + 'Workflow should allow transition from WIP to PUBLISHED' + ); + } + + public function workflowCanTransitionFromPublishedToInquiries(FunctionalTester $I): void + { + $I->wantTo('verify that workflow allows transition from PUBLISHED to INQUIRIES'); + + $organizer = UserFactory::createApprovedUser(); + $larp = $I->createPublishedLarp($organizer); + + $canTransition = $this->larpWorkflow->can($larp, 'to_inquiries'); + + $I->assertTrue( + $canTransition, + 'Workflow should allow transition from PUBLISHED to INQUIRIES' + ); + } + + public function workflowCanTransitionFromInquiriesToConfirmed(FunctionalTester $I): void + { + $I->wantTo('verify that workflow allows transition from INQUIRIES to CONFIRMED'); + + $organizer = UserFactory::createApprovedUser(); + $larp = $I->createLarp($organizer, LarpStageStatus::INQUIRIES); + + $canTransition = $this->larpWorkflow->can($larp, 'to_confirmed'); + + $I->assertTrue( + $canTransition, + 'Workflow should allow transition from INQUIRIES to CONFIRMED' + ); + } + + public function workflowCanTransitionFromConfirmedToCompleted(FunctionalTester $I): void + { + $I->wantTo('verify that workflow allows transition from CONFIRMED to COMPLETED'); + + $organizer = UserFactory::createApprovedUser(); + $larp = $I->createLarp($organizer, LarpStageStatus::CONFIRMED); + + $canTransition = $this->larpWorkflow->can($larp, 'to_completed'); + + $I->assertTrue( + $canTransition, + 'Workflow should allow transition from CONFIRMED to COMPLETED' + ); + } + + public function workflowCanTransitionFromConfirmedToCancelled(FunctionalTester $I): void + { + $I->wantTo('verify that workflow allows transition from CONFIRMED to CANCELLED'); + + $organizer = UserFactory::createApprovedUser(); + $larp = $I->createLarp($organizer, LarpStageStatus::CONFIRMED); + + $canTransition = $this->larpWorkflow->can($larp, 'to_cancelled'); + + $I->assertTrue( + $canTransition, + 'Workflow should allow transition from CONFIRMED to CANCELLED' + ); + } + + public function workflowCanTransitionBackFromPublishedToDraft(FunctionalTester $I): void + { + $I->wantTo('verify that workflow allows transition back from PUBLISHED to DRAFT'); + + $organizer = UserFactory::createApprovedUser(); + $larp = $I->createPublishedLarp($organizer); + + $canTransition = $this->larpWorkflow->can($larp, 'back_to_draft'); + + $I->assertTrue( + $canTransition, + 'Workflow should allow transition back from PUBLISHED to DRAFT' + ); + } + + public function workflowCanTransitionBackFromPublishedToWip(FunctionalTester $I): void + { + $I->wantTo('verify that workflow allows transition back from PUBLISHED to WIP'); + + $organizer = UserFactory::createApprovedUser(); + $larp = $I->createPublishedLarp($organizer); + + $canTransition = $this->larpWorkflow->can($larp, 'back_to_wip'); + + $I->assertTrue( + $canTransition, + 'Workflow should allow transition back from PUBLISHED to WIP' + ); + } + + public function workflowAppliesTransitionCorrectly(FunctionalTester $I): void + { + $I->wantTo('verify that workflow applies transition correctly'); + + $organizer = UserFactory::createApprovedUser(); + $larp = LarpFactory::createDraftLarp($organizer); + + $I->assertEquals(LarpStageStatus::DRAFT, $larp->getStatus()); + + $this->larpWorkflow->apply($larp, 'to_wip'); + + $I->assertEquals( + LarpStageStatus::WIP, + $larp->getStatus(), + 'Status should change to WIP after applying transition' + ); + } + + public function workflowMarkingIsUpdatedAfterTransition(FunctionalTester $I): void + { + $I->wantTo('verify that workflow marking is updated after transition'); + + $organizer = UserFactory::createApprovedUser(); + $larp = LarpFactory::createDraftLarp($organizer); + + $this->larpWorkflow->apply($larp, 'to_published'); + + $I->assertEquals( + LarpStageStatus::PUBLISHED->value, + $larp->getMarking(), + 'Marking should be updated after transition' + ); + } + + public function workflowCannotTransitionFromCompletedToDraft(FunctionalTester $I): void + { + $I->wantTo('verify that workflow does not allow transition from COMPLETED back to DRAFT'); + + $organizer = UserFactory::createApprovedUser(); + $larp = $I->createLarp($organizer, LarpStageStatus::COMPLETED); + + $canTransition = $this->larpWorkflow->can($larp, 'back_to_draft'); + + $I->assertFalse( + $canTransition, + 'Workflow should not allow transition from COMPLETED back to DRAFT' + ); + } + + public function workflowStatusPersistsAfterTransition(FunctionalTester $I): void + { + $I->wantTo('verify that workflow status persists after transition'); + + $organizer = UserFactory::createApprovedUser(); + $larp = LarpFactory::createDraftLarp($organizer); + $larpId = $larp->getId(); + + $this->larpWorkflow->apply($larp, 'to_published'); + + $I->getEntityManager()->flush(); + $I->getEntityManager()->clear(); + + $reloadedLarp = $I->getEntityManager()->find( + \App\Domain\Core\Entity\Larp::class, + $larpId + ); + + $I->assertNotNull($reloadedLarp); + $I->assertEquals( + LarpStageStatus::PUBLISHED, + $reloadedLarp->getStatus(), + 'Status should persist after transition' + ); + } + + public function getEnabledTransitionsReturnsAvailableTransitions(FunctionalTester $I): void + { + $I->wantTo('verify that getEnabledTransitions returns available transitions'); + + $organizer = UserFactory::createApprovedUser(); + $larp = LarpFactory::createDraftLarp($organizer); + + $enabledTransitions = $this->larpWorkflow->getEnabledTransitions($larp); + + $transitionNames = array_map( + fn ($transition) => $transition->getName(), + $enabledTransitions + ); + + $I->assertContains( + 'to_wip', + $transitionNames, + 'DRAFT should allow transition to WIP' + ); + $I->assertContains( + 'to_published', + $transitionNames, + 'DRAFT should allow transition to PUBLISHED' + ); + } + + public function multipleTransitionsInSequence(FunctionalTester $I): void + { + $I->wantTo('verify that multiple transitions work in sequence'); + + $organizer = UserFactory::createApprovedUser(); + $larp = LarpFactory::createDraftLarp($organizer); + + // DRAFT -> WIP -> PUBLISHED -> INQUIRIES -> CONFIRMED + $I->assertEquals(LarpStageStatus::DRAFT, $larp->getStatus()); + + $this->larpWorkflow->apply($larp, 'to_wip'); + $I->assertEquals(LarpStageStatus::WIP, $larp->getStatus()); + + $this->larpWorkflow->apply($larp, 'to_published'); + $I->assertEquals(LarpStageStatus::PUBLISHED, $larp->getStatus()); + + $this->larpWorkflow->apply($larp, 'to_inquiries'); + $I->assertEquals(LarpStageStatus::INQUIRIES, $larp->getStatus()); + + $this->larpWorkflow->apply($larp, 'to_confirmed'); + $I->assertEquals(LarpStageStatus::CONFIRMED, $larp->getStatus()); + } + + public function workflowFinalStateCompleted(FunctionalTester $I): void + { + $I->wantTo('verify that COMPLETED is a final state'); + + $organizer = UserFactory::createApprovedUser(); + $larp = $I->createLarp($organizer, LarpStageStatus::CONFIRMED); + + $this->larpWorkflow->apply($larp, 'to_completed'); + + $I->assertEquals(LarpStageStatus::COMPLETED, $larp->getStatus()); + + // Cannot go back from completed + $I->assertFalse($this->larpWorkflow->can($larp, 'back_to_draft')); + $I->assertFalse($this->larpWorkflow->can($larp, 'back_to_wip')); + } + + public function allPublicStatusesAreVisible(FunctionalTester $I): void + { + $I->wantTo('verify that all public statuses are visible'); + + $publicStatuses = [ + LarpStageStatus::PUBLISHED, + LarpStageStatus::INQUIRIES, + LarpStageStatus::CONFIRMED, + LarpStageStatus::COMPLETED, + ]; + + foreach ($publicStatuses as $status) { + $I->assertTrue( + $status->isVisibleForEveryone(), + "{$status->value} should be publicly visible" + ); + } + } + + public function allPrivateStatusesAreNotVisible(FunctionalTester $I): void + { + $I->wantTo('verify that all private statuses are not visible'); + + $privateStatuses = [ + LarpStageStatus::DRAFT, + LarpStageStatus::WIP, + LarpStageStatus::CANCELLED, + ]; + + foreach ($privateStatuses as $status) { + $I->assertFalse( + $status->isVisibleForEveryone(), + "{$status->value} should not be publicly visible" + ); + } + } +} diff --git a/tests/Integration/Service/LarpWorkflowTest.php b/tests/Integration/Service/LarpWorkflowTest.php deleted file mode 100644 index c3bdb90..0000000 --- a/tests/Integration/Service/LarpWorkflowTest.php +++ /dev/null @@ -1,394 +0,0 @@ -clearTestData(); - - $this->larpWorkflow = static::getContainer()->get('workflow.larp_stage_status'); - } - - protected function tearDown(): void - { - $this->clearTestData(); - parent::tearDown(); - } - - public function test_new_larp_starts_in_draft_status(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createLarp($organizer); - - $this->assertEquals( - LarpStageStatus::DRAFT, - $larp->getStatus(), - 'New LARP should start in DRAFT status' - ); - } - - public function test_draft_is_not_publicly_visible(): void - { - $status = LarpStageStatus::DRAFT; - - $this->assertFalse( - $status->isVisibleForEveryone(), - 'DRAFT status should not be publicly visible' - ); - } - - public function test_wip_is_not_publicly_visible(): void - { - $status = LarpStageStatus::WIP; - - $this->assertFalse( - $status->isVisibleForEveryone(), - 'WIP status should not be publicly visible' - ); - } - - public function test_published_is_publicly_visible(): void - { - $status = LarpStageStatus::PUBLISHED; - - $this->assertTrue( - $status->isVisibleForEveryone(), - 'PUBLISHED status should be publicly visible' - ); - } - - public function test_inquiries_is_publicly_visible(): void - { - $status = LarpStageStatus::INQUIRIES; - - $this->assertTrue( - $status->isVisibleForEveryone(), - 'INQUIRIES status should be publicly visible' - ); - } - - public function test_confirmed_is_publicly_visible(): void - { - $status = LarpStageStatus::CONFIRMED; - - $this->assertTrue( - $status->isVisibleForEveryone(), - 'CONFIRMED status should be publicly visible' - ); - } - - public function test_completed_is_publicly_visible(): void - { - $status = LarpStageStatus::COMPLETED; - - $this->assertTrue( - $status->isVisibleForEveryone(), - 'COMPLETED status should be publicly visible' - ); - } - - public function test_cancelled_is_not_publicly_visible(): void - { - $status = LarpStageStatus::CANCELLED; - - $this->assertFalse( - $status->isVisibleForEveryone(), - 'CANCELLED status should not be publicly visible' - ); - } - - public function test_workflow_can_transition_from_draft_to_wip(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createDraftLarp($organizer); - - $canTransition = $this->larpWorkflow->can($larp, 'to_wip'); - - $this->assertTrue( - $canTransition, - 'Workflow should allow transition from DRAFT to WIP' - ); - } - - public function test_workflow_can_transition_from_draft_to_published(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createDraftLarp($organizer); - - $canTransition = $this->larpWorkflow->can($larp, 'to_published'); - - $this->assertTrue( - $canTransition, - 'Workflow should allow transition from DRAFT to PUBLISHED' - ); - } - - public function test_workflow_can_transition_from_wip_to_published(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createWipLarp($organizer); - - $canTransition = $this->larpWorkflow->can($larp, 'to_published'); - - $this->assertTrue( - $canTransition, - 'Workflow should allow transition from WIP to PUBLISHED' - ); - } - - public function test_workflow_can_transition_from_published_to_inquiries(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createPublishedLarp($organizer); - - $canTransition = $this->larpWorkflow->can($larp, 'to_inquiries'); - - $this->assertTrue( - $canTransition, - 'Workflow should allow transition from PUBLISHED to INQUIRIES' - ); - } - - public function test_workflow_can_transition_from_inquiries_to_confirmed(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createLarp($organizer, LarpStageStatus::INQUIRIES); - - $canTransition = $this->larpWorkflow->can($larp, 'to_confirmed'); - - $this->assertTrue( - $canTransition, - 'Workflow should allow transition from INQUIRIES to CONFIRMED' - ); - } - - public function test_workflow_can_transition_from_confirmed_to_completed(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createLarp($organizer, LarpStageStatus::CONFIRMED); - - $canTransition = $this->larpWorkflow->can($larp, 'to_completed'); - - $this->assertTrue( - $canTransition, - 'Workflow should allow transition from CONFIRMED to COMPLETED' - ); - } - - public function test_workflow_can_transition_from_confirmed_to_cancelled(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createLarp($organizer, LarpStageStatus::CONFIRMED); - - $canTransition = $this->larpWorkflow->can($larp, 'to_cancelled'); - - $this->assertTrue( - $canTransition, - 'Workflow should allow transition from CONFIRMED to CANCELLED' - ); - } - - public function test_workflow_can_transition_back_from_published_to_draft(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createPublishedLarp($organizer); - - $canTransition = $this->larpWorkflow->can($larp, 'back_to_draft'); - - $this->assertTrue( - $canTransition, - 'Workflow should allow transition back from PUBLISHED to DRAFT' - ); - } - - public function test_workflow_can_transition_back_from_published_to_wip(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createPublishedLarp($organizer); - - $canTransition = $this->larpWorkflow->can($larp, 'back_to_wip'); - - $this->assertTrue( - $canTransition, - 'Workflow should allow transition back from PUBLISHED to WIP' - ); - } - - public function test_workflow_applies_transition_correctly(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createDraftLarp($organizer); - - $this->assertEquals(LarpStageStatus::DRAFT, $larp->getStatus()); - - $this->larpWorkflow->apply($larp, 'to_wip'); - - $this->assertEquals( - LarpStageStatus::WIP, - $larp->getStatus(), - 'Status should change to WIP after applying transition' - ); - } - - public function test_workflow_marking_is_updated_after_transition(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createDraftLarp($organizer); - - $this->larpWorkflow->apply($larp, 'to_published'); - - $this->assertEquals( - LarpStageStatus::PUBLISHED->value, - $larp->getMarking(), - 'Marking should be updated after transition' - ); - } - - public function test_workflow_cannot_transition_from_completed_to_draft(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createLarp($organizer, LarpStageStatus::COMPLETED); - - $canTransition = $this->larpWorkflow->can($larp, 'back_to_draft'); - - $this->assertFalse( - $canTransition, - 'Workflow should not allow transition from COMPLETED back to DRAFT' - ); - } - - public function test_workflow_status_persists_after_transition(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createDraftLarp($organizer); - $larpId = $larp->getId(); - - $this->larpWorkflow->apply($larp, 'to_published'); - - $this->getEntityManager()->flush(); - $this->getEntityManager()->clear(); - - $reloadedLarp = $this->getEntityManager()->find( - \App\Domain\Core\Entity\Larp::class, - $larpId - ); - - $this->assertNotNull($reloadedLarp); - $this->assertEquals( - LarpStageStatus::PUBLISHED, - $reloadedLarp->getStatus(), - 'Status should persist after transition' - ); - } - - public function test_get_enabled_transitions_returns_available_transitions(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createDraftLarp($organizer); - - $enabledTransitions = $this->larpWorkflow->getEnabledTransitions($larp); - - $transitionNames = array_map( - fn ($transition) => $transition->getName(), - $enabledTransitions - ); - - $this->assertContains( - 'to_wip', - $transitionNames, - 'DRAFT should allow transition to WIP' - ); - $this->assertContains( - 'to_published', - $transitionNames, - 'DRAFT should allow transition to PUBLISHED' - ); - } - - public function test_multiple_transitions_in_sequence(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createDraftLarp($organizer); - - // DRAFT -> WIP -> PUBLISHED -> INQUIRIES -> CONFIRMED - $this->assertEquals(LarpStageStatus::DRAFT, $larp->getStatus()); - - $this->larpWorkflow->apply($larp, 'to_wip'); - $this->assertEquals(LarpStageStatus::WIP, $larp->getStatus()); - - $this->larpWorkflow->apply($larp, 'to_published'); - $this->assertEquals(LarpStageStatus::PUBLISHED, $larp->getStatus()); - - $this->larpWorkflow->apply($larp, 'to_inquiries'); - $this->assertEquals(LarpStageStatus::INQUIRIES, $larp->getStatus()); - - $this->larpWorkflow->apply($larp, 'to_confirmed'); - $this->assertEquals(LarpStageStatus::CONFIRMED, $larp->getStatus()); - } - - public function test_workflow_final_state_completed(): void - { - $organizer = $this->createApprovedUser(); - $larp = $this->createLarp($organizer, LarpStageStatus::CONFIRMED); - - $this->larpWorkflow->apply($larp, 'to_completed'); - - $this->assertEquals(LarpStageStatus::COMPLETED, $larp->getStatus()); - - // Cannot go back from completed - $this->assertFalse($this->larpWorkflow->can($larp, 'back_to_draft')); - $this->assertFalse($this->larpWorkflow->can($larp, 'back_to_wip')); - } - - public function test_all_public_statuses_are_visible(): void - { - $publicStatuses = [ - LarpStageStatus::PUBLISHED, - LarpStageStatus::INQUIRIES, - LarpStageStatus::CONFIRMED, - LarpStageStatus::COMPLETED, - ]; - - foreach ($publicStatuses as $status) { - $this->assertTrue( - $status->isVisibleForEveryone(), - "{$status->value} should be publicly visible" - ); - } - } - - public function test_all_private_statuses_are_not_visible(): void - { - $privateStatuses = [ - LarpStageStatus::DRAFT, - LarpStageStatus::WIP, - LarpStageStatus::CANCELLED, - ]; - - foreach ($privateStatuses as $status) { - $this->assertFalse( - $status->isVisibleForEveryone(), - "{$status->value} should not be publicly visible" - ); - } - } -} diff --git a/tests/Integration/Service/LocationApprovalServiceCest.php b/tests/Integration/Service/LocationApprovalServiceCest.php new file mode 100644 index 0000000..fefee24 --- /dev/null +++ b/tests/Integration/Service/LocationApprovalServiceCest.php @@ -0,0 +1,371 @@ +locationApprovalService = $I->grabService(LocationApprovalService::class); + } + + public function canUserCreateLocationReturnsFalseForPendingUser(FunctionalTester $I): void + { + $I->wantTo('verify that PENDING user cannot create locations'); + + $pendingUser = $I->createPendingUser(); + + $canCreate = $this->locationApprovalService->canUserCreateLocation($pendingUser); + + $I->assertFalse( + $canCreate, + 'PENDING user should not be able to create locations' + ); + } + + public function canUserCreateLocationReturnsTrueForApprovedUser(FunctionalTester $I): void + { + $I->wantTo('verify that APPROVED user can create locations'); + + $approvedUser = UserFactory::createApprovedUser(); + + $canCreate = $this->locationApprovalService->canUserCreateLocation($approvedUser); + + $I->assertTrue( + $canCreate, + 'APPROVED user should be able to create locations' + ); + } + + public function canUserCreateLocationReturnsFalseForSuspendedUser(FunctionalTester $I): void + { + $I->wantTo('verify that SUSPENDED user cannot create locations'); + + $suspendedUser = $I->createSuspendedUser(); + + $canCreate = $this->locationApprovalService->canUserCreateLocation($suspendedUser); + + $I->assertFalse( + $canCreate, + 'SUSPENDED user should not be able to create locations' + ); + } + + public function canUserCreateLocationReturnsFalseForBannedUser(FunctionalTester $I): void + { + $I->wantTo('verify that BANNED user cannot create locations'); + + $bannedUser = $I->createBannedUser(); + + $canCreate = $this->locationApprovalService->canUserCreateLocation($bannedUser); + + $I->assertFalse( + $canCreate, + 'BANNED user should not be able to create locations' + ); + } + + public function canUserCreateLocationReturnsTrueForSuperAdmin(FunctionalTester $I): void + { + $I->wantTo('verify that SUPER_ADMIN can create locations'); + + $superAdmin = $I->createSuperAdmin(); + + $canCreate = $this->locationApprovalService->canUserCreateLocation($superAdmin); + + $I->assertTrue( + $canCreate, + 'SUPER_ADMIN should be able to create locations' + ); + } + + public function approveUpdatesLocationStatusCorrectly(FunctionalTester $I): void + { + $I->wantTo('verify that approve method updates location status correctly'); + + $user = UserFactory::createApprovedUser(); + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createPendingLocation($user); + + $I->assertEquals(LocationApprovalStatus::PENDING, $location->getApprovalStatus()); + + $this->locationApprovalService->approve($location, $superAdmin); + + $I->assertEquals(LocationApprovalStatus::APPROVED, $location->getApprovalStatus()); + $I->assertEquals($superAdmin, $location->getApprovedBy()); + $I->assertInstanceOf(\DateTimeInterface::class, $location->getApprovedAt()); + $I->assertNull($location->getRejectionReason()); + } + + public function approveSetsApprovedAtTimestamp(FunctionalTester $I): void + { + $I->wantTo('verify that approve method sets approvedAt timestamp'); + + $user = UserFactory::createApprovedUser(); + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createPendingLocation($user); + + $beforeApproval = new \DateTime(); + + $this->locationApprovalService->approve($location, $superAdmin); + + $afterApproval = new \DateTime(); + + $I->assertNotNull($location->getApprovedAt()); + $I->assertGreaterThanOrEqual( + $beforeApproval->getTimestamp(), + $location->getApprovedAt()->getTimestamp() + ); + $I->assertLessThanOrEqual( + $afterApproval->getTimestamp(), + $location->getApprovedAt()->getTimestamp() + ); + } + + public function rejectUpdatesLocationStatusCorrectly(FunctionalTester $I): void + { + $I->wantTo('verify that reject method updates location status correctly'); + + $user = UserFactory::createApprovedUser(); + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createPendingLocation($user); + + $rejectionReason = 'Invalid address provided'; + + $this->locationApprovalService->reject($location, $superAdmin, $rejectionReason); + + $I->assertEquals(LocationApprovalStatus::REJECTED, $location->getApprovalStatus()); + $I->assertEquals($rejectionReason, $location->getRejectionReason()); + $I->assertNull($location->getApprovedBy()); + $I->assertNull($location->getApprovedAt()); + } + + public function rejectWithNullReason(FunctionalTester $I): void + { + $I->wantTo('verify that reject method works with null reason'); + + $user = UserFactory::createApprovedUser(); + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createPendingLocation($user); + + $this->locationApprovalService->reject($location, $superAdmin, null); + + $I->assertEquals(LocationApprovalStatus::REJECTED, $location->getApprovalStatus()); + $I->assertNull($location->getRejectionReason()); + } + + public function rejectClearsPreviousApproval(FunctionalTester $I): void + { + $I->wantTo('verify that reject method clears previous approval'); + + $user = UserFactory::createApprovedUser(); + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createPendingLocation($user); + + // First approve + $this->locationApprovalService->approve($location, $superAdmin); + + $I->assertEquals(LocationApprovalStatus::APPROVED, $location->getApprovalStatus()); + $I->assertNotNull($location->getApprovedBy()); + $I->assertNotNull($location->getApprovedAt()); + + // Then reject + $this->locationApprovalService->reject($location, $superAdmin, 'Changed mind'); + + $I->assertEquals(LocationApprovalStatus::REJECTED, $location->getApprovalStatus()); + $I->assertNull($location->getApprovedBy()); + $I->assertNull($location->getApprovedAt()); + $I->assertEquals('Changed mind', $location->getRejectionReason()); + } + + public function autoApproveSetsCorrectStatusAndApprover(FunctionalTester $I): void + { + $I->wantTo('verify that autoApprove method sets correct status and approver'); + + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createPendingLocation($superAdmin); + + $this->locationApprovalService->autoApprove($location, $superAdmin); + + $I->assertEquals(LocationApprovalStatus::APPROVED, $location->getApprovalStatus()); + $I->assertEquals($superAdmin, $location->getApprovedBy()); + $I->assertNotNull($location->getApprovedAt()); + } + + public function autoApproveOnlyForSuperAdmin(FunctionalTester $I): void + { + $I->wantTo('verify that autoApprove only works for SUPER_ADMIN'); + + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createLocation($superAdmin); + + $this->locationApprovalService->autoApprove($location, $superAdmin); + + $I->assertEquals( + LocationApprovalStatus::APPROVED, + $location->getApprovalStatus(), + 'SUPER_ADMIN location should be auto-approved' + ); + } + + public function canUserEditLocationReturnsTrueForOwnPendingLocation(FunctionalTester $I): void + { + $I->wantTo('verify that user can edit their own PENDING location'); + + $user = UserFactory::createApprovedUser(); + $location = $I->createPendingLocation($user); + + $canEdit = $this->locationApprovalService->canUserEditLocation($user, $location); + + $I->assertTrue( + $canEdit, + 'User should be able to edit their own PENDING location' + ); + } + + public function canUserEditLocationReturnsTrueForOwnRejectedLocation(FunctionalTester $I): void + { + $I->wantTo('verify that user can edit their own REJECTED location'); + + $user = UserFactory::createApprovedUser(); + $location = $I->createRejectedLocation($user); + + $canEdit = $this->locationApprovalService->canUserEditLocation($user, $location); + + $I->assertTrue( + $canEdit, + 'User should be able to edit their own REJECTED location' + ); + } + + public function canUserEditLocationReturnsFalseForOwnApprovedLocation(FunctionalTester $I): void + { + $I->wantTo('verify that user cannot edit their own APPROVED location'); + + $user = UserFactory::createApprovedUser(); + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createPendingLocation($user); + $this->locationApprovalService->approve($location, $superAdmin); + + $canEdit = $this->locationApprovalService->canUserEditLocation($user, $location); + + $I->assertFalse( + $canEdit, + 'User should not be able to edit their own APPROVED location' + ); + } + + public function canUserEditLocationReturnsFalseForOtherUsersLocation(FunctionalTester $I): void + { + $I->wantTo('verify that user cannot edit other user\'s location'); + + $user1 = $I->createApprovedUser('user1@example.com'); + $user2 = $I->createApprovedUser('user2@example.com'); + + $location = $I->createPendingLocation($user1); + + $canEdit = $this->locationApprovalService->canUserEditLocation($user2, $location); + + $I->assertFalse( + $canEdit, + 'User should not be able to edit other user\'s location' + ); + } + + public function canUserEditLocationReturnsTrueForSuperAdmin(FunctionalTester $I): void + { + $I->wantTo('verify that SUPER_ADMIN can edit any location'); + + $user = UserFactory::createApprovedUser(); + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createApprovedLocation($user); + + $canEdit = $this->locationApprovalService->canUserEditLocation($superAdmin, $location); + + $I->assertTrue( + $canEdit, + 'SUPER_ADMIN should be able to edit any location' + ); + } + + public function approvalChangesPersistToDatabase(FunctionalTester $I): void + { + $I->wantTo('verify that approval changes persist to database'); + + $user = UserFactory::createApprovedUser(); + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createPendingLocation($user); + $locationId = $location->getId(); + + $this->locationApprovalService->approve($location, $superAdmin); + + // Clear entity manager to force reload from database + $I->getEntityManager()->clear(); + + $reloadedLocation = $I->getEntityManager()->find( + \App\Domain\Core\Entity\Location::class, + $locationId + ); + + $I->assertNotNull($reloadedLocation); + $I->assertEquals( + LocationApprovalStatus::APPROVED, + $reloadedLocation->getApprovalStatus(), + 'Approval should persist in database' + ); + $I->assertNotNull($reloadedLocation->getApprovedBy()); + $I->assertNotNull($reloadedLocation->getApprovedAt()); + } + + public function rejectionChangesPersistToDatabase(FunctionalTester $I): void + { + $I->wantTo('verify that rejection changes persist to database'); + + $user = UserFactory::createApprovedUser(); + $superAdmin = $I->createSuperAdmin(); + + $location = $I->createPendingLocation($user); + $locationId = $location->getId(); + + $this->locationApprovalService->reject($location, $superAdmin, 'Test reason'); + + // Clear entity manager to force reload from database + $I->getEntityManager()->clear(); + + $reloadedLocation = $I->getEntityManager()->find( + \App\Domain\Core\Entity\Location::class, + $locationId + ); + + $I->assertNotNull($reloadedLocation); + $I->assertEquals( + LocationApprovalStatus::REJECTED, + $reloadedLocation->getApprovalStatus(), + 'Rejection should persist in database' + ); + $I->assertEquals('Test reason', $reloadedLocation->getRejectionReason()); + } +} diff --git a/tests/Integration/Service/LocationApprovalServiceTest.php b/tests/Integration/Service/LocationApprovalServiceTest.php deleted file mode 100644 index df9c7a6..0000000 --- a/tests/Integration/Service/LocationApprovalServiceTest.php +++ /dev/null @@ -1,345 +0,0 @@ -clearTestData(); - - $this->locationApprovalService = static::getContainer()->get(LocationApprovalService::class); - } - - protected function tearDown(): void - { - $this->clearTestData(); - parent::tearDown(); - } - - public function test_can_user_create_location_returns_false_for_pending_user(): void - { - $pendingUser = $this->createPendingUser(); - - $canCreate = $this->locationApprovalService->canUserCreateLocation($pendingUser); - - $this->assertFalse( - $canCreate, - 'PENDING user should not be able to create locations' - ); - } - - public function test_can_user_create_location_returns_true_for_approved_user(): void - { - $approvedUser = $this->createApprovedUser(); - - $canCreate = $this->locationApprovalService->canUserCreateLocation($approvedUser); - - $this->assertTrue( - $canCreate, - 'APPROVED user should be able to create locations' - ); - } - - public function test_can_user_create_location_returns_false_for_suspended_user(): void - { - $suspendedUser = $this->createSuspendedUser(); - - $canCreate = $this->locationApprovalService->canUserCreateLocation($suspendedUser); - - $this->assertFalse( - $canCreate, - 'SUSPENDED user should not be able to create locations' - ); - } - - public function test_can_user_create_location_returns_false_for_banned_user(): void - { - $bannedUser = $this->createBannedUser(); - - $canCreate = $this->locationApprovalService->canUserCreateLocation($bannedUser); - - $this->assertFalse( - $canCreate, - 'BANNED user should not be able to create locations' - ); - } - - public function test_can_user_create_location_returns_true_for_super_admin(): void - { - $superAdmin = $this->createSuperAdmin(); - - $canCreate = $this->locationApprovalService->canUserCreateLocation($superAdmin); - - $this->assertTrue( - $canCreate, - 'SUPER_ADMIN should be able to create locations' - ); - } - - public function test_approve_updates_location_status_correctly(): void - { - $user = $this->createApprovedUser(); - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createPendingLocation($user); - - $this->assertEquals(LocationApprovalStatus::PENDING, $location->getApprovalStatus()); - - $this->locationApprovalService->approve($location, $superAdmin); - - $this->assertEquals(LocationApprovalStatus::APPROVED, $location->getApprovalStatus()); - $this->assertEquals($superAdmin, $location->getApprovedBy()); - $this->assertInstanceOf(\DateTimeInterface::class, $location->getApprovedAt()); - $this->assertNull($location->getRejectionReason()); - } - - public function test_approve_sets_approved_at_timestamp(): void - { - $user = $this->createApprovedUser(); - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createPendingLocation($user); - - $beforeApproval = new \DateTime(); - - $this->locationApprovalService->approve($location, $superAdmin); - - $afterApproval = new \DateTime(); - - $this->assertNotNull($location->getApprovedAt()); - $this->assertGreaterThanOrEqual( - $beforeApproval->getTimestamp(), - $location->getApprovedAt()->getTimestamp() - ); - $this->assertLessThanOrEqual( - $afterApproval->getTimestamp(), - $location->getApprovedAt()->getTimestamp() - ); - } - - public function test_reject_updates_location_status_correctly(): void - { - $user = $this->createApprovedUser(); - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createPendingLocation($user); - - $rejectionReason = 'Invalid address provided'; - - $this->locationApprovalService->reject($location, $superAdmin, $rejectionReason); - - $this->assertEquals(LocationApprovalStatus::REJECTED, $location->getApprovalStatus()); - $this->assertEquals($rejectionReason, $location->getRejectionReason()); - $this->assertNull($location->getApprovedBy()); - $this->assertNull($location->getApprovedAt()); - } - - public function test_reject_with_null_reason(): void - { - $user = $this->createApprovedUser(); - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createPendingLocation($user); - - $this->locationApprovalService->reject($location, $superAdmin, null); - - $this->assertEquals(LocationApprovalStatus::REJECTED, $location->getApprovalStatus()); - $this->assertNull($location->getRejectionReason()); - } - - public function test_reject_clears_previous_approval(): void - { - $user = $this->createApprovedUser(); - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createPendingLocation($user); - - // First approve - $this->locationApprovalService->approve($location, $superAdmin); - - $this->assertEquals(LocationApprovalStatus::APPROVED, $location->getApprovalStatus()); - $this->assertNotNull($location->getApprovedBy()); - $this->assertNotNull($location->getApprovedAt()); - - // Then reject - $this->locationApprovalService->reject($location, $superAdmin, 'Changed mind'); - - $this->assertEquals(LocationApprovalStatus::REJECTED, $location->getApprovalStatus()); - $this->assertNull($location->getApprovedBy()); - $this->assertNull($location->getApprovedAt()); - $this->assertEquals('Changed mind', $location->getRejectionReason()); - } - - public function test_auto_approve_sets_correct_status_and_approver(): void - { - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createPendingLocation($superAdmin); - - $this->locationApprovalService->autoApprove($location, $superAdmin); - - $this->assertEquals(LocationApprovalStatus::APPROVED, $location->getApprovalStatus()); - $this->assertEquals($superAdmin, $location->getApprovedBy()); - $this->assertNotNull($location->getApprovedAt()); - } - - public function test_auto_approve_only_for_super_admin(): void - { - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createLocation($superAdmin); - - $this->locationApprovalService->autoApprove($location, $superAdmin); - - $this->assertEquals( - LocationApprovalStatus::APPROVED, - $location->getApprovalStatus(), - 'SUPER_ADMIN location should be auto-approved' - ); - } - - public function test_can_user_edit_location_returns_true_for_own_pending_location(): void - { - $user = $this->createApprovedUser(); - $location = $this->createPendingLocation($user); - - $canEdit = $this->locationApprovalService->canUserEditLocation($user, $location); - - $this->assertTrue( - $canEdit, - 'User should be able to edit their own PENDING location' - ); - } - - public function test_can_user_edit_location_returns_true_for_own_rejected_location(): void - { - $user = $this->createApprovedUser(); - $location = $this->createRejectedLocation($user); - - $canEdit = $this->locationApprovalService->canUserEditLocation($user, $location); - - $this->assertTrue( - $canEdit, - 'User should be able to edit their own REJECTED location' - ); - } - - public function test_can_user_edit_location_returns_false_for_own_approved_location(): void - { - $user = $this->createApprovedUser(); - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createPendingLocation($user); - $this->locationApprovalService->approve($location, $superAdmin); - - $canEdit = $this->locationApprovalService->canUserEditLocation($user, $location); - - $this->assertFalse( - $canEdit, - 'User should not be able to edit their own APPROVED location' - ); - } - - public function test_can_user_edit_location_returns_false_for_other_users_location(): void - { - $user1 = $this->createApprovedUser('user1@example.com'); - $user2 = $this->createApprovedUser('user2@example.com'); - - $location = $this->createPendingLocation($user1); - - $canEdit = $this->locationApprovalService->canUserEditLocation($user2, $location); - - $this->assertFalse( - $canEdit, - 'User should not be able to edit other user\'s location' - ); - } - - public function test_can_user_edit_location_returns_true_for_super_admin(): void - { - $user = $this->createApprovedUser(); - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createApprovedLocation($user); - - $canEdit = $this->locationApprovalService->canUserEditLocation($superAdmin, $location); - - $this->assertTrue( - $canEdit, - 'SUPER_ADMIN should be able to edit any location' - ); - } - - public function test_approval_changes_persist_to_database(): void - { - $user = $this->createApprovedUser(); - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createPendingLocation($user); - $locationId = $location->getId(); - - $this->locationApprovalService->approve($location, $superAdmin); - - // Clear entity manager to force reload from database - $this->getEntityManager()->clear(); - - $reloadedLocation = $this->getEntityManager()->find( - \App\Domain\Core\Entity\Location::class, - $locationId - ); - - $this->assertNotNull($reloadedLocation); - $this->assertEquals( - LocationApprovalStatus::APPROVED, - $reloadedLocation->getApprovalStatus(), - 'Approval should persist in database' - ); - $this->assertNotNull($reloadedLocation->getApprovedBy()); - $this->assertNotNull($reloadedLocation->getApprovedAt()); - } - - public function test_rejection_changes_persist_to_database(): void - { - $user = $this->createApprovedUser(); - $superAdmin = $this->createSuperAdmin(); - - $location = $this->createPendingLocation($user); - $locationId = $location->getId(); - - $this->locationApprovalService->reject($location, $superAdmin, 'Test reason'); - - // Clear entity manager to force reload from database - $this->getEntityManager()->clear(); - - $reloadedLocation = $this->getEntityManager()->find( - \App\Domain\Core\Entity\Location::class, - $locationId - ); - - $this->assertNotNull($reloadedLocation); - $this->assertEquals( - LocationApprovalStatus::REJECTED, - $reloadedLocation->getApprovalStatus(), - 'Rejection should persist in database' - ); - $this->assertEquals('Test reason', $reloadedLocation->getRejectionReason()); - } -} diff --git a/tests/Integration/StoryMarketplace/RecruitmentProposalRepositoryCest.php b/tests/Integration/StoryMarketplace/RecruitmentProposalRepositoryCest.php new file mode 100644 index 0000000..afffdac --- /dev/null +++ b/tests/Integration/StoryMarketplace/RecruitmentProposalRepositoryCest.php @@ -0,0 +1,39 @@ +wantTo('verify that save() method persists and flushes the entity'); + + $entity = new RecruitmentProposal(); + $em = $this->createMock(EntityManagerInterface::class); + $em->expects($this->once())->method('persist')->with($entity); + $em->expects($this->once())->method('flush'); + + $registry = $this->createMock(ManagerRegistry::class); + $repository = new class($registry, $em) extends \App\Domain\StoryMarketplace\Repository\RecruitmentProposalRepository { + public function __construct(ManagerRegistry $registry, private readonly EntityManagerInterface $em) + { + parent::__construct($registry); + } + + protected function getEntityManager(): EntityManagerInterface + { + return $this->em; + } + }; + + $repository->save($entity); + } +} diff --git a/tests/Integration/StoryMarketplace/StoryRecruitmentRepositoryCest.php b/tests/Integration/StoryMarketplace/StoryRecruitmentRepositoryCest.php new file mode 100644 index 0000000..7c984e7 --- /dev/null +++ b/tests/Integration/StoryMarketplace/StoryRecruitmentRepositoryCest.php @@ -0,0 +1,39 @@ +wantTo('verify that save() method persists and flushes the entity'); + + $entity = new StoryRecruitment(); + $em = $this->createMock(EntityManagerInterface::class); + $em->expects($this->once())->method('persist')->with($entity); + $em->expects($this->once())->method('flush'); + + $registry = $this->createMock(ManagerRegistry::class); + $repository = new class($registry, $em) extends \App\Domain\StoryMarketplace\Repository\StoryRecruitmentRepository { + public function __construct(ManagerRegistry $registry, private readonly EntityManagerInterface $em) + { + parent::__construct($registry); + } + + protected function getEntityManager(): EntityManagerInterface + { + return $this->em; + } + }; + + $repository->save($entity); + } +} diff --git a/tests/Integration/StoryObject/StoryGraphFactionFilterCest.php b/tests/Integration/StoryObject/StoryGraphFactionFilterCest.php new file mode 100644 index 0000000..4b534fc --- /dev/null +++ b/tests/Integration/StoryObject/StoryGraphFactionFilterCest.php @@ -0,0 +1,60 @@ +wantTo('verify that faction filter includes all connected nodes in the graph'); + + $faction = new Faction(); + $character = new Character(); + $thread = new Thread(); + $quest = new Quest(); + + $faction->addMember($character); + $character->addFaction($faction); + + $thread->addInvolvedCharacter($character); + $character->addThread($thread); + + $quest->setThread($thread); + $thread->getQuests()->add($quest); + $quest->addInvolvedCharacter($character); + $character->addQuest($quest); + + // Use actual instances since classes are readonly and can't be mocked + $nodeBuilder = new GraphNodeBuilder(); + $relationRepository = $this->createMock(\App\Domain\StoryObject\Repository\RelationRepository::class); + $implicitRelationBuilder = new \App\Domain\StoryObject\Service\ImplicitRelationBuilder(); + $edgeBuilder = new GraphEdgeBuilder($relationRepository, $implicitRelationBuilder); + + $explorer = new StoryObjectRelationExplorer($nodeBuilder, $edgeBuilder); + $graph = $explorer->getGraphFromResults([ + $faction, + $character, + $thread, + $quest, + ]); + + $nodeIds = array_map(static fn (array $n) => $n['data']['id'], $graph['nodes']); + + $I->assertContains($faction->getId()->toRfc4122(), $nodeIds); + $I->assertContains($character->getId()->toRfc4122(), $nodeIds); + $I->assertContains($thread->getId()->toRfc4122(), $nodeIds); + $I->assertContains($quest->getId()->toRfc4122(), $nodeIds); + } +} diff --git a/tests/Integration/StoryObject/StoryObjectVersionServiceCest.php b/tests/Integration/StoryObject/StoryObjectVersionServiceCest.php new file mode 100644 index 0000000..ad1a2d5 --- /dev/null +++ b/tests/Integration/StoryObject/StoryObjectVersionServiceCest.php @@ -0,0 +1,46 @@ +wantTo('verify that version history diffs are calculated correctly'); + + $character = new Character(); + + $e1 = new StoryObjectLogEntry(); + $ref = new \ReflectionClass($e1); + $dataProp = $ref->getProperty('data'); + $dataProp->setAccessible(true); + $dataProp->setValue($e1, ['title' => 'Hero']); + + $e2 = new StoryObjectLogEntry(); + $dataProp->setValue($e2, ['title' => 'Hero Updated']); + + $repo = $this->createMock(LogEntryRepository::class); + $repo->expects($this->once())->method('getLogEntries')->with($character)->willReturn([$e2, $e1]); + + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getRepository')->with(StoryObjectLogEntry::class)->willReturn($repo); + + $service = new StoryObjectVersionService($em); + $history = $service->getVersionHistory($character); + + $I->assertCount(2, $history); + $I->assertSame('Hero Updated', $history[0]['entry']->getData()['title']); + $I->assertSame('Hero', $history[1]['entry']->getData()['title']); + $I->assertSame(['old' => 'Hero', 'new' => 'Hero Updated'], $history[0]['diff']['title']); + } +} diff --git a/tests/Support/AcceptanceTester.php b/tests/Support/AcceptanceTester.php index 6ccbabd..4f9e6c9 100644 --- a/tests/Support/AcceptanceTester.php +++ b/tests/Support/AcceptanceTester.php @@ -4,6 +4,8 @@ namespace Tests\Support; +use Codeception\Actor; + /** * Inherited Methods * @method void wantTo($text) @@ -19,7 +21,7 @@ * * @SuppressWarnings(PHPMD) */ -class AcceptanceTester extends \Codeception\Actor +class AcceptanceTester extends Actor { use _generated\AcceptanceTesterActions; diff --git a/tests/Support/Factory/Account/UserFactory.php b/tests/Support/Factory/Account/UserFactory.php index 52fa756..47f5ade 100644 --- a/tests/Support/Factory/Account/UserFactory.php +++ b/tests/Support/Factory/Account/UserFactory.php @@ -110,11 +110,33 @@ public function withPlan(mixed $plan = null): self ]); } - /** - * User who organizes LARPs (has approved status and a plan) - */ public function organizer(): self { return $this->approved()->withPlan(); } + + public static function createPendingUser(): User + { + return UserFactory::new()->pending()->create()->_real(); + } + + public static function createSuperAdmin(): User + { + return UserFactory::new()->approved()->superAdmin()->create()->_real(); + } + + public static function createApprovedUser(): User + { + return UserFactory::new()->approved()->create()->_real(); + } + + public static function createSuspendedUser(): User + { + return UserFactory::new()->suspended()->create()->_real(); + } + + public static function createBannedUser(): User + { + return UserFactory::new()->banned()->create()->_real(); + } } diff --git a/tests/Support/Factory/Application/LarpApplicationChoiceFactory.php b/tests/Support/Factory/Application/LarpApplicationChoiceFactory.php new file mode 100644 index 0000000..3ea002c --- /dev/null +++ b/tests/Support/Factory/Application/LarpApplicationChoiceFactory.php @@ -0,0 +1,111 @@ + + */ +final class LarpApplicationChoiceFactory extends PersistentProxyObjectFactory +{ + public static function class(): string + { + return LarpApplicationChoice::class; + } + + protected function defaults(): array + { + return [ + 'application' => LarpApplicationFactory::new(), + 'character' => CharacterFactory::new(), + 'priority' => self::faker()->numberBetween(1, 10), + 'justification' => self::faker()->optional()->paragraph(), + 'visual' => self::faker()->optional()->paragraph(), + 'votes' => 0, + ]; + } + + protected function initialize(): static + { + return $this + // ->afterInstantiate(function(Larp $larp): void {}) + ; + } + + // ======================================================================== + // Factory Configuration Methods + // ======================================================================== + + /** + * Choice for a specific application + */ + public function forApplication(mixed $application): self + { + return $this->with([ + 'application' => $application, + ]); + } + + /** + * Choice for a specific character + */ + public function forCharacter(mixed $character): self + { + return $this->with([ + 'character' => $character, + ]); + } + + /** + * Choice with specific priority (1 = highest) + */ + public function withPriority(int $priority): self + { + return $this->with([ + 'priority' => $priority, + ]); + } + + /** + * Top priority choice (priority = 1) + */ + public function topPriority(): self + { + return $this->withPriority(1); + } + + /** + * Choice with specific number of votes + */ + public function withVotes(int $votes): self + { + return $this->with([ + 'votes' => $votes, + ]); + } + + /** + * Choice with justification + */ + public function withJustification(string $justification): self + { + return $this->with([ + 'justification' => $justification, + ]); + } + + /** + * Choice with visual description + */ + public function withVisual(string $visual): self + { + return $this->with([ + 'visual' => $visual, + ]); + } +} diff --git a/tests/Support/Factory/Application/LarpApplicationFactory.php b/tests/Support/Factory/Application/LarpApplicationFactory.php new file mode 100644 index 0000000..5e1056d --- /dev/null +++ b/tests/Support/Factory/Application/LarpApplicationFactory.php @@ -0,0 +1,162 @@ + + */ +final class LarpApplicationFactory extends PersistentProxyObjectFactory +{ + public static function class(): string + { + return LarpApplication::class; + } + + protected function defaults(): array + { + $approvedUser = UserFactory::new()->approved(); + return [ + 'larp' => LarpFactory::new(), + 'user' => $approvedUser, + 'status' => SubmissionStatus::NEW, + 'notes' => self::faker()->optional()->paragraph(), + 'favouriteStyle' => self::faker()->optional()->paragraph(), + 'triggers' => self::faker()->optional()->paragraph(), + 'contactEmail' => self::faker()->optional()->safeEmail(), + 'createdBy' => $approvedUser, + ]; + } + + protected function initialize(): static + { + return $this + // ->afterInstantiate(function(Larp $larp): void {}) + ; + } + + /** + * Application with CONSIDER status (under review) + */ + public function consider(): self + { + return $this->with([ + 'status' => SubmissionStatus::CONSIDER, + ]); + } + + /** + * Application with REJECTED status + */ + public function rejected(): self + { + return $this->with([ + 'status' => SubmissionStatus::REJECTED, + ]); + } + + /** + * Application with ACCEPTED status + */ + public function accepted(): self + { + return $this->with([ + 'status' => SubmissionStatus::ACCEPTED, + ]); + } + + /** + * Application with OFFERED status (character assigned, awaiting confirmation) + */ + public function offered(): self + { + return $this->with([ + 'status' => SubmissionStatus::OFFERED, + ]); + } + + /** + * Application with CONFIRMED status (player confirmed assignment) + */ + public function confirmed(): self + { + return $this->with([ + 'status' => SubmissionStatus::CONFIRMED, + ]); + } + + /** + * Application with DECLINED status (player declined assignment) + */ + public function declined(): self + { + return $this->with([ + 'status' => SubmissionStatus::DECLINED, + ]); + } + + // ======================================================================== + // Factory Configuration Methods + // ======================================================================== + + /** + * Application for a specific LARP + */ + public function forLarp(mixed $larp): self + { + return $this->with([ + 'larp' => $larp, + ]); + } + + /** + * Application for a specific User + */ + public function forUser(mixed $user): self + { + return $this->with([ + 'user' => $user, + 'createdBy' => $user, + ]); + } + + /** + * Application with specific notes + */ + public function withNotes(string $notes): self + { + return $this->with([ + 'notes' => $notes, + ]); + } + + /** + * Application with specific contact email + */ + public function withContactEmail(string $contactEmail): self + { + return $this->with([ + 'contactEmail' => $contactEmail, + ]); + } + + /** + * Application with choices + */ + public function withChoices(int $count = 3): self + { + return $this->afterPersist(function (LarpApplication $application) use ($count) { + LarpApplicationChoiceFactory::new() + ->forApplication($application) + ->many($count) + ->create(); + }); + } +} diff --git a/tests/Support/Factory/Core/LarpFactory.php b/tests/Support/Factory/Core/LarpFactory.php index 3b15cd6..12d4e87 100644 --- a/tests/Support/Factory/Core/LarpFactory.php +++ b/tests/Support/Factory/Core/LarpFactory.php @@ -2,8 +2,12 @@ namespace Tests\Support\Factory\Core; +use App\Domain\Account\Entity\User; +use App\Domain\Core\Entity\Enum\LarpStageStatus; use App\Domain\Core\Entity\Larp; +use App\Domain\Core\Entity\LarpParticipant; use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory; +use Zenstruck\Foundry\Persistence\Proxy; /** * @extends PersistentProxyObjectFactory @@ -15,6 +19,8 @@ public static function class(): string return Larp::class; } + + protected function defaults(): array { return [ @@ -39,6 +45,13 @@ protected function initialize(): static // Factory States (Workflow states) // ======================================================================== + public function withStatus(LarpStageStatus $status): self + { + return $this->with([ + 'marking' => $status->value, + ]); + } + /** * LARP in DRAFT state (initial state) */ @@ -123,23 +136,40 @@ public function withLocation(mixed $location = null): self ]); } - /** - * LARP with organizer participant - */ - public function withOrganizer(mixed $user = null): self + public function withCreator(mixed $user): self { - $larp = $this->create(); - - if ($user === null) { - $user = \Tests\Support\Factory\Account\UserFactory::new()->organizer()->create(); + return $this->with([ + 'createdBy' => $user, + ]); + } + + public static function createDraftLarp(User|Proxy $user, ?string $title = null): Larp|Proxy + { + $larpFactory = LarpFactory::new() + ->draft() + ->withCreator($user); + + if ($title !== null) { + $larpFactory = $larpFactory->withTitle($title); } + // Create the LARP first so it has an ID + $larp = $larpFactory->create(); + + // Now create the participant with the persisted LARP LarpParticipantFactory::new() + ->forUser($user) ->organizer() ->forLarp($larp) - ->forUser($user) ->create(); - return $this; + return $larp; + } + + public function withTitle(string $title): self + { + return $this->with([ + 'title' => $title, + ]); } } diff --git a/tests/Support/Factory/Core/LarpParticipantFactory.php b/tests/Support/Factory/Core/LarpParticipantFactory.php index 6ab4053..ab84781 100644 --- a/tests/Support/Factory/Core/LarpParticipantFactory.php +++ b/tests/Support/Factory/Core/LarpParticipantFactory.php @@ -1,7 +1,10 @@ afterInstantiate(function(Larp $larp): void {}) + ; + } + protected function defaults(): array { return [ 'user' => UserFactory::new()->approved(), 'larp' => LarpFactory::new(), - 'roles' => ['ROLE_PLAYER'], + 'roles' => [ParticipantRole::PLAYER], ]; } - protected function initialize(): static - { - return $this - // ->afterInstantiate(function(LarpParticipant $larpParticipant): void {}) - ; - } + // ======================================================================== - // Factory States + // Factory States - Role Methods // ======================================================================== /** @@ -42,7 +47,7 @@ protected function initialize(): static public function player(): self { return $this->with([ - 'roles' => ['ROLE_PLAYER'], + 'roles' => [ParticipantRole::PLAYER], ]); } @@ -52,17 +57,27 @@ public function player(): self public function organizer(): self { return $this->with([ - 'roles' => ['ROLE_ORGANIZER'], + 'roles' => [ParticipantRole::ORGANIZER], ]); } /** - * Participant with ADMIN role + * Participant with STAFF role */ - public function admin(): self + public function staff(): self { return $this->with([ - 'roles' => ['ROLE_ADMIN'], + 'roles' => [ParticipantRole::STAFF], + ]); + } + + /** + * Participant with MAIN_STORY_WRITER role + */ + public function mainStoryWriter(): self + { + return $this->with([ + 'roles' => [ParticipantRole::MAIN_STORY_WRITER], ]); } @@ -72,7 +87,7 @@ public function admin(): self public function storyWriter(): self { return $this->with([ - 'roles' => ['ROLE_STORY_WRITER'], + 'roles' => [ParticipantRole::STORY_WRITER], ]); } @@ -82,20 +97,124 @@ public function storyWriter(): self public function photographer(): self { return $this->with([ - 'roles' => ['ROLE_PHOTOGRAPHER'], + 'roles' => [ParticipantRole::PHOTOGRAPHER], + ]); + } + + /** + * Participant with CRAFTER role + */ + public function crafter(): self + { + return $this->with([ + 'roles' => [ParticipantRole::CRAFTER], + ]); + } + + /** + * Participant with MAKEUP_ARTIST role + */ + public function makeupArtist(): self + { + return $this->with([ + 'roles' => [ParticipantRole::MAKEUP_ARTIST], + ]); + } + + /** + * Participant with GAME_MASTER role + */ + public function gameMaster(): self + { + return $this->with([ + 'roles' => [ParticipantRole::GAME_MASTER], + ]); + } + + /** + * Participant with NPC_LONG role (long-duration NPC) + */ + public function npcLong(): self + { + return $this->with([ + 'roles' => [ParticipantRole::NPC_LONG], ]); } /** - * Participant with TRUST_PERSON role + * Participant with NPC_SHORT role (short-duration NPC) + */ + public function npcShort(): self + { + return $this->with([ + 'roles' => [ParticipantRole::NPC_SHORT], + ]); + } + + /** + * Participant with MEDIC role + */ + public function medic(): self + { + return $this->with([ + 'roles' => [ParticipantRole::MEDIC], + ]); + } + + /** + * Participant with TRASHER role + */ + public function trasher(): self + { + return $this->with([ + 'roles' => [ParticipantRole::TRASHER], + ]); + } + + /** + * Participant with TRUST_PERSON role (person of trust) */ public function trustPerson(): self { return $this->with([ - 'roles' => ['ROLE_TRUST_PERSON'], + 'roles' => [ParticipantRole::TRUST_PERSON], ]); } + /** + * Participant with OUTFIT_APPROVER role + */ + public function outfitApprover(): self + { + return $this->with([ + 'roles' => [ParticipantRole::OUTFIT_APPROVER], + ]); + } + + /** + * Participant with ACCOUNTANT role + */ + public function accountant(): self + { + return $this->with([ + 'roles' => [ParticipantRole::ACCOUNTANT], + ]); + } + + /** + * Participant with GASTRONOMY role + */ + public function gastronomy(): self + { + return $this->with([ + 'roles' => [ParticipantRole::GASTRONOMY], + ]); + } + + // ======================================================================== + // Factory Configuration Methods + // ======================================================================== + /** * Participant for a specific LARP */ @@ -118,6 +237,8 @@ public function forUser(mixed $user): self /** * Participant with multiple roles + * + * @param ParticipantRole[] $roles */ public function withRoles(array $roles): self { diff --git a/tests/Support/Factory/Core/LocationFactory.php b/tests/Support/Factory/Core/LocationFactory.php index 6fea633..ec8802f 100644 --- a/tests/Support/Factory/Core/LocationFactory.php +++ b/tests/Support/Factory/Core/LocationFactory.php @@ -93,6 +93,16 @@ public function approvedBy(mixed $user): self ]); } + /** + * Location created by specific user + */ + public function createdBy(mixed $user): self + { + return $this->with([ + 'createdBy' => $user, + ]); + } + /** * Public location (visible to all) */ @@ -122,4 +132,15 @@ public function inactive(): self 'isActive' => false, ]); } + + /** + * Add rejection reason and rejectedAT + */ + public function withRejectionReason(string $reason): self + { + return $this->with([ + 'rejectionReason' => $reason, + 'rejectedAt' => self::faker()->date(), + ]); + } } diff --git a/tests/Support/Factory/Incidents/LarpIncidentFactory.php b/tests/Support/Factory/Incidents/LarpIncidentFactory.php new file mode 100644 index 0000000..4c822c9 --- /dev/null +++ b/tests/Support/Factory/Incidents/LarpIncidentFactory.php @@ -0,0 +1,160 @@ + + */ +final class LarpIncidentFactory extends PersistentProxyObjectFactory +{ + public static function class(): string + { + return LarpIncident::class; + } + + protected function defaults(): array + { + return [ + 'larp' => LarpFactory::new(), + 'createdBy' => UserFactory::new()->approved(), + 'reportCode' => strtoupper(self::faker()->lexify('??????')), + 'caseId' => strtoupper(self::faker()->bothify('INC-####-???')), + 'createdAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTimeBetween('-30 days', 'now')), + 'description' => self::faker()->paragraph(), + 'allowFeedback' => self::faker()->boolean(), + 'contactAccused' => self::faker()->boolean(), + 'allowMediator' => self::faker()->boolean(), + 'stayAnonymous' => self::faker()->boolean(), + 'status' => LarpIncidentStatus::NEW, + 'needsPoliceSupport' => self::faker()->optional()->boolean(), + ]; + } + + + + // ======================================================================== + // Factory States (Incident Status) + // ======================================================================== + + /** + * New incident (just reported) + */ + public function new(): self + { + return $this->with([ + 'status' => LarpIncidentStatus::NEW, + ]); + } + + /** + * Incident in progress (being handled) + */ + public function inProgress(): self + { + return $this->with([ + 'status' => LarpIncidentStatus::IN_PROGRESS, + ]); + } + + /** + * Incident with feedback given + */ + public function feedbackGiven(): self + { + return $this->with([ + 'status' => LarpIncidentStatus::FEEDBACK_GIVEN, + ]); + } + + /** + * Closed incident + */ + public function closed(): self + { + return $this->with([ + 'status' => LarpIncidentStatus::CLOSED, + ]); + } + + // ======================================================================== + // Factory Configuration Methods + // ======================================================================== + + /** + * Incident for a specific LARP + */ + public function forLarp(mixed $larp): self + { + return $this->with([ + 'larp' => $larp, + ]); + } + + /** + * Incident created by specific user + */ + public function createdBy(mixed $user): self + { + return $this->with([ + 'createdBy' => $user, + ]); + } + + /** + * Anonymous incident report + */ + public function anonymous(): self + { + return $this->with([ + 'stayAnonymous' => true, + ]); + } + + /** + * Incident that allows feedback + */ + public function withFeedback(): self + { + return $this->with([ + 'allowFeedback' => true, + ]); + } + + /** + * Incident that requests mediator + */ + public function withMediator(): self + { + return $this->with([ + 'allowMediator' => true, + ]); + } + + /** + * Incident that requests contacting accused + */ + public function contactingAccused(): self + { + return $this->with([ + 'contactAccused' => true, + ]); + } + + /** + * Incident requiring police support + */ + public function requiresPolice(): self + { + return $this->with([ + 'needsPoliceSupport' => true, + ]); + } +} diff --git a/tests/Support/Factory/Mailing/MailTemplateFactory.php b/tests/Support/Factory/Mailing/MailTemplateFactory.php new file mode 100644 index 0000000..ff79963 --- /dev/null +++ b/tests/Support/Factory/Mailing/MailTemplateFactory.php @@ -0,0 +1,205 @@ + + */ +final class MailTemplateFactory extends PersistentProxyObjectFactory +{ + public static function class(): string + { + return MailTemplate::class; + } + + protected function defaults(): array + { + return [ + 'larp' => LarpFactory::new(), + 'type' => self::faker()->randomElement(MailTemplateType::cases()), + 'name' => self::faker()->words(3, true), + 'subject' => self::faker()->sentence(), + 'body' => self::faker()->paragraphs(3, true), + 'enabled' => true, + 'availablePlaceholders' => ['{{larp_name}}', '{{user_name}}', '{{user_email}}'], + ]; + } + + + + // ======================================================================== + // Factory States (Template Types) + // ======================================================================== + + /** + * Enquiry open template + */ + public function enquiryOpen(): self + { + return $this->with([ + 'type' => MailTemplateType::ENQUIRY_OPEN, + 'name' => 'Enquiry Open', + ]); + } + + /** + * Enquiry closing soon template + */ + public function enquiryClosingSoon(): self + { + return $this->with([ + 'type' => MailTemplateType::ENQUIRY_CLOSING_SOON, + 'name' => 'Enquiry Closing Soon', + ]); + } + + /** + * Payment reminder template + */ + public function paymentReminder(): self + { + return $this->with([ + 'type' => MailTemplateType::PAYMENT_REMINDER, + 'name' => 'Payment Reminder', + ]); + } + + /** + * Installment due template + */ + public function installmentDue(): self + { + return $this->with([ + 'type' => MailTemplateType::INSTALLMENT_DUE, + 'name' => 'Installment Due', + ]); + } + + /** + * Ticket confirmed template + */ + public function ticketConfirmed(): self + { + return $this->with([ + 'type' => MailTemplateType::TICKET_CONFIRMED, + 'name' => 'Ticket Confirmed', + ]); + } + + /** + * Costume review template + */ + public function costumeReview(): self + { + return $this->with([ + 'type' => MailTemplateType::COSTUME_REVIEW, + 'name' => 'Costume Review', + ]); + } + + /** + * Lore update template + */ + public function loreUpdate(): self + { + return $this->with([ + 'type' => MailTemplateType::LORE_UPDATE, + 'name' => 'Lore Update', + ]); + } + + /** + * Character assignment published template + */ + public function characterAssignmentPublished(): self + { + return $this->with([ + 'type' => MailTemplateType::CHARACTER_ASSIGNMENT_PUBLISHED, + 'name' => 'Character Assignment Published', + ]); + } + + /** + * Application rejected template + */ + public function applicationRejected(): self + { + return $this->with([ + 'type' => MailTemplateType::APPLICATION_REJECTED, + 'name' => 'Application Rejected', + ]); + } + + /** + * Waitlist notification template + */ + public function waitlistNotification(): self + { + return $this->with([ + 'type' => MailTemplateType::WAITLIST_NOTIFICATION, + 'name' => 'Waitlist Notification', + ]); + } + + /** + * Organizer broadcast template + */ + public function organizerBroadcast(): self + { + return $this->with([ + 'type' => MailTemplateType::ORGANIZER_BROADCAST, + 'name' => 'Organizer Broadcast', + ]); + } + + // ======================================================================== + // Factory Configuration Methods + // ======================================================================== + + /** + * Template for a specific LARP + */ + public function forLarp(mixed $larp): self + { + return $this->with([ + 'larp' => $larp, + ]); + } + + /** + * Disabled template + */ + public function disabled(): self + { + return $this->with([ + 'enabled' => false, + ]); + } + + /** + * Enabled template + */ + public function enabled(): self + { + return $this->with([ + 'enabled' => true, + ]); + } + + /** + * Template with specific placeholders + */ + public function withPlaceholders(array $placeholders): self + { + return $this->with([ + 'availablePlaceholders' => $placeholders, + ]); + } +} diff --git a/tests/Support/Factory/StoryObject/CharacterFactory.php b/tests/Support/Factory/StoryObject/CharacterFactory.php new file mode 100644 index 0000000..78e93bc --- /dev/null +++ b/tests/Support/Factory/StoryObject/CharacterFactory.php @@ -0,0 +1,191 @@ + + */ +final class CharacterFactory extends PersistentProxyObjectFactory +{ + public static function class(): string + { + return Character::class; + } + + protected function defaults(): array + { + return [ + 'title' => self::faker()->name(), + 'description' => self::faker()->optional()->paragraph(), + 'larp' => LarpFactory::new(), + 'inGameName' => self::faker()->optional()->name(), + 'gender' => self::faker()->optional()->randomElement(Gender::cases()), + 'preferredGender' => self::faker()->optional()->randomElement(Gender::cases()), + 'characterType' => CharacterType::Player, + 'availableForRecruitment' => false, + 'notes' => self::faker()->optional()->paragraph(), + 'createdBy' => UserFactory::new(), + ]; + } + + protected function initialize(): static + { + return $this + // ->afterInstantiate(function(Larp $larp): void {}) + ; + } + + // ======================================================================== + // Factory States (Character Types) + // ======================================================================== + + /** + * Player character + */ + public function player(): self + { + return $this->with([ + 'characterType' => CharacterType::Player, + ]); + } + + /** + * Long-duration NPC character + */ + public function longNpc(): self + { + return $this->with([ + 'characterType' => CharacterType::LongNpc, + ]); + } + + /** + * Short-duration NPC character + */ + public function shortNpc(): self + { + return $this->with([ + 'characterType' => CharacterType::ShortNpc, + ]); + } + + /** + * Game Master character + */ + public function gameMaster(): self + { + return $this->with([ + 'characterType' => CharacterType::GameMaster, + ]); + } + + /** + * Generic NPC character (e.g., raider, bandit, monk) + */ + public function genericNpc(): self + { + return $this->with([ + 'characterType' => CharacterType::GenericNpc, + ]); + } + + // ======================================================================== + // Factory Configuration Methods + // ======================================================================== + + /** + * Character for a specific LARP + */ + public function forLarp(mixed $larp): self + { + return $this->with([ + 'larp' => $larp, + ]); + } + + /** + * Character with specific gender + */ + public function withGender(Gender $gender): self + { + return $this->with([ + 'gender' => $gender, + 'preferredGender' => $gender, + ]); + } + + /** + * Male character + */ + public function male(): self + { + return $this->withGender(Gender::Male); + } + + /** + * Female character + */ + public function female(): self + { + return $this->withGender(Gender::Female); + } + + /** + * Character with other gender + */ + public function other(): self + { + return $this->withGender(Gender::Other); + } + + /** + * Character available for recruitment + */ + public function availableForRecruitment(): self + { + return $this->with([ + 'availableForRecruitment' => true, + ]); + } + + /** + * Character with specific title + */ + public function withTitle(string $title): self + { + return $this->with([ + 'title' => $title, + ]); + } + + /** + * Character with specific in-game name + */ + public function withInGameName(string $inGameName): self + { + return $this->with([ + 'inGameName' => $inGameName, + ]); + } + + public function withCreator(null|User|Proxy $user): self + { + if(null === $user) { + $user = UserFactory::new(); + } + return $this->with([ + 'createdBy' => $user, + ]); + } +} diff --git a/tests/Support/Factory/StoryObject/FactionFactory.php b/tests/Support/Factory/StoryObject/FactionFactory.php new file mode 100644 index 0000000..6b79f16 --- /dev/null +++ b/tests/Support/Factory/StoryObject/FactionFactory.php @@ -0,0 +1,87 @@ + + */ +final class FactionFactory extends PersistentProxyObjectFactory +{ + public static function class(): string + { + return Faction::class; + } + + protected function defaults(): array + { + return [ + 'title' => self::faker()->company(), + 'description' => self::faker()->optional()->paragraphs(2, true), + 'larp' => LarpFactory::new(), + ]; + } + + protected function initialize(): static + { + return $this + // ->afterInstantiate(function(Larp $larp): void {}) + ; + } + + // ======================================================================== + // Factory Configuration Methods + // ======================================================================== + + /** + * Faction for a specific LARP + */ + public function forLarp(mixed $larp): self + { + return $this->with([ + 'larp' => $larp, + ]); + } + + /** + * Faction with specific title + */ + public function withTitle(string $title): self + { + return $this->with([ + 'title' => $title, + ]); + } + + /** + * Faction with specific description + */ + public function withDescription(string $description): self + { + return $this->with([ + 'description' => $description, + ]); + } + + /** + * Faction with member characters + */ + public function withMembers(int $count = 3): self + { + return $this->afterPersist(function (Faction $faction) use ($count) { + $characters = CharacterFactory::new() + ->forLarp($faction->getLarp()) + ->many($count) + ->create(); + + foreach ($characters as $character) { + $faction->addMember($character->_real()); + } + }); + } +} diff --git a/tests/Support/Factory/StoryObject/ThreadFactory.php b/tests/Support/Factory/StoryObject/ThreadFactory.php new file mode 100644 index 0000000..feeafff --- /dev/null +++ b/tests/Support/Factory/StoryObject/ThreadFactory.php @@ -0,0 +1,115 @@ + + */ +final class ThreadFactory extends PersistentProxyObjectFactory +{ + public static function class(): string + { + return Thread::class; + } + + protected function defaults(): array + { + return [ + 'title' => self::faker()->sentence(3), + 'description' => self::faker()->optional()->paragraphs(2, true), + 'larp' => LarpFactory::new(), + 'decisionTree' => null, + ]; + } + + protected function initialize(): static + { + return $this + // ->afterInstantiate(function(Larp $larp): void {}) + ; + } + + // ======================================================================== + // Factory Configuration Methods + // ======================================================================== + + /** + * Thread for a specific LARP + */ + public function forLarp(mixed $larp): self + { + return $this->with([ + 'larp' => $larp, + ]); + } + + /** + * Thread with specific title + */ + public function withTitle(string $title): self + { + return $this->with([ + 'title' => $title, + ]); + } + + /** + * Thread with specific description + */ + public function withDescription(string $description): self + { + return $this->with([ + 'description' => $description, + ]); + } + + /** + * Thread with involved characters + */ + public function withInvolvedCharacters(int $count = 2): self + { + return $this->afterPersist(function (Thread $thread) use ($count) { + $characters = CharacterFactory::new() + ->forLarp($thread->getLarp()) + ->many($count) + ->create(); + + foreach ($characters as $character) { + $thread->addInvolvedCharacter($character->_real()); + } + }); + } + + /** + * Thread with involved factions + */ + public function withInvolvedFactions(int $count = 1): self + { + return $this->afterPersist(function (Thread $thread) use ($count) { + $factions = FactionFactory::new() + ->forLarp($thread->getLarp()) + ->many($count) + ->create(); + + foreach ($factions as $faction) { + $thread->addInvolvedFaction($faction->_real()); + } + }); + } + + /** + * Thread with decision tree + */ + public function withDecisionTree(array $decisionTree): self + { + return $this->with([ + 'decisionTree' => $decisionTree, + ]); + } +} diff --git a/tests/Support/Factory/Survey/SurveyAnswerFactory.php b/tests/Support/Factory/Survey/SurveyAnswerFactory.php new file mode 100644 index 0000000..dab0ad7 --- /dev/null +++ b/tests/Support/Factory/Survey/SurveyAnswerFactory.php @@ -0,0 +1,109 @@ + + */ +final class SurveyAnswerFactory extends PersistentProxyObjectFactory +{ + public static function class(): string + { + return SurveyAnswer::class; + } + + protected function defaults(): array + { + return [ + 'answerText' => null, + 'response' => SurveyResponseFactory::new(), + 'question' => SurveyQuestionFactory::new(), + ]; + } + + protected function initialize(): static + { + return $this + // ->afterInstantiate(function(Larp $larp): void {}) + ; + } + + /** + * Answer for a specific question + */ + public function forQuestion(mixed $question): self + { + return $this->with(['question' => $question]); + } + + /** + * Answer for a specific response + */ + public function forResponse(mixed $response): self + { + return $this->with(['response' => $response]); + } + + /** + * Text answer + */ + public function textAnswer(?string $text = null): self + { + return $this->with([ + 'answerText' => $text ?? self::faker()->sentence(), + ]); + } + + /** + * Rating answer (1-5) + */ + public function ratingAnswer(int $rating): self + { + if ($rating < 1 || $rating > 5) { + throw new \InvalidArgumentException('Rating must be between 1 and 5'); + } + + return $this->with([ + 'answerText' => (string) $rating, + ]); + } + + /** + * Single choice answer with specific option + */ + public function singleChoiceAnswer(mixed $option): self + { + return $this->afterPersist(function (SurveyAnswer $answer) use ($option): void { + $answer->addSelectedOption($option); + }); + } + + /** + * Multiple choice answer with specific options + */ + public function multipleChoiceAnswer(array $options): self + { + return $this->afterPersist(function (SurveyAnswer $answer) use ($options): void { + foreach ($options as $option) { + $answer->addSelectedOption($option); + } + }); + } + + /** + * Tag selection answer with specific tags + */ + public function tagSelectionAnswer(array $tags): self + { + return $this->afterPersist(function (SurveyAnswer $answer) use ($tags): void { + foreach ($tags as $tag) { + $answer->addSelectedTag($tag); + } + }); + } +} diff --git a/tests/Support/Factory/Survey/SurveyFactory.php b/tests/Support/Factory/Survey/SurveyFactory.php new file mode 100644 index 0000000..c90182e --- /dev/null +++ b/tests/Support/Factory/Survey/SurveyFactory.php @@ -0,0 +1,76 @@ + + */ +final class SurveyFactory extends PersistentProxyObjectFactory +{ + public static function class(): string + { + return Survey::class; + } + + protected function defaults(): array + { + return [ + 'title' => self::faker()->sentence(4), + 'description' => self::faker()->paragraph(), + 'isActive' => true, + 'larp' => LarpFactory::new(), + ]; + } + + protected function initialize(): static + { + return $this + // ->afterInstantiate(function(Larp $larp): void {}) + ; + } + + /** + * Active survey (accepting responses) + */ + public function active(): self + { + return $this->with(['isActive' => true]); + } + + /** + * Inactive survey (not accepting responses) + */ + public function inactive(): self + { + return $this->with(['isActive' => false]); + } + + /** + * Survey for a specific LARP + */ + public function forLarp(mixed $larp): self + { + return $this->with(['larp' => $larp]); + } + + /** + * Survey with questions + */ + public function withQuestions(int $count = 3): self + { + return $this->afterPersist(function (Survey $survey) use ($count): void { + for ($i = 0; $i < $count; $i++) { + SurveyQuestionFactory::new() + ->forSurvey($survey) + ->with(['orderPosition' => $i]) + ->create(); + } + }); + } +} diff --git a/tests/Support/Factory/Survey/SurveyQuestionFactory.php b/tests/Support/Factory/Survey/SurveyQuestionFactory.php new file mode 100644 index 0000000..74b8c60 --- /dev/null +++ b/tests/Support/Factory/Survey/SurveyQuestionFactory.php @@ -0,0 +1,134 @@ + + */ +final class SurveyQuestionFactory extends PersistentProxyObjectFactory +{ + public static function class(): string + { + return SurveyQuestion::class; + } + + protected function defaults(): array + { + return [ + 'questionText' => self::faker()->sentence() . '?', + 'helpText' => self::faker()->optional(0.3)->sentence(), + 'questionType' => SurveyQuestionType::TEXT, + 'isRequired' => true, + 'orderPosition' => 0, + 'tagCategory' => null, + 'survey' => SurveyFactory::new(), + ]; + } + + protected function initialize(): static + { + return $this + // ->afterInstantiate(function(Larp $larp): void {}) + ; + } + + /** + * Question for a specific survey + */ + public function forSurvey(mixed $survey): self + { + return $this->with(['survey' => $survey]); + } + + /** + * Short text question + */ + public function text(): self + { + return $this->with(['questionType' => SurveyQuestionType::TEXT]); + } + + /** + * Long text question (textarea) + */ + public function textarea(): self + { + return $this->with(['questionType' => SurveyQuestionType::TEXTAREA]); + } + + /** + * Single choice question with options + */ + public function singleChoice(int $optionCount = 3): self + { + return $this->with(['questionType' => SurveyQuestionType::SINGLE_CHOICE]) + ->afterPersist(function (SurveyQuestion $question) use ($optionCount): void { + for ($i = 0; $i < $optionCount; $i++) { + SurveyQuestionOptionFactory::new() + ->forQuestion($question) + ->with(['orderPosition' => $i]) + ->create(); + } + }); + } + + /** + * Multiple choice question with options + */ + public function multipleChoice(int $optionCount = 4): self + { + return $this->with(['questionType' => SurveyQuestionType::MULTIPLE_CHOICE]) + ->afterPersist(function (SurveyQuestion $question) use ($optionCount): void { + for ($i = 0; $i < $optionCount; $i++) { + SurveyQuestionOptionFactory::new() + ->forQuestion($question) + ->with(['orderPosition' => $i]) + ->create(); + } + }); + } + + /** + * Rating question (1-5 scale) + */ + public function rating(): self + { + return $this->with([ + 'questionType' => SurveyQuestionType::RATING, + 'questionText' => 'Rate from 1 to 5: ' . self::faker()->sentence(3), + ]); + } + + /** + * Tag selection question + */ + public function tagSelection(?string $category = null): self + { + return $this->with([ + 'questionType' => SurveyQuestionType::TAG_SELECTION, + 'tagCategory' => $category, + ]); + } + + /** + * Required question + */ + public function required(): self + { + return $this->with(['isRequired' => true]); + } + + /** + * Optional question + */ + public function optional(): self + { + return $this->with(['isRequired' => false]); + } +} diff --git a/tests/Support/Factory/Survey/SurveyQuestionOptionFactory.php b/tests/Support/Factory/Survey/SurveyQuestionOptionFactory.php new file mode 100644 index 0000000..d09e71c --- /dev/null +++ b/tests/Support/Factory/Survey/SurveyQuestionOptionFactory.php @@ -0,0 +1,43 @@ + + */ +final class SurveyQuestionOptionFactory extends PersistentProxyObjectFactory +{ + public static function class(): string + { + return SurveyQuestionOption::class; + } + + protected function defaults(): array + { + return [ + 'optionText' => self::faker()->words(3, true), + 'orderPosition' => 0, + 'question' => SurveyQuestionFactory::new(), + ]; + } + + protected function initialize(): static + { + return $this + // ->afterInstantiate(function(Larp $larp): void {}) + ; + } + + /** + * Option for a specific question + */ + public function forQuestion(mixed $question): self + { + return $this->with(['question' => $question]); + } +} diff --git a/tests/Support/Factory/Survey/SurveyResponseFactory.php b/tests/Support/Factory/Survey/SurveyResponseFactory.php new file mode 100644 index 0000000..7a84a50 --- /dev/null +++ b/tests/Support/Factory/Survey/SurveyResponseFactory.php @@ -0,0 +1,140 @@ + + */ +final class SurveyResponseFactory extends PersistentProxyObjectFactory +{ + public static function class(): string + { + return SurveyResponse::class; + } + + protected function defaults(): array + { + return [ + 'status' => SubmissionStatus::NEW, + 'matchSuggestions' => null, + 'assignedCharacter' => null, + 'organizerNotes' => null, + 'survey' => SurveyFactory::new(), + 'larp' => LarpFactory::new(), + 'user' => UserFactory::new()->approved(), + ]; + } + + protected function initialize(): static + { + return $this + // ->afterInstantiate(function(Larp $larp): void {}) + ; + } + + /** + * Response for a specific survey + */ + public function forSurvey(mixed $survey): self + { + return $this->with(['survey' => $survey]); + } + + /** + * Response for a specific LARP + */ + public function forLarp(mixed $larp): self + { + return $this->with(['larp' => $larp]); + } + + /** + * Response by a specific user + */ + public function forUser(mixed $user): self + { + return $this->with(['user' => $user]); + } + + /** + * Pending response (NEW status, not assigned) + */ + public function pending(): self + { + return $this->with([ + 'status' => SubmissionStatus::NEW, + 'assignedCharacter' => null, + ]); + } + + /** + * Assigned response (with character) + */ + public function assigned(mixed $character = null): self + { + return $this->with([ + 'status' => SubmissionStatus::ASSIGNED, + 'assignedCharacter' => $character, + ]); + } + + /** + * Response with specific status + */ + public function withStatus(LarpStageStatus $status): self + { + return $this->with(['status' => $status]); + } + + /** + * Response with match suggestions + */ + public function withMatchSuggestions(array $suggestions): self + { + return $this->with(['matchSuggestions' => $suggestions]); + } + + /** + * Response with organizer notes + */ + public function withNotes(string $notes): self + { + return $this->with(['organizerNotes' => $notes]); + } + + /** + * Response with answers to all survey questions + */ + public function withAnswers(): self + { + return $this->afterPersist(function (SurveyResponse $response): void { + $survey = $response->getSurvey(); + if ($survey) { + foreach ($survey->getQuestions() as $question) { + $answer = SurveyAnswerFactory::new() + ->forResponse($response) + ->forQuestion($question); + + // Provide appropriate answer based on question type + match ($question->getQuestionType()) { + SurveyQuestionType::TEXT => $answer->textAnswer(), + SurveyQuestionType::TEXTAREA => $answer->textAnswer(self::faker()->paragraph()), + SurveyQuestionType::RATING => $answer->ratingAnswer(self::faker()->numberBetween(1, 5)), + default => $answer, + }; + + $answer->create(); + } + } + }); + } +} diff --git a/tests/Support/FunctionalTester.php b/tests/Support/FunctionalTester.php index 71fb8e4..c7ca322 100644 --- a/tests/Support/FunctionalTester.php +++ b/tests/Support/FunctionalTester.php @@ -4,6 +4,8 @@ namespace Tests\Support; +use Codeception\Actor; + /** * Inherited Methods * @method void wantTo($text) @@ -19,7 +21,7 @@ * * @SuppressWarnings(PHPMD) */ -class FunctionalTester extends \Codeception\Actor +class FunctionalTester extends Actor { use _generated\FunctionalTesterActions; diff --git a/tests/Support/Helper/Authentication.php b/tests/Support/Helper/Authentication.php index 60c734c..fa09464 100644 --- a/tests/Support/Helper/Authentication.php +++ b/tests/Support/Helper/Authentication.php @@ -5,27 +5,42 @@ namespace Tests\Support\Helper; use App\Domain\Account\Entity\User; +use App\Domain\Core\Entity\Enum\LarpStageStatus; +use App\Domain\Core\Entity\Larp; +use App\Domain\Core\Entity\LarpParticipant; +use App\Domain\Core\Entity\Location; +use Codeception\Exception\ModuleException; use Codeception\Module; use Codeception\Module\Symfony; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\BrowserKit\Cookie; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Tests\Support\Factory\Account\UserFactory; +use Tests\Support\Factory\Core\LarpFactory; +use Tests\Support\Factory\Core\LarpParticipantFactory; +use Tests\Support\Factory\Core\LocationFactory; /** * Authentication Helper for Codeception Tests * - * Follows Single Responsibility Principle: - * - Only handles session-based authentication for Codeception tests - * - User creation delegated to Foundry factories - * - URL generation and service access through parent Symfony module + * Integrates Foundry factories with Codeception actors: + * - Session-based authentication via amLoggedInAs() + * - User creation using UserFactory + * - LARP/Location creation using domain factories + * - Route generation and service access through Symfony module */ class Authentication extends Module { protected ?Symfony $symfony = null; + /** + * @throws ModuleException + */ public function _beforeSuite($settings = []): void { - $this->symfony = $this->getModule('Symfony'); + /** @var Symfony $module */ + $module = $this->getModule('Symfony'); + $this->symfony = $module; } /** @@ -48,7 +63,9 @@ public function amLoggedInAs(User $user, string $firewall = 'main'): void */ public function getEntityManager(): EntityManagerInterface { - return $this->symfony->grabService(EntityManagerInterface::class); + /** @var EntityManagerInterface $service */ + $service = $this->symfony->grabService(EntityManagerInterface::class); + return $service; } /** @@ -58,4 +75,9 @@ public function getUrl(string $route, array $parameters = []): string { return $this->symfony->grabService('router')->generate($route, $parameters); } + + public function createSuperAdmin(): User + { + return UserFactory::new()->approved()->superAdmin()->create()->_real(); + } } diff --git a/tests/Support/UnitTester.php b/tests/Support/UnitTester.php index 710bfbd..2d5c045 100644 --- a/tests/Support/UnitTester.php +++ b/tests/Support/UnitTester.php @@ -4,6 +4,8 @@ namespace Tests\Support; +use Codeception\Actor; + /** * Inherited Methods * @method void wantTo($text) @@ -19,7 +21,7 @@ * * @SuppressWarnings(PHPMD) */ -class UnitTester extends \Codeception\Actor +class UnitTester extends Actor { use _generated\UnitTesterActions; diff --git a/tests/Traits/AuthenticationTestTrait.php b/tests/Traits/AuthenticationTestTrait.php index bb2eb23..757472a 100644 --- a/tests/Traits/AuthenticationTestTrait.php +++ b/tests/Traits/AuthenticationTestTrait.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Tests\Traits; +namespace Tests\Traits; use App\Domain\Account\Entity\Enum\UserStatus; use App\Domain\Account\Entity\Plan; diff --git a/tests/Domain/Application/Service/SubmissionStatsServiceTest.php b/tests/Unit/Application/Service/SubmissionStatsServiceTest.php old mode 100755 new mode 100644 similarity index 93% rename from tests/Domain/Application/Service/SubmissionStatsServiceTest.php rename to tests/Unit/Application/Service/SubmissionStatsServiceTest.php index f014b88..7ca7f7b --- a/tests/Domain/Application/Service/SubmissionStatsServiceTest.php +++ b/tests/Unit/Application/Service/SubmissionStatsServiceTest.php @@ -1,6 +1,6 @@ wantTo('verify that valid participant codes are accepted'); + $larp = new Larp(); $generator = new ParticipantCodeGenerator(); $validator = new ParticipantCodeValidator(); + $code = $generator->generate($larp); - $this->assertTrue($validator->validate($code, $larp)); + + $I->assertTrue($validator->validate($code, $larp)); } - public function testInvalidCode(): void + public function invalidCodeIsRejected(UnitTester $I): void { + $I->wantTo('verify that invalid participant codes are rejected'); + $larp = new Larp(); $other = new Larp(); $generator = new ParticipantCodeGenerator(); $validator = new ParticipantCodeValidator(); + $code = $generator->generate($larp); - $this->assertFalse($validator->validate($code, $other)); + + $I->assertFalse($validator->validate($code, $other)); } } diff --git a/tests/Domain/Infrastructure/Repository/BaseRepositoryTest.php b/tests/Unit/Infrastructure/Repository/BaseRepositoryTest.php old mode 100755 new mode 100644 similarity index 74% rename from tests/Domain/Infrastructure/Repository/BaseRepositoryTest.php rename to tests/Unit/Infrastructure/Repository/BaseRepositoryTest.php index d8a82da..c47f12e --- a/tests/Domain/Infrastructure/Repository/BaseRepositoryTest.php +++ b/tests/Unit/Infrastructure/Repository/BaseRepositoryTest.php @@ -1,15 +1,20 @@ wantTo('verify that save() persists and flushes the entity'); + $entity = new \stdClass(); $em = $this->createMock(EntityManagerInterface::class); $em->expects($this->once())->method('persist')->with($entity); @@ -19,6 +24,7 @@ public function testSavePersistsAndFlushes(): void public function __construct(private readonly EntityManagerInterface $em) { } + protected function getEntityManager(): EntityManagerInterface { return $this->em; @@ -28,8 +34,10 @@ protected function getEntityManager(): EntityManagerInterface $repository->save($entity); } - public function testRemoveDeletesAndFlushes(): void + public function removeDeletesAndFlushes(UnitTester $I): void { + $I->wantTo('verify that remove() deletes and flushes the entity'); + $entity = new \stdClass(); $em = $this->createMock(EntityManagerInterface::class); $em->expects($this->once())->method('remove')->with($entity); @@ -39,6 +47,7 @@ public function testRemoveDeletesAndFlushes(): void public function __construct(private readonly EntityManagerInterface $em) { } + protected function getEntityManager(): EntityManagerInterface { return $this->em; diff --git a/tests/Unit/Mailing/Service/MailTemplateDefinitionProviderTest.php b/tests/Unit/Mailing/Service/MailTemplateDefinitionProviderTest.php new file mode 100644 index 0000000..10671f3 --- /dev/null +++ b/tests/Unit/Mailing/Service/MailTemplateDefinitionProviderTest.php @@ -0,0 +1,27 @@ +wantTo('verify that definitions exist for every mail template type'); + + $provider = new MailTemplateDefinitionProvider(); + $definitions = $provider->getDefinitions(); + + $I->assertCount(count(MailTemplateType::cases()), $definitions); + $I->assertArrayHasKey(MailTemplateType::CHARACTER_ASSIGNMENT_PUBLISHED->value, $definitions); + + $assignmentDefinition = $definitions[MailTemplateType::CHARACTER_ASSIGNMENT_PUBLISHED->value]; + $I->assertContains('character_public_url', $assignmentDefinition->placeholders); + } +} diff --git a/tests/Domain/StoryMarketplace/Repository/RecruitmentProposalRepositoryTest.php b/tests/Unit/StoryMarketplace/Repository/RecruitmentProposalRepositoryTest.php old mode 100755 new mode 100644 similarity index 95% rename from tests/Domain/StoryMarketplace/Repository/RecruitmentProposalRepositoryTest.php rename to tests/Unit/StoryMarketplace/Repository/RecruitmentProposalRepositoryTest.php index 9a29b8b..fe5c66a --- a/tests/Domain/StoryMarketplace/Repository/RecruitmentProposalRepositoryTest.php +++ b/tests/Unit/StoryMarketplace/Repository/RecruitmentProposalRepositoryTest.php @@ -1,6 +1,6 @@ {} }; - } - - position(dim, val) { - this.forEach(node => node.position(dim, val)); - } -} - -class FakeNode { - constructor(data) { - this.data = data; - this.pos = { x: 0, y: 0 }; - this.childrenNodes = []; - } - - position(dim, val) { - if (val === undefined) { - if (dim === undefined) { - return this.pos; - } - if (typeof dim === 'string') { - return this.pos[dim]; - } - if (dim.x !== undefined) { this.pos.x = dim.x; } - if (dim.y !== undefined) { this.pos.y = dim.y; } - return this.pos; - } - this.pos[dim] = val; - } - - children(selector) { - if (!selector) return new FakeCollection(...this.childrenNodes); - const type = selector.match(/\[type="(.+)"\]/)[1]; - return new FakeCollection(...this.childrenNodes.filter(c => c.data.type === type)); - } -} - -class FakeCy { - constructor(elements) { - this.nodesMap = new Map(); - elements.forEach(el => { - const node = new FakeNode(el.data); - if (el.position) { - node.pos = { ...el.position }; - } - this.nodesMap.set(el.data.id, node); - }); - elements.forEach(el => { - if (el.data.parent) { - this.nodesMap.get(el.data.parent).childrenNodes.push( - this.nodesMap.get(el.data.id) - ); - } - }); - } - - nodes(selector) { - if (selector) { - const type = selector.match(/\[type="(.+)"\]/)[1]; - return Array.from(this.nodesMap.values()).filter(n => n.data.type === type); - } - return Array.from(this.nodesMap.values()); - } - - getElementById(id) { - return this.nodesMap.get(id); - } -} - -const cy = new FakeCy([ - { data: { id: 'group1', type: 'factionGroup' }, position: { x: 100, y: 50 } }, - { data: { id: 'faction1', type: 'faction', parent: 'group1' }, position: { x: 120, y: 60 } }, - { data: { id: 'char1', type: 'character', parent: 'group1' } }, - { data: { id: 'group2', type: 'factionGroup' }, position: { x: 200, y: 100 } }, - { data: { id: 'faction2', type: 'faction', parent: 'group2' }, position: { x: 220, y: 110 } }, -]); - -applyFactionGroupLayout(cy); - -const g1 = cy.getElementById('group1'); -const g2 = cy.getElementById('group2'); -assert.strictEqual(g1.position('y'), g2.position('y')); - -const faction1 = cy.getElementById('faction1'); -assert.strictEqual(faction1.position('x'), g1.position('x')); -assert.strictEqual(faction1.position('y'), g1.position('y')); - -console.log('factionGroupLayout test passed'); - diff --git a/translations/forms.en.yaml b/translations/forms.en.yaml index 6881e77..830c419 100755 --- a/translations/forms.en.yaml +++ b/translations/forms.en.yaml @@ -378,6 +378,7 @@ location: address: "Address" approval_status: "Approval Status" pending_message: "This location is pending approval. It will be reviewed by an administrator before becoming visible to other users." + no_locations_found: "No locations found" mail_template: name: "Template name" @@ -386,3 +387,10 @@ mail_template: body: "Email body" enabled: "Send automatically" available_placeholders: "Available placeholders" + +name: Name + +boolean: + yes: "Yes" + no: "No" + yes_or_no: 'Yes or no' \ No newline at end of file diff --git a/translations/forms.pl.yaml b/translations/forms.pl.yaml index 834c3d7..2f63049 100755 --- a/translations/forms.pl.yaml +++ b/translations/forms.pl.yaml @@ -294,3 +294,9 @@ mail_template: body: "Treść wiadomości" enabled: "Wysyłaj automatycznie" available_placeholders: "Dostępne znaczniki" + + +boolean: + yes: "Tak" + no: "Nie" + yes_or_no: 'Obojętnie' \ No newline at end of file diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 7273441..115e2a9 100755 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -163,6 +163,12 @@ larp: application_open: 'Application form is open' application_open_description: 'LARP is ready to collect Your application' apply_for_character: 'Apply for character' + browse_characters: 'Browse Available Characters' + browse_characters_description: 'Explore the character gallery and apply for characters that interest you' + view_character_gallery: 'View Character Gallery' + survey_application: 'Fill Out Application Survey' + survey_application_description: 'Complete our survey to help us match you with the perfect character' + fill_out_survey: 'Fill Out Survey' application_form: 'Application Form' submit_application: 'Submit Application' application_form_description: 'Use this form to apply for the most interesting roles, please select tags that are interesting for You to play and the tags that You would like to omit in Your gameplay (triggers)' @@ -437,6 +443,8 @@ larp: create: 'Add Participant' modify: 'Edit Participant' view: 'Participant Details' + error: + cannot_delete_last_organizer: 'You cannot remove yourself from this LARP as you are the last organizer. Please assign another organizer before leaving.' event: singular: 'Event' plural: 'Events' @@ -564,10 +572,17 @@ marketplace: player_preferences: "Player Preferences" match_score: "Match Score" +search: "Search" + #Translations specific for public location pages location: + title: 'Location' + address: 'Address' + city: 'City' list: 'Locations' singular: 'Location' + status: 'Status' + approval: 'Approval' website: 'Website url' details: 'Location Details' about: 'About This Location' @@ -595,6 +610,7 @@ location: approve_confirm: "Are you sure you want to approve this location?" reject_location: "Reject Location" reject_warning: "You are about to reject the location '%location%'. The creator will be able to see the rejection reason and resubmit after making changes." + rejection_reason: 'Rejection Reason' #Translations specific for Core Organizers backoffice @@ -662,6 +678,14 @@ public: empty: 'You have not been assigned any characters yet.' view_public_page: 'View public page' no_characters: 'No characters have been assigned to you yet.' + characters: + gallery: 'Character Gallery' + gallery_description: 'Browse available characters for this LARP' + no_characters: 'No characters are currently available' + character: + available: 'Available for Recruitment' + apply_for_larp: 'Apply for this LARP' + login_to_apply: 'Login to Apply' character_type: player: "Player" @@ -1146,6 +1170,63 @@ cookie: examples: 'Examples' always_active: 'Always Active' +# Survey System +survey: + edit: 'Edit Survey' + builder: 'Survey Builder' + questions: 'Questions' + add_question: 'Add Question' + question: 'Question' + options: 'Options' + add_option: 'Add Option' + active: 'Active' + inactive: 'Inactive' + no_questions: 'No questions have been added yet' + required_fields_notice: 'Fields marked with * are required' + what_happens_next: 'What happens next?' + submission_process_explanation: 'After submitting your survey, our organizers will review your responses and match you with a suitable character. You will be notified once a character has been assigned to you.' + not_accepting_responses: 'This LARP is not currently accepting survey responses' + not_in_survey_mode: 'This LARP is not using survey-based applications' + not_available: 'The survey is not currently available' + already_submitted: 'You have already submitted a survey response for this LARP' + submitted_successfully: 'Thank you! Your survey has been submitted successfully' + help: + title: 'Survey Builder Help' + drag_reorder: 'Drag and drop questions using the grip icon to reorder them' + question_types: 'Use different question types: text, textarea, single choice, multiple choice, rating (1-5), and tag selection' + tag_selection: 'Tag selection questions allow players to choose from LARP-specific tags' + preview: 'Use the preview button to see how players will view the survey' + preview: + title: 'Preview Survey' + short_text: 'Short answer text...' + long_text: 'Longer answer text...' + tag_selection_info: 'Players will be able to select tags from this LARP' + submit_button: 'Submit Survey (Preview Only)' + response: + details: 'Survey Response Details' + regenerate_matches: 'Regenerate Match Suggestions' + assigned_character: 'Assigned Character' + match_suggestions: 'Character Match Suggestions' + points: 'points' + assign_confirm: 'Are you sure you want to assign %character% to this player?' + assign: 'Assign Character' + no_matches: 'No character matches found. This may be because all suitable characters are already assigned, or the player''s preferences don''t match any available characters.' + answers: 'Survey Answers' + no_answers: 'No answers recorded' + organizer_notes: 'Organizer Notes' + no_notes: 'No notes yet' + responses: + list: 'Survey Responses' + regenerate_all_confirm: 'This will regenerate match suggestions for all responses. This may take a while. Continue?' + regenerate_all: 'Regenerate All Matches' + assigned_character: 'Assigned Character' + match_score: 'Match Score' + empty: 'No survey responses yet' + total: 'Total Responses' + pending: 'Pending Assignment' + assigned: 'Assigned' + confirmed: 'Confirmed' + # Legal Pages privacy_policy: title: 'Privacy Policy' diff --git a/translations/messages.pl.yaml b/translations/messages.pl.yaml index 239c004..fdd6200 100755 --- a/translations/messages.pl.yaml +++ b/translations/messages.pl.yaml @@ -372,6 +372,8 @@ larp: create: 'Dodaj uczestnika' modify: 'Edytuj uczestnika' view: 'Szczegóły uczestnika' + error: + cannot_delete_last_organizer: 'Nie możesz usunąć siebie z tego LARPu, ponieważ jesteś ostatnim organizatorem. Przed opuszczeniem przypisz innego organizatora.' event: singular: 'Wydarzenie' plural: 'Wydarzenia' From 0356d2f1ccfdc781ea5b692f4ac43945971bcbf3 Mon Sep 17 00:00:00 2001 From: TomaszB Date: Wed, 17 Dec 2025 17:42:21 +0100 Subject: [PATCH 02/16] Remove `AuthenticationTestTrait` and migrate dependent tests to use factory methods for entity creation. --- .../CharacterApplicationsControllerCest.php | 2 - .../UserSignupAndApprovalCest.php | 12 +- .../LarpParticipantDeletionCest.php | 51 ++- .../Incidents/LarpIncidentsTemplateCest.php | 3 +- .../Public/PublicCommonControllerCest.php | 33 +- .../Security/BackofficeAccessCest.php | 26 +- .../Security/LarpVisibilitySecurityCest.php | 18 +- .../Security/LocationApprovalSecurityCest.php | 39 +- .../Functional/Security/PublicAccessCest.php | 28 +- .../RecruitmentControllerCest.php | 11 +- .../Service/StoryGraphFactionFilterTest.php | 6 +- .../Repository/LarpRepositoryCest.php | 15 +- .../Integration/Service/LarpWorkflowCest.php | 8 +- .../Service/LocationApprovalServiceCest.php | 1 + .../StoryRecruitmentRepositoryCest.php | 3 +- tests/Support/Factory/Account/PlanFactory.php | 11 +- tests/Support/Factory/Account/UserFactory.php | 5 +- tests/Support/Factory/Core/LarpFactory.php | 23 ++ tests/Support/Helper/Authentication.php | 332 +++++++++++++++ tests/Traits/AuthenticationTestTrait.php | 379 ------------------ .../Service/SubmissionStatsServiceTest.php | 20 +- 21 files changed, 546 insertions(+), 480 deletions(-) delete mode 100644 tests/Traits/AuthenticationTestTrait.php diff --git a/tests/Functional/Application/Controller/CharacterApplicationsControllerCest.php b/tests/Functional/Application/Controller/CharacterApplicationsControllerCest.php index cad47ab..e2f8999 100644 --- a/tests/Functional/Application/Controller/CharacterApplicationsControllerCest.php +++ b/tests/Functional/Application/Controller/CharacterApplicationsControllerCest.php @@ -4,12 +4,10 @@ namespace Functional\Application\Controller; -use App\Domain\Account\Entity\User; use Tests\Support\Factory\Account\UserFactory; use Tests\Support\Factory\Application\LarpApplicationChoiceFactory; use Tests\Support\Factory\Application\LarpApplicationFactory; use Tests\Support\Factory\Core\LarpFactory; -use Tests\Support\Factory\Core\LarpParticipantFactory; use Tests\Support\Factory\StoryObject\CharacterFactory; use Tests\Support\FunctionalTester; diff --git a/tests/Functional/Authentication/UserSignupAndApprovalCest.php b/tests/Functional/Authentication/UserSignupAndApprovalCest.php index cba51eb..e310d97 100644 --- a/tests/Functional/Authentication/UserSignupAndApprovalCest.php +++ b/tests/Functional/Authentication/UserSignupAndApprovalCest.php @@ -64,7 +64,7 @@ public function pendingUserCannotAccessBackoffice(FunctionalTester $I): void $I->amOnRoute('backoffice_larp_create'); // Should be redirected to pending approval page - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); } public function pendingUserCannotAccessLarpCreation(FunctionalTester $I): void @@ -77,7 +77,7 @@ public function pendingUserCannotAccessLarpCreation(FunctionalTester $I): void $I->amOnRoute('backoffice_larp_create'); // Should be redirected to pending approval page - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); } public function approvedUserCanAccessBackoffice(FunctionalTester $I): void @@ -153,7 +153,7 @@ public function suspendedUserCannotAccessBackoffice(FunctionalTester $I): void $I->amOnRoute('backoffice_dashboard'); - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); $I->assertResponseRedirects($I->getUrl('backoffice_account_pending_approval')); } @@ -166,7 +166,7 @@ public function bannedUserCannotAccessBackoffice(FunctionalTester $I): void $I->amOnRoute('backoffice_dashboard'); - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); $I->assertResponseRedirects($I->getUrl('backoffice_account_pending_approval')); } @@ -179,7 +179,7 @@ public function suspendedUserCannotCreateLarp(FunctionalTester $I): void $I->amOnRoute('backoffice_larp_create'); - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); $I->assertResponseRedirects($I->getUrl('backoffice_account_pending_approval')); } @@ -192,7 +192,7 @@ public function bannedUserCannotCreateLarp(FunctionalTester $I): void $I->amOnRoute('backoffice_larp_create'); - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); $I->assertResponseRedirects($I->getUrl('backoffice_account_pending_approval')); } diff --git a/tests/Functional/Authorization/LarpParticipantDeletionCest.php b/tests/Functional/Authorization/LarpParticipantDeletionCest.php index 55e9df9..a515729 100644 --- a/tests/Functional/Authorization/LarpParticipantDeletionCest.php +++ b/tests/Functional/Authorization/LarpParticipantDeletionCest.php @@ -185,7 +185,7 @@ public function lastOrganizerCannotDeleteThemselves(FunctionalTester $I): void // Follow redirect and check flash message $I->followRedirect(); - $I->see('cannot remove yourself', '.alert-danger'); + $I->see('cannot remove yourself'); // Verify organizer still exists LarpParticipantFactory::assert()->exists($organizerParticipant); @@ -214,23 +214,22 @@ public function organizerCanDeleteThemselvesWhenMultipleOrganizersExist(Function 'user' => $organizer1->_real() ]); + // Save ID before deletion (proxy may become invalid after entity is deleted) + $organizer1ParticipantId = $organizer1Participant->getId(); + // Act: First organizer deletes themselves (second organizer still exists) $I->amLoggedInAs($organizer1->_real()); $I->amOnRoute('backoffice_larp_participant_delete', [ 'larp' => $larp->getId(), - 'participant' => $organizer1Participant->getId(), + 'participant' => $organizer1ParticipantId, ]); // Assert: Successful deletion with redirect + // Note: After self-deletion, organizer1 loses LARP access, so we don't follow the redirect $I->seeResponseCodeIsRedirection(); - $I->assertResponseRedirects($I->getUrl('backoffice_larp_participant_list', ['larp' => $larp->getId()])); - - // Follow redirect and check success message - $I->followRedirect(); - $I->seeElement('.alert-success'); // Verify organizer1 was deleted - LarpParticipantFactory::assert()->notExists(['id' => $organizer1Participant->getId()]); + LarpParticipantFactory::assert()->notExists(['id' => $organizer1ParticipantId]); // Verify organizer2 still exists LarpParticipantFactory::assert()->exists($organizer2Participant); @@ -252,11 +251,14 @@ public function organizerCanDeletePlayer(FunctionalTester $I): void ->player() ->create(); + // Save ID before deletion (proxy may become invalid after entity is deleted) + $playerParticipantId = $playerParticipant->getId(); + // Act: Organizer deletes player $I->amLoggedInAs($organizer->_real()); $I->amOnRoute('backoffice_larp_participant_delete', [ 'larp' => $larp->getId(), - 'participant' => $playerParticipant->getId(), + 'participant' => $playerParticipantId, ]); // Assert: Successful deletion @@ -264,10 +266,10 @@ public function organizerCanDeletePlayer(FunctionalTester $I): void $I->assertResponseRedirects($I->getUrl('backoffice_larp_participant_list', ['larp' => $larp->getId()])); $I->followRedirect(); - $I->seeElement('.alert-success'); + $I->see('Successfully removed'); // Verify player was deleted - LarpParticipantFactory::assert()->notExists(['id' => $playerParticipant->getId()]); + LarpParticipantFactory::assert()->notExists(['id' => $playerParticipantId]); } public function organizerCanDeleteNpc(FunctionalTester $I): void @@ -286,20 +288,23 @@ public function organizerCanDeleteNpc(FunctionalTester $I): void ->npcLong() ->create(); + // Save ID before deletion (proxy may become invalid after entity is deleted) + $npcParticipantId = $npcParticipant->getId(); + // Act: Organizer deletes NPC $I->amLoggedInAs($organizer->_real()); $I->amOnRoute('backoffice_larp_participant_delete', [ 'larp' => $larp->getId(), - 'participant' => $npcParticipant->getId(), + 'participant' => $npcParticipantId, ]); // Assert: Successful deletion $I->seeResponseCodeIsRedirection(); $I->followRedirect(); - $I->seeElement('.alert-success'); + $I->see('Successfully removed'); // Verify NPC was deleted - LarpParticipantFactory::assert()->notExists(['id' => $npcParticipant->getId()]); + LarpParticipantFactory::assert()->notExists(['id' => $npcParticipantId]); } public function organizerCanDeleteStoryWriter(FunctionalTester $I): void @@ -318,20 +323,23 @@ public function organizerCanDeleteStoryWriter(FunctionalTester $I): void ->storyWriter() ->create(); + // Save ID before deletion (proxy may become invalid after entity is deleted) + $writerParticipantId = $writerParticipant->getId(); + // Act: Organizer deletes story writer $I->amLoggedInAs($organizer->_real()); $I->amOnRoute('backoffice_larp_participant_delete', [ 'larp' => $larp->getId(), - 'participant' => $writerParticipant->getId(), + 'participant' => $writerParticipantId, ]); // Assert: Successful deletion $I->seeResponseCodeIsRedirection(); $I->followRedirect(); - $I->seeElement('.alert-success'); + $I->see('Successfully removed'); // Verify writer was deleted - LarpParticipantFactory::assert()->notExists(['id' => $writerParticipant->getId()]); + LarpParticipantFactory::assert()->notExists(['id' => $writerParticipantId]); } public function organizerCanDeleteAnotherOrganizerWhenMultipleExist(FunctionalTester $I): void @@ -357,20 +365,23 @@ public function organizerCanDeleteAnotherOrganizerWhenMultipleExist(FunctionalTe ->organizer() ->create(); + // Save ID before deletion (proxy may become invalid after entity is deleted) + $organizer2ParticipantId = $organizer2Participant->getId(); + // Act: Organizer1 deletes Organizer2 (Organizer3 still exists) $I->amLoggedInAs($organizer1->_real()); $I->amOnRoute('backoffice_larp_participant_delete', [ 'larp' => $larp->getId(), - 'participant' => $organizer2Participant->getId(), + 'participant' => $organizer2ParticipantId, ]); // Assert: Successful deletion $I->seeResponseCodeIsRedirection(); $I->followRedirect(); - $I->seeElement('.alert-success'); + $I->see('Successfully removed'); // Verify organizer2 was deleted - LarpParticipantFactory::assert()->notExists(['id' => $organizer2Participant->getId()]); + LarpParticipantFactory::assert()->notExists(['id' => $organizer2ParticipantId]); // Verify organizer3 still exists LarpParticipantFactory::assert()->exists($organizer3Participant); diff --git a/tests/Functional/Incidents/LarpIncidentsTemplateCest.php b/tests/Functional/Incidents/LarpIncidentsTemplateCest.php index 49f2e3d..75f6f53 100644 --- a/tests/Functional/Incidents/LarpIncidentsTemplateCest.php +++ b/tests/Functional/Incidents/LarpIncidentsTemplateCest.php @@ -10,6 +10,7 @@ use App\Domain\Incidents\Entity\LarpIncident; use App\Domain\Incidents\Form\Filter\LarpIncidentFilterType; use Symfony\Component\Form\FormFactoryInterface; +use Symfony\Component\HttpFoundation\Request; use Tests\Support\FunctionalTester; use Twig\Environment; @@ -26,7 +27,7 @@ public function _before(FunctionalTester $I): void // Ensure request stack has a request $requestStack = $I->grabService('request_stack'); if (!$requestStack->getCurrentRequest()) { - $requestStack->push(new \Symfony\Component\HttpFoundation\Request()); + $requestStack->push(new Request()); } } diff --git a/tests/Functional/Public/PublicCommonControllerCest.php b/tests/Functional/Public/PublicCommonControllerCest.php index b097d4a..0a6a933 100644 --- a/tests/Functional/Public/PublicCommonControllerCest.php +++ b/tests/Functional/Public/PublicCommonControllerCest.php @@ -12,8 +12,37 @@ public function localeCanBeSwitched(FunctionalTester $I): void { $I->wantTo('verify that locale can be switched via switch-locale route'); + // First request: Switch locale to German + // This redirects back to referer (/) and sets _locale in session + $I->stopFollowingRedirects(); $I->amOnPage('/switch-locale/de'); - $I->seeResponseCodeIs(302); - $I->seeInSession('_locale', 'de'); + $I->seeResponseCodeIsRedirection(); + + // Follow the redirect to verify the page loads correctly + $I->followRedirect(); + $I->seeResponseCodeIsSuccessful(); + } + + public function localeRouteReturnsAllowedLocales(FunctionalTester $I): void + { + $I->wantTo('verify that switch-locale route accepts valid locales'); + + $allowedLocales = ['en', 'pl', 'de', 'es', 'cz', 'sl', 'it', 'no', 'sv']; + + $I->stopFollowingRedirects(); + foreach ($allowedLocales as $locale) { + $I->amOnPage('/switch-locale/' . $locale); + $I->seeResponseCodeIsRedirection(); + } + } + + public function localeRouteHandlesInvalidLocale(FunctionalTester $I): void + { + $I->wantTo('verify that switch-locale route handles invalid locales gracefully'); + + // Invalid locale should default to 'en' and still redirect + $I->stopFollowingRedirects(); + $I->amOnPage('/switch-locale/invalid'); + $I->seeResponseCodeIsRedirection(); } } diff --git a/tests/Functional/Security/BackofficeAccessCest.php b/tests/Functional/Security/BackofficeAccessCest.php index ba91dba..c86073b 100644 --- a/tests/Functional/Security/BackofficeAccessCest.php +++ b/tests/Functional/Security/BackofficeAccessCest.php @@ -4,6 +4,7 @@ namespace Tests\Functional\Security; +use Tests\Support\Factory\Account\UserFactory; use Tests\Support\FunctionalTester; /** @@ -20,12 +21,17 @@ */ class BackofficeAccessCest { + public function _before(FunctionalTester $I): void + { + $I->stopFollowingRedirects(); + } + public function unauthenticatedUserRedirectedFromBackoffice(FunctionalTester $I): void { $I->wantTo('verify unauthenticated users are redirected from backoffice'); $I->amOnRoute('backoffice_dashboard'); - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); } public function pendingUserCannotAccessBackoffice(FunctionalTester $I): void @@ -36,7 +42,7 @@ public function pendingUserCannotAccessBackoffice(FunctionalTester $I): void $I->amLoggedInAs($pendingUser); $I->amOnRoute('backoffice_dashboard'); - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); } public function approvedUserCanAccessBackoffice(FunctionalTester $I): void @@ -58,7 +64,7 @@ public function suspendedUserCannotAccessBackoffice(FunctionalTester $I): void $I->amLoggedInAs($suspendedUser); $I->amOnRoute('backoffice_dashboard'); - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); } public function bannedUserCannotAccessBackoffice(FunctionalTester $I): void @@ -69,7 +75,7 @@ public function bannedUserCannotAccessBackoffice(FunctionalTester $I): void $I->amLoggedInAs($bannedUser); $I->amOnRoute('backoffice_dashboard'); - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); } public function superAdminCanAccessSuperAdminRoutes(FunctionalTester $I): void @@ -110,7 +116,7 @@ public function unauthenticatedUserRedirectedFromSuperAdminRoutes(FunctionalTest $I->wantTo('verify unauthenticated users are redirected from super-admin routes'); $I->amOnRoute('super_admin_users_list'); - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); } public function multipleAccessControlLayersWorkTogether(FunctionalTester $I): void @@ -122,15 +128,15 @@ public function multipleAccessControlLayersWorkTogether(FunctionalTester $I): vo // Try backoffice (should redirect due to PENDING status) $I->amOnRoute('backoffice_dashboard'); - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); // Try LARP creation (should redirect due to voter) $I->amOnRoute('backoffice_larp_create'); - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); // Try location creation (should redirect due to voter) $I->amOnPage($I->getUrl('backoffice_location_modify_global', ['location' => 'new'])); - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); } public function statusChangeAffectsAccessImmediately(FunctionalTester $I): void @@ -142,7 +148,7 @@ public function statusChangeAffectsAccessImmediately(FunctionalTester $I): void // Initially cannot access backoffice $I->amOnRoute('backoffice_dashboard'); - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); // Approve user $user->setStatus(\App\Domain\Account\Entity\Enum\UserStatus::APPROVED); @@ -219,7 +225,7 @@ public function unauthenticatedUserRedirectedFromBackofficeRoute(FunctionalTeste $I->wantTo('verify unauthenticated users are redirected from backoffice LARP list'); $I->amOnRoute('backoffice_larp_list'); - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); } public function pendingUserCanAccessPublicRoutes(FunctionalTester $I): void diff --git a/tests/Functional/Security/LarpVisibilitySecurityCest.php b/tests/Functional/Security/LarpVisibilitySecurityCest.php index f683ad8..5b03dd6 100644 --- a/tests/Functional/Security/LarpVisibilitySecurityCest.php +++ b/tests/Functional/Security/LarpVisibilitySecurityCest.php @@ -6,6 +6,8 @@ use App\Domain\Core\Entity\Enum\LarpStageStatus; use App\Domain\Core\Entity\Enum\ParticipantRole; +use Tests\Support\Factory\Account\UserFactory; +use Tests\Support\Factory\Core\LarpFactory; use Tests\Support\FunctionalTester; /** @@ -28,7 +30,7 @@ public function publishedLarpIsPubliclyVisible(FunctionalTester $I): void $I->wantTo('verify that a PUBLISHED LARP is publicly visible'); $organizer = UserFactory::createApprovedUser(); - $larp = $I->createPublishedLarp($organizer); + $larp = LarpFactory::createPublishedLarp($organizer); $I->assertTrue( $larp->getStatus()->isVisibleForEveryone(), @@ -106,7 +108,7 @@ public function unauthenticatedUserCanSeePublicLarpInList(FunctionalTester $I): $I->wantTo('verify that unauthenticated users can see public LARPs in the list'); $organizer = UserFactory::createApprovedUser(); - $publicLarp = $I->createPublishedLarp($organizer, 'Public LARP'); + $publicLarp = LarpFactory::createPublishedLarp($organizer, 'Public LARP'); // Ensure the entity manager is flushed and cleared before making the request $em = $I->getEntityManager(); @@ -137,11 +139,12 @@ public function unauthenticatedUserCannotAccessLarpBackoffice(FunctionalTester $ $I->wantTo('verify that unauthenticated users cannot access LARP backoffice'); $organizer = UserFactory::createApprovedUser(); - $larp = $I->createPublishedLarp($organizer); + $larp = LarpFactory::createPublishedLarp($organizer); + $I->stopFollowingRedirects(); $I->amOnRoute('backoffice_larp_dashboard', ['larp' => $larp->getId()]); - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); } public function pendingUserCannotAccessLarpBackoffice(FunctionalTester $I): void @@ -149,14 +152,17 @@ public function pendingUserCannotAccessLarpBackoffice(FunctionalTester $I): void $I->wantTo('verify that pending users cannot access LARP backoffice'); $organizer = UserFactory::createApprovedUser(); - $larp = $I->createPublishedLarp($organizer); + $larp = LarpFactory::createPublishedLarp($organizer); $pendingUser = UserFactory::createPendingUser(); $I->amLoggedInAs($pendingUser); + $I->stopFollowingRedirects(); $I->amOnRoute('backoffice_larp_dashboard', ['larp' => $larp->getId()]); - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); + // Follow redirect to verify destination + $I->followRedirect(); $I->seeCurrentRouteIs('backoffice_account_pending_approval'); } diff --git a/tests/Functional/Security/LocationApprovalSecurityCest.php b/tests/Functional/Security/LocationApprovalSecurityCest.php index c649be8..d1ccade 100644 --- a/tests/Functional/Security/LocationApprovalSecurityCest.php +++ b/tests/Functional/Security/LocationApprovalSecurityCest.php @@ -5,6 +5,7 @@ namespace Tests\Functional\Security; use App\Domain\Core\Entity\Enum\LocationApprovalStatus; +use Tests\Support\Factory\Account\UserFactory; use Tests\Support\FunctionalTester; /** @@ -31,7 +32,7 @@ public function userCanEditTheirPendingLocation(FunctionalTester $I): void $I->amLoggedInAs($user); - $authChecker = $I->getContainer()->get('security.authorization_checker'); + $authChecker = $I->grabService('security.authorization_checker'); $canEdit = $authChecker->isGranted('EDIT_LOCATION', $location); $I->assertTrue($canEdit, 'User should be able to edit their PENDING location'); @@ -46,7 +47,7 @@ public function userCanEditTheirRejectedLocation(FunctionalTester $I): void $I->amLoggedInAs($user); - $authChecker = $I->getContainer()->get('security.authorization_checker'); + $authChecker = $I->grabService('security.authorization_checker'); $canEdit = $authChecker->isGranted('EDIT_LOCATION', $location); $I->assertTrue($canEdit, 'User should be able to edit their REJECTED location'); @@ -62,14 +63,14 @@ public function userCannotEditTheirApprovedLocation(FunctionalTester $I): void $location = $I->createPendingLocation($user); // Approve it - $locationApprovalService = $I->getContainer()->get( + $locationApprovalService = $I->grabService( \App\Domain\Core\Service\LocationApprovalService::class ); $locationApprovalService->approve($location, $superAdmin); $I->amLoggedInAs($user); - $authChecker = $I->getContainer()->get('security.authorization_checker'); + $authChecker = $I->grabService('security.authorization_checker'); $canEdit = $authChecker->isGranted('EDIT_LOCATION', $location); $I->assertFalse($canEdit, 'User should not be able to edit their APPROVED location'); @@ -86,7 +87,7 @@ public function userCannotEditOtherUsersLocation(FunctionalTester $I): void $I->amLoggedInAs($user2); - $authChecker = $I->getContainer()->get('security.authorization_checker'); + $authChecker = $I->grabService('security.authorization_checker'); $canEdit = $authChecker->isGranted('EDIT_LOCATION', $location); $I->assertFalse($canEdit, 'User should not be able to edit other user\'s location'); @@ -101,7 +102,7 @@ public function regularUserCannotApproveLocation(FunctionalTester $I): void $I->amLoggedInAs($user); - $authChecker = $I->getContainer()->get('security.authorization_checker'); + $authChecker = $I->grabService('security.authorization_checker'); $canApprove = $authChecker->isGranted('APPROVE_LOCATION', $location); $I->assertFalse($canApprove, 'Regular user should not be able to approve locations'); @@ -118,7 +119,7 @@ public function superAdminCanApproveLocation(FunctionalTester $I): void $I->amLoggedInAs($superAdmin); - $authChecker = $I->getContainer()->get('security.authorization_checker'); + $authChecker = $I->grabService('security.authorization_checker'); $canApprove = $authChecker->isGranted('APPROVE_LOCATION', $location); $I->assertTrue($canApprove, 'SUPER_ADMIN should be able to approve locations'); @@ -135,7 +136,7 @@ public function locationApprovalUpdatesStatus(FunctionalTester $I): void $I->assertEquals(LocationApprovalStatus::PENDING, $location->getApprovalStatus()); - $locationApprovalService = $I->getContainer()->get( + $locationApprovalService = $I->grabService( \App\Domain\Core\Service\LocationApprovalService::class ); $locationApprovalService->approve($location, $superAdmin); @@ -154,7 +155,7 @@ public function regularUserCannotRejectLocation(FunctionalTester $I): void $I->amLoggedInAs($user); - $authChecker = $I->getContainer()->get('security.authorization_checker'); + $authChecker = $I->grabService('security.authorization_checker'); $canReject = $authChecker->isGranted('REJECT_LOCATION', $location); $I->assertFalse($canReject, 'Regular user should not be able to reject locations'); @@ -171,7 +172,7 @@ public function superAdminCanRejectLocation(FunctionalTester $I): void $I->amLoggedInAs($superAdmin); - $authChecker = $I->getContainer()->get('security.authorization_checker'); + $authChecker = $I->grabService('security.authorization_checker'); $canReject = $authChecker->isGranted('REJECT_LOCATION', $location); $I->assertTrue($canReject, 'SUPER_ADMIN should be able to reject locations'); @@ -188,7 +189,7 @@ public function locationRejectionStoresReason(FunctionalTester $I): void $rejectionReason = 'Invalid address provided'; - $locationApprovalService = $I->getContainer()->get( + $locationApprovalService = $I->grabService( \App\Domain\Core\Service\LocationApprovalService::class ); $locationApprovalService->reject($location, $superAdmin, $rejectionReason); @@ -206,7 +207,7 @@ public function userCanDeleteTheirPendingLocation(FunctionalTester $I): void $I->amLoggedInAs($user); - $authChecker = $I->getContainer()->get('security.authorization_checker'); + $authChecker = $I->grabService('security.authorization_checker'); $canDelete = $authChecker->isGranted('DELETE_LOCATION', $location); $I->assertTrue($canDelete, 'User should be able to delete their PENDING location'); @@ -221,7 +222,7 @@ public function userCanDeleteTheirRejectedLocation(FunctionalTester $I): void $I->amLoggedInAs($user); - $authChecker = $I->getContainer()->get('security.authorization_checker'); + $authChecker = $I->grabService('security.authorization_checker'); $canDelete = $authChecker->isGranted('DELETE_LOCATION', $location); $I->assertTrue($canDelete, 'User should be able to delete their REJECTED location'); @@ -237,14 +238,14 @@ public function userCannotDeleteTheirApprovedLocation(FunctionalTester $I): void $location = $I->createPendingLocation($user); // Approve it - $locationApprovalService = $I->getContainer()->get( + $locationApprovalService = $I->grabService( \App\Domain\Core\Service\LocationApprovalService::class ); $locationApprovalService->approve($location, $superAdmin); $I->amLoggedInAs($user); - $authChecker = $I->getContainer()->get('security.authorization_checker'); + $authChecker = $I->grabService('security.authorization_checker'); $canDelete = $authChecker->isGranted('DELETE_LOCATION', $location); $I->assertFalse($canDelete, 'User should not be able to delete their APPROVED location'); @@ -261,7 +262,7 @@ public function superAdminCanDeleteAnyLocation(FunctionalTester $I): void $I->amLoggedInAs($superAdmin); - $authChecker = $I->getContainer()->get('security.authorization_checker'); + $authChecker = $I->grabService('security.authorization_checker'); $canDelete = $authChecker->isGranted('DELETE_LOCATION', $location); $I->assertTrue($canDelete, 'SUPER_ADMIN should be able to delete any location'); @@ -278,7 +279,7 @@ public function superAdminCanEditAnyLocation(FunctionalTester $I): void $I->amLoggedInAs($superAdmin); - $authChecker = $I->getContainer()->get('security.authorization_checker'); + $authChecker = $I->grabService('security.authorization_checker'); $canEdit = $authChecker->isGranted('EDIT_LOCATION', $location); $I->assertTrue($canEdit, 'SUPER_ADMIN should be able to edit any location'); @@ -370,7 +371,7 @@ public function pendingUserCannotCreateLocation(FunctionalTester $I): void $I->amLoggedInAs($pendingUser); - $authChecker = $I->getContainer()->get('security.authorization_checker'); + $authChecker = $I->grabService('security.authorization_checker'); $canCreate = $authChecker->isGranted('CREATE_LOCATION'); $I->assertFalse($canCreate, 'PENDING user should not be able to create locations'); @@ -384,7 +385,7 @@ public function approvedUserCanCreateLocation(FunctionalTester $I): void $I->amLoggedInAs($approvedUser); - $authChecker = $I->getContainer()->get('security.authorization_checker'); + $authChecker = $I->grabService('security.authorization_checker'); $canCreate = $authChecker->isGranted('CREATE_LOCATION'); $I->assertTrue($canCreate, 'APPROVED user should be able to create locations'); diff --git a/tests/Functional/Security/PublicAccessCest.php b/tests/Functional/Security/PublicAccessCest.php index e9759f5..e0faee0 100644 --- a/tests/Functional/Security/PublicAccessCest.php +++ b/tests/Functional/Security/PublicAccessCest.php @@ -4,6 +4,7 @@ namespace Tests\Functional\Security; +use Tests\Support\Factory\Account\UserFactory; use Tests\Support\FunctionalTester; /** @@ -20,6 +21,11 @@ */ class PublicAccessCest { + public function _before(FunctionalTester $I): void + { + $I->stopFollowingRedirects(); + } + public function approvedUserCanAccessPlayerRoutes(FunctionalTester $I): void { $I->wantTo('verify APPROVED users can access player routes'); @@ -27,6 +33,7 @@ public function approvedUserCanAccessPlayerRoutes(FunctionalTester $I): void $approvedUser = UserFactory::createApprovedUser(); $I->amLoggedInAs($approvedUser); + $I->startFollowingRedirects(); $I->amOnRoute('public_larp_my_larps'); $I->seeResponseCodeIsSuccessful(); } @@ -38,6 +45,7 @@ public function pendingUserCanAccessPlayerRoutes(FunctionalTester $I): void $pendingUser = UserFactory::createPendingUser(); $I->amLoggedInAs($pendingUser); + $I->startFollowingRedirects(); $I->amOnRoute('public_larp_my_larps'); $I->seeResponseCodeIsSuccessful(); } @@ -49,6 +57,7 @@ public function approvedUserCanAccessAccountRoutes(FunctionalTester $I): void $approvedUser = UserFactory::createApprovedUser(); $I->amLoggedInAs($approvedUser); + $I->startFollowingRedirects(); $I->amOnRoute('account_settings'); $I->seeResponseCodeIsSuccessful(); } @@ -60,6 +69,7 @@ public function pendingUserCanAccessAccountRoutes(FunctionalTester $I): void $pendingUser = UserFactory::createPendingUser(); $I->amLoggedInAs($pendingUser); + $I->startFollowingRedirects(); $I->amOnRoute('account_settings'); $I->seeResponseCodeIsSuccessful(); } @@ -73,15 +83,15 @@ public function multipleAccessControlLayersWorkTogether(FunctionalTester $I): vo // Try backoffice (should redirect due to PENDING status) $I->amOnRoute('backoffice_dashboard'); - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); // Try LARP creation (should redirect due to voter) $I->amOnRoute('backoffice_larp_create'); - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); // Try location creation (should redirect due to voter) $I->amOnPage($I->getUrl('backoffice_location_modify_global', ['location' => 'new'])); - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); } public function statusChangeAffectsAccessImmediately(FunctionalTester $I): void @@ -93,7 +103,7 @@ public function statusChangeAffectsAccessImmediately(FunctionalTester $I): void // Initially cannot access backoffice (redirected due to PENDING status) $I->amOnRoute('backoffice_dashboard'); - $I->seeResponseCodeIs(302); + $I->seeResponseCodeIsRedirection(); // Approve user $user->setStatus(\App\Domain\Account\Entity\Enum\UserStatus::APPROVED); @@ -103,7 +113,8 @@ public function statusChangeAffectsAccessImmediately(FunctionalTester $I): void $I->getEntityManager()->clear(); $I->amLoggedInAs($user); - // Now should be able to access backoffice + // Now should be able to access backoffice - need to follow redirects for this check + $I->startFollowingRedirects(); $I->amOnRoute('backoffice_dashboard'); $I->seeResponseCodeIsSuccessful(); } @@ -123,6 +134,8 @@ public function superAdminCanAccessAllRoutes(FunctionalTester $I): void ['route' => 'backoffice_location_modify_global', 'params' => ['location' => 'new']], ]; + $I->startFollowingRedirects(); + foreach ($routes as $routeData) { $route = $routeData['route']; $params = $routeData['params'] ?? []; @@ -157,6 +170,7 @@ public function roleHierarchyIsRespected(FunctionalTester $I): void ); // Verify role hierarchy works by checking access to admin routes + $I->startFollowingRedirects(); $I->amOnRoute('super_admin_users_list'); $I->seeResponseCodeIsSuccessful(); } @@ -168,9 +182,9 @@ public function publicRoutesAccessibleToAll(FunctionalTester $I): void // Public routes should be accessible without authentication $publicRoutes = [ 'public_larp_list', - 'public_larp_list', // Intentional duplicate from original test ]; + $I->startFollowingRedirects(); foreach ($publicRoutes as $route) { $I->amOnRoute($route); $I->seeResponseCodeIsSuccessful(); @@ -187,9 +201,9 @@ public function pendingUserCanAccessPublicRoutes(FunctionalTester $I): void // Public routes should be accessible even with PENDING status $publicRoutes = [ 'public_larp_list', - 'public_larp_list', // Intentional duplicate from original test ]; + $I->startFollowingRedirects(); foreach ($publicRoutes as $route) { $I->amOnRoute($route); $I->seeResponseCodeIsSuccessful(); diff --git a/tests/Functional/StoryMarketplace/RecruitmentControllerCest.php b/tests/Functional/StoryMarketplace/RecruitmentControllerCest.php index 5124dc6..09ea36d 100644 --- a/tests/Functional/StoryMarketplace/RecruitmentControllerCest.php +++ b/tests/Functional/StoryMarketplace/RecruitmentControllerCest.php @@ -8,6 +8,11 @@ class RecruitmentControllerCest { + public function _before(FunctionalTester $I): void + { + $I->stopFollowingRedirects(); + } + public function recruitmentRouteRequiresAuthentication(FunctionalTester $I): void { $I->wantTo('verify that recruitment route requires authentication'); @@ -15,10 +20,6 @@ public function recruitmentRouteRequiresAuthentication(FunctionalTester $I): voi $I->amOnPage('/backoffice/larp/00000000-0000-0000-0000-000000000000/story/thread/123/recruitment'); // Unauthenticated users should be redirected or receive a client error - $responseCode = $I->grabResponseCode(); - $I->assertTrue( - $responseCode >= 300 && $responseCode < 400 || $responseCode >= 400 && $responseCode < 500, - 'Response should be a redirect (3xx) or client error (4xx)' - ); + $I->seeResponseCodeIsRedirection(); } } diff --git a/tests/Functional/StoryObject/Service/StoryGraphFactionFilterTest.php b/tests/Functional/StoryObject/Service/StoryGraphFactionFilterTest.php index 7cc667b..39f47a9 100644 --- a/tests/Functional/StoryObject/Service/StoryGraphFactionFilterTest.php +++ b/tests/Functional/StoryObject/Service/StoryGraphFactionFilterTest.php @@ -6,8 +6,10 @@ use App\Domain\StoryObject\Entity\Faction; use App\Domain\StoryObject\Entity\Quest; use App\Domain\StoryObject\Entity\Thread; +use App\Domain\StoryObject\Repository\RelationRepository; use App\Domain\StoryObject\Service\GraphEdgeBuilder; use App\Domain\StoryObject\Service\GraphNodeBuilder; +use App\Domain\StoryObject\Service\ImplicitRelationBuilder; use App\Domain\StoryObject\Service\StoryObjectRelationExplorer; use PHPUnit\Framework\TestCase; @@ -34,8 +36,8 @@ public function testFactionFilterIncludesConnectedNodes(): void // Use actual instances since classes are readonly and can't be mocked $nodeBuilder = new GraphNodeBuilder(); // GraphEdgeBuilder needs dependencies - ImplicitRelationBuilder is readonly, create real instance - $relationRepository = $this->createMock(\App\Domain\StoryObject\Repository\RelationRepository::class); - $implicitRelationBuilder = new \App\Domain\StoryObject\Service\ImplicitRelationBuilder(); + $relationRepository = $this->createMock(RelationRepository::class); + $implicitRelationBuilder = new ImplicitRelationBuilder(); $edgeBuilder = new GraphEdgeBuilder($relationRepository, $implicitRelationBuilder); $explorer = new StoryObjectRelationExplorer($nodeBuilder, $edgeBuilder); diff --git a/tests/Integration/Repository/LarpRepositoryCest.php b/tests/Integration/Repository/LarpRepositoryCest.php index c64094a..7a85480 100644 --- a/tests/Integration/Repository/LarpRepositoryCest.php +++ b/tests/Integration/Repository/LarpRepositoryCest.php @@ -4,9 +4,12 @@ namespace Tests\Integration\Repository; +use App\Domain\Account\Entity\User; use App\Domain\Core\Entity\Enum\LarpStageStatus; use App\Domain\Core\Entity\Enum\ParticipantRole; use App\Domain\Core\Repository\LarpRepository; +use Tests\Support\Factory\Account\UserFactory; +use Tests\Support\Factory\Core\LarpFactory; use Tests\Support\FunctionalTester; /** @@ -31,7 +34,7 @@ public function findAllReturnsAllLarps(FunctionalTester $I): void $organizer2 = $I->createApprovedUser('organizer2@example.com'); $larp1 = LarpFactory::createDraftLarp($organizer1, 'LARP 1'); - $larp2 = $I->createPublishedLarp($organizer2, 'LARP 2'); + $larp2 = LarpFactory::createPublishedLarp($organizer2, 'LARP 2'); $allLarps = $this->larpRepository->findAll(); @@ -68,7 +71,7 @@ public function findPubliclyVisibleLarps(FunctionalTester $I): void $draftLarp = LarpFactory::createDraftLarp($organizer, 'Draft'); $wipLarp = $I->createWipLarp($organizer, 'WIP'); - $publishedLarp = $I->createPublishedLarp($organizer, 'Published'); + $publishedLarp = LarpFactory::createPublishedLarp($organizer, 'Published'); $inquiriesLarp = $I->createLarp($organizer, LarpStageStatus::INQUIRIES, 'Inquiries'); // Query for public LARPs @@ -124,7 +127,7 @@ public function findLarpsByStatus(FunctionalTester $I): void LarpFactory::createDraftLarp($organizer, 'Draft 1'); LarpFactory::createDraftLarp($organizer, 'Draft 2'); - $I->createPublishedLarp($organizer, 'Published 1'); + LarpFactory::createPublishedLarp($organizer, 'Published 1'); $draftLarps = $this->larpRepository->createQueryBuilder('l') ->where('l.status = :status') @@ -218,7 +221,7 @@ public function findFutureLarps(FunctionalTester $I): void $organizer = UserFactory::createApprovedUser(); // Create future LARP - $futureLarp = $I->createPublishedLarp($organizer, 'Future LARP'); + $futureLarp = LarpFactory::createPublishedLarp($organizer, 'Future LARP'); $futureLarps = $this->larpRepository->createQueryBuilder('l') ->where('l.startDate > :now') @@ -241,7 +244,7 @@ public function findLarpsByDateRange(FunctionalTester $I): void $organizer = UserFactory::createApprovedUser(); - $larp = $I->createPublishedLarp($organizer, 'LARP in Range'); + $larp = LarpFactory::createPublishedLarp($organizer, 'LARP in Range'); $startDate = new \DateTime('-1 month'); $endDate = new \DateTime('+2 months'); @@ -297,7 +300,7 @@ public function userOrganizerLarpCountMethod(FunctionalTester $I): void // Clear and reload user to get fresh count $I->getEntityManager()->clear(); $reloadedUser = $I->getEntityManager()->find( - \App\Domain\Account\Entity\User::class, + User::class, $organizer->getId() ); diff --git a/tests/Integration/Service/LarpWorkflowCest.php b/tests/Integration/Service/LarpWorkflowCest.php index 703c970..7187ed4 100644 --- a/tests/Integration/Service/LarpWorkflowCest.php +++ b/tests/Integration/Service/LarpWorkflowCest.php @@ -5,6 +5,8 @@ namespace Tests\Integration\Service; use App\Domain\Core\Entity\Enum\LarpStageStatus; +use Tests\Support\Factory\Account\UserFactory; +use Tests\Support\Factory\Core\LarpFactory; use Tests\Support\FunctionalTester; use Symfony\Component\Workflow\WorkflowInterface; @@ -170,7 +172,7 @@ public function workflowCanTransitionFromPublishedToInquiries(FunctionalTester $ $I->wantTo('verify that workflow allows transition from PUBLISHED to INQUIRIES'); $organizer = UserFactory::createApprovedUser(); - $larp = $I->createPublishedLarp($organizer); + $larp = LarpFactory::createPublishedLarp($organizer); $canTransition = $this->larpWorkflow->can($larp, 'to_inquiries'); @@ -230,7 +232,7 @@ public function workflowCanTransitionBackFromPublishedToDraft(FunctionalTester $ $I->wantTo('verify that workflow allows transition back from PUBLISHED to DRAFT'); $organizer = UserFactory::createApprovedUser(); - $larp = $I->createPublishedLarp($organizer); + $larp = LarpFactory::createPublishedLarp($organizer); $canTransition = $this->larpWorkflow->can($larp, 'back_to_draft'); @@ -245,7 +247,7 @@ public function workflowCanTransitionBackFromPublishedToWip(FunctionalTester $I) $I->wantTo('verify that workflow allows transition back from PUBLISHED to WIP'); $organizer = UserFactory::createApprovedUser(); - $larp = $I->createPublishedLarp($organizer); + $larp = LarpFactory::createPublishedLarp($organizer); $canTransition = $this->larpWorkflow->can($larp, 'back_to_wip'); diff --git a/tests/Integration/Service/LocationApprovalServiceCest.php b/tests/Integration/Service/LocationApprovalServiceCest.php index fefee24..92c7235 100644 --- a/tests/Integration/Service/LocationApprovalServiceCest.php +++ b/tests/Integration/Service/LocationApprovalServiceCest.php @@ -6,6 +6,7 @@ use App\Domain\Core\Entity\Enum\LocationApprovalStatus; use App\Domain\Core\Service\LocationApprovalService; +use Tests\Support\Factory\Account\UserFactory; use Tests\Support\FunctionalTester; /** diff --git a/tests/Integration/StoryMarketplace/StoryRecruitmentRepositoryCest.php b/tests/Integration/StoryMarketplace/StoryRecruitmentRepositoryCest.php index 7c984e7..a29d3c1 100644 --- a/tests/Integration/StoryMarketplace/StoryRecruitmentRepositoryCest.php +++ b/tests/Integration/StoryMarketplace/StoryRecruitmentRepositoryCest.php @@ -5,6 +5,7 @@ namespace Tests\Integration\StoryMarketplace; use App\Domain\StoryMarketplace\Entity\StoryRecruitment; +use App\Domain\StoryMarketplace\Repository\StoryRecruitmentRepository; use Codeception\Test\Unit; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; @@ -22,7 +23,7 @@ public function saveMethodPersistsAndFlushes(FunctionalTester $I): void $em->expects($this->once())->method('flush'); $registry = $this->createMock(ManagerRegistry::class); - $repository = new class($registry, $em) extends \App\Domain\StoryMarketplace\Repository\StoryRecruitmentRepository { + $repository = new class($registry, $em) extends StoryRecruitmentRepository { public function __construct(ManagerRegistry $registry, private readonly EntityManagerInterface $em) { parent::__construct($registry); diff --git a/tests/Support/Factory/Account/PlanFactory.php b/tests/Support/Factory/Account/PlanFactory.php index 8e6fd4c..47deecf 100644 --- a/tests/Support/Factory/Account/PlanFactory.php +++ b/tests/Support/Factory/Account/PlanFactory.php @@ -17,8 +17,9 @@ public static function class(): string protected function defaults(): array { + $suffix = uniqid('', true); return [ - 'name' => self::faker()->words(2, true), + 'name' => 'Plan_' . $suffix, 'description' => self::faker()->sentence(), 'maxLarps' => 3, 'maxParticipantsPerLarp' => 50, @@ -49,7 +50,7 @@ protected function initialize(): static public function free(): self { return $this->with([ - 'name' => 'Free', + 'name' => 'Free_' . uniqid('', true), 'maxLarps' => 1, 'maxParticipantsPerLarp' => 20, 'storageLimitMb' => 100, @@ -67,7 +68,7 @@ public function free(): self public function unlimited(): self { return $this->with([ - 'name' => 'Unlimited', + 'name' => 'Unlimited_' . uniqid('', true), 'maxLarps' => null, 'maxParticipantsPerLarp' => null, 'storageLimitMb' => null, @@ -85,7 +86,7 @@ public function unlimited(): self public function premium(): self { return $this->with([ - 'name' => 'Premium', + 'name' => 'Premium_' . uniqid('', true), 'maxLarps' => 10, 'maxParticipantsPerLarp' => 200, 'storageLimitMb' => 5000, @@ -103,7 +104,7 @@ public function premium(): self public function basic(): self { return $this->with([ - 'name' => 'Basic', + 'name' => 'Basic_' . uniqid('', true), 'maxLarps' => 3, 'maxParticipantsPerLarp' => 50, 'storageLimitMb' => 1000, diff --git a/tests/Support/Factory/Account/UserFactory.php b/tests/Support/Factory/Account/UserFactory.php index 47f5ade..535e5ff 100644 --- a/tests/Support/Factory/Account/UserFactory.php +++ b/tests/Support/Factory/Account/UserFactory.php @@ -22,9 +22,10 @@ public static function class(): string */ protected function defaults(): array { + $suffix = uniqid('', true); return [ - 'username' => self::faker()->unique()->userName(), - 'contactEmail' => self::faker()->unique()->safeEmail(), + 'username' => self::faker()->userName() . '_' . $suffix, + 'contactEmail' => 'user_' . $suffix . '@test.local', 'preferredLocale' => Locale::EN, 'status' => UserStatus::APPROVED, 'roles' => ['ROLE_USER'], diff --git a/tests/Support/Factory/Core/LarpFactory.php b/tests/Support/Factory/Core/LarpFactory.php index 12d4e87..3fe3828 100644 --- a/tests/Support/Factory/Core/LarpFactory.php +++ b/tests/Support/Factory/Core/LarpFactory.php @@ -166,6 +166,29 @@ public static function createDraftLarp(User|Proxy $user, ?string $title = null): return $larp; } + public static function createPublishedLarp(User|Proxy $user, ?string $title = null): Larp|Proxy + { + $larpFactory = LarpFactory::new() + ->published() + ->withCreator($user); + + if ($title !== null) { + $larpFactory = $larpFactory->withTitle($title); + } + + // Create the LARP first so it has an ID + $larp = $larpFactory->create(); + + // Now create the participant with the persisted LARP + LarpParticipantFactory::new() + ->forUser($user) + ->organizer() + ->forLarp($larp) + ->create(); + + return $larp; + } + public function withTitle(string $title): self { return $this->with([ diff --git a/tests/Support/Helper/Authentication.php b/tests/Support/Helper/Authentication.php index fa09464..ed7f5b8 100644 --- a/tests/Support/Helper/Authentication.php +++ b/tests/Support/Helper/Authentication.php @@ -4,8 +4,11 @@ namespace Tests\Support\Helper; +use App\Domain\Account\Entity\Plan; use App\Domain\Account\Entity\User; use App\Domain\Core\Entity\Enum\LarpStageStatus; +use App\Domain\Core\Entity\Enum\LocationApprovalStatus; +use App\Domain\Core\Entity\Enum\ParticipantRole; use App\Domain\Core\Entity\Larp; use App\Domain\Core\Entity\LarpParticipant; use App\Domain\Core\Entity\Location; @@ -15,6 +18,7 @@ use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\BrowserKit\Cookie; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Tests\Support\Factory\Account\PlanFactory; use Tests\Support\Factory\Account\UserFactory; use Tests\Support\Factory\Core\LarpFactory; use Tests\Support\Factory\Core\LarpParticipantFactory; @@ -43,6 +47,27 @@ public function _beforeSuite($settings = []): void $this->symfony = $module; } + /** + * Clean up after each test to prevent connection pool exhaustion + */ + public function _after(\Codeception\TestInterface $test): void + { + // Close the entity manager's connection to free up the database connection + try { + if ($this->symfony && $this->symfony->client) { + $container = $this->symfony->client->getContainer(); + if ($container && $container->has('doctrine.orm.entity_manager')) { + $em = $container->get('doctrine.orm.entity_manager'); + if ($em && $em->getConnection()->isConnected()) { + $em->getConnection()->close(); + } + } + } + } catch (\Exception $e) { + // Silently ignore cleanup errors + } + } + /** * Log in as a specific user by creating a session token * This is the core authentication functionality for Codeception @@ -80,4 +105,311 @@ public function createSuperAdmin(): User { return UserFactory::new()->approved()->superAdmin()->create()->_real(); } + + /** + * Create a pending location (awaiting approval) + */ + public function createPendingLocation(User $creator, string $name = 'Pending Location'): Location + { + return LocationFactory::new() + ->pending() + ->createdBy($creator) + ->with(['title' => $name]) + ->create() + ->_real(); + } + + /** + * Create an approved location + */ + public function createApprovedLocation(User $creator, string $name = 'Approved Location'): Location + { + return LocationFactory::new() + ->approved() + ->createdBy($creator) + ->approvedBy($creator) + ->with(['title' => $name]) + ->create() + ->_real(); + } + + /** + * Create a rejected location + */ + public function createRejectedLocation( + User $creator, + string $reason = 'Does not meet requirements', + string $name = 'Rejected Location' + ): Location { + return LocationFactory::new() + ->rejected($reason) + ->createdBy($creator) + ->with(['title' => $name]) + ->create() + ->_real(); + } + + // ======================================================================== + // User Factory Methods + // ======================================================================== + + /** + * Create a PENDING user + */ + public function createPendingUser(): User + { + return UserFactory::createPendingUser(); + } + + /** + * Create an APPROVED user with optional plan + */ + public function createApprovedUser(?string $name = null, ?Plan $plan = null): User + { + $factory = UserFactory::new()->approved(); + + if ($name !== null) { + $factory = $factory->with(['username' => $name]); + } + + if ($plan !== null) { + $factory = $factory->with(['plan' => $plan]); + } + + return $factory->create()->_real(); + } + + /** + * Create a SUSPENDED user + */ + public function createSuspendedUser(): User + { + return UserFactory::createSuspendedUser(); + } + + /** + * Create a BANNED user + */ + public function createBannedUser(): User + { + return UserFactory::createBannedUser(); + } + + // ======================================================================== + // Plan Factory Methods + // ======================================================================== + + /** + * Create a free plan (maxLarps = 1) + */ + public function createFreePlan(): Plan + { + return PlanFactory::new()->free()->create()->_real(); + } + + /** + * Create a premium plan with specified maxLarps + */ + public function createPremiumPlan(int $maxLarps = 10): Plan + { + return PlanFactory::new() + ->premium() + ->with(['maxLarps' => $maxLarps]) + ->create() + ->_real(); + } + + /** + * Create an unlimited plan (no LARP limit) + */ + public function createUnlimitedPlan(): Plan + { + return PlanFactory::new()->unlimited()->create()->_real(); + } + + // ======================================================================== + // LARP Factory Methods + // ======================================================================== + + /** + * Create a LARP with specified status (default: DRAFT) + */ + public function createLarp(User $organizer, ?LarpStageStatus $status = null): Larp + { + $status = $status ?? LarpStageStatus::DRAFT; + + $larpFactory = LarpFactory::new() + ->withStatus($status) + ->withCreator($organizer); + + $larp = $larpFactory->create(); + + // Add organizer as participant + LarpParticipantFactory::new() + ->forUser($organizer) + ->organizer() + ->forLarp($larp) + ->create(); + + return $larp->_real(); + } + + /** + * Create a DRAFT LARP + */ + public function createDraftLarp(User $organizer, ?string $title = null): Larp + { + return LarpFactory::createDraftLarp($organizer, $title)->_real(); + } + + /** + * Create a PUBLISHED LARP + */ + public function createPublishedLarp(User $organizer, ?string $title = null): Larp + { + return LarpFactory::createPublishedLarp($organizer, $title)->_real(); + } + + /** + * Create a WIP (Work In Progress) LARP + */ + public function createWipLarp(User $organizer): Larp + { + $larpFactory = LarpFactory::new() + ->wip() + ->withCreator($organizer); + + $larp = $larpFactory->create(); + + LarpParticipantFactory::new() + ->forUser($organizer) + ->organizer() + ->forLarp($larp) + ->create(); + + return $larp->_real(); + } + + // ======================================================================== + // Location Factory Methods + // ======================================================================== + + /** + * Create a Location with specified status (default: PENDING) + */ + public function createLocation( + User $creator, + ?LocationApprovalStatus $status = null, + ?string $name = null + ): Location { + $status = $status ?? LocationApprovalStatus::PENDING; + + $factory = LocationFactory::new() + ->createdBy($creator); + + if ($name !== null) { + $factory = $factory->with(['title' => $name]); + } + + $factory = match ($status) { + LocationApprovalStatus::PENDING => $factory->pending(), + LocationApprovalStatus::APPROVED => $factory->approved()->approvedBy($creator), + LocationApprovalStatus::REJECTED => $factory->rejected(), + }; + + return $factory->create()->_real(); + } + + // ======================================================================== + // Participant Factory Methods + // ======================================================================== + + /** + * Add a participant to a LARP with specified roles (default: PLAYER) + * + * @param ParticipantRole[] $roles + */ + public function addParticipantToLarp( + Larp $larp, + User $user, + array $roles = [] + ): LarpParticipant { + if (empty($roles)) { + $roles = [ParticipantRole::PLAYER]; + } + + return LarpParticipantFactory::new() + ->forLarp($larp) + ->forUser($user) + ->withRoles($roles) + ->create() + ->_real(); + } + + // ======================================================================== + // HTTP Request Methods + // ======================================================================== + + /** + * Send a POST request to a URL + * + * @param string $url The URL to send the POST request to + * @param array $params The POST parameters + */ + public function sendPOST(string $url, array $params = []): void + { + $this->symfony->client->request('POST', $url, $params); + } + + /** + * Send a GET request to a URL + * + * @param string $url The URL to send the GET request to + * @param array $params The query parameters + */ + public function sendGET(string $url, array $params = []): void + { + $this->symfony->client->request('GET', $url, $params); + } + + /** + * Grab the Response object from the last request + * + * @return \Symfony\Component\HttpFoundation\Response + */ + public function grabResponse(): \Symfony\Component\HttpFoundation\Response + { + return $this->symfony->client->getResponse(); + } + + /** + * Check if response code is between min and max (inclusive) + * + * @param int $min Minimum expected status code + * @param int $max Maximum expected status code + */ + public function seeResponseCodeIsBetween(int $min, int $max): void + { + $statusCode = $this->symfony->client->getResponse()->getStatusCode(); + \PHPUnit\Framework\Assert::assertGreaterThanOrEqual( + $min, + $statusCode, + "Expected status code to be at least {$min}, got {$statusCode}" + ); + \PHPUnit\Framework\Assert::assertLessThanOrEqual( + $max, + $statusCode, + "Expected status code to be at most {$max}, got {$statusCode}" + ); + } + + /** + * Grab the HTTP response code from the last request + * + * @return int The HTTP status code + */ + public function grabHttpResponseCode(): int + { + return $this->symfony->client->getResponse()->getStatusCode(); + } } diff --git a/tests/Traits/AuthenticationTestTrait.php b/tests/Traits/AuthenticationTestTrait.php deleted file mode 100644 index 757472a..0000000 --- a/tests/Traits/AuthenticationTestTrait.php +++ /dev/null @@ -1,379 +0,0 @@ -get(EntityManagerInterface::class); - } - - /** - * Generate URL from route name and parameters - */ - private function generateUrl(string $route, array $parameters = []): string - { - $router = static::getContainer()->get(UrlGeneratorInterface::class); - return $router->generate($route, $parameters); - } - - /** - * Create a test user with specified status - */ - private function createUser( - string $username, - UserStatus $status = UserStatus::PENDING, - array $roles = [], - ?Plan $plan = null - ): User { - $user = new User(); - $user->setUsername($username); - $user->setContactEmail($username . '@example.com'); - $user->setRoles($roles); - $user->setStatus($status); - - if ($plan !== null) { - $user->setPlan($plan); - } - - $em = $this->getEntityManager(); - $em->persist($user); - $em->flush(); - - return $user; - } - - /** - * Create a PENDING user (default new user state) - */ - private function createPendingUser(?string $username = null): User - { - $username = $username ?? 'pending_user_' . uniqid(); - return $this->createUser($username, UserStatus::PENDING); - } - - /** - * Create an APPROVED user - */ - private function createApprovedUser(?string $username = null, ?Plan $plan = null): User - { - $username = $username ?? 'approved_user_' . uniqid(); - return $this->createUser($username, UserStatus::APPROVED, [], $plan); - } - - /** - * Create a SUSPENDED user - */ - private function createSuspendedUser(?string $username = null): User - { - $username = $username ?? 'suspended_user_' . uniqid(); - return $this->createUser($username, UserStatus::SUSPENDED); - } - - /** - * Create a BANNED user - */ - private function createBannedUser(?string $username = null): User - { - $username = $username ?? 'banned_user_' . uniqid(); - return $this->createUser($username, UserStatus::BANNED); - } - - /** - * Create a SUPER_ADMIN user - */ - private function createSuperAdmin(?string $username = null): User - { - $username = $username ?? 'super_admin_' . uniqid(); - return $this->createUser($username, UserStatus::APPROVED, ['ROLE_SUPER_ADMIN']); - } - - /** - * Create a Plan with specified limits - */ - private function createPlan( - string $name, - ?int $maxLarps = null, - bool $isActive = true - ): Plan { - $plan = new Plan(); - $plan->setName($name); - $plan->setMaxLarps($maxLarps); - $plan->setIsActive($isActive); - $plan->setDescription("Test plan: {$name}"); - - $em = $this->getEntityManager(); - $em->persist($plan); - $em->flush(); - - return $plan; - } - - /** - * Create a free tier plan (1 LARP limit) - */ - private function createFreePlan(): Plan - { - return $this->createPlan('Free Tier ' . uniqid(), 1); - } - - /** - * Create an unlimited plan - */ - private function createUnlimitedPlan(): Plan - { - return $this->createPlan('Unlimited ' . uniqid(), null); - } - - /** - * Create a premium plan with custom limit - */ - private function createPremiumPlan(int $maxLarps = 5): Plan - { - return $this->createPlan("Premium ({$maxLarps} LARPs) " . uniqid(), $maxLarps); - } - - /** - * Create a LARP with specified status and organizer - */ - private function createLarp( - User $organizer, - LarpStageStatus $status = LarpStageStatus::DRAFT, - string $title = 'Test LARP' - ): Larp { - $larp = new Larp(); - $larp->setTitle($title); - $larp->setDescription('A test LARP for automated testing'); - $larp->setStatus($status); - $larp->setMarking($status->value); - $larp->setCreatedBy($organizer); // Set the creator (required field) - - $startDate = new \DateTime('+1 month'); - $endDate = (clone $startDate)->modify('+3 days'); - $larp->setStartDate($startDate); - $larp->setEndDate($endDate); - - $em = $this->getEntityManager(); - $em->persist($larp); - - // Add organizer as participant - $this->addParticipantToLarp($larp, $organizer, [ParticipantRole::ORGANIZER]); - - $em->flush(); - - // Ensure slug is generated (Gedmo might not trigger in tests) - if ($larp->getSlug() === null) { - // Generate slug manually if Gedmo didn't generate it - $slug = strtolower(trim(preg_replace('/[^A-Za-z0-9-]+/', '-', $title), '-')); - $larp->setSlug($slug); - $em->flush(); - } - - // Refresh to get the latest state from database - $em->refresh($larp); - - return $larp; - } - - /** - * Create a DRAFT LARP - */ - private function createDraftLarp(User $organizer, string $title = 'Draft LARP'): Larp - { - return $this->createLarp($organizer, LarpStageStatus::DRAFT, $title); - } - - /** - * Create a PUBLISHED LARP (publicly visible) - */ - private function createPublishedLarp(User $organizer, string $title = 'Published LARP'): Larp - { - return $this->createLarp($organizer, LarpStageStatus::PUBLISHED, $title); - } - - /** - * Create a WIP LARP - */ - private function createWipLarp(User $organizer, string $title = 'WIP LARP'): Larp - { - return $this->createLarp($organizer, LarpStageStatus::WIP, $title); - } - - /** - * Add a participant to a LARP with specific roles - */ - private function addParticipantToLarp( - Larp $larp, - User $user, - array $roles = [ParticipantRole::PLAYER] - ): LarpParticipant { - $participant = new LarpParticipant(); - $participant->setUser($user); - $participant->setRoles(array_map(fn ($role) => $role->value, $roles)); - - // Use addParticipant() to properly manage both sides of the relationship - $larp->addParticipant($participant); - - $em = $this->getEntityManager(); - $em->persist($participant); - $em->flush(); - - return $participant; - } - - /** - * Create a Location with specified approval status - */ - private function createLocation( - User $creator, - LocationApprovalStatus $approvalStatus = LocationApprovalStatus::PENDING, - string $name = 'Test Location' - ): Location { - $location = new Location(); - $location->setTitle($name); - $location->setAddress('123 Test Street'); - $location->setCity('Test City'); - $location->setCountry('Test Country'); - $location->setPostalCode('12345'); - $location->setLatitude('52.2297'); - $location->setLongitude('21.0122'); - $location->setCreatedBy($creator); - $location->setApprovalStatus($approvalStatus); - - $em = $this->getEntityManager(); - $em->persist($location); - $em->flush(); - - return $location; - } - - /** - * Create a PENDING Location - */ - private function createPendingLocation(User $creator, string $name = 'Pending Location'): Location - { - return $this->createLocation($creator, LocationApprovalStatus::PENDING, $name); - } - - /** - * Create an APPROVED Location - */ - private function createApprovedLocation(User $creator, string $name = 'Approved Location'): Location - { - $location = $this->createLocation($creator, LocationApprovalStatus::APPROVED, $name); - $location->setApprovedBy($creator); - $location->setApprovedAt(new \DateTime()); - - $em = $this->getEntityManager(); - $em->flush(); - - return $location; - } - - /** - * Create a REJECTED Location - */ - private function createRejectedLocation( - User $creator, - string $name = 'Rejected Location', - string $reason = 'Test rejection reason' - ): Location { - $location = $this->createLocation($creator, LocationApprovalStatus::REJECTED, $name); - $location->setRejectionReason($reason); - - $em = $this->getEntityManager(); - $em->flush(); - - return $location; - } - - /** - * Approve a user programmatically - */ - private function approveUser(User $user): void - { - $user->setStatus(UserStatus::APPROVED); - - $em = $this->getEntityManager(); - $em->flush(); - } - - /** - * Suspend a user programmatically - */ - private function suspendUser(User $user): void - { - $user->setStatus(UserStatus::SUSPENDED); - - $em = $this->getEntityManager(); - $em->flush(); - } - - /** - * Ban a user programmatically - */ - private function banUser(User $user): void - { - $user->setStatus(UserStatus::BANNED); - - $em = $this->getEntityManager(); - $em->flush(); - } - - /** - * Clear all test data - */ - private function clearTestData(): void - { - $em = $this->getEntityManager(); - - try { - // Disable foreign key checks temporarily for cleanup - $connection = $em->getConnection(); - - // Use TRUNCATE with CASCADE to handle all foreign keys - $connection->executeStatement('SET CONSTRAINTS ALL DEFERRED'); - - // Clear in correct order to respect foreign key constraints - // First delete child tables that reference others - $em->createQuery('DELETE FROM App\Domain\Core\Entity\LarpParticipant')->execute(); - - // Delete Story Objects (they reference LARPs) - $connection->executeStatement('TRUNCATE TABLE story_object RESTART IDENTITY CASCADE'); - - // Delete Locations - $em->createQuery('DELETE FROM App\Domain\Core\Entity\Location')->execute(); - - // Delete LARPs - $em->createQuery('DELETE FROM App\Domain\Core\Entity\Larp')->execute(); - - // Delete Users - $em->createQuery('DELETE FROM App\Domain\Account\Entity\User')->execute(); - - // Delete Plans - $em->createQuery('DELETE FROM App\Domain\Core\Entity\Plan')->execute(); - - $connection->executeStatement('SET CONSTRAINTS ALL IMMEDIATE'); - - $em->clear(); - } catch (\Exception $e) { - // If cleanup fails, just clear the entity manager - $em->clear(); - } - } -} diff --git a/tests/Unit/Application/Service/SubmissionStatsServiceTest.php b/tests/Unit/Application/Service/SubmissionStatsServiceTest.php index 7ca7f7b..8fd2ecc 100644 --- a/tests/Unit/Application/Service/SubmissionStatsServiceTest.php +++ b/tests/Unit/Application/Service/SubmissionStatsServiceTest.php @@ -39,13 +39,25 @@ public function testGetStatsForLarp(): void $factionsArray = $larp->getFactions()->toArray(); + // Track preload calls since withConsecutive() was removed in PHPUnit 10 + $preloadCalls = []; $preloader = $this->createMock(EntityPreloader::class); $preloader->expects($this->exactly(2)) ->method('preload') - ->withConsecutive( - [$this->identicalTo([$application]), 'choices'], - [$this->identicalTo($factionsArray), 'members'], - ); + ->willReturnCallback(function ($entities, $relation) use (&$preloadCalls, $application, $factionsArray) { + $preloadCalls[] = ['entities' => $entities, 'relation' => $relation]; + + // Verify the calls match expected arguments + if (count($preloadCalls) === 1) { + $this->assertSame([$application], $entities); + $this->assertSame('choices', $relation); + } elseif (count($preloadCalls) === 2) { + $this->assertSame($factionsArray, $entities); + $this->assertSame('members', $relation); + } + + return $entities; + }); $service = new SubmissionStatsService($repo, $preloader); $stats = $service->getStatsForLarp($larp); From 59a23853ab7d193fc1bc8d9e55a9815d3c6cc47a Mon Sep 17 00:00:00 2001 From: TomaszB Date: Wed, 17 Dec 2025 18:42:57 +0100 Subject: [PATCH 03/16] Remove `bg-light` class from various templates and refactor SCSS to use CSS variables for color consistency and theming. --- assets/styles/app.scss | 4 - .../components/_character_choice_styles.scss | 20 +-- assets/styles/components/_colors.scss | 51 +++++-- assets/styles/components/_dark_mode.scss | 50 ++++++- assets/styles/components/_folder_browser.scss | 9 +- assets/styles/components/_kanban.scss | 137 +++++++++--------- assets/styles/components/_sidebar_menu.scss | 59 ++++---- assets/styles/components/_wysiwyg.scss | 23 +-- assets/styles/components/feedback.scss | 75 +++++----- assets/styles/components/timeline.scss | 53 +++---- .../larp/application/list.html.twig | 2 +- .../larp/application/view.html.twig | 2 +- .../backoffice/larp/mailing/edit.html.twig | 2 +- .../backoffice/larp/mailing/list.html.twig | 2 +- .../larp/partials/_sharedFileList.html.twig | 4 +- .../story/comment/_comment_thread.html.twig | 2 +- .../larp/story/comment/discussions.html.twig | 2 +- templates/backoffice/survey/edit.html.twig | 2 +- .../components/ResourceBookingForm.html.twig | 2 +- .../StoryObjectReferenceForm.html.twig | 2 +- .../StoryObjectRelationForm.html.twig | 2 +- templates/domain/gallery/view.html.twig | 6 +- templates/public/larp/_info_header.html.twig | 2 +- .../public/larp/application_form.html.twig | 2 +- .../public/larp/confirm_character.html.twig | 2 +- .../public/larp/decline_character.html.twig | 2 +- templates/public/larp/details.html.twig | 2 +- templates/public/larp/list.html.twig | 2 +- templates/public/location/details.html.twig | 2 +- templates/super_admin/users/list.html.twig | 2 +- 30 files changed, 296 insertions(+), 231 deletions(-) diff --git a/assets/styles/app.scss b/assets/styles/app.scss index 177ae21..41227f8 100755 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -78,10 +78,6 @@ a.nav-link.active { transition: transform 0.3s ease; } -.badge { - color: #000 !important; -} - // Mobile-first responsiveness @media (max-width: 767.98px) { table { diff --git a/assets/styles/components/_character_choice_styles.scss b/assets/styles/components/_character_choice_styles.scss index 69901f6..96625b2 100755 --- a/assets/styles/components/_character_choice_styles.scss +++ b/assets/styles/components/_character_choice_styles.scss @@ -4,18 +4,18 @@ border: 2px solid transparent; } .character-choice-item:hover { - background-color: #f8f9fa; - border-color: #dee2e6; + background-color: var(--bg-secondary); + border-color: var(--border-color); } .character-choice-item.sortable-chosen { - background-color: #fff; + background-color: var(--card-bg); box-shadow: 0 8px 25px rgba(0,0,0,0.3); - border-color: #0d6efd; + border-color: var(--bo-primary); transform: rotate(3deg); } .character-choice-item.sortable-ghost { - background-color: #e7f3ff; - border: 2px dashed #0d6efd; + background-color: var(--bo-primary-light); + border: 2px dashed var(--bo-primary); opacity: 0.5; } .priority-badge { @@ -29,18 +29,18 @@ } .drag-handle { cursor: move; - color: #6c757d; + color: var(--text-secondary); font-size: 1.2em; padding: 5px; border-radius: 4px; transition: all 0.2s ease; } .drag-handle:hover { - color: #495057; - background-color: #f8f9fa; + color: var(--text-primary); + background-color: var(--bg-secondary); } .choice-content { - border-left: 3px solid #e9ecef; + border-left: 3px solid var(--border-color-light); padding-left: 1rem; } .choice-header { diff --git a/assets/styles/components/_colors.scss b/assets/styles/components/_colors.scss index 168c507..710642c 100644 --- a/assets/styles/components/_colors.scss +++ b/assets/styles/components/_colors.scss @@ -101,8 +101,8 @@ --bg-secondary: #f8f9fa; --bg-tertiary: #f0f0f0; --bg-card: #ffffff; - --bg-sidebar: linear-gradient(180deg, #2d2d2d 0%, #f8f9fa 100%); - --bg-sidebar-header: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + --bg-sidebar: #ffffff; + --bg-sidebar-header: #0d6efd; // Text colors --text-primary: #212529; @@ -132,24 +132,47 @@ --shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); --shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175); - // Sidebar specific + // Sidebar specific (using primary blue) --sidebar-text: #2d3748; - --sidebar-link-hover-bg: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%); - --sidebar-link-active-bg: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%); + --sidebar-link-hover-bg: rgba(13, 110, 253, 0.08); + --sidebar-link-active-bg: rgba(13, 110, 253, 0.15); --sidebar-scrollbar-track: rgba(0, 0, 0, 0.05); - --sidebar-scrollbar-thumb: rgba(102, 126, 234, 0.3); - --sidebar-scrollbar-thumb-hover: rgba(102, 126, 234, 0.5); + --sidebar-scrollbar-thumb: rgba(13, 110, 253, 0.3); + --sidebar-scrollbar-thumb-hover: rgba(13, 110, 253, 0.5); } // Dark mode variables html.dark-mode { + // Header Colors (dark mode) + --bo-header-bg: #2d2d2d; + --bo-header-border: #404040; + --bo-header-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + + // Navigation Colors (dark mode) + --bo-nav-text: #b8b8b8; + --bo-nav-text-hover: #6ea8fe; + --bo-nav-text-active: #ffffff; + --bo-nav-bg-hover: rgba(13, 110, 253, 0.15); + --bo-nav-bg-active: rgba(13, 110, 253, 0.25); + --bo-nav-border-active: #0d6efd; + + // LARP Title Colors (dark mode) + --bo-title-color: #e8e8e8; + --bo-title-icon-color: #6ea8fe; + + // Gray scale (dark mode) + --bo-gray-50: #3a3a3a; + --bo-gray-100: #4a4a4a; + --bo-gray-200: #5a5a5a; + --bo-gray-500: #999999; + // Background colors --bg-primary: #1a1a1a; --bg-secondary: #2f2f2f; --bg-tertiary: #4a4a4a; --bg-card: #2d2d2d; - --bg-sidebar: linear-gradient(180deg, #2d2d2d 0%, #1a1a1a 100%); - --bg-sidebar-header: linear-gradient(135deg, #5568d3 0%, #6a4c93 100%); + --bg-sidebar: #4a4a4a; + --bg-sidebar-header: var(--bo-primary); // Text colors --text-primary: #e8e8e8; @@ -179,11 +202,11 @@ html.dark-mode { --shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.4); --shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.5); - // Sidebar specific + // Sidebar specific (using primary blue) --sidebar-text: #e8e8e8; - --sidebar-link-hover-bg: linear-gradient(135deg, rgba(85, 104, 211, 0.15) 0%, rgba(106, 76, 147, 0.15) 100%); - --sidebar-link-active-bg: linear-gradient(135deg, rgba(85, 104, 211, 0.25) 0%, rgba(106, 76, 147, 0.25) 100%); + --sidebar-link-hover-bg: rgba(13, 110, 253, 0.15); + --sidebar-link-active-bg: rgba(13, 110, 253, 0.25); --sidebar-scrollbar-track: rgba(255, 255, 255, 0.05); - --sidebar-scrollbar-thumb: rgba(85, 104, 211, 0.4); - --sidebar-scrollbar-thumb-hover: rgba(85, 104, 211, 0.6); + --sidebar-scrollbar-thumb: rgba(13, 110, 253, 0.4); + --sidebar-scrollbar-thumb-hover: rgba(13, 110, 253, 0.6); } \ No newline at end of file diff --git a/assets/styles/components/_dark_mode.scss b/assets/styles/components/_dark_mode.scss index 221cce2..373d3c9 100644 --- a/assets/styles/components/_dark_mode.scss +++ b/assets/styles/components/_dark_mode.scss @@ -84,9 +84,10 @@ html.dark-mode { &.dropdown { .dropdown-menu { + background-color: var(--bg-card); border: 1px solid var(--bo-header-border); border-radius: var(--bo-border-radius-lg); - box-shadow: 0 4px 12px rgba(var(--bo-black), 0.1); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); .dropdown-item { border-radius: var(--bo-border-radius); @@ -195,7 +196,7 @@ html.dark-mode { &:focus { background-color: var(--input-bg); color: var(--input-text); - border-color: #667eea; + border-color: var(--bo-primary); } } @@ -339,4 +340,49 @@ html.dark-mode { border-bottom: 1px solid rgba(var(--bo-black), 0.08); background: linear-gradient(180deg, #2d2d2d 0%, var(--bo-gray-50) 100%); } +} + +// Google Maps dark mode styling +html.dark-mode { + // Invert map colors for dark mode effect + #map, + .google-map, + [data-google-map] { + filter: invert(90%) hue-rotate(180deg); + } + + // Preserve marker and image colors by double-inverting + #map .gm-style img, + #map .gm-style canvas, + .google-map .gm-style img, + .google-map .gm-style canvas { + filter: invert(100%) hue-rotate(180deg); + } + + // InfoWindow styling + .gm-style .gm-style-iw-c, + .gm-style .gm-style-iw-d { + background-color: var(--card-bg) !important; + } + + .gm-style .gm-style-iw-tc::after { + background: var(--card-bg) !important; + } + + .gm-style .gm-style-iw { + color: var(--text-primary) !important; + + h6, .h6 { + color: var(--text-primary) !important; + } + + p, .text-muted { + color: var(--text-secondary) !important; + } + } + + // Close button in InfoWindow + .gm-style .gm-ui-hover-effect { + filter: invert(100%); + } } \ No newline at end of file diff --git a/assets/styles/components/_folder_browser.scss b/assets/styles/components/_folder_browser.scss index 342fb78..1a474d4 100755 --- a/assets/styles/components/_folder_browser.scss +++ b/assets/styles/components/_folder_browser.scss @@ -14,7 +14,7 @@ } .tree-row:hover { - background-color: #f0f0f0; + background-color: var(--bg-tertiary); } .checkbox-label { @@ -29,17 +29,18 @@ .tree-label { cursor: pointer; transition: color 0.2s; + color: var(--text-primary); } .tree-label:hover { - color: #007bff; + color: var(--bo-primary); } .subfolders { margin-left: 20px; } -/* ✅ Loader styling */ +/* Loader styling */ .loader { text-align: center; } @@ -47,4 +48,4 @@ .loading-icon { width: 18px; height: 18px; -} \ No newline at end of file +} diff --git a/assets/styles/components/_kanban.scss b/assets/styles/components/_kanban.scss index e0c8fbb..c315413 100755 --- a/assets/styles/components/_kanban.scss +++ b/assets/styles/components/_kanban.scss @@ -1,22 +1,22 @@ //assets/styles/components/_kanban.scss .kanban-board { - background: #f8f9fa; + background: var(--bg-secondary); padding: 20px; border-radius: 8px; } .kanban-column { - background: #ffffff; + background: var(--card-bg); border-radius: 8px; padding: 15px; margin: 0 10px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + box-shadow: var(--shadow-sm); min-height: 600px; } .kanban-column h5 { - background: #6c757d; - color: white; + background: var(--bo-gray-500); + color: var(--text-inverse); padding: 10px; border-radius: 5px; margin-bottom: 15px; @@ -24,30 +24,30 @@ } .kanban-column[data-status="TODO"] h5 { - background: #6c757d; + background: var(--bo-gray-500); } .kanban-column[data-status="IN_PROGRESS"] h5 { - background: #ffc107; + background: var(--bo-warning); } .kanban-column[data-status="DONE"] h5 { - background: #28a745; + background: var(--bo-success); } .kanban-task { - background: #fff; - border: 1px solid #dee2e6; + background: var(--card-bg); + border: 1px solid var(--border-color); border-radius: 5px; padding: 10px; margin-bottom: 10px; cursor: move; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); + box-shadow: var(--shadow-sm); transition: all 0.2s; } .kanban-task:hover { - box-shadow: 0 3px 6px rgba(0,0,0,0.15); + box-shadow: var(--shadow); transform: translateY(-1px); } @@ -56,7 +56,7 @@ } .kanban-task.sortable-chosen { - background: #e3f2fd; + background: var(--bo-primary-light); } .task-header { @@ -68,11 +68,11 @@ .task-title { font-weight: bold; - color: #495057; + color: var(--text-primary); cursor: pointer; - + &:hover { - color: #007bff; + color: var(--bo-primary); } } @@ -80,24 +80,24 @@ font-size: 0.8em; padding: 2px 6px; border-radius: 3px; - color: white; + color: var(--text-inverse); } .task-priority.high { - background: #dc3545; + background: var(--bo-danger); } .task-priority.medium { - background: #ffc107; + background: var(--bo-warning); } .task-priority.low { - background: #28a745; + background: var(--bo-success); } .task-assignee { font-size: 0.9em; - color: #6c757d; + color: var(--text-secondary); margin-top: 5px; } @@ -107,12 +107,12 @@ .task-due-date { font-size: 0.8em; - color: #dc3545; + color: var(--bo-danger); margin-top: 5px; } .task-form { - background: #f8f9fa; + background: var(--bg-secondary); padding: 20px; border-radius: 8px; margin-top: 20px; @@ -120,17 +120,17 @@ .sortable-ghost { opacity: 0.4; - background: #c8ebfb; + background: var(--bo-info-light); } .assignment-dropdown { position: absolute; top: 100%; right: 0; - background: white; - border: 1px solid #dee2e6; + background: var(--card-bg); + border: 1px solid var(--border-color); border-radius: 5px; - box-shadow: 0 2px 10px rgba(0,0,0,0.1); + box-shadow: var(--shadow); min-width: 200px; z-index: 1000; display: none; @@ -147,10 +147,11 @@ background: none; width: 100%; text-align: left; + color: var(--text-primary); } .assignment-dropdown .dropdown-item:hover { - background: #f8f9fa; + background: var(--bg-secondary); } // Floating Add Button @@ -160,8 +161,8 @@ right: 30px; width: 60px; height: 60px; - background: #007bff; - color: white; + background: var(--bo-primary); + color: var(--text-inverse); border: none; border-radius: 50%; font-size: 24px; @@ -169,16 +170,16 @@ align-items: center; justify-content: center; cursor: pointer; - box-shadow: 0 4px 12px rgba(0,123,255,0.4); + box-shadow: 0 4px 12px rgba(13, 110, 253, 0.4); transition: all 0.3s ease; z-index: 1000; - + &:hover { - background: #0056b3; + background: var(--bo-primary-hover); transform: scale(1.1); - box-shadow: 0 6px 16px rgba(0,123,255,0.5); + box-shadow: 0 6px 16px rgba(13, 110, 253, 0.5); } - + &:active { transform: scale(0.95); } @@ -199,7 +200,7 @@ opacity: 0; visibility: hidden; transition: all 0.3s ease; - + &.show { opacity: 1; visibility: visible; @@ -208,7 +209,7 @@ .modal-content { padding: 10px; - background: white !important; + background: var(--modal-bg) !important; border-radius: 8px; max-width: 600px; width: 90%; @@ -216,7 +217,7 @@ overflow-y: auto; transform: scale(0.9); transition: transform 0.3s ease; - + .modal-overlay.show & { transform: scale(1); } @@ -224,14 +225,14 @@ .modal-header { padding: 20px; - border-bottom: 1px solid #dee2e6; + border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; - + h5 { margin: 0; - color: #495057; + color: var(--text-primary); } } @@ -240,10 +241,10 @@ border: none; font-size: 24px; cursor: pointer; - color: #6c757d; - + color: var(--text-secondary); + &:hover { - color: #495057; + color: var(--text-primary); } } @@ -253,7 +254,7 @@ .modal-footer { padding: 20px; - border-top: 1px solid #dee2e6; + border-top: 1px solid var(--border-color); display: flex; justify-content: flex-end; gap: 10px; @@ -266,60 +267,60 @@ flex-wrap: wrap; gap: 15px; margin-bottom: 20px; - + .meta-item { display: flex; align-items: center; gap: 5px; - + .meta-label { font-weight: bold; - color: #6c757d; + color: var(--text-secondary); } - + .meta-value { - color: #495057; + color: var(--text-primary); } } } - + .task-description { margin-bottom: 20px; - + h6 { - color: #6c757d; + color: var(--text-secondary); margin-bottom: 10px; } - + .description-content { - background: #f8f9fa; + background: var(--bg-secondary); padding: 15px; border-radius: 5px; - border: 1px solid #dee2e6; + border: 1px solid var(--border-color); } } - + .task-activity { h6 { - color: #6c757d; + color: var(--text-secondary); margin-bottom: 15px; } - + .activity-item { padding: 10px; - border-left: 3px solid #007bff; + border-left: 3px solid var(--bo-primary); margin-bottom: 10px; - background: #f8f9fa; + background: var(--bg-secondary); border-radius: 0 5px 5px 0; - + .activity-type { font-weight: bold; - color: #007bff; + color: var(--bo-primary); } - + .activity-time { font-size: 0.9em; - color: #6c757d; + color: var(--text-secondary); } } } @@ -331,12 +332,12 @@ justify-content: center; align-items: center; padding: 40px; - + .spinner { width: 40px; height: 40px; - border: 4px solid #f3f3f3; - border-top: 4px solid #007bff; + border: 4px solid var(--border-color-light); + border-top: 4px solid var(--bo-primary); border-radius: 50%; animation: spin 1s linear infinite; } diff --git a/assets/styles/components/_sidebar_menu.scss b/assets/styles/components/_sidebar_menu.scss index bd27bf1..96249ab 100755 --- a/assets/styles/components/_sidebar_menu.scss +++ b/assets/styles/components/_sidebar_menu.scss @@ -2,12 +2,7 @@ $sidebar-width-mobile: 300px; $sidebar-width-tablet: 340px; $sidebar-width-desktop: 360px; -$sidebar-bg: linear-gradient(180deg, #ffffff 0%, #f8f9fa 100%); -$sidebar-border-color: #e0e0e0; -$sidebar-shadow: -4px 0 20px rgba(0, 0, 0, 0.08); -$sidebar-shadow-open: -8px 0 30px rgba(0, 0, 0, 0.12); $sidebar-transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); -$overlay-bg: rgba(0, 0, 0, 0.6); $overlay-transition: opacity 0.3s ease, visibility 0.3s ease; // Breakpoints @@ -25,9 +20,9 @@ $desktop-min: 769px; width: 48px; height: 48px; border-radius: 12px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: var(--bg-sidebar-header); border: none; - box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); + box-shadow: 0 4px 12px rgba(13, 110, 253, 0.3); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); display: flex; align-items: center; @@ -35,14 +30,14 @@ $desktop-min: 769px; i { font-size: 24px; - color: white; + color: var(--text-inverse); transition: transform 0.2s ease; } &:hover { transform: translateY(-2px); - box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4); - background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); + box-shadow: 0 6px 16px rgba(13, 110, 253, 0.4); + filter: brightness(1.1); i { transform: scale(1.1); @@ -51,7 +46,7 @@ $desktop-min: 769px; &:active { transform: translateY(0); - box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); + box-shadow: 0 2px 8px rgba(13, 110, 253, 0.3); } // Hide button when sidebar is open @@ -75,14 +70,14 @@ $desktop-min: 769px; right: 0; width: $sidebar-width-mobile; height: 100%; - background: $sidebar-bg; - border-left: 1px solid $sidebar-border-color; + background: var(--bg-sidebar); + border-left: 1px solid var(--border-color-light); overflow-y: auto; overflow-x: hidden; z-index: 1050; transform: translateX(100%); transition: $sidebar-transition; - box-shadow: $sidebar-shadow; + box-shadow: -4px 0 20px rgba(0, 0, 0, 0.08); // Custom scrollbar &::-webkit-scrollbar { @@ -90,15 +85,15 @@ $desktop-min: 769px; } &::-webkit-scrollbar-track { - background: rgba(0, 0, 0, 0.05); + background: var(--sidebar-scrollbar-track); } &::-webkit-scrollbar-thumb { - background: rgba(102, 126, 234, 0.3); + background: var(--sidebar-scrollbar-thumb); border-radius: 4px; &:hover { - background: rgba(102, 126, 234, 0.5); + background: var(--sidebar-scrollbar-thumb-hover); } } @@ -111,7 +106,7 @@ $desktop-min: 769px; // Sidebar header .sidebar-header { padding: 1.5rem 1.25rem; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: var(--bg-sidebar-header); display: flex; justify-content: space-between; align-items: center; @@ -124,7 +119,7 @@ $desktop-min: 769px; margin: 0; font-size: 1.375rem; font-weight: 700; - color: white; + color: var(--text-inverse); letter-spacing: -0.02em; } @@ -144,7 +139,7 @@ $desktop-min: 769px; } &:focus { - outline: 2px solid white; + outline: 2px solid var(--text-inverse); outline-offset: 2px; } } @@ -161,7 +156,7 @@ $desktop-min: 769px; left: 0; width: 100%; height: 100%; - background: $overlay-bg; + background: rgba(0, 0, 0, 0.6); z-index: 1040; opacity: 0; visibility: hidden; @@ -207,7 +202,7 @@ $desktop-min: 769px; width: $sidebar-width-desktop; &.open { - box-shadow: $sidebar-shadow-open; + box-shadow: -8px 0 30px rgba(0, 0, 0, 0.12); } } @@ -220,7 +215,7 @@ $desktop-min: 769px; // Enhanced menu items styling .sidebar-body { .nav-link { - color: #2d3748; + color: var(--sidebar-text); padding: 0.875rem 1rem; margin-bottom: 0.375rem; border-radius: 10px; @@ -241,14 +236,14 @@ $desktop-min: 769px; top: 0; height: 100%; width: 3px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: var(--bg-sidebar-header); transform: scaleY(0); transition: transform 0.25s ease; } &:hover { - background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%); - color: #667eea; + background: var(--sidebar-link-hover-bg); + color: var(--bo-primary); transform: translateX(4px); padding-left: 1.125rem; @@ -258,10 +253,10 @@ $desktop-min: 769px; } &.active { - background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%); - color: #667eea; + background: var(--sidebar-link-active-bg); + color: var(--bo-primary); font-weight: 600; - box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15); + box-shadow: 0 2px 8px rgba(13, 110, 253, 0.15); &::before { transform: scaleY(1); @@ -300,7 +295,7 @@ $desktop-min: 769px; .nav { margin-top: 0.375rem; padding-left: 1rem; - border-left: 2px solid rgba(102, 126, 234, 0.1); + border-left: 2px solid rgba(13, 110, 253, 0.1); margin-left: 0.5rem; } @@ -314,7 +309,7 @@ $desktop-min: 769px; // Accessibility improvements .sidebar-menu { &:focus-within { - outline: 2px solid #667eea; + outline: 2px solid var(--bo-primary); outline-offset: -2px; } -} \ No newline at end of file +} diff --git a/assets/styles/components/_wysiwyg.scss b/assets/styles/components/_wysiwyg.scss index e81c93b..83a936c 100755 --- a/assets/styles/components/_wysiwyg.scss +++ b/assets/styles/components/_wysiwyg.scss @@ -3,17 +3,17 @@ .wysiwyg-editor { min-height: 300px; padding: 0.75rem; - border: 1px solid #ced4da; + border: 1px solid var(--input-border); border-radius: 0.25rem; - background-color: #fff; - color: #212529; + background-color: var(--input-bg); + color: var(--input-text); transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; overflow: auto; line-height: 1.5; resize: vertical; &:focus { - border-color: #86b7fe; + border-color: var(--bo-primary); outline: 0; box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); } @@ -23,9 +23,9 @@ display: inline-block; padding: 0 0.25rem; margin: 0 0.125rem; - background-color: rgba(13, 110, 253, 0.1); + background-color: var(--bo-primary-light); border-radius: 0.25rem; - color: #0d6efd; + color: var(--bo-primary); font-weight: 500; white-space: nowrap; cursor: default; @@ -41,21 +41,22 @@ max-width: 350px; max-height: 250px; overflow-y: auto; - background-color: #fff; - border: 1px solid rgba(0, 0, 0, 0.125); + background-color: var(--card-bg); + border: 1px solid var(--border-color); border-radius: 0.25rem; - box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + box-shadow: var(--shadow); .mention-item { padding: 0.5rem 1rem; cursor: pointer; + color: var(--text-primary); &:hover, &.selected { - background-color: #f8f9fa; + background-color: var(--bg-secondary); } &.selected { - background-color: rgba(13, 110, 253, 0.1); + background-color: var(--bo-primary-light); } } } diff --git a/assets/styles/components/feedback.scss b/assets/styles/components/feedback.scss index 4fb7b2b..d21c6a5 100644 --- a/assets/styles/components/feedback.scss +++ b/assets/styles/components/feedback.scss @@ -3,13 +3,9 @@ // Unified with Sidebar Menu Gradient Design // ============================================ -// Gradient Variables (matching sidebar menu) -$feedback-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -$feedback-gradient-hover: linear-gradient(135deg, #764ba2 0%, #667eea 100%); -$feedback-shadow: rgba(102, 126, 234, 0.3); -$feedback-shadow-hover: rgba(102, 126, 234, 0.4); -$feedback-light-bg: rgba(102, 126, 234, 0.08); -$feedback-light-bg-hover: rgba(102, 126, 234, 0.15); +// Shadow Variables (using primary blue) +$feedback-shadow: rgba(13, 110, 253, 0.3); +$feedback-shadow-hover: rgba(13, 110, 253, 0.4); // Floating feedback button (matches sidebar menu button) .feedback-float-button { @@ -19,14 +15,14 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); width: 56px; height: 56px; border-radius: 50%; - background: $feedback-gradient; + background: var(--bg-sidebar-header); border: none; box-shadow: 0 4px 12px $feedback-shadow; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; - color: white; + color: var(--text-inverse); z-index: 1040; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); cursor: pointer; @@ -38,7 +34,7 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); &:hover { transform: translateY(-3px) scale(1.05); box-shadow: 0 6px 16px $feedback-shadow-hover; - background: $feedback-gradient-hover; + filter: brightness(1.1); i { transform: scale(1.1); @@ -51,7 +47,7 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); } &:focus { - outline: 2px solid white; + outline: 2px solid var(--text-inverse); outline-offset: 2px; } @@ -81,7 +77,7 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); #feedbackModal { // Modal header with gradient (matching sidebar header) .modal-header { - background: $feedback-gradient; + background: var(--bg-sidebar-header); border-bottom: none; padding: 1.5rem 1.25rem; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); @@ -92,7 +88,7 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); align-items: center; font-weight: 600; font-size: 1.375rem; - color: white; + color: var(--text-inverse); letter-spacing: -0.02em; i { @@ -116,7 +112,7 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); } &:focus { - outline: 2px solid white; + outline: 2px solid var(--text-inverse); outline-offset: 2px; box-shadow: none; } @@ -126,24 +122,27 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); // Modal body .modal-body { padding: 1.5rem 1.25rem; + background-color: var(--modal-bg); } // Form elements .form-label { font-weight: 500; margin-bottom: 0.5rem; - color: #2d3748; + color: var(--sidebar-text); } .form-select, .form-control { border-radius: 8px; - border: 1px solid #dee2e6; + border: 1px solid var(--border-color); + background-color: var(--input-bg); + color: var(--input-text); transition: all 0.2s ease; &:focus { - border-color: #667eea; - box-shadow: 0 0 0 0.25rem rgba(102, 126, 234, 0.25); + border-color: var(--bo-primary); + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); } } @@ -154,9 +153,9 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); // Modal footer with gradient buttons .modal-footer { - border-top: 1px solid #dee2e6; + border-top: 1px solid var(--border-color); padding: 1rem 1.25rem; - background-color: #f8f9fa; + background-color: var(--bg-secondary); .btn-secondary { border-radius: 8px; @@ -168,7 +167,7 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); } .btn-primary { - background: $feedback-gradient; + background: var(--bg-sidebar-header); border: none; border-radius: 8px; box-shadow: 0 2px 8px $feedback-shadow; @@ -176,7 +175,7 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); font-weight: 500; &:hover { - background: $feedback-gradient-hover; + filter: brightness(1.1); transform: translateY(-2px); box-shadow: 0 4px 12px $feedback-shadow-hover; } @@ -187,11 +186,11 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); } &:focus { - box-shadow: 0 0 0 0.25rem rgba(102, 126, 234, 0.25); + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); } &:disabled { - background: linear-gradient(135deg, rgba(102, 126, 234, 0.6) 0%, rgba(118, 75, 162, 0.6) 100%); + filter: brightness(0.7); cursor: not-allowed; } } @@ -207,7 +206,7 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); #screenshotPreview { max-height: 300px; overflow-y: auto; - background-color: #f8f9fa; + background-color: var(--bg-secondary); border-radius: 8px; img { @@ -222,35 +221,37 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); .accordion-button { font-size: 0.875rem; padding: 0.75rem 1rem; - background-color: #f8f9fa; + background-color: var(--bg-secondary); border-radius: 8px; transition: all 0.2s ease; + color: var(--text-primary); &:not(.collapsed) { - background: $feedback-light-bg; - color: #667eea; + background: var(--sidebar-link-hover-bg); + color: var(--bo-primary); font-weight: 500; - box-shadow: 0 2px 4px rgba(102, 126, 234, 0.15); + box-shadow: 0 2px 4px rgba(13, 110, 253, 0.15); &::after { - filter: brightness(0) saturate(100%) invert(42%) sepia(93%) saturate(2384%) hue-rotate(226deg); + filter: brightness(0) saturate(100%) invert(37%) sepia(98%) saturate(1561%) hue-rotate(207deg) brightness(101%) contrast(101%); } } &:hover { - background: $feedback-light-bg-hover; + background: var(--sidebar-link-active-bg); } &:focus { - box-shadow: 0 0 0 0.25rem rgba(102, 126, 234, 0.25); - border-color: #667eea; + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); + border-color: var(--bo-primary); } } .accordion-body { padding: 1rem; font-size: 0.875rem; - background-color: #f8f9fa; + background-color: var(--bg-secondary); + color: var(--text-primary); } } @@ -266,9 +267,9 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); #captureScreenshot { &:hover { - background: $feedback-light-bg; - border-color: #667eea; - color: #667eea; + background: var(--sidebar-link-hover-bg); + border-color: var(--bo-primary); + color: var(--bo-primary); } } diff --git a/assets/styles/components/timeline.scss b/assets/styles/components/timeline.scss index fbb0196..406ec95 100644 --- a/assets/styles/components/timeline.scss +++ b/assets/styles/components/timeline.scss @@ -6,9 +6,9 @@ // vis-timeline container #timeline-container { - border: 1px solid #dee2e6; + border: 1px solid var(--border-color); border-radius: 0.375rem; - background: #fff; + background: var(--card-bg); } // vis-timeline event category styling @@ -18,35 +18,35 @@ font-size: 14px; &.timeline-event-historical { - background-color: #6c757d; - border-color: #5a6268; - color: white; + background-color: var(--bo-gray-500); + border-color: var(--bo-gray-600, #5a6268); + color: var(--text-inverse); &.vis-selected { - background-color: #5a6268; - border-color: #4e555b; + background-color: var(--bo-gray-600, #5a6268); + border-color: var(--bo-gray-700, #4e555b); } } &.timeline-event-current { - background-color: #0d6efd; - border-color: #0a58ca; - color: white; + background-color: var(--bo-primary); + border-color: var(--bo-primary-active); + color: var(--text-inverse); &.vis-selected { - background-color: #0a58ca; - border-color: #084298; + background-color: var(--bo-primary-active); + border-color: var(--bo-primary-dark); } } &.timeline-event-future { - background-color: #0dcaf0; - border-color: #0aa2c0; - color: #000; + background-color: var(--bo-info); + border-color: var(--bo-info-hover, #0aa2c0); + color: var(--text-inverse); &.vis-selected { - background-color: #0aa2c0; - border-color: #088799; + background-color: var(--bo-info-hover, #0aa2c0); + border-color: var(--bo-info-dark); } } } @@ -89,16 +89,17 @@ // Current time marker .vis-time-axis .vis-text.vis-major { font-weight: 600; + color: var(--text-primary); } .vis-current-time { - background-color: #dc3545; + background-color: var(--bo-danger); width: 2px; } // Timeline background .vis-panel.vis-background { - background-color: #f8f9fa; + background-color: var(--bg-secondary); } // Hover effects @@ -124,8 +125,8 @@ transform: translate(-50%, -50%); width: 3rem; height: 3rem; - border: 0.25rem solid rgba(0, 0, 0, 0.1); - border-top-color: #0d6efd; + border: 0.25rem solid var(--border-color-light); + border-top-color: var(--bo-primary); border-radius: 50%; animation: spinner-border 0.75s linear infinite; } @@ -160,7 +161,7 @@ width: 16px; height: 16px; border-radius: 50%; - border: 3px solid white; + border: 3px solid var(--card-bg); box-shadow: 0 0 0 2px currentColor; z-index: 2; } @@ -168,7 +169,7 @@ .timeline-line { flex: 1; width: 2px; - background: #dee2e6; + background: var(--border-color); margin-top: 4px; } @@ -181,15 +182,15 @@ // Category-specific styles for fallback timeline .lore-timeline { .timeline-event-historical .timeline-dot { - box-shadow: 0 0 0 2px #6c757d; + box-shadow: 0 0 0 2px var(--bo-gray-500); } .timeline-event-current .timeline-dot { - box-shadow: 0 0 0 2px #0d6efd; + box-shadow: 0 0 0 2px var(--bo-primary); } .timeline-event-future .timeline-dot { - box-shadow: 0 0 0 2px #0dcaf0; + box-shadow: 0 0 0 2px var(--bo-info); } } diff --git a/templates/backoffice/larp/application/list.html.twig b/templates/backoffice/larp/application/list.html.twig index f244542..e167d49 100755 --- a/templates/backoffice/larp/application/list.html.twig +++ b/templates/backoffice/larp/application/list.html.twig @@ -237,7 +237,7 @@ {% for choice in application.choices %} {{ choice.character.title }} - #{{ choice.priority }} + #{{ choice.priority }} {% endfor %} diff --git a/templates/backoffice/larp/application/view.html.twig b/templates/backoffice/larp/application/view.html.twig index b824de1..d5cf4fa 100755 --- a/templates/backoffice/larp/application/view.html.twig +++ b/templates/backoffice/larp/application/view.html.twig @@ -193,7 +193,7 @@
{# Voting Form #} -
+
{{ 'larp.applications.cast_vote'|trans }}
diff --git a/templates/backoffice/larp/mailing/edit.html.twig b/templates/backoffice/larp/mailing/edit.html.twig index 4bd9361..ca40728 100644 --- a/templates/backoffice/larp/mailing/edit.html.twig +++ b/templates/backoffice/larp/mailing/edit.html.twig @@ -25,7 +25,7 @@
{{ 'mail_template.available_placeholders'|trans }}
{% for placeholder in definition.placeholders %} - {{ '{{ ' ~ placeholder ~ ' }}' }} + {{ '{{ ' ~ placeholder ~ ' }}' }} {% endfor %}
diff --git a/templates/backoffice/larp/mailing/list.html.twig b/templates/backoffice/larp/mailing/list.html.twig index fd68875..85e0fe3 100644 --- a/templates/backoffice/larp/mailing/list.html.twig +++ b/templates/backoffice/larp/mailing/list.html.twig @@ -47,7 +47,7 @@ {% endif %} - {{ template.type.label }} + {{ template.type.label }} {{ definition ? definition.description : '-' }} diff --git a/templates/backoffice/larp/partials/_sharedFileList.html.twig b/templates/backoffice/larp/partials/_sharedFileList.html.twig index 4ab43d1..329c8e9 100755 --- a/templates/backoffice/larp/partials/_sharedFileList.html.twig +++ b/templates/backoffice/larp/partials/_sharedFileList.html.twig @@ -45,8 +45,8 @@
-
{{ mapping.mappingConfiguration|json_encode(constant('JSON_PRETTY_PRINT')) }}
-
{{ mapping.metaConfiguration|json_encode(constant('JSON_PRETTY_PRINT')) }}
+
{{ mapping.mappingConfiguration|json_encode(constant('JSON_PRETTY_PRINT')) }}
+
{{ mapping.metaConfiguration|json_encode(constant('JSON_PRETTY_PRINT')) }}
diff --git a/templates/backoffice/larp/story/comment/_comment_thread.html.twig b/templates/backoffice/larp/story/comment/_comment_thread.html.twig index 67fb279..335ef8e 100644 --- a/templates/backoffice/larp/story/comment/_comment_thread.html.twig +++ b/templates/backoffice/larp/story/comment/_comment_thread.html.twig @@ -22,7 +22,7 @@ {{ comment.createdAt|date('M d, Y H:i') }} {% if comment.createdAt != comment.updatedAt %} - + {% endif %} diff --git a/templates/backoffice/larp/story/comment/discussions.html.twig b/templates/backoffice/larp/story/comment/discussions.html.twig index b6c8fda..4bde14f 100644 --- a/templates/backoffice/larp/story/comment/discussions.html.twig +++ b/templates/backoffice/larp/story/comment/discussions.html.twig @@ -24,7 +24,7 @@ data-comments-show-resolved-value="{{ showResolved ? 'true' : 'false' }}"> {# Header with show/hide resolved toggle #} -
+
diff --git a/templates/backoffice/survey/edit.html.twig b/templates/backoffice/survey/edit.html.twig index 5318387..7c70e72 100644 --- a/templates/backoffice/survey/edit.html.twig +++ b/templates/backoffice/survey/edit.html.twig @@ -70,7 +70,7 @@ {% for question in form.questions %}
-
+
diff --git a/templates/components/ResourceBookingForm.html.twig b/templates/components/ResourceBookingForm.html.twig index 6e86a3a..3c73b1b 100644 --- a/templates/components/ResourceBookingForm.html.twig +++ b/templates/components/ResourceBookingForm.html.twig @@ -1,6 +1,6 @@ {# templates/components/ResourceBookingForm.html.twig #}
-
+
{% if not saved %} {{ form_start(form, { diff --git a/templates/components/StoryObjectReferenceForm.html.twig b/templates/components/StoryObjectReferenceForm.html.twig index 6ce2d66..b5b9267 100755 --- a/templates/components/StoryObjectReferenceForm.html.twig +++ b/templates/components/StoryObjectReferenceForm.html.twig @@ -1,6 +1,6 @@ {# templates/components/StoryObjectReferenceForm.html.twig #}
-
+
{% set isExisting = form.vars.data.createdAt is not null %} diff --git a/templates/components/StoryObjectRelationForm.html.twig b/templates/components/StoryObjectRelationForm.html.twig index 659e32e..f47a35f 100755 --- a/templates/components/StoryObjectRelationForm.html.twig +++ b/templates/components/StoryObjectRelationForm.html.twig @@ -1,6 +1,6 @@ {# templates/components/CharacterRelationForm.twig #}
-
+
{{ form_start(form, { attr: { diff --git a/templates/domain/gallery/view.html.twig b/templates/domain/gallery/view.html.twig index 1f3c5a4..da41d54 100644 --- a/templates/domain/gallery/view.html.twig +++ b/templates/domain/gallery/view.html.twig @@ -45,7 +45,7 @@
{% if gallery.externalAlbumUrl %}
-
+
{{ 'gallery.external_album'|trans }}
@@ -59,7 +59,7 @@ {% if gallery.zipDownloadUrl %}
-
+
{{ 'gallery.zip_download'|trans }}
@@ -73,7 +73,7 @@ {% if gallery.zipFile %}
-
+
-
+
{{ 'details'|trans }}