diff --git a/lib/RoutePackage/Structure.php b/lib/RoutePackage/Structure.php index 108683f..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; @@ -428,6 +429,11 @@ public function loadRoutes(): void 'required' => true, 'default' => null, ], + 'priority' => [ + 'type' => 'int', + 'required' => false, + 'default' => null, + ], ], $Values, // value1...19 $Medias, // media1...10 @@ -524,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 @@ -912,6 +946,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) { @@ -1423,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. * 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); + } }