diff --git a/tests/Unit/AdminTest.php b/tests/Unit/AdminTest.php index 95e1152..bb1c775 100644 --- a/tests/Unit/AdminTest.php +++ b/tests/Unit/AdminTest.php @@ -1273,4 +1273,241 @@ public function testEnqueueScriptsForNewAnnouncementPage(): void { $this->assertContains('mayo-admin', $enqueuedScripts); } + + /** + * Test handle_copy_event fails with invalid nonce + */ + public function testHandleCopyEventFailsWithInvalidNonce(): void { + $_POST['_ajax_nonce'] = 'invalid-nonce'; + $_POST['post_id'] = '123'; + + $died = false; + Functions\when('wp_verify_nonce')->justReturn(false); + Functions\when('wp_die')->alias(function($msg) use (&$died) { + $died = true; + }); + // Mock wp_send_json_error since execution continues in tests + Functions\when('current_user_can')->justReturn(false); + Functions\when('wp_send_json_error')->justReturn(null); + + Admin::handle_copy_event(); + + $this->assertTrue($died); + + $_POST = []; + } + + /** + * Test handle_copy_event fails without post ID + */ + public function testHandleCopyEventFailsWithoutPostId(): void { + $_POST['_ajax_nonce'] = 'valid-nonce'; + $_POST['post_id'] = '0'; + + $errorSent = false; + Functions\when('wp_verify_nonce')->justReturn(true); + Functions\when('current_user_can')->justReturn(true); + Functions\when('wp_send_json_error')->alias(function($msg) use (&$errorSent) { + $errorSent = true; + }); + + Admin::handle_copy_event(); + + $this->assertTrue($errorSent); + + $_POST = []; + } + + /** + * Test handle_copy_event fails for non mayo_event post + */ + public function testHandleCopyEventFailsForNonMayoEvent(): void { + $_POST['_ajax_nonce'] = 'valid-nonce'; + $_POST['post_id'] = '123'; + + $post = $this->createMockPost([ + 'ID' => 123, + 'post_type' => 'post' // Not mayo_event + ]); + + $errorSent = false; + Functions\when('wp_verify_nonce')->justReturn(true); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post')->justReturn($post); + Functions\when('wp_send_json_error')->alias(function($msg) use (&$errorSent) { + $errorSent = true; + }); + + Admin::handle_copy_event(); + + $this->assertTrue($errorSent); + + $_POST = []; + } + + /** + * Test handle_copy_event successfully copies event + */ + public function testHandleCopyEventSuccessfullyCopiesEvent(): void { + $_POST['_ajax_nonce'] = 'valid-nonce'; + $_POST['post_id'] = '100'; + + $originalPost = $this->createMockPost([ + 'ID' => 100, + 'post_title' => 'Original Event', + 'post_content' => 'Event content', + 'post_type' => 'mayo_event' + ]); + + $this->setPostMeta(100, [ + 'event_type' => 'conference', + 'service_body' => '5', + 'event_start_date' => '2025-06-01', + 'event_end_date' => '2025-06-02' + ]); + + $successData = null; + Functions\when('wp_verify_nonce')->justReturn(true); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post')->justReturn($originalPost); + Functions\when('get_current_user_id')->justReturn(1); + Functions\when('wp_insert_post')->justReturn(200); + Functions\when('update_post_meta')->justReturn(true); + Functions\when('has_post_thumbnail')->justReturn(false); + Functions\when('wp_get_post_categories')->justReturn([]); + Functions\when('wp_get_post_tags')->justReturn([]); + Functions\when('get_edit_post_link')->justReturn('http://example.com/edit/200'); + Functions\when('wp_send_json_success')->alias(function($data) use (&$successData) { + $successData = $data; + }); + + Admin::handle_copy_event(); + + $this->assertNotNull($successData); + $this->assertEquals(200, $successData['new_post_id']); + $this->assertStringContainsString('edit/200', $successData['edit_url']); + + $_POST = []; + } + + /** + * Test handle_copy_event copies featured image + */ + public function testHandleCopyEventCopiesFeaturedImage(): void { + $_POST['_ajax_nonce'] = 'valid-nonce'; + $_POST['post_id'] = '100'; + + $originalPost = $this->createMockPost([ + 'ID' => 100, + 'post_title' => 'Event with Image', + 'post_content' => 'Content', + 'post_type' => 'mayo_event' + ]); + + $this->setPostMeta(100, []); + + $thumbnailSet = false; + Functions\when('wp_verify_nonce')->justReturn(true); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post')->justReturn($originalPost); + Functions\when('get_current_user_id')->justReturn(1); + Functions\when('wp_insert_post')->justReturn(201); + Functions\when('update_post_meta')->justReturn(true); + Functions\when('has_post_thumbnail')->justReturn(true); + Functions\when('get_post_thumbnail_id')->justReturn(50); + Functions\when('set_post_thumbnail')->alias(function($post_id, $thumb_id) use (&$thumbnailSet) { + $thumbnailSet = ($thumb_id === 50); + return true; + }); + Functions\when('wp_get_post_categories')->justReturn([]); + Functions\when('wp_get_post_tags')->justReturn([]); + Functions\when('get_edit_post_link')->justReturn('http://example.com/edit/201'); + Functions\when('wp_send_json_success')->justReturn(null); + + Admin::handle_copy_event(); + + $this->assertTrue($thumbnailSet); + + $_POST = []; + } + + /** + * Test handle_copy_event copies categories and tags + */ + public function testHandleCopyEventCopiesCategoriesAndTags(): void { + $_POST['_ajax_nonce'] = 'valid-nonce'; + $_POST['post_id'] = '100'; + + $originalPost = $this->createMockPost([ + 'ID' => 100, + 'post_title' => 'Event with Taxonomies', + 'post_content' => 'Content', + 'post_type' => 'mayo_event' + ]); + + $this->setPostMeta(100, []); + + $categoriesSet = false; + $tagsSet = false; + Functions\when('wp_verify_nonce')->justReturn(true); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post')->justReturn($originalPost); + Functions\when('get_current_user_id')->justReturn(1); + Functions\when('wp_insert_post')->justReturn(202); + Functions\when('update_post_meta')->justReturn(true); + Functions\when('has_post_thumbnail')->justReturn(false); + Functions\when('wp_get_post_categories')->justReturn([1, 2, 3]); + Functions\when('wp_set_post_categories')->alias(function($post_id, $cats) use (&$categoriesSet) { + $categoriesSet = ($cats === [1, 2, 3]); + return true; + }); + Functions\when('wp_get_post_tags')->justReturn([ + (object)['term_id' => 5], + (object)['term_id' => 6] + ]); + Functions\when('wp_set_post_tags')->alias(function($post_id, $tags) use (&$tagsSet) { + $tagsSet = true; + return true; + }); + Functions\when('get_edit_post_link')->justReturn('http://example.com/edit/202'); + Functions\when('wp_send_json_success')->justReturn(null); + + Admin::handle_copy_event(); + + $this->assertTrue($categoriesSet); + $this->assertTrue($tagsSet); + + $_POST = []; + } + + /** + * Test handle_copy_event handles wp_insert_post failure + */ + public function testHandleCopyEventHandlesInsertFailure(): void { + $_POST['_ajax_nonce'] = 'valid-nonce'; + $_POST['post_id'] = '100'; + + $originalPost = $this->createMockPost([ + 'ID' => 100, + 'post_title' => 'Original Event', + 'post_content' => 'Content', + 'post_type' => 'mayo_event' + ]); + + $errorSent = false; + Functions\when('wp_verify_nonce')->justReturn(true); + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post')->justReturn($originalPost); + Functions\when('get_current_user_id')->justReturn(1); + Functions\when('wp_insert_post')->justReturn(new \WP_Error('error', 'Insert failed')); + Functions\when('wp_send_json_error')->alias(function($msg) use (&$errorSent) { + $errorSent = true; + }); + + Admin::handle_copy_event(); + + $this->assertTrue($errorSent); + + $_POST = []; + } } diff --git a/tests/Unit/AnnouncementTest.php b/tests/Unit/AnnouncementTest.php index 938c8ca..ec2e43d 100644 --- a/tests/Unit/AnnouncementTest.php +++ b/tests/Unit/AnnouncementTest.php @@ -1195,4 +1195,412 @@ public function testResolveEventRefReturnsNullWithoutId(): void { $ref2 = ['type' => 'external', 'source_id' => 'source1']; // No id $this->assertNull(Announcement::resolve_event_ref($ref2)); } + + /** + * Test get_active_announcements returns published announcements + */ + public function testGetActiveAnnouncementsReturnsPublishedAnnouncements(): void { + $announcement1 = $this->createMockPost([ + 'ID' => 500, + 'post_title' => 'Test Announcement 1', + 'post_type' => 'mayo_announcement', + 'post_status' => 'publish', + 'post_content' => 'Content 1' + ]); + + $announcement2 = $this->createMockPost([ + 'ID' => 501, + 'post_title' => 'Test Announcement 2', + 'post_type' => 'mayo_announcement', + 'post_status' => 'publish', + 'post_content' => 'Content 2' + ]); + + Functions\when('get_posts')->justReturn([$announcement1, $announcement2]); + Functions\when('wp_parse_args')->alias(function($args, $defaults) { + return array_merge($defaults, $args); + }); + Functions\when('get_the_excerpt')->justReturn('Test excerpt'); + Functions\when('get_the_post_thumbnail_url')->justReturn(''); + + $this->setPostMeta(500, [ + 'display_start_date' => date('Y-m-d', strtotime('-1 day')), + 'display_end_date' => date('Y-m-d', strtotime('+1 day')), + 'priority' => 'normal', + 'linked_event_refs' => [] + ]); + + $this->setPostMeta(501, [ + 'display_start_date' => '', + 'display_end_date' => '', + 'priority' => 'high', + 'linked_event_refs' => [] + ]); + + $result = Announcement::get_active_announcements(); + + $this->assertCount(2, $result); + $this->assertEquals('Test Announcement 1', $result[0]['title']); + $this->assertEquals('Test Announcement 2', $result[1]['title']); + } + + /** + * Test get_active_announcements resolves linked local events + */ + public function testGetActiveAnnouncementsResolvesLinkedLocalEvents(): void { + $announcement = $this->createMockPost([ + 'ID' => 600, + 'post_title' => 'Event Announcement', + 'post_type' => 'mayo_announcement', + 'post_status' => 'publish', + 'post_content' => 'Check out this event' + ]); + + $event = $this->createMockPost([ + 'ID' => 700, + 'post_title' => 'Linked Local Event', + 'post_name' => 'linked-local-event', + 'post_type' => 'mayo_event', + 'post_status' => 'publish' + ]); + + Functions\when('get_posts')->justReturn([$announcement]); + Functions\when('wp_parse_args')->alias(function($args, $defaults) { + return array_merge($defaults, $args); + }); + Functions\when('get_the_excerpt')->justReturn('Test excerpt'); + Functions\when('get_the_post_thumbnail_url')->justReturn(''); + + $this->setPostMeta(600, [ + 'display_start_date' => '', + 'display_end_date' => '', + 'priority' => 'normal', + 'linked_event_refs' => [ + ['type' => 'local', 'id' => 700] + ] + ]); + + $this->setPostMeta(700, [ + 'event_start_date' => '2025-05-15', + 'event_end_date' => '2025-05-15', + 'event_start_time' => '10:00', + 'event_end_time' => '12:00', + 'location_name' => 'Test Venue', + 'location_address' => '123 Test St' + ]); + + Functions\when('get_post')->justReturn($event); + + $result = Announcement::get_active_announcements(); + + $this->assertCount(1, $result); + $this->assertArrayHasKey('linked_events', $result[0]); + $this->assertCount(1, $result[0]['linked_events']); + $this->assertEquals('Linked Local Event', $result[0]['linked_events'][0]['title']); + $this->assertEquals('local', $result[0]['linked_events'][0]['source']['type']); + } + + /** + * Test get_active_announcements handles multiple linked event types + */ + public function testGetActiveAnnouncementsHandlesMultipleLinkedEventTypes(): void { + $announcement = $this->createMockPost([ + 'ID' => 601, + 'post_title' => 'Multi-Event Announcement', + 'post_type' => 'mayo_announcement', + 'post_status' => 'publish', + 'post_content' => 'Multiple events' + ]); + + $localEvent = $this->createMockPost([ + 'ID' => 701, + 'post_title' => 'Local Event', + 'post_name' => 'local-event', + 'post_type' => 'mayo_event' + ]); + + Functions\when('get_posts')->justReturn([$announcement]); + Functions\when('wp_parse_args')->alias(function($args, $defaults) { + return array_merge($defaults, $args); + }); + Functions\when('get_the_excerpt')->justReturn('Test excerpt'); + Functions\when('get_the_post_thumbnail_url')->justReturn(''); + + $this->setPostMeta(601, [ + 'display_start_date' => '', + 'display_end_date' => '', + 'priority' => 'normal', + 'linked_event_refs' => [ + ['type' => 'local', 'id' => 701], + ['type' => 'custom', 'url' => 'https://example.com/info', 'title' => 'More Info', 'icon' => 'external'], + ['type' => 'external', 'id' => 123, 'source_id' => 'source1'] + ] + ]); + + $this->setPostMeta(701, [ + 'event_start_date' => '2025-06-01', + 'event_end_date' => '2025-06-01', + 'event_start_time' => '14:00', + 'event_end_time' => '16:00', + 'location_name' => '', + 'location_address' => '' + ]); + + Functions\when('get_post')->alias(function($id) use ($localEvent) { + if ($id === 701) return $localEvent; + return null; + }); + + // Mock external event fetch + $this->mockWpRemoteGet([ + 'event-manager/v1/events' => [ + 'code' => 200, + 'body' => [ + 'id' => 123, + 'title' => 'External Event', + 'slug' => 'external-event', + 'meta' => [ + 'event_start_date' => '2025-06-05', + 'event_end_date' => '2025-06-05' + ] + ] + ] + ]); + $this->mockTrailingslashit(); + Functions\when('wp_remote_retrieve_response_code')->justReturn(200); + + $result = Announcement::get_active_announcements(); + + $this->assertCount(1, $result); + // At least local and custom should resolve (external may fail without full mock) + $this->assertGreaterThanOrEqual(2, count($result[0]['linked_events'])); + } + + /** + * Test get_active_announcements excludes expired announcements + */ + public function testGetActiveAnnouncementsExcludesExpiredAnnouncements(): void { + $activeAnnouncement = $this->createMockPost([ + 'ID' => 602, + 'post_title' => 'Active Announcement', + 'post_type' => 'mayo_announcement', + 'post_status' => 'publish', + 'post_content' => 'Still active' + ]); + + Functions\when('get_posts')->justReturn([$activeAnnouncement]); + Functions\when('wp_parse_args')->alias(function($args, $defaults) { + return array_merge($defaults, $args); + }); + Functions\when('get_the_excerpt')->justReturn('Test excerpt'); + Functions\when('get_the_post_thumbnail_url')->justReturn(''); + + $this->setPostMeta(602, [ + 'display_start_date' => date('Y-m-d', strtotime('-7 days')), + 'display_end_date' => date('Y-m-d', strtotime('+7 days')), + 'priority' => 'normal', + 'linked_event_refs' => [] + ]); + + $result = Announcement::get_active_announcements(); + + $this->assertCount(1, $result); + $this->assertEquals('Active Announcement', $result[0]['title']); + } + + /** + * Test get_active_announcements filters by service body + */ + public function testGetActiveAnnouncementsFiltersByServiceBody(): void { + $announcement = $this->createMockPost([ + 'ID' => 603, + 'post_title' => 'Area Announcement', + 'post_type' => 'mayo_announcement', + 'post_status' => 'publish', + 'post_content' => 'For specific area' + ]); + + Functions\when('get_posts')->justReturn([$announcement]); + Functions\when('wp_parse_args')->alias(function($args, $defaults) { + return array_merge($defaults, $args); + }); + Functions\when('get_the_excerpt')->justReturn('Test excerpt'); + Functions\when('get_the_post_thumbnail_url')->justReturn(''); + + $this->setPostMeta(603, [ + 'display_start_date' => '', + 'display_end_date' => '', + 'priority' => 'normal', + 'service_body' => '10', + 'linked_event_refs' => [] + ]); + + $result = Announcement::get_active_announcements(['service_body' => '10']); + + $this->assertCount(1, $result); + $this->assertEquals('Area Announcement', $result[0]['title']); + } + + /** + * Test get_active_announcements returns empty when no announcements + */ + public function testGetActiveAnnouncementsReturnsEmptyWhenNoAnnouncements(): void { + Functions\when('get_posts')->justReturn([]); + Functions\when('wp_parse_args')->alias(function($args, $defaults) { + return array_merge($defaults, $args); + }); + + $result = Announcement::get_active_announcements(); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + /** + * Test render_custom_columns handles unknown column gracefully + */ + public function testRenderCustomColumnsHandlesUnknownColumnGracefully(): void { + $this->setPostMeta(700, []); + + ob_start(); + Announcement::render_custom_columns('unknown_column', 700); + $output = ob_get_clean(); + + // Should not crash and output should be empty or minimal + $this->assertIsString($output); + } + + /** + * Test render_custom_columns linked_events with local events only + */ + public function testRenderCustomColumnsLinkedEventsWithLocalEvents(): void { + $event1 = $this->createMockPost([ + 'ID' => 801, + 'post_title' => 'First Event', + 'post_type' => 'mayo_event' + ]); + + $event2 = $this->createMockPost([ + 'ID' => 802, + 'post_title' => 'Second Event', + 'post_type' => 'mayo_event' + ]); + + $this->setPostMeta(119, [ + 'linked_event_refs' => [ + ['type' => 'local', 'id' => 801], + ['type' => 'local', 'id' => 802] + ] + ]); + + Functions\when('get_post')->alias(function($id) use ($event1, $event2) { + if ($id === 801) return $event1; + if ($id === 802) return $event2; + return null; + }); + + ob_start(); + Announcement::render_custom_columns('linked_events', 119); + $output = ob_get_clean(); + + $this->assertStringContainsString('First Event', $output); + $this->assertStringContainsString('Second Event', $output); + } + + + /** + * Test fetch_external_event returns null when event not found + */ + public function testFetchExternalEventReturnsNullWhenEventNotFound(): void { + $this->mockWpRemoteGet([ + 'event-manager/v1/events' => [ + 'code' => 200, + 'body' => null // No event returned + ] + ]); + $this->mockTrailingslashit(); + Functions\when('wp_remote_retrieve_response_code')->justReturn(200); + + $result = Announcement::fetch_external_event('source1', 999); + + $this->assertNull($result); + } + + /** + * Test resolve_event_ref handles malformed custom link + */ + public function testResolveEventRefHandlesMalformedCustomLink(): void { + // Custom link with empty URL + $ref = [ + 'type' => 'custom', + 'url' => '', + 'title' => 'Broken Link' + ]; + $this->assertNull(Announcement::resolve_event_ref($ref)); + + // Custom link with empty title + $ref2 = [ + 'type' => 'custom', + 'url' => 'https://example.com', + 'title' => '' + ]; + $this->assertNull(Announcement::resolve_event_ref($ref2)); + } + + /** + * Test get_active_announcements handles WP_Error from get_posts + */ + public function testGetActiveAnnouncementsHandlesWpError(): void { + Functions\when('wp_parse_args')->alias(function($args, $defaults) { + return array_merge($defaults, $args); + }); + Functions\when('get_posts')->justReturn(new \WP_Error('db_error', 'Database error')); + + $result = Announcement::get_active_announcements(); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + /** + * Test build_external_event_permalink with valid source and slug + */ + public function testBuildExternalEventPermalinkWithValidSourceAndSlug(): void { + $source = ['url' => 'https://external-site.example.com']; + $this->mockTrailingslashit(); + + $result = Announcement::build_external_event_permalink($source, 'test-event-slug'); + + $this->assertStringContainsString('external-site.example.com', $result); + $this->assertStringContainsString('test-event-slug', $result); + } + + /** + * Test handle_custom_orderby returns original for non-admin + */ + public function testHandleCustomOrderbyReturnsOriginalForNonAdmin(): void { + Functions\when('is_admin')->justReturn(false); + + $mockQuery = \Mockery::mock('WP_Query'); + $mockQuery->shouldReceive('is_main_query')->andReturn(true); + $mockQuery->shouldReceive('get')->with('orderby')->andReturn('title'); + + $result = Announcement::handle_custom_orderby('original_orderby', $mockQuery); + + $this->assertEquals('original_orderby', $result); + } + + /** + * Test handle_custom_orderby returns original for non-main query + */ + public function testHandleCustomOrderbyReturnsOriginalForNonMainQuery(): void { + Functions\when('is_admin')->justReturn(true); + + $mockQuery = \Mockery::mock('WP_Query'); + $mockQuery->shouldReceive('is_main_query')->andReturn(false); + + $result = Announcement::handle_custom_orderby('original_orderby', $mockQuery); + + $this->assertEquals('original_orderby', $result); + } } diff --git a/tests/Unit/CalendarFeedTest.php b/tests/Unit/CalendarFeedTest.php index 01f969e..5235169 100644 --- a/tests/Unit/CalendarFeedTest.php +++ b/tests/Unit/CalendarFeedTest.php @@ -323,6 +323,216 @@ public function testGetIcsItemsUsesDefaultTimezone(): void { $this->assertCount(1, $result); } + /** + * Test get_ics_items with both categories and tags filters + */ + public function testGetIcsItemsWithCategoriesAndTags(): void { + Functions\when('get_posts')->justReturn([]); + + $method = $this->getPrivateMethod('get_ics_items'); + $result = $method->invoke(null, '', '', 'AND', 'news,events', 'featured,important'); + + $this->assertIsArray($result); + } + + /** + * Test get_ics_items with OR relation + */ + public function testGetIcsItemsWithOrRelation(): void { + Functions\when('get_posts')->justReturn([]); + + $method = $this->getPrivateMethod('get_ics_items'); + $result = $method->invoke(null, 'Service', '5', 'OR'); + + $this->assertIsArray($result); + } + + /** + * Test get_ics_items handles DateTime exception gracefully + */ + public function testGetIcsItemsHandlesDateTimeException(): void { + $post = $this->createMockPost([ + 'ID' => 200, + 'post_title' => 'Event With Bad Date', + 'post_content' => 'Test', + 'post_type' => 'mayo_event' + ]); + + // Set invalid date that will cause DateTime exception + $this->setPostMeta(200, [ + 'event_type' => 'Meeting', + 'event_start_date' => 'invalid-date-format', + 'event_end_date' => 'also-invalid', + 'event_start_time' => 'not-a-time', + 'event_end_time' => 'also-not-time', + 'timezone' => 'Invalid/Timezone' + ]); + + Functions\when('get_posts')->justReturn([$post]); + + $method = $this->getPrivateMethod('get_ics_items'); + $result = $method->invoke(null); + + // Should handle the exception gracefully and return empty or skip the event + $this->assertIsArray($result); + } + + /** + * Test get_ics_items handles events with missing meta + */ + public function testGetIcsItemsHandlesMissingMeta(): void { + $post = $this->createMockPost([ + 'ID' => 201, + 'post_title' => 'Event Without Meta', + 'post_content' => '', + 'post_type' => 'mayo_event' + ]); + + // Empty meta - all defaults should kick in + $this->setPostMeta(201, []); + + Functions\when('get_posts')->justReturn([$post]); + + $method = $this->getPrivateMethod('get_ics_items'); + $result = $method->invoke(null); + + // Should handle gracefully + $this->assertIsArray($result); + } + + /** + * Test get_ics_items builds correct description with location + */ + public function testGetIcsItemsBuildsCorrectDescriptionWithLocation(): void { + $post = $this->createMockPost([ + 'ID' => 202, + 'post_title' => 'Event With Location', + 'post_content' => 'This is the event description.', + 'post_type' => 'mayo_event' + ]); + + $this->setPostMeta(202, [ + 'event_type' => 'Convention', + 'event_start_date' => date('Y-m-d', strtotime('+10 days')), + 'event_end_date' => date('Y-m-d', strtotime('+12 days')), + 'event_start_time' => '09:00:00', + 'event_end_time' => '17:00:00', + 'timezone' => 'America/Los_Angeles', + 'location_name' => 'Grand Convention Center' + ]); + + Functions\when('get_posts')->justReturn([$post]); + + $method = $this->getPrivateMethod('get_ics_items'); + $result = $method->invoke(null); + + $this->assertCount(1, $result); + $this->assertStringContainsString('Convention', $result[0]['description']); + $this->assertStringContainsString('Grand Convention Center', $result[0]['description']); + $this->assertStringContainsString('This is the event description', $result[0]['description']); + } + + /** + * Test get_ics_items handles multiple events + */ + public function testGetIcsItemsHandlesMultipleEvents(): void { + $post1 = $this->createMockPost([ + 'ID' => 203, + 'post_title' => 'First Event', + 'post_content' => 'Description 1', + 'post_type' => 'mayo_event' + ]); + + $post2 = $this->createMockPost([ + 'ID' => 204, + 'post_title' => 'Second Event', + 'post_content' => 'Description 2', + 'post_type' => 'mayo_event' + ]); + + $this->setPostMeta(203, [ + 'event_type' => 'Meeting', + 'event_start_date' => date('Y-m-d', strtotime('+5 days')), + 'event_end_date' => date('Y-m-d', strtotime('+5 days')), + 'event_start_time' => '18:00:00', + 'event_end_time' => '20:00:00', + 'timezone' => 'UTC' + ]); + + $this->setPostMeta(204, [ + 'event_type' => 'Service', + 'event_start_date' => date('Y-m-d', strtotime('+14 days')), + 'event_end_date' => date('Y-m-d', strtotime('+14 days')), + 'event_start_time' => '10:00:00', + 'event_end_time' => '14:00:00', + 'timezone' => 'UTC' + ]); + + Functions\when('get_posts')->justReturn([$post1, $post2]); + + $method = $this->getPrivateMethod('get_ics_items'); + $result = $method->invoke(null); + + $this->assertCount(2, $result); + $this->assertEquals('First Event', $result[0]['summary']); + $this->assertEquals('Second Event', $result[1]['summary']); + } + + /** + * Test escape_ical_text handles empty string + */ + public function testEscapeIcalTextHandlesEmptyString(): void { + $method = $this->getPrivateMethod('escape_ical_text'); + + $result = $method->invoke(null, ''); + + $this->assertEquals('', $result); + } + + /** + * Test escape_ical_text handles multiple line breaks + */ + public function testEscapeIcalTextHandlesMultipleLineBreaks(): void { + $method = $this->getPrivateMethod('escape_ical_text'); + + $result = $method->invoke(null, "Line 1\n\nLine 2\n\n\nLine 3"); + + // All newlines should be escaped + $this->assertStringNotContainsString("\n", $result); + } + + /** + * Test get_ics_items strips HTML from content + */ + public function testGetIcsItemsStripsHtmlFromContent(): void { + $post = $this->createMockPost([ + 'ID' => 205, + 'post_title' => 'Event With HTML', + 'post_content' => '
This is bold text with links.
', + 'post_type' => 'mayo_event' + ]); + + $this->setPostMeta(205, [ + 'event_type' => 'Meeting', + 'event_start_date' => date('Y-m-d', strtotime('+8 days')), + 'event_end_date' => date('Y-m-d', strtotime('+8 days')), + 'event_start_time' => '19:00:00', + 'event_end_time' => '21:00:00', + 'timezone' => 'UTC' + ]); + + Functions\when('get_posts')->justReturn([$post]); + + $method = $this->getPrivateMethod('get_ics_items'); + $result = $method->invoke(null); + + $this->assertCount(1, $result); + // HTML should be stripped + $this->assertStringNotContainsString('', $result[0]['description']); + $this->assertStringNotContainsString('', $result[0]['description']); + $this->assertStringContainsString('bold', $result[0]['description']); + } + /** * Helper to get private method */ diff --git a/tests/Unit/FrontendTest.php b/tests/Unit/FrontendTest.php index 55fb061..65fc782 100644 --- a/tests/Unit/FrontendTest.php +++ b/tests/Unit/FrontendTest.php @@ -328,4 +328,146 @@ public function testMultipleShortcodeInstancesGetUniqueIds(): void { // Instances should be different $this->assertNotEquals($matches1[1], $matches2[1]); } + + /** + * Test is_shortcode_present_in_widgets returns false when no sidebars + */ + public function testIsShortcodePresentInWidgetsReturnsFalseWhenNoSidebars(): void { + global $wp_registered_sidebars; + $wp_registered_sidebars = []; + + $method = new \ReflectionMethod(Frontend::class, 'is_shortcode_present_in_widgets'); + $method->setAccessible(true); + + $result = $method->invoke(null, 'mayo_event_list'); + + $this->assertFalse($result); + } + + /** + * Test is_shortcode_present_in_widgets returns false for inactive sidebar + */ + public function testIsShortcodePresentInWidgetsReturnsFalseForInactiveSidebar(): void { + global $wp_registered_sidebars; + $wp_registered_sidebars = ['sidebar-1' => ['id' => 'sidebar-1', 'name' => 'Test Sidebar']]; + + Functions\when('is_active_sidebar')->justReturn(false); + + $method = new \ReflectionMethod(Frontend::class, 'is_shortcode_present_in_widgets'); + $method->setAccessible(true); + + $result = $method->invoke(null, 'mayo_event_list'); + + $this->assertFalse($result); + } + + /** + * Test is_shortcode_present_in_widgets returns true when shortcode found + */ + public function testIsShortcodePresentInWidgetsReturnsTrueWhenShortcodeFound(): void { + global $wp_registered_sidebars, $wp_registered_widgets; + + $widget_obj = new \stdClass(); + $widget_obj->id_base = 'text'; + + $wp_registered_sidebars = ['sidebar-1' => ['id' => 'sidebar-1', 'name' => 'Test Sidebar']]; + $wp_registered_widgets = [ + 'text-1' => [ + 'callback' => [$widget_obj, 'widget'] + ] + ]; + + Functions\when('is_active_sidebar')->justReturn(true); + Functions\when('wp_get_sidebars_widgets')->justReturn([ + 'sidebar-1' => ['text-1'] + ]); + Functions\when('get_option')->justReturn([ + 1 => ['content' => '[mayo_event_list]'] + ]); + Functions\when('has_shortcode')->justReturn(true); + + $method = new \ReflectionMethod(Frontend::class, 'is_shortcode_present_in_widgets'); + $method->setAccessible(true); + + $result = $method->invoke(null, 'mayo_event_list'); + + $this->assertTrue($result); + } + + /** + * Test is_shortcode_present_in_widgets returns false when shortcode not in content + */ + public function testIsShortcodePresentInWidgetsReturnsFalseWhenNoShortcode(): void { + global $wp_registered_sidebars, $wp_registered_widgets; + + $widget_obj = new \stdClass(); + $widget_obj->id_base = 'text'; + + $wp_registered_sidebars = ['sidebar-1' => ['id' => 'sidebar-1', 'name' => 'Test Sidebar']]; + $wp_registered_widgets = [ + 'text-1' => [ + 'callback' => [$widget_obj, 'widget'] + ] + ]; + + Functions\when('is_active_sidebar')->justReturn(true); + Functions\when('wp_get_sidebars_widgets')->justReturn([ + 'sidebar-1' => ['text-1'] + ]); + Functions\when('get_option')->justReturn([ + 1 => ['content' => 'Just some text'] + ]); + Functions\when('has_shortcode')->justReturn(false); + + $method = new \ReflectionMethod(Frontend::class, 'is_shortcode_present_in_widgets'); + $method->setAccessible(true); + + $result = $method->invoke(null, 'mayo_event_list'); + + $this->assertFalse($result); + } + + /** + * Test enqueue_scripts when shortcode present in widgets + */ + public function testEnqueueScriptsWhenShortcodeInWidgets(): void { + global $wp_registered_sidebars, $wp_registered_widgets; + + $widget_obj = new \stdClass(); + $widget_obj->id_base = 'text'; + + $wp_registered_sidebars = ['sidebar-1' => ['id' => 'sidebar-1', 'name' => 'Test Sidebar']]; + $wp_registered_widgets = [ + 'text-1' => [ + 'callback' => [$widget_obj, 'widget'] + ] + ]; + + $enqueuedScripts = []; + + Functions\when('get_post')->justReturn(null); + Functions\when('is_active_sidebar')->justReturn(true); + Functions\when('wp_get_sidebars_widgets')->justReturn([ + 'sidebar-1' => ['text-1'] + ]); + Functions\when('get_option')->justReturn([ + 1 => ['content' => '[mayo_event_list]'] + ]); + Functions\when('has_shortcode')->justReturn(true); + Functions\when('is_post_type_archive')->justReturn(false); + Functions\when('is_singular')->justReturn(false); + Functions\when('wp_enqueue_script')->alias(function($name) use (&$enqueuedScripts) { + $enqueuedScripts[] = $name; + }); + Functions\when('wp_enqueue_style')->justReturn(true); + Functions\when('plugin_dir_url')->justReturn('https://example.com/plugins/mayo/'); + Functions\when('wp_localize_script')->justReturn(true); + Functions\when('rest_url')->justReturn('https://example.com/wp-json/'); + Functions\when('esc_url_raw')->alias(function($url) { return $url; }); + Functions\when('wp_create_nonce')->justReturn('test-nonce'); + + Frontend::enqueue_scripts(); + + $this->assertContains('mayo-public', $enqueuedScripts); + } } diff --git a/tests/Unit/Rest/EventsControllerTest.php b/tests/Unit/Rest/EventsControllerTest.php index 98e6e1f..780d051 100644 --- a/tests/Unit/Rest/EventsControllerTest.php +++ b/tests/Unit/Rest/EventsControllerTest.php @@ -1448,4 +1448,5 @@ public function testGetEventsWithMonthlyLastWeekdayOfMonth(): void { $this->assertInstanceOf(\WP_REST_Response::class, $response); } + } diff --git a/tests/Unit/Rest/Helpers/FileUploadTest.php b/tests/Unit/Rest/Helpers/FileUploadTest.php index e3dac18..3d4cc07 100644 --- a/tests/Unit/Rest/Helpers/FileUploadTest.php +++ b/tests/Unit/Rest/Helpers/FileUploadTest.php @@ -130,4 +130,41 @@ public function testProcessUploadsSkipsEmptyFiles(): void { $_FILES = []; } + + /** + * Test get_uploaded_file_names skips files with empty names + */ + public function testGetUploadedFileNamesSkipsFilesWithEmptyNames(): void { + $_FILES = [ + 'file1' => ['name' => 'valid.txt'], + 'file2' => ['name' => ''], + 'file3' => ['name' => 'another.doc'] + ]; + + $result = FileUpload::get_uploaded_file_names(); + + $this->assertCount(2, $result); + $this->assertContains('valid.txt', $result); + $this->assertContains('another.doc', $result); + + $_FILES = []; + } + + /** + * Test maybe_set_featured_image with svg image type + */ + public function testMaybeSetFeaturedImageWithSvgImage(): void { + $setCalled = false; + + Functions\when('has_post_thumbnail')->justReturn(false); + Functions\when('set_post_thumbnail')->alias(function() use (&$setCalled) { + $setCalled = true; + return true; + }); + + FileUpload::maybe_set_featured_image(123, 456, 'image/svg+xml'); + + // SVG is an image type + $this->assertTrue($setCalled); + } } diff --git a/tests/Unit/RestTest.php b/tests/Unit/RestTest.php new file mode 100644 index 0000000..2e73ade --- /dev/null +++ b/tests/Unit/RestTest.php @@ -0,0 +1,85 @@ +alias(function($tag, $callback, $priority = 10) use (&$actionsAdded) { + $actionsAdded[] = $tag; + }); + + Rest::init(); + + $this->assertContains('rest_api_init', $actionsAdded); + } + + /** + * Test register_routes delegates to all controllers + */ + public function testRegisterRoutesDelegatesToControllers(): void { + // Since register_routes just calls other static methods, + // we verify it doesn't throw an exception + Rest::register_routes(); + + $this->assertTrue(true); + } + + /** + * Test bmltenabled_mayo_get_events returns WP_REST_Response + */ + public function testBmltenabledMayoGetEventsReturnsRestResponse(): void { + Functions\when('get_posts')->justReturn([]); + + $this->mockGetOption([ + 'mayo_external_sources' => [] + ]); + + $result = Rest::bmltenabled_mayo_get_events(); + + $this->assertInstanceOf(\WP_REST_Response::class, $result); + } + + /** + * Test bmltenabled_mayo_get_events creates request when null passed + */ + public function testBmltenabledMayoGetEventsCreatesRequestWhenNullPassed(): void { + Functions\when('get_posts')->justReturn([]); + + $this->mockGetOption([ + 'mayo_external_sources' => [] + ]); + + $result = Rest::bmltenabled_mayo_get_events(null); + + $this->assertInstanceOf(\WP_REST_Response::class, $result); + $data = $result->get_data(); + $this->assertArrayHasKey('events', $data); + } + + /** + * Test bmltenabled_mayo_get_events returns events array in response + */ + public function testBmltenabledMayoGetEventsReturnsEventsArray(): void { + Functions\when('get_posts')->justReturn([]); + + $this->mockGetOption([ + 'mayo_external_sources' => [] + ]); + + $result = Rest::bmltenabled_mayo_get_events(); + + $this->assertInstanceOf(\WP_REST_Response::class, $result); + $data = $result->get_data(); + $this->assertArrayHasKey('events', $data); + $this->assertIsArray($data['events']); + } +} diff --git a/tests/Unit/RssFeedTest.php b/tests/Unit/RssFeedTest.php index 0fec7ed..65ff585 100644 --- a/tests/Unit/RssFeedTest.php +++ b/tests/Unit/RssFeedTest.php @@ -4,6 +4,7 @@ use BmltEnabled\Mayo\RssFeed; use Brain\Monkey\Functions; +use Mockery; use ReflectionClass; class RssFeedTest extends TestCase { diff --git a/tests/Unit/SubscriberTest.php b/tests/Unit/SubscriberTest.php index 561ba81..a924f3c 100644 --- a/tests/Unit/SubscriberTest.php +++ b/tests/Unit/SubscriberTest.php @@ -1025,6 +1025,672 @@ public function testGetWpdbIntegration(): void { $this->assertEquals('wp_mayo_subscribers', TestableSubscriber::get_table_name()); } + /** + * Test get_match_reason returns categories when they match + */ + public function testGetMatchReasonReturnsCategoriesWhenMatched(): void { + $method = $this->getPrivateMethod('get_match_reason'); + + // Mock get_term to return a category term + Functions\when('get_term')->alias(function($term_id, $taxonomy) { + if ($taxonomy === 'category') { + $term = new \stdClass(); + $term->term_id = $term_id; + $term->name = 'News Category'; + return $term; + } + return null; + }); + + $subscriber = (object)[ + 'email' => 'test@example.com', + 'preferences' => json_encode([ + 'categories' => [5, 10], + 'tags' => [], + 'service_bodies' => [] + ]) + ]; + + $announcement_data = [ + 'categories' => [5, 15], // 5 matches + 'tags' => [], + 'service_body' => '' + ]; + + $result = $method->invoke(null, $subscriber, $announcement_data, []); + + $this->assertIsArray($result); + $this->assertNotEmpty($result['categories']); + } + + /** + * Test get_match_reason returns tags when they match + */ + public function testGetMatchReasonReturnsTagsWhenMatched(): void { + $method = $this->getPrivateMethod('get_match_reason'); + + // Mock get_term to return a tag term + Functions\when('get_term')->alias(function($term_id, $taxonomy) { + if ($taxonomy === 'post_tag') { + $term = new \stdClass(); + $term->term_id = $term_id; + $term->name = 'Featured Tag'; + return $term; + } + return null; + }); + + $subscriber = (object)[ + 'email' => 'test@example.com', + 'preferences' => json_encode([ + 'categories' => [], + 'tags' => [7, 8], + 'service_bodies' => [] + ]) + ]; + + $announcement_data = [ + 'categories' => [], + 'tags' => [8, 9], // 8 matches + 'service_body' => '' + ]; + + $result = $method->invoke(null, $subscriber, $announcement_data, []); + + $this->assertIsArray($result); + $this->assertNotEmpty($result['tags']); + } + + /** + * Test get_match_reason returns service body when it matches + */ + public function testGetMatchReasonReturnsServiceBodyWhenMatched(): void { + $method = $this->getPrivateMethod('get_match_reason'); + + $subscriber = (object)[ + 'email' => 'test@example.com', + 'preferences' => json_encode([ + 'categories' => [], + 'tags' => [], + 'service_bodies' => ['15', '20'] + ]) + ]; + + $announcement_data = [ + 'categories' => [], + 'tags' => [], + 'service_body' => '15' + ]; + + $service_body_names = [ + '15' => 'Test Service Body', + '20' => 'Another Service Body' + ]; + + $result = $method->invoke(null, $subscriber, $announcement_data, $service_body_names); + + $this->assertIsArray($result); + $this->assertEquals('Test Service Body', $result['service_body']); + } + + /** + * Test get_match_reason uses fallback for unknown service body + */ + public function testGetMatchReasonUsesFallbackForUnknownServiceBody(): void { + $method = $this->getPrivateMethod('get_match_reason'); + + $subscriber = (object)[ + 'email' => 'test@example.com', + 'preferences' => json_encode([ + 'categories' => [], + 'tags' => [], + 'service_bodies' => ['999'] + ]) + ]; + + $announcement_data = [ + 'categories' => [], + 'tags' => [], + 'service_body' => '999' + ]; + + // Empty service body names lookup + $result = $method->invoke(null, $subscriber, $announcement_data, []); + + $this->assertIsArray($result); + $this->assertEquals('Service Body 999', $result['service_body']); + } + + /** + * Test get_announcement_data returns categories from wp_get_post_terms + */ + public function testGetAnnouncementDataReturnsCategories(): void { + $method = $this->getPrivateMethod('get_announcement_data'); + + $this->setPostMeta(300, [ + 'service_body' => '10' + ]); + + Functions\when('wp_get_post_terms')->alias(function($post_id, $taxonomy, $args = []) { + if ($taxonomy === 'category') { + return [1, 2, 3]; + } + if ($taxonomy === 'post_tag') { + return [5, 6]; + } + return []; + }); + + $result = $method->invoke(null, 300); + + $this->assertIsArray($result); + $this->assertEquals([1, 2, 3], $result['categories']); + $this->assertEquals([5, 6], $result['tags']); + $this->assertEquals('10', $result['service_body']); + } + + /** + * Test get_announcement_data handles empty wp_get_post_terms results + */ + public function testGetAnnouncementDataHandlesEmptyTerms(): void { + $method = $this->getPrivateMethod('get_announcement_data'); + + $this->setPostMeta(301, [ + 'service_body' => '' + ]); + + Functions\when('wp_get_post_terms')->justReturn([]); + + $result = $method->invoke(null, 301); + + $this->assertIsArray($result); + $this->assertEquals([], $result['categories']); + $this->assertEquals([], $result['tags']); + } + + /** + * Test get_linked_events_text formats local events + */ + public function testGetLinkedEventsTextFormatsLocalEvents(): void { + $method = $this->getPrivateMethod('get_linked_events_text'); + + $this->setPostMeta(400, [ + 'linked_event_refs' => [ + ['type' => 'local', 'id' => 500] + ] + ]); + + // Create a mock event post + $event = $this->createMockPost([ + 'ID' => 500, + 'post_title' => 'Test Local Event', + 'post_type' => 'mayo_event' + ]); + + $this->setPostMeta(500, [ + 'event_start_date' => '2025-02-15', + 'event_end_date' => '2025-02-15', + 'event_start_time' => '14:00', + 'event_end_time' => '16:00', + 'location_name' => 'Community Center', + 'location_address' => '123 Main Street' + ]); + + Functions\when('get_post')->justReturn($event); + + $result = $method->invoke(null, 400); + + $this->assertStringContainsString('RELATED EVENT', $result); + $this->assertStringContainsString('Test Local Event', $result); + $this->assertStringContainsString('February', $result); + $this->assertStringContainsString('Community Center', $result); + $this->assertStringContainsString('123 Main Street', $result); + } + + /** + * Test get_linked_events_text handles multiple date range events + */ + public function testGetLinkedEventsTextHandlesDateRange(): void { + $method = $this->getPrivateMethod('get_linked_events_text'); + + $this->setPostMeta(401, [ + 'linked_event_refs' => [ + ['type' => 'local', 'id' => 501] + ] + ]); + + $event = $this->createMockPost([ + 'ID' => 501, + 'post_title' => 'Multi-Day Event', + 'post_type' => 'mayo_event' + ]); + + $this->setPostMeta(501, [ + 'event_start_date' => '2025-03-01', + 'event_end_date' => '2025-03-03', // Different end date + 'event_start_time' => '09:00', + 'event_end_time' => '17:00', + 'location_name' => '', + 'location_address' => '' + ]); + + Functions\when('get_post')->justReturn($event); + + $result = $method->invoke(null, 401); + + $this->assertStringContainsString('Multi-Day Event', $result); + // Should show both dates + $this->assertStringContainsString('March', $result); + } + + /** + * Test get_linked_events_text handles custom links + */ + public function testGetLinkedEventsTextHandlesCustomLinks(): void { + $method = $this->getPrivateMethod('get_linked_events_text'); + + $this->setPostMeta(402, [ + 'linked_event_refs' => [ + [ + 'type' => 'custom', + 'url' => 'https://example.com/info', + 'title' => 'Registration Link', + 'icon' => 'external' + ] + ] + ]); + + $result = $method->invoke(null, 402); + + $this->assertStringContainsString('RELATED EVENT', $result); + $this->assertStringContainsString('Registration Link', $result); + $this->assertStringContainsString('https://example.com/info', $result); + } + + /** + * Test get_linked_events_text handles empty external event refs + */ + public function testGetLinkedEventsTextHandlesEmptyLinkedEvents(): void { + $method = $this->getPrivateMethod('get_linked_events_text'); + + $this->setPostMeta(403, [ + 'linked_event_refs' => [] + ]); + + $result = $method->invoke(null, 403); + + // Should return empty string for no linked events + $this->assertEquals('', $result); + } + + /** + * Test get_linked_events_text handles missing linked_event_refs meta + */ + public function testGetLinkedEventsTextHandlesMissingMeta(): void { + $method = $this->getPrivateMethod('get_linked_events_text'); + + // Set empty meta (no linked_event_refs) + $this->setPostMeta(404, []); + + $result = $method->invoke(null, 404); + + // Should return empty string when meta is missing + $this->assertEquals('', $result); + } + + /** + * Test send_announcement_email does nothing for non-announcement post type + */ + public function testSendAnnouncementEmailIgnoresNonAnnouncementPostType(): void { + $post = $this->createMockPost([ + 'ID' => 600, + 'post_type' => 'post', // Not mayo_announcement + 'post_title' => 'Regular Post', + 'post_content' => 'Content' + ]); + + Functions\when('get_post')->justReturn($post); + + // Should not throw an error and should return early + Subscriber::send_announcement_email(600); + + // No emails should be captured + $this->assertEmpty($this->capturedEmails); + } + + /** + * Test send_announcement_email does nothing when no subscribers + */ + public function testSendAnnouncementEmailDoesNothingWhenNoSubscribers(): void { + $post = $this->createMockPost([ + 'ID' => 601, + 'post_type' => 'mayo_announcement', + 'post_title' => 'Test Announcement', + 'post_content' => 'Announcement content' + ]); + + Functions\when('get_post')->justReturn($post); + + $mockWpdb = $this->createMockWpdb(); + $mockWpdb->shouldReceive('get_results')->andReturn([]); // No subscribers + TestableSubscriber::$mockWpdb = $mockWpdb; + + TestableSubscriber::send_announcement_email(601); + + // No emails should be captured + $this->assertEmpty($this->capturedEmails); + } + + /** + * Test send_announcement_email sends to matching subscribers + */ + public function testSendAnnouncementEmailSendsToMatchingSubscribers(): void { + $post = $this->createMockPost([ + 'ID' => 602, + 'post_type' => 'mayo_announcement', + 'post_title' => 'Important Announcement', + 'post_content' => 'This is an important announcement.' + ]); + + Functions\when('get_post')->justReturn($post); + Functions\when('get_the_title')->justReturn('Important Announcement'); + Functions\when('the_content')->justReturn('This is an important announcement.'); + Functions\when('wp_get_post_terms')->justReturn([]); + + $this->setPostMeta(602, [ + 'service_body' => '5', + 'linked_event_refs' => [] + ]); + + $mockWpdb = $this->createMockWpdb(); + $mockWpdb->shouldReceive('get_results')->andReturn([ + (object)[ + 'id' => 1, + 'email' => 'subscriber1@example.com', + 'status' => 'active', + 'token' => 'token123', + 'preferences' => null // Legacy subscriber, gets all + ], + (object)[ + 'id' => 2, + 'email' => 'subscriber2@example.com', + 'status' => 'active', + 'token' => 'token456', + 'preferences' => json_encode([ + 'categories' => [], + 'tags' => [], + 'service_bodies' => ['5'] // Matches + ]) + ] + ]); + TestableSubscriber::$mockWpdb = $mockWpdb; + + TestableSubscriber::send_announcement_email(602); + + // Should have sent 2 emails + $this->assertCount(2, $this->capturedEmails); + $this->assertEquals('subscriber1@example.com', $this->capturedEmails[0]['to']); + $this->assertEquals('subscriber2@example.com', $this->capturedEmails[1]['to']); + $this->assertStringContainsString('Important Announcement', $this->capturedEmails[0]['subject']); + } + + /** + * Test send_announcement_email skips non-matching subscribers + */ + public function testSendAnnouncementEmailSkipsNonMatchingSubscribers(): void { + $post = $this->createMockPost([ + 'ID' => 603, + 'post_type' => 'mayo_announcement', + 'post_title' => 'Targeted Announcement', + 'post_content' => 'Only for certain subscribers.' + ]); + + Functions\when('get_post')->justReturn($post); + Functions\when('get_the_title')->justReturn('Targeted Announcement'); + Functions\when('the_content')->justReturn('Only for certain subscribers.'); + Functions\when('wp_get_post_terms')->justReturn([]); + + $this->setPostMeta(603, [ + 'service_body' => '10', + 'linked_event_refs' => [] + ]); + + $mockWpdb = $this->createMockWpdb(); + $mockWpdb->shouldReceive('get_results')->andReturn([ + (object)[ + 'id' => 1, + 'email' => 'matching@example.com', + 'status' => 'active', + 'token' => 'token789', + 'preferences' => json_encode([ + 'categories' => [], + 'tags' => [], + 'service_bodies' => ['10'] // Matches + ]) + ], + (object)[ + 'id' => 2, + 'email' => 'nonmatching@example.com', + 'status' => 'active', + 'token' => 'tokenabc', + 'preferences' => json_encode([ + 'categories' => [999], // Doesn't match + 'tags' => [999], + 'service_bodies' => ['999'] + ]) + ] + ]); + TestableSubscriber::$mockWpdb = $mockWpdb; + + TestableSubscriber::send_announcement_email(603); + + // Should have sent only 1 email + $this->assertCount(1, $this->capturedEmails); + $this->assertEquals('matching@example.com', $this->capturedEmails[0]['to']); + } + + /** + * Test send_announcement_email includes linked events text + */ + public function testSendAnnouncementEmailIncludesLinkedEventsText(): void { + $post = $this->createMockPost([ + 'ID' => 604, + 'post_type' => 'mayo_announcement', + 'post_title' => 'Event Announcement', + 'post_content' => 'Check out this event!' + ]); + + Functions\when('get_post')->justReturn($post); + Functions\when('get_the_title')->justReturn('Event Announcement'); + Functions\when('the_content')->justReturn('Check out this event!'); + Functions\when('wp_get_post_terms')->justReturn([]); + + $event = $this->createMockPost([ + 'ID' => 700, + 'post_title' => 'Linked Event', + 'post_type' => 'mayo_event' + ]); + + $this->setPostMeta(604, [ + 'service_body' => '', + 'linked_event_refs' => [ + ['type' => 'local', 'id' => 700] + ] + ]); + + $this->setPostMeta(700, [ + 'event_start_date' => '2025-05-20', + 'event_end_date' => '2025-05-20', + 'event_start_time' => '18:00', + 'event_end_time' => '20:00', + 'location_name' => 'Event Venue', + 'location_address' => '456 Event St' + ]); + + // Override get_post to return the right post + Functions\when('get_post')->alias(function($id) use ($post, $event) { + if ($id === 604) return $post; + if ($id === 700) return $event; + return null; + }); + + $mockWpdb = $this->createMockWpdb(); + $mockWpdb->shouldReceive('get_results')->andReturn([ + (object)[ + 'id' => 1, + 'email' => 'eventfan@example.com', + 'status' => 'active', + 'token' => 'tokenxyz', + 'preferences' => null + ] + ]); + TestableSubscriber::$mockWpdb = $mockWpdb; + + TestableSubscriber::send_announcement_email(604); + + $this->assertCount(1, $this->capturedEmails); + $this->assertStringContainsString('RELATED EVENT', $this->capturedEmails[0]['message']); + $this->assertStringContainsString('Linked Event', $this->capturedEmails[0]['message']); + $this->assertStringContainsString('Event Venue', $this->capturedEmails[0]['message']); + } + + /** + * Test format_date handles invalid date gracefully + */ + public function testFormatDateHandlesInvalidDateGracefully(): void { + $method = $this->getPrivateMethod('format_date'); + + // Test with a date that DateTime can parse but is weird + $result = $method->invoke(null, '0000-00-00'); + + // Should return something (either formatted or original) + $this->assertIsString($result); + } + + /** + * Test format_time handles invalid time gracefully + */ + public function testFormatTimeHandlesInvalidTimeGracefully(): void { + $method = $this->getPrivateMethod('format_time'); + + // Test with invalid time string + $result = $method->invoke(null, 'invalid-time'); + + // Should return something (either formatted or original) + $this->assertIsString($result); + } + + /** + * Test get_service_body_names makes BMLT API call + */ + public function testGetServiceBodyNamesMakesBmltApiCall(): void { + $method = $this->getPrivateMethod('get_service_body_names'); + + $this->mockGetOption([ + 'mayo_settings' => [ + 'bmlt_root_server' => 'https://bmlt.example.com' + ] + ]); + + $this->mockWpRemoteGet([ + 'GetServiceBodies' => [ + 'code' => 200, + 'body' => [ + ['id' => '1', 'name' => 'Region 1'], + ['id' => '2', 'name' => 'Area 2'] + ] + ] + ]); + + $result = $method->invoke(null); + + $this->assertIsArray($result); + $this->assertEquals('Region 1', $result['1']); + $this->assertEquals('Area 2', $result['2']); + } + + /** + * Test get_service_body_names returns empty on error + */ + public function testGetServiceBodyNamesReturnsEmptyOnError(): void { + $method = $this->getPrivateMethod('get_service_body_names'); + + $this->mockGetOption([ + 'mayo_settings' => [ + 'bmlt_root_server' => 'https://bmlt.example.com' + ] + ]); + + Functions\when('wp_remote_get')->justReturn(new \WP_Error('error', 'Connection failed')); + + $result = $method->invoke(null); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + /** + * Test get_service_body_names returns empty when no BMLT server configured + */ + public function testGetServiceBodyNamesReturnsEmptyWhenNoBmltServer(): void { + $method = $this->getPrivateMethod('get_service_body_names'); + + $this->mockGetOption([ + 'mayo_settings' => [ + 'bmlt_root_server' => '' // No server configured + ] + ]); + + $result = $method->invoke(null); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + /** + * Test get_matching_with_reasons returns subscribers with reasons + */ + public function testGetMatchingWithReasonsReturnsSubscribersWithReasons(): void { + $mockWpdb = $this->createMockWpdb(); + $mockWpdb->shouldReceive('get_results')->andReturn([ + (object)[ + 'id' => 1, + 'email' => 'all@example.com', + 'status' => 'active', + 'preferences' => null // Legacy + ], + (object)[ + 'id' => 2, + 'email' => 'specific@example.com', + 'status' => 'active', + 'preferences' => json_encode([ + 'categories' => [], + 'tags' => [], + 'service_bodies' => ['5'] + ]) + ] + ]); + TestableSubscriber::$mockWpdb = $mockWpdb; + + $this->mockGetOption([ + 'mayo_settings' => [ + 'bmlt_root_server' => '' + ] + ]); + + $result = TestableSubscriber::get_matching_with_reasons([ + 'categories' => [], + 'tags' => [], + 'service_body' => '5' + ]); + + $this->assertCount(2, $result); + $this->assertEquals('all@example.com', $result[0]['subscriber']->email); + $this->assertTrue($result[0]['reason']['all']); + $this->assertEquals('specific@example.com', $result[1]['subscriber']->email); + $this->assertEquals('Service Body 5', $result[1]['reason']['service_body']); + } + /** * Helper to get private method */