diff --git a/test/unit/lib/auth.test.ts b/test/unit/lib/auth.test.ts new file mode 100644 index 0000000..dc93f9e --- /dev/null +++ b/test/unit/lib/auth.test.ts @@ -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' } }) + }) +}) diff --git a/test/unit/lib/email.test.ts b/test/unit/lib/email.test.ts new file mode 100644 index 0000000..17bcd5e --- /dev/null +++ b/test/unit/lib/email.test.ts @@ -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: '
Hello
' }) + expect(mockSendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'user@example.com', + subject: 'Test', + html: 'Hello
', + }) + ) + }) + + it('includes optional text field when provided', async () => { + await sendEmail({ to: 'u@e.com', subject: 'S', html: 'h
', 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: 'h
' })).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/) + }) +}) diff --git a/test/unit/lib/rate-limit.test.ts b/test/unit/lib/rate-limit.test.ts new file mode 100644 index 0000000..aeb7359 --- /dev/null +++ b/test/unit/lib/rate-limit.test.ts @@ -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() }) }) + ) + }) +}) diff --git a/test/unit/lib/storage.test.ts b/test/unit/lib/storage.test.ts new file mode 100644 index 0000000..b7e1606 --- /dev/null +++ b/test/unit/lib/storage.test.ts @@ -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')) + }) +}) diff --git a/test/unit/lib/validate.test.ts b/test/unit/lib/validate.test.ts new file mode 100644 index 0000000..9af9a5b --- /dev/null +++ b/test/unit/lib/validate.test.ts @@ -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) + } + }) +})