winebox 0.1.3__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. {winebox-0.1.3 → winebox-0.1.4}/PKG-INFO +4 -1
  2. winebox-0.1.4/docs/_static/screenshots/cellar.png +0 -0
  3. winebox-0.1.4/docs/_static/screenshots/checkin.png +0 -0
  4. winebox-0.1.4/docs/_static/screenshots/dashboard.png +0 -0
  5. winebox-0.1.4/docs/_static/screenshots/search.png +0 -0
  6. {winebox-0.1.3 → winebox-0.1.4}/docs/user-guide.md +58 -0
  7. {winebox-0.1.3 → winebox-0.1.4}/pyproject.toml +4 -1
  8. {winebox-0.1.3 → winebox-0.1.4}/tasks.py +137 -3
  9. winebox-0.1.4/tests/test_checkin_e2e.py +342 -0
  10. winebox-0.1.4/tests/test_wines.py +385 -0
  11. {winebox-0.1.3 → winebox-0.1.4}/uv.lock +209 -0
  12. {winebox-0.1.3 → winebox-0.1.4}/winebox/__init__.py +1 -1
  13. winebox-0.1.4/winebox/config.py +78 -0
  14. {winebox-0.1.3 → winebox-0.1.4}/winebox/main.py +48 -1
  15. {winebox-0.1.3 → winebox-0.1.4}/winebox/models/user.py +2 -0
  16. winebox-0.1.4/winebox/routers/auth.py +204 -0
  17. {winebox-0.1.3 → winebox-0.1.4}/winebox/routers/wines.py +130 -71
  18. winebox-0.1.4/winebox/services/image_storage.py +219 -0
  19. {winebox-0.1.3 → winebox-0.1.4}/winebox/services/vision.py +50 -23
  20. {winebox-0.1.3 → winebox-0.1.4}/winebox/static/css/style.css +201 -0
  21. {winebox-0.1.3 → winebox-0.1.4}/winebox/static/index.html +176 -19
  22. {winebox-0.1.3 → winebox-0.1.4}/winebox/static/js/app.js +343 -62
  23. winebox-0.1.3/tests/test_wines.py +0 -212
  24. winebox-0.1.3/winebox/config.py +0 -47
  25. winebox-0.1.3/winebox/routers/auth.py +0 -90
  26. winebox-0.1.3/winebox/services/image_storage.py +0 -90
  27. {winebox-0.1.3 → winebox-0.1.4}/.github/workflows/ci.yml +0 -0
  28. {winebox-0.1.3 → winebox-0.1.4}/.github/workflows/publish.yml +0 -0
  29. {winebox-0.1.3 → winebox-0.1.4}/.gitignore +0 -0
  30. {winebox-0.1.3 → winebox-0.1.4}/.python-version +0 -0
  31. {winebox-0.1.3 → winebox-0.1.4}/LICENSE +0 -0
  32. {winebox-0.1.3 → winebox-0.1.4}/README.md +0 -0
  33. {winebox-0.1.3 → winebox-0.1.4}/docs/api-reference.md +0 -0
  34. {winebox-0.1.3 → winebox-0.1.4}/docs/conf.py +0 -0
  35. {winebox-0.1.3 → winebox-0.1.4}/docs/index.md +0 -0
  36. {winebox-0.1.3 → winebox-0.1.4}/docs/screenshots/cellar.png +0 -0
  37. {winebox-0.1.3 → winebox-0.1.4}/docs/screenshots/checkin.png +0 -0
  38. {winebox-0.1.3 → winebox-0.1.4}/docs/screenshots/dashboard.png +0 -0
  39. {winebox-0.1.3 → winebox-0.1.4}/docs/screenshots/login.png +0 -0
  40. {winebox-0.1.3 → winebox-0.1.4}/tests/__init__.py +0 -0
  41. {winebox-0.1.3 → winebox-0.1.4}/tests/conftest.py +0 -0
  42. {winebox-0.1.3 → winebox-0.1.4}/tests/test_ocr.py +0 -0
  43. {winebox-0.1.3 → winebox-0.1.4}/tests/test_search.py +0 -0
  44. {winebox-0.1.3 → winebox-0.1.4}/tests/test_transactions.py +0 -0
  45. {winebox-0.1.3 → winebox-0.1.4}/winebox/cli/__init__.py +0 -0
  46. {winebox-0.1.3 → winebox-0.1.4}/winebox/cli/server.py +0 -0
  47. {winebox-0.1.3 → winebox-0.1.4}/winebox/cli/user_admin.py +0 -0
  48. {winebox-0.1.3 → winebox-0.1.4}/winebox/database.py +0 -0
  49. {winebox-0.1.3 → winebox-0.1.4}/winebox/models/__init__.py +0 -0
  50. {winebox-0.1.3 → winebox-0.1.4}/winebox/models/inventory.py +0 -0
  51. {winebox-0.1.3 → winebox-0.1.4}/winebox/models/transaction.py +0 -0
  52. {winebox-0.1.3 → winebox-0.1.4}/winebox/models/wine.py +0 -0
  53. {winebox-0.1.3 → winebox-0.1.4}/winebox/routers/__init__.py +0 -0
  54. {winebox-0.1.3 → winebox-0.1.4}/winebox/routers/cellar.py +0 -0
  55. {winebox-0.1.3 → winebox-0.1.4}/winebox/routers/search.py +0 -0
  56. {winebox-0.1.3 → winebox-0.1.4}/winebox/routers/transactions.py +0 -0
  57. {winebox-0.1.3 → winebox-0.1.4}/winebox/schemas/__init__.py +0 -0
  58. {winebox-0.1.3 → winebox-0.1.4}/winebox/schemas/transaction.py +0 -0
  59. {winebox-0.1.3 → winebox-0.1.4}/winebox/schemas/wine.py +0 -0
  60. {winebox-0.1.3 → winebox-0.1.4}/winebox/services/__init__.py +0 -0
  61. {winebox-0.1.3 → winebox-0.1.4}/winebox/services/auth.py +0 -0
  62. {winebox-0.1.3 → winebox-0.1.4}/winebox/services/ocr.py +0 -0
  63. {winebox-0.1.3 → winebox-0.1.4}/winebox/services/wine_parser.py +0 -0
  64. {winebox-0.1.3 → winebox-0.1.4}/winebox/static/favicon.svg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: winebox
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Wine Cellar Management Application with OCR label scanning
5
5
  Project-URL: Homepage, https://github.com/jdrumgoole/winebox
