Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions tests/Feature/Extraction/BlockBindingsContractTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php

declare(strict_types=1);

use Brain\Monkey\Functions;
use Illuminate\Config\Repository;
use Illuminate\Events\Dispatcher;
use Illuminate\Foundation\Application;
use Illuminate\Translation\ArrayLoader;
use Illuminate\Translation\Translator;
use Illuminate\Validation\Factory;
use Pollora\BlockBinding\Application\Services\BindingSourceRegistry;
use Pollora\BlockBinding\Domain\Contracts\ContentVisibilityInterface;
use Pollora\BlockBinding\Domain\Contracts\ValuePresenterInterface;
use Pollora\BlockBinding\Domain\Models\BindingContext;
use Pollora\BlockBinding\Infrastructure\Providers\BlockBindingServiceProvider;
use Pollora\BlockBinding\Infrastructure\Services\BindingEditorData;
use Pollora\BlockBinding\Infrastructure\Sources\OptionSource;
use Pollora\Hook\Domain\Contract\Action;
use Pollora\Hook\Domain\Contract\Filter;
use Pollora\Meta\Application\Services\MetaSchemaBuilder;
use Pollora\Meta\Application\Services\MetaSchemaRepository;
use Pollora\Meta\Domain\Contracts\MetaStoreInterface;
use Pollora\Meta\Infrastructure\Providers\MetaServiceProvider;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Tests\Unit\BlockBinding\Fixtures\ArrayMetaStore;
use Tests\Unit\BlockBinding\Fixtures\ArtistProfile;
use Tests\Unit\BlockBinding\Fixtures\Concert;
use Tests\Unit\BlockBinding\Fixtures\FakePresenter;
use Tests\Unit\BlockBinding\Fixtures\FakeVisibility;
use Tests\Unit\BlockBinding\Fixtures\VenueExtras;

require_once dirname(__DIR__, 2).'/Unit/BlockBinding/Fixtures/blocks.php';

/**
* Block Bindings with the real provider and the real Laravel container, over the typed meta of Meta: the sources
* Pollora ships, what the editor is told, and the options a project lists in `config/block-bindings.php`. It
* freezes what the extraction of `pollora/block-bindings` must keep, in particular that the options reach the
* sources and the editor data through the container (they must not turn into silent `null`s).
*/
beforeEach(function (): void {
\Brain\Monkey\setUp();
Functions\when('did_action')->justReturn(0);
Functions\when('number_format_i18n')->alias(fn (float|int $number, int $decimals = 0): string => number_format((float) $number, $decimals, ',', ' '));
Functions\when('get_block_bindings_source')->justReturn();
Functions\when('get_option')->alias(fn (string $name): string => $name === 'blogdescription' ? 'Live music' : 'secret');

$this->registered = [];
Functions\when('register_block_bindings_source')->alias(function (string $name, array $args): void {
$this->registered[$name] = $args;
});

$this->app = new Application(sys_get_temp_dir());
$this->app->instance('config', new Repository(['app' => ['debug' => false], 'block-bindings' => ['options' => ['blogdescription']]]));
$this->app->instance(LoggerInterface::class, new NullLogger);
$this->app->instance('events', new Dispatcher);
$this->app->instance('validator', new Factory(new Translator(new ArrayLoader, 'en')));

$this->hooks = [];
$action = Mockery::mock(Action::class);
$action->shouldReceive('add')->andReturnUsing(function (string $hook, Closure $callback) use ($action): Action {
$this->hooks[$hook][] = $callback;

return $action;
});
$this->app->instance(Action::class, $action);
$this->app->instance(Filter::class, Mockery::mock(Filter::class)->shouldIgnoreMissing());

(new MetaServiceProvider($this->app))->register();
$this->provider = new BlockBindingServiceProvider($this->app);
$this->provider->register();

$this->app->instance(MetaStoreInterface::class, new ArrayMetaStore(['post' => [7 => ['capacity' => '1200']], 'user' => [5 => ['stage_name' => 'The Hums', 'phone' => '0600000000']]]));
$this->app->instance(ContentVisibilityInterface::class, new FakeVisibility);
$this->app->instance(ValuePresenterInterface::class, new FakePresenter);

$schemas = $this->app->make(MetaSchemaRepository::class);
foreach ([Concert::class, ArtistProfile::class, VenueExtras::class] as $class) {
$schemas->add($this->app->make(MetaSchemaBuilder::class)->build($class));
}

$this->provider->boot();
});

