diff --git a/docs/superpowers/plans/2026-11-06-kompatibilitaetsschicht.md b/docs/superpowers/plans/2026-11-06-kompatibilitaetsschicht.md index fa46e30..2841c74 100644 --- a/docs/superpowers/plans/2026-11-06-kompatibilitaetsschicht.md +++ b/docs/superpowers/plans/2026-11-06-kompatibilitaetsschicht.md @@ -68,8 +68,16 @@ Optionsnamen. | 1.24 | Texte/CSS (`lit-mcp-divi-*` → `lit-mcp-compat-*`), `ReleaseStatus:24`, `docs/guides/overview.md`, `Uninstaller` (`lit_mcp_pods_*`), `divi-release-smoke.sh` | `ReleaseStatusTest`, `UninstallerTest` | DDEV Divi-Smoke grün | sonnet | | 1.25 | Pods-Smoke: `/pods-check`, Freigabe schreibt `lit_mcp_pods_releases` ohne Sperrwirkung, hohe `minVersion` sperrt | Smoke rot ohne 1.19–1.21 | DDEV Pods- und Divi-Smoke grün | sonnet | +**Erledigt 1.15–1.25:** 7d6a97a, 85f4138, b40b81f, 239d35c. + +| 1.26 | Pods-Verhaltensprobe als `extraSteps` (Benutzerentscheidung 2026-11-06): auf einem Wegwerf-Eintrag ein Feld über die Pods-API schreiben, lesen und alles wieder löschen; nur aus Prüfroute/CLI, nie in normalen Requests; Ausnahmen → fester Text + `Log::failure`; roter Schritt sperrt über die Berichtssperre | `PodsProbeTest` | DDEV: Pods-Smoke grün, Probe hinterlässt nichts | sonnet | + ## Phase 2 – WP Super Cache 3.1.5 +Zusätzlich (Benutzerentscheidung 2026-11-06): **2.6 Super-Cache-Verhaltensprobe** als `extraSteps` – eine private Testseite +cachen (bzw. Cache-Datei anlegen lassen) und per `wp_cache_post_edit` leeren, danach prüfen, dass die Datei weg ist; Testseite +wieder löschen; nur aus Prüfroute/CLI. + | # | Ziel und Dateien | Zuerst rot | Akzeptanz | Modell | |---|---|---|---|---| | 2.1 | Hook `lit_mcp_changed(ability, objectType, objectId, logId)` in `ChangeLog::record`, nur bei `ok`, auch bei fehlgeschlagenem Insert; werfender Listener → `Log::failure` | `ChangeLogTest` | bestehende Fälle grün | sonnet | diff --git a/src/Integrations/Pods/PodsCheckSteps.php b/src/Integrations/Pods/PodsCheckSteps.php new file mode 100644 index 0000000..c0dbfdd --- /dev/null +++ b/src/Integrations/Pods/PodsCheckSteps.php @@ -0,0 +1,56 @@ +|null) $probe PodsProbe::run(). + * @return Closure(bool): list + */ + public static function extra(Closure $probe): Closure + { + return static function (bool $manifestClean) use ($probe): array { + if (! $manifestClean) { + return [new CheckStep(self::PROBE, [], true, self::LABEL)]; + } + + $none = false; + $problems = CompatibilityCheck::guarded(static function () use ($probe, &$none): array { + $result = $probe(); + $none = $result === null; + + return $result ?? []; + }); + + return [new CheckStep(self::PROBE, $problems, false, $none ? self::LABEL_NO_TARGET : self::LABEL)]; + }; + } + + public static function label(CheckStep $step): string + { + // Reports saved before labels were stored have none. + return $step->label + ?? ($step->key === self::PROBE ? self::LABEL : null) + ?? CheckStep::genericLabel($step->key, PodsCompatibility::LABEL) + ?? $step->key; + } +} diff --git a/src/Integrations/Pods/PodsCompatibility.php b/src/Integrations/Pods/PodsCompatibility.php index e06b07a..bd43f85 100644 --- a/src/Integrations/Pods/PodsCompatibility.php +++ b/src/Integrations/Pods/PodsCompatibility.php @@ -14,18 +14,20 @@ defined('ABSPATH') || exit; /** * Pods as a compatibility profile: the calls PodsFieldSource makes, checked against Pods 3.3.9.2. Argument counts - * are those PodsFieldSource passes. A hard problem (missing function or constant, Pods below 2.8) locks the - * affected capabilities; Pods has no per-version release. + * are those PodsFieldSource passes; the check adds a behaviour probe (PodsProbe). A hard problem (missing function + * or constant, Pods below 2.8) locks the affected capabilities; Pods has no per-version release. */ final class PodsCompatibility { + public const LABEL = 'Pods'; + public static function profile(): Profile { $all = PodsFeature::cases(); return new Profile( id: 'pods', - label: 'Pods', + label: self::LABEL, tier: Tier::LockOnFailure, capabilities: $all, manifests: [new Manifest( @@ -44,7 +46,11 @@ final class PodsCompatibility active: static fn (): bool => defined('PODS_VERSION'), version: static fn (): ?string => defined('PODS_VERSION') ? (string) PODS_VERSION : null, minVersion: '2.8', - testedVersions: ['3.3.9.2'] + testedVersions: ['3.3.9.2'], + extraSteps: PodsCheckSteps::extra( + static fn (): ?array => (new PodsProbe(new PodsFieldSource(static fn (): bool => true)))->run() + ), + stepLabel: PodsCheckSteps::label(...) ); } } diff --git a/src/Integrations/Pods/PodsIntegration.php b/src/Integrations/Pods/PodsIntegration.php index 7d040ae..8159ebd 100644 --- a/src/Integrations/Pods/PodsIntegration.php +++ b/src/Integrations/Pods/PodsIntegration.php @@ -4,17 +4,45 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Pods; +use LIT\MCP\Admin\CompatibilityTab; use LIT\MCP\Compatibility\ProvidesCompatibility; +use LIT\MCP\Integrations\Bootable; use LIT\MCP\Integrations\Integration; defined('ABSPATH') || exit; /** * Pods has no tools of its own (the content tools read its fields through PodsFieldSource); this integration only - * brings the compatibility profile. Without abilities it is no tool group. + * brings the compatibility profile and the cleanup of probe entries a dead check left behind. Without abilities it + * is no tool group. */ -final class PodsIntegration implements Integration, ProvidesCompatibility +final class PodsIntegration implements Integration, ProvidesCompatibility, Bootable { + /** @var callable(): bool */ + private $available; + + /** @param callable(): bool|null $available Whether Pods is loaded; null = PODS_VERSION is defined. */ + public function __construct(?callable $available = null) + { + $this->available = $available ?? static fn (): bool => defined('PODS_VERSION'); + } + + public function boot(): void + { + add_action(CompatibilityTab::LOAD_ACTION, [$this, 'removeOrphans']); + } + + /** + * lit_mcp_compat_profile_load('pods'), before any output: deletes probe entries a dead check left behind + * (PodsProbe::removeOrphans()). A GET without nonce on purpose: only the plugin's own leftovers, idempotent. + */ + public function removeOrphans(string $profileId): void + { + if ($profileId === 'pods' && $this->isAvailable() && current_user_can('manage_options')) { + PodsProbe::removeOrphans(); + } + } + public function id(): string { return 'pods'; @@ -27,7 +55,7 @@ final class PodsIntegration implements Integration, ProvidesCompatibility public function isAvailable(): bool { - return defined('PODS_VERSION'); + return ($this->available)(); } public function abilities(): array diff --git a/src/Integrations/Pods/PodsProbe.php b/src/Integrations/Pods/PodsProbe.php new file mode 100644 index 0000000..fd4f134 --- /dev/null +++ b/src/Integrations/Pods/PodsProbe.php @@ -0,0 +1,177 @@ + */ + private $postTypes; + + /** + * @param callable(string): mixed|null $loadPod Pods schema of a post type (raw field types). + * @param callable(): list|null $postTypes Post types to look at; the best-ranked target wins, then the + * first of them. + */ + public function __construct( + private PodsFieldSource $source, + ?callable $loadPod = null, + ?callable $postTypes = null + ) { + $this->loadPod = $loadPod ?? static fn (string $name): mixed => pods_api()->load_pod(['name' => $name]); + $this->postTypes = $postTypes ?? static fn (): array => array_values(array_unique([ + ...array_values(get_post_types(['_builtin' => false])), + 'post', + 'page', + ])); + } + + /** @return list|null German problems, [] = green; null = no pod with a plain text field to probe. */ + public function run(): ?array + { + try { + $target = $this->target(); + } catch (Throwable $e) { + Log::failure('pods probe', $e); + + return [self::ABORTED]; + } + if ($target === null) { + return null; + } + + [$postType, $field] = $target; + + return self::throwaway()->run($postType, fn (int $id): array => $this->exercise($id, $postType, $field)); + } + + /** @return list IDs of probe entries a dead check left behind, deleted now. */ + public static function removeOrphans(): array + { + return self::throwaway()->removeOrphans(array_values(get_post_types())); + } + + private static function throwaway(): ThrowawayPost + { + return new ThrowawayPost( + self::MARKER, + self::TITLE, + self::ABORTED, + 'Der Probe-Eintrag konnte nicht angelegt werden (Details im PHP-Error-Log).', + 'Der Probe-Eintrag %d konnte nicht gelöscht werden; bitte endgültig löschen (Titel „' + . self::TITLE . ' …“).', + 'pods probe' + ); + } + + /** @return array{0: string, 1: FieldDefinition}|null Post type and field of the best-ranked text pod. */ + private function target(): ?array + { + $best = null; + $bestRank = PHP_INT_MAX; + foreach (($this->postTypes)() as $postType) { + $pod = ($this->loadPod)($postType); + $field = is_object($pod) ? $this->textField($postType, $pod) : null; + if ($field === null) { + continue; + } + + $rank = self::rank($postType, $pod); + if ($rank === self::RANK_OWN) { + return [$postType, $field]; + } + if ($rank < $bestRank) { + [$best, $bestRank] = [[$postType, $field], $rank]; + } + } + + return $best; + } + + private static function rank(string $postType, object $pod): int + { + if ((string) $pod->get_arg('object', '') === '') { + return self::RANK_OWN; + } + + return in_array($postType, ['post', 'page'], true) ? self::RANK_CORE : self::RANK_EXTENDED; + } + + private function textField(string $postType, object $pod): ?FieldDefinition + { + $definitions = []; + foreach ($this->source->fieldsFor($postType) as $definition) { + $definitions[$definition->key] = $definition; + } + foreach ($pod->get_fields() as $field) { + // Raw type 'text' only: email, slug, website … validate or rewrite the value. + $definition = $definitions[(string) $field->get_name()] ?? null; + if ( + $definition !== null && (string) $field->get_type() === 'text' + && $definition->status === FieldDefinition::WRITABLE + && ! $definition->multiple + ) { + return $definition; + } + } + + return null; + } + + /** @return list */ + private function exercise(int $id, string $postType, FieldDefinition $field): array + { + $this->source->write($id, $postType, [$field->key => self::VALUE], [$field->key => $field]); + $read = $this->source->read($id, $field); + if ($read === self::VALUE) { + return []; + } + + // The field name and the value stay in the log: the report reaches MCP clients. + Log::line(sprintf( + 'pods probe: field %s read back %s instead of the written value.', + $field->key, + is_scalar($read) ? '"' . $read . '"' : gettype($read) + )); + + return ['Gelesener Wert weicht vom geschriebenen ab.']; + } +} diff --git a/tests/Integration/pods-compat-smoke.sh b/tests/Integration/pods-compat-smoke.sh index 7f30025..241510b 100644 --- a/tests/Integration/pods-compat-smoke.sh +++ b/tests/Integration/pods-compat-smoke.sh @@ -62,9 +62,16 @@ REPORT=$(stored_option lit_mcp_pods_check_report) echo "$REPORT" | jq -e '(.steps | length) >= 1 and all(.steps[]; .problems == [] and .skipped == false) and .version == "3.3.9.2" and (has("divi_version") | not)' >/dev/null || fail "the stored report must be green with key version: $REPORT" [ -z "$(stored_option lit_mcp_pods_check_lock)" ] || fail "the check lock must be released" +# The behaviour probe is part of the report (green, not skipped, its label is the real probe, not "not probed") ... +echo "$REPORT" | jq -e '[.steps[] | select(.key == "probe")] | length == 1 and (.[0] | .problems == [] and .skipped == false + and (.label | contains("nicht geprüft") | not))' >/dev/null || fail "the report must contain the green probe step: $REPORT" +# ... and leaves no entry behind (it ran on lit_person; any status, any marked entry). +LEFT=$(wp post list --post_type=any --post_status=any --meta_key=_lit_mcp_check_entry --format=count) +[ "$LEFT" = "0" ] || fail "the probe left $LEFT entries behind" +[ "$(wp post list --post_type=lit_person --post_status=any --format=count)" = "0" ] || fail "the probe left a lit_person entry" UNAUTH=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Host: $HOST" -H 'X-Forwarded-Proto: https' "$BASE/wp-json/lit-mcp/v1/pods-check") [ "$UNAUTH" = "401" ] || [ "$UNAUTH" = "403" ] || fail "anonymous check must be refused, got $UNAUTH" -pass "/pods-check: 303 to the tab, green report with key version, lock released, anonymous refused" +pass "/pods-check: 303 to the tab, green report with key version and probe step, no probe entry left, lock released, anonymous refused" # 3. The tab renders the Pods section and, as 3.3.9.2 is tested, offers no release TAB=$(wp --user="$ADMIN_LOGIN" eval ' @@ -78,6 +85,25 @@ echo "$TAB" | grep -q 'lit-mcp-compat-check-pods' || fail "the Pods section must echo "$TAB" | grep -q 'lit-mcp-compat-release-pods' && fail "no release button for a tested version" pass "tab Compatibility: Pods section with version and state, check button, no release button" +# 3b. Orphan cleanup: an old marked probe entry (a dead check) is deleted on the tab's profile-load hook, a young one and +# an unmarked entry with the same title are not. +ORPHANS=$(wp --user="$ADMIN_LOGIN" eval ' +$make = static fn (string $date, bool $marked): int => (int) wp_insert_post([ + "post_type" => "lit_person", "post_status" => "draft", "post_title" => LIT\MCP\Integrations\Pods\PodsProbe::TITLE . " smoke", + "post_date" => get_date_from_gmt($date), "post_date_gmt" => $date, + "meta_input" => $marked ? [LIT\MCP\Integrations\Pods\PodsProbe::MARKER => "1"] : [], +]); +$old = $make(gmdate("Y-m-d H:i:s", time() - 3600), true); +$young = $make(gmdate("Y-m-d H:i:s"), true); +$unmarked = $make(gmdate("Y-m-d H:i:s", time() - 3600), false); +do_action(LIT\MCP\Admin\CompatibilityTab::LOAD_ACTION, "pods"); +echo json_encode([$old, $young, $unmarked, get_post_status($old), get_post_status($young), get_post_status($unmarked)]);') +OLD_ID=$(echo "$ORPHANS" | jq -r '.[0]'); YOUNG_ID=$(echo "$ORPHANS" | jq -r '.[1]'); UNMARKED_ID=$(echo "$ORPHANS" | jq -r '.[2]') +CREATED_POSTS+=("$OLD_ID" "$YOUNG_ID" "$UNMARKED_ID") +echo "$ORPHANS" | jq -e '.[3] == false and .[4] == "draft" and .[5] == "draft"' >/dev/null \ + || fail "orphan cleanup must delete only the old marked entry: $ORPHANS" +pass "orphan cleanup: old marked probe entry deleted, young and unmarked ones kept" + # 4. Release: refused for the tested version; for an untested one (simulated profile) it is only documented [ "$(handler release real)" = "compat_release_refused" ] || fail "releasing a tested version must be refused" [ -z "$(stored_option lit_mcp_pods_releases)" ] || fail "a refused release must write nothing" diff --git a/tests/Unit/Integrations/Pods/PodsCheckStepsTest.php b/tests/Unit/Integrations/Pods/PodsCheckStepsTest.php new file mode 100644 index 0000000..fa8992e --- /dev/null +++ b/tests/Unit/Integrations/Pods/PodsCheckStepsTest.php @@ -0,0 +1,85 @@ +key); + self::assertTrue($steps[0]->skipped); + self::assertFalse($ran); + } + + public function testTheStepCarriesTheProbeProblems(): void + { + $steps = (PodsCheckSteps::extra(static fn (): array => ['rot']))(true); + + self::assertSame(['rot'], $steps[0]->problems); + self::assertFalse($steps[0]->skipped); + self::assertSame('Lese- und Schreibtest an einem Wegwerf-Eintrag', $steps[0]->label()); + } + + /** No target is no red step: a skipped one would lock Pods on a site that has no pod yet (CheckReport::green()). */ + public function testWithoutATargetTheStepIsGreenAndSaysSo(): void + { + $steps = (PodsCheckSteps::extra(static fn (): ?array => null))(true); + + self::assertTrue($steps[0]->green()); + self::assertStringContainsString('nicht geprüft', $steps[0]->label()); + } + + public function testALateExceptionOfTheClosureIsStillGuarded(): void + { + $logged = $this->logged(function (): void { + $steps = (PodsCheckSteps::extra(static fn (): never => throw new RuntimeException('boom')))(true); + self::assertSame(['Prüfschritt abgebrochen (Details im PHP-Error-Log).'], $steps[0]->problems); + }); + + self::assertStringContainsString('boom', $logged); + } + + public function testTheLabelOfAnOldStepComesFromTheKey(): void + { + self::assertSame( + 'Lese- und Schreibtest an einem Wegwerf-Eintrag', + PodsCheckSteps::label(new CheckStep(PodsCheckSteps::PROBE, [])) + ); + self::assertSame( + 'Pods-Funktionen, Klassen und Registrierungen', + PodsCheckSteps::label(new CheckStep(CheckStep::MANIFEST, [])) + ); + self::assertSame('other', PodsCheckSteps::label(new CheckStep('other', []))); + } +} diff --git a/tests/Unit/Integrations/Pods/PodsCompatibilityTest.php b/tests/Unit/Integrations/Pods/PodsCompatibilityTest.php index cf3a075..52959b5 100644 --- a/tests/Unit/Integrations/Pods/PodsCompatibilityTest.php +++ b/tests/Unit/Integrations/Pods/PodsCompatibilityTest.php @@ -5,9 +5,11 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Pods; use LIT\MCP\Compatibility\Call; +use LIT\MCP\Compatibility\CheckStep; use LIT\MCP\Compatibility\Compatibility; use LIT\MCP\Compatibility\Tier; use LIT\MCP\Integrations\Divi\Release\DiviProfile; +use LIT\MCP\Integrations\Pods\PodsCheckSteps; use LIT\MCP\Integrations\Pods\PodsCompatibility; use LIT\MCP\Integrations\Pods\PodsFeature; use LIT\MCP\Tests\Unit\TestCase; @@ -39,6 +41,21 @@ final class PodsCompatibilityTest extends TestCase self::assertSame(PodsFeature::cases(), $profile->capabilities); } + public function testTheCheckHasTheProbeAsExtraStep(): void + { + $profile = PodsCompatibility::profile(); + + self::assertNotNull($profile->extraSteps); + $steps = ($profile->extraSteps)(false); + self::assertSame([PodsCheckSteps::PROBE], array_map(static fn ($step): string => $step->key, $steps)); + self::assertTrue($steps[0]->skipped); + self::assertNotNull($profile->stepLabel); + self::assertSame( + 'Lese- und Schreibtest an einem Wegwerf-Eintrag', + ($profile->stepLabel)(new CheckStep(PodsCheckSteps::PROBE, [])) + ); + } + /** @runInSeparateProcess * @preserveGlobalState disabled */ diff --git a/tests/Unit/Integrations/Pods/PodsIntegrationTest.php b/tests/Unit/Integrations/Pods/PodsIntegrationTest.php index ebcc7ab..ede29f2 100644 --- a/tests/Unit/Integrations/Pods/PodsIntegrationTest.php +++ b/tests/Unit/Integrations/Pods/PodsIntegrationTest.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Pods; +use Brain\Monkey\Functions; +use LIT\MCP\Admin\CompatibilityTab; use LIT\MCP\Compatibility\ProvidesCompatibility; use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\Integration; @@ -26,6 +28,50 @@ final class PodsIntegrationTest extends TestCase self::assertEquals([PodsCompatibility::profile()], $integration->compatibilities()); } + public function testBootHooksTheOrphanCleanupIntoTheProfileLoad(): void + { + $integration = new PodsIntegration(); + $integration->boot(); + + self::assertNotFalse(has_action(CompatibilityTab::LOAD_ACTION, [$integration, 'removeOrphans'])); + } + + public function testNoCleanupForAnotherProfile(): void + { + Functions\expect('get_posts')->never(); + Functions\when('current_user_can')->justReturn(true); + + (new PodsIntegration(static fn (): bool => true))->removeOrphans('divi'); + $this->addToAssertionCount(1); + } + + public function testNoCleanupWithoutPods(): void + { + Functions\expect('get_posts')->never(); + Functions\when('current_user_can')->justReturn(true); + + (new PodsIntegration(static fn (): bool => false))->removeOrphans('pods'); + $this->addToAssertionCount(1); + } + + public function testNoCleanupWithoutTheCapability(): void + { + Functions\expect('get_posts')->never(); + Functions\when('current_user_can')->justReturn(false); + + (new PodsIntegration(static fn (): bool => true))->removeOrphans('pods'); + $this->addToAssertionCount(1); + } + + public function testCleansUpForTheAdministratorWithPods(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post_types')->justReturn(['post' => 'post']); + Functions\expect('get_posts')->once()->andReturn([]); + + (new PodsIntegration(static fn (): bool => true))->removeOrphans('pods'); + } + public function testIsAvailableOnlyWithPods(): void { self::assertSame(defined('PODS_VERSION'), (new PodsIntegration())->isAvailable()); diff --git a/tests/Unit/Integrations/Pods/PodsProbeTest.php b/tests/Unit/Integrations/Pods/PodsProbeTest.php new file mode 100644 index 0000000..b40fdf1 --- /dev/null +++ b/tests/Unit/Integrations/Pods/PodsProbeTest.php @@ -0,0 +1,353 @@ + */ + private array $deleted = []; + + /** @var array What the fake Pods object holds. */ + private array $stored = []; + + /** @var array|null The wp_insert_post arguments. */ + private ?array $inserted = null; + + protected function setUp(): void + { + parent::setUp(); + $this->deleted = []; + $this->stored = []; + $this->inserted = null; + Functions\when('wp_insert_post')->alias(function (array $post): int { + $this->inserted = $post; + + return 77; + }); + Functions\when('get_post_type')->justReturn('lit_person'); + Functions\when('is_wp_error')->alias(static fn ($value): bool => $value instanceof WP_Error); + Functions\when('wp_delete_post')->alias(function (int $id, bool $force): WP_Post { + self::assertTrue($force, 'never to the trash'); + $this->deleted[] = $id; + + return new WP_Post(['ID' => $id]); + }); + } + + /** @param array $args */ + private function field( + string $name, + string $type, + bool $required = false, + array $args = [], + bool $multiple = false + ): object { + $field = Mockery::mock(); + $field->allows('get_name')->andReturn($name); + $field->allows('get_label')->andReturn(ucfirst($name)); + $field->allows('get_type')->andReturn($type); + $field->allows('is_required')->andReturn($required); + $field->allows('is_multi_value')->andReturn($multiple); + $field->allows('get_arg')->andReturnUsing(static fn (string $key, $default = null) => $args[$key] ?? $default); + + return $field; + } + + /** @param list $fields */ + private function schema(array $fields, string $object = ''): object + { + $pod = Mockery::mock(); + $pod->allows('get_type')->andReturn('post_type'); + $pod->allows('get_arg')->andReturnUsing( + static fn (string $key, $default = null) => $key === 'object' ? $object : $default + ); + $pod->allows('get_fields')->andReturn($fields); + + return $pod; + } + + /** @param (Closure(array): mixed)|null $save */ + private function entry(?Closure $save = null, ?string $readBack = null): object + { + $entry = Mockery::mock(); + $entry->allows('save')->andReturnUsing(function (array $data) use ($save) { + if ($save !== null) { + return $save($data); + } + $this->stored = $data; + + return 77; + }); + $entry->allows('field')->andReturnUsing( + fn (array $args) => $readBack ?? $this->stored[$args['name']] ?? '' + ); + + return $entry; + } + + /** @param list|null $fields */ + private function probe(?array $fields = null, ?object $entry = null): PodsProbe + { + $pod = $this->schema($fields ?? [$this->field('role', 'text')]); + $entry ??= $this->entry(); + $source = new PodsFieldSource( + static fn (): bool => true, + static fn (string $name) => $name === 'lit_person' ? $pod : false, + static fn () => $entry, + static fn (object $field): ?bool => null + ); + + return new PodsProbe( + $source, + static fn (string $name) => $name === 'lit_person' ? $pod : false, + static fn (): array => ['post', 'lit_person'] + ); + } + + /** Captures what Log::failure writes to the PHP error log while $run runs. */ + private function logged(Closure $run): string + { + $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $log); + try { + $run(); + + return (string) file_get_contents($log); + } finally { + ini_set('error_log', (string) $previous); + unlink($log); + } + } + + public function testGreenWhenTheWrittenValueIsReadBackAndTheEntryIsGone(): void + { + self::assertSame([], $this->probe()->run()); + self::assertSame(['role' => PodsProbe::VALUE], $this->stored); + self::assertSame([77], $this->deleted); + } + + public function testCreatesAMarkedDraftWithThePrefixedTitleInThePodsPostType(): void + { + $this->probe()->run(); + + self::assertSame('lit_person', $this->inserted['post_type']); + self::assertSame('draft', $this->inserted['post_status']); + self::assertStringStartsWith(PodsProbe::TITLE, $this->inserted['post_title']); + self::assertSame(['_lit_mcp_check_entry' => '1'], $this->inserted['meta_input']); + } + + public function testADifferingValueIsAFixedTextAndTheValueGoesToTheLogOnly(): void + { + $problems = []; + $logged = $this->logged(function () use (&$problems): void { + $problems = $this->probe(null, $this->entry(null, 'etwas anderes'))->run(); + }); + + self::assertSame(['Gelesener Wert weicht vom geschriebenen ab.'], $problems); + self::assertStringContainsString('role', $logged); + self::assertStringContainsString('etwas anderes', $logged); + self::assertSame([77], $this->deleted); + } + + public function testAnExceptionBecomesTheFixedTextAndALogLineAndTheEntryIsDeleted(): void + { + $entry = $this->entry(static function (): never { + throw new RuntimeException('Pods kaputt'); + }); + $problems = []; + $logged = $this->logged(function () use (&$problems, $entry): void { + $problems = $this->probe(null, $entry)->run(); + }); + + self::assertSame(['Pods-Probe abgebrochen (Details im PHP-Error-Log).'], $problems); + self::assertStringContainsString('Pods kaputt', $logged); + self::assertSame([77], $this->deleted); + } + + public function testAFailingSchemaLookupIsTheFixedTextToo(): void + { + $broken = static fn () => throw new RuntimeException('Schema kaputt'); + $probe = new PodsProbe( + new PodsFieldSource(static fn (): bool => true, $broken), + $broken, + static fn (): array => ['lit_person'] + ); + $problems = []; + $logged = $this->logged(function () use (&$problems, $probe): void { + $problems = $probe->run(); + }); + + self::assertSame(['Pods-Probe abgebrochen (Details im PHP-Error-Log).'], $problems); + self::assertStringContainsString('Schema kaputt', $logged); + self::assertNull($this->inserted); + } + + public function testNothingToProbeWithoutAPodWithAPlainWritableTextField(): void + { + $fields = [$this->field('mail', 'email'), $this->field('intro', 'wysiwyg')]; + + self::assertNull($this->probe($fields)->run()); + self::assertNull($this->probe([])->run()); + self::assertNull($this->inserted, 'nothing is created without a target'); + self::assertSame([], $this->deleted); + } + + public function testARequiredTextFieldIsFineBecauseTheProbeWritesAValue(): void + { + self::assertSame([], $this->probe([$this->field('role', 'text', true)])->run()); + self::assertSame(['role' => PodsProbe::VALUE], $this->stored); + } + + public function testPicksThePlainTextFieldAmongOthers(): void + { + $this->probe([$this->field('mail', 'email'), $this->field('title2', 'text')])->run(); + + self::assertSame(['title2' => PodsProbe::VALUE], $this->stored); + } + + public function testAnEntryThatCannotBeCreatedIsAFixedTextAndNothingIsDeleted(): void + { + Functions\when('wp_insert_post')->justReturn(new WP_Error('x', 'Datenbank voll')); + $problems = []; + $logged = $this->logged(function () use (&$problems): void { + $problems = $this->probe()->run(); + }); + + self::assertSame(['Der Probe-Eintrag konnte nicht angelegt werden (Details im PHP-Error-Log).'], $problems); + self::assertStringContainsString('Datenbank voll', $logged); + self::assertSame([], $this->deleted); + } + + public function testAnEntryThatCannotBeDeletedIsReported(): void + { + Functions\when('wp_delete_post')->justReturn(false); + + $problems = $this->probe()->run(); + + self::assertCount(1, $problems); + self::assertStringContainsString('77', $problems[0]); + self::assertStringContainsString('gelöscht', $problems[0]); + } + + public function testOrphansAreLookedForInEveryPostTypeWithThePodsMarker(): void + { + Functions\when('get_post_types')->justReturn(['post' => 'post', 'lit_person' => 'lit_person']); + $query = null; + Functions\when('get_posts')->alias(static function (array $args) use (&$query): array { + $query = $args; + + return [ + new WP_Post(['ID' => 5, 'post_title' => PodsProbe::TITLE . ' 2026-11-06 10:00']), + new WP_Post(['ID' => 6, 'post_title' => 'Echte Person']), + ]; + }); + + self::assertSame([5], PodsProbe::removeOrphans()); + self::assertSame([5], $this->deleted); + self::assertSame('_lit_mcp_check_entry', $query['meta_key']); + self::assertSame(['post', 'lit_person'], $query['post_type']); + self::assertSame('post_modified_gmt', $query['date_query'][0]['column']); + } + + /** @param array $pods */ + private function probeOver(array $pods, ?callable $postTypes = null): PodsProbe + { + $load = static fn (string $name) => $pods[$name] ?? false; + $source = new PodsFieldSource( + static fn (): bool => true, + $load, + fn () => $this->entry(), + static fn (object $field): ?bool => null + ); + + return new PodsProbe($source, $load, $postTypes); + } + + public function testPodsOwnPostTypesComeBeforeExtendedForeignOnesAndPostAndPage(): void + { + $fields = [$this->field('role', 'text')]; + $pods = [ + 'post' => $this->schema($fields, 'post'), + 'event' => $this->schema($fields, 'event'), + 'lit_person' => $this->schema($fields), + ]; + + $this->probeOver($pods, static fn (): array => ['post', 'event', 'lit_person'])->run(); + + self::assertSame('lit_person', $this->inserted['post_type']); + } + + public function testAnExtendedForeignTypeComesBeforePostAndPage(): void + { + $fields = [$this->field('role', 'text')]; + $pods = ['post' => $this->schema($fields, 'post'), 'event' => $this->schema($fields, 'event')]; + + $this->probeOver($pods, static fn (): array => ['post', 'event'])->run(); + + self::assertSame('event', $this->inserted['post_type']); + } + + public function testPostIsTheLastResort(): void + { + $pods = ['post' => $this->schema([$this->field('role', 'text')], 'post')]; + + $this->probeOver($pods, static fn (): array => ['page', 'post'])->run(); + + self::assertSame('post', $this->inserted['post_type']); + } + + public function testALowerRankedTypeIsUsedWhenTheBetterOnesHaveNoTextField(): void + { + $pods = [ + 'lit_person' => $this->schema([$this->field('mail', 'email')]), + 'post' => $this->schema([$this->field('role', 'text')], 'post'), + ]; + + $this->probeOver($pods, static fn (): array => ['lit_person', 'post'])->run(); + + self::assertSame('post', $this->inserted['post_type']); + } + + public function testReadOnlyAndMultipleTextFieldsAreNotProbed(): void + { + $fields = [ + $this->field('locked', 'text', false, ['read_only' => true]), + $this->field('many', 'text', false, [], true), + ]; + + self::assertNull($this->probe($fields)->run()); + self::assertNull($this->inserted); + + $this->probe([...$fields, $this->field('role', 'text')])->run(); + self::assertSame(['role' => PodsProbe::VALUE], $this->stored); + } + + public function testTheDefaultPostTypesAreCustomTypesThenPostAndPage(): void + { + Functions\when('get_post_types')->justReturn(['lit_person' => 'lit_person', 'post' => 'post']); + $asked = []; + $probe = new PodsProbe( + new PodsFieldSource(static fn (): bool => true, static fn () => false), + static function (string $name) use (&$asked) { + $asked[] = $name; + + return false; + } + ); + + self::assertNull($probe->run()); + self::assertSame(['lit_person', 'post', 'page'], $asked); + } +} diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php index de7e9da..41c92f6 100644 --- a/tests/Unit/PluginTest.php +++ b/tests/Unit/PluginTest.php @@ -53,13 +53,13 @@ final class PluginTest extends TestCase ); } - /** Only Divi has boot steps (upgrader hook, compat listener, admin extras); a second boot registers nothing twice. */ + /** Divi and Pods boot (hooks, orphan cleanup); a second boot registers nothing twice. */ public function testTheBootableIntegrationsBootExactlyOnce(): void { Actions\expectAdded('upgrader_process_complete')->once(); Actions\expectAdded('lit_mcp_compat_changed')->once(); Filters\expectAdded(SettingsPage::TABS_FILTER)->never(); - Actions\expectAdded(CompatibilityTab::LOAD_ACTION)->once(); + Actions\expectAdded(CompatibilityTab::LOAD_ACTION)->times(2); // Divi and Pods Actions\expectAdded(CompatibilityTab::EXTRA_ACTION)->once(); Filters\expectAdded(CompatibilityTab::COLUMNS_FILTER)->once(); $registry = self::registry();