6
6
  Project-URL: Repository, https://github.com/jdrumgoole/winebox
@@ -33,6 +33,7 @@ Requires-Dist: pydantic>=2.0.0
33
33
  Requires-Dist: pytesseract>=0.3.10
34
34
  Requires-Dist: python-jose[cryptography]>=3.3.0
35
35
  Requires-Dist: python-multipart>=0.0.6
36
+ Requires-Dist: slowapi>=0.1.9
36
37
  Requires-Dist: sqlalchemy>=2.0.0
37
38
  Requires-Dist: uvicorn[standard]>=0.27.0
38
39
  Provides-Extra: dev
@@ -41,6 +42,8 @@ Requires-Dist: httpx>=0.26.0; extra == 'dev'
41
42
  Requires-Dist: invoke>=2.2.0; extra == 'dev'
42
43
  Requires-Dist: myst-parser>=2.0.0; extra == 'dev'
43
44
  Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
45
+ Requires-Dist: pytest-playwright>=0.4.0; extra == 'dev'
46
+ Requires-Dist: pytest-xdist>=3.5.0; extra == 'dev'
44
47
  Requires-Dist: pytest>=8.0.0; extra == 'dev'
45
48
  Requires-Dist: sphinx>=7.0.0; extra == 'dev'
46
49
  Description-Content-Type: text/markdown
