diff --git a/config/statamic/mcp.php b/config/statamic/mcp.php index 12922a0..80a4676 100644 --- a/config/statamic/mcp.php +++ b/config/statamic/mcp.php @@ -132,14 +132,15 @@ | the gate for that domain. The '*' wildcard gates every action. | | Defaults preserve historical behaviour: 'delete' everywhere plus - | create/update on blueprints. Operators can widen the gate per - | domain — e.g. require confirmation on entries.update — without - | forking the package. + | create/update on blueprints, and destructive revision actions on + | entries. Operators can widen the gate per domain — e.g. require + | confirmation on entries.update — without forking the package. */ 'actions' => [ 'default' => ['delete'], 'blueprints' => ['create', 'update', 'delete'], - // 'entries' => ['create', 'update', 'delete', 'publish', 'unpublish'], + 'entries' => ['delete', 'restore_revision', 'publish_working_copy'], + // 'entries' => ['create', 'update', 'delete', 'publish', 'unpublish', 'restore_revision', 'publish_working_copy'], // 'globals' => ['update'], // 'terms' => ['create', 'update', 'delete'], // 'assets' => ['upload', 'update', 'delete', 'move', 'copy'], diff --git a/src/Mcp/Tools/Concerns/EnforcesResourcePolicy.php b/src/Mcp/Tools/Concerns/EnforcesResourcePolicy.php index f85847e..df815f0 100644 --- a/src/Mcp/Tools/Concerns/EnforcesResourcePolicy.php +++ b/src/Mcp/Tools/Concerns/EnforcesResourcePolicy.php @@ -99,13 +99,53 @@ protected function filterOutputFields(array $result): array return $result; } - // Filter the 'data' key in the result + // Filter the 'data' key in the result (single-item responses) if (isset($result['data']) && is_array($result['data'])) { /** @var array $resultData */ $resultData = $result['data']; $result['data'] = $policy->filterFields($domain, $resultData); } + // Filter nested 'data' within domain-specific wrapper keys (e.g. entry.data, term.data) + foreach (['entry', 'term', 'global', 'asset', 'user'] as $wrapper) { + if (! isset($result[$wrapper]) || ! is_array($result[$wrapper])) { + continue; + } + + /** @var array $wrappedItem */ + $wrappedItem = $result[$wrapper]; + + if (isset($wrappedItem['data']) && is_array($wrappedItem['data'])) { + /** @var array $wrappedData */ + $wrappedData = $wrappedItem['data']; + $wrappedItem['data'] = $policy->filterFields($domain, $wrappedData); + $result[$wrapper] = $wrappedItem; + } + } + + // Filter list responses where items live under domain-specific keys + foreach (['entries', 'terms', 'globals', 'assets', 'users'] as $listKey) { + if (! isset($result[$listKey]) || ! is_array($result[$listKey])) { + continue; + } + + /** @var array $items */ + $items = $result[$listKey]; + $result[$listKey] = array_map(function (mixed $item) use ($policy, $domain): mixed { + if (! is_array($item)) { + return $item; + } + + if (isset($item['data']) && is_array($item['data'])) { + /** @var array $itemData */ + $itemData = $item['data']; + $item['data'] = $policy->filterFields($domain, $itemData); + } + + return $item; + }, $items); + } + return $result; } @@ -139,6 +179,7 @@ private function isWriteAction(string $action): bool 'activate', 'deactivate', 'assign_role', 'remove_role', 'move', 'copy', 'upload', 'configure', 'cache_clear', 'cache_warm', 'config_set', + 'restore_revision', 'publish_working_copy', ], true); } } diff --git a/src/Mcp/Tools/Concerns/HandlesRevisions.php b/src/Mcp/Tools/Concerns/HandlesRevisions.php new file mode 100644 index 0000000..52cc516 --- /dev/null +++ b/src/Mcp/Tools/Concerns/HandlesRevisions.php @@ -0,0 +1,388 @@ +revisionsEnabled(); + } catch (\Throwable) { + return false; + } + } + + /** + * Guard clause: returns error response if revisions are not enabled. + * + * @return array|null Error response or null if OK + */ + private function requireRevisionsEnabled(EntryContract $entry): ?array + { + if (! $this->entryRevisionsEnabled($entry)) { + return $this->createErrorResponse( + 'Revisions are not enabled for this entry. Ensure Statamic Pro is licensed and revisions are enabled for this collection.' + )->toArray(); + } + + return null; + } + + /** + * Save entry data as a working copy (revision-aware update for published entries). + * + * The entry already has its data set from the normal update pipeline. + * This creates/updates a working copy matching the CP behavior. + * + * @param array $processedData + * + * @return array + */ + private function saveAsWorkingCopy(EntryContract $entry, array $processedData, ?string $message): array + { + /** @var Entry $entry */ + + // Preserve the live entry state so we don't leak draft-only values back + // into the published entry object after creating the working copy. + $originalData = $entry->data()->all(); + $originalSlug = $entry->slug(); + $originalPublished = $entry->published(); + $originalDate = $entry->collection()->dated() ? $entry->date() : null; + + // Merge processed data onto the entry so makeWorkingCopy() captures the new state + $entry->merge($processedData); + + // Create the working copy (captures revisionAttributes from entry's current state) + $workingCopy = $entry->makeWorkingCopy(); + + if ($message !== null && $message !== '') { + $workingCopy->message($message); + } + + $workingCopy->save(); + + /** @var Entry $workingCopyEntry */ + $workingCopyEntry = $entry->makeFromRevision($workingCopy); + + // Restore the published entry state in-memory to prevent stache contamination. + $entry->data($originalData); + $entry->slug($originalSlug); + $entry->published($originalPublished); + if ($entry->collection()->dated()) { + $entry->date($originalDate); + } + + return [ + 'entry' => [ + 'id' => $workingCopyEntry->id(), + 'slug' => $workingCopyEntry->slug(), + 'collection' => $workingCopyEntry->collectionHandle(), + 'site' => $workingCopyEntry->site()->handle(), + 'published' => $workingCopyEntry->published(), + 'last_modified' => $workingCopyEntry->lastModified()?->toISOString(), + 'url' => $workingCopyEntry->url(), + ], + 'updated' => true, + 'working_copy' => true, + 'revision_status' => $this->getRevisionStatusMeta($entry), + ]; + } + + /** + * List revisions for an entry. + * + * @param array $arguments + * + * @return array + */ + private function listRevisionsAction(array $arguments): array + { + $id = is_string($arguments['id'] ?? null) ? $arguments['id'] : ''; + + try { + $entry = $this->resolveRevisionEntry($arguments); + + $notFound = $this->requireResource($entry, 'Entry', $id); + if ($notFound) { + return $notFound; + } + + /** @var Entry $entry */ + $revisionsError = $this->requireRevisionsEnabled($entry); + if ($revisionsError) { + return $revisionsError; + } + + $revisions = $entry->revisions(); + + $formatted = $revisions->map(function (Revision $revision) { + return $this->formatRevisionMeta($revision); + })->values()->all(); + + return [ + 'entry_id' => $entry->id(), + 'revisions' => $formatted, + 'total' => count($formatted), + 'revision_status' => $this->getRevisionStatusMeta($entry), + ]; + } catch (\Exception $e) { + return $this->createErrorResponse("Failed to list revisions: {$e->getMessage()}")->toArray(); + } + } + + /** + * Get a specific revision by timestamp ID. + * + * @param array $arguments + * + * @return array + */ + private function getRevisionAction(array $arguments): array + { + $id = is_string($arguments['id'] ?? null) ? $arguments['id'] : ''; + $revisionId = is_string($arguments['revision_id'] ?? null) ? $arguments['revision_id'] : ''; + + try { + $entry = $this->resolveRevisionEntry($arguments); + + $notFound = $this->requireResource($entry, 'Entry', $id); + if ($notFound) { + return $notFound; + } + + /** @var Entry $entry */ + $revisionsError = $this->requireRevisionsEnabled($entry); + if ($revisionsError) { + return $revisionsError; + } + + $revision = $entry->revision($revisionId); + + if ($revision === null) { + return $this->createErrorResponse("Revision not found: {$revisionId}")->toArray(); + } + + return [ + 'entry_id' => $entry->id(), + 'revision' => [ + ...$this->formatRevisionMeta($revision), + 'attributes' => $revision->attributes(), + ], + ]; + } catch (\Exception $e) { + return $this->createErrorResponse("Failed to get revision: {$e->getMessage()}")->toArray(); + } + } + + /** + * Restore an entry from a specific revision. + * + * Mirrors the CP's RestoreEntryRevisionController: + * - Published entries: revision becomes a working copy + * - Unpublished entries: entry is updated directly from revision + * + * @param array $arguments + * + * @return array + */ + private function restoreRevisionAction(array $arguments): array + { + $id = is_string($arguments['id'] ?? null) ? $arguments['id'] : ''; + $revisionId = is_string($arguments['revision_id'] ?? null) ? $arguments['revision_id'] : ''; + + try { + $entry = $this->resolveRevisionEntry($arguments); + + $notFound = $this->requireResource($entry, 'Entry', $id); + if ($notFound) { + return $notFound; + } + + /** @var Entry $entry */ + $revisionsError = $this->requireRevisionsEnabled($entry); + if ($revisionsError) { + return $revisionsError; + } + + $revision = $entry->revision($revisionId); + + if ($revision === null) { + return $this->createErrorResponse("Revision not found: {$revisionId}")->toArray(); + } + + if ($entry->published()) { + // Published: create a working copy from the revision + $revision->toWorkingCopy()->date(now())->save(); + } else { + // Unpublished: directly update the entry from the revision + /** @var Entry $restoredEntry */ + $restoredEntry = $entry->makeFromRevision($revision); + $restoredEntry->published(false)->save(); + } + + // Clear relevant caches + $this->clearStatamicCaches(['stache', 'static']); + + // Re-fetch entry for fresh state (in-memory object is stale after restore) + $refreshed = $this->resolveRevisionEntry($arguments); + + return [ + 'entry_id' => $entry->id(), + 'restored_from' => $revisionId, + 'restored_as_working_copy' => $refreshed !== null ? $refreshed->published() : $entry->published(), + 'revision_status' => $refreshed !== null + ? $this->getRevisionStatusMeta($refreshed) + : $this->getRevisionStatusMeta($entry), + ]; + } catch (\Exception $e) { + return $this->createErrorResponse("Failed to restore revision: {$e->getMessage()}")->toArray(); + } + } + + /** + * Publish the current working copy. + * + * Uses Statamic's built-in publish() which delegates to publishWorkingCopy() + * when revisions are enabled. + * + * @param array $arguments + * + * @return array + */ + private function publishWorkingCopyAction(array $arguments): array + { + $id = is_string($arguments['id'] ?? null) ? $arguments['id'] : ''; + + try { + $entry = $this->resolveRevisionEntry($arguments); + + $notFound = $this->requireResource($entry, 'Entry', $id); + if ($notFound) { + return $notFound; + } + + /** @var Entry $entry */ + $revisionsError = $this->requireRevisionsEnabled($entry); + if ($revisionsError) { + return $revisionsError; + } + + if (! $entry->hasWorkingCopy()) { + return $this->createErrorResponse('No working copy exists to publish')->toArray(); + } + + $options = array_filter([ + 'message' => is_string($arguments['revision_message'] ?? null) ? $arguments['revision_message'] : null, + ]); + + $entry->publish($options); + + // Clear relevant caches + $this->clearStatamicCaches(['stache', 'static']); + + // Re-fetch entry for fresh state with site context + $refreshed = $this->resolveRevisionEntry($arguments); + + if ($refreshed === null) { + return $this->createErrorResponse('Failed to re-fetch entry after publishing')->toArray(); + } + + return [ + 'entry' => [ + 'id' => $refreshed->id(), + 'slug' => $refreshed->slug(), + 'collection' => $refreshed->collectionHandle(), + 'site' => $refreshed->site()->handle(), + 'published' => $refreshed->published(), + 'url' => $refreshed->url(), + ], + 'published' => true, + 'revision_status' => $this->getRevisionStatusMeta($refreshed), + ]; + } catch (\Exception $e) { + return $this->createErrorResponse("Failed to publish working copy: {$e->getMessage()}")->toArray(); + } + } + + /** + * @param array $arguments + */ + private function resolveRevisionEntry(array $arguments): ?EntryContract + { + $id = is_string($arguments['id'] ?? null) ? $arguments['id'] : ''; + $entry = \Statamic\Facades\Entry::find($id); + + if ($entry === null) { + return null; + } + + $site = $this->resolveSiteHandle($arguments); + + if ($entry->site()->handle() === $site) { + return $entry; + } + + return $entry->in($site); + } + + /** + * Format a revision for list output (metadata only, no full data snapshot). + * + * @return array + */ + private function formatRevisionMeta(Revision $revision): array + { + return [ + 'id' => (string) $revision->date()->timestamp, + 'date' => $revision->date()->toISOString(), + 'user' => $revision->user()?->id(), + 'message' => $revision->message(), + 'action' => $revision->action(), + ]; + } + + /** + * Get revision status metadata for an entry. + * + * @return array + */ + private function getRevisionStatusMeta(EntryContract $entry): array + { + /** @var Entry $entry */ + if (! $this->entryRevisionsEnabled($entry)) { + return []; + } + + $meta = [ + 'revisions_enabled' => true, + 'has_working_copy' => $entry->hasWorkingCopy(), + ]; + + if ($entry->hasWorkingCopy()) { + $workingCopy = $entry->workingCopy(); + $meta['working_copy_date'] = $workingCopy?->date()?->toISOString(); + } + + return $meta; + } +} diff --git a/src/Mcp/Tools/Concerns/RouterHelpers.php b/src/Mcp/Tools/Concerns/RouterHelpers.php index b5605d6..3aed891 100644 --- a/src/Mcp/Tools/Concerns/RouterHelpers.php +++ b/src/Mcp/Tools/Concerns/RouterHelpers.php @@ -165,6 +165,7 @@ protected function getRequiredTokenScope(string $action): ?TokenScope 'activate', 'deactivate', 'assign_role', 'remove_role', 'move', 'copy', 'upload', 'configure', 'cache_clear', 'cache_warm', 'config_set', + 'restore_revision', 'publish_working_copy', ]); return TokenScope::tryFrom("{$domain}:" . ($isWrite ? 'write' : 'read')); diff --git a/src/Mcp/Tools/Concerns/SanitizesFieldData.php b/src/Mcp/Tools/Concerns/SanitizesFieldData.php index 778cf75..3bbe5f9 100644 --- a/src/Mcp/Tools/Concerns/SanitizesFieldData.php +++ b/src/Mcp/Tools/Concerns/SanitizesFieldData.php @@ -98,6 +98,11 @@ private function sanitizeFieldCollection(Collection $fields, array $data, bool $ return $data; } + /** + * @param array|string|int|float|bool|null $value + * + * @return array|string|int|float|bool|null + */ private function sanitizeFieldValue(Field $field, mixed $value, bool $allowLegacyCoercion, string $path): mixed { if ($value === null) { @@ -363,7 +368,28 @@ private function normalizeTableCell(mixed $cell, bool $allowLegacyCoercion, stri } if (is_array($cell) && array_key_exists('value', $cell)) { - return $this->normalizeTableCell($cell['value'], $allowLegacyCoercion, $path); + $inner = $cell['value']; + + if ($inner === null) { + return null; + } + + if (is_string($inner)) { + return $inner; + } + + if (is_int($inner) || is_float($inner) || is_bool($inner)) { + return (string) $inner; + } + + // Nested array — do not recurse further to prevent stack overflow + if ($allowLegacyCoercion) { + return null; + } + + throw new FieldFormatException( + "Field [{$path}] table cell['value'] must be a scalar or null, received " . get_debug_type($inner) . '.' + ); } if ($allowLegacyCoercion) { diff --git a/src/Mcp/Tools/Routers/BlueprintsRouter.php b/src/Mcp/Tools/Routers/BlueprintsRouter.php index 58a027a..59c597b 100644 --- a/src/Mcp/Tools/Routers/BlueprintsRouter.php +++ b/src/Mcp/Tools/Routers/BlueprintsRouter.php @@ -789,7 +789,11 @@ private function generateBlueprint(array $arguments): array return $existsError; } - // Build field definitions from user-provided input + // Build field definitions from user-provided input. + // The generate action accepts a simplified format where "type" is at the + // top level, so we normalize to the standard {handle, field} indexed array + // that Statamic's Blueprint::setContents() expects. + /** @var array}> $fieldDefinitions */ $fieldDefinitions = []; foreach ($fields as $field) { if (! is_array($field)) { @@ -802,7 +806,10 @@ private function generateBlueprint(array $arguments): array } $fieldConfig = $field; unset($fieldConfig['handle']); - $fieldDefinitions[$fieldHandle] = $fieldConfig; + $fieldDefinitions[] = [ + 'handle' => $fieldHandle, + 'field' => $fieldConfig, + ]; } if (empty($fieldDefinitions)) { diff --git a/src/Mcp/Tools/Routers/EntriesRouter.php b/src/Mcp/Tools/Routers/EntriesRouter.php index 593cc4d..3cf6a59 100644 --- a/src/Mcp/Tools/Routers/EntriesRouter.php +++ b/src/Mcp/Tools/Routers/EntriesRouter.php @@ -6,6 +6,7 @@ use Cboxdk\StatamicMcp\Mcp\Tools\BaseRouter; use Cboxdk\StatamicMcp\Mcp\Tools\Concerns\ClearsCaches; +use Cboxdk\StatamicMcp\Mcp\Tools\Concerns\HandlesRevisions; use Cboxdk\StatamicMcp\Mcp\Tools\Concerns\NormalizesDateFields; use Cboxdk\StatamicMcp\Mcp\Tools\Concerns\SanitizesFieldData; use Illuminate\Contracts\JsonSchema\JsonSchema as JsonSchemaContract; @@ -21,10 +22,11 @@ use Statamic\Support\Str; #[Name('statamic-entries')] -#[Description('Manage Statamic collection entries. Use statamic-blueprints get first to understand field structure before create/update. Actions: list, get, create, update, delete, publish, unpublish.')] +#[Description('Manage Statamic collection entries. Use statamic-blueprints get first to understand field structure before create/update. Actions: list, get, create, update, delete, publish, unpublish, list_revisions, get_revision, restore_revision, publish_working_copy.')] class EntriesRouter extends BaseRouter { use ClearsCaches; + use HandlesRevisions; use NormalizesDateFields; use SanitizesFieldData; @@ -40,14 +42,18 @@ protected function defineSchema(JsonSchemaContract $schema): array ->description( 'Action to perform. Required params per action: ' . 'list (collection; optional: limit, offset, filters, include_unpublished), ' - . 'get (collection, id), ' + . 'get (collection, id; optional: version), ' . 'create (collection, data — use statamic-blueprints get to see field structure first), ' - . 'update (collection, id, data), ' + . 'update (collection, id, data; optional: revision_message), ' . 'delete (collection, id), ' - . 'publish (collection, id), ' - . 'unpublish (collection, id)' + . 'publish (collection, id; optional: revision_message), ' + . 'unpublish (collection, id; optional: revision_message), ' + . 'list_revisions (collection, id), ' + . 'get_revision (collection, id, revision_id), ' + . 'restore_revision (collection, id, revision_id), ' + . 'publish_working_copy (collection, id; optional: revision_message)' ) - ->enum(['list', 'get', 'create', 'update', 'delete', 'publish', 'unpublish']) + ->enum(['list', 'get', 'create', 'update', 'delete', 'publish', 'unpublish', 'list_revisions', 'get_revision', 'restore_revision', 'publish_working_copy']) ->required(), 'collection' => JsonSchema::string() @@ -55,7 +61,7 @@ protected function defineSchema(JsonSchemaContract $schema): array ->required(), 'id' => JsonSchema::string() - ->description('Entry UUID. Required for get, update, delete, publish, unpublish actions'), + ->description('Entry UUID. Required for get, update, delete, publish, unpublish, list_revisions, get_revision, restore_revision, publish_working_copy actions'), 'site' => JsonSchema::string() ->description('Site handle for multi-site setups. Defaults to the default site. Example: "default", "en"'), @@ -78,6 +84,16 @@ protected function defineSchema(JsonSchemaContract $schema): array 'offset' => JsonSchema::integer() ->description('Number of results to skip for pagination. Use with limit for paging'), + + 'version' => JsonSchema::string() + ->description('Which version of the entry to return for get action. "published" (default): live published data, "working_copy": current working copy data, "latest": working copy if exists else published') + ->enum(['published', 'working_copy', 'latest']), + + 'revision_message' => JsonSchema::string() + ->description('Optional message to attach to a revision (for update, publish, unpublish, publish_working_copy actions)'), + + 'revision_id' => JsonSchema::string() + ->description('Timestamp-based revision ID (required for get_revision and restore_revision actions). Use list_revisions to discover available IDs'), ]); } @@ -117,6 +133,10 @@ protected function executeAction(array $arguments): array 'delete' => $this->deleteEntry($arguments), 'publish' => $this->publishEntry($arguments), 'unpublish' => $this->unpublishEntry($arguments), + 'list_revisions' => $this->listRevisionsAction($arguments), + 'get_revision' => $this->getRevisionAction($arguments), + 'restore_revision' => $this->restoreRevisionAction($arguments), + 'publish_working_copy' => $this->publishWorkingCopyAction($arguments), default => $this->createErrorResponse("Action {$action} not supported for entries")->toArray(), }; } @@ -131,12 +151,19 @@ protected function executeAction(array $arguments): array private function validateActionRequirements(string $action, array $arguments): ?array { // ID required for specific actions - if (in_array($action, ['get', 'update', 'delete', 'publish', 'unpublish'])) { + if (in_array($action, ['get', 'update', 'delete', 'publish', 'unpublish', 'list_revisions', 'get_revision', 'restore_revision', 'publish_working_copy'])) { if (empty($arguments['id'])) { return $this->createErrorResponse("Entry ID is required for {$action} action")->toArray(); } } + // Revision ID required for get_revision and restore_revision + if (in_array($action, ['get_revision', 'restore_revision'])) { + if (empty($arguments['revision_id'])) { + return $this->createErrorResponse("Revision ID is required for {$action} action")->toArray(); + } + } + // Data required for create actions if ($action === 'create' && empty($arguments['data'])) { return $this->createErrorResponse('Data is required for create action')->toArray(); @@ -161,11 +188,11 @@ protected function getRequiredPermissions(string $action, array $arguments): arr $collection = is_string($arguments['collection'] ?? '') ? ($arguments['collection'] ?? '') : ''; return match ($action) { - 'list', 'get' => ["view {$collection} entries"], + 'list', 'get', 'list_revisions', 'get_revision' => ["view {$collection} entries"], 'create' => ["create {$collection} entries"], 'update' => ["edit {$collection} entries"], 'delete' => ["delete {$collection} entries"], - 'publish', 'unpublish' => ["publish {$collection} entries"], + 'publish', 'unpublish', 'restore_revision', 'publish_working_copy' => ["publish {$collection} entries"], default => [], }; } @@ -243,6 +270,7 @@ private function getEntry(array $arguments): array { $id = is_string($arguments['id']) ? $arguments['id'] : ''; $site = $this->resolveSiteHandle($arguments); + $version = is_string($arguments['version'] ?? null) ? $arguments['version'] : 'published'; try { $entry = Entry::find($id); @@ -260,20 +288,41 @@ private function getEntry(array $arguments): array } } - return [ + $entryVersion = $entry; + + if ($version === 'working_copy' || $version === 'latest') { + if ($this->entryRevisionsEnabled($entry) && $entry->hasWorkingCopy()) { + $workingCopy = $entry->workingCopy(); + if ($workingCopy !== null) { + /** @var \Statamic\Entries\Entry $entryVersion */ + $entryVersion = $entry->makeFromRevision($workingCopy); + } + } elseif ($version === 'working_copy') { + return $this->createErrorResponse('No working copy exists for this entry')->toArray(); + } + } + + $response = [ 'entry' => [ - 'id' => $entry->id(), - 'collection' => $entry->collectionHandle(), - 'site' => $entry->site()->handle(), - 'slug' => $entry->slug(), - 'published' => $entry->published(), - 'date' => $entry->date()?->toISOString(), - 'last_modified' => $entry->lastModified()?->toISOString(), - 'url' => $entry->url(), - 'data' => $entry->data()->all(), + 'id' => $entryVersion->id(), + 'collection' => $entryVersion->collectionHandle(), + 'site' => $entryVersion->site()->handle(), + 'slug' => $entryVersion->slug(), + 'published' => $entryVersion->published(), + 'date' => $entryVersion->date()?->toISOString(), + 'last_modified' => $entryVersion->lastModified()?->toISOString(), + 'url' => $entryVersion->url(), + 'data' => $entryVersion->data()->all(), ], ]; + // Include revision status metadata when revisions are enabled + if ($this->entryRevisionsEnabled($entry)) { + $response['revision_status'] = $this->getRevisionStatusMeta($entry); + } + + return $response; + } catch (\Exception $e) { return $this->createErrorResponse("Failed to get entry: {$e->getMessage()}")->toArray(); } @@ -394,12 +443,20 @@ private function createEntry(array $arguments): array $entry->data($data); } - $entry->save(); + // Revision-aware create: use store() which saves as unpublished + // and creates an initial revision (matches CP behavior) + if ($this->entryRevisionsEnabled($entry)) { + $entry->store([ + 'message' => is_string($arguments['revision_message'] ?? null) ? $arguments['revision_message'] : null, + ]); + } else { + $entry->save(); + } // Clear relevant caches $this->clearStatamicCaches(['stache', 'static']); - return [ + $response = [ 'entry' => [ 'id' => $entry->id(), 'slug' => $entry->slug(), @@ -413,6 +470,12 @@ private function createEntry(array $arguments): array 'created' => true, ]; + if ($this->entryRevisionsEnabled($entry)) { + $response['revision_status'] = $this->getRevisionStatusMeta($entry); + } + + return $response; + } catch (\Exception $e) { // FieldFormatException + ValidationException carry curated messages with // field paths — they reach the client through this envelope. @@ -457,6 +520,12 @@ private function updateEntry(array $arguments): array // Extract published — it's a first-class entry property if (array_key_exists('published', $data)) { + if ($this->entryRevisionsEnabled($entry)) { + return $this->createErrorResponse( + 'The published flag cannot be changed via update when revisions are enabled. Use the publish or unpublish action instead.' + )->toArray(); + } + $entry->published((bool) $data['published']); unset($data['published']); } @@ -580,12 +649,18 @@ private function updateEntry(array $arguments): array $data = $processedData; } + // Revision-aware save: if revisions enabled and entry is published, + // save to working copy instead of directly modifying the published entry + if ($this->entryRevisionsEnabled($entry) && $entry->published()) { + return $this->saveAsWorkingCopy($entry, $data, is_string($arguments['revision_message'] ?? null) ? $arguments['revision_message'] : null); + } + $entry->merge($data)->save(); // Clear relevant caches $this->clearStatamicCaches(['stache', 'static']); - return [ + $response = [ 'entry' => [ 'id' => $entry->id(), 'slug' => $entry->slug(), @@ -598,6 +673,12 @@ private function updateEntry(array $arguments): array 'updated' => true, ]; + if ($this->entryRevisionsEnabled($entry)) { + $response['revision_status'] = $this->getRevisionStatusMeta($entry); + } + + return $response; + } catch (\Exception $e) { // FieldFormatException + ValidationException carry curated messages with // field paths — they reach the client through this envelope. @@ -669,12 +750,23 @@ private function publishEntry(array $arguments): array return $notFound; } - $entry->published(true)->save(); + /** @var \Statamic\Entries\Entry $entry */ + // Use Statamic's built-in publish() which delegates to publishWorkingCopy() + // when revisions are enabled, or sets published(true)->save() otherwise + $options = array_filter([ + 'message' => is_string($arguments['revision_message'] ?? null) ? $arguments['revision_message'] : null, + ]); + + $publishedEntry = $entry->publish($options); + + if ($publishedEntry instanceof \Statamic\Entries\Entry) { + $entry = $publishedEntry; + } // Clear relevant caches $this->clearStatamicCaches(['stache', 'static']); - return [ + $response = [ 'entry' => [ 'id' => $entry->id(), 'slug' => $entry->slug(), @@ -684,6 +776,12 @@ private function publishEntry(array $arguments): array 'published' => true, ]; + if ($this->entryRevisionsEnabled($entry)) { + $response['revision_status'] = $this->getRevisionStatusMeta($entry); + } + + return $response; + } catch (\Exception $e) { return $this->createErrorResponse("Failed to publish entry: {$e->getMessage()}")->toArray(); } @@ -708,12 +806,23 @@ private function unpublishEntry(array $arguments): array return $notFound; } - $entry->published(false)->save(); + /** @var \Statamic\Entries\Entry $entry */ + // Use Statamic's built-in unpublish() which delegates to unpublishWorkingCopy() + // when revisions are enabled, or sets published(false)->save() otherwise + $options = array_filter([ + 'message' => is_string($arguments['revision_message'] ?? null) ? $arguments['revision_message'] : null, + ]); + + $unpublishedEntry = $entry->unpublish($options); + + if ($unpublishedEntry instanceof \Statamic\Entries\Entry) { + $entry = $unpublishedEntry; + } // Clear relevant caches $this->clearStatamicCaches(['stache', 'static']); - return [ + $response = [ 'entry' => [ 'id' => $entry->id(), 'slug' => $entry->slug(), @@ -722,24 +831,40 @@ private function unpublishEntry(array $arguments): array 'unpublished' => true, ]; + if ($this->entryRevisionsEnabled($entry)) { + $response['revision_status'] = $this->getRevisionStatusMeta($entry); + } + + return $response; + } catch (\Exception $e) { return $this->createErrorResponse("Failed to unpublish entry: {$e->getMessage()}")->toArray(); } } + /** + * @return array + */ public function getActions(): array { return [ 'list' => 'List entries with filtering and pagination', - 'get' => 'Get specific entry with full data', - 'create' => 'Create new entry', - 'update' => 'Update existing entry', + 'get' => 'Get specific entry with full data (supports version param for revision-aware reads)', + 'create' => 'Create new entry (uses store() with initial revision when revisions enabled)', + 'update' => 'Update existing entry (creates working copy when revisions enabled and entry is published)', 'delete' => 'Delete entry', - 'publish' => 'Publish entry', - 'unpublish' => 'Unpublish entry', + 'publish' => 'Publish entry (promotes working copy when revisions enabled)', + 'unpublish' => 'Unpublish entry (creates revision snapshot when revisions enabled)', + 'list_revisions' => 'List revision history for an entry (requires revisions enabled)', + 'get_revision' => 'Get a specific revision by timestamp ID with full data snapshot', + 'restore_revision' => 'Restore entry from a specific revision (creates working copy for published, updates directly for unpublished)', + 'publish_working_copy' => 'Publish the current working copy (requires existing working copy)', ]; } + /** + * @return array + */ public function getTypes(): array { return [ diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index beeb18c..873e3a3 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -119,7 +119,7 @@ public function bootAddon(): void /** * Register any application services. */ - public function register() + public function register(): void { parent::register(); diff --git a/tests/Feature/Routers/EntriesRevisionTest.php b/tests/Feature/Routers/EntriesRevisionTest.php new file mode 100644 index 0000000..82780b7 --- /dev/null +++ b/tests/Feature/Routers/EntriesRevisionTest.php @@ -0,0 +1,915 @@ +router = new EntriesRouter; + $this->testId = bin2hex(random_bytes(8)); + + config(['filesystems.disks.assets' => [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/disks/assets'), + ]]); + + Storage::fake('assets'); + + $this->collectionHandle = "posts-{$this->testId}"; + Collection::make($this->collectionHandle) + ->title('Posts') + ->routes("/posts-{$this->testId}/{slug}") + ->save(); + + Stache::refresh(); + } + + /** + * Enable revisions for the test collection. + */ + private function enableRevisions(): void + { + config(['statamic.revisions.enabled' => true]); + config(['statamic.editions.pro' => true]); + + /** @var \Statamic\Entries\Collection $collection */ + $collection = Collection::find($this->collectionHandle); + $collection->revisionsEnabled(true)->save(); + + Stache::refresh(); + } + + private function enableMultisite(): void + { + config(['statamic.system.multisite' => true]); + + app(Sites::class)->setSites([ + 'default' => [ + 'name' => 'Default', + 'url' => '/', + 'locale' => 'en_US', + ], + 'fr' => [ + 'name' => 'French', + 'url' => '/fr/', + 'locale' => 'fr_FR', + ], + ]); + + /** @var \Statamic\Entries\Collection $collection */ + $collection = Collection::find($this->collectionHandle); + $collection->sites(['default', 'fr'])->save(); + + Stache::refresh(); + } + + // ======================================================================== + // Backward Compatibility Tests (revisions disabled) + // ======================================================================== + + public function test_update_saves_directly_when_revisions_disabled(): void + { + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("direct-save-{$this->testId}") + ->data(['title' => 'Original']) + ->published(true); + $entry->save(); + + $result = $this->router->execute([ + 'action' => 'update', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'data' => ['title' => 'Updated'], + ]); + + $this->assertTrue($result['success']); + $this->assertTrue($result['data']['updated']); + $this->assertArrayNotHasKey('working_copy', $result['data']); + + $reloaded = Entry::find($entry->id()); + $this->assertSame('Updated', $reloaded->get('title')); + } + + public function test_publish_sets_published_true_when_revisions_disabled(): void + { + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("publish-no-rev-{$this->testId}") + ->data(['title' => 'Draft Entry']) + ->published(false); + $entry->save(); + + $result = $this->router->execute([ + 'action' => 'publish', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + ]); + + $this->assertTrue($result['success']); + $this->assertTrue($result['data']['published']); + + $reloaded = Entry::find($entry->id()); + $this->assertTrue($reloaded->published()); + } + + public function test_unpublish_sets_published_false_when_revisions_disabled(): void + { + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("unpublish-no-rev-{$this->testId}") + ->data(['title' => 'Published Entry']) + ->published(true); + $entry->save(); + + $result = $this->router->execute([ + 'action' => 'unpublish', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + ]); + + $this->assertTrue($result['success']); + $this->assertTrue($result['data']['unpublished']); + + $reloaded = Entry::find($entry->id()); + $this->assertFalse($reloaded->published()); + } + + // ======================================================================== + // Revision-Aware Tests + // ======================================================================== + + public function test_update_creates_working_copy_when_revisions_enabled_and_published(): void + { + $this->enableRevisions(); + + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("wc-update-{$this->testId}") + ->data(['title' => 'Published Original']) + ->published(true); + $entry->save(); + + $result = $this->router->execute([ + 'action' => 'update', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'data' => ['title' => 'Updated via WC'], + ]); + + $this->assertTrue($result['success']); + $this->assertTrue($result['data']['updated']); + $this->assertTrue($result['data']['working_copy']); + $this->assertTrue($result['data']['revision_status']['has_working_copy']); + + // The published entry should still have the original title + $reloaded = Entry::find($entry->id()); + $this->assertSame('Published Original', $reloaded->get('title')); + } + + public function test_update_saves_directly_for_unpublished_entry_even_with_revisions_enabled(): void + { + $this->enableRevisions(); + + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("unpub-update-{$this->testId}") + ->data(['title' => 'Unpublished Original']) + ->published(false); + $entry->save(); + + $result = $this->router->execute([ + 'action' => 'update', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'data' => ['title' => 'Updated Directly'], + ]); + + $this->assertTrue($result['success']); + $this->assertTrue($result['data']['updated']); + $this->assertArrayNotHasKey('working_copy', $result['data']); + + $reloaded = Entry::find($entry->id()); + $this->assertSame('Updated Directly', $reloaded->get('title')); + } + + public function test_get_returns_published_data_by_default(): void + { + $this->enableRevisions(); + + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("get-pub-{$this->testId}") + ->data(['title' => 'Published Version']) + ->published(true); + $entry->save(); + + $result = $this->router->execute([ + 'action' => 'get', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + ]); + + $this->assertTrue($result['success']); + $this->assertSame('Published Version', $result['data']['entry']['data']['title']); + $this->assertArrayHasKey('revision_status', $result['data']); + $this->assertTrue($result['data']['revision_status']['revisions_enabled']); + } + + public function test_get_with_version_working_copy_returns_working_copy_data(): void + { + $this->enableRevisions(); + + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("get-wc-{$this->testId}") + ->data(['title' => 'Published Version']) + ->published(true); + $entry->save(); + + // Create a working copy by updating + $this->router->execute([ + 'action' => 'update', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'data' => ['title' => 'Working Copy Version'], + ]); + + $result = $this->router->execute([ + 'action' => 'get', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'version' => 'working_copy', + ]); + + $this->assertTrue($result['success']); + $this->assertSame('Working Copy Version', $result['data']['entry']['data']['title']); + } + + public function test_get_with_version_working_copy_returns_working_copy_metadata(): void + { + $this->enableRevisions(); + + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("get-wc-meta-{$this->testId}") + ->data(['title' => 'Published Version']) + ->published(true); + $entry->save(); + + $workingCopySlug = "working-copy-{$this->testId}"; + + $update = $this->router->execute([ + 'action' => 'update', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'data' => [ + 'title' => 'Working Copy Version', + 'slug' => $workingCopySlug, + ], + ]); + + $this->assertTrue($update['success']); + + $result = $this->router->execute([ + 'action' => 'get', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'version' => 'working_copy', + ]); + + $this->assertTrue($result['success']); + $this->assertSame('Working Copy Version', $result['data']['entry']['data']['title']); + $this->assertSame($workingCopySlug, $result['data']['entry']['slug']); + } + + public function test_get_with_version_working_copy_errors_when_no_working_copy(): void + { + $this->enableRevisions(); + + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("get-wc-none-{$this->testId}") + ->data(['title' => 'Published Only']) + ->published(true); + $entry->save(); + + $result = $this->router->execute([ + 'action' => 'get', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'version' => 'working_copy', + ]); + + $this->assertFalse($result['success']); + $this->assertStringContainsString('No working copy exists', $result['errors'][0]); + } + + public function test_get_with_version_latest_returns_working_copy_when_exists(): void + { + $this->enableRevisions(); + + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("get-latest-wc-{$this->testId}") + ->data(['title' => 'Published Version']) + ->published(true); + $entry->save(); + + // Create a working copy + $this->router->execute([ + 'action' => 'update', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'data' => ['title' => 'Latest WC Version'], + ]); + + $result = $this->router->execute([ + 'action' => 'get', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'version' => 'latest', + ]); + + $this->assertTrue($result['success']); + $this->assertSame('Latest WC Version', $result['data']['entry']['data']['title']); + } + + public function test_get_with_version_latest_returns_published_when_no_working_copy(): void + { + $this->enableRevisions(); + + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("get-latest-pub-{$this->testId}") + ->data(['title' => 'Published Only Version']) + ->published(true); + $entry->save(); + + $result = $this->router->execute([ + 'action' => 'get', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'version' => 'latest', + ]); + + $this->assertTrue($result['success']); + $this->assertSame('Published Only Version', $result['data']['entry']['data']['title']); + } + + public function test_get_includes_revision_status_when_revisions_enabled(): void + { + $this->enableRevisions(); + + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("get-status-{$this->testId}") + ->data(['title' => 'Status Test']) + ->published(true); + $entry->save(); + + $result = $this->router->execute([ + 'action' => 'get', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + ]); + + $this->assertTrue($result['success']); + $this->assertArrayHasKey('revision_status', $result['data']); + $this->assertTrue($result['data']['revision_status']['revisions_enabled']); + $this->assertFalse($result['data']['revision_status']['has_working_copy']); + } + + public function test_create_with_revisions_enabled_uses_store(): void + { + $this->enableRevisions(); + + $result = $this->router->execute([ + 'action' => 'create', + 'collection' => $this->collectionHandle, + 'data' => ['title' => 'Revision Created Entry'], + 'revision_message' => 'Initial creation', + ]); + + $this->assertTrue($result['success']); + $this->assertTrue($result['data']['created']); + $this->assertArrayHasKey('revision_status', $result['data']); + + // store() saves as unpublished + $this->assertFalse($result['data']['entry']['published']); + } + + // ======================================================================== + // Publish/Unpublish with Revisions + // ======================================================================== + + public function test_publish_promotes_working_copy_when_revisions_enabled(): void + { + $this->enableRevisions(); + + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("publish-wc-{$this->testId}") + ->data(['title' => 'Original Published']) + ->published(true); + $entry->save(); + + // Create a working copy with updated data + $this->router->execute([ + 'action' => 'update', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'data' => ['title' => 'Will Be Published'], + ]); + + // Now publish (promotes working copy) + $result = $this->router->execute([ + 'action' => 'publish', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'revision_message' => 'Publishing working copy', + ]); + + $this->assertTrue($result['success']); + $this->assertTrue($result['data']['published']); + + // The entry should now have the working copy data + $reloaded = Entry::find($entry->id()); + $this->assertSame('Will Be Published', $reloaded->get('title')); + $this->assertTrue($reloaded->published()); + } + + // ======================================================================== + // Revision CRUD Actions + // ======================================================================== + + public function test_list_revisions_returns_history(): void + { + $this->enableRevisions(); + + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("list-rev-{$this->testId}") + ->data(['title' => 'Rev History Test']) + ->published(false); + $entry->save(); + + // store() creates an initial revision + $entry->store(['message' => 'First version']); + + $result = $this->router->execute([ + 'action' => 'list_revisions', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + ]); + + $this->assertTrue($result['success']); + $this->assertArrayHasKey('revisions', $result['data']); + $this->assertGreaterThanOrEqual(1, $result['data']['total']); + } + + public function test_list_revisions_returns_error_when_revisions_disabled(): void + { + // Revisions NOT enabled (default) + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("list-rev-disabled-{$this->testId}") + ->data(['title' => 'No Revisions']) + ->published(true); + $entry->save(); + + $result = $this->router->execute([ + 'action' => 'list_revisions', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + ]); + + $this->assertFalse($result['success']); + $this->assertStringContainsString('Revisions are not enabled', $result['errors'][0]); + } + + public function test_get_revision_returns_specific_snapshot(): void + { + $this->enableRevisions(); + + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("get-rev-{$this->testId}") + ->data(['title' => 'Snapshot Test']) + ->published(false); + $entry->save(); + + // Create a revision + $entry->store(['message' => 'Snapshot version']); + + // List to get the revision ID + $listResult = $this->router->execute([ + 'action' => 'list_revisions', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + ]); + + $this->assertTrue($listResult['success']); + $this->assertGreaterThanOrEqual(1, count($listResult['data']['revisions'])); + + $revisionId = $listResult['data']['revisions'][0]['id']; + + // Get that specific revision + $result = $this->router->execute([ + 'action' => 'get_revision', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'revision_id' => $revisionId, + ]); + + $this->assertTrue($result['success']); + $this->assertArrayHasKey('revision', $result['data']); + $this->assertArrayHasKey('attributes', $result['data']['revision']); + } + + public function test_get_revision_returns_error_for_invalid_id(): void + { + $this->enableRevisions(); + + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("get-rev-invalid-{$this->testId}") + ->data(['title' => 'Invalid Rev Test']) + ->published(false); + $entry->save(); + + $result = $this->router->execute([ + 'action' => 'get_revision', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'revision_id' => '9999999999', + ]); + + $this->assertFalse($result['success']); + $this->assertStringContainsString('Revision not found', $result['errors'][0]); + } + + public function test_restore_revision_creates_working_copy_for_published_entry(): void + { + $this->enableRevisions(); + + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("restore-pub-{$this->testId}") + ->data(['title' => 'Original Version']) + ->published(false); + $entry->save(); + + // Create a revision (via store) + $entry->store(['message' => 'Version to restore']); + + // Publish the entry directly to make it published + $entry->published(true)->save(); + + // List revisions to get an ID + $listResult = $this->router->execute([ + 'action' => 'list_revisions', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + ]); + + $this->assertTrue($listResult['success']); + $revisionId = $listResult['data']['revisions'][0]['id']; + + // Restore that revision + $result = $this->router->execute([ + 'action' => 'restore_revision', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'revision_id' => $revisionId, + ]); + + $this->assertTrue($result['success']); + $this->assertTrue($result['data']['restored_as_working_copy']); + } + + public function test_restore_revision_updates_directly_for_unpublished_entry(): void + { + $this->enableRevisions(); + + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("restore-unpub-{$this->testId}") + ->data(['title' => 'First Version']) + ->published(false); + $entry->save(); + + // Create a revision + $entry->store(['message' => 'First version saved']); + + // Update entry data + $entry->set('title', 'Second Version')->save(); + + // List revisions to get the original + $listResult = $this->router->execute([ + 'action' => 'list_revisions', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + ]); + + $this->assertTrue($listResult['success']); + $revisionId = $listResult['data']['revisions'][0]['id']; + + // Restore + $result = $this->router->execute([ + 'action' => 'restore_revision', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'revision_id' => $revisionId, + ]); + + $this->assertTrue($result['success']); + $this->assertFalse($result['data']['restored_as_working_copy']); + } + + public function test_publish_working_copy_succeeds(): void + { + $this->enableRevisions(); + + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("pwc-success-{$this->testId}") + ->data(['title' => 'Original']) + ->published(true); + $entry->save(); + + // Create a working copy + $this->router->execute([ + 'action' => 'update', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'data' => ['title' => 'WC Data'], + ]); + + // Publish the working copy + $result = $this->router->execute([ + 'action' => 'publish_working_copy', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'revision_message' => 'Publishing WC', + ]); + + $this->assertTrue($result['success']); + $this->assertTrue($result['data']['published']); + + // The published entry should now have the WC data + $reloaded = Entry::find($entry->id()); + $this->assertSame('WC Data', $reloaded->get('title')); + } + + public function test_publish_working_copy_errors_when_no_working_copy(): void + { + $this->enableRevisions(); + + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("pwc-none-{$this->testId}") + ->data(['title' => 'No WC']) + ->published(true); + $entry->save(); + + $result = $this->router->execute([ + 'action' => 'publish_working_copy', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + ]); + + $this->assertFalse($result['success']); + $this->assertStringContainsString('No working copy exists', $result['errors'][0]); + } + + public function test_update_rejects_published_flag_when_revisions_enabled(): void + { + $this->enableRevisions(); + + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("reject-published-{$this->testId}") + ->data(['title' => 'Published Entry']) + ->published(true); + $entry->save(); + + $result = $this->router->execute([ + 'action' => 'update', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'data' => [ + 'published' => false, + ], + ]); + + $this->assertFalse($result['success']); + $this->assertStringContainsString('published flag cannot be changed via update', $result['errors'][0]); + + $reloaded = Entry::find($entry->id()); + $this->assertNotNull($reloaded); + $this->assertTrue($reloaded->published()); + $this->assertFalse($reloaded->hasWorkingCopy()); + } + + public function test_list_revisions_honours_site_when_root_id_is_provided(): void + { + $this->enableMultisite(); + $this->enableRevisions(); + + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("multisite-root-{$this->testId}") + ->data(['title' => 'Default Entry']) + ->published(true); + $entry->save(); + + $frEntry = $entry->makeLocalization('fr'); + $frEntry->data(['title' => 'Entree Francaise']); + $frEntry->save(); + $frEntry->makeRevision()->message('French revision')->save(); + + $result = $this->router->execute([ + 'action' => 'list_revisions', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'site' => 'fr', + ]); + + $this->assertTrue($result['success']); + $this->assertSame(1, $result['data']['total']); + $this->assertSame('French revision', $result['data']['revisions'][0]['message']); + } + + // ======================================================================== + // Validation Tests + // ======================================================================== + + public function test_missing_id_for_list_revisions(): void + { + $result = $this->router->execute([ + 'action' => 'list_revisions', + 'collection' => $this->collectionHandle, + ]); + + $this->assertFalse($result['success']); + $this->assertStringContainsString('Entry ID is required for list_revisions action', $result['errors'][0]); + } + + public function test_missing_id_for_get_revision(): void + { + $result = $this->router->execute([ + 'action' => 'get_revision', + 'collection' => $this->collectionHandle, + 'revision_id' => '12345', + ]); + + $this->assertFalse($result['success']); + $this->assertStringContainsString('Entry ID is required for get_revision action', $result['errors'][0]); + } + + public function test_missing_revision_id_for_get_revision(): void + { + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("missing-revid-{$this->testId}") + ->data(['title' => 'Test']) + ->published(true); + $entry->save(); + + $result = $this->router->execute([ + 'action' => 'get_revision', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + ]); + + $this->assertFalse($result['success']); + $this->assertStringContainsString('Revision ID is required for get_revision action', $result['errors'][0]); + } + + public function test_missing_revision_id_for_restore_revision(): void + { + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("missing-revid-restore-{$this->testId}") + ->data(['title' => 'Test']) + ->published(true); + $entry->save(); + + $result = $this->router->execute([ + 'action' => 'restore_revision', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + ]); + + $this->assertFalse($result['success']); + $this->assertStringContainsString('Revision ID is required for restore_revision action', $result['errors'][0]); + } + + public function test_missing_id_for_publish_working_copy(): void + { + $result = $this->router->execute([ + 'action' => 'publish_working_copy', + 'collection' => $this->collectionHandle, + ]); + + $this->assertFalse($result['success']); + $this->assertStringContainsString('Entry ID is required for publish_working_copy action', $result['errors'][0]); + } + + // ======================================================================== + // Permission Mapping Tests + // ======================================================================== + + public function test_correct_permissions_for_list_revisions(): void + { + // This is tested indirectly through the getRequiredPermissions method + // list_revisions should require view permissions + $reflection = new \ReflectionMethod($this->router, 'getRequiredPermissions'); + $reflection->setAccessible(true); + + $permissions = $reflection->invoke($this->router, 'list_revisions', ['collection' => $this->collectionHandle]); + $this->assertEquals(["view {$this->collectionHandle} entries"], $permissions); + } + + public function test_correct_permissions_for_get_revision(): void + { + $reflection = new \ReflectionMethod($this->router, 'getRequiredPermissions'); + $reflection->setAccessible(true); + + $permissions = $reflection->invoke($this->router, 'get_revision', ['collection' => $this->collectionHandle]); + $this->assertEquals(["view {$this->collectionHandle} entries"], $permissions); + } + + public function test_correct_permissions_for_restore_revision(): void + { + $reflection = new \ReflectionMethod($this->router, 'getRequiredPermissions'); + $reflection->setAccessible(true); + + $permissions = $reflection->invoke($this->router, 'restore_revision', ['collection' => $this->collectionHandle]); + $this->assertEquals(["publish {$this->collectionHandle} entries"], $permissions); + } + + public function test_correct_permissions_for_publish_working_copy(): void + { + $reflection = new \ReflectionMethod($this->router, 'getRequiredPermissions'); + $reflection->setAccessible(true); + + $permissions = $reflection->invoke($this->router, 'publish_working_copy', ['collection' => $this->collectionHandle]); + $this->assertEquals(["publish {$this->collectionHandle} entries"], $permissions); + } + + // ======================================================================== + // Graceful Error Handling + // ======================================================================== + + public function test_revisions_graceful_when_pro_not_licensed(): void + { + // Revisions config enabled but Pro not licensed + config(['statamic.revisions.enabled' => true]); + config(['statamic.editions.pro' => false]); + + $entry = Entry::make() + ->collection($this->collectionHandle) + ->slug("no-pro-{$this->testId}") + ->data(['title' => 'No Pro']) + ->published(true); + $entry->save(); + + // Update should save directly (graceful fallback) + $result = $this->router->execute([ + 'action' => 'update', + 'collection' => $this->collectionHandle, + 'id' => $entry->id(), + 'data' => ['title' => 'Updated Without Pro'], + ]); + + $this->assertTrue($result['success']); + $this->assertTrue($result['data']['updated']); + $this->assertArrayNotHasKey('working_copy', $result['data']); + + $reloaded = Entry::find($entry->id()); + $this->assertSame('Updated Without Pro', $reloaded->get('title')); + } +}