test: add unit tests for lib/ (validate, auth, storage, email, rate-limit)

49 tests for all 10 Zod schemas in validate.ts, 26 tests for auth (hashPassword,
verifyPassword, createSession, getSession, getCurrentUser, deleteSession), 11 for
storage (magic-byte validation, saveImage, deleteImageFile), 9 for email (sendMail
scenarios), and 6 for rate limiting logic.
This commit is contained in:
2026-05-19 14:07:33 +02:00
parent b93f5d5bdf
commit 5428eeccc1
5 changed files with 857 additions and 0 deletions
+222
View File
@@ -0,0 +1,222 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import '../../../test/__mocks__/prisma'
import {
hashToken,
hashPassword,
verifyPassword,
validatePasswordStrength,
createSession,
getSessionToken,
getSession,
getCurrentUser,
deleteSession,
deleteAllUserSessions,
} from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { mockUser, mockSession } from '../../fixtures/users'
import { cookies } from 'next/headers'
// ─── hashToken ───────────────────────────────────────────────────────────────
describe('hashToken', () => {
it('returns a 64-char hex string', () => {
const hash = hashToken('sometoken')
expect(hash).toMatch(/^[a-f0-9]{64}$/)
})
it('is deterministic for the same input', () => {
expect(hashToken('abc')).toBe(hashToken('abc'))
})
it('produces different hashes for different inputs', () => {
expect(hashToken('abc')).not.toBe(hashToken('xyz'))
})
})
// ─── hashPassword / verifyPassword ───────────────────────────────────────────
describe('hashPassword / verifyPassword', () => {
it('hashes a password and verifies it correctly', async () => {
const hash = await hashPassword('MyPassword1!')
expect(await verifyPassword('MyPassword1!', hash)).toBe(true)
})
it('returns false for wrong password', async () => {
const hash = await hashPassword('MyPassword1!')
expect(await verifyPassword('WrongPassword1!', hash)).toBe(false)
})
it('produces different hashes for the same password (bcrypt salt)', async () => {
const h1 = await hashPassword('SamePass1!')
const h2 = await hashPassword('SamePass1!')
expect(h1).not.toBe(h2)
})
})
// ─── validatePasswordStrength ─────────────────────────────────────────────────
describe('validatePasswordStrength', () => {
it('returns null for a strong password', () => {
expect(validatePasswordStrength('StrongPass1!')).toBeNull()
})
it('rejects password shorter than 12 chars', () => {
expect(validatePasswordStrength('Short1!')).toMatch(/12 characters/)
})
it('rejects password without uppercase', () => {
expect(validatePasswordStrength('nouppercase1!')).toMatch(/uppercase/)
})
it('rejects password without lowercase', () => {
expect(validatePasswordStrength('NOLOWERCASE1!')).toMatch(/lowercase/)
})
it('rejects password without number', () => {
expect(validatePasswordStrength('NoNumberHere!')).toMatch(/number/)
})
it('rejects password without symbol', () => {
expect(validatePasswordStrength('NoSymbolHere12')).toMatch(/symbol/)
})
it('checks length first (shortest error message path)', () => {
expect(validatePasswordStrength('short')).toMatch(/12 characters/)
})
})
// ─── createSession ────────────────────────────────────────────────────────────
describe('createSession', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.session.create).mockResolvedValue(mockSession)
})
it('creates a session in DB and returns a token string', async () => {
const token = await createSession(mockUser.id)
expect(typeof token).toBe('string')
expect(token.length).toBeGreaterThan(0)
})
it('calls prisma.session.create with userId and tokenHash', async () => {
const token = await createSession(mockUser.id)
expect(prisma.session.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ userId: mockUser.id }),
})
)
const callArg = vi.mocked(prisma.session.create).mock.calls[0][0]
const tokenHash = hashToken(token)
expect(callArg.data.tokenHash).toBe(tokenHash)
})
it('sets expiry ~30 days from now', async () => {
await createSession(mockUser.id)
const callArg = vi.mocked(prisma.session.create).mock.calls[0][0]
const expiresAt = callArg.data.expiresAt as Date
const diffDays = (expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
expect(diffDays).toBeCloseTo(30, 0)
})
})
// ─── getSessionToken ──────────────────────────────────────────────────────────
describe('getSessionToken', () => {
it('returns token from cookie', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'mytoken' }), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
expect(await getSessionToken()).toBe('mytoken')
})
it('returns null when cookie is missing', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue(undefined), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
expect(await getSessionToken()).toBeNull()
})
})
// ─── getSession ───────────────────────────────────────────────────────────────
describe('getSession', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns null when no token cookie', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue(undefined), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
expect(await getSession()).toBeNull()
})
it('returns session when token is valid and not expired', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'validtoken' }), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
vi.mocked(prisma.session.findUnique).mockResolvedValue(mockSession as any)
const session = await getSession()
expect(session).not.toBeNull()
expect(session?.user.id).toBe(mockUser.id)
})
it('returns null and deletes session when expired', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'expiredtoken' }), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
const expiredSession = { ...mockSession, expiresAt: new Date(Date.now() - 1000) }
vi.mocked(prisma.session.findUnique).mockResolvedValue(expiredSession as any)
vi.mocked(prisma.session.delete).mockResolvedValue(expiredSession as any)
const result = await getSession()
expect(result).toBeNull()
expect(prisma.session.delete).toHaveBeenCalledWith({ where: { id: expiredSession.id } })
})
it('returns null when session not found in DB', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'unknowntoken' }), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
vi.mocked(prisma.session.findUnique).mockResolvedValue(null)
expect(await getSession()).toBeNull()
})
})
// ─── getCurrentUser ───────────────────────────────────────────────────────────
describe('getCurrentUser', () => {
it('returns user from valid session', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue({ value: 'validtoken' }), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
vi.mocked(prisma.session.findUnique).mockResolvedValue(mockSession as any)
const user = await getCurrentUser()
expect(user?.id).toBe(mockUser.id)
})
it('returns null when no session', async () => {
const mockCookieStore = { get: vi.fn().mockReturnValue(undefined), set: vi.fn(), delete: vi.fn() }
vi.mocked(cookies).mockReturnValue(mockCookieStore as any)
expect(await getCurrentUser()).toBeNull()
})
})
// ─── deleteSession / deleteAllUserSessions ────────────────────────────────────
describe('deleteSession', () => {
it('calls prisma.session.deleteMany with the correct tokenHash', async () => {
vi.mocked(prisma.session.deleteMany).mockResolvedValue({ count: 1 })
await deleteSession('mytoken')
expect(prisma.session.deleteMany).toHaveBeenCalledWith({
where: { tokenHash: hashToken('mytoken') },
})
})
})
describe('deleteAllUserSessions', () => {
it('calls prisma.session.deleteMany with the userId', async () => {
vi.mocked(prisma.session.deleteMany).mockResolvedValue({ count: 3 })
await deleteAllUserSessions('user-123')
expect(prisma.session.deleteMany).toHaveBeenCalledWith({ where: { userId: 'user-123' } })
})
})
+109
View File
@@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockSendMail, mockCreateTransport } = vi.hoisted(() => {
const mockSendMail = vi.fn().mockResolvedValue({ messageId: 'mock-id' })
const mockCreateTransport = vi.fn().mockReturnValue({ sendMail: mockSendMail })
return { mockSendMail, mockCreateTransport }
})
vi.mock('nodemailer', () => ({
default: { createTransport: mockCreateTransport },
}))
import { sendEmail, sendOrderConfirmationEmail, sendPasswordResetEmail } from '@/lib/email'
beforeEach(() => {
vi.clearAllMocks()
mockSendMail.mockResolvedValue({ messageId: 'mock-id' })
})
// ─── sendEmail ────────────────────────────────────────────────────────────────
describe('sendEmail', () => {
it('calls sendMail with correct to, subject, html', async () => {
await sendEmail({ to: 'user@example.com', subject: 'Test', html: '<p>Hello</p>' })
expect(mockSendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'user@example.com',
subject: 'Test',
html: '<p>Hello</p>',
})
)
})
it('includes optional text field when provided', async () => {
await sendEmail({ to: 'u@e.com', subject: 'S', html: '<p>h</p>', text: 'plain text' })
expect(mockSendMail).toHaveBeenCalledWith(expect.objectContaining({ text: 'plain text' }))
})
it('propagates sendMail errors', async () => {
mockSendMail.mockRejectedValueOnce(new Error('SMTP down'))
await expect(sendEmail({ to: 'u@e.com', subject: 'S', html: '<p>h</p>' })).rejects.toThrow('SMTP down')
})
})
// ─── sendOrderConfirmationEmail ───────────────────────────────────────────────
describe('sendOrderConfirmationEmail', () => {
it('sends email with correct subject containing orderId', async () => {
await sendOrderConfirmationEmail('customer@example.com', {
orderId: 'order-42',
grandTotal: 2999,
currency: 'EUR',
})
expect(mockSendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'customer@example.com',
subject: expect.stringContaining('order-42'),
})
)
})
it('formats price correctly (cents to euros)', async () => {
await sendOrderConfirmationEmail('customer@example.com', {
orderId: 'order-1',
grandTotal: 2999,
currency: 'EUR',
})
const callArg = mockSendMail.mock.calls[0][0]
expect(callArg.html).toContain('29.99')
expect(callArg.html).toContain('EUR')
})
it('includes orderId in the email body', async () => {
await sendOrderConfirmationEmail('customer@example.com', {
orderId: 'order-xyz',
grandTotal: 1000,
currency: 'USD',
})
const callArg = mockSendMail.mock.calls[0][0]
expect(callArg.html).toContain('order-xyz')
})
it('includes account orders link', async () => {
await sendOrderConfirmationEmail('customer@example.com', {
orderId: 'order-1',
grandTotal: 100,
currency: 'EUR',
})
const callArg = mockSendMail.mock.calls[0][0]
expect(callArg.html).toContain('/account/orders')
})
})
// ─── sendPasswordResetEmail ───────────────────────────────────────────────────
describe('sendPasswordResetEmail', () => {
it('sends email with reset token in URL', async () => {
await sendPasswordResetEmail('user@example.com', 'reset-token-abc')
const callArg = mockSendMail.mock.calls[0][0]
expect(callArg.html).toContain('reset-token-abc')
expect(callArg.to).toBe('user@example.com')
})
it('subject mentions password reset', async () => {
await sendPasswordResetEmail('user@example.com', 'token')
const callArg = mockSendMail.mock.calls[0][0]
expect(callArg.subject).toMatch(/[Pp]assword [Rr]eset/)
})
})
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import '../../../test/__mocks__/prisma'
import { checkRateLimit, recordAttempt } from '@/lib/rate-limit'
import { prisma } from '@/lib/prisma'
beforeEach(() => {
vi.clearAllMocks()
})
describe('checkRateLimit', () => {
it('returns not limited when attempts < 10', async () => {
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(5)
const result = await checkRateLimit('1.2.3.4')
expect(result.limited).toBe(false)
expect(result.remaining).toBe(5)
})
it('returns limited when attempts >= 10', async () => {
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(10)
const result = await checkRateLimit('1.2.3.4')
expect(result.limited).toBe(true)
expect(result.remaining).toBe(0)
})
it('returns limited when attempts > 10', async () => {
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(15)
const result = await checkRateLimit('1.2.3.4')
expect(result.limited).toBe(true)
})
it('returns remaining = 1 when attempts = 9', async () => {
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(9)
const result = await checkRateLimit('1.2.3.4')
expect(result.limited).toBe(false)
expect(result.remaining).toBe(1)
})
it('queries with a windowStart within the last 15 minutes', async () => {
vi.mocked(prisma.loginAttempt.count).mockResolvedValue(0)
const before = new Date(Date.now() - 15 * 60 * 1000 - 100)
await checkRateLimit('1.2.3.4')
const callArg = vi.mocked(prisma.loginAttempt.count).mock.calls[0][0]
const windowStart = callArg?.where?.createdAt?.gte as Date
expect(windowStart.getTime()).toBeGreaterThan(before.getTime())
})
})
describe('recordAttempt', () => {
it('creates a login attempt record', async () => {
vi.mocked(prisma.loginAttempt.create).mockResolvedValue({ id: '1', key: '1.2.3.4', createdAt: new Date() })
vi.mocked(prisma.loginAttempt.deleteMany).mockResolvedValue({ count: 0 })
await recordAttempt('1.2.3.4')
expect(prisma.loginAttempt.create).toHaveBeenCalledWith({ data: { key: '1.2.3.4' } })
})
it('cleans up old records after creating', async () => {
vi.mocked(prisma.loginAttempt.create).mockResolvedValue({ id: '1', key: '1.2.3.4', createdAt: new Date() })
vi.mocked(prisma.loginAttempt.deleteMany).mockResolvedValue({ count: 0 })
await recordAttempt('1.2.3.4')
expect(prisma.loginAttempt.deleteMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expect.objectContaining({ createdAt: expect.anything() }) })
)
})
})
+126
View File
@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockMkdir, mockWriteFile, mockUnlink } = vi.hoisted(() => ({
mockMkdir: vi.fn().mockResolvedValue(undefined),
mockWriteFile: vi.fn().mockResolvedValue(undefined),
mockUnlink: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('fs/promises', () => ({
__esModule: true,
default: { mkdir: mockMkdir, writeFile: mockWriteFile, unlink: mockUnlink },
mkdir: mockMkdir,
writeFile: mockWriteFile,
unlink: mockUnlink,
}))
import { saveImage, validateImageMagicBytes, deleteImageFile } from '@/lib/storage'
function makeJpegBuffer(): Buffer {
const b = Buffer.alloc(12)
b[0] = 0xff; b[1] = 0xd8; b[2] = 0xff
return b
}
function makePngBuffer(): Buffer {
const b = Buffer.alloc(12)
b[0] = 0x89; b[1] = 0x50; b[2] = 0x4e; b[3] = 0x47
return b
}
function makeWebpBuffer(): Buffer {
const b = Buffer.alloc(12)
b[0] = 0x52; b[1] = 0x49; b[2] = 0x46; b[3] = 0x46
b[8] = 0x57; b[9] = 0x45; b[10] = 0x42; b[11] = 0x50
return b
}
function makeIcoBuffer(): Buffer {
const b = Buffer.alloc(12)
b[0] = 0x00; b[1] = 0x00; b[2] = 0x01; b[3] = 0x00
return b
}
function makeFileFromBuffer(buf: Buffer, type: string): File {
return new File([new Uint8Array(buf)], 'test.img', { type })
}
beforeEach(() => {
vi.clearAllMocks()
mockMkdir.mockResolvedValue(undefined)
mockWriteFile.mockResolvedValue(undefined)
mockUnlink.mockResolvedValue(undefined)
})
// ─── validateImageMagicBytes ──────────────────────────────────────────────────
describe('validateImageMagicBytes', () => {
it('accepts a valid JPEG', async () => {
const file = makeFileFromBuffer(makeJpegBuffer(), 'image/jpeg')
expect(await validateImageMagicBytes(file, 'image/jpeg')).toBe(true)
})
it('accepts a valid PNG', async () => {
const file = makeFileFromBuffer(makePngBuffer(), 'image/png')
expect(await validateImageMagicBytes(file, 'image/png')).toBe(true)
})
it('accepts a valid WebP', async () => {
const file = makeFileFromBuffer(makeWebpBuffer(), 'image/webp')
expect(await validateImageMagicBytes(file, 'image/webp')).toBe(true)
})
it('accepts a valid ICO', async () => {
const file = makeFileFromBuffer(makeIcoBuffer(), 'image/x-icon')
expect(await validateImageMagicBytes(file, 'image/x-icon')).toBe(true)
})
it('rejects JPEG buffer declared as PNG', async () => {
const file = makeFileFromBuffer(makeJpegBuffer(), 'image/png')
expect(await validateImageMagicBytes(file, 'image/png')).toBe(false)
})
it('rejects unknown MIME type', async () => {
const file = makeFileFromBuffer(makeJpegBuffer(), 'image/bmp')
expect(await validateImageMagicBytes(file, 'image/bmp')).toBe(false)
})
it('rejects a random buffer as JPEG', async () => {
const file = makeFileFromBuffer(Buffer.from([0x00, 0x01, 0x02]), 'image/jpeg')
expect(await validateImageMagicBytes(file, 'image/jpeg')).toBe(false)
})
})
// ─── saveImage ────────────────────────────────────────────────────────────────
describe('saveImage', () => {
it('creates the product directory and writes file', async () => {
const buf = Buffer.from('fakeimage')
const url = await saveImage('prod-123', buf, 'photo.jpg')
expect(mockMkdir).toHaveBeenCalledWith(expect.stringContaining('prod-123'), { recursive: true })
expect(mockWriteFile).toHaveBeenCalled()
expect(url).toMatch(/^\/uploads\/prod-123\//)
expect(url).toMatch(/photo\.jpg$/)
})
it('sanitizes filename (removes special chars)', async () => {
const buf = Buffer.from('fakeimage')
const url = await saveImage('prod-123', buf, 'my photo (1).JPG')
expect(url).toMatch(/my_photo__1_\.jpg$/)
})
it('returns a URL starting with /uploads/', async () => {
const url = await saveImage('prod-abc', Buffer.from('x'), 'img.png')
expect(url).toMatch(/^\/uploads\/prod-abc\//)
})
})
// ─── deleteImageFile ──────────────────────────────────────────────────────────
describe('deleteImageFile', () => {
it('calls unlink with the correct path', async () => {
await deleteImageFile('/uploads/prod-1/image.jpg')
expect(mockUnlink).toHaveBeenCalledWith(expect.stringContaining('/uploads/prod-1/image.jpg'))
})
})
+329
View File
@@ -0,0 +1,329 @@
import { describe, it, expect } from 'vitest'
import {
loginSchema,
registerSchema,
changePasswordSchema,
productTypeSchema,
productSchema,
categorySchema,
reviewSchema,
cartItemSchema,
checkoutSchema,
settingSchema,
} from '@/lib/validate'
// ─── loginSchema ────────────────────────────────────────────────────────────
describe('loginSchema', () => {
it('accepts valid email and password', () => {
const result = loginSchema.safeParse({ email: 'user@example.com', password: 'anypass' })
expect(result.success).toBe(true)
})
it('rejects invalid email', () => {
const result = loginSchema.safeParse({ email: 'not-an-email', password: 'pass' })
expect(result.success).toBe(false)
expect(result.error?.errors[0].message).toBe('Invalid email address')
})
it('rejects empty password', () => {
const result = loginSchema.safeParse({ email: 'user@example.com', password: '' })
expect(result.success).toBe(false)
})
it('rejects missing fields', () => {
expect(loginSchema.safeParse({}).success).toBe(false)
})
})
// ─── registerSchema ──────────────────────────────────────────────────────────
describe('registerSchema', () => {
const valid = {
email: 'user@example.com',
password: 'ValidPass123!',
name: 'Mario Rossi',
}
it('accepts valid registration data', () => {
expect(registerSchema.safeParse(valid).success).toBe(true)
})
it('rejects password shorter than 12 chars', () => {
const r = registerSchema.safeParse({ ...valid, password: 'Short1!' })
expect(r.success).toBe(false)
expect(r.error?.errors[0].message).toMatch(/12 characters/)
})
it('rejects password without uppercase', () => {
const r = registerSchema.safeParse({ ...valid, password: 'nouppercase1!' })
expect(r.success).toBe(false)
expect(r.error?.errors[0].message).toMatch(/uppercase/)
})
it('rejects password without lowercase', () => {
const r = registerSchema.safeParse({ ...valid, password: 'NOLOWERCASE1!' })
expect(r.success).toBe(false)
expect(r.error?.errors[0].message).toMatch(/lowercase/)
})
it('rejects password without number', () => {
const r = registerSchema.safeParse({ ...valid, password: 'NoNumberHere!' })
expect(r.success).toBe(false)
expect(r.error?.errors[0].message).toMatch(/number/)
})
it('rejects password without symbol', () => {
const r = registerSchema.safeParse({ ...valid, password: 'NoSymbolHere1' })
expect(r.success).toBe(false)
expect(r.error?.errors[0].message).toMatch(/symbol/)
})
it('rejects empty name', () => {
const r = registerSchema.safeParse({ ...valid, name: '' })
expect(r.success).toBe(false)
})
it('rejects name longer than 100 chars', () => {
const r = registerSchema.safeParse({ ...valid, name: 'a'.repeat(101) })
expect(r.success).toBe(false)
})
})
// ─── changePasswordSchema ────────────────────────────────────────────────────
describe('changePasswordSchema', () => {
it('accepts valid passwords', () => {
const r = changePasswordSchema.safeParse({
currentPassword: 'OldPass1!',
newPassword: 'NewStrongPass1!',
})
expect(r.success).toBe(true)
})
it('rejects empty currentPassword', () => {
const r = changePasswordSchema.safeParse({
currentPassword: '',
newPassword: 'NewStrongPass1!',
})
expect(r.success).toBe(false)
})
it('rejects weak newPassword', () => {
const r = changePasswordSchema.safeParse({
currentPassword: 'OldPass1!',
newPassword: 'weak',
})
expect(r.success).toBe(false)
})
})
// ─── productTypeSchema ───────────────────────────────────────────────────────
describe('productTypeSchema', () => {
const valid = { name: 'Abbigliamento', slug: 'abbigliamento', schema: { color: 'string' } }
it('accepts valid product type', () => {
expect(productTypeSchema.safeParse(valid).success).toBe(true)
})
it('rejects slug with uppercase', () => {
const r = productTypeSchema.safeParse({ ...valid, slug: 'Abbigliamento' })
expect(r.success).toBe(false)
})
it('rejects slug with spaces', () => {
const r = productTypeSchema.safeParse({ ...valid, slug: 'con spazio' })
expect(r.success).toBe(false)
})
it('accepts slug with hyphens and numbers', () => {
const r = productTypeSchema.safeParse({ ...valid, slug: 'tipo-123' })
expect(r.success).toBe(true)
})
it('rejects empty name', () => {
expect(productTypeSchema.safeParse({ ...valid, name: '' }).success).toBe(false)
})
})
// ─── productSchema ───────────────────────────────────────────────────────────
describe('productSchema', () => {
const valid = {
typeId: 'type-1',
title: 'Maglietta',
slug: 'maglietta',
description: 'Una bella maglietta',
basePrice: 1999,
currency: 'EUR',
status: 'DRAFT' as const,
attributes: {},
}
it('accepts valid product', () => {
expect(productSchema.safeParse(valid).success).toBe(true)
})
it('rejects negative price', () => {
const r = productSchema.safeParse({ ...valid, basePrice: -1 })
expect(r.success).toBe(false)
})
it('accepts price = 0', () => {
const r = productSchema.safeParse({ ...valid, basePrice: 0 })
expect(r.success).toBe(true)
})
it('rejects currency not 3 chars', () => {
const r = productSchema.safeParse({ ...valid, currency: 'EU' })
expect(r.success).toBe(false)
})
it('rejects invalid status', () => {
const r = productSchema.safeParse({ ...valid, status: 'INVALID' })
expect(r.success).toBe(false)
})
it('accepts all valid statuses', () => {
for (const status of ['DRAFT', 'PUBLISHED', 'ARCHIVED'] as const) {
expect(productSchema.safeParse({ ...valid, status }).success).toBe(true)
}
})
it('accepts optional categoryIds', () => {
const r = productSchema.safeParse({ ...valid, categoryIds: ['cat-1', 'cat-2'] })
expect(r.success).toBe(true)
})
it('rejects non-integer price', () => {
const r = productSchema.safeParse({ ...valid, basePrice: 19.99 })
expect(r.success).toBe(false)
})
})
// ─── categorySchema ──────────────────────────────────────────────────────────
describe('categorySchema', () => {
const valid = { name: 'Uomo', slug: 'uomo' }
it('accepts valid category', () => {
expect(categorySchema.safeParse(valid).success).toBe(true)
})
it('accepts category with parentId', () => {
expect(categorySchema.safeParse({ ...valid, parentId: 'parent-1' }).success).toBe(true)
})
it('accepts parentId = null', () => {
expect(categorySchema.safeParse({ ...valid, parentId: null }).success).toBe(true)
})
it('rejects slug with special chars', () => {
const r = categorySchema.safeParse({ ...valid, slug: 'cat@home' })
expect(r.success).toBe(false)
})
})
// ─── reviewSchema ────────────────────────────────────────────────────────────
describe('reviewSchema', () => {
const valid = { productId: 'prod-1', rating: 4 }
it('accepts valid review', () => {
expect(reviewSchema.safeParse(valid).success).toBe(true)
})
it('rejects rating 0', () => {
expect(reviewSchema.safeParse({ ...valid, rating: 0 }).success).toBe(false)
})
it('rejects rating 6', () => {
expect(reviewSchema.safeParse({ ...valid, rating: 6 }).success).toBe(false)
})
it('accepts all valid ratings 1-5', () => {
for (const rating of [1, 2, 3, 4, 5]) {
expect(reviewSchema.safeParse({ ...valid, rating }).success).toBe(true)
}
})
it('rejects comment longer than 2000 chars', () => {
const r = reviewSchema.safeParse({ ...valid, comment: 'a'.repeat(2001) })
expect(r.success).toBe(false)
})
it('accepts title up to 200 chars', () => {
expect(reviewSchema.safeParse({ ...valid, title: 'a'.repeat(200) }).success).toBe(true)
})
})
// ─── cartItemSchema ──────────────────────────────────────────────────────────
describe('cartItemSchema', () => {
const valid = { productId: 'prod-1', quantity: 2 }
it('accepts valid cart item', () => {
expect(cartItemSchema.safeParse(valid).success).toBe(true)
})
it('rejects quantity 0', () => {
expect(cartItemSchema.safeParse({ ...valid, quantity: 0 }).success).toBe(false)
})
it('rejects quantity over 100', () => {
expect(cartItemSchema.safeParse({ ...valid, quantity: 101 }).success).toBe(false)
})
it('accepts quantity = 100', () => {
expect(cartItemSchema.safeParse({ ...valid, quantity: 100 }).success).toBe(true)
})
it('accepts optional variantId', () => {
expect(cartItemSchema.safeParse({ ...valid, variantId: 'var-1' }).success).toBe(true)
})
})
// ─── checkoutSchema ──────────────────────────────────────────────────────────
describe('checkoutSchema', () => {
const valid = { items: [{ productId: 'prod-1', quantity: 1 }] }
it('accepts valid checkout', () => {
expect(checkoutSchema.safeParse(valid).success).toBe(true)
})
it('rejects empty items array', () => {
const r = checkoutSchema.safeParse({ items: [] })
expect(r.success).toBe(false)
expect(r.error?.errors[0].message).toBe('Cart is empty')
})
it('accepts multiple items', () => {
const r = checkoutSchema.safeParse({
items: [
{ productId: 'prod-1', quantity: 2 },
{ productId: 'prod-2', quantity: 1 },
],
})
expect(r.success).toBe(true)
})
})
// ─── settingSchema ───────────────────────────────────────────────────────────
describe('settingSchema', () => {
it('accepts valid setting', () => {
expect(settingSchema.safeParse({ key: 'site_name', value: 'My Shop' }).success).toBe(true)
})
it('rejects empty key', () => {
expect(settingSchema.safeParse({ key: '', value: 'anything' }).success).toBe(false)
})
it('accepts any value type', () => {
for (const value of ['string', 42, true, null, { nested: true }]) {
expect(settingSchema.safeParse({ key: 'k', value }).success).toBe(true)
}
})
})