spec(wpmc 1.1.0): Redirect::handle reverse-slug lookup on is_404 (washanyanya-style)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Vladimir Bryzgalov
2026-05-19 02:04:06 +05:00
parent d0a7b8eb9d
commit 20aff663d4
@@ -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
Нет. Все решения зафиксированы.