diff --git a/src/Integrations/WordPress/Menus/MenuWriter.php b/src/Integrations/WordPress/Menus/MenuWriter.php index 81a842d..427825d 100644 --- a/src/Integrations/WordPress/Menus/MenuWriter.php +++ b/src/Integrations/WordPress/Menus/MenuWriter.php @@ -431,8 +431,13 @@ class MenuWriter { $problems = []; foreach ($this->created as $itemId) { - if (! wp_delete_post($itemId, true) instanceof WP_Post) { - $problems[] = sprintf('created item %d not deleted', $itemId); + // Ruling S50: a throwing delete (e.g. a hook) must not stop the rest of the rollback. + try { + if (! wp_delete_post($itemId, true) instanceof WP_Post) { + $problems[] = sprintf('created item %d not deleted', $itemId); + } + } catch (Throwable $e) { + $problems[] = sprintf('created item %d not deleted: %s', $itemId, $e->getMessage()); } } diff --git a/tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php b/tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php index 4edcfe2..e25666d 100644 --- a/tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php +++ b/tests/Unit/Integrations/WordPress/Menus/MenuWriterTest.php @@ -345,6 +345,37 @@ final class MenuWriterTest extends TestCase self::assertCount(4, $calls); } + /** Ruling S50: a throwing delete of a new item does not stop the rollback; the result is partial_write. */ + public function testAThrowingDeleteOfANewItemStillRestoresTheRestAndIsPartial(): void + { + $calls = []; + Functions\when('wp_update_nav_menu_item')->alias(static function (int $menu, int $id, array $args) use (&$calls) { + $calls[] = [$id, $args]; + return count($calls) === 3 ? new WP_Error('db', 'kaputt') : ($id > 0 ? $id : 50); + }); + Functions\expect('wp_delete_post')->once()->with(50, true)->andThrow(new \RuntimeException('hook kaputt')); + + try { + (new MenuWriter())->apply(3, [ + ['id' => 11, 'title' => 'Wir', 'children' => [ + ['type' => 'custom', 'url' => '/a', 'title' => 'A'], + ['type' => 'custom', 'url' => '/b', 'title' => 'B'], + ]], + ], $this->current()); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertStringContainsString('kaputt', $e->getMessage()); + self::assertStringContainsString('created item 50 not deleted: hook kaputt', $e->getMessage()); + $error = $e->toError(); + self::assertToolError($error, 'partial_write'); + self::assertSame('Home', $error->get_error_data()['previous_items'][0]['title']); + } + + self::assertSame(11, $calls[3][0], 'the touched item is restored after the failed delete'); + self::assertCount(4, $calls); + } + public function testAFailedRemovalAfterAnotherRemovalIsAPartialWrite(): void { Functions\when('wp_update_nav_menu_item')->alias(static fn (int $menu, int $id): int => $id);