-
- -
-
-
- -
- +{{ team.memberCount - team.members.length }}
-
-
+
+
+ -
+
+
+ -
+ +{{ team.memberCount - team.members.length }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('circles', 'Move up') }}
+
+
+
+
+
+ {{ t('circles', 'Move down') }}
+
+
+
+
+
+ {{ team.isFavorite ? t('circles', 'Remove from favorites') : t('circles', 'Add to favorites') }}
+
+
-
+
diff --git a/tests/unit/lib/Repository/TeamFavoriteRepositoryTest.php b/tests/unit/lib/Repository/TeamFavoriteRepositoryTest.php
new file mode 100644
index 000000000..3a184dac8
--- /dev/null
+++ b/tests/unit/lib/Repository/TeamFavoriteRepositoryTest.php
@@ -0,0 +1,129 @@
+db = Server::get(IDBConnection::class);
+ $this->repository = Server::get(TeamFavoriteRepository::class);
+ $this->userId = 'favorite-test-' . bin2hex(random_bytes(8));
+ $this->first = bin2hex(random_bytes(7));
+ $this->second = bin2hex(random_bytes(7));
+ $this->third = bin2hex(random_bytes(7));
+ $this->db->beginTransaction();
+ }
+
+ protected function tearDown(): void {
+ $this->db->rollBack();
+ }
+
+ public function testAppendAfterCircleDeletionKeepsOrder(): void {
+ $this->repository->add($this->userId, $this->first);
+ $this->repository->add($this->userId, $this->second);
+ $this->repository->removeCircle($this->first);
+ $this->repository->add($this->userId, $this->third);
+ self::assertSame([$this->second, $this->third], $this->repository->getCircleIds($this->userId));
+ }
+
+ public function testReorderPreservesRowsAndDoesNotDeleteNewFavorites(): void {
+ $this->repository->add($this->userId, $this->first);
+ $this->repository->add($this->userId, $this->second);
+ $before = $this->rows();
+ $this->repository->add($this->userId, $this->third);
+ $this->repository->replaceOrder($this->userId, [$this->second, $this->first]);
+ self::assertSame([$this->second, $this->first, $this->third], $this->repository->getCircleIds($this->userId));
+ $after = $this->rows();
+ foreach ($before as $index => $row) {
+ self::assertSame($row, $after[$index]);
+ }
+ }
+
+ public function testReorderCannotResurrectRemovedFavorites(): void {
+ $this->repository->add($this->userId, $this->first);
+ $this->repository->add($this->userId, $this->second);
+ $this->repository->remove($this->userId, $this->first);
+ $this->repository->replaceOrder($this->userId, [$this->second, $this->first]);
+ self::assertSame([$this->second], $this->repository->getCircleIds($this->userId));
+ }
+
+ public function testReadIncludesDirectAndInheritedMembershipButNotLostAccess(): void {
+ $singleId = bin2hex(random_bytes(7));
+ $groupId = bin2hex(random_bytes(7));
+ $memberRequest = Server::get(MemberRequest::class);
+ $memberRequest->save($this->member($this->first, $singleId, $this->userId, Member::TYPE_USER));
+ $memberRequest->save($this->member($this->second, $groupId, 'favorite-test-group', Member::TYPE_GROUP));
+ $membership = new Membership();
+ $membership->setCircleId($this->second);
+ $membership->setSingleId($singleId);
+ $membership->setLevel(Member::LEVEL_MEMBER);
+ $membership->setInheritanceFirst($groupId);
+ $membership->setInheritanceLast($groupId);
+ $membership->setInheritancePath([$groupId]);
+ $membership->setInheritanceDepth(1);
+ $memberships = Server::get(MembershipRequest::class);
+ $memberships->insert($membership);
+ foreach ([$this->first, $this->second, $this->third] as $circleId) {
+ $this->repository->add($this->userId, $circleId);
+ }
+ self::assertSame([$this->first, $this->second], $this->repository->getCircleIds($this->userId, true));
+ $memberships->delete($membership);
+ self::assertSame([$this->first], $this->repository->getCircleIds($this->userId, true));
+ }
+
+ public function testUserCleanupDoesNotAffectOtherUsers(): void {
+ $this->repository->add($this->userId, $this->first);
+ $this->repository->add($this->userId . '-other', $this->first);
+ $this->repository->removeUser($this->userId);
+ self::assertSame([], $this->repository->getCircleIds($this->userId));
+ self::assertSame([$this->first], $this->repository->getCircleIds($this->userId . '-other'));
+ }
+
+ private function member(string $circleId, string $singleId, string $userId, int $type): Member {
+ $member = new Member();
+ $member->setCircleId($circleId);
+ $member->setSingleId($singleId);
+ $member->setId(bin2hex(random_bytes(7)));
+ $member->setUserId($userId);
+ $member->setUserType($type);
+ $member->setLevel(Member::LEVEL_MEMBER);
+ $member->setStatus(Member::STATUS_MEMBER);
+ return $member;
+ }
+
+ /**
+ * @return list
>
+ */
+ private function rows(): array {
+ $query = $this->db->getQueryBuilder();
+ $query->select('id', 'circle_id', 'created')->from('circles_team_favorites')
+ ->where($query->expr()->eq('user_id', $query->createNamedParameter($this->userId)))
+ ->orderBy('id');
+ $result = $query->executeQuery();
+ $rows = $result->fetchAll();
+ $result->closeCursor();
+ return $rows;
+ }
+}
diff --git a/tests/unit/lib/Service/TeamFavoriteServiceTest.php b/tests/unit/lib/Service/TeamFavoriteServiceTest.php
new file mode 100644
index 000000000..c87927e87
--- /dev/null
+++ b/tests/unit/lib/Service/TeamFavoriteServiceTest.php
@@ -0,0 +1,106 @@
+request = $this->createMock(TeamFavoriteRepository::class);
+ $this->permissions = $this->createMock(PermissionService::class);
+ $this->locks = $this->createMock(ILockingProvider::class);
+ $user = $this->createMock(IUser::class);
+ $user->method('getUID')->willReturn('alice');
+ $session = $this->createMock(IUserSession::class);
+ $session->method('getUser')->willReturn($user);
+ $this->service = new TeamFavoriteService($this->request, $this->permissions, $session, $this->locks);
+ }
+
+ public function testReadOnlyReturnsAccessibleFavorites(): void {
+ $this->request->expects(self::once())->method('getCircleIds')->with('alice', true)->willReturn(['B']);
+ $this->request->expects(self::never())->method('remove');
+ self::assertSame(['B'], $this->service->getFavoriteCircleIds());
+ }
+
+ public function testReorderPrunesFormerMembershipAndReturnsCanonicalOrder(): void {
+ $this->request->method('getCircleIds')->willReturnOnConsecutiveCalls(['gone', 'A', 'B'], ['A', 'B'], ['B', 'A']);
+ $this->request->expects(self::once())->method('remove')->with('alice', 'gone');
+ $this->request->expects(self::once())->method('replaceOrder')->with('alice', ['B', 'A']);
+ $this->permissions->expects(self::never())->method('userMustBeMember');
+ $this->expectLockReleased();
+ self::assertSame(['B', 'A'], $this->service->reorder(['B', 'A'], ['A', 'B']));
+ }
+
+ public function testStaleOrderIsRejectedWithoutOverwritingAnotherTab(): void {
+ $this->request->method('getCircleIds')->willReturn(['A', 'B', 'C']);
+ $this->request->expects(self::never())->method('replaceOrder');
+ $this->expectLockReleased();
+ $this->expectException(OCSException::class);
+ $this->expectExceptionCode(409);
+ $this->service->reorder(['B', 'A'], ['A', 'B']);
+ }
+
+ public function testChangedOrderWithSameFavoritesIsRejected(): void {
+ $this->request->method('getCircleIds')->willReturn(['B', 'A']);
+ $this->request->expects(self::never())->method('replaceOrder');
+ $this->expectLockReleased();
+ $this->expectException(OCSException::class);
+ $this->expectExceptionCode(409);
+ $this->service->reorder(['B', 'A'], ['A', 'B']);
+ }
+
+ public function testDuplicateIdsAreRejectedBeforeAccessingDatabase(): void {
+ $this->request->expects(self::never())->method('getCircleIds');
+ $this->expectException(OCSException::class);
+ $this->expectExceptionCode(400);
+ $this->service->reorder(['A', 'A'], ['A', 'B']);
+ }
+
+ public function testUnchangedOrderDoesNotWrite(): void {
+ $this->request->method('getCircleIds')->willReturn(['A']);
+ $this->request->expects(self::never())->method('replaceOrder');
+ $this->expectLockReleased();
+ self::assertSame(['A'], $this->service->reorder(['A'], ['A']));
+ }
+
+ public function testAlreadyFavoriteIsIdempotent(): void {
+ $this->request->method('getCircleIds')->willReturn(['A']);
+ $this->request->expects(self::never())->method('add');
+ $this->expectLockReleased();
+ self::assertSame(['A'], $this->service->setFavorite('A', true));
+ }
+
+ public function testLockIsReleasedOnDatabaseFailure(): void {
+ $this->request->method('getCircleIds')->willReturn(['A', 'B']);
+ $this->request->method('replaceOrder')->willThrowException(new \RuntimeException('database unavailable'));
+ $this->expectLockReleased();
+ $this->expectException(\RuntimeException::class);
+ $this->service->reorder(['B', 'A'], ['A', 'B']);
+ }
+
+ private function expectLockReleased(): void {
+ $lock = 'circles:team-favorites:' . hash('sha256', 'alice');
+ $this->locks->expects(self::once())->method('acquireLock')->with($lock, ILockingProvider::LOCK_EXCLUSIVE);
+ $this->locks->expects(self::once())->method('releaseLock')->with($lock, ILockingProvider::LOCK_EXCLUSIVE);
+ }
+}