-
Staff Dashboard
-
View and manage customer orders
+
+ Staff{" "}
+ Dashboard
+
+
View and manage customer orders
@@ -34,7 +40,7 @@ function StaffDashboard() {
{ label: 'Pending', value: stats.pending, icon: FaClock, color: 'amber' },
{ label: 'Processing', value: stats.processing, icon: FaBox, color: 'blue' },
{ label: 'Delivered', value: stats.delivered, icon: FaCheckCircle, color: 'green' },
- ].map((card, i) => (
+ ].map((card) => (
diff --git a/src/pages/dashboard/Staff/Orders.jsx b/src/pages/dashboard/Staff/Orders.jsx
index 5b22ff0..36db417 100644
--- a/src/pages/dashboard/Staff/Orders.jsx
+++ b/src/pages/dashboard/Staff/Orders.jsx
@@ -15,7 +15,7 @@ function StaffOrders() {
setOrders(data || [])
}
- useEffect(() => { loadOrders() }, [])
+ useEffect(() => { getOrders().then(data => setOrders(data || [])) }, [])
const handleStatusUpdate = async (id, status) => {
await updateOrderStatus(id, status)
@@ -27,8 +27,11 @@ function StaffOrders() {
-
Order Management
-
View and update order statuses
+
+ Order{" "}
+ Management
+
+
View and update order statuses
diff --git a/src/routes/AppRoutes.jsx b/src/routes/AppRoutes.jsx
index b2d8dfe..40e6410 100644
--- a/src/routes/AppRoutes.jsx
+++ b/src/routes/AppRoutes.jsx
@@ -1,74 +1,89 @@
+import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
+import ErrorBoundary from "@/components/ErrorBoundary";
+import { ProtectedRoute } from "@/components/ProtectedRoute";
+import { ROUTES } from "@/constants/routes";
-import Home from "@/pages/Home/Home";
-import Login from "@/pages/Login/Login";
-import Register from "@/pages/Register/Register";
-import NotFound from "@/pages/NotFound/NotFound";
-import Cart from "@/pages/Cart/Cart";
-import Checkout from "@/pages/Checkout/Checkout";
-import Wishlist from "@/pages/Wishlist/Wishlist";
-import Profile from "@/pages/Profile/Profile";
-import Search from "@/pages/Search/Search";
+const Home = lazy(() => import("@/pages/Home/Home"));
+const Login = lazy(() => import("@/pages/Login/Login"));
+const Register = lazy(() => import("@/pages/Register/Register"));
+const NotFound = lazy(() => import("@/pages/NotFound/NotFound"));
+const Cart = lazy(() => import("@/pages/Cart/Cart"));
+const Checkout = lazy(() => import("@/pages/Checkout/Checkout"));
+const Wishlist = lazy(() => import("@/pages/Wishlist/Wishlist"));
+const Profile = lazy(() => import("@/pages/Profile/Profile"));
+const Search = lazy(() => import("@/pages/Search/Search"));
-import Movies from "@/pages/Movies/Movies";
-import MovieDetails from "@/pages/Movies/MovieDetails";
+const Movies = lazy(() => import("@/pages/Movies/Movies"));
+const MovieDetails = lazy(() => import("@/pages/Movies/MovieDetails"));
-import Books from "@/pages/Books/Books";
-import BookDetails from "@/pages/Books/BookDetails";
+const Books = lazy(() => import("@/pages/Books/Books"));
+const BookDetails = lazy(() => import("@/pages/Books/BookDetails"));
-import Manga from "@/pages/Manga/Manga";
-import MangaDetails from "@/pages/Manga/MangaDetails";
+const Manga = lazy(() => import("@/pages/Manga/Manga"));
+const MangaDetails = lazy(() => import("@/pages/Manga/MangaDetails"));
-import Comics from "@/pages/Comics/Comics";
-import ComicDetails from "@/pages/Comics/ComicDetails";
+const Comics = lazy(() => import("@/pages/Comics/Comics"));
+const ComicDetails = lazy(() => import("@/pages/Comics/ComicDetails"));
-import AdminDashboard from "@/pages/dashboard/Admin/Dashboard";
-import AdminProducts from "@/pages/dashboard/Admin/Products";
-import AdminOrders from "@/pages/dashboard/Admin/Orders";
-import AdminUsers from "@/pages/dashboard/Admin/Users";
+const AdminDashboard = lazy(() => import("@/pages/dashboard/Admin/Dashboard"));
+const AdminProducts = lazy(() => import("@/pages/dashboard/Admin/Products"));
+const AdminOrders = lazy(() => import("@/pages/dashboard/Admin/Orders"));
+const AdminUsers = lazy(() => import("@/pages/dashboard/Admin/Users"));
-import ManagerDashboard from "@/pages/dashboard/Manager/Dashboard";
-import StaffManagement from "@/pages/dashboard/Manager/StaffManagement";
+const ManagerDashboard = lazy(() => import("@/pages/dashboard/Manager/Dashboard"));
+const StaffManagement = lazy(() => import("@/pages/dashboard/Manager/StaffManagement"));
-import StaffDashboard from "@/pages/dashboard/Staff/Dashboard";
-import StaffOrders from "@/pages/dashboard/Staff/Orders";
+const StaffDashboard = lazy(() => import("@/pages/dashboard/Staff/Dashboard"));
+const StaffOrders = lazy(() => import("@/pages/dashboard/Staff/Orders"));
-import { ProtectedRoute } from "@/components/ProtectedRoute";
-import { ROUTES } from "@/constants/routes";
+function PageLoader() {
+ return (
+
+ )
+}
+
+function PageBoundary({ children }) {
+ return
{children}
+}
function AppRoutes() {
return (
-
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
- } />
- } />
- } />
- } />
-
- } />
- } />
-
- } />
- } />
-
- } />
-
+
}>
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ } />
+ } />
+ } />
+ } />
+
+ } />
+ } />
+
+ } />
+ } />
+
+ } />
+
+
);
}
diff --git a/src/services/api.js b/src/services/api.js
deleted file mode 100644
index e69de29..0000000
diff --git a/src/services/auth.js b/src/services/auth.js
index 7b76814..ac69753 100644
--- a/src/services/auth.js
+++ b/src/services/auth.js
@@ -20,6 +20,7 @@ export async function registerUser({ name, email, password }) {
const { error: profileError } = await supabase.from('profiles').insert({
id: data.user.id,
name,
+ email,
role: 'customer',
})
if (profileError) return { success: false, error: profileError.message }
@@ -52,7 +53,8 @@ async function getProfile(userId) {
}
export async function getUsers() {
- const { data: profiles } = await supabase.from('profiles').select('*').order('created_at', { ascending: false })
+ const { data: profiles, error } = await supabase.from('profiles').select('*').order('created_at', { ascending: false })
+ if (error) throw new Error(error.message)
return profiles || []
}
@@ -62,27 +64,37 @@ export async function getUserById(id) {
}
export async function updateUser(id, updates) {
- const { data } = await supabase.from('profiles').update(updates).eq('id', id).select().single()
+ const { data, error } = await supabase.from('profiles').update(updates).eq('id', id).select().single()
+ if (error) throw new Error(error.message)
return data
}
export async function banUser(id) {
- return updateUser(id, { banned: true })
+ const { error } = await supabase.from('profiles').update({ banned: true }).eq('id', id)
+ if (error) throw new Error(error.message)
+ return { success: true }
}
export async function unbanUser(id) {
- return updateUser(id, { banned: false })
+ const { error } = await supabase.from('profiles').update({ banned: false }).eq('id', id)
+ if (error) throw new Error(error.message)
+ return { success: true }
}
export async function suspendUser(id) {
- return updateUser(id, { suspended: true })
+ const { error } = await supabase.from('profiles').update({ suspended: true }).eq('id', id)
+ if (error) throw new Error(error.message)
+ return { success: true }
}
export async function unsuspendUser(id) {
- return updateUser(id, { suspended: false })
+ const { error } = await supabase.from('profiles').update({ suspended: false }).eq('id', id)
+ if (error) throw new Error(error.message)
+ return { success: true }
}
export async function removeStaff(id) {
const { error } = await supabase.from('profiles').delete().eq('id', id)
- return !error
+ if (error) throw new Error(error.message)
+ return { success: true }
}
diff --git a/src/services/comics.js b/src/services/comics.js
index 73a1220..a2e571c 100644
--- a/src/services/comics.js
+++ b/src/services/comics.js
@@ -18,15 +18,6 @@ export async function searchComics(query, limit = 20) {
return (data.docs || []).map(normalizeComicSearch)
}
-async function getBookDetails(olid) {
- try {
- const { data } = await openLibrary.get(`/works/${olid}.json`)
- return data
- } catch {
- return null
- }
-}
-
function normalizeComic(work, subject) {
const id = work.key?.replace('/works/', '') || `comic_${Date.now()}`
return {
diff --git a/src/services/payment.js b/src/services/payment.js
deleted file mode 100644
index e69de29..0000000
diff --git a/src/test/CartContext.test.jsx b/src/test/CartContext.test.jsx
new file mode 100644
index 0000000..1623261
--- /dev/null
+++ b/src/test/CartContext.test.jsx
@@ -0,0 +1,92 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { renderHook, act } from '@testing-library/react'
+import { CartProvider, useCart } from '@/context/CartContext'
+
+const wrapper = ({ children }) =>
{children}
+
+const sampleProduct = {
+ id: '1',
+ title: 'Test Movie',
+ price: 2500,
+ category: 'movie',
+ image: '/test.jpg',
+}
+
+describe('CartContext', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ })
+
+ it('starts with empty cart', () => {
+ const { result } = renderHook(() => useCart(), { wrapper })
+ expect(result.current.items).toEqual([])
+ expect(result.current.itemCount).toBe(0)
+ expect(result.current.subtotal).toBe(0)
+ })
+
+ it('adds an item to the cart', () => {
+ const { result } = renderHook(() => useCart(), { wrapper })
+ act(() => result.current.addItem(sampleProduct))
+ expect(result.current.items).toHaveLength(1)
+ expect(result.current.items[0].title).toBe('Test Movie')
+ expect(result.current.items[0].quantity).toBe(1)
+ })
+
+ it('increments quantity when adding existing item', () => {
+ const { result } = renderHook(() => useCart(), { wrapper })
+ act(() => result.current.addItem(sampleProduct))
+ act(() => result.current.addItem(sampleProduct))
+ expect(result.current.items).toHaveLength(1)
+ expect(result.current.items[0].quantity).toBe(2)
+ })
+
+ it('removes an item from the cart', () => {
+ const { result } = renderHook(() => useCart(), { wrapper })
+ act(() => result.current.addItem(sampleProduct))
+ act(() => result.current.removeItem('1'))
+ expect(result.current.items).toHaveLength(0)
+ })
+
+ it('updates item quantity', () => {
+ const { result } = renderHook(() => useCart(), { wrapper })
+ act(() => result.current.addItem(sampleProduct))
+ act(() => result.current.updateQuantity('1', 5))
+ expect(result.current.items[0].quantity).toBe(5)
+ })
+
+ it('removes item when quantity drops below 1', () => {
+ const { result } = renderHook(() => useCart(), { wrapper })
+ act(() => result.current.addItem(sampleProduct))
+ act(() => result.current.updateQuantity('1', 0))
+ expect(result.current.items).toHaveLength(0)
+ })
+
+ it('clears the cart', () => {
+ const { result } = renderHook(() => useCart(), { wrapper })
+ act(() => result.current.addItem(sampleProduct))
+ act(() => result.current.clearCart())
+ expect(result.current.items).toHaveLength(0)
+ })
+
+ it('calculates subtotal correctly', () => {
+ const { result } = renderHook(() => useCart(), { wrapper })
+ act(() => result.current.addItem(sampleProduct))
+ act(() => result.current.addItem({ ...sampleProduct, id: '2', title: 'Test Book', price: 1500 }))
+ expect(result.current.subtotal).toBe(4000)
+ })
+
+ it('persists cart to localStorage', () => {
+ const { result } = renderHook(() => useCart(), { wrapper })
+ act(() => result.current.addItem(sampleProduct))
+ const stored = JSON.parse(localStorage.getItem('cineverse_cart'))
+ expect(stored).toHaveLength(1)
+ expect(stored[0].id).toBe('1')
+ })
+
+ it('loads cart from localStorage on mount', () => {
+ localStorage.setItem('cineverse_cart', JSON.stringify([{ ...sampleProduct, quantity: 2 }]))
+ const { result } = renderHook(() => useCart(), { wrapper })
+ expect(result.current.items).toHaveLength(1)
+ expect(result.current.items[0].quantity).toBe(2)
+ })
+})
diff --git a/src/test/ProtectedRoute.test.jsx b/src/test/ProtectedRoute.test.jsx
new file mode 100644
index 0000000..e4f182c
--- /dev/null
+++ b/src/test/ProtectedRoute.test.jsx
@@ -0,0 +1,110 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+
+
+vi.mock('@/context/AuthContext', () => ({
+ useAuth: vi.fn(),
+}))
+
+import { useAuth } from '@/context/AuthContext'
+import { ProtectedRoute } from '@/components/ProtectedRoute'
+
+describe('ProtectedRoute', () => {
+ it('shows loading spinner while auth is loading', () => {
+ useAuth.mockReturnValue({
+ isAuthenticated: false,
+ loading: true,
+ role: null,
+ })
+
+ const { container } = render(
+
+
+ Protected content
} />
+ Login page} />
+
+
+ )
+
+ expect(container.querySelector('.animate-spin')).toBeInTheDocument()
+ })
+
+ it('redirects unauthenticated users to login', () => {
+ useAuth.mockReturnValue({
+ isAuthenticated: false,
+ loading: false,
+ role: null,
+ })
+
+ render(
+
+
+ Protected content
} />
+ Login page } />
+
+
+ )
+
+ expect(screen.queryByText('Protected content')).not.toBeInTheDocument()
+ })
+
+ it('renders children for authenticated users', () => {
+ useAuth.mockReturnValue({
+ isAuthenticated: true,
+ loading: false,
+ user: { id: '1', email: 'test@test.com' },
+ profile: { role: 'customer' },
+ role: 'customer',
+ })
+
+ render(
+
+
+ Protected content
} />
+ Login page } />
+
+
+ )
+
+ expect(screen.getByText('Protected content')).toBeInTheDocument()
+ })
+
+ it('blocks users without required role', () => {
+ useAuth.mockReturnValue({
+ isAuthenticated: true,
+ loading: false,
+ role: 'customer',
+ })
+
+ render(
+
+
+ Admin only
} />
+ Home } />
+
+
+ )
+
+ expect(screen.queryByText('Admin only')).not.toBeInTheDocument()
+ })
+
+ it('allows users with required role', () => {
+ useAuth.mockReturnValue({
+ isAuthenticated: true,
+ loading: false,
+ role: 'admin',
+ })
+
+ render(
+
+
+ Admin only
} />
+ Home} />
+
+
+ )
+
+ expect(screen.getByText('Admin only')).toBeInTheDocument()
+ })
+})
diff --git a/src/test/WishlistContext.test.jsx b/src/test/WishlistContext.test.jsx
new file mode 100644
index 0000000..9595de4
--- /dev/null
+++ b/src/test/WishlistContext.test.jsx
@@ -0,0 +1,74 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { renderHook, act } from '@testing-library/react'
+import { WishlistProvider, useWishlist } from '@/context/WishlistContext'
+
+const wrapper = ({ children }) =>
{children}
+
+const sampleProduct = {
+ id: '1',
+ title: 'Test Movie',
+ price: 2500,
+ category: 'movie',
+ image: '/test.jpg',
+}
+
+describe('WishlistContext', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ })
+
+ it('starts with empty wishlist', () => {
+ const { result } = renderHook(() => useWishlist(), { wrapper })
+ expect(result.current.items).toEqual([])
+ })
+
+ it('adds an item to the wishlist', () => {
+ const { result } = renderHook(() => useWishlist(), { wrapper })
+ act(() => result.current.addItem(sampleProduct))
+ expect(result.current.items).toHaveLength(1)
+ expect(result.current.items[0].title).toBe('Test Movie')
+ })
+
+ it('does not add duplicate items', () => {
+ const { result } = renderHook(() => useWishlist(), { wrapper })
+ act(() => result.current.addItem(sampleProduct))
+ act(() => result.current.addItem(sampleProduct))
+ expect(result.current.items).toHaveLength(1)
+ })
+
+ it('removes an item from the wishlist', () => {
+ const { result } = renderHook(() => useWishlist(), { wrapper })
+ act(() => result.current.addItem(sampleProduct))
+ act(() => result.current.removeItem('1'))
+ expect(result.current.items).toHaveLength(0)
+ })
+
+ it('checks if an item is in the wishlist', () => {
+ const { result } = renderHook(() => useWishlist(), { wrapper })
+ act(() => result.current.addItem(sampleProduct))
+ expect(result.current.isInWishlist('1')).toBe(true)
+ expect(result.current.isInWishlist('2')).toBe(false)
+ })
+
+ it('clears the wishlist', () => {
+ const { result } = renderHook(() => useWishlist(), { wrapper })
+ act(() => result.current.addItem(sampleProduct))
+ act(() => result.current.clearWishlist())
+ expect(result.current.items).toHaveLength(0)
+ })
+
+ it('persists wishlist to localStorage', () => {
+ const { result } = renderHook(() => useWishlist(), { wrapper })
+ act(() => result.current.addItem(sampleProduct))
+ const stored = JSON.parse(localStorage.getItem('cineverse_wishlist'))
+ expect(stored).toHaveLength(1)
+ expect(stored[0].id).toBe('1')
+ })
+
+ it('loads wishlist from localStorage on mount', () => {
+ localStorage.setItem('cineverse_wishlist', JSON.stringify([sampleProduct]))
+ const { result } = renderHook(() => useWishlist(), { wrapper })
+ expect(result.current.items).toHaveLength(1)
+ expect(result.current.items[0].id).toBe('1')
+ })
+})
diff --git a/src/test/auth.test.jsx b/src/test/auth.test.jsx
new file mode 100644
index 0000000..430bfc5
--- /dev/null
+++ b/src/test/auth.test.jsx
@@ -0,0 +1,119 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+const mockSupabase = {
+ auth: {
+ signInWithPassword: vi.fn(),
+ signUp: vi.fn(),
+ signOut: vi.fn(),
+ getSession: vi.fn(),
+ onAuthStateChange: vi.fn(() => ({ data: { subscription: { unsubscribe: vi.fn() } } })),
+ },
+ from: vi.fn(() => ({
+ select: vi.fn(() => ({
+ eq: vi.fn(() => ({
+ single: vi.fn(),
+ order: vi.fn(() => ({
+ order: vi.fn(),
+ })),
+ })),
+ order: vi.fn(),
+ })),
+ insert: vi.fn(() => ({ error: null })),
+ update: vi.fn(() => ({
+ eq: vi.fn(() => ({
+ select: vi.fn(() => ({
+ single: vi.fn(),
+ })),
+ })),
+ })),
+ delete: vi.fn(() => ({
+ eq: vi.fn(),
+ })),
+ })),
+}
+
+vi.mock('@/lib/supabase', () => ({
+ supabase: mockSupabase,
+}))
+
+const auth = await import('@/services/auth')
+
+describe('auth service', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('loginUser returns success with user on valid credentials', async () => {
+ mockSupabase.auth.signInWithPassword.mockResolvedValue({
+ data: { user: { id: '1', email: 'test@test.com' } },
+ error: null,
+ })
+ mockSupabase.from.mockImplementation(() => ({
+ select: vi.fn(() => ({
+ eq: vi.fn(() => ({
+ single: vi.fn(() => ({ data: { id: '1', name: 'Test', role: 'customer' }, error: null })),
+ })),
+ })),
+ insert: vi.fn(() => ({ error: null })),
+ update: vi.fn(() => ({
+ eq: vi.fn(() => ({
+ select: vi.fn(() => ({
+ single: vi.fn(),
+ })),
+ })),
+ })),
+ delete: vi.fn(() => ({
+ eq: vi.fn(),
+ })),
+ }))
+
+ const result = await auth.loginUser('test@test.com', 'password123')
+ expect(result.success).toBe(true)
+ expect(result.user).toBeDefined()
+ })
+
+ it('loginUser returns error on invalid credentials', async () => {
+ mockSupabase.auth.signInWithPassword.mockResolvedValue({
+ data: { user: null },
+ error: { message: 'Invalid login credentials' },
+ })
+
+ const result = await auth.loginUser('wrong@test.com', 'badpassword')
+ expect(result.success).toBe(false)
+ expect(result.error).toBe('Invalid login credentials')
+ })
+
+ it('registerUser creates account and profile', async () => {
+ mockSupabase.auth.signUp.mockResolvedValue({
+ data: { user: { id: '2', email: 'new@test.com' } },
+ error: null,
+ })
+ mockSupabase.from.mockImplementation(() => ({
+ select: vi.fn(() => ({
+ eq: vi.fn(() => ({
+ single: vi.fn(() => ({ data: { id: '2', name: 'New User', role: 'customer' }, error: null })),
+ })),
+ })),
+ insert: vi.fn(() => ({ error: null })),
+ update: vi.fn(() => ({
+ eq: vi.fn(() => ({
+ select: vi.fn(() => ({
+ single: vi.fn(),
+ })),
+ })),
+ })),
+ delete: vi.fn(() => ({
+ eq: vi.fn(),
+ })),
+ }))
+
+ const result = await auth.registerUser({ name: 'New User', email: 'new@test.com', password: 'password123' })
+ expect(result.success).toBe(true)
+ })
+
+ it('logoutUser signs out successfully', async () => {
+ mockSupabase.auth.signOut.mockResolvedValue({ error: null })
+ const result = await auth.logoutUser()
+ expect(result.success).toBe(true)
+ })
+})
diff --git a/src/test/setup.js b/src/test/setup.js
new file mode 100644
index 0000000..010b0b5
--- /dev/null
+++ b/src/test/setup.js
@@ -0,0 +1 @@
+import '@testing-library/jest-dom'
\ No newline at end of file
diff --git a/src/test/usePaystack.test.jsx b/src/test/usePaystack.test.jsx
new file mode 100644
index 0000000..9880d29
--- /dev/null
+++ b/src/test/usePaystack.test.jsx
@@ -0,0 +1,66 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { renderHook, act } from '@testing-library/react'
+
+async function getHook() {
+ const { usePaystack } = await import('@/hooks/usePaystack')
+ return usePaystack
+}
+
+describe('usePaystack', () => {
+ beforeEach(() => {
+ vi.resetModules()
+ delete window.PaystackPop
+ document.body.innerHTML = ''
+ })
+
+ it('creates a script tag when PaystackPop is not loaded', async () => {
+ const usePaystack = await getHook()
+ const { result } = renderHook(() => usePaystack())
+
+ act(() => {
+ result.current.initializePayment({
+ email: 'test@test.com',
+ amount: 2500,
+ onSuccess: vi.fn(),
+ onClose: vi.fn(),
+ })
+ })
+
+ const scripts = document.querySelectorAll('script[src="https://js.paystack.co/v1/inline.js"]')
+ expect(scripts.length).toBe(1)
+ })
+
+ it('does not duplicate script tags on multiple calls', async () => {
+ const usePaystack = await getHook()
+ const { result } = renderHook(() => usePaystack())
+
+ act(() => {
+ result.current.initializePayment({ email: 'a@a.com', amount: 1000, onSuccess: vi.fn(), onClose: vi.fn() })
+ })
+ act(() => {
+ result.current.initializePayment({ email: 'b@b.com', amount: 2000, onSuccess: vi.fn(), onClose: vi.fn() })
+ })
+
+ const scripts = document.querySelectorAll('script[src="https://js.paystack.co/v1/inline.js"]')
+ expect(scripts.length).toBe(1)
+ })
+
+ it('calls openPaystack directly if PaystackPop is already loaded', async () => {
+ window.PaystackPop = {
+ setup: vi.fn(() => ({ openIframe: vi.fn() })),
+ }
+ const usePaystack = await getHook()
+ const { result } = renderHook(() => usePaystack())
+
+ act(() => {
+ result.current.initializePayment({
+ email: 'test@test.com',
+ amount: 2500,
+ onSuccess: vi.fn(),
+ onClose: vi.fn(),
+ })
+ })
+
+ expect(window.PaystackPop.setup).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/test/utils.test.jsx b/src/test/utils.test.jsx
new file mode 100644
index 0000000..1ed2774
--- /dev/null
+++ b/src/test/utils.test.jsx
@@ -0,0 +1,71 @@
+import { describe, it, expect } from 'vitest'
+import { sanitizeUrl, sanitizeText } from '@/utils/helpers'
+import { formatCurrency } from '@/utils/formatCurrency'
+import { formatDate, formatDateTime } from '@/utils/formatDate'
+
+describe('sanitizeUrl', () => {
+ it('allows https URLs', () => {
+ expect(sanitizeUrl('https://example.com/image.jpg')).toBe('https://example.com/image.jpg')
+ })
+
+ it('allows http URLs', () => {
+ expect(sanitizeUrl('http://example.com/image.jpg')).toBe('http://example.com/image.jpg')
+ })
+
+ it('rejects javascript: URLs', () => {
+ expect(sanitizeUrl('javascript:alert(1)')).toBe('')
+ })
+
+ it('rejects empty values', () => {
+ expect(sanitizeUrl('')).toBe('')
+ expect(sanitizeUrl(null)).toBe('')
+ expect(sanitizeUrl(undefined)).toBe('')
+ })
+})
+
+describe('sanitizeText', () => {
+ it('removes HTML tags', () => {
+ expect(sanitizeText('')).toBe('alert(1)')
+ })
+
+ it('trims whitespace', () => {
+ expect(sanitizeText(' hello ')).toBe('hello')
+ })
+
+ it('handles null/undefined', () => {
+ expect(sanitizeText(null)).toBe('')
+ expect(sanitizeText(undefined)).toBe('')
+ })
+})
+
+describe('formatCurrency', () => {
+ it('formats NGN currency', () => {
+ const result = formatCurrency(1500)
+ expect(result).toContain('1,500')
+ })
+
+ it('handles zero', () => {
+ expect(formatCurrency(0)).toBeDefined()
+ })
+})
+
+describe('formatDate', () => {
+ it('formats valid date string', () => {
+ const result = formatDate('2024-01-15')
+ expect(typeof result).toBe('string')
+ expect(result.length).toBeGreaterThan(0)
+ })
+
+ it('handles invalid date', () => {
+ const result = formatDate('not-a-date')
+ expect(typeof result).toBe('string')
+ })
+})
+
+describe('formatDateTime', () => {
+ it('formats valid datetime string', () => {
+ const result = formatDateTime('2024-01-15T10:30:00Z')
+ expect(typeof result).toBe('string')
+ expect(result.length).toBeGreaterThan(0)
+ })
+})
diff --git a/src/utils/helpers.js b/src/utils/helpers.js
index e69de29..cc57ee8 100644
--- a/src/utils/helpers.js
+++ b/src/utils/helpers.js
@@ -0,0 +1,11 @@
+export function sanitizeUrl(url) {
+ if (!url) return ''
+ if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//')) return url
+ if (url.startsWith('/')) return url
+ return ''
+}
+
+export function sanitizeText(text) {
+ if (!text) return ''
+ return String(text).replace(/<[^>]*>/g, '').trim()
+}
\ No newline at end of file
diff --git a/supabase-schema.sql b/supabase-schema.sql
index 5ad3795..8d0ba8a 100644
--- a/supabase-schema.sql
+++ b/supabase-schema.sql
@@ -4,6 +4,7 @@
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
+ email TEXT,
role TEXT NOT NULL DEFAULT 'customer' CHECK (role IN ('customer','staff','manager','admin')),
avatar TEXT,
banned BOOLEAN DEFAULT false,
@@ -103,6 +104,16 @@ CREATE POLICY "Profiles are viewable by everyone"
CREATE POLICY "Users can update own profile"
ON profiles FOR UPDATE USING (auth.uid() = id);
+CREATE POLICY "Admins can update any profile"
+ ON profiles FOR UPDATE USING (
+ EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
+ );
+
+CREATE POLICY "Admins can delete profiles"
+ ON profiles FOR DELETE USING (
+ EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
+ );
+
-- Products policies
CREATE POLICY "Products are viewable by everyone"
ON products FOR SELECT USING (true);
diff --git a/vite.config.js b/vite.config.js
index 008cd30..326d489 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,3 +1,4 @@
+///
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
@@ -17,4 +18,10 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: './src/test/setup.js',
+ css: true,
+ },
})
\ No newline at end of file