afterEach(function (): void {
\Brain\Monkey\tearDown();
});

it('registers the four sources Pollora ships, with WordPress, on init', function (): void {
foreach ($this->hooks['init'] as $register) {
$register();
}

expect(array_keys($this->registered))->toBe(['pollora/post-meta', 'pollora/term-meta', 'pollora/author-meta', 'pollora/option'])
->and($this->app->make(BindingSourceRegistry::class)->find('pollora/post-meta')?->name)->toBe('pollora/post-meta');
});

it('lists the options of the project, through the container, in the editor data', function (): void {
$data = $this->app->make(BindingEditorData::class)->toArray();

expect($data['route'])->toBe('pollora/v1/block-bindings/resolve')
->and($data['sources']['pollora/option']['fields'])->toBe([['label' => 'Blogdescription', 'args' => ['name' => 'blogdescription'], 'type' => 'string']])
->and(array_column(array_column($data['sources']['pollora/author-meta']['fields'][''], 'args'), 'key'))->toBe(['stage_name'])
->and(array_keys($data['sources']['pollora/post-meta']['fields']))->toContain('concert');
});

it('reads only the listed options, through the container', function (): void {
$source = $this->app->make(OptionSource::class);

expect($source(new BindingContext(args: ['name' => 'blogdescription'])))->toBe('Live music')
->and($source(new BindingContext(args: ['name' => 'admin_email'])))->toBeNull();
});

it('resolves a post meta, an author meta and an unlisted key through the resolver the container builds', function (): void {
foreach ($this->hooks['init'] as $register) {
$register();
}

$block = boundParagraph(['postId' => 7, 'postType' => 'concert']);

$resolve = fn (string $source, array $args): mixed => $this->registered[$source]['get_value_callback']($args, $block, 'content');

expect($resolve('pollora/post-meta', ['key' => 'capacity']))->toBe('1 200')
->and($resolve('pollora/post-meta', ['key' => 'backstage_code']))->toBeNull()
->and($resolve('pollora/option', ['name' => 'blogdescription']))->toBe('Live music')
->and($resolve('pollora/option', ['name' => 'admin_email']))->toBeNull();
});
119 changes: 119 additions & 0 deletions tests/Feature/Extraction/PublicSurfaceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

declare(strict_types=1);

/**
* The extraction of Meta, Block Bindings and Roles into packages keeps every class name and every public
* signature. This test freezes them: each type under src/Meta, src/Role and src/BlockBinding, with its kind,
* parents, constants and public/protected methods (the fluent ones included: `MetaRecord::set()`, `fill()`
* and `save()` return `static`).
*
* The snapshot is the contract. A class that moves to a package keeps its name and still resolves through
* the autoloader, so the snapshot has to stay the same. Regenerate it only for a deliberate API change:
*
* UPDATE_SURFACE=1 vendor/bin/pest tests/Feature/Extraction/PublicSurfaceTest.php
*/
const SURFACE_MODULES = ['Meta', 'Role', 'BlockBinding'];

