diff --git a/app/Liquid/Filters/Data.php b/app/Liquid/Filters/Data.php index 2387ac5..dd81ad8 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 2915247..c1fe093 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -11,7 +11,6 @@ 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; @@ -27,6 +26,7 @@ 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,25 +216,60 @@ class Plugin extends Model } } + /** + * Parse HTTP response, handling both JSON and XML content types + */ private function parseResponse(Response $httpResponse): array { - $parsers = app(ResponseParserRegistry::class)->getParsers(); - - foreach ($parsers as $parser) { - $parserName = class_basename($parser); - + if ($httpResponse->header('Content-Type') && str_contains($httpResponse->header('Content-Type'), 'xml')) { try { - $result = $parser->parse($httpResponse); - - if ($result !== null) { - return $result; + // Convert XML to array and wrap under 'rss' key + $xml = simplexml_load_string($httpResponse->body()); + if ($xml === false) { + throw new Exception('Invalid XML content'); } + + // Convert SimpleXML directly to array + $xmlArray = $this->xmlToArray($xml); + + return ['rss' => $xmlArray]; } catch (Exception $e) { - Log::warning("Failed to parse {$parserName} response: ".$e->getMessage()); + Log::warning('Failed to parse XML response: '.$e->getMessage()); + + return ['error' => 'Failed to parse XML response']; } } - return ['error' => 'Failed to parse response']; + 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; } /** diff --git a/app/Services/Plugin/Parsers/IcalResponseParser.php b/app/Services/Plugin/Parsers/IcalResponseParser.php deleted file mode 100644 index f87e71c..0000000 --- a/app/Services/Plugin/Parsers/IcalResponseParser.php +++ /dev/null @@ -1,111 +0,0 @@ -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 deleted file mode 100644 index 44ea0cb..0000000 --- a/app/Services/Plugin/Parsers/JsonOrTextResponseParser.php +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index b8f9c05..0000000 --- a/app/Services/Plugin/Parsers/ResponseParser.php +++ /dev/null @@ -1,15 +0,0 @@ - - */ - 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 deleted file mode 100644 index b82ba80..0000000 --- a/app/Services/Plugin/Parsers/XmlResponseParser.php +++ /dev/null @@ -1,46 +0,0 @@ -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 2281415..895b430 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,6 @@ "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 9d34443..fde3477 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": "3e4c22c016c04e49512b5fcd20983baa", + "content-hash": "7750ff686c4cad7f85390488c28b33ca", "packages": [ { "name": "aws/aws-crt-php", @@ -3710,57 +3710,6 @@ ], "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 5474615..9d8e9bb 100644 --- a/database/seeders/ExampleRecipesSeeder.php +++ b/database/seeders/ExampleRecipesSeeder.php @@ -144,42 +144,5 @@ 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 8411d6a..8dfc05c 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.30.0", + "puppeteer": "24.17.0", "tailwindcss": "^4.0.7", "vite": "^7.0.4" }, @@ -772,17 +772,17 @@ "license": "MIT" }, "node_modules/@puppeteer/browsers": { - "version": "2.10.13", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.13.tgz", - "integrity": "sha512-a9Ruw3j3qlnB5a/zHRTkruppynxqaeE4H9WNj5eYGRWqw0ZauZ23f4W2ARf3hghF5doozyD+CRtt7XSYuYRI/Q==", + "version": "2.10.7", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.7.tgz", + "integrity": "sha512-wHWLkQWBjHtajZeqCB74nsa/X70KheyOhySYBRmVQDJiNj0zjZR/naPCvdWjMhcG1LmjaMV/9WtTo5mpe8qWLw==", "license": "Apache-2.0", "dependencies": { - "debug": "^4.4.3", + "debug": "^4.4.1", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", - "semver": "^7.7.3", - "tar-fs": "^3.1.1", + "semver": "^7.7.2", + "tar-fs": "^3.1.0", "yargs": "^17.7.2" }, "bin": { @@ -1709,9 +1709,9 @@ } }, "node_modules/chromium-bidi": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-11.0.0.tgz", - "integrity": "sha512-cM3DI+OOb89T3wO8cpPSro80Q9eKYJ7hGVXoGS3GkDPxnYSqiv+6xwpIf6XERyJ9Tdsl09hmNmY94BkgZdVekw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-8.0.0.tgz", + "integrity": "sha512-d1VmE0FD7lxZQHzcDUCKZSNRtRwISXDsdg4HjdTR5+Ll5nQ/vzU12JeNmupD6VWffrPSlrnGhEWlLESKH3VO+g==", "license": "Apache-2.0", "dependencies": { "mitt": "^3.0.1", @@ -1895,9 +1895,9 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1521046", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz", - "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==", + "version": "0.0.1475386", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1475386.tgz", + "integrity": "sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA==", "license": "BSD-3-Clause", "peer": true }, @@ -3039,17 +3039,17 @@ } }, "node_modules/puppeteer": { - "version": "24.30.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.30.0.tgz", - "integrity": "sha512-A5OtCi9WpiXBQgJ2vQiZHSyrAzQmO/WDsvghqlN4kgw21PhxA5knHUaUQq/N3EMt8CcvSS0RM+kmYLJmedR3TQ==", + "version": "24.17.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.17.0.tgz", + "integrity": "sha512-CGrmJ8WgilK3nyE73k+pbxHggETPpEvL6AQ9H5JSK1RgZRGMQVJ+iO3MocGm9yBQXQJ9U5xijyLvkYXFeb0/+g==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.10.13", - "chromium-bidi": "11.0.0", + "@puppeteer/browsers": "2.10.7", + "chromium-bidi": "8.0.0", "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1521046", - "puppeteer-core": "24.30.0", + "devtools-protocol": "0.0.1475386", + "puppeteer-core": "24.17.0", "typed-query-selector": "^2.12.0" }, "bin": { @@ -3060,17 +3060,16 @@ } }, "node_modules/puppeteer-core": { - "version": "24.30.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.30.0.tgz", - "integrity": "sha512-2S3Smy0t0W4wJnNvDe7W0bE7wDmZjfZ3ljfMgJd6hn2Hq/f0jgN+x9PULZo2U3fu5UUIJ+JP8cNUGllu8P91Pg==", + "version": "24.17.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.17.0.tgz", + "integrity": "sha512-RYOBKFiF+3RdwIZTEacqNpD567gaFcBAOKTT7742FdB1icXudrPI7BlZbYTYWK2wgGQUXt9Zi1Yn+D5PmCs4CA==", "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.10.13", - "chromium-bidi": "11.0.0", - "debug": "^4.4.3", - "devtools-protocol": "0.0.1521046", + "@puppeteer/browsers": "2.10.7", + "chromium-bidi": "8.0.0", + "debug": "^4.4.1", + "devtools-protocol": "0.0.1475386", "typed-query-selector": "^2.12.0", - "webdriver-bidi-protocol": "0.3.8", "ws": "^8.18.3" }, "engines": { @@ -3527,12 +3526,6 @@ "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 7262ad1..4190067 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.30.0", + "puppeteer": "24.17.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 deleted file mode 100644 index f5f5403..0000000 --- a/resources/views/recipes/holidays-ical.blade.php +++ /dev/null @@ -1,87 +0,0 @@ -@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/PluginResponseTest.php b/tests/Feature/PluginXmlResponseTest.php similarity index 69% rename from tests/Feature/PluginResponseTest.php rename to tests/Feature/PluginXmlResponseTest.php index 2a75c9e..5811089 100644 --- a/tests/Feature/PluginResponseTest.php +++ b/tests/Feature/PluginXmlResponseTest.php @@ -3,7 +3,6 @@ declare(strict_types=1); use App\Models\Plugin; -use Carbon\Carbon; use Illuminate\Support\Facades\Http; test('plugin parses JSON responses correctly', function (): void { @@ -192,96 +191,3 @@ 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(); -});