post = new WP_Post([ 'ID' => 5, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Team', 'post_name' => 'team', 'post_content' => '

Alt

', ]); Functions\when('get_post')->justReturn($this->post); Functions\when('current_user_can')->justReturn(true); } private function schema(): ContentSchema { $schema = Mockery::mock(ContentSchema::class); $schema->allows('isManaged')->andReturn(true); return $schema; } private function ability(PostWriter $writer, ?ChangeLog $log = null): ContentUpdateAbility { return new ContentUpdateAbility($this->schema(), $writer, $log ?? Mockery::mock(ChangeLog::class)); } public function testRevIsRequired(): void { $writer = Mockery::mock(PostWriter::class); Functions\expect('wp_update_post')->never(); self::assertContains('rev', $this->ability($writer)->inputSchema()['required']); self::assertSame( 'lit_rev_mismatch', $this->ability($writer)->execute(['post_id' => 5, 'title' => 'Neu'])->get_error_code() ); } public function testStaleRevIsRejectedWithoutWriting(): void { Functions\expect('wp_update_post')->never(); $result = $this->ability(Mockery::mock(PostWriter::class)) ->execute(['post_id' => 5, 'rev' => 'stale', 'title' => 'Neu']); self::assertSame('lit_rev_mismatch', $result->get_error_code()); self::assertStringContainsString('lit-content-get', $result->get_error_data()['hint']); } public function testAContentOnlyRevIsStaleBecauseTitleAndStatusCountToo(): void { $result = $this->ability(Mockery::mock(PostWriter::class)) ->execute(['post_id' => 5, 'rev' => Rev::of('

Alt

'), 'title' => 'Neu']); self::assertSame('lit_rev_mismatch', $result->get_error_code()); } public function testRawContentOnDiviPagesIsUnsupported(): void { Functions\when('get_post_meta')->justReturn('on'); $writer = Mockery::mock(PostWriter::class); $writer->expects('prepare')->never(); $result = $this->ability($writer) ->execute(['post_id' => 5, 'rev' => Rev::ofPost($this->post), 'content' => '

Neu

']); self::assertSame('lit_unsupported', $result->get_error_code()); self::assertStringContainsString('lit-divi-outline', $result->get_error_message()); } public function testNothingToChangeIsAValidationError(): void { $writer = Mockery::mock(PostWriter::class); $writer->allows('postData')->andReturn([]); $writer->allows('hasExtras')->andReturn(false); $result = $this->ability($writer)->execute(['post_id' => 5, 'rev' => Rev::ofPost($this->post)]); self::assertSame('lit_validation_failed', $result->get_error_code()); } public function testUpdateKeepsTheStatusAndCapturesARestorePointFirst(): void { $order = []; $prepared = ['fields' => [], 'terms' => [], 'warnings' => []]; $input = ['post_id' => 5, 'rev' => Rev::ofPost($this->post), 'title' => 'Unser Team']; $writer = Mockery::mock(PostWriter::class); $writer->allows('postData')->andReturn(['post_title' => 'Unser Team']); $writer->allows('hasExtras')->andReturn(false); $writer->allows('prepare')->with('page', $input, false)->andReturn($prepared); $writer->allows('snapshot')->andReturn(['fields' => [], 'terms' => []]); $writer->expects('writeExtras')->with(5, 'page', $prepared, $input); Functions\expect('wp_save_post_revision')->once()->with(5)->andReturnUsing(static function () use (&$order) { $order[] = 'revision'; return 811; }); Functions\when('wp_get_post_revisions')->justReturn([811 => new WP_Post(['ID' => 811])]); Functions\expect('wp_update_post')->once()->with(['post_title' => 'Unser Team', 'ID' => 5], true) ->andReturnUsing(static function () use (&$order): int { $order[] = 'update'; return 5; }); Actions\expectDone('lit_mcp_after_save')->once()->with(5, 'lit/content-update', $input); Functions\when('get_preview_post_link')->justReturn('https://kunde.de/team/'); Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit'); $log = Mockery::mock(ChangeLog::class); $log->expects('record')->with('lit/content-update', 'page', '5', Mockery::type('string'), 811)->andReturn(4711); $result = $this->ability($writer, $log)->execute($input); self::assertSame(['revision', 'update'], $order); self::assertSame(811, $result['revision_id']); self::assertSame(4711, $result['log_id']); self::assertSame('updated', $result['changed'][0]['action']); self::assertSame(Rev::ofPost($this->post), $result['rev']); self::assertStringContainsString('Status unverändert: publish', $result['summary']); } /** * @param array $data * @return array{PostWriter&\Mockery\MockInterface, array} */ private function failingWriter(array $data, bool $restoreWorks = true): array { $input = ['post_id' => 5, 'rev' => Rev::ofPost($this->post), 'title' => 'Unser Team', 'fields' => ['a' => 1]]; $snapshot = ['fields' => ['a' => 0], 'terms' => []]; $writer = Mockery::mock(PostWriter::class); $writer->allows('postData')->andReturn($data); $writer->allows('prepare')->andReturn(['fields' => ['a' => 1], 'terms' => [], 'warnings' => []]); $writer->expects('snapshot')->with(5, 'page', Mockery::type('array'), $input)->andReturn($snapshot); $writer->allows('writeExtras')->andThrow(new RuntimeException('meta kaputt')); $restore = $writer->expects('restore')->with(5, 'page', $snapshot); if (! $restoreWorks) { $restore->andThrow(new RuntimeException('restore kaputt')); } $writer->expects('deleteCreatedTerms'); Functions\when('wp_save_post_revision')->justReturn(811); Functions\when('wp_get_post_revisions')->justReturn([811 => new WP_Post(['ID' => 811])]); Actions\expectDone('lit_mcp_after_save')->never(); return [$writer, $input]; } /** @param array $input */ private function executeExpectingFailure(PostWriter $writer, array $input, ?ChangeLog $log = null): WriteFailed { if ($log === null) { $log = Mockery::mock(ChangeLog::class); $log->expects('record')->never(); } try { $this->ability($writer, $log)->execute($input); } catch (WriteFailed $e) { self::assertStringContainsString('meta kaputt', $e->getMessage()); return $e; } self::fail('WriteFailed expected'); } /** @return list> Captured wp_update_post() calls. */ private function captureUpdates(array &$updates): void { Functions\when('wp_update_post')->alias(static function (array $data) use (&$updates): int { $updates[] = $data; return 5; }); } public function testFailingExtrasRestoreTheRevisionAndTheOldValues(): void { [$writer, $input] = $this->failingWriter(['post_title' => 'Unser Team', 'post_name' => 'unser-team']); $updates = []; $this->captureUpdates($updates); Functions\when('wp_revisions_enabled')->justReturn(true); Functions\expect('wp_restore_post_revision')->once()->with(811, ['post_title'])->andReturn(5); $failure = $this->executeExpectingFailure($writer, $input); self::assertTrue($failure->rolledBack); self::assertSame('lit_write_failed', $failure->toError()->get_error_code()); self::assertSame([ ['post_title' => 'Unser Team', 'post_name' => 'unser-team', 'ID' => 5], ['post_name' => 'team', 'ID' => 5], ], $updates); } public function testWithoutRevisionsTheOldColumnValuesIncludingTheOldTemplateAreWrittenBack(): void { [$writer, $input] = $this->failingWriter(['post_title' => 'Unser Team', 'page_template' => 'tpl-wide.php']); $template = 'tpl-alt.php'; $updates = []; Functions\when('get_page_template_slug')->alias(static function () use (&$template): string { return $template; }); Functions\when('wp_update_post')->alias(static function (array $data) use (&$updates, &$template): int { $updates[] = $data; $template = (string) ($data['page_template'] ?? $template); return 5; }); Functions\when('wp_revisions_enabled')->justReturn(false); Functions\expect('wp_restore_post_revision')->never(); $this->executeExpectingFailure($writer, $input); self::assertSame([ ['post_title' => 'Unser Team', 'page_template' => 'tpl-wide.php', 'ID' => 5], ['post_title' => 'Team', 'page_template' => 'tpl-alt.php', 'ID' => 5], ], $updates); } public function testAnUpdateThatThrowsStillRestoresTheRow(): void { [$writer, $input] = $this->failingWriter(['post_name' => 'unser-team']); $writer->shouldNotReceive('writeExtras'); $updates = []; Functions\when('wp_update_post')->alias(static function (array $data) use (&$updates): int { $updates[] = $data; if (count($updates) === 1) { throw new RuntimeException('meta kaputt im save_post-Hook'); } return 5; }); $failure = $this->executeExpectingFailure($writer, $input); self::assertTrue($failure->rolledBack); self::assertSame(['post_name' => 'team', 'ID' => 5], $updates[1]); } public function testAnIncompleteRollbackIsAPartialWriteAndLogged(): void { [$writer, $input] = $this->failingWriter(['post_title' => 'Unser Team'], false); $updates = []; $this->captureUpdates($updates); Functions\when('wp_revisions_enabled')->justReturn(false); $log = Mockery::mock(ChangeLog::class); $log->expects('recordPartial') ->with('lit/content-update', 'page', '5', Mockery::type('string'), 811) ->andReturn(12); $failure = $this->executeExpectingFailure($writer, $input, $log); self::assertFalse($failure->rolledBack); self::assertStringContainsString('restore kaputt', $failure->getMessage()); $error = $failure->toError(); self::assertSame('lit_partial_write', $error->get_error_code()); self::assertSame(5, $error->get_error_data()['post_id']); self::assertSame(811, $error->get_error_data()['revision_id']); } public function testFailuresAfterTheWriteOnlyAddWarnings(): void { $input = ['post_id' => 5, 'rev' => Rev::ofPost($this->post), 'title' => 'Unser Team']; $writer = Mockery::mock(PostWriter::class); $writer->allows('postData')->andReturn(['post_title' => 'Unser Team']); $writer->allows('prepare')->andReturn(['fields' => [], 'terms' => [], 'warnings' => []]); $writer->allows('snapshot')->andReturn(['fields' => [], 'terms' => []]); $writer->allows('writeExtras'); Functions\when('wp_save_post_revision')->justReturn(811); Functions\when('wp_get_post_revisions')->justReturn([811 => new WP_Post(['ID' => 811])]); Functions\when('wp_update_post')->justReturn(5); Functions\when('get_preview_post_link')->justReturn(''); Functions\when('get_edit_post_link')->justReturn(''); Actions\expectDone('lit_mcp_after_save')->once()->whenHappen(static function (): void { throw new RuntimeException('listener kaputt'); }); $log = Mockery::mock(ChangeLog::class); $log->expects('record')->andThrow(new RuntimeException('db weg')); $file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); $previous = ini_set('error_log', $file); try { $result = $this->ability($writer, $log)->execute($input); } finally { ini_set('error_log', (string) $previous); } self::assertTrue($result['ok']); self::assertNull($result['log_id']); self::assertCount(2, $result['warnings']); self::assertStringContainsString('listener kaputt', (string) file_get_contents($file)); unlink($file); } }