Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 7 additions & 14 deletions src/platform/auth/workspace/useWorkspaceSwitch.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
import type { WorkspaceWithRole } from '@/platform/auth/workspace/workspaceTypes'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'

const mockSwitchWorkspace = vi.hoisted(() => vi.fn())
const mockCurrentWorkspace = vi.hoisted(() => ({
const mockActiveWorkspace = vi.hoisted(() => ({
value: null as WorkspaceWithRole | null
}))

vi.mock('@/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => ({
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
switchWorkspace: mockSwitchWorkspace
})
}))

vi.mock('pinia', () => ({
storeToRefs: () => ({
currentWorkspace: mockCurrentWorkspace
activeWorkspace: mockActiveWorkspace
})
}))

Expand Down Expand Up @@ -46,19 +46,16 @@ vi.mock('vue-i18n', () => ({
})
}))

const mockReload = vi.fn()

describe('useWorkspaceSwitch', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCurrentWorkspace.value = {
mockActiveWorkspace.value = {
id: 'workspace-1',
name: 'Test Workspace',
type: 'personal',
role: 'owner'
}
mockModifiedWorkflows.length = 0
vi.stubGlobal('location', { reload: mockReload })
})

afterEach(() => {
Expand Down Expand Up @@ -109,7 +106,6 @@ describe('useWorkspaceSwitch', () => {
expect(result).toBe(true)
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2')
expect(mockReload).toHaveBeenCalled()
})

it('shows confirmation dialog when there are unsaved changes', async () => {
Expand All @@ -136,10 +132,9 @@ describe('useWorkspaceSwitch', () => {

expect(result).toBe(false)
expect(mockSwitchWorkspace).not.toHaveBeenCalled()
expect(mockReload).not.toHaveBeenCalled()
})

it('calls switchWorkspace and reloads page after user confirms', async () => {
it('calls switchWorkspace after user confirms', async () => {
mockModifiedWorkflows.push({ isModified: true })
mockConfirm.mockResolvedValue(true)
mockSwitchWorkspace.mockResolvedValue(undefined)
Expand All @@ -149,7 +144,6 @@ describe('useWorkspaceSwitch', () => {

expect(result).toBe(true)
expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2')
expect(mockReload).toHaveBeenCalled()
})

it('returns false if switchWorkspace throws an error', async () => {
Expand All @@ -160,7 +154,6 @@ describe('useWorkspaceSwitch', () => {
const result = await switchWithConfirmation('workspace-2')

expect(result).toBe(false)
expect(mockReload).not.toHaveBeenCalled()
})
})
})
12 changes: 6 additions & 6 deletions src/platform/auth/workspace/useWorkspaceSwitch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'

import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
import { useWorkspaceAuthStore } from '@/stores/workspaceAuthStore'

export function useWorkspaceSwitch() {
const { t } = useI18n()
const workspaceAuthStore = useWorkspaceAuthStore()
const { currentWorkspace } = storeToRefs(workspaceAuthStore)
const workspaceStore = useTeamWorkspaceStore()
const { activeWorkspace } = storeToRefs(workspaceStore)
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()

Expand All @@ -17,7 +17,7 @@ export function useWorkspaceSwitch() {
}

async function switchWithConfirmation(workspaceId: string): Promise<boolean> {
if (currentWorkspace.value?.id === workspaceId) {
if (activeWorkspace.value?.id === workspaceId) {
return true
}

Expand All @@ -34,8 +34,8 @@ export function useWorkspaceSwitch() {
}

try {
await workspaceAuthStore.switchWorkspace(workspaceId)
window.location.reload()
await workspaceStore.switchWorkspace(workspaceId)
// Note: switchWorkspace triggers page reload internally
return true
} catch {
return false
Expand Down
232 changes: 232 additions & 0 deletions src/platform/workspace/composables/useInviteUrlLoader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { useInviteUrlLoader } from './useInviteUrlLoader'

/**
* Unit tests for useInviteUrlLoader composable
*
* Tests the behavior of accepting workspace invites via URL query parameters:
* - ?invite=TOKEN accepts the invite and shows success toast
* - Invalid/missing token is handled gracefully
* - API errors show error toast
* - URL is cleaned up after processing
* - Preserved query is restored after login redirect
*/

const preservedQueryMocks = vi.hoisted(() => ({
clearPreservedQuery: vi.fn(),
hydratePreservedQuery: vi.fn(),
mergePreservedQueryIntoQuery: vi.fn()
}))

vi.mock(
'@/platform/navigation/preservedQueryManager',
() => preservedQueryMocks
)

const mockRouteQuery = vi.hoisted(() => ({
value: {} as Record<string, string>
}))
const mockRouterReplace = vi.hoisted(() => vi.fn())

vi.mock('vue-router', () => ({
useRoute: () => ({
query: mockRouteQuery.value
}),
useRouter: () => ({
replace: mockRouterReplace
})
}))

const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: mockToastAdd
})
}))

vi.mock('vue-i18n', () => ({
createI18n: () => ({
global: {
t: (key: string) => key
}
}),
useI18n: () => ({
t: vi.fn((key: string, params?: Record<string, unknown>) => {
if (key === 'workspace.inviteAccepted') return 'Invite Accepted'
if (key === 'workspace.addedToWorkspace') {
return `You have been added to ${params?.workspaceName}`
}
if (key === 'workspace.inviteFailed') return 'Failed to Accept Invite'
if (key === 'g.unknownError') return 'Unknown error'
return key
})
})
}))

const mockAcceptInvite = vi.hoisted(() => vi.fn())
vi.mock('../stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
acceptInvite: mockAcceptInvite
})
}))

describe('useInviteUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRouteQuery.value = {}
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue(null)
})

