Compare commits

..

1 Commits

Author SHA1 Message Date
Stockboy Steve e99fa8924e ci: remove trigger-uat job from cartsnitch workflow
Board API keys are not available in current Paperclip version, so CI
cannot create UAT issues. Rollback Rhonda will detect new dev deployments
via polling instead.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 11:05:50 +00:00
45 changed files with 1259 additions and 4028 deletions
+5 -66
View File
@@ -17,7 +17,6 @@ permissions:
env:
REGISTRY: ghcr.io
IMAGE_NAME: cartsnitch/cartsnitch
AUTH_IMAGE_NAME: cartsnitch/auth
jobs:
lint:
@@ -108,62 +107,9 @@ jobs:
git tag "v${{ steps.calver.outputs.version }}"
git push origin "v${{ steps.calver.outputs.version }}"
build-and-push-auth:
runs-on: runners-cartsnitch
needs: [lint, test]
outputs:
calver_tag: ${{ steps.calver.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate CalVer tag
id: calver
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
DATE_TAG=$(date -u +%Y.%m.%d)
EXISTING=$(git tag -l "v${DATE_TAG}*" | sort -V | tail -1)
if [ -z "$EXISTING" ]; then
VERSION="$DATE_TAG"
elif [ "$EXISTING" = "v${DATE_TAG}" ]; then
VERSION="${DATE_TAG}.2"
else
BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//")
VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (auth)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.AUTH_IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push auth Docker image
uses: docker/build-push-action@v6
with:
context: ./auth
file: ./auth/Dockerfile
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
deploy-dev:
runs-on: runners-cartsnitch
needs: [build-and-push, build-and-push-auth]
needs: [build-and-push]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Generate GitHub App token
@@ -172,8 +118,6 @@ jobs:
with:
app-id: ${{ secrets.CARTSNITCH_APP_ID }}
private-key: ${{ secrets.CARTSNITCH_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: infra
- name: Checkout infra repo
uses: actions/checkout@v4
@@ -181,25 +125,20 @@ jobs:
repository: cartsnitch/infra
token: ${{ steps.app-token.outputs.token }}
ref: main
path: infra
- name: Install kubectl
uses: azure/setup-kubectl@v4
- name: Install kustomize
uses: imranismail/setup-kustomize@v2
- name: Update dev overlay image tag
working-directory: apps/overlays/dev
run: |
cd infra/apps/overlays/dev
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }}
kustomize edit set image ghcr.io/cartsnitch/auth:${{ needs.build-and-push-auth.outputs.calver_tag }}
- name: Commit and push to infra
run: |
cd infra
cd apps/overlays/dev
git config user.name "cartsnitch-ci[bot]"
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add apps/overlays/dev/kustomization.yaml
git commit -m "ci(dev): update cartsnitch and auth images to ${{ needs.build-and-push.outputs.calver_tag }}"
git add kustomization.yaml
git commit -m "ci(dev): update cartsnitch image to ${{ needs.build-and-push.outputs.calver_tag }}"
git push origin main
-1
View File
@@ -11,7 +11,6 @@ node_modules
dist
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*
+2 -7
View File
@@ -12,7 +12,6 @@ CartSnitch is a self-hosted grocery price intelligence platform. This repo (`car
| Directory | Service | Purpose |
|-----------|---------|---------|
| `/` (root) | Frontend | React PWA, mobile-first (this directory) |
| `auth/` | Auth | Better-Auth Node.js service (session management, email/password, OAuth) |
| `api/` | API Gateway | Frontend-facing REST API |
| `common/` | Common | Shared Python models, schemas, Alembic migrations |
| `receiptwitness/` | ReceiptWitness | Purchase data ingestion via retailer scrapers |
@@ -167,13 +166,9 @@ frontend/
All data comes from the CartSnitch API gateway (`cartsnitch/api`). Base URL configured via environment variable `VITE_API_URL`.
- **Authentication via Better-Auth** (`auth/` service). Sessions are managed via httpOnly cookies — no tokens in localStorage or memory.
- Auth service URL configured via `VITE_AUTH_URL` (default: `http://localhost:3001`)
- Frontend uses `better-auth/react` client for sign-in, sign-up, sign-out, and `useSession()` hook
- API gateway validates sessions by querying the shared `sessions` table in Postgres
- Both cookie-based and Bearer token auth are supported (cookies for web, Bearer for API clients)
- JWT auth: store access token in memory (not localStorage), refresh token in httpOnly cookie if possible, or secure storage.
- TanStack Query handles caching, background refetching, and optimistic updates.
- API client sends `credentials: 'include'` on all requests to forward session cookies.
- API client should handle 401 responses by attempting token refresh before retrying.
## Development Workflow
+45 -1
View File
@@ -1 +1,45 @@
# CartSnitch
# CartSnitch Monorepo
CartSnitch is a self-hosted grocery price intelligence platform. This repo consolidates the core services and the flagship frontend PWA.
## Services
| Directory | Service | Purpose |
|-----------|---------|---------|
| `/` (root) | **Frontend** | React 18 PWA — mobile-first price intelligence UI |
| `api/` | **API Gateway** | FastAPI — frontend-facing REST API |
| `common/` | **Common** | Shared Python models, schemas, Alembic migrations |
| `receiptwitness/` | **ReceiptWitness** | Purchase ingestion via retailer scrapers |
## Quick Start
### Frontend (root)
```bash
npm install
npm run dev # http://localhost:5173
npm run build # production build
npm run test # unit tests (Vitest)
```
### Python Services
Each Python service uses [uv](https://github.com/astral-sh/uv) and has its own `pyproject.toml`:
```bash
cd api # or common / receiptwitness
uv sync
uv run pytest
```
## Development Workflow
- **Never push directly to main.** Always open a PR from a feature branch.
- Branch naming: `feature/<description>` or `fix/<description>`
- Conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`
## Architecture
For full details see [CLAUDE.md](./CLAUDE.md) or the per-service `CLAUDE.md` in each subdirectory.
CartSnitch is a polyrepo-style monorepo: each service can be built and deployed independently, but sharing code between `common/` and the other Python services is done via local path dependencies in `pyproject.toml`.
@@ -1,101 +0,0 @@
"""Add Better-Auth tables and extend users table.
Creates sessions, accounts, and verifications tables for Better-Auth.
Adds email_verified and image columns to existing users table.
Migrates password hashes from users.hashed_password to accounts.password.
Revision ID: 002_better_auth_tables
Revises: 001_encrypt_session_data
Create Date: 2026-03-28
"""
import sqlalchemy as sa
from sqlalchemy import text
from alembic import op
revision = "002_better_auth_tables"
down_revision = "001_encrypt_session_data"
branch_labels = None
depends_on = None
def upgrade() -> None:
# --- Extend users table for Better-Auth compatibility ---
op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"))
op.add_column("users", sa.Column("image", sa.Text(), nullable=True))
# --- Create sessions table ---
op.create_table(
"sessions",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("token", sa.Text(), nullable=False),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ip_address", sa.Text(), nullable=True),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sessions_token", "sessions", ["token"], unique=True)
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
# --- Create accounts table ---
op.create_table(
"accounts",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("account_id", sa.Text(), nullable=False),
sa.Column("provider_id", sa.Text(), nullable=False),
sa.Column("access_token", sa.Text(), nullable=True),
sa.Column("refresh_token", sa.Text(), nullable=True),
sa.Column("access_token_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("refresh_token_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("scope", sa.Text(), nullable=True),
sa.Column("id_token", sa.Text(), nullable=True),
sa.Column("password", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_accounts_user_id", "accounts", ["user_id"])
# --- Create verifications table ---
op.create_table(
"verifications",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("identifier", sa.Text(), nullable=False),
sa.Column("value", sa.Text(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
# --- Migrate existing password hashes to accounts table ---
# For each user with a hashed_password, create a 'credential' account row
conn = op.get_bind()
users = conn.execute(
text("SELECT id, hashed_password FROM users WHERE hashed_password IS NOT NULL")
).fetchall()
for user_id, hashed_password in users:
user_id_str = str(user_id)
conn.execute(
text(
"INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) "
"VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())"
),
{"user_id": user_id_str, "account_id": user_id_str, "password": hashed_password},
)
def downgrade() -> None:
op.drop_table("verifications")
op.drop_table("accounts")
op.drop_index("ix_sessions_user_id", table_name="sessions")
op.drop_index("ix_sessions_token", table_name="sessions")
op.drop_table("sessions")
op.drop_column("users", "image")
op.drop_column("users", "email_verified")
+17 -71
View File
@@ -1,88 +1,34 @@
"""FastAPI dependency injection for authentication.
"""FastAPI dependency injection for authentication."""
Validates Better-Auth session tokens from cookies or Bearer header.
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 import Depends, Header, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.jwt import decode_token
from cartsnitch_api.config import settings
from cartsnitch_api.database import get_db
# Keep Bearer scheme as optional — Better-Auth primarily uses cookies,
# 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"
async def _validate_session_token(token: str, db: AsyncSession) -> UUID:
"""Validate a Better-Auth session token against the sessions table.
Returns the user_id (as UUID) if the session is valid and not expired.
"""
result = await db.execute(
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
{"token": token},
)
row = result.first()
if not row:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid session token",
)
user_id, expires_at = row
if expires_at.tzinfo is None:
# Treat naive datetimes as UTC
expires_at = expires_at.replace(tzinfo=UTC)
if expires_at < datetime.now(UTC):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired",
)
return UUID(str(user_id))
bearer_scheme = HTTPBearer()
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
) -> UUID:
"""Extract and validate the session token from cookie or Authorization header.
Checks in order:
1. Better-Auth session cookie (primary — web clients)
2. Bearer token in Authorization header (fallback — API clients)
"""
token: str | None = None
# 1. Check session cookie
cookie_token = request.cookies.get(SESSION_COOKIE_NAME)
if cookie_token:
token = cookie_token
# 2. Fall back to Bearer header
if not token and credentials:
token = credentials.credentials
if not token:
try:
payload = decode_token(credentials.credentials)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
)
detail="Invalid or expired token",
) from None
return await _validate_session_token(token, db)
if payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type",
) from None
return UUID(payload["sub"])
async def verify_service_key(x_service_key: str = Header()) -> None:
+36 -6
View File
@@ -1,9 +1,4 @@
"""Auth routes: user profile management.
Registration, login, refresh, and session management are handled by
the Better-Auth service (auth/). This router provides user profile
endpoints that query our own user data from the shared database.
"""
"""Auth routes: register, login, refresh, me, update, delete."""
from uuid import UUID
@@ -13,6 +8,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.schemas import (
LoginRequest,
RefreshRequest,
RegisterRequest,
TokenResponse,
UpdateUserRequest,
UserResponse,
)
@@ -21,6 +20,37 @@ from cartsnitch_api.services.auth import AuthService
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
svc = AuthService(db)
try:
return await svc.register(body.email, body.password, body.display_name)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
svc = AuthService(db)
try:
return await svc.login(body.email, body.password)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password"
) from None
@router.post("/refresh", response_model=TokenResponse)
async def refresh(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
svc = AuthService(db)
try:
return await svc.refresh(body.refresh_token)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
) from None
@router.get("/me", response_model=UserResponse)
async def get_me(
user_id: UUID = Depends(get_current_user),
-2
View File
@@ -19,8 +19,6 @@ class Settings(BaseSettings):
# Valid Fernet key for local dev — MUST be overridden in production
fernet_key: str = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
auth_service_url: str = "http://auth:3001"
cors_origins: list[str] = ["http://localhost:3000", "https://cartsnitch.com"]
receiptwitness_url: str = "http://receiptwitness:8001"
+22 -2
View File
@@ -6,8 +6,28 @@ from uuid import UUID
from pydantic import BaseModel, EmailStr, Field
# ---------- Auth ----------
# Registration, login, and session management are handled by Better-Auth (auth/ service).
# These schemas are for the profile management endpoints only.
class RegisterRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
display_name: str = Field(min_length=1, max_length=100)
class LoginRequest(BaseModel):
email: EmailStr
password: str
class RefreshRequest(BaseModel):
refresh_token: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
class UpdateUserRequest(BaseModel):
+61 -6
View File
@@ -1,20 +1,67 @@
"""Auth service — user profile management.
Registration, login, token management, and session handling are now
handled by the Better-Auth service (auth/). This service provides
user lookup and profile update operations for the API gateway.
"""
"""Auth service — user registration, login, token management."""
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.jwt import create_access_token, create_refresh_token, decode_token
from cartsnitch_api.auth.passwords import hash_password, verify_password
from cartsnitch_api.config import settings
class AuthService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def register(self, email: str, password: str, display_name: str) -> dict:
from cartsnitch_api.models import User
existing = await self.db.execute(select(User).where(User.email == email))
if existing.scalar_one_or_none():
raise ValueError("Email already registered")
user = User(
email=email,
hashed_password=hash_password(password),
display_name=display_name,
)
self.db.add(user)
await self.db.commit()
await self.db.refresh(user)
return self._make_token_response(user.id)
async def login(self, email: str, password: str) -> dict:
from cartsnitch_api.models import User
result = await self.db.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if not user or not verify_password(password, user.hashed_password):
raise ValueError("Invalid email or password")
return self._make_token_response(user.id)
async def refresh(self, refresh_token: str) -> dict:
from cartsnitch_api.models import User
try:
payload = decode_token(refresh_token)
except ValueError:
raise ValueError("Invalid refresh token") from None
if payload.get("type") != "refresh":
raise ValueError("Invalid token type") from None
user_id = UUID(payload["sub"])
# Verify the user still exists before issuing new tokens
result = await self.db.execute(select(User).where(User.id == user_id))
if not result.scalar_one_or_none():
raise ValueError("User no longer exists")
return self._make_token_response(user_id)
async def get_user(self, user_id: UUID) -> dict:
from cartsnitch_api.models import User
@@ -68,3 +115,11 @@ class AuthService:
await self.db.delete(user)
await self.db.commit()
def _make_token_response(self, user_id: UUID) -> dict:
return {
"access_token": create_access_token(user_id),
"refresh_token": create_refresh_token(user_id),
"token_type": "bearer",
"expires_in": settings.jwt_access_token_expire_minutes * 60,
}
+15 -101
View File
@@ -1,16 +1,8 @@
"""Shared test fixtures with in-memory SQLite database.
Session-based auth: tests create users and sessions directly in the DB,
matching the Better-Auth session validation flow.
"""
import secrets
import uuid
from datetime import UTC, datetime, timedelta
"""Shared test fixtures with in-memory SQLite database."""
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import create_engine, event, text
from sqlalchemy import create_engine, event
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker
@@ -59,46 +51,6 @@ async def db_engine():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Create Better-Auth tables (not managed by SQLAlchemy models)
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
ip_address TEXT,
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
"""))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
access_token TEXT,
refresh_token TEXT,
access_token_expires_at TIMESTAMP,
refresh_token_expires_at TIMESTAMP,
scope TEXT,
id_token TEXT,
password TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
"""))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS verifications (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
value TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
"""))
yield engine
@@ -133,55 +85,17 @@ async def client(db_engine):
app.dependency_overrides.clear()
async def _create_test_user_and_session(client: AsyncClient, db_engine, **user_overrides) -> tuple[dict, str]:
"""Create a test user and a valid session directly in the DB.
Returns (user_dict, session_token).
"""
user_id = str(uuid.uuid4())
email = user_overrides.get("email", "test@example.com")
display_name = user_overrides.get("display_name", "Test User")
session_token = secrets.token_urlsafe(32)
session_id = str(uuid.uuid4())
now = datetime.now(UTC).isoformat()
expires = (datetime.now(UTC) + timedelta(days=7)).isoformat()
async with db_engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) "
"VALUES (:id, :email, :hashed_password, :display_name, :email_verified, :created_at, :updated_at)"
),
{
"id": user_id,
"email": email,
"hashed_password": "not-used-with-better-auth",
"display_name": display_name,
"email_verified": False,
"created_at": now,
"updated_at": now,
},
)
await conn.execute(
text(
"INSERT INTO sessions (id, token, user_id, expires_at, created_at, updated_at) "
"VALUES (:id, :token, :user_id, :expires_at, :created_at, :updated_at)"
),
{
"id": session_id,
"token": session_token,
"user_id": user_id,
"expires_at": expires,
"created_at": now,
"updated_at": now,
},
)
return {"id": user_id, "email": email, "display_name": display_name}, session_token
@pytest.fixture
async def auth_headers(client, db_engine):
"""Create a test user with a valid session and return auth headers."""
_, session_token = await _create_test_user_and_session(client, db_engine)
return {"Cookie": f"better-auth.session_token={session_token}"}
async def auth_headers(client):
"""Register a test user and return auth headers."""
resp = await client.post(
"/auth/register",
json={
"email": "test@example.com",
"password": "testpass123",
"display_name": "Test User",
},
)
assert resp.status_code == 201
token = resp.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
+165 -79
View File
@@ -1,13 +1,146 @@
"""Integration tests for auth profile endpoints.
Registration, login, and session management are handled by the Better-Auth
service. These tests cover the profile endpoints (GET/PATCH/DELETE /auth/me)
which validate sessions via the shared sessions table.
"""
"""Integration tests for auth endpoints."""
import pytest
@pytest.mark.asyncio
async def test_register_success(client):
resp = await client.post(
"/auth/register",
json={
"email": "new@example.com",
"password": "securepass123",
"display_name": "New User",
},
)
assert resp.status_code == 201
data = resp.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"
assert data["expires_in"] == 900 # 15 min * 60
@pytest.mark.asyncio
async def test_register_duplicate_email(client):
await client.post(
"/auth/register",
json={
"email": "dupe@example.com",
"password": "securepass123",
"display_name": "User One",
},
)
resp = await client.post(
"/auth/register",
json={
"email": "dupe@example.com",
"password": "securepass456",
"display_name": "User Two",
},
)
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_register_short_password(client):
resp = await client.post(
"/auth/register",
json={
"email": "short@example.com",
"password": "short",
"display_name": "Short Pass",
},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_login_success(client):
await client.post(
"/auth/register",
json={
"email": "login@example.com",
"password": "securepass123",
"display_name": "Login User",
},
)
resp = await client.post(
"/auth/login",
json={
"email": "login@example.com",
"password": "securepass123",
},
)
assert resp.status_code == 200
assert "access_token" in resp.json()
@pytest.mark.asyncio
async def test_login_wrong_password(client):
await client.post(
"/auth/register",
json={
"email": "wrong@example.com",
"password": "securepass123",
"display_name": "Wrong Pass",
},
)
resp = await client.post(
"/auth/login",
json={
"email": "wrong@example.com",
"password": "badpassword1",
},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_login_nonexistent_user(client):
resp = await client.post(
"/auth/login",
json={
"email": "ghost@example.com",
"password": "doesntmatter",
},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_refresh_token(client):
reg = await client.post(
"/auth/register",
json={
"email": "refresh@example.com",
"password": "securepass123",
"display_name": "Refresh User",
},
)
refresh_token = reg.json()["refresh_token"]
resp = await client.post(
"/auth/refresh",
json={
"refresh_token": refresh_token,
},
)
assert resp.status_code == 200
assert "access_token" in resp.json()
@pytest.mark.asyncio
async def test_refresh_with_invalid_token(client):
resp = await client.post(
"/auth/refresh",
json={
"refresh_token": "invalid.token.here",
},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_get_me(client, auth_headers):
resp = await client.get("/auth/me", headers=auth_headers)
@@ -22,32 +155,7 @@ async def test_get_me(client, auth_headers):
@pytest.mark.asyncio
async def test_get_me_unauthorized(client):
resp = await client.get("/auth/me")
assert resp.status_code in (401, 403)
@pytest.mark.asyncio
async def test_get_me_invalid_session(client):
resp = await client.get(
"/auth/me",
headers={"Cookie": "better-auth.session_token=invalid-token"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_get_me_with_bearer_token(client, db_engine):
"""Session tokens can also be passed as Bearer tokens for API clients."""
from tests.conftest import _create_test_user_and_session
_, session_token = await _create_test_user_and_session(
client, db_engine, email="bearer@example.com", display_name="Bearer User"
)
resp = await client.get(
"/auth/me",
headers={"Authorization": f"Bearer {session_token}"},
)
assert resp.status_code == 200
assert resp.json()["email"] == "bearer@example.com"
assert resp.status_code in (401, 403) # No auth header
@pytest.mark.asyncio
@@ -55,7 +163,9 @@ async def test_update_me(client, auth_headers):
resp = await client.patch(
"/auth/me",
headers=auth_headers,
json={"display_name": "Updated Name"},
json={
"display_name": "Updated Name",
},
)
assert resp.status_code == 200
assert resp.json()["display_name"] == "Updated Name"
@@ -66,58 +176,34 @@ async def test_delete_me(client, auth_headers):
resp = await client.delete("/auth/me", headers=auth_headers)
assert resp.status_code == 204
# Session is still valid but user is gone
# Verify user is gone (token still valid but user deleted)
resp = await client.get("/auth/me", headers=auth_headers)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_expired_session_rejected(client, db_engine):
"""Expired sessions must be rejected."""
import secrets
import uuid
from datetime import UTC, datetime, timedelta
async def test_refresh_after_delete_fails(client):
"""Refresh token for a deleted user must be rejected."""
reg = await client.post(
"/auth/register",
json={
"email": "ghost@example.com",
"password": "securepass123",
"display_name": "Ghost User",
},
)
tokens = reg.json()
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
from sqlalchemy import text
# Delete the user
resp = await client.delete("/auth/me", headers=headers)
assert resp.status_code == 204
user_id = str(uuid.uuid4())
session_token = secrets.token_urlsafe(32)
now = datetime.now(UTC).isoformat()
expired = (datetime.now(UTC) - timedelta(hours=1)).isoformat()
async with db_engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) "
"VALUES (:id, :email, :hp, :dn, :ev, :ca, :ua)"
),
{
"id": user_id,
"email": "expired@example.com",
"hp": "unused",
"dn": "Expired User",
"ev": False,
"ca": now,
"ua": now,
},
)
await conn.execute(
text(
"INSERT INTO sessions (id, token, user_id, expires_at, created_at, updated_at) "
"VALUES (:id, :token, :uid, :ea, :ca, :ua)"
),
{
"id": str(uuid.uuid4()),
"token": session_token,
"uid": user_id,
"ea": expired,
"ca": now,
"ua": now,
},
)
resp = await client.get(
"/auth/me",
headers={"Cookie": f"better-auth.session_token={session_token}"},
# Refresh token should now fail
resp = await client.post(
"/auth/refresh",
json={
"refresh_token": tokens["refresh_token"],
},
)
assert resp.status_code == 401
+5 -11
View File
@@ -10,9 +10,9 @@ from decimal import Decimal
from uuid import UUID
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from cartsnitch_api.auth.jwt import decode_token
from cartsnitch_api.models import (
Coupon,
NormalizedProduct,
@@ -126,16 +126,10 @@ async def seed_data(db_engine, auth_headers):
session.add_all(prices)
await session.flush()
# -- Get the user_id from the session token in auth_headers --
cookie_str = auth_headers.get("Cookie", "")
session_token = cookie_str.split("=", 1)[1] if "=" in cookie_str else ""
result = await session.execute(
text("SELECT user_id FROM sessions WHERE token = :token"),
{"token": session_token},
)
row = result.first()
user_id = UUID(row[0])
# -- Purchases (need the user_id from the registered test user) --
token = auth_headers["Authorization"].split(" ")[1]
payload = decode_token(token)
user_id = UUID(payload["sub"])
purchase1 = Purchase(
user_id=user_id,
+151 -100
View File
@@ -1,104 +1,133 @@
"""E2E: Auth and session validation flows.
"""E2E: Auth and token validation flows."""
Registration and login are handled by the Better-Auth service.
These tests validate session token handling at the API gateway level.
"""
import asyncio
import pytest
from tests.conftest import _create_test_user_and_session
@pytest.mark.asyncio
class TestAuthRegistrationLogin:
"""Full registration → login → token refresh → profile flow."""
async def test_full_auth_lifecycle(self, client, db_engine):
"""Register → login → get profile → refresh → get profile again."""
# Register
reg = await client.post(
"/auth/register",
json={
"email": "lifecycle@example.com",
"password": "securepass123",
"display_name": "Lifecycle User",
},
)
assert reg.status_code == 201
tokens = reg.json()
assert "access_token" in tokens
assert "refresh_token" in tokens
assert tokens["token_type"] == "bearer"
assert tokens["expires_in"] > 0
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
# Get profile with access token
me = await client.get("/auth/me", headers=headers)
assert me.status_code == 200
assert me.json()["email"] == "lifecycle@example.com"
assert me.json()["display_name"] == "Lifecycle User"
# Sleep 1s so the new token has a different exp than the registration token
await asyncio.sleep(1)
# Login with same credentials
login = await client.post(
"/auth/login",
json={"email": "lifecycle@example.com", "password": "securepass123"},
)
assert login.status_code == 200
login_tokens = login.json()
assert login_tokens["access_token"] != tokens["access_token"]
# Refresh token
refresh = await client.post(
"/auth/refresh",
json={"refresh_token": tokens["refresh_token"]},
)
assert refresh.status_code == 200
new_tokens = refresh.json()
assert new_tokens["access_token"] != tokens["access_token"]
# Use refreshed token to access profile
new_headers = {"Authorization": f"Bearer {new_tokens['access_token']}"}
me2 = await client.get("/auth/me", headers=new_headers)
assert me2.status_code == 200
assert me2.json()["email"] == "lifecycle@example.com"
@pytest.mark.asyncio
class TestSessionValidation:
"""Session edge cases and error responses."""
class TestTokenValidation:
"""Token edge cases and error responses."""
async def test_invalid_session_token_rejected(self, client, db_engine):
resp = await client.get(
"/auth/me",
headers={"Cookie": "better-auth.session_token=not-a-real-token"},
)
assert resp.status_code == 401
async def test_missing_auth(self, client, db_engine):
resp = await client.get("/auth/me")
assert resp.status_code in (401, 403)
async def test_bearer_token_also_works(self, client, db_engine):
"""Session tokens passed as Bearer tokens should also be accepted."""
_, session_token = await _create_test_user_and_session(
client, db_engine, email="bearer@e2e.com", display_name="Bearer E2E"
)
resp = await client.get(
"/auth/me",
headers={"Authorization": f"Bearer {session_token}"},
)
assert resp.status_code == 200
assert resp.json()["email"] == "bearer@e2e.com"
async def test_deleted_user_session_returns_not_found(self, client, db_engine):
"""After deleting a user, their session should result in 404 for profile."""
_, session_token = await _create_test_user_and_session(
client, db_engine, email="delete-me@e2e.com", display_name="Delete Me"
)
headers = {"Cookie": f"better-auth.session_token={session_token}"}
delete_resp = await client.delete("/auth/me", headers=headers)
assert delete_resp.status_code == 204
me = await client.get("/auth/me", headers=headers)
assert me.status_code == 404
async def test_expired_session_rejected(self, client, db_engine):
"""Expired sessions must be rejected."""
import secrets
async def test_expired_token_rejected(self, client, db_engine):
"""Manually craft an expired token and verify rejection."""
import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy import text
from jose import jwt
user_id = str(uuid.uuid4())
session_token = secrets.token_urlsafe(32)
now = datetime.now(UTC).isoformat()
expired = (datetime.now(UTC) - timedelta(hours=1)).isoformat()
from cartsnitch_api.config import settings
async with db_engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) "
"VALUES (:id, :email, :hp, :dn, :ev, :ca, :ua)"
),
{
"id": user_id,
"email": "expired@e2e.com",
"hp": "unused",
"dn": "Expired User",
"ev": False,
"ca": now,
"ua": now,
},
)
await conn.execute(
text(
"INSERT INTO sessions (id, token, user_id, expires_at, created_at, updated_at) "
"VALUES (:id, :token, :uid, :ea, :ca, :ua)"
),
{
"id": str(uuid.uuid4()),
"token": session_token,
"uid": user_id,
"ea": expired,
"ca": now,
"ua": now,
},
)
resp = await client.get(
"/auth/me",
headers={"Cookie": f"better-auth.session_token={session_token}"},
)
payload = {
"sub": str(uuid.uuid4()),
"exp": datetime.now(UTC) - timedelta(minutes=5),
"type": "access",
}
token = jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
resp = await client.get("/auth/me", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 401
async def test_invalid_token_rejected(self, client, db_engine):
resp = await client.get("/auth/me", headers={"Authorization": "Bearer not-a-real-token"})
assert resp.status_code == 401
async def test_missing_auth_header(self, client, db_engine):
resp = await client.get("/auth/me")
assert resp.status_code in (401, 403)
async def test_refresh_token_cannot_access_endpoints(self, client, db_engine):
"""A refresh token should not work as an access token."""
reg = await client.post(
"/auth/register",
json={
"email": "refresh-test@example.com",
"password": "securepass123",
"display_name": "Refresh Test",
},
)
refresh_token = reg.json()["refresh_token"]
resp = await client.get("/auth/me", headers={"Authorization": f"Bearer {refresh_token}"})
assert resp.status_code == 401
async def test_deleted_user_token_invalid(self, client, db_engine):
"""After deleting an account, tokens should no longer work."""
reg = await client.post(
"/auth/register",
json={
"email": "delete-me@example.com",
"password": "securepass123",
"display_name": "Delete Me",
},
)
tokens = reg.json()
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
# Delete account
delete_resp = await client.delete("/auth/me", headers=headers)
assert delete_resp.status_code == 204
# Profile should fail
me = await client.get("/auth/me", headers=headers)
assert me.status_code in (401, 404)
@pytest.mark.asyncio
class TestAuthProtectedEndpoints:
@@ -125,38 +154,60 @@ class TestAuthProtectedEndpoints:
class TestCrossUserDataIsolation:
"""Verify that users cannot access other users' data."""
async def test_user_b_cannot_access_user_a_purchases(self, client, db_engine, seed_data):
"""A second user cannot see User A's purchases."""
async def test_user_b_cannot_access_user_a_purchases(self, client, seed_data):
"""Register a second user and verify they cannot see User A's purchases."""
# User A's purchase (from seed_data)
purchase_id = str(seed_data["purchases"]["meijer_trip"].id)
_, session_token = await _create_test_user_and_session(
client, db_engine, email="userb@e2e.com", display_name="User B"
# Register User B
reg = await client.post(
"/auth/register",
json={
"email": "userb@example.com",
"password": "securepass123",
"display_name": "User B",
},
)
user_b_headers = {"Cookie": f"better-auth.session_token={session_token}"}
assert reg.status_code == 201
user_b_headers = {"Authorization": f"Bearer {reg.json()['access_token']}"}
# User B tries to access User A's specific purchase
resp = await client.get(f"/purchases/{purchase_id}", headers=user_b_headers)
assert resp.status_code in (403, 404), (
"User B should not be able to access User A's purchase"
)
async def test_user_b_purchase_list_is_empty(self, client, db_engine, seed_data):
"""A new user should see no purchases."""
_, session_token = await _create_test_user_and_session(
client, db_engine, email="userc@e2e.com", display_name="User C"
async def test_user_b_purchase_list_is_empty(self, client, seed_data):
"""A new user should see no purchases (not User A's purchases)."""
reg = await client.post(
"/auth/register",
json={
"email": "userc@example.com",
"password": "securepass123",
"display_name": "User C",
},
)
user_c_headers = {"Cookie": f"better-auth.session_token={session_token}"}
assert reg.status_code == 201
user_c_headers = {"Authorization": f"Bearer {reg.json()['access_token']}"}
resp = await client.get("/purchases", headers=user_c_headers)
assert resp.status_code == 200
assert len(resp.json()) == 0, "New user should have no purchases"
async def test_user_b_stores_isolated(self, client, db_engine, seed_data):
async def test_user_b_stores_isolated(self, client, seed_data):
"""User B's connected stores should be independent from User A."""
_, session_token = await _create_test_user_and_session(
client, db_engine, email="userd@e2e.com", display_name="User D"
reg = await client.post(
"/auth/register",
json={
"email": "userd@example.com",
"password": "securepass123",
"display_name": "User D",
},
)
user_d_headers = {"Cookie": f"better-auth.session_token={session_token}"}
assert reg.status_code == 201
user_d_headers = {"Authorization": f"Bearer {reg.json()['access_token']}"}
# User D should have no connected stores
resp = await client.get("/me/stores", headers=user_d_headers)
assert resp.status_code == 200
assert len(resp.json()) == 0, "New user should have no connected stores"
+13 -32
View File
@@ -1,25 +1,26 @@
"""Integration tests for purchase endpoints."""
import secrets
import uuid
from datetime import UTC, date, datetime, timedelta
from datetime import date
from decimal import Decimal
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from cartsnitch_api.auth.jwt import create_access_token
from cartsnitch_api.models import Purchase, PurchaseItem, Store, User
@pytest.fixture
async def purchase_data(db_engine):
"""Seed a user, store, purchase, items, and a valid session."""
"""Seed a user, store, purchase, and items."""
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
from cartsnitch_api.auth.passwords import hash_password
user = User(
email="buyer@example.com",
hashed_password="not-used-with-better-auth",
hashed_password=hash_password("testpass123"),
display_name="Buyer",
)
store = Store(name="Kroger", slug="kroger")
@@ -49,33 +50,13 @@ async def purchase_data(db_engine):
session.add(item)
await session.commit()
# Create a session token directly in the sessions table
session_token = secrets.token_urlsafe(32)
now = datetime.now(UTC).isoformat()
expires = (datetime.now(UTC) + timedelta(days=7)).isoformat()
async with db_engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO sessions (id, token, user_id, expires_at, created_at, updated_at) "
"VALUES (:id, :token, :user_id, :expires_at, :created_at, :updated_at)"
),
{
"id": str(uuid.uuid4()),
"token": session_token,
"user_id": str(user.id),
"expires_at": expires,
"created_at": now,
"updated_at": now,
},
)
return {
"user": user,
"store": store,
"purchase": purchase,
"headers": {"Cookie": f"better-auth.session_token={session_token}"},
}
token = create_access_token(user.id)
return {
"user": user,
"store": store,
"purchase": purchase,
"headers": {"Authorization": f"Bearer {token}"},
}
@pytest.mark.asyncio
-11
View File
@@ -1,11 +0,0 @@
# Required: Generate with `openssl rand -base64 32`
BETTER_AUTH_SECRET=change-me-in-production-min-32-chars!!
# Base URL of the auth service
BETTER_AUTH_URL=http://localhost:3001
# Shared PostgreSQL database
DATABASE_URL=postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch
# Port the auth service listens on
PORT=3001
-17
View File
@@ -1,17 +0,0 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ src/
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist/ dist/
USER 101
EXPOSE 3001
CMD ["node", "dist/index.js"]
-1754
View File
File diff suppressed because it is too large Load Diff
-24
View File
@@ -1,24 +0,0 @@
{
"name": "@cartsnitch/auth",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"generate": "npx @better-auth/cli generate"
},
"dependencies": {
"better-auth": "^1.2.0",
"pg": "^8.13.0",
"bcrypt": "^5.1.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/pg": "^8.11.0",
"@types/bcrypt": "^5.0.2",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}
-90
View File
@@ -1,90 +0,0 @@
import { betterAuth } from "better-auth";
import bcrypt from "bcrypt";
import pg from "pg";
const { Pool } = pg;
const pool = new Pool({
connectionString:
process.env.DATABASE_URL ??
"postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
});
const secret = process.env.BETTER_AUTH_SECRET;
if (!secret) {
throw new Error("BETTER_AUTH_SECRET environment variable is required");
}
export const auth = betterAuth({
database: pool,
basePath: "/auth",
secret,
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3001",
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
maxPasswordLength: 128,
password: {
hash: async (password: string) => {
return bcrypt.hash(password, 10);
},
verify: async (data: { hash: string; password: string }) => {
return bcrypt.compare(data.password, data.hash);
},
},
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // refresh after 1 day
cookieCache: {
enabled: true,
maxAge: 5 * 60, // 5-minute cookie cache
},
},
user: {
modelName: "users",
fields: {
name: "display_name",
emailVerified: "email_verified",
image: "image",
createdAt: "created_at",
updatedAt: "updated_at",
},
},
account: {
modelName: "accounts",
fields: {
userId: "user_id",
accountId: "account_id",
providerId: "provider_id",
accessToken: "access_token",
refreshToken: "refresh_token",
accessTokenExpiresAt: "access_token_expires_at",
refreshTokenExpiresAt: "refresh_token_expires_at",
idToken: "id_token",
createdAt: "created_at",
updatedAt: "updated_at",
},
},
verification: {
modelName: "verifications",
fields: {
expiresAt: "expires_at",
createdAt: "created_at",
updatedAt: "updated_at",
},
},
trustedOrigins: [
"http://localhost:3000",
"http://localhost:5173",
"https://cartsnitch.com",
"https://cartsnitch.farh.net",
"https://cartsnitch.dev.farh.net",
],
});
-23
View File
@@ -1,23 +0,0 @@
import { createServer } from "node:http";
import { toNodeHandler } from "better-auth/node";
import { auth } from "./auth.js";
const port = parseInt(process.env.PORT ?? "3001", 10);
const handler = toNodeHandler(auth);
const server = createServer(async (req, res) => {
// Health check
if (req.url === "/health" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
// All /auth/* routes handled by Better-Auth
await handler(req, res);
});
server.listen(port, "0.0.0.0", () => {
console.log(`CartSnitch auth service listening on port ${port}`);
});
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"resolveJsonModule": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
-28
View File
@@ -1,28 +0,0 @@
# CartSnitch Common
Shared models, schemas, and utilities for CartSnitch services.
## Test Users
The following users are seeded by `cartsnitch-seed` and can be used for local development and UAT.
| Email | Password | Display Name | Notes |
|---|---|---|---|
| `uat@cartsnitch.com` | `CartSnitch-UAT-2026!` | UAT Tester | Primary UAT account. Use for regression testing in the CartSnitch frontend. Created by the seed runner via Better-Auth's bcrypt path — credentials work against the live auth service. Idempotent; re-running the seed skips this user if it already exists. |
### Running the Seed
```bash
# Install with seed dependencies
pip install -e "cartsnitch-common[seed]"
# Run (requires CARTSNITCH_DATABASE_URL_SYNC)
CARTSNITCH_DATABASE_URL_SYNC=postgresql://user:pass@localhost:5432/cartsnitch \
cartsnitch-seed
```
### Architecture
- **Models** live in `src/cartsnitch_common/models/`
- **Alembic migrations** run via the `api` service (`api/alembic/`)
- **Seed runner** runs via `cartsnitch-seed` (installed as a package entry point)
-1
View File
@@ -27,7 +27,6 @@ dev = [
]
seed = [
"faker>=33.0,<34.0",
"bcrypt>=4.0,<6.0",
]
[project.scripts]
+1 -3
View File
@@ -4,7 +4,7 @@ import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy import JSON, DateTime, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_common.constants import AccountStatus
@@ -23,8 +23,6 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
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))
email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
image: Mapped[str | None] = mapped_column(Text, nullable=True)
# Relationships
store_accounts: Mapped[list["UserStoreAccount"]] = relationship(back_populates="user")
@@ -2,10 +2,8 @@
import random
import time
import uuid
from typing import Any
import bcrypt
from faker import Faker
from sqlalchemy import text
from sqlalchemy.orm import Session
@@ -186,65 +184,6 @@ def run_seed(
session.commit()
_seed_uat_user(session)
elapsed = time.monotonic() - t0
_log("")
_log(f"Seed complete in {elapsed:.1f}s")
# ---------------------------------------------------------------------------
# UAT seed user
# ---------------------------------------------------------------------------
UAT_EMAIL = "uat@cartsnitch.com"
UAT_PASSWORD = "CartSnitch-UAT-2026!"
UAT_DISPLAY_NAME = "UAT Tester"
UAT_USER_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
def _seed_uat_user(session: Session) -> None:
"""Insert or verify the dedicated UAT test user.
The user is created via Better-Auth's bcrypt hashing path so credentials
work against the live auth service. Idempotent — skips if the user already
exists.
"""
existing = session.execute(
text("SELECT id FROM users WHERE email = :email"),
{"email": UAT_EMAIL},
).fetchone()
if existing is not None:
_log(f"UAT user {UAT_EMAIL} already exists — skipping")
return
password_hash = bcrypt.hashpw(UAT_PASSWORD.encode(), bcrypt.gensalt()).decode()
session.execute(
text(
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) "
"VALUES (:id, :email, :hashed_password, :display_name, true, now(), now())"
),
{
"id": str(UAT_USER_ID),
"email": UAT_EMAIL,
"hashed_password": password_hash,
"display_name": UAT_DISPLAY_NAME,
},
)
session.execute(
text(
"INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) "
"VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())"
),
{
"user_id": str(UAT_USER_ID),
"account_id": str(UAT_USER_ID),
"password": password_hash,
},
)
session.commit()
_log(f"UAT user {UAT_EMAIL} created")
-151
View File
@@ -1,151 +0,0 @@
# CartSnitch UAT Runbook v1
**Version:** 1.0
**Author:** Savannah Savings, CTO
**Date:** 2026-03-30
**Effective:** Immediately upon Phase 1 completion
---
## 1. Defect Severity Classification
Every defect discovered during UAT **must** be classified by severity and priority before triage.
### Severity Levels
| Severity | Definition | Examples |
|----------|-----------|----------|
| **S1 — Critical** | Blocks all users from completing a core journey. System is down, data is lost, or security is breached. | Login page crashes for all users; purchase data deleted; auth tokens exposed in response |
| **S2 — High** | Blocks a major user flow for a significant portion of users. Core feature is broken but workarounds may exist. | Registration fails for email addresses with `+` character; price alerts never trigger; store comparison shows wrong prices |
| **S3 — Medium** | Feature is degraded but usable. User can complete the journey with friction. | Date formatting shows raw ISO string instead of friendly date; slow page load (>5s) on product detail; search results not sorted correctly |
| **S4 — Low** | Cosmetic issue, minor UI inconsistency, or edge case with minimal user impact. | Button text truncated on narrow screens; extra whitespace in footer; tooltip shows on hover but not on focus |
### Priority Levels
Priority determines **when** the defect must be fixed. Priority is set by the CTO based on severity, business impact, and sprint capacity.
| Priority | SLA | When to Use |
|----------|-----|------------|
| **P0 — Fix Now** | Triage within 1 hour, fix deployed within 4 hours | S1 defects, any security vulnerability, data integrity issues |
| **P1 — Fix This Sprint** | Triage within 4 hours, fix in current sprint | S2 defects blocking upcoming release, S1 defects with viable workaround |
| **P2 — Fix Next Sprint** | Triage within 24 hours, scheduled for next sprint | S3 defects, S2 defects with easy workarounds |
| **P3 — Backlog** | Triage within 48 hours, prioritized against backlog | S4 defects, minor improvements, nice-to-haves |
### Defect Report Template
Every defect filed during UAT must include:
```
**Title:** [Short description]
**Severity:** S1/S2/S3/S4
**Priority:** P0/P1/P2/P3 (set by CTO at triage)
**Journey:** [Which user journey — J1 through J10]
**Environment:** [Dev / Prod, deployed image tag]
**Steps to Reproduce:**
1. Navigate to ...
2. Click ...
3. Enter ...
**Expected Result:** ...
**Actual Result:** ...
**Screenshots/Logs:** [Attach or link]
**Browser/Device:** [e.g., Chromium 124, mobile viewport 390x844]
```
---
## 2. UAT Entry Criteria
UAT **must not begin** until ALL of the following are satisfied. Checkout Charlie verifies these before opening the UAT gate.
| # | Criterion | Verified By |
|---|-----------|------------|
| E1 | CI pipeline passes on the merged commit (lint, type-check, unit tests, build) | GitHub Actions (automated) |
| E2 | Docker image is built and pushed to GHCR with a CalVer tag | GitHub Actions (automated) |
| E3 | Dev environment is deployed and accessible at `cartsnitch.dev.farh.net` | Flux reconciliation + health check |
| E4 | All Playwright E2E tests pass in CI | GitHub Actions (automated) |
| E5 | No open S1/S2 defects from previous UAT cycle | Checkout Charlie (manual check) |
| E6 | PR has been reviewed and approved by QA (Checkout Charlie) and CTO (Savannah Savings) | GitHub PR approvals |
| E7 | PR has been merged to main by CEO (Coupon Carl) | GitHub merge event |
| E8 | Acceptance criteria for the feature/change are documented in the Paperclip issue | Checkout Charlie (manual check) |
**If any entry criterion is not met**, UAT is blocked. Checkout Charlie must comment on the Paperclip issue specifying which criteria failed and assign back to the responsible party.
---
## 3. UAT Exit Criteria
UAT is **complete** only when ALL of the following are satisfied. Rollback Rhonda verifies these before signing off.
| # | Criterion | Verified By |
|---|-----------|------------|
| X1 | All 10 critical user journeys (J1-J10) have been executed | Rollback Rhonda (full regression) |
| X2 | Zero open S1 (Critical) defects | Defect tracker |
| X3 | Zero open S2 (High) defects, OR CTO has granted a documented exception | Defect tracker + CTO sign-off |
| X4 | All S3/S4 defects are logged and triaged (not necessarily fixed) | Defect tracker |
| X5 | 100% test execution rate -- every test case was run, none skipped | Rollback Rhonda's UAT report |
| X6 | Accessibility scan (axe-core) reports zero critical violations | Automated in E2E suite |
| X7 | Lighthouse performance score >= 50, accessibility score >= 90 | Lighthouse CI |
| X8 | Written sign-off from Rollback Rhonda confirming all criteria met | Paperclip comment on issue |
**If any exit criterion is not met**, the release is blocked. Rollback Rhonda must:
1. File defects for all failures using the Defect Report Template above.
2. Comment on the Paperclip issue specifying which exit criteria failed.
3. Assign back to CTO for triage and redistribution.
---
## 4. UAT Execution Procedure
### 4.1 Pre-UAT (Checkout Charlie)
1. Verify all entry criteria (E1-E8) are met.
2. Comment on the Paperclip issue: "UAT gate open -- all entry criteria verified."
3. Assign to Rollback Rhonda with status todo.
### 4.2 UAT Execution (Rollback Rhonda)
1. **Full regression run** -- execute ALL 10 user journeys against cartsnitch.dev.farh.net. No partial runs. No exceptions.
2. For each journey, verify:
- All interactive elements respond correctly (buttons, forms, links, toggles)
- State transitions are correct (auth state, data mutations, navigation)
- Error states are handled gracefully (invalid input, network failures)
- Accessibility scan passes (axe-core integrated in Playwright)
3. Log results for each journey: PASS / FAIL with details.
4. File defects immediately for any failures.
5. Complete the UAT report with execution results.
### 4.3 Post-UAT Sign-Off
1. If all exit criteria (X1-X8) are met:
- Rollback Rhonda posts sign-off comment: "UAT PASSED -- all exit criteria met."
- Production promotion is automated via Flux on UAT pass.
2. If any exit criterion fails:
- Rollback Rhonda posts failure comment with specific failures.
- CTO triages defects and redistributes to engineers.
- After fixes are merged, UAT restarts from 4.1 (full cycle).
---
## 5. Critical User Journeys Reference
| ID | Journey | Key Interactions |
|----|---------|-----------------|
| J1 | Registration -> Login -> Dashboard | Form submission, auth state, redirect |
| J2 | Login -> Browse Products -> View Detail -> Price Chart | Search, navigation, data visualization |
| J3 | Login -> Purchases -> Purchase Detail -> Product Link | List navigation, detail view, cross-linking |
| J4 | Login -> Connect Store Account -> Verify Connection | OAuth flow, external integration |
| J5 | Login -> Create Price Alert -> View -> Delete Alert | CRUD operations, confirmation dialogs |
| J6 | Login -> Browse Coupons -> Copy Code | Clipboard interaction, toast feedback |
| J7 | Login -> Settings -> Toggle Preferences -> Sign Out | Checkbox toggles, theme switch, session termination |
| J8 | Login -> Store Comparison -> Compare Prices | Data comparison, sorting, price display |
| J9 | Forgot Password Flow | Email input, validation, redirect |
| J10 | Unauth Access -> Redirect to Login | Route protection, redirect behavior |
---
## 6. Revision History
| Version | Date | Author | Changes |
|---------|------|--------|---------|
| 1.0 | 2026-03-30 | Savannah Savings | Initial runbook -- defect taxonomy, entry/exit criteria, execution procedure |
+185 -469
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -13,7 +13,6 @@
},
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"better-auth": "^1.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.0.0",
+10 -6
View File
@@ -3,9 +3,10 @@ FROM python:3.12-slim AS build
WORKDIR /app
# build-essential and libpq-dev are needed to compile any C-extension wheels
# (e.g. psycopg2 fallback). git is no longer needed here.
# git is required to install cartsnitch-common from GitHub; build-essential and
# libpq-dev are needed to compile any C-extension wheels (e.g. psycopg2 fallback)
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
libpq-dev \
build-essential \
&& rm -rf /var/lib/apt/lists/*
@@ -13,10 +14,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY pyproject.toml ./
COPY src/ ./src/
# cartsnitch-common is not on PyPI — install it from the local monorepo common/
# directory. The common/ dir is the cartsnitch-common Python package.
COPY common/ /tmp/common/
RUN pip install --no-cache-dir --prefix=/install /tmp/common/ .
# cartsnitch-common is not on PyPI — install it directly from GitHub, then
# install the rest of the package dependencies in a single resolver pass so
# pip can satisfy the cartsnitch-common>=0.1.0 constraint declared in
# pyproject.toml without hitting PyPI for it.
RUN pip install --no-cache-dir --prefix=/install \
"cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b" \
.
# Stage 2: Production image with Playwright + Chromium
FROM python:3.12-slim AS prod
+1 -7
View File
@@ -1,13 +1,7 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect } 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 />)
+2 -17
View File
@@ -1,25 +1,10 @@
import { useEffect } from 'react'
import { Navigate, Outlet } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts'
export function ProtectedRoute() {
const { data: session, isPending } = authClient.useSession()
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
useEffect(() => {
setAuthenticated(!!session)
}, [session, setAuthenticated])
if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-blue border-t-transparent" />
</div>
)
}
if (!session) {
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
+100 -98
View File
@@ -1,98 +1,100 @@
import { useAuthStore } from '../stores/auth.ts'
import {
mockPurchases,
mockProducts,
mockCoupons,
mockAlerts,
getMockPriceHistory,
} from './mock-data.ts'
const API_BASE = import.meta.env.VITE_API_URL ?? '/api/v1'
const USE_MOCK = import.meta.env.VITE_MOCK_API === 'true'
// Mock response lookup table
const mockRoutes: Record<string, (path: string) => unknown> = {
'/purchases': () => mockPurchases,
'/products': () => mockProducts,
'/coupons': () => mockCoupons,
'/price-alerts': () => mockAlerts,
}
function matchMockRoute<T>(path: string): T | null {
// Exact match
if (mockRoutes[path]) return mockRoutes[path](path) as T
// /purchases/:id
const purchaseMatch = path.match(/^\/purchases\/(.+)$/)
if (purchaseMatch) {
const purchase = mockPurchases.find((p) => p.id === purchaseMatch[1])
return (purchase ?? null) as T
}
// /products/:id/price-history
const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/)
if (priceHistoryMatch) {
return getMockPriceHistory(priceHistoryMatch[1]) as T
}
// /products/:id
const productMatch = path.match(/^\/products\/(.+)$/)
if (productMatch) {
const product = mockProducts.find((p) => p.id === productMatch[1])
return (product ?? null) as T
}
const productsSearch = path.match(/^\/products\?q=(.+)$/)
if (productsSearch) {
const q = decodeURIComponent(productsSearch[1]).toLowerCase()
return mockProducts.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.brand.toLowerCase().includes(q) ||
p.category.toLowerCase().includes(q),
) as T
}
return null
}
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
// Mock interceptor: return mock data without hitting the network
if (USE_MOCK && (!options?.method || options.method === 'GET')) {
const mockResult = matchMockRoute<T>(path)
if (mockResult !== null) {
// Simulate network delay for realistic loading states
await new Promise((r) => setTimeout(r, 300))
return mockResult
}
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
credentials: 'include', // Send Better-Auth session cookie
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
if (res.status === 401) {
useAuthStore.getState().setAuthenticated(false)
throw new Error('Unauthorized')
}
if (!res.ok) {
throw new Error(`API error: ${res.status}`)
}
return res.json() as Promise<T>
}
export const api = {
get: <T>(path: string) => apiFetch<T>(path),
post: <T>(path: string, body: unknown) =>
apiFetch<T>(path, { method: 'POST', body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) =>
apiFetch<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
}
import { useAuthStore } from '../stores/auth.ts'
import {
mockPurchases,
mockProducts,
mockCoupons,
mockAlerts,
getMockPriceHistory,
} from './mock-data.ts'
const API_BASE = import.meta.env.VITE_API_URL ?? '/api/v1'
const USE_MOCK = import.meta.env.VITE_MOCK_API === 'true'
// Mock response lookup table
const mockRoutes: Record<string, (path: string) => unknown> = {
'/purchases': () => mockPurchases,
'/products': () => mockProducts,
'/coupons': () => mockCoupons,
'/price-alerts': () => mockAlerts,
}
function matchMockRoute<T>(path: string): T | null {
// Exact match
if (mockRoutes[path]) return mockRoutes[path](path) as T
// /purchases/:id
const purchaseMatch = path.match(/^\/purchases\/(.+)$/)
if (purchaseMatch) {
const purchase = mockPurchases.find((p) => p.id === purchaseMatch[1])
return (purchase ?? null) as T
}
// /products/:id/price-history
const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/)
if (priceHistoryMatch) {
return getMockPriceHistory(priceHistoryMatch[1]) as T
}
// /products?q=search or /products/:id
const productMatch = path.match(/^\/products\/(.+)$/)
if (productMatch) {
const product = mockProducts.find((p) => p.id === productMatch[1])
return (product ?? null) as T
}
const productsSearch = path.match(/^\/products\?q=(.+)$/)
if (productsSearch) {
const q = decodeURIComponent(productsSearch[1]).toLowerCase()
return mockProducts.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.brand.toLowerCase().includes(q) ||
p.category.toLowerCase().includes(q),
) as T
}
return null
}
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
// Mock interceptor: return mock data without hitting the network
if (USE_MOCK && (!options?.method || options.method === 'GET')) {
const mockResult = matchMockRoute<T>(path)
if (mockResult !== null) {
// Simulate network delay for realistic loading states
await new Promise((r) => setTimeout(r, 300))
return mockResult
}
}
const token = useAuthStore.getState().token
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
},
})
if (res.status === 401) {
useAuthStore.getState().logout()
throw new Error('Unauthorized')
}
if (!res.ok) {
throw new Error(`API error: ${res.status}`)
}
return res.json() as Promise<T>
}
export const api = {
get: <T>(path: string) => apiFetch<T>(path),
post: <T>(path: string, body: unknown) =>
apiFetch<T>(path, { method: 'POST', body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) =>
apiFetch<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
}
-36
View File
@@ -1,36 +0,0 @@
import { createAuthClient } from "better-auth/react"
import type { BetterFetchPlugin } from "@better-fetch/fetch"
/**
* Maps 'name' -> 'display_name' in register requests to match the API's RegisterRequest schema.
*/
const displayNameMapper: BetterFetchPlugin = {
id: "display-name-mapper",
name: "display-name-mapper",
hooks: {
onRequest: async (context) => {
const url = typeof context.url === "string" ? context.url : context.url.pathname
if (
url.endsWith("/auth/register") &&
context.method === "POST" &&
context.body &&
"name" in context.body
) {
context.body = {
...context.body,
display_name: context.body.name as string,
name: undefined,
}
}
return context
},
},
}
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_AUTH_URL || "",
basePath: "/auth",
fetchPlugins: [displayNameMapper],
})
export const { useSession, signIn, signUp, signOut } = authClient
+197 -200
View File
@@ -1,200 +1,197 @@
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 { 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()
if (isPending) {
return <DashboardSkeleton />
}
if (!session) {
return (
<div className="py-8 text-center">
<h1 className="text-2xl font-bold text-gray-900">CartSnitch</h1>
<p className="mt-2 text-sm text-gray-500">Track prices. Save money.</p>
<div className="mt-8 space-y-3">
<Link
to="/login"
className="block min-h-12 rounded-xl bg-brand-blue px-4 py-3 text-center text-base font-medium text-white active:bg-brand-blue/90"
>
Sign In
</Link>
<Link
to="/register"
className="block min-h-12 rounded-xl border border-gray-200 px-4 py-3 text-center text-base font-medium text-gray-700 active:bg-gray-50"
>
Create Account
</Link>
</div>
</div>
)
}
return <AuthenticatedDashboard userName={session.user?.name ?? 'there'} />
}
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 />
}
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">
Hi, {userName.split(' ')[0]}
</h1>
{/* Triggered alerts banner */}
{triggeredAlerts.length > 0 && (
<Link
to="/alerts"
className="mt-4 flex items-center gap-3 rounded-xl bg-green-50 p-4"
>
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500 text-lg text-white">
&#x2713;
</span>
<div>
<p className="text-sm font-semibold text-green-800">
{triggeredAlerts.length} price {triggeredAlerts.length === 1 ? 'alert' : 'alerts'} triggered!
</p>
<p className="text-xs text-green-700">
{triggeredAlerts.map((a) => a.productName).join(', ')}
</p>
</div>
</Link>
)}
{/* Quick stats */}
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="rounded-xl bg-white p-4 shadow-sm">
<p className="text-xs font-medium text-gray-500">Watching</p>
<p className="mt-1 text-2xl font-bold text-gray-900">{watchingAlerts.length}</p>
<p className="text-xs text-gray-400">price alerts</p>
</div>
<div className="rounded-xl bg-white p-4 shadow-sm">
<p className="text-xs font-medium text-gray-500">This Month</p>
<p className="mt-1 text-2xl font-bold text-gray-900">
${recentPurchases.reduce((sum, p) => sum + p.total, 0).toFixed(0)}
</p>
<p className="text-xs text-gray-400">grocery spend</p>
</div>
</div>
{/* 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>
</section>
{/* Recent purchases */}
<section className="mt-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-700">Recent Purchases</h2>
<Link to="/purchases" className="text-sm text-brand-blue">
View all
</Link>
</div>
<div className="mt-3 space-y-3">
{recentPurchases.map((purchase) => (
<Link
key={purchase.id}
to={`/purchases/${purchase.id}`}
className="flex items-center gap-3 rounded-xl bg-white p-4 shadow-sm active:bg-gray-50"
>
<StoreIcon storeId={purchase.storeId} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900">{purchase.storeName}</p>
<p className="text-xs text-gray-500">
{new Date(purchase.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})}{' '}
&middot; {purchase.items.length} items
</p>
</div>
<span className="text-sm font-semibold text-gray-900">
${purchase.total.toFixed(2)}
</span>
</Link>
))}
</div>
</section>
{/* Quick actions */}
<section className="mt-6 pb-4">
<h2 className="mb-3 text-lg font-semibold text-gray-700">Quick Actions</h2>
<div className="grid grid-cols-2 gap-3">
<Link
to="/products"
className="flex min-h-12 items-center justify-center rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-sm active:bg-gray-50"
>
Compare Prices
</Link>
<Link
to="/settings"
className="flex min-h-12 items-center justify-center rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-sm active:bg-gray-50"
>
Link a Store
</Link>
</div>
</section>
</div>
)
}
function DashboardSkeleton() {
return (
<div className="animate-pulse">
<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" />
<div className="h-24 rounded-xl bg-gray-200" />
</div>
<div className="mt-6 h-5 w-28 rounded bg-gray-200" />
<div className="mt-3 space-y-3">
<div className="h-16 rounded-xl bg-gray-200" />
<div className="h-16 rounded-xl bg-gray-200" />
</div>
</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>
)
}
import React, { Suspense } from 'react'
import { Link } from 'react-router-dom'
import { useAuthStore } from '../stores/auth.ts'
import { usePurchases, usePriceAlerts, usePriceHistory } 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 user = useAuthStore((s) => s.user)
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
if (!isAuthenticated) {
return (
<div className="py-8 text-center">
<h1 className="text-2xl font-bold text-gray-900">CartSnitch</h1>
<p className="mt-2 text-sm text-gray-500">Track prices. Save money.</p>
<div className="mt-8 space-y-3">
<Link
to="/login"
className="block min-h-12 rounded-xl bg-brand-blue px-4 py-3 text-center text-base font-medium text-white active:bg-brand-blue/90"
>
Sign In
</Link>
<Link
to="/register"
className="block min-h-12 rounded-xl border border-gray-200 px-4 py-3 text-center text-base font-medium text-gray-700 active:bg-gray-50"
>
Create Account
</Link>
</div>
</div>
)
}
return <AuthenticatedDashboard userName={user?.name ?? 'there'} />
}
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 />
}
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">
Hi, {userName.split(' ')[0]}
</h1>
{/* Triggered alerts banner */}
{triggeredAlerts.length > 0 && (
<Link
to="/alerts"
className="mt-4 flex items-center gap-3 rounded-xl bg-green-50 p-4"
>
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500 text-lg text-white">
&#x2713;
</span>
<div>
<p className="text-sm font-semibold text-green-800">
{triggeredAlerts.length} price {triggeredAlerts.length === 1 ? 'alert' : 'alerts'} triggered!
</p>
<p className="text-xs text-green-700">
{triggeredAlerts.map((a) => a.productName).join(', ')}
</p>
</div>
</Link>
)}
{/* Quick stats */}
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="rounded-xl bg-white p-4 shadow-sm">
<p className="text-xs font-medium text-gray-500">Watching</p>
<p className="mt-1 text-2xl font-bold text-gray-900">{watchingAlerts.length}</p>
<p className="text-xs text-gray-400">price alerts</p>
</div>
<div className="rounded-xl bg-white p-4 shadow-sm">
<p className="text-xs font-medium text-gray-500">This Month</p>
<p className="mt-1 text-2xl font-bold text-gray-900">
${recentPurchases.reduce((sum, p) => sum + p.total, 0).toFixed(0)}
</p>
<p className="text-xs text-gray-400">grocery spend</p>
</div>
</div>
{/* 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>
</section>
{/* Recent purchases */}
<section className="mt-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-700">Recent Purchases</h2>
<Link to="/purchases" className="text-sm text-brand-blue">
View all
</Link>
</div>
<div className="mt-3 space-y-3">
{recentPurchases.map((purchase) => (
<Link
key={purchase.id}
to={`/purchases/${purchase.id}`}
className="flex items-center gap-3 rounded-xl bg-white p-4 shadow-sm active:bg-gray-50"
>
<StoreIcon storeId={purchase.storeId} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900">{purchase.storeName}</p>
<p className="text-xs text-gray-500">
{new Date(purchase.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})}{' '}
&middot; {purchase.items.length} items
</p>
</div>
<span className="text-sm font-semibold text-gray-900">
${purchase.total.toFixed(2)}
</span>
</Link>
))}
</div>
</section>
{/* Quick actions */}
<section className="mt-6 pb-4">
<h2 className="mb-3 text-lg font-semibold text-gray-700">Quick Actions</h2>
<div className="grid grid-cols-2 gap-3">
<Link
to="/products"
className="flex min-h-12 items-center justify-center rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-sm active:bg-gray-50"
>
Compare Prices
</Link>
<Link
to="/settings"
className="flex min-h-12 items-center justify-center rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-sm active:bg-gray-50"
>
Link a Store
</Link>
</div>
</section>
</div>
)
}
function DashboardSkeleton() {
return (
<div className="animate-pulse">
<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" />
<div className="h-24 rounded-xl bg-gray-200" />
</div>
<div className="mt-6 h-5 w-28 rounded bg-gray-200" />
<div className="mt-3 space-y-3">
<div className="h-16 rounded-xl bg-gray-200" />
<div className="h-16 rounded-xl bg-gray-200" />
</div>
</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>
)
}
+92 -97
View File
@@ -1,97 +1,92 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts'
export function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (!email || !password) {
setError('Please fill in all fields.')
return
}
setLoading(true)
try {
const { error: authError } = await authClient.signIn.email({
email,
password,
})
if (authError) {
throw new Error(authError.message ?? 'Sign in failed')
}
setAuthenticated(true)
navigate('/')
} catch {
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
setAuthenticated(true)
navigate('/')
} else {
setError('Invalid email or password. Please try again.')
}
} finally {
setLoading(false)
}
}
return (
<div 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>
{error && (
<div className="mb-4 w-full max-w-sm rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
<form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
<button
type="submit"
disabled={loading}
className="min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90 disabled:opacity-60"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<Link to="/forgot-password" className="mt-4 text-sm text-brand-blue">
Forgot password?
</Link>
<p className="mt-6 text-sm text-gray-500">
Don't have an account?{' '}
<Link to="/register" className="text-brand-blue">
Sign up
</Link>
</p>
</div>
)
}
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/auth.ts'
import { api } from '../lib/api.ts'
import { mockUser } from '../lib/mock-data.ts'
import type { User } from '../types/api.ts'
export function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const setAuth = useAuthStore((s) => s.setAuth)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (!email || !password) {
setError('Please fill in all fields.')
return
}
setLoading(true)
try {
const res = await api.post<{ user: User; token: string }>('/auth/login', { email, password })
setAuth(res.user, res.token)
navigate('/')
} catch {
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
// Fallback to mock auth for demo
setAuth(mockUser, 'mock-jwt-token')
navigate('/')
} else {
setError('Invalid email or password. Please try again.')
}
} finally {
setLoading(false)
}
}
return (
<div 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>
{error && (
<div className="mb-4 w-full max-w-sm rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
<form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
<button
type="submit"
disabled={loading}
className="min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90 disabled:opacity-60"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<Link to="/forgot-password" className="mt-4 text-sm text-brand-blue">
Forgot password?
</Link>
<p className="mt-6 text-sm text-gray-500">
Don't have an account?{' '}
<Link to="/register" className="text-brand-blue">
Sign up
</Link>
</p>
</div>
)
}
+102 -108
View File
@@ -1,108 +1,102 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts'
export function Register() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (!name || !email || !password) {
setError('Please fill in all fields.')
return
}
if (password.length < 8) {
setError('Password must be at least 8 characters.')
return
}
setLoading(true)
try {
const { error: authError } = await authClient.signUp.email({
name,
email,
password,
})
if (authError) {
throw new Error(authError.message ?? 'Registration failed')
}
setAuthenticated(true)
navigate('/')
} catch {
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
setAuthenticated(true)
navigate('/')
} else {
setError('Registration failed. Please try again.')
}
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen flex-col items-center justify-center px-4">
<h1 className="mb-2 text-3xl font-bold text-gray-900">Create Account</h1>
<p className="mb-8 text-sm text-gray-500">Start tracking your grocery prices.</p>
{error && (
<div className="mb-4 w-full max-w-sm rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
<form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}>
<input
type="text"
placeholder="Full Name"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
<input
type="password"
placeholder="Password (min. 8 characters)"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
<button
type="submit"
disabled={loading}
className="min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90 disabled:opacity-60"
>
{loading ? 'Creating account...' : 'Create Account'}
</button>
</form>
<p className="mt-6 text-sm text-gray-500">
Already have an account?{' '}
<Link to="/login" className="text-brand-blue">
Sign in
</Link>
</p>
</div>
)
}
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/auth.ts'
import { api } from '../lib/api.ts'
import { mockUser } from '../lib/mock-data.ts'
import type { User } from '../types/api.ts'
export function Register() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const setAuth = useAuthStore((s) => s.setAuth)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (!name || !email || !password) {
setError('Please fill in all fields.')
return
}
if (password.length < 8) {
setError('Password must be at least 8 characters.')
return
}
setLoading(true)
try {
const res = await api.post<{ user: User; token: string }>('/auth/register', { name, email, password })
setAuth(res.user, res.token)
navigate('/')
} catch {
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
// Fallback to mock auth for demo
setAuth({ ...mockUser, name, email }, 'mock-jwt-token')
navigate('/')
} else {
setError('Registration failed. Please try again.')
}
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen flex-col items-center justify-center px-4">
<h1 className="mb-2 text-3xl font-bold text-gray-900">Create Account</h1>
<p className="mb-8 text-sm text-gray-500">Start tracking your grocery prices.</p>
{error && (
<div className="mb-4 w-full max-w-sm rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
<form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}>
<input
type="text"
placeholder="Full Name"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
<input
type="password"
placeholder="Password (min. 8 characters)"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
<button
type="submit"
disabled={loading}
className="min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90 disabled:opacity-60"
>
{loading ? 'Creating account...' : 'Create Account'}
</button>
</form>
<p className="mt-6 text-sm text-gray-500">
Already have an account?{' '}
<Link to="/login" className="text-brand-blue">
Sign in
</Link>
</p>
</div>
)
}
+5 -8
View File
@@ -1,21 +1,18 @@
import { Link, useNavigate } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts'
import { useThemeStore } from '../stores/theme.ts'
import { StoreIcon } from '../components/StoreIcon.tsx'
export function Settings() {
const { data: session } = authClient.useSession()
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
const user = useAuthStore((s) => s.user)
const logout = useAuthStore((s) => s.logout)
const navigate = useNavigate()
const { theme, setTheme } = useThemeStore()
const user = session?.user
const connectedStores: string[] = []
const connectedStores = user?.connectedStores ?? []
async function handleSignOut() {
await authClient.signOut()
setAuthenticated(false)
function handleSignOut() {
logout()
navigate('/login')
}
+27 -18
View File
@@ -1,18 +1,27 @@
import { create } from 'zustand'
/**
* Minimal auth state for UI reactivity.
*
* Session management is handled by Better-Auth via httpOnly cookies.
* This store only tracks whether we have an active session for UI
* gating (protected routes, nav state). No tokens in memory or localStorage.
*/
interface AuthState {
isAuthenticated: boolean
setAuthenticated: (value: boolean) => void
}
export const useAuthStore = create<AuthState>()((set) => ({
isAuthenticated: false,
setAuthenticated: (value) => set({ isAuthenticated: value }),
}))
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { User } from '../types/api.ts'
interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
setAuth: (user: User, token: string) => void
logout: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
setAuth: (user, token) => set({ user, token, isAuthenticated: true }),
logout: () => set({ user: null, token: null, isAuthenticated: false }),
}),
{
name: 'cartsnitch-auth',
partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
},
),
)
@@ -1,33 +0,0 @@
import { describe, it, expect } from 'vitest';
import { formatCurrency } from '../formatCurrency';
describe('formatCurrency', () => {
it('formats 0 cents as $0.00', () => {
expect(formatCurrency(0)).toBe('$0.00');
});
it('formats 199 cents as $1.99', () => {
expect(formatCurrency(199)).toBe('$1.99');
});
it('formats 10000 cents as $100.00', () => {
expect(formatCurrency(10000)).toBe('$100.00');
});
it('handles negative values', () => {
expect(formatCurrency(-500)).toBe('-$5.00');
});
it('handles large numbers', () => {
expect(formatCurrency(99999999)).toBe('$999,999.99');
});
it('supports custom locale', () => {
expect(formatCurrency(1999, 'de-DE', 'EUR')).toContain('19,99');
});
it('supports custom currency', () => {
const result = formatCurrency(1000, 'en-US', 'EUR');
expect(result).toContain('10.00');
});
});
-62
View File
@@ -1,62 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { formatDate } from '../formatDate';
describe('formatDate', () => {
describe('short style', () => {
it('formats an ISO date string', () => {
const result = formatDate('2024-03-15', 'short');
expect(result).toMatch(/Mar 15, 2024/);
});
it('formats a Date object', () => {
const result = formatDate(new Date('2024-03-15'), 'short');
expect(result).toMatch(/Mar 15, 2024/);
});
});
describe('long style', () => {
it('formats with weekday and full month name', () => {
const result = formatDate('2024-03-15', 'long');
expect(result).toMatch(/Friday/);
expect(result).toMatch(/March/);
});
});
describe('relative style', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('returns "just now" for very recent dates', () => {
const now = new Date('2024-01-01T12:00:00Z');
vi.setSystemTime(now);
const result = formatDate(new Date('2024-01-01T11:59:59Z'), 'relative');
expect(result).toBe('just now');
});
it('returns minutes ago', () => {
const now = new Date('2024-01-01T12:00:00Z');
vi.setSystemTime(now);
const result = formatDate(new Date('2024-01-01T11:45:00Z'), 'relative');
expect(result).toBe('15m ago');
});
it('returns hours ago', () => {
const now = new Date('2024-01-01T12:00:00Z');
vi.setSystemTime(now);
const result = formatDate(new Date('2024-01-01T09:00:00Z'), 'relative');
expect(result).toBe('3h ago');
});
it('returns days ago', () => {
const now = new Date('2024-01-05T12:00:00Z');
vi.setSystemTime(now);
const result = formatDate(new Date('2024-01-01T12:00:00Z'), 'relative');
expect(result).toBe('4d ago');
});
});
});
-46
View File
@@ -1,46 +0,0 @@
import { describe, it, expect } from 'vitest';
import { getStore, getStoreName, STORE_SLUGS } from '../storeSlugs';
describe('storeSlugs', () => {
describe('STORE_SLUGS constant', () => {
it('contains meijer, kroger, and target', () => {
expect(STORE_SLUGS).toHaveProperty('meijer');
expect(STORE_SLUGS).toHaveProperty('kroger');
expect(STORE_SLUGS).toHaveProperty('target');
});
});
describe('getStore', () => {
it('returns store data for known slug', () => {
const store = getStore('meijer');
expect(store).toEqual({
name: 'Meijer',
color: '#e31837',
icon: '/icons/stores/meijer.svg',
});
});
it('returns null for unknown slug', () => {
expect(getStore('unknown-store')).toBeNull();
});
it('is case insensitive', () => {
expect(getStore('KROGER')).toBeTruthy();
expect(getStore('Target')).toBeTruthy();
});
});
describe('getStoreName', () => {
it('returns store name for known slug', () => {
expect(getStoreName('kroger')).toBe('Kroger');
});
it('returns raw slug for unknown store', () => {
expect(getStoreName('unknown-store')).toBe('unknown-store');
});
it('is case insensitive', () => {
expect(getStoreName('TARGET')).toBe('Target');
});
});
});
-10
View File
@@ -1,10 +0,0 @@
export function formatCurrency(
cents: number,
locale = 'en-US',
currency = 'USD'
): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(cents / 100);
}
-34
View File
@@ -1,34 +0,0 @@
export function formatDate(
date: string | Date,
style: 'short' | 'long' | 'relative' = 'short'
): string {
const d = typeof date === 'string' ? new Date(date) : date;
if (style === 'short') {
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
if (style === 'long') {
return d.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
});
}
// relative
const diff = Date.now() - d.getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
-13
View File
@@ -1,13 +0,0 @@
export const STORE_SLUGS: Record<string, { name: string; color: string; icon: string }> = {
meijer: { name: 'Meijer', color: '#e31837', icon: '/icons/stores/meijer.svg' },
kroger: { name: 'Kroger', color: '#0033a0', icon: '/icons/stores/kroger.svg' },
target: { name: 'Target', color: '#cc0000', icon: '/icons/stores/target.svg' },
};
export function getStore(slug: string) {
return STORE_SLUGS[slug.toLowerCase()] ?? null;
}
export function getStoreName(slug: string): string {
return getStore(slug)?.name ?? slug;
}