summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/components/active-link.test.tsx57
-rw-r--r--src/components/inline-link.test.tsx41
-rw-r--r--src/components/json-ld.test.tsx66
-rw-r--r--src/components/numbered-pagination.test.tsx64
-rw-r--r--src/components/rich-text/node-format.test.ts40
-rw-r--r--src/components/section.test.tsx19
-rw-r--r--src/components/tags/tag-card.test.tsx36
-rw-r--r--src/components/tailwind-indicator.test.tsx30
-rw-r--r--src/components/theme-provider.test.tsx23
-rw-r--r--src/components/ui/alert.test.tsx30
-rw-r--r--src/components/ui/button-variants.test.ts28
-rw-r--r--src/components/ui/card.test.tsx40
-rw-r--r--src/components/ui/input.test.tsx22
-rw-r--r--src/components/ui/label.test.tsx15
-rw-r--r--src/components/ui/pagination.test.tsx61
-rw-r--r--src/components/ui/skeleton.test.tsx16
-rw-r--r--src/hooks/use-pagination.test.ts53
-rw-r--r--src/hooks/use-pagination.tsx5
-rw-r--r--src/lib/constants.test.ts54
-rw-r--r--src/lib/is-active.test.ts26
-rw-r--r--src/lib/metadata.test.ts58
-rw-r--r--src/lib/safe-action.test.ts42
-rw-r--r--src/lib/utils.test.ts21
-rw-r--r--src/lib/validators/newsletter.test.ts27
-rw-r--r--src/test/setup.ts6
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();
+});