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.
- {winebox-0.1.3 → winebox-0.1.4}/PKG-INFO +4 -1
- winebox-0.1.4/docs/_static/screenshots/cellar.png +0 -0
- winebox-0.1.4/docs/_static/screenshots/checkin.png +0 -0
- winebox-0.1.4/docs/_static/screenshots/dashboard.png +0 -0
- winebox-0.1.4/docs/_static/screenshots/search.png +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/docs/user-guide.md +58 -0
- {winebox-0.1.3 → winebox-0.1.4}/pyproject.toml +4 -1
- {winebox-0.1.3 → winebox-0.1.4}/tasks.py +137 -3
- winebox-0.1.4/tests/test_checkin_e2e.py +342 -0
- winebox-0.1.4/tests/test_wines.py +385 -0
- {winebox-0.1.3 → winebox-0.1.4}/uv.lock +209 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/__init__.py +1 -1
- winebox-0.1.4/winebox/config.py +78 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/main.py +48 -1
- {winebox-0.1.3 → winebox-0.1.4}/winebox/models/user.py +2 -0
- winebox-0.1.4/winebox/routers/auth.py +204 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/routers/wines.py +130 -71
- winebox-0.1.4/winebox/services/image_storage.py +219 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/services/vision.py +50 -23
- {winebox-0.1.3 → winebox-0.1.4}/winebox/static/css/style.css +201 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/static/index.html +176 -19
- {winebox-0.1.3 → winebox-0.1.4}/winebox/static/js/app.js +343 -62
- winebox-0.1.3/tests/test_wines.py +0 -212
- winebox-0.1.3/winebox/config.py +0 -47
- winebox-0.1.3/winebox/routers/auth.py +0 -90
- winebox-0.1.3/winebox/services/image_storage.py +0 -90
- {winebox-0.1.3 → winebox-0.1.4}/.github/workflows/ci.yml +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/.github/workflows/publish.yml +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/.gitignore +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/.python-version +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/LICENSE +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/README.md +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/docs/api-reference.md +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/docs/conf.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/docs/index.md +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/docs/screenshots/cellar.png +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/docs/screenshots/checkin.png +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/docs/screenshots/dashboard.png +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/docs/screenshots/login.png +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/tests/__init__.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/tests/conftest.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/tests/test_ocr.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/tests/test_search.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/tests/test_transactions.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/cli/__init__.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/cli/server.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/cli/user_admin.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/database.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/models/__init__.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/models/inventory.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/models/transaction.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/models/wine.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/routers/__init__.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/routers/cellar.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/routers/search.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/routers/transactions.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/schemas/__init__.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/schemas/transaction.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/schemas/wine.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/services/__init__.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/services/auth.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/services/ocr.py +0 -0
- {winebox-0.1.3 → winebox-0.1.4}/winebox/services/wine_parser.py +0 -0
- {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
|
+
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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
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
|
-
|
|
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")
|