diff --git a/INTEGRATION_TESTS.md b/INTEGRATION_TESTS.md new file mode 100644 index 0000000..e33bba6 --- /dev/null +++ b/INTEGRATION_TESTS.md @@ -0,0 +1,130 @@ +# Integration Test Outlines for Public View System + +This document outlines key integration tests for the public view system, including the `PublicHome` page and routing behaviors. These tests would typically be implemented using a framework like Cypress or Playwright, combined with React Testing Library for component interactions where appropriate. + +## I. PublicHome Page (`client/src/pages/PublicHome.tsx`) + +**Setup:** +* Ensure the backend server is running and accessible. +* Mock or seed the database with a known set of public and private snippets. Include snippets with various languages and tags. +* Use a testing utility or direct API calls to `/api/public/snippets` to verify data setup. + +**Test Scenarios:** + +1. **Initial Load and Display:** + * **Description:** Verify that `PublicHome` fetches and displays public snippets correctly on initial load. + * **Steps:** + 1. Navigate to the `/` route as an unauthenticated user. + 2. Observe that the `PublicHome` component renders. + 3. Check that a list/grid of snippets is displayed. + 4. Verify that only snippets marked `isPublic: true` in the database are shown. + 5. Confirm that elements like the header, tagline, and "Sign In" button are present. + 6. Check for loading states while data is being fetched. + * **Assertions:** + * Correct number of public snippets displayed. + * Snippet cards show appropriate information (title, description snippet, language, "Public" badge). + * No private snippets are visible. + * Header and sign-in elements are visible. + +2. **Search Functionality:** + * **Description:** Test if searching filters the displayed public snippets. + * **Steps:** + 1. On `PublicHome`, type a search term (e.g., a keyword from a known public snippet's title or description) into the search bar. + 2. Observe the list of snippets updating. + * **Assertions:** + * Only snippets matching the search term are displayed. + * If the search term matches no public snippets, an appropriate "No public snippets found" message is shown. + * The filtering should be reasonably fast (client-side or server-side depending on implementation). + +3. **Filter Functionality (Language/Tags):** + * **Description:** Test if filtering by language (and tags, if implemented) works correctly. + * **Steps:** + 1. On `PublicHome`, select a language from the language filter dropdown. + 2. Observe the list of snippets updating. + 3. (If applicable) Select a tag from a tag filter dropdown and observe further filtering. + * **Assertions:** + * Only snippets matching the selected language (and/or tag) are displayed. + * Combining search and filters works as expected. + * If no snippets match the filter criteria, an appropriate message is shown. + +4. **Empty State:** + * **Description:** Verify the behavior when no public snippets are available or match filters. + * **Steps:** + 1. Ensure the database has no snippets marked `isPublic: true`. + 2. Navigate to `PublicHome`. + 3. OR: Apply filters that result in no matches. + * **Assertions:** + * The correct "No public snippets found" (or similar) message is displayed. + * The layout remains intact. + +5. **Sign-In Button Navigation:** + * **Description:** Ensure the "Sign In" button navigates to the login flow. + * **Steps:** + 1. On `PublicHome`, click the "Sign In / Sign Up" button. + * **Assertions:** + * The user is redirected to the application's login page/mechanism (e.g., `/login` or triggers the Firebase auth flow). + +## II. Routing and Authentication State + +**Setup:** +* As above, backend running with mixed public/private data. +* Ability to simulate user login/logout within the test environment. + +**Test Scenarios:** + +1. **Unauthenticated User Access:** + * **Description:** Verify routes accessible to unauthenticated users. + * **Steps:** + 1. As an unauthenticated user, navigate to `/`. + 2. Navigate to `/shared/:shareId` (using a share ID of a public snippet). + 3. Navigate to `/shared/:shareId` (using a share ID of a private snippet). + 4. Attempt to navigate to an authenticated route (e.g., `/snippets` or `/settings`). + * **Assertions:** + * `/` loads `PublicHome`. + * `/shared/:shareId` for a public snippet loads the `SharedSnippet` page and displays the snippet. + * `/shared/:shareId` for a private snippet either shows a "not found/access denied" message within `SharedSnippet` or redirects (behavior depends on `SharedSnippet` implementation). + * Access to `/snippets` or `/settings` redirects to `PublicHome` (or the login page). + +2. **Authenticated User Access:** + * **Description:** Verify routes and UI changes for authenticated users. + * **Steps:** + 1. Log in as a user. + 2. Navigate to `/`. + 3. Navigate to other authenticated routes like `/snippets`, `/collections`. + 4. Navigate to `/shared/:shareId` (using a share ID of a snippet they own, and one they don't but is public). + * **Assertions:** + * `/` loads the authenticated dashboard (e.g., `Home.tsx` for authenticated users, not `PublicHome`). + * Authenticated routes are accessible and render correctly. + * Layout for authenticated users includes the sidebar and full header. + * Snippet cards on authenticated pages show owner controls for owned snippets. + * `SharedSnippet` page works correctly for owned and public shared snippets. + +3. **Navigation from Public to Authenticated:** + * **Description:** Test the transition when a user signs in from `PublicHome`. + * **Steps:** + 1. Start on `PublicHome` as an unauthenticated user. + 2. Click "Sign In" and complete the login process. + * **Assertions:** + * After successful login, the user is redirected to the authenticated dashboard (e.g., `/`). + * The UI updates to the authenticated layout (sidebar appears, etc.). + +## III. Shared Snippet Page (`client/src/pages/SharedSnippet.tsx`) + +**Note:** These depend heavily on `SharedSnippet.tsx`'s internal logic, which also needs to be context-aware. + +1. **Public Shared Snippet (Unauthenticated User):** + * **Description:** An unauthenticated user views a publicly shared snippet. + * **Assertions:** Snippet content is visible. No owner controls. +2. **Private Shared Snippet (Unauthenticated User):** + * **Description:** An unauthenticated user attempts to view a private shared snippet. + * **Assertions:** Snippet content is NOT visible. A "not found" or "access denied" message is shown. +3. **Private Shared Snippet (Authenticated Owner):** + * **Description:** The owner views their own private shared snippet. + * **Assertions:** Snippet content is visible. Owner controls might be visible (depends on design). +4. **Private Shared Snippet (Authenticated Non-Owner):** + * **Description:** An authenticated user (not the owner) attempts to view a private shared snippet. + * **Assertions:** Snippet content is NOT visible. A "not found" or "access denied" message. +5. **Public Shared Snippet (Authenticated User):** + * **Description:** An authenticated user views a public snippet (owned by someone else). + * **Assertions:** Snippet content is visible. No owner controls. +``` diff --git a/client/src/App.tsx b/client/src/App.tsx index 62ff3ce..8905ade 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -9,7 +9,8 @@ import { SnippetProvider } from "@/contexts/SnippetContext"; import { CollectionProvider } from "@/contexts/CollectionContext"; import { useAuthContext } from "@/contexts/AuthContext"; import NotFound from "@/pages/not-found"; -import Home from "@/pages/Home"; +import Home from "@/pages/Home"; // This is the authenticated dashboard +import PublicHome from '@/pages/PublicHome'; import Snippets from "@/pages/Snippets"; import Collections from "@/pages/Collections"; import CollectionDetail from "@/pages/CollectionDetail"; @@ -17,10 +18,11 @@ import Tags from "@/pages/Tags"; import Settings from "@/pages/Settings"; import SharedSnippet from "@/pages/SharedSnippet"; -function Router() { +// Renamed from Router to AuthenticatedRouter +function AuthenticatedRouter() { return ( - + {/* Authenticated home/dashboard */} @@ -32,6 +34,17 @@ function Router() { ); } +function PublicRouter() { + return ( + + + + {/* For non-matched routes, redirect to PublicHome */} + + + ); +} + // Added debug component to show authentication state function AuthDebug({ user, loading }: { user: any, loading: boolean }) { return ( @@ -48,7 +61,7 @@ function AuthDebug({ user, loading }: { user: any, loading: boolean }) { } export default function App() { - const { user, loading, signIn } = useAuthContext(); + const { user, loading } = useAuthContext(); // Removed signIn from here as it's not used directly for button anymore const [showDebug, setShowDebug] = useState(false); // Add explicit debugging to track auth state @@ -105,38 +118,16 @@ export default function App() { ); } - // 2) Not signed in → show Google button - if (!user) { - console.log("[App] No user detected, showing login button"); - return ( -
-
-

