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'])); } }