diff --git a/src-tauri/src/github.rs b/src-tauri/src/github.rs
index 879490d7..6c3ff814 100644
--- a/src-tauri/src/github.rs
+++ b/src-tauri/src/github.rs
@@ -491,4 +491,74 @@ mod tests {
assert!(repo.updated_at.is_none());
assert!(!repo.private);
}
+
+ #[test]
+ fn test_device_flow_start_serialization() {
+ let start = DeviceFlowStart {
+ device_code: "dc_123".to_string(),
+ user_code: "ABCD-1234".to_string(),
+ verification_uri: "https://github.com/login/device".to_string(),
+ expires_in: 900,
+ interval: 5,
+ };
+ let json = serde_json::to_string(&start).unwrap();
+ let parsed: DeviceFlowStart = serde_json::from_str(&json).unwrap();
+ assert_eq!(parsed.device_code, "dc_123");
+ assert_eq!(parsed.user_code, "ABCD-1234");
+ assert_eq!(parsed.verification_uri, "https://github.com/login/device");
+ assert_eq!(parsed.expires_in, 900);
+ assert_eq!(parsed.interval, 5);
+ }
+
+ #[test]
+ fn test_device_flow_poll_result_complete() {
+ let result = DeviceFlowPollResult {
+ status: "complete".to_string(),
+ access_token: Some("gho_abc123".to_string()),
+ error: None,
+ };
+ let json = serde_json::to_string(&result).unwrap();
+ let parsed: DeviceFlowPollResult = serde_json::from_str(&json).unwrap();
+ assert_eq!(parsed.status, "complete");
+ assert_eq!(parsed.access_token, Some("gho_abc123".to_string()));
+ assert!(parsed.error.is_none());
+ }
+
+ #[test]
+ fn test_device_flow_poll_result_pending() {
+ let result = DeviceFlowPollResult {
+ status: "pending".to_string(),
+ access_token: None,
+ error: Some("authorization_pending".to_string()),
+ };
+ let json = serde_json::to_string(&result).unwrap();
+ let parsed: DeviceFlowPollResult = serde_json::from_str(&json).unwrap();
+ assert_eq!(parsed.status, "pending");
+ assert!(parsed.access_token.is_none());
+ assert_eq!(
+ parsed.error,
+ Some("authorization_pending".to_string())
+ );
+ }
+
+ #[test]
+ fn test_github_user_serialization() {
+ let user = GitHubUser {
+ login: "lucaong".to_string(),
+ name: Some("Luca Ongaro".to_string()),
+ avatar_url: "https://avatars.githubusercontent.com/u/123".to_string(),
+ };
+ let json = serde_json::to_string(&user).unwrap();
+ let parsed: GitHubUser = serde_json::from_str(&json).unwrap();
+ assert_eq!(parsed.login, "lucaong");
+ assert_eq!(parsed.name, Some("Luca Ongaro".to_string()));
+ }
+
+ #[test]
+ fn test_github_user_deserialization_null_name() {
+ let json = r#"{"login":"bot","name":null,"avatar_url":"https://x"}"#;
+ let user: GitHubUser = serde_json::from_str(json).unwrap();
+ assert_eq!(user.login, "bot");
+ assert!(user.name.is_none());
+ }
}
diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx
index 16e1a58e..39cd7789 100644
--- a/src/components/SettingsPanel.test.tsx
+++ b/src/components/SettingsPanel.test.tsx
@@ -1,8 +1,18 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { render, screen, fireEvent } from '@testing-library/react'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { SettingsPanel } from './SettingsPanel'
import type { Settings } from '../types'
+// Mock the tauri/mock-tauri calls used by GitHubSection
+const mockInvokeFn = vi.fn()
+vi.mock('@tauri-apps/api/core', () => ({
+ invoke: (...args: unknown[]) => mockInvokeFn(...args),
+}))
+vi.mock('../mock-tauri', () => ({
+ isTauri: () => false,
+ mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
+}))
+
const emptySettings: Settings = {
anthropic_key: null,
openai_key: null,
@@ -189,4 +199,97 @@ describe('SettingsPanel', () => {
const updatedInput = screen.getByTestId('settings-key-anthropic') as HTMLInputElement
expect(updatedInput.value).toBe('new-key')
})
+
+ describe('GitHub OAuth section', () => {
+ it('shows Login with GitHub button when not connected', () => {
+ render(
+
+ )
+ expect(screen.getByTestId('github-login')).toBeInTheDocument()
+ expect(screen.getByText('Login with GitHub')).toBeInTheDocument()
+ })
+
+ it('does not show GitHub token input field', () => {
+ render(
+
+ )
+ expect(screen.queryByTestId('settings-key-github-token')).not.toBeInTheDocument()
+ expect(screen.queryByPlaceholderText('ghp_... or gho_...')).not.toBeInTheDocument()
+ })
+
+ it('shows connected state with username when GitHub is connected', () => {
+ const connectedSettings: Settings = {
+ ...emptySettings,
+ github_token: 'gho_test_token',
+ github_username: 'lucaong',
+ }
+ render(
+
+ )
+ expect(screen.getByTestId('github-connected')).toBeInTheDocument()
+ expect(screen.getByText('lucaong')).toBeInTheDocument()
+ expect(screen.getByText('Connected')).toBeInTheDocument()
+ expect(screen.getByTestId('github-disconnect')).toBeInTheDocument()
+ })
+
+ it('clears GitHub connection on disconnect', () => {
+ const connectedSettings: Settings = {
+ ...emptySettings,
+ github_token: 'gho_test_token',
+ github_username: 'lucaong',
+ }
+ render(
+
+ )
+ fireEvent.click(screen.getByTestId('github-disconnect'))
+
+ // onSave should be called with cleared GitHub fields
+ expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
+ github_token: null,
+ github_username: null,
+ }))
+ })
+
+ it('shows waiting state with user code during OAuth flow', async () => {
+ mockInvokeFn.mockImplementation(async (cmd: string) => {
+ if (cmd === 'github_device_flow_start') {
+ return {
+ device_code: 'test_device_code',
+ user_code: 'TEST-1234',
+ verification_uri: 'https://github.com/login/device',
+ expires_in: 900,
+ interval: 5,
+ }
+ }
+ if (cmd === 'github_device_flow_poll') {
+ return { status: 'pending', access_token: null, error: 'authorization_pending' }
+ }
+ return null
+ })
+
+ // Mock window.open
+ const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
+
+ render(
+
+ )
+
+ fireEvent.click(screen.getByTestId('github-login'))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('github-waiting')).toBeInTheDocument()
+ expect(screen.getByTestId('github-user-code')).toHaveTextContent('TEST-1234')
+ })
+
+ expect(windowOpen).toHaveBeenCalledWith('https://github.com/login/device', '_blank')
+ windowOpen.mockRestore()
+ })
+
+ it('shows GitHub section description about connecting', () => {
+ render(
+
+ )
+ expect(screen.getByText(/Connect your GitHub account/)).toBeInTheDocument()
+ })
+ })
})