From 87ee4365e7f51089742d1ca24687e25c3b251fb8 Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Thu, 31 Mar 2016 10:37:12 +1300 Subject: [PATCH] API Implement ChangeSets for batch publishing --- docs/en/04_Changelogs/4.0.0.md | 9 + model/UnsavedRelationList.php | 7 + model/versioning/ChangeSet.php | 331 ++++++++++++++++++++++ model/versioning/ChangeSetItem.php | 281 ++++++++++++++++++ tests/model/ChangeSetItemTest.php | 76 +++++ tests/model/ChangeSetTest.php | 439 +++++++++++++++++++++++++++++ tests/model/ChangeSetTest.yml | 17 ++ 7 files changed, 1160 insertions(+) create mode 100644 model/versioning/ChangeSet.php create mode 100644 model/versioning/ChangeSetItem.php create mode 100644 tests/model/ChangeSetItemTest.php create mode 100644 tests/model/ChangeSetTest.php create mode 100644 tests/model/ChangeSetTest.yml diff --git a/docs/en/04_Changelogs/4.0.0.md b/docs/en/04_Changelogs/4.0.0.md index 2e5af9e9d..e4ae528bc 100644 --- a/docs/en/04_Changelogs/4.0.0.md +++ b/docs/en/04_Changelogs/4.0.0.md @@ -88,6 +88,7 @@ * `$versionableExtensions` is now `private static` instead of `protected static` * `hasStages` is addded to check if an object has a given stage. * `stageTable` is added to get the table for a given class and stage. + * `ChangeSet` and `ChangeSetItem` have been added for batch publishing of versioned dataobjects. ### Front-end build tooling for CMS interface @@ -711,6 +712,14 @@ developers to declare dependencies between objects. See the By default all versioned dataobjects will automatically publish objects that they own. +### ChangeSet batch publishing + +ChangeSet objects have been added, which allow groups of objects to be published in +a single atomic transaction. + +This API will utilise the ownership API to ensure that changes to any object include +all necessary changes to owners or owned entities within the same changeset. + ### New `[image]` shortcode in `HTMLText` fields The new Ownership API relies on relationships between objects. diff --git a/model/UnsavedRelationList.php b/model/UnsavedRelationList.php index 09ddee9ed..0965b51f3 100644 --- a/model/UnsavedRelationList.php +++ b/model/UnsavedRelationList.php @@ -272,4 +272,11 @@ class UnsavedRelationList extends ArrayList implements Relation { public function dbObject($fieldName) { return singleton($this->dataClass)->dbObject($fieldName); } + + protected function extractValue($item, $key) { + if(is_numeric($item)) { + $item = DataObject::get_by_id($this->dataClass, $item); + } + return parent::extractValue($item, $key); + } } diff --git a/model/versioning/ChangeSet.php b/model/versioning/ChangeSet.php new file mode 100644 index 000000000..64a264c7c --- /dev/null +++ b/model/versioning/ChangeSet.php @@ -0,0 +1,331 @@ + 'Varchar', + 'State' => "Enum('open,published,reverted')", + ); + + private static $has_many = array( + 'Changes' => 'ChangeSetItem', + ); + + private static $defaults = array( + 'State' => 'open' + ); + + private static $has_one = array( + 'Owner' => 'Member', + ); + + /** + * Default permission to require for publishers. + * Publishers must either be able to use the campaign admin, or have all admin access. + * + * Also used as default permission for ChangeSetItem default permission. + * + * @config + * @var array + */ + private static $required_permission = array('CMS_ACCESS_CampaignAdmin', 'CMS_ACCESS_LeftAndMain'); + + /** + * Publish this changeset, then closes it. + * + * @throws Exception + */ + public function publish() { + // Logical checks prior to publish + if($this->State !== static::STATE_OPEN) { + throw new BadMethodCallException( + "ChangeSet can't be published if it has been already published or reverted." + ); + } + if(!$this->isSynced()) { + throw new ValidationException( + "ChangeSet does not include all necessary changes and cannot be published." + ); + } + if(!$this->canPublish()) { + throw new Exception("The current member does not have permission to publish this ChangeSet."); + } + + DB::get_conn()->withTransaction(function(){ + foreach($this->Changes() as $change) { + /** @var ChangeSetItem $change */ + $change->publish(); + } + + $this->State = static::STATE_PUBLISHED; + $this->write(); + }); + } + + /** + * Add a new change to this changeset. Will automatically include all owned + * changes as those are dependencies of this item. + * + * @param DataObject $object + */ + public function addObject(DataObject $object) { + if(!$this->isInDB()) { + throw new BadMethodCallException("ChangeSet must be saved before adding items"); + } + + $references = [ + 'ObjectID' => $object->ID, + 'ObjectClass' => $object->ClassName + ]; + + // Get existing item in case already added + $item = $this->Changes()->filter($references)->first(); + + if (!$item) { + $item = new ChangeSetItem($references); + $this->Changes()->add($item); + } + + $item->ReferencedBy()->removeAll(); + + $item->Added = ChangeSetItem::EXPLICITLY; + $item->write(); + + + $this->sync(); + } + + /** + * Remove an item from this changeset. Will automatically remove all changes + * which own (and thus depend on) the removed item. + * + * @param DataObject $object + */ + public function removeObject(DataObject $object) { + $item = ChangeSetItem::get()->filter([ + 'ObjectID' => $object->ID, + 'ObjectClass' => $object->ClassName, + 'ChangeSetID' => $this->ID + ])->first(); + + if ($item) { + // TODO: Handle case of implicit added item being removed. + + $item->delete(); + } + + $this->sync(); + } + + protected function implicitKey($item) { + if ($item instanceof ChangeSetItem) return $item->ObjectClass.'.'.$item->ObjectID; + return $item->ClassName.'.'.$item->ID; + } + + protected function calculateImplicit() { + /** @var string[][] $explicit List of all items that have been explicitly added to this ChangeSet */ + $explicit = array(); + + /** @var string[][] $referenced List of all items that are "referenced" by items in $explicit */ + $referenced = array(); + + /** @var string[][] $references List of which explicit items reference each thing in referenced */ + $references = array(); + + foreach ($this->Changes()->filter(['Added' => ChangeSetItem::EXPLICITLY]) as $item) { + $explicitKey = $this->implicitKey($item); + $explicit[$explicitKey] = true; + + foreach ($item->findReferenced() as $referee) { + $key = $this->implicitKey($referee); + + $referenced[$key] = [ + 'ObjectID' => $referee->ID, + 'ObjectClass' => $referee->ClassName + ]; + + $references[$key][] = $item->ID; + } + } + + /** @var string[][] $explicit List of all items that are either in $explicit, $referenced or both */ + $all = array_merge($referenced, $explicit); + + /** @var string[][] $implicit Anything that is in $all, but not in $explicit, is an implicit inclusion */ + $implicit = array_diff_key($all, $explicit); + + foreach($implicit as $key => $object) { + $implicit[$key]['ReferencedBy'] = $references[$key]; + } + + return $implicit; + } + + /** + * Add implicit changes that should be included in this changeset + * + * When an item is created or changed, all it's owned items which have + * changes are implicitly added + * + * When an item is deleted, it's owner (even if that owner does not have changes) + * is implicitly added + */ + public function sync() { + // Start a transaction (if we can) + DB::get_conn()->withTransaction(function() { + + // Get the implicitly included items for this ChangeSet + $implicit = $this->calculateImplicit(); + + // Adjust the existing implicit ChangeSetItems for this ChangeSet + foreach ($this->Changes()->filter(['Added' => ChangeSetItem::IMPLICITLY]) as $item) { + $objectKey = $this->implicitKey($item); + + // If a ChangeSetItem exists, but isn't in $implicit, it's no longer required, so delete it + if (!array_key_exists($objectKey, $implicit)) { + $item->delete(); + } + // Otherwise it is required, so update ReferencedBy and remove from $implicit + else { + $item->ReferencedBy()->setByIDList($implicit[$objectKey]['ReferencedBy']); + unset($implicit[$objectKey]); + } + } + + // Now $implicit is all those items that are implicitly included, but don't currently have a ChangeSetItem. + // So create new ChangeSetItems to match + + foreach ($implicit as $key => $props) { + $item = new ChangeSetItem($props); + $item->Added = ChangeSetItem::IMPLICITLY; + $item->ChangeSetID = $this->ID; + $item->ReferencedBy()->setByIDList($props['ReferencedBy']); + $item->write(); + } + }); + } + + /** Verify that any objects in this changeset include all owned changes */ + public function isSynced() { + $implicit = $this->calculateImplicit(); + + // Check the existing implicit ChangeSetItems for this ChangeSet + + foreach ($this->Changes()->filter(['Added' => ChangeSetItem::IMPLICITLY]) as $item) { + $objectKey = $this->implicitKey($item); + + // If a ChangeSetItem exists, but isn't in $implicit -> validation failure + if (!array_key_exists($objectKey, $implicit)) return false; + // Exists, remove from $implicit + unset($implicit[$objectKey]); + } + + // If there's anything left in $implicit -> validation failure + return empty($implicit); + } + + public function canView($member = null) { + return $this->can(__FUNCTION__, $member); + } + + public function canEdit($member = null) { + return $this->can(__FUNCTION__, $member); + } + + public function canCreate($member = null, $context = array()) { + return $this->can(__FUNCTION__, $member, $context); + } + + public function canDelete($member = null) { + return $this->can(__FUNCTION__, $member); + } + + /** + * Check if this item is allowed to be published + * + * @param Member $member + * @return bool + */ + public function canPublish($member = null) { + // All changes must be publishable + foreach($this->Changes() as $change) { + /** @var ChangeSetItem $change */ + if(!$change->canPublish($member)) { + return false; + } + } + + // Default permission + return $this->can(__FUNCTION__, $member); + } + + /** + * Check if this changeset (if published) can be reverted + * + * @param Member $member + * @return bool + */ + public function canRevert($member = null) { + // All changes must be publishable + foreach($this->Changes() as $change) { + /** @var ChangeSetItem $change */ + if(!$change->canRevert($member)) { + return false; + } + } + + // Default permission + return $this->can(__FUNCTION__, $member); + } + + /** + * Default permissions for this changeset + * + * @param string $perm + * @param Member $member + * @param array $context + * @return bool + */ + public function can($perm, $member = null, $context = array()) { + if(!$member) { + $member = Member::currentUser(); + } + + // Allow extensions to bypass default permissions, but only if + // each change can be individually published. + $extended = $this->extendedCan($perm, $member, $context); + if($extended !== null) { + return $extended; + } + + // Default permissions + return (bool)Permission::checkMember($member, $this->config()->required_permission); + } +} diff --git a/model/versioning/ChangeSetItem.php b/model/versioning/ChangeSetItem.php new file mode 100644 index 000000000..714dfe814 --- /dev/null +++ b/model/versioning/ChangeSetItem.php @@ -0,0 +1,281 @@ + 'Int', + 'VersionAfter' => 'Int', + 'Added' => "Enum('explicitly, implicitly', 'implicitly')" + ); + + private static $has_one = array( + 'ChangeSet' => 'ChangeSet', + 'Object' => 'DataObject', + ); + + private static $many_many = array( + 'ReferencedBy' => 'ChangeSetItem' + ); + + private static $belongs_many_many = array( + 'References' => 'ChangeSetItem.ReferencedBy' + ); + + private static $indexes = array( + 'ObjectUniquePerChangeSet' => array( + 'type' => 'unique', + 'value' => '"ObjectID", "ObjectClass", "ChangeSetID"' + ) + ); + + /** + * Get the type of change: none, created, deleted, modified, manymany + * + * @return string + */ + public function getChangeType() { + // Get change versions + if($this->VersionBefore || $this->VersionAfter) { + $draftVersion = $this->VersionAfter; // After publishing draft was written to stage + $liveVersion = $this->VersionBefore; // The live version before the publish + } else { + $draftVersion = Versioned::get_versionnumber_by_stage( + $this->ObjectClass, Versioned::DRAFT, $this->ObjectID, false + ); + $liveVersion = Versioned::get_versionnumber_by_stage( + $this->ObjectClass, Versioned::LIVE, $this->ObjectID, false + ); + } + + // Version comparisons + if ($draftVersion == $liveVersion) { + return self::CHANGE_NONE; + } elseif (!$liveVersion) { + return self::CHANGE_CREATED; + } elseif (!$draftVersion) { + return self::CHANGE_DELETED; + } else { + return self::CHANGE_MODIFIED; + } + } + + /** + * Find version of this object in the given stage + * + * @param string $stage + * @return Versioned|DataObject + */ + private function getObjectInStage($stage) { + return Versioned::get_by_stage($this->ObjectClass, $stage)->byID($this->ObjectID); + } + + /** + * Get all implicit objects for this change + * + * @return SS_List + */ + public function findReferenced() { + if($this->getChangeType() === ChangeSetItem::CHANGE_DELETED) { + // If deleted from stage, need to look at live record + return $this->getObjectInStage(Versioned::LIVE)->findOwners(false); + } else { + // If changed on stage, look at owned objects there + return $this->getObjectInStage(Versioned::DRAFT)->findOwned()->filterByCallback(function ($owned) { + /** @var Versioned|DataObject $owned */ + return $owned->stagesDiffer(Versioned::DRAFT, Versioned::LIVE); + }); + } + } + + /** + * Publish this item, then close it. + * + * Note: Unlike Versioned::doPublish() and Versioned::doUnpublish, this action is not recursive. + */ + public function publish() { + // Logical checks prior to publish + if(!$this->canPublish()) { + throw new Exception("The current member does not have permission to publish this ChangeSetItem."); + } + if($this->VersionBefore || $this->VersionAfter) { + throw new BadMethodCallException("This ChangeSetItem has already been published"); + } + + // Record state changed + $this->VersionAfter = Versioned::get_versionnumber_by_stage( + $this->ObjectClass, Versioned::DRAFT, $this->ObjectID, false + ); + $this->VersionBefore = Versioned::get_versionnumber_by_stage( + $this->ObjectClass, Versioned::LIVE, $this->ObjectID, false + ); + + switch($this->getChangeType()) { + case static::CHANGE_NONE: { + break; + } + case static::CHANGE_DELETED: { + // Non-recursive delete + $object = $this->getObjectInStage(Versioned::LIVE); + $object->deleteFromStage(Versioned::LIVE); + break; + } + case static::CHANGE_MODIFIED: + case static::CHANGE_CREATED: { + // Non-recursive publish + $object = $this->getObjectInStage(Versioned::DRAFT); + $object->publish(Versioned::DRAFT, Versioned::LIVE); + break; + } + } + + $this->write(); + } + + /** Reverts this item, then close it. **/ + public function revert() { + user_error('Not implemented', E_USER_ERROR); + } + + public function canView($member = null) { + return $this->can(__FUNCTION__, $member); + } + + public function canEdit($member = null) { + return $this->can(__FUNCTION__, $member); + } + + public function canCreate($member = null, $context = array()) { + return $this->can(__FUNCTION__, $member, $context); + } + + public function canDelete($member = null) { + return $this->can(__FUNCTION__, $member); + } + + /** + * Check if the BeforeVersion of this changeset can be restored to draft + * + * @param Member $member + * @return bool + */ + public function canRevert($member) { + // Just get the best version as this object may not even exist on either stage anymore. + /** @var Versioned|DataObject $object */ + $object = Versioned::get_latest_version($this->ObjectClass, $this->ObjectID); + if(!$object) { + return false; + } + + // Check change type + switch($this->getChangeType()) { + case static::CHANGE_CREATED: { + // Revert creation by deleting from stage + if(!$object->canDelete($member)) { + return false; + } + break; + } + default: { + // All other actions are typically editing draft stage + if(!$object->canEdit($member)) { + return false; + } + break; + } + } + + // If object can be published/unpublished let extensions deny + return $this->can(__FUNCTION__, $member); + } + + /** + * Check if this ChangeSetItem can be published + * + * @param Member $member + * @return bool + */ + public function canPublish($member = null) { + // Check canMethod to invoke on object + switch($this->getChangeType()) { + case static::CHANGE_DELETED: { + /** @var Versioned|DataObject $object */ + $object = Versioned::get_by_stage($this->ObjectClass, Versioned::LIVE)->byID($this->ObjectID); + if(!$object || !$object->canUnpublish($member)) { + return false; + } + break; + } + default: { + /** @var Versioned|DataObject $object */ + $object = Versioned::get_by_stage($this->ObjectClass, Versioned::DRAFT)->byID($this->ObjectID); + if(!$object || !$object->canPublish($member)) { + return false; + } + break; + } + } + + // If object can be published/unpublished let extensions deny + return $this->can(__FUNCTION__, $member); + } + + /** + * Default permissions for this ChangeSetItem + * + * @param string $perm + * @param Member $member + * @param array $context + * @return bool + */ + public function can($perm, $member = null, $context = array()) { + if(!$member) { + $member = Member::currentUser(); + } + + // Allow extensions to bypass default permissions, but only if + // each change can be individually published. + $extended = $this->extendedCan($perm, $member, $context); + if($extended !== null) { + return $extended; + } + + // Default permissions + return (bool)Permission::checkMember($member, ChangeSet::config()->required_permission); + } + +} diff --git a/tests/model/ChangeSetItemTest.php b/tests/model/ChangeSetItemTest.php new file mode 100644 index 000000000..d356f35a5 --- /dev/null +++ b/tests/model/ChangeSetItemTest.php @@ -0,0 +1,76 @@ + 'Int' + ]; + + private static $extensions = [ + "Versioned" + ]; + + function canEdit($member = null) { return true; } +} + +/** + * @package framework + * @subpackage tests + */ +class ChangeSetItemTest extends SapphireTest { + + protected $extraDataObjects = [ + 'ChangeSetItemTest_Versioned' + ]; + + function testChangeType() { + $object = new ChangeSetItemTest_Versioned(['Foo' => 1]); + $object->write(); + + $item = new ChangeSetItem([ + 'ObjectID' => $object->ID, + 'ObjectClass' => $object->ClassName + ]); + + $this->assertEquals( + ChangeSetItem::CHANGE_CREATED, $item->ChangeType, + 'New objects that aren\'t yet published should return created' + ); + + $object->doPublish(); + + $this->assertEquals( + ChangeSetItem::CHANGE_NONE, $item->ChangeType, + 'Objects that have just been published should return no change' + ); + + $object->Foo += 1; + $object->write(); + + $this->assertEquals( + ChangeSetItem::CHANGE_MODIFIED, $item->ChangeType, + 'Object that have unpublished changes written to draft should show as modified' + ); + + $object->doPublish(); + + $this->assertEquals( + ChangeSetItem::CHANGE_NONE, $item->ChangeType, + 'Objects that have just been published should return no change' + ); + + // We need to use a copy, because ID is set to 0 by delete, causing the following unpublish to fail + $objectCopy = clone $object; $objectCopy->delete(); + + $this->assertEquals( + ChangeSetItem::CHANGE_DELETED, $item->ChangeType, + 'Objects that have been deleted from draft (but not yet unpublished) should show as deleted' + ); + + $object->doUnpublish(); + + $this->assertEquals( + ChangeSetItem::CHANGE_NONE, $item->ChangeType, + 'Objects that have been deleted and then unpublished should return no change' + ); + } +} diff --git a/tests/model/ChangeSetTest.php b/tests/model/ChangeSetTest.php new file mode 100644 index 000000000..3fa767444 --- /dev/null +++ b/tests/model/ChangeSetTest.php @@ -0,0 +1,439 @@ +can(__FUNCTION__, $member); + } + + public function canDelete($member = null) { + return $this->can(__FUNCTION__, $member); + } + + public function canCreate($member = null, $context = array()) { + return $this->can(__FUNCTION__, $member, $context); + } + + public function canPublish($member = null, $context = array()) { + return $this->can(__FUNCTION__, $member, $context); + } + + public function canUnpublish($member = null, $context = array()) { + return $this->can(__FUNCTION__, $member, $context); + } + + public function can($perm, $member = null, $context = array()) { + $perms = [ + "PERM_{$perm}", + 'CAN_ALL', + ]; + return Permission::checkMember($member, $perms); + } +} + +/** + * @mixin Versioned + */ +class ChangeSetTest_Base extends DataObject implements TestOnly { + use ChangeSetTest_Permissions; + + private static $db = [ + 'Foo' => 'Int', + ]; + + private static $has_many = [ + 'Mids' => 'ChangeSetTest_Mid', + ]; + + private static $owns = [ + 'Mids', + ]; + + private static $extensions = [ + "Versioned", + ]; +} + +/** + * @mixin Versioned + */ +class ChangeSetTest_Mid extends DataObject implements TestOnly { + use ChangeSetTest_Permissions; + + private static $db = [ + 'Bar' => 'Int', + ]; + + private static $has_one = [ + 'Base' => 'ChangeSetTest_Base', + 'End' => 'ChangeSetTest_End', + ]; + + private static $owns = [ + 'End', + ]; + + private static $extensions = [ + "Versioned", + ]; +} + +/** + * @mixin Versioned + */ +class ChangeSetTest_End extends DataObject implements TestOnly { + use ChangeSetTest_Permissions; + + private static $db = [ + 'Baz' => 'Int', + ]; + + private static $extensions = [ + "Versioned", + ]; +} + +/** + * Test {@see ChangeSet} and {@see ChangeSetItem} models + */ +class ChangeSetTest extends SapphireTest { + + protected static $fixture_file = 'ChangeSetTest.yml'; + + protected $extraDataObjects = [ + 'ChangeSetTest_Base', + 'ChangeSetTest_Mid', + 'ChangeSetTest_End', + ]; + + /** + * Automatically publish all objects + */ + protected function publishAllFixtures() { + $this->logInWithPermission('ADMIN'); + foreach($this->fixtureFactory->getFixtures() as $class => $fixtures) { + foreach ($fixtures as $handle => $id) { + $this->objFromFixture($class, $handle)->doPublish(); + } + } + } + + /** + * Check that the changeset includes the given items + * + * @param ChangeSet $cs + * @param array $match Array of object fixture keys with change type values + */ + protected function assertChangeSetLooksLike($cs, $match) { + $items = $cs->Changes()->toArray(); + + foreach($match as $key => $mode) { + list($class, $identifier) = explode('.', $key); + $object = $this->objFromFixture($class, $identifier); + + foreach($items as $i => $item) { + if ($item->ObjectClass == $object->ClassName && $item->ObjectID == $object->ID && $item->Added == $mode) { + unset($items[$i]); + continue 2; + } + } + + throw new PHPUnit_Framework_ExpectationFailedException( + 'Change set didn\'t include expected item', + new \SebastianBergmann\Comparator\ComparisonFailure(array('Class' => $class, 'ID' => $object->ID, 'Added' => $mode), null, "$key => $mode", '') + ); + } + + if (count($items)) { + $extra = []; + foreach ($items as $item) $extra[] = ['Class' => $item->ObjectClass, 'ID' => $item->ObjectID, 'Added' => $item->Added, 'ChangeType' => $item->getChangeType()]; + throw new PHPUnit_Framework_ExpectationFailedException( + 'Change set included items that weren\'t expected', + new \SebastianBergmann\Comparator\ComparisonFailure(array(), $extra, '', print_r($extra, true)) + ); + } + } + + public function testRepeatedSyncIsNOP() { + $this->publishAllFixtures(); + + $cs = new ChangeSet(); + $cs->write(); + + $base = $this->objFromFixture('ChangeSetTest_Base', 'base'); + $cs->addObject($base); + + $cs->sync(); + $this->assertChangeSetLooksLike($cs, [ + 'ChangeSetTest_Base.base' => ChangeSetItem::EXPLICITLY + ]); + + $cs->sync(); + $this->assertChangeSetLooksLike($cs, [ + 'ChangeSetTest_Base.base' => ChangeSetItem::EXPLICITLY + ]); + } + + public function testSync() { + $this->publishAllFixtures(); + + $cs = new ChangeSet(); + $cs->write(); + + $base = $this->objFromFixture('ChangeSetTest_Base', 'base'); + + $cs->addObject($base); + $cs->sync(); + + $this->assertChangeSetLooksLike($cs, [ + 'ChangeSetTest_Base.base' => ChangeSetItem::EXPLICITLY + ]); + + $end = $this->objFromFixture('ChangeSetTest_End', 'end1'); + $end->Baz = 3; + $end->write(); + + $cs->sync(); + + $this->assertChangeSetLooksLike($cs, [ + 'ChangeSetTest_Base.base' => ChangeSetItem::EXPLICITLY, + 'ChangeSetTest_End.end1' => ChangeSetItem::IMPLICITLY + ]); + + $endItem = $cs->Changes()->filter('ObjectClass', 'ChangeSetTest_End')->first(); + + $this->assertEquals( + [$base->ID], + $endItem->ReferencedBy()->column("ID") + ); + + $this->assertDOSEquals([ + [ + 'Added' => ChangeSetItem::EXPLICITLY, + 'ObjectClass' => 'ChangeSetTest_Base', + 'ObjectID' => $base->ID, + 'ChangeSetID' => $cs->ID + ] + ], $endItem->ReferencedBy()); + } + + /** + * Test that sync includes implicit items + */ + public function testIsSynced() { + $this->publishAllFixtures(); + + $cs = new ChangeSet(); + $cs->write(); + + $base = $this->objFromFixture('ChangeSetTest_Base', 'base'); + $cs->addObject($base); + + $cs->sync(); + $this->assertChangeSetLooksLike($cs, [ + 'ChangeSetTest_Base.base' => ChangeSetItem::EXPLICITLY + ]); + $this->assertTrue($cs->isSynced()); + + $end = $this->objFromFixture('ChangeSetTest_End', 'end1'); + $end->Baz = 3; + $end->write(); + $this->assertFalse($cs->isSynced()); + + $cs->sync(); + + $this->assertChangeSetLooksLike($cs, [ + 'ChangeSetTest_Base.base' => ChangeSetItem::EXPLICITLY, + 'ChangeSetTest_End.end1' => ChangeSetItem::IMPLICITLY + ]); + $this->assertTrue($cs->isSynced()); + } + + + public function testCanPublish() { + // Create changeset containing all items (unpublished) + $this->logInWithPermission('ADMIN'); + $changeSet = new ChangeSet(); + $changeSet->write(); + $base = $this->objFromFixture('ChangeSetTest_Base', 'base'); + $changeSet->addObject($base); + $changeSet->sync(); + $this->assertEquals(5, $changeSet->Changes()->count()); + + // Test un-authenticated user cannot publish + Session::clear("loggedInAs"); + $this->assertFalse($changeSet->canPublish()); + + // User with only one of the necessary permissions cannot publish + $this->logInWithPermission('CMS_ACCESS_CampaignAdmin'); + $this->assertFalse($changeSet->canPublish()); + $this->logInWithPermission('PERM_canPublish'); + $this->assertFalse($changeSet->canPublish()); + + // Test user with the necessary minimum permissions can login + $this->logInWithPermission([ + 'CMS_ACCESS_CampaignAdmin', + 'PERM_canPublish' + ]); + $this->assertTrue($changeSet->canPublish()); + } + + public function testCanRevert() { + $this->markTestSkipped("Requires ChangeSet::revert to be implemented first"); + } + + public function testCanEdit() { + // Create changeset containing all items (unpublished) + $this->logInWithPermission('ADMIN'); + $changeSet = new ChangeSet(); + $changeSet->write(); + $base = $this->objFromFixture('ChangeSetTest_Base', 'base'); + $changeSet->addObject($base); + $changeSet->sync(); + $this->assertEquals(5, $changeSet->Changes()->count()); + + // Check canEdit + Session::clear("loggedInAs"); + $this->assertFalse($changeSet->canEdit()); + $this->logInWithPermission('SomeWrongPermission'); + $this->assertFalse($changeSet->canEdit()); + $this->logInWithPermission('CMS_ACCESS_CampaignAdmin'); + $this->assertTrue($changeSet->canEdit()); + } + + public function testCanCreate() { + // Check canCreate + Session::clear("loggedInAs"); + $this->assertFalse(ChangeSet::singleton()->canCreate()); + $this->logInWithPermission('SomeWrongPermission'); + $this->assertFalse(ChangeSet::singleton()->canCreate()); + $this->logInWithPermission('CMS_ACCESS_CampaignAdmin'); + $this->assertTrue(ChangeSet::singleton()->canCreate()); + } + + public function testCanDelete() { + // Create changeset containing all items (unpublished) + $this->logInWithPermission('ADMIN'); + $changeSet = new ChangeSet(); + $changeSet->write(); + $base = $this->objFromFixture('ChangeSetTest_Base', 'base'); + $changeSet->addObject($base); + $changeSet->sync(); + $this->assertEquals(5, $changeSet->Changes()->count()); + + // Check canDelete + Session::clear("loggedInAs"); + $this->assertFalse($changeSet->canDelete()); + $this->logInWithPermission('SomeWrongPermission'); + $this->assertFalse($changeSet->canDelete()); + $this->logInWithPermission('CMS_ACCESS_CampaignAdmin'); + $this->assertTrue($changeSet->canDelete()); + } + + public function testCanView() { + // Create changeset containing all items (unpublished) + $this->logInWithPermission('ADMIN'); + $changeSet = new ChangeSet(); + $changeSet->write(); + $base = $this->objFromFixture('ChangeSetTest_Base', 'base'); + $changeSet->addObject($base); + $changeSet->sync(); + $this->assertEquals(5, $changeSet->Changes()->count()); + + // Check canView + Session::clear("loggedInAs"); + $this->assertFalse($changeSet->canView()); + $this->logInWithPermission('SomeWrongPermission'); + $this->assertFalse($changeSet->canView()); + $this->logInWithPermission('CMS_ACCESS_CampaignAdmin'); + $this->assertTrue($changeSet->canView()); + } + + public function testPublish() { + $this->publishAllFixtures(); + + $base = $this->objFromFixture('ChangeSetTest_Base', 'base'); + $baseID = $base->ID; + $baseBefore = $base->Version; + $end1 = $this->objFromFixture('ChangeSetTest_End', 'end1'); + $end1ID = $end1->ID; + $end1Before = $end1->Version; + + // Create a new changest + $changeset = new ChangeSet(); + $changeset->write(); + $changeset->addObject($base); + $changeset->addObject($end1); + + // Make a lot of changes + // - ChangeSetTest_Base.base modified + // - ChangeSetTest_End.end1 deleted + // - new ChangeSetTest_Mid added + $base->Foo = 343; + $base->write(); + $baseAfter = $base->Version; + $midNew = new ChangeSetTest_Mid(); + $midNew->Bar = 39; + $midNew->write(); + $midNewID = $midNew->ID; + $midNewAfter = $midNew->Version; + $end1->delete(); + + $changeset->addObject($midNew); + + // Publish + $this->logInWithPermission('ADMIN'); + $this->assertTrue($changeset->canPublish()); + $this->assertTrue($changeset->isSynced()); + $changeset->publish(); + $this->assertEquals(ChangeSet::STATE_PUBLISHED, $changeset->State); + + // Check each item has the correct before/after version applied + $baseChange = $changeset->Changes()->filter([ + 'ObjectClass' => 'ChangeSetTest_Base', + 'ObjectID' => $baseID, + ])->first(); + $this->assertEquals((int)$baseBefore, (int)$baseChange->VersionBefore); + $this->assertEquals((int)$baseAfter, (int)$baseChange->VersionAfter); + $this->assertEquals((int)$baseChange->VersionBefore + 1, (int)$baseChange->VersionAfter); + $this->assertEquals( + (int)$baseChange->VersionAfter, + (int)Versioned::get_versionnumber_by_stage('ChangeSetTest_Base', Versioned::LIVE, $baseID) + ); + + $end1Change = $changeset->Changes()->filter([ + 'ObjectClass' => 'ChangeSetTest_End', + 'ObjectID' => $end1ID, + ])->first(); + $this->assertEquals((int)$end1Before, (int)$end1Change->VersionBefore); + $this->assertEquals(0, (int)$end1Change->VersionAfter); + $this->assertEquals( + 0, + (int)Versioned::get_versionnumber_by_stage('ChangeSetTest_End', Versioned::LIVE, $end1ID) + ); + + $midNewChange = $changeset->Changes()->filter([ + 'ObjectClass' => 'ChangeSetTest_Mid', + 'ObjectID' => $midNewID, + ])->first(); + $this->assertEquals(0, (int)$midNewChange->VersionBefore); + $this->assertEquals((int)$midNewAfter, (int)$midNewChange->VersionAfter); + $this->assertEquals( + (int)$midNewAfter, + (int)Versioned::get_versionnumber_by_stage('ChangeSetTest_Mid', Versioned::LIVE, $midNewID) + ); + + // Test trying to re-publish is blocked + $this->setExpectedException( + 'BadMethodCallException', + "ChangeSet can't be published if it has been already published or reverted." + ); + $changeset->publish(); + } + +} diff --git a/tests/model/ChangeSetTest.yml b/tests/model/ChangeSetTest.yml new file mode 100644 index 000000000..7c405610a --- /dev/null +++ b/tests/model/ChangeSetTest.yml @@ -0,0 +1,17 @@ +ChangeSetTest_Base: + base: + Foo: 1 +ChangeSetTest_End: + end1: + Baz: 1 + end2: + Baz: 2 +ChangeSetTest_Mid: + mid1: + Bar: 1 + Base: =>ChangeSetTest_Base.base + End: =>ChangeSetTest_End.end1 + mid2: + Bar: 2 + Base: =>ChangeSetTest_Base.base + End: =>ChangeSetTest_End.end2