/**
* @return array<string, array<string, mixed>>
*/
function describeSurface(string $class): array
{
$reflection = new ReflectionClass($class);

$kind = match (true) {
$reflection->isEnum() => 'enum',
$reflection->isInterface() => 'interface',
$reflection->isTrait() => 'trait',
default => ($reflection->isAbstract() ? 'abstract ' : '').($reflection->isFinal() ? 'final ' : '').($reflection->isReadOnly() ? 'readonly ' : '').'class',
};

$type = static fn (?ReflectionType $t): string => $t instanceof ReflectionType ? (string) $t : 'mixed';

$methods = [];
foreach ($reflection->getMethods() as $method) {
if ($method->isPrivate() || $method->getDeclaringClass()->getName() !== $class) {
continue;
}

$params = array_map(
static fn (ReflectionParameter $p): string => trim(
($p->isPassedByReference() ? '&' : '').($p->isVariadic() ? '...' : '').$type($p->getType()).' $'.$p->getName().($p->isOptional() && ! $p->isVariadic() ? ' = ?' : ''),
),
$method->getParameters(),
);
$methods[$method->getName()] = ($method->isPublic() ? 'public' : 'protected').($method->isStatic() ? ' static' : '').($method->isAbstract() ? ' abstract' : '')
.' ('.implode(', ', $params).'): '.$type($method->getReturnType());
}

ksort($methods);

$constants = [];
foreach ($reflection->getReflectionConstants() as $constant) {
if ($constant->isPublic() && $constant->getDeclaringClass()->getName() === $class) {
$constants[$constant->getName()] = $constant->isEnumCase() ? 'case' : 'const';
}
}

ksort($constants);

$interfaces = array_values(array_diff($reflection->getInterfaceNames(), $reflection->getParentClass() ? $reflection->getParentClass()->getInterfaceNames() : []));
sort($interfaces);

return [
'kind' => $kind,
'extends' => $reflection->getParentClass() ? $reflection->getParentClass()->getName() : null,
'implements' => $interfaces,
'constants' => $constants,
'methods' => $methods,
];
}

/**
* @return list<class-string>
*/
function surfaceClassesOnDisk(): array
{
$classes = [];
foreach (SURFACE_MODULES as $module) {
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(dirname(__DIR__, 3).'/src/'.$module, FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php' || str_contains($file->getPathname(), '/config/')) {
continue;
}

$relative = substr($file->getPathname(), strlen(dirname(__DIR__, 3).'/src/'), -4);
$classes[] = 'Pollora\\'.str_replace('/', '\\', $relative);
}
}

sort($classes);

return $classes;
}

