From 11404f5e078f76be25f6b1eee8bfc2f652b12092 Mon Sep 17 00:00:00 2001 From: CodedBay Date: Sat, 29 Aug 2026 00:11:39 +0000 Subject: [PATCH] =?UTF-8?q?test(dashboard):=20add=20auth=20guard=20tests?= =?UTF-8?q?=20for=20all=204=20token=C3=97merchant=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Install Jest + Testing Library infrastructure - Tests: token+merchant renders dashboard shell; token only returns null (blank, no redirect); no-token redirects to /auth/login; neither redirects to /auth/login --- jest.config.ts | 21 ++++ package.json | 12 ++- src/__mocks__/fileMock.ts | 2 + src/__mocks__/next/link.tsx | 6 ++ src/__mocks__/next/navigation.ts | 12 +++ src/__tests__/dashboard-layout.test.tsx | 135 ++++++++++++++++++++++++ src/setupTests.ts | 1 + 7 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 jest.config.ts create mode 100644 src/__mocks__/fileMock.ts create mode 100644 src/__mocks__/next/link.tsx create mode 100644 src/__mocks__/next/navigation.ts create mode 100644 src/__tests__/dashboard-layout.test.tsx create mode 100644 src/setupTests.ts diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 00000000..a17bccb0 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,21 @@ +import type { Config } from 'jest'; + +const config: Config = { + testEnvironment: 'jsdom', + transform: { + '^.+\\.(ts|tsx)$': ['ts-jest', { + tsconfig: { + jsx: 'react-jsx', + }, + }], + }, + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + '\\.(css|less|scss|sass)$': 'identity-obj-proxy', + '\\.(svg|png|jpg|jpeg|gif|webp)$': '/src/__mocks__/fileMock.ts', + }, + testMatch: ['**/__tests__/**/*.test.(ts|tsx)', '**/*.test.(ts|tsx)'], + setupFilesAfterEnv: ['/src/setupTests.ts'], +}; + +export default config; diff --git a/package.json b/package.json index 6a44228f..d7cb4bd9 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,10 @@ "build": "next build", "start": "next start", "lint": "next lint", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage" }, "dependencies": { "next": "14.2.5", @@ -25,14 +28,21 @@ "date-fns": "^3.6.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", "autoprefixer": "^10.0.1", "eslint": "^8", "eslint-config-next": "14.2.5", + "identity-obj-proxy": "^3.0.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "postcss": "^8", "tailwindcss": "^3.4.1", + "ts-jest": "^29.4.0", "typescript": "^5" } } diff --git a/src/__mocks__/fileMock.ts b/src/__mocks__/fileMock.ts new file mode 100644 index 00000000..9559a6e4 --- /dev/null +++ b/src/__mocks__/fileMock.ts @@ -0,0 +1,2 @@ +const fileMock = 'test-file-stub'; +export default fileMock; diff --git a/src/__mocks__/next/link.tsx b/src/__mocks__/next/link.tsx new file mode 100644 index 00000000..203d8d4a --- /dev/null +++ b/src/__mocks__/next/link.tsx @@ -0,0 +1,6 @@ +import React from 'react'; + +const Link = ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => + React.createElement('a', { href, ...props }, children); + +export default Link; diff --git a/src/__mocks__/next/navigation.ts b/src/__mocks__/next/navigation.ts new file mode 100644 index 00000000..d5d1ebed --- /dev/null +++ b/src/__mocks__/next/navigation.ts @@ -0,0 +1,12 @@ +const useRouter = jest.fn(() => ({ + push: jest.fn(), + replace: jest.fn(), + prefetch: jest.fn(), + back: jest.fn(), +})); + +const usePathname = jest.fn(() => '/dashboard'); +const useSearchParams = jest.fn(() => new URLSearchParams()); +const redirect = jest.fn(); + +module.exports = { useRouter, usePathname, useSearchParams, redirect }; diff --git a/src/__tests__/dashboard-layout.test.tsx b/src/__tests__/dashboard-layout.test.tsx new file mode 100644 index 00000000..5cdd9e8e --- /dev/null +++ b/src/__tests__/dashboard-layout.test.tsx @@ -0,0 +1,135 @@ +/** + * Tests for /dashboard/layout.tsx auth guards + * Issue: all 4 combinations of token × merchant state + * - token + merchant → renders dashboard UI + * - token only → blank render (null), no redirect + * - merchant only → redirects to /auth/login (token missing) + * - neither → redirects to /auth/login + */ +import React from 'react'; +import { render, screen, act } from '@testing-library/react'; +import { useAuthStore } from '@/lib/store'; +import DashboardLayout from '@/app/dashboard/layout'; + +// ─── Mocks ────────────────────────────────────────────────────────────────── + +const mockPush = jest.fn(); + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), + usePathname: () => '/dashboard', +})); + +jest.mock('next/link', () => ({ + __esModule: true, + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); + +// We control useAuthStore by mocking the whole store module +jest.mock('@/lib/store'); + +const mockUseAuthStore = useAuthStore as jest.MockedFunction; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const MOCK_MERCHANT = { + id: 'merchant-1', + email: 'test@example.com', + businessName: 'Acme Corp', + status: 'active', +}; + +function stubAuth(overrides: { token?: string | null; merchant?: typeof MOCK_MERCHANT | null }) { + const state = { + token: overrides.token ?? null, + merchant: overrides.merchant ?? null, + logout: jest.fn(), + setAuth: jest.fn(), + }; + // useAuthStore is called with a selector fn: useAuthStore(state => state.foo) + mockUseAuthStore.mockImplementation((selector?: (s: typeof state) => unknown) => { + if (selector) return selector(state) as ReturnType; + return state as unknown as ReturnType; + }); +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); +}); + +describe('DashboardLayout — auth guards', () => { + it('[token + merchant] renders the dashboard shell and children', async () => { + stubAuth({ token: 'valid-token', merchant: MOCK_MERCHANT }); + + render( + +
Dashboard Content
+
, + ); + + // Children should be visible + expect(screen.getByTestId('child-content')).toBeInTheDocument(); + // Merchant business name should appear in sidebar + expect(screen.getByText('Acme Corp')).toBeInTheDocument(); + // Should NOT redirect + await act(async () => { jest.runAllTimers(); }); + expect(mockPush).not.toHaveBeenCalled(); + }); + + it('[token only, no merchant] returns null (blank render) — no redirect', async () => { + stubAuth({ token: 'valid-token', merchant: null }); + + const { container } = render( + +
Dashboard Content
+
, + ); + + // The layout returns null when merchant is missing + expect(container.firstChild).toBeNull(); + // Children should NOT be rendered + expect(screen.queryByTestId('child-content')).not.toBeInTheDocument(); + // Should NOT redirect (token is present) + await act(async () => { jest.runAllTimers(); }); + expect(mockPush).not.toHaveBeenCalled(); + }); + + it('[no token, merchant present] redirects to /auth/login', async () => { + stubAuth({ token: null, merchant: MOCK_MERCHANT }); + + render( + +
Dashboard Content
+
, + ); + + // The redirect is triggered in a useEffect — flush it + await act(async () => { jest.runAllTimers(); }); + + expect(mockPush).toHaveBeenCalledWith('/auth/login'); + }); + + it('[no token, no merchant] redirects to /auth/login', async () => { + stubAuth({ token: null, merchant: null }); + + render( + +
Dashboard Content
+
, + ); + + await act(async () => { jest.runAllTimers(); }); + + expect(mockPush).toHaveBeenCalledWith('/auth/login'); + }); +}); diff --git a/src/setupTests.ts b/src/setupTests.ts new file mode 100644 index 00000000..7b0828bf --- /dev/null +++ b/src/setupTests.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom';