From e3c2973313e1da738de5bb4332a912f6a52c15d6 Mon Sep 17 00:00:00 2001 From: Marco Hanke Date: Thu, 11 Jun 2026 10:01:04 +0200 Subject: [PATCH 1/3] feat(structure): support optional priority on slice add MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/structure/articles/{id}/slices now accepts an optional `priority` integer in the body. When omitted, the slice is appended at the end (default behaviour, unchanged). When set to 1 (or any positive integer), the slice is inserted at that priority within its (article, clang, ctype) group; the core service's rex_sql_util::organizePriorities() pass renormalises the sequence. The handler now forwards `priority` into the $data array that rex_content_service::addSlice() already understands (content_service.php:17-24) — no new SQL, no new EPs, no behavioural change for callers that don't send the param. --- lib/RoutePackage/Structure.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/RoutePackage/Structure.php b/lib/RoutePackage/Structure.php index 108683f..eea6b6d 100644 --- a/lib/RoutePackage/Structure.php +++ b/lib/RoutePackage/Structure.php @@ -428,6 +428,11 @@ public function loadRoutes(): void 'required' => true, 'default' => null, ], + 'priority' => [ + 'type' => 'int', + 'required' => false, + 'default' => null, + ], ], $Values, // value1...19 $Medias, // media1...10 @@ -912,6 +917,14 @@ public static function handleAddArticleSlices($Parameter, array $Route = []): Re } } + // Optional priority: rex_content_service::addSlice() accepts a `priority` key in $data + // (content_service.php:17-24). If omitted, the service appends at the end (MAX(priority)+1). + // priority <= 0 is normalised to 1 by the service. After insert the service calls + // rex_sql_util::organizePriorities() to renormalise the sequence. + if (null !== $Data['priority']) { + $SliceData['priority'] = (int) $Data['priority']; + } + try { $SliceId = null; rex_extension::register('SLICE_ADDED', static function (rex_extension_point $ep) use (&$SliceId) { From 7fac85f87dd1ea2690054d4252822825e1432626 Mon Sep 17 00:00:00 2001 From: Marco Hanke Date: Thu, 11 Jun 2026 10:02:50 +0200 Subject: [PATCH 2/3] feat(structure): add slice move endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New POST /api/structure/articles/{id}/slices/{slice_id}/move endpoint with body { "direction": "moveup" | "movedown" }. Mirrors REDAXO core's rex_api_content_move_slice exactly: - Scope: structure/articles/slices/move (Bearer) and the automatically generated backend/structure/articles/slices/move (session-cookie auth). - clang_id and module_id are derived from the slice row (no extra body params). - Permission cascade for session-cookie callers: moveSlice[] perm, structure category perm, modules complex-perm. Admins bypass moveSlice[]. - Delegates to rex_content_service::moveSlice() which fires SLICE_MOVE, swaps priority, runs rex_sql_util::organizePriorities(), calls rex_article_cache::deleteContent() and fires art_content_updated. No extra EPs or cache calls from the API layer — same as the core API function. - rex_api_exception (e.g. first slice tried to moveup) is surfaced as 422 with the original i18n message; any other Exception falls back to 500. --- lib/RoutePackage/Structure.php | 110 +++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/lib/RoutePackage/Structure.php b/lib/RoutePackage/Structure.php index eea6b6d..51fa08d 100644 --- a/lib/RoutePackage/Structure.php +++ b/lib/RoutePackage/Structure.php @@ -8,6 +8,7 @@ use FriendsOfRedaxo\Api\RouteCollection; use FriendsOfRedaxo\Api\RoutePackage; use rex; +use rex_api_exception; use rex_article; use rex_article_cache; use rex_article_service; @@ -529,6 +530,34 @@ public function loadRoutes(): void null, new BearerAuth() ); + + // Slice eines Artikels verschieben (moveup/movedown) ✅ + RouteCollection::registerRoute( + 'structure/articles/slices/move', + new Route( + 'structure/articles/{id}/slices/{slice_id}/move', + [ + '_controller' => 'FriendsOfRedaxo\Api\RoutePackage\Structure::handleMoveArticleSlice', + 'Body' => [ + 'direction' => [ + 'type' => 'string', + 'required' => true, + 'default' => null, + ], + ], + ], + [ + 'id' => '\d+', + 'slice_id' => '\d+', + ], + [], + '', + [], + ['POST']), + 'Move a slice up or down within its ctype', + null, + new BearerAuth() + ); } private static function checkStructurePerm(?rex_user $user, ?int $categoryId = null): ?Response @@ -1436,6 +1465,87 @@ public static function handleDeleteArticleSlice($Parameter, array $Route = []): } } + /** @api */ + public static function handleMoveArticleSlice($Parameter, array $Route = []): Response + { + $Data = json_decode(rex::getRequest()->getContent(), true); + + if (!is_array($Data)) { + return new JsonResponse(['error' => 'Invalid input'], 400); + } + + try { + $Data = RouteCollection::getQuerySet($Data, $Parameter['Body']); + } catch (Exception $e) { + return new JsonResponse(['error' => 'Body field: `' . $e->getMessage() . '` is required'], 400); + } + + $direction = $Data['direction']; + if ('moveup' !== $direction && 'movedown' !== $direction) { + return new JsonResponse([ + 'error' => 'Invalid direction', + 'direction' => rex_escape((string) $direction), + 'allowed' => ['moveup', 'movedown'], + ], 400); + } + + $sliceId = (int) $Parameter['slice_id']; + $articleId = (int) $Parameter['id']; + + $Slice = self::loadSliceForArticle($sliceId, $articleId); + if (null === $Slice) { + return new JsonResponse(['error' => 'Slice not found'], 404); + } + + $clangId = (int) $Slice['clang_id']; + $moduleId = (int) $Slice['module_id']; + + $Article = rex_article::get($articleId, $clangId); + if (!$Article) { + return new JsonResponse(['error' => 'Article not found'], 404); + } + + // Permission cascade mirrors rex_api_content_move_slice::execute() + // (addons/structure/plugins/content/lib/api_functions/api_content.php:29-50). + // For Bearer token calls (`$user === null`), scope grants access. + $user = RouteCollection::getBackendUser($Route); + if (null !== $user && !$user->isAdmin() && !$user->hasPerm('moveSlice[]')) { + return new JsonResponse(['error' => 'Permission denied'], 403); + } + $permResponse = self::checkStructurePerm($user, $Article->getCategoryId()); + if (null !== $permResponse) { + return $permResponse; + } + if (null !== $user && !$user->getComplexPerm('modules')->hasPerm($moduleId)) { + return new JsonResponse(['error' => 'Permission denied'], 403); + } + + try { + // rex_content_service::moveSlice() handles everything: fires SLICE_MOVE, swaps the + // priority, calls rex_sql_util::organizePriorities(), invalidates the content cache + // via rex_article_cache::deleteContent(), and fires art_content_updated. + // We mirror api_content.php exactly — no extra EPs, no extra cache calls. + $message = rex_content_service::moveSlice($sliceId, $clangId, $direction); + } catch (rex_api_exception $e) { + // moveSlice throws when the slice is already at the boundary (top can't moveup, etc.) + // — surface as 422 rather than 500 since the request was structurally valid but the + // requested state transition is not possible. + return new JsonResponse([ + 'error' => $e->getMessage(), + 'slice_id' => $sliceId, + 'direction' => $direction, + ], 422); + } catch (Exception $e) { + return new JsonResponse(['error' => $e->getMessage()], 500); + } + + return new JsonResponse([ + 'message' => $message, + 'slice_id' => $sliceId, + 'direction' => $direction, + ], 200); + } + /** * Load a slice by id and verify it belongs to the given article. Returns null when not found. * From 79910f753ac6d60347fd3476de7e2c93487a63bd Mon Sep 17 00:00:00 2001 From: Marco Hanke Date: Thu, 11 Jun 2026 10:04:56 +0200 Subject: [PATCH 3/3] test(structure): cover slice priority + move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bearer-scope tests in StructureApiTest.php: - testCreateArticleSliceAtTop: POST with priority=1, then verify the new slice is first in its ctype with priority=1. - testCreateArticleSliceWithoutPriorityAppends: regression — without the param, the slice keeps the previous append-at-end behaviour. - testMoveArticleSliceUp / testMoveArticleSliceDown: two-slice setups, swap order via the new /move endpoint and verify priorities flip. - testMoveArticleSliceInvalidDirection: direction=sideways → 400. - testMoveArticleSliceNotFound: 404 on non-existent slice id. Backend-session tests in BackendApiTest.php: - testAdminCreateArticleSliceAtTop: admin session can create with priority=1. - testAdminMoveArticleSlice: admin session moves a slice via /move. - testRestrictedUserCannotMoveArticleSlice: restricted user (no moveSlice[] perm, no category perm) must get 403/404, not 200. Tests gracefully skip when the test article's template lacks the configured module — same pattern as the existing slice tests. --- tests/BackendApiTest.php | 84 ++++++++++++++++ tests/StructureApiTest.php | 196 +++++++++++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+) diff --git a/tests/BackendApiTest.php b/tests/BackendApiTest.php index 7cbb078..ad01545 100644 --- a/tests/BackendApiTest.php +++ b/tests/BackendApiTest.php @@ -759,6 +759,90 @@ public function testAdminSliceCRUD(): void } } + public function testAdminCreateArticleSliceAtTop(): void + { + $articleId = self::$config['test_data']['existing_article_id']; + $moduleId = self::$config['test_data']['existing_module_id']; + $clangId = self::$config['test_data']['existing_clang_id']; + + $createResponse = $this->adminPost('structure/articles/' . $articleId . '/slices', [ + 'module_id' => $moduleId, + 'clang_id' => $clangId, + 'ctype_id' => 1, + 'priority' => 1, + 'value1' => 'BACKEND_TEST_top_' . uniqid(), + ]); + if (201 !== $createResponse['status']) { + $this->markTestSkipped('Slice konnte nicht angelegt werden (Template/Modul-Zuordnung fehlt).'); + } + $sliceId = (int) $createResponse['data']['slice_id']; + + try { + $getResponse = $this->adminGet('structure/articles/' . $articleId . '/slices/' . $sliceId); + $this->assertSame(200, $getResponse['status']); + $this->assertSame(1, (int) $getResponse['data']['priority']); + } finally { + $this->adminDelete('structure/articles/' . $articleId . '/slices/' . $sliceId); + } + } + + public function testAdminMoveArticleSlice(): void + { + $articleId = self::$config['test_data']['existing_article_id']; + $moduleId = self::$config['test_data']['existing_module_id']; + $clangId = self::$config['test_data']['existing_clang_id']; + + $first = $this->adminPost('structure/articles/' . $articleId . '/slices', [ + 'module_id' => $moduleId, 'clang_id' => $clangId, 'ctype_id' => 1, + 'priority' => 1, 'value1' => 'BACKEND_TEST_first_' . uniqid(), + ]); + if (201 !== $first['status']) { + $this->markTestSkipped('Slice konnte nicht angelegt werden.'); + } + $firstId = (int) $first['data']['slice_id']; + + $second = $this->adminPost('structure/articles/' . $articleId . '/slices', [ + 'module_id' => $moduleId, 'clang_id' => $clangId, 'ctype_id' => 1, + 'priority' => 2, 'value1' => 'BACKEND_TEST_second_' . uniqid(), + ]); + $this->assertSame(201, $second['status']); + $secondId = (int) $second['data']['slice_id']; + + try { + $moveResponse = $this->adminPost( + 'structure/articles/' . $articleId . '/slices/' . $secondId . '/move', + ['direction' => 'moveup'], + ); + $this->assertSame(200, $moveResponse['status']); + + $getSecond = $this->adminGet('structure/articles/' . $articleId . '/slices/' . $secondId); + $getFirst = $this->adminGet('structure/articles/' . $articleId . '/slices/' . $firstId); + $this->assertLessThan( + (int) $getFirst['data']['priority'], + (int) $getSecond['data']['priority'], + 'After moveup the second slice should now have a lower priority than the first', + ); + } finally { + $this->adminDelete('structure/articles/' . $articleId . '/slices/' . $secondId); + $this->adminDelete('structure/articles/' . $articleId . '/slices/' . $firstId); + } + } + + public function testRestrictedUserCannotMoveArticleSlice(): void + { + $articleId = self::$config['test_data']['existing_article_id']; + + $response = $this->restrictedPost( + 'structure/articles/' . $articleId . '/slices/1/move', + ['direction' => 'moveup'], + ); + + // Restricted user lacks both moveSlice[] perm and likely the structure category perm. + // Either 403 (Permission denied) or 404 (slice not visible) is acceptable; what we + // must NOT see is a 200. + $this->assertContains($response['status'], [403, 404]); + } + // ==================== ADMIN CRUD: Media Category ==================== public function testAdminMediaCategoryCRUD(): void diff --git a/tests/StructureApiTest.php b/tests/StructureApiTest.php index 3909762..0620d10 100644 --- a/tests/StructureApiTest.php +++ b/tests/StructureApiTest.php @@ -481,4 +481,200 @@ public function testDeleteArticleSliceNotFound(): void $this->assertStatus(404, $response); $this->assertError($response); } + + public function testCreateArticleSliceAtTop(): void + { + $articleId = self::$config['test_data']['existing_article_id']; + $clangId = self::$config['test_data']['existing_clang_id']; + $moduleId = self::$config['test_data']['existing_module_id']; + + $createResponse = $this->post('structure/articles/' . $articleId . '/slices', [ + 'module_id' => $moduleId, + 'clang_id' => $clangId, + 'ctype_id' => 1, + 'priority' => 1, + 'value1' => 'top-' . uniqid(), + ]); + + if (201 !== $createResponse['status']) { + $this->markTestSkipped('Slice create not available (module/template wiring): status ' . $createResponse['status']); + } + $sliceId = (int) $createResponse['data']['slice_id']; + + try { + $listResponse = $this->get('structure/articles/' . $articleId . '/slices', [ + 'clang_id' => $clangId, + 'revision' => 0, + ]); + $this->assertSuccess($listResponse); + + $slicesInCtype = array_values(array_filter( + $listResponse['data']['data'], + static fn (array $s): bool => 1 === (int) $s['ctype_id'], + )); + $this->assertNotEmpty($slicesInCtype); + $this->assertSame($sliceId, (int) $slicesInCtype[0]['id'], 'Slice with priority=1 should be first in the ctype'); + $this->assertSame(1, (int) $slicesInCtype[0]['priority']); + } finally { + $this->delete('structure/articles/' . $articleId . '/slices/' . $sliceId); + } + } + + public function testCreateArticleSliceWithoutPriorityAppends(): void + { + $articleId = self::$config['test_data']['existing_article_id']; + $clangId = self::$config['test_data']['existing_clang_id']; + $moduleId = self::$config['test_data']['existing_module_id']; + + $createResponse = $this->post('structure/articles/' . $articleId . '/slices', [ + 'module_id' => $moduleId, + 'clang_id' => $clangId, + 'ctype_id' => 1, + 'value1' => 'tail-' . uniqid(), + ]); + if (201 !== $createResponse['status']) { + $this->markTestSkipped('Slice create not available (module/template wiring): status ' . $createResponse['status']); + } + $sliceId = (int) $createResponse['data']['slice_id']; + + try { + $listResponse = $this->get('structure/articles/' . $articleId . '/slices', [ + 'clang_id' => $clangId, + 'revision' => 0, + ]); + $slicesInCtype = array_values(array_filter( + $listResponse['data']['data'], + static fn (array $s): bool => 1 === (int) $s['ctype_id'], + )); + + $maxPriority = 0; + $newSlicePriority = null; + foreach ($slicesInCtype as $slice) { + $maxPriority = max($maxPriority, (int) $slice['priority']); + if ((int) $slice['id'] === $sliceId) { + $newSlicePriority = (int) $slice['priority']; + } + } + $this->assertNotNull($newSlicePriority); + $this->assertSame($maxPriority, $newSlicePriority, 'Slice without priority should be appended at the end'); + } finally { + $this->delete('structure/articles/' . $articleId . '/slices/' . $sliceId); + } + } + + public function testMoveArticleSliceUp(): void + { + $articleId = self::$config['test_data']['existing_article_id']; + $clangId = self::$config['test_data']['existing_clang_id']; + $moduleId = self::$config['test_data']['existing_module_id']; + + $first = $this->post('structure/articles/' . $articleId . '/slices', [ + 'module_id' => $moduleId, 'clang_id' => $clangId, 'ctype_id' => 1, + 'priority' => 1, 'value1' => 'move-up-first-' . uniqid(), + ]); + if (201 !== $first['status']) { + $this->markTestSkipped('Slice create not available: status ' . $first['status']); + } + $firstId = (int) $first['data']['slice_id']; + + $second = $this->post('structure/articles/' . $articleId . '/slices', [ + 'module_id' => $moduleId, 'clang_id' => $clangId, 'ctype_id' => 1, + 'priority' => 2, 'value1' => 'move-up-second-' . uniqid(), + ]); + $this->assertStatus(201, $second); + $secondId = (int) $second['data']['slice_id']; + + try { + $moveResponse = $this->post( + 'structure/articles/' . $articleId . '/slices/' . $secondId . '/move', + ['direction' => 'moveup'], + ); + $this->assertSuccess($moveResponse); + $this->assertSame('moveup', $moveResponse['data']['direction']); + + $getSecond = $this->get('structure/articles/' . $articleId . '/slices/' . $secondId); + $getFirst = $this->get('structure/articles/' . $articleId . '/slices/' . $firstId); + $this->assertLessThan((int) $getFirst['data']['priority'], (int) $getSecond['data']['priority']); + } finally { + $this->delete('structure/articles/' . $articleId . '/slices/' . $secondId); + $this->delete('structure/articles/' . $articleId . '/slices/' . $firstId); + } + } + + public function testMoveArticleSliceDown(): void + { + $articleId = self::$config['test_data']['existing_article_id']; + $clangId = self::$config['test_data']['existing_clang_id']; + $moduleId = self::$config['test_data']['existing_module_id']; + + $first = $this->post('structure/articles/' . $articleId . '/slices', [ + 'module_id' => $moduleId, 'clang_id' => $clangId, 'ctype_id' => 1, + 'priority' => 1, 'value1' => 'move-down-first-' . uniqid(), + ]); + if (201 !== $first['status']) { + $this->markTestSkipped('Slice create not available: status ' . $first['status']); + } + $firstId = (int) $first['data']['slice_id']; + + $second = $this->post('structure/articles/' . $articleId . '/slices', [ + 'module_id' => $moduleId, 'clang_id' => $clangId, 'ctype_id' => 1, + 'priority' => 2, 'value1' => 'move-down-second-' . uniqid(), + ]); + $this->assertStatus(201, $second); + $secondId = (int) $second['data']['slice_id']; + + try { + $moveResponse = $this->post( + 'structure/articles/' . $articleId . '/slices/' . $firstId . '/move', + ['direction' => 'movedown'], + ); + $this->assertSuccess($moveResponse); + $this->assertSame('movedown', $moveResponse['data']['direction']); + + $getFirst = $this->get('structure/articles/' . $articleId . '/slices/' . $firstId); + $getSecond = $this->get('structure/articles/' . $articleId . '/slices/' . $secondId); + $this->assertGreaterThan((int) $getSecond['data']['priority'], (int) $getFirst['data']['priority']); + } finally { + $this->delete('structure/articles/' . $articleId . '/slices/' . $secondId); + $this->delete('structure/articles/' . $articleId . '/slices/' . $firstId); + } + } + + public function testMoveArticleSliceInvalidDirection(): void + { + $articleId = self::$config['test_data']['existing_article_id']; + $clangId = self::$config['test_data']['existing_clang_id']; + $moduleId = self::$config['test_data']['existing_module_id']; + + $createResponse = $this->post('structure/articles/' . $articleId . '/slices', [ + 'module_id' => $moduleId, 'clang_id' => $clangId, 'ctype_id' => 1, + 'value1' => 'bad-direction-' . uniqid(), + ]); + if (201 !== $createResponse['status']) { + $this->markTestSkipped('Slice create not available: status ' . $createResponse['status']); + } + $sliceId = (int) $createResponse['data']['slice_id']; + + try { + $response = $this->post( + 'structure/articles/' . $articleId . '/slices/' . $sliceId . '/move', + ['direction' => 'sideways'], + ); + $this->assertStatus(400, $response); + $this->assertError($response); + } finally { + $this->delete('structure/articles/' . $articleId . '/slices/' . $sliceId); + } + } + + public function testMoveArticleSliceNotFound(): void + { + $articleId = self::$config['test_data']['existing_article_id']; + $response = $this->post( + 'structure/articles/' . $articleId . '/slices/999999/move', + ['direction' => 'moveup'], + ); + $this->assertStatus(404, $response); + $this->assertError($response); + } }