Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
232 lines
10 KiB
PHP
232 lines
10 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateConditions;
|
||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WP_Post;
|
||
use WP_Term;
|
||
|
||
final class TemplateConditionsTest extends TestCase
|
||
{
|
||
/** @var list<string> Capabilities the user lacks (read_post of 2202 is always missing). */
|
||
private array $denied = ['list_users'];
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => match ($id) {
|
||
1053 => new WP_Post(['ID' => 1053, 'post_type' => 'page', 'post_status' => 'draft']),
|
||
2201 => new WP_Post(['ID' => 2201, 'post_type' => 'lit_person', 'post_status' => 'publish']),
|
||
2202 => new WP_Post(['ID' => 2202, 'post_type' => 'page', 'post_status' => 'private']),
|
||
2203 => new WP_Post(['ID' => 2203, 'post_type' => 'page', 'post_status' => 'trash']),
|
||
7 => new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'publish']),
|
||
default => null,
|
||
});
|
||
Functions\when('current_user_can')->alias(
|
||
fn (string $cap, mixed ...$args): bool => ! in_array($cap, $this->denied, true)
|
||
&& ! ($cap === 'read_post' && ($args[0] ?? 0) === 2202)
|
||
);
|
||
Functions\when('get_term')->alias(
|
||
static fn (int $id, string $taxonomy): ?WP_Term => $id === 5 && $taxonomy === 'category'
|
||
? new WP_Term(['term_id' => 5, 'slug' => 'news', 'name' => 'News'])
|
||
: null
|
||
);
|
||
Functions\when('get_userdata')->justReturn(false);
|
||
Functions\when('wp_roles')->justReturn(new class {
|
||
public function is_role(string $role): bool
|
||
{
|
||
return $role === 'editor';
|
||
}
|
||
});
|
||
}
|
||
|
||
private static function conditions(): TemplateConditions
|
||
{
|
||
return new TemplateConditions(ThemeBuilderFixtures::store());
|
||
}
|
||
|
||
public function testAcceptsDivisConditionsAndReadableObjects(): void
|
||
{
|
||
$checked = self::conditions()->check([
|
||
'singular:post_type:lit_person:all',
|
||
'homepage',
|
||
'404',
|
||
'singular:post_type:page:id:1053',
|
||
'singular:post_type:lit_person:id:2201',
|
||
'archive:taxonomy:category:term:id:5',
|
||
'archive:user:role:editor',
|
||
'homepage',
|
||
], 'use_on');
|
||
|
||
self::assertSame([], $checked['errors']);
|
||
self::assertSame([
|
||
'singular:post_type:lit_person:all',
|
||
'homepage',
|
||
'404',
|
||
'singular:post_type:page:id:1053',
|
||
'singular:post_type:lit_person:id:2201',
|
||
'archive:taxonomy:category:term:id:5',
|
||
'archive:user:role:editor',
|
||
], $checked['ids']);
|
||
}
|
||
|
||
public function testRefusesUnknownUnreadableAndMalformedConditions(): void
|
||
{
|
||
$checked = self::conditions()->check([
|
||
'singular:post_type:page:all:x',
|
||
'singular:post_type:page:id:2202',
|
||
'singular:post_type:page:id:2203',
|
||
'singular:post_type:page:id:2201',
|
||
'singular:post_type:page:id:0123',
|
||
"homepage\n",
|
||
'archive:user:id:1',
|
||
'archive:taxonomy:category:term:id:6',
|
||
'archive:user:role:hacker',
|
||
'singular:post_type:page:id:',
|
||
42,
|
||
], 'use_on');
|
||
|
||
self::assertSame([], $checked['ids']);
|
||
self::assertSame(
|
||
array_map(static fn (int $i): string => 'use_on.' . $i, range(0, 10)),
|
||
array_keys($checked['errors'])
|
||
);
|
||
self::assertStringContainsString('unbekannte Bedingung', $checked['errors']['use_on.0']);
|
||
// Private and trashed look the same – no oracle over other users' entries.
|
||
self::assertSame(
|
||
$checked['errors']['use_on.1'],
|
||
str_replace('2203', '2202', $checked['errors']['use_on.2'])
|
||
);
|
||
self::assertStringContainsString('list_users', $checked['errors']['use_on.6']);
|
||
}
|
||
|
||
public function testListShapeAndSize(): void
|
||
{
|
||
self::assertSame(['ids' => [], 'errors' => []], self::conditions()->check(null, 'use_on'));
|
||
self::assertArrayHasKey('use_on', self::conditions()->check('homepage', 'use_on')['errors']);
|
||
self::assertArrayHasKey('use_on', self::conditions()->check(['a' => 'homepage'], 'use_on')['errors']);
|
||
self::assertArrayHasKey(
|
||
'exclude_from',
|
||
self::conditions()->check(array_fill(0, TemplateConditions::MAX + 1, 'homepage'), 'exclude_from')['errors']
|
||
);
|
||
self::assertSame(
|
||
[],
|
||
self::conditions()->check(array_fill(0, TemplateConditions::MAX, 'homepage'), 'exclude_from')['errors']
|
||
);
|
||
}
|
||
|
||
public function testLabelsAndTheCatalog(): void
|
||
{
|
||
self::assertSame('Alle Personen', self::conditions()->label('singular:post_type:lit_person:all'));
|
||
self::assertSame('Bestimmte Seiten: 1053', self::conditions()->label('singular:post_type:page:id:1053'));
|
||
self::assertSame('Unterseiten bestimmter Seiten: 7', self::conditions()->label('singular:post_type:page:children:id:7'));
|
||
self::assertSame('404-Seite', self::conditions()->label('404'));
|
||
self::assertSame('unbekannt:x', self::conditions()->label('unbekannt:x'));
|
||
self::assertSame('singular:post_type:page:id:', self::conditions()->label('singular:post_type:page:id:'));
|
||
|
||
$available = array_column(self::conditions()->available(), null, 'id');
|
||
self::assertNull($available['homepage']['needs']);
|
||
self::assertSame('post_type:page', $available['singular:post_type:page:id:']['needs']);
|
||
self::assertSame('taxonomy:category', $available['archive:taxonomy:category:term:id:']['needs']);
|
||
self::assertSame('user', $available['archive:user:id:']['needs']);
|
||
self::assertSame('user_role', $available['archive:user:role:']['needs']);
|
||
self::assertSame('404-Seite', $available['404']['label']);
|
||
self::assertSame(array_keys(ThemeBuilderFixtures::conditions()), array_keys($available));
|
||
}
|
||
|
||
/** Every ID Divi offers round-trips: exactly, or with an object the user may read. */
|
||
public function testEveryConditionDiviOffersRoundTrips(): void
|
||
{
|
||
$this->denied = [];
|
||
Functions\when('get_userdata')->alias(static fn (int $id): object|false => $id === 1 ? (object) ['ID' => 1] : false);
|
||
$objects = [
|
||
'post_type:page' => '1053',
|
||
'post_type:lit_person' => '2201',
|
||
'taxonomy:category' => '5',
|
||
'user' => '1',
|
||
'user_role' => 'editor',
|
||
];
|
||
|
||
$ids = [];
|
||
foreach (self::conditions()->available() as $condition) {
|
||
$ids[] = $condition['id'] . ($condition['needs'] === null ? '' : $objects[$condition['needs']]);
|
||
}
|
||
$checked = self::conditions()->check($ids, 'use_on');
|
||
|
||
self::assertSame([], $checked['errors']);
|
||
self::assertSame($ids, $checked['ids']);
|
||
}
|
||
|
||
/** Ruling Q10: authors only with list_users, and a missing user reads like a forbidden one. */
|
||
public function testAuthorConditionsNeedListUsers(): void
|
||
{
|
||
Functions\when('get_userdata')->alias(static fn (int $id): object|false => $id === 1 ? (object) ['ID' => 1] : false);
|
||
|
||
$forbidden = self::conditions()->check(['archive:user:id:1'], 'use_on')['errors'];
|
||
$this->denied = [];
|
||
$missing = self::conditions()->check(['archive:user:id:2'], 'use_on')['errors'];
|
||
$allowed = self::conditions()->check(['archive:user:id:1'], 'use_on');
|
||
|
||
self::assertSame($forbidden['use_on.0'], $missing['use_on.0']);
|
||
self::assertSame(['archive:user:id:1'], $allowed['ids']);
|
||
}
|
||
|
||
/** A term the user may not assign reads like a missing one; no term name or slug in any message. */
|
||
public function testTermsMustBeAssignable(): void
|
||
{
|
||
$this->denied = ['list_users', 'assign_term'];
|
||
|
||
$errors = self::conditions()->check(
|
||
['archive:taxonomy:category:term:id:5', 'archive:taxonomy:category:term:id:6'],
|
||
'use_on'
|
||
)['errors'];
|
||
|
||
self::assertSame($errors['use_on.0'], str_replace('6', '5', $errors['use_on.1']));
|
||
self::assertStringNotContainsString('News', implode(' ', $errors));
|
||
self::assertStringNotContainsString('news', implode(' ', $errors));
|
||
}
|
||
|
||
/** The object is the last segment as Divi reads it back; types a filter adds stay builder-only. */
|
||
public function testObjectIsTheLastSegmentAndForeignTypesStayInTheBuilder(): void
|
||
{
|
||
$store = Mockery::mock(DiviThemeBuilder::class);
|
||
$store->allows('flatConditions')->andReturn(ThemeBuilderFixtures::conditions() + [
|
||
'woo:shop:id:' => ['label' => 'Shop', 'type' => 'product_set', 'value' => 'x'],
|
||
]);
|
||
$conditions = new TemplateConditions($store);
|
||
|
||
$errors = $conditions->check([
|
||
'singular:post_type:page:id:7:1053',
|
||
'woo:shop:id:3',
|
||
'singular:post_type:page:id:1053 ',
|
||
'Homepage',
|
||
], 'exclude_from')['errors'];
|
||
|
||
self::assertSame(
|
||
['exclude_from.0', 'exclude_from.1', 'exclude_from.2', 'exclude_from.3'],
|
||
array_keys($errors)
|
||
);
|
||
self::assertStringContainsString('unbekannte Bedingung', $errors['exclude_from.0']);
|
||
self::assertStringContainsString('Divi Theme Builder', $errors['exclude_from.1']);
|
||
self::assertStringContainsString('unbekannte Bedingung', $errors['exclude_from.3']);
|
||
}
|
||
|
||
/** No existence oracle: a readable post of another type reads like a missing one. */
|
||
public function testWrongTypeReadsLikeMissing(): void
|
||
{
|
||
$errors = self::conditions()->check(
|
||
['singular:post_type:lit_person:id:1053', 'singular:post_type:lit_person:id:9999'],
|
||
'use_on'
|
||
)['errors'];
|
||
|
||
self::assertSame($errors['use_on.0'], str_replace('9999', '1053', $errors['use_on.1']));
|
||
}
|
||
}
|