Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
539 lines
22 KiB
PHP
539 lines
22 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
|
|
|
use Brain\Monkey\Functions;
|
|
use LIT\MCP\Abilities\Errors;
|
|
use LIT\MCP\Abilities\WriteFailed;
|
|
use LIT\MCP\Integrations\Divi\Design\DiviFontsUploadAbility;
|
|
use LIT\MCP\Integrations\Divi\Design\FontCatalog;
|
|
use LIT\MCP\Integrations\Divi\Design\FontInstaller;
|
|
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
|
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
|
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
|
use LIT\MCP\Integrations\Media\RemoteFileFetcher;
|
|
use LIT\MCP\Limits;
|
|
use LIT\MCP\Tests\Support\FakeChangeLog;
|
|
use LIT\MCP\Tests\Support\ReleaseMatrices;
|
|
use LIT\MCP\Tests\Unit\TestCase;
|
|
use Mockery;
|
|
use Mockery\MockInterface;
|
|
use RuntimeException;
|
|
|
|
final class DiviFontsUploadAbilityTest extends TestCase
|
|
{
|
|
private const WOFF2 = "wOF2\x00\x01\x00\x00woff2-body";
|
|
private const TTF = "\x00\x01\x00\x00\x00\x0cttf-body";
|
|
private const WOFF2_URL = 'https://fonts.example.com/inter.woff2';
|
|
private const TTF_URL = 'https://fonts.example.com/inter.ttf';
|
|
|
|
/** @var list<string> */
|
|
private array $tempFiles = [];
|
|
|
|
/** @var array<string, string|null> URL => body the fake fetcher writes (null = download fails). */
|
|
private array $bodies = [];
|
|
|
|
/** @var list<array{url: string, tmp: string, max: int, context: string}> */
|
|
private array $fetches = [];
|
|
|
|
/** @var array<string, string> Path => content of the temp files when install() ran. */
|
|
private array $installedFrom = [];
|
|
|
|
/** URL whose allows() turns false after the first call (it was valid on input, refused at download). */
|
|
private ?string $revokedUrl = null;
|
|
|
|
/** @var array<string, int> */
|
|
private array $allowCalls = [];
|
|
|
|
private string $errorLog = '';
|
|
private string|false $previousErrorLog = false;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
$this->errorLog = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
|
|
$this->previousErrorLog = ini_set('error_log', $this->errorLog);
|
|
$this->bodies = [self::WOFF2_URL => self::WOFF2, self::TTF_URL => self::TTF];
|
|
Functions\when('current_user_can')->justReturn(true);
|
|
Functions\when('get_option')->justReturn(['upload_mb' => 1]);
|
|
Functions\when('wp_tempnam')->alias(function (): string {
|
|
$file = (string) tempnam(sys_get_temp_dir(), 'lit-font');
|
|
$this->tempFiles[] = $file;
|
|
|
|
return $file;
|
|
});
|
|
Functions\when('wp_delete_file')->alias(static function (string $file): void {
|
|
if (is_file($file)) {
|
|
unlink($file);
|
|
}
|
|
});
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
ini_set('error_log', (string) $this->previousErrorLog);
|
|
foreach ([...$this->tempFiles, $this->errorLog] as $file) {
|
|
if (is_file($file)) {
|
|
unlink($file);
|
|
}
|
|
}
|
|
parent::tearDown();
|
|
}
|
|
|
|
private function fetcher(): RemoteFileFetcher
|
|
{
|
|
$test = $this;
|
|
|
|
return new class ($test) extends RemoteFileFetcher {
|
|
public function __construct(private DiviFontsUploadAbilityTest $test)
|
|
{
|
|
parent::__construct(static fn (string $host): array => ['93.184.216.34']);
|
|
}
|
|
|
|
public function allows(string $url): bool
|
|
{
|
|
return $this->test->allowed($url);
|
|
}
|
|
|
|
public function fetch(string $url, string $tmp, int $maxBytes, string $context): ?array
|
|
{
|
|
return $this->test->fakeFetch($url, $tmp, $maxBytes, $context);
|
|
}
|
|
};
|
|
}
|
|
|
|
/** The fake fetcher's allows(). */
|
|
public function allowed(string $url): bool
|
|
{
|
|
$this->allowCalls[$url] = ($this->allowCalls[$url] ?? 0) + 1;
|
|
if ($url === $this->revokedUrl && $this->allowCalls[$url] > 1) {
|
|
return false;
|
|
}
|
|
|
|
return str_starts_with($url, 'https://') && ! str_contains($url, 'intern');
|
|
}
|
|
|
|
/** The fake fetcher's download: writes the body of $url into $tmp. */
|
|
public function fakeFetch(string $url, string $tmp, int $maxBytes, string $context): ?array
|
|
{
|
|
if (! $this->allowed($url)) { // like the real fetch(): a refused URL is never requested
|
|
return null;
|
|
}
|
|
$this->fetches[] = ['url' => $url, 'tmp' => $tmp, 'max' => $maxBytes, 'context' => $context];
|
|
$body = $this->bodies[$url] ?? null;
|
|
if ($body === null) {
|
|
return null;
|
|
}
|
|
file_put_contents($tmp, substr($body, 0, $maxBytes + 1));
|
|
|
|
return ['response' => ['code' => 200], 'headers' => []];
|
|
}
|
|
|
|
/** @param list<string> $reserved */
|
|
private static function catalog(array $reserved = ['Open Sans', 'Arial'], bool $canManage = true): FontCatalog
|
|
{
|
|
$catalog = Mockery::mock(FontCatalog::class);
|
|
$catalog->allows('reserved')->andReturn($reserved);
|
|
$catalog->allows('canManage')->andReturn($canManage);
|
|
|
|
return $catalog;
|
|
}
|
|
|
|
private static function installer(bool $accepts = true): FontInstaller&MockInterface
|
|
{
|
|
$installer = Mockery::mock(FontInstaller::class);
|
|
$installer->allows('formats')->andReturn(['woff2', 'woff', 'ttf', 'otf']);
|
|
$installer->allows('nameMaxLength')->andReturn(100);
|
|
$installer->allows('accepts')->andReturn($accepts);
|
|
|
|
return $installer;
|
|
}
|
|
|
|
private function ability(
|
|
?FontInstaller $installer = null,
|
|
?FakeChangeLog $log = null,
|
|
?FontCatalog $catalog = null
|
|
): DiviFontsUploadAbility {
|
|
return new DiviFontsUploadAbility(
|
|
$catalog ?? self::catalog(),
|
|
$installer ?? self::installer(),
|
|
$this->fetcher(),
|
|
$log ?? FakeChangeLog::forbidden(),
|
|
new Limits()
|
|
);
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private static function input(array $overrides = []): array
|
|
{
|
|
return array_merge([
|
|
'name' => 'Inter Display',
|
|
'files' => ['woff2' => self::WOFF2_URL, 'ttf' => self::TTF_URL],
|
|
'weights' => [700, 400],
|
|
], $overrides);
|
|
}
|
|
|
|
private function assertTempFilesRemoved(): void
|
|
{
|
|
self::assertNotSame([], $this->tempFiles, 'the files were downloaded');
|
|
foreach ($this->tempFiles as $file) {
|
|
self::assertFileDoesNotExist($file);
|
|
}
|
|
}
|
|
|
|
public function testIsAnIndirectAdditiveDiviToolForFontUpload(): void
|
|
{
|
|
$ability = $this->ability();
|
|
|
|
self::assertSame('lit/divi-fonts-upload', $ability->name());
|
|
self::assertFalse($ability->isDirect());
|
|
self::assertSame(['readonly' => false, 'destructive' => false, 'idempotent' => false], $ability->annotations());
|
|
self::assertSame([DiviFeature::FontUpload], $ability->diviFeatures([]));
|
|
self::assertSame(['name', 'files', 'weights'], $ability->inputSchema()['required']);
|
|
self::assertFalse($ability->inputSchema()['properties']['files']['additionalProperties']);
|
|
}
|
|
|
|
public function testPermissionAndExecuteAskTheFontRights(): void
|
|
{
|
|
$ability = $this->ability(catalog: self::catalog(canManage: false));
|
|
|
|
self::assertToolError($ability->permission(self::input()), 'forbidden');
|
|
self::assertToolError($ability->execute(self::input()), 'forbidden');
|
|
|
|
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'upload_files');
|
|
self::assertToolError($this->ability()->permission(self::input()), 'forbidden');
|
|
self::assertToolError($this->ability()->execute(self::input()), 'forbidden');
|
|
self::assertSame([], $this->fetches, 'nothing is downloaded without the rights');
|
|
}
|
|
|
|
public function testEveryInputProblemIsReportedAtOnceWithItsPath(): void
|
|
{
|
|
$result = $this->ability()->execute([
|
|
'name' => 'Inter (Pro)',
|
|
'files' => ['woff2' => 'http://intern.example.com/a.woff2', 'eot' => self::TTF_URL, 'ttf' => 5],
|
|
'weights' => [400, 450, 400],
|
|
'generic_family' => 'serifs',
|
|
'confirm' => 'yes',
|
|
'style' => 'italic',
|
|
]);
|
|
|
|
$fields = self::errorFields($result);
|
|
self::assertToolError($result, 'validation_failed');
|
|
self::assertStringContainsString('„Inter Pro“', $fields['name']);
|
|
self::assertStringContainsString('öffentlich', $fields['files.woff2']);
|
|
self::assertStringContainsString('erlaubt: woff2, woff, ttf, otf', $fields['files.eot']);
|
|
self::assertSame('URL als Text erwartet', $fields['files.ttf']);
|
|
self::assertStringContainsString('100, 200, 300, 400, 500, 600, 700, 800, 900', $fields['weights']);
|
|
self::assertStringContainsString('sans-serif', $fields['generic_family']);
|
|
self::assertSame('true oder false', $fields['confirm']);
|
|
self::assertSame('unbekanntes Feld', $fields['style']);
|
|
self::assertSame([], $this->fetches);
|
|
}
|
|
|
|
/** @return array<string, array{bool}> */
|
|
public static function confirmModes(): array
|
|
{
|
|
return ['dry run' => [false], 'confirm' => [true]];
|
|
}
|
|
|
|
/** @dataProvider confirmModes */
|
|
public function testNothingIsFetchedWhenOneUrlIsRefusedByAllows(bool $confirm): void
|
|
{
|
|
$result = $this->ability(self::installer(), null)->execute(self::input([
|
|
'files' => ['woff2' => self::WOFF2_URL, 'ttf' => 'https://intern.example.com/inter.ttf'],
|
|
'confirm' => $confirm,
|
|
]));
|
|
|
|
self::assertToolError($result, 'validation_failed');
|
|
self::assertSame([], $this->fetches, 'the valid URL is not fetched either');
|
|
}
|
|
|
|
/** @dataProvider confirmModes */
|
|
public function testFetchIsNeverCalledForAUrlAllowsRefusesAtDownloadTime(bool $confirm): void
|
|
{
|
|
$this->revokedUrl = self::TTF_URL;
|
|
$installer = self::installer();
|
|
$installer->expects('install')->never();
|
|
|
|
$result = $this->ability($installer)->execute(self::input(['confirm' => $confirm]));
|
|
|
|
self::assertToolError($result, 'font_fetch_failed');
|
|
self::assertSame(['files.ttf' => 'nicht abrufbar'], self::errorFields($result));
|
|
self::assertSame([self::WOFF2_URL], array_column($this->fetches, 'url'), 'the refused URL was never requested');
|
|
$this->assertTempFilesRemoved();
|
|
}
|
|
|
|
public function testFilesAndWeightsNeedTheirShape(): void
|
|
{
|
|
$fields = self::errorFields($this->ability()->execute(self::input(['files' => [], 'weights' => []])));
|
|
self::assertSame('1 bis 4 Formate als Objekt {Format: URL}', $fields['files']);
|
|
self::assertStringContainsString('"all" oder Liste', $fields['weights']);
|
|
|
|
$fields = self::errorFields($this->ability()->execute(self::input([
|
|
'files' => [self::WOFF2_URL],
|
|
'weights' => 'bold',
|
|
])));
|
|
self::assertSame('1 bis 4 Formate als Objekt {Format: URL}', $fields['files']);
|
|
self::assertStringContainsString('"all" oder Liste', $fields['weights']);
|
|
|
|
$fields = self::errorFields($this->ability()->execute(['files' => ['woff2' => self::WOFF2_URL]]));
|
|
self::assertStringContainsString('Name fehlt', $fields['name']);
|
|
self::assertStringContainsString('"all" oder Liste', $fields['weights']);
|
|
}
|
|
|
|
public function testAFormatDiviDoesNotKnowIsRefused(): void
|
|
{
|
|
$installer = Mockery::mock(FontInstaller::class);
|
|
$installer->allows('formats')->andReturn(['ttf', 'otf']);
|
|
$installer->allows('nameMaxLength')->andReturn(100);
|
|
|
|
$fields = self::errorFields($this->ability($installer)->execute(self::input()));
|
|
|
|
self::assertStringContainsString('unterstützt Divi auf dieser Website nicht', $fields['files.woff2']);
|
|
self::assertArrayNotHasKey('files.ttf', $fields);
|
|
}
|
|
|
|
public function testANameThatIsTakenWhateverTheCaseIsAConflict(): void
|
|
{
|
|
$error = self::assertToolError(
|
|
$this->ability(catalog: self::catalog(['Open Sans', 'Inter Display']))->execute(
|
|
self::input(['name' => 'inter display'])
|
|
),
|
|
'conflict'
|
|
);
|
|
|
|
self::assertStringContainsString('„Inter Display“', $error['message']);
|
|
self::assertSame([], $this->fetches);
|
|
}
|
|
|
|
public function testTheDryRunDownloadsAndChecksEveryFileAndAsksForConfirmation(): void
|
|
{
|
|
$installer = self::installer();
|
|
$installer->expects('install')->never();
|
|
|
|
$error = self::assertToolError($this->ability($installer)->execute(self::input()), 'confirm_required');
|
|
|
|
self::assertStringContainsString('„Inter Display“', $error['message']);
|
|
self::assertStringContainsString('ganzen Website', $error['message']);
|
|
self::assertStringContainsString('Nichts wurde gespeichert (Probelauf).', $error['message']);
|
|
self::assertSame('Inter Display', $error['font']);
|
|
self::assertSame([
|
|
['format' => 'woff2', 'bytes' => strlen(self::WOFF2), 'sha256' => hash('sha256', self::WOFF2)],
|
|
['format' => 'ttf', 'bytes' => strlen(self::TTF), 'sha256' => hash('sha256', self::TTF)],
|
|
], $error['files']);
|
|
self::assertSame('400,700', $error['weights']);
|
|
self::assertSame('sans-serif', $error['generic_family']);
|
|
self::assertSame([self::WOFF2_URL, self::TTF_URL], array_column($this->fetches, 'url'));
|
|
self::assertSame(['font upload', 'font upload'], array_column($this->fetches, 'context'));
|
|
self::assertSame([1048576, 1048576], array_column($this->fetches, 'max'), 'upload limit 1 MB');
|
|
$this->assertTempFilesRemoved();
|
|
}
|
|
|
|
public function testTheSizeCapIsFiveMegabytesEvenWithAHigherUploadLimit(): void
|
|
{
|
|
Functions\when('get_option')->justReturn(['upload_mb' => 32]);
|
|
|
|
$this->ability()->execute(self::input());
|
|
|
|
self::assertSame([5242880, 5242880], array_column($this->fetches, 'max'));
|
|
}
|
|
|
|
public function testAFileAboveTheCapIsInvalid(): void
|
|
{
|
|
$this->bodies[self::TTF_URL] = self::TTF . str_repeat('x', 1048576);
|
|
|
|
$result = $this->ability()->execute(self::input());
|
|
|
|
self::assertToolError($result, 'font_invalid');
|
|
self::assertSame('größer als 1 MB', self::errorFields($result)['files.ttf']);
|
|
self::assertArrayNotHasKey('files.woff2', self::errorFields($result));
|
|
$this->assertTempFilesRemoved();
|
|
}
|
|
|
|
public function testTheContentMustBeTheFormatOfItsKey(): void
|
|
{
|
|
$this->bodies[self::WOFF2_URL] = self::TTF;
|
|
$this->bodies[self::TTF_URL] = '<?php evil();';
|
|
|
|
$result = $this->ability()->execute(self::input());
|
|
|
|
$fields = self::errorFields($result);
|
|
self::assertToolError($result, 'font_invalid');
|
|
self::assertSame('Inhalt ist eine ttf-Datei, keine woff2-Datei', $fields['files.woff2']);
|
|
self::assertSame('Inhalt ist keine Schriftdatei', $fields['files.ttf']);
|
|
$this->assertTempFilesRemoved();
|
|
}
|
|
|
|
public function testAnEmptyFileIsInvalid(): void
|
|
{
|
|
$this->bodies[self::TTF_URL] = '';
|
|
|
|
$result = $this->ability()->execute(self::input());
|
|
|
|
self::assertToolError($result, 'font_invalid');
|
|
self::assertSame('leere Datei', self::errorFields($result)['files.ttf']);
|
|
}
|
|
|
|
public function testAFileDivisContentCheckRefusesIsInvalid(): void
|
|
{
|
|
$result = $this->ability(self::installer(accepts: false))->execute(self::input());
|
|
|
|
self::assertToolError($result, 'font_invalid');
|
|
self::assertSame('von Divi nicht als Schriftdatei erkannt', self::errorFields($result)['files.woff2']);
|
|
$this->assertTempFilesRemoved();
|
|
}
|
|
|
|
public function testAFailedDownloadIsFontFetchFailedWithoutAnOracle(): void
|
|
{
|
|
$this->bodies[self::TTF_URL] = null;
|
|
$this->bodies[self::WOFF2_URL] = self::TTF;
|
|
|
|
$result = $this->ability()->execute(self::input());
|
|
|
|
$error = self::assertToolError($result, 'font_fetch_failed');
|
|
self::assertSame(
|
|
['files.woff2' => 'Inhalt ist eine ttf-Datei, keine woff2-Datei', 'files.ttf' => 'nicht abrufbar'],
|
|
self::errorFields($result)
|
|
);
|
|
self::assertStringContainsString('nicht geladen', $error['message']);
|
|
$this->assertTempFilesRemoved();
|
|
}
|
|
|
|
public function testConfirmInstallsFromTheCheckedTempFilesAndLogsTheChecksums(): void
|
|
{
|
|
$installer = self::installer();
|
|
$installer->expects('install')->once()
|
|
->with('Inter Display', Mockery::type('array'), '400,700', 'serif')
|
|
->andReturnUsing(function (string $name, array $files): array {
|
|
$this->installedFrom = array_map('file_get_contents', $files);
|
|
|
|
return array_keys($files);
|
|
});
|
|
$log = new FakeChangeLog(7);
|
|
|
|
$result = $this->ability($installer, $log)->execute(
|
|
self::input(['generic_family' => 'serif', 'confirm' => true])
|
|
);
|
|
|
|
self::assertSame(['woff2' => self::WOFF2, 'ttf' => self::TTF], $this->installedFrom);
|
|
self::assertTrue($result['ok']);
|
|
self::assertSame('Inter Display', $result['font']);
|
|
self::assertSame(['woff2', 'ttf'], array_column($result['files'], 'format'));
|
|
self::assertSame(7, $result['log_id']);
|
|
self::assertSame([['id' => 'Inter Display', 'type' => 'divi_font', 'action' => 'created',
|
|
'label' => 'Inter Display']], $result['changed']);
|
|
self::assertCount(1, $log->records);
|
|
self::assertSame('lit/divi-fonts-upload', $log->records[0]['ability']);
|
|
self::assertSame('divi_font', $log->records[0]['object_type']);
|
|
self::assertSame('Inter Display', $log->records[0]['object_id']);
|
|
self::assertStringContainsString('woff2 ' . hash('sha256', self::WOFF2), $log->records[0]['summary']);
|
|
self::assertStringContainsString('ttf ' . hash('sha256', self::TTF), $log->records[0]['summary']);
|
|
self::assertSame(2, count($this->fetches), 'downloaded and checked again for the install');
|
|
$this->assertTempFilesRemoved();
|
|
}
|
|
|
|
public function testWeightsAllArePassedOn(): void
|
|
{
|
|
$installer = self::installer();
|
|
$installer->expects('install')->once()->with('Inter Display', Mockery::type('array'), 'all', 'sans-serif')
|
|
->andReturn(['woff2', 'ttf']);
|
|
|
|
$result = $this->ability($installer, new FakeChangeLog())->execute(
|
|
self::input(['weights' => 'all', 'confirm' => true])
|
|
);
|
|
|
|
self::assertSame('all', $result['weights']);
|
|
}
|
|
|
|
public function testAPartialInstallIsRemovedAgain(): void
|
|
{
|
|
$installer = self::installer();
|
|
$installer->allows('install')->andReturn(['woff2']);
|
|
$installer->expects('remove')->once()->with('Inter Display');
|
|
|
|
try {
|
|
$this->ability($installer)->execute(self::input(['confirm' => true]));
|
|
self::fail('WriteFailed expected');
|
|
} catch (WriteFailed $e) {
|
|
self::assertTrue($e->rolledBack);
|
|
$error = self::assertToolError($e->toError(), 'write_failed');
|
|
self::assertStringContainsString('ttf', $error['message']);
|
|
}
|
|
$this->assertTempFilesRemoved();
|
|
}
|
|
|
|
public function testAnInstallThatStoredNothingIsACleanRollback(): void
|
|
{
|
|
$installer = self::installer();
|
|
$installer->allows('install')->andReturn([]);
|
|
$installer->expects('remove')->never();
|
|
$log = FakeChangeLog::partialsOnly();
|
|
|
|
try {
|
|
$this->ability($installer, $log)->execute(self::input(['confirm' => true]));
|
|
self::fail('WriteFailed expected');
|
|
} catch (WriteFailed $e) {
|
|
self::assertTrue($e->rolledBack);
|
|
$error = self::assertToolError($e->toError(), 'write_failed');
|
|
self::assertStringContainsString('nichts gespeichert', $error['message']);
|
|
}
|
|
self::assertSame([], $log->partials);
|
|
}
|
|
|
|
public function testAPartialInstallThatCannotBeRemovedIsAPartialWrite(): void
|
|
{
|
|
$installer = self::installer();
|
|
$installer->allows('install')->andReturn(['woff2']);
|
|
$installer->allows('remove')->andThrow(new RuntimeException('font_remove refused: Font Does not Exist.'));
|
|
$log = FakeChangeLog::partialsOnly();
|
|
|
|
try {
|
|
$this->ability($installer, $log)->execute(self::input(['confirm' => true]));
|
|
self::fail('WriteFailed expected');
|
|
} catch (WriteFailed $e) {
|
|
self::assertFalse($e->rolledBack);
|
|
$error = self::assertToolError($e->toError(), 'partial_write');
|
|
self::assertStringContainsString('„Inter Display“', $error['message']);
|
|
}
|
|
self::assertCount(1, $log->partials);
|
|
self::assertSame('divi_font', $log->partials[0]['object_type']);
|
|
}
|
|
|
|
public function testARefusedInstallIsAWriteFailureThatLeavesNothing(): void
|
|
{
|
|
$installer = self::installer();
|
|
$installer->allows('install')->andThrow(new RuntimeException('font_add refused: kaputt'));
|
|
$installer->expects('remove')->never();
|
|
|
|
try {
|
|
$this->ability($installer)->execute(self::input(['confirm' => true]));
|
|
self::fail('WriteFailed expected');
|
|
} catch (WriteFailed $e) {
|
|
self::assertTrue($e->rolledBack);
|
|
self::assertStringNotContainsString('kaputt', Errors::clientMessage($e->toError()));
|
|
}
|
|
$this->assertTempFilesRemoved();
|
|
}
|
|
|
|
public function testALockFromTheInstallerPropagatesInsteadOfBecomingAWriteFailure(): void
|
|
{
|
|
$locked = new DiviLocked(
|
|
ReleaseMatrices::uniform(ReleaseState::Incompatible, '5.3.0', ['x']),
|
|
[DiviFeature::FontUpload]
|
|
);
|
|
$installer = self::installer();
|
|
$installer->allows('install')->andThrow($locked);
|
|
$installer->expects('remove')->never();
|
|
|
|
try {
|
|
$this->ability($installer)->execute(self::input(['confirm' => true]));
|
|
self::fail('DiviLocked expected');
|
|
} catch (DiviLocked $e) {
|
|
self::assertSame($locked, $e);
|
|
}
|
|
$this->assertTempFilesRemoved();
|
|
}
|
|
}
|