Skip to content
Open
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
295 changes: 154 additions & 141 deletions bun.lock

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions e2e/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { test, expect } from '@playwright/test';

test.describe('Authentication Flow', () => {
test('User can log in successfully and be redirected to dashboard', async ({ page }) => {
// Navigate to the login page
await page.goto('/login');

// Verify we are on the login page
await expect(page).toHaveTitle(/Delta|Login/);
await expect(page.locator('h1')).toContainText('Login');

// Fill in the login form.
// We use the test credentials seeded in the database.
await page.fill('input[type="email"]', 'test@example.com');
await page.fill('input[type="password"]', 'testpassword123');

// Click the login button
await page.click('button[type="submit"]');

// Verify redirection to the dashboard
await expect(page).toHaveURL(/\/dashboard/);

// Verify dashboard elements are visible indicating successful login
await expect(page.getByText('test@example.com')).toBeVisible();
await expect(page.getByText('Delta.')).toBeVisible();
});
});
34 changes: 34 additions & 0 deletions e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { test, expect } from '@playwright/test';

test.describe('Dashboard Flow', () => {
test.beforeEach(async ({ page }) => {
// Navigate to login and authenticate before each dashboard test
await page.goto('/login');
await page.fill('input[type="email"]', 'test@example.com');
await page.fill('input[type="password"]', 'testpassword123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/dashboard/);
});

test('User can view the dashboard and interact with repositories', async ({ page }) => {
// Verify key UI components of the dashboard are present
await expect(page.getByText('Welcome back')).toBeVisible();

// Wait for the repositories to load (either empty state or actual repos)
// We wait for either the 'No repositories linked yet' text OR a repository item
const emptyState = page.getByText('No repositories linked yet');
const repoList = page.locator('.space-y-3.mt-4'); // Container for repos

// Check if the page has finished loading by looking for stats tiles
await expect(page.getByText('Total Installations')).toBeVisible();
await expect(page.getByText('Prs Waiting')).toBeVisible();

// The user should eventually see the empty state message or a list of repos
// We don't strictly assert the content since it depends on the seeded DB state,
// but we verify the dashboard fully renders without crashing.
await Promise.any([
expect(emptyState).toBeVisible(),
expect(repoList).toBeVisible(),
]);
});
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
Expand Down
190 changes: 190 additions & 0 deletions src/test/Integration.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import Dashboard from '../pages/Dashboard'

// Keep actual React functions intact, but override standard environment behaviors to prevent
// components from crashing due to missing configurations on test mount
const originalFetch = globalThis.fetch

const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
retry: false, // Turn off retries for faster test failures
staleTime: 0,
},
},
})

