Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 771c949ba8 | |||
| 07ed1703ba | |||
| 9b1689dc2f | |||
| 43e3efc5a2 | |||
| 9586810507 | |||
| 0ec9733368 | |||
| 20aff663d4 | |||
| d0a7b8eb9d | |||
| d36408a4a6 | |||
| c002b08275 | |||
| 82d2345d12 | |||
| 949fb19e95 | |||
| 19409ac062 | |||
| 4c6173619b |
@@ -2,6 +2,27 @@
|
|||||||
|
|
||||||
All notable changes to wp-multi-city. Format follows [Keep a Changelog](https://keepachangelog.com/).
|
All notable changes to wp-multi-city. Format follows [Keep a Changelog](https://keepachangelog.com/).
|
||||||
|
|
||||||
|
## [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).
|
||||||
|
|
||||||
|
## [1.0.1] — 2026-05-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Процедурный wrapper `wpmc_main_town_slug()` (документирован в README, отсутствовал в коде).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- WP 6.7+ notice `_load_textdomain_just_in_time`: убраны `__()` из labels CPT/таксономии/ACF field group.
|
||||||
|
- ACF `acf_get_value` "called too early": `did_action('acf/init')` guard в 5 Helpers методах.
|
||||||
|
|
||||||
## [1.0.0] — 2026-05-17
|
## [1.0.0] — 2026-05-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
# Redirect 1.1.0 — washanyanya-style reverse-slug lookup на is_404
|
||||||
|
|
||||||
|
- Beads: `cp-ifg` (discovered-from `cp-83m` S4)
|
||||||
|
- Gitea issue: <https://git.netranking.ru/bryzgalov/wp-multi-city/issues/1>
|
||||||
|
- Дата: 2026-05-19
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Заменить текущую семантику `Redirect::handle()` на washanyanya-style reverse-slug lookup. Старый handler триггерится только когда клон опубликован под собственным URL — edge-case, нерелевантный для production. Washanyanya-сценарий: клоны хранятся как `private` posts без собственного публичного URL; защита от утечки private URL делается через redirect на канонический городской.
|
||||||
|
|
||||||
|
Версия — **1.1.0** (minor bump: внутреннее breaking change семантики, внешне расширение функционала).
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
`Redirect::handle()` — полное переписывание. Старая логика (триггер на `$post->ID === page_new`) удаляется. Новая:
|
||||||
|
|
||||||
|
```
|
||||||
|
template_redirect@1
|
||||||
|
→ if !is_404() return
|
||||||
|
→ extract last URL segment as slug candidate
|
||||||
|
→ get_posts(name=$slug, post_type='page', status=[private,publish])
|
||||||
|
→ match candidate.ID against page_unic[i].page_new
|
||||||
|
→ matched: 301 → /<town>/<orig-canonical-path>/; exit
|
||||||
|
```
|
||||||
|
|
||||||
|
Hook переезжает с `wp@9998` на `template_redirect@1` — стандартный WP hook для redirect-логики, после resolve query (`is_404` гарантированно определён), раньше SEO-плагинов.
|
||||||
|
|
||||||
|
## Артефакты
|
||||||
|
|
||||||
|
```
|
||||||
|
wp-multi-city/
|
||||||
|
├── includes/class-redirect.php ← MODIFY: полное переписывание handle()
|
||||||
|
├── includes/class-plugin.php ← NO CHANGE: register() сам не меняется
|
||||||
|
├── tests/RedirectTest.php ← REWRITE: 13 тестов под новую семантику
|
||||||
|
├── tests/PluginBootS4Test.php ← MODIFY: hook check template_redirect@1 (was wp@9998)
|
||||||
|
├── readme.txt ← MODIFY: + = 1.1.0 = changelog
|
||||||
|
├── CHANGELOG.md ← MODIFY: + ## [1.1.0]
|
||||||
|
└── README.md ← MODIFY: roadmap mention 1.1.0 (Redirect semantics extended)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Алгоритм `Redirect::handle()`
|
||||||
|
|
||||||
|
```php
|
||||||
|
public static function register(): void
|
||||||
|
{
|
||||||
|
\add_action('template_redirect', [self::class, 'handle'], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 !== ''
|
||||||
|
));
|
||||||
|
return end($parts) ?: null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Helpers::without_url_filters` обёртка вокруг `get_permalink` нужна чтобы S5 LinkFilter не добавил town prefix повторно (защита от `/tyumen/tyumen/orig/`).
|
||||||
|
|
||||||
|
## Edge cases (явные)
|
||||||
|
|
||||||
|
| Условие | Поведение |
|
||||||
|
|---|---|
|
||||||
|
| `is_admin()` / `DOING_AJAX` | bail |
|
||||||
|
| `!is_404()` | bail — обычная страница, не наш кейс |
|
||||||
|
| `main_town_slug = null` | bail — нет canonical базы |
|
||||||
|
| URI `/` или пустой | bail — нет slug для lookup |
|
||||||
|
| `get_posts` returns `[]` | bail — slug не существует |
|
||||||
|
| Post найден, но не в `page_unic` | bail — чей-то совпадающий slug, не клон |
|
||||||
|
| `page_unic` пустой/null | bail |
|
||||||
|
| Matched row, но `town == main_town_slug` | bail — защита двойного префикса main URL |
|
||||||
|
| `get_permalink($orig_id)` пустой | bail — broken config |
|
||||||
|
|
||||||
|
## Тестовый план — `tests/RedirectTest.php`
|
||||||
|
|
||||||
|
Старые тесты удаляются полностью. Новые 13 тестов:
|
||||||
|
|
||||||
|
### Bail-out (6 тестов)
|
||||||
|
1. `test_bail_when_is_admin` — `is_admin = true`, redirect не вызывается.
|
||||||
|
2. `test_bail_when_doing_ajax` — `DOING_AJAX` true.
|
||||||
|
3. `test_bail_when_not_404` — `is_404 = false`.
|
||||||
|
4. `test_bail_when_main_town_slug_null` — `get_field('main_town_slug')` returns null.
|
||||||
|
5. `test_bail_when_request_uri_root` — `$_SERVER['REQUEST_URI'] = '/'`.
|
||||||
|
6. `test_bail_when_no_post_with_slug` — `get_posts` returns `[]`.
|
||||||
|
|
||||||
|
### Lookup misses (3 теста)
|
||||||
|
7. `test_bail_when_post_not_in_page_unic` — slug найден, но `post.ID` не матчит ни одну row.
|
||||||
|
8. `test_bail_when_page_unic_empty` — `get_field('page_unic')` returns `null`/`[]`.
|
||||||
|
9. `test_bail_when_matched_town_is_main` — page_unic matched, но `term->slug === main_town_slug`.
|
||||||
|
|
||||||
|
### Successful redirect (3 теста)
|
||||||
|
10. `test_redirect_for_private_clone` — private post по slug, matched, `wp_safe_redirect('/tyumen/tarif/', 301)`.
|
||||||
|
11. `test_redirect_for_published_clone_with_own_url` — published post (статус `publish` тоже в lookup), редирект работает идентично.
|
||||||
|
12. `test_redirect_strips_domain_from_get_permalink` — `get_permalink` returns `https://site.com/tarif/` → redirect target `/tyumen/tarif/`.
|
||||||
|
|
||||||
|
### Wrapping (1 тест)
|
||||||
|
13. `test_get_permalink_called_inside_without_url_filters` — verify bypass депти инкрементится перед `get_permalink` и декрементится после.
|
||||||
|
|
||||||
|
### `tests/PluginBootS4Test.php` обновление
|
||||||
|
|
||||||
|
Найти существующий ассерт про `has_action('wp', [Redirect::class, 'handle'])` или аналог с priority 9998 — заменить на `template_redirect` priority 1.
|
||||||
|
|
||||||
|
**Итого:** 109 + 13 (новый RedirectTest) - 10/11 (старые RedirectTest удаляются) = ~111-112 тестов. Точный delta зависит от текущего числа RedirectTest assertions; цель — ВСЕ зелёные.
|
||||||
|
|
||||||
|
## readme.txt + CHANGELOG.md + README.md
|
||||||
|
|
||||||
|
`readme.txt` — добавить блок перед `= 1.0.1 =`:
|
||||||
|
|
||||||
|
```
|
||||||
|
= 1.1.0 =
|
||||||
|
* Change: `Redirect::handle()` переписан на washanyanya-style. Триггерится на `is_404()`, ищет private/publish page по URL slug, матчит с 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).
|
||||||
|
```
|
||||||
|
|
||||||
|
`CHANGELOG.md` — добавить секцию вверху (после title):
|
||||||
|
|
||||||
|
```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`).
|
||||||
|
```
|
||||||
|
|
||||||
|
`README.md` — roadmap не нужно менять (S1-S7 status сохраняется). Опционально упомянуть 1.1.0 в Hook API под Redirect.
|
||||||
|
|
||||||
|
## Acceptance verification
|
||||||
|
|
||||||
|
1. `vendor/bin/phpunit` — все green (~112 tests).
|
||||||
|
2. PHP lint: `find includes -name '*.php' -not -path '*/lib/*' -exec php -l {} \;` — clean.
|
||||||
|
3. Smoke в `ural-dez.ru` wp-env:
|
||||||
|
- Создать `tarif` (page, public, ID=N1).
|
||||||
|
- Создать `tarif-tyumen` (page, **private**, ID=N2).
|
||||||
|
- В Options Page → page_unic row: `page_old=N1`, `page_new=N2`, `town=tyumen`.
|
||||||
|
- `curl -I http://localhost:8888/tarif-tyumen/` → 301 Location: `/tyumen/tarif/`.
|
||||||
|
- `curl -I http://localhost:8888/random-nonexistent/` → 404 (обычное поведение, нет редиректа).
|
||||||
|
4. `./release.sh 1.1.0` публикует Gitea release v1.1.0.
|
||||||
|
5. Gitea issue #1 закрывается с reference на коммит.
|
||||||
|
|
||||||
|
## Что НЕ входит в 1.1.0
|
||||||
|
|
||||||
|
- Lookup по post_type кроме `'page'`. ACF page_unic field обоих fields (`page_old`/`page_new`) ограничен `post_type='page'` (см. `OptionsPage::do_register`) — расширение бессмысленно.
|
||||||
|
- Cache reverse-lookup. `page_unic` массив маленький (десятки rows max), foreach дешёвый.
|
||||||
|
- Защита от output ссылок на private clones. Это handler-side fix; output-side — out of scope.
|
||||||
|
- Фильтр `wpmc_redirect_post_types` для extensibility. YAGNI.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
Нет. Все решения зафиксированы.
|
||||||
@@ -25,15 +25,15 @@ final class FieldGroup
|
|||||||
'fields' => [
|
'fields' => [
|
||||||
[
|
[
|
||||||
'key' => self::REPEATER_KEY,
|
'key' => self::REPEATER_KEY,
|
||||||
'label' => __('Подстановки по городам', 'wp-multi-city'),
|
'label' => 'Подстановки по городам',
|
||||||
'name' => 'list_shortcode',
|
'name' => 'list_shortcode',
|
||||||
'type' => 'repeater',
|
'type' => 'repeater',
|
||||||
'layout' => 'table',
|
'layout' => 'table',
|
||||||
'button_label' => __('Добавить город', 'wp-multi-city'),
|
'button_label' => 'Добавить город',
|
||||||
'sub_fields' => [
|
'sub_fields' => [
|
||||||
[
|
[
|
||||||
'key' => 'field_wpmc_list_shortcode_town',
|
'key' => 'field_wpmc_list_shortcode_town',
|
||||||
'label' => __('Город', 'wp-multi-city'),
|
'label' => 'Город',
|
||||||
'name' => 'town',
|
'name' => 'town',
|
||||||
'type' => 'taxonomy',
|
'type' => 'taxonomy',
|
||||||
'taxonomy' => 'town',
|
'taxonomy' => 'town',
|
||||||
@@ -45,7 +45,7 @@ final class FieldGroup
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => 'field_wpmc_list_shortcode_text',
|
'key' => 'field_wpmc_list_shortcode_text',
|
||||||
'label' => __('Значение', 'wp-multi-city'),
|
'label' => 'Значение',
|
||||||
'name' => 'text',
|
'name' => 'text',
|
||||||
'type' => 'textarea',
|
'type' => 'textarea',
|
||||||
'rows' => 4,
|
'rows' => 4,
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ final class Helpers
|
|||||||
private static array $page_unic_cache = [];
|
private static array $page_unic_cache = [];
|
||||||
private static int $bypass_depth = 0;
|
private static int $bypass_depth = 0;
|
||||||
|
|
||||||
|
public static function acf_ready(): bool
|
||||||
|
{
|
||||||
|
return \did_action('acf/init') > 0;
|
||||||
|
}
|
||||||
|
|
||||||
public static function reset_cache(): void
|
public static function reset_cache(): void
|
||||||
{
|
{
|
||||||
self::$current_town_memo = null;
|
self::$current_town_memo = null;
|
||||||
@@ -80,6 +85,9 @@ final class Helpers
|
|||||||
|
|
||||||
public static function alt_get_field(string $selector, $post_id = false, bool $format_value = true)
|
public static function alt_get_field(string $selector, $post_id = false, bool $format_value = true)
|
||||||
{
|
{
|
||||||
|
if (!self::acf_ready()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
$post_id = \apply_filters('wpmc_alt_get_field_post_id', $post_id, $selector);
|
$post_id = \apply_filters('wpmc_alt_get_field_post_id', $post_id, $selector);
|
||||||
if (!\function_exists('get_field')) {
|
if (!\function_exists('get_field')) {
|
||||||
return null;
|
return null;
|
||||||
@@ -106,6 +114,9 @@ final class Helpers
|
|||||||
|
|
||||||
public static function index_town_alt_page_id(): ?int
|
public static function index_town_alt_page_id(): ?int
|
||||||
{
|
{
|
||||||
|
if (!self::acf_ready()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (!\function_exists('get_field')) {
|
if (!\function_exists('get_field')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -116,6 +127,9 @@ final class Helpers
|
|||||||
|
|
||||||
public static function main_town_slug(): ?string
|
public static function main_town_slug(): ?string
|
||||||
{
|
{
|
||||||
|
if (!self::acf_ready()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (!\function_exists('get_field')) {
|
if (!\function_exists('get_field')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -132,6 +146,9 @@ final class Helpers
|
|||||||
if (array_key_exists($key, self::$page_unic_cache)) {
|
if (array_key_exists($key, self::$page_unic_cache)) {
|
||||||
return self::$page_unic_cache[$key];
|
return self::$page_unic_cache[$key];
|
||||||
}
|
}
|
||||||
|
if (!self::acf_ready()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
$term = self::get_term_by_slug($town_slug);
|
$term = self::get_term_by_slug($town_slug);
|
||||||
if ($term === null) {
|
if ($term === null) {
|
||||||
return self::$page_unic_cache[$key] = null;
|
return self::$page_unic_cache[$key] = null;
|
||||||
@@ -161,6 +178,9 @@ final class Helpers
|
|||||||
if ($path === null) {
|
if ($path === null) {
|
||||||
$path = $_SERVER['REQUEST_URI'] ?? '/';
|
$path = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
}
|
}
|
||||||
|
if (!self::acf_ready()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (!\function_exists('get_field')) {
|
if (!\function_exists('get_field')) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ final class OptionsPage
|
|||||||
'fields' => [
|
'fields' => [
|
||||||
[
|
[
|
||||||
'key' => 'field_wpmc_main_town_slug',
|
'key' => 'field_wpmc_main_town_slug',
|
||||||
'label' => __('Главный город (без префикса URL)', 'wp-multi-city'),
|
'label' => 'Главный город (без префикса URL)',
|
||||||
'name' => 'main_town_slug',
|
'name' => 'main_town_slug',
|
||||||
'type' => 'taxonomy',
|
'type' => 'taxonomy',
|
||||||
'taxonomy' => 'town',
|
'taxonomy' => 'town',
|
||||||
@@ -47,7 +47,7 @@ final class OptionsPage
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => 'field_wpmc_index_town_alt',
|
'key' => 'field_wpmc_index_town_alt',
|
||||||
'label' => __('Главная для других городов', 'wp-multi-city'),
|
'label' => 'Главная для других городов',
|
||||||
'name' => 'index_town_alt',
|
'name' => 'index_town_alt',
|
||||||
'type' => 'post_object',
|
'type' => 'post_object',
|
||||||
'post_type' => ['page'],
|
'post_type' => ['page'],
|
||||||
@@ -57,15 +57,15 @@ final class OptionsPage
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => 'field_wpmc_page_unic',
|
'key' => 'field_wpmc_page_unic',
|
||||||
'label' => __('Клоны страниц', 'wp-multi-city'),
|
'label' => 'Клоны страниц',
|
||||||
'name' => 'page_unic',
|
'name' => 'page_unic',
|
||||||
'type' => 'repeater',
|
'type' => 'repeater',
|
||||||
'layout' => 'table',
|
'layout' => 'table',
|
||||||
'button_label' => __('Добавить клон', 'wp-multi-city'),
|
'button_label' => 'Добавить клон',
|
||||||
'sub_fields' => [
|
'sub_fields' => [
|
||||||
[
|
[
|
||||||
'key' => 'field_wpmc_page_unic_page_old',
|
'key' => 'field_wpmc_page_unic_page_old',
|
||||||
'label' => __('Оригинал', 'wp-multi-city'),
|
'label' => 'Оригинал',
|
||||||
'name' => 'page_old',
|
'name' => 'page_old',
|
||||||
'type' => 'post_object',
|
'type' => 'post_object',
|
||||||
'post_type' => ['page'],
|
'post_type' => ['page'],
|
||||||
@@ -75,7 +75,7 @@ final class OptionsPage
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => 'field_wpmc_page_unic_page_new',
|
'key' => 'field_wpmc_page_unic_page_new',
|
||||||
'label' => __('Клон', 'wp-multi-city'),
|
'label' => 'Клон',
|
||||||
'name' => 'page_new',
|
'name' => 'page_new',
|
||||||
'type' => 'post_object',
|
'type' => 'post_object',
|
||||||
'post_type' => ['page'],
|
'post_type' => ['page'],
|
||||||
@@ -85,7 +85,7 @@ final class OptionsPage
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => 'field_wpmc_page_unic_town',
|
'key' => 'field_wpmc_page_unic_town',
|
||||||
'label' => __('Город', 'wp-multi-city'),
|
'label' => 'Город',
|
||||||
'name' => 'town',
|
'name' => 'town',
|
||||||
'type' => 'taxonomy',
|
'type' => 'taxonomy',
|
||||||
'taxonomy' => 'town',
|
'taxonomy' => 'town',
|
||||||
@@ -99,18 +99,18 @@ final class OptionsPage
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => 'field_wpmc_page_no_rep',
|
'key' => 'field_wpmc_page_no_rep',
|
||||||
'label' => __('Исключения из роутинга', 'wp-multi-city'),
|
'label' => 'Исключения из роутинга',
|
||||||
'name' => 'page_no_rep',
|
'name' => 'page_no_rep',
|
||||||
'type' => 'repeater',
|
'type' => 'repeater',
|
||||||
'layout' => 'table',
|
'layout' => 'table',
|
||||||
'button_label' => __('Добавить исключение', 'wp-multi-city'),
|
'button_label' => 'Добавить исключение',
|
||||||
'sub_fields' => [
|
'sub_fields' => [
|
||||||
[
|
[
|
||||||
'key' => 'field_wpmc_page_no_rep_link',
|
'key' => 'field_wpmc_page_no_rep_link',
|
||||||
'label' => __('Путь', 'wp-multi-city'),
|
'label' => 'Путь',
|
||||||
'name' => 'link',
|
'name' => 'link',
|
||||||
'type' => 'text',
|
'type' => 'text',
|
||||||
'instructions' => __('Префикс URL, например /privacy/', 'wp-multi-city'),
|
'instructions' => 'Префикс URL, например /privacy/',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -16,16 +16,16 @@ final class PostType
|
|||||||
{
|
{
|
||||||
register_post_type(self::SLUG, [
|
register_post_type(self::SLUG, [
|
||||||
'labels' => [
|
'labels' => [
|
||||||
'name' => __('Шорткоды', 'wp-multi-city'),
|
'name' => 'Шорткоды',
|
||||||
'singular_name' => __('Шорткод', 'wp-multi-city'),
|
'singular_name' => 'Шорткод',
|
||||||
'menu_name' => __('Шорткоды', 'wp-multi-city'),
|
'menu_name' => 'Шорткоды',
|
||||||
'add_new' => __('Добавить', 'wp-multi-city'),
|
'add_new' => 'Добавить',
|
||||||
'add_new_item' => __('Добавить шорткод', 'wp-multi-city'),
|
'add_new_item' => 'Добавить шорткод',
|
||||||
'edit_item' => __('Редактировать', 'wp-multi-city'),
|
'edit_item' => 'Редактировать',
|
||||||
'new_item' => __('Новый', 'wp-multi-city'),
|
'new_item' => 'Новый',
|
||||||
'view_item' => __('Смотреть', 'wp-multi-city'),
|
'view_item' => 'Смотреть',
|
||||||
'search_items' => __('Поиск', 'wp-multi-city'),
|
'search_items' => 'Поиск',
|
||||||
'not_found' => __('Не найдено', 'wp-multi-city'),
|
'not_found' => 'Не найдено',
|
||||||
],
|
],
|
||||||
'public' => false,
|
'public' => false,
|
||||||
'exclude_from_search' => true,
|
'exclude_from_search' => true,
|
||||||
|
|||||||
+48
-24
@@ -7,65 +7,89 @@ final class Redirect
|
|||||||
{
|
{
|
||||||
public static function register(): void
|
public static function register(): void
|
||||||
{
|
{
|
||||||
\add_action('wp', [self::class, 'handle'], 9998);
|
\add_action('template_redirect', [self::class, 'handle'], 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function handle(): void
|
public static function handle(): void
|
||||||
{
|
{
|
||||||
if (\is_admin()) {
|
if (\is_admin() || (\defined('DOING_AJAX') && \DOING_AJAX)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (\defined('DOING_AJAX') && \DOING_AJAX) {
|
if (!\is_404()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$main = Helpers::main_town_slug();
|
$main = Helpers::main_town_slug();
|
||||||
if ($main === null) {
|
if ($main === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
global $post;
|
|
||||||
if (!isset($post->ID)) {
|
$slug = self::extract_last_segment($_SERVER['REQUEST_URI'] ?? '/');
|
||||||
|
if ($slug === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$current_page_id = (int) $post->ID;
|
|
||||||
if (!\function_exists('get_field')) {
|
$candidates = \get_posts([
|
||||||
|
'name' => $slug,
|
||||||
|
'post_type' => 'page',
|
||||||
|
'post_status' => ['private', 'publish'],
|
||||||
|
'posts_per_page' => 1,
|
||||||
|
'no_found_rows' => true,
|
||||||
|
]);
|
||||||
|
if (empty($candidates)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$page_unic = \get_field('page_unic', 'option');
|
|
||||||
if (!is_array($page_unic)) {
|
$candidate_id = (int) $candidates[0]->ID;
|
||||||
|
|
||||||
|
if (!Helpers::acf_ready() || !\function_exists('get_field')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
|
$rows = \get_field('page_unic', 'option');
|
||||||
foreach ($page_unic as $row) {
|
if (!is_array($rows)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
if (!is_array($row)) {
|
if (!is_array($row)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ((int) ($row['page_new'] ?? 0) !== $current_page_id) {
|
if ((int) ($row['page_new'] ?? 0) !== $candidate_id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$town_term_id = (int) ($row['town'] ?? 0);
|
$town_term_id = (int) ($row['town'] ?? 0);
|
||||||
if (!$town_term_id) {
|
$orig_id = (int) ($row['page_old'] ?? 0);
|
||||||
|
if (!$town_term_id || !$orig_id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$term = \get_term_by('id', $town_term_id, 'town');
|
$term = \get_term_by('id', $town_term_id, 'town');
|
||||||
if (!($term instanceof \WP_Term) || $term->slug === '' || $term->slug === $main) {
|
if (!($term instanceof \WP_Term) || $term->slug === '' || $term->slug === $main) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$town_slug = $term->slug;
|
$orig_permalink = Helpers::without_url_filters(
|
||||||
if (preg_match('#^/' . preg_quote($town_slug, '#') . '(/|$)#', $request_uri)) {
|
static fn () => \get_permalink($orig_id)
|
||||||
return;
|
);
|
||||||
}
|
|
||||||
$orig_id = (int) ($row['page_old'] ?? 0);
|
|
||||||
if (!$orig_id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$orig_permalink = Helpers::without_url_filters(static fn () => \get_permalink($orig_id));
|
|
||||||
if (!is_string($orig_permalink) || $orig_permalink === '') {
|
if (!is_string($orig_permalink) || $orig_permalink === '') {
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
$path_only = Helpers::remove_domain_link($orig_permalink);
|
$path_only = Helpers::remove_domain_link($orig_permalink);
|
||||||
$redirect_to = '/' . $town_slug . $path_only;
|
$redirect_to = '/' . $term->slug . $path_only;
|
||||||
\wp_safe_redirect($redirect_to, 301);
|
\wp_safe_redirect($redirect_to, 301);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ final class Taxonomy
|
|||||||
{
|
{
|
||||||
register_taxonomy(self::SLUG, [PostType::SLUG], [
|
register_taxonomy(self::SLUG, [PostType::SLUG], [
|
||||||
'labels' => [
|
'labels' => [
|
||||||
'name' => __('Города', 'wp-multi-city'),
|
'name' => 'Города',
|
||||||
'singular_name' => __('Город', 'wp-multi-city'),
|
'singular_name' => 'Город',
|
||||||
'menu_name' => __('Города', 'wp-multi-city'),
|
'menu_name' => 'Города',
|
||||||
'add_new_item' => __('Добавить', 'wp-multi-city'),
|
'add_new_item' => 'Добавить',
|
||||||
],
|
],
|
||||||
'public' => false,
|
'public' => false,
|
||||||
'publicly_queryable' => false,
|
'publicly_queryable' => false,
|
||||||
|
|||||||
@@ -30,3 +30,10 @@ if (!function_exists('wpmc_remove_domain_link')) {
|
|||||||
return Helpers::remove_domain_link($url);
|
return Helpers::remove_domain_link($url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!function_exists('wpmc_main_town_slug')) {
|
||||||
|
function wpmc_main_town_slug(): ?string
|
||||||
|
{
|
||||||
|
return Helpers::main_town_slug();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+10
-1
@@ -4,7 +4,7 @@ Tags: multisite, multi-city, taxonomy, acf, shortcode
|
|||||||
Requires at least: 6.0
|
Requires at least: 6.0
|
||||||
Tested up to: 6.9
|
Tested up to: 6.9
|
||||||
Requires PHP: 8.0
|
Requires PHP: 8.0
|
||||||
Stable tag: 1.0.0
|
Stable tag: 1.1.0
|
||||||
License: GPLv2 or later
|
License: GPLv2 or later
|
||||||
|
|
||||||
Мультигородская подсистема: одна WP-инсталляция обслуживает несколько городов по схеме /{city}/{path}/ с подстановками контента и URL.
|
Мультигородская подсистема: одна WP-инсталляция обслуживает несколько городов по схеме /{city}/{path}/ с подстановками контента и URL.
|
||||||
@@ -23,6 +23,15 @@ License: GPLv2 or later
|
|||||||
|
|
||||||
== Changelog ==
|
== Changelog ==
|
||||||
|
|
||||||
|
= 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).
|
||||||
|
|
||||||
|
= 1.0.1 =
|
||||||
|
* Add: процедурный wrapper `wpmc_main_town_slug()` (был задокументирован в README, но отсутствовал в коде).
|
||||||
|
* Fix: WP 6.7+ notice `_load_textdomain_just_in_time` — убраны `__()` обёртки из labels CPT/таксономии/ACF field group (плагин не поставляется с переводами).
|
||||||
|
* Fix: ACF `acf_get_value` "called too early" notice — добавлен `did_action('acf/init')` guard в `Helpers::main_town_slug()`, `index_town_alt_page_id`, `page_unic_swap`, `is_routable_path`, `alt_get_field`. До инициализации ACF возвращают безопасные defaults (null/true).
|
||||||
|
|
||||||
= 1.0.0 =
|
= 1.0.0 =
|
||||||
* Первый релиз. MVP-ядро завершено (S1–S7 эпика cp-cw4).
|
* Первый релиз. MVP-ядро завершено (S1–S7 эпика cp-cw4).
|
||||||
* taxonomy town + CPT shortcode-town + ACF list_shortcode repeater.
|
* taxonomy town + CPT shortcode-town + ACF list_shortcode repeater.
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ final class HelpersTest extends TestCase
|
|||||||
|
|
||||||
public function test_alt_get_field_applies_do_shortcode_to_string_values(): void
|
public function test_alt_get_field_applies_do_shortcode_to_string_values(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
// expect() must come before when('function_exists') so Brain Monkey's
|
// expect() must come before when('function_exists') so Brain Monkey's
|
||||||
// FunctionStub::__construct can eval-define get_field() before the shim
|
// FunctionStub::__construct can eval-define get_field() before the shim
|
||||||
// fakes function_exists('get_field') === true.
|
// fakes function_exists('get_field') === true.
|
||||||
@@ -153,6 +154,7 @@ final class HelpersTest extends TestCase
|
|||||||
|
|
||||||
public function test_alt_get_field_returns_arrays_as_is(): void
|
public function test_alt_get_field_returns_arrays_as_is(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
// expect() before when('function_exists') to avoid FunctionStub seeing
|
// expect() before when('function_exists') to avoid FunctionStub seeing
|
||||||
// the shim and skipping the eval-declaration of get_field.
|
// the shim and skipping the eval-declaration of get_field.
|
||||||
// do_shortcode is intentionally NOT registered: if the implementation
|
// do_shortcode is intentionally NOT registered: if the implementation
|
||||||
@@ -167,6 +169,7 @@ final class HelpersTest extends TestCase
|
|||||||
|
|
||||||
public function test_alt_get_field_post_id_filter_overrides_caller_id(): void
|
public function test_alt_get_field_post_id_filter_overrides_caller_id(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\expect('get_field')
|
\Brain\Monkey\Functions\expect('get_field')
|
||||||
->once()
|
->once()
|
||||||
->with('title', 99, true)
|
->with('title', 99, true)
|
||||||
@@ -207,13 +210,27 @@ final class HelpersTest extends TestCase
|
|||||||
self::assertTrue(function_exists('wpmc_get_term_by_slug'));
|
self::assertTrue(function_exists('wpmc_get_term_by_slug'));
|
||||||
self::assertTrue(function_exists('wpmc_alt_get_field'));
|
self::assertTrue(function_exists('wpmc_alt_get_field'));
|
||||||
self::assertTrue(function_exists('wpmc_remove_domain_link'));
|
self::assertTrue(function_exists('wpmc_remove_domain_link'));
|
||||||
|
self::assertTrue(function_exists('wpmc_main_town_slug'));
|
||||||
|
|
||||||
// remove_domain_link is pure (no WP function dependencies) — exercise the proxy.
|
// remove_domain_link is pure (no WP function dependencies) — exercise the proxy.
|
||||||
self::assertSame('/foo/', wpmc_remove_domain_link('https://site.com/foo/'));
|
self::assertSame('/foo/', wpmc_remove_domain_link('https://site.com/foo/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_wpmc_main_town_slug_delegates_to_helpers(): void
|
||||||
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
|
\Brain\Monkey\Functions\expect('get_field')
|
||||||
|
->once()
|
||||||
|
->with('main_town_slug', 'option')
|
||||||
|
->andReturn('spb');
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/includes/wpmc-functions.php';
|
||||||
|
self::assertSame('spb', wpmc_main_town_slug());
|
||||||
|
}
|
||||||
|
|
||||||
public function test_main_town_slug_reads_option_via_acf(): void
|
public function test_main_town_slug_reads_option_via_acf(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\expect('get_field')
|
\Brain\Monkey\Functions\expect('get_field')
|
||||||
->once()
|
->once()
|
||||||
->with('main_town_slug', 'option')
|
->with('main_town_slug', 'option')
|
||||||
@@ -224,6 +241,7 @@ final class HelpersTest extends TestCase
|
|||||||
|
|
||||||
public function test_main_town_slug_returns_null_when_option_empty(): void
|
public function test_main_town_slug_returns_null_when_option_empty(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\expect('get_field')
|
\Brain\Monkey\Functions\expect('get_field')
|
||||||
->once()
|
->once()
|
||||||
->with('main_town_slug', 'option')
|
->with('main_town_slug', 'option')
|
||||||
@@ -234,6 +252,7 @@ final class HelpersTest extends TestCase
|
|||||||
|
|
||||||
public function test_is_routable_path_returns_true_when_no_exclusions(): void
|
public function test_is_routable_path_returns_true_when_no_exclusions(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\expect('get_field')
|
\Brain\Monkey\Functions\expect('get_field')
|
||||||
->once()
|
->once()
|
||||||
->with('page_no_rep', 'option')
|
->with('page_no_rep', 'option')
|
||||||
@@ -244,6 +263,7 @@ final class HelpersTest extends TestCase
|
|||||||
|
|
||||||
public function test_is_routable_path_returns_false_when_path_matches_exclusion(): void
|
public function test_is_routable_path_returns_false_when_path_matches_exclusion(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\expect('get_field')
|
\Brain\Monkey\Functions\expect('get_field')
|
||||||
->once()
|
->once()
|
||||||
->with('page_no_rep', 'option')
|
->with('page_no_rep', 'option')
|
||||||
@@ -257,6 +277,7 @@ final class HelpersTest extends TestCase
|
|||||||
|
|
||||||
public function test_is_routable_path_uses_request_uri_when_path_null(): void
|
public function test_is_routable_path_uses_request_uri_when_path_null(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
$_SERVER['REQUEST_URI'] = '/terms/';
|
$_SERVER['REQUEST_URI'] = '/terms/';
|
||||||
\Brain\Monkey\Functions\expect('get_field')
|
\Brain\Monkey\Functions\expect('get_field')
|
||||||
->once()
|
->once()
|
||||||
@@ -268,6 +289,7 @@ final class HelpersTest extends TestCase
|
|||||||
|
|
||||||
public function test_page_unic_swap_returns_clone_id_when_row_matches(): void
|
public function test_page_unic_swap_returns_clone_id_when_row_matches(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
$term = new \WP_Term();
|
$term = new \WP_Term();
|
||||||
$term->term_id = 11;
|
$term->term_id = 11;
|
||||||
$term->slug = 'tyumen';
|
$term->slug = 'tyumen';
|
||||||
@@ -285,6 +307,7 @@ final class HelpersTest extends TestCase
|
|||||||
|
|
||||||
public function test_page_unic_swap_returns_null_when_no_row_matches(): void
|
public function test_page_unic_swap_returns_null_when_no_row_matches(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
$term = new \WP_Term();
|
$term = new \WP_Term();
|
||||||
$term->term_id = 11;
|
$term->term_id = 11;
|
||||||
$term->slug = 'tyumen';
|
$term->slug = 'tyumen';
|
||||||
@@ -298,6 +321,7 @@ final class HelpersTest extends TestCase
|
|||||||
|
|
||||||
public function test_page_unic_swap_caches_within_request(): void
|
public function test_page_unic_swap_caches_within_request(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
$term = new \WP_Term();
|
$term = new \WP_Term();
|
||||||
$term->term_id = 11;
|
$term->term_id = 11;
|
||||||
\Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
|
\Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
|
||||||
@@ -314,6 +338,7 @@ final class HelpersTest extends TestCase
|
|||||||
|
|
||||||
public function test_index_town_alt_page_id_returns_int_when_option_set(): void
|
public function test_index_town_alt_page_id_returns_int_when_option_set(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\expect('get_field')
|
\Brain\Monkey\Functions\expect('get_field')
|
||||||
->once()
|
->once()
|
||||||
->with('index_town_alt', 'option')
|
->with('index_town_alt', 'option')
|
||||||
@@ -324,6 +349,7 @@ final class HelpersTest extends TestCase
|
|||||||
|
|
||||||
public function test_index_town_alt_page_id_returns_null_when_zero(): void
|
public function test_index_town_alt_page_id_returns_null_when_zero(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\expect('get_field')
|
\Brain\Monkey\Functions\expect('get_field')
|
||||||
->once()
|
->once()
|
||||||
->with('index_town_alt', 'option')
|
->with('index_town_alt', 'option')
|
||||||
@@ -334,6 +360,7 @@ final class HelpersTest extends TestCase
|
|||||||
|
|
||||||
public function test_reset_cache_clears_page_unic_cache(): void
|
public function test_reset_cache_clears_page_unic_cache(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
$term = new \WP_Term();
|
$term = new \WP_Term();
|
||||||
$term->term_id = 11;
|
$term->term_id = 11;
|
||||||
\Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
|
\Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
|
||||||
@@ -348,6 +375,48 @@ final class HelpersTest extends TestCase
|
|||||||
self::assertTrue(true);
|
self::assertTrue(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_main_town_slug_returns_null_before_acf_init(): void
|
||||||
|
{
|
||||||
|
\Brain\Monkey\Functions\expect('did_action')
|
||||||
|
->with('acf/init')
|
||||||
|
->andReturn(0);
|
||||||
|
// get_field should NOT be called — no expectation means Brain Monkey throws if it is.
|
||||||
|
|
||||||
|
self::assertNull(Helpers::main_town_slug());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_main_town_slug_calls_get_field_after_acf_init(): void
|
||||||
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
|
\Brain\Monkey\Functions\expect('get_field')
|
||||||
|
->once()
|
||||||
|
->with('main_town_slug', 'option')
|
||||||
|
->andReturn('spb');
|
||||||
|
|
||||||
|
self::assertSame('spb', Helpers::main_town_slug());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_is_routable_path_returns_true_before_acf_init(): void
|
||||||
|
{
|
||||||
|
\Brain\Monkey\Functions\expect('did_action')
|
||||||
|
->with('acf/init')
|
||||||
|
->andReturn(0);
|
||||||
|
// get_field should NOT be called.
|
||||||
|
|
||||||
|
self::assertTrue(Helpers::is_routable_path('/some/path/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_is_routable_path_checks_exclusions_after_acf_init(): void
|
||||||
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
|
\Brain\Monkey\Functions\expect('get_field')
|
||||||
|
->once()
|
||||||
|
->with('page_no_rep', 'option')
|
||||||
|
->andReturn([['link' => '/excluded/']]);
|
||||||
|
|
||||||
|
self::assertFalse(Helpers::is_routable_path('/excluded/page/'));
|
||||||
|
}
|
||||||
|
|
||||||
public function test_is_bypassed_returns_false_by_default(): void
|
public function test_is_bypassed_returns_false_by_default(): void
|
||||||
{
|
{
|
||||||
self::assertFalse(Helpers::is_bypassed());
|
self::assertFalse(Helpers::is_bypassed());
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ final class LinkFilterTest extends TestCase
|
|||||||
{
|
{
|
||||||
$_SERVER['REQUEST_URI'] = '/' . $town_slug . '/';
|
$_SERVER['REQUEST_URI'] = '/' . $town_slug . '/';
|
||||||
$_SERVER['HTTP_HOST'] = $host;
|
$_SERVER['HTTP_HOST'] = $host;
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => $town_slug]);
|
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => $town_slug]);
|
||||||
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) use ($main) {
|
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) use ($main) {
|
||||||
if ($name === 'main_town_slug') return $main;
|
if ($name === 'main_town_slug') return $main;
|
||||||
@@ -62,6 +63,7 @@ final class LinkFilterTest extends TestCase
|
|||||||
{
|
{
|
||||||
$_SERVER['REQUEST_URI'] = '/random/';
|
$_SERVER['REQUEST_URI'] = '/random/';
|
||||||
$_SERVER['HTTP_HOST'] = 'site.com';
|
$_SERVER['HTTP_HOST'] = 'site.com';
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\when('get_terms')->justReturn([10 => 'spb']);
|
\Brain\Monkey\Functions\when('get_terms')->justReturn([10 => 'spb']);
|
||||||
\Brain\Monkey\Functions\when('get_field')->justReturn(false);
|
\Brain\Monkey\Functions\when('get_field')->justReturn(false);
|
||||||
|
|
||||||
@@ -129,6 +131,7 @@ final class LinkFilterTest extends TestCase
|
|||||||
{
|
{
|
||||||
$_SERVER['REQUEST_URI'] = '/tyumen/';
|
$_SERVER['REQUEST_URI'] = '/tyumen/';
|
||||||
$_SERVER['HTTP_HOST'] = 'site.com';
|
$_SERVER['HTTP_HOST'] = 'site.com';
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
|
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
|
||||||
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
|
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
|
||||||
if ($name === 'main_town_slug') return 'spb';
|
if ($name === 'main_town_slug') return 'spb';
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ final class PluginBootS4Test extends TestCase
|
|||||||
'boot() must register Router on wp@9999'
|
'boot() must register Router on wp@9999'
|
||||||
);
|
);
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
9998,
|
1,
|
||||||
has_action('wp', ['WPMultiCity\\Redirect', 'handle']),
|
has_action('template_redirect', ['WPMultiCity\\Redirect', 'handle']),
|
||||||
'boot() must register Redirect on wp@9998'
|
'boot() must register Redirect on template_redirect@1'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ final class PluginBootTest extends TestCase
|
|||||||
\Brain\Monkey\Actions\expectAdded('acf/init')
|
\Brain\Monkey\Actions\expectAdded('acf/init')
|
||||||
->with([\WPMultiCity\TermMeta::class, 'do_register'])
|
->with([\WPMultiCity\TermMeta::class, 'do_register'])
|
||||||
->once();
|
->once();
|
||||||
\Brain\Monkey\Actions\expectAdded('wp')
|
\Brain\Monkey\Actions\expectAdded('template_redirect')
|
||||||
->with([\WPMultiCity\Redirect::class, 'handle'], 9998)
|
->with([\WPMultiCity\Redirect::class, 'handle'], 1)
|
||||||
->once();
|
->once();
|
||||||
\Brain\Monkey\Actions\expectAdded('wp')
|
\Brain\Monkey\Actions\expectAdded('wp')
|
||||||
->with([\WPMultiCity\Router::class, 'route'], 9999)
|
->with([\WPMultiCity\Router::class, 'route'], 9999)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace WPMultiCity\Tests;
|
||||||
|
|
||||||
|
use WPMultiCity\Redirect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Isolated test for the DOING_AJAX bail-out in Redirect::handle().
|
||||||
|
*
|
||||||
|
* Isolation is achieved via @runInSeparateProcess on the test method, which
|
||||||
|
* forks a fresh PHP process so that defining the DOING_AJAX constant cannot
|
||||||
|
* contaminate other tests (e.g. RouterTest). The previous Z-prefix ordering
|
||||||
|
* hack is no longer needed.
|
||||||
|
*/
|
||||||
|
final class RedirectDoingAjaxTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @runInSeparateProcess
|
||||||
|
* @preserveGlobalState disabled
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+264
-37
@@ -7,78 +7,97 @@ use WPMultiCity\Redirect;
|
|||||||
|
|
||||||
final class RedirectTest extends TestCase
|
final class RedirectTest extends TestCase
|
||||||
{
|
{
|
||||||
public function test_register_attaches_wp_action_at_priority_9998(): void
|
public function test_register_attaches_template_redirect_at_priority_1(): void
|
||||||
{
|
{
|
||||||
Redirect::register();
|
Redirect::register();
|
||||||
|
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
9998,
|
1,
|
||||||
has_action('wp', [Redirect::class, 'handle']),
|
has_action('template_redirect', [Redirect::class, 'handle']),
|
||||||
'Redirect::register() must hook wp action at priority 9998'
|
'Redirect::register() must hook template_redirect at priority 1'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_handle_skips_when_current_town_equals_main(): void
|
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();
|
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
|
||||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
|
||||||
$_SERVER['REQUEST_URI'] = '/spb/about/';
|
|
||||||
\Brain\Monkey\Functions\when('get_terms')->justReturn([10 => 'spb']);
|
|
||||||
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
|
|
||||||
if ($name === 'main_town_slug') return 'spb';
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
Redirect::handle();
|
Redirect::handle();
|
||||||
|
|
||||||
self::assertTrue(true);
|
self::assertTrue(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_handle_skips_when_post_not_set(): void
|
public function test_bail_when_not_404(): void
|
||||||
{
|
{
|
||||||
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||||
$_SERVER['REQUEST_URI'] = '/about/';
|
\Brain\Monkey\Functions\when('is_404')->justReturn(false);
|
||||||
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
|
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
|
||||||
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
|
|
||||||
if ($name === 'main_town_slug') return 'spb';
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
unset($GLOBALS['post']);
|
|
||||||
|
|
||||||
Redirect::handle();
|
Redirect::handle();
|
||||||
|
|
||||||
self::assertTrue(true);
|
self::assertTrue(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_handle_skips_when_url_already_has_town_prefix(): void
|
public function test_bail_when_main_town_slug_null(): void
|
||||||
{
|
{
|
||||||
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||||
$_SERVER['REQUEST_URI'] = '/tyumen/about/';
|
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
|
||||||
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
|
|
||||||
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
|
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
|
||||||
if ($name === 'main_town_slug') return 'spb';
|
if ($name === 'main_town_slug') return null;
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
$GLOBALS['post'] = (object) ['ID' => 84];
|
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
|
||||||
|
|
||||||
Redirect::handle();
|
Redirect::handle();
|
||||||
|
|
||||||
self::assertTrue(true);
|
self::assertTrue(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_handle_redirects_when_post_is_page_unic_clone_without_prefix(): void
|
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_admin')->justReturn(false);
|
||||||
$_SERVER['REQUEST_URI'] = '/about/';
|
\Brain\Monkey\Functions\when('is_404')->justReturn(true);
|
||||||
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
|
\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();
|
||||||
|
|
||||||
$term = new \WP_Term();
|
Redirect::handle();
|
||||||
$term->term_id = 11;
|
|
||||||
$term->slug = 'tyumen';
|
|
||||||
\Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
|
|
||||||
|
|
||||||
|
self::assertTrue(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
\Brain\Monkey\Functions\when('get_field')->alias(function ($name) {
|
||||||
if ($name === 'main_town_slug') return 'spb';
|
if ($name === 'main_town_slug') return 'spb';
|
||||||
if ($name === 'page_unic') return [
|
if ($name === 'page_unic') return [
|
||||||
@@ -86,12 +105,94 @@ final class RedirectTest extends TestCase
|
|||||||
];
|
];
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
$GLOBALS['post'] = (object) ['ID' => 84];
|
$_SERVER['REQUEST_URI'] = '/orphan-slug/';
|
||||||
\Brain\Monkey\Functions\when('get_permalink')->justReturn('https://site.com/about/');
|
\Brain\Monkey\Functions\when('get_posts')->justReturn([
|
||||||
|
(object) ['ID' => 999],
|
||||||
|
]);
|
||||||
|
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
|
||||||
|
|
||||||
|
Redirect::handle();
|
||||||
|
|
||||||
|
self::assertTrue(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
\Brain\Monkey\Functions\when('get_term_by')->justReturn($term);
|
||||||
|
|
||||||
|
\Brain\Monkey\Functions\expect('wp_safe_redirect')->never();
|
||||||
|
|
||||||
|
Redirect::handle();
|
||||||
|
|
||||||
|
self::assertTrue(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
\Brain\Monkey\Functions\expect('wp_safe_redirect')
|
||||||
->once()
|
->once()
|
||||||
->with('/tyumen/about/', 301)
|
->with('/tyumen/tarif/', 301)
|
||||||
->andThrow(new \RuntimeException('redirect-called'));
|
->andThrow(new \RuntimeException('redirect-called'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -101,4 +202,130 @@ final class RedirectTest extends TestCase
|
|||||||
self::assertSame('redirect-called', $e->getMessage());
|
self::assertSame('redirect-called', $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
\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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
$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()'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ final class RouterTest extends TestCase
|
|||||||
|
|
||||||
public function test_route_skips_when_current_town_is_null(): void
|
public function test_route_skips_when_current_town_is_null(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\expect('url_to_postid')->never();
|
\Brain\Monkey\Functions\expect('url_to_postid')->never();
|
||||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||||
$_SERVER['REQUEST_URI'] = '/something-not-a-city/';
|
$_SERVER['REQUEST_URI'] = '/something-not-a-city/';
|
||||||
@@ -61,6 +62,7 @@ final class RouterTest extends TestCase
|
|||||||
|
|
||||||
public function test_route_skips_when_current_town_equals_main_town(): void
|
public function test_route_skips_when_current_town_equals_main_town(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\expect('url_to_postid')->never();
|
\Brain\Monkey\Functions\expect('url_to_postid')->never();
|
||||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||||
$_SERVER['REQUEST_URI'] = '/spb/';
|
$_SERVER['REQUEST_URI'] = '/spb/';
|
||||||
@@ -77,6 +79,7 @@ final class RouterTest extends TestCase
|
|||||||
|
|
||||||
public function test_route_skips_when_path_not_routable(): void
|
public function test_route_skips_when_path_not_routable(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\expect('url_to_postid')->never();
|
\Brain\Monkey\Functions\expect('url_to_postid')->never();
|
||||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||||
$_SERVER['REQUEST_URI'] = '/tyumen/privacy/';
|
$_SERVER['REQUEST_URI'] = '/tyumen/privacy/';
|
||||||
@@ -94,6 +97,7 @@ final class RouterTest extends TestCase
|
|||||||
|
|
||||||
public function test_route_uses_main_page_alt_term_meta_for_root_url(): void
|
public function test_route_uses_main_page_alt_term_meta_for_root_url(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||||
$_SERVER['REQUEST_URI'] = '/tyumen/';
|
$_SERVER['REQUEST_URI'] = '/tyumen/';
|
||||||
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
|
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
|
||||||
@@ -123,6 +127,7 @@ final class RouterTest extends TestCase
|
|||||||
|
|
||||||
public function test_route_falls_back_to_index_town_alt_option(): void
|
public function test_route_falls_back_to_index_town_alt_option(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||||
$_SERVER['REQUEST_URI'] = '/tyumen/';
|
$_SERVER['REQUEST_URI'] = '/tyumen/';
|
||||||
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
|
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
|
||||||
@@ -148,6 +153,7 @@ final class RouterTest extends TestCase
|
|||||||
|
|
||||||
public function test_route_does_not_install_query_when_neither_main_nor_fallback_set(): void
|
public function test_route_does_not_install_query_when_neither_main_nor_fallback_set(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\expect('get_post')->never();
|
\Brain\Monkey\Functions\expect('get_post')->never();
|
||||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||||
$_SERVER['REQUEST_URI'] = '/tyumen/';
|
$_SERVER['REQUEST_URI'] = '/tyumen/';
|
||||||
@@ -169,6 +175,7 @@ final class RouterTest extends TestCase
|
|||||||
|
|
||||||
public function test_route_resolves_internal_page_via_url_to_postid(): void
|
public function test_route_resolves_internal_page_via_url_to_postid(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||||
$_SERVER['REQUEST_URI'] = '/tyumen/about/';
|
$_SERVER['REQUEST_URI'] = '/tyumen/about/';
|
||||||
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
|
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
|
||||||
@@ -193,6 +200,7 @@ final class RouterTest extends TestCase
|
|||||||
|
|
||||||
public function test_route_applies_page_unic_swap_when_clone_exists(): void
|
public function test_route_applies_page_unic_swap_when_clone_exists(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||||
$_SERVER['REQUEST_URI'] = '/tyumen/about/';
|
$_SERVER['REQUEST_URI'] = '/tyumen/about/';
|
||||||
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
|
\Brain\Monkey\Functions\when('get_terms')->justReturn([11 => 'tyumen']);
|
||||||
@@ -220,6 +228,7 @@ final class RouterTest extends TestCase
|
|||||||
|
|
||||||
public function test_route_does_not_install_query_when_url_to_postid_returns_zero(): void
|
public function test_route_does_not_install_query_when_url_to_postid_returns_zero(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\stubs(['did_action' => 1]);
|
||||||
\Brain\Monkey\Functions\expect('get_post')->never();
|
\Brain\Monkey\Functions\expect('get_post')->never();
|
||||||
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
\Brain\Monkey\Functions\when('is_admin')->justReturn(false);
|
||||||
$_SERVER['REQUEST_URI'] = '/tyumen/unknown/';
|
$_SERVER['REQUEST_URI'] = '/tyumen/unknown/';
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* Plugin Name: WP Multi City
|
* Plugin Name: WP Multi City
|
||||||
* Description: Мультигородская подсистема: taxonomy town, шорткоды для подстановки по городу, роутер /{city}/{path}/, page-clones по городам.
|
* Description: Мультигородская подсистема: taxonomy town, шорткоды для подстановки по городу, роутер /{city}/{path}/, page-clones по городам.
|
||||||
* Version: 1.0.0
|
* Version: 1.1.0
|
||||||
* Author: Vladimir Bryzgalov
|
* Author: Vladimir Bryzgalov
|
||||||
* Requires PHP: 8.0
|
* Requires PHP: 8.0
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
@@ -18,7 +18,7 @@ if (!defined('ABSPATH')) exit;
|
|||||||
defined('WPMC_PLUGIN_FILE') || define('WPMC_PLUGIN_FILE', __FILE__);
|
defined('WPMC_PLUGIN_FILE') || define('WPMC_PLUGIN_FILE', __FILE__);
|
||||||
defined('WPMC_PLUGIN_DIR') || define('WPMC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
defined('WPMC_PLUGIN_DIR') || define('WPMC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||||
defined('WPMC_PLUGIN_URL') || define('WPMC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
defined('WPMC_PLUGIN_URL') || define('WPMC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||||
defined('WPMC_VERSION') || define('WPMC_VERSION', '1.0.0');
|
defined('WPMC_VERSION') || define('WPMC_VERSION', '1.1.0');
|
||||||
|
|
||||||
require_once WPMC_PLUGIN_DIR . 'includes/class-plugin.php';
|
require_once WPMC_PLUGIN_DIR . 'includes/class-plugin.php';
|
||||||
\WPMultiCity\Plugin::register_autoloader();
|
\WPMultiCity\Plugin::register_autoloader();
|
||||||
|
|||||||
+4
-4
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "WP Multi City",
|
"name": "WP Multi City",
|
||||||
"slug": "wp-multi-city",
|
"slug": "wp-multi-city",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"download_url": "https://git.netranking.ru/bryzgalov/wp-multi-city/releases/download/v1.0.0/wp-multi-city-1.0.0.zip",
|
"download_url": "https://git.netranking.ru/bryzgalov/wp-multi-city/releases/download/v1.0.1/wp-multi-city-1.0.1.zip",
|
||||||
"homepage": "https://git.netranking.ru/bryzgalov/wp-multi-city",
|
"homepage": "https://git.netranking.ru/bryzgalov/wp-multi-city",
|
||||||
"requires": "6.0",
|
"requires": "6.0",
|
||||||
"tested": "6.9",
|
"tested": "6.9",
|
||||||
"requires_php": "8.0",
|
"requires_php": "8.0",
|
||||||
"last_updated": "2026-05-17 00:00:00",
|
"last_updated": "2026-05-17 12:02:11",
|
||||||
"author": "Vladimir Bryzgalov",
|
"author": "Vladimir Bryzgalov",
|
||||||
"sections": {
|
"sections": {
|
||||||
"description": "Мультигородская подсистема: taxonomy town, [shortcode_dmr] для подстановок, URL-роутер /{city}/{path}/, ACF-driven page clones.",
|
"description": "Мультигородская подсистема: taxonomy town, [shortcode_dmr] для подстановок, URL-роутер /{city}/{path}/, ACF-driven page clones.",
|
||||||
"changelog": "<h4>1.0.0</h4><ul><li>Первый релиз. MVP-ядро S1–S7.</li></ul>"
|
"changelog": "<h4>1.0.1</h4><ul><li>Add: процедурный wrapper `wpmc_main_town_slug()` (был задокументирован в README, но отсутствовал в коде).</li><li>Fix: WP 6.7+ notice `_load_textdomain_just_in_time` — убраны `__()` обёртки из labels CPT/таксономии/ACF field group (плагин не поставляется с переводами).</li><li>Fix: ACF `acf_get_value` \"called too early\" notice — добавлен `did_action('acf/init')` guard в `Helpers::main_town_slug()`, `index_town_alt_page_id`, `page_unic_swap`, `is_routable_path`, `alt_get_field`. До инициализации ACF возвращают безопасные defaults (null/true).</li></ul><h4>1.0.0</h4><ul><li>Первый релиз. MVP-ядро завершено (S1–S7 эпика cp-cw4).</li><li>taxonomy town + CPT shortcode-town + ACF list_shortcode repeater.</li><li>[shortcode_dmr id=N] для подстановок по городу.</li><li>URL-роутер /{city}/{path}/ с поддержкой page_unic / main_page_alt / page_no_rep.</li><li>Глобальные фильтры the_title, home_url, post_link, acf/load_value, rest_url.</li><li>Self-hosted updates через Gitea + plugin-update-checker v5.6.</li></ul>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user