>> Stored meta by post ID, as meta_value strings. */ private array $meta = []; /** @var list Raw postmeta inserts. */ private array $inserts = []; private bool $insertFails = false; /** Post IDs whose meta SELECT fails: 'null' returns null, 'error' sets $wpdb->last_error. */ private array $metaReadFails = []; protected function setUp(): void { parent::setUp(); Functions\when('wp_cache_delete')->justReturn(true); Functions\when('get_post')->justReturn(null); } private function cloner(): PostCloner { $db = Mockery::mock(\wpdb::class)->makePartial(); $db->allows('get_results')->andReturnUsing(function (string $sql) use ($db): ?array { preg_match('/post_id = (\d+)/', $sql, $match); $db->last_error = ''; $failure = $this->metaReadFails[(int) $match[1]] ?? null; if ($failure === 'null') { return null; } if ($failure === 'error') { $db->last_error = 'Table wp_postmeta is marked as crashed'; return []; } $rows = []; foreach ($this->meta[(int) $match[1]] ?? [] as $key => $values) { foreach ($values as $value) { $rows[] = ['meta_key' => $key, 'meta_value' => $value]; } } return $rows; }); $db->allows('insert')->andReturnUsing(function (string $table, array $data, array $format) { self::assertSame('wp_postmeta', $table); self::assertSame(['%d', '%s', '%s'], $format); $this->inserts[] = [$data['post_id'], $data['meta_key'], $data['meta_value']]; return $this->insertFails ? false : 1; }); return new PostCloner($db); } private function original(): WP_Post { return new WP_Post([ 'ID' => 5, 'post_type' => 'page', 'post_title' => 'Team', 'post_content' => '', 'post_excerpt' => 'Kurz', 'post_parent' => 2, 'menu_order' => 3, ]); } private function copy(): WP_Post { return new WP_Post([ 'ID' => 77, 'post_type' => 'page', 'post_title' => 'Team (Kopie)', 'post_content' => 'neu', 'post_excerpt' => 'Neu kurz', ]); } public function testDuplicateCopiesContentMetaAndTermsAsDraft(): void { Functions\when('get_current_user_id')->justReturn(4); Functions\expect('wp_insert_post')->once()->with($this->draftMatcher('Team (Kopie)'), true)->andReturn(77); $this->meta = [ 5 => [ '_edit_lock' => ['123:1'], '_edit_last' => ['1'], '_wp_old_slug' => ['team-alt'], '_lit_mcp_backup_of' => ['3'], '_et_pb_use_builder' => ['on'], '_et_pb_use_divi_5' => ['on'], '_wp_page_template' => ['page-template-blank.php'], '_thumbnail_id' => ['31'], 'role' => ['Leitung'], ], // Meta a save hook put on the fresh copy is replaced by the original's. 77 => ['_edit_lock' => ['999:4'], '_et_pb_use_builder' => ['off']], ]; Functions\expect('delete_post_meta')->once()->with(77, '_et_pb_use_builder'); Functions\when('get_object_taxonomies')->justReturn(['category']); Functions\when('wp_get_object_terms')->justReturn([4, 9]); Functions\expect('wp_set_object_terms')->once()->with(77, [4, 9], 'category', false); Functions\expect('update_post_meta')->once()->with(77, '_lit_mcp_copy_of', 5); self::assertSame(77, $this->cloner()->duplicate($this->original())); self::assertSame([ [77, '_et_pb_use_builder', 'on'], [77, '_et_pb_use_divi_5', 'on'], [77, '_wp_page_template', 'page-template-blank.php'], [77, '_thumbnail_id', '31'], [77, 'role', 'Leitung'], ], $this->inserts); } public function testMetaIsCopiedByteExact(): void { Functions\when('get_current_user_id')->justReturn(4); Functions\when('wp_insert_post')->justReturn(77); Functions\when('get_object_taxonomies')->justReturn([]); Functions\when('update_post_meta')->justReturn(true); $raw = [ 'path' => ['a:1:{s:1:"p";s:9:"C:\\new\\x\\";}'], 'quoted' => ['Er sagte \\"hallo\\"'], 'flag' => ['b:0;'], 'object' => ['O:8:"stdClass":1:{s:1:"a";i:1;}'], 'empty' => [''], 'null' => [null], ]; $this->meta = [5 => $raw]; $this->cloner()->duplicate($this->original()); self::assertSame([ [77, 'path', 'a:1:{s:1:"p";s:9:"C:\\new\\x\\";}'], [77, 'quoted', 'Er sagte \\"hallo\\"'], [77, 'flag', 'b:0;'], [77, 'object', 'O:8:"stdClass":1:{s:1:"a";i:1;}'], [77, 'empty', ''], [77, 'null', null], ], $this->inserts); } public function testDuplicateFailureThrows(): void { Functions\when('get_current_user_id')->justReturn(4); Functions\when('wp_insert_post')->justReturn(new WP_Error('x', 'kaputt')); $this->expectException(RuntimeException::class); $this->cloner()->duplicate($this->original()); } public function testDuplicateFailingAfterInsertDeletesTheNewDraft(): void { Functions\when('get_current_user_id')->justReturn(4); Functions\when('wp_insert_post')->justReturn(77); $this->meta = [5 => ['role' => ['Leitung']]]; $this->insertFails = true; Functions\expect('wp_delete_post')->once()->with(77, true)->andReturn(new WP_Post(['ID' => 77])); try { $this->cloner()->duplicate($this->original()); self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); } } public function testDuplicateReportsALeftoverDraftWhenItCannotBeDeleted(): void { Functions\when('get_current_user_id')->justReturn(4); Functions\when('wp_insert_post')->justReturn(77); Functions\when('get_object_taxonomies')->justReturn(['category']); Functions\when('wp_get_object_terms')->justReturn(new WP_Error('x', 'kaputt')); Functions\when('wp_delete_post')->justReturn(false); try { $this->cloner()->duplicate($this->original()); self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertFalse($e->rolledBack); self::assertSame(77, $e->postId); } } public function testBackupIsAFullCloneInTheTrash(): void { Functions\when('get_current_user_id')->justReturn(4); Functions\expect('wp_insert_post')->once()->with($this->draftMatcher('Team (Sicherung)'), true)->andReturn(88); $this->meta = [5 => ['_et_pb_use_builder' => ['on'], '_lit_mcp_copy_of' => ['1']]]; Functions\when('get_object_taxonomies')->justReturn(['category']); Functions\when('wp_get_object_terms')->justReturn([4]); Functions\expect('wp_set_object_terms')->once()->with(88, [4], 'category', false); Functions\expect('update_post_meta')->once()->with(88, '_lit_mcp_backup_of', 5)->andReturn(12); Functions\expect('wp_trash_post')->once()->with(88)->andReturn(new WP_Post(['ID' => 88])); Functions\when('get_post_status')->justReturn('trash'); Functions\expect('wp_delete_post')->never(); self::assertSame(88, $this->cloner()->backup($this->original())); self::assertSame([[88, '_et_pb_use_builder', 'on']], $this->inserts); } public function testABackupThatCannotBeTrashedIsDeletedAgain(): void { Functions\when('get_current_user_id')->justReturn(4); Functions\when('wp_insert_post')->justReturn(88); Functions\when('get_object_taxonomies')->justReturn([]); Functions\when('update_post_meta')->justReturn(12); Functions\when('wp_trash_post')->justReturn(new WP_Post(['ID' => 88])); Functions\when('get_post_status')->justReturn('draft'); Functions\expect('wp_delete_post')->once()->with(88, true)->andReturn(new WP_Post(['ID' => 88])); try { $this->cloner()->backup($this->original()); self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); self::assertToolError($e->toError(), 'write_failed'); } } public function testReplaceOverwritesContentMetaAndTermsButKeepsTheOriginalsIdentity(): void { Functions\expect('wp_update_post')->once()->with([ 'ID' => 5, 'post_title' => 'Team', 'post_content' => 'neu', 'post_excerpt' => 'Neu kurz', ], true)->andReturn(5); $this->meta = [ 5 => ['_edit_lock' => ['1'], '_wp_old_slug' => ['alt'], 'role' => ['Alt'], 'city' => ['Trier']], 77 => ['_lit_mcp_copy_of' => ['5'], '_edit_last' => ['4'], 'role' => ['Neu']], ]; Functions\expect('delete_post_meta')->once()->with(5, 'role'); Functions\expect('delete_post_meta')->once()->with(5, 'city'); Functions\when('get_object_taxonomies')->justReturn(['category']); Functions\when('wp_get_object_terms')->justReturn([12]); Functions\expect('wp_set_object_terms')->once()->with(5, [12], 'category', false); $this->cloner()->replace($this->original(), $this->copy()); self::assertSame([[5, 'role', 'Neu']], $this->inserts); } public function testAReplaceRejectedByWordPressNamesOnlyThePostRow(): void { Functions\when('get_object_taxonomies')->justReturn([]); Functions\when('wp_update_post')->justReturn(new WP_Error('x', 'kaputt')); Functions\expect('delete_post_meta')->never(); try { $this->cloner()->replace($this->original(), $this->copy()); self::fail('ReplaceFailed expected'); } catch (ReplaceFailed $e) { self::assertSame(['post'], $e->parts); } } public function testAFailingTermStepNamesAllParts(): void { Functions\when('wp_update_post')->justReturn(5); Functions\when('get_object_taxonomies')->justReturn(['category']); Functions\when('wp_get_object_terms')->justReturn([12]); Functions\when('wp_set_object_terms')->justReturn(new WP_Error('x', 'kaputt')); try { $this->cloner()->replace($this->original(), $this->copy()); self::fail('ReplaceFailed expected'); } catch (ReplaceFailed $e) { self::assertSame(['post', 'meta', 'terms'], $e->parts); } } /** @return array */ public function metaReadFailures(): array { return ['null result' => ['null'], 'database error' => ['error']]; } /** @dataProvider metaReadFailures */ public function testAFailedMetaReadIsAnErrorNotEmptyMeta(string $failure): void { $this->metaReadFails = [5 => $failure]; Functions\when('get_object_taxonomies')->justReturn([]); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Meta of post 5 could not be read'); $this->cloner()->snapshot($this->original()); } /** @dataProvider metaReadFailures */ public function testReplaceReadsTheCopyBeforeWritingAnything(string $failure): void { $this->metaReadFails = [77 => $failure]; Functions\when('get_object_taxonomies')->justReturn([]); Functions\expect('wp_update_post')->never(); Functions\expect('delete_post_meta')->never(); try { $this->cloner()->replace($this->original(), $this->copy()); self::fail('ReplaceFailed expected'); } catch (ReplaceFailed $e) { self::assertSame([], $e->parts); self::assertStringContainsString('Meta of post 77 could not be read', $e->getMessage()); } } public function testADuplicateWithAFailedMetaReadIsDeletedAgain(): void { $this->metaReadFails = [5 => 'error']; Functions\when('get_current_user_id')->justReturn(3); Functions\when('wp_insert_post')->justReturn(90); Functions\expect('wp_delete_post')->once()->with(90, true)->andReturn(new WP_Post(['ID' => 90])); try { $this->cloner()->duplicate($this->original()); self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); } } private function missedSchedule(): void { Functions\when('get_post')->justReturn(new WP_Post([ 'ID' => 5, 'post_type' => 'page', 'post_status' => 'future', 'post_date_gmt' => gmdate('Y-m-d H:i:s', time() - 60), ])); } public function testReplaceRefusesAMissedScheduleBeforeWriting(): void { $this->missedSchedule(); Functions\when('get_object_taxonomies')->justReturn([]); Functions\expect('wp_update_post')->never(); try { $this->cloner()->replace($this->original(), $this->copy()); self::fail('ReplaceFailed expected'); } catch (ReplaceFailed $e) { self::assertSame([], $e->parts); self::assertStringContainsString('missed schedule', $e->getMessage()); } } public function testRestoreNeverRewritesTheRowOfAMissedSchedule(): void { $this->missedSchedule(); Functions\expect('wp_update_post')->never(); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('missed schedule'); $this->cloner()->restore($this->original(), [ 'post' => ['post_title' => 'Team', 'post_content' => '', 'post_excerpt' => ''], 'meta' => [], 'terms' => [], ], ['post']); } public function testSnapshotAndRestoreBringBackRowMetaAndTerms(): void { $this->meta = [5 => ['_edit_lock' => ['1'], 'role' => ['b:0;']]]; Functions\when('get_object_taxonomies')->justReturn(['category']); Functions\when('wp_get_object_terms')->justReturn([4]); $cloner = $this->cloner(); $snapshot = $cloner->snapshot($this->original()); self::assertSame(['post_title' => 'Team', 'post_content' => '', 'post_excerpt' => 'Kurz'], $snapshot['post']); self::assertSame(['role' => ['b:0;']], $snapshot['meta']); self::assertSame(['category' => [4]], $snapshot['terms']); $this->meta = [5 => ['_edit_lock' => ['1'], 'role' => ['Neu'], 'city' => ['Trier']]]; Functions\expect('wp_update_post')->once() ->with(array_merge(['ID' => 5], $snapshot['post']), true)->andReturn(5); Functions\expect('delete_post_meta')->once()->with(5, 'role'); Functions\expect('delete_post_meta')->once()->with(5, 'city'); Functions\expect('wp_set_object_terms')->once()->with(5, [4], 'category', false); $cloner->restore($this->original(), $snapshot); self::assertSame([[5, 'role', 'b:0;']], $this->inserts); } public function testRestoreWritesOnlyThePartsReached(): void { Functions\expect('wp_update_post')->once()->andReturn(5); Functions\expect('delete_post_meta')->never(); Functions\expect('wp_set_object_terms')->never(); $this->cloner()->restore($this->original(), [ 'post' => ['post_title' => 'Team', 'post_content' => '', 'post_excerpt' => ''], 'meta' => ['role' => ['Alt']], 'terms' => ['category' => [4]], ], ['post']); self::assertSame([], $this->inserts); } public function testRestoreTriesEveryPartAndReportsFailures(): void { Functions\when('wp_update_post')->justReturn(new WP_Error('x', 'kaputt')); Functions\expect('wp_set_object_terms')->once()->with(5, [4], 'category', false); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Restore incomplete'); $this->cloner()->restore($this->original(), [ 'post' => ['post_title' => 'Team', 'post_content' => '', 'post_excerpt' => ''], 'meta' => [], 'terms' => ['category' => [4]], ]); } private function draftMatcher(string $title): \Mockery\Matcher\Closure { return Mockery::on(static fn (array $data): bool => $data['post_status'] === 'draft' && $data['post_type'] === 'page' && $data['post_title'] === $title && $data['post_content'] === '' && $data['post_excerpt'] === 'Kurz' && $data['post_parent'] === 2 && $data['menu_order'] === 3 && $data['post_author'] === 4); } }