*/ private array $specs = []; /** @var list */ private array $contents = []; /** @var list Contents DiviWriter saved (the source page must never be written). */ private array $saved = []; private FakeChangeLog $log; protected function setUp(): void { parent::setUp(); $this->page = LibraryFixtures::page(DiviFixtures::markup('stage3/page--globale-instanzen.html')); LibraryFixtures::given([$this->page]); Functions\when('get_term_by')->alias(static fn (string $field, string $name, string $taxonomy): WP_Term|false => $name === 'Fixture' ? new WP_Term(['term_id' => 26, 'name' => 'Fixture', 'taxonomy' => $taxonomy]) : false); $this->log = new FakeChangeLog(4711); } /** @param list $deniedRoles */ private function ability(array $deniedRoles = [], bool $writable = true, ?\Throwable $layoutFailure = null): LibrarySaveAbility { $store = LibraryFixtures::store(); $store->allows('create')->andReturnUsing(function (LibraryItemSpec $spec): int { $this->specs[] = $spec; return 2001; }); $saver = Mockery::mock(DiviSaver::class); $saver->allows('saveNew')->andReturnUsing(function (int $id, string $content, SaveScope $scope) use ($layoutFailure): array { if ($layoutFailure !== null) { throw $layoutFailure; } $this->contents[] = [$id, $content, $scope]; LibraryFixtures::given([$this->page, LibraryFixtures::item(1055, ['ID' => 2001, 'post_content' => $content])]); return []; }); $access = LibraryFixtures::access($deniedRoles, $writable, $store); $failures = []; return new LibrarySaveAbility( $access, LibraryFixtures::loader($access), DiviWriterFixture::writer($this->saved, $failures, false, LibraryFixtures::loader($access)), $saver, $this->log, DiviWriterFixture::modules() ); } /** @return array */ private function input(array $extra = []): array { return array_merge( ['post_id' => 7, 'tree_rev' => Rev::of((string) $this->page->post_content), 'id' => 's1', 'title' => 'Leistungen'], $extra ); } public function testSavesASectionLikeTheBuilder(): void { $result = $this->ability()->execute($this->input(['categories' => ['Fixture']])); self::assertTrue($result['ok']); self::assertSame(2001, $result['library_id']); self::assertSame('section', $this->specs[0]->layoutType); self::assertSame([26], $this->specs[0]->terms()['layout_category']); self::assertSame( str_replace("\r\n", "\n", DiviFixtures::markup('stage3/library--library-abschnitt.html')), $this->contents[0][1] ); self::assertSame(SaveScope::LibraryItem, $this->contents[0][2]); self::assertSame(Rev::of($this->contents[0][1]), $result['tree_rev']); self::assertSame('et_pb_layout', $this->log->records[0]['object_type']); self::assertSame('2001', $this->log->records[0]['object_id']); } public function testSavesAGlobalModule(): void { $result = $this->ability()->execute($this->input(['id' => 's0.r0.c0.m0', 'title' => 'Kontakt-Button', 'scope' => 'global'])); self::assertTrue($result['ok']); self::assertTrue($this->specs[0]->global); self::assertSame('divi/button', $this->specs[0]->meta()['_et_pb_module_type']); self::assertSame('[]', $this->specs[0]->meta()['_et_pb_excluded_global_options']); } public function testRefusesAGlobalInstanceAndColumnsAndCreatesNothing(): void { self::assertToolError($this->ability()->execute($this->input(['id' => 'g0'])), 'validation_failed'); self::assertToolError($this->ability()->execute($this->input(['id' => 's1.r0.c0'])), 'validation_failed'); self::assertSame([], $this->specs); } public function testStaleSourceTreeRevCreatesNothing(): void { self::assertToolError($this->ability()->execute($this->input(['tree_rev' => 'abcdef012345'])), 'rev_mismatch'); self::assertSame([], $this->specs); } public function testNeedsPublishPostsAndTheDiviRole(): void { self::assertToolError($this->ability(['save_library'])->permission($this->input()), 'forbidden'); self::assertToolError($this->ability([], false)->permission($this->input()), 'forbidden'); LibraryFixtures::given([$this->page], ['publish_posts']); self::assertToolError($this->ability()->permission($this->input()), 'forbidden'); self::assertSame([], $this->specs); } public function testEditorCannotSaveACodeModule(): void { $this->page = LibraryFixtures::page( '' . '' . '' ); LibraryFixtures::given([$this->page]); $result = $this->ability()->execute($this->input(['id' => 's0'])); self::assertToolError($result, 'validation_failed'); self::assertStringContainsString('Code-Modul', implode(' ', \LIT\MCP\Abilities\Errors::fieldsOf($result))); self::assertSame([], $this->specs); } public function testUnknownCategoryCreatesNothing(): void { $result = $this->ability()->execute($this->input(['categories' => ['Gibt es nicht']])); self::assertToolError($result, 'validation_failed'); self::assertSame([], $this->specs); } /** Ruling P6: the layout write failing rolls the new entry back through NewDraft (write_failed). */ public function testFailedLayoutWriteDeletesTheEntry(): void { Functions\expect('wp_delete_post')->once()->with(2001, true)->andReturn(true); try { $this->ability([], true, new RuntimeException('hook failed'))->execute($this->input()); self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); self::assertToolError($e->toError(), 'write_failed'); } self::assertSame([], $this->log->records); } /** Ruling P6: when the rollback fails too, the answer is partial_write and the change log says so. */ public function testFailedRollbackIsAPartialWrite(): void { Functions\when('wp_delete_post')->justReturn(false); $this->log = FakeChangeLog::any(4711); try { $this->ability([], true, new RuntimeException('hook failed'))->execute($this->input()); self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertFalse($e->rolledBack); self::assertSame(2001, $e->postId); self::assertToolError($e->toError(), 'partial_write'); } self::assertSame('et_pb_layout', $this->log->partials[0]['object_type']); self::assertSame('2001', $this->log->partials[0]['object_id']); } /** Ruling P7: scope "global" also needs Divi's edit_global_library. */ public function testGlobalScopeNeedsEditGlobalLibrary(): void { $input = $this->input(['id' => 's0.r0.c0.m0', 'scope' => 'global']); self::assertToolError($this->ability(['edit_global_library'])->permission($input), 'forbidden'); self::assertToolError($this->ability(['edit_global_library'])->execute($input), 'forbidden'); self::assertTrue($this->ability(['edit_global_library'])->permission($this->input())); self::assertSame([], $this->specs); } public function testTheSourcePageIsNeverWritten(): void { $this->ability()->execute($this->input(['scope' => 'normal'])); self::assertSame([], $this->saved); self::assertCount(1, $this->contents); self::assertSame(2001, $this->contents[0][0]); } /** Line breaks between blocks are unified like LibraryContent::load() (fixture finding 9). */ public function testLineBreaksBetweenBlocksAreUnified(): void { $this->page = LibraryFixtures::page( "\r\n\r\n" . '' . "\r\n\r\n" ); LibraryFixtures::given([$this->page]); $result = $this->ability()->execute($this->input(['id' => 's0'])); self::assertTrue($result['ok']); self::assertStringNotContainsString("\r", $this->contents[0][1]); self::assertStringContainsString("\n", $this->contents[0][1]); } public function testUnknownScopeCreatesNothing(): void { self::assertToolError($this->ability()->execute($this->input(['scope' => 'site'])), 'validation_failed'); self::assertSame([], $this->specs); } }