From 5f757d7b569ec9a495f2c00cb20acaf3a1e310f8 Mon Sep 17 00:00:00 2001 From: robin Date: Thu, 18 Dec 2025 11:49:40 +0100 Subject: [PATCH] feat: implement Save & Taste flow in CameraCapture --- package.json | 6 +- playwright.config.ts | 41 ++++++++++ src/components/CameraCapture.tsx | 57 +++++++++----- src/services/__tests__/save-tasting.test.ts | 83 +++++++++++++++++++++ src/tests/setup.ts | 10 +++ tests/e2e/auth.test.ts | 15 ++++ tests/e2e/collection.test.ts | 22 ++++++ tests/e2e/landing.test.ts | 12 +++ vitest.config.ts | 17 +++++ 9 files changed, 244 insertions(+), 19 deletions(-) create mode 100644 playwright.config.ts create mode 100644 src/services/__tests__/save-tasting.test.ts create mode 100644 src/tests/setup.ts create mode 100644 tests/e2e/auth.test.ts create mode 100644 tests/e2e/collection.test.ts create mode 100644 tests/e2e/landing.test.ts create mode 100644 vitest.config.ts diff --git a/package.json b/package.json index 12b0921..207fd7b 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test:unit": "vitest run", + "test:e2e": "playwright test" }, "dependencies": { "@google/generative-ai": "^0.24.1", @@ -34,4 +36,4 @@ "tailwindcss": "^3.3.0", "typescript": "^5" } -} +} \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d28da91 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,41 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests/e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npm run dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/src/components/CameraCapture.tsx b/src/components/CameraCapture.tsx index 102e6a5..23db0ac 100644 --- a/src/components/CameraCapture.tsx +++ b/src/components/CameraCapture.tsx @@ -1,17 +1,8 @@ 'use client'; import React, { useRef, useState } from 'react'; -import { Camera, Upload, CheckCircle2, AlertCircle, Sparkles, ExternalLink } from 'lucide-react'; -import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'; -import { analyzeBottle } from '@/services/analyze-bottle'; -import { saveBottle } from '@/services/save-bottle'; -import { BottleMetadata } from '@/types/whisky'; -import { savePendingBottle } from '@/lib/offline-db'; -import { v4 as uuidv4 } from 'uuid'; -import { findMatchingBottle } from '@/services/find-matching-bottle'; -import Link from 'next/link'; - -// ... (skipping to line 192 in the actual file, index adjust needed) +import { ChevronRight } from 'lucide-react'; +import { useRouter, useSearchParams } from 'next/navigation'; interface CameraCaptureProps { onImageCaptured?: (base64Image: string) => void; @@ -21,6 +12,10 @@ interface CameraCaptureProps { export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onSaveComplete }: CameraCaptureProps) { const supabase = createClientComponentClient(); + const router = useRouter(); + const searchParams = useSearchParams(); + const sessionId = searchParams.get('session_id'); + const fileInputRef = useRef(null); const [isProcessing, setIsProcessing] = useState(false); const [isSaving, setIsSaving] = useState(false); @@ -29,6 +24,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS const [analysisResult, setAnalysisResult] = useState(null); const [isQueued, setIsQueued] = useState(false); const [matchingBottle, setMatchingBottle] = useState<{ id: string; name: string } | null>(null); + const [lastSavedId, setLastSavedId] = useState(null); const handleCapture = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; @@ -100,11 +96,9 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS const response = await saveBottle(analysisResult, previewUrl, user.id); - if (response.success) { - setPreviewUrl(null); - setAnalysisResult(null); + if (response.success && response.data) { + setLastSavedId(response.data.id); if (onSaveComplete) onSaveComplete(); - // Optionale Erfolgsmeldung oder Redirect } else { setError(response.error || 'Speichern fehlgeschlagen.'); } @@ -190,9 +184,38 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS className="hidden" /> - {matchingBottle ? ( + {lastSavedId ? ( +
+
+ + Erfolgreich gespeichert! +
+ + + + +
+ ) : matchingBottle ? ( diff --git a/src/services/__tests__/save-tasting.test.ts b/src/services/__tests__/save-tasting.test.ts new file mode 100644 index 0000000..0b410bc --- /dev/null +++ b/src/services/__tests__/save-tasting.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { saveTasting } from '../save-tasting'; +import { createServerActionClient } from '@supabase/auth-helpers-nextjs'; + +// Mock Supabase +vi.mock('@supabase/auth-helpers-nextjs', () => ({ + createServerActionClient: vi.fn(), +})); + +// Mock next/headers +vi.mock('next/headers', () => ({ + cookies: vi.fn(), +})); + +// Mock next/cache +vi.mock('next/cache', () => ({ + revalidatePath: vi.fn(), +})); + +describe('saveTasting', () => { + const mockInsert = vi.fn(); + const mockSelect = vi.fn(); + const mockSingle = vi.fn(); + const mockGetSession = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + const mockSupabase = { + auth: { + getSession: mockGetSession, + }, + from: vi.fn().mockReturnValue({ + insert: mockInsert, + select: mockSelect, + eq: vi.fn(), + }), + }; + + mockInsert.mockReturnValue({ select: mockSelect }); + mockSelect.mockReturnValue({ single: mockSingle }); + + (createServerActionClient as any).mockReturnValue(mockSupabase); + }); + + it('should save a tasting note and tags', async () => { + mockGetSession.mockResolvedValue({ + data: { session: { user: { id: 'user-123' } } }, + }); + + mockSingle.mockResolvedValue({ + data: { id: 'tasting-456' }, + error: null, + }); + + // Mock the second insert for tags + const mockTagInsert = vi.fn().mockResolvedValue({ error: null }); + (createServerActionClient({ cookies: {} as any }).from as any).mockImplementation((table: string) => { + if (table === 'tastings') { + return { insert: mockInsert, select: mockSelect }; + } + return { insert: mockTagInsert }; + }); + + const result = await saveTasting({ + bottle_id: 'bottle-789', + rating: 90, + buddy_ids: ['buddy-1', 'buddy-2'], + }); + + expect(result.success).toBe(true); + expect(mockInsert).toHaveBeenCalledWith(expect.objectContaining({ + bottle_id: 'bottle-789', + rating: 90, + })); + + // Check tags were inserted with user_id (the fix for recursion) + expect(mockTagInsert).toHaveBeenCalledWith(expect.arrayContaining([ + expect.objectContaining({ buddy_id: 'buddy-1', user_id: 'user-123' }), + expect.objectContaining({ buddy_id: 'buddy-2', user_id: 'user-123' }), + ])); + }); +}); diff --git a/src/tests/setup.ts b/src/tests/setup.ts new file mode 100644 index 0000000..2939cdc --- /dev/null +++ b/src/tests/setup.ts @@ -0,0 +1,10 @@ +import '@testing-library/jest-dom'; +import { expect, afterEach } from 'vitest'; +import { cleanup } from '@testing-library/react'; +import * as matchers from '@testing-library/jest-dom/matchers'; + +expect.extend(matchers); + +afterEach(() => { + cleanup(); +}); diff --git a/tests/e2e/auth.test.ts b/tests/e2e/auth.test.ts new file mode 100644 index 0000000..dddae89 --- /dev/null +++ b/tests/e2e/auth.test.ts @@ -0,0 +1,15 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Authentication', () => { + test('should show error on invalid login', async ({ page }) => { + await page.goto('/'); + + await page.getByPlaceholder('name@beispiel.de').fill('wrong@example.com'); + await page.getByPlaceholder('••••••••').fill('wrongpassword'); + await page.getByRole('button', { name: 'Einloggen' }).click(); + + // Expect error message + // Note: This content depends on Supabase error message + await expect(page.locator('div:has-text("Invalid login credentials")')).toBeVisible(); + }); +}); diff --git a/tests/e2e/collection.test.ts b/tests/e2e/collection.test.ts new file mode 100644 index 0000000..e86741d --- /dev/null +++ b/tests/e2e/collection.test.ts @@ -0,0 +1,22 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Collection View', () => { + test('should show empty state message if no bottles found', async ({ page }) => { + // This test assumes a clean or specific state. + // In a real environment, we'd use a test user with 0 bottles. + await page.goto('/'); + + // If we can't control the user, we at least check that we don't see the technical error message + const errorBox = page.locator('text=Hoppla'); + await expect(errorBox).not.toBeVisible(); + + // Check for either the grid or the empty state message + const emptyMessage = page.locator('text=Noch keine Flaschen im Vault'); + const bottleGrid = page.locator('.grid'); + + const isGridVisible = await bottleGrid.isVisible(); + if (!isGridVisible) { + await expect(emptyMessage).toBeVisible(); + } + }); +}); diff --git a/tests/e2e/landing.test.ts b/tests/e2e/landing.test.ts new file mode 100644 index 0000000..6b57356 --- /dev/null +++ b/tests/e2e/landing.test.ts @@ -0,0 +1,12 @@ +import { test, expect } from '@playwright/test'; + +test('has title and login form', async ({ page }) => { + await page.goto('/'); + + // Expect a title "to contain" a substring. + await expect(page.locator('h1')).toContainText('WHISKYVAULT'); + + // Expect login form to be visible + await expect(page.getByPlaceholder('name@beispiel.de')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Einloggen' })).toBeVisible(); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..be41183 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/tests/setup.ts'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +});