docs(wpmc 1.1.0): implementation plan for Redirect reverse-slug lookup
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,837 @@
|
||||
# Redirect 1.1.0 — washanyanya-style reverse-slug lookup — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Заменить `Redirect::handle()` на washanyanya-style: триггер на `template_redirect` priority 1, при `is_404()` ищет page по slug через `get_posts(name=$slug, status=[private,publish])`, матчит с `page_unic`, делает 301 на канонический `/{city}/{orig-slug}/`.
|
||||
|
||||
**Architecture:** Полный rewrite handle() + перезапись RedirectTest (13 новых тестов). Hook переезжает с `wp@9998` на `template_redirect@1`. PluginBootS4Test обновляется. Никаких новых классов или файлов.
|
||||
|
||||
**Tech Stack:** PHP 8.0+, Brain Monkey 2.7 + Mockery 1.6 + Patchwork (тестовая инфра), PHPUnit 9.6.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-19-redirect-reverse-slug-lookup-design.md`
|
||||
|
||||
**Beads:** `cp-ifg`. **Gitea issue:** <https://git.netranking.ru/bryzgalov/wp-multi-city/issues/1>.
|
||||
|
||||
**Current state (baseline):**
|
||||
- 109 tests / 208 assertions green
|
||||
- Last commit on main: `20aff66 spec(wpmc 1.1.0): ...`
|
||||
- Working tree clean
|
||||
- `Redirect::register()` hooks `wp@9998`; `Redirect::handle()` triggers on `$post->ID === page_unic[i].page_new`
|
||||
- 4 tests in `tests/RedirectTest.php` cover old semantics
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Switch Redirect hook from `wp@9998` to `template_redirect@1`
|
||||
|
||||
**Files:**
|
||||
- Modify: `includes/class-redirect.php` (register() method, line 10)
|
||||
- Modify: `tests/RedirectTest.php` (test_register_attaches_wp_action_at_priority_9998 → rename + change assertion)
|
||||
- Modify: `tests/PluginBootS4Test.php` (line ~38, change `wp` priority 9998 → `template_redirect` priority 1)
|
||||
|
||||
- [ ] **Step 1: Update PluginBootS4Test assertion (red)**
|
||||
|
||||
In `tests/PluginBootS4Test.php`, replace the existing Redirect-related assertion:
|
||||
```php
|
||||
self::assertSame(
|
||||
9998,
|
||||
has_action('wp', ['WPMultiCity\\Redirect', 'handle']),
|
||||
'boot() must register Redirect on wp@9998'
|
||||
);
|
||||
```
|
||||
with:
|
||||
```php
|
||||
self::assertSame(
|
||||
1,
|
||||
has_action('template_redirect', ['WPMultiCity\\Redirect', 'handle']),
|
||||
'boot() must register Redirect on template_redirect@1'
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update RedirectTest hook test (red)**
|
||||
|
||||
In `tests/RedirectTest.php`, replace the existing test:
|
||||
```php
|
||||
public function test_register_attaches_wp_action_at_priority_9998(): void
|
||||
{
|
||||
Redirect::register();
|
||||
|
||||
self::assertSame(
|
||||
9998,
|
||||
has_action('wp', [Redirect::class, 'handle']),
|
||||
'Redirect::register() must hook wp action at priority 9998'
|
||||
);
|
||||
}
|
||||
```
|
||||
with:
|
||||
```php
|
||||
public function test_register_attaches_template_redirect_at_priority_1(): void
|
||||
{
|
||||
Redirect::register();
|
||||
|
||||
self::assertSame(
|
||||
1,
|
||||
has_action('template_redirect', [Redirect::class, 'handle']),
|
||||
'Redirect::register() must hook template_redirect at priority 1'
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run both tests — confirm they fail**
|
||||
|
||||
Run: `vendor/bin/phpunit --filter 'test_register_attaches_template_redirect_at_priority_1|test_boot_registers'`
|
||||
Expected: FAIL (Redirect::register still hooks `wp` not `template_redirect`).
|
||||
|
||||
- [ ] **Step 4: Update Redirect::register() (green)**
|
||||
|
||||
In `includes/class-redirect.php`, replace:
|
||||
```php
|
||||
public static function register(): void
|
||||
{
|
||||
\add_action('wp', [self::class, 'handle'], 9998);
|
||||
}
|
||||
```
|
||||
with:
|
||||
```php
|
||||
public static function register(): void
|
||||
{
|
||||
\add_action('template_redirect', [self::class, 'handle'], 1);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run full test suite — some old RedirectTest tests will fail (expected)**
|
||||
|
||||
Run: `vendor/bin/phpunit`
|
||||
Expected: hook tests pass; the 3 other old RedirectTest tests (`test_handle_skips_when_current_town_equals_main`, `test_handle_skips_when_post_not_set`, `test_handle_skips_when_url_already_has_town_prefix`, `test_handle_redirects_when_post_is_page_unic_clone_without_prefix`) may still pass because they don't exercise the hook itself, only `Redirect::handle()` directly. If they all still pass, that's fine — we'll replace them in Task 2.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add includes/class-redirect.php tests/RedirectTest.php tests/PluginBootS4Test.php
|
||||
git commit -m "feat(wpmc 1.1.0): Redirect hook moves to template_redirect@1
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Rewrite Redirect::handle() to washanyanya-style + replace RedirectTest
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `includes/class-redirect.php` (handle() method body — full replacement, +extract_last_segment helper)
|
||||
- Rewrite: `tests/RedirectTest.php` (drop 4 old handle() tests, add 13 new tests covering new semantics)
|
||||
|
||||
This task is the core of 1.1.0. Follow strict TDD: write each new test FIRST, run to confirm RED for the right reason, then implement (or extend) the minimum logic to make it pass. Take the tests in the order listed.
|
||||
|
||||
Note: the test from Task 1 (`test_register_attaches_template_redirect_at_priority_1`) stays. Only the 4 `test_handle_*` tests from the old version are dropped. They are replaced by 13 new tests.
|
||||
|
||||
**Common test setup (used by all 13 tests below).** Pattern from existing `tests/RedirectTest.php`:
|
||||
- `\Brain\Monkey\Functions\stubs(['did_action' => 1])` so the ACF guard in Helpers methods passes.
|
||||
- `\Brain\Monkey\Functions\when('is_admin')->justReturn(false)` by default; tests for admin override.
|
||||
- Use `\Brain\Monkey\Functions\expect('wp_safe_redirect')->never()` for bail-out cases.
|
||||
- Use `\Brain\Monkey\Functions\expect('wp_safe_redirect')->once()->with(...)->andThrow(new \RuntimeException('redirect-called'))` then try/catch to verify success path (because `exit` would terminate phpunit).
|
||||
|
||||
The new `Redirect::handle()` calls these WP functions: `is_admin`, `is_404`, `get_posts`, `get_field`, `get_term_by`, `get_permalink`, `wp_safe_redirect`. Plus helpers: `Helpers::main_town_slug`, `Helpers::without_url_filters`, `Helpers::remove_domain_link`. Stub the ones not exercised in each test using `Functions\when(...)->justReturn(...)` to keep tests focused.
|
||||
|
||||
### Sub-step 2a: Drop the 4 old `test_handle_*` tests
|
||||
|
||||
- [ ] **Step 1: Delete from `tests/RedirectTest.php`** — these 4 methods:
|
||||
- `test_handle_skips_when_current_town_equals_main`
|
||||
- `test_handle_skips_when_post_not_set`
|
||||
- `test_handle_skips_when_url_already_has_town_prefix`
|
||||
- `test_handle_redirects_when_post_is_page_unic_clone_without_prefix`
|
||||
|
||||
Leave only `test_register_attaches_template_redirect_at_priority_1`. Save.
|
||||
|
||||
- [ ] **Step 2: Run** — `vendor/bin/phpunit --filter RedirectTest` — expect 1 test pass.
|
||||
|
||||
### Sub-step 2b: Bail-out tests (write all 6, then implement the bail logic)
|
||||
|
||||
- [ ] **Step 1: Write `test_bail_when_is_admin`**
|
||||
|
||||
Append to `tests/RedirectTest.php`:
|
||||
```php
|
||||
public function test_bail_when_is_admin(): void
|
||||
{
|
||||
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(true);
|
||||
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
|
||||
|
||||
Redirect::handle();
|
||||
|
||||
self::assertTrue(true);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write `test_bail_when_doing_ajax`**
|
||||
|
||||
```php
|
||||
public function test_bail_when_doing_ajax(): void
|
||||
{
|
||||
if (!\defined('DOING_AJAX')) {
|
||||
\define('DOING_AJAX', true);
|
||||
}
|
||||
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
|
||||
|
||||
Redirect::handle();
|
||||
|
||||
self::assertTrue(true);
|
||||
}
|
||||
```
|
||||
|
||||
Note: `DOING_AJAX` is a constant; if previous tests `define`d it, it stays. The first time this test runs, it defines it; subsequent runs in the same process are no-ops. Order-dependence is acceptable here because the bail itself doesn't depend on values changing — once defined, it's defined.
|
||||
|
||||
- [ ] **Step 3: Write `test_bail_when_not_404`**
|
||||
|
||||
```php
|
||||
public function test_bail_when_not_404(): void
|
||||
{
|
||||
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||
\Brain\Monkey\Functions\when('is_404')->justReturn(false);
|
||||
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
|
||||
|
||||
Redirect::handle();
|
||||
|
||||
self::assertTrue(true);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write `test_bail_when_main_town_slug_null`**
|
||||
|
||||
```php
|
||||
public function test_bail_when_main_town_slug_null(): void
|
||||
{
|
||||
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
|
||||
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
|
||||
if ($name === 'main_town_slug') return null;
|
||||
return false;
|
||||
});
|
||||
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
|
||||
|
||||
Redirect::handle();
|
||||
|
||||
self::assertTrue(true);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Write `test_bail_when_request_uri_root`**
|
||||
|
||||
```php
|
||||
public function test_bail_when_request_uri_root(): void
|
||||
{
|
||||
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
|
||||
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
|
||||
if ($name === 'main_town_slug') return 'spb';
|
||||
return false;
|
||||
});
|
||||
$_SERVER['REQUEST_URI'] = '/';
|
||||
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
|
||||
\Brain\Monkey\Functions\expect('get_posts')->never();
|
||||
|
||||
Redirect::handle();
|
||||
|
||||
self::assertTrue(true);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Write `test_bail_when_no_post_with_slug`**
|
||||
|
||||
```php
|
||||
public function test_bail_when_no_post_with_slug(): void
|
||||
{
|
||||
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
|
||||
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
|
||||
if ($name === 'main_town_slug') return 'spb';
|
||||
return false;
|
||||
});
|
||||
$_SERVER['REQUEST_URI'] = '/tarif-tyumen/';
|
||||
\Brain\Monkey\Functions\when('get_posts')->justReturn([]);
|
||||
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
|
||||
|
||||
Redirect::handle();
|
||||
|
||||
self::assertTrue(true);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run all 6 bail-tests — confirm RED**
|
||||
|
||||
Run: `vendor/bin/phpunit --filter RedirectTest`
|
||||
Expected: register test passes; 6 bail tests fail (or some pass accidentally if old `handle()` happens to bail) — what matters is each test EITHER fails with a meaningful error OR passes for the right reason. Note the failures.
|
||||
|
||||
- [ ] **Step 8: Rewrite `Redirect::handle()` with bail logic only (no redirect yet)**
|
||||
|
||||
In `includes/class-redirect.php`, replace the entire `handle()` body with the bail-out section:
|
||||
|
||||
```php
|
||||
public static function handle(): void
|
||||
{
|
||||
if (\is_admin() || (\defined('DOING_AJAX') && \DOING_AJAX)) {
|
||||
return;
|
||||
}
|
||||
if (!\is_404()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$main = Helpers::main_town_slug();
|
||||
if ($main === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$slug = self::extract_last_segment($_SERVER['REQUEST_URI'] ?? '/');
|
||||
if ($slug === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$candidates = \get_posts([
|
||||
'name' => $slug,
|
||||
'post_type' => 'page',
|
||||
'post_status' => ['private', 'publish'],
|
||||
'posts_per_page' => 1,
|
||||
'no_found_rows' => true,
|
||||
'suppress_filters' => false,
|
||||
]);
|
||||
if (empty($candidates)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: matching + redirect (added in 2c)
|
||||
}
|
||||
|
||||
private static function extract_last_segment(string $uri): ?string
|
||||
{
|
||||
$path = strtok($uri, '?');
|
||||
if ($path === false) {
|
||||
return null;
|
||||
}
|
||||
$parts = array_values(array_filter(
|
||||
explode('/', $path),
|
||||
static fn (string $p) => $p !== ''
|
||||
));
|
||||
$last = end($parts);
|
||||
return $last !== false && $last !== '' ? $last : null;
|
||||
}
|
||||
```
|
||||
|
||||
Note: keep `extract_last_segment` here — Task 2c will use it.
|
||||
|
||||
The placeholder `// TODO: matching + redirect (added in 2c)` is acceptable ONLY during this intermediate step — it will be replaced in Sub-step 2c. Confirm no production code goes through the TODO without removal.
|
||||
|
||||
- [ ] **Step 9: Run all 6 bail-tests + register test — confirm GREEN**
|
||||
|
||||
Run: `vendor/bin/phpunit --filter RedirectTest`
|
||||
Expected: 7 tests pass (1 register + 6 bail). If `test_bail_when_no_post_with_slug` fails, ensure `Helpers::main_town_slug()` returns `'spb'` correctly under stub (check ACF guard `did_action`).
|
||||
|
||||
### Sub-step 2c: Lookup-miss tests (3 tests) + successful redirect tests (4 tests including wrapping)
|
||||
|
||||
- [ ] **Step 1: Write `test_bail_when_post_not_in_page_unic`**
|
||||
|
||||
```php
|
||||
public function test_bail_when_post_not_in_page_unic(): void
|
||||
{
|
||||
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
|
||||
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
|
||||
if ($name === 'main_town_slug') return 'spb';
|
||||
if ($name === 'page_unic') return [
|
||||
['town' => 11, 'page_old' => 42, 'page_new' => 84],
|
||||
];
|
||||
return false;
|
||||
});
|
||||
$_SERVER['REQUEST_URI'] = '/orphan-slug/';
|
||||
\Brain\Monkey\Functions\when('get_posts')->justReturn([
|
||||
(object) ['ID' => 999], // not 84
|
||||
]);
|
||||
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
|
||||
|
||||
Redirect::handle();
|
||||
|
||||
self::assertTrue(true);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write `test_bail_when_page_unic_empty`**
|
||||
|
||||
```php
|
||||
public function test_bail_when_page_unic_empty(): void
|
||||
{
|
||||
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
|
||||
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
|
||||
if ($name === 'main_town_slug') return 'spb';
|
||||
if ($name === 'page_unic') return null;
|
||||
return false;
|
||||
});
|
||||
$_SERVER['REQUEST_URI'] = '/tarif-tyumen/';
|
||||
\Brain\Monkey\Functions\when('get_posts')->justReturn([
|
||||
(object) ['ID' => 84],
|
||||
]);
|
||||
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
|
||||
|
||||
Redirect::handle();
|
||||
|
||||
self::assertTrue(true);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write `test_bail_when_matched_town_is_main`**
|
||||
|
||||
```php
|
||||
public function test_bail_when_matched_town_is_main(): void
|
||||
{
|
||||
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
|
||||
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
|
||||
if ($name === 'main_town_slug') return 'spb';
|
||||
if ($name === 'page_unic') return [
|
||||
['town' => 10, 'page_old' => 42, 'page_new' => 84],
|
||||
];
|
||||
return false;
|
||||
});
|
||||
$_SERVER['REQUEST_URI'] = '/tarif-spb/';
|
||||
\Brain\Monkey\Functions\when('get_posts')->justReturn([
|
||||
(object) ['ID' => 84],
|
||||
]);
|
||||
|
||||
$term = new \WP_Term();
|
||||
$term->term_id = 10;
|
||||
$term->slug = 'spb'; // == main_town_slug
|
||||
\Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
|
||||
|
||||
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
|
||||
|
||||
Redirect::handle();
|
||||
|
||||
self::assertTrue(true);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write `test_redirect_for_private_clone` (will fail until 2c implementation)**
|
||||
|
||||
```php
|
||||
public function test_redirect_for_private_clone(): void
|
||||
{
|
||||
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
|
||||
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
|
||||
if ($name === 'main_town_slug') return 'spb';
|
||||
if ($name === 'page_unic') return [
|
||||
['town' => 11, 'page_old' => 42, 'page_new' => 84],
|
||||
];
|
||||
return false;
|
||||
});
|
||||
$_SERVER['REQUEST_URI'] = '/tarif-tyumen/';
|
||||
\Brain\Monkey\Functions\when('get_posts')->justReturn([
|
||||
(object) ['ID' => 84, 'post_status' => 'private'],
|
||||
]);
|
||||
|
||||
$term = new \WP_Term();
|
||||
$term->term_id = 11;
|
||||
$term->slug = 'tyumen';
|
||||
\Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
|
||||
|
||||
\Brain\Monkey\Functions\when('get_permalink')->justReturn('https://site.com/tarif/');
|
||||
|
||||
\Brain\Monkey\Functions\expect('wp_safe_redirect')
|
||||
->once()
|
||||
->with('/tyumen/tarif/', 301)
|
||||
->andThrow(new \RuntimeException('redirect-called'));
|
||||
|
||||
try {
|
||||
Redirect::handle();
|
||||
self::fail('Expected redirect to be issued');
|
||||
} catch (\RuntimeException $e) {
|
||||
self::assertSame('redirect-called', $e->getMessage());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Write `test_redirect_for_published_clone_with_own_url`**
|
||||
|
||||
```php
|
||||
public function test_redirect_for_published_clone_with_own_url(): void
|
||||
{
|
||||
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
|
||||
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
|
||||
if ($name === 'main_town_slug') return 'spb';
|
||||
if ($name === 'page_unic') return [
|
||||
['town' => 11, 'page_old' => 42, 'page_new' => 84],
|
||||
];
|
||||
return false;
|
||||
});
|
||||
$_SERVER['REQUEST_URI'] = '/tarif-tyumen/';
|
||||
\Brain\Monkey\Functions\when('get_posts')->justReturn([
|
||||
(object) ['ID' => 84, 'post_status' => 'publish'],
|
||||
]);
|
||||
|
||||
$term = new \WP_Term();
|
||||
$term->term_id = 11;
|
||||
$term->slug = 'tyumen';
|
||||
\Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
|
||||
|
||||
\Brain\Monkey\Functions\when('get_permalink')->justReturn('https://site.com/tarif/');
|
||||
|
||||
\Brain\Monkey\Functions\expect('wp_safe_redirect')
|
||||
->once()
|
||||
->with('/tyumen/tarif/', 301)
|
||||
->andThrow(new \RuntimeException('redirect-called'));
|
||||
|
||||
try {
|
||||
Redirect::handle();
|
||||
self::fail('Expected redirect to be issued');
|
||||
} catch (\RuntimeException $e) {
|
||||
self::assertSame('redirect-called', $e->getMessage());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Write `test_redirect_strips_domain_from_get_permalink`**
|
||||
|
||||
```php
|
||||
public function test_redirect_strips_domain_from_get_permalink(): void
|
||||
{
|
||||
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
|
||||
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
|
||||
if ($name === 'main_town_slug') return 'spb';
|
||||
if ($name === 'page_unic') return [
|
||||
['town' => 11, 'page_old' => 42, 'page_new' => 84],
|
||||
];
|
||||
return false;
|
||||
});
|
||||
$_SERVER['REQUEST_URI'] = '/tarif-tyumen/';
|
||||
\Brain\Monkey\Functions\when('get_posts')->justReturn([
|
||||
(object) ['ID' => 84],
|
||||
]);
|
||||
|
||||
$term = new \WP_Term();
|
||||
$term->term_id = 11;
|
||||
$term->slug = 'tyumen';
|
||||
\Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
|
||||
|
||||
// get_permalink returns full URL — handler must strip host
|
||||
\Brain\Monkey\Functions\when('get_permalink')->justReturn('https://site.com/tarif/');
|
||||
|
||||
\Brain\Monkey\Functions\expect('wp_safe_redirect')
|
||||
->once()
|
||||
->with('/tyumen/tarif/', 301)
|
||||
->andThrow(new \RuntimeException('redirect-called'));
|
||||
|
||||
try {
|
||||
Redirect::handle();
|
||||
self::fail('Expected redirect to be issued');
|
||||
} catch (\RuntimeException $e) {
|
||||
self::assertSame('redirect-called', $e->getMessage());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Write `test_get_permalink_called_inside_without_url_filters`**
|
||||
|
||||
```php
|
||||
public function test_get_permalink_called_inside_without_url_filters(): void
|
||||
{
|
||||
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
|
||||
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
|
||||
if ($name === 'main_town_slug') return 'spb';
|
||||
if ($name === 'page_unic') return [
|
||||
['town' => 11, 'page_old' => 42, 'page_new' => 84],
|
||||
];
|
||||
return false;
|
||||
});
|
||||
$_SERVER['REQUEST_URI'] = '/tarif-tyumen/';
|
||||
\Brain\Monkey\Functions\when('get_posts')->justReturn([
|
||||
(object) ['ID' => 84],
|
||||
]);
|
||||
|
||||
$term = new \WP_Term();
|
||||
$term->term_id = 11;
|
||||
$term->slug = 'tyumen';
|
||||
\Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
|
||||
|
||||
// Capture bypass depth at the moment of get_permalink call
|
||||
$depth_during_call = -1;
|
||||
\Brain\Monkey\Functions\when('get_permalink')->alias(
|
||||
function () use (&$depth_during_call) {
|
||||
$depth_during_call = \WPMultiCity\Helpers::is_bypassed() ? 1 : 0;
|
||||
return 'https://site.com/tarif/';
|
||||
}
|
||||
);
|
||||
|
||||
\Brain\Monkey\Functions\expect('wp_safe_redirect')
|
||||
->once()
|
||||
->andThrow(new \RuntimeException('redirect-called'));
|
||||
|
||||
try {
|
||||
Redirect::handle();
|
||||
} catch (\RuntimeException $e) {
|
||||
// expected
|
||||
}
|
||||
|
||||
self::assertSame(
|
||||
1,
|
||||
$depth_during_call,
|
||||
'get_permalink must be wrapped in Helpers::without_url_filters'
|
||||
);
|
||||
self::assertFalse(
|
||||
\WPMultiCity\Helpers::is_bypassed(),
|
||||
'bypass depth must be restored to 0 after handle()'
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Run all RedirectTest — confirm RED for redirect cases**
|
||||
|
||||
Run: `vendor/bin/phpunit --filter RedirectTest`
|
||||
Expected: 10 tests pass (1 register + 6 bail + 3 lookup-miss BAIL — these may incidentally pass because handle() returns silently due to TODO placeholder); 4 redirect tests FAIL (because no `wp_safe_redirect` is being called).
|
||||
|
||||
- [ ] **Step 9: Implement matching + redirect — finish `Redirect::handle()`**
|
||||
|
||||
In `includes/class-redirect.php`, replace the `// TODO: matching + redirect (added in 2c)` placeholder with:
|
||||
|
||||
```php
|
||||
$candidate_id = (int) $candidates[0]->ID;
|
||||
|
||||
if (!\function_exists('get_field')) {
|
||||
return;
|
||||
}
|
||||
$rows = \get_field('page_unic', 'option');
|
||||
if (!is_array($rows)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
if ((int) ($row['page_new'] ?? 0) !== $candidate_id) {
|
||||
continue;
|
||||
}
|
||||
$town_term_id = (int) ($row['town'] ?? 0);
|
||||
$orig_id = (int) ($row['page_old'] ?? 0);
|
||||
if (!$town_term_id || !$orig_id) {
|
||||
continue;
|
||||
}
|
||||
$term = \get_term_by('id', $town_term_id, 'town');
|
||||
if (!($term instanceof \WP_Term) || $term->slug === '' || $term->slug === $main) {
|
||||
continue;
|
||||
}
|
||||
$orig_permalink = Helpers::without_url_filters(
|
||||
static fn () => \get_permalink($orig_id)
|
||||
);
|
||||
if (!is_string($orig_permalink) || $orig_permalink === '') {
|
||||
return;
|
||||
}
|
||||
$path_only = Helpers::remove_domain_link($orig_permalink);
|
||||
$redirect_to = '/' . $term->slug . $path_only;
|
||||
\wp_safe_redirect($redirect_to, 301);
|
||||
exit;
|
||||
}
|
||||
```
|
||||
|
||||
After replacement, `handle()` should contain NO `TODO` comments.
|
||||
|
||||
- [ ] **Step 10: Run full RedirectTest — confirm all 14 GREEN (1 register + 13 new)**
|
||||
|
||||
Run: `vendor/bin/phpunit --filter RedirectTest`
|
||||
Expected: 14 tests pass.
|
||||
|
||||
- [ ] **Step 11: Run full suite to verify nothing else broke**
|
||||
|
||||
Run: `vendor/bin/phpunit`
|
||||
Expected: ~118 tests (109 baseline - 4 old + 13 new = 118), all green. If some other test fails (e.g., from RouterTest because of stub bleed), inspect and fix root cause. Do NOT change RouterTest assertions; instead check whether the new handle() inadvertently runs during another test (it shouldn't — handle() is only called when `template_redirect` fires).
|
||||
|
||||
- [ ] **Step 12: Commit**
|
||||
|
||||
```bash
|
||||
git add includes/class-redirect.php tests/RedirectTest.php
|
||||
git commit -m "feat(wpmc 1.1.0): Redirect::handle reverse-slug lookup on is_404
|
||||
|
||||
Replace old triggering-on-page_new semantics with washanyanya-style:
|
||||
- template_redirect@1 + is_404() trigger
|
||||
- get_posts(name=\$slug, post_type=page, status=[private,publish])
|
||||
- match candidate.ID against page_unic[i].page_new
|
||||
- 301 to /<town>/<orig-canonical-path>/
|
||||
|
||||
Closes Gitea issue #1.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Bump changelog (readme.txt + CHANGELOG.md)
|
||||
|
||||
**Files:**
|
||||
- Modify: `readme.txt` (add `= 1.1.0 =` block in `== Changelog ==` BEFORE `= 1.0.1 =`)
|
||||
- Modify: `CHANGELOG.md` (add `## [1.1.0]` section at top, after the title)
|
||||
|
||||
NOTE: Do NOT bump `wp-multi-city.php` Version or `WPMC_VERSION` — `release.sh` does that.
|
||||
|
||||
- [ ] **Step 1: Read current readme.txt to find insertion point**
|
||||
|
||||
Run: `grep -n "^= " readme.txt`
|
||||
Confirm the order: `= 1.0.1 =` then `= 1.0.0 =`. Insert `= 1.1.0 =` BEFORE `= 1.0.1 =`.
|
||||
|
||||
- [ ] **Step 2: Edit readme.txt**
|
||||
|
||||
In `readme.txt`, locate the `== Changelog ==` section. Add this block immediately before the existing `= 1.0.1 =` line:
|
||||
|
||||
```
|
||||
= 1.1.0 =
|
||||
* Change: `Redirect::handle()` переписан на washanyanya-style. Триггерится на `is_404()` (хук `template_redirect` priority 1), ищет private/publish page по URL slug через `get_posts`, матчит с `page_unic`, делает 301 на канонический `/{city}/{orig-slug}/`. Старая семантика (триггер по `$post->ID === page_new`) удалена — она покрывала только редкий edge-кейс published clone with own URL.
|
||||
* Change: Redirect handler hook переехал с `wp` priority 9998 на `template_redirect` priority 1 (стандарт WP для redirects).
|
||||
|
||||
```
|
||||
|
||||
(Note the empty line after the block to separate it from `= 1.0.1 =`.)
|
||||
|
||||
- [ ] **Step 3: Verify readme.txt**
|
||||
|
||||
Run: `grep -E "^= [0-9]+\.[0-9]+\.[0-9]+ =$" readme.txt`
|
||||
Expected output:
|
||||
```
|
||||
= 1.1.0 =
|
||||
= 1.0.1 =
|
||||
= 1.0.0 =
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Read CHANGELOG.md to find insertion point**
|
||||
|
||||
Run: `head -5 CHANGELOG.md`
|
||||
Confirm structure: title, blank line, format-line, blank, then `## [1.0.1] — ...`.
|
||||
|
||||
- [ ] **Step 5: Edit CHANGELOG.md**
|
||||
|
||||
In `CHANGELOG.md`, add a new section immediately after the format-line (or wherever the most recent version section currently lives) — BEFORE the existing `## [1.0.1]` block:
|
||||
|
||||
```markdown
|
||||
## [1.1.0] — 2026-05-19
|
||||
|
||||
### Changed
|
||||
- **BREAKING (internal):** `Redirect::handle()` переписан на washanyanya-style.
|
||||
Триггер: `template_redirect` @ priority 1 + `is_404()`.
|
||||
Reverse slug lookup: `get_posts(name=$slug, post_type=page, status=[private,publish])`,
|
||||
затем match в `page_unic[i].page_new`. 301 на `/{city}/{orig-canonical-path}/`.
|
||||
- Удалена старая семантика (триггер по `$post->ID === page_new`).
|
||||
- Hook переехал с `wp` priority 9998 на `template_redirect` priority 1.
|
||||
|
||||
Closes [Gitea issue #1](https://git.netranking.ru/bryzgalov/wp-multi-city/issues/1).
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify CHANGELOG.md**
|
||||
|
||||
Run: `grep -E "^## \[" CHANGELOG.md`
|
||||
Expected output (in order):
|
||||
```
|
||||
## [1.1.0] — 2026-05-19
|
||||
## [1.0.1] — 2026-05-17
|
||||
## [1.0.0] — 2026-05-17
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run full suite (no code changes here but verify nothing accidentally broke)**
|
||||
|
||||
Run: `vendor/bin/phpunit`
|
||||
Expected: same green count as after Task 2.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add readme.txt CHANGELOG.md
|
||||
git commit -m "docs(wpmc 1.1.0): changelog entries in readme.txt + CHANGELOG.md
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification
|
||||
|
||||
- [ ] **Run full PHPUnit suite**
|
||||
|
||||
Run: `vendor/bin/phpunit`
|
||||
Expected: OK, ~118 tests, ~217+ assertions. All green.
|
||||
|
||||
- [ ] **PHP lint clean**
|
||||
|
||||
Run: `find includes -name '*.php' -not -path '*/lib/*' -exec php -l {} \;`
|
||||
Expected: every line "No syntax errors detected in ...".
|
||||
|
||||
- [ ] **Working tree clean**
|
||||
|
||||
Run: `git status`
|
||||
Expected: `nothing to commit, working tree clean`.
|
||||
|
||||
- [ ] **Smoke test in ural-dez.ru wp-env (manual, optional but recommended)**
|
||||
|
||||
1. `cd ural-dez.ru && npx wp-env start` (if not running).
|
||||
2. Create a public page `tarif` (note its ID, say N1).
|
||||
3. Create a private page `tarif-tyumen` (note its ID, say N2).
|
||||
4. WP admin → WP Multi City → Options → page_unic → add row: `page_old=N1`, `page_new=N2`, `town=tyumen`.
|
||||
5. `curl -I http://localhost:8888/tarif-tyumen/` → expect `HTTP/1.1 301 Moved Permanently` and `Location: /tyumen/tarif/`.
|
||||
6. `curl -I http://localhost:8888/random-nonexistent-slug/` → expect `404 Not Found` with no redirect.
|
||||
|
||||
- [ ] **Release**
|
||||
|
||||
Controller (not subagent) runs:
|
||||
```bash
|
||||
git push origin main
|
||||
./release.sh 1.1.0
|
||||
```
|
||||
|
||||
The release script will:
|
||||
1. Validate version, sync, token, readme.txt has `= 1.1.0 =`.
|
||||
2. Bump `wp-multi-city.php` Version: and `WPMC_VERSION` constant to `1.1.0`.
|
||||
3. Bump `readme.txt` Stable tag to `1.1.0`.
|
||||
4. Commit `release: 1.1.0`, push.
|
||||
5. Build zip, sanity-check, upload to new Gitea release v1.1.0.
|
||||
6. Regenerate `wp-plugin-info.json` with new download_url + commit + push.
|
||||
|
||||
- [ ] **Close Gitea issue #1 + bd close cp-ifg**
|
||||
|
||||
Controller (not subagent) closes Gitea issue #1 via API or web UI with reference to release commit, then:
|
||||
```bash
|
||||
bd close cp-ifg --reason "Released in 1.1.0. Redirect::handle переписан на washanyanya-style: template_redirect@1 + is_404 + get_posts reverse-slug lookup + page_unic match → 301 на /<city>/<orig>/. 13 tests cover new semantics. Closes Gitea issue #1."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage check
|
||||
|
||||
| Spec section | Tasks |
|
||||
|---|---|
|
||||
| Artifacts (class-redirect.php) | Task 1, Task 2 |
|
||||
| Artifacts (RedirectTest.php) | Task 2 (rewrite) |
|
||||
| Artifacts (PluginBootS4Test.php) | Task 1 |
|
||||
| Artifacts (readme.txt) | Task 3 |
|
||||
| Artifacts (CHANGELOG.md) | Task 3 |
|
||||
| Алгоритм `handle()` (bail + lookup + match) | Task 2 sub-steps 2b/2c |
|
||||
| `extract_last_segment` helper | Task 2 sub-step 2b step 8 |
|
||||
| 6 bail-out tests | Task 2 sub-step 2b |
|
||||
| 3 lookup-miss tests | Task 2 sub-step 2c (steps 1-3) |
|
||||
| 3 successful redirect tests | Task 2 sub-step 2c (steps 4-6) |
|
||||
| 1 wrapping test (`without_url_filters`) | Task 2 sub-step 2c (step 7) |
|
||||
| Hook change `template_redirect@1` | Task 1 |
|
||||
| Acceptance verification | Final verification block |
|
||||
| Release 1.1.0 | Final verification block (controller-driven) |
|
||||
| Close Gitea issue #1 + bd close cp-ifg | Final verification block (controller-driven) |
|
||||
|
||||
All spec requirements covered.
|
||||
Reference in New Issue
Block a user