// Since we can't use node_modules mocking comfortably, we will directly
// intercept window/global fetch which Tanstack react-query relies on behind the scenes
describe('Dashboard - True Integration Test via Fetch Interception', () => {
let queryClient: QueryClient

beforeEach(() => {
// Reset fetch to a mock and create a fresh query client cache
globalThis.fetch = vi.fn()
queryClient = createTestQueryClient()

// Mock a basic logged in user session by intercepting the /auth/me or similar query
// to prevent immediate layout crashes
vi.mock('@/hooks/useUser', () => ({
useCurrentUser: () => ({ data: { id: '1', full_name: 'Integration User' }, isLoading: false }),
getGravatarUrl: () => 'avatar.png'
}))
vi.mock('@/hooks/useAuth', () => ({
useLogout: () => ({ mutate: vi.fn(), isPending: false })
}))
vi.mock('@/components/shared/NotificationBell', () => ({
NotificationBell: () => <div>NotificationBell</div>
}))
})

afterEach(() => {
// Restore standard node behavior
globalThis.fetch = originalFetch
vi.restoreAllMocks()
queryClient.clear()
})

const renderWithProviders = (ui: React.ReactElement) => {
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
{ui}
</MemoryRouter>
</QueryClientProvider>
)
}

it('Scenario 1: Renders the beautiful "Empty State" UI when the API returns perfectly empty data', async () => {
// We configure the global fetch to return a 200 response with empty arrays/objects
// exactly as the FastAPI backend would if the user had zero installations
const mockFetch = globalThis.fetch as ReturnType<typeof vi.fn>

mockFetch.mockImplementation(async (url: string) => {
if (url.includes('/repos/')) {
return {
ok: true,
json: async () => [] // Zero repositories
}
}
if (url.includes('/api/dashboard/stats')) {
return {
ok: true,
json: async () => ({
installations_count: 0,
repos_linked_count: 0,
drift_events_count: 0,
pr_waiting_count: 0,
})
}
}

if (url.includes('/notifications')) {
return {
ok: true,
json: async () => []
}
}

// Fallback for any other unexpected requests
return { ok: true, json: async () => ({}) }
})

renderWithProviders(<Dashboard />)

// Wait for the components to finish mounting, fetching, and rendering
await waitFor(() => {
expect(screen.getByText(/No repositories linked/i)).toBeInTheDocument()
})

// Ensure that it specifically told them to connect their GitHub account
expect(screen.getByText(/Connect your GitHub repositories to start monitoring documentation drift/i)).toBeInTheDocument()

// Ensure stats tiles display 0 everywhere
const zeroes = screen.getAllByText('0')
expect(zeroes.length).toBeGreaterThanOrEqual(4)

// Verify that the React application natively dispatched API requests to the expected endpoints
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/repos/'), expect.anything())
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/dashboard/stats'), expect.anything())
})

it('Scenario 2: Renders a populated layout when the backend API successfully fetches linked repositories', async () => {
const mockFetch = globalThis.fetch as ReturnType<typeof vi.fn>

mockFetch.mockImplementation(async (url: string) => {
if (url.includes('/repos/')) {
// Return exactly the payload the backend produces for repositories
return {
ok: true,
json: async () => [
{ id: 1, repo_name: 'org/backend-service', language: 'Python', stargazers_count: 99, forks_count: 10, description: 'Test', avatar_url: '', is_active: true }
]
}
}
if (url.includes('/api/dashboard/stats')) {
return {
ok: true,
json: async () => ({
installations_count: 1,
repos_linked_count: 1,
drift_events_count: 3,
pr_waiting_count: 1,
})
}
}
if (url.includes('/notifications')) {
return {
ok: true,
json: async () => []
}
}
return { ok: true, json: async () => ({}) }
})

// We must wrap the render in act/waitFor to allow the QueryClient to digest the Promises
renderWithProviders(<Dashboard />)

// Give the react-query cache time to fulfill the Promises and trigger a re-render
// Testing-library requires targeting something that actually appears in the DOM
await waitFor(() => {
expect(screen.queryByText(/Loading.../i)).not.toBeInTheDocument()
})

await waitFor(() => {
// Verify the component successfully mapped the API data into physical DOM nodes
expect(screen.getByText('org/backend-service')).toBeInTheDocument()
expect(screen.queryByText(/No repositories linked/i)).not.toBeInTheDocument()
})
})

it('Scenario 3: Conditionally disables UI features and shows skeletons when the Backend returns a 500 error', async () => {
const mockFetch = globalThis.fetch as ReturnType<typeof vi.fn>

mockFetch.mockImplementation(async () => {
// Force the API to simulate an unhandled database crash
return {
ok: false,
status: 500,
json: async () => ({ detail: "Internal Server Error" })
}
})

renderWithProviders(<Dashboard />)

// React-Query will eventually give up (since we set retries: 0) causing an error state
// When useDashboardRepos/Stats fails, they return `isError: true` which the
// Dashboard uses to render generic skeleton states or "0"s instead of crashing
await waitFor(() => {
// Stats should fail gracefully to 0 rather than exploding
const zeroes = screen.getAllByText('0')
expect(zeroes.length).toBeGreaterThanOrEqual(4)
})

// No rogue data should be visible
expect(screen.queryByText('org/backend-service')).not.toBeInTheDocument()
})
})
Loading
Loading