diff --git a/.github/workflows/website-tests.yaml b/.github/workflows/website-tests.yaml new file mode 100644 index 00000000..f20e4bb3 --- /dev/null +++ b/.github/workflows/website-tests.yaml @@ -0,0 +1,44 @@ +name: Website Tests + +on: + push: + branches: [master] + paths: + - 'website/**' + - '.github/workflows/website-tests.yaml' + pull_request: + branches: [master] + paths: + - 'website/**' + - '.github/workflows/website-tests.yaml' + +jobs: + test: + name: Test & Coverage + runs-on: ubuntu-latest + defaults: + run: + working-directory: website + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: website/package-lock.json + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Run tests with coverage + run: npm run test:coverage + + - name: Upload coverage to Coveralls + uses: coverallsapp/github-action@v2 + with: + file: website/coverage/lcov.info + flag-name: frontend diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..7963e703 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,201 @@ +# Upgrade Plan: Next.js 16 & shadcn/ui Updates + +## Overview + +This plan outlines the steps to upgrade the dbdev website from Next.js 15.4.10 to Next.js 16.x and update shadcn/ui components to follow the latest patterns. + +--- + +## Current State + +| Component | Current | Target | +|-----------|---------|--------| +| Next.js | 15.4.10 | 16.1.x | +| React | 19.1.0 | 19.x (compatible) | +| shadcn/ui | Installed components | Latest patterns | +| Turbopack | Not default | Default (Next.js 16) | + +--- + +## Phase 1: Next.js 16 Upgrade + +### 1.1 Update Core Dependencies + +```bash +cd website +npm install next@latest react@latest react-dom@latest +``` + +### 1.2 Address Breaking Changes + +Based on Next.js 16 upgrade guide: + +- [ ] **Turbopack is now default** - Remove any `--turbo` flags, now automatic +- [ ] **Middleware renamed to proxy** - Check if any middleware needs updates +- [ ] **React Compiler** - Consider enabling for automatic memoization +- [ ] **Cache Components** - Evaluate `"use cache"` directive for applicable pages + +### 1.3 Configuration Updates + +Update `next.config.js` if needed: +- Review experimental flags that may now be stable +- Enable React Compiler if desired + +### 1.4 Test the Upgrade + +```bash +npm run dev +npm run build +npm run lint +``` + +--- + +## Phase 2: shadcn/ui Configuration Updates + +### 2.1 Update components.json + +Current configuration has `"rsc": false`. For App Router, consider enabling RSC: + +```json +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "styles/globals.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "~/components", + "utils": "~/lib/utils" + } +} +``` + +### 2.2 Update Radix UI Dependencies + +Current versions to update: + +| Package | Current | Action | +|---------|---------|--------| +| @radix-ui/react-avatar | ^1.1.10 | Check for updates | +| @radix-ui/react-dialog | ^1.1.14 | Check for updates | +| @radix-ui/react-dropdown-menu | ^2.1.15 | Check for updates | +| @radix-ui/react-label | ^2.1.7 | Check for updates | +| @radix-ui/react-separator | ^1.1.7 | Check for updates | +| @radix-ui/react-slot | ^1.2.3 | Check for updates | +| @radix-ui/react-tabs | ^1.1.12 | Check for updates | +| @radix-ui/react-toast | ^1.2.14 | Check for updates | + +```bash +npm update @radix-ui/react-avatar @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-label @radix-ui/react-separator @radix-ui/react-slot @radix-ui/react-tabs @radix-ui/react-toast +``` + +### 2.3 Update Utility Dependencies + +```bash +npm update class-variance-authority clsx tailwind-merge lucide-react +``` + +--- + +## Phase 3: Component Pattern Updates + +### 3.1 Review Existing Components + +Components in `website/components/ui/`: +- [ ] avatar.tsx +- [ ] badge.tsx +- [ ] button.tsx +- [ ] card.tsx +- [ ] dropdown-menu.tsx +- [ ] input.tsx +- [ ] label.tsx +- [ ] separator.tsx +- [ ] tabs.tsx +- [ ] toast.tsx + +### 3.2 Consider Adding New Components + +shadcn/ui has added new components. Evaluate if any would benefit the project: +- Sonner (modern toast alternative) +- Drawer +- Resizable +- Carousel +- Chart + +--- + +## Phase 4: Testing & Validation + +### 4.1 Development Testing + +```bash +npm run dev +``` + +- [ ] Verify all pages load correctly +- [ ] Test component functionality +- [ ] Check dark mode theming +- [ ] Verify form submissions + +### 4.2 Build Testing + +```bash +npm run build +npm run start +``` + +- [ ] Ensure production build succeeds +- [ ] No TypeScript errors +- [ ] No ESLint errors + +### 4.3 Visual Regression + +- [ ] Compare UI before/after upgrade +- [ ] Verify styling consistency + +--- + +## Rollback Plan + +If issues arise: + +1. Revert `package.json` changes +2. Delete `node_modules` and `package-lock.json` +3. Run `npm install` +4. Verify application works on previous versions + +--- + +## Files to Modify + +| File | Changes | +|------|---------| +| `website/package.json` | Update Next.js, React, Radix UI versions | +| `website/components.json` | Enable RSC support | +| `website/next.config.js` | Review for deprecated options | +| `website/components/ui/*` | Update component patterns if needed | + +--- + +## Timeline Considerations + +- Phase 1 (Next.js upgrade): Core dependency update +- Phase 2 (shadcn config): Configuration alignment +- Phase 3 (Components): Pattern updates as needed +- Phase 4 (Testing): Validation before merge + +--- + +## References + +- [Next.js 16 Release Notes](https://nextjs.org/blog/next-16) +- [Next.js 16.1 Release Notes](https://nextjs.org/blog/next-16-1) +- [Next.js 16 Upgrade Guide](https://nextjs.org/docs/app/guides/upgrading/version-16) +- [shadcn/ui Documentation](https://ui.shadcn.com) +- [shadcn/ui Changelog](https://github.com/shadcn-ui/ui/releases) diff --git a/website/__tests__/access-tokens.integration.test.tsx b/website/__tests__/access-tokens.integration.test.tsx new file mode 100644 index 00000000..c254063d --- /dev/null +++ b/website/__tests__/access-tokens.integration.test.tsx @@ -0,0 +1,138 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +// Mock react-hot-toast - must be before component import +vi.mock('react-hot-toast', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})) + +// Mock the delete mutation +vi.mock('~/data/access-tokens/delete-access-token', () => ({ + useDeleteAccessTokenMutation: ({ + onSuccess, + }: { + onSuccess?: () => void + }) => ({ + mutate: vi.fn((vars: { tokenId: string }) => { + onSuccess?.() + }), + isLoading: false, + }), +})) + +import { toast } from 'react-hot-toast' +import AccessTokenCard from '~/components/access-tokens/AccessTokenCard' + +// Setup dayjs with relativeTime +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +dayjs.extend(relativeTime) + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) +} + +function renderWithQueryClient(ui: React.ReactElement) { + const queryClient = createQueryClient() + return render( + {ui} + ) +} + +describe('Access Tokens Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders access token card with all information', () => { + const createdAt = new Date( + Date.now() - 7 * 24 * 60 * 60 * 1000 + ).toISOString() // 7 days ago + + renderWithQueryClient( + + ) + + expect(screen.getByText('My API Token')).toBeInTheDocument() + expect(screen.getByText('Token: sk_****_abcd')).toBeInTheDocument() + expect(screen.getByText(/Created 7 days ago/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /revoke/i })).toBeInTheDocument() + }) + + it('calls delete mutation and shows toast on revoke', async () => { + const user = userEvent.setup() + + renderWithQueryClient( + + ) + + const revokeButton = screen.getByRole('button', { name: /revoke/i }) + await user.click(revokeButton) + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith('Successfully revoked token!') + }) + }) + + it('renders multiple tokens in a list', () => { + const tokens = [ + { + id: '1', + name: 'Production API', + masked: 'sk_****_prod', + created: '2024-01-01', + }, + { + id: '2', + name: 'Development API', + masked: 'sk_****_dev', + created: '2024-06-01', + }, + { + id: '3', + name: 'CI/CD Token', + masked: 'sk_****_cicd', + created: '2024-12-01', + }, + ] + + renderWithQueryClient( +
+ {tokens.map((token) => ( + + ))} +
+ ) + + expect(screen.getByText('Production API')).toBeInTheDocument() + expect(screen.getByText('Development API')).toBeInTheDocument() + expect(screen.getByText('CI/CD Token')).toBeInTheDocument() + expect(screen.getAllByRole('button', { name: /revoke/i })).toHaveLength(3) + }) +}) diff --git a/website/__tests__/auth.integration.test.tsx b/website/__tests__/auth.integration.test.tsx new file mode 100644 index 00000000..7b4866b4 --- /dev/null +++ b/website/__tests__/auth.integration.test.tsx @@ -0,0 +1,200 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock supabase before importing auth +const mockGetSession = vi.fn() +const mockOnAuthStateChange = vi.fn() +const mockRefreshSession = vi.fn() + +vi.mock('~/lib/supabase', () => ({ + default: { + auth: { + getSession: () => mockGetSession(), + onAuthStateChange: (callback: any) => { + mockOnAuthStateChange(callback) + return { + data: { + subscription: { + unsubscribe: vi.fn(), + }, + }, + } + }, + refreshSession: () => mockRefreshSession(), + }, + }, +})) + +// Mock next/router +const mockPush = vi.fn() +vi.mock('next/router', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})) + +// Mock types +vi.mock('~/lib/types', () => ({ + isNextPageWithLayout: () => false, +})) + +import { + AuthProvider, + useAuth, + useSession, + useUser, + useIsLoggedIn, + withAuth, +} from '~/lib/auth' + +// Test component that uses auth hooks +function AuthStatus() { + const { session, isLoading } = useAuth() + const user = useUser() + const isLoggedIn = useIsLoggedIn() + + if (isLoading) return
Loading...
+ + return ( +
+
{isLoggedIn ? 'Yes' : 'No'}
+
{user?.email ?? 'No user'}
+
{session ? 'Has session' : 'No session'}
+
+ ) +} + +// Component to test withAuth HOC +function ProtectedPage() { + return
Protected Content
+} + +const ProtectedPageWithAuth = withAuth(ProtectedPage) + +describe('Auth Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ data: { session: null } }) + mockRefreshSession.mockResolvedValue({ data: { session: null } }) + }) + + it('shows loading state initially then resolves', async () => { + mockGetSession.mockResolvedValue({ data: { session: null } }) + + render( + + + + ) + + // Initially loading + expect(screen.getByText('Loading...')).toBeInTheDocument() + + // After session check resolves + await waitFor(() => { + expect(screen.getByTestId('logged-in')).toHaveTextContent('No') + expect(screen.getByTestId('user-email')).toHaveTextContent('No user') + expect(screen.getByTestId('session')).toHaveTextContent('No session') + }) + }) + + it('provides user session when authenticated', async () => { + const mockSession = { + user: { + id: 'user-123', + email: 'test@example.com', + }, + access_token: 'token-123', + } + + mockGetSession.mockResolvedValue({ data: { session: mockSession } }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByTestId('logged-in')).toHaveTextContent('Yes') + expect(screen.getByTestId('user-email')).toHaveTextContent( + 'test@example.com' + ) + expect(screen.getByTestId('session')).toHaveTextContent('Has session') + }) + }) + + it('withAuth HOC redirects to sign-in when not authenticated', async () => { + mockGetSession.mockResolvedValue({ data: { session: null } }) + + render( + + + + ) + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/sign-in') + }) + }) + + it('withAuth HOC renders component when authenticated', async () => { + const mockSession = { + user: { id: 'user-123', email: 'test@example.com' }, + access_token: 'token-123', + } + + mockGetSession.mockResolvedValue({ data: { session: mockSession } }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByText('Protected Content')).toBeInTheDocument() + expect(mockPush).not.toHaveBeenCalled() + }) + }) + + it('handles auth state changes', async () => { + let authCallback: any + + mockOnAuthStateChange.mockImplementation((callback) => { + authCallback = callback + return { + data: { + subscription: { unsubscribe: vi.fn() }, + }, + } + }) + + mockGetSession.mockResolvedValue({ data: { session: null } }) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByTestId('logged-in')).toHaveTextContent('No') + }) + + // Simulate sign in + const mockSession = { + user: { id: 'user-456', email: 'new@example.com' }, + access_token: 'new-token', + } + + authCallback('SIGNED_IN', mockSession) + + await waitFor(() => { + expect(screen.getByTestId('logged-in')).toHaveTextContent('Yes') + expect(screen.getByTestId('user-email')).toHaveTextContent( + 'new@example.com' + ) + }) + }) +}) diff --git a/website/__tests__/form.integration.test.tsx b/website/__tests__/form.integration.test.tsx new file mode 100644 index 00000000..03afaadc --- /dev/null +++ b/website/__tests__/form.integration.test.tsx @@ -0,0 +1,66 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi } from 'vitest' +import { z } from 'zod' +import Form from '~/components/forms/Form' +import FormInput from '~/components/forms/FormInput' +import FormButton from '~/components/forms/FormButton' + +const LoginSchema = z.object({ + email: z.string().email('Invalid email address'), + password: z.string().min(6, 'Password must be at least 6 characters'), +}) + +function LoginForm({ + onSubmit, +}: { + onSubmit: (values: z.infer) => void +}) { + return ( +
+ + + Sign In + + ) +} + +describe('Form Integration', () => { + it('submits valid form data', async () => { + const user = userEvent.setup() + const handleSubmit = vi.fn() + + render() + + await user.type(screen.getByLabelText(/email/i), 'test@example.com') + await user.type(screen.getByLabelText(/password/i), 'password123') + await user.click(screen.getByRole('button', { name: /sign in/i })) + + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalledWith( + { email: 'test@example.com', password: 'password123' }, + expect.anything(), + expect.anything() + ) + }) + }) + + it('shows validation errors for invalid input', async () => { + const user = userEvent.setup() + const handleSubmit = vi.fn() + + render() + + // Submit with invalid data + await user.type(screen.getByLabelText(/email/i), 'invalid-email') + await user.type(screen.getByLabelText(/password/i), '123') + await user.click(screen.getByRole('button', { name: /sign in/i })) + + await waitFor(() => { + expect(screen.getByText(/invalid email/i)).toBeInTheDocument() + expect(screen.getByText(/at least 6 characters/i)).toBeInTheDocument() + }) + + expect(handleSubmit).not.toHaveBeenCalled() + }) +}) diff --git a/website/__tests__/layout.integration.test.tsx b/website/__tests__/layout.integration.test.tsx new file mode 100644 index 00000000..69af93b0 --- /dev/null +++ b/website/__tests__/layout.integration.test.tsx @@ -0,0 +1,127 @@ +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import Footer, { rightLinks } from '~/components/layouts/Footer' +import Layout from '~/components/layouts/Layout' +import { ThemeContextProvider } from '~/components/themes/ThemeContext' + +// Mock supabase dependencies +vi.mock('~/lib/supabase', () => ({ + supabase: {}, +})) + +vi.mock('~/lib/avatars', () => ({ + getAvatarUrl: () => '/test-avatar.png', +})) + +// Mock next/link +vi.mock('next/link', () => ({ + default: ({ + children, + href, + }: { + children: React.ReactNode + href: string + }) => {children}, +})) + +// Mock next/router +vi.mock('next/router', () => ({ + useRouter: () => ({ + push: vi.fn(), + events: { + on: vi.fn(), + off: vi.fn(), + }, + }), +})) + +// Mock auth hook +vi.mock('~/lib/auth', () => ({ + useUser: () => null, +})) + +// Mock data hooks +vi.mock('~/data/auth/sign-out-mutation', () => ({ + useSignOutMutation: () => ({ mutate: vi.fn() }), +})) + +vi.mock('~/data/organizations/users-organizations-query', () => ({ + useUsersOrganizationsQuery: () => ({ + data: [], + isLoading: false, + isSuccess: true, + }), +})) + +vi.mock('~/data/packages/packages-search-query', () => ({ + usePackagesSearchQuery: () => ({ + data: [], + isLoading: false, + isSuccess: false, + isError: false, + }), +})) + +describe('Layout Integration', () => { + it('renders footer with all navigation links', () => { + render(