diff --git a/apps/next/src/components/integrations/github-integration-panel.tsx b/apps/next/src/components/integrations/github-integration-panel.tsx index 90bb999..a7ce792 100644 --- a/apps/next/src/components/integrations/github-integration-panel.tsx +++ b/apps/next/src/components/integrations/github-integration-panel.tsx @@ -48,6 +48,36 @@ export const GithubIntegrationPanel = () => { + {connection && connection.status !== 'active' ? ( +
+

+ {connection.status === 'revoked' + ? 'GitHub App installation removed' + : 'GitHub access needs re-authorization'} +

+

+ {connection.status === 'revoked' + ? 'Reinstall the app to resume syncing.' + : 'Reconnect the app to resume syncing.'} +

+ {installUrl ? ( + + Reconnect GitHub App + + ) : null} +
+ ) : null} {connection ? (
diff --git a/apps/next/src/components/spoons/spoon-status-badge.tsx b/apps/next/src/components/spoons/spoon-status-badge.tsx index 8de8e0f..69102a8 100644 --- a/apps/next/src/components/spoons/spoon-status-badge.tsx +++ b/apps/next/src/components/spoons/spoon-status-badge.tsx @@ -13,6 +13,7 @@ const labels: Record = { checking: 'Checking', conflict: 'Conflict', error: 'Error', + rate_limited: 'Rate limited', unknown: 'Unknown', active: 'Active', draft: 'Draft', @@ -35,10 +36,16 @@ const variants: Record< active: 'default', }; +// Warning-tone statuses that need an amber accent the base variants don't +// provide. Matches the app's existing amber warning styling. +const classNames: Record = { + rate_limited: 'border-amber-500/40 bg-amber-500/10 text-amber-700', +}; + export const SpoonStatusBadge = ({ status }: { status?: Status }) => { const value = status ?? 'unknown'; return ( - + {labels[value] ?? value.replaceAll('_', ' ')} ); diff --git a/apps/next/tests/component/github-integration-panel.test.tsx b/apps/next/tests/component/github-integration-panel.test.tsx new file mode 100644 index 0000000..6eeadf7 --- /dev/null +++ b/apps/next/tests/component/github-integration-panel.test.tsx @@ -0,0 +1,91 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { getFunctionName } from 'convex/server'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { api } from '@spoon/backend/convex/_generated/api.js'; + +import { GithubIntegrationPanel } from '../../src/components/integrations/github-integration-panel'; + +const { mockUseAction, mockUseQuery } = vi.hoisted(() => ({ + mockUseAction: vi.fn(), + mockUseQuery: vi.fn(), +})); + +vi.mock('convex/react', () => ({ + useAction: mockUseAction, + useQuery: mockUseQuery, +})); + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})); + +type ConnectionStatus = 'active' | 'needs_reauth' | 'revoked'; + +const setup = (status: ConnectionStatus) => { + mockUseAction.mockReturnValue(vi.fn()); + const connectionName = getFunctionName(api.github.getConnection); + const installUrlName = getFunctionName(api.github.getInstallUrl); + mockUseQuery.mockImplementation((ref: Parameters[0]) => { + const name = getFunctionName(ref); + if (name === connectionName) { + return { + _id: 'connection-1', + displayName: 'octocat', + installationId: '12345', + status, + }; + } + if (name === installUrlName) { + return 'https://github.com/apps/spoon/installations/new'; + } + return undefined; + }); +}; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('GithubIntegrationPanel', () => { + it('shows the reconnect warning when the connection needs re-authorization', () => { + setup('needs_reauth'); + render(); + + expect( + screen.getByText(/GitHub access needs re-authorization/i), + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /reconnect github app/i }), + ).toBeInTheDocument(); + }); + + it('shows the removed-installation warning when the connection is revoked', () => { + setup('revoked'); + render(); + + expect( + screen.getByText(/GitHub App installation removed/i), + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /reconnect github app/i }), + ).toBeInTheDocument(); + }); + + it('renders the normal connected state without a warning when active', () => { + setup('active'); + render(); + + expect(screen.getByText('octocat')).toBeInTheDocument(); + expect( + screen.queryByText(/needs re-authorization/i), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: /reconnect github app/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/next/tests/component/spoon-status-badge.test.tsx b/apps/next/tests/component/spoon-status-badge.test.tsx new file mode 100644 index 0000000..52a8b63 --- /dev/null +++ b/apps/next/tests/component/spoon-status-badge.test.tsx @@ -0,0 +1,18 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { SpoonStatusBadge } from '../../src/components/spoons/spoon-status-badge'; + +afterEach(cleanup); + +describe('SpoonStatusBadge', () => { + it('renders a dedicated "Rate limited" label for the rate_limited status', () => { + render(); + expect(screen.getByText('Rate limited')).toBeInTheDocument(); + }); + + it('still humanizes unknown statuses via the fallback', () => { + render(); + expect(screen.getByText('some future state')).toBeInTheDocument(); + }); +});