it('keeps the name and the public signatures of every Meta, Role and BlockBinding class', function (): void {
$snapshot = __DIR__.'/surface.json';

if (getenv('UPDATE_SURFACE') === '1') {
$surface = [];
foreach (surfaceClassesOnDisk() as $class) {
$surface[$class] = describeSurface($class);
}

file_put_contents($snapshot, json_encode($surface, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n");
}

$expected = json_decode((string) file_get_contents($snapshot), true, 512, JSON_THROW_ON_ERROR);
expect($expected)->not->toBeEmpty();

$actual = [];
foreach (array_keys($expected) as $class) {
expect(class_exists($class) || interface_exists($class) || trait_exists($class) || enum_exists($class))
->toBeTrue("{$class} no longer resolves");
$actual[$class] = describeSurface($class);
}

expect($actual)->toEqual($expected);
});
98 changes: 98 additions & 0 deletions tests/Feature/Extraction/RolesContractTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);

use Illuminate\Config\Repository;
use Illuminate\Events\Dispatcher;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Foundation\Application;
use Illuminate\Routing\Router;
use Illuminate\View\Compilers\BladeCompiler;
use Pollora\Hook\Domain\Contract\Action;
use Pollora\Hook\Domain\Contract\Filter;
use Pollora\Role\Application\Services\RoleDefinitionBuilder;
use Pollora\Role\Application\Services\RoleRegistry;
use Pollora\Role\Domain\Services\RoleCompiler;
use Pollora\Role\Infrastructure\Middleware\EnsureUserHasRole;
use Pollora\Role\Infrastructure\Providers\RoleServiceProvider;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Tests\Unit\Role\Fixtures\EditorAdjustments;
use Tests\Unit\Role\Fixtures\Event;
use Tests\Unit\Role\Fixtures\EventManager;
use Tests\Unit\Role\Fixtures\Genre;
use Tests\Unit\Role\Fixtures\Venue;

/**
* Roles declared in code, from the attributes to the capabilities WordPress stores, with the real provider, the
* real Laravel container and the real compiler; only WordPress's own role storage is out of the picture (the
* stored roles are given). It freezes what the extraction of `pollora/roles` must keep, and the Laravel bridge
* (the `role` middleware alias, `@role`) that stays in the framework.
*/
beforeEach(function (): void {
$this->app = new Application(sys_get_temp_dir());
$this->app->instance('config', new Repository(['roles' => ['super_roles' => ['administrator']]]));
$this->app->instance(LoggerInterface::class, new NullLogger);

$this->action = Mockery::mock(Action::class)->shouldIgnoreMissing();
$this->app->instance(Action::class, $this->action);
$this->app->instance(Filter::class, Mockery::mock(Filter::class)->shouldIgnoreMissing());
$this->app->instance('events', new Dispatcher);
(new RoleServiceProvider($this->app))->register();

$this->stored = [
'administrator' => ['name' => 'Administrator', 'capabilities' => ['manage_options' => true, 'edit_posts' => true, 'edit_theme_options' => true]],
'editor' => ['name' => 'Editor', 'capabilities' => ['edit_posts' => true, 'edit_theme_options' => true, 'moderate_comments' => true]],
'author' => ['name' => 'Author', 'capabilities' => ['read' => true, 'edit_posts' => true, 'publish_posts' => true, 'upload_files' => true]],
'subscriber' => ['name' => 'Subscriber', 'capabilities' => ['read' => true]],
];
});

it('compiles the declared roles and modifications into the capabilities WordPress stores', function (): void {
$builder = $this->app->make(RoleDefinitionBuilder::class);
$registry = $this->app->make(RoleRegistry::class);
$registry->addPostType(Event::class);
$registry->addPostType(Venue::class);
$registry->addTaxonomy(Genre::class);
$registry->addRole($builder->build(EventManager::class));
$registry->addModification($builder->build(EditorAdjustments::class));

$roles = $this->app->make(RoleCompiler::class)->compile(
$this->stored,
$registry->definitions(),
$registry->modifications(),
['administrator'],
$registry->declaredCapabilities(),
);

ksort($roles['event_manager']['capabilities']);
ksort($roles['editor']['capabilities']);
ksort($roles['administrator']['capabilities']);

expect(array_keys($roles))->toBe(['administrator', 'editor', 'author', 'subscriber', 'event_manager'])
->and($roles['event_manager']['name'])->toBe('Event manager')
->and($roles['event_manager']['_pollora'])->toBe(['managed' => true])
->and($roles['event_manager']['capabilities'])->not->toHaveKey('publish_posts')
->and($roles['event_manager']['capabilities'])->toHaveKeys(['read', 'export_attendees', 'scan_tickets', 'edit_others_events', 'open_venues', 'manage_genres'])
->and($roles['editor']['capabilities'])->not->toHaveKey('edit_theme_options')
->and($roles['editor']['capabilities'])->toHaveKey('scan_tickets')
->and($roles['administrator']['capabilities'])->toHaveKeys(['edit_others_events', 'manage_genres'])
->and($roles['administrator']['capabilities'])->not->toHaveKey('export_attendees');
expect(array_keys($roles['event_manager']['capabilities']))->toBe(['assign_genres', 'delete_events', 'delete_others_events', 'delete_private_events', 'delete_published_events', 'delete_published_venues', 'delete_venues', 'edit_events', 'edit_genres', 'edit_others_events', 'edit_posts', 'edit_private_events', 'edit_published_events', 'edit_published_venues', 'edit_venues', 'export_attendees', 'manage_genres', 'open_venues', 'publish_events', 'read', 'read_private_events', 'scan_tickets', 'upload_files']);
});

it('aliases the role middleware and compiles @role once the application has booted', function (): void {
$this->app->instance('router', $router = new Router(new Dispatcher));
$this->app->instance('blade.compiler', $blade = new BladeCompiler(new Filesystem, sys_get_temp_dir()));

$provider = new RoleServiceProvider($this->app);
$provider->boot();

$this->app->make('router');
$this->app->booted(static fn (): null => null);
$this->app->boot();
$this->app->make('blade.compiler');

expect($router->getMiddleware()['role'] ?? null)->toBe(EnsureUserHasRole::class)
->and($blade->compileString("@role('editor') shown @endrole"))->toContain('endif');
});
Loading
Loading