diff --git a/app/test/OAuthDiscord.test.tsx b/app/test/OAuthDiscord.test.tsx new file mode 100644 index 000000000..1eb53d48a --- /dev/null +++ b/app/test/OAuthDiscord.test.tsx @@ -0,0 +1,303 @@ +/// +/** + * Tests for Discord OAuth login via OAuthProviderButton. + * + * Coverage areas: + * - Discord button rendering (label, icon, indigo styling) + * - OAuth flow in both Tauri (desktop) and web environments + * - Loading / disabled state management + * - Error handling when backend URL lookup fails + * - dev-mode URL construction (?responseType=json) + */ +import type { ComponentProps } from 'react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../src/test/test-utils'; +import OAuthProviderButton from '../src/components/oauth/OAuthProviderButton'; +import { oauthProviderConfigs } from '../src/components/oauth/providerConfigs'; + +// --------------------------------------------------------------------------- +// Module mocks +// --------------------------------------------------------------------------- + +const { mockGetBackendUrl, mockOpenUrl, mockIsTauri } = vi.hoisted(() => ({ + mockGetBackendUrl: vi.fn(), + mockOpenUrl: vi.fn(), + mockIsTauri: vi.fn(), +})); + +vi.mock('../src/services/backendUrl', () => ({ getBackendUrl: mockGetBackendUrl })); +vi.mock('../src/utils/openUrl', () => ({ openUrl: mockOpenUrl })); +vi.mock('../src/utils/tauriCommands', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, isTauri: mockIsTauri }; +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const discordConfig = oauthProviderConfigs.find(p => p.id === 'discord')!; + +const renderDiscordButton = (props: Partial> = {}) => + renderWithProviders(); + +const clickButton = (btn: HTMLElement) => act(async () => { fireEvent.click(btn); }); + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Discord) — rendering', () => { + it('shows the Discord label', () => { + renderDiscordButton(); + expect(screen.getByText('Discord')).toBeInTheDocument(); + }); + + it('is enabled by default', () => { + renderDiscordButton(); + expect(screen.getByRole('button', { name: /discord/i })).toBeEnabled(); + }); + + it('is disabled when disabled prop is true', () => { + renderDiscordButton({ disabled: true }); + expect(screen.getByRole('button', { name: /discord/i })).toBeDisabled(); + }); + + it('renders the Discord SVG icon', () => { + const { container } = renderDiscordButton(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('has indigo background styling', () => { + renderDiscordButton(); + expect(screen.getByRole('button', { name: /discord/i })).toHaveClass('bg-indigo-600'); + }); + + it('has white text', () => { + const { container } = renderDiscordButton(); + const label = container.querySelector('span'); + expect(label).toHaveClass('text-white'); + }); +}); + +// --------------------------------------------------------------------------- +// Web OAuth flow +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Discord) — web OAuth flow', () => { + const originalLocation = window.location; + + beforeEach(() => { + mockGetBackendUrl.mockResolvedValue('http://localhost:5005'); + mockIsTauri.mockReturnValue(false); + delete (window as unknown as Record).location; + (window as unknown as Record).location = { href: '' }; + }); + + afterEach(() => { + (window as unknown as Record).location = originalLocation; + }); + + it('redirects to /auth/discord/login?responseType=json on click', async () => { + renderDiscordButton(); + await clickButton(screen.getByRole('button', { name: /discord/i })); + + await waitFor(() => { + expect((window.location as unknown as { href: string }).href).toBe( + 'http://localhost:5005/auth/discord/login?responseType=json' + ); + }); + }); + + it('does not call openUrl in web mode', async () => { + renderDiscordButton(); + await clickButton(screen.getByRole('button', { name: /discord/i })); + + await waitFor(() => + expect((window.location as unknown as { href: string }).href).not.toBe('') + ); + expect(mockOpenUrl).not.toHaveBeenCalled(); + }); + + it('calls getBackendUrl exactly once per click', async () => { + renderDiscordButton(); + await clickButton(screen.getByRole('button', { name: /discord/i })); + + await waitFor(() => + expect((window.location as unknown as { href: string }).href).not.toBe('') + ); + expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// Tauri OAuth flow +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Discord) — Tauri OAuth flow', () => { + beforeEach(() => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + }); + + it('calls openUrl with /auth/discord/login?responseType=json', async () => { + renderDiscordButton(); + await clickButton(screen.getByRole('button', { name: /discord/i })); + + await waitFor(() => { + expect(mockOpenUrl).toHaveBeenCalledWith( + 'https://api.example.com/auth/discord/login?responseType=json' + ); + }); + }); + + it('does not set window.location.href in Tauri mode', async () => { + const originalHref = window.location.href; + renderDiscordButton(); + await clickButton(screen.getByRole('button', { name: /discord/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalledTimes(1)); + expect(window.location.href).toBe(originalHref); + }); + + it('remains in loading state after openUrl resolves (awaits deep-link callback)', async () => { + renderDiscordButton(); + await clickButton(screen.getByRole('button', { name: /discord/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalledTimes(1)); + expect(screen.getByText('Connecting...')).toBeInTheDocument(); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Loading state +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Discord) — loading state', () => { + it('shows spinner and "Connecting..." while getBackendUrl is pending', async () => { + let resolve!: (_v: string) => void; + mockGetBackendUrl.mockReturnValue(new Promise(res => { resolve = res; })); + mockIsTauri.mockReturnValue(false); + + renderDiscordButton(); + const button = screen.getByRole('button', { name: /discord/i }); + await clickButton(button); + + await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument()); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + expect(button).toBeDisabled(); + + await act(async () => { resolve('http://localhost:5005'); }); + }); + + it('ignores a second click while already loading', async () => { + let resolve!: (_v: string) => void; + mockGetBackendUrl.mockReturnValue(new Promise(res => { resolve = res; })); + mockIsTauri.mockReturnValue(false); + + renderDiscordButton(); + const button = screen.getByRole('button', { name: /discord/i }); + + await clickButton(button); + await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument()); + + fireEvent.click(button); + expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); + + await act(async () => { resolve('http://localhost:5005'); }); + }); +}); + +// --------------------------------------------------------------------------- +// Error handling +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Discord) — error handling', () => { + beforeEach(() => { + mockIsTauri.mockReturnValue(false); + }); + + it('returns to enabled state after getBackendUrl throws', async () => { + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderDiscordButton(); + const button = screen.getByRole('button', { name: /discord/i }); + await clickButton(button); + + await waitFor(() => expect(button).toBeEnabled()); + expect(screen.getByText('Discord')).toBeInTheDocument(); + }); + + it('does not redirect on getBackendUrl error (web mode)', async () => { + const originalLocation = window.location; + delete (window as unknown as Record).location; + (window as unknown as Record).location = { href: '' }; + + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderDiscordButton(); + await clickButton(screen.getByRole('button', { name: /discord/i })); + + await waitFor(() => + expect(screen.getByRole('button', { name: /discord/i })).toBeEnabled() + ); + expect((window.location as unknown as { href: string }).href).toBe(''); + + (window as unknown as Record).location = originalLocation; + }); + + it('does not call openUrl on getBackendUrl error (Tauri mode)', async () => { + mockIsTauri.mockReturnValue(true); + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderDiscordButton(); + await clickButton(screen.getByRole('button', { name: /discord/i })); + + await waitFor(() => + expect(screen.getByRole('button', { name: /discord/i })).toBeEnabled() + ); + expect(mockOpenUrl).not.toHaveBeenCalled(); + }); + + it('is a no-op when disabled and clicked', async () => { + renderDiscordButton({ disabled: true }); + await clickButton(screen.getByRole('button', { name: /discord/i })); + expect(mockGetBackendUrl).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// URL construction +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Discord) — URL construction', () => { + it('uses /auth/discord/login path (not another provider)', async () => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + + renderDiscordButton(); + await clickButton(screen.getByRole('button', { name: /discord/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); + expect(mockOpenUrl.mock.calls[0][0]).toContain('/auth/discord/login'); + }); + + it('appends ?responseType=json in dev mode (Tauri)', async () => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + + renderDiscordButton(); + await clickButton(screen.getByRole('button', { name: /discord/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); + expect(mockOpenUrl.mock.calls[0][0]).toBe( + 'https://api.example.com/auth/discord/login?responseType=json' + ); + }); +}); diff --git a/app/test/OAuthGitHub.test.tsx b/app/test/OAuthGitHub.test.tsx new file mode 100644 index 000000000..be7851842 --- /dev/null +++ b/app/test/OAuthGitHub.test.tsx @@ -0,0 +1,303 @@ +/// +/** + * Tests for GitHub OAuth login via OAuthProviderButton. + * + * Coverage areas: + * - GitHub button rendering (label, icon, dark styling) + * - OAuth flow in both Tauri (desktop) and web environments + * - Loading / disabled state management + * - Error handling when backend URL lookup fails + * - dev-mode URL construction (?responseType=json) + */ +import type { ComponentProps } from 'react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../src/test/test-utils'; +import OAuthProviderButton from '../src/components/oauth/OAuthProviderButton'; +import { oauthProviderConfigs } from '../src/components/oauth/providerConfigs'; + +// --------------------------------------------------------------------------- +// Module mocks +// --------------------------------------------------------------------------- + +const { mockGetBackendUrl, mockOpenUrl, mockIsTauri } = vi.hoisted(() => ({ + mockGetBackendUrl: vi.fn(), + mockOpenUrl: vi.fn(), + mockIsTauri: vi.fn(), +})); + +vi.mock('../src/services/backendUrl', () => ({ getBackendUrl: mockGetBackendUrl })); +vi.mock('../src/utils/openUrl', () => ({ openUrl: mockOpenUrl })); +vi.mock('../src/utils/tauriCommands', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, isTauri: mockIsTauri }; +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const githubConfig = oauthProviderConfigs.find(p => p.id === 'github')!; + +const renderGitHubButton = (props: Partial> = {}) => + renderWithProviders(); + +const clickButton = (btn: HTMLElement) => act(async () => { fireEvent.click(btn); }); + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (GitHub) — rendering', () => { + it('shows the GitHub label', () => { + renderGitHubButton(); + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + it('is enabled by default', () => { + renderGitHubButton(); + expect(screen.getByRole('button', { name: /github/i })).toBeEnabled(); + }); + + it('is disabled when disabled prop is true', () => { + renderGitHubButton({ disabled: true }); + expect(screen.getByRole('button', { name: /github/i })).toBeDisabled(); + }); + + it('renders the GitHub SVG icon', () => { + const { container } = renderGitHubButton(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('has dark background styling', () => { + renderGitHubButton(); + expect(screen.getByRole('button', { name: /github/i })).toHaveClass('bg-gray-900'); + }); + + it('has white text', () => { + const { container } = renderGitHubButton(); + const label = container.querySelector('span'); + expect(label).toHaveClass('text-white'); + }); +}); + +// --------------------------------------------------------------------------- +// Web OAuth flow +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (GitHub) — web OAuth flow', () => { + const originalLocation = window.location; + + beforeEach(() => { + mockGetBackendUrl.mockResolvedValue('http://localhost:5005'); + mockIsTauri.mockReturnValue(false); + delete (window as unknown as Record).location; + (window as unknown as Record).location = { href: '' }; + }); + + afterEach(() => { + (window as unknown as Record).location = originalLocation; + }); + + it('redirects to /auth/github/login?responseType=json on click', async () => { + renderGitHubButton(); + await clickButton(screen.getByRole('button', { name: /github/i })); + + await waitFor(() => { + expect((window.location as unknown as { href: string }).href).toBe( + 'http://localhost:5005/auth/github/login?responseType=json' + ); + }); + }); + + it('does not call openUrl in web mode', async () => { + renderGitHubButton(); + await clickButton(screen.getByRole('button', { name: /github/i })); + + await waitFor(() => + expect((window.location as unknown as { href: string }).href).not.toBe('') + ); + expect(mockOpenUrl).not.toHaveBeenCalled(); + }); + + it('calls getBackendUrl exactly once per click', async () => { + renderGitHubButton(); + await clickButton(screen.getByRole('button', { name: /github/i })); + + await waitFor(() => + expect((window.location as unknown as { href: string }).href).not.toBe('') + ); + expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// Tauri OAuth flow +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (GitHub) — Tauri OAuth flow', () => { + beforeEach(() => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + }); + + it('calls openUrl with /auth/github/login?responseType=json', async () => { + renderGitHubButton(); + await clickButton(screen.getByRole('button', { name: /github/i })); + + await waitFor(() => { + expect(mockOpenUrl).toHaveBeenCalledWith( + 'https://api.example.com/auth/github/login?responseType=json' + ); + }); + }); + + it('does not set window.location.href in Tauri mode', async () => { + const originalHref = window.location.href; + renderGitHubButton(); + await clickButton(screen.getByRole('button', { name: /github/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalledTimes(1)); + expect(window.location.href).toBe(originalHref); + }); + + it('remains in loading state after openUrl resolves (awaits deep-link callback)', async () => { + renderGitHubButton(); + await clickButton(screen.getByRole('button', { name: /github/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalledTimes(1)); + expect(screen.getByText('Connecting...')).toBeInTheDocument(); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Loading state +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (GitHub) — loading state', () => { + it('shows spinner and "Connecting..." while getBackendUrl is pending', async () => { + let resolve!: (_v: string) => void; + mockGetBackendUrl.mockReturnValue(new Promise(res => { resolve = res; })); + mockIsTauri.mockReturnValue(false); + + renderGitHubButton(); + const button = screen.getByRole('button', { name: /github/i }); + await clickButton(button); + + await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument()); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + expect(button).toBeDisabled(); + + await act(async () => { resolve('http://localhost:5005'); }); + }); + + it('ignores a second click while already loading', async () => { + let resolve!: (_v: string) => void; + mockGetBackendUrl.mockReturnValue(new Promise(res => { resolve = res; })); + mockIsTauri.mockReturnValue(false); + + renderGitHubButton(); + const button = screen.getByRole('button', { name: /github/i }); + + await clickButton(button); + await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument()); + + fireEvent.click(button); + expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); + + await act(async () => { resolve('http://localhost:5005'); }); + }); +}); + +// --------------------------------------------------------------------------- +// Error handling +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (GitHub) — error handling', () => { + beforeEach(() => { + mockIsTauri.mockReturnValue(false); + }); + + it('returns to enabled state after getBackendUrl throws', async () => { + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderGitHubButton(); + const button = screen.getByRole('button', { name: /github/i }); + await clickButton(button); + + await waitFor(() => expect(button).toBeEnabled()); + expect(screen.getByText('GitHub')).toBeInTheDocument(); + }); + + it('does not redirect on getBackendUrl error (web mode)', async () => { + const originalLocation = window.location; + delete (window as unknown as Record).location; + (window as unknown as Record).location = { href: '' }; + + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderGitHubButton(); + await clickButton(screen.getByRole('button', { name: /github/i })); + + await waitFor(() => + expect(screen.getByRole('button', { name: /github/i })).toBeEnabled() + ); + expect((window.location as unknown as { href: string }).href).toBe(''); + + (window as unknown as Record).location = originalLocation; + }); + + it('does not call openUrl on getBackendUrl error (Tauri mode)', async () => { + mockIsTauri.mockReturnValue(true); + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderGitHubButton(); + await clickButton(screen.getByRole('button', { name: /github/i })); + + await waitFor(() => + expect(screen.getByRole('button', { name: /github/i })).toBeEnabled() + ); + expect(mockOpenUrl).not.toHaveBeenCalled(); + }); + + it('is a no-op when disabled and clicked', async () => { + renderGitHubButton({ disabled: true }); + await clickButton(screen.getByRole('button', { name: /github/i })); + expect(mockGetBackendUrl).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// URL construction — /auth/github/login path +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (GitHub) — URL construction', () => { + it('uses /auth/github/login path (not another provider)', async () => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + + renderGitHubButton(); + await clickButton(screen.getByRole('button', { name: /github/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); + expect(mockOpenUrl.mock.calls[0][0]).toContain('/auth/github/login'); + }); + + it('appends ?responseType=json in dev mode (Tauri)', async () => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + + renderGitHubButton(); + await clickButton(screen.getByRole('button', { name: /github/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); + expect(mockOpenUrl.mock.calls[0][0]).toBe( + 'https://api.example.com/auth/github/login?responseType=json' + ); + }); +}); diff --git a/app/test/OAuthLoginSection.test.tsx b/app/test/OAuthLoginSection.test.tsx new file mode 100644 index 000000000..0f7e5b53b --- /dev/null +++ b/app/test/OAuthLoginSection.test.tsx @@ -0,0 +1,396 @@ +/// +/** + * Tests for Google OAuth login via OAuthLoginSection and OAuthProviderButton. + * + * Coverage areas: + * - Section renders all providers including Google + * - Google button initiates OAuth in both Tauri (desktop) and web environments + * - Loading / disabled state management during login + * - Error handling when the backend URL lookup fails + * - dev-mode URL construction (responseType=json query param) + */ +import type { ComponentProps } from 'react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../src/test/test-utils'; +import OAuthLoginSection from '../src/components/oauth/OAuthLoginSection'; +import OAuthProviderButton from '../src/components/oauth/OAuthProviderButton'; +import { oauthProviderConfigs } from '../src/components/oauth/providerConfigs'; + +// --------------------------------------------------------------------------- +// Module mocks +// vi.hoisted() ensures mock functions are available inside vi.mock() factories +// (which are hoisted to the top of the file by Vitest). +// --------------------------------------------------------------------------- + +const { mockGetBackendUrl, mockOpenUrl, mockIsTauri } = vi.hoisted(() => ({ + mockGetBackendUrl: vi.fn(), + mockOpenUrl: vi.fn(), + mockIsTauri: vi.fn(), +})); + +vi.mock('../src/services/backendUrl', () => ({ + getBackendUrl: mockGetBackendUrl, +})); + +vi.mock('../src/utils/openUrl', () => ({ + openUrl: mockOpenUrl, +})); + +vi.mock('../src/utils/tauriCommands', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, isTauri: mockIsTauri }; +}); + +// IS_DEV is set to `true` by the global setup mock of '../utils/config' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const googleConfig = oauthProviderConfigs.find(p => p.id === 'google')!; + +const renderSection = (props: Partial> = {}) => + renderWithProviders(); + +const renderGoogleButton = (props: Partial> = {}) => + renderWithProviders(); + +// act() with an async callback returns Promise, making await valid. +const clickButton = (btn: HTMLElement) => act(async () => { fireEvent.click(btn); }); + +// --------------------------------------------------------------------------- +// OAuthLoginSection — rendering +// --------------------------------------------------------------------------- + +describe('OAuthLoginSection', () => { + it('renders the "Continue with" heading', () => { + renderSection(); + expect(screen.getByText('Continue with')).toBeInTheDocument(); + }); + + it('renders a button for every configured OAuth provider', () => { + renderSection(); + for (const provider of oauthProviderConfigs) { + expect( + screen.getByRole('button', { name: new RegExp(provider.name, 'i') }) + ).toBeInTheDocument(); + } + }); + + it('renders a Google login button', () => { + renderSection(); + expect(screen.getByRole('button', { name: /google/i })).toBeInTheDocument(); + }); + + it('renders buttons in a 2-column grid', () => { + const { container } = renderSection(); + const grid = container.querySelector('.grid.grid-cols-2'); + expect(grid).toBeInTheDocument(); + expect(grid!.children).toHaveLength(oauthProviderConfigs.length); + }); + + it('applies extra className to the wrapper div', () => { + const { container } = renderSection({ className: 'mt-8' }); + expect(container.firstChild).toHaveClass('mt-8'); + }); + + it('forwards disabled prop to every provider button', () => { + renderSection({ disabled: true }); + for (const btn of screen.getAllByRole('button')) { + expect(btn).toBeDisabled(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Google button — initial render +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Google) — rendering', () => { + beforeEach(() => { + mockGetBackendUrl.mockResolvedValue('http://localhost:5005'); + mockIsTauri.mockReturnValue(false); + }); + + it('shows the Google label', () => { + renderGoogleButton(); + expect(screen.getByText('Google')).toBeInTheDocument(); + }); + + it('is enabled by default', () => { + renderGoogleButton(); + expect(screen.getByRole('button', { name: /google/i })).toBeEnabled(); + }); + + it('is disabled when disabled prop is true', () => { + renderGoogleButton({ disabled: true }); + expect(screen.getByRole('button', { name: /google/i })).toBeDisabled(); + }); + + it('renders the Google SVG icon', () => { + const { container } = renderGoogleButton(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Google button — web environment OAuth flow +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Google) — web OAuth flow', () => { + const originalLocation = window.location; + + beforeEach(() => { + mockGetBackendUrl.mockResolvedValue('http://localhost:5005'); + mockIsTauri.mockReturnValue(false); + + // Replace window.location so we can assert href changes + delete (window as unknown as Record).location; + (window as unknown as Record).location = { href: '' }; + }); + + afterEach(() => { + (window as unknown as Record).location = originalLocation; + }); + + it('redirects to backend Google OAuth URL on click (web, IS_DEV=true)', async () => { + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => { + expect((window.location as unknown as { href: string }).href).toBe( + 'http://localhost:5005/auth/google/login?responseType=json' + ); + }); + }); + + it('does not call openUrl (Tauri) in web mode', async () => { + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => + expect((window.location as unknown as { href: string }).href).not.toBe('') + ); + expect(mockOpenUrl).not.toHaveBeenCalled(); + }); + + it('calls getBackendUrl exactly once per click', async () => { + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => + expect((window.location as unknown as { href: string }).href).not.toBe('') + ); + expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// Google button — Tauri (desktop) OAuth flow +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Google) — Tauri OAuth flow', () => { + beforeEach(() => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + }); + + it('calls openUrl with the Google OAuth URL (Tauri, IS_DEV=true)', async () => { + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => { + expect(mockOpenUrl).toHaveBeenCalledWith( + 'https://api.example.com/auth/google/login?responseType=json' + ); + }); + }); + + it('does not set window.location.href in Tauri mode', async () => { + const originalHref = window.location.href; + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalledTimes(1)); + expect(window.location.href).toBe(originalHref); + }); +}); + +// --------------------------------------------------------------------------- +// Google button — loading state +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Google) — loading state', () => { + it('shows spinner and "Connecting..." text while login is in progress', async () => { + let resolveBackendUrl!: (_v: string) => void; + mockGetBackendUrl.mockReturnValue( + new Promise(res => { resolveBackendUrl = res; }) + ); + mockIsTauri.mockReturnValue(false); + + renderGoogleButton(); + const button = screen.getByRole('button', { name: /google/i }); + + await clickButton(button); + + await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument()); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + expect(button).toBeDisabled(); + + // Settle the promise so React doesn't warn about state updates after unmount + await act(async () => { resolveBackendUrl('http://localhost:5005'); }); + }); + + it('does not respond to a second click while already loading', async () => { + let resolveBackendUrl!: (_v: string) => void; + mockGetBackendUrl.mockReturnValue( + new Promise(res => { resolveBackendUrl = res; }) + ); + mockIsTauri.mockReturnValue(false); + + renderGoogleButton(); + const button = screen.getByRole('button', { name: /google/i }); + + await clickButton(button); + await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument()); + + // Second click while loading — getBackendUrl must still be called only once + fireEvent.click(button); + expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); + + await act(async () => { resolveBackendUrl('http://localhost:5005'); }); + }); + + it('remains in loading state after successful Tauri openUrl (awaits deep-link callback)', async () => { + // By design: the app calls openUrl() to open the system browser and then waits + // for the deep-link callback. setIsLoading(false) is only called on error, so + // the button intentionally stays in "Connecting..." state. + mockGetBackendUrl.mockResolvedValue('http://localhost:5005'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalledTimes(1)); + expect(screen.getByText('Connecting...')).toBeInTheDocument(); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Google button — error handling +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Google) — error handling', () => { + beforeEach(() => { + mockIsTauri.mockReturnValue(false); + }); + + it('returns to enabled state after getBackendUrl throws', async () => { + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderGoogleButton(); + const button = screen.getByRole('button', { name: /google/i }); + await clickButton(button); + + await waitFor(() => expect(button).toBeEnabled()); + expect(screen.getByText('Google')).toBeInTheDocument(); + }); + + it('does not redirect when getBackendUrl throws (web mode)', async () => { + const originalLocation = window.location; + delete (window as unknown as Record).location; + (window as unknown as Record).location = { href: '' }; + + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => + expect(screen.getByRole('button', { name: /google/i })).toBeEnabled() + ); + expect((window.location as unknown as { href: string }).href).toBe(''); + + (window as unknown as Record).location = originalLocation; + }); + + it('does not call openUrl when getBackendUrl throws in Tauri mode', async () => { + mockIsTauri.mockReturnValue(true); + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => + expect(screen.getByRole('button', { name: /google/i })).toBeEnabled() + ); + expect(mockOpenUrl).not.toHaveBeenCalled(); + }); + + it('is a no-op when the button is disabled and clicked', async () => { + renderGoogleButton({ disabled: true }); + await clickButton(screen.getByRole('button', { name: /google/i })); + expect(mockGetBackendUrl).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// URL construction — dev mode query params (IS_DEV=true via global setup mock) +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Google) — dev mode URL params', () => { + // The global setup.ts mocks IS_DEV=true, so these assertions run in that context. + + it('appends ?responseType=json to the Google OAuth URL in dev mode (Tauri)', async () => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); + const calledUrl: string = mockOpenUrl.mock.calls[0][0]; + expect(calledUrl).toContain('?responseType=json'); + expect(calledUrl).toBe('https://api.example.com/auth/google/login?responseType=json'); + }); + + it('appends ?responseType=json to the Google OAuth URL in dev mode (web)', async () => { + const originalLocation = window.location; + delete (window as unknown as Record).location; + (window as unknown as Record).location = { href: '' }; + + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(false); + + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => + expect((window.location as unknown as { href: string }).href).not.toBe('') + ); + expect((window.location as unknown as { href: string }).href).toBe( + 'https://api.example.com/auth/google/login?responseType=json' + ); + + (window as unknown as Record).location = originalLocation; + }); + + it('uses the /auth/google/login path (not another provider)', async () => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + + renderGoogleButton(); + await clickButton(screen.getByRole('button', { name: /google/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); + const calledUrl: string = mockOpenUrl.mock.calls[0][0]; + expect(calledUrl).toContain('/auth/google/login'); + }); +}); diff --git a/app/test/OAuthTwitter.test.tsx b/app/test/OAuthTwitter.test.tsx new file mode 100644 index 000000000..649d85d10 --- /dev/null +++ b/app/test/OAuthTwitter.test.tsx @@ -0,0 +1,303 @@ +/// +/** + * Tests for Twitter/X OAuth login via OAuthProviderButton. + * + * Coverage areas: + * - Twitter button rendering (label, icon, black/dark styling) + * - OAuth flow in both Tauri (desktop) and web environments + * - Loading / disabled state management + * - Error handling when backend URL lookup fails + * - dev-mode URL construction (?responseType=json) + */ +import type { ComponentProps } from 'react'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../src/test/test-utils'; +import OAuthProviderButton from '../src/components/oauth/OAuthProviderButton'; +import { oauthProviderConfigs } from '../src/components/oauth/providerConfigs'; + +// --------------------------------------------------------------------------- +// Module mocks +// --------------------------------------------------------------------------- + +const { mockGetBackendUrl, mockOpenUrl, mockIsTauri } = vi.hoisted(() => ({ + mockGetBackendUrl: vi.fn(), + mockOpenUrl: vi.fn(), + mockIsTauri: vi.fn(), +})); + +vi.mock('../src/services/backendUrl', () => ({ getBackendUrl: mockGetBackendUrl })); +vi.mock('../src/utils/openUrl', () => ({ openUrl: mockOpenUrl })); +vi.mock('../src/utils/tauriCommands', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, isTauri: mockIsTauri }; +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const twitterConfig = oauthProviderConfigs.find(p => p.id === 'twitter')!; + +const renderTwitterButton = (props: Partial> = {}) => + renderWithProviders(); + +const clickButton = (btn: HTMLElement) => act(async () => { fireEvent.click(btn); }); + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Twitter) — rendering', () => { + it('shows the Twitter label', () => { + renderTwitterButton(); + expect(screen.getByText('Twitter')).toBeInTheDocument(); + }); + + it('is enabled by default', () => { + renderTwitterButton(); + expect(screen.getByRole('button', { name: /twitter/i })).toBeEnabled(); + }); + + it('is disabled when disabled prop is true', () => { + renderTwitterButton({ disabled: true }); + expect(screen.getByRole('button', { name: /twitter/i })).toBeDisabled(); + }); + + it('renders the Twitter SVG icon', () => { + const { container } = renderTwitterButton(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('has black background styling', () => { + renderTwitterButton(); + expect(screen.getByRole('button', { name: /twitter/i })).toHaveClass('bg-black'); + }); + + it('has white text', () => { + const { container } = renderTwitterButton(); + const label = container.querySelector('span'); + expect(label).toHaveClass('text-white'); + }); +}); + +// --------------------------------------------------------------------------- +// Web OAuth flow +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Twitter) — web OAuth flow', () => { + const originalLocation = window.location; + + beforeEach(() => { + mockGetBackendUrl.mockResolvedValue('http://localhost:5005'); + mockIsTauri.mockReturnValue(false); + delete (window as unknown as Record).location; + (window as unknown as Record).location = { href: '' }; + }); + + afterEach(() => { + (window as unknown as Record).location = originalLocation; + }); + + it('redirects to /auth/twitter/login?responseType=json on click', async () => { + renderTwitterButton(); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + + await waitFor(() => { + expect((window.location as unknown as { href: string }).href).toBe( + 'http://localhost:5005/auth/twitter/login?responseType=json' + ); + }); + }); + + it('does not call openUrl in web mode', async () => { + renderTwitterButton(); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + + await waitFor(() => + expect((window.location as unknown as { href: string }).href).not.toBe('') + ); + expect(mockOpenUrl).not.toHaveBeenCalled(); + }); + + it('calls getBackendUrl exactly once per click', async () => { + renderTwitterButton(); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + + await waitFor(() => + expect((window.location as unknown as { href: string }).href).not.toBe('') + ); + expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// Tauri OAuth flow +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Twitter) — Tauri OAuth flow', () => { + beforeEach(() => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + }); + + it('calls openUrl with /auth/twitter/login?responseType=json', async () => { + renderTwitterButton(); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + + await waitFor(() => { + expect(mockOpenUrl).toHaveBeenCalledWith( + 'https://api.example.com/auth/twitter/login?responseType=json' + ); + }); + }); + + it('does not set window.location.href in Tauri mode', async () => { + const originalHref = window.location.href; + renderTwitterButton(); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalledTimes(1)); + expect(window.location.href).toBe(originalHref); + }); + + it('remains in loading state after openUrl resolves (awaits deep-link callback)', async () => { + renderTwitterButton(); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalledTimes(1)); + expect(screen.getByText('Connecting...')).toBeInTheDocument(); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Loading state +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Twitter) — loading state', () => { + it('shows spinner and "Connecting..." while getBackendUrl is pending', async () => { + let resolve!: (_v: string) => void; + mockGetBackendUrl.mockReturnValue(new Promise(res => { resolve = res; })); + mockIsTauri.mockReturnValue(false); + + renderTwitterButton(); + const button = screen.getByRole('button', { name: /twitter/i }); + await clickButton(button); + + await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument()); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + expect(button).toBeDisabled(); + + await act(async () => { resolve('http://localhost:5005'); }); + }); + + it('ignores a second click while already loading', async () => { + let resolve!: (_v: string) => void; + mockGetBackendUrl.mockReturnValue(new Promise(res => { resolve = res; })); + mockIsTauri.mockReturnValue(false); + + renderTwitterButton(); + const button = screen.getByRole('button', { name: /twitter/i }); + + await clickButton(button); + await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument()); + + fireEvent.click(button); + expect(mockGetBackendUrl).toHaveBeenCalledTimes(1); + + await act(async () => { resolve('http://localhost:5005'); }); + }); +}); + +// --------------------------------------------------------------------------- +// Error handling +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Twitter) — error handling', () => { + beforeEach(() => { + mockIsTauri.mockReturnValue(false); + }); + + it('returns to enabled state after getBackendUrl throws', async () => { + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderTwitterButton(); + const button = screen.getByRole('button', { name: /twitter/i }); + await clickButton(button); + + await waitFor(() => expect(button).toBeEnabled()); + expect(screen.getByText('Twitter')).toBeInTheDocument(); + }); + + it('does not redirect on getBackendUrl error (web mode)', async () => { + const originalLocation = window.location; + delete (window as unknown as Record).location; + (window as unknown as Record).location = { href: '' }; + + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderTwitterButton(); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + + await waitFor(() => + expect(screen.getByRole('button', { name: /twitter/i })).toBeEnabled() + ); + expect((window.location as unknown as { href: string }).href).toBe(''); + + (window as unknown as Record).location = originalLocation; + }); + + it('does not call openUrl on getBackendUrl error (Tauri mode)', async () => { + mockIsTauri.mockReturnValue(true); + mockGetBackendUrl.mockRejectedValue(new Error('network error')); + + renderTwitterButton(); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + + await waitFor(() => + expect(screen.getByRole('button', { name: /twitter/i })).toBeEnabled() + ); + expect(mockOpenUrl).not.toHaveBeenCalled(); + }); + + it('is a no-op when disabled and clicked', async () => { + renderTwitterButton({ disabled: true }); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + expect(mockGetBackendUrl).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// URL construction +// --------------------------------------------------------------------------- + +describe('OAuthProviderButton (Twitter) — URL construction', () => { + it('uses /auth/twitter/login path (not another provider)', async () => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + + renderTwitterButton(); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); + expect(mockOpenUrl.mock.calls[0][0]).toContain('/auth/twitter/login'); + }); + + it('appends ?responseType=json in dev mode (Tauri)', async () => { + mockGetBackendUrl.mockResolvedValue('https://api.example.com'); + mockIsTauri.mockReturnValue(true); + mockOpenUrl.mockResolvedValue(undefined); + + renderTwitterButton(); + await clickButton(screen.getByRole('button', { name: /twitter/i })); + + await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); + expect(mockOpenUrl.mock.calls[0][0]).toBe( + 'https://api.example.com/auth/twitter/login?responseType=json' + ); + }); +});