diff --git a/app/Liquid/Filters/Data.php b/app/Liquid/Filters/Data.php index dd81ad8..2387ac5 100644 --- a/app/Liquid/Filters/Data.php +++ b/app/Liquid/Filters/Data.php @@ -131,6 +131,6 @@ class Data extends FiltersProvider */ public function map_to_i(array $input): array { - return array_map('intval', $input); + return array_map(intval(...), $input); } } diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index c1fe093..2915247 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -11,6 +11,7 @@ use App\Liquid\Filters\StandardFilters; use App\Liquid\Filters\StringMarkup; use App\Liquid\Filters\Uniqueness; use App\Liquid\Tags\TemplateTag; +use App\Services\Plugin\Parsers\ResponseParserRegistry; use App\Services\PluginImportService; use Carbon\Carbon; use Exception; @@ -26,7 +27,6 @@ use Illuminate\Support\Str; use Keepsuit\LaravelLiquid\LaravelLiquidExtension; use Keepsuit\Liquid\Exceptions\LiquidException; use Keepsuit\Liquid\Extensions\StandardExtension; -use SimpleXMLElement; class Plugin extends Model { @@ -216,60 +216,25 @@ class Plugin extends Model } } - /** - * Parse HTTP response, handling both JSON and XML content types - */ private function parseResponse(Response $httpResponse): array { - if ($httpResponse->header('Content-Type') && str_contains($httpResponse->header('Content-Type'), 'xml')) { + $parsers = app(ResponseParserRegistry::class)->getParsers(); + + foreach ($parsers as $parser) { + $parserName = class_basename($parser); + try { - // Convert XML to array and wrap under 'rss' key - $xml = simplexml_load_string($httpResponse->body()); - if ($xml === false) { - throw new Exception('Invalid XML content'); + $result = $parser->parse($httpResponse); + + if ($result !== null) { + return $result; } - - // Convert SimpleXML directly to array - $xmlArray = $this->xmlToArray($xml); - - return ['rss' => $xmlArray]; } catch (Exception $e) { - Log::warning('Failed to parse XML response: '.$e->getMessage()); - - return ['error' => 'Failed to parse XML response']; + Log::warning("Failed to parse {$parserName} response: ".$e->getMessage()); } } - try { - // Attempt to parse it into JSON - $json = $httpResponse->json(); - if ($json !== null) { - return $json; - } - - // Response doesn't seem to be JSON, wrap the response body text as a JSON object - return ['data' => $httpResponse->body()]; - } catch (Exception $e) { - Log::warning('Failed to parse JSON response: '.$e->getMessage()); - - return ['error' => 'Failed to parse JSON response']; - } - } - - /** - * Convert SimpleXML object to array recursively - */ - private function xmlToArray(SimpleXMLElement $xml): array - { - $array = (array) $xml; - - foreach ($array as $key => $value) { - if ($value instanceof SimpleXMLElement) { - $array[$key] = $this->xmlToArray($value); - } - } - - return $array; + return ['error' => 'Failed to parse response']; } /** diff --git a/app/Services/Plugin/Parsers/IcalResponseParser.php b/app/Services/Plugin/Parsers/IcalResponseParser.php new file mode 100644 index 0000000..f87e71c --- /dev/null +++ b/app/Services/Plugin/Parsers/IcalResponseParser.php @@ -0,0 +1,111 @@ +header('Content-Type'); + $body = $response->body(); + + if (! $this->isIcalResponse($contentType, $body)) { + return null; + } + + try { + $this->parser->parseString($body); + + $events = $this->parser->getEvents()->sorted()->getArrayCopy(); + $windowStart = now()->subDays(7); + $windowEnd = now()->addDays(30); + + $filteredEvents = array_values(array_filter($events, function (array $event) use ($windowStart, $windowEnd): bool { + $startDate = $this->asCarbon($event['DTSTART'] ?? null); + + if (!$startDate instanceof \Carbon\Carbon) { + return false; + } + + return $startDate->between($windowStart, $windowEnd, true); + })); + + $normalizedEvents = array_map($this->normalizeIcalEvent(...), $filteredEvents); + + return ['ical' => $normalizedEvents]; + } catch (Exception $exception) { + Log::warning('Failed to parse iCal response: '.$exception->getMessage()); + + return ['error' => 'Failed to parse iCal response']; + } + } + + private function isIcalResponse(?string $contentType, string $body): bool + { + $normalizedContentType = $contentType ? mb_strtolower($contentType) : ''; + + if ($normalizedContentType && str_contains($normalizedContentType, 'text/calendar')) { + return true; + } + + return str_contains($body, 'BEGIN:VCALENDAR'); + } + + private function asCarbon(DateTimeInterface|string|null $value): ?Carbon + { + if ($value instanceof Carbon) { + return $value; + } + + if ($value instanceof DateTimeInterface) { + return Carbon::instance($value); + } + + if (is_string($value) && $value !== '') { + try { + return Carbon::parse($value); + } catch (Exception $exception) { + Log::warning('Failed to parse date value: '.$exception->getMessage()); + + return null; + } + } + + return null; + } + + private function normalizeIcalEvent(array $event): array + { + $normalized = []; + + foreach ($event as $key => $value) { + $normalized[$key] = $this->normalizeIcalValue($value); + } + + return $normalized; + } + + private function normalizeIcalValue(mixed $value): mixed + { + if ($value instanceof DateTimeInterface) { + return Carbon::instance($value)->toAtomString(); + } + + if (is_array($value)) { + return array_map($this->normalizeIcalValue(...), $value); + } + + return $value; + } +} diff --git a/app/Services/Plugin/Parsers/JsonOrTextResponseParser.php b/app/Services/Plugin/Parsers/JsonOrTextResponseParser.php new file mode 100644 index 0000000..44ea0cb --- /dev/null +++ b/app/Services/Plugin/Parsers/JsonOrTextResponseParser.php @@ -0,0 +1,26 @@ +json(); + if ($json !== null) { + return $json; + } + + return ['data' => $response->body()]; + } catch (Exception $e) { + Log::warning('Failed to parse JSON response: '.$e->getMessage()); + + return ['error' => 'Failed to parse JSON response']; + } + } +} diff --git a/app/Services/Plugin/Parsers/ResponseParser.php b/app/Services/Plugin/Parsers/ResponseParser.php new file mode 100644 index 0000000..b8f9c05 --- /dev/null +++ b/app/Services/Plugin/Parsers/ResponseParser.php @@ -0,0 +1,15 @@ + + */ + private readonly array $parsers; + + /** + * @param array $parsers + */ + public function __construct(array $parsers = []) + { + $this->parsers = $parsers ?: [ + new XmlResponseParser(), + new IcalResponseParser(), + new JsonOrTextResponseParser(), + ]; + } + + /** + * @return array + */ + public function getParsers(): array + { + return $this->parsers; + } +} diff --git a/app/Services/Plugin/Parsers/XmlResponseParser.php b/app/Services/Plugin/Parsers/XmlResponseParser.php new file mode 100644 index 0000000..b82ba80 --- /dev/null +++ b/app/Services/Plugin/Parsers/XmlResponseParser.php @@ -0,0 +1,46 @@ +header('Content-Type'); + + if (! $contentType || ! str_contains(mb_strtolower($contentType), 'xml')) { + return null; + } + + try { + $xml = simplexml_load_string($response->body()); + if ($xml === false) { + throw new Exception('Invalid XML content'); + } + + return ['rss' => $this->xmlToArray($xml)]; + } catch (Exception $exception) { + Log::warning('Failed to parse XML response: '.$exception->getMessage()); + + return ['error' => 'Failed to parse XML response']; + } + } + + private function xmlToArray(SimpleXMLElement $xml): array + { + $array = (array) $xml; + + foreach ($array as $key => $value) { + if ($value instanceof SimpleXMLElement) { + $array[$key] = $this->xmlToArray($value); + } + } + + return $array; + } +} diff --git a/composer.json b/composer.json index 895b430..2281415 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "laravel/tinker": "^2.10.1", "livewire/flux": "^2.0", "livewire/volt": "^1.7", + "om/icalparser": "^3.2", "spatie/browsershot": "^5.0", "symfony/yaml": "^7.3", "wnx/sidecar-browsershot": "^2.6" diff --git a/composer.lock b/composer.lock index fde3477..9d34443 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7750ff686c4cad7f85390488c28b33ca", + "content-hash": "3e4c22c016c04e49512b5fcd20983baa", "packages": [ { "name": "aws/aws-crt-php", @@ -3710,6 +3710,57 @@ ], "time": "2025-11-20T02:34:59+00:00" }, + { + "name": "om/icalparser", + "version": "v3.2.0", + "source": { + "type": "git", + "url": "https://github.com/OzzyCzech/icalparser.git", + "reference": "3aa0716aa9e729f08fba20390773d6dcd685169b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/OzzyCzech/icalparser/zipball/3aa0716aa9e729f08fba20390773d6dcd685169b", + "reference": "3aa0716aa9e729f08fba20390773d6dcd685169b", + "shasum": "" + }, + "require": { + "php": ">=8.1.0" + }, + "require-dev": { + "nette/tester": "^2.5.6" + }, + "suggest": { + "ext-dom": "for timezone tool" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Roman Ožana", + "email": "roman@ozana.cz" + } + ], + "description": "Simple iCal parser", + "keywords": [ + "calendar", + "ical", + "parser" + ], + "support": { + "issues": "https://github.com/OzzyCzech/icalparser/issues", + "source": "https://github.com/OzzyCzech/icalparser/tree/v3.2.0" + }, + "time": "2025-09-08T07:04:53+00:00" + }, { "name": "paragonie/constant_time_encoding", "version": "v3.1.3", diff --git a/database/seeders/ExampleRecipesSeeder.php b/database/seeders/ExampleRecipesSeeder.php index 9d8e9bb..5474615 100644 --- a/database/seeders/ExampleRecipesSeeder.php +++ b/database/seeders/ExampleRecipesSeeder.php @@ -144,5 +144,42 @@ class ExampleRecipesSeeder extends Seeder 'flux_icon_name' => 'flower', ] ); + + Plugin::updateOrCreate( + [ + 'uuid' => '1d98bca4-837d-4b01-b1a1-e3b6e56eca90', + 'name' => 'Holidays (iCal)', + 'user_id' => $user_id, + 'data_payload' => null, + 'data_stale_minutes' => 720, + 'data_strategy' => 'polling', + 'configuration_template' => [ + 'custom_fields' => [ + [ + 'keyname' => 'calendar', + 'field_type' => 'select', + 'name' => 'Public Holidays Calendar', + 'options' => [ + ['USA' => 'usa'], + ['Austria' => 'austria'], + ['Australia' => 'australia'], + ['Canada' => 'canada'], + ['Germany' => 'germany'], + ['UK' => 'united-kingdom'], + ], + ], + ], + ], + 'configuration' => ['calendar' => 'usa'], + 'polling_url' => 'https://www.officeholidays.com/ics-clean/{{calendar}}', + 'polling_verb' => 'get', + 'polling_header' => null, + 'render_markup' => null, + 'render_markup_view' => 'recipes.holidays-ical', + 'detail_view_route' => null, + 'icon_url' => null, + 'flux_icon_name' => 'calendar', + ] + ); } } diff --git a/package-lock.json b/package-lock.json index 8dfc05c..8411d6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "codemirror": "^6.0.2", "concurrently": "^9.0.1", "laravel-vite-plugin": "^2.0", - "puppeteer": "24.17.0", + "puppeteer": "24.30.0", "tailwindcss": "^4.0.7", "vite": "^7.0.4" }, @@ -772,17 +772,17 @@ "license": "MIT" }, "node_modules/@puppeteer/browsers": { - "version": "2.10.7", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.7.tgz", - "integrity": "sha512-wHWLkQWBjHtajZeqCB74nsa/X70KheyOhySYBRmVQDJiNj0zjZR/naPCvdWjMhcG1LmjaMV/9WtTo5mpe8qWLw==", + "version": "2.10.13", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.13.tgz", + "integrity": "sha512-a9Ruw3j3qlnB5a/zHRTkruppynxqaeE4H9WNj5eYGRWqw0ZauZ23f4W2ARf3hghF5doozyD+CRtt7XSYuYRI/Q==", "license": "Apache-2.0", "dependencies": { - "debug": "^4.4.1", + "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", - "semver": "^7.7.2", - "tar-fs": "^3.1.0", + "semver": "^7.7.3", + "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { @@ -1709,9 +1709,9 @@ } }, "node_modules/chromium-bidi": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-8.0.0.tgz", - "integrity": "sha512-d1VmE0FD7lxZQHzcDUCKZSNRtRwISXDsdg4HjdTR5+Ll5nQ/vzU12JeNmupD6VWffrPSlrnGhEWlLESKH3VO+g==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-11.0.0.tgz", + "integrity": "sha512-cM3DI+OOb89T3wO8cpPSro80Q9eKYJ7hGVXoGS3GkDPxnYSqiv+6xwpIf6XERyJ9Tdsl09hmNmY94BkgZdVekw==", "license": "Apache-2.0", "dependencies": { "mitt": "^3.0.1", @@ -1895,9 +1895,9 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1475386", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1475386.tgz", - "integrity": "sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA==", + "version": "0.0.1521046", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz", + "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==", "license": "BSD-3-Clause", "peer": true }, @@ -3039,17 +3039,17 @@ } }, "node_modules/puppeteer": { - "version": "24.17.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.17.0.tgz", - "integrity": "sha512-CGrmJ8WgilK3nyE73k+pbxHggETPpEvL6AQ9H5JSK1RgZRGMQVJ+iO3MocGm9yBQXQJ9U5xijyLvkYXFeb0/+g==", + "version": "24.30.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.30.0.tgz", + "integrity": "sha512-A5OtCi9WpiXBQgJ2vQiZHSyrAzQmO/WDsvghqlN4kgw21PhxA5knHUaUQq/N3EMt8CcvSS0RM+kmYLJmedR3TQ==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.10.7", - "chromium-bidi": "8.0.0", + "@puppeteer/browsers": "2.10.13", + "chromium-bidi": "11.0.0", "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1475386", - "puppeteer-core": "24.17.0", + "devtools-protocol": "0.0.1521046", + "puppeteer-core": "24.30.0", "typed-query-selector": "^2.12.0" }, "bin": { @@ -3060,16 +3060,17 @@ } }, "node_modules/puppeteer-core": { - "version": "24.17.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.17.0.tgz", - "integrity": "sha512-RYOBKFiF+3RdwIZTEacqNpD567gaFcBAOKTT7742FdB1icXudrPI7BlZbYTYWK2wgGQUXt9Zi1Yn+D5PmCs4CA==", + "version": "24.30.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.30.0.tgz", + "integrity": "sha512-2S3Smy0t0W4wJnNvDe7W0bE7wDmZjfZ3ljfMgJd6hn2Hq/f0jgN+x9PULZo2U3fu5UUIJ+JP8cNUGllu8P91Pg==", "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.10.7", - "chromium-bidi": "8.0.0", - "debug": "^4.4.1", - "devtools-protocol": "0.0.1475386", + "@puppeteer/browsers": "2.10.13", + "chromium-bidi": "11.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1521046", "typed-query-selector": "^2.12.0", + "webdriver-bidi-protocol": "0.3.8", "ws": "^8.18.3" }, "engines": { @@ -3526,6 +3527,12 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.8.tgz", + "integrity": "sha512-21Yi2GhGntMc671vNBCjiAeEVknXjVRoyu+k+9xOMShu+ZQfpGQwnBqbNz/Sv4GXZ6JmutlPAi2nIJcrymAWuQ==", + "license": "Apache-2.0" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 4190067..7262ad1 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "codemirror": "^6.0.2", "concurrently": "^9.0.1", "laravel-vite-plugin": "^2.0", - "puppeteer": "24.17.0", + "puppeteer": "24.30.0", "tailwindcss": "^4.0.7", "vite": "^7.0.4" }, diff --git a/resources/views/recipes/holidays-ical.blade.php b/resources/views/recipes/holidays-ical.blade.php new file mode 100644 index 0000000..f5f5403 --- /dev/null +++ b/resources/views/recipes/holidays-ical.blade.php @@ -0,0 +1,87 @@ +@props(['size' => 'full']) +@php + use Carbon\Carbon; + + $events = collect($data['ical'] ?? []) + ->map(function (array $event): array { + $start = null; + $end = null; + + try { + $start = isset($event['DTSTART']) ? Carbon::parse($event['DTSTART'])->setTimezone(config('app.timezone')) : null; + } catch (Exception $e) { + $start = null; + } + + try { + $end = isset($event['DTEND']) ? Carbon::parse($event['DTEND'])->setTimezone(config('app.timezone')) : null; + } catch (Exception $e) { + $end = null; + } + + return [ + 'summary' => $event['SUMMARY'] ?? 'Untitled event', + 'location' => $event['LOCATION'] ?? null, + 'start' => $start, + 'end' => $end, + ]; + }) + ->filter(fn ($event) => $event['start']) + ->sortBy('start') + ->take($size === 'quadrant' ? 5 : 8) + ->values(); +@endphp + + + + + + + + Date + + + Time + + + Event + + + Location + + + + + @forelse($events as $event) + + + {{ $event['start']?->format('D, M j') }} + + + + {{ $event['start']?->format('H:i') }} + @if($event['end']) + – {{ $event['end']->format('H:i') }} + @endif + + + + {{ $event['summary'] }} + + + {{ $event['location'] ?? '—' }} + + + @empty + + + No events available + + + @endforelse + + + + + + diff --git a/tests/Feature/PluginXmlResponseTest.php b/tests/Feature/PluginResponseTest.php similarity index 69% rename from tests/Feature/PluginXmlResponseTest.php rename to tests/Feature/PluginResponseTest.php index 5811089..2a75c9e 100644 --- a/tests/Feature/PluginXmlResponseTest.php +++ b/tests/Feature/PluginResponseTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Models\Plugin; +use Carbon\Carbon; use Illuminate\Support\Facades\Http; test('plugin parses JSON responses correctly', function (): void { @@ -191,3 +192,96 @@ test('plugin handles POST requests with XML responses', function (): void { expect($plugin->data_payload['rss']['status'])->toBe('success'); expect($plugin->data_payload['rss']['data'])->toBe('test'); }); + +test('plugin parses iCal responses and filters to recent window', function (): void { + Carbon::setTestNow(Carbon::parse('2025-01-15 12:00:00', 'UTC')); + + $icalContent = <<<'ICS' +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:event-1@example.com +DTSTAMP:20250101T120000Z +DTSTART:20250110T090000Z +DTEND:20250110T100000Z +SUMMARY:Past within window +END:VEVENT +BEGIN:VEVENT +UID:event-2@example.com +DTSTAMP:20250101T120000Z +DTSTART:20250301T090000Z +DTEND:20250301T100000Z +SUMMARY:Far future +END:VEVENT +BEGIN:VEVENT +UID:event-3@example.com +DTSTAMP:20250101T120000Z +DTSTART:20250120T090000Z +DTEND:20250120T100000Z +SUMMARY:Upcoming within window +END:VEVENT +END:VCALENDAR +ICS; + + Http::fake([ + 'example.com/calendar.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/calendar']), + ]); + + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'polling', + 'polling_url' => 'https://example.com/calendar.ics', + 'polling_verb' => 'get', + ]); + + $plugin->updateDataPayload(); + $plugin->refresh(); + + $ical = $plugin->data_payload['ical']; + + expect($ical)->toHaveCount(2); + expect($ical[0]['SUMMARY'])->toBe('Past within window'); + expect($ical[1]['SUMMARY'])->toBe('Upcoming within window'); + expect(collect($ical)->pluck('SUMMARY'))->not->toContain('Far future'); + expect($ical[0]['DTSTART'])->toBe('2025-01-10T09:00:00+00:00'); + expect($ical[1]['DTSTART'])->toBe('2025-01-20T09:00:00+00:00'); + + Carbon::setTestNow(); +}); + +test('plugin detects iCal content without calendar content type', function (): void { + Carbon::setTestNow(Carbon::parse('2025-01-15 12:00:00', 'UTC')); + + $icalContent = <<<'ICS' +BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:event-body-detected@example.com +DTSTAMP:20250101T120000Z +DTSTART:20250116T090000Z +DTEND:20250116T100000Z +SUMMARY:Detected by body +END:VEVENT +END:VCALENDAR +ICS; + + Http::fake([ + 'example.com/calendar-body.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/plain']), + ]); + + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'polling', + 'polling_url' => 'https://example.com/calendar-body.ics', + 'polling_verb' => 'get', + ]); + + $plugin->updateDataPayload(); + $plugin->refresh(); + + expect($plugin->data_payload)->toHaveKey('ical'); + expect($plugin->data_payload['ical'])->toHaveCount(1); + expect($plugin->data_payload['ical'][0]['SUMMARY'])->toBe('Detected by body'); + expect($plugin->data_payload['ical'][0]['DTSTART'])->toBe('2025-01-16T09:00:00+00:00'); + + Carbon::setTestNow(); +});