From 655f8197f01c5d3ae6a3443b52af3181f30b136f Mon Sep 17 00:00:00 2001 From: Stefan Dietrich Date: Tue, 6 Oct 2026 09:48:48 +0200 Subject: [PATCH] fix: reintroduce team-joining and appoval feature Signed-off-by: Stefan Dietrich Assisted-by: GitHub Copilot:Claude Opus 5.5 Signed-off-by: Stefan Dietrich --- src/teams/api.spec.ts | 92 ++++- src/teams/api.ts | 100 ++++- .../components/DiscoverableTeamCard.spec.ts | 101 +++++ src/teams/components/DiscoverableTeamCard.vue | 102 +++++ src/teams/store.spec.ts | 119 +++++- src/teams/store.ts | 29 +- .../components/MemberList/MemberGridItem.vue | 2 +- src/teams/team-page/models/constants.ts | 2 +- src/teams/types.ts | 15 + src/teams/views/HomeView.spec.ts | 238 ++++++++++- src/teams/views/HomeView.vue | 65 ++- tests/playwright/e2e/join-teams.spec.ts | 376 ++++++++++++++++++ .../lib/Controller/LocalControllerTest.php | 159 ++++++++ 13 files changed, 1351 insertions(+), 49 deletions(-) create mode 100644 src/teams/components/DiscoverableTeamCard.spec.ts create mode 100644 src/teams/components/DiscoverableTeamCard.vue create mode 100644 tests/playwright/e2e/join-teams.spec.ts diff --git a/src/teams/api.spec.ts b/src/teams/api.spec.ts index e85ec1408..b8149538a 100644 --- a/src/teams/api.spec.ts +++ b/src/teams/api.spec.ts @@ -32,24 +32,30 @@ vi.mock('./team-page/services/collaborationAutocompletion.js', () => ({ getSuggestions: vi.fn(), })) -const { createTeam, fetchTeams, setTeamFavorite, reorderFavoriteTeams } = await import('./api.ts') +const { createTeam, fetchTeams, joinTeam, setTeamFavorite, reorderFavoriteTeams } = await import('./api.ts') describe('fetchTeams', () => { - it('drops circles the current user is not a member of', async () => { + it('splits active members from discoverable teams', async () => { const circles = [ - { id: 'team1', name: 'team1', displayName: 'Team One', population: 3, initiator: { level: 9 } }, - { id: 'visible1', name: 'visible1', displayName: 'Visible circle', population: 10, initiator: null }, + { id: 'owner-team', name: 'owner-team', displayName: 'Zebra owner', population: 3, initiator: { level: 9, status: 'Member' } }, + { id: 'open-team', name: 'open-team', displayName: 'Bravo open', population: 10, initiator: null, config: 24 }, + { id: 'closed-team', name: 'closed-team', displayName: 'Charlie closed', population: 7, initiator: null, config: 8 }, + { id: 'pending-team', name: 'pending-team', displayName: 'Alpha pending', population: 5, initiator: { level: 0, status: 'Requesting' }, config: 88 }, + { id: 'invited-team', name: 'invited-team', displayName: 'Echo invited', population: 2, initiator: { level: 0, status: 'Invited' } }, ] vi.mocked(axios.get) .mockResolvedValueOnce({ data: { ocs: { data: circles } } }) .mockResolvedValueOnce({ data: { ocs: { data: [] } } }) .mockResolvedValueOnce({ data: { ocs: { data: { circleIds: [] } } } }) - const teams = await fetchTeams() + const overview = await fetchTeams() - expect(teams).toHaveLength(1) - expect(teams[0].id).toBe('team1') - expect(teams[0].myRole).toBe('owner') + expect(overview.teams.map((team) => team.id)).toEqual(['owner-team', 'invited-team']) + expect(overview.teams[0].myRole).toBe('owner') + expect(overview.discoverableTeams.map((team) => team.id)).toEqual(['pending-team', 'open-team', 'closed-team']) + expect(overview.discoverableTeams[0]).toMatchObject({ pending: true, canJoin: false, memberCount: 5 }) + expect(overview.discoverableTeams[1]).toMatchObject({ pending: false, canJoin: true, memberCount: 10 }) + expect(overview.discoverableTeams[2]).toMatchObject({ pending: false, canJoin: false, memberCount: 7 }) }) it('returns an empty list when the circles response carries no data', async () => { @@ -58,7 +64,71 @@ describe('fetchTeams', () => { .mockResolvedValueOnce({ data: { ocs: { data: [] } } }) .mockResolvedValueOnce({ data: { ocs: { data: { circleIds: [] } } } }) - await expect(fetchTeams()).resolves.toEqual([]) + await expect(fetchTeams()).resolves.toEqual({ teams: [], discoverableTeams: [] }) + }) + + it('uses fallback metadata for discoverable and member teams', async () => { + const circles = [ + { id: 'discoverable-defaults', name: 'Fallback discoverable', displayName: '', initiator: null }, + { id: 'member-defaults', name: 'Fallback member', displayName: '', initiator: { level: 1, status: 'Member' } }, + { id: 'member-preview-count', name: 'Preview member', displayName: 'Preview member', initiator: { level: 1, status: 'Member' } }, + ] + vi.mocked(axios.get) + .mockResolvedValueOnce({ data: { ocs: { data: circles } } }) + .mockResolvedValueOnce({ + data: { + ocs: { + data: [{ + singleId: 'member-preview-count', + members: [{ singleId: 'alice', userId: 'alice', displayName: 'Alice', type: 1 }], + resources: [], + }], + }, + }, + }) + .mockResolvedValueOnce({ data: { ocs: { data: { circleIds: [] } } } }) + + const overview = await fetchTeams() + + expect(overview.discoverableTeams).toMatchObject([{ + id: 'discoverable-defaults', + displayName: 'Fallback discoverable', + description: '', + memberCount: 0, + canJoin: false, + pending: false, + }]) + expect(overview.teams).toMatchObject([ + { id: 'member-defaults', displayName: 'Fallback member', description: '', memberCount: 0 }, + { + id: 'member-preview-count', + displayName: 'Preview member', + memberCount: 1, + members: [{ id: 'alice', userId: 'alice', displayName: 'Alice', isUser: true, role: 'member' }], + }, + ]) + }) +}) + +describe('joinTeam', () => { + it('returns requested when the join requires approval', async () => { + vi.mocked(axios.put).mockResolvedValueOnce({ data: { ocs: { data: { status: 'Requesting' } } } }) + + await expect(joinTeam('team1')).resolves.toBe('requested') + expect(axios.put).toHaveBeenCalledWith(expect.stringMatching(/\/join$/), {}) + }) + + it('returns joined for an immediate membership', async () => { + vi.mocked(axios.put).mockResolvedValueOnce({ data: { ocs: { data: { status: 'Member' } } } }) + + await expect(joinTeam('team1')).resolves.toBe('joined') + expect(axios.put).toHaveBeenCalledWith(expect.stringMatching(/\/join$/), {}) + }) + + it('propagates a rejected join request', async () => { + vi.mocked(axios.put).mockRejectedValueOnce(new Error('join failed')) + + await expect(joinTeam('team1')).rejects.toThrow('join failed') }) }) @@ -110,7 +180,7 @@ describe('favorite teams API', () => { 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]]) + const overview = await fetchTeams() + expect(overview.teams.map((team) => [team.id, team.isFavorite])).toEqual([['A', false]]) }) }) diff --git a/src/teams/api.ts b/src/teams/api.ts index f5d66fe7d..49dd4e4c2 100644 --- a/src/teams/api.ts +++ b/src/teams/api.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Member, MemberCandidate, Resource, SharedResource, Team, TeamRole } from './types.ts' +import type { DiscoverableTeam, Member, MemberCandidate, Resource, SharedResource, Team, TeamRole } from './types.ts' import axios from '@nextcloud/axios' import { FileType } from '@nextcloud/files' @@ -13,6 +13,11 @@ import { logger } from '../logger.ts' import { SHARES_TYPES_MEMBER_MAP } from './team-page/models/constants.ts' import { getRecommendations, getSuggestions } from './team-page/services/collaborationAutocompletion.js' +/** `Circle::CFG_OPEN`: anyone can join or request to join. */ +const CIRCLE_CONFIG_OPEN = 16 +/** `Member::STATUS_REQUEST`: a join request awaiting moderator approval. */ +const MEMBER_STATUS_REQUEST = 'Requesting' + /** `SHARES_TYPES_MEMBER_MAP` is built dynamically, so type its shape explicitly. */ const shareTypeToMemberType = SHARES_TYPES_MEMBER_MAP as Record @@ -48,7 +53,40 @@ interface RawCircle { displayName: string description?: string population?: number - initiator?: { level?: number } | null + config?: number + initiator?: { level?: number, status?: string } | null +} + +/** Teams the user is part of, and visible teams they could join. */ +export interface TeamsOverview { + teams: Team[] + discoverableTeams: DiscoverableTeam[] +} + +/** + * Whether the user's only link to the circle is a pending join request. + * + * @param circle - The raw circle + */ +function isPendingRequest(circle: RawCircle): boolean { + return circle.initiator?.status === MEMBER_STATUS_REQUEST +} + +/** + * Map a circle the user is not an active member of. + * + * @param circle - The raw circle + */ +function mapDiscoverableTeam(circle: RawCircle): DiscoverableTeam { + const pending = isPendingRequest(circle) + return { + id: circle.id, + displayName: circle.displayName || circle.name, + description: circle.description ?? '', + memberCount: circle.population ?? 0, + canJoin: !pending && ((circle.config ?? 0) & CIRCLE_CONFIG_OPEN) !== 0, + pending, + } } /** Raw team as returned by the dashboard widget endpoint. */ @@ -136,10 +174,10 @@ function mapFullMember(raw: RawMember): Member { } /** - * Fetch all of the current user's teams, merging team metadata (name, - * description, member count, our role) with the members preview and resources. + * Fetch the current user's teams and discoverable teams, merging active team + * metadata (name, description, member count, our role) with previews. */ -export async function fetchTeams(): Promise { +export async function fetchTeams(): Promise { 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'), @@ -150,7 +188,12 @@ export async function fetchTeams(): Promise { if (circlesRes.status === 'rejected') { throw circlesRes.reason } - const circles = (circlesRes.value.data.ocs.data ?? []).filter((circle) => circle.initiator) + const allCircles = circlesRes.value.data.ocs.data ?? [] + const memberCircles = allCircles.filter((circle) => circle.initiator && !isPendingRequest(circle)) + const discoverableTeams = allCircles + .filter((circle) => !circle.initiator || isPendingRequest(circle)) + .map(mapDiscoverableTeam) + .sort((a, b) => a.displayName.localeCompare(b.displayName)) // The dashboard only enriches each team with member/resource previews, so // treat a failure there as "no previews" rather than failing the whole page. @@ -169,20 +212,23 @@ export async function fetchTeams(): Promise { } const favoritePositions = new Map(favoriteCircleIds.map((circleId, position) => [circleId, position])) - return circles.map((circle) => { - const extra = dashboardById.get(circle.id) - return { - 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), - resources: (extra?.resources ?? []).map(mapResource), - } - }) + return { + teams: memberCircles.map((circle) => { + const extra = dashboardById.get(circle.id) + return { + 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), + resources: (extra?.resources ?? []).map(mapResource), + } + }), + discoverableTeams, + } } /** @@ -257,6 +303,20 @@ export async function leaveTeam(teamId: string): Promise { ) } +/** + * Join a team, or request to join it when it requires approval. + * + * @param teamId - The team single id + * @return 'requested' when the join awaits approval, otherwise 'joined' + */ +export async function joinTeam(teamId: string): Promise<'joined' | 'requested'> { + const res = await axios.put>( + generateOcsUrl('apps/circles/circles/{circleId}/join', { circleId: teamId }), + {}, + ) + return res.data.ocs.data?.status === MEMBER_STATUS_REQUEST ? 'requested' : 'joined' +} + /** * Delete a team. * diff --git a/src/teams/components/DiscoverableTeamCard.spec.ts b/src/teams/components/DiscoverableTeamCard.spec.ts new file mode 100644 index 000000000..4514b37c0 --- /dev/null +++ b/src/teams/components/DiscoverableTeamCard.spec.ts @@ -0,0 +1,101 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { DiscoverableTeam } from '../types.ts' + +import { shallowMount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import DiscoverableTeamCard from './DiscoverableTeamCard.vue' + +vi.mock('@nextcloud/l10n', async (importOriginal) => ({ + ...(await importOriginal()), + t: (_app: string, text: string) => text, + n: (_app: string, _singular: string, _plural: string, count: number) => `${count} members`, +})) + +const NcAvatarStub = { + name: 'NcAvatar', + props: ['displayName', 'isNoUser', 'size', 'hideStatus', 'disableMenu', 'disableTooltip'], + template: '
', +} + +const NcButtonWithIconStub = { + name: 'NcButton', + props: { disabled: Boolean }, + template: '', +} + +const team: DiscoverableTeam = { + id: 'team-1', + displayName: 'Product team', + description: 'A team for product work', + memberCount: 4, + canJoin: true, + pending: false, +} + +describe('DiscoverableTeamCard', () => { + it('emits join when the join button is clicked', async () => { + const wrapper = shallowMount(DiscoverableTeamCard, { props: { team } }) + const button = wrapper.findComponent({ name: 'NcButton' }) + + expect(button.exists()).toBe(true) + await button.trigger('click') + expect(wrapper.emitted('join')).toEqual([[]]) + }) + + it('shows pending state without a join button', () => { + const wrapper = shallowMount(DiscoverableTeamCard, { + props: { team: { ...team, canJoin: false, pending: true } }, + }) + + expect(wrapper.text()).toContain('Request pending') + expect(wrapper.findComponent({ name: 'NcButton' }).exists()).toBe(false) + }) + + it('shows neither a pending state nor a join button when closed', () => { + const wrapper = shallowMount(DiscoverableTeamCard, { + props: { team: { ...team, canJoin: false } }, + }) + + expect(wrapper.text()).not.toContain('Request pending') + expect(wrapper.findComponent({ name: 'NcButton' }).exists()).toBe(false) + }) + + it('disables joining and shows a loading icon while the request is in flight', () => { + const wrapper = shallowMount(DiscoverableTeamCard, { + props: { team, joining: true }, + global: { stubs: { NcButton: NcButtonWithIconStub } }, + }) + const button = wrapper.findComponent({ name: 'NcButton' }) + + expect(button.props('disabled')).toBe(true) + expect(wrapper.findComponent({ name: 'NcLoadingIcon' }).exists()).toBe(true) + }) + + it('disables joining without a loading icon when another request is in flight', () => { + const wrapper = shallowMount(DiscoverableTeamCard, { + props: { team, disabled: true }, + global: { stubs: { NcButton: NcButtonWithIconStub } }, + }) + const button = wrapper.findComponent({ name: 'NcButton' }) + + expect(button.props('disabled')).toBe(true) + expect(wrapper.findComponent({ name: 'NcLoadingIcon' }).exists()).toBe(false) + }) + + it('has no link and gives NcAvatar no member-only props', () => { + const wrapper = shallowMount(DiscoverableTeamCard, { + props: { team }, + global: { stubs: { NcAvatar: NcAvatarStub } }, + }) + const avatar = wrapper.findComponent({ name: 'NcAvatar' }) + + expect(wrapper.find('a').exists()).toBe(false) + expect(wrapper.findComponent({ name: 'RouterLink' }).exists()).toBe(false) + expect(avatar.vm.$attrs).not.toHaveProperty('url') + expect(avatar.vm.$attrs).not.toHaveProperty('user') + }) +}) diff --git a/src/teams/components/DiscoverableTeamCard.vue b/src/teams/components/DiscoverableTeamCard.vue new file mode 100644 index 000000000..6ade71b9c --- /dev/null +++ b/src/teams/components/DiscoverableTeamCard.vue @@ -0,0 +1,102 @@ + + + + + + + diff --git a/src/teams/store.spec.ts b/src/teams/store.spec.ts index dee485953..5246c68f5 100644 --- a/src/teams/store.spec.ts +++ b/src/teams/store.spec.ts @@ -3,15 +3,21 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Team } from './types.ts' +import type { TeamsOverview } from './api.ts' +import type { DiscoverableTeam, Team } from './types.ts' import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import * as api from './api.ts' import { useTeamsStore } from './store.ts' -vi.mock('./api.ts', () => ({ setTeamFavorite: vi.fn(), reorderFavoriteTeams: vi.fn(), fetchTeams: vi.fn() })) -vi.mock('../logger.ts', () => ({ logger: { error: vi.fn() } })) +const fetchTeams = vi.hoisted(() => vi.fn<() => Promise>()) +const joinTeam = vi.hoisted(() => vi.fn<(id: string) => Promise<'joined' | 'requested'>>()) +const setTeamFavorite = vi.hoisted(() => vi.fn<(id: string, isFavorite: boolean) => Promise>()) +const reorderFavoriteTeams = vi.hoisted(() => vi.fn<(ids: string[], expectedIds: string[]) => Promise>()) + +vi.mock('./api.ts', () => ({ fetchTeams, joinTeam, setTeamFavorite, reorderFavoriteTeams })) +vi.mock('../logger.ts', () => ({ logger: { error: vi.fn(), warn: vi.fn() } })) function team(id: string, position: number | null): Team { return { id, displayName: id, description: '', isFavorite: position !== null, favoritePosition: position, memberCount: 0, myRole: 'member', members: [], resources: [] } @@ -64,9 +70,114 @@ describe('favorite teams', () => { const store = useTeamsStore() store.teams = [team('A', 0), team('B', 1)] vi.mocked(api.reorderFavoriteTeams).mockRejectedValue(new Error('conflict')) - vi.mocked(api.fetchTeams).mockResolvedValue([team('A', 0), team('B', 1), team('C', 2)]) + vi.mocked(api.fetchTeams).mockResolvedValue({ + teams: [team('A', 0), team('B', 1), team('C', 2)], + discoverableTeams: [], + }) await expect(store.reorderFavoriteTeams(['B', 'A'])).rejects.toThrow('conflict') expect(store.favoriteTeams.map((item) => item.id)).toEqual(['A', 'B', 'C']) expect(store.favoritesUpdating).toBe(false) }) }) + +const memberTeam: Team = { + id: 'member-team', + displayName: 'Product', + description: '', + isFavorite: false, + favoritePosition: null, + memberCount: 2, + myRole: 'member', + members: [], + resources: [], +} + +const openTeam: DiscoverableTeam = { + id: 'open-team', + displayName: 'Alpha team', + description: '', + memberCount: 4, + canJoin: true, + pending: false, +} + +const closedTeam: DiscoverableTeam = { + id: 'closed-team', + displayName: 'Beta team', + description: '', + memberCount: 7, + canJoin: false, + pending: false, +} + +const overview: TeamsOverview = { + teams: [memberTeam], + discoverableTeams: [openTeam, closedTeam], +} + +describe('teams store: discoverable teams', () => { + beforeEach(() => { + vi.clearAllMocks() + setActivePinia(createPinia()) + }) + + it('loads active and discoverable teams from one overview', async () => { + fetchTeams.mockResolvedValue(overview) + + const store = useTeamsStore() + await store.loadTeams() + + expect(fetchTeams).toHaveBeenCalledTimes(1) + expect(store.teams).toEqual([memberTeam]) + expect(store.discoverableTeams).toEqual([openTeam, closedTeam]) + }) + + it('keeps both lists when loading fails', async () => { + fetchTeams.mockRejectedValueOnce(new Error('network error')) + + const store = useTeamsStore() + store.teams = [memberTeam] + store.discoverableTeams = [openTeam] + await store.loadTeams() + + expect(store.teams).toEqual([memberTeam]) + expect(store.discoverableTeams).toEqual([openTeam]) + expect(store.loadError).toBe(true) + }) + + it('filters discoverable teams by a trimmed case-insensitive name', () => { + const store = useTeamsStore() + store.discoverableTeams = [openTeam, closedTeam] + + expect(store.searchDiscoverableTeams(' ALPHA ')).toEqual([openTeam]) + expect(store.searchDiscoverableTeams('')).toEqual([openTeam, closedTeam]) + }) + + it('joins a team, reloads the lists, and returns the API result', async () => { + const events: string[] = [] + joinTeam.mockImplementation(async (id) => { + events.push(`join:${id}`) + return 'requested' + }) + fetchTeams.mockImplementation(async () => { + events.push('fetchTeams') + return overview + }) + + const store = useTeamsStore() + + await expect(store.joinTeam('open-team')).resolves.toBe('requested') + + expect(events).toEqual(['join:open-team', 'fetchTeams']) + expect(store.discoverableTeams).toEqual([openTeam, closedTeam]) + }) + + it('does not reload when joining fails', async () => { + joinTeam.mockRejectedValueOnce(new Error('join failed')) + + const store = useTeamsStore() + + await expect(store.joinTeam('open-team')).rejects.toThrow('join failed') + expect(fetchTeams).not.toHaveBeenCalled() + }) +}) diff --git a/src/teams/store.ts b/src/teams/store.ts index 16485bd93..58db10e41 100644 --- a/src/teams/store.ts +++ b/src/teams/store.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Member, MemberCandidate, Team } from './types.ts' +import type { DiscoverableTeam, Member, MemberCandidate, Team } from './types.ts' import { defineStore } from 'pinia' import { logger } from '../logger.ts' @@ -12,6 +12,8 @@ import * as api from './api.ts' interface TeamsState { /** All teams the current user is part of. */ teams: Team[] + /** Visible teams the user is not an active member of. */ + discoverableTeams: DiscoverableTeam[] /** Whether the teams list is currently loading. */ loading: boolean /** Whether the last teams load failed. */ @@ -29,6 +31,7 @@ interface TeamsState { export const useTeamsStore = defineStore('teams', { state: (): TeamsState => ({ teams: [], + discoverableTeams: [], loading: false, loadError: false, createWizardOpen: false, @@ -51,6 +54,15 @@ export const useTeamsStore = defineStore('teams', { favoriteTeams: (state): Team[] => state.teams .filter((team) => team.isFavorite) .sort((left, right) => (left.favoritePosition ?? 0) - (right.favoritePosition ?? 0)), + + // Filter discoverable teams by a free-text query against their display name. + searchDiscoverableTeams: (state) => (query: string): DiscoverableTeam[] => { + const needle = query.trim().toLowerCase() + if (!needle) { + return state.discoverableTeams + } + return state.discoverableTeams.filter((team) => team.displayName.toLowerCase().includes(needle)) + }, }, actions: { @@ -64,7 +76,9 @@ export const useTeamsStore = defineStore('teams', { this.loading = true this.loadError = false try { - this.teams = await api.fetchTeams() + const overview = await api.fetchTeams() + this.teams = overview.teams + this.discoverableTeams = overview.discoverableTeams } catch (error) { this.loadError = true logger.error('Failed to load teams', { error }) @@ -128,6 +142,17 @@ export const useTeamsStore = defineStore('teams', { } }, + /** + * Join (or request to join) a team, then reload the lists. + * + * @param id - The team id + */ + async joinTeam(id: string): Promise<'joined' | 'requested'> { + const result = await api.joinTeam(id) + await this.loadTeams() + return result + }, + /** * Fetch the full member list (with roles) for a team. * diff --git a/src/teams/team-page/components/MemberList/MemberGridItem.vue b/src/teams/team-page/components/MemberList/MemberGridItem.vue index 234e833f4..eecfab4e8 100644 --- a/src/teams/team-page/components/MemberList/MemberGridItem.vue +++ b/src/teams/team-page/components/MemberList/MemberGridItem.vue @@ -214,7 +214,7 @@ export default { */ isPendingApproval() { return this.member.level === MemberLevels.NONE - && this.member.status === MemberStatus.PENDING + && this.member.status === MemberStatus.REQUESTING }, /** diff --git a/src/teams/team-page/models/constants.ts b/src/teams/team-page/models/constants.ts index ff9acfcce..aed02923e 100644 --- a/src/teams/team-page/models/constants.ts +++ b/src/teams/team-page/models/constants.ts @@ -98,7 +98,7 @@ export const PUBLIC_CIRCLE_CONFIG = { component: CircleConfigCheckboxesList, props: { configs: { - [CIRCLE_CONFIG_OPEN]: t('circles', 'Anyone can request membership'), + [CIRCLE_CONFIG_OPEN]: t('circles', 'Anyone can request membership (requires "Visible to everyone")'), [CIRCLE_CONFIG_INVITE]: t('circles', 'Members need to accept invitation'), [CIRCLE_CONFIG_REQUEST]: t('circles', 'Memberships must be confirmed/accepted by a Moderator (requires "Anyone can request membership")'), [CIRCLE_CONFIG_FRIEND]: t('circles', 'Members can also invite'), diff --git a/src/teams/types.ts b/src/teams/types.ts index 4747b053f..b5889854e 100644 --- a/src/teams/types.ts +++ b/src/teams/types.ts @@ -72,6 +72,21 @@ export interface Team { resources: Resource[] } +/** + * A team the current user can see but is not an active member of + * ("Visible to everyone" teams, or teams with a pending join request). + */ +export interface DiscoverableTeam { + id: string + displayName: string + description: string + memberCount: number + /** Whether the user can join or request to join right now. */ + canJoin: boolean + /** Whether the user already requested to join and awaits approval. */ + pending: boolean +} + /** * A candidate member surfaced by the sharee autocompletion search (users, * groups, emails, contacts, other teams…), before they have been added to a diff --git a/src/teams/views/HomeView.spec.ts b/src/teams/views/HomeView.spec.ts index 0a2b74385..faaaf921e 100644 --- a/src/teams/views/HomeView.spec.ts +++ b/src/teams/views/HomeView.spec.ts @@ -4,24 +4,83 @@ */ import type * as L10n from '@nextcloud/l10n' -import type { Team } from '../types.ts' +import type { TeamsOverview } from '../api.ts' +import type { DiscoverableTeam, Team } from '../types.ts' -import { flushPromises, shallowMount } from '@vue/test-utils' +import { flushPromises, mount, shallowMount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { nextTick, ref } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import DiscoverableTeamCard from '../components/DiscoverableTeamCard.vue' import TeamCard from '../components/TeamCard.vue' import HomeView from './HomeView.vue' import * as api from '../api.ts' import { useTeamsStore } from '../store.ts' -vi.mock('../api.ts', () => ({ reorderFavoriteTeams: vi.fn(), setTeamFavorite: vi.fn(), fetchTeams: vi.fn() })) -vi.mock('../../logger.ts', () => ({ logger: { error: vi.fn() } })) -vi.mock('@nextcloud/dialogs', () => ({ showError: vi.fn() })) -vi.mock('@nextcloud/vue/composables/useIsDarkTheme', () => ({ useIsDarkTheme: () => ref(false) })) +const fetchTeams = vi.hoisted(() => vi.fn<() => Promise>()) +const joinTeam = vi.hoisted(() => vi.fn<(teamId: string) => Promise<'joined' | 'requested'>>()) +const setTeamFavorite = vi.hoisted(() => vi.fn<(teamId: string, isFavorite: boolean) => Promise>()) +const reorderFavoriteTeams = vi.hoisted(() => vi.fn<(teamIds: string[], expectedIds: string[]) => Promise>()) +const routerPush = vi.hoisted(() => vi.fn()) +const showSuccess = vi.hoisted(() => vi.fn()) +const showError = vi.hoisted(() => vi.fn()) +const loggerError = vi.hoisted(() => vi.fn()) + +vi.mock('../api.ts', () => ({ fetchTeams, joinTeam, setTeamFavorite, reorderFavoriteTeams })) +vi.mock('../../logger.ts', () => ({ logger: { error: loggerError, warn: vi.fn() } })) +vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) })) +vi.mock('@nextcloud/dialogs', () => ({ showError, showSuccess })) +vi.mock('@nextcloud/router', () => ({ imagePath: () => '/teams-illustration.svg' })) +vi.mock('@nextcloud/vue/composables/useIsDarkTheme', () => ({ useIsDarkTheme: () => false })) vi.mock('@nextcloud/l10n', async (importOriginal) => ({ ...await importOriginal(), - t: (_app: string, text: string) => text, + t: (_app: string, text: string, variables?: Record) => { + if (!variables) { + return text + } + return text.replace(/\{(\w+)\}/g, (_, key: string) => variables[key] ?? `{${key}}`) + }, + n: (_app: string, singular: string, plural: string, count: number) => (count === 1 ? singular : plural).replace('%n', String(count)), +})) +vi.mock('@nextcloud/vue/components/NcAvatar', () => ({ + default: { name: 'NcAvatar', template: '
' }, +})) +vi.mock('@nextcloud/vue/components/NcButton', () => ({ + default: { + name: 'NcButton', + props: { disabled: Boolean, variant: String }, + emits: ['click'], + template: '', + }, +})) +vi.mock('@nextcloud/vue/components/NcEmptyContent', () => ({ + default: { + name: 'NcEmptyContent', + props: { name: String, description: String }, + template: '
{{ name }}

{{ description }}

', + }, +})) +vi.mock('@nextcloud/vue/components/NcIconSvgWrapper', () => ({ + default: { name: 'NcIconSvgWrapper', template: '' }, +})) +vi.mock('@nextcloud/vue/components/NcLoadingIcon', () => ({ + default: { name: 'NcLoadingIcon', template: '' }, +})) +vi.mock('@nextcloud/vue/components/NcTextField', () => ({ + default: { + name: 'NcTextField', + props: { modelValue: String, label: String }, + emits: ['update:modelValue'], + template: '
{{ label }}
', + }, +})) +vi.mock('../components/TeamCard.vue', () => ({ + default: { + name: 'TeamCard', + props: ['team', 'draggable', 'sortable', 'favoriteBusy', 'canMoveUp', 'canMoveDown'], + emits: ['dragStart', 'dragOver', 'dragEnd', 'toggleFavorite', 'moveUp', 'moveDown'], + template: '
', + }, })) function mountHome() { @@ -100,3 +159,164 @@ describe('favorite ordering in HomeView', () => { wrapper.unmount() }) }) + +const teamA: DiscoverableTeam = { + id: 'team-a', + displayName: 'Alpha team', + description: '', + memberCount: 2, + canJoin: true, + pending: false, +} + +const teamB: DiscoverableTeam = { + id: 'team-b', + displayName: 'Beta team', + description: '', + memberCount: 3, + canJoin: true, + pending: false, +} + +const memberTeam: Team = { + id: teamA.id, + displayName: teamA.displayName, + description: '', + isFavorite: false, + favoritePosition: null, + memberCount: teamA.memberCount, + myRole: 'member', + members: [], + resources: [], +} + +const discoverableOverview: TeamsOverview = { + teams: [], + discoverableTeams: [teamA, teamB], +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +let wrapper: ReturnType | undefined + +function mountHomeView() { + const pinia = createPinia() + setActivePinia(pinia) + const store = useTeamsStore() + store.discoverableTeams = [teamA, teamB] + wrapper = mount(HomeView, { global: { plugins: [pinia] } }) + return { wrapper, store } +} + +function joinButton(teamName: string) { + return wrapper!.find(`article[aria-label="${teamName}"] button`) +} + +describe('HomeView team joining', () => { + beforeEach(() => { + fetchTeams.mockReset() + fetchTeams.mockResolvedValue({ teams: [], discoverableTeams: [] }) + joinTeam.mockReset() + joinTeam.mockResolvedValue('joined') + routerPush.mockReset() + showSuccess.mockReset() + showError.mockReset() + loggerError.mockReset() + }) + + afterEach(() => { + wrapper?.unmount() + wrapper = undefined + }) + + it('keeps one join in flight and disables every Join button', async () => { + const pendingJoin = deferred<'joined' | 'requested'>() + joinTeam.mockReturnValueOnce(pendingJoin.promise) + fetchTeams.mockResolvedValue(discoverableOverview) + const { wrapper: home } = mountHomeView() + + await joinButton(teamA.displayName).trigger('click') + + expect(joinTeam).toHaveBeenCalledTimes(1) + expect((joinButton(teamA.displayName).element as HTMLButtonElement).disabled).toBe(true) + expect((joinButton(teamB.displayName).element as HTMLButtonElement).disabled).toBe(true) + expect(home.find(`article[aria-label="${teamA.displayName}"] [data-testid="loading-icon"]`).exists()).toBe(true) + expect(home.find(`article[aria-label="${teamB.displayName}"] [data-testid="loading-icon"]`).exists()).toBe(false) + + home.findAllComponents(DiscoverableTeamCard)[1].vm.$emit('join') + await flushPromises() + expect(joinTeam).toHaveBeenCalledTimes(1) + + pendingJoin.resolve('joined') + await flushPromises() + + expect((joinButton(teamA.displayName).element as HTMLButtonElement).disabled).toBe(false) + expect((joinButton(teamB.displayName).element as HTMLButtonElement).disabled).toBe(false) + expect(home.findAll('[data-testid="loading-icon"]')).toHaveLength(0) + }) + + it('shows success and navigates when refreshed state includes the team', async () => { + fetchTeams.mockResolvedValue({ teams: [memberTeam], discoverableTeams: [teamB] }) + const { store } = mountHomeView() + + await joinButton(teamA.displayName).trigger('click') + await flushPromises() + + expect(showSuccess).toHaveBeenCalledWith('You have joined the team Alpha team.') + expect(store.getTeam(teamA.id)).toBeDefined() + expect(routerPush).toHaveBeenCalledWith({ name: 'team', params: { teamId: teamA.id } }) + }) + + it('shows the pending toast without navigating when approval is required', async () => { + joinTeam.mockResolvedValue('requested') + mountHomeView() + + await joinButton(teamA.displayName).trigger('click') + await flushPromises() + + expect(showSuccess).toHaveBeenCalledWith('Your request to join this team is pending approval') + expect(routerPush).not.toHaveBeenCalled() + }) + + it('shows the error, reloads teams, and stays home when the join is rejected', async () => { + const error = new Error('join rejected') + joinTeam.mockRejectedValueOnce(error) + mountHomeView() + + await joinButton(teamA.displayName).trigger('click') + await flushPromises() + + expect(loggerError).toHaveBeenCalledWith('Could not join the team', { error }) + expect(showError).toHaveBeenCalledWith('Unable to join the team') + expect(fetchTeams).toHaveBeenCalledTimes(1) + expect(routerPush).not.toHaveBeenCalled() + }) + + it('does not navigate when the join succeeds but refreshing teams fails', async () => { + fetchTeams.mockRejectedValueOnce(new Error('refresh failed')) + const { store, wrapper: home } = mountHomeView() + + await joinButton(teamA.displayName).trigger('click') + await flushPromises() + + expect(showSuccess).toHaveBeenCalledWith('You have joined the team Alpha team.') + expect(routerPush).not.toHaveBeenCalled() + expect(joinTeam).toHaveBeenCalledTimes(1) + expect(store.loadError).toBe(true) + expect(home.text()).toContain('Could not load teams') + expect(home.text()).toContain('Try again') + + await home.findAll('button').find((button) => button.text().includes('Try again'))!.trigger('click') + await flushPromises() + expect(fetchTeams).toHaveBeenCalledTimes(2) + expect(joinTeam).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/teams/views/HomeView.vue b/src/teams/views/HomeView.vue index 781516e94..2a7cdeb5d 100644 --- a/src/teams/views/HomeView.vue +++ b/src/teams/views/HomeView.vue @@ -4,24 +4,30 @@ --> + +
+

+ {{ t('circles', 'Other teams') }} +

+
+ +
+
@@ -394,6 +451,12 @@ const isDarkTheme = useIsDarkTheme() font-weight: 700; } + &__section-title { + margin: 0; + font-size: 1.25em; + font-weight: 700; + } + &__subtitle { margin: calc(0.5 * var(--default-grid-baseline)) 0 0; color: var(--color-text-maxcontrast); diff --git a/tests/playwright/e2e/join-teams.spec.ts b/tests/playwright/e2e/join-teams.spec.ts new file mode 100644 index 000000000..7cbcd2d87 --- /dev/null +++ b/tests/playwright/e2e/join-teams.spec.ts @@ -0,0 +1,376 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from '@nextcloud/e2e-test-server' +import type { APIRequestContext, Locator, Page } from '@playwright/test' + +import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright' +import { expect, request } from '@playwright/test' +import { randomUUID } from 'node:crypto' +import { userTest as test } from '../support/fixtures.ts' +import { waitForApiResponse } from '../support/helpers.ts' + +const CIRCLES_API = '/ocs/v2.php/apps/circles/circles' +const CONFIG_VISIBLE = 8 +const CONFIG_OPEN = 16 +const CONFIG_REQUEST = 64 +const OPEN_CONFIG = CONFIG_VISIBLE | CONFIG_OPEN +const REQUEST_CONFIG = OPEN_CONFIG | CONFIG_REQUEST +const HOME_PATH = 'apps/circles/teams' + +interface OcsResponse { + ocs: { + data: T + } +} + +interface TeamFixture { + id: string + name: string +} + +interface CircleMemberFixture { + userId?: string | null + level?: number + status?: string +} + +interface DiscoveryFixtures { + open: TeamFixture + request: TeamFixture + closed: TeamFixture + hidden: TeamFixture +} + +type CreateTeam = (label: string, config?: number) => Promise + +test.describe('Discoverable teams', () => { + test.describe.configure({ mode: 'serial' }) + + test('lists visible teams without probing protected non-member endpoints', async ({ page, baseURL }) => { + await withOwnerTeams(baseURL, async (_ownerApi, createTeam) => { + const teams = await createDiscoveryFixtures(createTeam) + const fixtureIds = new Set(Object.values(teams).map((team) => team.id)) + const protectedRequests: string[] = [] + const forbiddenResponses: string[] = [] + + page.on('request', (apiRequest) => { + if (isProtectedCircleRequest(apiRequest.url(), fixtureIds)) { + protectedRequests.push(`${apiRequest.method()} ${apiRequest.url()}`) + } + }) + page.on('response', (response) => { + if (response.status() === 403) { + forbiddenResponses.push(`${response.status()} ${response.url()}`) + } + }) + + const circlesResponsePromise = page.waitForResponse((response) => response.url().includes('/ocs/v2.php/apps/circles/circles?limit=-1')) + await visitTeamsHome(page) + const circlesResponse = await circlesResponsePromise + if (!circlesResponse.ok()) { + throw new Error(`Circle listing failed at ${circlesResponse.url()} with HTTP ${circlesResponse.status()}: ${await circlesResponse.text()}`) + } + const listedCircleIds = (await circlesResponse.json() as OcsResponse>).ocs.data.map(({ id }) => id) + expect(listedCircleIds).toEqual(expect.arrayContaining([teams.open.id, teams.request.id, teams.closed.id])) + await expectInitialDiscovery(page, teams) + + for (const team of [teams.open, teams.request, teams.closed]) { + await expect(getDiscoverableCard(page, team).getByRole('link')).toHaveCount(0) + } + + expect(protectedRequests).toEqual([]) + expect(forbiddenResponses).toEqual([]) + }) + }) + + test('joins an open team and persists a pending request', async ({ page, baseURL }) => { + await withOwnerTeams(baseURL, async (ownerApi, createTeam) => { + const openTeam = await createTeam('Open', OPEN_CONFIG) + const requestTeam = await createTeam('Request', REQUEST_CONFIG) + + await visitTeamsHome(page) + const homeURL = page.url() + + const openJoin = waitForApiResponse(page, 'PUT') + await getDiscoverableCard(page, openTeam).getByRole('button', { name: 'Join', exact: true }).click() + const openJoinResponse = await openJoin + expect(openJoinResponse.url()).toContain(`/circles/${openTeam.id}/join`) + expect(openJoinResponse.ok()).toBeTruthy() + + await expect(page.getByText(`You have joined the team ${openTeam.name}.`, { exact: true })).toBeVisible() + await expectJoinedTeamPage(page, openTeam) + + const openMembers = await readMembers(ownerApi, openTeam) + const joinedViewer = openMembers.find((member) => member.level === 1 && member.status === 'Member') + expect(joinedViewer?.userId).toBeTruthy() + const viewerId = joinedViewer?.userId + if (!viewerId) { + throw new Error('The joined member response did not include a user ID') + } + + await visitTeamsHome(page) + await expect(getTeamLink(page, openTeam)).toBeVisible() + await expect(getDiscoverableCard(page, openTeam)).toHaveCount(0) + + const requestJoin = waitForApiResponse(page, 'PUT') + await getDiscoverableCard(page, requestTeam).getByRole('button', { name: 'Join', exact: true }).click() + const requestJoinResponse = await requestJoin + expect(requestJoinResponse.url()).toContain(`/circles/${requestTeam.id}/join`) + expect(requestJoinResponse.ok()).toBeTruthy() + + await expect(page.getByText('Your request to join this team is pending approval', { exact: true })).toBeVisible() + await expect(page).toHaveURL(homeURL) + const pendingCard = getDiscoverableCard(page, requestTeam) + await expect(pendingCard.getByText('Request pending', { exact: true })).toBeVisible() + await expect(pendingCard.getByRole('button')).toHaveCount(0) + await expect(getTeamLink(page, requestTeam)).toHaveCount(0) + + await page.reload({ waitUntil: 'networkidle' }) + await expect(page).toHaveURL(homeURL) + await expect(getDiscoverableCard(page, requestTeam).getByText('Request pending', { exact: true })).toBeVisible() + await expect(getDiscoverableCard(page, requestTeam).getByRole('button')).toHaveCount(0) + + const requestMembers = await readMembers(ownerApi, requestTeam) + expect(requestMembers).toContainEqual(expect.objectContaining({ + userId: viewerId, + level: 0, + status: 'Requesting', + })) + }) + }) + + test('reloads a closed team after join failure and filters both team lists', async ({ page, baseURL }) => { + await withOwnerTeams(baseURL, async (ownerApi, createTeam) => { + const openTeam = await createTeam('Open', OPEN_CONFIG) + const requestTeam = await createTeam('Request', REQUEST_CONFIG) + const closedTeam = await createTeam('Closed', CONFIG_VISIBLE) + const closingTeam = await createTeam('Closing', OPEN_CONFIG) + + await visitTeamsHome(page) + const homeURL = page.url() + + const openJoin = waitForApiResponse(page, 'PUT') + await getDiscoverableCard(page, openTeam).getByRole('button', { name: 'Join', exact: true }).click() + const openJoinResponse = await openJoin + expect(openJoinResponse.ok()).toBeTruthy() + await expectJoinedTeamPage(page, openTeam) + + await visitTeamsHome(page) + await setTeamConfig(ownerApi, closingTeam.id, CONFIG_VISIBLE) + + const failedJoin = waitForApiResponse(page, 'PUT') + await getDiscoverableCard(page, closingTeam).getByRole('button', { name: 'Join', exact: true }).click() + const failedJoinResponse = await failedJoin + expect(failedJoinResponse.url()).toContain(`/circles/${closingTeam.id}/join`) + await expect(page.getByText('Unable to join the team', { exact: true })).toBeVisible() + await expect(page).toHaveURL(homeURL) + await expect(getDiscoverableCard(page, closingTeam).getByRole('button')).toHaveCount(0) + + const search = page.getByRole('textbox', { name: 'Search teams', exact: true }) + await search.fill(requestTeam.name) + await expect(getTeamLink(page, openTeam)).toHaveCount(0) + await expect(getDiscoverableCard(page, requestTeam)).toBeVisible() + await expect(getDiscoverableCard(page, closedTeam)).toHaveCount(0) + await expect(getDiscoverableCard(page, closingTeam)).toHaveCount(0) + + await search.fill('') + await expect(getTeamLink(page, openTeam)).toBeVisible() + for (const team of [requestTeam, closedTeam, closingTeam]) { + await expect(getDiscoverableCard(page, team)).toBeVisible() + } + }) + }) + + test('returns a team to discovery after the viewer leaves', async ({ page, baseURL }) => { + await withOwnerTeams(baseURL, async (_ownerApi, createTeam) => { + const openTeam = await createTeam('Open', OPEN_CONFIG) + await visitTeamsHome(page) + const homeURL = page.url() + + const openJoin = waitForApiResponse(page, 'PUT') + await getDiscoverableCard(page, openTeam).getByRole('button', { name: 'Join', exact: true }).click() + const openJoinResponse = await openJoin + expect(openJoinResponse.ok()).toBeTruthy() + await expectJoinedTeamPage(page, openTeam) + + await page.getByRole('button', { name: `Switch team: ${openTeam.name}`, exact: true }).click() + await page.getByRole('button', { name: 'Team actions', exact: true }).click() + await page.getByRole('menuitem', { name: 'Leave team', exact: true }).click() + + const leaveDialog = page.getByRole('dialog', { name: 'Leave team', exact: true }) + await expect(leaveDialog).toBeVisible() + const leave = waitForApiResponse(page, 'PUT') + await leaveDialog.getByRole('button', { name: 'Leave team', exact: true }).click() + const leaveResponse = await leave + expect(leaveResponse.url()).toContain(`/circles/${openTeam.id}/leave`) + expect(leaveResponse.ok()).toBeTruthy() + + await expect(page.getByText(`You left "${openTeam.name}"`, { exact: true })).toBeVisible() + await expect(page).toHaveURL(homeURL) + const openCard = getDiscoverableCard(page, openTeam) + await expect(openCard).toBeVisible() + await expect(openCard.getByRole('button', { name: 'Join', exact: true })).toBeVisible() + await expect(getTeamLink(page, openTeam)).toHaveCount(0) + }) + }) + + test('discovers and joins an open team at mobile width', async ({ page, baseURL }) => { + await withOwnerTeams(baseURL, async (_ownerApi, createTeam) => { + const teams = await createDiscoveryFixtures(createTeam) + await page.setViewportSize({ width: 375, height: 800 }) + await visitTeamsHome(page) + await expectInitialDiscovery(page, teams) + + for (const team of [teams.open, teams.request, teams.closed]) { + await expectFitsViewport(getDiscoverableCard(page, team), 375) + } + await expectFitsViewport(getDiscoverableCard(page, teams.open).getByRole('button', { name: 'Join', exact: true }), 375) + await expectFitsViewport(getDiscoverableCard(page, teams.request).getByRole('button', { name: 'Join', exact: true }), 375) + + const openJoin = waitForApiResponse(page, 'PUT') + await getDiscoverableCard(page, teams.open).getByRole('button', { name: 'Join', exact: true }).click() + const openJoinResponse = await openJoin + expect(openJoinResponse.url()).toContain(`/circles/${teams.open.id}/join`) + expect(openJoinResponse.ok()).toBeTruthy() + await expect(page.getByText(`You have joined the team ${teams.open.name}.`, { exact: true })).toBeVisible() + await expectJoinedTeamPage(page, teams.open) + }) + }) +}) + +async function withOwnerTeams( + baseURL: string | undefined, + run: (ownerApi: APIRequestContext, createTeam: CreateTeam) => Promise, +): Promise { + if (!baseURL) { + throw new Error('Playwright baseURL is required') + } + + const owner: User = await createRandomUser() + const ownerApi = await request.newContext({ + baseURL, + extraHTTPHeaders: { + 'OCS-APIRequest': 'true', + Accept: 'application/json', + }, + }) + await login(ownerApi, owner) + const createdTeams: TeamFixture[] = [] + const createTeam: CreateTeam = async (label, config) => { + const name = `${label} ${randomUUID()}` + const response = await ownerApi.post(CIRCLES_API, { + data: { name, createTeamFolder: false }, + }) + if (!response.ok()) { + throw new Error(`Team creation failed at ${response.url()} with HTTP ${response.status()}: ${await response.text()}`) + } + const result = await response.json() as OcsResponse<{ id: string }> + const team = { id: result.ocs.data.id, name } + expect(team.id).toBeTruthy() + createdTeams.push(team) + + if (config !== undefined) { + await setTeamConfig(ownerApi, team.id, config) + } + + return team + } + + try { + await run(ownerApi, createTeam) + } finally { + try { + for (const team of createdTeams.reverse()) { + const response = await ownerApi.delete(`${CIRCLES_API}/${encodeURIComponent(team.id)}`) + expect(response.ok()).toBeTruthy() + } + } finally { + await ownerApi.dispose() + } + } +} + +async function createDiscoveryFixtures(createTeam: CreateTeam): Promise { + return { + open: await createTeam('Open', OPEN_CONFIG), + request: await createTeam('Request', REQUEST_CONFIG), + closed: await createTeam('Closed', CONFIG_VISIBLE), + hidden: await createTeam('Hidden'), + } +} + +async function setTeamConfig(ownerApi: APIRequestContext, teamId: string, value: number): Promise { + const response = await ownerApi.put(`${CIRCLES_API}/${encodeURIComponent(teamId)}/config`, { + data: { value }, + }) + expect(response.ok()).toBeTruthy() +} + +async function readMembers(ownerApi: APIRequestContext, team: TeamFixture): Promise { + const response = await ownerApi.get(`${CIRCLES_API}/${encodeURIComponent(team.id)}/members`) + expect(response.ok()).toBeTruthy() + const result = await response.json() as OcsResponse + return result.ocs.data +} + +async function visitTeamsHome(page: Page): Promise { + await page.goto(HOME_PATH, { waitUntil: 'networkidle' }) + await expect(page.getByRole('heading', { name: 'My teams', exact: true })).toBeVisible() +} + +async function expectInitialDiscovery(page: Page, teams: DiscoveryFixtures): Promise { + await expect(page.getByText('No teams yet', { exact: true })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Other teams', exact: true })).toBeVisible() + + for (const team of [teams.open, teams.request, teams.closed]) { + await expect(getDiscoverableCard(page, team)).toBeVisible() + } + await expect(getDiscoverableCard(page, teams.hidden)).toHaveCount(0) + await expect(getDiscoverableCard(page, teams.open).getByRole('button', { name: 'Join', exact: true })).toBeVisible() + await expect(getDiscoverableCard(page, teams.request).getByRole('button', { name: 'Join', exact: true })).toBeVisible() + await expect(getDiscoverableCard(page, teams.closed).getByRole('button')).toHaveCount(0) +} + +function getDiscoverableCard(page: Page, team: TeamFixture) { + return page.getByRole('region', { name: 'Other teams', exact: true }) + .getByRole('article', { name: team.name, exact: true }) +} + +function getTeamLink(page: Page, team: TeamFixture) { + return page.getByRole('link', { name: new RegExp(escapeRegExp(team.name)) }) +} + +async function expectJoinedTeamPage(page: Page, team: TeamFixture): Promise { + // The team landing route forwards to its first section, e.g. /home or /folder. + const teamRoute = new RegExp(`/apps/circles/teams/team/${escapeRegExp(team.id)}(?:/.*)?$`) + await expect(page).toHaveURL(teamRoute) + const openNavigationButton = page.getByRole('button', { name: 'Open navigation', exact: true }) + if (await openNavigationButton.isVisible()) { + await openNavigationButton.click() + } + await expect(page.getByRole('button', { name: `Switch team: ${team.name}`, exact: true })).toContainText(team.name) + await expect(page.getByText('Team not found', { exact: true })).toHaveCount(0) +} + +async function expectFitsViewport(locator: Locator, viewportWidth: number): Promise { + const box = await locator.boundingBox() + expect(box).not.toBeNull() + if (box === null) { + return + } + + expect(box.x).toBeGreaterThanOrEqual(0) + expect(box.x + box.width).toBeLessThanOrEqual(viewportWidth) +} + +function isProtectedCircleRequest(url: string, circleIds: Set): boolean { + const match = new URL(url).pathname.match(/\/apps\/circles\/circles\/([^/]+)(?:\/avatar)?\/?$/) + return match !== null && circleIds.has(decodeURIComponent(match[1])) +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/tests/unit/lib/Controller/LocalControllerTest.php b/tests/unit/lib/Controller/LocalControllerTest.php index f2f2e0387..528be6c25 100644 --- a/tests/unit/lib/Controller/LocalControllerTest.php +++ b/tests/unit/lib/Controller/LocalControllerTest.php @@ -20,6 +20,8 @@ use OCA\Circles\Service\PermissionService; use OCA\Circles\Service\SearchService; use OCA\Circles\Tools\Traits\TDeserialize; +use OCP\AppFramework\Http; +use OCP\AppFramework\OCS\OCSException; use OCP\IRequest; use OCP\IUserManager; use OCP\IUserSession; @@ -107,6 +109,10 @@ public static function tearDownAfterClass(): void { protected function tearDown(): void { parent::tearDown(); + $owner = $this->userManager->get(self::TEST_USER_1); + $this->userSession->setUser($owner); + $this->container->get(FederatedUserService::class)->setLocalCurrentUser($owner); + $circleService = $this->container->get(CircleService::class); foreach ($this->circlesToCleanup as $circleId) { @@ -118,6 +124,19 @@ protected function tearDown(): void { } } + private function actAs(string $userId): void { + $this->userSession->setUser($this->userManager->get($userId)); + } + + private function findListedCircle(string $circleId): ?array { + foreach ($this->localController->circles()->getData() as $circle) { + if ($circle['id'] === $circleId) { + return $circle; + } + } + return null; + } + /** * @dataProvider dataForCirclesList */ @@ -140,6 +159,146 @@ public static function dataForCirclesList(): array { ]; } + public function testCirclesListShowsVisibleTeamsToNonMembers(): void { + $visibleOpenRequest = $this->localController->create( + 'test-visible-open-request-' . bin2hex(random_bytes(4)), + createTeamFolder: false, + )->getData(); + $visibleOpenRequestId = $visibleOpenRequest['id']; + $this->circlesToCleanup[] = $visibleOpenRequestId; + $visibleOpenRequestConfig = Circle::CFG_VISIBLE | Circle::CFG_OPEN | Circle::CFG_REQUEST; + $this->localController->editConfig($visibleOpenRequestId, $visibleOpenRequestConfig); + $visibleOpenRequestDetails = $this->localController->circleDetails($visibleOpenRequestId)->getData(); + $this->assertSame($visibleOpenRequestConfig, $visibleOpenRequestDetails['config'] & $visibleOpenRequestConfig); + + $visible = $this->localController->create( + 'test-visible-' . bin2hex(random_bytes(4)), + createTeamFolder: false, + )->getData(); + $visibleId = $visible['id']; + $this->circlesToCleanup[] = $visibleId; + $this->localController->editConfig($visibleId, Circle::CFG_VISIBLE); + $visibleDetails = $this->localController->circleDetails($visibleId)->getData(); + $this->assertSame(Circle::CFG_VISIBLE, $visibleDetails['config'] & Circle::CFG_VISIBLE); + + $default = $this->localController->create( + 'test-default-' . bin2hex(random_bytes(4)), + createTeamFolder: false, + )->getData(); + $defaultId = $default['id']; + $this->circlesToCleanup[] = $defaultId; + + $this->actAs(self::TEST_USER_2); + + $listedVisibleOpenRequest = $this->findListedCircle($visibleOpenRequestId); + $this->assertNotNull($listedVisibleOpenRequest); + $this->assertNull($listedVisibleOpenRequest['initiator']); + $this->assertSame(Circle::CFG_VISIBLE | Circle::CFG_OPEN, $listedVisibleOpenRequest['config']); + $this->assertArrayHasKey('population', $listedVisibleOpenRequest); + $this->assertIsInt($listedVisibleOpenRequest['population']); + + $listedVisible = $this->findListedCircle($visibleId); + $this->assertNotNull($listedVisible); + $this->assertNull($listedVisible['initiator']); + $this->assertSame(Circle::CFG_VISIBLE, $listedVisible['config']); + $this->assertArrayHasKey('population', $listedVisible); + $this->assertIsInt($listedVisible['population']); + + $this->assertNull($this->findListedCircle($defaultId)); + } + + public function testCircleJoinOpenTeamGrantsMembership(): void { + $circle = $this->localController->create( + 'test-open-' . bin2hex(random_bytes(4)), + createTeamFolder: false, + )->getData(); + $circleId = $circle['id']; + $this->circlesToCleanup[] = $circleId; + $config = Circle::CFG_VISIBLE | Circle::CFG_OPEN; + $this->localController->editConfig($circleId, $config); + $circleDetails = $this->localController->circleDetails($circleId)->getData(); + $this->assertSame($config, $circleDetails['config'] & $config); + + $this->actAs(self::TEST_USER_2); + + $member = $this->localController->circleJoin($circleId)->getData(); + $this->assertSame(Member::LEVEL_MEMBER, $member['level']); + $this->assertSame(Member::STATUS_MEMBER, $member['status']); + + $listedCircle = $this->findListedCircle($circleId); + $this->assertNotNull($listedCircle); + $this->assertSame(Member::LEVEL_MEMBER, $listedCircle['initiator']['level']); + } + + public function testCircleJoinRequestTeamCreatesPendingRequest(): void { + $circle = $this->localController->create( + 'test-open-request-' . bin2hex(random_bytes(4)), + createTeamFolder: false, + )->getData(); + $circleId = $circle['id']; + $this->circlesToCleanup[] = $circleId; + $config = Circle::CFG_VISIBLE | Circle::CFG_OPEN | Circle::CFG_REQUEST; + $this->localController->editConfig($circleId, $config); + $circleDetails = $this->localController->circleDetails($circleId)->getData(); + $this->assertSame($config, $circleDetails['config'] & $config); + + $this->actAs(self::TEST_USER_2); + + $member = $this->localController->circleJoin($circleId)->getData(); + $this->assertSame(Member::LEVEL_NONE, $member['level']); + $this->assertSame(Member::STATUS_REQUEST, $member['status']); + + $listedCircle = $this->findListedCircle($circleId); + $this->assertNotNull($listedCircle); + $this->assertSame(Member::LEVEL_NONE, $listedCircle['initiator']['level']); + $this->assertSame(Member::STATUS_REQUEST, $listedCircle['initiator']['status']); + $this->assertSame(Http::STATUS_OK, $this->localController->circleDetails($circleId)->getStatus()); + + $this->expectException(OCSException::class); + $this->localController->circleJoin($circleId); + } + + public function testCircleJoinClosedVisibleTeamFails(): void { + $circle = $this->localController->create( + 'test-closed-visible-' . bin2hex(random_bytes(4)), + createTeamFolder: false, + )->getData(); + $circleId = $circle['id']; + $this->circlesToCleanup[] = $circleId; + $this->localController->editConfig($circleId, Circle::CFG_VISIBLE); + $circleDetails = $this->localController->circleDetails($circleId)->getData(); + $this->assertSame(Circle::CFG_VISIBLE, $circleDetails['config'] & Circle::CFG_VISIBLE); + + $this->actAs(self::TEST_USER_2); + + try { + $this->localController->circleJoin($circleId); + $this->fail('Expected OCSException'); + } catch (OCSException) { + } + + $listedCircle = $this->findListedCircle($circleId); + $this->assertNotNull($listedCircle); + $this->assertNull($listedCircle['initiator']); + } + + public function testNonMemberCannotReadVisibleTeamDetailsOrAvatar(): void { + $circle = $this->localController->create( + 'test-visible-restricted-' . bin2hex(random_bytes(4)), + createTeamFolder: false, + )->getData(); + $circleId = $circle['id']; + $this->circlesToCleanup[] = $circleId; + $this->localController->editConfig($circleId, Circle::CFG_VISIBLE); + $circleDetails = $this->localController->circleDetails($circleId)->getData(); + $this->assertSame(Circle::CFG_VISIBLE, $circleDetails['config'] & Circle::CFG_VISIBLE); + + $this->actAs(self::TEST_USER_2); + + $this->assertSame(Http::STATUS_FORBIDDEN, $this->localController->circleDetails($circleId)->getStatus()); + $this->assertSame(Http::STATUS_FORBIDDEN, $this->localController->circleAvatar($circleId)->getStatus()); + } + public function testMemberAdd(): void { // Add TEST_USER_2 as a member $memberResult = $this->localController->memberAdd($this->circleId, self::TEST_USER_2, Member::TYPE_USER)->getData();