CodePatchwork

-

Please sign in to access your snippets

-
- - {showDebug && } -
- ); - } - - // 3) Signed in → render the full app - console.log("[App] User authenticated, rendering full app:", user); + // 2) Routing logic based on authentication state + // PublicHome will now handle the sign-in prompt. + console.log(`[App] Rendering routers. User: ${user ? user.id : 'null'}, Loading: ${loading}`); return ( - - + {/* SnippetProvider might be needed by SharedSnippet too */} + {/* CollectionProvider might be needed by SharedSnippet too */} - + {user ? : } {showDebug && } diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 07e0ae4..9c28aab 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -4,9 +4,10 @@ import Header from "./Header"; interface LayoutProps { children: ReactNode; + isPublicView?: boolean; } -export default function Layout({ children }: LayoutProps) { +export default function Layout({ children, isPublicView = false }: LayoutProps) { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const toggleMobileMenu = () => { @@ -19,25 +20,29 @@ export default function Layout({ children }: LayoutProps) { return (
- {/* Sidebar - hidden on mobile, visible on larger screens */} - + {/* Sidebar - hidden on mobile, visible on larger screens, hidden in public view */} + {!isPublicView && ( + + )} - {/* Mobile Sidebar */} -
-
-
- + {/* Mobile Sidebar - Entire mechanism hidden in public view */} + {!isPublicView && ( +
+
+
+ +
-
+ )}
- {/* Header */} -
+ {/* Header - Pass isPublicView to handle mobile menu toggle visibility */} +
- {/* Main Content Area - FIXED */} + {/* Main Content Area - Should expand if sidebar is not present */}
{children}
diff --git a/client/src/components/SnippetCard.tsx b/client/src/components/SnippetCard.tsx index 4265c1f..1393f25 100644 --- a/client/src/components/SnippetCard.tsx +++ b/client/src/components/SnippetCard.tsx @@ -9,6 +9,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@/lib/queryClient"; import CodeBlock from "./CodeBlock"; import { useSnippetContext } from "@/contexts/SnippetContext"; +import { useAuthContext } from '@/contexts/AuthContext'; import AddSnippetDialog from "./AddSnippetDialog"; import AddToCollectionDialog from "./AddToCollectionDialog"; import ShareLinkDialog from "./ShareLinkDialog"; @@ -33,11 +34,13 @@ import { interface SnippetCardProps { snippet: Snippet; viewMode: "grid" | "list"; + isPublicView?: boolean; } -export default function SnippetCard({ snippet, viewMode }: SnippetCardProps) { +export default function SnippetCard({ snippet, viewMode, isPublicView = false }: SnippetCardProps) { // Use the context for all operations const { toggleFavorite, deleteSnippet } = useSnippetContext(); + const { user } = useAuthContext(); const { toast } = useToast(); const queryClient = useQueryClient(); const [showConfirmDelete, setShowConfirmDelete] = useState(false); @@ -47,6 +50,8 @@ export default function SnippetCard({ snippet, viewMode }: SnippetCardProps) { const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); const [shareUrl, setShareUrl] = useState(''); const [isTogglePublic, setIsTogglePublic] = useState(false); + + const isOwner = user && snippet.userId && user.id === snippet.userId; // Function to get language color const getLanguageColor = (language?: string) => { @@ -188,69 +193,82 @@ export default function SnippetCard({ snippet, viewMode }: SnippetCardProps) { viewMode === "list" && "flex flex-col md:flex-row" )}>
-
+ {snippet.isPublic && ( + + Public + + )} +
{/* Added pt-4 to avoid overlap with badge */}

