feat: implement Save & Taste flow in CameraCapture
This commit is contained in:
@@ -6,7 +6,9 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"test:unit": "vitest run",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/generative-ai": "^0.24.1",
|
"@google/generative-ai": "^0.24.1",
|
||||||
@@ -34,4 +36,4 @@
|
|||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^3.3.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
41
playwright.config.ts
Normal file
41
playwright.config.ts
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,17 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { Camera, Upload, CheckCircle2, AlertCircle, Sparkles, ExternalLink } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
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)
|
|
||||||
|
|
||||||
interface CameraCaptureProps {
|
interface CameraCaptureProps {
|
||||||
onImageCaptured?: (base64Image: string) => void;
|
onImageCaptured?: (base64Image: string) => void;
|
||||||
@@ -21,6 +12,10 @@ interface CameraCaptureProps {
|
|||||||
|
|
||||||
export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onSaveComplete }: CameraCaptureProps) {
|
export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onSaveComplete }: CameraCaptureProps) {
|
||||||
const supabase = createClientComponentClient();
|
const supabase = createClientComponentClient();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const sessionId = searchParams.get('session_id');
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@@ -29,6 +24,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
const [analysisResult, setAnalysisResult] = useState<BottleMetadata | null>(null);
|
const [analysisResult, setAnalysisResult] = useState<BottleMetadata | null>(null);
|
||||||
const [isQueued, setIsQueued] = useState(false);
|
const [isQueued, setIsQueued] = useState(false);
|
||||||
const [matchingBottle, setMatchingBottle] = useState<{ id: string; name: string } | null>(null);
|
const [matchingBottle, setMatchingBottle] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
const [lastSavedId, setLastSavedId] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleCapture = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleCapture = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
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);
|
const response = await saveBottle(analysisResult, previewUrl, user.id);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success && response.data) {
|
||||||
setPreviewUrl(null);
|
setLastSavedId(response.data.id);
|
||||||
setAnalysisResult(null);
|
|
||||||
if (onSaveComplete) onSaveComplete();
|
if (onSaveComplete) onSaveComplete();
|
||||||
// Optionale Erfolgsmeldung oder Redirect
|
|
||||||
} else {
|
} else {
|
||||||
setError(response.error || 'Speichern fehlgeschlagen.');
|
setError(response.error || 'Speichern fehlgeschlagen.');
|
||||||
}
|
}
|
||||||
@@ -190,9 +184,38 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{matchingBottle ? (
|
{lastSavedId ? (
|
||||||
|
<div className="flex flex-col gap-3 w-full animate-in zoom-in-95 duration-300">
|
||||||
|
<div className="flex items-center gap-2 text-green-600 font-bold justify-center p-2">
|
||||||
|
<CheckCircle2 size={24} className="text-green-500" />
|
||||||
|
Erfolgreich gespeichert!
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const url = `/bottles/${lastSavedId}${sessionId ? `?session_id=${sessionId}` : ''}`;
|
||||||
|
router.push(url);
|
||||||
|
}}
|
||||||
|
className="w-full py-4 px-6 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-2xl font-black uppercase tracking-widest flex items-center justify-center gap-2 hover:bg-zinc-800 dark:hover:bg-white transition-all shadow-xl"
|
||||||
|
>
|
||||||
|
Jetzt verkosten
|
||||||
|
<ChevronRight size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setAnalysisResult(null);
|
||||||
|
setLastSavedId(null);
|
||||||
|
}}
|
||||||
|
className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-200 font-bold transition-colors"
|
||||||
|
>
|
||||||
|
Später (Zurück zur Liste)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : matchingBottle ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/bottles/${matchingBottle.id}`}
|
href={`/bottles/${matchingBottle.id}${sessionId ? `?session_id=${sessionId}` : ''}`}
|
||||||
className="w-full py-4 px-6 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg shadow-amber-600/20"
|
className="w-full py-4 px-6 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg shadow-amber-600/20"
|
||||||
>
|
>
|
||||||
<ExternalLink size={20} />
|
<ExternalLink size={20} />
|
||||||
|
|||||||
83
src/services/__tests__/save-tasting.test.ts
Normal file
83
src/services/__tests__/save-tasting.test.ts
Normal file
@@ -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' }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
});
|
||||||
10
src/tests/setup.ts
Normal file
10
src/tests/setup.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
15
tests/e2e/auth.test.ts
Normal file
15
tests/e2e/auth.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
22
tests/e2e/collection.test.ts
Normal file
22
tests/e2e/collection.test.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
12
tests/e2e/landing.test.ts
Normal file
12
tests/e2e/landing.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
17
vitest.config.ts
Normal file
17
vitest.config.ts
Normal file
@@ -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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user