diff --git a/appinfo/info.xml b/appinfo/info.xml index 08e2c7f2b..73b023b6c 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -12,7 +12,7 @@ Those groups of people can then be used by any other app for sharing purpose. ]]> - 36.0.0-dev.0 + 36.0.0-dev.1 AGPL-3.0-or-later Maxence Lange diff --git a/appinfo/routes.php b/appinfo/routes.php index ba43f0f51..5ae7dbd85 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -53,6 +53,9 @@ ['name' => 'TeamFolder#getLinkableTeamFolders', 'url' => '/teams/{circleId}/folder/linkable', 'verb' => 'GET'], ['name' => 'TeamFolder#linkTeamFolder', 'url' => '/teams/{circleId}/folder/link', 'verb' => 'POST'], ['name' => 'TeamFolder#updateTeamFolderQuota', 'url' => '/teams/{circleId}/folder/quota', 'verb' => 'PUT'], + ['name' => 'TeamFavorites#getFavorites', 'url' => '/teams/favorites', 'verb' => 'GET'], + ['name' => 'TeamFavorites#setFavorite', 'url' => '/teams/{circleId}/favorite', 'verb' => 'PUT'], + ['name' => 'TeamFavorites#reorder', 'url' => '/teams/favorites/order', 'verb' => 'PUT'], // Teams Dashboard widget endpoint ['name' => 'TeamsDashboard#getCompleteTeamsData', 'url' => '/teams/dashboard/widget', 'verb' => 'GET'], diff --git a/lib/Controller/TeamFavoritesController.php b/lib/Controller/TeamFavoritesController.php new file mode 100644 index 000000000..ced460565 --- /dev/null +++ b/lib/Controller/TeamFavoritesController.php @@ -0,0 +1,55 @@ + $this->teamFavoriteService->getFavoriteCircleIds(), + ]); + } + + #[NoAdminRequired] + public function setFavorite(string $circleId, bool $isFavorite): DataResponse { + $circleIds = $this->teamFavoriteService->setFavorite($circleId, $isFavorite); + + return new DataResponse([ + 'circleId' => $circleId, + 'isFavorite' => $isFavorite, + 'circleIds' => $circleIds, + ]); + } + + /** + * @param list $circleIds + * @param list $expectedCircleIds + */ + #[NoAdminRequired] + public function reorder(array $circleIds, array $expectedCircleIds): DataResponse { + return new DataResponse([ + 'circleIds' => $this->teamFavoriteService->reorder($circleIds, $expectedCircleIds), + ]); + } +} diff --git a/lib/Db/CircleRequest.php b/lib/Db/CircleRequest.php index 99436aba0..93ff586c2 100644 --- a/lib/Db/CircleRequest.php +++ b/lib/Db/CircleRequest.php @@ -21,6 +21,9 @@ use OCA\Circles\Model\Member; use OCA\Circles\Model\Probes\CircleProbe; use OCA\Circles\Model\Probes\DataProbe; +use OCA\Circles\Repository\TeamFavoriteRepository; +use OCA\Circles\Service\ConfigService; +use OCA\Circles\Service\TimezoneService; use OCP\DB\QueryBuilder\IQueryBuilder; /** @@ -29,6 +32,14 @@ * @package OCA\Circles\Db */ class CircleRequest extends CircleRequestBuilder { + public function __construct( + private readonly TeamFavoriteRepository $teamFavoriteRepository, + TimezoneService $timezoneService, + ConfigService $configService, + ) { + parent::__construct($timezoneService, $configService); + } + /** * @param Circle $circle * @@ -523,6 +534,7 @@ public function delete(Circle $circle): void { $qb->limitToUniqueId($circle->getSingleId()); $qb->executeStatement(); + $this->teamFavoriteRepository->removeCircle($circle->getSingleId()); } /** diff --git a/lib/Entity/TeamFavorite.php b/lib/Entity/TeamFavorite.php new file mode 100644 index 000000000..a73667d69 --- /dev/null +++ b/lib/Entity/TeamFavorite.php @@ -0,0 +1,40 @@ +created = new DateTime('now', new DateTimeZone('UTC')); + } +} diff --git a/lib/Listeners/UserDeleted.php b/lib/Listeners/UserDeleted.php index 2cfee73e2..e7e906d25 100644 --- a/lib/Listeners/UserDeleted.php +++ b/lib/Listeners/UserDeleted.php @@ -10,6 +10,7 @@ namespace OCA\Circles\Listeners; use Exception; +use OCA\Circles\Repository\TeamFavoriteRepository; use OCA\Circles\Service\SyncService; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; @@ -24,6 +25,7 @@ class UserDeleted implements IEventListener { */ public function __construct( private readonly SyncService $syncService, + private readonly TeamFavoriteRepository $teamFavoriteRepository, ) { } @@ -36,6 +38,7 @@ public function handle(Event $event): void { } $user = $event->getUser(); + $this->teamFavoriteRepository->removeUser($user->getUID()); try { $this->syncService->userDeleted($user->getUID()); diff --git a/lib/Migration/Version20260922120000.php b/lib/Migration/Version20260922120000.php new file mode 100644 index 000000000..a5c723f2b --- /dev/null +++ b/lib/Migration/Version20260922120000.php @@ -0,0 +1,63 @@ +hasTable('circles_team_favorites')) { + $table = $schema->createTable('circles_team_favorites'); + $table->addColumn('id', ColumnType::Integer, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 8, + 'unsigned' => true, + ]); + $table->addColumn('user_id', ColumnType::String, [ + 'length' => 255, + 'notnull' => true, + ]); + $table->addColumn('circle_id', ColumnType::String, [ + 'length' => 32, + 'notnull' => true, + ]); + $table->addColumn('position', ColumnType::Integer, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('created', ColumnType::Datetime, [ + 'notnull' => true, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['user_id', 'circle_id']); + $table->addIndex(['user_id', 'position']); + $table->addIndex(['circle_id']); + } + + return $schema; + } +} diff --git a/lib/Repository/TeamFavoriteRepository.php b/lib/Repository/TeamFavoriteRepository.php new file mode 100644 index 000000000..79e588321 --- /dev/null +++ b/lib/Repository/TeamFavoriteRepository.php @@ -0,0 +1,107 @@ + + */ +class TeamFavoriteRepository extends Repository { + public const string entityClass = TeamFavorite::class; + + /** + * @return list + */ + public function getCircleIds(string $userId, bool $accessibleOnly = false): array { + $qb = $this->connection->getQueryBuilder(); + $qb->selectDistinct(['favorite.circle_id', 'favorite.position']) + ->from($this->getTableName(), 'favorite') + ->where($qb->expr()->eq('favorite.user_id', $qb->createNamedParameter($userId))); + + if ($accessibleOnly) { + $expr = $qb->expr(); + $qb->innerJoin('favorite', 'circles_member', 'user_member', + $expr->eq('user_member.user_id', 'favorite.user_id')); + $qb->leftJoin('user_member', 'circles_membership', 'membership', $expr->andX( + $expr->eq('membership.single_id', 'user_member.single_id'), + $expr->eq('membership.circle_id', 'favorite.circle_id'), + )); + $qb->leftJoin('membership', 'circles_member', 'inherited_member', $expr->andX( + $expr->eq('inherited_member.single_id', 'membership.inheritance_first'), + $expr->eq('inherited_member.circle_id', 'favorite.circle_id'), + )); + $qb->andWhere($expr->orX( + $expr->eq('user_member.circle_id', 'favorite.circle_id'), + $expr->isNotNull('inherited_member.single_id'), + )); + } + + $qb->orderBy('favorite.position', 'ASC') + ->addOrderBy('favorite.circle_id', 'ASC'); + + $result = $qb->executeQuery(); + $circleIds = []; + while ($row = $result->fetch()) { + $circleIds[] = $row['circle_id']; + } + $result->closeCursor(); + + return $circleIds; + } + + public function add(string $userId, string $circleId): void { + $last = iterator_to_array($this->findBy( + ['userId' => $userId], + ['position' => \SortDirection::Descending], + 1, + ), false); + + $favorite = new TeamFavorite(); + $favorite->userId = $userId; + $favorite->circleId = $circleId; + $favorite->position = $last === [] ? 0 : $last[0]->position + 1; + $this->insert($favorite); + } + + public function remove(string $userId, string $circleId): void { + $this->deleteBy(['userId' => $userId, 'circleId' => $circleId]); + } + + /** + * @param list $circleIds + */ + public function replaceOrder(string $userId, array $circleIds): void { + $positions = array_flip($circleIds); + $this->connection->beginTransaction(); + try { + foreach ($this->findBy(['userId' => $userId]) as $favorite) { + if (!isset($positions[$favorite->circleId])) { + continue; + } + $favorite->position = $positions[$favorite->circleId]; + $this->update($favorite); + } + $this->connection->commit(); + } catch (\Throwable $exception) { + $this->connection->rollBack(); + throw $exception; + } + } + + public function removeCircle(string $circleId): void { + $this->deleteBy(['circleId' => $circleId]); + } + + public function removeUser(string $userId): void { + $this->deleteBy(['userId' => $userId]); + } +} diff --git a/lib/Service/TeamFavoriteService.php b/lib/Service/TeamFavoriteService.php new file mode 100644 index 000000000..25cae6fe0 --- /dev/null +++ b/lib/Service/TeamFavoriteService.php @@ -0,0 +1,121 @@ + + */ + public function getFavoriteCircleIds(): array { + return $this->teamFavoriteRepository->getCircleIds($this->getCurrentUserId(), true); + } + + /** + * @return list + */ + public function setFavorite(string $circleId, bool $isFavorite): array { + $userId = $this->getCurrentUserId(); + $lock = $this->acquireLock($userId); + try { + $favoriteCircleIds = $this->pruneFavorites($userId); + if (!$isFavorite) { + $this->teamFavoriteRepository->remove($userId, $circleId); + } elseif (!in_array($circleId, $favoriteCircleIds, true)) { + try { + $this->permissionService->userMustBeMember($userId, $circleId); + } catch (InsufficientPermissionException $exception) { + throw new OCSException($exception->getMessage(), Http::STATUS_FORBIDDEN); + } + $this->teamFavoriteRepository->add($userId, $circleId); + } + return $this->teamFavoriteRepository->getCircleIds($userId, true); + } finally { + $this->lockingProvider->releaseLock($lock, ILockingProvider::LOCK_EXCLUSIVE); + } + } + + /** + * @param list $circleIds + * @param list $expectedCircleIds + * @return list + */ + public function reorder(array $circleIds, array $expectedCircleIds): array { + foreach ([$circleIds, $expectedCircleIds] as $ids) { + if (!array_is_list($ids) || array_filter($ids, 'is_string') !== $ids + || count($ids) !== count(array_unique($ids))) { + throw new OCSException('Invalid favorite team order.', Http::STATUS_BAD_REQUEST); + } + } + if (count($circleIds) !== count($expectedCircleIds) || array_diff($circleIds, $expectedCircleIds) !== []) { + throw new OCSException('Invalid favorite team order.', Http::STATUS_BAD_REQUEST); + } + $userId = $this->getCurrentUserId(); + $lock = $this->acquireLock($userId); + try { + $currentCircleIds = $this->pruneFavorites($userId); + if ($expectedCircleIds !== $currentCircleIds) { + throw new OCSException('Favorite teams have changed. Reload before reordering.', Http::STATUS_CONFLICT); + } + if ($circleIds !== $currentCircleIds) { + $this->teamFavoriteRepository->replaceOrder($userId, $circleIds); + } + return $this->teamFavoriteRepository->getCircleIds($userId, true); + } finally { + $this->lockingProvider->releaseLock($lock, ILockingProvider::LOCK_EXCLUSIVE); + } + } + + /** + * @return list + */ + private function pruneFavorites(string $userId): array { + $stored = $this->teamFavoriteRepository->getCircleIds($userId); + $accessible = $this->teamFavoriteRepository->getCircleIds($userId, true); + foreach (array_diff($stored, $accessible) as $circleId) { + $this->teamFavoriteRepository->remove($userId, $circleId); + } + return $accessible; + } + + private function acquireLock(string $userId): string { + $lock = 'circles:team-favorites:' . hash('sha256', $userId); + try { + $this->lockingProvider->acquireLock($lock, ILockingProvider::LOCK_EXCLUSIVE); + } catch (LockedException) { + throw new OCSException('Favorite teams are being updated. Please retry.', Http::STATUS_CONFLICT); + } + return $lock; + } + + private function getCurrentUserId(): string { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new OCSException('No authenticated user.', Http::STATUS_UNAUTHORIZED); + } + + return $user->getUID(); + } +} diff --git a/src/teams/api.spec.ts b/src/teams/api.spec.ts index 44e0609f2..e85ec1408 100644 --- a/src/teams/api.spec.ts +++ b/src/teams/api.spec.ts @@ -32,7 +32,7 @@ vi.mock('./team-page/services/collaborationAutocompletion.js', () => ({ getSuggestions: vi.fn(), })) -const { createTeam, fetchTeams } = await import('./api.ts') +const { createTeam, fetchTeams, setTeamFavorite, reorderFavoriteTeams } = await import('./api.ts') describe('fetchTeams', () => { it('drops circles the current user is not a member of', async () => { @@ -43,6 +43,7 @@ describe('fetchTeams', () => { vi.mocked(axios.get) .mockResolvedValueOnce({ data: { ocs: { data: circles } } }) .mockResolvedValueOnce({ data: { ocs: { data: [] } } }) + .mockResolvedValueOnce({ data: { ocs: { data: { circleIds: [] } } } }) const teams = await fetchTeams() @@ -55,6 +56,7 @@ describe('fetchTeams', () => { vi.mocked(axios.get) .mockResolvedValueOnce({ data: { ocs: {} } }) .mockResolvedValueOnce({ data: { ocs: { data: [] } } }) + .mockResolvedValueOnce({ data: { ocs: { data: { circleIds: [] } } } }) await expect(fetchTeams()).resolves.toEqual([]) }) @@ -85,3 +87,30 @@ describe('createTeam', () => { ) }) }) + +describe('favorite teams API', () => { + beforeEach(() => vi.resetAllMocks()) + + it('returns the canonical favorite list after a toggle', async () => { + vi.mocked(axios.put).mockResolvedValue({ data: { ocs: { data: { circleIds: ['A', 'B'] } } } }) + expect(await setTeamFavorite('B', true)).toEqual(['A', 'B']) + expect(axios.put).toHaveBeenCalledWith(expect.stringContaining('/favorite'), { isFavorite: true }) + }) + + it('includes the expected order and reads the canonical response', async () => { + vi.mocked(axios.put).mockResolvedValue({ data: { ocs: { data: { circleIds: ['B', 'A'] } } } }) + expect(await reorderFavoriteTeams(['B', 'A'], ['A', 'B'])).toEqual(['B', 'A']) + expect(axios.put).toHaveBeenCalledWith('/ocs/apps/circles/teams/favorites/order', { + circleIds: ['B', 'A'], + expectedCircleIds: ['A', 'B'], + }) + }) + + it('still loads teams when favorites cannot be read', async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ data: { ocs: { data: [{ id: 'A', name: 'A', displayName: 'A', initiator: { level: 1 } }] } } }) + .mockResolvedValueOnce({ data: { ocs: { data: [] } } }) + .mockRejectedValueOnce(new Error('favorites unavailable')) + const teams = await fetchTeams() + expect(teams.map((team) => [team.id, team.isFavorite])).toEqual([['A', false]]) + }) +}) diff --git a/src/teams/api.ts b/src/teams/api.ts index 9de42fae6..f5d66fe7d 100644 --- a/src/teams/api.ts +++ b/src/teams/api.ts @@ -140,9 +140,10 @@ function mapFullMember(raw: RawMember): Member { * description, member count, our role) with the members preview and resources. */ export async function fetchTeams(): Promise { - const [circlesRes, dashRes] = await Promise.allSettled([ + const [circlesRes, dashRes, favoritesRes] = await Promise.allSettled([ axios.get>(generateOcsUrl('apps/circles/circles') + '?limit=-1'), axios.get>(generateOcsUrl('apps/circles/teams/dashboard/widget') + '?limit=200&offset=0'), + axios.get>(generateOcsUrl('apps/circles/teams/favorites')), ]) // The team list is required; without it we have nothing to show. @@ -160,6 +161,13 @@ export async function fetchTeams(): Promise { logger.warn('Failed to load team dashboard previews', { error: dashRes.reason }) } const dashboardById = new Map(dashboard.map((team) => [team.singleId, team])) + let favoriteCircleIds: string[] = [] + if (favoritesRes.status === 'fulfilled') { + favoriteCircleIds = favoritesRes.value.data.ocs.data.circleIds + } else { + logger.warn('Failed to load favorite teams', { error: favoritesRes.reason }) + } + const favoritePositions = new Map(favoriteCircleIds.map((circleId, position) => [circleId, position])) return circles.map((circle) => { const extra = dashboardById.get(circle.id) @@ -167,6 +175,8 @@ export async function fetchTeams(): Promise { id: circle.id, displayName: circle.displayName || circle.name, description: circle.description ?? '', + isFavorite: favoritePositions.has(circle.id), + favoritePosition: favoritePositions.get(circle.id) ?? null, memberCount: circle.population ?? extra?.members.length ?? 0, myRole: levelToRole(circle.initiator?.level), members: (extra?.members ?? []).map(mapPreviewMember), @@ -175,6 +185,28 @@ export async function fetchTeams(): Promise { }) } +/** + * Update a favorite and return the canonical order. + * + * @param teamId - Team identifier + * @param isFavorite - Desired favorite state + */ +export async function setTeamFavorite(teamId: string, isFavorite: boolean): Promise { + const response = await axios.put>(generateOcsUrl('apps/circles/teams/{circleId}/favorite', { circleId: teamId }), { isFavorite }) + return response.data.ocs.data.circleIds +} + +/** + * Save a new order only if the server still has the expected order. + * + * @param teamIds - Desired order + * @param expectedCircleIds - Previously loaded order + */ +export async function reorderFavoriteTeams(teamIds: string[], expectedCircleIds: string[]): Promise { + const response = await axios.put>(generateOcsUrl('apps/circles/teams/favorites/order'), { circleIds: teamIds, expectedCircleIds }) + return response.data.ocs.data.circleIds +} + /** * Fetch the full member list for a team (includes roles). * diff --git a/src/teams/components/TeamAvatar.vue b/src/teams/components/TeamAvatar.vue index 9e549999a..88a84010a 100644 --- a/src/teams/components/TeamAvatar.vue +++ b/src/teams/components/TeamAvatar.vue @@ -35,6 +35,7 @@ export function getAvatarUrl(circleId: string): string { + + diff --git a/src/teams/components/TeamCard.spec.ts b/src/teams/components/TeamCard.spec.ts new file mode 100644 index 000000000..b47484faf --- /dev/null +++ b/src/teams/components/TeamCard.spec.ts @@ -0,0 +1,155 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Team } from '../types.ts' + +import { shallowMount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import TeamCard from './TeamCard.vue' + +// @nextcloud/vue pulls RTL helpers from the same module, so keep the rest. +vi.mock('@nextcloud/l10n', async (importOriginal) => ({ + ...(await importOriginal()), + t: (app: string, text: string) => text, +})) + +function makeTeam(overrides: Partial = {}): Team { + return { + id: 'team1', + displayName: 'Team One', + description: 'A team', + isFavorite: false, + favoritePosition: null, + memberCount: 2, + myRole: 'member', + members: [ + { id: 'm1', userId: 'alice', displayName: 'Alice', isUser: true, role: 'member' }, + { id: 'm2', userId: 'bob', displayName: 'Bob', isUser: true, role: 'member' }, + ], + resources: [], + ...overrides, + } +} + +function mountCard(props: Record = {}, team: Team = makeTeam()) { + return shallowMount(TeamCard, { + props: { team, ...props }, + global: { + // Render slots of stubbed components so actions and links stay reachable. + renderStubDefaultSlot: true, + stubs: { RouterLink: { template: '' } }, + }, + }) +} + +function actionButtons(wrapper: ReturnType) { + return wrapper.findAllComponents({ name: 'NcActionButton' }) +} + +describe('TeamCard actions', () => { + it('only offers the favorite action when the card is not sortable', () => { + const buttons = actionButtons(mountCard()) + + expect(buttons).toHaveLength(1) + }) + + it('offers move up, move down and favorite when sortable', () => { + const buttons = actionButtons(mountCard({ sortable: true })) + + expect(buttons).toHaveLength(3) + }) + + it('emits toggleFavorite with the team', () => { + const team = makeTeam() + const wrapper = mountCard({}, team) + + actionButtons(wrapper)[0].vm.$emit('click') + + expect(wrapper.emitted('toggleFavorite')).toEqual([[team]]) + }) + + it('emits moveUp and moveDown with the team', () => { + const team = makeTeam({ isFavorite: true }) + const wrapper = mountCard({ sortable: true, canMoveUp: true, canMoveDown: true }, team) + const [up, down] = actionButtons(wrapper) + + up.vm.$emit('click') + down.vm.$emit('click') + + expect(wrapper.emitted('moveUp')).toEqual([[team]]) + expect(wrapper.emitted('moveDown')).toEqual([[team]]) + }) + + it('disables moving beyond the ends of the list', () => { + const [up, down] = actionButtons(mountCard({ sortable: true, canMoveUp: false, canMoveDown: true })) + + expect(up.props('disabled')).toBe(true) + expect(down.props('disabled')).toBe(false) + }) + + it('disables every action while a favorite update is running', () => { + const buttons = actionButtons(mountCard({ sortable: true, canMoveUp: true, canMoveDown: true, favoriteBusy: true })) + + expect(buttons.map((button) => button.props('disabled'))).toEqual([true, true, true]) + }) +}) + +describe('TeamCard dragging', () => { + it('is draggable only when requested', () => { + expect(mountCard({ draggable: true }).get('article').attributes('draggable')).toBe('true') + expect(mountCard({ draggable: false }).get('article').attributes('draggable')).toBe('false') + }) + + it('forwards drag events with the team', async () => { + const team = makeTeam({ isFavorite: true }) + const wrapper = mountCard({ draggable: true }, team) + const article = wrapper.get('article') + + await article.trigger('dragstart') + await article.trigger('dragover') + await article.trigger('dragend') + + expect(wrapper.emitted('dragStart')?.[0][0]).toEqual(team) + expect(wrapper.emitted('dragOver')?.[0][0]).toEqual(team) + expect(wrapper.emitted('dragEnd')).toHaveLength(1) + }) +}) + +describe('TeamCard content', () => { + it('uses one team link for all card content', () => { + const links = mountCard().findAll('a') + + expect(links).toHaveLength(1) + expect(links[0].text()).toContain('Team One') + }) + + it('shows the description only when there is one', () => { + expect(mountCard().text()).toContain('A team') + expect(mountCard({}, makeTeam({ description: '' })).find('p').exists()).toBe(false) + }) + + it('shows how many members are not part of the preview', () => { + const wrapper = mountCard({}, makeTeam({ memberCount: 9 })) + + expect(wrapper.text()).toContain('+7') + }) + + it('shows no overflow counter when all members are previewed', () => { + expect(mountCard().text()).not.toContain('+') + }) + + it('previews at most five members', () => { + const members = Array.from({ length: 8 }, (_, index): Team['members'][number] => ({ + id: `m${index}`, + userId: `user${index}`, + displayName: `User ${index}`, + isUser: true, + role: 'member', + })) + const wrapper = mountCard({}, makeTeam({ members, memberCount: 8 })) + + expect(wrapper.findAllComponents({ name: 'NcAvatar' })).toHaveLength(5) + }) +}) diff --git a/src/teams/components/TeamCard.vue b/src/teams/components/TeamCard.vue index 4d05f3883..792aeceda 100644 --- a/src/teams/components/TeamCard.vue +++ b/src/teams/components/TeamCard.vue @@ -6,57 +6,141 @@ 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); + } +}