PluginProbe
Custom 404 Pro / trunk
Custom 404 Pro vtrunk
3.15.2 3.15.4 3.15.5 3.15.6 3.16.0 3.15.1 3.15.0 3.14.1 3.14.0 3.13.0 trunk 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.1.5 1.1.6 1.2.0 1.3.10 1.3.12 1.3.5 All 100 releases
custom-404-pro / tests / integration / RedirectTest.php

RedirectTest.php in Custom 404 Pro trunk, at tests/integration/RedirectTest.php

345 lines 10.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Integration tests for the 404 redirect behaviour.
4 *
5 * @package Custom_404_Pro
6 */
7
8 /**
9 * Tests AdminClass::custom_404_pro_redirect() against a real WordPress + MySQL environment.
10 *
11 * Strategy for intercepting wp_safe_redirect():
12 * wp_safe_redirect() fires the 'wp_redirect' filter before sending headers and
13 * calling exit(). Returning false from that filter suppresses the redirect so
14 * the test process does not terminate. We capture the URL and status code in
15 * instance properties for assertions.
16 *
17 * Strategy for simulating a 404 request:
18 * We set $GLOBALS['wp_query']->is_404 to true, which makes is_404() return
19 * true without needing a real HTTP request.
20 */
21 class C404P_Integration_RedirectTest extends WP_UnitTestCase {
22
23 /**
24 * The redirect URL captured by the wp_redirect filter.
25 *
26 * @var string|null
27 */
28 private $redirect_url;
29
30 /**
31 * The HTTP status code captured by the wp_redirect filter.
32 *
33 * @var int|null
34 */
35 private $redirect_status;
36
37 /**
38 * AdminClass instance under test.
39 *
40 * @var AdminClass
41 */
42 private $admin;
43
44 /**
45 * Helpers instance for setting up option values.
46 *
47 * @var Helpers
48 */
49 private $helpers;
50
51 /**
52 * Set up: create the logs table, seed default settings, configure 404 state.
53 */
54 public function setUp(): void {
55 parent::setUp();
56
57 $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
58 wp_set_current_user( $admin_id );
59
60 ActivateClass::create_tables();
61 ActivateClass::initialize_options(); // seeds wp_options defaults via add_option()
62
63 $this->helpers = new Helpers();
64 $this->admin = new AdminClass();
65 $this->redirect_url = null;
66 $this->redirect_status = null;
67
68 // wp_safe_redirect() calls wp_validate_redirect() which rejects cross-domain
69 // URLs (test site is example.org). Allow the domains used in tests so the
70 // redirect URL reaches our capture filter unchanged.
71 add_filter( 'allowed_redirect_hosts', array( $this, 'allow_test_hosts' ) );
72
73 // Intercept wp_safe_redirect() before it sends headers / calls exit().
74 add_filter( 'wp_redirect', array( $this, 'capture_redirect' ), 10, 2 );
75
76 // Make is_404() return true.
77 $GLOBALS['wp_query'] = new WP_Query();
78 $GLOBALS['wp_query']->is_404 = true;
79 }
80
81 /**
82 * Tear down: remove filters, reset globals, drop the logs table.
83 *
84 * Settings are stored in wp_options (DML) so they are rolled back automatically
85 * by WP_UnitTestCase. Only the logs table requires a manual DROP because
86 * CREATE TABLE is DDL and implicitly commits.
87 */
88 public function tearDown(): void {
89 global $wpdb;
90
91 remove_filter( 'allowed_redirect_hosts', array( $this, 'allow_test_hosts' ) );
92 remove_filter( 'wp_redirect', array( $this, 'capture_redirect' ), 10 );
93
94 unset( $GLOBALS['wp_query'] );
95
96 $wpdb->query( 'DROP TABLE IF EXISTS ' . $wpdb->prefix . $this->helpers->table_logs ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
97
98 parent::tearDown();
99 }
100
101 /**
102 * allowed_redirect_hosts filter — permits external domains used in tests.
103 *
104 * @param array $hosts Allowed hosts.
105 * @return array
106 */
107 public function allow_test_hosts( $hosts ) {
108 $hosts[] = 'example.com';
109 $hosts[] = 'example.org'; // WP test environment site domain.
110 return $hosts;
111 }
112
113 /**
114 * wp_redirect filter callback — captures location/status and cancels redirect.
115 *
116 * @param string $location Redirect URL.
117 * @param int $status HTTP status code.
118 * @return false Returning false prevents headers from being sent.
119 */
120 public function capture_redirect( $location, $status ) {
121 $this->redirect_url = $location;
122 $this->redirect_status = $status;
123 return false;
124 }
125
126 // -------------------------------------------------------------------------
127 // Tests
128 // -------------------------------------------------------------------------
129
130 /**
131 * No redirect should occur when mode is empty (default state).
132 */
133 public function test_redirect_does_nothing_when_mode_is_empty() {
134 // Default value of 'mode' after initialize_options() is ''.
135 $this->admin->custom_404_pro_redirect();
136 $this->assertNull( $this->redirect_url, 'No redirect should fire when mode is empty.' );
137 }
138
139 /**
140 * URL mode should redirect to the configured URL.
141 */
142 public function test_redirect_sends_correct_url_in_url_mode() {
143 $this->helpers->update_settings( array( 'mode' => 'url', 'mode_url' => 'https://example.com/custom-error' ) );
144
145 $this->admin->custom_404_pro_redirect();
146
147 $this->assertSame( 'https://example.com/custom-error', $this->redirect_url );
148 }
149
150 /**
151 * The HTTP status code from the redirect_error_code option should be used.
152 */
153 public function test_redirect_uses_configured_status_code() {
154 $this->helpers->update_settings( array( 'mode' => 'url', 'mode_url' => 'https://example.com', 'redirect_error_code' => 301 ) );
155
156 $this->admin->custom_404_pro_redirect();
157
158 $this->assertSame( 301, $this->redirect_status );
159 }
160
161 /**
162 * Page mode should redirect to the current permalink of the configured WordPress page.
163 */
164 public function test_redirect_page_mode_uses_current_permalink() {
165 $page_id = self::factory()->post->create(
166 array(
167 'post_type' => 'page',
168 'post_status' => 'publish',
169 'post_title' => 'Custom 404 Page',
170 )
171 );
172
173 $this->helpers->update_settings( array( 'mode' => 'page', 'mode_page' => (string) $page_id ) );
174
175 $this->admin->custom_404_pro_redirect();
176
177 $this->assertSame( get_permalink( $page_id ), $this->redirect_url );
178 }
179
180 /**
181 * Page mode should redirect to the current permalink even when the post GUID
182 * is stale (e.g. after a domain migration or HTTP→HTTPS upgrade).
183 *
184 * This is the regression test for the bug reported against Genesis themes:
185 * the old code used $page->guid which is never updated after post creation,
186 * causing wp_safe_redirect() to reject the cross-domain URL silently.
187 */
188 public function test_redirect_page_mode_uses_permalink_not_stale_guid() {
189 global $wpdb;
190
191 $page_id = self::factory()->post->create(
192 array(
193 'post_type' => 'page',
194 'post_status' => 'publish',
195 'post_title' => 'Custom 404 Page',
196 )
197 );
198
199 // Simulate a stale GUID left over from a domain migration.
200 $stale_guid = 'http://old-domain.example.com/?page_id=' . $page_id;
201 $wpdb->update( $wpdb->posts, array( 'guid' => $stale_guid ), array( 'ID' => $page_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
202 clean_post_cache( $page_id );
203
204 $this->helpers->update_settings( array( 'mode' => 'page', 'mode_page' => (string) $page_id ) );
205
206 $this->admin->custom_404_pro_redirect();
207
208 $expected_url = get_permalink( $page_id );
209 $this->assertNotNull( $this->redirect_url, 'A redirect should fire in page mode.' );
210 $this->assertSame( $expected_url, $this->redirect_url, 'Redirect should use get_permalink(), not the stale GUID.' );
211 $this->assertNotSame( $stale_guid, $this->redirect_url, 'Redirect must not use the stale GUID.' );
212 }
213
214 /**
215 * No redirect should fire when the current request is not a 404.
216 */
217 public function test_redirect_does_not_fire_when_not_404() {
218 $GLOBALS['wp_query']->is_404 = false;
219
220 $this->helpers->update_settings( array( 'mode' => 'url', 'mode_url' => 'https://example.com' ) );
221
222 $this->admin->custom_404_pro_redirect();
223
224 $this->assertNull( $this->redirect_url, 'No redirect should fire for non-404 requests.' );
225 }
226
227 /**
228 * A log entry should be created when logging is enabled.
229 */
230 public function test_redirect_creates_log_entry_when_logging_enabled() {
231 $this->helpers->update_settings( array( 'logging_enabled' => true, 'mode' => 'url', 'mode_url' => 'https://example.com' ) );
232
233 $this->admin->custom_404_pro_redirect();
234
235 $logs = $this->helpers->get_logs();
236 $this->assertNotEmpty( $logs, 'A log entry should be created when logging is enabled.' );
237 }
238
239 /**
240 * No log entry should be created when logging is disabled (default).
241 */
242 public function test_redirect_does_not_log_when_logging_disabled() {
243 // Default logging_enabled is false after initialize_options().
244 $this->helpers->update_settings( array( 'mode' => 'url', 'mode_url' => 'https://example.com' ) );
245
246 $this->admin->custom_404_pro_redirect();
247
248 $logs = $this->helpers->get_logs();
249 $this->assertEmpty( $logs, 'No log entry should be created when logging is disabled.' );
250 }
251
252 // -------------------------------------------------------------------------
253 // Email cooldown tests
254 // -------------------------------------------------------------------------
255
256 /**
257 * The cooldown transient should be set after a notification email is sent.
258 */
259 public function test_email_cooldown_transient_is_set_after_notification_sent() {
260 $this->helpers->update_settings(
261 array(
262 'logging_enabled' => true,
263 'send_email' => true,
264 'mode' => 'url',
265 'mode_url' => 'https://example.com',
266 )
267 );
268
269 $this->assertFalse(
270 get_transient( 'custom_404_pro_email_cooldown' ),
271 'Cooldown transient should not exist before the first 404.'
272 );
273
274 $this->admin->custom_404_pro_redirect();
275
276 $this->assertNotFalse(
277 get_transient( 'custom_404_pro_email_cooldown' ),
278 'Cooldown transient should be set after an email notification is sent.'
279 );
280 }
281
282 /**
283 * No email should be sent when the cooldown transient is already active.
284 *
285 * Uses a wp_mail filter to count how many times wp_mail() is invoked.
286 */
287 public function test_email_not_sent_during_active_cooldown() {
288 $this->helpers->update_settings(
289 array(
290 'logging_enabled' => true,
291 'send_email' => true,
292 'mode' => 'url',
293 'mode_url' => 'https://example.com',
294 )
295 );
296
297 // Pre-set the cooldown transient to simulate a recent send.
298 set_transient( 'custom_404_pro_email_cooldown', true, HOUR_IN_SECONDS );
299
300 $mail_count = 0;
301 $counter = function ( $args ) use ( &$mail_count ) {
302 ++$mail_count;
303 return $args;
304 };
305 add_filter( 'wp_mail', $counter );
306
307 $this->admin->custom_404_pro_redirect();
308
309 remove_filter( 'wp_mail', $counter );
310
311 $this->assertSame( 0, $mail_count, 'No email should be sent while the cooldown transient is active.' );
312 }
313
314 /**
315 * Exactly one email should be sent on the first 404, and none on the second.
316 */
317 public function test_only_one_email_sent_across_two_consecutive_404s() {
318 $this->helpers->update_settings(
319 array(
320 'logging_enabled' => true,
321 'send_email' => true,
322 'mode' => 'url',
323 'mode_url' => 'https://example.com',
324 )
325 );
326
327 $mail_count = 0;
328 $counter = function ( $args ) use ( &$mail_count ) {
329 ++$mail_count;
330 return $args;
331 };
332 add_filter( 'wp_mail', $counter );
333
334 // First 404 — should trigger an email and set the transient.
335 $this->admin->custom_404_pro_redirect();
336
337 // Second 404 — transient is now active, email should be suppressed.
338 $this->admin->custom_404_pro_redirect();
339
340 remove_filter( 'wp_mail', $counter );
341
342 $this->assertSame( 1, $mail_count, 'Exactly one email should be sent across two consecutive 404s.' );
343 }
344 }
345