diff --git a/src/Compatibility/ThrowawayPost.php b/src/Compatibility/ThrowawayPost.php new file mode 100644 index 0000000..e3ae428 --- /dev/null +++ b/src/Compatibility/ThrowawayPost.php @@ -0,0 +1,133 @@ + $exercise Gets the ID of the draft, returns the problems found. + * @return list Fixed German problem texts; [] = green. + */ + public function run(string $postType, callable $exercise): array + { + $id = 0; + $problems = []; + try { + $id = $this->create($postType); + $problems = $id > 0 ? $exercise($id) : [$this->createFailedText]; + } catch (Throwable $e) { + Log::failure($this->logContext, $e); + $problems = [$this->abortedText]; + } finally { + if ($id > 0 && ! self::delete($id)) { + $problems[] = sprintf($this->deleteFailedFormat, $id); + } + } + + return $problems; + } + + /** + * @param array|string $postTypes + * @return list IDs of entries a dead check left behind, deleted now. Only drafts whose title starts with + * the prefix are considered. + */ + public function removeOrphans(array|string $postTypes): array + { + $posts = get_posts([ + 'post_type' => $postTypes, + 'post_status' => 'draft', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- a handful of marked entries at most. + 'meta_key' => $this->marker, + // post_modified_gmt, not post_date_gmt: an edit by the check keeps it current. A literal GMT timestamp, + // because a relative string would be read in the site's time zone against a GMT column. + 'date_query' => [[ + 'before' => gmdate('Y-m-d H:i:s', time() - self::ORPHAN_AGE_SECONDS), + 'column' => 'post_modified_gmt', + ]], + 'posts_per_page' => 20, + 'no_found_rows' => true, + 'suppress_filters' => true, + ]); + + $removed = []; + foreach (is_array($posts) ? $posts : [] as $post) { + if (! $post instanceof WP_Post || ! str_starts_with((string) $post->post_title, $this->titlePrefix)) { + continue; + } + if (self::delete((int) $post->ID)) { + $removed[] = (int) $post->ID; + } + } + + return $removed; + } + + /** @return int The new ID; 0 when WordPress refused (logged). */ + private function create(string $postType): int + { + $id = wp_insert_post([ + 'post_type' => $postType, + 'post_status' => 'draft', + // Without it a draft keeps post_date_gmt and post_modified_gmt at 0000-00-00 and looks ancient. + 'post_date_gmt' => gmdate('Y-m-d H:i:s'), + 'post_title' => $this->titlePrefix . ' ' . gmdate('Y-m-d H:i'), + 'post_content' => '', + 'meta_input' => [$this->marker => '1'], + ], true); + if ($id instanceof WP_Error) { + Log::failure($this->logContext, 'could not create the entry: ' . $id->get_error_message()); + + return 0; + } + if ((int) $id <= 0) { + Log::failure($this->logContext, 'could not create the entry: no ID'); + } + + return max(0, (int) $id); + } + + private static function delete(int $id): bool + { + return wp_delete_post($id, true) instanceof WP_Post; + } +} diff --git a/src/Integrations/Divi/Release/WriteProbe.php b/src/Integrations/Divi/Release/WriteProbe.php index 6d7ad40..5b467d7 100644 --- a/src/Integrations/Divi/Release/WriteProbe.php +++ b/src/Integrations/Divi/Release/WriteProbe.php @@ -5,11 +5,10 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Release; use LIT\MCP\Abilities\Errors; +use LIT\MCP\Compatibility\ThrowawayPost; use LIT\MCP\Integrations\Divi\Pages\DiviPages; use LIT\MCP\Integrations\WordPress\Content\ContentComposer; use LIT\MCP\Integrations\WordPress\Render\PageRenderer; -use LIT\MCP\Log; -use Throwable; use WP_Error; use WP_Post; @@ -20,7 +19,7 @@ defined('ABSPATH') || exit; * → Serializer → DiviSaver, with the release lock opened for this page only), checked (content, builder flags, * Divi renders every module without a PHP warning from Divi's files) and always deleted for good – in finally, * not to the trash, with its revisions and meta. Pages a dead check left behind carry MARKER; removeOrphans() - * deletes them. No change log entry. Not final so CompatibilityCheck can be tested with a mock. + * deletes them (both in ThrowawayPost). No change log entry. Not final so CompatibilityCheck can be tested with a mock. */ class WriteProbe { @@ -70,9 +69,6 @@ class WriteProbe 'litcheck-accordion', 'litcheck-tab', 'litcheck-icon-list', ]; - /** Only pages older than this are orphans – a running check's page is younger. */ - private const ORPHAN_AGE = '10 minutes ago'; - /** * @param string|null $diviDir PHP warnings from files below it fail the check; null = DiviEnvironment::themeDir(). */ @@ -86,66 +82,28 @@ class WriteProbe /** @return list German problems; [] = green. */ public function run(): array { - $id = wp_insert_post([ - 'post_type' => 'page', - 'post_status' => 'draft', - 'post_title' => self::TITLE . ' ' . gmdate('Y-m-d H:i'), - 'post_content' => '', - 'meta_input' => [self::MARKER => '1'], - ], true); - if ($id instanceof WP_Error || (int) $id <= 0) { - return ['Die Testseite konnte nicht angelegt werden: ' - . ($id instanceof WP_Error ? $id->get_error_message() : 'keine ID')]; - } - $id = (int) $id; - - try { - $problems = $this->exercise($id); - } catch (Throwable $e) { - // The report reaches MCP clients through the lock reason; the exception text stays in the log. - Log::failure('divi write test', $e); - $problems = ['Schreibtest abgebrochen (Details im PHP-Error-Log).']; - } finally { - if (! self::delete($id)) { - $problems[] = sprintf( - 'Die Testseite %d konnte nicht gelöscht werden; bitte unter Seiten endgültig löschen.', - $id - ); - } - } - - return $problems; + return self::throwaway()->run('page', fn (int $id): array => $this->exercise($id)); } /** - * @return list IDs of check pages a dead check left behind, deleted now. Only drafts whose title starts - * with self::TITLE are considered – the marker meta alone is not enough (it is only a query hint here). + * @return list IDs of check pages a dead check left behind, deleted now (ThrowawayPost::removeOrphans(): + * marked drafts with the check title only). */ public static function removeOrphans(): array { - $posts = get_posts([ - 'post_type' => 'page', - 'post_status' => 'draft', - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- a handful of marked pages at most. - 'meta_key' => self::MARKER, - 'date_query' => [['before' => self::ORPHAN_AGE, 'column' => 'post_date_gmt']], - 'fields' => 'all', - 'posts_per_page' => 20, - 'no_found_rows' => true, - 'suppress_filters' => true, - ]); + return self::throwaway()->removeOrphans('page'); + } - $removed = []; - foreach (is_array($posts) ? $posts : [] as $post) { - if (! $post instanceof WP_Post || ! str_starts_with((string) $post->post_title, self::TITLE)) { - continue; - } - if (self::delete((int) $post->ID)) { - $removed[] = (int) $post->ID; - } - } - - return $removed; + private static function throwaway(): ThrowawayPost + { + return new ThrowawayPost( + self::MARKER, + self::TITLE, + 'Schreibtest abgebrochen (Details im PHP-Error-Log).', + 'Die Testseite konnte nicht angelegt werden (Details im PHP-Error-Log).', + 'Die Testseite %d konnte nicht gelöscht werden; bitte unter Seiten endgültig löschen.', + 'divi write test' + ); } /** @return list */ @@ -219,9 +177,4 @@ class WriteProbe return [$html, $warnings]; } - - private static function delete(int $id): bool - { - return wp_delete_post($id, true) instanceof WP_Post; - } } diff --git a/tests/Unit/Compatibility/ThrowawayPostTest.php b/tests/Unit/Compatibility/ThrowawayPostTest.php new file mode 100644 index 0000000..43f576f --- /dev/null +++ b/tests/Unit/Compatibility/ThrowawayPostTest.php @@ -0,0 +1,247 @@ + */ + private array $deleted = []; + + /** @var array|null The wp_insert_post arguments. */ + private ?array $inserted = null; + + protected function setUp(): void + { + parent::setUp(); + $this->deleted = []; + $this->inserted = null; + Functions\when('wp_insert_post')->alias(function (array $post): int { + $this->inserted = $post; + + return 41; + }); + Functions\when('wp_delete_post')->alias(function (int $id, bool $force): WP_Post { + self::assertTrue($force, 'never to the trash'); + $this->deleted[] = $id; + + return new WP_Post(['ID' => $id]); + }); + } + + private function throwaway(): ThrowawayPost + { + return new ThrowawayPost('_marker', '[Prefix]', self::ABORTED, self::CREATE_FAILED, self::DELETE_FAILED, 'ctx'); + } + + /** Captures what Log writes to the PHP error log while $run runs. */ + private function logged(Closure $run): string + { + $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $log); + try { + $run(); + + return (string) file_get_contents($log); + } finally { + ini_set('error_log', (string) $previous); + unlink($log); + } + } + + public function testRunsTheExerciseOnAMarkedDraftAndDeletesItForGood(): void + { + $seen = null; + $problems = $this->throwaway()->run('lit_person', static function (int $id) use (&$seen): array { + $seen = $id; + + return ['rot']; + }); + + self::assertSame(['rot'], $problems); + self::assertSame(41, $seen); + self::assertSame([41], $this->deleted); + self::assertSame('lit_person', $this->inserted['post_type']); + self::assertSame('draft', $this->inserted['post_status']); + self::assertStringStartsWith('[Prefix] ', $this->inserted['post_title']); + self::assertSame(['_marker' => '1'], $this->inserted['meta_input']); + } + + /** wp_insert_post leaves post_date_gmt AND post_modified_gmt at 0000-00-00 for a draft without a GMT date. */ + public function testTheDraftGetsAGmtDateSoItsAgeIsKnown(): void + { + $this->throwaway()->run('page', static fn (): array => []); + + self::assertMatchesRegularExpression('/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/', $this->inserted['post_date_gmt']); + self::assertEqualsWithDelta(time(), strtotime($this->inserted['post_date_gmt'] . ' UTC'), 5); + } + + public function testGreenIsAnEmptyList(): void + { + self::assertSame([], $this->throwaway()->run('page', static fn (): array => [])); + } + + public function testAnExceptionBecomesTheFixedTextAndALogLineAndTheEntryIsDeleted(): void + { + $problems = []; + $logged = $this->logged(function () use (&$problems): void { + $problems = $this->throwaway()->run('page', static function (): never { + throw new RuntimeException('Innen kaputt'); + }); + }); + + self::assertSame([self::ABORTED], $problems); + self::assertStringContainsString('ctx failed', $logged); + self::assertStringContainsString('Innen kaputt', $logged); + self::assertSame([41], $this->deleted); + } + + public function testAnInsertErrorIsAFixedTextAndTheMessageGoesToTheLogOnly(): void + { + Functions\when('wp_insert_post')->justReturn(new WP_Error('x', 'Datenbank voll')); + $ran = false; + $problems = []; + $logged = $this->logged(function () use (&$problems, &$ran): void { + $problems = $this->throwaway()->run('page', static function () use (&$ran): array { + $ran = true; + + return []; + }); + }); + + self::assertSame([self::CREATE_FAILED], $problems); + self::assertStringContainsString('Datenbank voll', $logged); + self::assertFalse($ran); + self::assertSame([], $this->deleted); + } + + public function testAnInsertWithoutAnIdIsTheSameFixedText(): void + { + Functions\when('wp_insert_post')->justReturn(0); + + $problems = $this->logged(fn () => self::assertSame( + [self::CREATE_FAILED], + $this->throwaway()->run('page', static fn (): array => []) + )); + + self::assertStringContainsString('ctx', $problems); + self::assertSame([], $this->deleted); + } + + public function testAnInsertThatThrowsIsAbortedAndNothingIsDeleted(): void + { + Functions\when('wp_insert_post')->alias(static function (): never { + throw new RuntimeException('Hook kaputt'); + }); + $problems = []; + $logged = $this->logged(function () use (&$problems): void { + $problems = $this->throwaway()->run('page', static fn (): array => []); + }); + + self::assertSame([self::ABORTED], $problems); + self::assertStringContainsString('Hook kaputt', $logged); + self::assertSame([], $this->deleted); + } + + public function testAnEntryThatCannotBeDeletedIsReportedNextToTheProblems(): void + { + Functions\when('wp_delete_post')->justReturn(false); + + $problems = $this->throwaway()->run('page', static fn (): array => ['rot']); + + self::assertSame(['rot', 'Eintrag 41 nicht gelöscht'], $problems); + } + + /** + * Drafts have post_date_gmt 0000-00-00 00:00:00: only post_modified_gmt tells how old they are. A fake + * get_posts applies the column of the date query to the rows, like WP_Query would. + * + * @param list $rows + */ + private function fakeQuery(array $rows, ?array &$query = null): void + { + Functions\when('get_posts')->alias(static function (array $args) use ($rows, &$query): array { + $query = $args; + $filter = $args['date_query'][0]; + $found = array_filter( + $rows, + static fn (array $row): bool => $row[$filter['column']] < $filter['before'] + ); + + return array_map(static fn (array $row): WP_Post => new WP_Post($row), array_values($found)); + }); + } + + /** @return array{ID: int, post_title: string, post_date_gmt: string, post_modified_gmt: string} */ + private static function draft(int $id, int $ageSeconds, string $title = '[Prefix] 2026-11-06 10:00'): array + { + return [ + 'ID' => $id, + 'post_title' => $title, + 'post_date_gmt' => '0000-00-00 00:00:00', + 'post_modified_gmt' => gmdate('Y-m-d H:i:s', time() - $ageSeconds), + ]; + } + + public function testARunningChecksDraftIsKeptEvenThoughItsDateIsZero(): void + { + $this->fakeQuery([self::draft(5, 60)]); + + self::assertSame([], $this->throwaway()->removeOrphans('page')); + self::assertSame([], $this->deleted); + } + + public function testADraftOlderThanElevenMinutesIsRemoved(): void + { + $query = null; + $this->fakeQuery([self::draft(5, 660), self::draft(6, 60)], $query); + + self::assertSame([5], $this->throwaway()->removeOrphans('page')); + self::assertSame([5], $this->deleted); + self::assertSame('post_modified_gmt', $query['date_query'][0]['column']); + self::assertMatchesRegularExpression('/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/', $query['date_query'][0]['before']); + } + + public function testTheQueryIsNarrowedToMarkedDraftsOfTheGivenTypes(): void + { + $query = null; + $this->fakeQuery([], $query); + + $this->throwaway()->removeOrphans(['post', 'lit_person']); + self::assertSame(['post', 'lit_person'], $query['post_type']); + self::assertSame('draft', $query['post_status']); + self::assertSame('_marker', $query['meta_key']); + + $this->throwaway()->removeOrphans('page'); + self::assertSame('page', $query['post_type']); + } + + public function testAMarkedDraftWithAnotherTitleIsKept(): void + { + $this->fakeQuery([self::draft(5, 900, 'Echte Person')]); + + self::assertSame([], $this->throwaway()->removeOrphans('page')); + self::assertSame([], $this->deleted); + } + + public function testAnOrphanThatCannotBeDeletedIsNotReportedAsRemoved(): void + { + $this->fakeQuery([self::draft(5, 900)]); + Functions\when('wp_delete_post')->justReturn(false); + + self::assertSame([], $this->throwaway()->removeOrphans('page')); + } +} diff --git a/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php b/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php index 16ed2b7..a1f3e42 100644 --- a/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php +++ b/tests/Unit/Integrations/Divi/Release/WriteProbeTest.php @@ -156,28 +156,28 @@ final class WriteProbeTest extends TestCase self::assertSame(['_lit_mcp_check_page' => '1'], $captured['meta_input']); } - public function testRemovesOrphanedCheckPages(): void + public function testRemovesOrphanedCheckPagesThroughTheSharedCleanup(): void { Functions\expect('get_posts')->once()->with(Mockery::on(static fn (array $query): bool => $query['meta_key'] === '_lit_mcp_check_page' + && $query['post_type'] === 'page' && $query['post_status'] === 'draft' - && $query['fields'] === 'all' - && ($query['date_query'][0]['before'] ?? null) === '10 minutes ago'))->andReturn([ + && ($query['date_query'][0]['column'] ?? null) === 'post_modified_gmt'))->andReturn([ new WP_Post(['ID' => 51, 'post_title' => WriteProbe::TITLE . ' 2026-01-01 00:00']), - new WP_Post(['ID' => 52, 'post_title' => WriteProbe::TITLE . ' 2026-01-02 00:00']), + new WP_Post(['ID' => 60, 'post_title' => 'Ein anderer Entwurf']), ]); - self::assertSame([51, 52], WriteProbe::removeOrphans()); - self::assertSame([51, 52], $this->deleted); + self::assertSame([51], WriteProbe::removeOrphans()); + self::assertSame([51], $this->deleted); } - public function testKeepsAMarkedDraftWhoseTitleDoesNotMatch(): void + public function testAPageThatCannotBeCreatedIsAFixedTextWithoutTheWordPressMessage(): void { - Functions\when('get_posts')->justReturn([ - new WP_Post(['ID' => 60, 'post_title' => 'Ein anderer Entwurf']), - ]); + Functions\when('wp_insert_post')->justReturn(new \WP_Error('x', 'Datenbank voll')); - self::assertSame([], WriteProbe::removeOrphans()); + $problems = (new WriteProbe($this->composer(), $this->renderer(''), '/nonexistent-divi'))->run(); + + self::assertSame(['Die Testseite konnte nicht angelegt werden (Details im PHP-Error-Log).'], $problems); self::assertSame([], $this->deleted); } }