Compare commits

...

53 Commits

Author SHA1 Message Date
cartsnitch-ceo[bot] 1b418e7c6f fix(api): change purchased_at and expires_at schema types from datetime to date
fix(api): change purchased_at and expires_at schema types from datetime to date
2026-04-01 23:56:49 +00:00
cartsnitch-ceo[bot] 0b31badbcd Merge branch 'main' into fix/api-date-schema-types 2026-04-01 20:56:13 +00:00
cartsnitch-ceo[bot] eb579dcaa5 fix(frontend): remove hardcoded mock product IDs from Dashboard price trends
fix(frontend): remove hardcoded mock product IDs from Dashboard price trends
2026-04-01 20:25:19 +00:00
cartsnitch-ceo[bot] 086868d450 Merge branch 'main' into fix/dashboard-hardcoded-product-ids 2026-04-01 20:19:22 +00:00
CartSnitch Engineer Bot 63621df0b8 fix(frontend): remove unused React import from Dashboard.tsx
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 19:58:41 +00:00
cartsnitch-engineer[bot] 41e6bfdcf5 feat(scripts): add dev environment seed script and K8s Job (#99)
* fix(api): replace UUID type with str for Better-Auth nanoid user IDs

Better-Auth uses nanoid strings for user IDs, not UUIDs. Changed all
user_id parameter/return types in the API layer from UUID to str,
removed the obsolete UUID import where unused, and updated the
_validate_session_token return type accordingly.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* feat(scripts): add dev environment seed script and K8s Job

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: CartSnitch Engineer Bot <cartnoreply@cartsnitch.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-01 19:51:45 +00:00
CartSnitch Engineer Bot a60859f22f fix(frontend): remove unused React import from Dashboard.tsx
Removes the unused `import React from 'react'` line from Dashboard.tsx
to resolve TS6133 error in lighthouse CI. No other code in the file
references the React namespace.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 19:49:14 +00:00
CartSnitch Engineer Bot 8e8d4a4774 fix(api): change purchased_at and expires_at schema types from datetime to date
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 19:41:35 +00:00
CartSnitch Engineer Bot e85d757cc6 fix(frontend): remove hardcoded mock product IDs from Dashboard price trends
Removed usePriceHistory calls with hardcoded string product IDs (prod1, prod10)
that caused 422 errors against the UUID-expecting API. The Price Trends section
now shows a placeholder until a proper featured-products endpoint is available.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 19:37:44 +00:00
CartSnitch Engineer Bot 43cb62a4d6 fix(api): remove TimestampMixin from models whose DB tables lack timestamp columns
Remove TimestampMixin (created_at/updated_at) from Purchase, PurchaseItem,
PriceHistory, Coupon, and ShrinkflationEvent models since their PostgreSQL
tables do not have those columns. This was causing 500 errors on
/api/v1/purchases and /api/v1/purchases/stats.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 19:36:21 +00:00
cartsnitch-engineer[bot] f7e1574176 fix(api): replace UUID type with str for Better-Auth nanoid user IDs (#98)
Better-Auth uses nanoid strings for user IDs, not UUIDs. Changed all
user_id parameter/return types in the API layer from UUID to str,
removed the obsolete UUID import where unused, and updated the
_validate_session_token return type accordingly.

Co-authored-by: CartSnitch Engineer Bot <cartnoreply@cartsnitch.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-01 19:15:58 +00:00
cartsnitch-ceo[bot] ee6352a2f5 fix(api): parse signed session cookie instead of SHA-256 hashing
fix(api): parse signed session cookie instead of SHA-256 hashing
2026-04-01 11:34:59 +00:00
CartSnitch Engineer Bot 2f37f0501f fix(api): parse signed session cookie instead of SHA-256 hashing
Better-Auth v1.5.6 stores raw tokens in sessions.token, not SHA-256
hashes. The session cookie is signed (rawToken.hmacSignature), so
strip the HMAC signature suffix before querying the DB.

Fixes 401 errors on all data endpoints caused by the incorrect hash.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 11:09:29 +00:00
cartsnitch-cto[bot] 4c36fd4156 fix(api): restore SHA-256 session token hashing (regression from PR #95)
Restores sha256 import and token hashing in _validate_session_token.

Regression introduced when PR #95 (cookie name fix) was merged without
the hash fix from PR #93.

QA approved: CAR-324 (Checkout Charlie)
CTO approved: Paperclip (Savannah Savings)
Resolves CAR-323

cc @cpfarhood
2026-04-01 10:29:05 +00:00
cartsnitch-ceo[bot] c9172f088f fix(api): read __Secure- prefixed session cookie for HTTPS environments
Merges fix/secure-cookie-name. Resolves CAR-321.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 08:16:41 +00:00
cartsnitch-engineer[bot] ac4cba2b0d fix(api): read __Secure- prefixed session cookie for HTTPS environments
Better-Auth automatically prefixes cookie names with __Secure- when serving
over HTTPS. The API gateway now tries __Secure-better-auth.session_token
first (HTTPS/deployed), falling back to better-auth.session_token (HTTP/local dev).

Fixes CAR-321.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 04:02:49 +00:00
cartsnitch-cto[bot] 0c47be8ef3 fix(frontend): align API route paths with backend (alerts, price-history)
CEO merge: QA approved (cartsnitch-qa[bot]), CTO approved (cartsnitch-cto[bot]), CI green. Merging per SDLC gatekeeper role.
2026-04-01 03:13:01 +00:00
cartsnitch-cto[bot] 440f92e96e Merge branch 'main' into fix/frontend-api-routes 2026-04-01 03:08:44 +00:00
cartsnitch-ceo[bot] 97bbdf68a5 fix(api): hash session token before DB lookup to match Better-Auth storage
fix(api): hash session token before DB lookup to match Better-Auth storage
2026-04-01 02:49:07 +00:00
CartSnitch Engineer Bot 02e5bee390 fix(frontend): align API route paths with backend (alerts, price-history)
Change frontend to call /alerts (was /price-alerts) and /products/{id}/prices
(was /products/{id}/price-history) to match the backend router mounts.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 02:10:12 +00:00
CartSnitch Engineer Bot d475b3876a fix(api): hash session token before DB lookup to match Better-Auth storage
Better-Auth v1.5.6+ stores session tokens as SHA-256 hashes in the
sessions table. The raw token from the cookie was being queried directly,
causing all authenticated /api/v1/* requests to return 401.

Fixes CAR-313.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-01 02:09:55 +00:00
cartsnitch-qa[bot] 76bcc53992 fix(api): mount data routers under /api/v1 prefix
Merges fix for CAR-310 / CAR-161 UAT failure. QA approved, CTO approved, CI green.
2026-04-01 01:50:20 +00:00
cartsnitch-qa[bot] 470b615528 Merge branch 'main' into fix/api-v1-prefix 2026-04-01 01:45:37 +00:00
CartSnitch Engineer Bot f26f8f7e56 fix(api): mount data routers under /api/v1 prefix
Fixes CAR-161 UAT failure: k8s HTTPRoute forwards /api/* to the API
gateway without path rewriting, so requests arrive at FastAPI as
/api/v1/purchases, /api/v1/products, etc. FastAPI previously mounted
data routers at root, causing 404s on all /api/v1/* calls.

Keep health and auth routers at root (probes hit /health directly;
auth traffic is routed to the auth service via HTTPRoute).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 23:56:20 +00:00
cartsnitch-ceo[bot] 78b7831d43 Merge pull request #91 from cartsnitch/fix/registration-redirect
fix(auth): wait for session confirmation before post-auth redirect
2026-03-31 23:14:04 +00:00
CartSnitch Engineer Bot e45b510519 Merge commit '8af7b37b38f3d5c5cb13b3e98530ec4d6127b755' into fix/registration-redirect 2026-03-31 23:08:22 +00:00
CartSnitch Engineer Bot f25044ea7e fix(auth): restore setAuthenticated in mock-auth catch block
The try-block getSession() pattern is correct for real auth mode.
The mock-auth catch block (VITE_MOCK_AUTH) still needs to set
the Zustand flag so ProtectedRoute respects the authenticated state.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 22:30:05 +00:00
CartSnitch Engineer Bot b637fd9c11 fix(auth): wait for session confirmation before post-auth redirect
Race condition between signUp/signIn completion and ProtectedRoute's
useSession() call caused redirect loops — Better-Auth's session cookie
is not immediately visible to useSession() after signUp/signIn resolves.

Fix: call authClient.getSession() explicitly after signUp/signIn to
synchronize before navigating to protected routes. Fall back to error
message if session not confirmed.

Also removes dead setAuthenticated() calls that only work in mock mode.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 22:11:20 +00:00
cartsnitch-engineer[bot] 983ee2c398 fix(ci): disable FullPageScreenshot gatherer to prevent Chrome crash
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 21:58:30 +00:00
cartsnitch-engineer[bot] 8af7b37b38 fix(api): run Alembic migrations on startup (#90)
Merged by Coupon Carl (CEO). QA approved, CTO approved. CI green (lighthouse failure is known/tracked). cc @cpfarhood
2026-03-31 21:55:00 +00:00
Barcode Betty b21a30b2e7 fix(ci): skip bf-cache audit to prevent Chrome TARGET_CRASHED in CI
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 21:17:32 +00:00
Barcode Betty 361ad3acc2 fix(ci): add --disable-gpu and --disable-dev-shm-usage to Lighthouse Chrome flags 2026-03-31 21:07:44 +00:00
Stockboy Steve 5e165d277e fix(ci): add Chrome sandbox flags and fix CHROME_PATH for Lighthouse
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 20:48:19 +00:00
cartsnitch-ceo[bot] 6828e4d0a9 fix: change users.id and FK columns from uuid to text for Better-Auth (#88)
fix: change users.id and FK columns from uuid to text for Better-Auth
2026-03-31 19:20:01 +00:00
cartsnitch-ceo[bot] 0b9dd74f7d feat: add E2E journey tests for registration and unauth access (#86)
Adds E2E journey tests (J1: registration/login, J8: unauthenticated access), fixes Dashboard auth protection, adds ProtectedRoute mock auth mode, and fixes Login page a11y.

Reviewed and approved by QA (cartsnitch-qa[bot]) and CTO (cartsnitch-cto[bot]).
2026-03-31 19:01:32 +00:00
Stockboy Steve 7a06f0618b fix(test): update App.test.tsx for ProtectedRoute redirect behavior
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 18:42:47 +00:00
Paperclip 9385463171 fix(a11y): add underline to Login page links for WCAG contrast compliance
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 18:33:21 +00:00
Paperclip b658f77f9c fix(e2e): correct smoke test heading assertion to match Login page
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 18:15:07 +00:00
Barcode Betty 8706112be3 fix(e2e): address CTO/QA review — remove mock-incompatible test, fix smoke test, fix a11y
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 17:59:42 +00:00
Stockboy Steve 00b2b2469b fix: change users.id and FK columns from uuid to text for Better-Auth compatibility
Better-Auth generates nanoid-style text IDs (e.g. pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI),
but the users table used PostgreSQL uuid type, causing registration failures:
  ERROR: invalid input syntax for type uuid: "pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI"

Changes:
- User.id: removed UUIDPrimaryKeyMixin, use explicit text PK
- UserStoreAccount.user_id: Mapped[uuid.UUID] -> Mapped[str]
- Purchase.user_id: Mapped[uuid.UUID] -> Mapped[str]
- UserResponse schema: id field from UUID -> str
- New Alembic migration 004_fix_user_id_text: drops FKs, alters column
  types, re-adds FKs (using id::text cast)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 17:56:13 +00:00
Barcode Betty 1a464fd77d Merge main into feat/e2e-journey-tests, resolve conflict in smoke.spec.ts (keep single quotes) 2026-03-31 17:50:19 +00:00
Barcode Betty 962e64b72a Merge remote-tracking branch 'origin/main' into feat/e2e-journey-tests 2026-03-31 17:49:40 +00:00
Barcode Betty ff91003e90 fix(e2e): remove broken wrong-password test, update smoke test for auth redirect
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 17:37:08 +00:00
Paperclip cd733fbc7d fix(e2e): resolve lint error, Dashboard auth gap, and mock auth redirect
- Remove unused `response` variable in j8-unauth-access.spec.ts:40
- Move Dashboard route inside ProtectedRoute wrapper in App.tsx
- Add VITE_MOCK_AUTH mode to ProtectedRoute: check Zustand
  isAuthenticated flag instead of calling authClient.useSession()

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 17:05:09 +00:00
cartsnitch-ceo[bot] 1f9086f2f2 Merge PR #79 — feat: integrate axe-core accessibility scanning into E2E tests
feat: integrate axe-core accessibility scanning into E2E tests
2026-03-31 16:57:07 +00:00
cartsnitch-ceo[bot] 59407ae54a Merge branch 'main' into feat/axe-core-playwright 2026-03-31 16:53:19 +00:00
Barcode Betty 8659b99059 feat(e2e): add J1 and J8 journey tests
feat(e2e): add J1 and J8 journey tests

- J1: Registration and Login — register flow, validation errors,
  sign-in with existing account, nav between pages
- J8: Unauthenticated Access — /, /purchases, /products, /coupons
  all redirect to /login when no session
- Enable VITE_MOCK_AUTH in playwright webServer so registration
  tests work without a live Better-Auth instance
- Add playwright to devDependencies to ensure CI has the package

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 16:49:36 +00:00
Barcode Betty a28e9d9dd4 fix(Dashboard): add sr-only h1 to skeleton to satisfy axe page-has-heading-one
The axe-core accessibility scan runs against the page before the auth
session resolves, showing DashboardSkeleton instead of real content.
DashboardSkeleton had no h1, causing a false-positive
'page-has-heading-one' violation.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 04:28:37 +00:00
Barcode Betty d405caceca chore(deps): add axe-core packages to package-lock.json
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 04:06:43 +00:00
Barcode Betty f0d1694a1c fix: correct typos in package.json preventing npm ci
- @eslint/jsj → @eslint/js
- eslint-plugin-react-hooks: ^w.0.1 → ^7.0.1
- eslint-plugin-react-refresh: Z0.5.2 → ^0.5.2
- test:e2e: npm playwright test → npx playwright test

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 03:50:35 +00:00
cartsnitch-engineer[bot] 1966b94a97 feat(e2e): add @axe-core/playwright dependency 2026-03-31 02:27:19 +00:00
cartsnitch-engineer[bot] a33b6a0c30 feat(e2e): use fixtures in smoke test for auto axe scan 2026-03-31 02:26:56 +00:00
cartsnitch-engineer[bot] c2b5ccb830 feat(e2e): add axe-core accessibility fixture 2026-03-31 02:26:45 +00:00
45 changed files with 602 additions and 173 deletions
+1 -1
View File
@@ -95,7 +95,7 @@ jobs:
run: |
CHROME_PATH=$(find /home/runner/.cache/ms-playwright -name chrome -type f 2>/dev/null | head -1)
npm install -g @lhci/cli
LHCI_CHROME_PATH="$CHROME_PATH" lhci autorun
CHROME_PATH="$CHROME_PATH" lhci autorun --chrome-flags="--headless=new --no-sandbox --disable-gpu --disable-dev-shm-usage"
build-and-push:
runs-on: runners-cartsnitch
+1 -1
View File
@@ -30,4 +30,4 @@ EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
CMD ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["sh", "-c", "python -m alembic upgrade head && uvicorn cartsnitch_api.main:app --host 0.0.0.0 --port 8000"]
@@ -0,0 +1,122 @@
"""Fix users.id UUID->text type mismatch for Better-Auth compatibility.
Better-Auth generates nanoid-style text IDs (e.g. pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI),
but the users table was using PostgreSQL uuid type. When Better-Auth tries to INSERT
a new user, Postgres throws:
ERROR: invalid input syntax for type uuid: "pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI"
The sessions, accounts, and verifications tables already use text IDs — only users,
user_store_accounts.user_id, and purchases.user_id needed fixing.
Revision ID: 004_fix_user_id_text
Revises: 003_make_users_hashed_password_nullable
Create Date: 2026-03-31
"""
import sqlalchemy as sa
from sqlalchemy import text
from alembic import op
revision = "004_fix_user_id_text"
down_revision = "003_make_users_hashed_password_nullable"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Step 1: Drop existing FK constraints
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
# Step 2: Alter users.id from uuid to text
op.alter_column(
"users",
"id",
type_=sa.Text(),
existing_type=sa.UUID(),
postgresql_using="id::text",
)
# Step 3: Alter user_store_accounts.user_id from uuid to text
op.alter_column(
"user_store_accounts",
"user_id",
type_=sa.Text(),
existing_type=sa.UUID(),
postgresql_using="user_id::text",
)
# Step 4: Alter purchases.user_id from uuid to text
op.alter_column(
"purchases",
"user_id",
type_=sa.Text(),
existing_type=sa.UUID(),
postgresql_using="user_id::text",
)
# Step 5: Re-add FK constraints
op.execute(
text(
"ALTER TABLE user_store_accounts "
"ADD CONSTRAINT user_store_accounts_user_id_fkey "
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
)
)
op.execute(
text(
"ALTER TABLE purchases "
"ADD CONSTRAINT purchases_user_id_fkey "
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
)
)
def downgrade() -> None:
# Drop FK constraints
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
# Revert users.id from text to uuid
op.alter_column(
"users",
"id",
type_=sa.UUID(),
existing_type=sa.Text(),
postgresql_using="id::uuid",
)
# Revert user_store_accounts.user_id from text to uuid
op.alter_column(
"user_store_accounts",
"user_id",
type_=sa.UUID(),
existing_type=sa.Text(),
postgresql_using="user_id::uuid",
)
# Revert purchases.user_id from text to uuid
op.alter_column(
"purchases",
"user_id",
type_=sa.UUID(),
existing_type=sa.Text(),
postgresql_using="user_id::uuid",
)
# Re-add FK constraints (PostgreSQL will auto-name them)
op.execute(
text(
"ALTER TABLE user_store_accounts "
"ADD CONSTRAINT user_store_accounts_user_id_fkey "
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
)
)
op.execute(
text(
"ALTER TABLE purchases "
"ADD CONSTRAINT purchases_user_id_fkey "
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
)
)
+22 -10
View File
@@ -5,7 +5,6 @@ Sessions are verified by querying the shared sessions table directly.
"""
from datetime import UTC, datetime
from uuid import UUID
from fastapi import Cookie, Depends, Header, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -19,18 +18,27 @@ from cartsnitch_api.database import get_db
# but we support Bearer tokens for service-to-service or mobile clients.
bearer_scheme = HTTPBearer(auto_error=False)
# Better-Auth session cookie name
SESSION_COOKIE_NAME = "better-auth.session_token"
# Better-Auth session cookie names.
# Over HTTPS Better-Auth adds the __Secure- prefix automatically.
SESSION_COOKIE_NAMES = [
"__Secure-better-auth.session_token", # HTTPS (deployed)
"better-auth.session_token", # HTTP (local dev)
]
async def _validate_session_token(token: str, db: AsyncSession) -> UUID:
async def _validate_session_token(token: str, db: AsyncSession) -> str:
"""Validate a Better-Auth session token against the sessions table.
Returns the user_id (as UUID) if the session is valid and not expired.
Returns the user_id (as str) if the session is valid and not expired.
Better-Auth v1.5.6 stores raw tokens in the DB. The session cookie
is signed: ``rawToken.base64HMACSignature``. Strip the signature
before querying.
"""
# Signed cookie format: rawToken.hmacSignature — split and use only the token part
raw_token = token.split(".")[0] if "." in token else token
result = await db.execute(
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
{"token": token},
{"token": raw_token},
)
row = result.first()
@@ -51,14 +59,14 @@ async def _validate_session_token(token: str, db: AsyncSession) -> UUID:
detail="Session expired",
)
return UUID(str(user_id))
return str(user_id)
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
) -> UUID:
) -> str:
"""Extract and validate the session token from cookie or Authorization header.
Checks in order:
@@ -67,8 +75,12 @@ async def get_current_user(
"""
token: str | None = None
# 1. Check session cookie
cookie_token = request.cookies.get(SESSION_COOKIE_NAME)
# 1. Check session cookie (try both names for HTTP/HTTPS compatibility)
cookie_token = None
for name in SESSION_COOKIE_NAMES:
cookie_token = request.cookies.get(name)
if cookie_token:
break
if cookie_token:
token = cookie_token
+4 -5
View File
@@ -2,22 +2,21 @@
from datetime import UTC, datetime, timedelta
from typing import Any, cast
from uuid import UUID
from jose import JWTError, jwt
from cartsnitch_api.config import settings
def create_access_token(user_id: UUID) -> str:
def create_access_token(user_id: str) -> str:
expire = datetime.now(UTC) + timedelta(minutes=settings.jwt_access_token_expire_minutes)
payload = {"sub": str(user_id), "exp": expire, "type": "access"}
payload = {"sub": user_id, "exp": expire, "type": "access"}
return cast(str, jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm))
def create_refresh_token(user_id: UUID) -> str:
def create_refresh_token(user_id: str) -> str:
expire = datetime.now(UTC) + timedelta(days=settings.jwt_refresh_token_expire_days)
payload = {"sub": str(user_id), "exp": expire, "type": "refresh"}
payload = {"sub": user_id, "exp": expire, "type": "refresh"}
return cast(str, jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm))
+3 -5
View File
@@ -5,8 +5,6 @@ the Better-Auth service (auth/). This router provides user profile
endpoints that query our own user data from the shared database.
"""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -23,7 +21,7 @@ router = APIRouter(prefix="/auth", tags=["auth"])
@router.get("/me", response_model=UserResponse)
async def get_me(
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AuthService(db)
@@ -38,7 +36,7 @@ async def get_me(
@router.patch("/me", response_model=UserResponse)
async def update_me(
body: UpdateUserRequest,
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AuthService(db)
@@ -54,7 +52,7 @@ async def update_me(
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
async def delete_me(
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AuthService(db)
+14 -10
View File
@@ -2,7 +2,7 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import APIRouter, FastAPI
from cartsnitch_api.auth.routes import router as auth_router
from cartsnitch_api.middleware.cors import add_cors_middleware
@@ -46,15 +46,19 @@ def create_app() -> FastAPI:
# Routers
app.include_router(health_router)
app.include_router(auth_router)
app.include_router(stores_router)
app.include_router(purchases_router)
app.include_router(products_router)
app.include_router(prices_router)
app.include_router(coupons_router)
app.include_router(shopping_router)
app.include_router(alerts_router)
app.include_router(scraping_router)
app.include_router(public_router)
# Data endpoints mounted under /api/v1
v1_router = APIRouter(prefix="/api/v1")
v1_router.include_router(stores_router)
v1_router.include_router(purchases_router)
v1_router.include_router(products_router)
v1_router.include_router(prices_router)
v1_router.include_router(coupons_router)
v1_router.include_router(shopping_router)
v1_router.include_router(alerts_router)
v1_router.include_router(scraping_router)
v1_router.include_router(public_router)
app.include_router(v1_router)
return app
+2 -2
View File
@@ -9,14 +9,14 @@ from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import DiscountType
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from cartsnitch_api.models.base import Base, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from cartsnitch_api.models.product import NormalizedProduct
from cartsnitch_api.models.store import Store
class Coupon(UUIDPrimaryKeyMixin, TimestampMixin, Base):
class Coupon(UUIDPrimaryKeyMixin, Base):
"""A coupon or deal for a product at a store."""
__tablename__ = "coupons"
+2 -2
View File
@@ -9,7 +9,7 @@ from sqlalchemy import Date, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import PriceSource
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from cartsnitch_api.models.base import Base, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from cartsnitch_api.models.product import NormalizedProduct
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
from cartsnitch_api.models.store import Store
class PriceHistory(UUIDPrimaryKeyMixin, TimestampMixin, Base):
class PriceHistory(UUIDPrimaryKeyMixin, Base):
"""A single price observation for a product at a store on a date."""
__tablename__ = "price_history"
+4 -4
View File
@@ -18,7 +18,7 @@ from sqlalchemy import (
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from cartsnitch_api.models.base import Base, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from cartsnitch_api.models.price import PriceHistory
@@ -27,12 +27,12 @@ if TYPE_CHECKING:
from cartsnitch_api.models.user import User
class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
class Purchase(UUIDPrimaryKeyMixin, Base):
"""A single shopping trip / receipt."""
__tablename__ = "purchases"
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
store_location_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("store_locations.id"))
receipt_id: Mapped[str] = mapped_column(String(200), nullable=False)
@@ -61,7 +61,7 @@ class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
)
class PurchaseItem(UUIDPrimaryKeyMixin, TimestampMixin, Base):
class PurchaseItem(UUIDPrimaryKeyMixin, Base):
"""Individual line item on a receipt."""
__tablename__ = "purchase_items"
@@ -9,13 +9,13 @@ from sqlalchemy import Date, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import SizeUnit
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from cartsnitch_api.models.base import Base, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from cartsnitch_api.models.product import NormalizedProduct
class ShrinkflationEvent(UUIDPrimaryKeyMixin, TimestampMixin, Base):
class ShrinkflationEvent(UUIDPrimaryKeyMixin, Base):
"""Detected shrinkflation event — product size changed while price held or rose."""
__tablename__ = "shrinkflation_events"
+4 -3
View File
@@ -4,7 +4,7 @@ import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import AccountStatus
@@ -16,11 +16,12 @@ if TYPE_CHECKING:
from cartsnitch_api.models.store import Store
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
class User(TimestampMixin, Base):
"""Application user."""
__tablename__ = "users"
id: Mapped[str] = mapped_column(Text, primary_key=True)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[str | None] = mapped_column(String(100))
@@ -36,7 +37,7 @@ class UserStoreAccount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "user_store_accounts"
__table_args__ = (UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
session_data: Mapped[dict | None] = mapped_column(EncryptedJSON)
session_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+3 -5
View File
@@ -1,7 +1,5 @@
"""Alert routes: list alerts, manage settings."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -15,7 +13,7 @@ router = APIRouter(prefix="/alerts", tags=["alerts"])
@router.get("", response_model=list[AlertResponse])
async def list_alerts(
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AlertService(db)
@@ -24,7 +22,7 @@ async def list_alerts(
@router.get("/settings", response_model=AlertSettingsResponse)
async def get_alert_settings(
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AlertService(db)
@@ -34,7 +32,7 @@ async def get_alert_settings(
@router.put("/settings")
async def update_alert_settings(
body: AlertSettingsRequest,
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
raise HTTPException(
+2 -2
View File
@@ -16,7 +16,7 @@ router = APIRouter(prefix="/coupons", tags=["coupons"])
@router.get("", response_model=list[CouponResponse])
async def list_coupons(
store_id: UUID | None = Query(None),
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = CouponService(db)
@@ -25,7 +25,7 @@ async def list_coupons(
@router.get("/relevant", response_model=list[CouponResponse])
async def relevant_coupons(
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = CouponService(db)
+3 -3
View File
@@ -20,7 +20,7 @@ router = APIRouter(prefix="/prices", tags=["prices"])
@router.get("/trends", response_model=list[PriceTrendResponse])
async def price_trends(
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
category: str | None = Query(None),
db: AsyncSession = Depends(get_db),
):
@@ -30,7 +30,7 @@ async def price_trends(
@router.get("/increases", response_model=list[PriceIncreaseResponse])
async def price_increases(
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = PriceService(db)
@@ -40,7 +40,7 @@ async def price_increases(
@router.get("/comparison", response_model=list[PriceComparisonResponse])
async def price_comparison(
product_ids: Annotated[list[UUID], Query()],
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = PriceService(db)
+3 -3
View File
@@ -15,7 +15,7 @@ router = APIRouter(prefix="/products", tags=["products"])
@router.get("", response_model=list[ProductResponse])
async def list_products(
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
q: str | None = Query(None),
category: str | None = Query(None),
page: int = Query(1, ge=1),
@@ -29,7 +29,7 @@ async def list_products(
@router.get("/{product_id}", response_model=ProductDetailResponse)
async def get_product(
product_id: UUID,
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = ProductService(db)
@@ -44,7 +44,7 @@ async def get_product(
@router.get("/{product_id}/prices", response_model=PriceTrendResponse)
async def get_product_prices(
product_id: UUID,
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = ProductService(db)
+3 -3
View File
@@ -15,7 +15,7 @@ router = APIRouter(prefix="/purchases", tags=["purchases"])
@router.get("", response_model=list[PurchaseResponse])
async def list_purchases(
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
store_id: UUID | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
@@ -27,7 +27,7 @@ async def list_purchases(
@router.get("/stats", response_model=PurchaseStatsResponse)
async def purchase_stats(
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = PurchaseService(db)
@@ -37,7 +37,7 @@ async def purchase_stats(
@router.get("/{purchase_id}", response_model=PurchaseDetailResponse)
async def get_purchase(
purchase_id: UUID,
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = PurchaseService(db)
+2 -4
View File
@@ -1,7 +1,5 @@
"""Scraping routes: trigger sync, check status (proxy to ReceiptWitness)."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from httpx import HTTPStatusError, RequestError
@@ -13,7 +11,7 @@ router = APIRouter(prefix="/scraping", tags=["scraping"])
@router.post("/{store_slug}/sync", response_model=SyncTriggerResponse)
async def trigger_sync(store_slug: str, user_id: UUID = Depends(get_current_user)):
async def trigger_sync(store_slug: str, user_id: str = Depends(get_current_user)):
client = ReceiptWitnessClient()
try:
result = await client.trigger_sync(str(user_id), store_slug)
@@ -31,7 +29,7 @@ async def trigger_sync(store_slug: str, user_id: UUID = Depends(get_current_user
@router.get("/status", response_model=list[SyncStatusResponse])
async def sync_status(user_id: UUID = Depends(get_current_user)):
async def sync_status(user_id: str = Depends(get_current_user)):
client = ReceiptWitnessClient()
try:
return await client.get_sync_status(str(user_id))
+2 -4
View File
@@ -1,7 +1,5 @@
"""Shopping routes: optimize list, saved lists."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from httpx import HTTPStatusError, RequestError
@@ -13,7 +11,7 @@ router = APIRouter(prefix="/shopping", tags=["shopping"])
@router.post("/optimize", response_model=OptimizeResponse)
async def optimize_shopping(body: OptimizeRequest, user_id: UUID = Depends(get_current_user)):
async def optimize_shopping(body: OptimizeRequest, user_id: str = Depends(get_current_user)):
client = ClipArtistClient()
try:
result = await client.optimize(
@@ -37,7 +35,7 @@ async def optimize_shopping(body: OptimizeRequest, user_id: UUID = Depends(get_c
@router.get("/lists", response_model=list[ShoppingListResponse])
async def list_shopping_lists(user_id: UUID = Depends(get_current_user)):
async def list_shopping_lists(user_id: str = Depends(get_current_user)):
client = ClipArtistClient()
try:
return await client.get_shopping_lists(str(user_id))
+3 -5
View File
@@ -1,7 +1,5 @@
"""Store routes: list stores, manage user store connections."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -21,7 +19,7 @@ async def list_stores(db: AsyncSession = Depends(get_db)):
@router.get("/me/stores", response_model=list[StoreAccountResponse])
async def list_user_stores(
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = StoreService(db)
@@ -36,7 +34,7 @@ async def list_user_stores(
async def connect_store(
store_slug: str,
body: ConnectStoreRequest,
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = StoreService(db)
@@ -51,7 +49,7 @@ async def connect_store(
@router.delete("/me/stores/{store_slug}", status_code=status.HTTP_204_NO_CONTENT)
async def disconnect_store(
store_slug: str,
user_id: UUID = Depends(get_current_user),
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = StoreService(db)
+4 -4
View File
@@ -1,6 +1,6 @@
"""Pydantic v2 request/response schemas for all API endpoints."""
from datetime import datetime
from datetime import date, datetime
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field
@@ -16,7 +16,7 @@ class UpdateUserRequest(BaseModel):
class UserResponse(BaseModel):
id: UUID
id: str
email: str
display_name: str
created_at: datetime
@@ -60,7 +60,7 @@ class PurchaseResponse(BaseModel):
id: UUID
store_id: UUID
store_name: str
purchased_at: datetime
purchased_at: date
total: float
item_count: int
@@ -142,7 +142,7 @@ class CouponResponse(BaseModel):
discount_value: float
discount_type: str
product_id: UUID | None = None
expires_at: datetime | None = None
expires_at: date | None = None
# ---------- Shopping ----------
+3 -5
View File
@@ -4,8 +4,6 @@ Alerts are generated by StickerShock and ShrinkRay services and written to the D
This service reads them for the API gateway.
"""
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -15,7 +13,7 @@ class AlertService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list_alerts(self, user_id: UUID) -> list[dict]:
async def list_alerts(self, user_id: str) -> list[dict]:
"""List shrinkflation events for products the user has purchased."""
from cartsnitch_api.models import Purchase, PurchaseItem, ShrinkflationEvent
@@ -57,7 +55,7 @@ class AlertService:
for e in events
]
async def get_settings(self, user_id: UUID) -> dict:
async def get_settings(self, user_id: str) -> dict:
# Alert settings would be stored in a user_settings table.
# For now, return defaults since the table doesn't exist yet in common lib.
return {
@@ -66,7 +64,7 @@ class AlertService:
"email_notifications": False,
}
async def update_settings(self, user_id: UUID, **fields) -> dict:
async def update_settings(self, user_id: str, **fields) -> dict:
# Would update user_settings table. Return merged defaults for now.
current = await self.get_settings(user_id)
for k, v in fields.items():
+3 -5
View File
@@ -5,8 +5,6 @@ handled by the Better-Auth service (auth/). This service provides
user lookup and profile update operations for the API gateway.
"""
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -15,7 +13,7 @@ class AuthService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get_user(self, user_id: UUID) -> dict:
async def get_user(self, user_id: str) -> dict:
from cartsnitch_api.models import User
result = await self.db.execute(select(User).where(User.id == user_id))
@@ -30,7 +28,7 @@ class AuthService:
"created_at": user.created_at,
}
async def update_user(self, user_id: UUID, **fields) -> dict:
async def update_user(self, user_id: str, **fields) -> dict:
from cartsnitch_api.models import User
result = await self.db.execute(select(User).where(User.id == user_id))
@@ -58,7 +56,7 @@ class AuthService:
"created_at": user.created_at,
}
async def delete_user(self, user_id: UUID) -> None:
async def delete_user(self, user_id: str) -> None:
from cartsnitch_api.models import User
result = await self.db.execute(select(User).where(User.id == user_id))
+1 -1
View File
@@ -29,7 +29,7 @@ class CouponService:
coupons = result.scalars().all()
return [self._to_dict(c) for c in coupons]
async def relevant_coupons(self, user_id: UUID) -> list[dict]:
async def relevant_coupons(self, user_id: str) -> list[dict]:
"""Coupons for products the user has purchased."""
from cartsnitch_api.models import Coupon, PurchaseItem
+3 -3
View File
@@ -13,7 +13,7 @@ class PurchaseService:
async def list_purchases(
self,
user_id: UUID,
user_id: str,
store_id: UUID | None = None,
page: int = 1,
page_size: int = 20,
@@ -56,7 +56,7 @@ class PurchaseService:
for p, item_count, store_name in result.all()
]
async def get_purchase(self, purchase_id: UUID, user_id: UUID) -> dict:
async def get_purchase(self, purchase_id: UUID, user_id: str) -> dict:
from cartsnitch_api.models import Purchase
result = await self.db.execute(
@@ -88,7 +88,7 @@ class PurchaseService:
],
}
async def get_stats(self, user_id: UUID) -> dict:
async def get_stats(self, user_id: str) -> dict:
from cartsnitch_api.models import Purchase
result = await self.db.execute(
+3 -4
View File
@@ -1,7 +1,6 @@
"""Store service — list stores, manage user store account connections."""
import json
from uuid import UUID
from cryptography.fernet import Fernet
from sqlalchemy import select
@@ -35,7 +34,7 @@ class StoreService:
for s in stores
]
async def list_user_stores(self, user_id: UUID) -> list[dict]:
async def list_user_stores(self, user_id: str) -> list[dict]:
from cartsnitch_api.models import UserStoreAccount
result = await self.db.execute(
@@ -60,7 +59,7 @@ class StoreService:
for a in accounts
]
async def connect_store(self, user_id: UUID, store_slug: str, credentials: dict | None) -> dict:
async def connect_store(self, user_id: str, store_slug: str, credentials: dict | None) -> dict:
from cartsnitch_api.models import Store, UserStoreAccount
result = await self.db.execute(select(Store).where(Store.slug == store_slug))
@@ -107,7 +106,7 @@ class StoreService:
"sync_status": "active",
}
async def disconnect_store(self, user_id: UUID, store_slug: str) -> None:
async def disconnect_store(self, user_id: str, store_slug: str) -> None:
from cartsnitch_api.models import Store, UserStoreAccount
result = await self.db.execute(select(Store).where(Store.slug == store_slug))
+12
View File
@@ -0,0 +1,12 @@
import { test as base, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
export const test = base.extend<{ axeCheck: void }>({
axeCheck: [async ({ page }, use) => {
await use();
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
}, { auto: true }],
});
export { expect } from "@playwright/test";
@@ -0,0 +1,56 @@
import { test, expect } from '@playwright/test';
const uniqueEmail = () => `betty+e2e-${Date.now()}@cartsnitch.test`;
test.describe('J1: Registration and Login', () => {
test('can register a new account and lands on dashboard', async ({ page }) => {
await page.goto('/register');
await page.fill('[placeholder="Full Name"]', 'Betty Tester');
await page.fill('[placeholder="Email"]', uniqueEmail());
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
await page.click('button[type="submit"]');
// With VITE_MOCK_AUTH=true the app navigates to "/" on success
await expect(page).toHaveURL('http://localhost:5173/');
await expect(page.getByRole('heading', { name: /cart/i })).toBeVisible();
});
test('shows validation error when registration fields are empty', async ({ page }) => {
await page.goto('/register');
await page.click('button[type="submit"]');
await expect(page.locator('.bg-red-50')).toContainText('Please fill in all fields');
});
test('can navigate from register to login', async ({ page }) => {
await page.goto('/register');
await page.getByRole('link', { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
});
test('can sign in with credentials and land on dashboard', async ({ page }) => {
// Register first so we have a real account
const email = uniqueEmail();
await page.goto('/register');
await page.fill('[placeholder="Full Name"]', 'Login Betty');
await page.fill('[placeholder="Email"]', email);
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('http://localhost:5173/');
// Sign out by clearing the mock session (reload with no session)
await page.goto('/');
await page.reload();
// Now sign in
await page.goto('/login');
await page.fill('[placeholder="Email"]', email);
await page.fill('[placeholder="Password"]', 'TestPass123!');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('http://localhost:5173/');
});
});
+49
View File
@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';
test.describe('J8: Unauthenticated Access', () => {
test('redirects /dashboard (/) to /login when not authenticated', async ({ page }) => {
// No session cookie — start fresh
await page.context().clearCookies();
await page.goto('/');
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
});
test('redirects /purchases to /login when not authenticated', async ({ page }) => {
await page.context().clearCookies();
await page.goto('/purchases');
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
});
test('redirects /products to /login when not authenticated', async ({ page }) => {
await page.context().clearCookies();
await page.goto('/products');
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
});
test('redirects /coupons to /login when not authenticated', async ({ page }) => {
await page.context().clearCookies();
await page.goto('/coupons');
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
});
test('shows loading spinner while auth session is pending', async ({ page }) => {
// Intercept but don't respond — session stays pending
await page.context().clearCookies();
await page.request.fetch('/api/auth/session', {
method: 'GET',
});
// Just navigate to a protected route — ProtectedRoute will show spinner while session is pending
await page.goto('/purchases');
// Spinner is visible briefly; once resolved, should redirect to login
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
});
});
+4 -2
View File
@@ -1,6 +1,8 @@
import { test, expect } from '@playwright/test';
import { test, expect } from './fixtures';
test('app loads', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/CartSnitch/);
// Unauthenticated users are redirected to /login
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /CartSnitch/i })).toBeVisible();
});
+7 -2
View File
@@ -3,7 +3,12 @@
"collect": {
"staticDistDir": "./dist",
"url": ["http://localhost:4173/"],
"numberOfRuns": 1
"numberOfRuns": 1,
"settings": {
"chromeFlags": ["--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
"skipAudits": ["bf-cache"],
"disableFullPageScreenshot": true
}
},
"assert": {
"assertions": {
@@ -16,4 +21,4 @@
"target": "temporary-public-storage"
}
}
}
}
+26 -1
View File
@@ -18,8 +18,9 @@
"zustand": "^5.0.0"
},
"devDependencies": {
"@axe-core/playwright": "^4.10.0",
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.49.0",
"@playwright/test": "^1.58.2",
"@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.2",
@@ -33,6 +34,7 @@
"globals": "^17.4.0",
"jsdom": "^25.0.1",
"msw": "^2.12.14",
"playwright": "^1.58.2",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.56.1",
@@ -69,6 +71,19 @@
"devOptional": true,
"license": "ISC"
},
"node_modules/@axe-core/playwright": {
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz",
"integrity": "sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"axe-core": "~4.11.1"
},
"peerDependencies": {
"playwright-core": ">= 1.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -4493,6 +4508,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/axe-core": {
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz",
"integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==",
"dev": true,
"license": "MPL-2.0",
"engines": {
"node": ">=4"
}
},
"node_modules/babel-plugin-polyfill-corejs2": {
"version": "0.4.16",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.16.tgz",
+4 -2
View File
@@ -23,8 +23,9 @@
"zustand": "^5.0.0"
},
"devDependencies": {
"@axe-core/playwright": "^4.10.0",
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.49.0",
"@playwright/test": "^1.58.2",
"@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.2",
@@ -38,6 +39,7 @@
"globals": "^17.4.0",
"jsdom": "^25.0.1",
"msw": "^2.12.14",
"playwright": "^1.58.2",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.56.1",
@@ -50,4 +52,4 @@
"flatted": "^3.4.2",
"serialize-javascript": "7.0.5"
}
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ export default defineConfig({
},
],
webServer: {
command: 'npm run dev',
command: 'VITE_MOCK_AUTH=true npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
+61
View File
@@ -0,0 +1,61 @@
# seed-dev-job.yaml
# K8s Job to run the CartSnitch seed runner against the dev database.
#
# Usage:
# kubectl apply -f seed-dev-job.yaml -n cartsnitch-dev
#
# To view logs:
# kubectl logs -n cartsnitch-dev job/seed-dev -f
#
# To re-run after fixing issues:
# kubectl delete -f seed-dev-job.yaml -n cartsnitch-dev && kubectl apply -f seed-dev-job.yaml -n cartsnitch-dev
#
apiVersion: batch/v1
kind: Job
metadata:
name: seed-dev
namespace: cartsnitch-dev
labels:
app: cartsnitch
component: seed
environment: dev
annotations:
description: "Runs cartsnitch-common seed runner to populate dev database with realistic test data."
spec:
# Prevent retries — a failed seed run should be investigated, not auto-repeated.
backoffLimit: 0
# Do not run concurrently; sequential runs are safer for truncate+reseed.
concurrencyPolicy: Forbid
template:
metadata:
labels:
app: cartsnitch
component: seed
environment: dev
spec:
restartPolicy: Never
containers:
- name: seed
# Use slim Python image with the cartsnitch-common package installed from git.
# The common repo is public; no additional secret is needed for the pip install.
image: python:3.12-slim
command:
- sh
- -c
- |
pip install --no-cache-dir "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@main" && \
python -m cartsnitch_common.seed --database-url "$${DATABASE_URL}"
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: cartsnitch-secrets
key: database-url-pg
optional: false
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# =============================================================================
# seed-dev.sh — Run the CartSnitch seed runner against the dev database.
#
# Usage:
# ./seed-dev.sh Run full seed against dev
# ./seed-dev.sh --dry-run Show planned record counts without writing
# ./seed-dev.sh --help Show this help
#
# Prerequisites:
# - kubectl configured for the cartsnitch-dev cluster
# - Namespace cartsnitch-dev exists (CNPG Postgres must be running)
#
# What it does:
# 1. Starts a background port-forward to cartsnitch-pg-rw:5432
# 2. Waits for the tunnel to be ready
# 3. Runs python -m cartsnitch_common.seed with --database-url pointing
# to localhost:<forwarded-port>/cartsnitch
# 4. Cleans up the port-forward on exit (normal, interrupt, or error)
# =============================================================================
set -euo pipefail
# --- Config -------------------------------------------------------------------
readonly NAMESPACE="cartsnitch-dev"
readonly SVC_NAME="cartsnitch-pg-rw"
readonly LOCAL_PORT="5433" # use a non-privileged port to avoid conflicts
readonly DB_NAME="cartsnitch"
readonly PG_USER="cartsnitch"
# Retrieve password from the CNPG credentials secret
readonly PG_PASSWORD="$(
kubectl get secret cartsnitch-pg-credentials \
-n "$NAMESPACE" \
-o jsonpath='{.data.password}' \
| base64 -d
)"
readonly DB_URL="postgresql://${PG_USER}:${PG_PASSWORD}@localhost:${LOCAL_PORT}/${DB_NAME}"
# --- Helpers ------------------------------------------------------------------
log() { echo "[seed-dev] $*"; }
fail() { log "ERROR: $*" >&2; exit 1; }
# Cleanup port-forward and exit.
cleanup() {
if [[ -n "${PF_PID:-}" ]]; then
log "Stopping port-forward (PID $PF_PID)..."
kill "$PF_PID" 2>/dev/null || true
wait "$PF_PID" 2>/dev/null || true
fi
}
trap cleanup EXIT
# --- Args ---------------------------------------------------------------------
DRY_RUN=""
HELP_FLAG=""
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN="--dry-run"; shift ;;
--help) HELP_FLAG="1"; shift ;;
*) fail "Unknown argument: $1";;
esac
done
if [[ -n "$HELP_FLAG" ]]; then
sed -n '3,/^# ---/p' "$0" | head -n -1 | sed 's/^# //'
echo ""
echo "Additional arguments are passed through to the seed runner."
echo "Common seed-runner options:"
echo " --dry-run Show planned record counts without writing"
echo " --seed N Set random seed (default: 42)"
exit 0
fi
# --- Prerequisites ------------------------------------------------------------
if ! command -v kubectl &>/dev/null; then
fail "kubectl not found — must be installed and configured."
fi
# --- Port-forward -------------------------------------------------------------
log "Starting port-forward ${SVC_NAME}:5432 -> localhost:${LOCAL_PORT} ..."
kubectl port-forward \
-n "$NAMESPACE" \
svc/"$SVC_NAME" \
"${LOCAL_PORT}:5432" \
&>/dev/null &
PF_PID=$!
# Give the tunnel a moment to establish
sleep 2
# Verify the tunnel is up
if ! kill -0 "$PF_PID" 2>/dev/null; then
fail "Port-forward failed to start."
fi
log "Port-forward active (PID $PF_PID) on localhost:${LOCAL_PORT}"
# --- Seed --------------------------------------------------------------------
log "Running seed against dev database..."
set -x
python -m cartsnitch_common.seed --database-url "$DB_URL" $DRY_RUN
set +x
log "Done."
+17 -23
View File
@@ -1,23 +1,17 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import App from './App.tsx'
vi.mock('./lib/auth-client.ts', () => ({
authClient: {
useSession: () => ({ data: null, isPending: false }),
},
}))
describe('App', () => {
it('renders the dashboard on the root route', () => {
render(<App />)
expect(screen.getByText('CartSnitch')).toBeInTheDocument()
})
it('renders the bottom navigation', () => {
render(<App />)
expect(screen.getByText('Home')).toBeInTheDocument()
expect(screen.getByText('Purchases')).toBeInTheDocument()
expect(screen.getByText('Products')).toBeInTheDocument()
})
})
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import App from './App.tsx'
vi.mock('./lib/auth-client.ts', () => ({
authClient: {
useSession: () => ({ data: null, isPending: false }),
},
}))
describe('App', () => {
it('redirects unauthenticated users to login', () => {
render(<App />)
expect(screen.getByText('CartSnitch')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
})
})
+1 -1
View File
@@ -31,8 +31,8 @@ export default function App() {
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route index element={<Dashboard />} />
<Route element={<ProtectedRoute />}>
<Route index element={<Dashboard />} />
<Route path="purchases" element={<Purchases />} />
<Route path="purchases/:id" element={<PurchaseDetail />} />
<Route path="products" element={<Products />} />
+12 -2
View File
@@ -4,12 +4,22 @@ import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts'
export function ProtectedRoute() {
const isMockAuth = import.meta.env.VITE_MOCK_AUTH === 'true'
const { data: session, isPending } = authClient.useSession()
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
useEffect(() => {
setAuthenticated(!!session)
}, [session, setAuthenticated])
if (!isMockAuth) {
setAuthenticated(!!session)
}
}, [session, setAuthenticated, isMockAuth])
// In mock auth mode, rely on Zustand store (set by Login/Register pages)
if (isMockAuth) {
if (!isAuthenticated) return <Navigate to="/login" replace />
return <Outlet />
}
if (isPending) {
return (
+2 -2
View File
@@ -35,7 +35,7 @@ export function useProduct(id: string) {
export function usePriceHistory(productId: string) {
return useQuery({
queryKey: ['priceHistory', productId],
queryFn: () => api.get<PriceHistory[]>(`/products/${productId}/price-history`),
queryFn: () => api.get<PriceHistory[]>(`/products/${productId}/prices`),
enabled: !!productId,
})
}
@@ -50,6 +50,6 @@ export function useCoupons() {
export function usePriceAlerts() {
return useQuery({
queryKey: ['priceAlerts'],
queryFn: () => api.get<PriceAlert[]>('/price-alerts'),
queryFn: () => api.get<PriceAlert[]>('/alerts'),
})
}
+2 -2
View File
@@ -15,7 +15,7 @@ const mockRoutes: Record<string, (path: string) => unknown> = {
'/purchases': () => mockPurchases,
'/products': () => mockProducts,
'/coupons': () => mockCoupons,
'/price-alerts': () => mockAlerts,
'/alerts': () => mockAlerts,
}
function matchMockRoute<T>(path: string): T | null {
@@ -30,7 +30,7 @@ function matchMockRoute<T>(path: string): T | null {
}
// /products/:id/price-history
const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/)
const priceHistoryMatch = path.match(/^\/products\/(.+)\/prices$/)
if (priceHistoryMatch) {
return getMockPriceHistory(priceHistoryMatch[1]) as T
}
+4 -31
View File
@@ -1,13 +1,8 @@
import React, { Suspense } from 'react'
import { Link } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts'
import { usePurchases, usePriceAlerts, usePriceHistory } from '../hooks/useApi.ts'
import { usePurchases, usePriceAlerts } from '../hooks/useApi.ts'
import { StoreIcon } from '../components/StoreIcon.tsx'
const LazySparklineCard = React.lazy(() =>
import('../components/SparklineChart.tsx').then((mod) => ({ default: mod.SparklineCard }))
)
export function Dashboard() {
const { data: session, isPending } = authClient.useSession()
@@ -44,19 +39,11 @@ export function Dashboard() {
function AuthenticatedDashboard({ userName }: { userName: string }) {
const { data: purchases = [], isLoading: purchasesLoading } = usePurchases()
const { data: alerts = [], isLoading: alertsLoading } = usePriceAlerts()
const { data: eggHistory = [] } = usePriceHistory('prod10')
const { data: milkHistory = [] } = usePriceHistory('prod1')
const triggeredAlerts = alerts.filter((a) => a.triggered)
const watchingAlerts = alerts.filter((a) => !a.triggered)
const recentPurchases = purchases.slice(0, 3)
const sparklineData = eggHistory.filter((p) => p.storeId === 'meijer').slice(-8)
const milkSparkline = milkHistory.filter((p) => p.storeId === 'kroger').slice(-8)
const eggCurrent = sparklineData.length > 0 ? `$${sparklineData[sparklineData.length - 1].price.toFixed(2)}` : '—'
const milkCurrent = milkSparkline.length > 0 ? `$${milkSparkline[milkSparkline.length - 1].price.toFixed(2)}` : '—'
if (purchasesLoading || alertsLoading) {
return <DashboardSkeleton />
}
@@ -106,11 +93,8 @@ function AuthenticatedDashboard({ userName }: { userName: string }) {
{/* Price trend sparklines */}
<section className="mt-6">
<h2 className="mb-3 text-lg font-semibold text-gray-700">Price Trends</h2>
<div className="space-y-3">
<Suspense fallback={<SparklinePlaceholder />}>
<LazySparklineCard label="Eggs (dozen)" data={sparklineData} current={eggCurrent} />
<LazySparklineCard label="Whole Milk (1 gal)" data={milkSparkline} current={milkCurrent} />
</Suspense>
<div className="rounded-xl bg-white p-4 shadow-sm text-center text-sm text-gray-400">
Connect a store to see price trends
</div>
</section>
@@ -173,6 +157,7 @@ function AuthenticatedDashboard({ userName }: { userName: string }) {
function DashboardSkeleton() {
return (
<div className="animate-pulse">
<h1 className="sr-only">Loading CartSnitch</h1>
<div className="h-8 w-40 rounded bg-gray-200" />
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="h-24 rounded-xl bg-gray-200" />
@@ -186,15 +171,3 @@ function DashboardSkeleton() {
</div>
)
}
function SparklinePlaceholder() {
return (
<div className="flex items-center gap-4 rounded-xl bg-white p-4 shadow-sm animate-pulse">
<div className="min-w-0 flex-1">
<div className="h-4 w-24 rounded bg-gray-200" />
<div className="mt-2 h-6 w-16 rounded bg-gray-200" />
</div>
<div className="h-10 w-24 rounded bg-gray-100" />
</div>
)
}
+11 -5
View File
@@ -31,8 +31,14 @@ export function Login() {
throw new Error(authError.message ?? 'Sign in failed')
}
setAuthenticated(true)
navigate('/')
// After successful signIn, force a session fetch to confirm the cookie is set
// before navigating to the protected route
const sessionResult = await authClient.getSession()
if (sessionResult.data) {
navigate('/')
} else {
setError('Sign in failed. Please try again.')
}
} catch {
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
setAuthenticated(true)
@@ -46,7 +52,7 @@ export function Login() {
}
return (
<div className="flex min-h-screen flex-col items-center justify-center px-4">
<main className="flex min-h-screen flex-col items-center justify-center px-4">
<h1 className="mb-2 text-3xl font-bold text-gray-900">CartSnitch</h1>
<p className="mb-8 text-sm text-gray-500">Track prices. Save money.</p>
@@ -88,10 +94,10 @@ export function Login() {
<p className="mt-6 text-sm text-gray-500">
Don't have an account?{' '}
<Link to="/register" className="text-brand-blue">
<Link to="/register" className="text-brand-blue underline">
Sign up
</Link>
</p>
</div>
</main>
)
}
+9 -2
View File
@@ -38,8 +38,15 @@ export function Register() {
throw new Error(authError.message ?? 'Registration failed')
}
setAuthenticated(true)
navigate('/')
// After successful signUp, force a session fetch to confirm the cookie is set
// before navigating to the protected route
const sessionResult = await authClient.getSession()
if (sessionResult.data) {
navigate('/')
} else {
// Session not established — show success message and link to login
setError('Account created! Please sign in.')
}
} catch {
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
setAuthenticated(true)
+1 -1
View File
@@ -61,5 +61,5 @@ export const handlers = [
http.get('/api/v1/products', () => HttpResponse.json(mockProducts)),
http.get('/api/v1/products/prod_1', () => HttpResponse.json(mockProducts[0])),
http.get('/api/v1/coupons', () => HttpResponse.json(mockCoupons)),
http.get('/api/v1/price-alerts', () => HttpResponse.json(mockAlerts)),
http.get('/api/v1/alerts', () => HttpResponse.json(mockAlerts)),
]