diff --git a/src/Integrations/Divi/Design/DiviFontsUploadAbility.php b/src/Integrations/Divi/Design/DiviFontsUploadAbility.php new file mode 100644 index 0000000..15fbdb4 --- /dev/null +++ b/src/Integrations/Divi/Design/DiviFontsUploadAbility.php @@ -0,0 +1,449 @@ + 'string', 'description' => 'Öffentliche http(s)-URL der Datei.']; + + return [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'description' => 'Name der Schrift (Familienname in Divi).'], + 'files' => [ + 'type' => 'object', + 'properties' => array_fill_keys(FontInstaller::FORMATS, $url), + 'additionalProperties' => false, + ], + 'weights' => [ + 'type' => ['string', 'array'], + 'items' => ['type' => 'integer'], + 'description' => '"all" oder Liste der Schriftstärken aus 100, 200, … 900.', + ], + 'generic_family' => ['type' => 'string', 'enum' => self::FAMILIES], + 'confirm' => ['type' => 'boolean'], + ], + 'required' => ['name', 'files', 'weights'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return ToolResult::schema([ + 'font' => ['type' => 'string'], + 'files' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'format' => ['type' => 'string'], + 'bytes' => ['type' => 'integer'], + 'sha256' => ['type' => 'string'], + ], + ], + ], + 'weights' => ['type' => 'string'], + 'generic_family' => ['type' => 'string'], + ], ['font', 'files', 'weights', 'generic_family']); + } + + public function annotations(): array + { + return Annotations::write(false); + } + + public function isDirect(): bool + { + return false; + } + + /** FontInstaller and the font lists of FontCatalog (name collision) both serve font-upload (ruling W1). */ + public function diviFeatures(array $input): array + { + return [DiviFeature::FontUpload]; + } + + public function permission(array $input): bool|WP_Error + { + return DesignRights::fonts($this->catalog) ?? true; + } + + /** @throws WriteFailed */ + public function execute(array $input): mixed + { + $refusal = DesignRights::fonts($this->catalog); + if ($refusal !== null) { + return $refusal; + } + + $errors = []; + foreach (array_diff(array_map('strval', array_keys($input)), self::KEYS) as $key) { + $errors[$key] = 'unbekanntes Feld'; + } + $name = is_string($input['name'] ?? null) ? $input['name'] : ''; + $taken = FontNameRules::taken($name, $this->catalog->reserved()); + $nameProblem = $taken === null + ? FontNameRules::check($name, [], $this->installer->nameMaxLength()) + : null; + if ($nameProblem !== null) { + $errors['name'] = $nameProblem; + } + $urls = $this->urls($input['files'] ?? null, $errors); + $weights = self::weights($input['weights'] ?? null, $errors); + $family = $input['generic_family'] ?? self::DEFAULT_FAMILY; + if (! in_array($family, self::FAMILIES, true)) { + $errors['generic_family'] = 'eine von: ' . implode(', ', self::FAMILIES); + } + $confirm = $input['confirm'] ?? false; + if (! is_bool($confirm)) { + $errors['confirm'] = 'true oder false'; + } + if ($errors !== []) { + return Errors::invalidInput('Ungültige Angaben zur Schrift; nichts wurde gespeichert.', $errors); + } + + if ($taken !== null) { + return Errors::make( + 'conflict', + sprintf('Eine Schrift „%s“ gibt es schon; nichts wurde gespeichert.', $taken), + 'Einen anderen Namen wählen (Groß-/Kleinschreibung zählt nicht) oder die vorhandene Schrift verwenden.', + 409 + ); + } + + return $this->upload($name, $urls, $weights, (string) $family, $confirm === true); + } + + /** + * @param array $errors + * @return array Format => URL, in the order of the input. + */ + private function urls(mixed $files, array &$errors): array + { + if (! is_array($files) || $files === [] || array_is_list($files) || count($files) > 4) { + $errors['files'] = self::FILE_RULE; + + return []; + } + + $known = $this->installer->formats(); + $urls = []; + foreach ($files as $format => $url) { + $field = 'files.' . $format; + if (! in_array($format, FontInstaller::FORMATS, true)) { + $errors[$field] = 'unbekanntes Format; erlaubt: ' . implode(', ', FontInstaller::FORMATS); + } elseif (! in_array($format, $known, true)) { + $errors[$field] = 'dieses Format unterstützt Divi auf dieser Website nicht'; + } elseif (! is_string($url) || $url === '') { + $errors[$field] = 'URL als Text erwartet'; + } elseif (! $this->fetcher->allows($url)) { + $errors[$field] = 'nur öffentlich erreichbare http(s)-Adressen'; + } else { + $urls[$format] = $url; + } + } + + return $urls; + } + + /** + * @param array $errors + * @return string "all" or the sorted list as Divi stores it ("400,700"). + */ + private static function weights(mixed $weights, array &$errors): string + { + if ($weights === 'all') { + return 'all'; + } + + if ( + ! is_array($weights) || $weights === [] || ! array_is_list($weights) + || array_filter($weights, 'is_int') !== $weights + || array_diff($weights, self::WEIGHTS) !== [] || count(array_unique($weights)) !== count($weights) + ) { + $errors['weights'] = '"all" oder Liste verschiedener Schriftstärken aus ' + . implode(', ', self::WEIGHTS); + + return ''; + } + + sort($weights); + + return implode(',', $weights); + } + + /** + * Downloads and checks every file into temp files – for the dry run and again for the install – and deletes + * them on every path. + * + * @param array $urls + * @return array|WP_Error + * @throws WriteFailed + */ + private function upload(string $name, array $urls, string $weights, string $family, bool $confirm): array|WP_Error + { + AdminIncludes::load(); + $temps = []; + try { + $files = $this->download($name, $urls, $temps); + if ($files instanceof WP_Error) { + return $files; + } + + $report = array_values(array_map( + static fn (array $file): array => array_diff_key($file, ['path' => true]), + $files + )); + if (! $confirm) { + return Errors::confirmRequired( + sprintf( + 'Schrift „%s“ hochladen (%s): Sie steht danach auf der ganzen Website in Divi zur Auswahl ' + . '(Builder, Theme-Optionen, Schrift-Variablen). Nichts wurde gespeichert (Probelauf).', + $name, + implode(', ', array_keys($files)) + ), + ['font' => $name, 'files' => $report, 'weights' => $weights, 'generic_family' => $family] + ); + } + + $paths = array_map(static fn (array $file): string => $file['path'], $files); + $this->install($name, $paths, $weights, $family); + } finally { + foreach ($temps as $tmp) { + if (file_exists($tmp)) { + wp_delete_file($tmp); + } + } + } + + $summary = sprintf('Schrift „%s“ hochgeladen (%s).', $name, implode(', ', array_map( + static fn (array $file): string => $file['format'] . ' ' . $file['sha256'], + $report + ))); + $logId = $this->log->record($this->name(), self::OBJECT_TYPE, $name, $summary); + + return ToolResult::ok(sprintf( + 'Schrift „%s“ hochgeladen; sie steht jetzt in Divi zur Auswahl (z. B. für lit/divi-theme-options-update).', + $name + )) + ->changed($name, self::OBJECT_TYPE, 'created', $name) + ->logId($logId) + ->with('font', $name) + ->with('files', $report) + ->with('weights', $weights) + ->with('generic_family', $family) + ->toArray(); + } + + /** + * @param array $urls + * @param list $temps Gets every temp file created (the caller deletes them). + * @return array|WP_Error + */ + private function download(string $name, array $urls, array &$temps): array|WP_Error + { + $max = min($this->limits->uploadBytes(), self::MAX_FILE_BYTES); + $files = []; + $problems = []; + $unreachable = false; + foreach ($urls as $format => $url) { + // fetch() does not check the URL itself: a refused URL is never requested (same answer as a failed one). + if (! $this->fetcher->allows($url)) { + $problems['files.' . $format] = 'nicht abrufbar'; + $unreachable = true; + continue; + } + $tmp = (string) wp_tempnam('lit-mcp-font'); + if ($tmp === '') { + return Errors::writeFailed('Die temporäre Datei konnte nicht angelegt werden.'); + } + $temps[] = $tmp; + if ($this->fetcher->fetch($url, $tmp, $max, 'font upload') === null) { + $problems['files.' . $format] = 'nicht abrufbar'; + $unreachable = true; + continue; + } + + $problem = $this->problemOf($tmp, $format, $name . '.' . $format, $max); + if ($problem !== null) { + $problems['files.' . $format] = $problem; + continue; + } + $files[$format] = [ + 'format' => $format, + 'bytes' => (int) filesize($tmp), + 'sha256' => (string) hash_file('sha256', $tmp), + 'path' => $tmp, + ]; + } + + if ($unreachable) { + return Errors::make( + 'font_fetch_failed', + 'Mindestens eine Schriftdatei konnte nicht geladen werden; nichts wurde gespeichert.', + 'URLs prüfen; nur öffentlich erreichbare Dateien lassen sich laden.', + 400, + ['fields' => $problems] + ); + } + + return $problems === [] ? $files : Errors::make( + 'font_invalid', + 'Mindestens eine Datei ist keine gültige Schriftdatei ihres Formats; nichts wurde gespeichert.', + sprintf( + 'Je Format die passende Datei angeben (woff2, woff, ttf oder otf, höchstens %d MB je Datei).', + intdiv($max, 1024 * 1024) + ), + 400, + ['fields' => $problems] + ); + } + + /** The problem of a downloaded file (size, magic bytes, Divi's content check), null when it is fine. */ + private function problemOf(string $tmp, string $format, string $filename, int $max): ?string + { + clearstatcache(true, $tmp); + $bytes = (int) filesize($tmp); + if ($bytes === 0) { + return 'leere Datei'; + } + if ($bytes > $max) { + return sprintf('größer als %d MB', intdiv($max, 1024 * 1024)); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local temp file. + $header = (string) file_get_contents($tmp, false, null, 0, FontSignature::HEADER_BYTES); + $detected = FontSignature::detect($header); + if ($detected === null) { + return 'Inhalt ist keine Schriftdatei'; + } + if ($detected !== $format) { + return sprintf('Inhalt ist eine %s-Datei, keine %s-Datei', $detected, $format); + } + + return $this->installer->accepts($tmp, $filename) ? null : 'von Divi nicht als Schriftdatei erkannt'; + } + + /** + * @param array $paths Format => checked temp file. + * @throws WriteFailed When Divi refuses the font or stores only some files (removed again if possible). + */ + private function install(string $name, array $paths, string $weights, string $family): void + { + try { + $installed = $this->installer->install($name, $paths, $weights, $family); + } catch (RuntimeException $e) { + // FontInstaller stores nothing when it throws. + throw WriteFailed::rolledBack(self::REASON, $e, 'font upload: ' . $e->getMessage()); + } + + $missing = array_values(array_diff(array_keys($paths), $installed)); + if ($missing === []) { + return; + } + + $detail = sprintf( + 'font upload: Divi stored %s of %s', + implode(',', $installed), + implode(',', array_keys($paths)) + ); + $failures = Rollback::attempt(fn () => $this->installer->remove($name)); + if ($failures === []) { + throw WriteFailed::rolledBack( + self::REASON . sprintf(' Divi hat %s nicht gespeichert.', implode(', ', $missing)), + detail: $detail + ); + } + + $this->log->recordPartial( + $this->name(), + self::OBJECT_TYPE, + $name, + sprintf('Schrift „%s“ nur teilweise hochgeladen, nicht entfernt (%s)', $name, $failures[0]) + ); + throw WriteFailed::partial( + self::REASON, + detail: $detail . '; remove failed: ' . $failures[0], + state: sprintf('Die Schrift „%s“ ist ohne %s in Divi gespeichert.', $name, implode(', ', $missing)), + hint: 'Den Aufruf nicht wiederholen; die Schrift im Divi-Builder (Schriften verwalten) prüfen und ggf. ' + . 'löschen und dem Nutzer Bescheid geben.' + ); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/DiviFontsUploadAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviFontsUploadAbilityTest.php new file mode 100644 index 0000000..ab3fc53 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DiviFontsUploadAbilityTest.php @@ -0,0 +1,495 @@ + */ + private array $tempFiles = []; + + /** @var array URL => body the fake fetcher writes (null = download fails). */ + private array $bodies = []; + + /** @var list */ + private array $fetches = []; + + /** @var array 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 */ + 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 + { + $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 $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 */ + 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 */ + 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'), 'fetch() skipped the refused URL'); + $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] = '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 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(); + } +}