['post_title' => 'Team'], 'meta' => [], 'terms' => []]; private WP_Post $original; private WP_Post $copy; protected function setUp(): void { parent::setUp(); $this->original = new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_title' => 'Team', 'post_status' => 'publish']); $this->copy = new WP_Post(['ID' => 77, 'post_type' => 'page', 'post_title' => 'Team (Kopie)', 'post_status' => 'draft']); Functions\when('get_post')->alias(fn (int $id) => $id === 5 ? $this->original : $this->copy); Functions\when('current_user_can')->justReturn(true); Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) ['create_posts' => 'edit_pages']]); Functions\when('get_post_meta')->justReturn('5'); Functions\when('get_permalink')->justReturn('https://kunde.de/team/'); Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=5&action=edit'); } private function ability(PostCloner $cloner, ChangeLog $log, bool $trash = true): PostReplaceWithCopyAbility { $schema = Mockery::mock(ContentSchema::class); $schema->allows('isManaged')->andReturn(true); return new PostReplaceWithCopyAbility($schema, $cloner, $log, static fn (): bool => $trash); } /** @return array */ private function input(array $extra = []): array { return array_merge( ['original_id' => 5, 'copy_id' => 77, 'rev' => Rev::ofPost($this->original), 'confirm' => true], $extra ); } private function revisionAt900(): void { Functions\when('wp_save_post_revision')->justReturn(900); Functions\when('wp_get_post_revisions')->justReturn([900 => new WP_Post(['ID' => 900])]); } public function testRequiresConfirmation(): void { $cloner = Mockery::mock(PostCloner::class); $cloner->expects('replace')->never(); $result = $this->ability($cloner, Mockery::mock(ChangeLog::class)) ->execute(['original_id' => 5, 'copy_id' => 77, 'rev' => Rev::ofPost($this->original)]); self::assertSame('lit_confirm_required', $result->get_error_code()); } public function testCopyMustStemFromTheOriginal(): void { Functions\when('get_post_meta')->justReturn('12'); $cloner = Mockery::mock(PostCloner::class); $cloner->expects('replace')->never(); $result = $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute($this->input()); self::assertSame('lit_validation_failed', $result->get_error_code()); } /** @return list */ public function refusedStatuses(): array { return [ 'trashed copy' => ['copy', 'trash'], 'published copy' => ['copy', 'publish'], 'scheduled copy' => ['copy', 'future'], 'trashed original' => ['original', 'trash'], ]; } /** @dataProvider refusedStatuses */ public function testRefusesCopiesThatAreNoDraftAndTrashedOriginals(string $which, string $status): void { $this->{$which}->post_status = $status; $cloner = Mockery::mock(PostCloner::class); $cloner->expects('backup')->never(); $cloner->expects('replace')->never(); $result = $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute($this->input()); self::assertSame('lit_validation_failed', $result->get_error_code()); } public function testAMissedScheduleOfTheOriginalIsRefused(): void { $this->original->post_status = 'future'; $this->original->post_date_gmt = gmdate('Y-m-d H:i:s', time() - 600); $cloner = Mockery::mock(PostCloner::class); $cloner->expects('backup')->never(); $cloner->expects('replace')->never(); $result = $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute($this->input()); self::assertSame('lit_validation_failed', $result->get_error_code()); self::assertArrayHasKey('original_id', $result->get_error_data()['fields']); } public function testAPendingCopyIsAccepted(): void { $this->copy->post_status = 'pending'; $this->revisionAt900(); $cloner = Mockery::mock(PostCloner::class); $cloner->allows('snapshot')->andReturn(self::SNAPSHOT); $cloner->allows('backup')->andReturn(88); $cloner->expects('replace'); Functions\when('wp_trash_post')->justReturn(new WP_Post(['ID' => 77])); $log = Mockery::mock(ChangeLog::class); $log->allows('record')->andReturn(11); self::assertTrue($this->ability($cloner, $log)->execute($this->input())['ok']); } public function testAStaleRevOfTheOriginalIsRefused(): void { $cloner = Mockery::mock(PostCloner::class); $cloner->expects('replace')->never(); Functions\expect('wp_save_post_revision')->never(); $result = $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute($this->input(['rev' => 'veraltet'])); self::assertSame('lit_rev_mismatch', $result->get_error_code()); } public function testDisabledTrashIsRefusedBeforeAnyChange(): void { $cloner = Mockery::mock(PostCloner::class); $cloner->expects('replace')->never(); $result = $this->ability($cloner, Mockery::mock(ChangeLog::class), false)->execute($this->input()); self::assertSame('lit_unsupported', $result->get_error_code()); } public function testRevisionThenReplaceThenTrashTheCopy(): void { $order = []; Functions\expect('wp_save_post_revision')->once()->with(5)->andReturnUsing(static function () use (&$order) { $order[] = 'revision'; return 900; }); Functions\when('wp_get_post_revisions')->justReturn([900 => new WP_Post(['ID' => 900])]); $cloner = Mockery::mock(PostCloner::class); $cloner->expects('snapshot')->with($this->original)->andReturnUsing(static function () use (&$order): array { $order[] = 'snapshot'; return self::SNAPSHOT; }); $cloner->expects('backup')->with($this->original)->andReturnUsing(static function () use (&$order): int { $order[] = 'backup'; return 88; }); $cloner->expects('replace')->with($this->original, $this->copy)->andReturnUsing(static function () use (&$order): void { $order[] = 'replace'; }); $cloner->expects('restore')->never(); Functions\expect('wp_trash_post')->once()->with(77)->andReturnUsing(static function () use (&$order): WP_Post { $order[] = 'trash'; return new WP_Post(['ID' => 77]); }); $log = Mockery::mock(ChangeLog::class); $log->expects('record')->with('lit/post-replace-with-copy', 'page', '5', Mockery::type('string'), 900)->andReturn(11); $result = $this->ability($cloner, $log)->execute($this->input()); self::assertSame(['snapshot', 'backup', 'revision', 'replace', 'trash'], $order); self::assertSame(88, $result['backup_id']); self::assertStringContainsString('Sicherung ID 88 im Papierkorb', $result['summary']); self::assertSame(900, $result['revision_id']); self::assertSame(11, $result['log_id']); self::assertSame(Rev::ofPost($this->original), $result['rev']); self::assertSame(['replaced', 'trashed'], array_column($result['changed'], 'action')); self::assertSame([], $result['warnings']); } public function testAFailedTrashOfTheCopyIsAWarningOnly(): void { $this->revisionAt900(); $cloner = Mockery::mock(PostCloner::class); $cloner->allows('snapshot')->andReturn(self::SNAPSHOT); $cloner->allows('backup')->andReturn(88); $cloner->expects('replace'); Functions\when('wp_trash_post')->justReturn(false); $log = Mockery::mock(ChangeLog::class); $log->allows('record')->andReturn(11); $result = $this->ability($cloner, $log)->execute($this->input()); self::assertTrue($result['ok']); self::assertSame(['replaced'], array_column($result['changed'], 'action')); self::assertSame(['Kopie 77 konnte nicht in den Papierkorb gelegt werden'], $result['warnings']); } public function testAFailedReplaceRestoresTheOriginal(): void { $this->revisionAt900(); $cloner = Mockery::mock(PostCloner::class); $cloner->allows('snapshot')->andReturn(self::SNAPSHOT); $cloner->allows('backup')->andReturn(88); $cloner->expects('replace')->andThrow(new ReplaceFailed(['post'], new RuntimeException('row kaputt'))); $cloner->expects('restore')->with($this->original, self::SNAPSHOT, ['post']); Functions\expect('wp_trash_post')->never(); try { $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute($this->input()); self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); self::assertSame('lit_write_failed', $e->toError()->get_error_code()); self::assertSame(88, $e->toError()->get_error_data()['backup_id']); $message = $e->toError()->get_error_message(); self::assertStringNotContainsString('Nichts wurde gespeichert', $message); self::assertStringContainsString('Die Änderung wurde nicht gespeichert (Sicherung 88', $message); } } public function testAnIncompleteRestoreIsAPartialWrite(): void { $this->revisionAt900(); $cloner = Mockery::mock(PostCloner::class); $cloner->allows('snapshot')->andReturn(self::SNAPSHOT); $cloner->allows('backup')->andReturn(88); $cloner->expects('replace')->andThrow(new RuntimeException('hook kaputt')); $cloner->expects('restore')->with($this->original, self::SNAPSHOT, PostCloner::PARTS)->andThrow(new RuntimeException('Restore incomplete: terms')); Functions\expect('wp_trash_post')->never(); $log = Mockery::mock(ChangeLog::class); $log->expects('recordPartial') ->with('lit/post-replace-with-copy', 'page', '5', Mockery::type('string'), 900) ->andReturn(12); try { $this->ability($cloner, $log)->execute($this->input()); self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertFalse($e->rolledBack); self::assertSame(5, $e->postId); self::assertSame(900, $e->revisionId); $error = $e->toError(); self::assertSame('lit_partial_write', $error->get_error_code()); self::assertSame(88, $error->get_error_data()['backup_id']); self::assertStringContainsString('Sicherung', $error->get_error_message()); self::assertStringContainsString('88', $error->get_error_message()); } } public function testNeedsTheRightToCreateEntriesForTheBackup(): void { Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_pages'); $cloner = Mockery::mock(PostCloner::class); $cloner->expects('backup')->never(); $cloner->expects('replace')->never(); $ability = $this->ability($cloner, Mockery::mock(ChangeLog::class)); self::assertSame('lit_forbidden', $ability->permission($this->input())->get_error_code()); self::assertSame('lit_forbidden', $ability->execute($this->input())->get_error_code()); } public function testAFailedBackupAbortsBeforeTheOriginalIsTouched(): void { $cloner = Mockery::mock(PostCloner::class); $cloner->allows('snapshot')->andReturn(self::SNAPSHOT); $cloner->expects('backup')->andThrow(WriteFailed::rolledBack('Die Sicherung konnte nicht angelegt werden.')); $cloner->expects('replace')->never(); $cloner->expects('restore')->never(); Functions\expect('wp_save_post_revision')->never(); Functions\expect('wp_trash_post')->never(); try { $this->ability($cloner, Mockery::mock(ChangeLog::class))->execute($this->input()); self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertSame('lit_write_failed', $e->toError()->get_error_code()); } } public function testRequiresRevAndIsDestructive(): void { $ability = $this->ability(Mockery::mock(PostCloner::class), Mockery::mock(ChangeLog::class)); self::assertContains('rev', $ability->inputSchema()['required']); self::assertContains('backup_id', $ability->outputSchema()['required']); self::assertTrue($ability->annotations()['destructive']); self::assertFalse($ability->annotations()['idempotent']); self::assertFalse($ability->isDirect()); } }