diff --git a/backend/app/Exceptions/ResourceNotFoundException.php b/backend/app/Exceptions/ResourceNotFoundException.php new file mode 100644 index 0000000000..1904e1a65e --- /dev/null +++ b/backend/app/Exceptions/ResourceNotFoundException.php @@ -0,0 +1,11 @@ + 'application/xml', + ]; + + $allHeaders = array_merge($defaultHeaders, $headers); + + return Response::make($xmlContent, $statusCode, $allHeaders); + } + protected function isActionAuthorized( int $entityId, string $entityType, diff --git a/backend/app/Http/Actions/Sitemap/GetSitemapEventsAction.php b/backend/app/Http/Actions/Sitemap/GetSitemapEventsAction.php new file mode 100644 index 0000000000..2e6707aece --- /dev/null +++ b/backend/app/Http/Actions/Sitemap/GetSitemapEventsAction.php @@ -0,0 +1,39 @@ +handler->handle($page); + $cacheTtl = (int) config('sitemap.cache_ttl'); + + return $this->xmlResponse( + xmlContent: $xml, + headers: [ + 'Content-Type' => self::CONTENT_TYPE_XML, + 'Cache-Control' => "public, max-age=$cacheTtl", + ] + ); + } catch (ResourceNotFoundException) { + return $this->notFoundResponse(); + } + } +} diff --git a/backend/app/Http/Actions/Sitemap/GetSitemapIndexAction.php b/backend/app/Http/Actions/Sitemap/GetSitemapIndexAction.php new file mode 100644 index 0000000000..88a7f2ab33 --- /dev/null +++ b/backend/app/Http/Actions/Sitemap/GetSitemapIndexAction.php @@ -0,0 +1,33 @@ +handler->handle(); + $cacheTtl = (int)config('sitemap.cache_ttl'); + + return $this->xmlResponse( + xmlContent: $xml, + headers: [ + 'Content-Type' => self::CONTENT_TYPE_XML, + 'Cache-Control' => "public, max-age=$cacheTtl", + ]); + } +} diff --git a/backend/app/Http/Actions/Sitemap/GetSitemapOrganizersAction.php b/backend/app/Http/Actions/Sitemap/GetSitemapOrganizersAction.php new file mode 100644 index 0000000000..43949683b3 --- /dev/null +++ b/backend/app/Http/Actions/Sitemap/GetSitemapOrganizersAction.php @@ -0,0 +1,38 @@ +handler->handle($page); + $cacheTtl = (int) config('sitemap.cache_ttl'); + + return $this->xmlResponse( + xmlContent: $xml, + headers: [ + 'Content-Type' => self::CONTENT_TYPE_XML, + 'Cache-Control' => "public, max-age=$cacheTtl", + ] + ); + } catch (ResourceNotFoundException) { + return $this->notFoundResponse(); + } + } +} diff --git a/backend/app/Http/DTO/GetSitemapEventsDTO.php b/backend/app/Http/DTO/GetSitemapEventsDTO.php new file mode 100644 index 0000000000..af242467c4 --- /dev/null +++ b/backend/app/Http/DTO/GetSitemapEventsDTO.php @@ -0,0 +1,14 @@ +paginate($perPage); } + + public function getSitemapEvents(int $page, int $perPage): LengthAwarePaginator + { + return $this->handleResults($this->model + ->select([ + 'events.' . EventDomainObjectAbstract::ID, + 'events.' . EventDomainObjectAbstract::TITLE, + 'events.' . EventDomainObjectAbstract::UPDATED_AT, + 'events.' . EventDomainObjectAbstract::START_DATE, + ]) + ->join('event_settings', 'events.id', '=', 'event_settings.event_id') + ->where('events.' . EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name) + ->where('event_settings.' . EventSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true) + ->whereNull('events.' . EventDomainObjectAbstract::DELETED_AT) + ->orderBy('events.' . EventDomainObjectAbstract::ID) + ->paginate($perPage, ['*'], 'page', $page)); + } + + public function getSitemapEventCount(): int + { + return $this->model + ->newQuery() + ->join('event_settings', 'events.id', '=', 'event_settings.event_id') + ->where('events.' . EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name) + ->where('event_settings.' . EventSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true) + ->whereNull('events.' . EventDomainObjectAbstract::DELETED_AT) + ->count(); + } } diff --git a/backend/app/Repository/Eloquent/OrganizerRepository.php b/backend/app/Repository/Eloquent/OrganizerRepository.php index 8ae25d8ba9..3257efc956 100644 --- a/backend/app/Repository/Eloquent/OrganizerRepository.php +++ b/backend/app/Repository/Eloquent/OrganizerRepository.php @@ -4,10 +4,14 @@ namespace HiEvents\Repository\Eloquent; +use HiEvents\DomainObjects\Generated\OrganizerDomainObjectAbstract; +use HiEvents\DomainObjects\Generated\OrganizerSettingDomainObjectAbstract; use HiEvents\DomainObjects\OrganizerDomainObject; +use HiEvents\DomainObjects\Status\OrganizerStatus; use HiEvents\Models\Organizer; use HiEvents\Repository\DTO\Organizer\OrganizerStatsResponseDTO; use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface; +use Illuminate\Pagination\LengthAwarePaginator; class OrganizerRepository extends BaseRepository implements OrganizerRepositoryInterface { @@ -21,6 +25,33 @@ public function getDomainObject(): string return OrganizerDomainObject::class; } + public function getSitemapOrganizers(int $page, int $perPage): LengthAwarePaginator + { + return $this->handleResults($this->model + ->select([ + 'organizers.' . OrganizerDomainObjectAbstract::ID, + 'organizers.' . OrganizerDomainObjectAbstract::NAME, + 'organizers.' . OrganizerDomainObjectAbstract::UPDATED_AT, + ]) + ->join('organizer_settings', 'organizers.id', '=', 'organizer_settings.organizer_id') + ->where('organizers.' . OrganizerDomainObjectAbstract::STATUS, OrganizerStatus::LIVE->name) + ->where('organizer_settings.' . OrganizerSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true) + ->whereNull('organizers.' . OrganizerDomainObjectAbstract::DELETED_AT) + ->orderBy('organizers.' . OrganizerDomainObjectAbstract::ID) + ->paginate($perPage, ['*'], 'page', $page)); + } + + public function getSitemapOrganizerCount(): int + { + return $this->model + ->newQuery() + ->join('organizer_settings', 'organizers.id', '=', 'organizer_settings.organizer_id') + ->where('organizers.' . OrganizerDomainObjectAbstract::STATUS, OrganizerStatus::LIVE->name) + ->where('organizer_settings.' . OrganizerSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true) + ->whereNull('organizers.' . OrganizerDomainObjectAbstract::DELETED_AT) + ->count(); + } + public function getOrganizerStats(int $organizerId, int $accountId, string $currencyCode): OrganizerStatsResponseDTO { $totalsQuery = << @@ -14,4 +15,8 @@ interface OrganizerRepositoryInterface extends RepositoryInterface { public function getOrganizerStats(int $organizerId, int $accountId, string $currencyCode): OrganizerStatsResponseDTO; + + public function getSitemapOrganizers(int $page, int $perPage): LengthAwarePaginator; + + public function getSitemapOrganizerCount(): int; } diff --git a/backend/app/Services/Application/Handlers/Sitemap/GetSitemapEventsHandler.php b/backend/app/Services/Application/Handlers/Sitemap/GetSitemapEventsHandler.php new file mode 100644 index 0000000000..03150c92dc --- /dev/null +++ b/backend/app/Services/Application/Handlers/Sitemap/GetSitemapEventsHandler.php @@ -0,0 +1,56 @@ +eventRepository->getSitemapEventCount(); + $totalPages = $this->calculateTotalPages($totalEvents, $eventsPerPage); + + if ($page > $totalPages) { + throw new ResourceNotFoundException(__('Page not found')); + } + + $cacheTtl = (int) config('sitemap.cache_ttl'); + $cacheKey = self::CACHE_KEY_PREFIX . $page; + + return Cache::remember($cacheKey, $cacheTtl, function () use ($page, $eventsPerPage): string { + $events = $this->eventRepository->getSitemapEvents($page, $eventsPerPage); + $baseUrl = rtrim((string) config('app.frontend_url'), '/'); + + return $this->sitemapGenerator->generateEventsSitemap( + $events->getCollection(), + $baseUrl + ); + }); + } + + private function calculateTotalPages(int $totalEvents, int $eventsPerPage): int + { + return max(self::MIN_PAGE, (int) ceil($totalEvents / $eventsPerPage)); + } +} diff --git a/backend/app/Services/Application/Handlers/Sitemap/GetSitemapIndexHandler.php b/backend/app/Services/Application/Handlers/Sitemap/GetSitemapIndexHandler.php new file mode 100644 index 0000000000..8e501615ca --- /dev/null +++ b/backend/app/Services/Application/Handlers/Sitemap/GetSitemapIndexHandler.php @@ -0,0 +1,54 @@ +eventRepository->getSitemapEventCount(); + $totalOrganizers = $this->organizerRepository->getSitemapOrganizerCount(); + + $totalEventPages = $this->calculateTotalPages($totalEvents, $eventsPerPage); + $totalOrganizerPages = $this->calculateTotalPages($totalOrganizers, $organizersPerPage); + + $baseUrl = rtrim((string) config('app.frontend_url'), '/'); + $lastMod = now()->toAtomString(); + + return $this->sitemapGenerator->generateSitemapIndex( + $totalEventPages, + $totalOrganizerPages, + $baseUrl, + $lastMod + ); + }); + } + + private function calculateTotalPages(int $total, int $perPage): int + { + return max(self::MIN_PAGES, (int) ceil($total / $perPage)); + } +} diff --git a/backend/app/Services/Application/Handlers/Sitemap/GetSitemapOrganizersHandler.php b/backend/app/Services/Application/Handlers/Sitemap/GetSitemapOrganizersHandler.php new file mode 100644 index 0000000000..8652110de9 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Sitemap/GetSitemapOrganizersHandler.php @@ -0,0 +1,58 @@ +organizerRepository->getSitemapOrganizerCount(); + $totalPages = $this->calculateTotalPages($totalOrganizers, $organizersPerPage); + + if ($page > $totalPages) { + throw new ResourceNotFoundException(__('Page not found')); + } + + $cacheTtl = (int) config('sitemap.cache_ttl'); + $cacheKey = self::CACHE_KEY_PREFIX . $page; + + return Cache::remember($cacheKey, $cacheTtl, function () use ($page, $organizersPerPage): string { + $organizers = $this->organizerRepository->getSitemapOrganizers($page, $organizersPerPage); + $baseUrl = rtrim((string) config('app.frontend_url'), '/'); + + return $this->sitemapGenerator->generateOrganizersSitemap( + $organizers->getCollection(), + $baseUrl + ); + }); + } + + private function calculateTotalPages(int $totalOrganizers, int $organizersPerPage): int + { + return max(self::MIN_PAGE, (int) ceil($totalOrganizers / $organizersPerPage)); + } +} diff --git a/backend/app/Services/Domain/Sitemap/SitemapGeneratorService.php b/backend/app/Services/Domain/Sitemap/SitemapGeneratorService.php new file mode 100644 index 0000000000..a702ba3b5d --- /dev/null +++ b/backend/app/Services/Domain/Sitemap/SitemapGeneratorService.php @@ -0,0 +1,158 @@ +createXmlWriter(); + + $writer->startDocument(self::XML_VERSION, self::XML_ENCODING); + $writer->startElement('sitemapindex'); + $writer->writeAttribute('xmlns', self::SITEMAP_NAMESPACE); + + for ($page = 1; $page <= $totalEventPages; $page++) { + $this->writeSitemapEntry($writer, $baseUrl . sprintf(self::SITEMAP_EVENTS_URL_PATTERN, $page), $lastMod); + } + + for ($page = 1; $page <= $totalOrganizerPages; $page++) { + $this->writeSitemapEntry($writer, $baseUrl . sprintf(self::SITEMAP_ORGANIZERS_URL_PATTERN, $page), $lastMod); + } + + $writer->endElement(); + $writer->endDocument(); + + return $writer->outputMemory(); + } + + private function writeSitemapEntry(XMLWriter $writer, string $loc, string $lastMod): void + { + $writer->startElement('sitemap'); + $writer->writeElement('loc', $loc); + $writer->writeElement('lastmod', $lastMod); + $writer->endElement(); + } + + /** + * @param Collection $events + */ + public function generateEventsSitemap(Collection $events, string $baseUrl): string + { + $writer = $this->createXmlWriter(); + + $writer->startDocument(self::XML_VERSION, self::XML_ENCODING); + $writer->startElement('urlset'); + $writer->writeAttribute('xmlns', self::SITEMAP_NAMESPACE); + + $now = now(); + + foreach ($events as $event) { + $this->writeEventUrl($writer, $event, $baseUrl, $now); + } + + $writer->endElement(); + $writer->endDocument(); + + return $writer->outputMemory(); + } + + private function createXmlWriter(): XMLWriter + { + $writer = new XMLWriter(); + $writer->openMemory(); + $writer->setIndent(true); + $writer->setIndentString(self::INDENT_STRING); + + return $writer; + } + + private function writeEventUrl(XMLWriter $writer, EventDomainObject $event, string $baseUrl, Carbon $now): void + { + $slug = Str::slug($event->getTitle()) ?: self::DEFAULT_EVENT_SLUG; + $eventUrl = $baseUrl . sprintf(self::EVENT_URL_PATTERN, $event->getId(), $slug); + + $isUpcoming = $this->isEventUpcoming($event, $now); + $lastMod = Carbon::parse($event->getUpdatedAt())->toAtomString(); + + $writer->startElement('url'); + $writer->writeElement('loc', $eventUrl); + $writer->writeElement('lastmod', $lastMod); + $writer->writeElement('changefreq', $isUpcoming ? self::CHANGEFREQ_DAILY : self::CHANGEFREQ_WEEKLY); + $writer->writeElement('priority', $isUpcoming ? self::PRIORITY_HIGH : self::PRIORITY_LOW); + $writer->endElement(); + } + + private function isEventUpcoming(EventDomainObject $event, Carbon $now): bool + { + $startDate = $event->getStartDate(); + + return $startDate !== null && Carbon::parse($startDate)->gte($now); + } + + /** + * @param Collection $organizers + */ + public function generateOrganizersSitemap(Collection $organizers, string $baseUrl): string + { + $writer = $this->createXmlWriter(); + + $writer->startDocument(self::XML_VERSION, self::XML_ENCODING); + $writer->startElement('urlset'); + $writer->writeAttribute('xmlns', self::SITEMAP_NAMESPACE); + + foreach ($organizers as $organizer) { + $this->writeOrganizerUrl($writer, $organizer, $baseUrl); + } + + $writer->endElement(); + $writer->endDocument(); + + return $writer->outputMemory(); + } + + private function writeOrganizerUrl(XMLWriter $writer, OrganizerDomainObject $organizer, string $baseUrl): void + { + $slug = Str::slug($organizer->getName()) ?: self::DEFAULT_ORGANIZER_SLUG; + $organizerUrl = $baseUrl . sprintf(self::ORGANIZER_URL_PATTERN, $organizer->getId(), $slug); + $lastMod = Carbon::parse($organizer->getUpdatedAt())->toAtomString(); + + $writer->startElement('url'); + $writer->writeElement('loc', $organizerUrl); + $writer->writeElement('lastmod', $lastMod); + $writer->writeElement('changefreq', self::CHANGEFREQ_WEEKLY); + $writer->writeElement('priority', self::PRIORITY_MEDIUM); + $writer->endElement(); + } +} diff --git a/backend/composer.json b/backend/composer.json index 4159c6b489..bcb60aa744 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -27,7 +27,8 @@ "spatie/icalendar-generator": "^3.0", "spatie/laravel-data": "^4.15", "spatie/laravel-webhook-server": "^3.8", - "stripe/stripe-php": "^17.0" + "stripe/stripe-php": "^17.0", + "ext-xmlwriter": "*" }, "require-dev": { "druc/laravel-langscanner": "dev-l12-compatibility", diff --git a/backend/config/sitemap.php b/backend/config/sitemap.php new file mode 100644 index 0000000000..4e6d314191 --- /dev/null +++ b/backend/config/sitemap.php @@ -0,0 +1,7 @@ + env('SITEMAP_EVENTS_PER_PAGE', 1000), + 'organizers_per_page' => env('SITEMAP_ORGANIZERS_PER_PAGE', 1000), + 'cache_ttl' => env('SITEMAP_CACHE_TTL', 3600), // 1 hour +]; diff --git a/backend/routes/api.php b/backend/routes/api.php index 419f578b80..6429b89ee4 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -134,6 +134,9 @@ use HiEvents\Http\Actions\Questions\SortQuestionsAction; use HiEvents\Http\Actions\Reports\GetOrganizerReportAction; use HiEvents\Http\Actions\Reports\GetReportAction; +use HiEvents\Http\Actions\Sitemap\GetSitemapEventsAction; +use HiEvents\Http\Actions\Sitemap\GetSitemapIndexAction; +use HiEvents\Http\Actions\Sitemap\GetSitemapOrganizersAction; use HiEvents\Http\Actions\TaxesAndFees\CreateTaxOrFeeAction; use HiEvents\Http\Actions\TaxesAndFees\DeleteTaxOrFeeAction; use HiEvents\Http\Actions\TaxesAndFees\EditTaxOrFeeAction; @@ -439,6 +442,11 @@ function (Router $router): void { // Ticket Lookup $router->post('/ticket-lookup', SendTicketLookupEmailAction::class); $router->get('/ticket-lookup/{token}', GetOrdersByLookupTokenAction::class); + + // Sitemap + $router->get('/sitemap.xml', GetSitemapIndexAction::class); + $router->get('/sitemap-events-{page}.xml', GetSitemapEventsAction::class)->where('page', '[0-9]+'); + $router->get('/sitemap-organizers-{page}.xml', GetSitemapOrganizersAction::class)->where('page', '[0-9]+'); } ); diff --git a/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapEventsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapEventsHandlerTest.php new file mode 100644 index 0000000000..5d9f15b43b --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapEventsHandlerTest.php @@ -0,0 +1,210 @@ +eventRepository = m::mock(EventRepositoryInterface::class); + $this->sitemapGenerator = m::mock(SitemapGeneratorService::class); + + $this->handler = new GetSitemapEventsHandler( + $this->eventRepository, + $this->sitemapGenerator, + ); + + config(['sitemap.cache_ttl' => 3600]); + config(['sitemap.events_per_page' => 1000]); + config(['app.frontend_url' => 'https://example.com']); + } + + public function testHandleReturnsCachedXml(): void + { + $expectedXml = ''; + + $this->eventRepository + ->shouldReceive('getSitemapEventCount') + ->once() + ->andReturn(500); + + Cache::shouldReceive('remember') + ->once() + ->with('sitemap:events:1', 3600, m::type('Closure')) + ->andReturn($expectedXml); + + $result = $this->handler->handle(1); + + $this->assertEquals($expectedXml, $result); + } + + public function testHandleGeneratesXmlWhenCacheMiss(): void + { + $expectedXml = ''; + $events = new Collection([m::mock(EventDomainObject::class)]); + $paginator = m::mock(LengthAwarePaginator::class); + $paginator->shouldReceive('getCollection')->andReturn($events); + + $this->eventRepository + ->shouldReceive('getSitemapEventCount') + ->once() + ->andReturn(500); + + $this->eventRepository + ->shouldReceive('getSitemapEvents') + ->once() + ->with(1, 1000) + ->andReturn($paginator); + + $this->sitemapGenerator + ->shouldReceive('generateEventsSitemap') + ->once() + ->with($events, 'https://example.com') + ->andReturn($expectedXml); + + Cache::shouldReceive('remember') + ->once() + ->with('sitemap:events:1', 3600, m::type('Closure')) + ->andReturnUsing(fn($key, $ttl, $callback) => $callback()); + + $result = $this->handler->handle(1); + + $this->assertEquals($expectedXml, $result); + } + + public function testHandleThrowsExceptionForPageLessThanOne(): void + { + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Page must be a positive integer'); + + $this->handler->handle(0); + } + + public function testHandleThrowsExceptionForNegativePage(): void + { + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Page must be a positive integer'); + + $this->handler->handle(-1); + } + + public function testHandleThrowsExceptionForPageBeyondTotal(): void + { + $this->eventRepository + ->shouldReceive('getSitemapEventCount') + ->once() + ->andReturn(500); + + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Page not found'); + + $this->handler->handle(2); + } + + public function testHandleAllowsLastValidPage(): void + { + config(['sitemap.events_per_page' => 100]); + + $events = new Collection([]); + $paginator = m::mock(LengthAwarePaginator::class); + $paginator->shouldReceive('getCollection')->andReturn($events); + + $this->eventRepository + ->shouldReceive('getSitemapEventCount') + ->once() + ->andReturn(250); + + $this->eventRepository + ->shouldReceive('getSitemapEvents') + ->once() + ->with(3, 100) + ->andReturn($paginator); + + $this->sitemapGenerator + ->shouldReceive('generateEventsSitemap') + ->once() + ->andReturn('xml'); + + Cache::shouldReceive('remember') + ->once() + ->andReturnUsing(fn($key, $ttl, $callback) => $callback()); + + $result = $this->handler->handle(3); + + $this->assertEquals('xml', $result); + } + + public function testHandleUsesCorrectCacheKeyForDifferentPages(): void + { + $this->eventRepository + ->shouldReceive('getSitemapEventCount') + ->andReturn(5000); + + Cache::shouldReceive('remember') + ->once() + ->with('sitemap:events:3', 3600, m::type('Closure')) + ->andReturn('xml'); + + $result = $this->handler->handle(3); + + $this->assertEquals('xml', $result); + } + + public function testHandleTrimsTrailingSlashFromBaseUrl(): void + { + config(['app.frontend_url' => 'https://example.com/']); + + $events = new Collection([]); + $paginator = m::mock(LengthAwarePaginator::class); + $paginator->shouldReceive('getCollection')->andReturn($events); + + $this->eventRepository + ->shouldReceive('getSitemapEventCount') + ->once() + ->andReturn(100); + + $this->eventRepository + ->shouldReceive('getSitemapEvents') + ->once() + ->andReturn($paginator); + + $this->sitemapGenerator + ->shouldReceive('generateEventsSitemap') + ->once() + ->with($events, 'https://example.com') + ->andReturn('xml'); + + Cache::shouldReceive('remember') + ->once() + ->andReturnUsing(fn($key, $ttl, $callback) => $callback()); + + $result = $this->handler->handle(1); + + $this->assertEquals('xml', $result); + } + + protected function tearDown(): void + { + m::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapIndexHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapIndexHandlerTest.php new file mode 100644 index 0000000000..7f6880dc80 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapIndexHandlerTest.php @@ -0,0 +1,179 @@ +eventRepository = m::mock(EventRepositoryInterface::class); + $this->organizerRepository = m::mock(OrganizerRepositoryInterface::class); + $this->sitemapGenerator = m::mock(SitemapGeneratorService::class); + + $this->handler = new GetSitemapIndexHandler( + $this->eventRepository, + $this->organizerRepository, + $this->sitemapGenerator, + ); + + config(['sitemap.cache_ttl' => 3600]); + config(['sitemap.events_per_page' => 1000]); + config(['sitemap.organizers_per_page' => 1000]); + config(['app.frontend_url' => 'https://example.com']); + } + + public function testHandleReturnsCachedXml(): void + { + $expectedXml = ''; + + Cache::shouldReceive('remember') + ->once() + ->with('sitemap:index', 3600, m::type('Closure')) + ->andReturn($expectedXml); + + $result = $this->handler->handle(); + + $this->assertEquals($expectedXml, $result); + } + + public function testHandleGeneratesXmlWhenCacheMiss(): void + { + $expectedXml = ''; + + $this->eventRepository + ->shouldReceive('getSitemapEventCount') + ->once() + ->andReturn(2500); + + $this->organizerRepository + ->shouldReceive('getSitemapOrganizerCount') + ->once() + ->andReturn(500); + + $this->sitemapGenerator + ->shouldReceive('generateSitemapIndex') + ->once() + ->with(3, 1, 'https://example.com', m::type('string')) + ->andReturn($expectedXml); + + Cache::shouldReceive('remember') + ->once() + ->with('sitemap:index', 3600, m::type('Closure')) + ->andReturnUsing(function ($key, $ttl, $callback) { + return $callback(); + }); + + $result = $this->handler->handle(); + + $this->assertEquals($expectedXml, $result); + } + + public function testHandleCalculatesCorrectPageCount(): void + { + config(['sitemap.events_per_page' => 500]); + config(['sitemap.organizers_per_page' => 500]); + + $this->eventRepository + ->shouldReceive('getSitemapEventCount') + ->once() + ->andReturn(1250); + + $this->organizerRepository + ->shouldReceive('getSitemapOrganizerCount') + ->once() + ->andReturn(750); + + $this->sitemapGenerator + ->shouldReceive('generateSitemapIndex') + ->once() + ->with(3, 2, m::any(), m::any()) + ->andReturn('xml'); + + Cache::shouldReceive('remember') + ->once() + ->andReturnUsing(fn($key, $ttl, $callback) => $callback()); + + $result = $this->handler->handle(); + + $this->assertEquals('xml', $result); + } + + public function testHandleReturnsAtLeastOnePage(): void + { + $this->eventRepository + ->shouldReceive('getSitemapEventCount') + ->once() + ->andReturn(0); + + $this->organizerRepository + ->shouldReceive('getSitemapOrganizerCount') + ->once() + ->andReturn(0); + + $this->sitemapGenerator + ->shouldReceive('generateSitemapIndex') + ->once() + ->with(1, 1, m::any(), m::any()) + ->andReturn('xml'); + + Cache::shouldReceive('remember') + ->once() + ->andReturnUsing(fn($key, $ttl, $callback) => $callback()); + + $result = $this->handler->handle(); + + $this->assertEquals('xml', $result); + } + + public function testHandleTrimsTrailingSlashFromBaseUrl(): void + { + config(['app.frontend_url' => 'https://example.com/']); + + $this->eventRepository + ->shouldReceive('getSitemapEventCount') + ->once() + ->andReturn(100); + + $this->organizerRepository + ->shouldReceive('getSitemapOrganizerCount') + ->once() + ->andReturn(50); + + $this->sitemapGenerator + ->shouldReceive('generateSitemapIndex') + ->once() + ->with(1, 1, 'https://example.com', m::any()) + ->andReturn('xml'); + + Cache::shouldReceive('remember') + ->once() + ->andReturnUsing(fn($key, $ttl, $callback) => $callback()); + + $result = $this->handler->handle(); + + $this->assertEquals('xml', $result); + } + + protected function tearDown(): void + { + m::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapOrganizersHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapOrganizersHandlerTest.php new file mode 100644 index 0000000000..448f112ecd --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapOrganizersHandlerTest.php @@ -0,0 +1,210 @@ +organizerRepository = m::mock(OrganizerRepositoryInterface::class); + $this->sitemapGenerator = m::mock(SitemapGeneratorService::class); + + $this->handler = new GetSitemapOrganizersHandler( + $this->organizerRepository, + $this->sitemapGenerator, + ); + + config(['sitemap.cache_ttl' => 3600]); + config(['sitemap.organizers_per_page' => 1000]); + config(['app.frontend_url' => 'https://example.com']); + } + + public function testHandleReturnsCachedXml(): void + { + $expectedXml = ''; + + $this->organizerRepository + ->shouldReceive('getSitemapOrganizerCount') + ->once() + ->andReturn(500); + + Cache::shouldReceive('remember') + ->once() + ->with('sitemap:organizers:1', 3600, m::type('Closure')) + ->andReturn($expectedXml); + + $result = $this->handler->handle(1); + + $this->assertEquals($expectedXml, $result); + } + + public function testHandleGeneratesXmlWhenCacheMiss(): void + { + $expectedXml = ''; + $organizers = new Collection([m::mock(OrganizerDomainObject::class)]); + $paginator = m::mock(LengthAwarePaginator::class); + $paginator->shouldReceive('getCollection')->andReturn($organizers); + + $this->organizerRepository + ->shouldReceive('getSitemapOrganizerCount') + ->once() + ->andReturn(500); + + $this->organizerRepository + ->shouldReceive('getSitemapOrganizers') + ->once() + ->with(1, 1000) + ->andReturn($paginator); + + $this->sitemapGenerator + ->shouldReceive('generateOrganizersSitemap') + ->once() + ->with($organizers, 'https://example.com') + ->andReturn($expectedXml); + + Cache::shouldReceive('remember') + ->once() + ->with('sitemap:organizers:1', 3600, m::type('Closure')) + ->andReturnUsing(fn($key, $ttl, $callback) => $callback()); + + $result = $this->handler->handle(1); + + $this->assertEquals($expectedXml, $result); + } + + public function testHandleThrowsExceptionForPageLessThanOne(): void + { + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Page must be a positive integer'); + + $this->handler->handle(0); + } + + public function testHandleThrowsExceptionForNegativePage(): void + { + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Page must be a positive integer'); + + $this->handler->handle(-1); + } + + public function testHandleThrowsExceptionForPageBeyondTotal(): void + { + $this->organizerRepository + ->shouldReceive('getSitemapOrganizerCount') + ->once() + ->andReturn(500); + + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Page not found'); + + $this->handler->handle(2); + } + + public function testHandleAllowsLastValidPage(): void + { + config(['sitemap.organizers_per_page' => 100]); + + $organizers = new Collection([]); + $paginator = m::mock(LengthAwarePaginator::class); + $paginator->shouldReceive('getCollection')->andReturn($organizers); + + $this->organizerRepository + ->shouldReceive('getSitemapOrganizerCount') + ->once() + ->andReturn(250); + + $this->organizerRepository + ->shouldReceive('getSitemapOrganizers') + ->once() + ->with(3, 100) + ->andReturn($paginator); + + $this->sitemapGenerator + ->shouldReceive('generateOrganizersSitemap') + ->once() + ->andReturn('xml'); + + Cache::shouldReceive('remember') + ->once() + ->andReturnUsing(fn($key, $ttl, $callback) => $callback()); + + $result = $this->handler->handle(3); + + $this->assertEquals('xml', $result); + } + + public function testHandleUsesCorrectCacheKeyForDifferentPages(): void + { + $this->organizerRepository + ->shouldReceive('getSitemapOrganizerCount') + ->andReturn(5000); + + Cache::shouldReceive('remember') + ->once() + ->with('sitemap:organizers:3', 3600, m::type('Closure')) + ->andReturn('xml'); + + $result = $this->handler->handle(3); + + $this->assertEquals('xml', $result); + } + + public function testHandleTrimsTrailingSlashFromBaseUrl(): void + { + config(['app.frontend_url' => 'https://example.com/']); + + $organizers = new Collection([]); + $paginator = m::mock(LengthAwarePaginator::class); + $paginator->shouldReceive('getCollection')->andReturn($organizers); + + $this->organizerRepository + ->shouldReceive('getSitemapOrganizerCount') + ->once() + ->andReturn(100); + + $this->organizerRepository + ->shouldReceive('getSitemapOrganizers') + ->once() + ->andReturn($paginator); + + $this->sitemapGenerator + ->shouldReceive('generateOrganizersSitemap') + ->once() + ->with($organizers, 'https://example.com') + ->andReturn('xml'); + + Cache::shouldReceive('remember') + ->once() + ->andReturnUsing(fn($key, $ttl, $callback) => $callback()); + + $result = $this->handler->handle(1); + + $this->assertEquals('xml', $result); + } + + protected function tearDown(): void + { + m::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Domain/Sitemap/SitemapGeneratorServiceTest.php b/backend/tests/Unit/Services/Domain/Sitemap/SitemapGeneratorServiceTest.php new file mode 100644 index 0000000000..3c151e9cd2 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Sitemap/SitemapGeneratorServiceTest.php @@ -0,0 +1,363 @@ +service = new SitemapGeneratorService(); + } + + public function testGenerateSitemapIndexWithSinglePage(): void + { + $baseUrl = 'https://example.com'; + $lastMod = '2025-01-15T10:30:00+00:00'; + + $xml = $this->service->generateSitemapIndex(1, 1, $baseUrl, $lastMod); + + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('https://example.com/sitemap-events-1.xml', $xml); + $this->assertStringContainsString('https://example.com/sitemap-organizers-1.xml', $xml); + $this->assertStringContainsString('2025-01-15T10:30:00+00:00', $xml); + $this->assertStringContainsString('', $xml); + } + + public function testGenerateSitemapIndexWithMultiplePages(): void + { + $baseUrl = 'https://example.com'; + $lastMod = '2025-01-15T10:30:00+00:00'; + + $xml = $this->service->generateSitemapIndex(3, 2, $baseUrl, $lastMod); + + $this->assertStringContainsString('https://example.com/sitemap-events-1.xml', $xml); + $this->assertStringContainsString('https://example.com/sitemap-events-2.xml', $xml); + $this->assertStringContainsString('https://example.com/sitemap-events-3.xml', $xml); + $this->assertStringNotContainsString('sitemap-events-4.xml', $xml); + $this->assertStringContainsString('https://example.com/sitemap-organizers-1.xml', $xml); + $this->assertStringContainsString('https://example.com/sitemap-organizers-2.xml', $xml); + $this->assertStringNotContainsString('sitemap-organizers-3.xml', $xml); + } + + public function testGenerateSitemapIndexIsValidXml(): void + { + $xml = $this->service->generateSitemapIndex(2, 1, 'https://example.com', '2025-01-15T10:30:00+00:00'); + + $dom = new \DOMDocument(); + $result = $dom->loadXML($xml); + + $this->assertTrue($result, 'Generated XML should be valid'); + } + + public function testGenerateEventsSitemapWithUpcomingEvent(): void + { + Carbon::setTestNow('2025-01-15 10:00:00'); + + $event = $this->createMockEvent( + id: 123, + title: 'My Amazing Event', + startDate: '2025-02-01 18:00:00', + updatedAt: '2025-01-10 12:00:00' + ); + + $events = new Collection([$event]); + $baseUrl = 'https://example.com'; + + $xml = $this->service->generateEventsSitemap($events, $baseUrl); + + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('https://example.com/event/123/my-amazing-event', $xml); + $this->assertStringContainsString('daily', $xml); + $this->assertStringContainsString('0.8', $xml); + + Carbon::setTestNow(); + } + + public function testGenerateEventsSitemapWithPastEvent(): void + { + Carbon::setTestNow('2025-01-15 10:00:00'); + + $event = $this->createMockEvent( + id: 456, + title: 'Past Concert', + startDate: '2024-12-01 18:00:00', + updatedAt: '2024-11-15 12:00:00' + ); + + $events = new Collection([$event]); + $baseUrl = 'https://example.com'; + + $xml = $this->service->generateEventsSitemap($events, $baseUrl); + + $this->assertStringContainsString('https://example.com/event/456/past-concert', $xml); + $this->assertStringContainsString('weekly', $xml); + $this->assertStringContainsString('0.5', $xml); + + Carbon::setTestNow(); + } + + public function testGenerateEventsSitemapWithSpecialCharactersInTitle(): void + { + $event = $this->createMockEvent( + id: 789, + title: 'Event with Special & "Quotes"', + startDate: '2025-02-01 18:00:00', + updatedAt: '2025-01-10 12:00:00' + ); + + $events = new Collection([$event]); + $xml = $this->service->generateEventsSitemap($events, 'https://example.com'); + + $dom = new \DOMDocument(); + $result = $dom->loadXML($xml); + + $this->assertTrue($result, 'XML with special characters should be valid'); + $this->assertStringContainsString('event-with-special-characters-quotes', $xml); + } + + public function testGenerateEventsSitemapWithEmptySlugFallsBackToDefault(): void + { + $event = $this->createMockEvent( + id: 101, + title: '日本語タイトル', + startDate: '2025-02-01 18:00:00', + updatedAt: '2025-01-10 12:00:00' + ); + + $events = new Collection([$event]); + $xml = $this->service->generateEventsSitemap($events, 'https://example.com'); + + $this->assertStringContainsString('/event/101/', $xml); + + $dom = new \DOMDocument(); + $this->assertTrue($dom->loadXML($xml)); + } + + public function testGenerateEventsSitemapWithNullStartDate(): void + { + Carbon::setTestNow('2025-01-15 10:00:00'); + + $event = $this->createMockEvent( + id: 202, + title: 'TBD Event', + startDate: null, + updatedAt: '2025-01-10 12:00:00' + ); + + $events = new Collection([$event]); + $xml = $this->service->generateEventsSitemap($events, 'https://example.com'); + + $this->assertStringContainsString('weekly', $xml); + $this->assertStringContainsString('0.5', $xml); + + Carbon::setTestNow(); + } + + public function testGenerateEventsSitemapIncludesLastModFromUpdatedAt(): void + { + Carbon::setTestNow(Carbon::parse('2025-01-15 10:00:00', 'UTC')); + + $event = $this->createMockEvent( + id: 303, + title: 'New Event', + startDate: '2025-02-01 18:00:00', + updatedAt: '2025-01-12 15:30:00' + ); + + $events = new Collection([$event]); + $xml = $this->service->generateEventsSitemap($events, 'https://example.com'); + + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('2025-01-12', $xml); + + $dom = new \DOMDocument(); + $this->assertTrue($dom->loadXML($xml)); + + Carbon::setTestNow(); + } + + public function testGenerateEventsSitemapWithEmptyCollection(): void + { + $events = new Collection([]); + $xml = $this->service->generateEventsSitemap($events, 'https://example.com'); + + $this->assertStringContainsString('urlset', $xml); + $this->assertStringContainsString('http://www.sitemaps.org/schemas/sitemap/0.9', $xml); + $this->assertStringNotContainsString('', $xml); + + $dom = new \DOMDocument(); + $this->assertTrue($dom->loadXML($xml)); + } + + public function testGenerateEventsSitemapWithMultipleEvents(): void + { + Carbon::setTestNow('2025-01-15 10:00:00'); + + $events = new Collection([ + $this->createMockEvent(1, 'Event One', '2025-02-01 18:00:00', '2025-01-10 12:00:00'), + $this->createMockEvent(2, 'Event Two', '2025-03-01 18:00:00', '2025-01-11 12:00:00'), + $this->createMockEvent(3, 'Event Three', '2024-12-01 18:00:00', '2024-11-15 12:00:00'), + ]); + + $xml = $this->service->generateEventsSitemap($events, 'https://example.com'); + + $this->assertStringContainsString('https://example.com/event/1/event-one', $xml); + $this->assertStringContainsString('https://example.com/event/2/event-two', $xml); + $this->assertStringContainsString('https://example.com/event/3/event-three', $xml); + + $dom = new \DOMDocument(); + $dom->loadXML($xml); + $urls = $dom->getElementsByTagName('url'); + $this->assertEquals(3, $urls->length); + + Carbon::setTestNow(); + } + + public function testGenerateOrganizersSitemapWithOrganizer(): void + { + $organizer = $this->createMockOrganizer( + id: 123, + name: 'My Amazing Organizer', + updatedAt: '2025-01-10 12:00:00' + ); + + $organizers = new Collection([$organizer]); + $baseUrl = 'https://example.com'; + + $xml = $this->service->generateOrganizersSitemap($organizers, $baseUrl); + + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('https://example.com/events/123/my-amazing-organizer', $xml); + $this->assertStringContainsString('weekly', $xml); + $this->assertStringContainsString('0.6', $xml); + } + + public function testGenerateOrganizersSitemapWithSpecialCharactersInName(): void + { + $organizer = $this->createMockOrganizer( + id: 789, + name: 'Organizer with Special & "Quotes"', + updatedAt: '2025-01-10 12:00:00' + ); + + $organizers = new Collection([$organizer]); + $xml = $this->service->generateOrganizersSitemap($organizers, 'https://example.com'); + + $dom = new \DOMDocument(); + $result = $dom->loadXML($xml); + + $this->assertTrue($result, 'XML with special characters should be valid'); + $this->assertStringContainsString('organizer-with-special-characters-quotes', $xml); + } + + public function testGenerateOrganizersSitemapWithEmptySlugFallsBackToDefault(): void + { + $organizer = $this->createMockOrganizer( + id: 101, + name: '日本語名', + updatedAt: '2025-01-10 12:00:00' + ); + + $organizers = new Collection([$organizer]); + $xml = $this->service->generateOrganizersSitemap($organizers, 'https://example.com'); + + $this->assertStringContainsString('/events/101/', $xml); + + $dom = new \DOMDocument(); + $this->assertTrue($dom->loadXML($xml)); + } + + public function testGenerateOrganizersSitemapWithEmptyCollection(): void + { + $organizers = new Collection([]); + $xml = $this->service->generateOrganizersSitemap($organizers, 'https://example.com'); + + $this->assertStringContainsString('urlset', $xml); + $this->assertStringContainsString('http://www.sitemaps.org/schemas/sitemap/0.9', $xml); + $this->assertStringNotContainsString('', $xml); + + $dom = new \DOMDocument(); + $this->assertTrue($dom->loadXML($xml)); + } + + public function testGenerateOrganizersSitemapWithMultipleOrganizers(): void + { + $organizers = new Collection([ + $this->createMockOrganizer(1, 'Organizer One', '2025-01-10 12:00:00'), + $this->createMockOrganizer(2, 'Organizer Two', '2025-01-11 12:00:00'), + $this->createMockOrganizer(3, 'Organizer Three', '2024-11-15 12:00:00'), + ]); + + $xml = $this->service->generateOrganizersSitemap($organizers, 'https://example.com'); + + $this->assertStringContainsString('https://example.com/events/1/organizer-one', $xml); + $this->assertStringContainsString('https://example.com/events/2/organizer-two', $xml); + $this->assertStringContainsString('https://example.com/events/3/organizer-three', $xml); + + $dom = new \DOMDocument(); + $dom->loadXML($xml); + $urls = $dom->getElementsByTagName('url'); + $this->assertEquals(3, $urls->length); + } + + public function testGenerateOrganizersSitemapIncludesLastModFromUpdatedAt(): void + { + $organizer = $this->createMockOrganizer( + id: 303, + name: 'New Organizer', + updatedAt: '2025-01-12 15:30:00' + ); + + $organizers = new Collection([$organizer]); + $xml = $this->service->generateOrganizersSitemap($organizers, 'https://example.com'); + + $this->assertStringContainsString('', $xml); + $this->assertStringContainsString('2025-01-12', $xml); + + $dom = new \DOMDocument(); + $this->assertTrue($dom->loadXML($xml)); + } + + private function createMockEvent(int $id, string $title, ?string $startDate, ?string $updatedAt): EventDomainObject + { + $event = m::mock(EventDomainObject::class); + $event->shouldReceive('getId')->andReturn($id); + $event->shouldReceive('getTitle')->andReturn($title); + $event->shouldReceive('getStartDate')->andReturn($startDate); + $event->shouldReceive('getUpdatedAt')->andReturn($updatedAt); + + return $event; + } + + private function createMockOrganizer(int $id, string $name, string $updatedAt): OrganizerDomainObject + { + $organizer = m::mock(OrganizerDomainObject::class); + $organizer->shouldReceive('getId')->andReturn($id); + $organizer->shouldReceive('getName')->andReturn($name); + $organizer->shouldReceive('getUpdatedAt')->andReturn($updatedAt); + + return $organizer; + } + + protected function tearDown(): void + { + m::close(); + parent::tearDown(); + } +} diff --git a/frontend/server.js b/frontend/server.js index ca6d03fbc8..14649760cb 100644 --- a/frontend/server.js +++ b/frontend/server.js @@ -11,6 +11,7 @@ import {fileURLToPath} from "node:url"; import * as nodePath from "node:path"; import * as nodeUrl from "node:url"; import "dotenv/config"; +import {sitemapIndexHandler, sitemapEventsHandler, sitemapOrganizersHandler} from "./src/sitemap/proxy.js"; installGlobals(); @@ -61,6 +62,22 @@ async function main() { return JSON.stringify(envVars); }; + app.get('/robots.txt', (req, res) => { + const frontendUrl = process.env.VITE_FRONTEND_URL || `${req.protocol}://${req.get('host')}`; + const robotsTxt = `User-agent: * +Allow: / + +Sitemap: ${frontendUrl}/sitemap.xml +`; + res.setHeader('Content-Type', 'text/plain'); + res.setHeader('Cache-Control', 'public, max-age=86400'); + res.status(200).send(robotsTxt); + }); + + app.get('/sitemap.xml', sitemapIndexHandler); + app.get('/sitemap-events-:page.xml', sitemapEventsHandler); + app.get('/sitemap-organizers-:page.xml', sitemapOrganizersHandler); + app.use("*", async (req, res) => { const url = req.originalUrl.replace(base, ""); diff --git a/frontend/src/sitemap/proxy.js b/frontend/src/sitemap/proxy.js new file mode 100644 index 0000000000..c6aa0fb63a --- /dev/null +++ b/frontend/src/sitemap/proxy.js @@ -0,0 +1,56 @@ +import axios from 'axios'; + +const getBackendUrl = () => { + const backendUrl = process.env.VITE_API_URL_SERVER; + if (!backendUrl) { + throw new Error('VITE_API_URL_SERVER environment variable is not set'); + } + return backendUrl; +}; + +const fetchSitemap = async (path, res, errorContext) => { + try { + const backendUrl = getBackendUrl(); + const response = await axios.get(`${backendUrl}/public${path}`, { + headers: { 'Accept': 'application/xml' }, + responseType: 'text', + }); + + res.setHeader('Content-Type', 'application/xml'); + if (response.headers['cache-control']) { + res.setHeader('Cache-Control', response.headers['cache-control']); + } + res.status(200).send(response.data); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + res.status(404).send('Sitemap not found'); + return; + } + console.error(`Error fetching ${errorContext}:`, error); + res.status(500).send('Internal server error'); + } +}; + +const validatePageParam = (page, res) => { + if (!page || !/^\d+$/.test(page)) { + res.status(400).send('Invalid page parameter'); + return false; + } + return true; +}; + +export const sitemapIndexHandler = async (_req, res) => { + await fetchSitemap('/sitemap.xml', res, 'sitemap index'); +}; + +export const sitemapEventsHandler = async (req, res) => { + const { page } = req.params; + if (!validatePageParam(page, res)) return; + await fetchSitemap(`/sitemap-events-${page}.xml`, res, 'sitemap events'); +}; + +export const sitemapOrganizersHandler = async (req, res) => { + const { page } = req.params; + if (!validatePageParam(page, res)) return; + await fetchSitemap(`/sitemap-organizers-${page}.xml`, res, 'sitemap organizers'); +};