import { mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import OAuthCallbackView from '@/views/auth/OAuthCallbackView.vue' const { routeState, locationState, routerReplaceMock, showErrorMock, showSuccessMock, setTokenMock, copyToClipboardMock, exchangePendingOAuthCompletionMock, apiPostMock, } = vi.hoisted(() => ({ routeState: { path: '/auth/callback', query: {} as Record, }, locationState: { current: { href: 'http://localhost/auth/callback', hash: '', } as { href: string; hash: string }, }, routerReplaceMock: vi.fn(), showErrorMock: vi.fn(), showSuccessMock: vi.fn(), setTokenMock: vi.fn(), copyToClipboardMock: vi.fn(), exchangePendingOAuthCompletionMock: vi.fn(), apiPostMock: vi.fn(), })) vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ replace: (...args: any[]) => routerReplaceMock(...args), }), })) vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key, }), })) vi.mock('@/stores', () => ({ useAuthStore: () => ({ setToken: (...args: any[]) => setTokenMock(...args), }), useAppStore: () => ({ showError: (...args: any[]) => showErrorMock(...args), showSuccess: (...args: any[]) => showSuccessMock(...args), }), })) vi.mock('@/api/client', () => ({ apiClient: { post: (...args: any[]) => apiPostMock(...args), }, })) vi.mock('@/api/auth', async () => { const actual = await vi.importActual('@/api/auth') return { ...actual, exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletionMock(...args), persistOAuthTokenContext: vi.fn(), } }) vi.mock('@/composables/useClipboard', () => ({ useClipboard: () => ({ copyToClipboard: (...args: any[]) => copyToClipboardMock(...args), }), })) describe('OAuthCallbackView', () => { beforeEach(() => { routeState.path = '/auth/callback' routeState.query = {} locationState.current = { href: 'http://localhost/auth/callback', hash: '', } Object.defineProperty(window, 'location', { configurable: true, value: locationState.current, }) routerReplaceMock.mockReset() showErrorMock.mockReset() showSuccessMock.mockReset() setTokenMock.mockReset() copyToClipboardMock.mockReset() exchangePendingOAuthCompletionMock.mockReset() apiPostMock.mockReset() window.sessionStorage.clear() }) it('renders localized callback copy actions', () => { routeState.query = { code: 'oauth-code', state: 'oauth-state', } const wrapper = mount(OAuthCallbackView) expect(wrapper.text()).toContain('auth.oauth.callbackTitle') expect(wrapper.text()).toContain('auth.oauth.callbackHint') expect(wrapper.text()).toContain('common.copy') expect(wrapper.find('input[value="oauth-code"]').exists()).toBe(true) expect(wrapper.find('input[value="oauth-state"]').exists()).toBe(true) }) it('sends callback errors to toast instead of rendering inline red text', () => { routeState.query = { error: 'oauth failed', } const wrapper = mount(OAuthCallbackView) expect(showErrorMock).toHaveBeenCalledWith('oauth failed') expect(wrapper.text()).not.toContain('oauth failed') expect(wrapper.find('.bg-red-50').exists()).toBe(false) }) it('does not render manual copy fields for direct email oauth callback visits', async () => { routeState.path = '/auth/oauth/callback' exchangePendingOAuthCompletionMock.mockRejectedValue(new Error('pending session not found')) const wrapper = mount(OAuthCallbackView) await vi.dynamicImportSettled() expect(exchangePendingOAuthCompletionMock).toHaveBeenCalledTimes(1) expect(wrapper.text()).toContain('auth.oauth.invalidCallbackTitle') expect(wrapper.text()).toContain('auth.oauth.invalidCallbackHint') expect(wrapper.find('input[readonly]').exists()).toBe(false) }) it('forwards frontend email oauth provider callbacks back to the backend callback endpoint', async () => { routeState.path = '/auth/oauth/callback' routeState.query = { code: 'provider-code', state: 'provider-state', } window.sessionStorage.setItem('email_oauth_pending_provider', 'google') mount(OAuthCallbackView) await vi.dynamicImportSettled() expect(locationState.current.href).toBe( '/api/v1/auth/oauth/google/callback?code=provider-code&state=provider-state' ) expect(exchangePendingOAuthCompletionMock).not.toHaveBeenCalled() }) it('submits stored affiliate code when completing invited email oauth registration', async () => { routeState.path = '/auth/oauth/callback' exchangePendingOAuthCompletionMock.mockResolvedValue({ error: 'invitation_required', provider: 'google', redirect: '/dashboard', resolved_email: 'pending@example.com', invitation_required: true, }) apiPostMock.mockResolvedValue({ data: { access_token: 'token-1', }, }) window.sessionStorage.setItem('oauth_aff_code', 'AFF456') const wrapper = mount(OAuthCallbackView) await vi.dynamicImportSettled() const passwordInputs = wrapper.findAll('input[type="password"]') await passwordInputs[0].setValue('secret-123') await passwordInputs[1].setValue('secret-123') const invitationInput = wrapper.find('input[type="text"]') await invitationInput.setValue('INVITE456') await wrapper.findAll('button').at(0)?.trigger('click') expect(apiPostMock).toHaveBeenCalledWith('/auth/oauth/google/complete-registration', { password: 'secret-123', invitation_code: 'INVITE456', aff_code: 'AFF456', }) expect(setTokenMock).toHaveBeenCalledWith('token-1') }) it('completes email oauth registration with readonly email and without posting email', async () => { routeState.path = '/auth/oauth/callback' exchangePendingOAuthCompletionMock.mockResolvedValue({ error: 'registration_completion_required', provider: 'github', redirect: '/dashboard', resolved_email: 'verified@example.com', invitation_required: false, }) apiPostMock.mockResolvedValue({ data: { access_token: 'token-2', }, }) const wrapper = mount(OAuthCallbackView) await vi.dynamicImportSettled() const emailInput = wrapper.find('input[type="email"]') expect(emailInput.exists()).toBe(true) expect((emailInput.element as HTMLInputElement).value).toBe('verified@example.com') expect(emailInput.attributes('readonly')).toBeDefined() expect(emailInput.attributes('disabled')).toBeDefined() const passwordInputs = wrapper.findAll('input[type="password"]') await passwordInputs[0].setValue('secret-456') await passwordInputs[1].setValue('secret-456') await wrapper.findAll('button').at(0)?.trigger('click') expect(apiPostMock).toHaveBeenCalledWith('/auth/oauth/github/complete-registration', { password: 'secret-456', }) expect(apiPostMock.mock.calls[0][1]).not.toHaveProperty('email') expect(setTokenMock).toHaveBeenCalledWith('token-2') }) })