diff options
Diffstat (limited to 'src')
25 files changed, 879 insertions, 1 deletions
diff --git a/src/components/active-link.test.tsx b/src/components/active-link.test.tsx new file mode 100644 index 0000000..0c603cb --- /dev/null +++ b/src/components/active-link.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, test, vi } from 'vitest'; +import { ActiveLink } from './active-link'; +import { usePathname } from 'next/navigation'; + +vi.mock('next/navigation', () => ({ + usePathname: vi.fn(), +})); + +vi.mock('next/link', () => ({ + default: ({ + href, + children, + ...props + }: { + href: string; + children: ReactNode; + }) => ( + <a href={href} {...props}> + {children} + </a> + ), +})); + +describe('ActiveLink', () => { + test('applies active styles when pathname matches href', () => { + vi.mocked(usePathname).mockReturnValue('/posts'); + + render(<ActiveLink href='/posts'>Posts</ActiveLink>); + + const link = screen.getByRole('link', { name: 'Posts' }); + expect(link).toHaveClass('font-medium'); + }); + + test('does not apply active styles for non-matching paths', () => { + vi.mocked(usePathname).mockReturnValue('/tags'); + + render(<ActiveLink href='/posts'>Posts</ActiveLink>); + + const link = screen.getByRole('link', { name: 'Posts' }); + expect(link).not.toHaveClass('font-medium'); + }); + + test('supports nested path matching when nested prop is enabled', () => { + vi.mocked(usePathname).mockReturnValue('/posts/testing-vitest'); + + render( + <ActiveLink href='/posts' nested> + Posts + </ActiveLink>, + ); + + const link = screen.getByRole('link', { name: 'Posts' }); + expect(link).toHaveClass('font-medium'); + }); +}); diff --git a/src/components/inline-link.test.tsx b/src/components/inline-link.test.tsx new file mode 100644 index 0000000..ad9813d --- /dev/null +++ b/src/components/inline-link.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, test, vi } from 'vitest'; +import { InlineLink } from './inline-link'; + +vi.mock('next/link', () => ({ + default: ({ + href, + children, + ...props + }: { + href: string; + children: ReactNode; + }) => ( + <a href={href} {...props}> + {children} + </a> + ), +})); + +describe('InlineLink', () => { + test('renders as a normal link by default', () => { + render(<InlineLink href='/posts'>Posts</InlineLink>); + + const link = screen.getByRole('link', { name: 'Posts' }); + expect(link).toHaveAttribute('href', '/posts'); + expect(link).not.toHaveAttribute('target', '_blank'); + }); + + test('sets target blank when blank prop is true', () => { + render( + <InlineLink href='https://example.com' blank> + External + </InlineLink>, + ); + + const link = screen.getByRole('link', { name: 'External' }); + expect(link).toHaveAttribute('href', 'https://example.com'); + expect(link).toHaveAttribute('target', '_blank'); + }); +}); diff --git a/src/components/json-ld.test.tsx b/src/components/json-ld.test.tsx new file mode 100644 index 0000000..c96e5c3 --- /dev/null +++ b/src/components/json-ld.test.tsx @@ -0,0 +1,66 @@ +import { render } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +vi.mock('@/app/(main)/layout.config', () => ({ + title: 'Blog', + owner: 'Test Owner', +})); + +vi.mock('@/lib/constants', () => ({ + baseUrl: new URL('http://localhost:3000'), +})); + +import { PostJsonLd, TagJsonLd } from './json-ld'; + +describe('json-ld components', () => { + test('renders post json-ld with blog posting and breadcrumb graph', () => { + const { container } = render( + <PostJsonLd + post={{ + id: 1, + title: 'Testing Post', + slug: 'testing-post', + url: '/posts/testing-post', + description: 'A post for tests', + content: null, + image: '/cover.png', + author: 'Alice', + tags: ['testing'], + date: new Date('2025-01-01T00:00:00.000Z'), + createdAt: new Date('2025-01-01T00:00:00.000Z'), + updatedAt: new Date('2025-01-02T00:00:00.000Z'), + }} + />, + ); + + const script = container.querySelector('script[type="application/ld+json"]'); + expect(script).toBeInTheDocument(); + + const payload = JSON.parse(script?.innerHTML ?? '{}'); + expect(payload['@context']).toBe('https://schema.org'); + expect(payload['@graph']).toHaveLength(2); + expect(payload['@graph'][0]['@type']).toBe('BlogPosting'); + expect(payload['@graph'][0].mainEntityOfPage['@id']).toBe( + 'http://localhost:3000/posts/testing-post', + ); + }); + + test('returns null when post is not provided', () => { + const { container } = render(<PostJsonLd post={null as unknown as any} />); + expect(container.firstChild).toBeNull(); + }); + + test('renders tag json-ld with tag-specific breadcrumb item', () => { + const { container } = render(<TagJsonLd tag='nextjs' />); + + const script = container.querySelector('script[type="application/ld+json"]'); + expect(script).toBeInTheDocument(); + + const payload = JSON.parse(script?.innerHTML ?? '{}'); + expect(payload['@graph']).toHaveLength(1); + expect(payload['@graph'][0]['@type']).toBe('BreadcrumbList'); + expect(payload['@graph'][0].itemListElement[2].item).toBe( + 'http://localhost:3000/tags/nextjs', + ); + }); +}); diff --git a/src/components/numbered-pagination.test.tsx b/src/components/numbered-pagination.test.tsx new file mode 100644 index 0000000..d99ab46 --- /dev/null +++ b/src/components/numbered-pagination.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; +import { NumberedPagination } from './numbered-pagination'; + +describe('NumberedPagination', () => { + test('renders page controls and current page', () => { + render( + <NumberedPagination + currentPage={5} + totalPages={10} + paginationItemsToDisplay={5} + onPageChange={vi.fn()} + />, + ); + + expect(screen.getByRole('link', { name: 'Go to previous page' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Go to next page' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '5' })).toHaveAttribute('aria-current'); + expect(screen.getAllByText('...')).toHaveLength(2); + }); + + test('calls onPageChange when selecting a valid page', () => { + const onPageChange = vi.fn(); + render( + <NumberedPagination + currentPage={2} + totalPages={5} + paginationItemsToDisplay={5} + onPageChange={onPageChange} + />, + ); + + fireEvent.click(screen.getByRole('link', { name: '3' })); + expect(onPageChange).toHaveBeenCalledWith(3); + }); + + test('does not call onPageChange for out-of-range previous/next actions', () => { + const onPageChange = vi.fn(); + const { rerender } = render( + <NumberedPagination + currentPage={1} + totalPages={3} + paginationItemsToDisplay={5} + onPageChange={onPageChange} + />, + ); + + fireEvent.click(screen.getByRole('link', { name: 'Go to previous page' })); + expect(onPageChange).not.toHaveBeenCalled(); + + rerender( + <NumberedPagination + currentPage={3} + totalPages={3} + paginationItemsToDisplay={5} + onPageChange={onPageChange} + />, + ); + + fireEvent.click(screen.getByRole('link', { name: 'Go to next page' })); + expect(onPageChange).not.toHaveBeenCalled(); + }); +}); + diff --git a/src/components/rich-text/node-format.test.ts b/src/components/rich-text/node-format.test.ts new file mode 100644 index 0000000..476a0d9 --- /dev/null +++ b/src/components/rich-text/node-format.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'vitest'; +import { + IS_BOLD, + IS_CODE, + IS_HIGHLIGHT, + IS_ITALIC, + IS_STRIKETHROUGH, + IS_SUBSCRIPT, + IS_SUPERSCRIPT, + IS_UNDERLINE, +} from './node-format'; + +describe('node format bit flags', () => { + test('uses power-of-two values for unique bitmasks', () => { + const flags = [ + IS_BOLD, + IS_ITALIC, + IS_STRIKETHROUGH, + IS_UNDERLINE, + IS_CODE, + IS_SUBSCRIPT, + IS_SUPERSCRIPT, + IS_HIGHLIGHT, + ]; + + for (const flag of flags) { + expect((flag & (flag - 1)) === 0).toBe(true); + } + }); + + test('can combine and detect individual flags with bitwise operations', () => { + const format = IS_BOLD | IS_ITALIC | IS_CODE; + + expect((format & IS_BOLD) !== 0).toBe(true); + expect((format & IS_ITALIC) !== 0).toBe(true); + expect((format & IS_CODE) !== 0).toBe(true); + expect((format & IS_UNDERLINE) !== 0).toBe(false); + }); +}); + diff --git a/src/components/section.test.tsx b/src/components/section.test.tsx new file mode 100644 index 0000000..18002ae --- /dev/null +++ b/src/components/section.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { Section } from './section'; + +describe('Section', () => { + test('renders children and forwards section attributes', () => { + render( + <Section data-testid='section' sectionClassName='py-20' className='px-4'> + <div>Body</div> + </Section>, + ); + + const section = screen.getByTestId('section'); + expect(section.tagName).toBe('SECTION'); + expect(section.className).toContain('py-20'); + expect(screen.getByText('Body')).toBeInTheDocument(); + }); +}); + diff --git a/src/components/tags/tag-card.test.tsx b/src/components/tags/tag-card.test.tsx new file mode 100644 index 0000000..645441b --- /dev/null +++ b/src/components/tags/tag-card.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, test, vi } from 'vitest'; +import { TagCard } from './tag-card'; + +vi.mock('next/link', () => ({ + default: ({ + href, + children, + ...props + }: { + href: string; + children: ReactNode; + }) => ( + <a href={href} {...props}> + {children} + </a> + ), +})); + +describe('TagCard', () => { + test('renders a tag link with computed tag url', () => { + render(<TagCard name='react' />); + + const link = screen.getByRole('link', { name: /react/i }); + expect(link).toHaveAttribute('href', '/tags/react'); + }); + + test('renders count only when displayCount is true and count exists', () => { + const { rerender } = render(<TagCard name='nextjs' displayCount count={12} />); + expect(screen.getByText('(12)')).toBeInTheDocument(); + + rerender(<TagCard name='nextjs' count={12} />); + expect(screen.queryByText('(12)')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/tailwind-indicator.test.tsx b/src/components/tailwind-indicator.test.tsx new file mode 100644 index 0000000..4244ef6 --- /dev/null +++ b/src/components/tailwind-indicator.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import { afterAll, beforeEach, describe, expect, test } from 'vitest'; +import { TailwindIndicator } from './tailwind-indicator'; + +const originalEnv = process.env; + +describe('TailwindIndicator', () => { + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + test('renders in non-production environments', () => { + process.env.NODE_ENV = 'test'; + render(<TailwindIndicator />); + + expect(screen.getByText('xs')).toBeInTheDocument(); + }); + + test('returns null in production', () => { + process.env.NODE_ENV = 'production'; + const { container } = render(<TailwindIndicator />); + + expect(container.firstChild).toBeNull(); + }); +}); + diff --git a/src/components/theme-provider.test.tsx b/src/components/theme-provider.test.tsx new file mode 100644 index 0000000..0ac54e1 --- /dev/null +++ b/src/components/theme-provider.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, test, vi } from 'vitest'; +import { ThemeProvider } from './theme-provider'; + +vi.mock('next-themes', () => ({ + ThemeProvider: ({ children }: { children: ReactNode }) => ( + <div data-testid='next-themes-provider'>{children}</div> + ), +})); + +describe('ThemeProvider', () => { + test('renders children through next-themes provider', () => { + render( + <ThemeProvider attribute='class'> + <span>content</span> + </ThemeProvider>, + ); + + expect(screen.getByTestId('next-themes-provider')).toBeInTheDocument(); + expect(screen.getByText('content')).toBeInTheDocument(); + }); +}); diff --git a/src/components/ui/alert.test.tsx b/src/components/ui/alert.test.tsx new file mode 100644 index 0000000..ded0356 --- /dev/null +++ b/src/components/ui/alert.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { Alert, AlertDescription, AlertTitle } from './alert'; + +describe('Alert', () => { + test('renders with role and slot attributes', () => { + render( + <Alert> + <AlertTitle>Heads up</AlertTitle> + <AlertDescription>Something happened.</AlertDescription> + </Alert>, + ); + + const alert = screen.getByRole('alert'); + expect(alert).toHaveAttribute('data-slot', 'alert'); + expect(screen.getByText('Heads up')).toHaveAttribute('data-slot', 'alert-title'); + expect(screen.getByText('Something happened.')).toHaveAttribute( + 'data-slot', + 'alert-description', + ); + }); + + test('applies destructive variant classes', () => { + render(<Alert variant='destructive'>Danger</Alert>); + + const alert = screen.getByRole('alert'); + expect(alert.className).toContain('text-destructive'); + }); +}); + diff --git a/src/components/ui/button-variants.test.ts b/src/components/ui/button-variants.test.ts new file mode 100644 index 0000000..5090703 --- /dev/null +++ b/src/components/ui/button-variants.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'vitest'; +import { buttonVariants } from './button'; + +describe('buttonVariants', () => { + test('returns default variant and size classes when no args are provided', () => { + const classes = buttonVariants(); + + expect(classes).toContain('bg-primary'); + expect(classes).toContain('h-9'); + }); + + test('returns destructive and icon classes for explicit options', () => { + const classes = buttonVariants({ + variant: 'destructive', + size: 'icon', + }); + + expect(classes).toContain('bg-destructive'); + expect(classes).toContain('size-9'); + }); + + test('includes caller-provided className', () => { + const classes = buttonVariants({ className: 'w-full' }); + + expect(classes).toContain('w-full'); + }); +}); + diff --git a/src/components/ui/card.test.tsx b/src/components/ui/card.test.tsx new file mode 100644 index 0000000..aba3d00 --- /dev/null +++ b/src/components/ui/card.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from './card'; + +describe('Card', () => { + test('renders all card slots', () => { + render( + <Card> + <CardHeader> + <CardTitle>Card Title</CardTitle> + <CardDescription>Card Description</CardDescription> + <CardAction>Action</CardAction> + </CardHeader> + <CardContent>Content</CardContent> + <CardFooter>Footer</CardFooter> + </Card>, + ); + + expect(screen.getByText('Card Title')).toHaveAttribute( + 'data-slot', + 'card-title', + ); + expect(screen.getByText('Card Description')).toHaveAttribute( + 'data-slot', + 'card-description', + ); + expect(screen.getByText('Action')).toHaveAttribute('data-slot', 'card-action'); + expect(screen.getByText('Content')).toHaveAttribute('data-slot', 'card-content'); + expect(screen.getByText('Footer')).toHaveAttribute('data-slot', 'card-footer'); + }); +}); + diff --git a/src/components/ui/input.test.tsx b/src/components/ui/input.test.tsx new file mode 100644 index 0000000..aabe76f --- /dev/null +++ b/src/components/ui/input.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { Input } from './input'; + +describe('Input', () => { + test('renders an input with the expected slot attribute', () => { + render(<Input aria-label='Email' />); + + const input = screen.getByLabelText('Email'); + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute('data-slot', 'input'); + }); + + test('supports input type and disabled props', () => { + render(<Input aria-label='Password' type='password' disabled />); + + const input = screen.getByLabelText('Password'); + expect(input).toHaveAttribute('type', 'password'); + expect(input).toBeDisabled(); + }); +}); + diff --git a/src/components/ui/label.test.tsx b/src/components/ui/label.test.tsx new file mode 100644 index 0000000..08031b6 --- /dev/null +++ b/src/components/ui/label.test.tsx @@ -0,0 +1,15 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { Label } from './label'; + +describe('Label', () => { + test('renders label text and exposes data-slot', () => { + render(<Label htmlFor='email'>Email</Label>); + + const label = screen.getByText('Email'); + expect(label).toBeInTheDocument(); + expect(label).toHaveAttribute('for', 'email'); + expect(label).toHaveAttribute('data-slot', 'label'); + }); +}); + diff --git a/src/components/ui/pagination.test.tsx b/src/components/ui/pagination.test.tsx new file mode 100644 index 0000000..77309b8 --- /dev/null +++ b/src/components/ui/pagination.test.tsx @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from './pagination'; + +describe('Pagination UI', () => { + test('renders pagination container and list slots', () => { + render( + <Pagination> + <PaginationContent> + <PaginationItem> + <PaginationLink href='#'>1</PaginationLink> + </PaginationItem> + </PaginationContent> + </Pagination>, + ); + + const nav = screen.getByLabelText('pagination'); + expect(nav).toHaveAttribute('data-slot', 'pagination'); + expect(screen.getByRole('link', { name: '1' })).toHaveAttribute( + 'data-slot', + 'pagination-link', + ); + }); + + test('sets active page attributes', () => { + render( + <PaginationLink href='#' isActive> + 2 + </PaginationLink>, + ); + + const link = screen.getByRole('link', { name: '2' }); + expect(link).toHaveAttribute('aria-current', 'page'); + expect(link).toHaveAttribute('data-active', 'true'); + }); + + test('renders previous, next, and ellipsis affordances', () => { + render( + <div> + <PaginationPrevious href='#' /> + <PaginationNext href='#' /> + <PaginationEllipsis /> + </div>, + ); + + expect( + screen.getByRole('link', { name: 'Go to previous page' }), + ).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Go to next page' })).toBeInTheDocument(); + expect(screen.getByText('More pages')).toBeInTheDocument(); + }); +}); + diff --git a/src/components/ui/skeleton.test.tsx b/src/components/ui/skeleton.test.tsx new file mode 100644 index 0000000..4459d77 --- /dev/null +++ b/src/components/ui/skeleton.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { Skeleton } from './skeleton'; + +describe('Skeleton', () => { + test('renders with default and custom classes', () => { + render(<Skeleton data-testid='skeleton' className='h-4 w-20' />); + + const skeleton = screen.getByTestId('skeleton'); + expect(skeleton).toHaveAttribute('data-slot', 'skeleton'); + expect(skeleton.className).toContain('animate-pulse'); + expect(skeleton.className).toContain('h-4'); + expect(skeleton.className).toContain('w-20'); + }); +}); + diff --git a/src/hooks/use-pagination.test.ts b/src/hooks/use-pagination.test.ts new file mode 100644 index 0000000..ba70f5e --- /dev/null +++ b/src/hooks/use-pagination.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'vitest'; +import { usePagination } from './use-pagination'; + +describe('usePagination', () => { + test('returns all pages when total pages are less than display limit', () => { + const result = usePagination({ + currentPage: 2, + totalPages: 4, + paginationItemsToDisplay: 5, + }); + + expect(result.pages).toEqual([1, 2, 3, 4]); + expect(result.showLeftEllipsis).toBe(false); + expect(result.showRightEllipsis).toBe(false); + }); + + test('shows only right ellipsis near the start of the range', () => { + const result = usePagination({ + currentPage: 1, + totalPages: 10, + paginationItemsToDisplay: 5, + }); + + expect(result.pages).toEqual([1, 2, 3, 4]); + expect(result.showLeftEllipsis).toBe(false); + expect(result.showRightEllipsis).toBe(true); + }); + + test('shows both ellipses around middle pages', () => { + const result = usePagination({ + currentPage: 5, + totalPages: 10, + paginationItemsToDisplay: 5, + }); + + expect(result.pages).toEqual([4, 5, 6]); + expect(result.showLeftEllipsis).toBe(true); + expect(result.showRightEllipsis).toBe(true); + }); + + test('shows only left ellipsis near the end of the range', () => { + const result = usePagination({ + currentPage: 10, + totalPages: 10, + paginationItemsToDisplay: 5, + }); + + expect(result.pages).toEqual([7, 8, 9, 10]); + expect(result.showLeftEllipsis).toBe(true); + expect(result.showRightEllipsis).toBe(false); + }); +}); + diff --git a/src/hooks/use-pagination.tsx b/src/hooks/use-pagination.tsx index d009cd4..40ed979 100644 --- a/src/hooks/use-pagination.tsx +++ b/src/hooks/use-pagination.tsx @@ -15,8 +15,11 @@ export function usePagination({ totalPages, paginationItemsToDisplay, }: UsePaginationProps): UsePaginationReturn { - const showLeftEllipsis = currentPage - 1 > paginationItemsToDisplay / 2; + const shouldTruncate = totalPages > paginationItemsToDisplay; + const showLeftEllipsis = + shouldTruncate && currentPage - 1 > paginationItemsToDisplay / 2; const showRightEllipsis = + shouldTruncate && totalPages - currentPage + 1 > paginationItemsToDisplay / 2; function calculatePaginationRange(): number[] { diff --git a/src/lib/constants.test.ts b/src/lib/constants.test.ts new file mode 100644 index 0000000..65fb466 --- /dev/null +++ b/src/lib/constants.test.ts @@ -0,0 +1,54 @@ +import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest'; + +const originalEnv = process.env; + +function setEnv(key: string, value?: string) { + if (value === undefined) { + delete process.env[key]; + return; + } + + process.env[key] = value; +} + +describe('constants', () => { + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + test('uses localhost base url outside production', async () => { + setEnv('NODE_ENV', 'development'); + setEnv('VERCEL_PROJECT_PRODUCTION_URL', 'example.com'); + + const constants = await import('./constants'); + + expect(constants.isProduction).toBe(false); + expect(constants.baseUrl.href).toBe('http://localhost:3000/'); + }); + + test('uses vercel production url in production', async () => { + setEnv('NODE_ENV', 'production'); + setEnv('VERCEL_PROJECT_PRODUCTION_URL', 'blog.example.com'); + + const constants = await import('./constants'); + + expect(constants.isProduction).toBe(true); + expect(constants.baseUrl.href).toBe('https://blog.example.com/'); + }); + + test('falls back to localhost when production url is missing', async () => { + setEnv('NODE_ENV', 'production'); + setEnv('VERCEL_PROJECT_PRODUCTION_URL'); + + const constants = await import('./constants'); + + expect(constants.isProduction).toBe(true); + expect(constants.baseUrl.href).toBe('http://localhost:3000/'); + }); +}); + diff --git a/src/lib/is-active.test.ts b/src/lib/is-active.test.ts new file mode 100644 index 0000000..fcc42d0 --- /dev/null +++ b/src/lib/is-active.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'vitest'; +import { isActive } from './is-active'; + +describe('isActive', () => { + test('returns true for exact match', () => { + expect(isActive('/posts', '/posts')).toBe(true); + }); + + test('normalizes trailing slashes before comparing', () => { + expect(isActive('/posts/', '/posts')).toBe(true); + expect(isActive('/posts', '/posts/')).toBe(true); + }); + + test('returns true for nested paths when nested is enabled', () => { + expect(isActive('/posts', '/posts/hello-world', true)).toBe(true); + }); + + test('returns false for nested paths when nested is disabled', () => { + expect(isActive('/posts', '/posts/hello-world', false)).toBe(false); + }); + + test('returns false for unrelated paths', () => { + expect(isActive('/posts', '/tags')).toBe(false); + }); +}); + diff --git a/src/lib/metadata.test.ts b/src/lib/metadata.test.ts new file mode 100644 index 0000000..ce5c5b0 --- /dev/null +++ b/src/lib/metadata.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'vitest'; +import { createMetadata } from './metadata'; + +describe('createMetadata', () => { + test('builds metadata with default open graph, twitter, and alternates fields', () => { + const metadata = createMetadata({ + title: 'My Post', + description: 'Post description', + }); + + expect(metadata.openGraph).toMatchObject({ + title: 'My Post', + description: 'Post description', + url: 'https://blog.techwithanirudh.com', + images: '/banner.png', + siteName: 'Blog', + }); + + expect(metadata.twitter).toMatchObject({ + card: 'summary_large_image', + creator: '@AnirudhWith', + title: 'My Post', + description: 'Post description', + images: '/banner.png', + }); + + expect(metadata.alternates).toMatchObject({ + canonical: '/', + types: { + 'application/rss+xml': '/api/rss.xml', + }, + }); + }); + + test('allows nested metadata fields to override defaults and top-level fallbacks', () => { + const metadata = createMetadata({ + title: 'Top Level Title', + openGraph: { + title: 'OG Title', + url: 'https://example.com/posts/1', + }, + twitter: { + title: 'Twitter Title', + card: 'summary', + }, + alternates: { + canonical: '/posts/1', + }, + }); + + expect(metadata.openGraph?.title).toBe('OG Title'); + expect(metadata.openGraph?.url).toBe('https://example.com/posts/1'); + expect(metadata.twitter?.title).toBe('Twitter Title'); + expect(metadata.twitter?.card).toBe('summary'); + expect(metadata.alternates?.canonical).toBe('/posts/1'); + }); +}); + diff --git a/src/lib/safe-action.test.ts b/src/lib/safe-action.test.ts new file mode 100644 index 0000000..eee5679 --- /dev/null +++ b/src/lib/safe-action.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('next-safe-action', () => ({ + DEFAULT_SERVER_ERROR_MESSAGE: 'Internal server error', + createSafeActionClient: vi.fn((options: unknown) => options), +})); + +describe('safe-action', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + test('returns custom message for ActionError', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { ActionError, actionClient } = await import('./safe-action'); + const client = actionClient as unknown as { + handleServerError: (e: Error) => string; + }; + + const message = client.handleServerError(new ActionError('Custom error')); + + expect(message).toBe('Custom error'); + expect(errorSpy).toHaveBeenCalledWith( + 'Failed to execute action:', + 'Custom error', + ); + }); + + test('returns default message for unknown errors', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { actionClient } = await import('./safe-action'); + const client = actionClient as unknown as { + handleServerError: (e: Error) => string; + }; + + const message = client.handleServerError(new Error('Unexpected')); + + expect(message).toBe('Internal server error'); + expect(errorSpy).toHaveBeenCalledWith('Failed to execute action:', 'Unexpected'); + }); +}); diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts new file mode 100644 index 0000000..9a61d41 --- /dev/null +++ b/src/lib/utils.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'vitest'; +import { cn } from './utils'; + +describe('cn', () => { + test('merges class names and resolves tailwind conflicts', () => { + expect(cn('px-2', 'px-4')).toBe('px-4'); + }); + + test('handles conditional and falsy inputs', () => { + expect(cn('text-sm', false && 'hidden', undefined, 'font-medium')).toBe( + 'text-sm font-medium', + ); + }); + + test('supports object and array style class inputs', () => { + expect( + cn(['inline-flex', ['items-center']], { 'cursor-pointer': true }), + ).toBe('inline-flex items-center cursor-pointer'); + }); +}); + diff --git a/src/lib/validators/newsletter.test.ts b/src/lib/validators/newsletter.test.ts new file mode 100644 index 0000000..12e3f46 --- /dev/null +++ b/src/lib/validators/newsletter.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'vitest'; +import { NewsletterSchema } from './newsletter'; + +describe('NewsletterSchema', () => { + test('accepts a valid email payload', () => { + const parsed = NewsletterSchema.parse({ + email: 'user@example.com', + }); + + expect(parsed).toEqual({ email: 'user@example.com' }); + }); + + test('rejects invalid email payloads', () => { + expect( + NewsletterSchema.safeParse({ + email: 'invalid-email', + }).success, + ).toBe(false); + + expect( + NewsletterSchema.safeParse({ + email: '', + }).success, + ).toBe(false); + }); +}); + diff --git a/src/test/setup.ts b/src/test/setup.ts index bb02c60..0d74b73 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1 +1,7 @@ import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +afterEach(() => { + cleanup(); +}); |
