by default', () => {
+ const { container } = render()
+ const el = container.firstChild as HTMLElement
+ expect(el.tagName).toBe('SECTION')
+ })
+
+ it('renders as when as="div" is passed', () => {
+ const { container } = render()
+ const el = container.firstChild as HTMLElement
+ expect(el.tagName).toBe('DIV')
+ })
+
+ it('renders children', () => {
+ render()
+ expect(screen.getByText('Section child text')).toBeInTheDocument()
+ })
+
+ it('applies base padding classes', () => {
+ const { container } = render()
+ const el = container.firstChild as HTMLElement
+ expect(el.className).toMatch(/py-12/)
+ })
+
+ it('accepts and merges a custom className', () => {
+ const { container } = render()
+ const el = container.firstChild as HTMLElement
+ expect(el.className).toMatch(/custom-section/)
+ })
+})
diff --git a/src/components/ui/Section.tsx b/src/components/ui/Section.tsx
new file mode 100644
index 0000000..f198c3e
--- /dev/null
+++ b/src/components/ui/Section.tsx
@@ -0,0 +1,22 @@
+import { cn } from '@/lib/cn'
+import type { ElementType, ReactNode, ComponentPropsWithoutRef } from 'react'
+
+type SectionProps = {
+ as?: T
+ children?: ReactNode
+ className?: string
+} & Omit, 'as' | 'children' | 'className'>
+
+export function Section({
+ as,
+ children,
+ className,
+ ...rest
+}: SectionProps) {
+ const Tag = (as ?? 'section') as ElementType
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/instrumentation.ts b/src/instrumentation.ts
new file mode 100644
index 0000000..8bfd4a4
--- /dev/null
+++ b/src/instrumentation.ts
@@ -0,0 +1,8 @@
+export async function register() {
+ if (process.env['NEXT_RUNTIME'] !== 'nodejs') return
+
+ if (process.env['NODE_ENV'] === 'production') {
+ const { validateEnv } = await import('./lib/validateEnv')
+ validateEnv()
+ }
+}
diff --git a/src/lib/cn.ts b/src/lib/cn.ts
new file mode 100644
index 0000000..0aad290
--- /dev/null
+++ b/src/lib/cn.ts
@@ -0,0 +1,16 @@
+import { clsx, type ClassValue } from 'clsx'
+import { twMerge } from 'tailwind-merge'
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
+
+/**
+ * Sanitizes a CMS-supplied href value.
+ * Returns undefined if the value is falsy or contains a dangerous protocol.
+ */
+export function safeCmsHref(href?: string | null): string | undefined {
+ if (!href) return undefined
+ if (/^javascript:/i.test(href.trim())) return undefined
+ return href
+}
diff --git a/src/lib/ezy.ts b/src/lib/ezy.ts
index f96cfac..6a84e94 100644
--- a/src/lib/ezy.ts
+++ b/src/lib/ezy.ts
@@ -7,7 +7,7 @@ const EzyTariffSchema = z.object({
id: z.number(),
name: z.string(),
price: z.number(),
- params: z.record(z.unknown()).optional(),
+ params: z.record(z.string(), z.unknown()).optional(),
})
export type EzyTariff = z.infer
diff --git a/src/lib/payload.ts b/src/lib/payload.ts
new file mode 100644
index 0000000..acbae33
--- /dev/null
+++ b/src/lib/payload.ts
@@ -0,0 +1,7 @@
+import { getPayload } from 'payload'
+import configPromise from '@payload-config'
+
+export async function getGlobal(slug: string): Promise {
+ const payload = await getPayload({ config: configPromise })
+ return payload.findGlobal({ slug }) as Promise
+}
diff --git a/src/lib/syncTariffs.ts b/src/lib/syncTariffs.ts
index 80a637c..d48731b 100644
--- a/src/lib/syncTariffs.ts
+++ b/src/lib/syncTariffs.ts
@@ -3,6 +3,16 @@ import config from '@payload-config'
import { getTariffs } from '@/lib/ezy'
import { logger } from '@/lib/logger'
+function guessCategoryTag(name: string): string {
+ const n = name.toLowerCase()
+ if (/лабіринт|лабір|maze/.test(n)) return 'maze'
+ if (/диво\s*ліс|dyvolis/.test(n)) return 'dyvolis'
+ if (/динопарк|динозавр|dyno/.test(n)) return 'dyno'
+ if (/сімей|family/.test(n)) return 'family'
+ if (/комбо|combo/.test(n)) return 'combo'
+ return 'dyno'
+}
+
export type SyncResult = {
synced: number
created: number
@@ -46,7 +56,7 @@ export async function syncTariffs(): Promise {
last_synced_name: tariff.name,
last_synced_price: tariff.price,
last_synced_at: new Date().toISOString(),
- category_tag: 'dyno',
+ category_tag: guessCategoryTag(tariff.name),
visible: true,
sort: 0,
} as never,
diff --git a/src/lib/validateEnv.ts b/src/lib/validateEnv.ts
new file mode 100644
index 0000000..0638fe6
--- /dev/null
+++ b/src/lib/validateEnv.ts
@@ -0,0 +1,17 @@
+const REQUIRED_VARS = [
+ 'DATABASE_URL',
+ 'PAYLOAD_SECRET',
+ 'EZY_ACTIVITY',
+ 'BINOTEL_HMAC_SECRET',
+ 'REVALIDATE_SECRET',
+ 'RESEND_API_KEY',
+] as const
+
+export function validateEnv(): void {
+ const missing = REQUIRED_VARS.filter((key) => !process.env[key])
+ if (missing.length > 0) {
+ throw new Error(
+ `Missing required environment variables:\n${missing.map((k) => ` - ${k}`).join('\n')}\n\nCheck your .env file.`,
+ )
+ }
+}
diff --git a/src/seed.ts b/src/seed.ts
index c1021c4..3a531a0 100644
--- a/src/seed.ts
+++ b/src/seed.ts
@@ -5,6 +5,7 @@ import config from '../payload.config'
async function seed(): Promise {
const payload = await getPayload({ config })
+ // Admin user
const { totalDocs } = await payload.find({
collection: 'users',
limit: 1,
@@ -27,22 +28,88 @@ async function seed(): Promise {
console.log('Users already exist, skipping user seed.')
}
- const globalSlugs = [
- 'home-page',
- 'checkout-page',
- 'thank-you-page',
- 'header',
- 'footer',
- 'site-settings',
- ] as const
+ // Home page global with real content
+ await payload.updateGlobal({
+ slug: 'home-page',
+ data: {
+ hero: {
+ title: 'Шуміленд — світ, де казка оживає',
+ subtitle: 'Сімейний тематичний парк розваг. ДиноПарк, Диво Ліс, Дзеркальний Лабіринт — незабутні емоції для всієї родини.',
+ ctaLabel: 'Купити квиток',
+ ctaHref: '/kvytky',
+ },
+ locations: [
+ {
+ name: 'ДиноПарк',
+ shortDesc: 'Прогуляйтесь серед реалістичних динозаврів у повний зріст',
+ href: '/lokatsii',
+ },
+ {
+ name: 'Диво Ліс',
+ shortDesc: 'Чарівний ліс з інтерактивними атракціонами та мотузковими парками',
+ href: '/lokatsii',
+ },
+ {
+ name: 'Дзеркальний Лабіринт',
+ shortDesc: 'Захоплюючий лабіринт з дзеркалами та оптичними ілюзіями',
+ href: '/lokatsii',
+ },
+ ],
+ features: [
+ { icon: '🦕', title: 'Безпека', description: 'Атракціони сертифіковані, майданчик під постійним наглядом' },
+ { icon: '🌲', title: 'Природа', description: 'Парк розташований серед живої природи' },
+ { icon: '🎉', title: 'Свята', description: 'Дні народження, корпоративи та шкільні екскурсії' },
+ { icon: '🎟️', title: 'Квитки онлайн', description: 'Купуйте квитки онлайн без черги на касі' },
+ { icon: '🍕', title: 'Кафе та їжа', description: 'Власне кафе з дитячим меню та легкими закусками' },
+ { icon: '🅿️', title: 'Парковка', description: 'Безкоштовна парковка для відвідувачів' },
+ ],
+ news: { title: 'Новини', limit: 3 },
+ } as never,
+ overrideAccess: true,
+ })
+ console.log('Seeded home-page global')
- for (const slug of globalSlugs) {
+ // Header global
+ await payload.updateGlobal({
+ slug: 'header',
+ data: {
+ navLinks: [
+ { label: 'Головна', href: '/' },
+ { label: 'Локації', href: '/lokatsii' },
+ { label: 'Блог', href: '/blog' },
+ { label: 'Дні народження', href: '/dni-narodzhennia' },
+ { label: 'Групові відвідування', href: '/grupovi-vidviduvannia' },
+ ],
+ ctaLabel: 'Купити квиток',
+ ctaHref: '/kvytky',
+ } as never,
+ overrideAccess: true,
+ })
+ console.log('Seeded header global')
+
+ // Footer global
+ await payload.updateGlobal({
+ slug: 'footer',
+ data: { copyrightText: `© Шуміленд ${new Date().getFullYear()}` } as never,
+ overrideAccess: true,
+ })
+ console.log('Seeded footer global')
+
+ // Site settings
+ await payload.updateGlobal({
+ slug: 'site-settings',
+ data: {
+ siteName: 'Шуміленд',
+ siteURL: process.env['NEXT_PUBLIC_SITE_URL'] ?? 'https://shumiland.ua',
+ } as never,
+ overrideAccess: true,
+ })
+ console.log('Seeded site-settings global')
+
+ // Remaining globals — initialize empty
+ for (const slug of ['checkout-page', 'thank-you-page'] as const) {
try {
- await payload.updateGlobal({
- slug,
- data: {} as never,
- overrideAccess: true,
- })
+ await payload.updateGlobal({ slug, data: {} as never, overrideAccess: true })
console.log(`Initialized global: ${slug}`)
} catch (err) {
console.warn(`Could not initialize global ${slug}:`, err)
diff --git a/src/types/globals.ts b/src/types/globals.ts
new file mode 100644
index 0000000..02c6622
--- /dev/null
+++ b/src/types/globals.ts
@@ -0,0 +1,96 @@
+export interface Media {
+ id?: string
+ url?: string | null
+ filename?: string | null
+ alt?: string
+ width?: number | null
+ height?: number | null
+ mimeType?: string | null
+}
+
+export interface NavLink {
+ label?: string | null
+ href?: string | null
+ openInNewTab?: boolean | null
+}
+
+export interface HeaderGlobal {
+ id?: string
+ logo?: Media | string | null
+ logoAlt?: string | null
+ navLinks?: NavLink[] | null
+ updatedAt?: string
+ createdAt?: string
+}
+
+export interface FooterContacts {
+ phone?: string | null
+ email?: string | null
+ address?: string | null
+}
+
+export interface FooterSocial {
+ platform?: 'instagram' | 'facebook' | 'youtube' | 'tiktok' | null
+ url?: string | null
+}
+
+export interface FooterNavLink {
+ label?: string | null
+ href?: string | null
+}
+
+export interface FooterGlobal {
+ id?: string
+ logo?: Media | string | null
+ logoAlt?: string | null
+ navLinks?: FooterNavLink[] | null
+ contacts?: FooterContacts | null
+ socials?: FooterSocial[] | null
+ copyrightText?: string | null
+ updatedAt?: string
+ createdAt?: string
+}
+
+export interface HomePageHero {
+ title?: string | null
+ subtitle?: string | null
+ ctaLabel?: string | null
+ ctaHref?: string | null
+ backgroundVideo?: string | null
+ backgroundImage?: Media | string | null
+}
+
+export interface HomePageLocation {
+ name?: string | null
+ shortDesc?: string | null
+ image?: Media | string | null
+ href?: string | null
+}
+
+export interface HomePageFeature {
+ icon?: string | null
+ title?: string | null
+ description?: string | null
+}
+
+export interface HomePageNews {
+ title?: string | null
+ limit?: number | null
+}
+
+export interface HomePageNewsletter {
+ title?: string | null
+ subtitle?: string | null
+ ctaLabel?: string | null
+}
+
+export interface HomePageGlobal {
+ id?: string
+ hero?: HomePageHero | null
+ locations?: HomePageLocation[] | null
+ features?: HomePageFeature[] | null
+ news?: HomePageNews | null
+ newsletter?: HomePageNewsletter | null
+ updatedAt?: string
+ createdAt?: string
+}
diff --git a/tests/api/binotel-webhook.test.ts b/tests/api/binotel-webhook.test.ts
index e986950..29b9d92 100644
--- a/tests/api/binotel-webhook.test.ts
+++ b/tests/api/binotel-webhook.test.ts
@@ -124,4 +124,14 @@ describe('POST /api/binotel/webhook', () => {
expect(res.status).toBe(200)
expect((await res.json()).ok).toBe(true)
})
+
+ it('returns 500 when BINOTEL_HMAC_SECRET is not configured', async () => {
+ vi.stubEnv('BINOTEL_HMAC_SECRET', '')
+ vi.resetModules()
+ const mod = await import('@/app/api/binotel/webhook/route')
+ const body = JSON.stringify({ externalNumber: '0501234567' })
+ const req = makeRequest(body, makeSignature(body))
+ const res = await mod.POST(req)
+ expect(res.status).toBe(500)
+ })
})
diff --git a/tests/api/cron-tariffs.test.ts b/tests/api/cron-tariffs.test.ts
new file mode 100644
index 0000000..3b86791
--- /dev/null
+++ b/tests/api/cron-tariffs.test.ts
@@ -0,0 +1,62 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { NextRequest } from 'next/server'
+
+const mockSyncTariffs = vi.fn()
+vi.mock('@/lib/syncTariffs', () => ({ syncTariffs: mockSyncTariffs }))
+
+const CRON_SECRET = 'test-cron-secret' // matches vitest.setup.ts
+
+function makeRequest(token?: string): NextRequest {
+ return new NextRequest('http://localhost/api/cron/tariffs', {
+ method: 'GET',
+ headers: token !== undefined ? { authorization: `Bearer ${token}` } : {},
+ })
+}
+
+let GET: (req: NextRequest) => Promise
+
+beforeEach(async () => {
+ vi.resetModules()
+ mockSyncTariffs.mockReset()
+ vi.stubEnv('CRON_SECRET', CRON_SECRET)
+ const mod = await import('@/app/api/cron/tariffs/route')
+ GET = mod.GET
+})
+
+describe('GET /api/cron/tariffs', () => {
+ it('returns 401 when authorization header is missing', async () => {
+ const res = await GET(makeRequest())
+ expect(res.status).toBe(401)
+ })
+
+ it('returns 401 for a wrong token', async () => {
+ const res = await GET(makeRequest('wrong-token'))
+ expect(res.status).toBe(401)
+ })
+
+ it('returns 401 when CRON_SECRET is empty (no bypass allowed)', async () => {
+ vi.stubEnv('CRON_SECRET', '')
+ vi.resetModules()
+ const mod = await import('@/app/api/cron/tariffs/route')
+ const res = await mod.GET(makeRequest(''))
+ expect(res.status).toBe(401)
+ })
+
+ it('returns ok:true with sync counts on success', async () => {
+ mockSyncTariffs.mockResolvedValueOnce({ synced: 3, created: 1, hidden: 0 })
+ const res = await GET(makeRequest(CRON_SECRET))
+ expect(res.status).toBe(200)
+ const json = await res.json()
+ expect(json.ok).toBe(true)
+ expect(json.synced).toBe(3)
+ expect(json.created).toBe(1)
+ expect(json.hidden).toBe(0)
+ })
+
+ it('returns 500 when syncTariffs throws', async () => {
+ mockSyncTariffs.mockRejectedValueOnce(new Error('sync error'))
+ const res = await GET(makeRequest(CRON_SECRET))
+ expect(res.status).toBe(500)
+ expect((await res.json()).error).toBe('Sync failed')
+ })
+})
diff --git a/tests/api/health.test.ts b/tests/api/health.test.ts
index 1a6204f..ba3b0e8 100644
--- a/tests/api/health.test.ts
+++ b/tests/api/health.test.ts
@@ -1,12 +1,36 @@
-import { describe, it, expect } from 'vitest'
-import { GET } from '@/app/api/health/route'
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+
+const mockQuery = vi.fn()
+const mockGetPayload = vi.fn()
+
+vi.mock('payload', () => ({ getPayload: mockGetPayload }))
+vi.mock('@payload-config', () => ({ default: {} }))
+
+let GET: () => Promise
+
+beforeEach(async () => {
+ vi.resetModules()
+ mockQuery.mockReset()
+ mockGetPayload.mockReset()
+ mockGetPayload.mockResolvedValue({ db: { pool: { query: mockQuery } } })
+ mockQuery.mockResolvedValue([])
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true } as Response)
+ const mod = await import('@/app/api/health/route')
+ GET = mod.GET
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+})
describe('GET /api/health', () => {
- it('returns 200 with status:healthy', async () => {
+ it('returns 200 with status:healthy when db and ezy are up', async () => {
const res = await GET()
expect(res.status).toBe(200)
const json = await res.json()
expect(json.status).toBe('healthy')
+ expect(json.db).toBe('ok')
+ expect(json.ezy).toBe('ok')
})
it('includes a numeric timestamp', async () => {
@@ -18,4 +42,65 @@ describe('GET /api/health', () => {
expect(ts).toBeGreaterThanOrEqual(before)
expect(ts).toBeLessThanOrEqual(after)
})
+
+ it('returns 503 with status:degraded when db is down', async () => {
+ mockGetPayload.mockRejectedValueOnce(new Error('db connection failed'))
+ const res = await GET()
+ expect(res.status).toBe(503)
+ const json = await res.json()
+ expect(json.status).toBe('degraded')
+ expect(json.db).toBe('error')
+ })
+
+ it('returns 503 with status:degraded when ezy returns non-ok', async () => {
+ vi.mocked(globalThis.fetch).mockResolvedValueOnce({ ok: false } as Response)
+ const res = await GET()
+ expect(res.status).toBe(503)
+ const json = await res.json()
+ expect(json.status).toBe('degraded')
+ expect(json.ezy).toBe('error')
+ })
+
+ it('returns 503 with status:degraded when ezy fetch throws', async () => {
+ vi.mocked(globalThis.fetch).mockRejectedValueOnce(new Error('network error'))
+ const res = await GET()
+ expect(res.status).toBe(503)
+ const json = await res.json()
+ expect(json.status).toBe('degraded')
+ expect(json.ezy).toBe('error')
+ })
+
+ it('returns 503 with ezy:error when EZY_ACTIVITY env var is not set', async () => {
+ vi.stubEnv('EZY_ACTIVITY', '')
+ vi.resetModules()
+ mockGetPayload.mockResolvedValue({ db: { pool: { query: mockQuery } } })
+ mockQuery.mockResolvedValue([])
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true } as Response)
+ const mod = await import('@/app/api/health/route')
+ const res = await mod.GET()
+ expect(res.status).toBe(503)
+ const json = await res.json()
+ expect(json.ezy).toBe('error')
+ expect(json.status).toBe('degraded')
+ })
+
+ it('returns 503 with db:error when pool.query itself throws', async () => {
+ mockQuery.mockRejectedValueOnce(new Error('query failed'))
+ const res = await GET()
+ expect(res.status).toBe(503)
+ const json = await res.json()
+ expect(json.db).toBe('error')
+ expect(json.status).toBe('degraded')
+ })
+
+ it('returns 503 with degraded when both db and ezy fail', async () => {
+ mockGetPayload.mockRejectedValueOnce(new Error('db down'))
+ vi.mocked(globalThis.fetch).mockRejectedValueOnce(new Error('ezy down'))
+ const res = await GET()
+ expect(res.status).toBe(503)
+ const json = await res.json()
+ expect(json.db).toBe('error')
+ expect(json.ezy).toBe('error')
+ expect(json.status).toBe('degraded')
+ })
})
diff --git a/tests/api/leads.test.ts b/tests/api/leads.test.ts
index 966e488..05fc3c3 100644
--- a/tests/api/leads.test.ts
+++ b/tests/api/leads.test.ts
@@ -115,4 +115,68 @@ describe('POST /api/leads', () => {
const res = await POST(req)
expect(res.status).toBe(201)
})
+
+ it('accepts a phone number containing valid special characters (+, spaces, dashes, parens)', async () => {
+ mockPayloadCreate.mockResolvedValueOnce({ id: 'lead-special' })
+ const res = await POST(makeRequest({ ...VALID_LEAD, phone: '+38 (050) 123-45-67' }, '7.7.7.1'))
+ expect(res.status).toBe(201)
+ })
+
+ it('returns 422 when formSource is an empty string', async () => {
+ const res = await POST(makeRequest({ ...VALID_LEAD, formSource: '' }, '7.7.7.2'))
+ expect(res.status).toBe(422)
+ })
+
+ it('returns 422 when phone contains letters', async () => {
+ const res = await POST(makeRequest({ ...VALID_LEAD, phone: '+380abc234567' }, '7.7.7.3'))
+ expect(res.status).toBe(422)
+ })
+
+ it('accepts name at exactly 100 characters (boundary)', async () => {
+ mockPayloadCreate.mockResolvedValueOnce({ id: 'lead-boundary' })
+ const res = await POST(makeRequest({ ...VALID_LEAD, name: 'A'.repeat(100) }, '7.7.7.4'))
+ expect(res.status).toBe(201)
+ })
+
+ it('returns 422 when name exceeds 100 characters', async () => {
+ const res = await POST(makeRequest({ ...VALID_LEAD, name: 'A'.repeat(101) }, '7.7.7.5'))
+ expect(res.status).toBe(422)
+ })
+
+ it('does NOT fire alerts when DB create throws (early return before allSettled)', async () => {
+ const { sendLeadAlert: telegramAlert } = await import('@/lib/telegram')
+ const { sendLeadAlert: emailAlert } = await import('@/lib/resend')
+ vi.mocked(telegramAlert).mockClear()
+ vi.mocked(emailAlert).mockClear()
+
+ mockPayloadCreate.mockRejectedValueOnce(new Error('DB error'))
+ await POST(makeRequest(VALID_LEAD, '7.7.7.6'))
+
+ expect(vi.mocked(telegramAlert)).not.toHaveBeenCalled()
+ expect(vi.mocked(emailAlert)).not.toHaveBeenCalled()
+ })
+
+ it('fires Telegram and email alerts with the correct lead data after a successful save', async () => {
+ mockPayloadCreate.mockResolvedValueOnce({ id: 'lead-alert' })
+ await POST(makeRequest({ ...VALID_LEAD, utmSource: 'google' }, '8.8.8.8'))
+
+ const { sendLeadAlert: telegramAlert } = await import('@/lib/telegram')
+ const { sendLeadAlert: emailAlert } = await import('@/lib/resend')
+
+ expect(vi.mocked(telegramAlert)).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: VALID_LEAD.name,
+ phone: VALID_LEAD.phone,
+ formSource: VALID_LEAD.formSource,
+ utmSource: 'google',
+ })
+ )
+ expect(vi.mocked(emailAlert)).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: VALID_LEAD.name,
+ phone: VALID_LEAD.phone,
+ formSource: VALID_LEAD.formSource,
+ })
+ )
+ })
})
diff --git a/tests/api/tariffs-sync.test.ts b/tests/api/tariffs-sync.test.ts
new file mode 100644
index 0000000..ede1871
--- /dev/null
+++ b/tests/api/tariffs-sync.test.ts
@@ -0,0 +1,61 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { NextRequest } from 'next/server'
+
+const mockSyncTariffs = vi.fn()
+vi.mock('@/lib/syncTariffs', () => ({ syncTariffs: mockSyncTariffs }))
+
+const SYNC_SECRET = 'test-sync-secret'
+
+function makeRequest(token?: string): NextRequest {
+ return new NextRequest('http://localhost/api/tariffs/sync', {
+ method: 'POST',
+ headers: token !== undefined ? { authorization: `Bearer ${token}` } : {},
+ })
+}
+
+let POST: (req: NextRequest) => Promise
+
+beforeEach(async () => {
+ vi.resetModules()
+ mockSyncTariffs.mockReset()
+ vi.stubEnv('SYNC_SECRET', SYNC_SECRET)
+ const mod = await import('@/app/api/tariffs/sync/route')
+ POST = mod.POST
+})
+
+describe('POST /api/tariffs/sync', () => {
+ it('returns 401 when authorization header is missing', async () => {
+ const res = await POST(makeRequest())
+ expect(res.status).toBe(401)
+ })
+
+ it('returns 401 for a wrong token', async () => {
+ const res = await POST(makeRequest('bad-token'))
+ expect(res.status).toBe(401)
+ })
+
+ it('returns 401 when SYNC_SECRET is empty (no bypass allowed)', async () => {
+ vi.stubEnv('SYNC_SECRET', '')
+ vi.resetModules()
+ const mod = await import('@/app/api/tariffs/sync/route')
+ const res = await mod.POST(makeRequest(''))
+ expect(res.status).toBe(401)
+ })
+
+ it('returns ok:true with sync counts on success', async () => {
+ mockSyncTariffs.mockResolvedValueOnce({ synced: 5, created: 0, hidden: 2 })
+ const res = await POST(makeRequest(SYNC_SECRET))
+ expect(res.status).toBe(200)
+ const json = await res.json()
+ expect(json.ok).toBe(true)
+ expect(json.synced).toBe(5)
+ expect(json.hidden).toBe(2)
+ })
+
+ it('returns 500 when syncTariffs throws', async () => {
+ mockSyncTariffs.mockRejectedValueOnce(new Error('sync error'))
+ const res = await POST(makeRequest(SYNC_SECRET))
+ expect(res.status).toBe(500)
+ expect((await res.json()).error).toBe('Sync failed')
+ })
+})
diff --git a/tests/api/tickets-checkout.test.ts b/tests/api/tickets-checkout.test.ts
index 7428d0b..0f3ab86 100644
--- a/tests/api/tickets-checkout.test.ts
+++ b/tests/api/tickets-checkout.test.ts
@@ -99,6 +99,28 @@ describe('POST /api/tickets/checkout', () => {
expect((await res.json()).url).toBe('https://pay.ezy.com/xyz')
})
+ it('accepts count of exactly 10 (max valid boundary)', async () => {
+ mockCreatePayment.mockResolvedValueOnce({ url: 'https://pay.ezy.com/max' })
+ mockPayloadCreate.mockResolvedValueOnce({})
+ const res = await POST(makeRequest({ ...VALID_BODY, items: [{ tariff: 'adult', count: '10' }] }))
+ expect(res.status).toBe(200)
+ })
+
+ it('returns 422 when items array has more than 20 entries', async () => {
+ const items = Array.from({ length: 21 }, (_, i) => ({ tariff: `t${i}`, count: '1' }))
+ const res = await POST(makeRequest({ ...VALID_BODY, items }))
+ expect(res.status).toBe(422)
+ })
+
+ it('fires sendOrderAlert even when DB order creation fails', async () => {
+ mockCreatePayment.mockResolvedValueOnce({ url: 'https://pay.ezy.com/alert' })
+ mockPayloadCreate.mockRejectedValueOnce(new Error('DB error'))
+ await POST(makeRequest(VALID_BODY))
+ // fire-and-forget — wait a tick
+ await new Promise((r) => setTimeout(r, 0))
+ expect(mockSendOrderAlert).toHaveBeenCalledOnce()
+ })
+
it('accepts items array with multiple entries', async () => {
mockCreatePayment.mockResolvedValueOnce({ url: 'https://pay.ezy.com/multi' })
mockPayloadCreate.mockResolvedValueOnce({})
diff --git a/tests/api/tickets-tariffs.test.ts b/tests/api/tickets-tariffs.test.ts
index 0d5103c..f9785fa 100644
--- a/tests/api/tickets-tariffs.test.ts
+++ b/tests/api/tickets-tariffs.test.ts
@@ -102,6 +102,84 @@ describe('GET /api/tickets/tariffs', () => {
expect(json.error).toBeDefined()
})
+ it('treats null sort as 0 for ordering purposes', async () => {
+ mockGetTariffs.mockResolvedValueOnce([
+ { id: 1, name: 'A', price: 100 },
+ { id: 2, name: 'B', price: 200 },
+ ])
+ mockPayloadFind.mockResolvedValueOnce({
+ docs: [
+ { ezy_id: 2, display_name: 'B', sort: null },
+ { ezy_id: 1, display_name: 'A', sort: 1 },
+ ],
+ })
+
+ const res = await GET()
+ const { tariffs } = await res.json()
+ expect(tariffs[0].id).toBe(2) // null → 0, sorts before 1
+ expect(tariffs[1].id).toBe(1)
+ })
+
+ it('falls back to ezy name when display_name is null', async () => {
+ mockGetTariffs.mockResolvedValueOnce([{ id: 1, name: 'Ezy Name', price: 100 }])
+ mockPayloadFind.mockResolvedValueOnce({
+ docs: [{ ezy_id: 1, display_name: null, sort: 0 }],
+ })
+
+ const res = await GET()
+ const { tariffs } = await res.json()
+ expect(tariffs[0].name).toBe('Ezy Name')
+ })
+
+ it('includes categoryTag, description, image, and icon in merged response', async () => {
+ mockGetTariffs.mockResolvedValueOnce([{ id: 1, name: 'Adult', price: 250 }])
+ mockPayloadFind.mockResolvedValueOnce({
+ docs: [
+ {
+ ezy_id: 1,
+ display_name: 'Adult UA',
+ category_tag: 'adult',
+ description: 'Full-day pass',
+ image: { url: '/img/adult.jpg' },
+ icon: '🎿',
+ sort: 0,
+ },
+ ],
+ })
+
+ const res = await GET()
+ const { tariffs } = await res.json()
+ expect(tariffs[0].categoryTag).toBe('adult')
+ expect(tariffs[0].description).toBe('Full-day pass')
+ expect(tariffs[0].image).toEqual({ url: '/img/adult.jpg' })
+ expect(tariffs[0].icon).toBe('🎿')
+ })
+
+ it('fallback response uses last_synced_price and includes stale:true', async () => {
+ mockGetTariffs.mockRejectedValueOnce(new Error('ezy down'))
+ mockPayloadFind.mockResolvedValueOnce({
+ docs: [
+ {
+ ezy_id: 3,
+ display_name: 'Weekend',
+ last_synced_name: 'Weekend pass',
+ last_synced_price: 199,
+ category_tag: 'weekend',
+ description: null,
+ image: null,
+ icon: null,
+ sort: 0,
+ },
+ ],
+ })
+
+ const res = await GET()
+ const { tariffs } = await res.json()
+ expect(tariffs[0].price).toBe(199)
+ expect(tariffs[0].stale).toBe(true)
+ expect(tariffs[0].id).toBe(3)
+ })
+
it('returns tariffs sorted in ascending sort order', async () => {
mockGetTariffs.mockResolvedValueOnce([
{ id: 2, name: 'B', price: 100 },
diff --git a/tests/e2e/check.spec.ts b/tests/e2e/check.spec.ts
new file mode 100644
index 0000000..14c8d19
--- /dev/null
+++ b/tests/e2e/check.spec.ts
@@ -0,0 +1,23 @@
+import { test, expect } from '@playwright/test'
+
+test('homepage visual check', async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 900 })
+ const errors: string[] = []
+ page.on('console', msg => { if (msg.type() === 'error') errors.push(msg.text()) })
+
+ await page.goto('http://localhost:3000/')
+ await page.waitForLoadState('networkidle')
+ await page.screenshot({ path: '/tmp/home-desktop.png', fullPage: false })
+ await page.screenshot({ path: '/tmp/home-desktop-full.png', fullPage: true })
+
+ const imgs = await page.$$eval('img', imgs => imgs.map(i => ({ src: i.src, ok: i.naturalWidth > 0 })))
+ const broken = imgs.filter(i => !i.ok)
+ const headerH = await page.$eval('header', h => h.offsetHeight).catch(() => null)
+
+ console.log('Header height:', headerH)
+ console.log('Images:', imgs.length, 'broken:', broken.length)
+ if (broken.length) console.log('BROKEN:', broken.map(i => i.src.split('/').pop()))
+ console.log('Errors:', errors.length ? errors : 'none')
+
+ expect(broken.length, 'broken images').toBe(0)
+})
diff --git a/tests/e2e/screenshots.spec.ts b/tests/e2e/screenshots.spec.ts
new file mode 100644
index 0000000..5815550
--- /dev/null
+++ b/tests/e2e/screenshots.spec.ts
@@ -0,0 +1,13 @@
+import { test } from '@playwright/test'
+
+test('capture section screenshots', async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 1080 })
+ await page.goto('http://localhost:3000', { waitUntil: 'networkidle' })
+
+ await page.screenshot({ path: '/tmp/sec-fullpage.png', fullPage: true })
+
+ const sections = await page.locator('section').all()
+ for (let i = 0; i < sections.length; i++) {
+ await sections[i]!.screenshot({ path: `/tmp/sec-${i}.png` })
+ }
+})
diff --git a/tests/e2e/sections.spec.ts b/tests/e2e/sections.spec.ts
new file mode 100644
index 0000000..8253d52
--- /dev/null
+++ b/tests/e2e/sections.spec.ts
@@ -0,0 +1,18 @@
+import { test } from '@playwright/test'
+
+test('section screenshots', async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 900 })
+ await page.goto('http://localhost:3000/')
+ await page.waitForLoadState('networkidle')
+
+ // Hero
+ const hero = page.locator('section').first()
+ await hero.screenshot({ path: '/tmp/s-hero.png' })
+
+ // Each section
+ const sections = await page.locator('section').all()
+ for (let i = 0; i < sections.length; i++) {
+ await sections[i]!.screenshot({ path: `/tmp/s-${i}.png` })
+ }
+ console.log('Total sections:', sections.length)
+})
diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts
new file mode 100644
index 0000000..faec6e7
--- /dev/null
+++ b/tests/e2e/smoke.spec.ts
@@ -0,0 +1,20 @@
+import { test, expect } from '@playwright/test'
+
+test('health endpoint returns 200', async ({ request }) => {
+ const res = await request.get('/api/health')
+ expect(res.status()).toBe(200)
+ const body = await res.json()
+ expect(body).toHaveProperty('status')
+})
+
+test('home page loads', async ({ page }) => {
+ const res = await page.goto('/')
+ expect(res?.status()).toBe(200)
+ await expect(page).toHaveTitle(/Shumiland/)
+})
+
+test('admin redirects or loads login', async ({ page }) => {
+ const res = await page.goto('/admin')
+ // Either 200 (login form) or 3xx redirect — both are acceptable
+ expect(res?.status()).toBeLessThan(400)
+})
diff --git a/tests/unit/hooks/revalidatePath.test.ts b/tests/unit/hooks/revalidatePath.test.ts
new file mode 100644
index 0000000..f2dcb6c
--- /dev/null
+++ b/tests/unit/hooks/revalidatePath.test.ts
@@ -0,0 +1,183 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+
+const mockFetch = vi.fn()
+vi.stubGlobal('fetch', mockFetch)
+
+let revalidateAfterChange: (args: {
+ doc: Record
+ collection: { slug: string }
+ req: object
+ previousDoc: object
+ context: object
+ operation: string
+}) => unknown
+let revalidateGlobalAfterChange: (args: {
+ doc: object
+ global: { slug: string }
+ req: object
+ context: object
+ previousDoc: object
+}) => unknown
+
+beforeEach(async () => {
+ vi.resetModules()
+ mockFetch.mockReset()
+ vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'http://localhost:3000')
+ vi.stubEnv('REVALIDATE_SECRET', 'test-revalidate-secret')
+ const mod = await import('@/hooks/revalidatePath')
+ revalidateAfterChange = mod.revalidateAfterChange as typeof revalidateAfterChange
+ revalidateGlobalAfterChange = mod.revalidateGlobalAfterChange as typeof revalidateGlobalAfterChange
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+})
+
+describe('revalidateAfterChange', () => {
+ it('returns the doc unchanged (fire-and-forget pattern)', () => {
+ mockFetch.mockResolvedValueOnce({ ok: true, status: 200 })
+ const doc = { id: '1', slug: 'about-us' }
+ const result = revalidateAfterChange({
+ doc,
+ collection: { slug: 'pages' },
+ req: {},
+ previousDoc: {},
+ context: {},
+ operation: 'update',
+ })
+ expect(result).toBe(doc)
+ })
+
+ it('POSTs to /api/revalidate with slug and collection when doc has slug', async () => {
+ mockFetch.mockResolvedValueOnce({ ok: true })
+ revalidateAfterChange({
+ doc: { id: '1', slug: 'about-us' },
+ collection: { slug: 'pages' },
+ req: {},
+ previousDoc: {},
+ context: {},
+ operation: 'update',
+ })
+ await new Promise((r) => setTimeout(r, 0))
+
+ expect(mockFetch).toHaveBeenCalledOnce()
+ const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]
+ expect(url).toBe('http://localhost:3000/api/revalidate')
+ expect((init.headers as Record)['Authorization']).toBe(
+ 'Bearer test-revalidate-secret'
+ )
+ const body = JSON.parse(init.body as string)
+ expect(body).toMatchObject({ slug: 'about-us', collection: 'pages' })
+ })
+
+ it('sends slug as undefined when doc has no slug field', async () => {
+ mockFetch.mockResolvedValueOnce({ ok: true })
+ revalidateAfterChange({
+ doc: { id: '2' },
+ collection: { slug: 'media' },
+ req: {},
+ previousDoc: {},
+ context: {},
+ operation: 'create',
+ })
+ await new Promise((r) => setTimeout(r, 0))
+
+ const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]
+ const body = JSON.parse(init.body as string)
+ expect(body.collection).toBe('media')
+ // doc.slug is undefined, coerced to undefined in JSON → omitted or undefined
+ expect(body.slug).toBeUndefined()
+ })
+
+ it('does not throw when fetch rejects (errors are swallowed)', () => {
+ mockFetch.mockRejectedValueOnce(new Error('network error'))
+ expect(() =>
+ revalidateAfterChange({
+ doc: { id: '1', slug: 'test' },
+ collection: { slug: 'pages' },
+ req: {},
+ previousDoc: {},
+ context: {},
+ operation: 'update',
+ })
+ ).not.toThrow()
+ })
+
+ it('does not throw when revalidate endpoint returns non-ok (errors are swallowed)', async () => {
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 401 })
+ expect(() =>
+ revalidateAfterChange({
+ doc: { id: '1', slug: 'test' },
+ collection: { slug: 'pages' },
+ req: {},
+ previousDoc: {},
+ context: {},
+ operation: 'update',
+ })
+ ).not.toThrow()
+ })
+
+ it('uses NEXT_PUBLIC_SITE_URL env for the request URL', async () => {
+ vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'https://shumiland.ua')
+ vi.resetModules()
+ const mod = await import('@/hooks/revalidatePath')
+ mockFetch.mockResolvedValueOnce({ ok: true })
+ ;(
+ mod.revalidateAfterChange as typeof revalidateAfterChange
+ )({
+ doc: { id: '1', slug: 'home' },
+ collection: { slug: 'pages' },
+ req: {},
+ previousDoc: {},
+ context: {},
+ operation: 'update',
+ })
+ await new Promise((r) => setTimeout(r, 0))
+ expect(mockFetch.mock.calls[0]?.[0]).toBe('https://shumiland.ua/api/revalidate')
+ })
+})
+
+describe('revalidateGlobalAfterChange', () => {
+ it('returns the doc unchanged', () => {
+ mockFetch.mockResolvedValueOnce({ ok: true })
+ const doc = { id: 'g1' }
+ const result = revalidateGlobalAfterChange({
+ doc,
+ global: { slug: 'header' },
+ req: {},
+ context: {},
+ previousDoc: {},
+ })
+ expect(result).toBe(doc)
+ })
+
+ it('POSTs to /api/revalidate with global slug', async () => {
+ mockFetch.mockResolvedValueOnce({ ok: true })
+ revalidateGlobalAfterChange({
+ doc: {},
+ global: { slug: 'header' },
+ req: {},
+ context: {},
+ previousDoc: {},
+ })
+ await new Promise((r) => setTimeout(r, 0))
+
+ const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]
+ expect(url).toBe('http://localhost:3000/api/revalidate')
+ const body = JSON.parse(init.body as string)
+ expect(body).toEqual({ global: 'header' })
+ })
+
+ it('does not throw when fetch rejects', () => {
+ mockFetch.mockRejectedValueOnce(new Error('timeout'))
+ expect(() =>
+ revalidateGlobalAfterChange({
+ doc: {},
+ global: { slug: 'footer' },
+ req: {},
+ context: {},
+ previousDoc: {},
+ })
+ ).not.toThrow()
+ })
+})
diff --git a/tests/unit/lib/ezy.test.ts b/tests/unit/lib/ezy.test.ts
index 88a5a06..97a711d 100644
--- a/tests/unit/lib/ezy.test.ts
+++ b/tests/unit/lib/ezy.test.ts
@@ -77,6 +77,20 @@ describe('getTariffs', () => {
// 1 initial + 2 retries = 3 calls
expect(mockFetch).toHaveBeenCalledTimes(3)
})
+
+ it('re-fetches from API after the 5-minute cache TTL has expired', async () => {
+ vi.useFakeTimers()
+ mockFetch.mockResolvedValue(okResponse(sampleTariffs))
+
+ await getTariffs() // populates cache
+ expect(mockFetch).toHaveBeenCalledTimes(1)
+
+ vi.advanceTimersByTime(5 * 60 * 1_000 + 1) // expire cache
+ await getTariffs()
+ expect(mockFetch).toHaveBeenCalledTimes(2)
+
+ vi.useRealTimers()
+ })
})
describe('createPayment', () => {
@@ -118,4 +132,27 @@ describe('createPayment', () => {
expect(body.get('email')).toBe('buyer@example.com')
expect(body.get('partner_key')).toBe('test-partner-key')
})
+
+ it('retries up to 2 times on network failure, then throws', async () => {
+ mockFetch.mockRejectedValue(new Error('network error'))
+ await expect(
+ createPayment('user@example.com', [{ tariff: 't1', count: '1' }])
+ ).rejects.toThrow('network error')
+ expect(mockFetch).toHaveBeenCalledTimes(3)
+ })
+
+ it('serializes items as JSON in the order FormData field', async () => {
+ mockFetch.mockResolvedValueOnce(okResponse({ url: 'https://pay.example.com/2' }))
+ await createPayment('buyer@example.com', [
+ { tariff: 'adult', count: '2' },
+ { tariff: 'child', count: '1' },
+ ])
+
+ const [, init] = mockFetch.mock.calls[0] as [string, RequestInit & { body: FormData }]
+ const order = JSON.parse((init.body as FormData).get('order') as string)
+ expect(order).toEqual([
+ { tariff: 'adult', count: '2' },
+ { tariff: 'child', count: '1' },
+ ])
+ })
})
diff --git a/tests/unit/lib/resend.test.ts b/tests/unit/lib/resend.test.ts
new file mode 100644
index 0000000..5ae3236
--- /dev/null
+++ b/tests/unit/lib/resend.test.ts
@@ -0,0 +1,69 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+const mockSend = vi.fn()
+vi.mock('resend', () => ({
+ Resend: vi.fn(function MockResend() {
+ return { emails: { send: mockSend } }
+ }),
+}))
+vi.mock('@react-email/components', () => ({ render: vi.fn(async () => 'email') }))
+vi.mock('@/emails/LeadAlert', () => ({ LeadAlertEmail: vi.fn(() => null) }))
+
+const LEAD = {
+ name: 'Іван Іванов',
+ phone: '+380501234567',
+ formSource: 'hero-form',
+}
+
+let sendLeadAlert: (lead: typeof LEAD & { email?: string; utmSource?: string }) => Promise
+
+beforeEach(async () => {
+ vi.resetModules()
+ mockSend.mockReset()
+ vi.stubEnv('RESEND_API_KEY', 're_test_key')
+ vi.stubEnv('MANAGER_EMAILS', 'manager@shumiland.ua')
+ const mod = await import('@/lib/resend')
+ sendLeadAlert = mod.sendLeadAlert
+})
+
+describe('sendLeadAlert', () => {
+ it('sends email to all configured manager addresses', async () => {
+ mockSend.mockResolvedValueOnce({})
+ await sendLeadAlert(LEAD)
+ expect(mockSend).toHaveBeenCalledOnce()
+ const call = mockSend.mock.calls[0]![0]!
+ expect(call.to).toContain('manager@shumiland.ua')
+ })
+
+ it('includes the lead name and formSource in the email subject', async () => {
+ mockSend.mockResolvedValueOnce({})
+ await sendLeadAlert(LEAD)
+ const { subject } = mockSend.mock.calls[0]![0]!
+ expect(subject).toContain('Іван Іванов')
+ expect(subject).toContain('hero-form')
+ })
+
+ it('skips sending when MANAGER_EMAILS is not configured', async () => {
+ vi.stubEnv('MANAGER_EMAILS', '')
+ vi.resetModules()
+ const mod = await import('@/lib/resend')
+ await mod.sendLeadAlert(LEAD)
+ expect(mockSend).not.toHaveBeenCalled()
+ })
+
+ it('does not throw when resend.emails.send rejects', async () => {
+ mockSend.mockRejectedValueOnce(new Error('API error'))
+ await expect(sendLeadAlert(LEAD)).resolves.not.toThrow()
+ })
+
+ it('sends to all managers when MANAGER_EMAILS is comma-separated', async () => {
+ vi.stubEnv('MANAGER_EMAILS', 'a@example.com,b@example.com')
+ vi.resetModules()
+ const mod = await import('@/lib/resend')
+ mockSend.mockResolvedValueOnce({})
+ await mod.sendLeadAlert(LEAD)
+ const { to } = mockSend.mock.calls[0]![0]!
+ expect(to).toContain('a@example.com')
+ expect(to).toContain('b@example.com')
+ })
+})
diff --git a/tests/unit/lib/syncTariffs.test.ts b/tests/unit/lib/syncTariffs.test.ts
new file mode 100644
index 0000000..ef5c568
--- /dev/null
+++ b/tests/unit/lib/syncTariffs.test.ts
@@ -0,0 +1,136 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+const mockGetTariffs = vi.fn()
+const mockPayloadFind = vi.fn()
+const mockPayloadUpdate = vi.fn()
+const mockPayloadCreate = vi.fn()
+const mockGetPayload = vi.fn(() => ({
+ find: mockPayloadFind,
+ update: mockPayloadUpdate,
+ create: mockPayloadCreate,
+}))
+
+vi.mock('@/lib/ezy', () => ({ getTariffs: mockGetTariffs }))
+vi.mock('payload', () => ({ getPayload: mockGetPayload }))
+vi.mock('@payload-config', () => ({ default: {} }))
+
+let syncTariffs: () => Promise<{ synced: number; created: number; hidden: number }>
+
+beforeEach(async () => {
+ vi.resetModules()
+ mockGetTariffs.mockReset()
+ mockPayloadFind.mockReset()
+ mockPayloadUpdate.mockReset()
+ mockPayloadCreate.mockReset()
+ const mod = await import('@/lib/syncTariffs')
+ syncTariffs = mod.syncTariffs
+})
+
+describe('syncTariffs', () => {
+ it('updates existing tariffs and returns correct synced count', async () => {
+ mockGetTariffs.mockResolvedValueOnce([
+ { id: 1, name: 'Adult', price: 250 },
+ { id: 2, name: 'Child', price: 150 },
+ ])
+ // per-tariff find calls + final visible-tariffs scan
+ mockPayloadFind
+ .mockResolvedValueOnce({ docs: [{ id: 'tariff-1', ezy_id: 1 }] })
+ .mockResolvedValueOnce({ docs: [{ id: 'tariff-2', ezy_id: 2 }] })
+ .mockResolvedValueOnce({ docs: [] })
+ mockPayloadUpdate.mockResolvedValue({})
+
+ const result = await syncTariffs()
+ expect(result.synced).toBe(2)
+ expect(result.created).toBe(0)
+ expect(result.hidden).toBe(0)
+ expect(mockPayloadUpdate).toHaveBeenCalledTimes(2)
+ })
+
+ it('creates new tariffs when ezy_id does not exist in DB', async () => {
+ mockGetTariffs.mockResolvedValueOnce([
+ { id: 1, name: 'Adult', price: 250 },
+ { id: 2, name: 'Child', price: 150 },
+ ])
+ mockPayloadFind
+ .mockResolvedValueOnce({ docs: [] })
+ .mockResolvedValueOnce({ docs: [] })
+ .mockResolvedValueOnce({ docs: [] })
+ mockPayloadCreate.mockResolvedValue({})
+
+ const result = await syncTariffs()
+ expect(result.created).toBe(2)
+ expect(result.synced).toBe(0)
+ expect(mockPayloadCreate).toHaveBeenCalledTimes(2)
+ })
+
+ it('hides DB tariffs that are no longer present in ezy', async () => {
+ mockGetTariffs.mockResolvedValueOnce([{ id: 1, name: 'Adult', price: 250 }])
+ mockPayloadFind
+ .mockResolvedValueOnce({ docs: [{ id: 'tariff-1', ezy_id: 1 }] })
+ // visible scan: id:99 is not in ezy anymore
+ .mockResolvedValueOnce({ docs: [{ id: 'tariff-stale', ezy_id: 99 }] })
+ mockPayloadUpdate.mockResolvedValue({})
+
+ const result = await syncTariffs()
+ expect(result.hidden).toBe(1)
+ expect(mockPayloadUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'tariff-stale',
+ data: { visible: false },
+ })
+ )
+ })
+
+ it('returns zero counts when ezy returns an empty array', async () => {
+ mockGetTariffs.mockResolvedValueOnce([])
+ mockPayloadFind.mockResolvedValueOnce({ docs: [] })
+
+ const result = await syncTariffs()
+ expect(result).toEqual({ synced: 0, created: 0, hidden: 0 })
+ })
+
+ it('propagates error when getTariffs throws', async () => {
+ mockGetTariffs.mockRejectedValueOnce(new Error('ezy unavailable'))
+ await expect(syncTariffs()).rejects.toThrow('ezy unavailable')
+ })
+
+ it('writes last_synced_name and last_synced_price when updating an existing tariff', async () => {
+ mockGetTariffs.mockResolvedValueOnce([{ id: 1, name: 'Updated Adult', price: 300 }])
+ mockPayloadFind
+ .mockResolvedValueOnce({ docs: [{ id: 'tariff-1', ezy_id: 1 }] })
+ .mockResolvedValueOnce({ docs: [] })
+ mockPayloadUpdate.mockResolvedValue({})
+
+ await syncTariffs()
+ expect(mockPayloadUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ collection: 'tariffs',
+ id: 'tariff-1',
+ data: expect.objectContaining({
+ last_synced_name: 'Updated Adult',
+ last_synced_price: 300,
+ }),
+ })
+ )
+ })
+
+ it('creates new tariff with category_tag dyno and visible:true', async () => {
+ mockGetTariffs.mockResolvedValueOnce([{ id: 5, name: 'VIP', price: 500 }])
+ mockPayloadFind
+ .mockResolvedValueOnce({ docs: [] })
+ .mockResolvedValueOnce({ docs: [] })
+ mockPayloadCreate.mockResolvedValue({})
+
+ await syncTariffs()
+ expect(mockPayloadCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ collection: 'tariffs',
+ data: expect.objectContaining({
+ ezy_id: 5,
+ category_tag: 'dyno',
+ visible: true,
+ }),
+ })
+ )
+ })
+})
diff --git a/tsconfig.json b/tsconfig.json
index e0f25e8..ed8b51b 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
- "lib": ["dom", "dom.iterable", "esnext"],
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -12,7 +16,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -20,10 +24,26 @@
}
],
"paths": {
- "@/*": ["./src/*"],
- "@payload-config": ["./payload.config.ts"]
- }
+ "@/*": [
+ "./src/*"
+ ],
+ "@payload-config": [
+ "./payload.config.ts"
+ ]
+ },
+ "types": [
+ "vitest/globals",
+ "@figma/code-connect/figma-types"
+ ]
},
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["node_modules"]
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
}
diff --git a/vercel.json b/vercel.json
new file mode 100644
index 0000000..2b9027c
--- /dev/null
+++ b/vercel.json
@@ -0,0 +1,8 @@
+{
+ "crons": [
+ {
+ "path": "/api/cron/tariffs",
+ "schedule": "0 * * * *"
+ }
+ ]
+}
diff --git a/vitest.config.ts b/vitest.config.ts
index 4c49005..4a2a709 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -5,16 +5,19 @@ import path from 'path'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig({
+ esbuild: {
+ jsx: 'automatic',
+ },
test: {
pool: 'forks',
globals: true,
environment: 'node',
- include: ['tests/**/*.test.ts'],
- setupFiles: ['./vitest.setup.ts'],
+ include: ['tests/**/*.test.ts', 'src/components/**/*.test.tsx', 'tests/components/**/*.test.tsx'],
+ setupFiles: ['./vitest.setup.ts', './vitest.setup.dom.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
- include: ['src/lib/**', 'src/access/**', 'src/app/api/**'],
+ include: ['src/lib/**', 'src/access/**', 'src/app/api/**', 'src/components/**'],
exclude: ['node_modules/**', 'src/payload-types.ts', '.next/**'],
thresholds: {
lines: 70,
diff --git a/vitest.setup.dom.ts b/vitest.setup.dom.ts
new file mode 100644
index 0000000..a9d0dd3
--- /dev/null
+++ b/vitest.setup.dom.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom/vitest'