diff --git a/includes/RssFeed.php b/includes/RssFeed.php
index 9364dbc..94ee456 100644
--- a/includes/RssFeed.php
+++ b/includes/RssFeed.php
@@ -14,7 +14,19 @@ public static function register_feed() {
}
- private static function get_rss_items_from_rest_api($eventType = '', $serviceBody = '', $sourceIds = '', $relation = 'AND', $categories = '', $tags = '', $per_page = 10) {
+ /**
+ * Get RSS items from the REST API
+ *
+ * @param string $eventType Event type filter
+ * @param string $serviceBody Service body filter
+ * @param string $sourceIds Source IDs filter
+ * @param string $relation Taxonomy relation (AND/OR)
+ * @param string $categories Category filter
+ * @param string $tags Tag filter
+ * @param int $per_page Number of items per page
+ * @return array Array of RSS item data
+ */
+ public static function get_rss_items_from_rest_api($eventType = '', $serviceBody = '', $sourceIds = '', $relation = 'AND', $categories = '', $tags = '', $per_page = 10) {
// Store original $_GET to restore later
$original_get = $_GET;
@@ -187,6 +199,55 @@ private static function get_rss_items_from_rest_api($eventType = '', $serviceBod
}
+ /**
+ * Build the RSS XML string from events data
+ *
+ * This method is separated from generate_rss_feed() to allow for unit testing.
+ *
+ * @param array $events Array of event data from get_rss_items_from_rest_api()
+ * @param string $site_name Site name for feed title
+ * @param string $site_url Site URL for feed link
+ * @param string $feed_description Description for the feed
+ * @param string $language Language code
+ * @param string|null $build_date Optional build date (defaults to current time)
+ * @return string Complete RSS XML document
+ */
+ public static function build_rss_xml($events, $site_name, $site_url, $feed_description, $language = 'en_US', $build_date = null) {
+ $xml = '' . "\n";
+ $xml .= '' . "\n";
+ $xml .= '' . "\n";
+ $xml .= '' . self::escape_xml_text($site_name . ' - Events') . '' . "\n";
+ $xml .= '' . self::escape_xml_text($site_url) . '' . "\n";
+ $xml .= '' . self::escape_xml_text($feed_description) . '' . "\n";
+ $xml .= '' . ($build_date ?? date('D, d M Y H:i:s O')) . '' . "\n";
+ $xml .= '' . $language . '' . "\n";
+ $xml .= 'Mayo Events Manager' . "\n";
+
+ foreach ($events as $event) {
+ $xml .= '- ' . "\n";
+ $xml .= '' . self::escape_xml_text($event['title']) . '' . "\n";
+ $xml .= '' . self::escape_xml_text($event['link']) . '' . "\n";
+ $xml .= '' . self::escape_xml_text($event['link']) . '' . "\n";
+ $xml .= '' . $event['pub_date'] . '' . "\n";
+ $xml .= '' . "\n";
+ $xml .= '' . "\n";
+
+ // Add categories if available
+ if (!empty($event['categories'])) {
+ foreach ($event['categories'] as $category) {
+ $xml .= '' . self::escape_xml_text($category) . '' . "\n";
+ }
+ }
+
+ $xml .= '
' . "\n";
+ }
+
+ $xml .= '' . "\n";
+ $xml .= '' . "\n";
+
+ return $xml;
+ }
+
public static function generate_rss_feed() {
// Get parameters from URL (for manual overrides) or detect from page shortcode
$eventType = isset($_GET['event_type']) ? \sanitize_text_field($_GET['event_type']) : '';
@@ -196,7 +257,7 @@ public static function generate_rss_feed() {
$categories = isset($_GET['categories']) ? \sanitize_text_field($_GET['categories']) : '';
$tags = isset($_GET['tags']) ? \sanitize_text_field($_GET['tags']) : '';
$per_page = isset($_GET['per_page']) ? \sanitize_text_field($_GET['per_page']) : '';
-
+
// If no URL parameters provided, try to get them from the current page's shortcode
if (empty($eventType) && empty($serviceBody) && empty($sourceIds) && empty($categories) && empty($tags) && empty($per_page)) {
$shortcode_params = self::get_shortcode_params_from_current_page();
@@ -221,42 +282,11 @@ public static function generate_rss_feed() {
}
$site_name = \get_bloginfo('name');
$site_url = \home_url();
- $site_description = \get_bloginfo('description');
-
+
// Build descriptive feed description with active filters
$feed_description = self::build_feed_description($site_name, $eventType, $serviceBody, $sourceIds, $relation, $categories, $tags, $per_page);
- echo '' . "\n";
- echo '' . "\n";
- echo '' . "\n";
- echo '' . self::escape_xml_text($site_name . ' - Events') . '' . "\n";
- echo '' . self::escape_xml_text($site_url) . '' . "\n";
- echo '' . self::escape_xml_text($feed_description) . '' . "\n";
- echo '' . date('D, d M Y H:i:s O') . '' . "\n";
- echo '' . \get_locale() . '' . "\n";
- echo 'Mayo Events Manager' . "\n";
-
- foreach ($events as $event) {
- echo '- ' . "\n";
- echo '' . self::escape_xml_text($event['title']) . '' . "\n";
- echo '' . self::escape_xml_text($event['link']) . '' . "\n";
- echo '' . self::escape_xml_text($event['link']) . '' . "\n";
- echo '' . $event['pub_date'] . '' . "\n";
- echo '' . "\n";
- echo '' . "\n";
-
- // Add categories if available
- if (!empty($event['categories'])) {
- foreach ($event['categories'] as $category) {
- echo '' . self::escape_xml_text($category) . '' . "\n";
- }
- }
-
- echo '
' . "\n";
- }
-
- echo '' . "\n";
- echo '' . "\n";
+ echo self::build_rss_xml($events, $site_name, $site_url, $feed_description, \get_locale());
exit;
}
diff --git a/includes/Subscriber.php b/includes/Subscriber.php
index 13c31a4..08abe51 100644
--- a/includes/Subscriber.php
+++ b/includes/Subscriber.php
@@ -14,12 +14,26 @@ class Subscriber
*/
const TABLE_NAME = 'mayo_subscribers';
+ /**
+ * Get the wpdb instance
+ *
+ * This method is separated to allow for mocking in unit tests.
+ * Tests can extend this class and override this method to return a mock.
+ *
+ * @return \wpdb The WordPress database object
+ */
+ protected static function get_wpdb()
+ {
+ global $wpdb;
+ return $wpdb;
+ }
+
/**
* Get the full table name with prefix
*/
public static function get_table_name()
{
- global $wpdb;
+ $wpdb = static::get_wpdb();
return $wpdb->prefix . self::TABLE_NAME;
}
@@ -29,7 +43,7 @@ public static function get_table_name()
*/
public static function create_table()
{
- global $wpdb;
+ $wpdb = static::get_wpdb();
$table_name = self::get_table_name();
$charset_collate = $wpdb->get_charset_collate();
@@ -69,7 +83,7 @@ public static function generate_token()
*/
public static function subscribe($email, $preferences = null)
{
- global $wpdb;
+ $wpdb = static::get_wpdb();
$email = sanitize_email($email);
@@ -189,7 +203,7 @@ public static function subscribe($email, $preferences = null)
*/
public static function get_by_token($token)
{
- global $wpdb;
+ $wpdb = static::get_wpdb();
$token = sanitize_text_field($token);
$table_name = self::get_table_name();
@@ -209,7 +223,7 @@ public static function get_by_token($token)
*/
public static function update_preferences($token, $preferences)
{
- global $wpdb;
+ $wpdb = static::get_wpdb();
$token = sanitize_text_field($token);
$table_name = self::get_table_name();
@@ -233,7 +247,7 @@ public static function update_preferences($token, $preferences)
*/
public static function confirm($token)
{
- global $wpdb;
+ $wpdb = static::get_wpdb();
$token = sanitize_text_field($token);
$table_name = self::get_table_name();
@@ -288,7 +302,7 @@ public static function confirm($token)
*/
public static function unsubscribe($token)
{
- global $wpdb;
+ $wpdb = static::get_wpdb();
$token = sanitize_text_field($token);
$table_name = self::get_table_name();
@@ -336,7 +350,7 @@ public static function unsubscribe($token)
*/
public static function get_active_subscribers()
{
- global $wpdb;
+ $wpdb = static::get_wpdb();
$table_name = self::get_table_name();
@@ -352,7 +366,7 @@ public static function get_active_subscribers()
*/
public static function get_all_subscribers()
{
- global $wpdb;
+ $wpdb = static::get_wpdb();
$table_name = self::get_table_name();
@@ -370,7 +384,7 @@ public static function get_all_subscribers()
*/
public static function update_status($id, $status)
{
- global $wpdb;
+ $wpdb = static::get_wpdb();
$id = intval($id);
$status = sanitize_text_field($status);
@@ -405,7 +419,7 @@ public static function update_status($id, $status)
*/
public static function update_preferences_by_id($id, $preferences)
{
- global $wpdb;
+ $wpdb = static::get_wpdb();
$id = intval($id);
$table_name = self::get_table_name();
@@ -429,7 +443,7 @@ public static function update_preferences_by_id($id, $preferences)
*/
public static function delete($id)
{
- global $wpdb;
+ $wpdb = static::get_wpdb();
$id = intval($id);
$table_name = self::get_table_name();
diff --git a/tests/Unit/Rest/SubscribersControllerTest.php b/tests/Unit/Rest/SubscribersControllerTest.php
index 62afb82..38e232c 100644
--- a/tests/Unit/Rest/SubscribersControllerTest.php
+++ b/tests/Unit/Rest/SubscribersControllerTest.php
@@ -326,4 +326,530 @@ public function testGetSubscriptionOptionsFormatsTagsCorrectly(): void {
$this->assertEquals('Tag One', $data['tags'][0]['name']);
$this->assertEquals('tag-one', $data['tags'][0]['slug']);
}
+
+ /**
+ * Test get_subscriber returns 404 for non-existent token
+ */
+ public function testGetSubscriberReturnsNotFoundForInvalidToken(): void {
+ // Mock the Subscriber::get_by_token to return null via global $wpdb mock
+ global $wpdb;
+ $wpdb = \Mockery::mock('wpdb');
+ $wpdb->prefix = 'wp_';
+ $wpdb->shouldReceive('prepare')->andReturn('SQL');
+ $wpdb->shouldReceive('get_row')->andReturn(null);
+
+ $request = new \WP_REST_Request('GET', '/event-manager/v1/subscriber/invalidtoken123');
+ $request->set_param('token', 'invalidtoken123');
+
+ $response = SubscribersController::get_subscriber($request);
+
+ $this->assertEquals(404, $response->get_status());
+ $data = $response->get_data();
+ $this->assertFalse($data['success']);
+ $this->assertEquals('not_found', $data['code']);
+ }
+
+ /**
+ * Test get_subscriber returns subscriber data for valid token
+ */
+ public function testGetSubscriberReturnsDataForValidToken(): void {
+ global $wpdb;
+ $wpdb = \Mockery::mock('wpdb');
+ $wpdb->prefix = 'wp_';
+
+ $subscriber = (object)[
+ 'id' => 1,
+ 'email' => 'test@example.com',
+ 'status' => 'active',
+ 'preferences' => json_encode(['categories' => [1, 2]]),
+ 'token' => 'validtoken123'
+ ];
+
+ $wpdb->shouldReceive('prepare')->andReturn('SQL');
+ $wpdb->shouldReceive('get_row')->andReturn($subscriber);
+
+ $request = new \WP_REST_Request('GET', '/event-manager/v1/subscriber/validtoken123');
+ $request->set_param('token', 'validtoken123');
+
+ $response = SubscribersController::get_subscriber($request);
+
+ $this->assertEquals(200, $response->get_status());
+ $data = $response->get_data();
+ $this->assertEquals('test@example.com', $data['email']);
+ $this->assertEquals('active', $data['status']);
+ $this->assertIsArray($data['preferences']);
+ }
+
+ /**
+ * Test get_subscriber handles subscriber without preferences
+ */
+ public function testGetSubscriberHandlesNoPreferences(): void {
+ global $wpdb;
+ $wpdb = \Mockery::mock('wpdb');
+ $wpdb->prefix = 'wp_';
+
+ $subscriber = (object)[
+ 'id' => 1,
+ 'email' => 'legacy@example.com',
+ 'status' => 'active',
+ 'preferences' => null,
+ 'token' => 'validtoken123'
+ ];
+
+ $wpdb->shouldReceive('prepare')->andReturn('SQL');
+ $wpdb->shouldReceive('get_row')->andReturn($subscriber);
+
+ $request = new \WP_REST_Request('GET', '/event-manager/v1/subscriber/validtoken123');
+ $request->set_param('token', 'validtoken123');
+
+ $response = SubscribersController::get_subscriber($request);
+
+ $this->assertEquals(200, $response->get_status());
+ $data = $response->get_data();
+ $this->assertNull($data['preferences']);
+ }
+
+ /**
+ * Test update_subscriber returns 404 for non-existent token
+ */
+ public function testUpdateSubscriberReturnsNotFoundForInvalidToken(): void {
+ global $wpdb;
+ $wpdb = \Mockery::mock('wpdb');
+ $wpdb->prefix = 'wp_';
+ $wpdb->shouldReceive('prepare')->andReturn('SQL');
+ $wpdb->shouldReceive('get_row')->andReturn(null);
+
+ $request = new \WP_REST_Request('PUT', '/event-manager/v1/subscriber/invalidtoken123');
+ $request->set_param('token', 'invalidtoken123');
+ $request->set_body_params(['preferences' => ['categories' => [1]]]);
+
+ $response = SubscribersController::update_subscriber($request);
+
+ $this->assertEquals(404, $response->get_status());
+ $data = $response->get_data();
+ $this->assertEquals('not_found', $data['code']);
+ }
+
+ /**
+ * Test update_subscriber returns error for non-active subscriber
+ */
+ public function testUpdateSubscriberReturnsErrorForNonActive(): void {
+ global $wpdb;
+ $wpdb = \Mockery::mock('wpdb');
+ $wpdb->prefix = 'wp_';
+
+ $subscriber = (object)[
+ 'id' => 1,
+ 'email' => 'test@example.com',
+ 'status' => 'pending',
+ 'token' => 'pendingtoken123'
+ ];
+
+ $wpdb->shouldReceive('prepare')->andReturn('SQL');
+ $wpdb->shouldReceive('get_row')->andReturn($subscriber);
+
+ $request = new \WP_REST_Request('PUT', '/event-manager/v1/subscriber/pendingtoken123');
+ $request->set_param('token', 'pendingtoken123');
+ $request->set_body_params(['preferences' => ['categories' => [1]]]);
+
+ $response = SubscribersController::update_subscriber($request);
+
+ $this->assertEquals(400, $response->get_status());
+ $data = $response->get_data();
+ $this->assertEquals('not_active', $data['code']);
+ }
+
+ /**
+ * Test update_subscriber returns error when preferences missing
+ */
+ public function testUpdateSubscriberReturnsErrorWhenPreferencesMissing(): void {
+ global $wpdb;
+ $wpdb = \Mockery::mock('wpdb');
+ $wpdb->prefix = 'wp_';
+
+ $subscriber = (object)[
+ 'id' => 1,
+ 'email' => 'test@example.com',
+ 'status' => 'active',
+ 'token' => 'activetoken123'
+ ];
+
+ $wpdb->shouldReceive('prepare')->andReturn('SQL');
+ $wpdb->shouldReceive('get_row')->andReturn($subscriber);
+
+ $request = new \WP_REST_Request('PUT', '/event-manager/v1/subscriber/activetoken123');
+ $request->set_param('token', 'activetoken123');
+ // No preferences in body params
+
+ $response = SubscribersController::update_subscriber($request);
+
+ $this->assertEquals(400, $response->get_status());
+ $data = $response->get_data();
+ $this->assertEquals('invalid_preferences', $data['code']);
+ }
+
+ /**
+ * Test update_subscriber returns error when preferences empty
+ */
+ public function testUpdateSubscriberReturnsErrorWhenPreferencesEmpty(): void {
+ global $wpdb;
+ $wpdb = \Mockery::mock('wpdb');
+ $wpdb->prefix = 'wp_';
+
+ $subscriber = (object)[
+ 'id' => 1,
+ 'email' => 'test@example.com',
+ 'status' => 'active',
+ 'token' => 'activetoken123'
+ ];
+
+ $wpdb->shouldReceive('prepare')->andReturn('SQL');
+ $wpdb->shouldReceive('get_row')->andReturn($subscriber);
+
+ $request = new \WP_REST_Request('PUT', '/event-manager/v1/subscriber/activetoken123');
+ $request->set_param('token', 'activetoken123');
+ $request->set_body_params(['preferences' => ['categories' => [], 'tags' => [], 'service_bodies' => []]]);
+
+ $response = SubscribersController::update_subscriber($request);
+
+ $this->assertEquals(400, $response->get_status());
+ $data = $response->get_data();
+ $this->assertEquals('no_preferences', $data['code']);
+ }
+
+ /**
+ * Test update_subscriber success
+ */
+ public function testUpdateSubscriberSuccess(): void {
+ global $wpdb;
+ $wpdb = \Mockery::mock('wpdb');
+ $wpdb->prefix = 'wp_';
+
+ $subscriber = (object)[
+ 'id' => 1,
+ 'email' => 'test@example.com',
+ 'status' => 'active',
+ 'token' => 'activetoken123'
+ ];
+
+ $wpdb->shouldReceive('prepare')->andReturn('SQL');
+ $wpdb->shouldReceive('get_row')->andReturn($subscriber);
+ $wpdb->shouldReceive('update')->andReturn(1);
+
+ $request = new \WP_REST_Request('PUT', '/event-manager/v1/subscriber/activetoken123');
+ $request->set_param('token', 'activetoken123');
+ $request->set_body_params(['preferences' => ['categories' => [1, 2], 'tags' => [], 'service_bodies' => []]]);
+
+ $response = SubscribersController::update_subscriber($request);
+
+ $this->assertEquals(200, $response->get_status());
+ $data = $response->get_data();
+ $this->assertTrue($data['success']);
+ }
+
+ /**
+ * Test update_subscriber handles update failure
+ */
+ public function testUpdateSubscriberHandlesUpdateFailure(): void {
+ global $wpdb;
+ $wpdb = \Mockery::mock('wpdb');
+ $wpdb->prefix = 'wp_';
+
+ $subscriber = (object)[
+ 'id' => 1,
+ 'email' => 'test@example.com',
+ 'status' => 'active',
+ 'token' => 'activetoken123'
+ ];
+
+ $wpdb->shouldReceive('prepare')->andReturn('SQL');
+ $wpdb->shouldReceive('get_row')->andReturn($subscriber);
+ $wpdb->shouldReceive('update')->andReturn(false);
+
+ $request = new \WP_REST_Request('PUT', '/event-manager/v1/subscriber/activetoken123');
+ $request->set_param('token', 'activetoken123');
+ $request->set_body_params(['preferences' => ['categories' => [1], 'tags' => [], 'service_bodies' => []]]);
+
+ $response = SubscribersController::update_subscriber($request);
+
+ $this->assertEquals(500, $response->get_status());
+ $data = $response->get_data();
+ $this->assertEquals('update_failed', $data['code']);
+ }
+
+ /**
+ * Test get_all_subscribers returns formatted subscribers
+ */
+ public function testGetAllSubscribersReturnsFormattedSubscribers(): void {
+ $this->loginAsAdmin();
+
+ global $wpdb;
+ $wpdb = \Mockery::mock('wpdb');
+ $wpdb->prefix = 'wp_';
+
+ $subscribers = [
+ (object)[
+ 'id' => 1,
+ 'email' => 'a@example.com',
+ 'status' => 'active',
+ 'created_at' => '2024-01-01 12:00:00',
+ 'confirmed_at' => '2024-01-01 12:05:00',
+ 'preferences' => json_encode(['categories' => [1], 'tags' => [], 'service_bodies' => []])
+ ],
+ (object)[
+ 'id' => 2,
+ 'email' => 'b@example.com',
+ 'status' => 'pending',
+ 'created_at' => '2024-01-02 10:00:00',
+ 'confirmed_at' => null,
+ 'preferences' => null
+ ]
+ ];
+
+ $wpdb->shouldReceive('get_results')->andReturn($subscribers);
+
+ Functions\when('get_terms')->alias(function($args) {
+ if ($args['taxonomy'] === 'category') {
+ return [(object)['term_id' => 1, 'name' => 'News']];
+ }
+ return [];
+ });
+
+ $this->mockWpRemoteGet([
+ 'GetServiceBodies' => ['code' => 200, 'body' => []]
+ ]);
+
+ $request = new \WP_REST_Request('GET', '/event-manager/v1/subscribers');
+ $response = SubscribersController::get_all_subscribers($request);
+
+ $this->assertEquals(200, $response->get_status());
+ $data = $response->get_data();
+ $this->assertCount(2, $data);
+ $this->assertEquals('a@example.com', $data[0]['email']);
+ $this->assertArrayHasKey('preferences_display', $data[0]);
+ }
+
+ /**
+ * Test count_matching_subscribers returns count and list
+ */
+ public function testCountMatchingSubscribersReturnsCountAndList(): void {
+ $this->loginAsEditor();
+
+ global $wpdb;
+ $wpdb = \Mockery::mock('wpdb');
+ $wpdb->prefix = 'wp_';
+
+ $subscribers = [
+ (object)[
+ 'id' => 1,
+ 'email' => 'match@example.com',
+ 'status' => 'active',
+ 'preferences' => null
+ ]
+ ];
+
+ $wpdb->shouldReceive('get_results')->andReturn($subscribers);
+
+ $this->mockWpRemoteGet([
+ 'GetServiceBodies' => ['code' => 200, 'body' => []]
+ ]);
+
+ $request = new \WP_REST_Request('POST', '/event-manager/v1/subscribers/count');
+ $request->set_header('Content-Type', 'application/json');
+
+ // Simulate JSON body
+ $reflectionClass = new \ReflectionClass($request);
+ $property = $reflectionClass->getProperty('body_params');
+ $property->setAccessible(true);
+ $property->setValue($request, ['categories' => [1], 'tags' => [], 'service_body' => null]);
+
+ // Need to mock get_json_params
+ $response = SubscribersController::count_matching_subscribers($request);
+
+ $this->assertEquals(200, $response->get_status());
+ $data = $response->get_data();
+ $this->assertArrayHasKey('count', $data);
+ $this->assertArrayHasKey('subscribers', $data);
+ }
+
+ /**
+ * Test admin_update_subscriber updates status
+ */
+ public function testAdminUpdateSubscriberUpdatesStatus(): void {
+ $this->loginAsAdmin();
+
+ global $wpdb;
+ $wpdb = \Mockery::mock('wpdb');
+ $wpdb->prefix = 'wp_';
+ $wpdb->shouldReceive('update')->andReturn(1);
+
+ $request = new \WP_REST_Request('PUT', '/event-manager/v1/subscribers/1');
+ $request->set_param('id', 1);
+
+ $reflectionClass = new \ReflectionClass($request);
+ $property = $reflectionClass->getProperty('body_params');
+ $property->setAccessible(true);
+ $property->setValue($request, ['status' => 'active']);
+
+ $response = SubscribersController::admin_update_subscriber($request);
+
+ $this->assertEquals(200, $response->get_status());
+ $data = $response->get_data();
+ $this->assertTrue($data['success']);
+ }
+
+ /**
+ * Test admin_update_subscriber updates preferences
+ */
+ public function testAdminUpdateSubscriberUpdatesPreferences(): void {
+ $this->loginAsAdmin();
+
+ global $wpdb;
+ $wpdb = \Mockery::mock('wpdb');
+ $wpdb->prefix = 'wp_';
+ $wpdb->shouldReceive('update')->andReturn(1);
+
+ $request = new \WP_REST_Request('PUT', '/event-manager/v1/subscribers/1');
+ $request->set_param('id', 1);
+
+ $reflectionClass = new \ReflectionClass($request);
+ $property = $reflectionClass->getProperty('body_params');
+ $property->setAccessible(true);
+ $property->setValue($request, ['preferences' => ['categories' => [1, 2]]]);
+
+ $response = SubscribersController::admin_update_subscriber($request);
+
+ $this->assertEquals(200, $response->get_status());
+ $data = $response->get_data();
+ $this->assertTrue($data['success']);
+ }
+
+ /**
+ * Test admin_update_subscriber ignores invalid status
+ */
+ public function testAdminUpdateSubscriberIgnoresInvalidStatus(): void {
+ $this->loginAsAdmin();
+
+ global $wpdb;
+ $wpdb = \Mockery::mock('wpdb');
+ $wpdb->prefix = 'wp_';
+
+ $request = new \WP_REST_Request('PUT', '/event-manager/v1/subscribers/1');
+ $request->set_param('id', 1);
+
+ $reflectionClass = new \ReflectionClass($request);
+ $property = $reflectionClass->getProperty('body_params');
+ $property->setAccessible(true);
+ $property->setValue($request, ['status' => 'invalid_status']);
+
+ $response = SubscribersController::admin_update_subscriber($request);
+
+ $this->assertEquals(200, $response->get_status());
+ $data = $response->get_data();
+ // Update wasn't actually called because status is invalid
+ $this->assertFalse($data['updated']);
+ }
+
+ /**
+ * Test admin_delete_subscriber deletes subscriber
+ */
+ public function testAdminDeleteSubscriberDeletesSubscriber(): void {
+ $this->loginAsAdmin();
+
+ global $wpdb;
+ $wpdb = \Mockery::mock('wpdb');
+ $wpdb->prefix = 'wp_';
+ $wpdb->shouldReceive('delete')->andReturn(1);
+
+ $request = new \WP_REST_Request('DELETE', '/event-manager/v1/subscribers/1');
+ $request->set_param('id', 1);
+
+ $response = SubscribersController::admin_delete_subscriber($request);
+
+ $this->assertEquals(200, $response->get_status());
+ $data = $response->get_data();
+ $this->assertTrue($data['success']);
+ }
+
+ /**
+ * Test admin_delete_subscriber returns 404 when subscriber not found
+ */
+ public function testAdminDeleteSubscriberReturns404WhenNotFound(): void {
+ $this->loginAsAdmin();
+
+ global $wpdb;
+ $wpdb = \Mockery::mock('wpdb');
+ $wpdb->prefix = 'wp_';
+ $wpdb->shouldReceive('delete')->andReturn(false);
+
+ $request = new \WP_REST_Request('DELETE', '/event-manager/v1/subscribers/999');
+ $request->set_param('id', 999);
+
+ $response = SubscribersController::admin_delete_subscriber($request);
+
+ $this->assertEquals(404, $response->get_status());
+ $data = $response->get_data();
+ $this->assertFalse($data['success']);
+ }
+
+ /**
+ * Test get_subscription_options with service bodies from BMLT
+ */
+ public function testGetSubscriptionOptionsWithServiceBodies(): void {
+ $this->mockWpRemoteGet([
+ 'GetServiceBodies' => [
+ 'code' => 200,
+ 'body' => [
+ ['id' => '10', 'name' => 'Region A', 'url' => 'https://region-a.example.com'],
+ ['id' => '20', 'name' => 'Region B', 'url' => 'https://region-b.example.com'],
+ ['id' => '30', 'name' => 'Region C', 'url' => 'https://region-c.example.com']
+ ]
+ ]
+ ]);
+
+ Functions\when('get_terms')->justReturn([]);
+
+ $request = new \WP_REST_Request('GET', '/event-manager/v1/subscription-options');
+ $response = SubscribersController::get_subscription_options($request);
+
+ $this->assertEquals(200, $response->get_status());
+ $data = $response->get_data();
+ $this->assertArrayHasKey('service_bodies', $data);
+ $this->assertCount(2, $data['service_bodies']);
+ }
+
+ /**
+ * Test subscribe with valid preferences calls Subscriber::subscribe
+ */
+ public function testSubscribeWithValidPreferencesCallsSubscriber(): void {
+ global $wpdb;
+ $wpdb = \Mockery::mock('wpdb');
+ $wpdb->prefix = 'wp_';
+ $wpdb->shouldReceive('prepare')->andReturn('SQL');
+ $wpdb->shouldReceive('get_row')->andReturn(null);
+ $wpdb->shouldReceive('insert')->andReturn(1);
+
+ Functions\when('get_bloginfo')->alias(function($show) {
+ if ($show === 'name') return 'Test Site';
+ return '';
+ });
+
+ // Mock wp_mail for email sending
+ $this->mockWpMail();
+
+ $request = $this->createRestRequest('POST', '/event-manager/v1/subscribe', [
+ 'email' => 'newuser@example.com',
+ 'preferences' => [
+ 'categories' => [1],
+ 'tags' => [],
+ 'service_bodies' => []
+ ]
+ ]);
+
+ $response = SubscribersController::subscribe($request);
+
+ $this->assertEquals(200, $response->get_status());
+ $data = $response->get_data();
+ $this->assertTrue($data['success']);
+ }
}
diff --git a/tests/Unit/RssFeedTest.php b/tests/Unit/RssFeedTest.php
index b29cd6e..0fec7ed 100644
--- a/tests/Unit/RssFeedTest.php
+++ b/tests/Unit/RssFeedTest.php
@@ -380,6 +380,293 @@ public function testGetShortcodeParamsWithMultipleShortcodes(): void {
$this->assertIsArray($result);
}
+ /**
+ * Test build_rss_xml returns valid XML structure
+ */
+ public function testBuildRssXmlReturnsValidXmlStructure(): void {
+ $events = [];
+ $xml = RssFeed::build_rss_xml(
+ $events,
+ 'Test Site',
+ 'https://example.com',
+ 'Test description',
+ 'en_US',
+ 'Mon, 01 Jan 2024 12:00:00 +0000'
+ );
+
+ $this->assertStringContainsString('', $xml);
+ $this->assertStringContainsString('assertStringContainsString('', $xml);
+ $this->assertStringContainsString('', $xml);
+ $this->assertStringContainsString('', $xml);
+ }
+
+ /**
+ * Test build_rss_xml includes channel elements
+ */
+ public function testBuildRssXmlIncludesChannelElements(): void {
+ $events = [];
+ $xml = RssFeed::build_rss_xml(
+ $events,
+ 'My Test Site',
+ 'https://mysite.example.com',
+ 'Events from our site',
+ 'fr_FR',
+ 'Tue, 15 Jan 2024 10:30:00 +0000'
+ );
+
+ $this->assertStringContainsString('
My Test Site - Events', $xml);
+ $this->assertStringContainsString('https://mysite.example.com', $xml);
+ $this->assertStringContainsString('Events from our site', $xml);
+ $this->assertStringContainsString('Tue, 15 Jan 2024 10:30:00 +0000', $xml);
+ $this->assertStringContainsString('fr_FR', $xml);
+ $this->assertStringContainsString('Mayo Events Manager', $xml);
+ }
+
+ /**
+ * Test build_rss_xml formats events correctly
+ */
+ public function testBuildRssXmlFormatsEventsCorrectly(): void {
+ $events = [
+ [
+ 'title' => 'Test Event',
+ 'link' => 'https://example.com/event/1',
+ 'pub_date' => 'Wed, 20 Jan 2024 14:00:00 +0000',
+ 'description' => 'Event on January 20',
+ 'content' => 'Full event content here
',
+ 'categories' => []
+ ]
+ ];
+
+ $xml = RssFeed::build_rss_xml(
+ $events,
+ 'Test Site',
+ 'https://example.com',
+ 'Test description',
+ 'en_US',
+ 'Mon, 01 Jan 2024 12:00:00 +0000'
+ );
+
+ $this->assertStringContainsString('- ', $xml);
+ $this->assertStringContainsString('Test Event', $xml);
+ $this->assertStringContainsString('https://example.com/event/1', $xml);
+ $this->assertStringContainsString('https://example.com/event/1', $xml);
+ $this->assertStringContainsString('Wed, 20 Jan 2024 14:00:00 +0000', $xml);
+ $this->assertStringContainsString('', $xml);
+ $this->assertStringContainsString('Full event content here]]>', $xml);
+ $this->assertStringContainsString('
', $xml);
+ }
+
+ /**
+ * Test build_rss_xml includes multiple items
+ */
+ public function testBuildRssXmlIncludesMultipleItems(): void {
+ $events = [
+ [
+ 'title' => 'First Event',
+ 'link' => 'https://example.com/event/1',
+ 'pub_date' => 'Wed, 20 Jan 2024 14:00:00 +0000',
+ 'description' => 'First event',
+ 'content' => 'Content 1',
+ 'categories' => []
+ ],
+ [
+ 'title' => 'Second Event',
+ 'link' => 'https://example.com/event/2',
+ 'pub_date' => 'Thu, 21 Jan 2024 14:00:00 +0000',
+ 'description' => 'Second event',
+ 'content' => 'Content 2',
+ 'categories' => []
+ ],
+ [
+ 'title' => 'Third Event',
+ 'link' => 'https://example.com/event/3',
+ 'pub_date' => 'Fri, 22 Jan 2024 14:00:00 +0000',
+ 'description' => 'Third event',
+ 'content' => 'Content 3',
+ 'categories' => []
+ ]
+ ];
+
+ $xml = RssFeed::build_rss_xml(
+ $events,
+ 'Test Site',
+ 'https://example.com',
+ 'Test description',
+ 'en_US'
+ );
+
+ $this->assertStringContainsString('First Event', $xml);
+ $this->assertStringContainsString('Second Event', $xml);
+ $this->assertStringContainsString('Third Event', $xml);
+ $this->assertEquals(3, substr_count($xml, '- '));
+ $this->assertEquals(3, substr_count($xml, '
'));
+ }
+
+ /**
+ * Test build_rss_xml includes categories
+ */
+ public function testBuildRssXmlIncludesCategories(): void {
+ $events = [
+ [
+ 'title' => 'Categorized Event',
+ 'link' => 'https://example.com/event/1',
+ 'pub_date' => 'Wed, 20 Jan 2024 14:00:00 +0000',
+ 'description' => 'Event with categories',
+ 'content' => 'Content',
+ 'categories' => ['News', 'Events', 'Featured']
+ ]
+ ];
+
+ $xml = RssFeed::build_rss_xml(
+ $events,
+ 'Test Site',
+ 'https://example.com',
+ 'Test description',
+ 'en_US'
+ );
+
+ $this->assertStringContainsString('News', $xml);
+ $this->assertStringContainsString('Events', $xml);
+ $this->assertStringContainsString('Featured', $xml);
+ }
+
+ /**
+ * Test build_rss_xml escapes XML special characters in title
+ */
+ public function testBuildRssXmlEscapesSpecialCharactersInTitle(): void {
+ $events = [
+ [
+ 'title' => 'Event & "Special" ',
+ 'link' => 'https://example.com/event/1',
+ 'pub_date' => 'Wed, 20 Jan 2024 14:00:00 +0000',
+ 'description' => 'Description',
+ 'content' => 'Content',
+ 'categories' => []
+ ]
+ ];
+
+ $xml = RssFeed::build_rss_xml(
+ $events,
+ 'Site & "Name"',
+ 'https://example.com',
+ 'Description with ',
+ 'en_US'
+ );
+
+ $this->assertStringContainsString('&', $xml);
+ $this->assertStringContainsString('"', $xml);
+ $this->assertStringContainsString('<', $xml);
+ $this->assertStringContainsString('>', $xml);
+ }
+
+ /**
+ * Test build_rss_xml handles empty events array
+ */
+ public function testBuildRssXmlHandlesEmptyEventsArray(): void {
+ $xml = RssFeed::build_rss_xml(
+ [],
+ 'Test Site',
+ 'https://example.com',
+ 'Test description',
+ 'en_US'
+ );
+
+ $this->assertStringContainsString('', $xml);
+ $this->assertStringContainsString('', $xml);
+ $this->assertStringNotContainsString('- ', $xml);
+ }
+
+ /**
+ * Test build_rss_xml uses current date when build_date is null
+ */
+ public function testBuildRssXmlUsesCurrentDateWhenBuildDateIsNull(): void {
+ $xml = RssFeed::build_rss_xml(
+ [],
+ 'Test Site',
+ 'https://example.com',
+ 'Test description',
+ 'en_US',
+ null
+ );
+
+ // Should contain a date in RFC 2822 format
+ $this->assertMatchesRegularExpression('/[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} [+-]\d{4}<\/lastBuildDate>/', $xml);
+ }
+
+ /**
+ * Test build_rss_xml is well-formed XML
+ */
+ public function testBuildRssXmlIsWellFormedXml(): void {
+ $events = [
+ [
+ 'title' => 'Test Event',
+ 'link' => 'https://example.com/event/1',
+ 'pub_date' => 'Wed, 20 Jan 2024 14:00:00 +0000',
+ 'description' => 'Event description',
+ 'content' => '
Content
',
+ 'categories' => ['Category1']
+ ]
+ ];
+
+ $xml = RssFeed::build_rss_xml(
+ $events,
+ 'Test Site',
+ 'https://example.com',
+ 'Test description',
+ 'en_US',
+ 'Mon, 01 Jan 2024 12:00:00 +0000'
+ );
+
+ // Parse XML to verify it's well-formed
+ $doc = new \DOMDocument();
+ $result = @$doc->loadXML($xml);
+ $this->assertTrue($result, 'XML should be well-formed and parseable');
+ }
+
+ /**
+ * Test build_rss_xml RSS namespace for content:encoded
+ */
+ public function testBuildRssXmlHasContentNamespace(): void {
+ $xml = RssFeed::build_rss_xml(
+ [],
+ 'Test Site',
+ 'https://example.com',
+ 'Test description',
+ 'en_US'
+ );
+
+ $this->assertStringContainsString('xmlns:content="http://purl.org/rss/1.0/modules/content/"', $xml);
+ $this->assertStringContainsString('xmlns:dc="http://purl.org/dc/elements/1.1/"', $xml);
+ }
+
+ /**
+ * Test build_rss_xml handles event with empty categories
+ */
+ public function testBuildRssXmlHandlesEventWithEmptyCategories(): void {
+ $events = [
+ [
+ 'title' => 'Event Without Categories',
+ 'link' => 'https://example.com/event/1',
+ 'pub_date' => 'Wed, 20 Jan 2024 14:00:00 +0000',
+ 'description' => 'Description',
+ 'content' => 'Content',
+ 'categories' => []
+ ]
+ ];
+
+ $xml = RssFeed::build_rss_xml(
+ $events,
+ 'Test Site',
+ 'https://example.com',
+ 'Test description',
+ 'en_US'
+ );
+
+ $this->assertStringContainsString('- ', $xml);
+ $this->assertStringNotContainsString('', $xml);
+ }
+
/**
* Helper to get private method
*/
diff --git a/tests/Unit/SubscriberTest.php b/tests/Unit/SubscriberTest.php
index 34cf730..561ba81 100644
--- a/tests/Unit/SubscriberTest.php
+++ b/tests/Unit/SubscriberTest.php
@@ -5,6 +5,25 @@
use BmltEnabled\Mayo\Subscriber;
use Brain\Monkey\Functions;
use ReflectionClass;
+use Mockery;
+
+/**
+ * Testable Subscriber class that allows mocking $wpdb
+ */
+class TestableSubscriber extends Subscriber {
+ public static $mockWpdb = null;
+
+ protected static function get_wpdb() {
+ if (self::$mockWpdb !== null) {
+ return self::$mockWpdb;
+ }
+ return parent::get_wpdb();
+ }
+
+ public static function resetMock() {
+ self::$mockWpdb = null;
+ }
+}
class SubscriberTest extends TestCase {
@@ -33,6 +52,29 @@ protected function setUp(): void {
// Mock get_term
Functions\when('get_term')->justReturn(null);
+
+ // Mock get_bloginfo for email methods
+ Functions\when('get_bloginfo')->alias(function($show) {
+ if ($show === 'name') return 'Test Site';
+ return '';
+ });
+
+ // Reset testable subscriber mock
+ TestableSubscriber::resetMock();
+ }
+
+ protected function tearDown(): void {
+ TestableSubscriber::resetMock();
+ parent::tearDown();
+ }
+
+ /**
+ * Create a mock wpdb object for testing
+ */
+ private function createMockWpdb() {
+ $mock = Mockery::mock('wpdb');
+ $mock->prefix = 'wp_';
+ return $mock;
}
/**
@@ -392,6 +434,597 @@ public function testTableNameConstant(): void {
$this->assertEquals('mayo_subscribers', Subscriber::TABLE_NAME);
}
+ /**
+ * Test get_table_name returns prefixed table name
+ */
+ public function testGetTableNameReturnsPrefixedTableName(): void {
+ $mockWpdb = $this->createMockWpdb();
+ $mockWpdb->prefix = 'test_prefix_';
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::get_table_name();
+
+ $this->assertEquals('test_prefix_mayo_subscribers', $result);
+ }
+
+ /**
+ * Test subscribe returns error for invalid email
+ */
+ public function testSubscribeReturnsErrorForInvalidEmail(): void {
+ $result = TestableSubscriber::subscribe('not-an-email');
+
+ $this->assertIsArray($result);
+ $this->assertFalse($result['success']);
+ $this->assertEquals('invalid_email', $result['code']);
+ }
+
+ /**
+ * Test subscribe creates new subscriber
+ */
+ public function testSubscribeCreatesNewSubscriber(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ // No existing subscriber
+ $mockWpdb->shouldReceive('prepare')
+ ->once()
+ ->andReturn('SELECT * FROM wp_mayo_subscribers WHERE email = "test@example.com"');
+
+ $mockWpdb->shouldReceive('get_row')
+ ->once()
+ ->andReturn(null);
+
+ // Insert new subscriber
+ $mockWpdb->shouldReceive('insert')
+ ->once()
+ ->andReturn(1);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::subscribe('test@example.com');
+
+ $this->assertIsArray($result);
+ $this->assertTrue($result['success']);
+ $this->assertEquals('confirmation_sent', $result['code']);
+ }
+
+ /**
+ * Test subscribe with preferences creates new subscriber with preferences
+ */
+ public function testSubscribeWithPreferencesCreatesSubscriberWithPreferences(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $mockWpdb->shouldReceive('prepare')
+ ->once()
+ ->andReturn('SQL');
+
+ $mockWpdb->shouldReceive('get_row')
+ ->once()
+ ->andReturn(null);
+
+ $mockWpdb->shouldReceive('insert')
+ ->once()
+ ->withArgs(function($table, $data) {
+ return isset($data['preferences']) && $data['preferences'] !== null;
+ })
+ ->andReturn(1);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $preferences = ['categories' => [1, 2], 'tags' => [], 'service_bodies' => []];
+ $result = TestableSubscriber::subscribe('test@example.com', $preferences);
+
+ $this->assertTrue($result['success']);
+ }
+
+ /**
+ * Test subscribe returns error when email already active
+ */
+ public function testSubscribeReturnsErrorWhenEmailAlreadyActive(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $existingSubscriber = (object)[
+ 'id' => 1,
+ 'email' => 'test@example.com',
+ 'status' => 'active',
+ 'token' => 'existingtoken123'
+ ];
+
+ $mockWpdb->shouldReceive('prepare')->once()->andReturn('SQL');
+ $mockWpdb->shouldReceive('get_row')->once()->andReturn($existingSubscriber);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::subscribe('test@example.com');
+
+ $this->assertFalse($result['success']);
+ $this->assertEquals('already_subscribed', $result['code']);
+ }
+
+ /**
+ * Test subscribe resends confirmation for pending subscriber
+ */
+ public function testSubscribeResendsConfirmationForPending(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $existingSubscriber = (object)[
+ 'id' => 1,
+ 'email' => 'test@example.com',
+ 'status' => 'pending',
+ 'token' => 'existingtoken123'
+ ];
+
+ $mockWpdb->shouldReceive('prepare')->once()->andReturn('SQL');
+ $mockWpdb->shouldReceive('get_row')->once()->andReturn($existingSubscriber);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::subscribe('test@example.com');
+
+ $this->assertTrue($result['success']);
+ $this->assertEquals('confirmation_resent', $result['code']);
+ }
+
+ /**
+ * Test subscribe re-subscribes unsubscribed user
+ */
+ public function testSubscribeResubscribesUnsubscribedUser(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $existingSubscriber = (object)[
+ 'id' => 1,
+ 'email' => 'test@example.com',
+ 'status' => 'unsubscribed',
+ 'token' => 'existingtoken123'
+ ];
+
+ $mockWpdb->shouldReceive('prepare')->once()->andReturn('SQL');
+ $mockWpdb->shouldReceive('get_row')->once()->andReturn($existingSubscriber);
+ $mockWpdb->shouldReceive('update')->once()->andReturn(1);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::subscribe('test@example.com');
+
+ $this->assertTrue($result['success']);
+ $this->assertEquals('resubscribed', $result['code']);
+ }
+
+ /**
+ * Test subscribe returns error on database failure
+ */
+ public function testSubscribeReturnsErrorOnDatabaseFailure(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $mockWpdb->shouldReceive('prepare')->once()->andReturn('SQL');
+ $mockWpdb->shouldReceive('get_row')->once()->andReturn(null);
+ $mockWpdb->shouldReceive('insert')->once()->andReturn(false);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::subscribe('test@example.com');
+
+ $this->assertFalse($result['success']);
+ $this->assertEquals('database_error', $result['code']);
+ }
+
+ /**
+ * Test get_by_token returns subscriber
+ */
+ public function testGetByTokenReturnsSubscriber(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $subscriber = (object)[
+ 'id' => 1,
+ 'email' => 'test@example.com',
+ 'status' => 'active',
+ 'token' => 'abc123def456'
+ ];
+
+ $mockWpdb->shouldReceive('prepare')->once()->andReturn('SQL');
+ $mockWpdb->shouldReceive('get_row')->once()->andReturn($subscriber);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::get_by_token('abc123def456');
+
+ $this->assertNotNull($result);
+ $this->assertEquals('test@example.com', $result->email);
+ }
+
+ /**
+ * Test get_by_token returns null for invalid token
+ */
+ public function testGetByTokenReturnsNullForInvalidToken(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $mockWpdb->shouldReceive('prepare')->once()->andReturn('SQL');
+ $mockWpdb->shouldReceive('get_row')->once()->andReturn(null);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::get_by_token('invalidtoken');
+
+ $this->assertNull($result);
+ }
+
+ /**
+ * Test confirm activates subscriber
+ */
+ public function testConfirmActivatesSubscriber(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $subscriber = (object)[
+ 'id' => 1,
+ 'email' => 'test@example.com',
+ 'status' => 'pending',
+ 'token' => 'validtoken123'
+ ];
+
+ $mockWpdb->shouldReceive('prepare')->once()->andReturn('SQL');
+ $mockWpdb->shouldReceive('get_row')->once()->andReturn($subscriber);
+ $mockWpdb->shouldReceive('update')->once()->andReturn(1);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::confirm('validtoken123');
+
+ $this->assertTrue($result['success']);
+ $this->assertEquals('confirmed', $result['code']);
+ }
+
+ /**
+ * Test confirm returns error for invalid token
+ */
+ public function testConfirmReturnsErrorForInvalidToken(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $mockWpdb->shouldReceive('prepare')->once()->andReturn('SQL');
+ $mockWpdb->shouldReceive('get_row')->once()->andReturn(null);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::confirm('invalidtoken');
+
+ $this->assertFalse($result['success']);
+ $this->assertEquals('invalid_token', $result['code']);
+ }
+
+ /**
+ * Test confirm returns already confirmed for active subscriber
+ */
+ public function testConfirmReturnsAlreadyConfirmedForActive(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $subscriber = (object)[
+ 'id' => 1,
+ 'email' => 'test@example.com',
+ 'status' => 'active',
+ 'token' => 'validtoken123'
+ ];
+
+ $mockWpdb->shouldReceive('prepare')->once()->andReturn('SQL');
+ $mockWpdb->shouldReceive('get_row')->once()->andReturn($subscriber);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::confirm('validtoken123');
+
+ $this->assertTrue($result['success']);
+ $this->assertEquals('already_confirmed', $result['code']);
+ }
+
+ /**
+ * Test unsubscribe marks subscriber as unsubscribed
+ */
+ public function testUnsubscribeMarksSubscriberAsUnsubscribed(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $subscriber = (object)[
+ 'id' => 1,
+ 'email' => 'test@example.com',
+ 'status' => 'active',
+ 'token' => 'validtoken123'
+ ];
+
+ $mockWpdb->shouldReceive('prepare')->once()->andReturn('SQL');
+ $mockWpdb->shouldReceive('get_row')->once()->andReturn($subscriber);
+ $mockWpdb->shouldReceive('update')->once()->andReturn(1);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::unsubscribe('validtoken123');
+
+ $this->assertTrue($result['success']);
+ $this->assertEquals('unsubscribed', $result['code']);
+ }
+
+ /**
+ * Test unsubscribe returns error for invalid token
+ */
+ public function testUnsubscribeReturnsErrorForInvalidToken(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $mockWpdb->shouldReceive('prepare')->once()->andReturn('SQL');
+ $mockWpdb->shouldReceive('get_row')->once()->andReturn(null);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::unsubscribe('invalidtoken');
+
+ $this->assertFalse($result['success']);
+ $this->assertEquals('invalid_token', $result['code']);
+ }
+
+ /**
+ * Test unsubscribe returns already_unsubscribed for unsubscribed user
+ */
+ public function testUnsubscribeReturnsAlreadyUnsubscribed(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $subscriber = (object)[
+ 'id' => 1,
+ 'email' => 'test@example.com',
+ 'status' => 'unsubscribed',
+ 'token' => 'validtoken123'
+ ];
+
+ $mockWpdb->shouldReceive('prepare')->once()->andReturn('SQL');
+ $mockWpdb->shouldReceive('get_row')->once()->andReturn($subscriber);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::unsubscribe('validtoken123');
+
+ $this->assertTrue($result['success']);
+ $this->assertEquals('already_unsubscribed', $result['code']);
+ }
+
+ /**
+ * Test get_active_subscribers returns only active subscribers
+ */
+ public function testGetActiveSubscribersReturnsOnlyActive(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $subscribers = [
+ (object)['id' => 1, 'email' => 'active1@example.com', 'status' => 'active'],
+ (object)['id' => 2, 'email' => 'active2@example.com', 'status' => 'active']
+ ];
+
+ $mockWpdb->shouldReceive('get_results')->once()->andReturn($subscribers);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::get_active_subscribers();
+
+ $this->assertIsArray($result);
+ $this->assertCount(2, $result);
+ }
+
+ /**
+ * Test get_all_subscribers returns all subscribers
+ */
+ public function testGetAllSubscribersReturnsAll(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $subscribers = [
+ (object)['id' => 1, 'email' => 'a@example.com', 'status' => 'active'],
+ (object)['id' => 2, 'email' => 'b@example.com', 'status' => 'pending'],
+ (object)['id' => 3, 'email' => 'c@example.com', 'status' => 'unsubscribed']
+ ];
+
+ $mockWpdb->shouldReceive('get_results')->once()->andReturn($subscribers);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::get_all_subscribers();
+
+ $this->assertIsArray($result);
+ $this->assertCount(3, $result);
+ }
+
+ /**
+ * Test update_status updates subscriber status
+ */
+ public function testUpdateStatusUpdatesSubscriberStatus(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $mockWpdb->shouldReceive('update')
+ ->once()
+ ->withArgs(function($table, $data, $where) {
+ return $data['status'] === 'active';
+ })
+ ->andReturn(1);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::update_status(1, 'active');
+
+ $this->assertTrue($result);
+ }
+
+ /**
+ * Test update_status sets confirmed_at when activating
+ */
+ public function testUpdateStatusSetsConfirmedAtWhenActivating(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $mockWpdb->shouldReceive('update')
+ ->once()
+ ->withArgs(function($table, $data, $where) {
+ return isset($data['confirmed_at']);
+ })
+ ->andReturn(1);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::update_status(1, 'active');
+
+ $this->assertTrue($result);
+ }
+
+ /**
+ * Test update_preferences updates subscriber preferences
+ */
+ public function testUpdatePreferencesUpdatesSubscriberPreferences(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $mockWpdb->shouldReceive('update')
+ ->once()
+ ->andReturn(1);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::update_preferences('token123', ['categories' => [1]]);
+
+ $this->assertTrue($result);
+ }
+
+ /**
+ * Test update_preferences returns false on failure
+ */
+ public function testUpdatePreferencesReturnsFalseOnFailure(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $mockWpdb->shouldReceive('update')
+ ->once()
+ ->andReturn(false);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::update_preferences('token123', ['categories' => [1]]);
+
+ $this->assertFalse($result);
+ }
+
+ /**
+ * Test update_preferences_by_id updates subscriber preferences by ID
+ */
+ public function testUpdatePreferencesByIdUpdatesPreferences(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $mockWpdb->shouldReceive('update')
+ ->once()
+ ->andReturn(1);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::update_preferences_by_id(1, ['categories' => [1, 2]]);
+
+ $this->assertTrue($result);
+ }
+
+ /**
+ * Test delete removes subscriber
+ */
+ public function testDeleteRemovesSubscriber(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $mockWpdb->shouldReceive('delete')
+ ->once()
+ ->andReturn(1);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::delete(1);
+
+ $this->assertTrue($result);
+ }
+
+ /**
+ * Test delete returns false when subscriber not found
+ */
+ public function testDeleteReturnsFalseWhenNotFound(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $mockWpdb->shouldReceive('delete')
+ ->once()
+ ->andReturn(false);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::delete(999);
+
+ $this->assertFalse($result);
+ }
+
+ /**
+ * Test get_matching returns subscribers matching criteria
+ */
+ public function testGetMatchingReturnsMatchingSubscribers(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $subscribers = [
+ (object)[
+ 'id' => 1,
+ 'email' => 'match@example.com',
+ 'status' => 'active',
+ 'preferences' => json_encode(['categories' => [1], 'tags' => [], 'service_bodies' => []])
+ ]
+ ];
+
+ $mockWpdb->shouldReceive('get_results')->once()->andReturn($subscribers);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::get_matching([
+ 'categories' => [1],
+ 'tags' => [],
+ 'service_body' => ''
+ ]);
+
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ }
+
+ /**
+ * Test count_matching returns count of matching subscribers
+ */
+ public function testCountMatchingReturnsCount(): void {
+ $mockWpdb = $this->createMockWpdb();
+
+ $subscribers = [
+ (object)[
+ 'id' => 1,
+ 'email' => 'match1@example.com',
+ 'status' => 'active',
+ 'preferences' => null
+ ],
+ (object)[
+ 'id' => 2,
+ 'email' => 'match2@example.com',
+ 'status' => 'active',
+ 'preferences' => null
+ ]
+ ];
+
+ $mockWpdb->shouldReceive('get_results')->once()->andReturn($subscribers);
+
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ $result = TestableSubscriber::count_matching([
+ 'categories' => [1],
+ 'tags' => [],
+ 'service_body' => ''
+ ]);
+
+ $this->assertEquals(2, $result);
+ }
+
+ /**
+ * Test get_wpdb returns wpdb instance
+ * Note: create_table can't be fully tested without WordPress test environment
+ * because it requires require_once for upgrade.php
+ */
+ public function testGetWpdbIntegration(): void {
+ // The base class get_wpdb() is tested implicitly by other tests
+ // This test verifies the TestableSubscriber mock pattern works
+ $mockWpdb = $this->createMockWpdb();
+ TestableSubscriber::$mockWpdb = $mockWpdb;
+
+ // Verify the mock is returned
+ $this->assertEquals('wp_mayo_subscribers', TestableSubscriber::get_table_name());
+ }
+
/**
* Helper to get private method
*/
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index baa3b27..5486997 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -171,6 +171,10 @@ public function set_body($body) {
$this->body = $body;
}
+ public function get_json_params() {
+ return $this->body_params;
+ }
+
#[\ReturnTypeWillChange]
public function offsetExists($offset): bool {
$params = $this->get_params();