@@ -41,6 +41,11 @@ The check-in process adds bottles to your cellar inventory.
41
41
  6. **Add notes** (optional):
42
42
  - Where you purchased it, price, occasion, etc.
43
43
  7. Click **Check In Wine**
44
+ 8. **Review in confirmation dialog**:
45
+ - A confirmation dialog appears with all editable fields
46
+ - Make any final adjustments to wine details
47
+ - View raw label text by expanding the "Raw Label Text" section
48
+ - Click **Confirm** to save or **Cancel** to return to the form
44
49
 
45
50
  ### Using the API
46
51
 
@@ -178,3 +183,56 @@ Your data is stored in:
178
183
  - **Images**: `data/images/`
179
184
 
180
185
  Back up these files regularly to preserve your cellar records.
186
+
187
+ ## Development
188
+
189
+ ### Running Tests
190
+
191
+ WineBox has both unit tests and end-to-end (E2E) browser tests.
192
+
193
+ ```bash
194
+ # Run all tests (unit + E2E)
195
+ invoke test
196
+
197
+ # Run only unit tests (fast, no server required)
198
+ invoke test-unit
199
+
200
+ # Run only E2E tests (requires running server)
201
+ invoke test-e2e
202
+
203
+ # Run E2E tests with more workers for faster execution
204
+ invoke test-e2e --workers 8
205
+ ```
206
+
207
+ **Note**: E2E tests use Playwright for browser automation and create unique test users for parallel execution. The server must be running (`invoke start-background`) before running E2E tests.
208
+
209
+ ### Invoke Tasks
210
+
211
+ Common development tasks:
212
+
213
+ ```bash
214
+ # Server management
215
+ invoke start # Start server in foreground
216
+ invoke start-background # Start server in background
217
+ invoke stop # Stop the server
218
+ invoke restart # Restart the server
219
+ invoke status # Check server status
220
+ invoke logs # View server logs
221
+
222
+ # Database management
223
+ invoke init-db # Initialize database
224
+ invoke purge --force # Delete database and images
225
+ invoke purge-wines --force # Delete wines but keep users
226
+
227
+ # User management
228
+ invoke add-user <username> --password <pass>
229
+ invoke remove-user <username> --force
230
+ invoke list-users
231
+ invoke disable-user <username>
232
+ invoke enable-user <username>
233
+ invoke passwd <username> --password <newpass>
234
+
235
+ # Documentation
236
+ invoke docs-build # Build Sphinx documentation
237
+ invoke docs-serve # Build and serve docs locally
238
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "winebox"
3
- version = "0.1.3"
3
+ version = "0.1.4"
4
4
  description = "Wine Cellar Management Application with OCR label scanning"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -37,6 +37,7 @@ dependencies = [
37
37
  "bcrypt>=4.0.0,<4.1.0",
38
38
  "python-jose[cryptography]>=3.3.0",
39
39
  "anthropic>=0.40.0",
40
+ "slowapi>=0.1.9",
40
41
  ]
41
42
 
42
43
  [project.urls]
@@ -48,6 +49,8 @@ Issues = "https://github.com/jdrumgoole/winebox/issues"
48
49
  dev = [
49
50
  "pytest>=8.0.0",
50
51
  "pytest-asyncio>=0.23.0",
52
+ "pytest-playwright>=0.4.0",
53
+ "pytest-xdist>=3.5.0",
51
54
  "httpx>=0.26.0",
52
55
  "invoke>=2.2.0",
53
56
  "sphinx>=7.0.0",
@@ -88,15 +88,47 @@ def logs(ctx: Context, follow: bool = False, lines: int = 50) -> None:
88
88
 
89
89
 
90
90
  @task
91
- def test(ctx: Context, verbose: bool = False, coverage: bool = False) -> None:
92
- """Run the test suite.
91
+ def test(ctx: Context, verbose: bool = False, coverage: bool = False, no_purge: bool = False) -> None:
92
+ """Run the full test suite (unit tests + E2E tests).
93
93
 
94
94
  Args:
95
95
  ctx: Invoke context
96
96
  verbose: Enable verbose output
97
97
  coverage: Run with coverage report
98
+ no_purge: Skip purging test data after E2E tests (default: False)
98
99
  """
99
- cmd = "uv run pytest"
100
+ # Run unit tests first (no parallel due to async issues)
101
+ print("Running unit tests...")
102
+ cmd = "uv run python -m pytest tests/ --ignore=tests/test_checkin_e2e.py"
103
+ if verbose:
104
+ cmd += " -v"
105
+ if coverage:
106
+ cmd += " --cov=winebox --cov-report=term-missing"
107
+ ctx.run(cmd, pty=True)
108
+
109
+ # Run E2E tests with parallel execution
110
+ print("\nRunning E2E tests...")
111
+ e2e_cmd = "uv run python -m pytest tests/test_checkin_e2e.py -n 4"
112
+ if verbose:
113
+ e2e_cmd += " -v"
114
+ ctx.run(e2e_cmd, pty=True)
115
+
116
+ # Purge test data after E2E tests
117
+ if not no_purge:
118
+ print("\nPurging test data...")
119
+ purge_wines(ctx, include_images=True, force=True)
120
+
121
+
122
+ @task(name="test-unit")
123
+ def test_unit(ctx: Context, verbose: bool = False, coverage: bool = False) -> None:
124
+ """Run unit tests only (faster, no server required).
125
+
126
+ Args:
127
+ ctx: Invoke context
128
+ verbose: Enable verbose output
129
+ coverage: Run with coverage report
130
+ """
131
+ cmd = "uv run python -m pytest tests/ --ignore=tests/test_checkin_e2e.py"
100
132
  if verbose:
101
133
  cmd += " -v"
102
134
  if coverage:
@@ -104,6 +136,27 @@ def test(ctx: Context, verbose: bool = False, coverage: bool = False) -> None:
104
136
  ctx.run(cmd, pty=True)
105
137
 
106
138
 
139
+ @task(name="test-e2e")
140
+ def test_e2e(ctx: Context, verbose: bool = False, workers: int = 4, no_purge: bool = False) -> None:
141
+ """Run E2E tests only (requires running server).
142
+
143
+ Args:
144
+ ctx: Invoke context
145
+ verbose: Enable verbose output
146
+ workers: Number of parallel workers (default: 4)
147
+ no_purge: Skip purging test data after tests (default: False)
148
+ """
149
+ cmd = f"uv run python -m pytest tests/test_checkin_e2e.py -n {workers}"
150
+ if verbose:
151
+ cmd += " -v"
152
+ ctx.run(cmd, pty=True)
153
+
154
+ # Purge test data after E2E tests
155
+ if not no_purge:
156
+ print("\nPurging test data...")
157
+ purge_wines(ctx, include_images=True, force=True)
158
+
159
+
107
160
  @task(name="init-db")
108
161
  def init_db(ctx: Context) -> None:
109
162
  """Initialize the database."""
@@ -300,6 +353,87 @@ asyncio.run(delete_wine_data())
300
353
  print("\nWine data purge complete. User accounts preserved.")
301
354
 
302
355
 
356
+ # User Management Tasks
357
+ @task(name="add-user")
358
+ def add_user(
359
+ ctx: Context,
360
+ username: str,
361
+ password: str,
362
+ email: str = "",
363
+ admin: bool = False,
364
+ ) -> None:
365
+ """Add a new user to the system.
366
+
367
+ Args:
368
+ ctx: Invoke context
369
+ username: Username for the new user
370
+ password: Password for the new user
371
+ email: Optional email address
372
+ admin: Make user an admin (default: False)
373
+ """
374
+ cmd = f"uv run winebox-admin add {username} --password {password}"
375
+ if email:
376
+ cmd += f" --email {email}"
377
+ if admin:
378
+ cmd += " --admin"
379
+ ctx.run(cmd)
380
+
381
+
382
+ @task(name="remove-user")
383
+ def remove_user(ctx: Context, username: str, force: bool = False) -> None:
384
+ """Remove a user from the system.
385
+
386
+ Args:
387
+ ctx: Invoke context
388
+ username: Username to remove
389
+ force: Skip confirmation prompt
390
+ """
391
+ cmd = f"uv run winebox-admin remove {username}"
392
+ if force:
393
+ cmd += " --force"
394
+ ctx.run(cmd, pty=True)
395
+
396
+
397
+ @task(name="list-users")
398
+ def list_users(ctx: Context) -> None:
399
+ """List all users in the system."""
400
+ ctx.run("uv run winebox-admin list")
401
+
402
+
403
+ @task(name="disable-user")
404
+ def disable_user(ctx: Context, username: str) -> None:
405
+ """Disable a user account.
406
+
407
+ Args:
408
+ ctx: Invoke context
409
+ username: Username to disable
410
+ """
411
+ ctx.run(f"uv run winebox-admin disable {username}")
412
+
413
+
414
+ @task(name="enable-user")
415
+ def enable_user(ctx: Context, username: str) -> None:
416
+ """Enable a user account.
417
+
418
+ Args:
419
+ ctx: Invoke context
420
+ username: Username to enable
421
+ """
422
+ ctx.run(f"uv run winebox-admin enable {username}")
423
+
424
+
425
+ @task(name="passwd")
426
+ def change_password(ctx: Context, username: str, password: str) -> None:
427
+ """Change a user's password.
428
+
429
+ Args:
430
+ ctx: Invoke context
431
+ username: Username to change password for
432
+ password: New password
433
+ """
434
+ ctx.run(f"uv run winebox-admin passwd {username} --password {password}")
435
+
436
+
303
437
  @task(name="docs-build")
304
438
  def docs_build(ctx: Context) -> None:
305
439
  """Build the Sphinx documentation."""
@@ -0,0 +1,342 @@
1
+ """End-to-end tests for wine checkin flow using Playwright.
2
+
3
+ These tests require a running WineBox server. Start the server with:
4
+ invoke start-background
5
+
6
+ Note: These tests use real wine label images and will call the configured
7
+ OCR/Vision API if WINEBOX_ANTHROPIC_API_KEY is set.
8
+
9
+ For parallel execution, run with: pytest -n auto tests/test_checkin_e2e.py
10
+ Each worker gets its own test user to avoid conflicts.
11
+ """
12
+
13
+ import os
14
+ import re
15
+ import subprocess
16
+ from pathlib import Path
17
+
18
+ import pytest
19
+ from playwright.sync_api import Page, expect
20
+
21
+ # Test data directory containing wine label images
22
+ TEST_DATA_DIR = Path(__file__).parent / "data" / "wine_labels"
23
+
24
+ # Server URL - can be overridden with WINEBOX_TEST_URL env var
25
+ BASE_URL = os.environ.get("WINEBOX_TEST_URL", "http://localhost:8000")
26
+
27
+
28
+ def get_worker_id(request: pytest.FixtureRequest) -> str:
29
+ """Get the pytest-xdist worker ID, or 'main' if not running in parallel."""
30
+ if hasattr(request.config, "workerinput"):
31
+ return request.config.workerinput["workerid"]
32
+ return "main"
33
+
34
+
35
+ @pytest.fixture(scope="session")
36
+ def base_url() -> str:
37
+ """Return the base URL for the test server."""
38
+ return BASE_URL
39
+
40
+
41
+ @pytest.fixture(scope="function")
42
+ def test_user(request: pytest.FixtureRequest) -> tuple[str, str]:
43
+ """Create a unique test user for this test function.
44
+
45
+ Returns (username, password) tuple.
46
+ Uses worker ID + test name to create unique users for parallel execution.
47
+ """
48
+ import time
49
+
50
+ worker_id = get_worker_id(request)
51
+ # Create a unique username based on worker and test name
52
+ test_name = request.node.name.replace("[", "_").replace("]", "_").replace("-", "_")
53
+ # Keep it short but unique
54
+ username = f"e2e_{worker_id}_{hash(test_name) % 10000:04d}"
55
+ password = "testpass123"
56
+
57
+ # Create the user via CLI (ignore errors if user exists)
58
+ project_dir = Path(__file__).parent.parent
59
+ try:
60
+ result = subprocess.run(
61
+ ["uv", "run", "winebox-admin", "add", username, "--password", password],
62
+ cwd=project_dir,
63
+ capture_output=True,
64
+ timeout=30,
65
+ )
66
+ # Small delay to ensure database commits the user
67
+ time.sleep(0.5)
68
+ except subprocess.TimeoutExpired:
69
+ pass
70
+
71
+ yield username, password
72
+
73
+ # Cleanup: remove the test user after the test
74
+ try:
75
+ subprocess.run(
76
+ ["uv", "run", "winebox-admin", "remove", username, "--force"],
77
+ cwd=project_dir,
78
+ capture_output=True,
79
+ timeout=30,
80
+ )
81
+ except subprocess.TimeoutExpired:
82
+ pass
83
+
84
+
85
+ @pytest.fixture(scope="function")
86
+ def authenticated_page(page: Page, test_user: tuple[str, str]) -> Page:
87
+ """Log in and return an authenticated page with a unique test user."""
88
+ username, password = test_user
89
+
90
+ page.goto(BASE_URL)
91
+
92
+ # Wait for login form
93
+ page.wait_for_selector("#login-form", state="visible")
94
+
95
+ # Fill in credentials (correct IDs from index.html)
96
+ page.fill("#login-username", username)
97
+ page.fill("#login-password", password)
98
+
99
+ # Click login
100
+ page.click("#login-form button[type='submit']")
101
+
102
+ # Wait for main content to become visible (login successful)
103
+ page.wait_for_selector("#main-content", state="visible", timeout=10000)
104
+
105
+ return page
106
+
107
+
108
+ @pytest.fixture
109
+ def wine_images() -> list[Path]:
110
+ """Return list of wine label image paths from test data."""
111
+ if not TEST_DATA_DIR.exists():
112
+ pytest.skip(f"Test data directory not found: {TEST_DATA_DIR}")
113
+
114
+ images = list(TEST_DATA_DIR.glob("*"))
115
+ images = [img for img in images if img.suffix.lower() in (".jpg", ".jpeg", ".png", ".webp")]
116
+
117
+ if not images:
118
+ pytest.skip(f"No wine images found in {TEST_DATA_DIR}")
119
+
120
+ return images
121
+
122
+
123
+ class TestCheckinFlow:
124
+ """Test the complete wine checkin flow."""
125
+
126
+ def test_login(self, page: Page, test_user: tuple[str, str]) -> None:
127
+ """Test that login works correctly."""
128
+ username, password = test_user
129
+
130
+ page.goto(BASE_URL)
131
+
132
+ # Should see login form
133
+ expect(page.locator("#login-form")).to_be_visible()
134
+
135
+ # Fill credentials
136
+ page.fill("#login-username", username)
137
+ page.fill("#login-password", password)
138
+ page.click("#login-form button[type='submit']")
139
+
140
+ # Should show main content after login
141
+ expect(page.locator("#main-content")).to_be_visible(timeout=10000)
142
+
143
+ def test_navigate_to_checkin(self, authenticated_page: Page) -> None:
144
+ """Test navigating to the checkin page."""
145
+ page = authenticated_page
146
+
147
+ # Click Check In nav link (uses data-page attribute)
148
+ page.click("a[data-page='checkin']")
149
+
150
+ # Should show checkin page
151
+ expect(page.locator("#page-checkin")).to_be_visible()
152
+ expect(page.locator("#front-label")).to_be_visible()
153
+
154
+ def test_upload_image_triggers_scan(self, authenticated_page: Page, wine_images: list[Path]) -> None:
155
+ """Test that uploading an image triggers a label scan."""
156
+ page = authenticated_page
157
+
158
+ # Navigate to checkin
159
+ page.click("a[data-page='checkin']")
160
+ page.wait_for_selector("#page-checkin", state="visible")
161
+
162
+ # Upload first wine image
163
+ image_path = wine_images[0]
164
+ page.set_input_files("#front-label", str(image_path))
165
+
166
+ # Should show scanning status or results
167
+ # Wait for either the preview or form fields to be populated
168
+ page.wait_for_selector("#front-preview img, #wine-name:not([value=''])",
169
+ state="visible", timeout=30000)
170
+
171
+ def test_checkin_button_opens_confirmation_dialog(
172
+ self, authenticated_page: Page, wine_images: list[Path]
173
+ ) -> None:
174
+ """Test that clicking Check In opens confirmation dialog without saving."""
175
+ page = authenticated_page
176
+
177
+ # Navigate to checkin
178
+ page.click("a[data-page='checkin']")
179
+ page.wait_for_selector("#page-checkin", state="visible")
180
+
181
+ # Upload image
182
+ image_path = wine_images[0]
183
+ page.set_input_files("#front-label", str(image_path))
184
+
185
+ # Wait for scan to complete (either preview shows or we have form data)
186
+ page.wait_for_timeout(3000) # Give time for OCR/scan
187
+
188
+ # Fill in quantity
189
+ page.fill("#quantity", "2")
190
+
191
+ # Click Check In button
192
+ page.click("#checkin-form button[type='submit']")
193
+
194
+ # Confirmation dialog should appear
195
+ expect(page.locator("#checkin-confirm-modal")).to_have_class(re.compile(r"active"))
196
+
197
+ # Confirm and Cancel buttons should be visible
198
+ expect(page.locator("#checkin-confirm-btn")).to_be_visible()
199
+ expect(page.locator("#checkin-cancel-btn")).to_be_visible()
200
+
201
+ def test_cancel_closes_dialog_without_saving(
202
+ self, authenticated_page: Page, wine_images: list[Path]
203
+ ) -> None:
204
+ """Test that Cancel closes the dialog and returns to form."""
205
+ page = authenticated_page
206
+
207
+ # Navigate to checkin
208
+ page.click("a[data-page='checkin']")
209
+ page.wait_for_selector("#page-checkin", state="visible")
210
+
211
+ # Upload image
212
+ image_path = wine_images[0]
213
+ page.set_input_files("#front-label", str(image_path))
214
+ page.wait_for_timeout(3000)
215
+
216
+ # Fill quantity and click Check In
217
+ page.fill("#quantity", "1")
218
+ page.click("#checkin-form button[type='submit']")
219
+
220
+ # Wait for confirmation dialog
221
+ page.wait_for_selector("#checkin-confirm-modal.active", state="visible")
222
+
223
+ # Click Cancel
224
+ page.click("#checkin-cancel-btn")
225
+
226
+ # Dialog should close
227
+ expect(page.locator("#checkin-confirm-modal")).not_to_have_class(re.compile(r"active"))
228
+
229
+ # Should still be on checkin page
230
+ expect(page.locator("#page-checkin")).to_be_visible()
231
+
232
+ def test_confirm_saves_wine_to_database(
233
+ self, authenticated_page: Page, wine_images: list[Path]
234
+ ) -> None:
235
+ """Test that Confirm actually saves the wine to the database."""
236
+ page = authenticated_page
237
+
238
+ # Navigate to checkin
239
+ page.click("a[data-page='checkin']")
240
+ page.wait_for_selector("#page-checkin", state="visible")
241
+
242
+ # Upload image
243
+ image_path = wine_images[0]
244
+ page.set_input_files("#front-label", str(image_path))
245
+ page.wait_for_timeout(3000)
246
+
247
+ # Fill in details
248
+ page.fill("#quantity", "3")
249
+ page.fill("#wine-name", f"E2E Test Wine - {image_path.stem}")
250
+
251
+ # Click Check In
252
+ page.click("#checkin-form button[type='submit']")
253
+
254
+ # Wait for confirmation dialog
255
+ page.wait_for_selector("#checkin-confirm-modal.active", state="visible")
256
+
257
+ # Click Confirm
258
+ page.click("#checkin-confirm-btn")
259
+
260
+ # Should show cellar page after successful checkin
261
+ page.wait_for_selector("#page-cellar", state="visible", timeout=10000)
262
+
263
+ # The wine should now appear in the cellar
264
+ # Look for at least one wine card (use .first to avoid strict mode error)
265
+ expect(page.locator(".wine-card").first).to_be_visible(timeout=5000)
266
+
267
+ def test_confirmation_dialog_has_editable_fields(
268
+ self, authenticated_page: Page, wine_images: list[Path]
269
+ ) -> None:
270
+ """Test that the confirmation dialog fields are editable."""
271
+ page = authenticated_page
272
+
273
+ # Navigate to checkin
274
+ page.click("a[data-page='checkin']")
275
+ page.wait_for_selector("#page-checkin", state="visible")
276
+
277
+ # Upload image
278
+ image_path = wine_images[0]
279
+ page.set_input_files("#front-label", str(image_path))
280
+ page.wait_for_timeout(3000)
281
+
282
+ # Fill initial data
283
+ page.fill("#wine-name", "Initial Name")
284
+ page.fill("#quantity", "1")
285
+
286
+ # Open confirmation dialog
287
+ page.click("#checkin-form button[type='submit']")
288
+ page.wait_for_selector("#checkin-confirm-modal.active", state="visible")
289
+
290
+ # Edit fields in the confirmation dialog
291
+ confirm_name_field = page.locator("#confirm-wine-name")
292
+ expect(confirm_name_field).to_be_editable()
293
+
294
+ # Change the name in the dialog
295
+ confirm_name_field.fill("Modified Name in Dialog")
296
+
297
+ # Click Confirm
298
+ page.click("#checkin-confirm-btn")
299
+
300
+ # Wait for save and navigation
301
+ page.wait_for_selector("#page-cellar", state="visible", timeout=10000)
302
+
303
+
304
+ class TestWineImageUploads:
305
+ """Test uploading each wine label image from test data."""
306
+
307
+ @pytest.mark.parametrize("image_name", [
308
+ "damaged.jpg",
309
+ "Jo_Pithon_Clos_des_Bois_SGN_1994_label.jpg",
310
+ "Reading_Wine_Labels01.webp",
311
+ "rounded label.jpg",
312
+ ])
313
+ def test_upload_wine_image(self, authenticated_page: Page, image_name: str) -> None:
314
+ """Test uploading a specific wine label image."""
315
+ image_path = TEST_DATA_DIR / image_name
316
+ if not image_path.exists():
317
+ pytest.skip(f"Image not found: {image_path}")
318
+
319
+ page = authenticated_page
320
+
321
+ # Navigate to checkin
322
+ page.click("a[data-page='checkin']")
323
+ page.wait_for_selector("#page-checkin", state="visible")
324
+
325
+ # Upload the image
326
+ page.set_input_files("#front-label", str(image_path))
327
+
328
+ # Wait for image preview to appear
329
+ expect(page.locator("#front-preview img")).to_be_visible(timeout=10000)
330
+
331
+ # Wait a bit for OCR/scan if enabled
332
+ page.wait_for_timeout(2000)
333
+
334
+ # Should be able to fill quantity and click Check In
335
+ page.fill("#quantity", "1")
336
+ page.click("#checkin-form button[type='submit']")
337
+
338
+ # Confirmation dialog should appear
339
+ expect(page.locator("#checkin-confirm-modal")).to_have_class(re.compile(r"active"))
340
+
341
+ # Cancel to clean up (don't actually save during parameterized tests)
342
+ page.click("#checkin-cancel-btn")