diff --git a/appinfo/info.xml b/appinfo/info.xml index 2e534010c..261f763a5 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -1,96 +1,97 @@ - - - - deck - Deck - Personal planning and team project organization - Deck is a kanban style organization tool aimed at personal planning and project organization for teams integrated with Nextcloud. - - -- ๐Ÿ“ฅ Add your tasks to cards and put them in order -- ๐Ÿ“„ Write down additional notes in Markdown -- ๐Ÿ”– Assign labels for even better organization -- ๐Ÿ‘ฅ Share with your team, friends or family -- ๐Ÿ“Ž Attach files and embed them in your Markdown description -- ๐Ÿ’ฌ Discuss with your team using comments -- โšก Keep track of changes in the activity stream -- ๐Ÿš€ Get your project organized - - - 4.0.0-dev.1 - agpl - Julius Hรคrtl - Deck - - - - - https://deck.readthedocs.io/en/latest/User_documentation_en/ - https://deck.readthedocs.io/en/latest/API/ - - organization - office - https://github.com/nextcloud/deck - https://github.com/nextcloud/deck/issues - https://github.com/nextcloud/deck.git - https://download.bitgrid.net/nextcloud/deck/screenshots/1.0/Deck-1.png - https://download.bitgrid.net/nextcloud/deck/screenshots/1.0/Deck-2.png - - pgsql - sqlite - mysql - - - - OCA\Deck\Cron\DeleteCron - OCA\Deck\Cron\ScheduledNotifications - OCA\Deck\Cron\CardDescriptionActivity - OCA\Deck\Cron\SessionsCleanup - - - - OCA\Deck\Migration\DeletedCircleCleanup - - - OCA\Deck\Migration\LabelMismatchCleanup - - - - OCA\Deck\Command\UserExport - OCA\Deck\Command\BoardImport - OCA\Deck\Command\TransferOwnership - OCA\Deck\Command\CalendarToggle - - - - OCA\Deck\Activity\SettingChanges - OCA\Deck\Activity\SettingDescription - OCA\Deck\Activity\SettingComment - - - OCA\Deck\Activity\Filter - - - OCA\Deck\Activity\DeckProvider - - - - OCA\Deck\Provider\DeckProvider - - - - Deck - deck.page.index - deck.svg - 10 - - - - - OCA\Deck\DAV\CalendarPlugin - - - + + + + deck + Deck + Personal planning and team project organization + Deck is a kanban style organization tool aimed at personal planning and project organization for teams integrated with Nextcloud. + + +- ๐Ÿ“ฅ Add your tasks to cards and put them in order +- ๐Ÿ“„ Write down additional notes in Markdown +- ๐Ÿ”– Assign labels for even better organization +- ๐Ÿ‘ฅ Share with your team, friends or family +- ๐Ÿ“Ž Attach files and embed them in your Markdown description +- ๐Ÿ’ฌ Discuss with your team using comments +- โšก Keep track of changes in the activity stream +- ๐Ÿš€ Get your project organized + + + 4.0.0-dev.2 + agpl + Julius Hรคrtl + Deck + + + + + https://deck.readthedocs.io/en/latest/User_documentation_en/ + https://deck.readthedocs.io/en/latest/API/ + + organization + office + https://github.com/nextcloud/deck + https://github.com/nextcloud/deck/issues + https://github.com/nextcloud/deck.git + https://download.bitgrid.net/nextcloud/deck/screenshots/1.0/Deck-1.png + https://download.bitgrid.net/nextcloud/deck/screenshots/1.0/Deck-2.png + + pgsql + sqlite + mysql + + + + OCA\Deck\Cron\DeleteCron + OCA\Deck\Cron\ScheduledNotifications + OCA\Deck\Cron\CardDescriptionActivity + OCA\Deck\Cron\SessionsCleanup + + + + OCA\Deck\Migration\DeletedCircleCleanup + + + OCA\Deck\Migration\LabelMismatchCleanup + OCA\Deck\Migration\AclTimestampBackfill + + + + OCA\Deck\Command\UserExport + OCA\Deck\Command\BoardImport + OCA\Deck\Command\TransferOwnership + OCA\Deck\Command\CalendarToggle + + + + OCA\Deck\Activity\SettingChanges + OCA\Deck\Activity\SettingDescription + OCA\Deck\Activity\SettingComment + + + OCA\Deck\Activity\Filter + + + OCA\Deck\Activity\DeckProvider + + + + OCA\Deck\Provider\DeckProvider + + + + Deck + deck.page.index + deck.svg + 10 + + + + + OCA\Deck\DAV\CalendarPlugin + + + diff --git a/lib/Db/Acl.php b/lib/Db/Acl.php index 735f9a19f..52aba43b1 100644 --- a/lib/Db/Acl.php +++ b/lib/Db/Acl.php @@ -21,6 +21,10 @@ * @method void setOwner(int $owner) * @method void setToken(string $token) * @method string getToken() + * @method int getCreatedAt() + * @method void setCreatedAt(int $createdAt) + * @method int getLastModifiedAt() + * @method void setLastModifiedAt(int $lastModifiedAt) * */ class Acl extends RelationalEntity { @@ -42,6 +46,8 @@ class Acl extends RelationalEntity { protected $permissionManage = false; protected $owner = false; protected $token = null; + protected $createdAt = 0; + protected $lastModifiedAt = 0; public function __construct() { $this->addType('id', 'integer'); @@ -52,6 +58,8 @@ public function __construct() { $this->addType('type', 'integer'); $this->addType('owner', 'boolean'); $this->addType('token', 'string'); + $this->addType('createdAt', 'integer'); + $this->addType('lastModifiedAt', 'integer'); $this->addRelation('owner'); $this->addResolvable('participant'); } diff --git a/lib/Db/AclMapper.php b/lib/Db/AclMapper.php index 669ca7a5e..defba19d1 100644 --- a/lib/Db/AclMapper.php +++ b/lib/Db/AclMapper.php @@ -8,6 +8,7 @@ namespace OCA\Deck\Db; use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\Entity; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; @@ -20,7 +21,7 @@ public function __construct(IDBConnection $db) { public function findByAccessToken(string $accessToken) { $qb = $this->db->getQueryBuilder(); - $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage', 'token') + $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage', 'token', 'created_at', 'last_modified_at') ->from('deck_board_acl') ->where($qb->expr()->eq('token', $qb->createNamedParameter($accessToken, IQueryBuilder::PARAM_STR))) ->setMaxResults(1); @@ -34,7 +35,7 @@ public function findByAccessToken(string $accessToken) { */ public function findAll(int $boardId, ?int $limit = null, ?int $offset = null) { $qb = $this->db->getQueryBuilder(); - $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage', 'token') + $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage', 'token', 'created_at', 'last_modified_at') ->from('deck_board_acl') ->where($qb->expr()->eq('board_id', $qb->createNamedParameter($boardId, IQueryBuilder::PARAM_INT))) ->setMaxResults($limit) @@ -45,7 +46,7 @@ public function findAll(int $boardId, ?int $limit = null, ?int $offset = null) { public function findIn(array $boardIds, ?int $limit = null, ?int $offset = null): array { $qb = $this->db->getQueryBuilder(); - $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage') + $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage', 'created_at', 'last_modified_at') ->from('deck_board_acl') ->where($qb->expr()->in('board_id', $qb->createParameter('boardIds'))) ->setMaxResults($limit) @@ -127,4 +128,18 @@ public function findByType(int $type): array { ->where($qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT))); return $this->findEntities($qb); } + + public function insert(Entity $entity): Entity { + /** @var Acl $entity */ + $now = time(); + $entity->setCreatedAt($now); + $entity->setLastModifiedAt($now); + return parent::insert($entity); + } + + public function update(Entity $entity): Entity { + /** @var Acl $entity */ + $entity->setLastModifiedAt(time()); + return parent::update($entity); + } } diff --git a/lib/Migration/AclTimestampBackfill.php b/lib/Migration/AclTimestampBackfill.php new file mode 100644 index 000000000..aa49688d4 --- /dev/null +++ b/lib/Migration/AclTimestampBackfill.php @@ -0,0 +1,68 @@ +db->getQueryBuilder(); + $selectQb->select('a.id AS acl_id', 'b.last_modified AS board_last_modified') + ->from('deck_board_acl', 'a') + ->join('a', 'deck_boards', 'b', $selectQb->expr()->eq('b.id', 'a.board_id')) + ->where($selectQb->expr()->eq('a.created_at', $selectQb->createNamedParameter(0, IQueryBuilder::PARAM_INT))); + + $result = $selectQb->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + + if ($rows === []) { + $output->info('AclTimestampBackfill: no rows to update'); + return; + } + + // Group ACL IDs by target timestamp to issue one UPDATE per group + // rather than one UPDATE per row (avoids N+1 queries). + $now = time(); + $groups = []; + foreach ($rows as $row) { + $timestamp = ((int)$row['board_last_modified'] > 0) ? (int)$row['board_last_modified'] : $now; + $groups[$timestamp][] = (int)$row['acl_id']; + } + + $updated = 0; + foreach ($groups as $timestamp => $ids) { + // Chunk at 1000 for Oracle compatibility (same limit used by chunkQuery). + foreach (array_chunk($ids, 1000) as $chunk) { + $updateQb = $this->db->getQueryBuilder(); + $updateQb->update('deck_board_acl') + ->set('created_at', $updateQb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)) + ->set('last_modified_at', $updateQb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)) + ->where($updateQb->expr()->in('id', $updateQb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY))); + $updateQb->executeStatement(); + $updated += count($chunk); + } + } + + $output->info('AclTimestampBackfill: updated ' . $updated . ' row(s)'); + } +} diff --git a/lib/Migration/Version11002Date20260611000000.php b/lib/Migration/Version11002Date20260611000000.php new file mode 100644 index 000000000..79b1cd601 --- /dev/null +++ b/lib/Migration/Version11002Date20260611000000.php @@ -0,0 +1,41 @@ +getTable('deck_board_acl'); + + if (!$table->hasColumn('created_at')) { + $table->addColumn('created_at', 'integer', [ + 'notnull' => true, + 'default' => 0, + 'unsigned' => true, + ]); + } + + if (!$table->hasColumn('last_modified_at')) { + $table->addColumn('last_modified_at', 'integer', [ + 'notnull' => true, + 'default' => 0, + 'unsigned' => true, + ]); + } + + return $schema; + } +} diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index 44bc4e29b..1610352ae 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -400,6 +400,9 @@ public function addAcl(int $boardId, int $type, $participant, bool $edit, bool $ $acl->setPermissionEdit($edit); $acl->setPermissionShare($share); $acl->setPermissionManage($manage); + $now = time(); + $acl->setCreatedAt($now); + $acl->setLastModifiedAt($now); $newAcl = $this->aclMapper->insert($acl); $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_BOARD, $newAcl, ActivityManager::SUBJECT_BOARD_SHARE, [], $this->userId); @@ -451,6 +454,7 @@ public function updateAcl(int $id, bool $edit, bool $share, bool $manage): Acl { $acl->setPermissionEdit($edit); $acl->setPermissionShare($share); $acl->setPermissionManage($manage); + $acl->setLastModifiedAt(time()); $this->boardMapper->mapAcl($acl); $acl = $this->aclMapper->update($acl); $this->changeHelper->boardChanged($acl->getBoardId()); diff --git a/tests/integration/base-query-count.txt b/tests/integration/base-query-count.txt index ce8c60422..627806590 100644 --- a/tests/integration/base-query-count.txt +++ b/tests/integration/base-query-count.txt @@ -1 +1 @@ -93102 +96706 \ No newline at end of file diff --git a/tests/integration/import/ImportExportTest.php b/tests/integration/import/ImportExportTest.php index a9e00e0cb..586c11c3a 100644 --- a/tests/integration/import/ImportExportTest.php +++ b/tests/integration/import/ImportExportTest.php @@ -134,7 +134,7 @@ public function testReimportOcc() { ); } - public static function writeArrayStructure(string $prefix = '', array $array = [], array $skipKeyList = ['id', 'boardId', 'cardId', 'stackId', 'ETag', 'permissions', 'shared', 'version', 'done', 'referenceData', 'token']): string { + public static function writeArrayStructure(string $prefix = '', array $array = [], array $skipKeyList = ['id', 'boardId', 'cardId', 'stackId', 'ETag', 'permissions', 'shared', 'version', 'done', 'referenceData', 'token', 'createdAt', 'lastModifiedAt']): string { $output = ''; $arrayIsList = array_keys($array) === range(0, count($array) - 1); foreach ($array as $key => $value) { diff --git a/tests/unit/Db/AclMapperTest.php b/tests/unit/Db/AclMapperTest.php index af3f03196..0154ee62b 100644 --- a/tests/unit/Db/AclMapperTest.php +++ b/tests/unit/Db/AclMapperTest.php @@ -120,6 +120,34 @@ public function testFindBoardIdDatabase() { $this->assertEquals($this->boards[0]->getId(), $this->aclMapper->findBoardId($this->acls[1]->getId())); } + public function testInsertSetsCreatedAtAndLastModifiedAt(): void { + $before = time(); + $acl = $this->getAcl('user', 'timestamps_user', false, false, false, $this->boards[0]->getId()); + $inserted = $this->aclMapper->insert($acl); + $after = time(); + + $this->assertGreaterThanOrEqual($before, $inserted->getCreatedAt()); + $this->assertLessThanOrEqual($after, $inserted->getCreatedAt()); + $this->assertGreaterThanOrEqual($before, $inserted->getLastModifiedAt()); + $this->assertLessThanOrEqual($after, $inserted->getLastModifiedAt()); + + $this->aclMapper->delete($inserted); + } + + public function testUpdateChangesLastModifiedAtButNotCreatedAt(): void { + $acl = $this->getAcl('user', 'timestamps_user2', false, false, false, $this->boards[0]->getId()); + $inserted = $this->aclMapper->insert($acl); + $originalCreatedAt = $inserted->getCreatedAt(); + + $inserted->setPermissionEdit(true); + $updated = $this->aclMapper->update($inserted); + + $this->assertSame($originalCreatedAt, $updated->getCreatedAt()); + $this->assertGreaterThan(0, $updated->getLastModifiedAt()); + + $this->aclMapper->delete($updated); + } + public function tearDown(): void { parent::tearDown(); foreach ($this->acls as $acl) { diff --git a/tests/unit/Db/AclTest.php b/tests/unit/Db/AclTest.php index e1538215e..069cb98dd 100644 --- a/tests/unit/Db/AclTest.php +++ b/tests/unit/Db/AclTest.php @@ -59,7 +59,9 @@ public function testJsonSerialize() { 'permissionEdit' => true, 'permissionShare' => true, 'permissionManage' => true, - 'owner' => false + 'owner' => false, + 'createdAt' => 0, + 'lastModifiedAt' => 0, ], $acl->jsonSerialize()); $acl = $this->createAclGroup(); $this->assertEquals([ @@ -70,7 +72,9 @@ public function testJsonSerialize() { 'permissionEdit' => true, 'permissionShare' => true, 'permissionManage' => true, - 'owner' => false + 'owner' => false, + 'createdAt' => 0, + 'lastModifiedAt' => 0, ], $acl->jsonSerialize()); } @@ -85,7 +89,9 @@ public function testSetOwner() { 'permissionEdit' => true, 'permissionShare' => true, 'permissionManage' => true, - 'owner' => true + 'owner' => true, + 'createdAt' => 0, + 'lastModifiedAt' => 0, ], $acl->jsonSerialize()); } diff --git a/tests/unit/Migration/AclTimestampBackfillTest.php b/tests/unit/Migration/AclTimestampBackfillTest.php new file mode 100644 index 000000000..cc9ddc09e --- /dev/null +++ b/tests/unit/Migration/AclTimestampBackfillTest.php @@ -0,0 +1,181 @@ +db = $this->createMock(IDBConnection::class); + $this->output = $this->createMock(IOutput::class); + $this->backfill = new AclTimestampBackfill($this->db); + } + + public function testGetName(): void { + $this->assertNotEmpty($this->backfill->getName()); + } + + public function testRunNoRowsIsNoop(): void { + [$selectQb] = $this->buildSelectQb([]); + + $this->db->method('getQueryBuilder')->willReturn($selectQb); + $this->output->expects($this->once()) + ->method('info') + ->with($this->stringContains('no rows')); + + $this->backfill->run($this->output); + } + + public function testRunGroupsRowsWithSameTimestampIntoOneUpdate(): void { + // Two ACLs from the same board share the same timestamp โ†’ only 1 UPDATE + $rows = [ + ['acl_id' => 1, 'board_last_modified' => 1000000], + ['acl_id' => 2, 'board_last_modified' => 1000000], + ]; + [$selectQb] = $this->buildSelectQb($rows); + $updateQb = $this->buildUpdateQb(1); // single IN-clause UPDATE for both IDs + + $this->db->expects($this->exactly(2)) // 1 SELECT + 1 UPDATE + ->method('getQueryBuilder') + ->willReturnOnConsecutiveCalls($selectQb, $updateQb); + + $this->output->expects($this->once()) + ->method('info') + ->with($this->stringContains('2')); + + $this->backfill->run($this->output); + } + + public function testRunIssuesSeparateUpdatePerDistinctTimestamp(): void { + // Two ACLs from different boards โ†’ two distinct timestamps โ†’ 2 UPDATEs + $rows = [ + ['acl_id' => 1, 'board_last_modified' => 1000000], + ['acl_id' => 2, 'board_last_modified' => 2000000], + ]; + [$selectQb] = $this->buildSelectQb($rows); + $updateQb1 = $this->buildUpdateQb(1); + $updateQb2 = $this->buildUpdateQb(1); + + $this->db->expects($this->exactly(3)) // 1 SELECT + 2 UPDATEs + ->method('getQueryBuilder') + ->willReturnOnConsecutiveCalls($selectQb, $updateQb1, $updateQb2); + + $this->output->expects($this->once()) + ->method('info') + ->with($this->stringContains('2')); + + $this->backfill->run($this->output); + } + + public function testRunUsesBoardTimestampWhenAvailable(): void { + $rows = [['acl_id' => 1, 'board_last_modified' => 1234567]]; + [$selectQb] = $this->buildSelectQb($rows); + + $capturedTs = null; + $updateQb = $this->buildUpdateQb(1, function (mixed $value, int $type) use (&$capturedTs): void { + if ($type === IQueryBuilder::PARAM_INT) { + $capturedTs = $value; + } + }); + + $this->db->expects($this->exactly(2)) + ->method('getQueryBuilder') + ->willReturnOnConsecutiveCalls($selectQb, $updateQb); + $this->output->method('info'); + + $this->backfill->run($this->output); + + $this->assertSame(1234567, $capturedTs); + } + + public function testRunUsesCurrentTimeWhenBoardTimestampIsZero(): void { + $rows = [['acl_id' => 1, 'board_last_modified' => 0]]; + [$selectQb] = $this->buildSelectQb($rows); + + $capturedTs = null; + $before = time(); + $updateQb = $this->buildUpdateQb(1, function (mixed $value, int $type) use (&$capturedTs): void { + if ($type === IQueryBuilder::PARAM_INT) { + $capturedTs = $value; + } + }); + + $this->db->expects($this->exactly(2)) + ->method('getQueryBuilder') + ->willReturnOnConsecutiveCalls($selectQb, $updateQb); + $this->output->method('info'); + + $this->backfill->run($this->output); + $after = time(); + + $this->assertGreaterThanOrEqual($before, $capturedTs); + $this->assertLessThanOrEqual($after, $capturedTs); + } + + /** + * @return array{0: IQueryBuilder&MockObject} + */ + private function buildSelectQb(array $rows): array { + $expr = $this->createMock(IExpressionBuilder::class); + $expr->method('eq')->willReturn('1=1'); + + $result = $this->createMock(IResult::class); + $result->method('fetchAll')->willReturn($rows); + $result->expects($this->once())->method('closeCursor'); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('select')->willReturnSelf(); + $qb->method('from')->willReturnSelf(); + $qb->method('join')->willReturnSelf(); + $qb->method('where')->willReturnSelf(); + $qb->method('createNamedParameter')->willReturn('?'); + $qb->method('expr')->willReturn($expr); + $qb->method('executeQuery')->willReturn($result); + + return [$qb]; + } + + private function buildUpdateQb(int $expectedExecutions, ?\Closure $onCreateNamedParameter = null): IQueryBuilder&MockObject { + $expr = $this->createMock(IExpressionBuilder::class); + $expr->method('in')->willReturn('1=1'); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('update')->willReturnSelf(); + $qb->method('set')->willReturnSelf(); + $qb->method('where')->willReturnSelf(); + $qb->method('expr')->willReturn($expr); + $qb->expects($this->exactly($expectedExecutions))->method('executeStatement'); + + if ($onCreateNamedParameter !== null) { + $qb->method('createNamedParameter')->willReturnCallback( + function (mixed $value, int $type) use ($onCreateNamedParameter): string { + $onCreateNamedParameter($value, $type); + return '?'; + } + ); + } else { + $qb->method('createNamedParameter')->willReturn('?'); + } + + return $qb; + } +} diff --git a/tests/unit/Service/BoardServiceTest.php b/tests/unit/Service/BoardServiceTest.php index 549021bf0..e28a133a7 100644 --- a/tests/unit/Service/BoardServiceTest.php +++ b/tests/unit/Service/BoardServiceTest.php @@ -301,7 +301,16 @@ public function testAddAcl() { ->method('sendBoardShared'); $this->aclMapper->expects($this->once()) ->method('insert') - ->with($acl) + ->with($this->callback(function (Acl $actual) use ($acl) { + return $actual->getBoardId() === $acl->getBoardId() + && $actual->getType() === $acl->getType() + && $actual->getParticipant() === $acl->getParticipant() + && $actual->getPermissionEdit() === $acl->getPermissionEdit() + && $actual->getPermissionShare() === $acl->getPermissionShare() + && $actual->getPermissionManage() === $acl->getPermissionManage() + && $actual->getCreatedAt() > 0 + && $actual->getLastModifiedAt() > 0; + })) ->willReturn($acl); $this->permissionService->expects($this->any()) ->method('findUsers') @@ -407,11 +416,30 @@ public function testAddAclExtendPermission($currentUserAcl, $providedAcl, $resul $expected = clone $acl; $this->aclMapper->expects($this->once()) ->method('insert') - ->with($acl) + ->with($this->callback(function (Acl $actual) use ($acl) { + return $actual->getBoardId() === $acl->getBoardId() + && $actual->getType() === $acl->getType() + && $actual->getParticipant() === $acl->getParticipant() + && $actual->getPermissionEdit() === $acl->getPermissionEdit() + && $actual->getPermissionShare() === $acl->getPermissionShare() + && $actual->getPermissionManage() === $acl->getPermissionManage() + && $actual->getCreatedAt() > 0 + && $actual->getLastModifiedAt() > 0; + })) ->willReturn($acl); $this->eventDispatcher->expects(self::once()) ->method('dispatchTyped') - ->with(new AclCreatedEvent($acl)); + ->with($this->callback(function (AclCreatedEvent $event) use ($acl) { + $eventAcl = $event->getAcl(); + return $eventAcl->getBoardId() === $acl->getBoardId() + && $eventAcl->getType() === $acl->getType() + && $eventAcl->getParticipant() === $acl->getParticipant() + && $eventAcl->getPermissionEdit() === $acl->getPermissionEdit() + && $eventAcl->getPermissionShare() === $acl->getPermissionShare() + && $eventAcl->getPermissionManage() === $acl->getPermissionManage() + && $eventAcl->getCreatedAt() > 0 + && $eventAcl->getLastModifiedAt() > 0; + })); $this->assertEquals($expected, $this->service->addAcl( 123, Acl::PERMISSION_TYPE_USER, 'admin', $providedAcl[0], $providedAcl[1], $providedAcl[2] ));