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:
@@ -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' } })
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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() }) })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user