diff --git a/.github/workflows/code_checks.yaml b/.github/workflows/code_checks.yaml index 0c02c69..8ad9158 100644 --- a/.github/workflows/code_checks.yaml +++ b/.github/workflows/code_checks.yaml @@ -56,7 +56,7 @@ jobs: - name: PHPStan continue-on-error: ${{ !matrix.is-current }} - run: vendor/bin/phpstan analyse src tests --level 9 + run: vendor/bin/phpstan analyse - name: Execute tests env: diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index efa0cc1..df668df 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -16,6 +16,7 @@ 'php_unit_internal_class' => false, 'php_unit_test_class_requires_covers' => false, + 'method_chaining_indentation' => false, ]) ->setFinder( PhpCsFixer\Finder::create() diff --git a/README.md b/README.md index c8b4651..03dd58e 100755 --- a/README.md +++ b/README.md @@ -17,47 +17,22 @@ Documentation The source of the documentation is stored in the `docs/` folder in this bundle -- [Link the calendar to a CRUD and allow create, update, delete & show events](docs/doctrine-crud.md) -- [Webpack Encore and fullcalendar.js](docs/es6-encore.md) -- [Multi calendar](docs/multi-calendar.md) -- [Migrating to v8.1](docs/upgrade-to-81.md) - ### Installation -1. [Download CalendarBundle using composer](#1-download-calendarbundle-using-composer) -2. [Create the subscriber](#2-create-the-subscriber) -3. [Add styles and scripts in your template](#3-add-styles-and-scripts-in-your-template) -4. [Security Configuration](#4-security-configuration) - -#### 1. Download CalendarBundle using composer - ```sh composer require tattali/calendar-bundle ``` -The recipe will import the routes for you -Check the existence of the file `config/routes/calendar.yaml` or create it +Import the routes: ```yaml # config/routes/calendar.yaml calendar: resource: '@CalendarBundle/config/routing.yaml' ``` -#### 2. Create the subscriber -You need to create a subscriber class to load your data into the calendar. - -This subscriber must be registered **only if autoconfigure is false**. -```yaml -# config/services.yaml -services: - # ... - - App\EventSubscriber\CalendarSubscriber: -``` - -Then, create the subscriber class to fill the calendar +### Quick Start -See the [doctrine subscriber example](docs/doctrine-crud.md#full-subscriber) +Create a subscriber to populate calendar data: ```php // src/EventSubscriber/CalendarSubscriber.php @@ -71,14 +46,14 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface; class CalendarSubscriber implements EventSubscriberInterface { - public static function getSubscribedEvents() + public static function getSubscribedEvents(): array { return [ SetDataEvent::class => 'onCalendarSetData', ]; } - public function onCalendarSetData(SetDataEvent $setDataEvent) + public function onCalendarSetData(SetDataEvent $setDataEvent): void { $start = $setDataEvent->getStart(); $end = $setDataEvent->getEnd(); @@ -87,9 +62,9 @@ class CalendarSubscriber implements EventSubscriberInterface // You may want to make a custom query from your database to fill the calendar $setDataEvent->addEvent(new Event( - 'Event 1', + 'Event title', new \DateTime('Tuesday this week'), - new \DateTime('Wednesdays this week') + new \DateTime('Wednesday this week') )); // If the end date is null or not defined, it creates a all day event @@ -101,131 +76,46 @@ class CalendarSubscriber implements EventSubscriberInterface } ``` -#### 3. Add styles and scripts in your template - -Include the html template were you want to display the calendar: - +Add the calendar to your template: ```twig {% block body %}
{% endblock %} -``` - -Add styles and js. Click [here](https://fullcalendar.io/download) to see other css and js download methods, you can also found the [plugins list](https://fullcalendar.io/docs/plugin-index) -```twig {% block javascripts %} + {% endblock %} ``` -#### 4. Security Configuration - -If your application uses Symfony's security firewall, ensure the `/fc-load-events` route is accessible. Add it to your `access_control` configuration: - -```yaml -# config/packages/security.yaml -security: - access_control: - - { path: ^/fc-load-events, roles: PUBLIC_ACCESS } - # Or restrict to authenticated users: - # - { path: ^/fc-load-events, roles: ROLE_USER } -``` - -You can also define your own route pointing directly to the bundle's controller by replacing `config/routes/calendar.yaml`: - -```yaml -# config/routes/calendar.yaml -fc_load_events: - path: /fc-load-events - controller: CalendarBundle\Controller\CalendarController::load -``` - -```yaml -# config/packages/security.yaml -security: - access_control: - - { path: ^/fc-load-events, roles: ROLE_USER } -``` - -Alternatively, you can create your own controller that wraps the bundle's controller and add security attributes: - -```php -// src/Controller/CalendarController.php -namespace App\Controller; - -use CalendarBundle\Controller\CalendarController as BaseCalendarController; -use Symfony\Component\HttpFoundation\JsonResponse; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Routing\Attribute\Route; -use Symfony\Component\Security\Http\Attribute\IsGranted; - -class CalendarController -{ - public function __construct( - private BaseCalendarController $calendarController, - ) {} - - #[Route('/fc-load-events', name: 'fc_load_events', methods: ['POST'])] - #[IsGranted('ROLE_USER')] - public function load(Request $request): JsonResponse - { - return $this->calendarController->load($request); - } -} -``` - -Then disable the bundle's route by removing or commenting out `config/routes/calendar.yaml`. - -### Basic functionalities - -You will probably want to customize the Calendar javascript to fit the needs of your application. -To do this, you can copy the following settings and modify them by consulting the [fullcalendar.js documentation](https://fullcalendar.io/docs). -```js -document.addEventListener('DOMContentLoaded', () => { - const calendarEl = document.getElementById('calendar-holder'); - - const calendar = new FullCalendar.Calendar(calendarEl, { - defaultView: 'dayGridMonth', - editable: true, - eventSources: [ - { - url: '/fc-load-events', - method: 'POST', - extraParams: { - filters: JSON.stringify({}) - }, - failure: () => { - // alert('There was an error while fetching FullCalendar!'); - }, - }, - ], - headerToolbar: { - start: 'prev,next today', - center: 'title', - end: 'dayGridMonth,timeGridWeek,timeGridDay' - }, - timeZone: 'UTC', - }); - - calendar.render(); -}); -``` +### Documentation -You can use [Plugins](https://fullcalendar.io/docs/plugin-index) to reduce loadtime. +- [Configuration](docs/configuration.md) - Caching, JSON depth limit, routing +- [Doctrine CRUD](docs/doctrine-crud.md) - Create, update, delete events with Doctrine +- [Webpack Encore](docs/es6-encore.md) - ES6 module setup +- [Multi-calendar](docs/multi-calendar.md) - Multiple calendars on one page +- [Security](docs/configuration.md#security) - Securing the endpoint -## Troubleshoot AJAX requests +#### Upgrade Guides -* To debug AJAX requests, show the Network monitor, then reload the page. Finally click on `fc-load-events` and select the `Response` or `Preview` tab - - Firefox: `Ctrl + Shift + E` ( `Command + Option + E` on Mac ) - - Chrome: `Ctrl + Shift + I` ( `Command + Option + I` on Mac ) +- [Migrating to v8.2](docs/upgrade-to-82.md) +- [Migrating to v8.1](docs/upgrade-to-81.md) -Contribute and feedback ------------------------ +### Troubleshooting -Any feedback and contribution will be very appreciated. +Debug AJAX requests using browser Network monitor (`Ctrl+Shift+E` Firefox, `Ctrl+Shift+I` Chrome). -License -------- +### License -This bundle is under the MIT license. See the complete [license](LICENSE) in the bundle +MIT - See [LICENSE](LICENSE) diff --git a/config/services.yaml b/config/services.yaml index 1919c4f..e6a95ac 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -8,6 +8,12 @@ services: calendar.serializer: alias: CalendarBundle\Serializer\Serializer + CalendarBundle\Request\RequestParser: + class: CalendarBundle\Request\RequestParser + + CalendarBundle\Request\RequestParserInterface: + alias: CalendarBundle\Request\RequestParser + CalendarBundle\Controller\CalendarController: class: CalendarBundle\Controller\CalendarController public: true @@ -15,6 +21,7 @@ services: arguments: $eventDispatcher: '@event_dispatcher' $serializer: '@CalendarBundle\Serializer\Serializer' + $requestParser: '@CalendarBundle\Request\RequestParser' tags: - controller.service_arguments diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..c8dc93e --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,140 @@ +# Configuration + +CalendarBundle provides optional configuration for caching and security. + +## Full Configuration Reference + +```yaml +# config/packages/calendar.yaml +calendar: + cache_max_age: 300 # HTTP cache max-age in seconds (default: 300, 0 to disable) + json_max_depth: 4 # Max JSON nesting for filters (default: 4, range: 1-512) +``` + +## HTTP Caching + +The bundle includes built-in HTTP caching using ETag headers for the `/fc-load-events` endpoint. + +### How It Works + +When `cache_max_age > 0`: +- Response includes `Cache-Control: public, max-age=X` header +- ETag header generated using xxh3 hash (fast, non-cryptographic) +- Clients sending `If-None-Match` header receive `304 Not Modified` when content unchanged + +### Configuration + +```yaml +calendar: + # Cache for 5 minutes (default) + cache_max_age: 300 + + # Cache for 1 hour + cache_max_age: 3600 + + # Disable caching entirely + cache_max_age: 0 +``` + +### When to Disable Caching + +Set `cache_max_age: 0` if: +- Events change frequently and must be real-time +- You implement your own caching strategy +- Testing/debugging requires fresh responses + +## JSON Filters Security + +The `filters` query parameter accepts JSON that is passed to your subscriber. The `json_max_depth` option limits nesting depth to prevent DoS attacks. + +### Configuration + +```yaml +calendar: + # Default - sufficient for most use cases + json_max_depth: 4 + + # Allow deeper nesting if needed + json_max_depth: 10 +``` + +### Example + +With `json_max_depth: 4`, these filters are valid: +```json +{"category": "meeting", "tags": ["work", "urgent"]} +``` + +But deeply nested structures will throw a `BadRequestHttpException`: +```json +{"a": {"b": {"c": {"d": {"e": "too deep"}}}}} +``` + +## Routing Configuration + +The bundle provides a default route. Import it in your application: + +```yaml +# config/routes/calendar.yaml +calendar: + resource: '@CalendarBundle/config/routing.yaml' +``` + +Or define your own route with custom path: + +```yaml +# config/routes/calendar.yaml +fc_load_events: + path: /api/calendar/events + controller: CalendarBundle\Controller\CalendarController::load +``` + +## Security + +### Access Control + +If your application uses Symfony's security firewall, configure access to the endpoint: + +```yaml +# config/packages/security.yaml +security: + access_control: + # Public access + - { path: ^/fc-load-events, roles: PUBLIC_ACCESS } + + # Or restrict to authenticated users + - { path: ^/fc-load-events, roles: ROLE_USER } +``` + +### Custom Controller with Security Attributes + +For fine-grained control, wrap the bundle's controller: + +```php +// src/Controller/CalendarController.php +calendarController->load($request); + } +} +``` + +Then remove or comment out `config/routes/calendar.yaml`. diff --git a/docs/upgrade-to-82.md b/docs/upgrade-to-82.md new file mode 100644 index 0000000..51031dd --- /dev/null +++ b/docs/upgrade-to-82.md @@ -0,0 +1,145 @@ +# Migrating from v8.1.1 to v8.2 + +This guide explains how to upgrade your application from CalendarBundle 8.1.1 to 8.2. + +## New Features + +### Bundle Configuration + +The bundle now supports configuration options. See [configuration.md](configuration.md) for details. + +```yaml +# config/packages/calendar.yaml +calendar: + cache_max_age: 300 # HTTP cache (0 to disable) + json_max_depth: 4 # JSON nesting limit for filters +``` + +### HTTP Caching + +The `/fc-load-events` endpoint now includes HTTP caching by default: +- ETag headers using xxh3 hash +- `Cache-Control: public, max-age=300` +- Automatic `304 Not Modified` responses + +To disable: set `cache_max_age: 0` in configuration. + +### Fluent Interface + +Event setters now return `$this` for method chaining: + +```php +$event = (new Event('Title', new \DateTime())) + ->setEnd(new \DateTime('+1 hour')) + ->setAllDay(false) + ->setResourceId('room-1') + ->addOption('color', 'blue'); +``` + +### Custom Exceptions + +New exceptions for better error handling: + +| Exception | When Thrown | +|-----------|-------------| +| `InvalidDateException` | Missing or malformed `start`/`end` parameters | +| `InvalidJsonException` | Invalid JSON in `filters` parameter | + +Both implement `CalendarExceptionInterface` for catch-all handling. + +## Breaking Changes + +### 1. Event::getOption() Return Value + +**Bug fix**: `getOption()` now returns `null` for missing keys instead of the key itself. + +**Before (buggy behavior):** +```php +$event->getOption('nonexistent'); // returned 'nonexistent' +``` + +**After (correct behavior):** +```php +$event->getOption('nonexistent'); // returns null +$event->getOption('nonexistent', 'default'); // returns 'default' +``` + +**Migration**: If you relied on the buggy behavior, update your code to handle `null`. + +### 2. Event::setEnd(null) Behavior + +Setting `end` to `null` now automatically sets `allDay` to `true`. + +**Before:** +```php +$event->setAllDay(false); +$event->setEnd(null); +// allDay remained false +``` + +**After:** +```php +$event->setAllDay(false); +$event->setEnd(null); +// allDay is now true (no end = all day event) +``` + +**Migration**: If you need a non-all-day event without end time, this is logically inconsistent with FullCalendar. Review your use case. + +### 3. Event::setAllDay(true) Normalizes Start Time + +Setting `allDay` to `true` now normalizes the start time to midnight. + +**Before:** +```php +$event = new Event('Title', new \DateTime('2024-01-15 14:30:00')); +$event->setAllDay(true); +// start remained 2024-01-15 14:30:00 +``` + +**After:** +```php +$event = new Event('Title', new \DateTime('2024-01-15 14:30:00')); +$event->setAllDay(true); +// start is now 2024-01-15 00:00:00 +``` + +**Migration**: This ensures consistency with FullCalendar. No action needed unless you relied on preserving time for all-day events. + +### 4. JSON Depth Limit + +The `filters` parameter now has a default depth limit of 4 levels. Deeply nested JSON will throw `BadRequestHttpException`. + +**Migration**: If your application uses deeply nested filters, increase `json_max_depth` in configuration. + +## Migration Steps + +1. **Update the bundle**: + ```bash + composer update tattali/calendar-bundle + ``` + +2. **Review Event::getOption() usage** in your code for null handling + +3. **Optional**: Add configuration file if you need non-default values: + ```yaml + # config/packages/calendar.yaml + calendar: + cache_max_age: 300 + json_max_depth: 4 + ``` + +4. **Clear cache**: + ```bash + php bin/console cache:clear + ``` + +## No Changes Required + +The following remain fully backward compatible: + +- Event subscriber pattern (`SetDataEvent`) +- API endpoint path (`/fc-load-events`) +- Event entity constructor and basic usage +- Frontend JavaScript integration +- Service IDs and aliases diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..08566d0 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 9 + paths: + - src + - tests diff --git a/src/CalendarBundle.php b/src/CalendarBundle.php index aa3ff95..d015d68 100644 --- a/src/CalendarBundle.php +++ b/src/CalendarBundle.php @@ -4,12 +4,35 @@ namespace CalendarBundle; +use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\HttpKernel\Bundle\AbstractBundle; class CalendarBundle extends AbstractBundle { + public function configure(DefinitionConfigurator $definition): void + { + $rootNode = $definition->rootNode(); + \assert($rootNode instanceof \Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition); + + $rootNode + ->children() + ->integerNode('cache_max_age') + ->defaultValue(300) + ->min(0) + ->info('HTTP cache max-age in seconds (0 to disable)') + ->end() + ->integerNode('json_max_depth') + ->defaultValue(4) + ->min(1) + ->max(512) + ->info('Maximum JSON nesting depth for filters parameter') + ->end() + ->end() + ; + } + /** * @param array $config */ @@ -19,5 +42,15 @@ public function loadExtension( ContainerBuilder $builder, ): void { $container->import('../config/services.yaml'); + + $container->services() + ->get('CalendarBundle\Controller\CalendarController') + ->arg('$cacheMaxAge', $config['cache_max_age']) + ; + + $container->services() + ->get('CalendarBundle\Request\RequestParser') + ->arg('$jsonMaxDepth', $config['json_max_depth']) + ; } } diff --git a/src/Controller/CalendarController.php b/src/Controller/CalendarController.php index 0620415..ea6ea74 100755 --- a/src/Controller/CalendarController.php +++ b/src/Controller/CalendarController.php @@ -5,6 +5,8 @@ namespace CalendarBundle\Controller; use CalendarBundle\Event\SetDataEvent; +use CalendarBundle\Exception\CalendarExceptionInterface; +use CalendarBundle\Request\RequestParserInterface; use CalendarBundle\Serializer\SerializerInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\JsonResponse; @@ -17,53 +19,44 @@ class CalendarController public function __construct( private readonly EventDispatcherInterface $eventDispatcher, private readonly SerializerInterface $serializer, + private readonly RequestParserInterface $requestParser, + private readonly int $cacheMaxAge = 300, ) {} public function load(Request $request): JsonResponse { try { - $start = $request->query->getString('start'); - if ($start) { - try { - $start = new \DateTime($start); - } catch (\DateMalformedStringException $e) { - throw new \UnexpectedValueException('Query parameter "start" is not a valid date', previous: $e); - } - } else { - throw new \UnexpectedValueException('Query parameter "start" should be a string'); - } - - $end = $request->query->getString('end'); - if ($end) { - try { - $end = new \DateTime($end); - } catch (\DateMalformedStringException $e) { - throw new \UnexpectedValueException('Query parameter "end" is not a valid date', previous: $e); - } - } else { - throw new \UnexpectedValueException('Query parameter "end" should be a string'); - } - - try { - $filters = $request->query->getString('filters', '{}'); - /** - * @var mixed[] - */ - $filters = json_decode($filters, true, flags: \JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - throw new \UnexpectedValueException('Query parameter "filters" is not a valid JSON', previous: $e); - } - } catch (\UnexpectedValueException $e) { + $params = $this->requestParser->parse($request); + } catch (CalendarExceptionInterface $e) { throw new BadRequestHttpException($e->getMessage(), $e); } - $setDataEvent = $this->eventDispatcher->dispatch(new SetDataEvent($start, $end, $filters)); + $setDataEvent = $this->eventDispatcher->dispatch( + new SetDataEvent($params->start, $params->end, $params->filters), + ); $content = $this->serializer->serialize($setDataEvent->getEvents()); - return JsonResponse::fromJsonString( - $content, - empty($content) ? Response::HTTP_NO_CONTENT : Response::HTTP_OK, - ); + if ('' === $content) { + $response = new JsonResponse(); + $response->setStatusCode(Response::HTTP_NO_CONTENT); + $response->setContent(''); + + return $response; + } + + $response = JsonResponse::fromJsonString($content); + + if ($this->cacheMaxAge > 0) { + $response->setETag(hash('xxh3', $content)); + $response->setPublic(); + $response->setMaxAge($this->cacheMaxAge); + + if ($response->isNotModified($request)) { + return $response; + } + } + + return $response; } } diff --git a/src/Entity/Event.php b/src/Entity/Event.php index c256f16..bf4bc74 100755 --- a/src/Entity/Event.php +++ b/src/Entity/Event.php @@ -22,27 +22,32 @@ public function __construct( $this->setStart($start); } - public function getTitle(): ?string + public function getTitle(): string { return $this->title; } - public function setTitle(string $title): void + public function setTitle(string $title): self { $this->title = $title; + + return $this; } - public function getStart(): ?\DateTime + public function getStart(): \DateTime { return $this->start; } - public function setStart(\DateTime $start): void + public function setStart(\DateTime $start): self { if ($this->allDay) { + $start = clone $start; $start->setTime(0, 0, 0, 0); } $this->start = $start; + + return $this; } public function getEnd(): ?\DateTime @@ -50,12 +55,12 @@ public function getEnd(): ?\DateTime return $this->end; } - public function setEnd(?\DateTime $end): void + public function setEnd(?\DateTime $end): self { - if ($end) { - $this->allDay = false; - } + $this->allDay = null === $end; $this->end = $end; + + return $this; } public function isAllDay(): bool @@ -63,9 +68,15 @@ public function isAllDay(): bool return $this->allDay; } - public function setAllDay(bool $allDay): void + public function setAllDay(bool $allDay): self { $this->allDay = $allDay; + if ($allDay) { + $this->start = clone $this->start; + $this->start->setTime(0, 0, 0, 0); + } + + return $this; } public function getResourceId(): ?string @@ -73,9 +84,11 @@ public function getResourceId(): ?string return $this->resourceId; } - public function setResourceId(?string $resourceId): void + public function setResourceId(?string $resourceId): self { $this->resourceId = $resourceId; + + return $this; } /** @@ -89,19 +102,23 @@ public function getOptions(): array /** * @param mixed[] $options */ - public function setOptions(array $options): void + public function setOptions(array $options): self { $this->options = $options; + + return $this; } public function getOption(string $name): mixed { - return $this->options[$name]; + return $this->options[$name] ?? null; } - public function addOption(string $name, mixed $value): void + public function addOption(string $name, mixed $value): self { $this->options[$name] = $value; + + return $this; } public function removeOption(string $name): mixed @@ -122,19 +139,19 @@ public function removeOption(string $name): mixed public function toArray(): array { $event = [ - 'title' => $this->getTitle(), - 'start' => $this->getStart()?->format(\DateTime::ATOM), - 'allDay' => $this->isAllDay(), + 'title' => $this->title, + 'start' => $this->start->format(\DateTime::ATOM), + 'allDay' => $this->allDay, ]; - if ($this->getEnd()) { - $event['end'] = $this->getEnd()->format(\DateTime::ATOM); + if (null !== $this->end) { + $event['end'] = $this->end->format(\DateTime::ATOM); } - if ($this->getResourceId()) { - $event['resourceId'] = $this->getResourceId(); + if (null !== $this->resourceId) { + $event['resourceId'] = $this->resourceId; } - return [...$event, ...$this->getOptions()]; + return $event + $this->options; } } diff --git a/src/Event/SetDataEvent.php b/src/Event/SetDataEvent.php index c60784a..3a721f1 100644 --- a/src/Event/SetDataEvent.php +++ b/src/Event/SetDataEvent.php @@ -61,4 +61,9 @@ public function getEvents(): array { return $this->events; } + + public function getEventCount(): int + { + return \count($this->events); + } } diff --git a/src/Exception/CalendarExceptionInterface.php b/src/Exception/CalendarExceptionInterface.php new file mode 100644 index 0000000..6d47184 --- /dev/null +++ b/src/Exception/CalendarExceptionInterface.php @@ -0,0 +1,7 @@ + */ + private readonly int $jsonMaxDepth; + + /** + * @param int<1, 512> $jsonMaxDepth + */ + public function __construct( + int $jsonMaxDepth = 4, + ) { + $this->jsonMaxDepth = $jsonMaxDepth; + } + + public function parse(Request $request): QueryParameters + { + return new QueryParameters( + $this->parseDate($request, 'start'), + $this->parseDate($request, 'end'), + $this->parseFilters($request), + ); + } + + private function parseDate(Request $request, string $parameter): \DateTime + { + $value = $request->query->getString($parameter); + + if ('' === $value) { + throw InvalidDateException::missingParameter($parameter); + } + + try { + return new \DateTime($value); + } catch (\DateMalformedStringException $e) { + throw InvalidDateException::forParameter($parameter, $e); + } + } + + /** + * @return mixed[] + */ + private function parseFilters(Request $request): array + { + $value = $request->query->getString('filters', '{}'); + + try { + $filters = json_decode($value, associative: true, depth: $this->jsonMaxDepth, flags: \JSON_THROW_ON_ERROR); + \assert(\is_array($filters)); + + return $filters; + } catch (\JsonException $e) { + throw InvalidJsonException::forParameter('filters', $e); + } + } +} diff --git a/src/Request/RequestParserInterface.php b/src/Request/RequestParserInterface.php new file mode 100644 index 0000000..448a06f --- /dev/null +++ b/src/Request/RequestParserInterface.php @@ -0,0 +1,18 @@ +eventDispatcher = $this->createMock(EventDispatcherInterface::class); $this->serializer = $this->createMock(SerializerInterface::class); + $this->requestParser = new RequestParser(); $this->controller = new CalendarController( $this->eventDispatcher, $this->serializer, + $this->requestParser, + cacheMaxAge: 300, ); } @@ -78,9 +83,59 @@ public function testItProvidesAnEventsFeedForACalendar(): void $response = $this->controller->load($request); self::assertInstanceOf(JsonResponse::class, $response); - self::assertSame($data, $response->getContent()); self::assertSame(JsonResponse::HTTP_OK, $response->getStatusCode()); + + // Verify cache headers + self::assertTrue($response->headers->has('ETag')); + self::assertStringContainsString('public', (string) $response->headers->get('Cache-Control')); + self::assertStringContainsString('max-age=300', (string) $response->headers->get('Cache-Control')); + } + + public function testItReturns304WhenEtagMatches(): void + { + $data = '[{"title":"Event","start":"2016-03-01T12:00:00Z","allDay":true}]'; + $etag = hash('xxh3', $data); + + $request = Request::create('/fc-load-events', parameters: [ + 'start' => '2016-03-01', + 'end' => '2016-03-19', + ]); + $request->headers->set('If-None-Match', '"' . $etag . '"'); + + $this->calendarEvent->method('getEvents')->willReturn([$this->event]); + $this->eventDispatcher->method('dispatch')->willReturn($this->calendarEvent); + $this->serializer->method('serialize')->willReturn($data); + + $response = $this->controller->load($request); + + self::assertSame(JsonResponse::HTTP_NOT_MODIFIED, $response->getStatusCode()); + } + + public function testCacheDisabledWhenMaxAgeIsZero(): void + { + $controller = new CalendarController( + $this->eventDispatcher, + $this->serializer, + $this->requestParser, + cacheMaxAge: 0, + ); + + $request = Request::create('/fc-load-events', parameters: [ + 'start' => '2016-03-01', + 'end' => '2016-03-19', + ]); + + $data = '[{"title":"Event"}]'; + $this->calendarEvent->method('getEvents')->willReturn([$this->event]); + $this->eventDispatcher->method('dispatch')->willReturn($this->calendarEvent); + $this->serializer->method('serialize')->willReturn($data); + + $response = $controller->load($request); + + self::assertSame(JsonResponse::HTTP_OK, $response->getStatusCode()); + self::assertFalse($response->headers->has('ETag')); + self::assertStringNotContainsString('public', (string) $response->headers->get('Cache-Control')); } public function testItNotFindAnyEvents(): void diff --git a/tests/DependencyInjection/CalendarBundleTest.php b/tests/DependencyInjection/CalendarBundleTest.php index 9f86290..7921fc1 100644 --- a/tests/DependencyInjection/CalendarBundleTest.php +++ b/tests/DependencyInjection/CalendarBundleTest.php @@ -74,7 +74,10 @@ public function testBundleLoadExtension(): void $configurator = new ContainerConfigurator($container, $loader, $instanceOf, $bundlePath, 'calendar_test'); - $bundle->loadExtension([], $configurator, $container); + $bundle->loadExtension([ + 'cache_max_age' => 300, + 'json_max_depth' => 4, + ], $configurator, $container); self::assertTrue($container->hasDefinition(Serializer::class)); self::assertTrue($container->hasDefinition(CalendarController::class)); diff --git a/tests/Entity/EventTest.php b/tests/Entity/EventTest.php index 03a2cba..7b781ef 100755 --- a/tests/Entity/EventTest.php +++ b/tests/Entity/EventTest.php @@ -60,6 +60,7 @@ public function testItShouldConvertItsValuesInToArray(): void $this->entity->removeOption('be-removed'); self::assertNull($this->entity->removeOption('no-found-key')); + self::assertNull($this->entity->getOption('non-existent-key')); $this->entity->setOptions($options); self::assertSame($options, $this->entity->getOptions()); @@ -105,4 +106,66 @@ public function testItSetPropertiesAccordingly(): void $this->entity->setResourceId($newValue); self::assertSame($newValue, $this->entity->getResourceId()); } + + public function testSetStartDoesNotMutateOriginalDateTimeForAllDayEvent(): void + { + $originalTime = '14:30:00'; + $date = new \DateTime('2024-01-15 ' . $originalTime); + + $event = new Event('All day event', $date); + + self::assertTrue($event->isAllDay()); + self::assertSame($originalTime, $date->format('H:i:s'), 'Original DateTime should not be mutated'); + self::assertSame('00:00:00', $event->getStart()->format('H:i:s'), 'Event start time should be zeroed'); + } + + public function testSetEndNullResetsAllDayToTrue(): void + { + $event = new Event( + 'Meeting', + new \DateTime('2024-01-15 14:00:00'), + new \DateTime('2024-01-15 15:00:00'), + ); + + self::assertFalse($event->isAllDay()); + + $event->setEnd(null); + + self::assertTrue($event->isAllDay()); + } + + public function testSetAllDayTrueNormalizesStartTime(): void + { + $event = new Event( + 'Meeting', + new \DateTime('2024-01-15 14:30:00'), + new \DateTime('2024-01-15 15:30:00'), + ); + + self::assertFalse($event->isAllDay()); + self::assertSame('14:30:00', $event->getStart()->format('H:i:s')); + + $event->setAllDay(true); + + self::assertTrue($event->isAllDay()); + self::assertSame('00:00:00', $event->getStart()->format('H:i:s')); + } + + public function testFluentInterface(): void + { + $event = new Event('Initial', new \DateTime('2024-01-01')); + + $result = $event + ->setTitle('Updated') + ->setResourceId('resource-1') + ->addOption('color', 'blue') + ->addOption('url', 'https://example.com') + ; + + self::assertSame($event, $result); + self::assertSame('Updated', $event->getTitle()); + self::assertSame('resource-1', $event->getResourceId()); + self::assertSame('blue', $event->getOption('color')); + self::assertSame('https://example.com', $event->getOption('url')); + } } diff --git a/tests/Event/SetDataEventTest.php b/tests/Event/SetDataEventTest.php index be650ad..4fb75e1 100755 --- a/tests/Event/SetDataEventTest.php +++ b/tests/Event/SetDataEventTest.php @@ -45,15 +45,19 @@ public function testItHasRequireValues(): void public function testItHandleEvents(): void { self::assertCount(0, $this->event->getEvents()); + self::assertSame(0, $this->event->getEventCount()); $this->event->addEvent($this->eventEntity); self::assertSame([$this->eventEntity], $this->event->getEvents()); self::assertCount(1, $this->event->getEvents()); + self::assertSame(1, $this->event->getEventCount()); $this->event->addEvent($this->eventEntity); self::assertCount(2, $this->event->getEvents()); + self::assertSame(2, $this->event->getEventCount()); $this->event->addEvent($this->eventEntity2); self::assertCount(3, $this->event->getEvents()); + self::assertSame(3, $this->event->getEventCount()); } } diff --git a/tests/Request/RequestParserTest.php b/tests/Request/RequestParserTest.php new file mode 100644 index 0000000..ea564dd --- /dev/null +++ b/tests/Request/RequestParserTest.php @@ -0,0 +1,141 @@ +parser = new RequestParser(); + } + + public function testParseValidRequest(): void + { + $request = Request::create('/fc-load-events', parameters: [ + 'start' => '2024-01-01', + 'end' => '2024-01-31', + 'filters' => '{"category":"meeting"}', + ]); + + $params = $this->parser->parse($request); + + self::assertInstanceOf(QueryParameters::class, $params); + self::assertSame('2024-01-01', $params->start->format('Y-m-d')); + self::assertSame('2024-01-31', $params->end->format('Y-m-d')); + self::assertSame(['category' => 'meeting'], $params->filters); + } + + public function testParseDefaultFilters(): void + { + $request = Request::create('/fc-load-events', parameters: [ + 'start' => '2024-01-01', + 'end' => '2024-01-31', + ]); + + $params = $this->parser->parse($request); + + self::assertSame([], $params->filters); + } + + public function testParseMissingStartThrowsException(): void + { + $this->expectException(InvalidDateException::class); + $this->expectExceptionMessage('Query parameter "start" is required'); + + $request = Request::create('/fc-load-events', parameters: [ + 'end' => '2024-01-31', + ]); + + $this->parser->parse($request); + } + + public function testParseMissingEndThrowsException(): void + { + $this->expectException(InvalidDateException::class); + $this->expectExceptionMessage('Query parameter "end" is required'); + + $request = Request::create('/fc-load-events', parameters: [ + 'start' => '2024-01-01', + ]); + + $this->parser->parse($request); + } + + public function testParseInvalidStartDateThrowsException(): void + { + $this->expectException(InvalidDateException::class); + $this->expectExceptionMessage('Query parameter "start" is not a valid date'); + + $request = Request::create('/fc-load-events', parameters: [ + 'start' => 'not-a-date', + 'end' => '2024-01-31', + ]); + + $this->parser->parse($request); + } + + public function testParseInvalidEndDateThrowsException(): void + { + $this->expectException(InvalidDateException::class); + $this->expectExceptionMessage('Query parameter "end" is not a valid date'); + + $request = Request::create('/fc-load-events', parameters: [ + 'start' => '2024-01-01', + 'end' => 'invalid', + ]); + + $this->parser->parse($request); + } + + public function testParseInvalidJsonFiltersThrowsException(): void + { + $this->expectException(InvalidJsonException::class); + $this->expectExceptionMessage('Query parameter "filters" is not valid JSON'); + + $request = Request::create('/fc-load-events', parameters: [ + 'start' => '2024-01-01', + 'end' => '2024-01-31', + 'filters' => '{invalid}', + ]); + + $this->parser->parse($request); + } + + public function testParseNestedFiltersWithinDepthLimit(): void + { + $request = Request::create('/fc-load-events', parameters: [ + 'start' => '2024-01-01', + 'end' => '2024-01-31', + 'filters' => '{"level1":{"level2":{"level3":"value"}}}', + ]); + + $params = $this->parser->parse($request); + + self::assertSame(['level1' => ['level2' => ['level3' => 'value']]], $params->filters); + } + + public function testParseDeeplyNestedFiltersThrowsException(): void + { + $this->expectException(InvalidJsonException::class); + + // Depth 5 exceeds MAX_JSON_DEPTH of 4 + $request = Request::create('/fc-load-events', parameters: [ + 'start' => '2024-01-01', + 'end' => '2024-01-31', + 'filters' => '{"l1":{"l2":{"l3":{"l4":{"l5":"too deep"}}}}}', + ]); + + $this->parser->parse($request); + } +}