Skip to content

Commit

Permalink
feat(federation): manage invitations through the store
Browse files Browse the repository at this point in the history
Signed-off-by: Maksim Sukharev <antreesy.web@gmail.com>
  • Loading branch information
Antreesy committed Feb 14, 2024
1 parent ab5ee21 commit 1268dbc
Show file tree
Hide file tree
Showing 5 changed files with 529 additions and 10 deletions.
30 changes: 20 additions & 10 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ import debounce from 'debounce'
import PreventUnload from 'vue-prevent-unload'

import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { generateUrl } from '@nextcloud/router'

Expand All @@ -64,6 +63,7 @@ import Router from './router/router.js'
import BrowserStorage from './services/BrowserStorage.js'
import { EventBus } from './services/EventBus.js'
import { leaveConversationSync } from './services/participantsService.js'
import { useFederationStore } from './stores/federation.js'
import { checkBrowser } from './utils/browserCheck.js'
import { signalingKill } from './utils/webrtc/index.js'

Expand All @@ -87,6 +87,7 @@ export default {
isMobile: useIsMobile(),
isNextcloudTalkHashDirty: useHashCheck(),
supportSessionState: useActiveSession(),
federationStore: useFederationStore(),
}
},

Expand Down Expand Up @@ -548,16 +549,24 @@ export default {
// Federation invitation handling
if (event.notification.objectType === 'remote_talk_share') {
try {
const response = await axios.post(event.action.url)
this.$store.dispatch('addConversation', response.data.ocs.data)
this.$router.push({
name: 'conversation',
params: {
token: response.data.ocs.data.token,
},
})

event.cancelAction = true
const conversation = await this.federationStore.acceptShare(event.notification.objectId)
if (conversation.token) {
this.$store.dispatch('addConversation', conversation)
this.$router.push({ name: 'conversation', params: { token: conversation.token } })
}
} catch (error) {
console.error(error)
}
}
break
}
case 'DELETE': {
// Federation invitation handling
if (event.notification.objectType === 'remote_talk_share') {
try {
event.cancelAction = true
this.federationStore.rejectShare(event.notification.objectId)
} catch (error) {
console.error(error)
}
Expand Down Expand Up @@ -600,6 +609,7 @@ export default {
}
// Federation invitation handling
case 'remote_talk_share': {
this.federationStore.addInvitationFromNotification(event.notification)
break
}
default: break
Expand Down
7 changes: 7 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,3 +267,10 @@ export const AVATAR = {
FULL: 512,
},
}

export const FEDERATION = {
STATE: {
PENDING: 0,
ACCEPTED: 1,
},
}
59 changes: 59 additions & 0 deletions src/services/federationService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* @copyright Copyright (c) 2024 Maksim Sukharev <antreesy.web@gmail.com>
*
* @author Maksim Sukharev <antreesy.web@gmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'

/**
* Fetches list of shares for a current user
*
* @param {object} [options] options;
*/
const getShares = async function(options) {
return axios.get(generateOcsUrl('apps/spreed/api/v1/federation/invitation', undefined, options), options)
}

/**
* Accept an invitation by provided id.
*
* @param {number} id invitation id;
* @param {object} [options] options;
*/
const acceptShare = async function(id, options) {
return axios.post(generateOcsUrl('apps/spreed/api/v1/federation/invitation/{id}', { id }, options), {}, options)
}

/**
* Reject an invitation by provided id.
*
* @param {number} id invitation id;
* @param {object} [options] options;
*/
const rejectShare = async function(id, options) {
return axios.delete(generateOcsUrl('apps/spreed/api/v1/federation/invitation/{id}', { id }, options), options)
}

export {
getShares,
acceptShare,
rejectShare,
}
262 changes: 262 additions & 0 deletions src/stores/__tests__/federation.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { setActivePinia, createPinia } from 'pinia'

import { getShares, acceptShare, rejectShare } from '../../services/federationService.js'
import { generateOCSErrorResponse, generateOCSResponse } from '../../test-helpers.js'
import { useFederationStore } from '../federation.js'

jest.mock('../../services/federationService', () => ({
getShares: jest.fn(),
acceptShare: jest.fn(),
rejectShare: jest.fn(),
}))

describe('federationStore', () => {
const invites = [
{
id: 2,
userId: 'user0',
state: 0,
localRoomId: 10,
accessToken: 'ACCESS_TOKEN',
remoteServerUrl: 'remote.nextcloud.com',
remoteToken: 'TOKEN_2',
remoteAttendeeId: 11,
inviterCloudId: 'user2@remote.nextcloud.com',
inviterDisplayName: 'User Two',
roomName: 'Federation room 2'
},
{
id: 1,
userId: 'user0',
state: 1,
localRoomId: 9,
accessToken: 'ACCESS_TOKEN',
remoteServerUrl: 'remote.nextcloud.com',
remoteToken: 'TOKEN_1',
remoteAttendeeId: 11,
inviterCloudId: 'user1@remote.nextcloud.com',
inviterDisplayName: 'User One',
roomName: 'Federation room 1'
},
]
const notifications = [
{
notificationId: 122,
app: 'spreed',
user: 'user0',
objectType: 'remote_talk_share',
objectId: '2',
messageRichParameters: {
user1: {
type: 'user',
id: 'user2',
name: 'User Two',
server: 'remote.nextcloud.com'
},
roomName: {
type: 'highlight',
id: 'remote.nextcloud.com::TOKEN_2',
name: 'Federation room 2'
},
},
},
{
notificationId: 123,
app: 'spreed',
user: 'user0',
objectType: 'remote_talk_share',
objectId: '3',
messageRichParameters: {
user1: {
type: 'user',
id: 'user3',
name: 'User Three',
server: 'remote.nextcloud.com'
},
roomName: {
type: 'highlight',
id: 'remote.nextcloud.com::TOKEN_3',
name: 'Federation room 3'
},
},
}
]
let federationStore

beforeEach(async () => {
setActivePinia(createPinia())
federationStore = useFederationStore()
})

afterEach(async () => {
jest.clearAllMocks()
})

it('returns an empty objects when invitations are not loaded yet', async () => {
// Assert: check initial state of the store
expect(federationStore.pendingShares).toStrictEqual({})
expect(federationStore.acceptedShares).toStrictEqual({})
})

it('does not handle accepted invitations when missing in the store', async () => {
// Act: accept invite from notification
await federationStore.markInvitationAccepted(invites[0].id, {})

// Assert: check initial state of the store
expect(federationStore.pendingShares).toStrictEqual({})
expect(federationStore.acceptedShares).toStrictEqual({})
})

it('processes a response from server and stores invites', async () => {
// Arrange
const response = generateOCSResponse({ payload: invites })
getShares.mockResolvedValueOnce(response)

// Act: load invites from server
await federationStore.getShares()

// Assert
expect(getShares).toHaveBeenCalled()
expect(federationStore.pendingShares).toMatchObject({ [invites[0].id]: invites[0] })
expect(federationStore.acceptedShares).toMatchObject({ [invites[1].id]: invites[1] })
})

it('handles error in server request for getShares', async () => {
// Arrange
const response = generateOCSErrorResponse({ status: 404, payload: [] })
getShares.mockRejectedValueOnce(response)
console.error = jest.fn()

// Act
await federationStore.getShares()

// Assert: store hasn't changed
expect(federationStore.pendingShares).toStrictEqual({})
expect(federationStore.acceptedShares).toStrictEqual({})
})

it('updates invites in the store after receiving a notification', async () => {
// Arrange
const response = generateOCSResponse({ payload: invites })
getShares.mockResolvedValueOnce(response)
await federationStore.getShares()

// Act: trigger notification handling
notifications.forEach(federationStore.addInvitationFromNotification)

// Assert
expect(federationStore.pendingShares).toMatchObject({
[invites[0].id]: invites[0],
[notifications[1].objectId]: { id: notifications[1].objectId },
})
expect(federationStore.acceptedShares).toMatchObject({ [invites[1].id]: invites[1] })
})

it('accepts invitation and modify store', async () => {
// Arrange
const response = generateOCSResponse({ payload: invites })
getShares.mockResolvedValueOnce(response)
await federationStore.getShares()

const room = {
id: 10,
remoteAccessToken: 'ACCESS_TOKEN',
}
const acceptResponse = generateOCSResponse({ payload: room })
acceptShare.mockResolvedValueOnce(acceptResponse)

// Act: accept invite
const conversation = await federationStore.acceptShare(invites[0].id)

// Assert
expect(acceptShare).toHaveBeenCalledWith(invites[0].id)
expect(conversation).toMatchObject(room)
expect(federationStore.pendingShares).toStrictEqual({})
expect(federationStore.acceptedShares).toMatchObject({
[invites[0].id]: { ...invites[0], state: 1 },
[invites[1].id]: invites[1],
})
})

it('skip already accepted invitations', async () => {
// Arrange
const response = generateOCSResponse({ payload: invites })
getShares.mockResolvedValueOnce(response)
await federationStore.getShares()

// Act: accept invite
await federationStore.acceptShare(invites[1].id)

// Assert
expect(acceptShare).not.toHaveBeenCalled()
expect(federationStore.pendingShares).toMatchObject({ [invites[0].id]: invites[0] })
expect(federationStore.acceptedShares).toMatchObject({ [invites[1].id]: invites[1] })
})

it('handles error in server request for acceptShare', async () => {
// Arrange
const response = generateOCSResponse({ payload: invites })
getShares.mockResolvedValueOnce(response)
await federationStore.getShares()
const errorResponse = generateOCSErrorResponse({ status: 404, payload: [] })
acceptShare.mockRejectedValueOnce(errorResponse)
console.error = jest.fn()

// Act
await federationStore.acceptShare(invites[0].id)

// Assert: store hasn't changed
expect(federationStore.pendingShares).toMatchObject({ [invites[0].id]: invites[0] })
expect(federationStore.acceptedShares).toMatchObject({ [invites[1].id]: invites[1] })
})

it('rejects invitation and modify store', async () => {
// Arrange
const response = generateOCSResponse({ payload: invites })
getShares.mockResolvedValueOnce(response)
await federationStore.getShares()

const rejectResponse = generateOCSResponse({ payload: [] })
rejectShare.mockResolvedValueOnce(rejectResponse)

// Act: reject invite
await federationStore.rejectShare(invites[0].id)

// Assert
expect(rejectShare).toHaveBeenCalledWith(invites[0].id)
expect(federationStore.pendingShares).toStrictEqual({})
expect(federationStore.acceptedShares).toMatchObject({ [invites[1].id]: invites[1] })
})

it('skip already rejected invitations', async () => {
// Arrange
const response = generateOCSResponse({ payload: invites })
getShares.mockResolvedValueOnce(response)
await federationStore.getShares()

// Act: reject invite
await federationStore.rejectShare(invites[1].id)

// Assert
expect(rejectShare).not.toHaveBeenCalled()
expect(federationStore.pendingShares).toMatchObject({ [invites[0].id]: invites[0] })
expect(federationStore.acceptedShares).toMatchObject({ [invites[1].id]: invites[1] })
})

it('handles error in server request for rejectShare', async () => {
// Arrange
const response = generateOCSResponse({ payload: invites })
getShares.mockResolvedValueOnce(response)
await federationStore.getShares()
const errorResponse = generateOCSErrorResponse({ status: 404, payload: [] })
rejectShare.mockRejectedValueOnce(errorResponse)
console.error = jest.fn()

// Act
await federationStore.rejectShare(invites[0].id)

// Assert: store hasn't changed
expect(federationStore.pendingShares).toMatchObject({ [invites[0].id]: invites[0] })
expect(federationStore.acceptedShares).toMatchObject({ [invites[1].id]: invites[1] })
})
})
Loading

0 comments on commit 1268dbc

Please sign in to comment.