feat(pods): Verhaltensprobe als Prüfschritt
Beim Prüfen schreibt und liest PodsProbe auf einem Wegwerf-Entwurf ein Textfeld genau so, wie LIT MCP es tut, vergleicht die Werte und löscht den Entwurf wieder. Bevorzugt von Pods angelegte Typen; ohne Pod mit Textfeld gilt der Schritt als grün mit Hinweis „nicht geprüft“. Ein roter Schritt sperrt Pods über die Berichtssperre. Liegengebliebene Probe-Einträge räumt der Load-Hook des Reiters auf. Pods-Smoke um Probe und Bereinigung erweitert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
42aa4f8dc9
commit
1a294b7881
@@ -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.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 |
|
| 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
|
## 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 |
|
| # | 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 |
|
| 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 |
|
||||||
|
|||||||
Generated
+56
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Pods;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use LIT\MCP\Compatibility\CheckStep;
|
||||||
|
use LIT\MCP\Compatibility\CompatibilityCheck;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/** The check step only Pods has (the behaviour probe, PodsProbe) and its labels. */
|
||||||
|
final class PodsCheckSteps
|
||||||
|
{
|
||||||
|
public const PROBE = 'probe';
|
||||||
|
|
||||||
|
private const LABEL = 'Lese- und Schreibtest an einem Wegwerf-Eintrag';
|
||||||
|
private const LABEL_NO_TARGET = self::LABEL . ' (kein Pod mit Textfeld vorhanden, nicht geprüft)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Profile::$extraSteps of Pods: the probe, skipped while the manifest is red (it would fail anyway). No pod
|
||||||
|
* to probe (null) is a green step that says so, not a skipped one: a skipped step makes the report red and
|
||||||
|
* would lock Pods on a site that simply has no pod yet.
|
||||||
|
*
|
||||||
|
* @param Closure(): (list<string>|null) $probe PodsProbe::run().
|
||||||
|
* @return Closure(bool): list<CheckStep>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-4
@@ -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
|
* 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
|
* are those PodsFieldSource passes; the check adds a behaviour probe (PodsProbe). A hard problem (missing function
|
||||||
* affected capabilities; Pods has no per-version release.
|
* or constant, Pods below 2.8) locks the affected capabilities; Pods has no per-version release.
|
||||||
*/
|
*/
|
||||||
final class PodsCompatibility
|
final class PodsCompatibility
|
||||||
{
|
{
|
||||||
|
public const LABEL = 'Pods';
|
||||||
|
|
||||||
public static function profile(): Profile
|
public static function profile(): Profile
|
||||||
{
|
{
|
||||||
$all = PodsFeature::cases();
|
$all = PodsFeature::cases();
|
||||||
|
|
||||||
return new Profile(
|
return new Profile(
|
||||||
id: 'pods',
|
id: 'pods',
|
||||||
label: 'Pods',
|
label: self::LABEL,
|
||||||
tier: Tier::LockOnFailure,
|
tier: Tier::LockOnFailure,
|
||||||
capabilities: $all,
|
capabilities: $all,
|
||||||
manifests: [new Manifest(
|
manifests: [new Manifest(
|
||||||
@@ -44,7 +46,11 @@ final class PodsCompatibility
|
|||||||
active: static fn (): bool => defined('PODS_VERSION'),
|
active: static fn (): bool => defined('PODS_VERSION'),
|
||||||
version: static fn (): ?string => defined('PODS_VERSION') ? (string) PODS_VERSION : null,
|
version: static fn (): ?string => defined('PODS_VERSION') ? (string) PODS_VERSION : null,
|
||||||
minVersion: '2.8',
|
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(...)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-3
@@ -4,17 +4,45 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Integrations\Pods;
|
namespace LIT\MCP\Integrations\Pods;
|
||||||
|
|
||||||
|
use LIT\MCP\Admin\CompatibilityTab;
|
||||||
use LIT\MCP\Compatibility\ProvidesCompatibility;
|
use LIT\MCP\Compatibility\ProvidesCompatibility;
|
||||||
|
use LIT\MCP\Integrations\Bootable;
|
||||||
use LIT\MCP\Integrations\Integration;
|
use LIT\MCP\Integrations\Integration;
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pods has no tools of its own (the content tools read its fields through PodsFieldSource); this integration only
|
* 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
|
public function id(): string
|
||||||
{
|
{
|
||||||
return 'pods';
|
return 'pods';
|
||||||
@@ -27,7 +55,7 @@ final class PodsIntegration implements Integration, ProvidesCompatibility
|
|||||||
|
|
||||||
public function isAvailable(): bool
|
public function isAvailable(): bool
|
||||||
{
|
{
|
||||||
return defined('PODS_VERSION');
|
return ($this->available)();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function abilities(): array
|
public function abilities(): array
|
||||||
|
|||||||
Generated
+177
@@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Pods;
|
||||||
|
|
||||||
|
use LIT\MCP\Compatibility\ThrowawayPost;
|
||||||
|
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||||||
|
use LIT\MCP\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Behaviour probe of the Pods check: through PodsFieldSource (always usable here – the probe is what frees a locked
|
||||||
|
* profile again) it loads the schema of an existing pod, writes a plain text field of a throwaway draft entry,
|
||||||
|
* reads it back and compares. The pod and its schema are never touched (no save_pod, so no rewrite flush, no
|
||||||
|
* schema cache change); only the entry is created and always deleted for good (ThrowawayPost). Entries a dead
|
||||||
|
* check left behind carry MARKER; removeOrphans() deletes them. Runs only from the check route
|
||||||
|
* (Compatibility::check).
|
||||||
|
*
|
||||||
|
* Target: a pod of a post type Pods created itself (pod arg 'object' empty) comes first, then a foreign type Pods
|
||||||
|
* only extends (object = the type, e.g. an events plugin), last post and page. Creating and deleting the entry
|
||||||
|
* fires the hooks of other plugins on that type (unavoidable with wp_insert_post/wp_delete_post), and Pods may
|
||||||
|
* check other required fields on a partial save than the plain text field the probe writes.
|
||||||
|
*
|
||||||
|
* shortcut: relations (output ids) are not probed – they need a second pod entry; add when a Pods update breaks them.
|
||||||
|
*/
|
||||||
|
final class PodsProbe
|
||||||
|
{
|
||||||
|
public const MARKER = '_lit_mcp_check_entry';
|
||||||
|
|
||||||
|
public const TITLE = '[LIT MCP Prüfung]';
|
||||||
|
|
||||||
|
public const VALUE = 'litcheck-pods-probe';
|
||||||
|
|
||||||
|
private const ABORTED = 'Pods-Probe abgebrochen (Details im PHP-Error-Log).';
|
||||||
|
|
||||||
|
private const RANK_OWN = 0;
|
||||||
|
private const RANK_EXTENDED = 1;
|
||||||
|
private const RANK_CORE = 2;
|
||||||
|
|
||||||
|
/** @var callable(string): mixed */
|
||||||
|
private $loadPod;
|
||||||
|
|
||||||
|
/** @var callable(): list<string> */
|
||||||
|
private $postTypes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable(string): mixed|null $loadPod Pods schema of a post type (raw field types).
|
||||||
|
* @param callable(): list<string>|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<string>|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<int> 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<string> */
|
||||||
|
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.'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
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"
|
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"
|
[ -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=$(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"
|
[ "$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
|
# 3. The tab renders the Pods section and, as 3.3.9.2 is tested, offers no release
|
||||||
TAB=$(wp --user="$ADMIN_LOGIN" eval '
|
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"
|
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"
|
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
|
# 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"
|
[ "$(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"
|
[ -z "$(stored_option lit_mcp_pods_releases)" ] || fail "a refused release must write nothing"
|
||||||
|
|||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Pods;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use LIT\MCP\Compatibility\CheckStep;
|
||||||
|
use LIT\MCP\Integrations\Pods\PodsCheckSteps;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class PodsCheckStepsTest extends TestCase
|
||||||
|
{
|
||||||
|
/** 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 testStepsAreSkippedWithARedManifestAndTheProbeIsNotRun(): void
|
||||||
|
{
|
||||||
|
$ran = false;
|
||||||
|
$steps = (PodsCheckSteps::extra(static function () use (&$ran): array {
|
||||||
|
$ran = true;
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}))(false);
|
||||||
|
|
||||||
|
self::assertCount(1, $steps);
|
||||||
|
self::assertSame(PodsCheckSteps::PROBE, $steps[0]->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', [])));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,11 @@ declare(strict_types=1);
|
|||||||
namespace LIT\MCP\Tests\Unit\Integrations\Pods;
|
namespace LIT\MCP\Tests\Unit\Integrations\Pods;
|
||||||
|
|
||||||
use LIT\MCP\Compatibility\Call;
|
use LIT\MCP\Compatibility\Call;
|
||||||
|
use LIT\MCP\Compatibility\CheckStep;
|
||||||
use LIT\MCP\Compatibility\Compatibility;
|
use LIT\MCP\Compatibility\Compatibility;
|
||||||
use LIT\MCP\Compatibility\Tier;
|
use LIT\MCP\Compatibility\Tier;
|
||||||
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
|
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
|
||||||
|
use LIT\MCP\Integrations\Pods\PodsCheckSteps;
|
||||||
use LIT\MCP\Integrations\Pods\PodsCompatibility;
|
use LIT\MCP\Integrations\Pods\PodsCompatibility;
|
||||||
use LIT\MCP\Integrations\Pods\PodsFeature;
|
use LIT\MCP\Integrations\Pods\PodsFeature;
|
||||||
use LIT\MCP\Tests\Unit\TestCase;
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
@@ -39,6 +41,21 @@ final class PodsCompatibilityTest extends TestCase
|
|||||||
self::assertSame(PodsFeature::cases(), $profile->capabilities);
|
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
|
/** @runInSeparateProcess
|
||||||
* @preserveGlobalState disabled
|
* @preserveGlobalState disabled
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\Tests\Unit\Integrations\Pods;
|
namespace LIT\MCP\Tests\Unit\Integrations\Pods;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Admin\CompatibilityTab;
|
||||||
use LIT\MCP\Compatibility\ProvidesCompatibility;
|
use LIT\MCP\Compatibility\ProvidesCompatibility;
|
||||||
use LIT\MCP\Guides\InstructionsBuilder;
|
use LIT\MCP\Guides\InstructionsBuilder;
|
||||||
use LIT\MCP\Integrations\Integration;
|
use LIT\MCP\Integrations\Integration;
|
||||||
@@ -26,6 +28,50 @@ final class PodsIntegrationTest extends TestCase
|
|||||||
self::assertEquals([PodsCompatibility::profile()], $integration->compatibilities());
|
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
|
public function testIsAvailableOnlyWithPods(): void
|
||||||
{
|
{
|
||||||
self::assertSame(defined('PODS_VERSION'), (new PodsIntegration())->isAvailable());
|
self::assertSame(defined('PODS_VERSION'), (new PodsIntegration())->isAvailable());
|
||||||
|
|||||||
+353
@@ -0,0 +1,353 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Pods;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use Closure;
|
||||||
|
use LIT\MCP\Integrations\Pods\PodsFieldSource;
|
||||||
|
use LIT\MCP\Integrations\Pods\PodsProbe;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use Mockery;
|
||||||
|
use RuntimeException;
|
||||||
|
use WP_Error;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
final class PodsProbeTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @var list<int> */
|
||||||
|
private array $deleted = [];
|
||||||
|
|
||||||
|
/** @var array<string, mixed> What the fake Pods object holds. */
|
||||||
|
private array $stored = [];
|
||||||
|
|
||||||
|
/** @var array<string, mixed>|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<string, mixed> $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<object> $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<string, mixed>): 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<object>|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<string, object> $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
public function testTheBootableIntegrationsBootExactlyOnce(): void
|
||||||
{
|
{
|
||||||
Actions\expectAdded('upgrader_process_complete')->once();
|
Actions\expectAdded('upgrader_process_complete')->once();
|
||||||
Actions\expectAdded('lit_mcp_compat_changed')->once();
|
Actions\expectAdded('lit_mcp_compat_changed')->once();
|
||||||
Filters\expectAdded(SettingsPage::TABS_FILTER)->never();
|
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();
|
Actions\expectAdded(CompatibilityTab::EXTRA_ACTION)->once();
|
||||||
Filters\expectAdded(CompatibilityTab::COLUMNS_FILTER)->once();
|
Filters\expectAdded(CompatibilityTab::COLUMNS_FILTER)->once();
|
||||||
$registry = self::registry();
|
$registry = self::registry();
|
||||||
|
|||||||
Reference in New Issue
Block a user