{snippet.title}

- - - - - - - - - Copy code - - - - {isSharing ? "Creating share link..." : "Share"} - - - - {isTogglePublic - ? "Updating..." - : snippet.isPublic - ? "Make private" - : "Make public" - } - - setIsCollectionDialogOpen(true)}> - - Add to collection - - - - Edit - - setShowConfirmDelete(true)} - className="text-red-600 focus:text-red-600" - > - - Delete - - - + {!isPublicView && ( + + )} + {!isPublicView && ( + + + + + + + + Copy code + + {isOwner && ( + <> + + + {isSharing ? "Creating share link..." : "Share"} + + + + {isTogglePublic + ? "Updating..." + : snippet.isPublic + ? "Make private" + : "Make public" + } + + setIsCollectionDialogOpen(true)}> + + Add to collection + + + + Edit + + setShowConfirmDelete(true)} + className="text-red-600 focus:text-red-600" + > + + Delete + + + )} + + + )}
diff --git a/client/src/components/SnippetGrid.tsx b/client/src/components/SnippetGrid.tsx index 0d9b108..3eeb760 100644 --- a/client/src/components/SnippetGrid.tsx +++ b/client/src/components/SnippetGrid.tsx @@ -9,9 +9,10 @@ interface SnippetGridProps { isLoading: boolean; error: Error | null; viewMode: "grid" | "list"; + isPublicView?: boolean; } -export default function SnippetGrid({ snippets, isLoading, error, viewMode }: SnippetGridProps) { +export default function SnippetGrid({ snippets, isLoading, error, viewMode, isPublicView = false }: SnippetGridProps) { if (error) { return ( @@ -49,9 +50,13 @@ export default function SnippetGrid({ snippets, isLoading, error, viewMode }: Sn if (snippets.length === 0) { return (
-

No snippets found

+

+ {isPublicView ? "No Public Snippets Found" : "No Snippets Found"} +

- Try adjusting your search or filters, or create a new snippet. + {isPublicView + ? "Try adjusting your search or filters, or check back later!" + : "Try adjusting your search or filters, or create a new snippet."}

); @@ -60,7 +65,7 @@ export default function SnippetGrid({ snippets, isLoading, error, viewMode }: Sn return (
{snippets.map((snippet) => ( - + ))}
); diff --git a/client/src/components/__tests__/SnippetCard.test.tsx b/client/src/components/__tests__/SnippetCard.test.tsx new file mode 100644 index 0000000..ec12d2a --- /dev/null +++ b/client/src/components/__tests__/SnippetCard.test.tsx @@ -0,0 +1,193 @@ +// client/src/components/__tests__/SnippetCard.test.tsx + +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; // Or your preferred testing library +import '@testing-library/jest-dom'; // For extended DOM matchers +import SnippetCard from '../SnippetCard'; // Adjust path +import { AuthContext, type AuthContextType } from '@/contexts/AuthContext'; // Adjust path +import { type Snippet } from '@shared/schema'; // Adjust path +import { TooltipProvider } from '@/components/ui/tooltip'; // Wrapper if needed +import { SnippetProvider, useSnippetContext } from '@/contexts/SnippetContext'; // Wrapper if needed + +// Mock parts of SnippetContext if its functions are called directly by SnippetCard actions +// For now, we focus on rendering logic, not action execution. +jest.mock('@/contexts/SnippetContext', () => ({ + // Keep original exports for types or other non-hook exports if any + ...(jest.requireActual('@/contexts/SnippetContext') as any), + useSnippetContext: () => ({ + toggleFavorite: jest.fn(), + deleteSnippet: jest.fn(), + // Add other functions if SnippetCard calls them directly and they affect rendering/setup + }), + SnippetProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock('@/hooks/use-toast', () => ({ + useToast: () => ({ + toast: jest.fn(), + }), +})); + +// Mock child components that are complex and not directly part of this card's core logic test +jest.mock('@/components/CodeBlock', () => () =>
Mocked Code Block
); +jest.mock('@/components/AddSnippetDialog', () => () =>
Mocked Edit Dialog
); +jest.mock('@/components/AddToCollectionDialog', () => () =>
Mocked Collection Dialog
); +jest.mock('@/components/ShareLinkDialog', () => () =>
Mocked Share Dialog
); + + +const mockSnippet: Snippet = { + id: 1, + title: 'Test Snippet', + code: 'console.log("test");', + language: 'javascript', + description: 'A test snippet.', + tags: ['test', 'example'], + userId: 'user123', // Owner of the snippet + createdAt: new Date(), + updatedAt: new Date(), + viewCount: 10, + isFavorite: false, + shareId: 'share123', + isPublic: false, // Default to private for owner-specific tests +}; + +const mockPublicSnippet: Snippet = { + ...mockSnippet, + id: 2, + isPublic: true, + userId: 'user456', // Different owner for public non-owned view +}; + +const renderWithAuth = ( + ui: React.ReactElement, + providerProps: Partial +) => { + return render( + + {/* Required by DropdownMenu often */} + {/* Assuming SnippetCard might use it, even if actions are mocked */} + {ui} + + + + ); +}; + +describe('SnippetCard Component', () => { + describe('Public View (`isPublicView={true}`)', () => { + it('should display title, code, language, description, tags', () => { + renderWithAuth(, {}); + expect(screen.getByText(mockPublicSnippet.title)).toBeInTheDocument(); + expect(screen.getByTestId('code-block-mock')).toBeInTheDocument(); // Assuming CodeBlock shows code + expect(screen.getByText(mockPublicSnippet.language!)).toBeInTheDocument(); + expect(screen.getByText(mockPublicSnippet.description!)).toBeInTheDocument(); + mockPublicSnippet.tags?.forEach(tag => { + expect(screen.getByText(tag)).toBeInTheDocument(); + }); + }); + + it('should display "Public" badge if snippet is public', () => { + renderWithAuth(, {}); + expect(screen.getByText('Public')).toBeInTheDocument(); // Check for the badge text + }); + + it('should NOT display "Public" badge if snippet is not public (even in public view mode - though typically only public snippets are shown here)', () => { + const privateSnippetInPublicView = { ...mockSnippet, isPublic: false }; + renderWithAuth(, {}); + expect(screen.queryByText('Public')).not.toBeInTheDocument(); + }); + + it('should NOT show owner action buttons (Edit, Delete, Share, Make Public/Private, Add to Collection)', () => { + renderWithAuth(, {}); + expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument(); + // Check within dropdown (which itself should not be visible) + expect(screen.queryByLabelText(/more options/i)).not.toBeInTheDocument(); // MoreVertical icon for dropdown + }); + + it('should NOT show Favorite button', () => { + renderWithAuth(, {}); + expect(screen.queryByLabelText(/add to favorites/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/remove from favorites/i)).not.toBeInTheDocument(); + }); + + it('should always show "Copy Code" button (assuming one is visible directly, not just in dropdown)', () => { + // This test depends on how "Copy Code" is implemented. + // If SnippetCard has a distinct, always-visible copy button for the code block: + // renderWithAuth(, {}); + // const copyButton = screen.getAllByRole('button', { name: /copy code/i }); // Might be multiple if one in dropdown too + // expect(copyButton.length).toBeGreaterThanOrEqual(1); + // For now, we assume the one on the code block itself exists and is not part of this test's conditional logic. + // The one in the dropdown is already covered by "NOT show owner action buttons". + expect(true).toBe(true); // Placeholder if no distinct always-visible copy button + }); + }); + + describe('Authenticated View (`isPublicView={false}` or undefined)', () => { + describe('User is Owner', () => { + const currentUser = { id: 'user123', email: 'owner@example.com', name: 'Test Owner', photoURL: null, createdAt: new Date(), updatedAt: new Date() }; + beforeEach(() => { + // Ensure the snippet being tested is owned by currentUser and is private by default for these tests + mockSnippet.isPublic = false; + mockSnippet.userId = currentUser.id; + }); + + it('should show owner action buttons (Edit, Delete, Share, Make Public/Private, Add to Collection) via Dropdown', () => { + renderWithAuth(, { user: currentUser }); + + const moreOptionsButton = screen.getByLabelText(/more options/i); + expect(moreOptionsButton).toBeInTheDocument(); + // Note: Testing dropdown content might require userEvent.click and then checking. + // For simplicity here, we assume if the dropdown menu trigger is there, items are conditionally rendered correctly by SnippetCard. + // A more thorough test would click the button and then query for items within the opened menu. + }); + + it('should show Favorite button', () => { + renderWithAuth(, { user: currentUser }); + expect(screen.getByLabelText(/add to favorites/i)).toBeInTheDocument(); // Or "Remove from favorites" if isFavorite is true + }); + + it('should display "Public" badge if snippet is public and owned', () => { + const publicOwnedSnippet = { ...mockSnippet, isPublic: true }; + renderWithAuth(, { user: currentUser }); + expect(screen.getByText('Public')).toBeInTheDocument(); + }); + }); + + describe('User is NOT Owner', () => { + const currentUser = { id: 'otherUser', email: 'other@example.com', name: 'Other User', photoURL: null, createdAt: new Date(), updatedAt: new Date() }; + const nonOwnedSnippet = { ...mockSnippet, userId: 'user123', isPublic: false }; // Owned by user123 + + it('should NOT show owner-specific action buttons (Edit, Delete, Share, Make Public/Private, Add to Collection) in Dropdown', () => { + renderWithAuth(, { user: currentUser }); + // The dropdown might still be visible for non-owner actions like "Copy Code" if it's there. + // The prompt asks to hide Edit/Delete/Favorite. Favorite is separate. + // If the *only* actions in dropdown are owner actions, then dropdown itself could be hidden. + // Current SnippetCard.tsx logic: DropdownMenu is hidden if isPublicView. + // If !isPublicView, DropdownMenu is shown, but internal items are conditional on isOwner. + + const moreOptionsButton = screen.queryByLabelText(/more options/i); + // If your implementation shows the dropdown for non-owners (e.g., for a "Copy code" or "Report" action): + // expect(moreOptionsButton).toBeInTheDocument(); + // Then you'd need to click and check *inside* that "Edit", "Delete" etc. are NOT there. + // If the dropdown is hidden for non-owners (when !isPublicView): + // expect(moreOptionsButton).not.toBeInTheDocument(); + + // Assuming the DropdownMenu is always shown in authenticated view, but items are conditional: + expect(moreOptionsButton).toBeInTheDocument(); + // Further tests would click it and assert that owner actions are not present. + }); + + it('should still show Favorite button (user can favorite any snippet they see in authenticated view)', () => { + renderWithAuth(, { user: currentUser }); + expect(screen.getByLabelText(/add to favorites/i)).toBeInTheDocument(); + }); + + it('should display "Public" badge if snippet is public and not owned', () => { + const publicNonOwnedSnippet = { ...mockPublicSnippet, userId: 'user456' }; // owned by user456 + renderWithAuth(, { user: currentUser }); + expect(screen.getByText('Public')).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/client/src/pages/PublicHome.tsx b/client/src/pages/PublicHome.tsx new file mode 100644 index 0000000..7010dc2 --- /dev/null +++ b/client/src/pages/PublicHome.tsx @@ -0,0 +1,199 @@ +// client/src/pages/PublicHome.tsx +import React, { useState, useEffect, useMemo } from 'react'; +import { Link } from 'wouter'; // Or your routing library +import SnippetGrid from '@/components/SnippetGrid'; // Adjust path as needed +import { Input } from '@/components/ui/input'; // Adjust path for your UI components +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; // Adjust path +import { Button } from '@/components/ui/button'; // Adjust path +import { type Snippet } from '@shared/schema'; // Adjust path +import Layout from '@/components/Layout'; // Adjust path for Layout component + +const PublicHome: React.FC = () => { + const [snippets, setSnippets] = useState([]); + const [allSnippets, setAllSnippets] = useState([]); // Store all fetched snippets for client-side filtering + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedLanguage, setSelectedLanguage] = useState(''); + const [selectedTag, setSelectedTag] = useState(''); + + const [availableLanguages, setAvailableLanguages] = useState([]); + const [availableTags, setAvailableTags] = useState([]); + + // Fetch initial public snippets + useEffect(() => { + const fetchPublicSnippets = async () => { + setIsLoading(true); + setError(null); + try { + // Construct query parameters + const params = new URLSearchParams(); + if (searchTerm) params.append('search', searchTerm); + if (selectedLanguage) params.append('language', selectedLanguage); + if (selectedTag) params.append('tag', selectedTag); + + // For the initial load and if server-side filtering is fully implemented for all fields, + // you might fetch with filters directly: + // const response = await fetch(`/api/public/snippets?${params.toString()}`); + + // For now, fetching all and filtering client-side for simplicity in search/filter UI updates + // until server-side filtering for all params is confirmed. + // The action plan mentions /api/public/snippets supports query params, so ideally, debounced fetching would be used. + // Let's assume for now we fetch all public snippets and then apply client-side filtering for dynamic updates. + const response = await fetch('/api/public/snippets'); + if (!response.ok) { + throw new Error(`Failed to fetch snippets: ${response.statusText}`); + } + const data: Snippet[] = await response.json(); + setAllSnippets(data); + setSnippets(data); // Initially display all fetched snippets + + // Extract available languages and tags from fetched snippets + const languages = new Set(); + const tags = new Set(); + data.forEach(snippet => { + if (snippet.language) languages.add(snippet.language); + snippet.tags?.forEach(tag => tags.add(tag)); + }); + setAvailableLanguages(['', ...Array.from(languages)]); // Add empty option for "All" + setAvailableTags(['', ...Array.from(tags)]); // Add empty option for "All" + + } catch (err) { + setError(err instanceof Error ? err.message : 'An unknown error occurred.'); + console.error("Error fetching public snippets:", err); + } finally { + setIsLoading(false); + } + }; + + fetchPublicSnippets(); + }, []); // Initial fetch + + // Apply client-side filters when searchTerm, selectedLanguage, or selectedTag changes + useEffect(() => { + let filtered = allSnippets; + + if (searchTerm) { + filtered = filtered.filter(snippet => + snippet.title.toLowerCase().includes(searchTerm.toLowerCase()) || + snippet.description?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + if (selectedLanguage) { + filtered = filtered.filter(snippet => snippet.language === selectedLanguage); + } + + if (selectedTag) { + filtered = filtered.filter(snippet => snippet.tags?.includes(selectedTag)); + } + + setSnippets(filtered); + }, [searchTerm, selectedLanguage, selectedTag, allSnippets]); + + // Memoize the SnippetGrid to prevent re-renders if snippets haven't changed. + const memoizedSnippetGrid = useMemo(() => ( + + ), [snippets, isLoading, error]); + + + return ( + +
+
+

+ CodePatchwork +

+

+ Discover & Share Code Snippets with the World. +

+ {/* Adjust if your login route is different */} + + +
+ +
+
+
+ + setSearchTerm(e.target.value)} + className="w-full" + /> +
+
+ + +
+ {/* Tag filter can be added similarly if desired, for now keeping it simple */} + {/*
+ + +
*/} +
+
+ + {memoizedSnippetGrid} + + {/* Empty state message handled by SnippetGrid, but can be enhanced here if needed */} + {!isLoading && !error && snippets.length === 0 && ( +
+

+ No public snippets found matching your criteria. +

+ {/* Optional: Suggest signing up or contributing */} +
+ )} + {error && ( +
+

+ Could not load snippets. Please try again later. +

+
+ )} +
+
+ ); +}; + +export default PublicHome; diff --git a/server/__tests__/routes.test.ts b/server/__tests__/routes.test.ts new file mode 100644 index 0000000..74ec356 --- /dev/null +++ b/server/__tests__/routes.test.ts @@ -0,0 +1,170 @@ +// server/__tests__/routes.test.ts + +import request from 'supertest'; +import express, { Express } from 'express'; +import { registerRoutes, authMiddleware } from '../routes'; // Adjust path +import { storage } from '../storage'; // Adjust path +import { type Snippet } from '@shared/schema'; // Adjust path + +// Mock the storage module +jest.mock('../storage', () => ({ + storage: { + getSnippets: jest.fn(), + getSnippet: jest.fn(), + // Add any other storage methods if they get called by routes indirectly + // For GET /api/public/snippets/:id, incrementSnippetViewCount might be called + // Let's add it to avoid potential undefined function errors if the route calls it. + incrementSnippetViewCount: jest.fn().mockResolvedValue(undefined), + }, +})); + +// Mock the authMiddleware to prevent actual auth logic from running +jest.mock('../routes', () => { + const originalModule = jest.requireActual('../routes'); + return { + ...originalModule, + authMiddleware: jest.fn((req, res, next) => { + // Simulate an authenticated user if needed for other tests, but not for public routes + // req.user = { id: 'test-user', email: 'test@example.com' }; + next(); + }), + }; +}); + +// Mock pool for health check, not strictly necessary for these tests but good practice +// if registerRoutes touches it. +jest.mock('../db', () => ({ + pool: { + connect: jest.fn().mockResolvedValue({ + query: jest.fn().mockResolvedValue({ rows: [{ now: new Date().toISOString() }] }), + release: jest.fn(), + }), + }, +})); + + +let app: Express; + +beforeEach(async () => { // Make beforeEach async if registerRoutes is async + app = express(); + app.use(express.json()); // Required for Express to parse JSON request bodies + await registerRoutes(app); // Register all routes, ensure this completes if it's async + + // Clear mock history before each test + (storage.getSnippets as jest.Mock).mockClear(); + (storage.getSnippet as jest.Mock).mockClear(); + (storage.incrementSnippetViewCount as jest.Mock).mockClear(); + (authMiddleware as jest.Mock).mockClear(); +}); + +describe('Public API Snippet Routes', () => { + describe('GET /api/public/snippets', () => { + it('should return a list of public snippets and call storage.getSnippets with isPublic:true', async () => { + const mockPublicSnippets: Partial[] = [ + { id: 1, title: 'Public Snippet 1', isPublic: true, language: 'javascript' }, + { id: 2, title: 'Public Snippet 2', isPublic: true, language: 'python' }, + ]; + (storage.getSnippets as jest.Mock).mockResolvedValue(mockPublicSnippets); + + const response = await request(app).get('/api/public/snippets'); + + expect(response.status).toBe(200); + expect(response.body).toEqual(mockPublicSnippets); + expect(storage.getSnippets).toHaveBeenCalledWith(expect.objectContaining({ // Use objectContaining for flexibility + isPublic: true, + })); + expect(authMiddleware).not.toHaveBeenCalled(); // Ensure authMiddleware is not called for this public route + }); + + it('should pass query parameters (search, language, tag) to storage.getSnippets', async () => { + (storage.getSnippets as jest.Mock).mockResolvedValue([]); + const query = { search: 'test', language: 'javascript', tag: 'example' }; + + await request(app).get('/api/public/snippets').query(query); + + expect(storage.getSnippets).toHaveBeenCalledWith({ + isPublic: true, + search: query.search, + language: query.language, + tag: query.tag, + }); + }); + + it('should handle errors from storage.getSnippets gracefully', async () => { + (storage.getSnippets as jest.Mock).mockRejectedValue(new Error('Storage failure')); + + const response = await request(app).get('/api/public/snippets'); + + expect(response.status).toBe(500); + // Check against the actual error message in your route handler for GET /api/public/snippets + expect(response.body).toEqual(expect.objectContaining({ message: 'Failed to get public snippets' })); + }); + }); + + describe('GET /api/public/snippets/:id', () => { + it('should return a single public snippet if it exists and isPublic is true', async () => { + const mockSnippet: Partial = { id: 1, title: 'Test Snippet', isPublic: true, code: 'code', language: 'js' }; + (storage.getSnippet as jest.Mock).mockResolvedValue(mockSnippet); + + const response = await request(app).get('/api/public/snippets/1'); + + expect(response.status).toBe(200); + expect(response.body).toEqual(mockSnippet); + expect(storage.getSnippet).toHaveBeenCalledWith(1); // ID is number + // The route for /api/public/snippets/:id in the provided routes.ts does NOT call incrementSnippetViewCount. + // The /api/snippets/:id (non-public) and /api/shared/:shareId do. + // So, we should expect it NOT to be called here. + expect(storage.incrementSnippetViewCount).not.toHaveBeenCalled(); + expect(authMiddleware).not.toHaveBeenCalled(); + }); + + it('should return 404 if snippet is not found by storage.getSnippet', async () => { + (storage.getSnippet as jest.Mock).mockResolvedValue(null); // Snippet not found + + const response = await request(app).get('/api/public/snippets/999'); + + expect(response.status).toBe(404); + // Check against the actual error message in your route handler + expect(response.body).toEqual({ message: "Snippet not found or not public" }); + }); + + it('should return 404 if snippet is found but isPublic is false', async () => { + const mockPrivateSnippet: Partial = { id: 2, title: 'Private Snippet', isPublic: false, code: 'private', language: 'js' }; + (storage.getSnippet as jest.Mock).mockResolvedValue(mockPrivateSnippet); + + const response = await request(app).get('/api/public/snippets/2'); + + expect(response.status).toBe(404); + // Check against the actual error message in your route handler + expect(response.body).toEqual({ message: "Snippet not found or not public" }); + }); + + it('should handle errors from storage.getSnippet gracefully', async () => { + (storage.getSnippet as jest.Mock).mockRejectedValue(new Error('Storage failure for single snippet')); + + const response = await request(app).get('/api/public/snippets/1'); + + expect(response.status).toBe(500); + // Check against the actual error message in your route handler for GET /api/public/snippets/:id + expect(response.body).toEqual(expect.objectContaining({ message: 'Failed to get public snippet' })); + }); + + it('should handle invalid ID format (e.g., non-numeric string)', async () => { + // Express route matching with `Number(req.params.id)` will pass NaN to `storage.getSnippet` + // if the ID is non-numeric. We need to simulate `storage.getSnippet` returning null for NaN. + (storage.getSnippet as jest.Mock).mockImplementation(async (id) => { + if (isNaN(id)) { + return null; + } + // Fallback for other ID types if needed, though this test focuses on NaN + return null; + }); + + const response = await request(app).get('/api/public/snippets/abc'); + + expect(storage.getSnippet).toHaveBeenCalledWith(NaN); + expect(response.status).toBe(404); + expect(response.body).toEqual({ message: "Snippet not found or not public" }); + }); + }); +}); diff --git a/server/__tests__/storage.test.ts b/server/__tests__/storage.test.ts new file mode 100644 index 0000000..529c504 --- /dev/null +++ b/server/__tests__/storage.test.ts @@ -0,0 +1,128 @@ +// server/__tests__/storage.test.ts + +import { MemStorage, DatabaseStorage } from '../storage'; // Adjust path as needed +import { type Snippet, type InsertSnippet } from '@shared/schema'; // Adjust path + +// Mock database dependencies for DatabaseStorage if necessary (e.g., mock 'drizzle-orm' or './db') +// For example, using Jest: +// jest.mock('../db', () => ({ +// db: { +// select: jest.fn().mockReturnThis(), +// from: jest.fn().mockReturnThis(), +// where: jest.fn().mockReturnThis(), +// orderBy: jest.fn().mockResolvedValue([]), // Default mock +// // Add other Drizzle functions used in getSnippets if necessary +// } +// })); + +describe('Storage Tests', () => { + describe('MemStorage', () => { + let memStorage: MemStorage; + const sampleSnippets: InsertSnippet[] = [ + { title: 'Public Snippet 1', code: 'console.log("public 1");', language: 'javascript', userId: 'user1', isPublic: true, tags: ['public'] }, + { title: 'Private Snippet 1', code: 'console.log("private 1");', language: 'javascript', userId: 'user1', isPublic: false, tags: ['private'] }, + { title: 'Public Snippet 2', code: 'print("public 2");', language: 'python', userId: 'user2', isPublic: true, tags: ['public', 'python'] }, + { title: 'Private Snippet 2', code: 'print("private 2");', language: 'python', userId: 'user2', isPublic: false, tags: ['private', 'python'] }, + { title: 'Another Public Snippet', code: '

Hello

', language: 'html', userId: 'user1', isPublic: true, tags: ['public', 'html'] }, + ]; + + beforeEach(async () => { + memStorage = new MemStorage(); // Re-initialize before each test for isolation + // MemStorage initializes with its own sample data. For more controlled tests, clear it or use specific test data. + // Let's clear and add our own for predictability: + (memStorage as any).snippets = new Map(); // Clear internal snippets + (memStorage as any).snippetIdCounter = 1; // Reset counter + for (const snippet of sampleSnippets) { + await memStorage.createSnippet(snippet); + } + }); + + test('getSnippets should return all snippets if no filter is provided', async () => { + const snippets = await memStorage.getSnippets(); + expect(snippets.length).toBe(sampleSnippets.length); + }); + + test('getSnippets should filter by isPublic: true', async () => { + const publicSnippets = await memStorage.getSnippets({ isPublic: true }); + expect(publicSnippets.length).toBe(3); + publicSnippets.forEach(snippet => { + expect(snippet.isPublic).toBe(true); + }); + }); + + test('getSnippets should filter by isPublic: false', async () => { + const privateSnippets = await memStorage.getSnippets({ isPublic: false }); + expect(privateSnippets.length).toBe(2); + privateSnippets.forEach(snippet => { + expect(snippet.isPublic).toBe(false); + }); + }); + + test('getSnippets should combine isPublic filter with other filters (e.g., language)', async () => { + const publicPythonSnippets = await memStorage.getSnippets({ isPublic: true, language: 'python' }); + expect(publicPythonSnippets.length).toBe(1); + expect(publicPythonSnippets[0].title).toBe('Public Snippet 2'); + + const privateJsSnippets = await memStorage.getSnippets({ isPublic: false, language: 'javascript' }); + expect(privateJsSnippets.length).toBe(1); + expect(privateJsSnippets[0].title).toBe('Private Snippet 1'); + }); + + test('getSnippets should return empty array if no snippets match isPublic filter', async () => { + // First, clear all snippets to ensure a specific state + (memStorage as any).snippets = new Map(); + await memStorage.createSnippet({ title: 'Private Only', code: 'test', language: 'text', userId: 'user1', isPublic: false }); + + const publicSnippets = await memStorage.getSnippets({ isPublic: true }); + expect(publicSnippets.length).toBe(0); + }); + }); + + describe('DatabaseStorage', () => { + // These tests will be more conceptual without a running DB or fully configured mock. + // The goal is to outline what should be tested. + let dbStorage: DatabaseStorage; + // let mockDb: any; // Reference to the mocked db instance if using jest.mock + + beforeEach(() => { + dbStorage = new DatabaseStorage(); + // If using jest.mock, you might want to clear mock call history: + // mockDb = require('../db').db; // Get the mocked instance + // mockDb.select.mockClear(); + // mockDb.from.mockClear(); + // mockDb.where.mockClear(); + // mockDb.orderBy.mockClear(); + }); + + test('getSnippets should call db.where with isPublic condition when isPublic: true is passed', async () => { + // Placeholder: This test would verify that the ORM's `where` clause is correctly + // constructed when isPublic: true is in filters. + // Example using a Jest mock (actual implementation depends on how db is mocked): + // mockDb.orderBy.mockResolvedValueOnce([{ id: 1, title: 'Test Public', isPublic: true, code: '', language: '' }]); + // await dbStorage.getSnippets({ isPublic: true }); + // expect(mockDb.where).toHaveBeenCalledWith(expect.stringContaining("isPublic = true")); // or ORM equivalent + expect(true).toBe(true); // Placeholder assertion + console.log('Placeholder for DatabaseStorage.getSnippets isPublic: true test'); + }); + + test('getSnippets should call db.where with isPublic condition when isPublic: false is passed', async () => { + // Placeholder for isPublic: false + // mockDb.orderBy.mockResolvedValueOnce([{ id: 2, title: 'Test Private', isPublic: false, code: '', language: '' }]); + // await dbStorage.getSnippets({ isPublic: false }); + // expect(mockDb.where).toHaveBeenCalledWith(expect.stringContaining("isPublic = false")); + expect(true).toBe(true); // Placeholder assertion + console.log('Placeholder for DatabaseStorage.getSnippets isPublic: false test'); + }); + + test('getSnippets should correctly combine isPublic filter with other filters (e.g., language) for DatabaseStorage', async () => { + // Placeholder for combined filters test + // Example: + // await dbStorage.getSnippets({ isPublic: true, language: 'javascript' }); + // Expect mockDb.where to have been called with conditions for both isPublic AND language. + expect(true).toBe(true); // Placeholder assertion + console.log('Placeholder for DatabaseStorage.getSnippets combined filters test'); + }); + + // Add more tests for other filter combinations if necessary. + }); +}); diff --git a/server/routes.ts b/server/routes.ts index db45945..38eb491 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -484,6 +484,45 @@ export async function registerRoutes(app: Express): Promise { } }); + // ──────────────────────────────────────────────────────────────── + // ─── 3.2.1) PUBLIC Snippets endpoints ────────────────────────── + // ──────────────────────────────────────────────────────────────── + + // GET all public snippets + app.get("/api/public/snippets", async (req, res) => { + try { + const filters: any = { isPublic: true }; + if (req.query.search) filters.search = String(req.query.search); + if (req.query.language) filters.language = req.query.language; + if (req.query.tag) filters.tag = req.query.tag; + + const snippets = await storage.getSnippets(filters); + res.json(snippets); + } catch (err: any) { + console.error("GET /api/public/snippets error:", err); + res.status(500).json({ message: "Failed to get public snippets", error: err.message }); + } + }); + + // GET single public snippet by ID + app.get("/api/public/snippets/:id", async (req, res) => { + try { + const id = Number(req.params.id); + const snippet = await storage.getSnippet(id); + + if (snippet && snippet.isPublic) { + // Optionally increment view count for public views as well + // await storage.incrementSnippetViewCount(id); + res.json(snippet); + } else { + res.status(404).json({ message: "Snippet not found or not public" }); + } + } catch (err: any) { + console.error(`GET /api/public/snippets/${req.params.id} error:`, err); + res.status(500).json({ message: "Failed to get public snippet", error: err.message }); + } + }); + // TOGGLE FAVORITE (requires authentication) app.post("/api/snippets/:id/favorite", authMiddleware, async (req, res) => { try { diff --git a/server/storage.ts b/server/storage.ts index 450e00b..6e5597e 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -21,6 +21,7 @@ export interface IStorage { language?: string | string[]; tag?: string | string[]; favorites?: boolean; + isPublic?: boolean; }): Promise; getSnippet(id: number): Promise; createSnippet(snippet: InsertSnippet): Promise; @@ -136,7 +137,8 @@ function useLocalStorage( tags: ["react", "hooks", "typescript"], userId: null, isFavorite: false, - viewCount: 12 + viewCount: 12, + isPublic: true }, { title: "Python Decorator for Timing", @@ -169,7 +171,8 @@ waste_some_time(100)`, tags: ["python", "decorators", "performance"], userId: null, isFavorite: false, - viewCount: 24 + viewCount: 24, + isPublic: false }, { title: "CSS Grid Layout Template", @@ -215,7 +218,8 @@ waste_some_time(100)`, tags: ["css", "grid", "responsive"], userId: null, isFavorite: true, - viewCount: 41 + viewCount: 41, + isPublic: true }, { title: "JavaScript Array Methods Cheatsheet", @@ -255,7 +259,8 @@ string.split(separator); // Split string into array`, tags: ["javascript", "arrays", "cheatsheet"], userId: null, isFavorite: true, - viewCount: 137 + viewCount: 137, + isPublic: false }, { title: "Tailwind Dark Mode Toggle", @@ -304,7 +309,8 @@ const DarkModeToggle = () => { tags: ["react", "tailwind", "darkmode"], userId: null, isFavorite: false, - viewCount: 52 + viewCount: 52, + isPublic: true }, { title: "Go Error Handling Pattern", @@ -363,7 +369,8 @@ func main() { tags: ["go", "error-handling", "best-practices"], userId: null, isFavorite: false, - viewCount: 18 + viewCount: 18, + isPublic: false } ]; @@ -438,6 +445,7 @@ func main() { language?: string | string[]; tag?: string | string[]; favorites?: boolean; + isPublic?: boolean; }): Promise { let snippets = Array.from(this.snippets.values()); @@ -490,6 +498,11 @@ func main() { (s.code && s.code.toLowerCase().includes(searchTerm)) ); } + + // Filter by public status + if (filters.isPublic !== undefined) { + snippets = snippets.filter(s => s.isPublic === filters.isPublic); + } } // Sort by most recently updated @@ -514,7 +527,9 @@ func main() { createdAt: now, updatedAt: now, viewCount: snippet.viewCount || 0, - isFavorite: snippet.isFavorite || false + isFavorite: snippet.isFavorite || false, + isPublic: snippet.isPublic || false, // Ensure isPublic is set, defaulting to false + shareId: snippet.shareId || null }; this.snippets.set(id, newSnippet); @@ -859,6 +874,7 @@ export class DatabaseStorage implements IStorage { language?: string | string[]; tag?: string | string[]; favorites?: boolean; + isPublic?: boolean; }): Promise { const { db } = await import('./db'); let query = db.select().from(snippets); @@ -911,6 +927,11 @@ export class DatabaseStorage implements IStorage { if (filters.favorites) { query = query.where(eq(snippets.isFavorite, true)); } + + // Filter by public status + if (filters.isPublic !== undefined) { + query = query.where(eq(snippets.isPublic, filters.isPublic)); + } } // Order by most recently updated