afterEach(() => {
vi.restoreAllMocks()
})

describe('loadInviteFromUrl', () => {
it('does nothing when no invite param present', async () => {
mockRouteQuery.value = {}

const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()

expect(mockAcceptInvite).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockRouterReplace).not.toHaveBeenCalled()
})

it('restores preserved query and processes invite', async () => {
mockRouteQuery.value = {}
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue({
invite: 'preserved-token'
})
mockAcceptInvite.mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})

const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()

expect(preservedQueryMocks.hydratePreservedQuery).toHaveBeenCalledWith(
'invite'
)
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { invite: 'preserved-token' }
})
expect(mockAcceptInvite).toHaveBeenCalledWith('preserved-token')
})

it('accepts invite and shows success toast on success', async () => {
mockRouteQuery.value = { invite: 'valid-token' }
mockAcceptInvite.mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})

const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()

expect(mockAcceptInvite).toHaveBeenCalledWith('valid-token')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'Invite Accepted',
detail: 'You have been added to Test Workspace',
life: 5000
})
})

it('shows error toast when invite acceptance fails', async () => {
mockRouteQuery.value = { invite: 'invalid-token' }
mockAcceptInvite.mockRejectedValue(new Error('Invalid invite'))

const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()

expect(mockAcceptInvite).toHaveBeenCalledWith('invalid-token')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Failed to Accept Invite',
detail: 'Invalid invite',
life: 5000
})
})

it('cleans up URL after processing invite', async () => {
mockRouteQuery.value = { invite: 'valid-token', other: 'param' }
mockAcceptInvite.mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})

const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()

// Should replace with query without invite param
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { other: 'param' }
})
})

it('clears preserved query after processing', async () => {
mockRouteQuery.value = { invite: 'valid-token' }
mockAcceptInvite.mockResolvedValue({
workspaceId: 'ws-123',
workspaceName: 'Test Workspace'
})

const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()

expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'invite'
)
})

it('clears preserved query even on error', async () => {
mockRouteQuery.value = { invite: 'invalid-token' }
mockAcceptInvite.mockRejectedValue(new Error('Invalid invite'))

const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()

expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'invite'
)
})

it('sends any token format to backend for validation', async () => {
mockRouteQuery.value = { invite: 'any-token-format==' }
mockAcceptInvite.mockRejectedValue(new Error('Invalid token'))

const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()

// Token is sent to backend, which validates and rejects
expect(mockAcceptInvite).toHaveBeenCalledWith('any-token-format==')
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Failed to Accept Invite',
detail: 'Invalid token',
life: 5000
})
})

it('ignores empty invite param', async () => {
mockRouteQuery.value = { invite: '' }

const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()

expect(mockAcceptInvite).not.toHaveBeenCalled()
})

it('ignores non-string invite param', async () => {
mockRouteQuery.value = { invite: ['array', 'value'] as unknown as string }

const { loadInviteFromUrl } = useInviteUrlLoader()
await loadInviteFromUrl()

expect(mockAcceptInvite).not.toHaveBeenCalled()
})
})
})
Loading
Loading