From 06cb97e8a5fe0da96d2117101c07d5f4c261f3cf Mon Sep 17 00:00:00 2001 From: edalzell Date: Tue, 8 Sep 2026 15:03:05 -0700 Subject: [PATCH] Add first-class location and online_url for hybrid events. Declare the fields, map them unambiguously into ICS, and migrate old address/link sniffing via a 7.0 update script. --- DOCUMENTATION.md | 57 +++- resources/fieldsets/event.yaml | 44 +++ src/Types/Event.php | 67 ++-- src/Types/MultiDayEvent.php | 32 +- src/Types/RecurringEvent.php | 32 +- src/UpdateScripts/MigrateLocationFields.php | 132 ++++++++ tests/Http/Contollers/IcsControllerTest.php | 292 +++++++++++------- .../MigrateLocationFieldsTest.php | 173 +++++++++++ 8 files changed, 634 insertions(+), 195 deletions(-) create mode 100644 src/UpdateScripts/MigrateLocationFields.php create mode 100644 tests/UpdateScripts/MigrateLocationFieldsTest.php diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index b1f2579..ea4f8d8 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -42,7 +42,8 @@ If you are using a different Statamic collection, update it in the addon setting ICS downloads use the following fields if they exist: -- `address` +- `location` +- `online_url` - `coordinates` - `description` @@ -74,6 +75,26 @@ Using the sample fieldset is the fastest way to get started. ## Fields +### Location & Online URL + +`location` and `online_url` are independent and can be combined (hybrid events). + +| Field | Description | +|-------|-------------| +| `location` | Free text: a plain place description or a full address | +| `online_url` | Join link for online or hybrid events (Zoom, livestream, etc.) | +| `coordinates` | Optional `latitude` / `longitude` for ICS `GEO` | + +ICS mapping: + +| Event | `LOCATION:` | `URL:` | `GEO:` | +|-------|-------------|--------|--------| +| Physical only | `location` | — | `coordinates` | +| Online only | `online_url` | `online_url` | — | +| Hybrid | `location` | `online_url` | `coordinates` | + +A non-string `location` (for example a group field from another package) is skipped at runtime and never causes a download failure. For custom shapes, map into these handles with a [Computed Value](https://statamic.dev/content-modeling/computed-values#defining-computed-values). + ### Single-Day Events | Field | Required | Description | @@ -264,5 +285,37 @@ Generates an ICS download link. Includes: - `location` +- `online_url` +- `coordinates` - `description` -- `link` + +--- + +## Upgrading to 7.0 + +**Back up your content before upgrading.** The 7.0 update script rewrites event entries in the collections configured in addon settings. + +### Field renames + +| Before | After | +|--------|-------| +| `address` | `location` | +| `link` | `online_url` | +| string `location` that is a URL | `online_url` | + +`location` remains for place text. The old URL-sniffing behaviour (`location` treated as a join link when it looked like a URL) is removed. + +### What the update script does automatically + +- `address` → `location` +- `link` → `online_url` +- URL-valued string `location` → `online_url` +- Removes the old `address` / `link` handles after a successful migrate + +### What it skips (logged with entry IDs) + +- Non-string / array-shaped `location` (left completely untouched — another package may own that shape) +- Both `address` and a non-URL `location` set +- Both `link` and a URL-valued `location` set + +Those ambiguous entries need a manual resolve. Computed-value mappings are not rewritten by the script. diff --git a/resources/fieldsets/event.yaml b/resources/fieldsets/event.yaml index c7baee8..8bdf380 100644 --- a/resources/fieldsets/event.yaml +++ b/resources/fieldsets/event.yaml @@ -203,3 +203,47 @@ fields: field: 'events::event.all_day' config: width: 25 + - + handle: location_section + field: + type: section + display: Location + - + handle: location + field: + type: text + display: Location + localizable: true + instructions: 'A place description (e.g. "Outside the side exit") or a full address (e.g. "123 Main St, Surrey, BC").' + - + handle: online_url + field: + type: text + input_type: url + display: 'Online URL' + localizable: false + validate: + - nullable + - url + instructions: 'Join link for online or hybrid events (Zoom, livestream, etc.).' + - + handle: coordinates + field: + type: group + display: Coordinates + fullscreen: false + border: false + localizable: true + fields: + - + handle: latitude + field: + type: float + display: Latitude + width: 50 + - + handle: longitude + field: + type: float + display: Longitude + width: 50 diff --git a/src/Types/Event.php b/src/Types/Event.php index 6ca2c9c..f026539 100644 --- a/src/Types/Event.php +++ b/src/Types/Event.php @@ -7,7 +7,6 @@ use DateTimeInterface; use Illuminate\Support\Arr; use Illuminate\Support\Collection; -use Illuminate\Support\Str; use RRule\RRuleInterface; use Spatie\IcalendarGenerator\Components\Event as ICalendarEvent; use Statamic\Entries\Entry; @@ -107,59 +106,67 @@ public function toICalendarEvent(string|CarbonInterface $date): ?ICalendarEvent $immutableDate = $this->toCarbonImmutable($date); - $iCalEvent = ICalendarEvent::create($this->event->title) - ->withoutTimezone() - ->uniqueIdentifier($this->event->id()) - ->startsAt($immutableDate->setTimeFromTimeString($this->startTime())) - ->endsAt($immutableDate->setTimeFromTimeString($this->endTime())); + return $this->decorate( + ICalendarEvent::create($this->event->title) + ->withoutTimezone() + ->uniqueIdentifier($this->event->id()) + ->startsAt($immutableDate->setTimeFromTimeString($this->startTime())) + ->endsAt($immutableDate->setTimeFromTimeString($this->endTime())) + ); + } + + /** + * @return ICalendarEvent[] + */ + public function toICalendarEvents(): array + { + return Arr::wrap($this->toICalendarEvent($this->start())); + } - if ($address = $this->icsAddress()) { - $iCalEvent->address($address); + protected function decorate(ICalendarEvent $iCalEvent): ICalendarEvent + { + if ($location = $this->icsLocation()) { + $iCalEvent->address($location); } - if (! is_null($coords = $this->event->coordinates)) { - $iCalEvent->coordinates($coords['latitude'], $coords['longitude']); + if ($this->hasValidCoordinates($coords = $this->event->get('coordinates'))) { + $iCalEvent->coordinates((float) $coords['latitude'], (float) $coords['longitude']); } - if (! is_null($description = $this->event->description)) { + if (is_string($description = $this->event->get('description')) && $description !== '') { $iCalEvent->description($description); } - if (! is_null($link = $this->eventUrl())) { - $iCalEvent->url($link); + if ($url = $this->icsUrl()) { + $iCalEvent->url($url); } return $iCalEvent; } - /** - * @return ICalendarEvent[] - */ - public function toICalendarEvents(): array + protected function icsUrl(): ?string { - return Arr::wrap($this->toICalendarEvent($this->start())); + $url = $this->event->get('online_url'); + + return is_string($url) && $url !== '' ? $url : null; } - protected function eventUrl(): ?string + protected function icsLocation(): ?string { - if (! is_null($link = $this->event->link)) { - return $link; - } - $location = $this->event->get('location'); - if (! is_string($location)) { - return null; + if (is_string($location) && $location !== '') { + return $location; } - return Str::isUrl($location) ? $location : null; + return $this->icsUrl(); } - protected function icsAddress(): ?string + protected function hasValidCoordinates(mixed $coords): bool { - $address = $this->event->address ?? $this->event->get('location'); - - return is_string($address) && $address !== '' ? $address : null; + return is_array($coords) + && is_numeric($coords['latitude'] ?? null) + && is_numeric($coords['longitude'] ?? null); } protected function supplement(CarbonInterface $date): ?Entry diff --git a/src/Types/MultiDayEvent.php b/src/Types/MultiDayEvent.php index 9dd2a46..9c5db39 100644 --- a/src/Types/MultiDayEvent.php +++ b/src/Types/MultiDayEvent.php @@ -59,28 +59,12 @@ public function toICalendarEvent(string|CarbonInterface $date): ?ICalendarEvent $immutableDate = $this->toCarbonImmutable($date); $day = $this->getDayFromDate($immutableDate); - $iCalEvent = ICalendarEvent::create($this->event->title) - ->uniqueIdentifier($this->event->id()) - ->startsAt($immutableDate->setTimeFromTimeString($day->start())) - ->endsAt($immutableDate->setTimeFromTimeString($day->end())); - - if ($address = $this->icsAddress()) { - $iCalEvent->address($address); - } - - if (! is_null($coords = $this->event->coordinates)) { - $iCalEvent->coordinates($coords['latitude'], $coords['longitude']); - } - - if (! is_null($description = $this->event->description)) { - $iCalEvent->description($description); - } - - if (! is_null($link = $this->eventUrl())) { - $iCalEvent->url($link); - } - - return $iCalEvent; + return $this->decorate( + ICalendarEvent::create($this->event->title) + ->uniqueIdentifier($this->event->id()) + ->startsAt($immutableDate->setTimeFromTimeString($day->start())) + ->endsAt($immutableDate->setTimeFromTimeString($day->end())) + ); } /** @@ -89,7 +73,9 @@ public function toICalendarEvent(string|CarbonInterface $date): ?ICalendarEvent public function toICalendarEvents(): array { return collect($this->days) - ->map(fn (Day $day, int $index) => $day->toICalendarEvent($this->event->title, $index)) + ->map(fn (Day $day, int $index) => $this->decorate( + $day->toICalendarEvent($this->event->title, $index) + )) ->all(); } diff --git a/src/Types/RecurringEvent.php b/src/Types/RecurringEvent.php index 69c8120..6969481 100644 --- a/src/Types/RecurringEvent.php +++ b/src/Types/RecurringEvent.php @@ -27,29 +27,15 @@ public function interval(): int */ public function toICalendarEvents(): array { - $iCalEvent = ICalendarEvent::create($this->event->title) - ->uniqueIdentifier($this->event->id()) - ->startsAt($this->start()) - ->endsAt($this->end()) - ->rrule($this->spatieRule()); - - if ($address = $this->icsAddress()) { - $iCalEvent->address($address); - } - - if (! is_null($coords = $this->event->coordinates)) { - $iCalEvent->coordinates($coords['latitude'], $coords['longitude']); - } - - if (! is_null($description = $this->event->description)) { - $iCalEvent->description($description); - } - - if (! is_null($link = $this->eventUrl())) { - $iCalEvent->url($link); - } - - return [$iCalEvent]; + return [ + $this->decorate( + ICalendarEvent::create($this->event->title) + ->uniqueIdentifier($this->event->id()) + ->startsAt($this->start()) + ->endsAt($this->end()) + ->rrule($this->spatieRule()) + ), + ]; } protected function rule(bool $useEnd = false): RRuleInterface diff --git a/src/UpdateScripts/MigrateLocationFields.php b/src/UpdateScripts/MigrateLocationFields.php new file mode 100644 index 0000000..cf34243 --- /dev/null +++ b/src/UpdateScripts/MigrateLocationFields.php @@ -0,0 +1,132 @@ +isUpdatingTo('7.0'); + } + + public function update() + { + $this->skippedArrayLocation = collect(); + $this->skippedAddressConflict = collect(); + $this->skippedLinkConflict = collect(); + + $collections = collect(Events::setting('collections', ['events']))->filter()->values(); + + EntryFacade::query() + ->whereIn('collection', $collections->all()) + ->get() + ->each(fn (Entry $entry) => $this->migrate($entry)); + + $this->reportSkips(); + } + + private function migrate(Entry $entry): void + { + $address = $entry->get('address'); + $link = $entry->get('link'); + $location = $entry->get('location'); + + if (! is_null($location) && ! is_string($location)) { + $this->skippedArrayLocation->push($entry->id()); + + return; + } + + $locationIsUrl = is_string($location) && $location !== '' && Str::isUrl($location); + $hasNonUrlLocation = is_string($location) && $location !== '' && ! $locationIsUrl; + $hasAddress = is_string($address) && $address !== ''; + $hasLink = is_string($link) && $link !== ''; + + if ($hasAddress && $hasNonUrlLocation) { + $this->skippedAddressConflict->push($entry->id()); + + return; + } + + if ($hasLink && $locationIsUrl) { + $this->skippedLinkConflict->push($entry->id()); + + return; + } + + $dirty = false; + + if ($hasAddress) { + $entry->set('location', $address); + $entry->remove('address'); + $dirty = true; + + if ($locationIsUrl) { + $entry->set('online_url', $location); + } + } elseif ($locationIsUrl) { + $entry->set('online_url', $location); + $entry->remove('location'); + $dirty = true; + } + + if ($hasLink) { + $entry->set('online_url', $link); + $entry->remove('link'); + $dirty = true; + } + + if ($entry->has('address')) { + $entry->remove('address'); + $dirty = true; + } + + if ($entry->has('link')) { + $entry->remove('link'); + $dirty = true; + } + + if ($dirty) { + $entry->save(); + } + } + + private function reportSkips(): void + { + if ($this->skippedArrayLocation->isNotEmpty()) { + $this->console()->warn( + 'Skipped entries with non-string location (owned by another package): ' + .$this->skippedArrayLocation->unique()->implode(', ') + ); + } + + if ($this->skippedAddressConflict->isNotEmpty()) { + $this->console()->warn( + 'Skipped entries with both address and a non-URL location (resolve manually): ' + .$this->skippedAddressConflict->unique()->implode(', ') + ); + } + + if ($this->skippedLinkConflict->isNotEmpty()) { + $this->console()->warn( + 'Skipped entries with both link and a URL-valued location (resolve manually): ' + .$this->skippedLinkConflict->unique()->implode(', ') + ); + } + + $this->console()->info('Migrated event location fields to location / online_url.'); + } +} diff --git a/tests/Http/Contollers/IcsControllerTest.php b/tests/Http/Contollers/IcsControllerTest.php index 1f66697..9920cb4 100755 --- a/tests/Http/Contollers/IcsControllerTest.php +++ b/tests/Http/Contollers/IcsControllerTest.php @@ -15,8 +15,7 @@ 'start_date' => Carbon::now()->toDateString(), 'start_time' => '11:00', 'end_time' => '12:00', - 'address' => '123 Main St', - 'location' => 'The Location', + 'location' => '123 Main St', 'coordinates' => [ 'latitude' => 40, 'longitude' => 50, @@ -35,10 +34,147 @@ $content = $response->streamedContent(); - $this->assertStringContainsString('DTSTART:'.now()->setTimeFromTimeString('11:00')->format('Ymd\THis'), $response->streamedContent()); + $this->assertStringContainsString('DTSTART:'.now()->setTimeFromTimeString('11:00')->format('Ymd\THis'), $content); $this->assertStringContainsString('LOCATION:123 Main St', $content); $this->assertStringContainsString('DESCRIPTION:The description', $content); $this->assertStringContainsString('GEO:40;50', $content); + $this->assertStringNotContainsString('URL:', $content); +}); + +test('physical only maps location without url', function () { + Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); + + Entry::make() + ->collection('events') + ->slug('physical-event') + ->id('physical-id') + ->data([ + 'title' => 'Physical Event', + 'start_date' => Carbon::now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'location' => 'Outside the side exit', + ])->save(); + + $content = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'physical-id', + ]))->assertDownload('physical-event.ics')->streamedContent(); + + $this->assertStringContainsString('LOCATION:Outside the side exit', $content); + $this->assertStringNotContainsString('URL:', $content); +}); + +test('online only maps online_url to location and url', function () { + Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); + + Entry::make() + ->collection('events') + ->slug('online-event') + ->id('online-id') + ->data([ + 'title' => 'Online Event', + 'start_date' => Carbon::now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'online_url' => 'https://zoom.us/j/123', + ])->save(); + + $content = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'online-id', + ]))->assertDownload('online-event.ics')->streamedContent(); + + $this->assertStringContainsString('LOCATION:https://zoom.us/j/123', $content); + $this->assertStringContainsString('URL:https://zoom.us/j/123', $content); +}); + +test('hybrid maps location and online_url separately', function () { + Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); + + Entry::make() + ->collection('events') + ->slug('hybrid-event') + ->id('hybrid-id') + ->data([ + 'title' => 'Hybrid Event', + 'start_date' => Carbon::now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'location' => '123 Main St, Surrey, BC', + 'online_url' => 'https://zoom.us/j/456', + 'coordinates' => [ + 'latitude' => 40, + 'longitude' => 50, + ], + ])->save(); + + $content = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'hybrid-id', + ]))->assertDownload('hybrid-event.ics')->streamedContent(); + + $this->assertStringContainsString('LOCATION:123 Main St\\, Surrey\\, BC', $content); + $this->assertStringContainsString('URL:https://zoom.us/j/456', $content); + $this->assertStringContainsString('GEO:40;50', $content); +}); + +test('partial coordinates are not fatal and omit geo', function () { + Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); + + Entry::make() + ->collection('events') + ->slug('partial-coords-event') + ->id('partial-coords-id') + ->data([ + 'title' => 'Partial Coords Event', + 'start_date' => Carbon::now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'location' => 'The Hall', + 'coordinates' => [ + 'latitude' => 40, + ], + ])->save(); + + $content = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'partial-coords-id', + ]))->assertDownload('partial-coords-event.ics')->streamedContent(); + + $this->assertStringContainsString('LOCATION:The Hall', $content); + $this->assertStringNotContainsString('GEO:', $content); +}); + +test('non-string location is ignored rather than fatal', function () { + Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); + + Entry::make() + ->collection('events') + ->slug('grouped-location-event') + ->id('the-grouped-location-id') + ->data([ + 'title' => 'Grouped Location Event', + 'start_date' => Carbon::now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + 'location' => [ + 'details' => 'Virtual', + 'coordinates' => [ + 'latitude' => 40, + 'longitude' => 50, + ], + ], + 'description' => 'The description', + ])->save(); + + $content = $this->get(route('statamic.events.ics.show', [ + 'date' => now()->toDateString(), + 'event' => 'the-grouped-location-id', + ]))->assertDownload('grouped-location-event.ics')->streamedContent(); + + $this->assertStringNotContainsString('LOCATION:', $content); + $this->assertStringContainsString('DESCRIPTION:The description', $content); }); test('can create single day recurring event ics file', function () { @@ -172,6 +308,42 @@ $this->assertStringContainsString('DESCRIPTION:The description', $response->streamedContent()); }); +test('multi-day whole-event download includes location url and description', function () { + Carbon::setTestNow(now()); + + Entry::make() + ->slug('multi-day-whole-event') + ->collection('events') + ->id('the-multi-day-whole-event') + ->data([ + 'title' => 'Multi-day Whole Event', + 'multi_day' => true, + 'location' => 'The Hall', + 'online_url' => 'https://zoom.us/j/789', + 'description' => 'The description', + 'days' => [ + [ + 'date' => now()->toDateString(), + 'start_time' => '19:00', + 'end_time' => '21:00', + ], + [ + 'date' => now()->addDay()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '15:00', + ], + ], + ])->save(); + + $content = $this->get(route('statamic.events.ics.show', [ + 'event' => 'the-multi-day-whole-event', + ]))->assertDownload('multi-day-whole-event.ics')->streamedContent(); + + $this->assertStringContainsString('LOCATION:The Hall', $content); + $this->assertStringContainsString('URL:https://zoom.us/j/789', $content); + $this->assertStringContainsString('DESCRIPTION:The description', $content); +}); + test('throws 404 error when event does not occur on date', function () { Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); @@ -196,117 +368,3 @@ 'event' => 'the-id', ]))->assertStatus(404); }); - -test('can create ics when location is a group without address', function () { - Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); - - Entry::make() - ->collection('events') - ->slug('grouped-location-event') - ->id('the-grouped-location-id') - ->data([ - 'title' => 'Grouped Location Event', - 'start_date' => Carbon::now()->toDateString(), - 'start_time' => '11:00', - 'end_time' => '12:00', - 'location' => [ - 'details' => 'Virtual', - 'coordinates' => [ - 'latitude' => 40, - 'longitude' => 50, - ], - ], - 'description' => 'The description', - ])->save(); - - $response = $this->get(route('statamic.events.ics.show', [ - 'date' => now()->toDateString(), - 'event' => 'the-grouped-location-id', - ]))->assertDownload('grouped-location-event.ics'); - - $content = $response->streamedContent(); - - $this->assertStringNotContainsString('LOCATION:', $content); - $this->assertStringContainsString('DESCRIPTION:The description', $content); -}); - -test('falls back to string location when address is missing', function () { - Carbon::setTestNow(now()->setTimeFromTimeString('10:00')); - - Entry::make() - ->collection('events') - ->slug('location-fallback-event') - ->id('the-location-fallback-id') - ->data([ - 'title' => 'Location Fallback Event', - 'start_date' => Carbon::now()->toDateString(), - 'start_time' => '11:00', - 'end_time' => '12:00', - 'location' => 'The Location', - 'description' => 'The description', - ])->save(); - - $response = $this->get(route('statamic.events.ics.show', [ - 'date' => now()->toDateString(), - 'event' => 'the-location-fallback-id', - ]))->assertDownload('location-fallback-event.ics'); - - $this->assertStringContainsString('LOCATION:The Location', $response->streamedContent()); -}); - -test('can create recurring ics when location is a group without address', function () { - Carbon::setTestNow(now()->addDay()->setTimeFromTimeString('10:00')); - - Entry::make() - ->collection('events') - ->slug('grouped-recurring-event') - ->id('the-grouped-recurring-id') - ->data([ - 'title' => 'Grouped Recurring Event', - 'start_date' => Carbon::now()->toDateString(), - 'start_time' => '11:00', - 'end_time' => '12:00', - 'recurrence' => 'weekly', - 'location' => [ - 'details' => 'Virtual', - ], - 'description' => 'The description', - ])->save(); - - $response = $this->get(route('statamic.events.ics.show', [ - 'event' => 'the-grouped-recurring-id', - ]))->assertDownload('grouped-recurring-event.ics'); - - $this->assertStringNotContainsString('LOCATION:', $response->streamedContent()); -}); - -test('can create multi-day ics when location is a group without address', function () { - Carbon::setTestNow(now()); - - Entry::make() - ->slug('grouped-multi-day-event') - ->collection('events') - ->id('the-grouped-multi-day-id') - ->data([ - 'title' => 'Grouped Multi-day Event', - 'multi_day' => true, - 'location' => [ - 'details' => 'Virtual', - ], - 'description' => 'The description', - 'days' => [ - [ - 'date' => now()->toDateString(), - 'start_time' => '19:00', - 'end_time' => '21:00', - ], - ], - ])->save(); - - $response = $this->get(route('statamic.events.ics.show', [ - 'date' => now()->toDateString(), - 'event' => 'the-grouped-multi-day-id', - ]))->assertDownload('grouped-multi-day-event.ics'); - - $this->assertStringNotContainsString('LOCATION:', $response->streamedContent()); -}); diff --git a/tests/UpdateScripts/MigrateLocationFieldsTest.php b/tests/UpdateScripts/MigrateLocationFieldsTest.php new file mode 100644 index 0000000..32789bb --- /dev/null +++ b/tests/UpdateScripts/MigrateLocationFieldsTest.php @@ -0,0 +1,173 @@ +assertUpdateScriptRegistered(MigrateLocationFields::class); +}); + +test('migrates address to location', function () { + Entry::make() + ->collection('events') + ->slug('address-event') + ->id('address-id') + ->data([ + 'title' => 'Address Event', + 'start_date' => now()->toDateString(), + 'address' => '123 Main St', + ])->save(); + + $this->runUpdateScript(MigrateLocationFields::class); + + $entry = Entry::find('address-id'); + + expect($entry->get('location'))->toBe('123 Main St') + ->and($entry->get('address'))->toBeNull(); +}); + +test('migrates link to online_url', function () { + Entry::make() + ->collection('events') + ->slug('link-event') + ->id('link-id') + ->data([ + 'title' => 'Link Event', + 'start_date' => now()->toDateString(), + 'link' => 'https://zoom.us/j/123', + ])->save(); + + $this->runUpdateScript(MigrateLocationFields::class); + + $entry = Entry::find('link-id'); + + expect($entry->get('online_url'))->toBe('https://zoom.us/j/123') + ->and($entry->get('link'))->toBeNull(); +}); + +test('migrates url-valued location to online_url', function () { + Entry::make() + ->collection('events') + ->slug('url-location-event') + ->id('url-location-id') + ->data([ + 'title' => 'URL Location Event', + 'start_date' => now()->toDateString(), + 'location' => 'https://zoom.us/j/123', + ])->save(); + + $this->runUpdateScript(MigrateLocationFields::class); + + $entry = Entry::find('url-location-id'); + + expect($entry->get('online_url'))->toBe('https://zoom.us/j/123') + ->and($entry->get('location'))->toBeNull(); +}); + +test('migrates address and url-valued location together', function () { + Entry::make() + ->collection('events') + ->slug('address-url-event') + ->id('address-url-id') + ->data([ + 'title' => 'Address URL Event', + 'start_date' => now()->toDateString(), + 'address' => '123 Main St', + 'location' => 'https://zoom.us/j/123', + ])->save(); + + $this->runUpdateScript(MigrateLocationFields::class); + + $entry = Entry::find('address-url-id'); + + expect($entry->get('location'))->toBe('123 Main St') + ->and($entry->get('online_url'))->toBe('https://zoom.us/j/123') + ->and($entry->get('address'))->toBeNull(); +}); + +test('leaves non-url string location untouched', function () { + Entry::make() + ->collection('events') + ->slug('place-event') + ->id('place-id') + ->data([ + 'title' => 'Place Event', + 'start_date' => now()->toDateString(), + 'location' => 'Outside the side exit', + ])->save(); + + $this->runUpdateScript(MigrateLocationFields::class); + + expect(Entry::find('place-id')->get('location'))->toBe('Outside the side exit'); +}); + +test('skips array-shaped location completely', function () { + Entry::make() + ->collection('events') + ->slug('group-location-event') + ->id('group-location-id') + ->data([ + 'title' => 'Group Location Event', + 'start_date' => now()->toDateString(), + 'location' => [ + 'details' => 'Virtual', + ], + 'address' => '123 Main St', + 'link' => 'https://zoom.us/j/123', + ])->save(); + + $this->runUpdateScript(MigrateLocationFields::class); + + $entry = Entry::find('group-location-id'); + + expect($entry->get('location'))->toBe([ + 'details' => 'Virtual', + ]) + ->and($entry->get('address'))->toBe('123 Main St') + ->and($entry->get('link'))->toBe('https://zoom.us/j/123') + ->and($entry->get('online_url'))->toBeNull(); +}); + +test('skips address and non-url location conflict', function () { + Entry::make() + ->collection('events') + ->slug('address-conflict-event') + ->id('address-conflict-id') + ->data([ + 'title' => 'Address Conflict Event', + 'start_date' => now()->toDateString(), + 'address' => '123 Main St', + 'location' => 'The Hall', + ])->save(); + + $this->runUpdateScript(MigrateLocationFields::class); + + $entry = Entry::find('address-conflict-id'); + + expect($entry->get('address'))->toBe('123 Main St') + ->and($entry->get('location'))->toBe('The Hall'); +}); + +test('skips link and url-valued location conflict', function () { + Entry::make() + ->collection('events') + ->slug('link-conflict-event') + ->id('link-conflict-id') + ->data([ + 'title' => 'Link Conflict Event', + 'start_date' => now()->toDateString(), + 'link' => 'https://zoom.us/j/111', + 'location' => 'https://zoom.us/j/222', + ])->save(); + + $this->runUpdateScript(MigrateLocationFields::class); + + $entry = Entry::find('link-conflict-id'); + + expect($entry->get('link'))->toBe('https://zoom.us/j/111') + ->and($entry->get('location'))->toBe('https://zoom.us/j/222') + ->and($entry->get('online_url'))->toBeNull(); +});